Algorithmen und Datenstrukturen Prof. Jürgen Sauer Spezielle Algorithmen Skriptum zur Vorlesung im SS 2009 1 Algorithmen und Datenstrukturen 2 Algorithmen und Datenstrukturen Inhaltsverzeichnis Literaturverzeichnis.............................................................................................................................................. 5 1. GRUNDLEGENDE KONZEPTE ......................................................................................................... 7 1.1 Die zentralen Begriffe ..................................................................................................................................... 7 1.1.1 Datenstruktur und Algorithmus ................................................................................................................. 7 1.1.2 Ein einführendes Beispiel: Das Durchlaufen eines Binärbaums................................................................ 8 1.1.2.1 Rekursive Problemlösung ..................................................................................................................... 10 1.1.2.2 Nichtrekursive Problemlösung.............................................................................................................. 11 1.2 Algorithmische Grundkonzepte................................................................................................................... 15 1.2.1 Algorithmenbegriffe ................................................................................................................................ 15 1.2.2 Terminierung und Determinismus ........................................................................................................... 15 1.2.3 Algorithmenbausteine .............................................................................................................................. 16 1.2.4 Paradigmen der Algorithmenbeschreibung.............................................................................................. 18 1.2.4.1 Applikative Algorithmen .................................................................................................................. 19 1.2.4.2 Imperative Algorithmen .................................................................................................................... 21 1.2.4.3 Objektorientierte Algorithmen .......................................................................................................... 21 1.2.4.4 Paradigmen und Programmiersprachen ............................................................................................ 24 1.2.5 Beschreibung von Algorithmen ............................................................................................................... 24 1.2.6 Formale Eigenschaften von Algorithmen ................................................................................................ 28 1.2.6.1 Korrektheit, Terminierung, Hoare-Kalkül, Halteproblem................................................................. 28 1.2.6.1.1 Korrektheit, Terminierung ......................................................................................................... 28 1.2.6.1.2 Hoare-Kalkül.............................................................................................................................. 29 1.2.6.1.3 Halteproblem.............................................................................................................................. 29 1.2.6.2 Effizienz............................................................................................................................................ 31 1.2.7 Komplexität.............................................................................................................................................. 33 1.2.7.1 Laufzeitberechnungen....................................................................................................................... 35 1.2.7.1.1 Analyse der Laufzeit .................................................................................................................. 35 1.2.7.1.2 Asymptotische Analyse der Laufzeit („Big-O“) ........................................................................ 36 1.2.7.2 O(logN)-Algorithmen ....................................................................................................................... 40 1.2.7.3 Berechnungsgrundlagen für rechnerische Komplexität .................................................................... 41 1.2.7.3.1 System-Effizienz und rechnerische Effizienz ............................................................................ 41 1.2.7.3.2 P- bzw. NP-Probleme................................................................................................................. 41 1.2.7.3.3 Grenzen der Berechenbarkeit..................................................................................................... 42 1.3 Daten und Datenstrukturen ......................................................................................................................... 44 1.3.1 Datentyp................................................................................................................................................... 44 1.3.2 Datenstruktur............................................................................................................................................ 45 1.3.3 Relationen und Ordnungen ...................................................................................................................... 50 1.3.4 Klassifikation von Datenstrukturen ......................................................................................................... 54 1.3.4.1 Lineare Ordnungsgruppen ................................................................................................................ 54 1.3.4.2 Nichtlineare Kollektion..................................................................................................................... 57 1.3.4.2.1 Hierarchische angeordnete Sammlung (Bäume)........................................................................ 57 1.3.4.2.2 Gruppenkollektionen.................................................................................................................. 62 1.3.4.3 Dateien und Datenbanken ................................................................................................................. 63 1.3.5 Definitionsmethoden für Datenstrukturen................................................................................................ 66 1.3.5.1 Der abstrakte Datentyp...................................................................................................................... 66 1.3.5.2 Die axiomatische Methode................................................................................................................ 67 1.3.5.3 Die konstruktive Methode................................................................................................................. 69 1.3.5.4 Die objektorientierte Modellierung abstrakter Datentypen............................................................... 70 2. GRAPHEN UND GRAPHENALGORITHMEN.................................................................................. 78 2.1 Einführung .................................................................................................................................................... 78 3 Algorithmen und Datenstrukturen 2.1.1 Grundlagen............................................................................................................................................... 78 2.1.2 Definitionen ............................................................................................................................................. 82 2.1.3 Darstellung in Rechnerprogrammen ........................................................................................................ 88 2.2 Durchlaufen von Graphen ........................................................................................................................... 93 2.2.1 Tiefensuche (depth-first search)............................................................................................................... 93 2.2.1.1 Algorithmus ...................................................................................................................................... 93 2.2.1.2 Eigenschaften von DFS..................................................................................................................... 97 2.2.1.3 Kantenklassenfikation mit DFS ........................................................................................................ 98 2.2.1.4 Zusammenhangskomponenten........................................................................................................ 101 2.2.1.5 Topologisches Sortieren mittels Tiefensuche ................................................................................. 108 2.2.2 Breitensuche (breadth-first search) ........................................................................................................ 113 2.2.3 Implementierung .................................................................................................................................... 116 2.3 Topologischer Sort ...................................................................................................................................... 120 2.4 Transitive Hülle........................................................................................................................................... 123 2.4.1 Berechnung der Erreichbarkeit mittels Matrixmultiplikation ................................................................ 123 2.4.2 Warshalls Algorithmus zur Bestimmung der Wegematrix .................................................................... 125 2.4.3 Floyds Algorithmus zur Bestimmung der Abstandsmatrix.................................................................... 126 2.5 Kürzeste Wege............................................................................................................................................. 127 2.5.1 Die Datenstrukturen Graph, Vertex, Edge für die Berechnung kürzester Wege ................................... 127 2.5.2 Kürzeste Pfade in gerichteten, ungewichteten Graphen. ....................................................................... 128 2.5.3 Berechnung der kürzesten Pfadlängen in gewichteten Graphen (Algorithmus von Dijkstra) ............... 132 2.5.4 Berechnung der kürzesten Pfadlängen in gewichteten Graphen mit negativen Kosten......................... 137 2.5.5 Berechnung der kürzesten Pfadlängen in gewichteten, azyklischen Graphen ....................................... 138 2.5.6 All pairs shorted Path............................................................................................................................. 140 2.6 Minimale Spannbäume............................................................................................................................... 142 2.6.1 Der Algorithmus von Prim..................................................................................................................... 142 2.6.2 Der Algorithmus von Kruskal................................................................................................................ 145 2.7 Netzwerkflüsse............................................................................................................................................. 148 2.7.1 Maximale Flüsse .................................................................................................................................... 148 27.1.1 Netzwerk und maximaler Fluß......................................................................................................... 148 2.7.1.2 Optimieren und Finden augmentierender Pfade (Erweiterter Weg) ............................................... 150 2.7.1.2 Algorithmus für optimalen Fluss .................................................................................................... 152 2.7.1.4 Schnitte und das Max-Flow-Min-Cut Problem............................................................................... 157 2.7.2 Konsteminimale Flüsse .......................................................................................................................... 159 2.8 Matching ...................................................................................................................................................... 161 2.8.1 Ausgangspunkt, Motivierendes Beispiel, Definitionen, maximales Matching ...................................... 161 2.8.2 Bipartiter Graph ..................................................................................................................................... 165 2.8.3 Maximale Zuordnung im allgemeinen Fall............................................................................................ 170 4 Algorithmen und Datenstrukturen Literaturverzeichnis Sauer, Jürgen: Programmieren in Java, Skriptum zur Vorlesung im WS 2005/2007 http://fbim.fh-regensburg.de/~saj39122/pgj/index.html Sauer, Jürgen: Programmieren in C++, Skriptum zur Vorlesung im SS 2006 http://fbim.fh-regensburg.de/~saj39122/pgc/index.html Sauer, Jürgen: Datenbanken, Skriptum zur Vorlesung im SS 2007 http://fbim.fh-regensburg.de/~saj39122/dbnew/index.html Sauer, Jürgen: Operations Research, Skriptum zur Vorlesung im SS 2005 Sedgewick, Robert: Algorithmen in Java, 3.überarbeitete Auflage, Pearson Studium, München …. , 2003 Sedgewick, Robert: Algorithmen in C++, Teil 1 bis 4, 3.überarbeitete Auflage, Pearson Studium, München …. , 2002 Wirth, Nicklaus: Algorithmen und Datenstrukturen, 2. duchgesehene Auflage, Teubner, Stuttgart 1979 Ottmann, Thomas und Widmayer, Peter: Algorithmen und Datenstrukturen, BI Wissenschaftsverlag, Mannheim /Wien /Zürich 1990 Weiss, Marc Allen: Data Structures and Algorithm Analysis in Java, Pearson, Boston …., 2007 Saake, Gunter und Sattler, Kai Uwe: Algorithmen und Datenstrukturen, dpunkt.verlag, 2. überarbeitete Auflage, Heidelberg, 2004 Maurer, H.: Datenstrukturen und Programmierverfahren, Teubner,Stuttgart 1974 Krüger, Guido und Stark, Thomas: Handbuch der Java-Programmierung, 5. Auflage, HTML-Ausgabe 5.0.1, Addison-Wesley, 2007 Ullenboom, Christian: Java ist auch eine Insel, 7. aktualisierte Auflage, HTMLVersion Ammeraal, Leendert: Programmdesign und Algorithmrn in C, Hanser Verlag München Wien, 1989 5 Algorithmen und Datenstrukturen 6 Algorithmen und Datenstrukturen 1. Grundlegende Konzepte 1.1 Die zentralen Begriffe 1.1.1 Datenstruktur und Algorithmus In den 50er Jahren bedeutete „Rechnen“ auf einem Computer weitgehend „numerisches Lösen“ wissenschaftlich-technischer Probleme. Kontroll- und Datenstrukturen waren sehr einfach und brauchten daher nicht weiter untersucht werden. Ein bedeutender Anstoß kam hier aus der kommerziellen Datenverarbeitung (DV). So führte hier bspw. die Frage des Zugriffs auf ein Element einer endlichen Menge zu einer großen Sammlung von Algorithmen 1, die grundlegende Aufgaben der DV lösen. Dabei ergab sich: Die Leistungsfähigkeit dieser Lösungen (Programme) ist wesentlich bestimmt durch geeignete Organisationsformen für die zu bearbeitenden Daten. Der Datentyp oder die Datenstruktur und die zugehörigen Algorithmen sind demnach ein entscheidender Bestandteil eines leistungsfähigen Programms. Datenstrukturen und Programmierverfahren bilden eine Einheit. Bei der Formulierung des Lösungswegs ist man auf eine bestimmte Darstellung der Daten festgelegt. Rein gefühlsmäßig könnte man sagen: Daten gehen den Algorithmen voraus. Programmieren führt direkt zum Denken in Datenstrukturen, um Datenelemente, die zueinander in Beziehung stehen, zusammen zu fassen. Mit Hilfe solcher Datenstrukturen ist es möglich, sich auf die relevanten Eigenschaften der Umwelt zu konzentrieren und eigene Modelle zu bilden. Die Leistung des Rechners wird dabei vom reinen Zahlenrechnen auf das weitaus höhere Niveau der „Verarbeitung von Daten“ angehoben Datenstrukturen und Algorithmen bilden die wesentlichen Bestandteile der Programmierung. Ein erster Versuch soll diese zentralen Begriffe so festlegen (bzw. abgrenzen): Datenstruktur Ein auf Daten anwendbares Ordnungsschema (z.B. ein Datensatz oder Array). Mit der Hilfe von Datenstrukturen lassen sich die Daten interpretieren und spezifische Operationen auf ihnen ausführen Algorithmus Verarbeitungsvorschrift, die angibt, wie Eingabe(daten) schrittweise mit Hilfe von Anweisungen auf Rechnern in Ausgabe(daten) umgewandelt werden. Für die Lösung eines Problems existieren meist mehrere Algorithmen, die sich in der Länge sowie der für die Ausführung benötigte Zeit unterscheiden. Programm und Programmiersprache Ein Programm ist die Formulierung eines Algorithmus und seiner Datenbereiche in einer Programmiersprache. 1 D. E. Knuth hat einen großen Teil dieses Wissens in "The Art of Computer Programming" zusammengefaßt 7 Algorithmen und Datenstrukturen Eine Programmiersprache erlaubt, Algorithmen präzise zu beschreiben. Insbesondere legen sie fest: - die elementaren Operationen - die Möglichkeiten zu ihrer Kombination - die zulässigen Datenbereiche 1.1.2 Ein einführendes Beispiel: Das Durchlaufen eines Binärbaums Das ist eine Grundaufgabe zur Behandlung von Datenstrukturen. Ein binärer Baum B ist entweder leer, oder er besteht aus einem linken Baum BL, einem Knoten W und einem rechten Teilbaum BR. Diese Definition ist rekursiv. Den Knoten W eines nichtleeren Baumes nennt man seine Wurzel. Beim Durchlaufen des binären Baumes sind alle Knoten aufzusuchen (, z. B. in einer vorgegebenen „von links nach rechts"-Reihenfolge,) mit Hilfe eines systematischen Weges, der aus Kanten aufgebaut ist 2. Die Darstellung bzw. die Implementierung eines binären Baums benötigt einen Binärbaum-Knoten: Dateninformation Knotenzeiger Links Rechts Zeiger Zeiger zum linken zum rechten Nachfolgeknoten Abb. 1.1-0: Knoten eines binären Suchbaums Eine derartige Struktur stellt die Klassenschablone baumKnoten bereit 3: #ifndef BAUMKNOTEN #define BAUMKNOTEN #ifndef NULL const int NULL = 0; #endif // NULL // Deklaration eines Binaerbaumknotens fuer einen binaeren Baum template <class T> class baumKnoten { protected: // zeigt auf die linken und rechten Nachfolger des Knoten baumKnoten<T> *links; baumKnoten<T> *rechts; public: // Das oeffentlich zugaenglich Datenelement "daten" T daten; // Konstruktor baumKnoten (const T& merkmal, baumKnoten<T> *lzgr = NULL, baumKnoten<T> *rzgr = NULL); // virtueller Destruktor virtual ~baumKnoten(void); // Zugriffsmethoden auf Zeigerfelder 2 3 vgl. Skriptum: Algorithmen und Datenstrukturen SS09, 4.2.3 vgl. pr11_1, baumkno.h 8 Algorithmen und Datenstrukturen baumKnoten<T>* holeLinks(void) const; baumKnoten<T>* holeRechts(void) const; // Die Klasse binSBaum benoetigt den Zugriff auf // "links" und "rechts" }; // Schnittstellenfunktionen // Konstruktor: Initialisiert "daten" und die Zeigerfelder // Der Zeiger NULL verweist auf einen leeren Baum template <class T> baumKnoten<T>::baumKnoten (const T& merkmal, baumKnoten<T> *lzgr, baumKnoten<T> *rzgr): daten(merkmal), links(lzgr), rechts(rzgr) {} // Die Methode holeLinks ermoeglicht den Zugriff auf den linken // Nachfolger template <class T> baumKnoten<T>* baumKnoten<T>::holeLinks(void) const { // Rueckgabe des Werts vom privaten Datenelement links return links; } // Die Methode "holeRechts" erlaubt dem Benutzer den Zugriff auf den // rechten Nachfoger template <class T> baumKnoten<T>* baumKnoten<T>::holeRechts(void) const { // Rueckgabe des Werts vom privaten Datenelement rechts return rechts; } // Destruktor: tut eigentlich nichts template <class T> baumKnoten<T>::~baumKnoten(void) {} #endif // BAUMKNOTEN Mit der vorliegenden Implementierung zu einem Binärbaum-Knoten kann bspw. die folgende Gestalt eines binären Baums erzeugt werden: 1 2 3 5 4 Abb1.1-1: Eine binäre Baumstruktur Benötigt wird dazu die folgenden Anweisungen im Hauptprogrammabschnitt: // Hauptprogramm int main() { int zahl; baumKnoten<int> *wurzel; 9 Algorithmen und Datenstrukturen baumKnoten<int> *lKind, *rKind, *z; lKind = new baumKnoten<int>(3); rKind = new baumKnoten<int>(4); z = new baumKnoten<int>(2,lKind,rKind); lKind = z; rKind = new baumKnoten<int>(5); z = new baumKnoten<int>(1,lKind,rKind); wurzel = z; } 1.1.2.1 Rekursive Problemlösung Rekursive Datenstrukturen (z.B. Bäume) werden zweckmäßigerweise mit Hilfe rekursiv formulierter Zugriffsalgorithmen bearbeitet. Das zeigt die folgende Lösung in C++: #include<iostream.h> #include<stdlib.h> #include "baumkno.h" // Funktionsschablone fuer Baumdurchlauf template <class T> void wlr(baumKnoten<T>* b) { if (b != NULL) { cout << b->daten << ' '; wlr(b->holeLinks()); // linker Abstieg wlr(b->holeRechts()); // rechter Abstieg } } // Hauptprogramm int main() { int zahl; baumKnoten<int> *wurzel; baumKnoten<int> *lKind, *rKind, *z; lKind = new baumKnoten<int>(3); rKind = new baumKnoten<int>(4); z = new baumKnoten<int>(2,lKind,rKind); lKind = z; rKind = new baumKnoten<int>(5); z = new baumKnoten<int>(1,lKind,rKind); wurzel = z; cout << "Inorder: " << endl; ausgBaum(wurzel,0); wlr(wurzel); // Rekursive Problemlösung cout << endl; // Nichtrekursive Problemlösung wlrnr(wurzel); cout << endl; } Das Durchlaufen geht offensichtlich von der Wurzel aus, ignoriert zuerst die rechten Teilbäume, bis man auf leere Bäume stößt. Dann werden die Teiluntersuchungen abgeschlossen und beim Rückweg die rechten Bäume durchlaufen. Jeder Baumknoten enthält 2 Zeiger (Adressen von BL und BR). Die Zeiger, die auf leere Bäume hinweisen, werden auf „NULL“ gestellt. 10 Algorithmen und Datenstrukturen 1.1.2.2 Nichtrekursive Problemlösung Das vorliegende Beispiel ist in C++ notiert. C++ läßt rekursiv formulierte Prozeduren zu. Was ist zu tun, wenn eine Programmiersprache rekursive Prozeduren nicht zuläßt? Rekursive Lösungsangaben sind außerdem schwer verständlich, da ein wesentlicher Teil des Lösungswegs dem Benutzer verborgen bleibt. Die Ausführung rekursiver Prozeduren verlangt bekanntlich einen Stapel (stack). Ein Stapel ist eine Datenstruktur, die auf eine Folge von Elementen 2 wesentliche Operationen ermöglicht: Die beiden wesentlichen Stackprozeduren sind PUSH und POP. PUSH fügt dem Stapel ein neues Element an der Spitze (top of stack) hinzu. POP entfernt das Spitzenelement. Die beiden Prozeduren sind mit der Typdefinition des Stapel beschrieben. Der Stapel nimmt Zeiger auf die Baumknoten auf. Jedes Stapelelement ist mit seinen Nachfolgern verkettet: Top-Element Zeiger auf Baumknoten Zeiger auf Baumknoten Zeiger auf Baumknoten nil nil Abb. 1.1-2: Aufbau eines Stapels Der nicht rekursive Baumdurchlauf-Algorithmus läßt sich mit Hilfe der Stapelprozeduren der Containerklasse Stack der Standard Template Library (STL) 4 so formulieren: template <class T> void wlrnr(baumKnoten<T>* z) { stack<baumKnoten<T>*, vector<baumKnoten<T>*> > s; s.push(NULL); while (z != NULL) { cout << z->daten << ' '; if (z->holeRechts() != NULL) s.push(z->holeRechts()); if (z->holeLinks() != NULL) z = z->holeLinks(); else { z = s.top(); s.pop(); } } } 4 vgl. Skriptum: Algorithmen und Datenstrukturen SS09, 2.2 11 Algorithmen und Datenstrukturen Dieser Algorithmus ist zu überprüfen mit Hilfe des folgenden binären Baumes Z1 1 Z2 Z5 2 5 Z3 Z4 3 4 Abb. 1.1-3: Zeiger im Binärbaum Welche Baumknoten (bzw. die Zeiger auf die Baumknoten) werden beim Durchlaufen des vorliegenden Baumes (vgl. Abb. 1.1-3) über die Funktionsschablone wlrnr aufgesucht? Welche Werte (Zeiger auf Baumknoten) nimmt der Stapel an? Besuchte Knoten ¦ Stapel -----------------+------------¦ Null Z1 ¦ Z5 Null Z2 ¦ Z4 Z5 Null Z3 ¦ Z4 Z5 Null Z4 ¦ Z5 Null Z5 ¦ Null Verallgemeinerung Bäume treten in vielen Situationen auf. Beispiele dafür sind: - die Struktur einer Familie, z.B.: Christian Ludwig Jürgen Martin Karl Ernst Abb. 1.1-4: Ein Familienstammbaum - Bäume sind auch Verallgemeinerungen von Feldern (arrays), z.B.: 12 Fritz Algorithmen und Datenstrukturen -- das 1-dimensionale Feld F F F[1] F[2] F[3] Abb. 1.1-5: Baumdarstellung eines eindimensionalen Felds -- der 2-dimensionale Bereich B B[1,_] B[2,_] B[1,1] B[1,2] B[2,1] B[2,2] Abb. 1.1-6: Baumdarstellung eines zweidimensionalen Felds -- Auch arithmetische Ausdrücke lassen sich als Bäume darstellen. So läßt sich bspw. der Ausdruck ((3 * (5 - 2)) + 7) so darstellen: + * 7 3 - 5 2 Abb. 1.1-6: Baumdarstellung des arithmetischen Ausdrucks ((3 * (5 - 2)) + 7), sog. Kantorowitch-Baum Durch das Aufnehmen des arithmetischen Ausdrucks in die Baumdarstellung können Klammern zur Regelung der Abarbeitungsreihenfolge eingespart werden. Die korrekte Auswertung des arithmetischen Ausdrucks ist auch ohne Klammern bei geeignetem Durchlaufen und Verarbeiten der in den Baumknoten gespeicherten Informationen gewährleistet. 13 Algorithmen und Datenstrukturen Die Datenstruktur „Baum“ ist offensichtlich in vielen Anwendungsfällen die geeignete Abbildung für Probleme, die mit Rechnerunterstützung gelöst werden sollen. Der zur Lösung notwendige Verfahrensablauf ist durch das Aufsuchen der Baumknoten festgelegt. Das einführende Beispiel zeigt das Zusammenwirken von Datenstruktur und Programmierverfahren für Problemlösungen. Bäume sind deshalb auch Bestandteil von Container-Klassen aktueller Compiler (C++, Java). 14 Algorithmen und Datenstrukturen 1.2 Algorithmische Grundkonzepte 1.2.1 Algorithmenbegriffe Algorithmen im Alltag Gegeben ist ein Problem. Eine Handlungsvorschrift, deren mechanisches Befolgen - ohne Verständnis des Problems - mit sinnvollen Eingabedaten - zur Lösung des Problems führt, wird Algorithmus genannt. Ein Problem, für dessen Lösung ein Algorithmus existiert, heißt berechenbar. Bsp.: - Zerlegung handwerklicher Arbeiten in einzelne Schritte - Kochrezepte - Verfahren zur schriftlichen Multiplikation - Algorithmen zur Bestimmung des größten gemeinsamen Teiles zweier natürlichen Zahlen - Bestimmung eines Schaltjahres - Spielregeln Der intuitive Algorithmenbegriff Ein Algorithmus ist eine präzise (d.h. in einer festgelegten Sprache abgefasste) endliche Verarbeitungsvorschrift, die genau festlegt, wie die Instanzen einer Klasse von Problemen gelöst werden. Ein Algorithmus liefert eine Funktion (Abbildung), die festlegt, wie aus einer zulässigen Eingabe die Ausgabe ermittelt werden kann. Ein Algorithmus (in der EDV) ist - ein Lösungsschritt für eine Problemklasse (konkretes Problem wird durch Eingabeparameter identifiziert) - geeignet für die Implementierung als Rechnerpogramm - endliche Folge von elementaren, ausführbaren Instruktionen Verarbeitungsschritten 1.2.2 Terminierung und Determinismus Abgeleitet vom intuitiven Algorithmenbegriff spielen bei der Konzeption von Algorithmen die Begriffe Terminierung, Determinismus und Vollständigkeit eine Rolle: Terminierung Ein Algorithmus heißt terminierend, wenn er (bei jeder erlaubten Eingabe von Parametern) nach endlich vielen Schritten abbricht. Determinismus Ein Algorithmus hat einen deterministischen Ablauf, wenn er eine eindeutige Schrittfolge besitzt. Der Algorithmus läuft bei jedem Ablauf mit den gleichen 15 Algorithmen und Datenstrukturen Eingaben durch dieselbe Berechnung. Ein Algorithmus liefert ein determiniertes Ergebnis, wenn bei vorgegebener Eingabe (auch bei mehrfacher Durchführung) stets ein eindeutiges Ergebnis erreicht wird. Nicht deterministische Algorithmen mit determiniertem Ergebnis heißen determinierter Algorithmus.Nicht deterministische Algorithmen können zu einem determiniertem Ergebnis führen, z.B.: 1. Nimm eine Zahl x ungleich Null 2. Entweder: Addiere das Dreifache von x zu x und teile das Ergebnis durch den Anfangswert von x Oder: Subtrahiere 4 von x und subtrahiere das Ergebnis von x 3. Schreibe das Ergebnis auf Vollständigkeit Alle Fälle, die bei korrekten Eingabedaten auftreten können, werden berücksichtigt. Bsp.: Nichtvollständige Algorithmen (1) Wähle zufällig eine Zahl x (2) Wähle zufällig eine Zahl y (3) Das Ergebnis ist x/y Was ist, wenn y == 0 sein sollte Nicht terminierender Algorithmus (1) Wähle zufällig eine Zahl x (2) Ist die Zahl gerade, wiederhole ab (1) (3) Ist die Zahl ungerade, wiederhole ab (1) Nicht determinierter Algorithmus 60 64 (1) Wähle zufällig eine natürliche Zahl zwischen 2 und 2 (2) Prüfe, ob die Zahl eine Primzahl ist. (3) Falls nicht, wiederhole ab 1. Das Ergenis ist immer eine Primzahl, aber nicht die gleiche, daher ist der Algorithmus nicht determiniert. Deterministische, terminierende Algorithmen definieren jeweils eine Ein/Ausgabefunktion: f : Eingabewerte -> Ausgabewerte Algorithmen geben eine konstruktiv ausführbare Beschreibung dieser Funktion, die Funktion heißt Bedeutung (Semantik) des Algorithmus. Es kann mehrere verschiedene Algorithmen mit der gleichen Bedeutung geben. 1.2.3 Algorithmenbausteine Gängige Bausteine zur Beschreibung bzw. Ausführung von Algorithmen sind: - elementare Operationen - sequentielle Ausführung (ein Prozessor) Der Sequenzoperator ist „;“. Sequenzen ohne Sequenzoperator sind häufig durchnummeriert und können schrittweise verfeinert werden, z.B: (1) Koche Wasser (2) Gib Kaffepulver in Tasse (3) Fülle Wasser in Tasse (2) kann verfeinert werden zu: 16 Algorithmen und Datenstrukturen Öffne Kaffeedose; Entnehme Löffel von Kaffee; Kippe Löffel in Tasse; Schließe Kaffeedose; - parallele Ausführung - bedingte Ausführung Die Auswahl / Selektion kann allgemein so formuliert werden: falls Bedingung, dann Schritt bzw. falls Bedingung dann Schritt a sonst Schritt b „falls ... dann ... sonst ...“ entspricht in Programmiersprachen den Konstrukten: if Bedingung then ... else … fi if Bedingung then … else …endif if (Bedingung) … else … - Schleife (Iteration) Dafür schreibt man allgemein wiederhole Schritte bis Abbruchkriterium Häufig findet man auch die Variante solange Bedingung führe aus Schritte bzw. die Iteration über festen Bereich wiederhole für Bereichsangabe Schleifenrumpf Diese Schleifenkonstrukte Konstrukten: wiederhole ... bis ... solange … führe aus wiederhole für entsprechen repeat ... until … do … while ... while … do ... while ( ... ) ... for each ... do … for ... do … for ( ... ) ... - Unterprogramm (Teilalgoritmus) 17 jeweils den Programmiersprachen- Algorithmen und Datenstrukturen - Rekursion Eine Funktion (mit oder ohne Rückgabewert, mit oder ohne Parameter) darf in der Deklaration ihres Rumpfes den eigenen Namen verwenden. Hierdurch kommt es zu einem rekursiven Aufruf. Typischerweise werden die aktuellen Parameter so modifiziert, daß die Problemgröße schrumpft, damit nach mehrmaligem Wiederholen dieses Prinzips kein weiterer Aufruf erforderlich ist und die Rekursion abbrechen kann. 1.2.4 Paradigmen der Algorithmenbeschreibung Ein Algorithmenparadigma legt Denkmuster fest, die einer Beschreibung eines Algorithmus zugrunde liegen. Faßt man einen Algorithmus als Beschreibung eines allgemeinen Verfahrens unter Verwendung ausführbarer elementarer Schritte auf, dann gibt es 2 grundlegende Arten, Schritte von Algorithmen zu notieren: - Applikative Algorithmen sind eine Verallgemeinerung der Funktionsauswertung mathematisch notierter Funktionen. In ihnen spielt die Rekursion 5 eine wesentliche Rolle. - Imperative Algorithmen basieren auf einem einfachen Maschinenmodell mit gespeicherten und änderbaren Werten. Hier werden primär Schleifen und Alternativen als Kontrollbausteine eingesetzt. In der Informatik sind darüber hinaus noch folgende Paradigmen wichtig: - Objektorientiete Algorithmen. In einem objektorientierten Algorithmus werden Datenstrukturen und Methoden zu einer Klasse zusammengefasst. Von jeder Klasse können Objekte gemäß der Datenstruktur erstellt und über die Methode manipuliert werden. Das objektorientierte Paradigma ist kein Algorithmenparadigma im engeren Sinne, da es sich um ein Paradigma zur Strukturierung von Algorithmen handelt, das sowohl mit applikativen, imperativen und logischen Konzepten zusammen eingesetzt werden kann. - logische (deduktive) Algorithmen. Ein logischer Algorithmus führt Berechnungen durch, indem er aus Fakten und Regeln durch Ableitungen in einem logischen Kalkül weitere Fakten ausweist. 5 vgl. Skriptum: Algorithmen und Datenstrukturen SS09, 3.3 18 Algorithmen und Datenstrukturen 1.2.4.1 Applikative Algorithmen Applikative Algorithmen sind die Grundlage eine Reihe von universellen Programmiersprachen wie APL, Lisp, Scheme etc. Diese Programmiersprachen werden als funktionale Programmiersprachen bezeichnet. Idee: Defintion zusammengesetzter Funktionen durch Ausdrücke / Terme, z.B. f ( x) = 5 x + 1 . Definitionen 6 Ein applikativer Algorithmus ist eine Liste von Funktionsdefinitionen f1 (v1,1 ,..., v1,n1 ) = t1 (v1,1 ,..., v1,n1 ) . . f m (v m1,1 ,..., v m1,nm ) = t m (v m1,1 ,..., v m ,nm ) v1 ,..., v n : Unbestimmte vom Typ τ 1 ,...,τ n , formale Parameter. τ ist dabei der Typ des Terms t (v1 ,..., v n ) t (v1 ,..., v n ) : ein Term (/Ausdruck), heißt Funktionsausdruck Die erste Funktion wird ausgewertet und bestimmt die Bedeutung (Semantik) des Algorithmus. Bsp.: 7 1. f ( x, y ) = if g ( x, y ) then h( x + y ) else h( x − y ) fi g ( x, y ) = ( x = y ) or odd ( y ) h( x) = j ( x + 1) ∗ j ( x − 1) j ( x) = 2 x − 3 8 f (1,2) a if g (1,2) then h(1 + 2) else h(1 − 2) fi a if 1 = 2 or odd (2) then h(1 + 2) else h(1 − 2) fi a if 1 = 2 or false then h(1 + 2) else h(1 − 2) fi a if false or false then h(1 + 2) else h(1 − 2) fi a if false then h(1 + 2) else h(1 − 2) fi a h(1 − 2) a h(−1) a j (−1 + 1) ∗ j (−1 − 1) a j (0) ∗ j (−1 − 1) a j (0) ∗ j (−1 − 1) a j (0) ∗ j (−2) a j (2 ∗ 0 − 3) ∗ j (−2) a (−3) ∗ (−7) 6 Hier erfolgt eine Beschränkung der Definitionen auf Fünktionen über int und bool, obwohl die Konzepte natürlich für beliebige Datentypen gelten. 7 x, y : ganze Zahlen 8 a : konsekutive Ausführung mehrerer elementarer Termauswertungsgebiete 19 Algorithmen und Datenstrukturen a 21 2. f ( x, y ) = if x = 0 then y else ( if x > 0 then f ( x − 1, y ) + 1else − f (− x,− y ) fi ) fi f (0, y ) a y für alle y f (1, y ) a f (0, y ) + 1a y + 1 f (2, y ) a f (1, y ) + 1a y + 1 + 1 a y + 2 …. f (n, y ) a y + n f (−1, y ) a − f (1,− y ) a −(1 − y ) a y − 1 … f ( x, y ) = x + y Eine Funktionsdefinition definiert eine Funktion mit folgender Signatur: f : τ 1 × τ 2 × ...τ n → τ n Sind a1 ,..., a n Werte vom Typ τ 1 ,...,τ n , so ersetzt man bei der Auswertung von f (a1 ,..., a n ) im definierten Vorkommen v1 durch a1 und wertet t (a1 ,..., a n ) aus. a1 ,..., a n : aktuelle Parameter f (a1 ,..., a n ) : Funktionsaufruf Aufrufe definierter Funktionen dürfen als Terme verwendet werden. Bsp. für applikative Algorithmen 1. Fakultätsberechnung x!= x ∗ ( x − 1) ∗ ( x − 2) ∗ ... ∗ 2 ∗ 1 für x > 0 fak ( x) = if x ≤ 0 then1else x ∗ fak ( x − 1) mathematische Funktion applikativer Algorithmus 2. Größter gemeinsamer Teiler 9(ggT) ggT ( x, x) = x mathematische ggT ( x, y ) = ggT ( y, x) Gesetzmäßigkeiten ggT ( x, y ) = ggT ( x, y − x) für x < y applikativer Algorithmus ggT ( x, y ) = if ( x ≤ 0) or ( y ≤ 0) then ggT ( x, y ) else if x = y then x else if x > y then ggT ( y, x) else ggT ( x, y − x) fi fi fi; ggT ist korrekt für positive Eingaben, bei negativen Eingaben ergeben sich nicht abbrechbare Berechnungen (undefinierte Funktionen) 10. ggT (39,15) a ggT (15,39) + ggT (15,24) a ggT (15,9) a ggT (9,15) a ggT (9,6) a ggT (6,9) ggT (6,3) a ggT (3,6) + ggT (3,3) a 3 3. Fibonacci-Zahlen: f 0 = f 1 = 1, f i = f i −1 + f i − 2 für i > 0 fib( x) = if ( x = 0) or ( x = 1) then1 else fib( x − 2) + fib( x − 1) fi ⎧ x _ te Fibonacci − Zahl falls x ≥ 0 Bedeutung: fib( x) = ⎨ sonst ⎩ 1 9 vgl. 1.2.5 Das Berechnungsschema stützt sich auf eine Formularisierung des Originalverfahrens von Euklid ab. 10 20 Algorithmen und Datenstrukturen 1.2.4.2 Imperative Algorithmen In einem imperativen Algorithmus gibt es Variable, die verschiedene Werte annehmen können. Die Menge aller Variablen und ihrer Werte (sowie der Programmzähler) beschreiben den Zustand zu einem bestimmten Zeitpunkt. Ein Algorithmus bewirkt eine Zustandstransformation. Imperative Konzepte - Anweisungen -- primitive Anweisungen: Zuweisung, Block, Prozeduraufruf -- zusammengesetzte Anweisungen: Sequenz, Auswahl, Iteration - Ausdrücke -- primitive Ausdrücke: Konstante, Variable, Funktionsaufruf -- zusammengesetzte Ausdrücke: Operanden / Operatoren - Datentypen -- primitive Datentypen: Wahrheitswerte, Zeichen, Zahlen, Aufzählung -- zusammengesetzte Datentypen: Felder, Verbund, Vereinigung, Zeiger - Abstraktion -- Anweisung -- Ausdruck: Funktionsdeklaration -- Datentyp: Typdeklaration - Weitere Konzepte -- Ein- und Ausgabe -- Ausnahmenbehandlung -- Bibliotheken -- Parallele und verteilte Berechnungen Wertzuweisungen sind die einzigen elementaren Anweisungen imperativer Algorithmen. Aus ihnen werden zusammengesetzte Anweisungen gebildet, aus denen imperative Algorithmen bestehen. Elementare Anweisungen können auf unterschiedliche Art zu komplexen Anweisungen zusammengestzt werden: (1) sequentielle Ausführung (2) bedingte Ausführung (3) wiederholte Ausführung (4) Ausführung als Unterprogramm (5) rekursive Ausführung eines Unterprogramms Diese Möglichkeiten werden als Kontrollstrukturen bezeichnet. 1.2.4.3 Objektorientierte Algorithmen Das objektorientierte Paradigma der Algorithmenentwicklung hat verschiedene Wurzeln: - 11 Realisierung abstrakter Datentypen 11 Rechnergeeignete Modellierung der realen Welt (objektorientierte Analyse) Problemnaher Entwurf von Softwaresystemen (objektorientiertes Design) Problemnahe Implementierung (objektorientierte Programmierung Vgl. 1.3.5.1 21 Algorithmen und Datenstrukturen Ein Objekt ist die Repräsentation eines Gegenstands und Sachverhalts der realen Welt oder eines gedanklichen Konzepts. Es ist gekennzeichnet durch - - eine eindeutige Identität, durch die es sich von anderen Objekten unterscheidet Wertbasierte Objektmodelle: In diesem Modell besitzen Objekte keine eigene Identität im eigentlichen Sinn. Zwei Objekte werden schon als identisch angesehen, wenn ihr Zustand gleich ist. Identitätsbasierte Objektmodelle: Jedem Objekt innerhalb des Systems wird eine vom Wert unabhängige Identität zugeordnet, statische Eigenschaften zur Darstellung des Zustands des Objekts in Form von Attributen dynamische Eigenschaften in Form von Methoden, die das Verhalten des Objekts beschreiben Der Zustand eines Objekts zu einem Zeitpunkt entspricht der Belegung der Attribute des Objekts zu diesem Zeitpunkt. Der Zustand kann mit Hilfe von Methoden erfragt und geändert werden. Methoden sind in der programmiesprachlichen Umsetzung Prozeduren und Funktionen, denen Parameter übergeben werden können. Der Zustand eines eine Methode ausführenden Objekts (und nur dieses Objekts) ist der Methode im Sinne einer Menge globaler Variablen direkt zugänglich. Es kann daher sowohl gelesen als auch geändert werden. Objekte verwenden das Geheimnisprinzip und das Prinzip der Kapselung. Sie verbergen ihre Interna: - Zustand (Belegung der Attribute) Implementierung ihres Zustands Implementierung ihres Verhaltens Objekte sind nur über ihre Schnittstelle, also über die Menge der vom Objekt der Außenwelt zur Verfügung gestellten Methoden zugänglich. Man spricht von den Diensten des Objekts. Objekte interagieren über Nachrichten: - Ein Objekt x sendet eine Nachricht an Objekt y. y empfängt die Nachricht von x Innerhalb der Programmiersprache wird dieser Vorgang meistens durch einen Methodenaufruf implementiert Nachrichten (Methodenaufrufe) können den Zustand eines Objekts verändern Ein Objekt kann sich selbst Nachrichten schicken. Objekte können in Beziehung zueinander stehen. - - - Die Beteiligten an eine Beziehung nehmen Rollen ein, z.B.: Rolle des Arztes: „behandelnder Arzt“, Rolle des Patienten: „Patient“ Ein Objekt kann mit mehreren Objekten in Beziehung stehen Rolle vom Arzt: „behandelnder Arzt“ Rolle von Patient 1: „Patient“, Rolle von Patient 2: „Patient“ Nachrichen können nur ausgetauscht werden, wenn eine Beziehung besteht Beziehungen können sich während der lebenszeit eines Objekts verändern Es gibt in der Regel Objekte, die sich bezüglich der Attribute, Methoden und Beziehungen ähnlich sind. Daher bieten es sich an, diese Objekt zu einer Klasse zusammenzufassen. Die Klasse beinhaltet dann auch Angaben darüber, wie Objekte dieser Klasse verwaltet (z.B. erzeugt oder gelöscht) werden können. 22 Algorithmen und Datenstrukturen - Klassendefinitionen sind eng verwandt mit abstrakten Datentypen. Sie legen Attribute und Methoden der zugehörigen Objekte fest Objekte dieser Klasse nennt man auch Instanzen dieser Klasse Beziehungen (Assoziationen) zwischen Objekten werden auf Klassenebene beschrieben Ein Konstruktor ist eine Methode zur Erzeugung von Objekten 12. - Es gibt Attribute von Klassen, die nicht an konkrete Instanzen gebunden sind. Diese heißen Klassenvariable oder statische Vartiable. Klassenvariable existieren für die gesamte Lebensdauer einer Klasse genau einmal – unabhängig davon, wie viele Objekte erzeugt wurden Neben Klassenvariablen gibt es auch Klassenmethoden, d.h. Methoden, deren Existenz nicht an konkrete Objekte gebunden ist. Klassenmethoden werden auch statische Methoden genannt. Zu ähnlichen Klassen versucht man eine gemeinsame Oberklasse (Basisklasse) zu finden, die die Ähnlichkeiten aufnimmt. Unterklassen (Subklassen) werden nur um individuelle Eigenschaften ergänzt, denn eine Unterklasse erbt die Attribute und Methoden der Oberklasse. Eine Veraible vom Typ einer Basisklasse kann während ihrer Lebensdauer sowohl Objekte ihres eigenen Typs als auch soche von abgeleiteten Klassen aufnehmen. Dieses wird als Polymorphismus 13 bezeichner. - - - Eine Unterklasse erbt von ihrere Oberklasse alle Attribute und Methoden und kann diese um weitere Methoden ergänzen Erben heißt: Die Attribute und Methoden können in der Unterklasse verwendet werden, als wären sie in der Klasse selbst definiert. Vererbungen können mehrstufig sein. Es entstehen Vererbungshierarchien. Eine Unterklasse kann eine Variable deklarieren, die denselben Namen trägt, wie eine der Oberklasse. Hierdurch wird eine weiter oben liegende Variable verdeckt. Dies wird häufig dazu benutzt, um den Typ einert Variablen der Oberklasse zu überschreiben. In manchen Programmiersprachen gibt es Konstrukte, die den Zugriff auf verdeckte Variable ermöglichen Metoden, die aus der Basisklasse geerbt wurden, dürfen in der abgeleiteten Klasse überlagert, d.h. neu definiert werden. Da eine Variable einer Basisklasse Werte von verschiedenen Typen annehmen kann, entscheidet sichj bei überlagerten Mathoden erst zur Laufzeit, welche Methode zu verwenden ist: Dynamische Methodensuche Wird eine Methode in einer abgeleiteten Klasse überlagert, wird die ursprüngliche Methode verdeckt. Aufrufe der Methode beziehen sich auf die überlagerte Variante In amnchen Programmiersprachen gibt esw Konstrukte, die den Zugriff auf überlagerte Methoden ermöglichen 14. Mit Hilfe von Modifikatoren 15 können Sichtbarkeit und Eigenschaften von Klassen, Variablen und Methoden beeinflusst werden. - Die Sichbarkeit bestimmt, ob eine Klasse, Variable oder Methode in anderen Klassen genutzt werden kann. Eigenschaften, die über Modifikatoren gesteuert werden können, sind z.B. die Lebensdauer und die Veränderbarkeit Abstrakte Methoden: Eine Methode heißt abstrakt, wenn ihre Deklaration nur die Schnittstelle, nicht aber die Implementierung enthält. Im Gegensatz dazu stehen die 12 Vgl. Skriptum zur Vorlesung im WS 2005 / 2006: Programmieren in Java, 1.4.1.1.3 bzw. Skriptum zur Vorlesung im SS 2006: Programmieren in C++, 3., 3.1, 3.2 13 Vgl. Skriptum zur Vorlesung im WS 2005 / 2006: Programmieren in Java, 1.4.1.8 14 in Java: Verwendung des Präfixes: super 15 Bsp. für Modifikatoren in Java sind: public, private, static, final, … 23 Algorithmen und Datenstrukturen konkreten Methoden, deren Deklaration auch Implementierungen besitzen 16. Abstrakte Methoden können nicht aufgerufen werden, sie definieren nur eine Schnittstelle. Erst durch Überlagerung in einer abgeleiteten Klasse und durch Angabe der fehlenden Implementierung wird eine abstrakte Klasse konkret. Abstraklte Klassen: Eine Klasse, die nicht instanziiert werden kann, heißt abstrakte Klasse. Klassen, von denen Objekte erzeugt werden können, sind konkrete Klassen. Jede Klasse, die mindestens eine abstrakte Methode besitzt, ist abstrakt 17. Schnittstellen: Eine Schnittstelle (interface) ist in Java eine Klasse, die ausschließlich Konstanten und abstrakte Methoden enthält. Zur Definition einer Schnittstelle wird das Schlüsselwort class durch das Schlüsselwort interface ersetzt. Mitt „interfaces“ kann in Java das Konzept der Mehrfachvererbung implementiert werden, das in C++ direkt realisierber ist. Generizität: Unter Generizität versteht man die Parametrisierung von Klassen, Datentypen, Prozeduren, Moduln, Funktionen, etc. Als Parameter werden in der Regel Datentypen (manchmal auch Algorithmen in Form von Prozeduren) verwendet. 1.2.4.4 Paradigmen und Programmiersprachen Zu den Paradigmen korrespondieren jeweils Programmiersprachen, die diesen Ansatz realisieren. Moderne Programmiersprachen vereinen oft Ansätze mehrerer Paradigmen. So ist bspw. Java bzw. C++ objektorientiert 18, umfasst aber auch imperative und applikative Elemente. 1.2.5 Beschreibung von Algorithmen Verbreitetes Grundschema von Algorithmen Name des Algorithmus und Parameterliste Spezifikation des Ein- und Ausgabeverhaltes 1. Schritt Einführung von Hilfsgrößen Vorbereitung Initialisierungen 2. Schritt Prüfe, ob ein einfacher Fall vorliegt Trivialfall Falls ja: Ergebnis ausgeben und enden 3. Schritt Reduziere Problemstellung A auf einfachere Form B Problemreduktion, (z.B. Aufteilen in Teilprobleme) Ergebnisaufbau 4. Schritt entweder Rekursion: oder Iteration: Rekursion bzw. Rufe Algorithmus mit Fahre mit B anstelle a bei Iteration reduziertem B auf Schritt 2 fort 16 Java: Die Deklaration einer abstrakten Methode erfolgt durch den Modifikator abstract. Java: Es ist erforderlich, abstrakte Klassen abzuleiten und in der abgeleiteten Klasse eine oder mehrere abstrakte Methoden zu implementieren. Die Konkretisierung kann über mehrere Stufen erfolgen. 18 Vgl. Skriptum zur Vorlesung im WS 2005 / 2006: Programmieren in Java, 1. bzw. Skriptum zur Vorlesung im SS 2006: Programmieren in C++ 17 24 Algorithmen und Datenstrukturen Verbale Umschreibung von Algorithmen Eine derartige Handlungsanweisung könnte bspw. die „Berechnung des größten gemeinsamen Teilers von a und b“ in folgender Weise sein: 1. Weise x den Wert von a zu 2. Weise y den Wert von b zu 3. Falls x gleich y ist: gehe zu 9 4. Falls x kleiner als y ist: gehe zu 7 5. Weise x den Wert von (x-y) zu 6. Gehe zu 3 7. Weise y den Wert von (y-x) zu 8. gehe zu 3 9. Weise ggTden Wert von x zu Pseudo-Code - Abstrakte Beschreibung eines Algorithmus - Strukturierter als Beschreibung mit normalen Sprachvokabular - weniger detailliert als ein Programm - Bevorzugete Notation zur Beschreibung eines Algorithmus - versteckt Programmimplementierungsprobleme Bsp.: Finden des größten Elements in einem Array Algorithmus arrayMax(a,n) Input array a mit n Ganzzahlen Output größtes Element von a currentMax = a[0] for i= 1 to n-1 do if (a[i] > curentMax then currentMax = a[i] return currentMax Pseudocode-Details: - Kontrollfluss -- if … then … [else …] -- while … do … -- repeat … until … -- for … do - Einrücken ersetzt Klammern - Deklaration von Methoden Algorithmus methode(arg [,arg …]) Input … Output … - Rückgabewert return Ausdruck - Ausdrücke = Zuweisung == Gleiheitstest 2 Subscripts und andere mathematische Formulierungen sind erlaubt n Pseudo-Code Elemente: Sequenz Verzeigung { Anweisung_1 Anweisung_2 … if Bedingung { Anweisung_1 Anweisung_2 25 Algorithmen und Datenstrukturen Anweisung_n } … Anweissung_n } else { Anweisung_m … Anweisung_k } Iteration While Bedingung { Anweisung_1 Anweisung_2 … Anweisung_n } Graphische Darstellung von Flußdiagrammen Normierte Methode (DIN 66001) zur Darstellung von Programmen Kontrollstrukturen und Struktogramme Strukturblock Anweisung_1 Anweisung_2 …. Java-Struktur 19 Kommentar Block in geschweiften Klammern { Anweisung_1; Anweisung_2; ….. Eine Folge von Anweisungen, die alle der Reihe nach abgearbeitet werden, bezeichnet man als Sequenz. Anweisung_n; } Anweisung_n Sequenz if-Anweisung if (Bedingung) { anweisung1; } else { Anweisung2; } Fallunterscheidung (bedingte Anweisung) 19 1 2 3 A1 A2 A3 Fall …. sonst An Mit einer Anweisung der Form Wenn Bedingung erfüllt dann führe Anweisung1 aus sonst führe Anweisung 2 aus führt man eine Fallunterscheidung durch switch-Anweisung Mehrfachauswahl switch (Ausdruck) { case Wert1 : Anweisung1; break; case Wert2 : Anweisung2; Der Ausdruck muß ganzzahlig sein. Das Programm wird an der case-Anweisung fortgesetzt., deren Wert dem Ausdruck entspricht. Falls Ausdruck keinem der Werte Vgl. Skriptum zur Vorlesung im WS 2005 / 2006: Programmieren in Java, 2.4 26 Algorithmen und Datenstrukturen Mehrfachauswahl break; default: Anweisungn } entspricht, geht es mit der default-Anweisung weiter for-Schleife Eine Anweisung der Form for (int i=1;i <=n; i++) { Anweisung; } Für Zähler = Anfang bis Ende Anweisung while-Schleife Eine Anweisung der Form while (Bedingung) { Anweisung; } Solange bedingung erfüllt führe Anweisung aus do-while-(repeat)Schleife Eine Anweisung der Form für i=1 bis n Anweisung Gezählte Schleife for-Schleife heißt gezählte Schleife. Gezählte Schleifen werden dann benutzt, wenn man weiß, wie oft eine Schleife durchlaufen werden muß solange Bedingung Anweisung while-Schleife Anweisung bis Bedingung repeat-Schleife do { Anweisung; } while (Bedingung); Prozeduraufruf Prozedurname(Arg1, Arg2, … , Argn); Prozeduraufruf 27 heißt Schleife mit Eingangsbedingung. Trifft die Bedingung anfangs nicht zu, so wird die Wiederholungsanweisung nicht ausgeführt Wiedrhole Anweisung Solange Bedingung erfüllt heißt Schleife mit Ausgangsbedingung. Im Unterschied zur while-Schleife wird die zu wiederholende Anweisung mindestens einmal ausgeführt. Prozeduren werden über ihren Namen aufgerufen. In Klammern kann man Argumente übergeben. Algorithmen und Datenstrukturen 1.2.6 Formale Eigenschaften von Algorithmen 1.2.6.1 Korrektheit, Terminierung, Hoare-Kalkül, Halteproblem 1.2.6.1.1 Korrektheit, Terminierung Die wichtigste formale Eigenschaft eines Algorithmus ist die Korrektheit. Dazu muß gezeigt werden, daß der Algorithmus die jeweils gestellte Aufgabe richtig löst. Man kann die Korrektheit eines Algorithmus im Allg. nicht durch Testen an ausgewählten Beispielen nachweisen20: Durch Testen kann lediglich nachgewiesen werden, dass sich ein Programm für endlich viele Eingaben korrekt verhält. Durch eine Verifikation kann nachgewiesen werden, dass sich das Programm für alle Eingaben korrekt verhält. Bei der Zusicherungsmethode sind zwischen den Statements sogenannte Zusicherungen eingesetzt, die eine Aussage darstellen über die momentane Beziehung zwischen den Variablen. Typischerweise gibt man Zusicherungen als Kommentare vor. Programmverifikation ist der Nachweis, dass die Zusicherungen für ein Programm tatsächlich gelten. Sie entspricht der Durchführung eines mathematischen Beweises (einer Ableitung). Gezeigt wird damit: Das entsprechende Programm ist korrekt bzgl. seiner Spezifikation. /* P */ while (b) { /* P && b */ … /* P */ } /* P && !b */ Die Schleifeninvariante P muß eine Aussage über das in der Schleife errechnete Resultat R enthalten: P ∧ ¬B ⇒ R Zusicherungen enthalten boolsche Ausdrücke, von denen der Programmierer annimmt, dass sie an entsprechender Stelle gelten. Beginnend mit der ersten, offensichtlich richtigen Zusicherung lässt sich als letzte Zusicherung eine Aussage über das berechnete Ergebnis durch Anwendung der Korrektheitsformel 21 ableiten: { P } A { Q } P und Q sind Zusicherungen P ist die pre-condition (Vorbedingung), beschreibt die Bedingungen (constraints). Q ist die post-condition (Nachbedingung), beschreibt den Zustand nach Ausführung der Methode Die Korrektheitsformel bedeutet: Jede Ausführung von A, bei der zu Beginn P erfüllt ist, terminiert in einem Zustand, in dem Q erfüllt ist. 20 E. Dijkstra formulierte das so: Man kann durch Testen die Anwesenheit von Fehlern, aber nicht die Abwesenheit von Fehlern nachweisen. 21 Robert Floyd hatte 1967 die Idee den Kanten von Flussdiagrammen Prädikate zuzuordnen, um Korrektheitsbeweise zu führen. C.A.R. Hoare entwickelte die Idee weiter, indem er Programme mit "Zusicherungen" anreicherte. Er entwickelte das nach ihm benannte "Hoare Tripel" 28 Algorithmen und Datenstrukturen Die Korrektheitsformel bestimmt partielle Korrektheit : "Wenn P beim Start von A erfüllt ist, und A terminiert, dann wird am Ende Q gelten". Für die Terminierung gilt folgende Formel: { P} A. Sie bedeutet: "Wenn P beim Start von A erfüllt ist, wird A terminieren. Partielle Korrektheit und Terminierung führen zur totale Korrektheit. Totale Korrektheit ist eine stärkere Anforderung an das Programm. Bsp.: 1. Partielle Korrektheit nicht aber totale Korrektheit zeigt {true} while (x!=0) x = x-1; {x==0}, da keine Terminierung bzgl. x < 0. 2. Die Hoare-Formel {x>0} while (x > 0) x = x+1; {false} terminiert nie. Sie ist partiell korrekt, aber nicht total korrekt. Generell drückt die Gültigkeit von {P} A {false} Nichtterminierung aus, d.h. {P} A {false} ist partiell korrekt, A terminmiert aber nicht, für alle Anfangszustände, die P erfüllen. 1.2.6.1.2 Hoare-Kalkül Das Hoare Kalkül umfasst eine Menge von Regeln, die sich aus Prämissen und Schlussfolgerung zusammensetzen: Prämisse1 Prämisse2 … Prämissen --------------Konklusion Mit dem Hoare Kalkül kann partielle (und evtl. totale) Korrektheit eines Programms nachgewiesen werden: - Zerlege den Algorithmus in seine einzelnen Anweisungen und füge vor (und nach) jeder Ausführung geeignete Vor- und Nachbedingungen ein. - Zeige, dass die einzelnen Anweisungen korrekt sind - Beweise die Korrektheit des gesamten Algorithmus aus der Korrektheit der einzelnen Aussagen. Die grundlegende Idee von Hoare zum konstruktiven Beweis partieller und totaler Korrektheit ist: Leite (rückwärts schreitend) ausgehend von der (gewünschten) Nachbedingung die Vorbedingung ab. 1.2.6.1.3 Halteproblem Das Halteproblem kann durch die folgende Fragestellung beschrieben werden: „Gibt es ein Programm, das für ein beliebiges anderes Programm entscheidet, ob es für eine bestimmte Eingabe in eine Endlosschleife gerät oder nicht?“ Das allgemeine Halteproblem drückt offenbar folgende Frage aus: „Hält Algorithmus x bei der Eingabe von y?“ 29 Algorithmen und Datenstrukturen Anschaulicher Beweis der Unentscheidbarkeit des Halteproblems Annahme. Es gibt eine Maschine (Algorithmus) STOP mit 2 Eingaben: „Algorithmentext x und eine Eingabe y“ und 2 Ausgaben: - JA: x stoppt bei der Eingabe von y - NEIN: x stoppt nicht bei der Eingabe von y x JA STOP y NEIN Mit dieser Maschine STOP kann man eine Maschine SELTSAM konstruieren: SELTSAM JA x x x OK NEIN Die Eingabe von x wird getestet, ob x bei der Eingabe von x stoppt. Im JA-Fall wird in eine Endlosschleife gegangen, die nie anhält. Im NEIN-Fall hält SELTSAM mit der Anzeige OK an. Es folgt nun die Eingabe von SELTSAM (für sich selbst) mit der Frage: „Hält SELTSAM bei der Eingabe von SELTSAM?“ 1. Wenn JA, wird die JA-Anweisung von STOP angelaufen und SELTSAM gerät in eine Endlosschleife, hält also nicht (Widerspruch!) 2. Wenn NEIN, so wird der NEIN-Ausgang von STOP angelaufen, und SELTSAM stoppt mit OK (Widerspruch!) Der Widerspruch folgt aus der Annahme, dass eine STOP-Maschine existiert, was verneint werden muß. Nicht entscheidbare (berechenbare) Probleme Das Halteproblem ist ein Bsp. für ein „semantisches“ Problem von Algorithmen, nämlich ein Problem der folgenden Art: Kann man anhand eines Programmtextes entscheiden, ob die berechnete Funktion (Semantik) eine bestimmte Eigenschaft hat. Die Algorithmentheorie (Satz von Rice) hat dazu folgende Aussage gegeben: Jede nicht triviale semantische Eigenschaft von Algorithmen ist nicht entscheidbar. Nicht entscheidbar sind u.a. folgende Probleme: 1. Ist die Funktion überall definiert? 2. Berechnen 2 gegebene Algorithmen dieselbe Funktion? 3. Ist ein gegebener Algorithmus korrekt, d.h. berechnet er die gegebene (gewünschte) Funktion? Das bedeutet nicht, dass man solche Fragen nicht im Einzelfall entscheiden könnte. Es ist jedoch prinzipell unmöglich, eine allgemeine Methode hierfür zu finden, also 30 Algorithmen und Datenstrukturen z.B. eine Algorithmus, der die Korrektheit aller Algorithmen nachweist (und damit auch seine eigene). 1.2.6.2 Effizienz Die zweite wichtige Eigenschaft eines Algorithmus ist seine Effizienz. Die wichtigsten Maße für die Effizienz sind der zur Ausführung des Algorithmus benötigte Speicherplatz und die benötigte Rechenzeit (Laufzeit): 1. Man kann die Laufzeit durch Implementierung des Algorithmus in einer Programmiersprache (z.B. C++) auf einem konkreten Rechner für eine Menge repräsentativer Eingaben messen. Bsp.: Implementierung eines einfachen Sortieralgorithmus in C++ mit Messen der CPU-Zeit 22. #include <time.h> // … clock_t start, finish; start = clock(); sort(…); finish = clock(); cout << "sort hat " << double (finish – start) / CLOCKS_PER_SEC << " Sek. benoetigt\n"; // … Solche experimentell ermittelten Meßergebnisse lassen sich nicht oder nur schwer auf andere Implementierungen und andere Rechner übertragen. 2. Aus dieser Schwierigkeit bieten sich 2 Auswege an: 1. Man benutzt einen idealiserenden Modellrechner als Referenzmaschine und mißt die auf diesem Rechner zur Ausführung des Algorithmus benötigte Zeit und benötigten Speicherplatz. Ein in der Literatur 23 zu diesem Zweck häufig benutztes Maschinenmodell ist das der RAM (Random-AccessMaschine). Eine solche Maschine verfügt über einige Register und eine (abzählbar unendliche) Menge einzeln addressierbarer Speicherzellen. Register und Speicherzellen können je eine (im Prinzip) unbeschränkt große (ganze oder reelle) Zahl aufnehmen. Das Befehlsrepertoire für eine RAM ähnelt einer einfachen, herkömmlichen Assemblersprache. Die Kostenmaße Speicherplatz und Laufzeit enthalten dann folgende Bedeutung: Der von einem Algorithmus benötigte Speicherplatz ist die Anzahl der zur Ausführung benötigten RAM-Speicherzellen. Die benötigte Zeit ist die Zahl der ausgeführten RAM-Befehle. 2. Bestimmung einiger für die Effizienz des Algorithmus besonders charakteristischer Parameter 24. Laufzeit und Speicherbedarf eines Algorithmus hängen in der Regel von der Größe der Eingabe ab 25. Man unterscheidet zwischen dem Verhalten im besten Fall, dem Verhalten im Mittel (average case) und dem Verhalten im schlechtesten Fall (worst case). In den meisten Fällen führt man eine worstcase Analyse für die Ausführung eines Algorithmus der Problengröße N durch. Dabei kommt es auf den Speicherplatz nicht an, lediglich die Größenordnung der Laufzeit- und Speicherplatzfunktionen in Abhängigkeit von der Größe der Eingabe N wird bestimmt. Zum Ausdruch dieser Größenordnung hat sich eine besondere Notation eingebürgert: die O-Notation bzw. Big-O-Notation. 22 In Java steht zur Zeitmessung die Methode Methode currentTimeMillis() aus System zur Verfügung. currentTimeMillis bestimmt die Anzahl der Millisekunden, die seit Mitternacht des 1.1.1970 vergangen sind. 23 Vgl. Aho, Hopcroft, Ullman: The Design and Analysis of Computer Algorithms, Addison-Wesley Publishing Company 24 So ist es bspw. üblich, die Laufzeit eines Verfahrens zum Sortieren einer Folge von Schlüsseln durch die Anzahl der dabei ausgeführten Vergleichsoperationen zwischen Schlüsseln und die Anzahl der ausgeführten Bewegungen von den jeweiligen betroffenen Datensätzen zu messen. 25 die im Einheitskostenmaß oder im logarithmischen Kostenmaß gemessen wird 31 Algorithmen und Datenstrukturen Laufzeit T(N): Die Laufzeit gibt exakt an, wieviel Schritte ein Algorithmus bei einer Eingabelänge N benötigt. T(N) kann man im Rahmen sog. assymptotischer Kostenmaße abschätzen. Für diese Abschätzung existieren die sog. Big-O-Notation (bzw. Ω - und Θ -Notation): Big-O-Notation: Ein Funktion f (N ) heißt von der Ordnung O ( g ( N )) , wenn 2 Konstante c0 und n0 existieren, so dass f ( N ) ≤ c o ⋅ g ( N ) für alle N > n0 . Die Big-O-Notation liefert eine Obergrenze für die Wachstumsrate von Funktionen: f ∈ O(g ) , wenn f höchstens so schnell wie g wächst. Man sagt dann: die Laufzeit eines Algorithmus "T(N) ist O(N)" oder "T(N) ist ein O(N)". Big- Ω -Notation: Ein Funktion f (N ) heißt von der Ordnung Ω( g ( N )) , wenn 2 Konstante c0 und n0 existieren, so dass f ( N ) ≥ c o ⋅ g ( N ) für alle N > n0 . Die Big- Ω -Notation liefert eine Untergrenze für die Wachstumsrate von Funktionen: f ∈ Ω(g ) , wenn f mindestens so schnell wie g wächst. θ -Notation: Das Laufzeitverhalten eines Algorithmus ist θ (N ) , falls O( N ) = Ω( N ) . Über θ (N ) kann das Laufzeitverhalten exakt beschrieben werden. Damit lässt sich der Zeitbedarf eines Algorithmus darstellen als eine Zeitfunktion T (N ) 26 aus dem Bereich der positiven reellen Zahlen: Ein Algorithmus hat die Komplexität O (g ) , wenn T ( N ) ∈ O ( g ) gilt. Meistens erfolgt die Abschätzung hinsichtlich der oberen Schranken (Worst Case): Groß-O-Notation. T (N ) c1 g (n) f ∈ Θ(g ) c 2 g ( n) n0 N Abb. 1.2-71: Assymptotische Kostenmaße Zeitbedarf eines Algorithmus: Ist N die Problemgröße, A ein Algorithmus, dann hat ein Algorithmus die Komplexität O(g ) , wenn für den Zeitbedarf von A T A (n) ∈ O ( g ) gilt. Wenn nicht explizit anders beschrieben, ist T A ( n) maximale Laufzeit für die gegebene Faustregel in der O-Notation 26 falls nicht explizit anders beschrieben, ist T (N ) die maximale Laufzeit für die gegebene Problemgröße N 32 Algorithmen und Datenstrukturen Rechenregeln zur O-Notation. ⎧O( f ), falls g ∈ O( f ) Addition: f + g ∈ O(max( f , g )) = ⎨ ⎩O( g ), falls f ∈ O( g ) Die Additionsregel dient zur Bestimmung der Komplexität bei Hintereinanderausführung der Programme Multiplikation: f ⋅ g ∈ O( f ⋅ g ) Die Multiplikationsregel dient zur Bestimmung der Komplexität von ineinandergeschachtelten Schleifen Linearität: f (n) = a ⋅ g (n) + b ∧ Ω(1) ⇒ f ∈ O( g ) 1.2.7 Komplexität Für die algorithmische Lösung eines gegebenen Problems ist es unerläßlich, daß der gefundene Algorithmus das Problem korrekt löst. Darüber hinaus ist es natürlich wünschenswert, daß er dies mit möglichst geringem Aufwand tut. Die Theorie der Komplexität von Algorithmen beschäftigt sich damit, gegebene Algorithmen hinsichtlich ihres Aufwands abzuschätzen und – darüber hinaus – für gegebene Problemklassen anzugeben, mit welchem Mindestaufwand Probleme dieser Klasse gelöst werden können. Meistens geht es bei der Ananlyse der Komplexität von Algorithmen (bzw. Problemklassen) darum, als Maß für den Aufwand eine Funktion anzugeben, wobei f ( Ν) = a bedeutet: „ Bei einem Problem der Größe N ist der Aufwand a“. Die Problemgröße „N“ bezeichnet dabei in der Regel ein grobes Maß für den Umfang einer Eingabe, z.B. die Anzahl der Elemente in der Eingabeliste oder die Größe eines bestimmten Eingabewertes. Der Aufwand „a“ ist in der Regel ein grobes Maß für die Rechenzeit. Die Rechenzeit wird häufig dadurch abgeschätzt, daß man zählt, wie häufig eine bestimmte Operation ausgeführt wird, z.B. Speicherzugriffe, Multiplikationen, Additionen, Vergleiche, etc. Bsp.: Wie oft wird die Wertzuweisung „x = x + 1“ in folgenden Anweisungen ausgeführt? 1. x = x + 1; ............ ..1-mal 2. for (i=1; i <= n; i++) x = x + 1;.. ..n-mal 3. for (i=1; i <= n; i++) for (j = 1; j <= n; j++) x = x + 1;................................... ......... n2-mal Die Aufwandfunktion läßt sich in den wenigsten Fällen exakt bestimmen. Vorherrschende Analysemethoden sind: - Abschätzungen des Aufwands im schlechtesten Fall - Abschätzungen des Aufwands im Mittel Selbst hierfür lassen sich im Allg. keine exakten Angaben machen. Man beschränkt sich dann auf „ungefähres Rechnen in Größenordnungen“. Bsp.: Gegeben: n ≥ 0 a1 , a 2 , a3 ,..., a n ∈ Z Gesucht: Der Index i der (ersten) größten Zahl unter den ai (i=1,...,n) Lösung: max = 1; for (i=2;i<=n;i++) if (amax < ai) max = i 33 Algorithmen und Datenstrukturen Wie oft wird die Anweisung „max = i“ im Mittel ausgeführt (abhängig von n)? Die gesuchte mittlere Anzahl sei Tn. Offenbar gilt: 1 ≤ Tn ≤ n . „max = i“ wird genau dann ausgeführt, wenn ai das größte der Elemente a1 , a 2 , a3 ,..., ai ist. Angenommen wird Gleichverteilung: Für jedes i = 1, ... , n hat jedes der Elemente a1 , a 2 , a3 ,..., a n die gleiche Chance das größte zu sein, d.h.: Bei N Durchläufen wird N/n-mal die Anweisung „max = i“ ausgeführt. Daraus folgt für N ⋅ Tn (Aufwendungen bei N Durchläufen vom „max = i“): N ⋅ Tn = N + N N N 1 1 1 + + ... + = N (1 + + + ... + ) 2 3 n 2 3 n Dies ist Hn, die n-te harmonische Zahl. Für Hn ist keine geschlossene Formel bekannt, jedoch eine ungefähre Abschätzung: Tn = H n ≈ ln n + γ 27. Interessant ist nur, daß Tn logarithmisch von n abhängt. Man schreibt Tn ist „von der Ordnung logn“, die multiplikative und additive Konstante sowie die Basis des Logarithmus bleiben unspezifiziert. Diese sog. (Landau'sche) Big-O-Notation läßt sich mathematisch exakt definieren: f ( n) f (n) = O( g (n)) :⇔ ∃c, n0 ∀n ≥ n0 : f (n) ≤ c ⋅ g (n) , d.h. ist für genügend große n g ( n) durch eine Konstante c beschränkt. „f“ wächst nicht stärker als „g“. Diese Begriffsbildung wendet man bei der Analyse von Algorithmen an, um Aufwandsfunktionen durch Eingabe einer einfachen Vergleichsfunktion abzuschätzen, so daß f (n) = O( g (n)) gilt, also das Wachstum von f durch das von g beschränkt ist. Gebräuchliche Vergleichsfunktionen sind: O-Notation O(1) Aufwand Konstanter Aufwand O(log n) Logarithmischer Aufwand O(n) Linearer Aufwand Problemklasse Einige Suchverfahren für Tabellen („Hashing“) Allgemeine Suchverfahren für Tabellen (Binäre Suche) Sequentielle Suche, Suche in Texten, syntaktische Analyse in Programmen „schlaues Sortieren“, z.B. Quicksort O(n ⋅ log n) O(n 2 ) Quadratischer Aufwand Einige dynamische Optimierungsverfahren, z.B. optimale Suchbäume); „dummes Sortieren“, z.B. Bubble-Sort Multiplikationen Matrix mal Vektor Exponentieller Aufwand Viele Optimierungsprobleme, automatisches Beweisen (im Prädikatenkalkül 1. Stufe) Alle Permutationen O(n k ) für k ≥ 0 O(2 n ) O(n!) Zur Veranschaulichung des Wachstums konnen die folgende Tabellen betrachtet werden: f(N) ldN N N ⋅ ldN N2 27 N=2 1 2 2 4 Eulersche Konstante 24=16 4 16 64 256 25=256 8 256 1808 65536 γ = 0.57721566 34 210 10 1024 10240 1048576 220 20 1048576 20971520 ≈ 1012 Algorithmen und Datenstrukturen N3 2N 8 4 4096 65536 16777200 ≈ 1077 ≈ 109 ≈ 10308 ≈ 1018 ≈ 10315653 Unter der Annahme „1 Schritt dauert 1 μs = 10 −6 s folgt für N= N N2 N3 2N 3N N! 10 10 μs 100 μs 1 ms 1 ms 59 ms 3,62 s 20 20 μs 400 μs 8 ms 1s 58 min 771 Jahre 30 30 μs 900 μs 27 ms 18 min 6.5 Jahre 1016 Jahre 40 40 μs 1.6 ms 64 ms 13 Tage 3855 Jahre 1032 Jahre 50 50 μs 2.5 ms 125 ms 36 Jahre 108 Jahre 1049 Jahre 60 60 μs 3.6 ms 216 ms 366 Jahre 1013 Jahre 1066 Jahre Abb. 1.2-2: Polynomial- und Exponentialzeit 1.2.7.1 Laufzeitberechnungen 1.2.7.1.1 Analyse der Laufzeit Die Laufzeit ist bestimmt durch die Anzahl der durchgeführten elementaren Operationen (Grundrechenarten, Vergleiche, Feldzugriffe, Zugriffe auf die Komponenten einer Struktur, etc.) Die Angabe der Laufzeit in Abhängigkeit von konkreten Eingabewerten ist im Allg. nicht möglich oder sehr aufwendig. Daher betrachtet man die Laufzeit häufig in Abhängigkeit von der Größe (dem Umfang) der Eingabe. Definition: T(n) = Anzahl der elementaren Operationen, die zur Bearbeitung einer Eingabe der Größe n bearbeitet werden. Eine Analyse der Laufzeit bezieht sich auf den besten, den schlechtesten und den mittleren Fall: -Tmin(n) = minimale Anzahl der Operationen, die durchgeführt werden, um eine Eingabe der Größe n zu bearbeiten. - Tmax(n) = maximale Anzahl der elementaren Operationen, die durchgeführt werden, um eine Eingabe der Größe n zu bearbeiten. Ist eine Wahrscheinlichkeitsverteilung der Eingabedaten gegeben, kann auch eine mittlere Laufzeit Tmit(n) ermittelt werden. Bsp.: Sequentielle Suche in Folgen Gegeben ist eine Zahl n ≥ 0 , n Zahlen a1, a2, …, an (alle verschieden), eine Zahl b. Gesucht ist der Index i = 1,2,…,n, so dass b == ai, falls ein Index existiert. Andernfalls ist i = n+1. Lösung: i = 1; while (i <= n && b != ai) i = i + 1; Ergebnis hängt von der Eingabe ab, d.h. von n, a1, …, an und b Aufwand der Suche: 1. erfolgreiche Suche (wenn b == ai): S = i Schritte 2. erfolglose Suche S = n+1 Schritte Ziel: globalere Aussagen, die nur von einer einfachen Größe abhängen, z.B. von der Länge n der Folge. 1) Wie groß ist S für gegebenes n im schlechtesten Fall? - im schlechtesten Fall: b wird erst im letzten Schritt gefunden: b = an , S = n im schlechtesten Fall 2) Wie groß ist S für gegebenes n im Mittel - im Mittel - Wiederholte Anwendung mit verschiedenen Eingaben - Annahme über Häufigkeit: Wie oft wird b an erster, zweiter, … letzter Stelle gefunden? - Insgesamt für N-Suchvorgänge 35 Algorithmen und Datenstrukturen N N N N N n(n + 1) n +1 ⋅ 1 + ⋅ 2 + ... + ⋅ n = (1 + 2 + ... + n ) = ⋅ =N n n n n n 2 2 M n +1 Schritte, also S = im Mittel bei Gleichverteilung - für eine Suche S = N 2 M = 1.2.7.1.2 Asymptotische Analyse der Laufzeit („Big-O“) (Analyse der Komplexität durch Angabe einer Funktion f : N → N als Maß für den Aufwand) Definition: f (n) ist in der Größenordnung von g (n) „ f (n) = O( g (n)) “, falls Konstante c und n0 existieren 28, so dass f (n) ≤ c ⋅ g (n) für n ≥ n0 . f ( n) ist für genügend große n durch eine Konstante c beschränkt, d.h. f wächst g ( n) nicht schneller als g. Ziel der Charakterisierung T (n) = O( g (n)) ist es, eine möglichst einfache Funktion g (n) zu finden. Bspw. ist T (n) = O(n) besser als T (n) = O(5n + 10) . Wünschenswert ist auch die Charakterisierung der Laufzeit mit einer möglichst kleinen Größenordnung. Die O-Natation besteht in der Angabe einer asymptotischen oberen Schranke für die Aufwandsfunktion (Wachstumsgeschwindigkeit bzw. Größenordnung) Vorgehensweise bei der Analyse für Kontrollstrukturen Die Algorithmen werden gemäß ihrer Kontrollstruktur von innen nach außen analysiert. In der Laufzeit, die sich dann ergibt, werden anschließend die Konstanten durch den Übergang zur O-Notation beseitigt. Anweisungen: Anweisungen, die aus einer konstanten Anzahl von elementaren Operationen bestehen, erhalten eine konstante Laufzeit. Sequenz A1,A2,…,An. Werden für die einzelnen Anweisungen, die Laufzeiten T1, T2,…,Tn ermittelt, dann ergibt sich für die Sequenz die Laufzeit T=T1+T2+…+Tn Schleife, die genau n-mal durchlaufen wird, z.B. for-Schleife ohne break: for (i=1;i<=n;i++) A; Wird für A die Laufzeit Ti ermittelt, dann ergibt sich als n Laufzeit für die for-Schleife T = ∑ Ti . Eigentlich müsste zu Ti noch eine Konstante C1 i =1 für i <=n und i++ und C2 für i = 1 hinzugezählt werden. Beim späteren Übergang zur O-Notation würde die Konstanten jedoch wegfallen 29. Fallunterscheidung (mit else-Teil): if (B) A1, else A2; Hier muß zwischen der Laufzeit im besten und schlechtesten Fall unterschieden werden: Tmin=min(T1,T2), Tmax=max(T1,T2), wobei T1 die Laufzeit für A1 und T2 die Laufzeit für A2 ist. Man geht davon aus, dass die Bedingung B konstante Zeit benötigt und wegen des späteren Übergangs zur O-Notation einfachheitshalber nicht mitgezählt werden muß. Schleife mit k-maligen Durchläufen, wobei n1<=k<=n2. Diese tritt typischerweise bei while-Schleifen auf. Es muß dann eine Analyse für den besten Fall (k=n1) und den schlechtesten Fall (k=n2) durchgeführt werden. Rekursion mit n → n − 1 : Es ergeben sich rekursive Gleichungen für die Laufzeiten 28 geeignetes n0 und c müssen angegeben werden, um zu zeigen, dass 29 vgl. Skriptum, 1.2.7 36 f (n) = O( g (n)) gilt Algorithmen und Datenstrukturen Bsp.: rekursive Fakultätsberechnung int fak(int n) { if (n == 0) return 1; else return n*fak(n-1); } Man erhält folgende Laufzeit: Tn = C 0 für n = 0 Tn = C1 + T (n − 1) ) für n > 0 Durch wiederholtes Einsetzen: T ( n) = C1 + C1 + ... + C1 + C 0 = O ( n) 1442443 n − mal Rekursion mit Teile und Herrsche. f ( x, n ) { if (n == 1) (1) { // Basisfall /* löse Pr oblem direkt , Ergebnis sei loes * / return loes; } else { // Teileschritt /* teile x in 2Teilprobleme x1und x 2 jeweils derGröße n / 2 * / (2) loe1 = f ( x1, n / 2); loe2 = f ( x 2, n / 2); // Herrscheschritt /* Setze Loesung loes für x aus loe1und loe2 zusammen * / (3) return loes; } } Für den Basisfall (1) wird eine konstante Anzahl C0 Operationen angesetzt. (2) und (3) benötigen linearen Aufwand und damit C1 ⋅ n Operationen. T ( n) = C 0 falls n = 1 T (n) = C1 ⋅ n + 2 ⋅ T (n / 2 ) 3031 Durch Einsetzen ergibt sich: T ( n) = C1 n + 2(C1 ⋅ n / 2 + 2T ( n / 4) ) = 2 ⋅ C1 ⋅ n + 4 ⋅ T (n / 4) ) Durch nochmaliges Einsetzen ergibt sich: T ( n) = 3 ⋅ C1 ⋅ n + 8 ⋅ T (n / 8) ) T (n) = log 2 (n) ⋅ C1 ⋅ n + 2 log n T (1) log n Mit 2 = n und T (1) = C 0 erhält man: T (n) = C1 ⋅ n ⋅ log 2 (n) + C 0 ⋅ n 30 n lässt sich ⎣log 2 n ⎦ -mal halbieren. Falls n eine Zweierpotenz ist (d.h. n = 2k), lässt sich n sogar exakt log 2 n = k oft halbieren. n soll der Einfachheit halber hier eine Zweierpotenz sein. 31 Rekurrenzgleichung: Die Analyse rekursiver Algorithmen führt meistens auf eine sog. Rekurrenzgleichung 37 Algorithmen und Datenstrukturen Lösung von Rekurrenzgleichungen Eine Rekurrenzrelation (kurz Rekurrenz) ist eine Methode, eine Funktion durch einen Ausdruck zu definieren, der die zu definierende Funktion selbst enthält, z.B. Fibonacci-Zahlen 32. Wie löst man Rekurrenzgleichungen? Es gibt 2 Verfahren: Substitutionsmethode bzw. Mastertheorem. Zur Lösung von Rekurrenzgleichungen haben sind 2 Verfahrenstechniken bekannt: Substitutionsmethode bzw. Mastertheorem. Lösung mit der Substitutionsmethode: „Rate eine Lösung“ (z.B. über den Rekursionsbaum) Beweise die Korrektheit der Lösung per Induktion Lösung mit dem Mastertheorem: Mit dem Mastertheorem kann man sehr einfach Rekurrenzen der Form ⎛N⎞ T (n) = 2 ⋅ T ⎜ ⎟ + Θ(n) berechnen ⎝2⎠ Vollständige Induktion Das Beweisverfahren der vollständigen Induktion ist ein Verfahren, mit dem Aussagen über natürliche Zahlen bewiesen werden können. Neben Aussagen über natürliche Zahlen können auch damit gut Aussagen bewiesen werden die - rekursiv definierte Strukturen und - abzählbare Strukturen betreffen. Grundidee: Eine Aussage ist gültig für alle natürlichen Zahlen n ∈ N , wenn man nachweisen kann: Die Aussage gilt für die erste natürliche Zahl n = 1 (Induktionsanfang) Wenn die Aussage für eine natürliche Zahl n gilt, dann gilt sie auch für ihren Nachfolger n+1 (Induktionsschritt) n Einf. Bsp.: S (n) = ∑ i = 1 + 2 + 3 + ... + n = i =1 1 ⋅ n ⋅ (n + 1) 2 Beweis: Induktionsanfang: 1 ⋅ 1 ⋅ (1 + 1) = 1 2 Induktionsschritt: Induktionsvoraussetzung: 1 ⋅ k ⋅ (k + 1) 2 1 ⋅ (k + 1) ⋅ (k +)2 2 k +1 k 1 1 1 i = i + (k + 1) ) = ⋅ k ⋅ (k + 1) + k + 1 = ⋅ (k 2 + k ) + (2k + 2) ∑ ∑ 2 2 2 i =1 i =1 1 1 = k 2 + k + 2k + 2 = (k + 2) ⋅ (k + 1) 2 2 Zu zeigen, dass gilt: ( 32 ) vgl. Skriptum: Algorithmen und Datenstrukturen SS09, 3.2.3 38 Algorithmen und Datenstrukturen Asymptotische Abschätzung mit dem Master-Theorem Das Mastertheorem hilft bei der Abschätzung der Rekurrenzen der Form T (n) = a ⋅ T (n / b) + f (n) 33 Master-Theorem - a ≥ 1 und b > 1 sind Konstanten. f (n) ist eine Finktion und T (n) ist über den nichtnegativen ganzen Zahlen durch folgende Rekurrenzgleichung definiert: T (n) = a ⋅ T (n / b ) + f (n) . Interpretiere n / b so, dass entweder ⎣n / b⎦ oder ⎡n / b⎤ - Dann kann T (n) folgendermaßen asymptotisch abgeschätzt werden: ⎧ Θ(n logb a ) falls gilt : ∃ε > 0 mit f (n) = O(n logb a −ε ) ⎪ T (n) = ⎨ Θ n logb a ⋅ log n falls gilt : f (n) = Θ(n logb a ) ⎪Θ( f (n)) falls : ∃ε > 0 mit f (n) = Ω(n logb a +ε ) ∧ ∃c < 1 : ∀n > n0 : a ⋅ f (n / b) ≤ c ⋅ f (n) ⎩ ( ) Anwendung des Theorems an einigen Beispielen 1. T (n) = 9 ⋅ T (n / 3) + n a = 9, b = 3, f (n) = n Da f (n) = O(n log3 9−ε ) mit ε = 1 gilt, kann Fall 1 des Master-Theorems angewendet werden. Somit gilt: T (n) = Θ n log3 9 = Θ n 2 2. T (n) = T (2n / 3) + 1 a = 1, b = 3 / 2 Da n logb a = n log3 / 2 1 = n 0 = 1 ist, gilt f (n) = Θ(n logb a ) = Θ(1) , und es kommt Fall 2 des Master-Theorems zur Anwendung. Somit gilt: T (n) = Θ(n log3 / 2 1 log n) = Θ(log n) 3. T (n) = 3 ⋅ T (n / 4) + n ⋅ log n a = 3, b = 4, f (n) = n log n Es ist n logb a = n log 4 3 = O(n 0.379 ). Somit ist f (n) = Ω(n log 4 3+ε ) mit ε ≈ 0.2 . Weiterhin gilt für hinreichend große n: a ⋅ f (n) = 3 ⋅ (n / 4) ⋅ log(n / 4) ≤ (3 / 4 )n log n . Fall 3 des MasterTheorems kann damit angewandt werden: T (n) = O( f (n)) = Θ(n ⋅ log n) ( ) ( ) Achtung! Es gibt Fälle, in denen die Struktur der Gleichung zu passen „scheint“, aber kein Fall des Master-Theorems existiert, für den alle Bedingungen erfüllt sind. 33 Solche Rekurrenzen treten oft bei der Analyse sogenannter Divide-and-Conquer-Algorithmen auf. 39 Algorithmen und Datenstrukturen 1.2.7.2 O(logN)-Algorithmen Gelingt es die Problemgröße in konstanter Zeit (O(1)) zu halbieren, dann zeigt der zugehörige Algorithmus das Leistungsverhalten O(logN)). Nur spezielle Probleme können dieses Leistungsverhalten erreichen. Binäre Suche Aufgabe: Gegeben ist eine Zahl X und eine sortiert vorliegenden Folge von Ganzzahlen A0, A1, A2, ... , AN-1 im Arbeitsspeicher. Finde die Position i so, daß Ai=X bzw. gib i=-1 zurück, wenn X nicht gefunden wurde. Implementierung public static int binaereSuche(Comparable a[], Comparable x) { /* 1 */ int links = 0, rechts = a.length - 1; /* 2 */ while (links < rechts) { /* 3 */ int mitte = (links + rechts) / 2; /* 4 */ if (a[mitte].compareTo(x) < 0) /* 5 */ links = mitte + 1; /* 6 */ else if (a[mitte].compareTo(x) > 0) /* 7 */ rechts = mitte - 1; else /* 8 */ return mitte; // Gefunden } /* 9 */ return -1; // Nicht gefunden } Leistungsanalyse: Entscheidend für das Leistungsverhalten ist die Schleife (/* 2 */. Sie beginnt mit (rechts – links) = N-1 und endet mit (rechts – links) = -1. Bei jedem Schleifendurchgang muß (rechts – links) halbiert werden. Ist bspw. (rechts – links) = 128, dann sind die maximalen Werte nach jeder Iteration: 64, 32, 16, 8, 4, 2, 1, 0, -1. Die Laufzeit läßt sich demnach in der Größenordnung O(logN) sehen. Die binäre Suche ist eine Implementierung eines Algorithmus für eine Datenstruktur (sequentiell gespeicherte Liste, Array). Zum Aufsuchen von Datenelementen wird eine Zeit von O(logN) verbraucht. Alle anderen Operationen (z.B. Einfügen) nehmen ein Leistungsverhalten von O(N) in Anspruch. 40 Algorithmen und Datenstrukturen 1.2.7.3 Berechnungsgrundlagen für rechnerische Komplexität 1.2.7.3.1 System-Effizienz und rechnerische Effizienz Effiziente Algorithmen zeichnen sich aus durch - schnelle Bearbeitungsfolgen (Systemeffizienz) auf unterschiedliche Rechnersystemen. Hier wird die Laufzeit der diversen Suchalgorithmen auf dem Rechner (bzw. verschiedene Rechnersysteme) ermittelt und miteinander verglichen. Die zeitliche Beanspruchung wird über die interne Systemuhr gemessen und ist abhängig vom Rechnertyp - Inanspruchnahme von möglichst wenig (Arbeits-) Speicher - Optimierung wichtiger Leistungsmerkmale, z.B. die Anzahl der Vergleichsbedingungen, die Anzahl der Iterationen, die Anzahl der Anweisungen (, die der Algorithmus benutzt). Die Berechnungskriterien bestimmen die sog. rechnerische Komplexität in einer Datensammlung. Man spricht auch von der rechnerischen Effizienz. 1.2.7.3.2 P- bzw. NP-Probleme Von besonderem Interesse für die Praxis ist der Unterschied zwischen Problemen mit polynomialer Laufzeit (d.h. T ( N ) = O( p ( N )) , p = Polynom in N) und solchen mit nicht polynomialer Laufzeit. Probleme mit polynomialer Laufzeit nennt man leicht, alle übrigen Probleme heißen hart (oder unzugänglich). Harte Probleme sind praktisch nicht mehr (wohl aber theoretisch) algorithmisch lösbar, denn selbst für kleine Eingaben benötigt ein derartiger Algorithmus Rechenzeit, die nicht mehr zumutbar ist und leicht ein Menschenalter überschreitet 34. Viele wichtige Problemlösungsverfahren liegen in dem Bereich zwischen leichten und harten Problemen.. Man kann nicht zeigen, dass diese Probleme leicht sind, denn es gibt für sie keinen Polynomialzeit-Algorithmus. Umgekehrt kann man auch nicht sagen, dass es sich um harte Probleme handelt. Der Fakt, dass kein PolynomialzeitAlgorithmus gefunden wurde, schließt die Existenz eines solchem Algorithmus nicht aus. Möglicherweise hat man sich bei der Suche danach bisher noch nicht klug genug angestellt. Es wird dann nach seit Jahrzenten erfogloser Forschung angenommen, dass es für diese Probleme keine polynomiellen Algorithmen gibt. Man spricht in diesem Fall von der Klasse der sog. NP-vollständigen Probleme. 35 Es ist heute allgemeine Überzeugung, daß höchstens solche Algorithmen praktikabel sind, deren Laufzeit durch ein Polynom in der Problemgröße beschränkt bleibt. Algorithmen, die exponentielle Schrittzahl erfordern, sind schon für relativ kleine Problemgrößen nicht mehr ausführbar. 34 35 vgl. Abb. 1.2-2 nichtdeterministisch polynomial 41 Algorithmen und Datenstrukturen 1.2.7.3.3 Grenzen der Berechenbarkeit Generell kann man für Algorithmen folgende Grenzfälle bzgl. der Rechenbarkeit beobachten: - kombinatorische Explosion Es gibt eine Reihe von klassischen Problemen, die immer wieder in der Mathematik oder der DVLiteratur auftauchen, weil sie knapp darzustellen und im Prinzip einfach zu verstehen sind. Manche von ihnen sind nur von theoretischen Interesse, wie etwa die Türme von Hanoi. Ein anderes klassisches Problem ist dagegen das Problem des Handlungsreisenden 36 (Travelling Salesman Problem, TSP). Es besteht darin, daß ein Handlungsreisender eine Rundreise zwischen einer Reihe von Städten machen soll, wobei er am Ende wieder am Abfahrtort ankommt. Dabei will er den Aufwand (gefahrene Kilometer, gesamte Reisezeit, Eisenbahn- oder Flugkosten, je nach dem jeweiligen Optimierungswunsch) minimieren. So zeigt bspw. die folgende Entfernungstabelle die zu besuchenden Städte und die Kilometer zwischen ihnen: München Frankfurt Heidelberg Karlsruhe Mannheim Frankfurt 395 - Heidelberg 333 95 - Karlsruhe 287 143 54 - Mannheim 347 88 21 68 - Wiesbaden 427 32 103 150 92 Grundsätzlich (und bei wenigen Städten, wie in diesem Bsp., auch tatsächlich) ist die exakte Lösung dieser Optimierungsaufgabe mit einem trivialen Suchalgorithmus zu erledigen. Man rechnet sich einfach die Route der Gesamtstrecke aus und wählt die kürzeste. Der benötigte Rechenaufwand steigt mit der Zahl N der zu besuchenden Städte sprunghaft an. Erhöht man bspw. N von 5 auf 10, so verlängert sich die Rechenzeit etwa auf das „dreißigtausendfache“. Dies nennt man kombinatorische Explosion, weil der Suchprozeß jede mögliche Kombination der für das Problem relevanten Objekte einzeln durchprobieren muß. Der Aufwand steigt proportional zur Fakultät (N!). - exponentielle Explosion Wie kann man die vollständige Prüfung aller möglichen Kombinationen und damit die kombinatorische Explosion umgehen? Naheliegend für das TSP ist, nicht alle möglichen Routen zu berechnen und erst dann die optimale zu suchen, sondern sich immer die bis jetzt beste zu merken und das Ausprobieren einer neuen Wegkombination sofort abzubrechen, wenn bereits eine Teilstrecke zu größeren Kilometerzahlen führt als das bisherige Optimum, z.B.: Route 1 München Karlsruhe Heidelberg Mannheim Wiesbaden Frankfurt München Streckensumme 0 287 341 362 454 486 881 Route 2 München Wiesbaden Karlsruhe Frankfurt Heidelberg Streckensumme 0 429 722 865 960 Route 2 kann abgebrochen werden, weil die Teilstrecke der Route 2 (960) bereits länger ist als die Gesamtstrecke der Route 1. Diese Verbesserung vermeidet die kombinatorische Explosion, ersetzt 36 Vorbild für viele Optimierungsaufgaben, wie sie vor allem im Operations Research immer wieder vorkommen. 42 Algorithmen und Datenstrukturen sie aber leider nur durch die etwas schwächere exponentielle Explosion. Die Rechenzeit nimmt exponetiell, d.h. mit aN für irgendeinen problemspezifischen Wert zu. Im vorliegenden Fall ist a etwa 1.26. - polynomiales Zeitverhalten In der Regel ist polynomiales Zeitverhalten das beste, auf das man hoffen kann. Hiervon redet man, wenn man die benötigte Rechenzeit durch ein Polynom T = a n N + ... + a 2 N + a1 N + a 0 n 2 ausgedrückt werden. „N“ ist bestimmt durch die zu suchenden problemspezifischen Werte, n beschreibt den Exponenten. Da gegen das erste Glied mit der höchsten Potenz bei größeren Objektzahlen alle anderen Terme des Ausdrucks vernachlässigt werden können, klassifiziert man das polynomiale Zeitverhalten nach dieser höchsten Potenz. Man sagt, ein Verfahren zeigt polynomiales Zeitverhalten O(Nn), falls die benötigte Rechenzeit mit der nten Potenz der Zahl der zu bearbeitenden Objekte anwächst. Die einzigen bekannten Lösungen des TSP, die in polynomialer Zeit ablaufen, verzichten darauf, unter allen Umständen die beste Lösung zu finden, sondern geben sich mit einer recht guten Lösung zufrieden. In der Fachsprache wird das so ausgedrückt, daß das TSP NP-vollständig sei. Das bedeutet: In polynomialer Zeit kann nur eine nichtdeterministische Lösung berechnet werden, also eine, die nicht immer deterministisch ein und dasselbe (optimale) Ergebnis findet. Ein Verfahren, für das nicht garantiert werden kann, daß es in allen Fällen ein exaktes Resultat liefert, wird heuristisch genannt. Eine naheliegende heuristische Lösung für das TSP ist der „Nächste Nachbarn-Algorithmus“. Er beginnt die Route mit der Stadt, die am nächsten zum Ausgangsort liegt und setzt sie immer mit derjenigen noch nicht besuchten Stadt fort, die wiederum die nächste zum jeweiligen Aufenthaltsort ist. Da in jeder der N Städte alle (d.h. im Durchschnitt (N-1)/2) noch nicht besuchte Orte nach dem nächsten benachbarten durchsucht werden müssen, ist der Teitaufwand für 2 das Durchsuchen proportional N ⋅ ( N − 1) / 2 , d.h. O(N ), und damit polynomial „in quadratischer Zeit“ Die meisten Algorithmen für Datenstrukturen bewegen sich in einem schmalen Band rechnerischer Komplexität. So ist ein Algorithmus von der Ordnung O(1) unabhängig von der Anzahl der Datenelemente in der Datensammlung. Der Algorithmus läuft unter konstanter Zeit ab, z.B.: Das Suchen des zuletzt in eine Schlange eingefügten Elements bzw. die Suche des Topelements in einem Stapel. Ein Algorithmus mit dem Verhalten O(N) ist linear. Zeitlich verhält er sich proportional zur Größe der Liste. Bsp.: Das Bestimmen des größten Elements in einer Liste. Es sind N Elemente zu überprüfen, bevor das Ende der Liste erkannt wird. Andere Algorithmen zeigen „logarithmisches Verhalten“. Ein solches Verhalten läßt sich beobachten, falls Teildaten die Größe einer Liste auf die Hälfte, ein Viertel, ein Achtel... reduzieren. Die Lösungssuche ist dann auf die Teilliste beschränkt. Derartiges Verhalten findet sich bei der Behandlung binärer Bäume bzw. tritt auf beim „binären Suchen“. Der Algorithmus für binäre Suche zeigt das Verhalten O(log 2 N ) , Sortieralgorithmen wie der Quicksort und Heapsort besitzen eine rechnerische Komplexität von O( N log 2 N ) . Einfache Sortierverfahren (z.B. „BubbleSort“) bestehen aus Algorithmen mit einer Komplexität von O( N 2 ) . Sie sind deshalb auch nur für kleine Datenmengen brauchbar. Algorithmen mit kubischem Verhalten O( N 3 ) sind bereits äußerst langsam (z.B. der Algorithmus von Warshall zur Bearbeitung von Graphen). Ein Algorithmus mit einer Komplexität von O( 2 N ) zeigt exponentielle Komplexität. Ein derartiger Algorithmus ist nur für kleine N auf einem Rechner lauffähig. 43 Algorithmen und Datenstrukturen 1.3 Daten und Datenstrukturen 1.3.1 Datentyp Ein Algorithmus verarbeitet Daten. Ein Datentyp soll gleichartige zusammenfassen und die nötigen Basisoperationen zur Verfügung stellen. Ein Datentyp ist durch 2 Angaben festgelegt: Daten 1. Eine Menge von Daten (Werte) 2. Eine Menge von Operationen auf diesen Daten Ein Datentyp 37ist demnach eine Zusammenfassung von Wertebereichen und Operationen zu einer Einheit. Eine passende Abstraktion für Datentypen sind Algebren. Eine Algebra ist eine Wertemenge plus Operation auf diesen Werten. Ein typisches Beispiel für dieses Konzept sind die natürlichen Zahlen mit den Operationen +, -, * ,%, etc. Wertemengen eines Datentyps werden in der Informatik als Sorten bezeichnet. Die Operationen eines Datentyps entsprechen Funktionen und werden durch Algorithmen realisiert, In der Regel liegt eine mehrwertige Algebra vor, also eine Algebra mit mehreren Sorten als Wertebereiche. Bsp.:Natürliche Zahlen plus Wahrheitswerte mit den Operationen +, -, *, % auf Zahlen, ¬, ∧, ∨ ,... auf Wahrheitswerten und =, <, > , ≤,... als Verbindung zwischen den Sorten. Signatur von Datentypen. Darunter versteht man eine Formularisierung der Schnittstellenbeschreibung eines Datentyps. Sie besteht aus Angaben der Namen der Sorten und der Operationen. Die Operationen werden neben dem Bezeichner der Operation auch die Stelligkeit der Operanden und der Sorten der einzelnen Parameter angegeben. Die Konstanten werden als 0-stellige Operationen realisert, z.B 38.: typ nat sorts nat, bool functions 0 -> nat succ : nat -> nat + : nat x nat -> nat <= : nat x nat -> bool ….. 37 38 Vgl. Skriptum Programmieren in Java WS 2005 / 2006: 1.3.4, 1.4.1.3, 2.3 Das Beispiel ist angelehnt an die algebraische Spezifikation von Datenstrukturen 44 Algorithmen und Datenstrukturen 1.3.2 Datenstruktur Komplexe Datentypen, sog. Datenstrukturen, werden durch Kombination primitiver Datentypen gebildet. Sie besitzen selbst spezifische Operationen. Eine Datenstruktur ist ein Datentyp und dient zur Organisation von Daten zur effizienten Unterstützung bestimmter Operationen. Betrachtet wird ein Ausschnitt aus der realen Welt, z.B. die Hörer dieser Vorlesung an einem bestimmten Tag: Juergen Josef Liesel Maria ........ Regensburg ......... ......... ......... ......... Bad Hersfeld ......... ......... ......... ......... 13.11.70 ........ ........ ........ ........ Friedrich-. Ebertstr. 14 .......... .......... .......... .......... Diese Daten können sich zeitlich ändern, z.B. eine Woche später kann eine veränderte Zusammensetzung der Zuhörerschaft vorliegen. Es ist aber deutlich erkennbar: Die Modelldaten entsprechen einem zeitinvarianten Schema: NAME WOHNORT GEBURTSORT GEB.-DATUM STRASSE Diese Feststellung entspricht einem Abstraktionsprozeß und führt zur Datenstruktur. Sie bestimmt den Rahmen (Schema) für die Beschreibung eines Datenbestandes. Der Datenbestand ist dann eine Ansammlung von Datenelementen (Knoten), der Knotentyp ist durch das Schema festgelegt. Der Wert eines Knoten k ∈ K wird mit wk bezeichnet und ist ein n ≥ 0 -Tupel von Zeichenfolgen; w i k bezeichnet die i-te Komponente des Knoten. Es gilt wk = ( w1k , w2 k ,...., wn k ) Die Knotenwerte des vorstehenden Beispiels sind: wk1 = (Jürgen____,Regensburg,Bad Hersfeld,....__,Ulmenweg__) wk2 = (Josef_____,Straubing_,......______,....__,........__) wk3 = (Liesel____,....._____,......______,....__,........__) .......... wkn = (__________,__________,____________,______,__________) Welche Operationen sind mit dieser Datenstruktur möglich? Bei der vorliegenden Tabelle sind z.B. Zugriffsfunktionen zum Einfügen, Löschen und Ändern eines Tabelleneintrages mögliche Operationen. Generell bestimmen Datenstrukturen auch die Operationen, die mit diesen Strukturen ausgeführt werden dürfen. Zusammenhänge zwischen den Knoten eines Datenbestandes lassen sich mit Hilfe von Relationen bequem darstellen. Den vorliegenden Datenbestand wird man aus Verarbeitungsgründen bspw. nach einem bestimmten Merkmal anordnen (Ordnungsrelation). Dafür steht hier (im vorliegenden Beispiel) der Name der Studenten: 45 Algorithmen und Datenstrukturen Josef Juergen Liesel Abb. 1.3-1: Einfacher Zusammenhang zwischen Knoten eines Datenbestandes Datenstrukturen bestehen also aus Knoten(den einzelnen Datenobjekten) und Relationen (Verbindungen). Die Verbindungen bestimmen die Struktur des Datenbestandes. Bsp.: 1. An Bayerischen Fachhochschulen sind im Hauptstudium mindestens 2 allgemeinwissenschaftliche Wahlfächer zu absolvieren. Zwischen den einzelnen Fächern, den Dozenten, die diese Fächer betreuen, und den Studenten bestehen Verbindungen. Die Objektmengen der Studenten und die der Dozenten ist nach den Namen sortiert (geordnet). Die Datenstruktur, aus der hervorgeht, welche Vorlesungen die Studenten bei welchen Dozenten hören, ist: 46 Algorithmen und Datenstrukturen STUDENT FACH DATEN DOZENT DATEN DATEN DATEN DATEN DATEN DATEN DATEN DATEN DATEN geordnet (z.B. nach Matrikelnummern) geordnet (z.B. nach Titel im Vorlesungsverzeichnis) geordnet (z.B. nach Namen) Abb. 1.3-3: Komplexer Zusammenhang zwischen den Knoten eines Datenbestands 2. Ein Gerät soll sich in folgender Form aus verschiedenen Teilen zusammensetzen: Anfangszeiger Analyse Anfangszeiger Vorrat G1, 5 B2, 4 B1, 3 B3, 2 B4, 1 Abb. 1.3-4: Darstellung der Zusammensetzung eines Geräts 47 Algorithmen und Datenstrukturen 2 Relationen können hier unterschieden werden: 1) Beziehungsverhältnisse eines Knoten zu seinen unmittelbaren Nachfolgeknoten. Die Relation Analyse beschreibt den Aufbau eines Gerätes 2) Die Relation Vorrat gibt die Knoten mit w2k <= 3 an. Die Beschreibung eines Geräts erfordert in der Praxis eine weit komplexere Datenstruktur (größere Knotenzahl, zusätzliche Relationen). 3. Eine Bibliotheksverwaltung 39 soll angeben, welches Buch welcher Student entliehen hat. Es ist ausreichend, Bücher mit dem Namen des Verfassers (z.B. „Stroustrup“) und die Entleiher mit ihrem Vornamen (z.B. „Juergen“, „Josef“) anzugeben. Damit kann die Bibliotheksverwaltung Aussagen, z.B. „Josef hat Stroustrup ausgeliehen“ oder „Juergen hat Goldberg zurückgegeben“ bzw. Fragen, z.B. „welche Bücher hat Juergen ausgeliehen?“, realisieren. In die Bibliothek sind Objekte aufzunehmen, die Bücher repäsentieren, z.B.: Buch „Stroustrup“ Weiterhin muß es Objekte geben, die Personen repräsentieren, z.B.: Person „Juergen“ Falls „Juergen“ Stroustrup“ ausleiht, ergibt sich folgende Darstellung: Person „Juergen“ Buch „Stroustrup“ Abb. 1.3-5: Objekte und ihre Beziehung in der Bibliotheksverwaltung Der Pfeil von „Stroustrup“ nach „Juergen“ zeigt: „Juergen“ ist der Entleiher von „Stroustrup“, der Pfeil von „Juergen“ nach „Stroustrup“ besagt: „Stroustrup“ ist eines der von „Juergen“ entliehenen Bücher. Für mehrere Personen kann sich folgende Darstellung ergeben: 39 pr13_3 48 Algorithmen und Datenstrukturen Person Person „Juergen“ Person „Josef“ Buch Buch „Stroustrup“ „Goldberg“ Buch „Lippman“ Abb. 1.3-6: Objektverknüpfungen in der Bibliotheksverwaltung Zur Verbindung der Klasse „Person“ bzw. „Buch“ wird eine Verbindungsstruktur benötigt: Person buecher = Verbindungsstruktur „Juergen“ Buch „Stroustrup“ Abb. 1.3-7: Verbindungsstruktur zwischen den Objekttypen „Person“ und „Buch“ Ein bestimmtes Problem kann auf vielfätige Art in Rechnersystemen abgebildet werden. So kann das vorliegende Problem über verkettete Listen im Arbeitsspeicher oder auf Externspeicher (Dateien) realisiert werden. Die vorliegenden Beispiele können folgendermaßen zusammengefaßt werden: Die Verkörperung einer Datenstruktur wird durch das Paar D = (K,R) definiert. K ist die Knotenmenge (Objektmenge) und R ist eine endliche Menge von binären Relationen über K. 49 Algorithmen und Datenstrukturen 1.3.3 Relationen und Ordnungen Relationen Zusammenhänge zwischen den Knoten eines Datenbestandes lassen sich mit Hilfe von Relationen bequem darstellen. Eine Relation ist bspw. in folgender Form gegeben: R = {(1,2),(1,3),(2,4),(2,5),(2,6),(3,5),(3,7),(5,7),(6,7)} Diese Relation bezeichnet eine Menge geordneter Paare oder eine Produktmenge M × N . Sind M und N also Mengen, dann nennt man jede Teilmenge M × N eine zweistellige oder binäre Relation über M × N (oder nur über M , wenn M = N ist). Jede binäre Relation auf einer Produktmenge kann durch einen Graphen dargestellt werden, z.B.: 1 3 2 5 6 4 7 Abb. 1.3-8: Ein Graph zur Darstellung einer binären Relation Bsp.: Gegeben ist S (eine Menge der Studenten) und V (eine Menge von Vorlesungen). Die Beziehung ist: x ∈ S hört y ∈V . Diese Beziehung kann man durch die Angabe aller Paare ( x , y ) beschreiben, für die gilt: Student x hört Vorlesung y . Jedes dieser Paare ist Element des kartesischen Produkts S × V der Mengen S und V. Für Relationen sind aus der Mathematik folgende Erklärungen bekannt: 1. Vorgänger und Nachfolger R ist eine Relation über der Menge M. Gilt ( a , b ) ∈ R , dann sagt man: „a ist Vorgänger von b, b ist Nachfolger von a“. Zweckmäßigerweise unterscheidet man in diesem Zusammenhang auch den Definitions- und Bildbereich Def(R) = { x | ( x , y ) ∈ R } Bild(R) = { y | ( x , y ) ∈ R } 2. Inverse Relation (Umkehrrelation) 50 Algorithmen und Datenstrukturen Relationen sind umkehrbar. Die Beziehungen zwischen 2 Grössen x und y können auch als Beziehung zwischen y und x dargestellt werden, z.B.: Aus „x ist Vater von y“ wird durch Umkehrung „y ist Sohn von x“. Allgemein gilt: R-1 = { (y,x) | ( x , y ) ∈ R } 3. Reflexive Relation ∀ ( x , x ) ∈ R (Für alle Elemente x aus M gilt, x steht in Relation zu x) x ∈M Beschreibt man bspw. die Relation "... ist Teiler von ..." für die Menge M = {2,4,6,12} in einem Grafen, so erhält man: 12 4 6 2 Abb. 1.3-9: Die binäre Relation „... ist Teiler von ... “ Alle Pfeile, die von einer bestimmten Zahl ausgehen und wieder auf diese Zahl verweisen, sind Kennzeichen einer reflexiven Relation ( in der Darstellung sind das Schleifen). Eine Relation, die nicht reflexiv ist, ist antireflexiv oder irreflexiv. 4. Symmetrische Relation Aus (( ( x , y ) ∈ R ) folgt auch (( ( y , x ) ∈ R ). Das läßt sich auch so schreiben: Wenn ein geordnetes Paar (x,y) der Relation R angehört, dann gehört auch das umgekehrte Paar (y,x) ebenfalls dieser Relation an. Bsp.: a) g ist parallel zu h h ist parallel zu g b) g ist senkrecht zu h h ist senkrecht zu g 5. Asymmetrische Relation Solche Relationen sind auch aus dem täglichen Leben bekannt. Es gilt bspw. „x ist Vater von y“ aber nicht gleichzeitig „y ist Vater von x“. Eine binäre Relation ist unter folgenden Bedingungen streng asymetrisch: ∀ ( x , y ) ∈ R → (( y , x ) ∉ R ) ( x , y )∈R 51 Algorithmen und Datenstrukturen Das läßt sich auch so ausdrücken: Gehört das geordnete Paar (x,y) zur Relation, so gehört das entgegengesetzte Paar (y,x) nicht zur Relation. Gilt für x <> y die vorstehende Relation und für x = y ∀( x , x ) ∈ R , so wird diese binäre Relation "unstreng asymmetrisch" oder "antisymmetrisch" genannt. 6. Transitive Relation Eine binäre Relation ist transitiv, wenn (( ( x , y ) ∈ R ) und (( ( y , z ) ∈ R ) ist, dann ist auch (( ( x , z ) ∈ R ). x hat also y zur Folge und y hat z zur Folge. Somit hat x auch z zur Folge. 7. Äquivalenzrelation Eine binäre Relation ist eine Äquivalenzrelation, wenn sie folgenden Eigenschaften entspricht: - Reflexivität - Transitivität - Symmetrie Bsp.: Die Beziehung "... ist ebenso gross wie ..." ist eine Äquivalenzrelation. 1. Wenn x1 ebenso groß ist wie x2, dann ist x2 ebenso groß wie x1. Die Relation ist symmetrisch. 2. x1 ist ebenso groß wie x1. Die Relation ist reflexiv. 3. Wenn x1 ebenso groß wie x2 und x2 ebenso gross ist wie x3, dann ist x1 ebenso groß wie x3. Die Relation ist transitiv. Klasseneinteilung - Ist eine Äquivalenzrelation R in einer Menge M erklärt, so ist M in Klassen eingeteilt - Jede Klasse enthält Elemente aus M, die untereinander äquivalent sind - Die Einteilung in Klassen beruht auf Mengen M1, M2, ... , Mx, ... , My Für die Teilmengen gilt: Mx ∩ M y = 0 (2) M1 ∪ M 2 ∪....∪ M y = M (1) (3) Mx <> 0 (keine Teilmenge ist die leere Menge) Bsp.: Klasseneinteilungen können sein: - Die Menge der Studenten der FH Regensburg: Äquivalenzrelation "... ist im gleichen Semester wie ..." - Die Menge aller Einwohner einer Stadt in die Klassen der Einwohner, die in der-selben Straße wohnen: Äquivalenzrelation ".. wohnt in der gleichen Strasse wie .." Aufgabe 1. Welche der folgenden Relationen sind transitiv bzw. nicht transitiv? 1) 2) 3) 4) 5) ... ... ... ... ... ist ist ist ist ist der Teiler von .... der Kamerad von ... Bruder von ... deckungsgleich mit ... senkrecht zu ... (transitiv) (transitiv) (transitiv) (transitiv) (nicht transitiv) 2. Welche der folgenden Relationen sind Aequivalenzrelationen? 1) 2) 3) 4) ... ... ... ... gehört dem gleichen Sportverein an ... hat denselben Geburtsort wie ... wohnt in derselben Stadt wie ... hat diesselbe Anzahl von Söhnen 52 Algorithmen und Datenstrukturen Ordnungen 1. Halbordnung Eine binäre Relation ist eine "Halbordnung", wenn sie folgende Eigenschaften besitzt: "Reflexivität, Transitivität" 2. Strenge Ordnungsrelation Eine binäre Relation ist eine "strenge Ordnungsrelation", wenn sie folgende Eigenschaft besitzt: "Transitivität, Asymmetrie" 3. Unstrenge Ordnungsrelation Eine binäre Relation ist eine "unstrenge Ordnungsrelation", wenn sie folgende Eigenschaften besitzt: Transitivität, unstrenge Asymmetrie 4. Totale Ordnungsrelation und partielle Ordnungsrelation Tritt in der Ordnungsrelation x vor y auf, so verwendet man das Symbol < (x < y). Vergleicht man die Abb. 1.2-9, so kann man für (1) schreiben: e < a < b < d und c < d Das Element c kann man weder mit den Elementen e, a noch mit b in eine gemeinsame Ordnung bringen. Daher bezeichnet man diese Ordnungsrelation als partielle Ordnung (teilweise Ordnung). Eine totale Ordnungsrelation enthält im Gegensatz dazu Abb. 1.2-9 in (2): e < a < b<c<d Kann also jedes Element hinsichtlich aller anderen Elemente geordnet werden, so ist die Ordnungsrelation eine totale, andernfalls heißt sie partiell. (1) (2) a a b e e b d c d Abb.1.3-10: Totale und partielle Ordnungsrelationen 53 c Algorithmen und Datenstrukturen 1.3.4 Klassifikation von Datenstrukturen Eine Datenstruktur ist ein Datentyp mit folgenden Eigenschaften 1. Sie besteht aus mehreren Datenelementen. Diese können - atomare Datentypen oder selbst Datenstrukturen sein 2. Sie setzt die Elemente durch eine Menge von Regeln (eine Struktur) in eine Beziehung (Relation). Elementare Strukturrelationen Menge lineare Struktur (gerichtete 1:1-Relation) Baum (hierarchisch) (gerichtete 1 : n – Relation) Graph (Netzwerk) ( n : m Relation) Abb. 1.3.-11: Elementare Datenstrukturen Eine Datenstruktur ist durch Anzahl und Eigenschaften der Relationen bestimmt. Obwohl sehr viele Relationstypen denkbar sind, gibt es nur 4 fundamentale Datenstrukturen 40, die immer wieder verwendet werden und auf die andere Datenstrukturen zurückgeführt werden können. Den 4 Datenstrukturen ist gemeinsam, daß sie nur binäre Relationen verwenden. 1.3.4.1 Lineare Ordnungsgruppen Sie sind über eine (oder mehrere) totale Ordnung(en) definiert. Die bekanntesten Verkörperungen der linearen Ordnung sind: - (ein- oder mehrdimensionale) Felder (lineare Felder) - Stapel - Schlangen - lineare Listen Lineare Ordnungsgruppen können sequentiell (seqentiell gespeichert) bzw. verkettet (verkettet gespeichert) angeordnet werden. 40 nach: Rembold, Ulrich (Hrsg.): "Einführung in die Informatik", München/Wien, 1987 54 Algorithmen und Datenstrukturen 1. Sammlungen mit direktem Zugriff Ein „array“ (Reihung) ist eine Sammlung von Komponenten desselben Typs, auf den direkt zugegriffen werden kann. „array“-Kollektion Daten Eine Kollektion von Objekten desselben (einheitlichen) Typs Operationen Die Daten an jeder Stelle des „array“ können über einen ganzzahligen Index erreicht werden. Ein statisches Feld („array“) enthält eine feste Anzahl von Elementen und ist zur Übersetzungszeit festgelegt. Ein dynamisches Feld benutzt Techniken zur Speicherbeschaffung und kann während der Laufzeit an die Gegebenheiten angepaßt werden. Ein „array“ kann zur Speicherung einer Liste herangezogen werden. Allerdings können Elemente der Liste nur effizient am Ende des „array“ eingefügt werden. Anderenfalls sind für spezielle Einfügeoperationen Verschiebungen der bereits vorliegenden Elemente (ab Einfügeposition) nötig. Eine „array“-Klasse sollte Bereichsgrenzenüberwachung für Indexe und dynamische Erweiterungsmöglichkeiten erhalten. Implementierungen aktueller Programmiersprachen umfassen Array-Klassen mit nützlichen Bearbeitungsmethoden bzw. mit dynamischer Anpassung der Bereichsgrenzen zur Laufzeit. Eine Zeichenkette („character string“) ist ein spezialisierter „array“, dessen Elemente aus Zeichen bestehen: „character string“-Kollektion Daten Eine Zusammenstellung von Zeichen in bekannter Länge Operationen Sie umfassen Bestimmen der Länge der Zeichenkette, Kopieren bzw. Verketten einer Zeichenkette auf eine bzw. mit einer anderen Zeichenkette, Vergleich zweier Zeichenketten (für die Musterverarbeitung), Ein-, Ausgabe von Zeichenketten In einem „array“ kann ein Element über einen Index direkt angesprochen werden. In vielen Anwendungen ist ein spezifisches Datenelement, der Schlüssel (key) für den Zugriff auf einen Datensatz vorgesehen. Behälter, die Schlüssel und übrige Datenelemente zusammen aufnehmen, sind Tabellen. Ein Dictionary ist eine Menge von Elementen, die über einen Schlüssel identifiziert werden. Das Paar aus Schlüsseln und zugeordnetem Wert heißt Assoziation, man spricht auch von „assoziativen Arrays“. Derartige Tabellen ermöglichen den Direktzugriff über Schlüssel so, wie in einem Array der Direktzugriff über den Index erreicht wird, z.B.: Die Klasse Hashtable in Java Der Verbund (record in Pascal, struct in C) ist in der Regel eine Zusammenfassung von Datenbehältern unterschiedlichen Typs: „record“-Kollektion Daten 55 Algorithmen und Datenstrukturen Ein Element mit einer Sammlung von Datenfeldern mit möglicherweise unterschiedlichen Typen. Operationen Der Operator . ist für den Direktzugriff auf den Datenbehälter vorgesehen. Eine Datei (file) ist eine extern eingerichtete Sammlung, die mit einer Datenstruktur („stream“) 41 genannt verknüpft wird. „file“-Kollektion Daten Eine Folge von Bytes, die auf einem externen Gerät abgelegt ist. Die Daten fließen wie ein Strom von und zum Gerät. Operationen Öffnen (open) der Datei, einlesen der Daten aus der Datei, schreiben der Daten in die Datei, aufsuchen (seek) eines bestimmten Punkts in der Datei (Direktzugriff) und schließen (close) der Datei. Bsp.: Die RandomAccessFile-Klasse in Java 42 dient zum Zugriff auf RandomAccess-Dateien. 2. Sammlungen mit sequentiellem Zugriff Darunter versteht man lineare Listen (linear list) 43, die Daten in sequentieller Anordnung aufnehmen: „list“-Kollektion Daten Ein meist größere Objektsammlung von Daten gleichen Typs. Operationen Das Durchlaufen der Liste mit Zugriff auf die einzelnen Elemente beginnt an einem Anfangspunkt, schreitet danach von Element zu Element fort bis der gewünschte Ort erreicht ist. Operationen zum Einfügen und Löschen verändern die Größe der Liste. Stapel (stack) und Schlangen (queue) 44 sind spezielle Versionen linearer Listen, deren Zugriffsmöglichkeiten eingeschränkt sind. „Stapel“-Kollektion Daten Eine Liste mit Elementen, auf die man nur über die Spitze („top“) zugreifen kann. Operationen Unterstützt werden „push“ und „pop“. „push“ fügt ein neues Element an der Spitze der Liste hinzu, „pop“ entfernt ein Element von der Spitze („top“) der Liste. 41 vgl. Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 3.4.1, 3.4.2, 3.4.6 Implementiert das Interface DataInput und DataOutput mit eigenen Methoden. 43 Vgl. Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 5.2 44 http://www.galileocomputing.de/openbook/javainsel7/javainsel_12_007.htm Stand: März 2008 bzw. Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 5.2 42 56 Algorithmen und Datenstrukturen „Schlange“-Kollektion Daten Eine Sammlung von Elementen mit Zugriff am Anfang und Ende der Liste. Operationen Ein Element wird am Ende der Liste hinzugefügt und am Anfang der Liste entfernt. Eine Schlange 45 ist besonders geeignet zur Verwaltung von „Wartelisten“ und kann zur Simulation von Wartesystemen eingesetzt werden. Eine Schlange kann ihre Elemente nach Prioritäten bzgl. der Verarbeitung geordnet haben (priority queue) 46. Entfernt wird dann zuerst das Element, das die höchste Priorität besitzt. „prioritätsgesteuerte Schlange“-Kollektion Daten Eine Sammlung von Elementen, von denen jedes Element eine Priorität besitzt. Operationen Hinzufügen von Elementen zur Liste. Entfernt wird immer das Element, das die höchste (oder niedrigste) Priorität besitzt. 1.3.4.2 Nichtlineare Kollektion 1.3.4.2.1 Hierarchische angeordnete Sammlung (Bäume) Bäume sind im wesentlichen durch die Äquivalenzrelation bestimmt. Bsp.: Gliederung zur Vorlesung Algorithmen und Datenstrukturen Algorithmen und Datenstrukturen Kapitel 1: Datenverarbeitung und Datenorganisation Abschnitt 1: Ein einführendes Beispiel Kapitel 2: Suchverfahren Abschnitt 2: Begriffe Abb.: Gliederung zur Vorlesung Datenorganisation Die Verkörperung dieser Vorlesung ist das vorliegende Skriptum. Diese Skriptum {Seite 1, Seite 2, ..... , Seite n} teilt sich in einzelne Kapitel, diese wiederum gliedern sich in Abschnitte. Die folgenden Äquivalenzrelationen definieren diesen Baum: 1. Seite i gehört zum gleichen Kapitel wie Seite j 2. Seite i gehört zum gleichen Abschnitt von Kapitel 1 wie Seite j 3. ........ 45 Vgl. Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 5.2 http://java.sun.com/j2se/1.5.0/docs/api/java/util/PriorityQueue.html Stand März 2008 46 57 Algorithmen und Datenstrukturen Die Definitionen eines Baums mit Hilfe von Äquivalenzrelationen regelt ausschließlich "Vorgänger/Nachfolger" - Beziehungen (in vertikaler Richtung) zwischen den Knoten eines Baums. Ein Baum ist auch hinsichtlich der Baumknoten in der horizontalen Ebene geordnet, wenn zur Definition des Baums neben der Äquivalenzrelation auch eine partielle Ordnung (Knoten Ki kommt vor Knoten Kj, z.B. Kapitel1 kommt vor Kapitel 2) eingeführt wird. Eine hierarchisch angeordnete Sammlung von Datenbehältern ist gewöhnlich ein Baum mit einem Ursprungs- bzw. Ausgangsknoten, der „Wurzel“ genannt wird. Von besonderer Bedeutung ist eine Baumstruktur, in der jeder Baumknoten zwei Zeiger auf nachfolgende Knoten aufnehmen kann. Diese Binärbaum-Struktur kann mit Hilfe einer speziellen angeordneten Folge der Baumknoten zu einem binären Suchbaum erweitert werden. Binäre Suchbäume bilden die Ausgangsbasis für das Speichern großer Datenmengen. „Baum“-Kollektion Daten Eine hierarchisch angeordnete Ansammlung von Knotenelementen, die von einem Wurzelknoten abgeleitet sind. Jeder Knoten hält Zeiger zu Nachfolgeknoten, die wiederum Wurzeln von Teilbäumen sein können. Operationen Die Baumstruktur erlaubt das Hinzufügen und Löschen von Knoten. Obwohl die Baumstruktur nichtlinear ist, ermöglichen Algorithmen zum Ansteuern der Baumknoten den Zugriff auf die in den Knoten gespeicherten Informationen. Ein „heap“ ist eine spezielle Version, in der das kleinste bzw. größte Element den Wurzelknoten besetzt. Operationen zum Löschen entfernen den Wurzelknoten, dabei wird, wie auch beim Einfügen, der Baum reorganisiert. Basis der Heap-Darstellung ist ein „array“ (Feldbaum), dessen Komponenten eine Binärbaumstruktur überlagert ist. In der folgenden Darstellung ist eine derartige Interpretation durch Markierung der Knotenelemente eines Binärbaums mit Indexpositionen sichtbar: [1] wk1 [2] [3] wk2 wk3 [4] [5] wk4 [8] [0] wk5 [9] [10] wk9 wk8 [6] wk6 [11] wk10 [7] [12] wk11 wk7 [13] wk12 wk13 [14] wk14 [15] wk15 wk1 wk2 wk3 wk4 wk5 wk6 wk7 wk8 wk9 wk10 wk11 wk12 wk13 wk14 wk15 [1] [2] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12] [13] [14] [15] Abb. 1.3-10: Darstellung eines Feldbaums 58 Algorithmen und Datenstrukturen Liegen die Knoten auf den hier in Klammern angegebenen Positionen, so kann man von jeder Position I mit Pl = 2 * I Pr = 2 * I + 1 Pv = I div 2 auf die Position des linken (Pl) und des rechten (Pr) Nachfolgers und des Vorgängers (Pv) gelangen. Ein binärer Baum kann somit in einem eindimensionalen Feld (array) gespeichert werden, falls folgende Relationen eingehalten werden: X[I] <= X[2*I] X[I] <= X[2*I+1] X[1] = min(X[I] .. X[N]) Anstatt auf das kleinste kann auch auf das größte Element Bezug genommen werden Aufbau des Heap: Ausgangspunkt ist ein „array“ mit bspw. folgenden Daten: X[1] 40 X[2] 10 X[3] 30 X[4] ...... , der folgendermaßen interpretiert wird: X[1] 40 X[2] X[3] 10 30 Abb. 1.3-11: Interpretation von Feldinhalten im Rahmen eines binären Baums Durch eine neue Anordnung der Daten in den Feldkomponenten entsteht ein „heap“: X[1] 10 X[2] X[3] 40 30 Abb. 1.3-12: 59 Algorithmen und Datenstrukturen Falls ein neues Element eingefügt wird, dann wird nach dem Ordnen gemäß der „heap“-Bedingung erreicht: X[1] 10 X[2] X[3] 40 30 X[4] 15 X[1] 10 X[2] X[3] 15 30 X[4] 40 Abb.1.3-13: Das Einbringen eines Elements in einen Heap Beim Löschen wird das Wurzelelement an der 1. Position entfernt. Das letzte Element im „heap“ wird dazu benutzt, das Vakuum zu füllen. Anschließend wird reorganisiert: X[1] 10 X[2] X[3] 15 30 X[4] 40 60 Algorithmen und Datenstrukturen X[1] 40 X[2] X[3] 15 30 X[1] 15 X[2] X[3] 40 30 Abb. 1.3-14: Das Löschen eines Elements im Heap Implementierung des Heap in C++ 47. // // // // // // // // // // // // // BinaryHeap class Erzeugung mit optionaler Angabe zur Kapazitaet (Defaultwert: 100) ******************PUBLIC OPERATIONS********************* void insert( x ) --> Insert x deleteMin( minItem ) --> Remove (and optionally return) smallest item Comparable findMin( ) --> Return smallest item bool isEmpty( ) --> Return true if empty; else false void makeEmpty( ) --> Remove all items ******************ERRORS******************************** Throws UnderflowException as warranted Anwendung Der Binary Heap kann zum Sortieren 48 herangezogen werden. Ein Heap kann aber auch in Simulationsprogrammen und vor allem zur Implementierung von Priority Queues verwendet werden. Hier wird vielfach der einfache Heap durch komplexere Datenstrukturen ( Binomial Heap, Fibonacci Heap) ersetzt. 47 48 Vgl. pr13_421 http://fbim.fh-regensburg.de/~saj39122/AD/projekte/heapsort/html/index.html 61 Algorithmen und Datenstrukturen 1.3.4.2.2 Gruppenkollektionen Menge (Set) Eine Gruppe umfaßt nichtlineare Kollektionen ohne jegliche Ordnungsbeziehung. Eine Menge (set) einheitlicher Elemente ist. bspw. eine Gruppe. Operationen auf die Kollektion „Set“ umfassen Vereinigung, Differenz und Schnittmengenbildung. „Set“-Kollektion Daten Eine ungeordnete Ansammlung von Objekten ohne Ordnung Operationen Die binäre Operationen über Mitgliedschaft, Vereinigung, Schnittmenge und Differenz bearbeiten die Strukturart „Set“. Weiter Operationen testen auf Teilmengenbeziehungen. Graph Ein Graph (graph) ist eine Datenstruktur, die durch eine Menge Knoten und eine Menge Kanten, die die Knoten verbinden, definiert ist. In seiner einfachsten Form besteht eine Verkörperung dieser Datenstruktur aus einer Knotenmenge K (Objektmenge) und einer festen aber beliebigen Relation R über dieser Menge 49. Die folgende Darstellung zeigt einen Netzplan zur Ermittlung des kritischen Wegs: Die einzelnen Knoten des Graphen sind Anfangs- und Endereignispunkte der Tätigkeiten, die an den Kanten angegeben sind. Die Kanten (Pfeile) beschreiben die Vorgangsdauer und sind Abbildungen binärer Relationen. Zwischen den Knoten liegt eine partielle Ordnungsrelation. Bestelle A 50 Tage Baue B 1 Teste B 4 20 Tage Korrigiere Fehler 2 25 Tage 3 15 Tage Handbucherstellung 60 Tage Abb. 1.3-11: Ein Graph der Netzplantechnik „graph“-Kollektion Daten Eine Menge von Knoten und eine Menge verbindender Kanten. Operationen Der Graph kann Knoten hinzufügen bzw. löschen. Bestimmte Algorithmen starten an einem gegebenen Knoten und finden alle von diesem Knoten aus erreichbaren Knoten. Andere Algorithmen erreichen jeden Knoten im Graphen über „Tiefen“ bzw. „Breiten“ - Suche. 49 vgl. 1.2.2.2, Abb. 1.2-7 62 Algorithmen und Datenstrukturen Ein Netzwerk ist spezielle Form eines Graphen, in dem jede Kante ein bestimmtes Gewicht trägt. Den Gewichten können Kosten, Entfernungen etc. zugeordnet sein. 1.3.4.3 Dateien und Datenbanken Datei Damit ist eine Datenstruktur bestimmt, bei der Verbindungen zwischen den Datenobjekten durch beliebige, binäre Relationen beschrieben werden. Die Anzahl der Relationen ist somit im Gegensatz zur Datenstruktur Graph nicht auf eine beschränkt. Verkörperungen solcher assoziativer Datenstrukturen sind vor allem Dateien. In der Praxis wird statt mehrere binärer Relationen eine n-stellige Relation (Datensatz) gespeichert. Eine Datei ist dann eine Sammlung von Datensätzen gleichen Typs. Bsp.: Studenten-Datei Sie faßt die relevanten Daten der Studenten 50 nach einem ganz bestimmten Schema zusammen. Ein solches Schema beschreibt einen Datensatz. Alle Datensätze, die nach diesem Schema aufgestellt werden, ergeben die Studenten-Datei. Es sind binäre Relationen (Student - Wohnort, Student Geburtsort, ...), die aus Speicheraufwandsgründen zu einer n-stelligen Relation (bezogen auf eine Datei) zusammengefaßt werden. Datenbanken Eine Datenbank ist die Sammlung von verschiedenen Datensatz-Typen. Fachbereich 1 betreut M Student Abb. 1.3-14: „ER“-Diagramm zur Darstellung der Beziehung „Fachbereich-Student“ Auch hier zeigt sich: Knoten bzw. Knotentypen und ihre Beziehungen bzw. Beziehungstypen stehen im Mittelpunkt der Datenbank-Beschreibung. Statt Knoten spricht man hier von Entitäten (bzw. Entitätstypen) und Beziehungen werden Relationen genannt. Dies ist dann Basis für den Entity-Relationship (ER) -Ansatz von P.S. Chen. Zur Beschreibung der Entitätstypen und ihrer Beziehungen benutzt der ER-Ansatz in einem ER-Diagramm rechteckige Kanten bzw. Rauten: Die als „1“ und „M“ an den Kanten aufgeschriebenen Zahlen zeigen: Ein Fachbereich betreut mehrere (viele) Studenten. Solche Beziehungen können vom Typ 1:M, 1:1, M:N sein. Es ist auch die Bezugnahme auf gleiche Entitätstypen möglich, z.B.: 50 vgl. 1.2.2.1 63 Algorithmen und Datenstrukturen Person 1 1 Heirat Abb.: 1.3-15: Bezugnahme auf den gleiche Entitätstyp „Person“ Zur Verwaltung großer Datenbestände nutzen Datenbanken - die Speicherung von Daten in Tabellenform mit effizienten Zugriffsalgorithmen nach wahlfreien Kriterien und SQL als Datenzugriffssprache Datenbanken zeigen den praktischen Einsatz vieler vorgestellten Methoden, Algorithmen und Datenstrukturen, z.B. die Speicherung in Tabellen hinsichtlich - - der Dateiorganisationsform o sortiert nach Schlüssel o B-Baum nach Schlüssel o Hash-Tabelle nach Schlüssel Zugriffsunterstützung nach Index o Primärindexe verweisen auf Hauptdatei o B –Bäume für Nichtschlüsselattribute zur Bescheunigung des Zugriffs (Sekundärindex) Die folgende Darstellung einer Datenbank in einem ER-Diagramm Abt_ID Bezeichnung Job_ID Titel Abteilung Gehalt Job Abt-Ang Job-Ang Qualifikation Angestellte Ang_ID Name GebJahr Abb.: ER-Diagramm zur Datenbank Personalverwaltung führt zum folgenden Schemaentwurf einer relationalen Datenbank - Abteilung(Abt_ID,Bezeichnung) - Angestellte(Ang_ID,Name,Gebjahr,Abt_ID,Job_ID) - Job(Job_ID,Titel,Gehalt) - Qualifikation(Ang_ID,Job_ID) und resultiert in folgender relationalen Datenbank für das Personalwesen: 64 Algorithmen und Datenstrukturen ABTEILUNG ABT_ID KO OD PA RZ VT BEZEICHNUNG Konstruktion Organisation und Datenverarbeitung Personalabteilung Rechenzentrum Vertrieb ANG_ID A1 A2 A3 A4 A5 A6 A7 A8 A9 A10 A11 A12 A13 A14 NAME Fritz Tom Werner Gerd Emil Uwe Eva Rita Ute Willi Erna Anton Josef Maria ANGESTELLTE GEBJAHR 2.1.1950 2.3.1951 23.4.1948 3.11.1950 2.3.1960 3.4.1952 17.11.1955 02.12.1957 08.09.1962 7.7.1956 13.10.1966 5.7.1948 2.8.1952 17.09.1964 ABT_ID OD KO OD VT PA RZ KO KO OD KO OD OD KO PA JOB_ID SY IN PR KA PR OP TA TA SY IN KA SY SY KA JOB JOB_ID KA TA SY PR OP TITEL Kaufm. Angestellter Techn. Angestellter Systemplaner Programmierer Operateur GEHALT 3000,00 DM 3000,00 DM 6000,00 DM 5000,00 DM 3500,00 DM QUALIFIKATION ANG_ID A1 A1 A1 A2 A2 A3 A4 A5 A6 A7 A8 A9 A10 A11 A12 A13 A14 JOB_ID SY PR OP IN SY PR KA PR OP TA IN SY IN KA SY IN KA Abb. 1.3-17: Tabellen zur relationalen Datenbank 65 Algorithmen und Datenstrukturen Die relationale Datenbank besteht, wie die vorliegende Darstellung zeigt aus einer Datenstruktur, die Dateien (Tabellen) vernetzt. Die Verbindung besorgen Referenzen (Fremdschlüssel). 1.3.5 Definitionsmethoden für Datenstrukturen 1.3.5.1 Der abstrakte Datentyp Anforderungen an die Definition eines Datentyps 51 - Die Spezifikation eines Datentyps sollte unabhängig von seiner Implementierung sein. Daurch kann die Spezifikation für unterschiedliche Implementierungen verwendet werden. Reduzierung der von außen sichtbaren (zugänglichen) Aspekte auf der Schnittstelle des Datentyps. Dadurch kann die Implementierung später verwendet werden, ohne dass Programmteile, die den Datentyp benutzen, angepasst werden. Aus diesen Anforderungen ergeben sich zwei Prinzipien: - Kapselung (ecucapsultaion): Alle Zugriffe gehen immer nur über die Schnittstelle des Datentyps Geheimnisprinzip (programming by contract). Die interne Realisierung des Datentyps bleibt dem Benutzer verborgen Ein Datentyp, dem nur Spezifikation und Eigenschaften (bspw. in Form von Regeln oder Gesetzmäßigkeiten bekannt sind, heißt abstrakt. Man abstrahiert hier von der konkreten Implementierung. Ein abstrakter Datentyp wird als ADT bezeichnet. Die Definition einer Datenstruktur ist bestimmt durch Daten (Datenfelder, Aufbau, Wertebereiche) und die für die Daten gültigen Rechenvorschriften (Algorithmen, Operationen). Datenfelder und Algorithmen bilden einen Typ, den abstrakten Datentyp (ADT). Eine Spezifikation eines ADT besteht aus: - Angabe der Signatur. Sie legt die Namen der Typen sowie die Funktionstypen fest und bildet die Schnittstelle eines ADT. Mengen und Funktionen, die zur Signatur passen, heißen Algebren Gleichungen dienen als Axiome zur Einschränkung möglicher Algebren als Modell. Zusätzlich erfolgt evtl. ein Import anderer Spezifikationen. Daten und Algorithmen als Einheit zu sehen, war bereits schon vor 1980 bekannt. Da damals noch keine allgemein einsetzbare Implementierung vorlag, hat man Methoden zur Deklaration von ADT 52 bereitgestellt. Damit sollte dem Programmierer wenigstens durch die Spezifikation die Einheit von Daten und zugehörigen Operationen vermittelt werden. 51 52 vgl. 1.3.1 vgl. Guttag, John: "Abstract Data Types and the Development of Data Structures", CACM, June 1977 66 Algorithmen und Datenstrukturen 1.3.5.2 Die axiomatische Methode Die axiomatische Methode beschreibt abstrakte (Daten-)Typen über die Angabe einer Menge von Operationen und deren Eigenschaften, die in der Form von Axiomen präzisiert werden. Problematisch ist jedoch: Die Axiomenmenge ist so anzugeben, daß Widerspruchsfreiheit, Vollständigkeit und möglichst Eindeutigkeit erzielt wird. Algebraische Spezifikation Eine spezielle axiomatische Methode ist die algebraische Spezifikation von Datenstrukturen. Sie soll hier stellvertretend für axiomatische Definitionsmethoden an einem Beispiel vorgestellt werden. 1. Bsp.: Die algebraische Spezifikation des (ADT) Schlange Konventionell würde die Datenstruktur Schlange so definiert werden: Eine Schlange ist ein lineares Feld, bei dem nur am Anfang Knoten entfernt und nur am Ende Knoten hinzugefügt werden können. Die Definition ist ungenau. Operationen sollten mathematisch exakt als Funktionen und Beziehungen der Operationen als Gleichungen angegeben sein. Erst dann ist die Prüfung auf Konsistenz und der Nachweis der korrekten Implementierung möglich. Die algebraische Spezifikation bestimmt den ADT Schlange deshalb folgendermaßen: ADT Schlange Typen Schlange<T>, boolean Funktionen (Protokoll) NeueSchlange FuegeHinzu : Vorn : Entferne : Leer : → Schlange<T> T,Schlange<T> → Schlange<T> → T Schlange<T> Schlange<T> → Schlange<T> → boolean Schlange<T> Axiome Für alle t : T bzw. s : Schlange<T> gilt: 1. 2. 3. 4. 5. 6. Leer(NeueSchlange) = wahr Leer(FuegeHinzu(t,s)) = falsch Vorn(NeueSchlange) = Fehler Vorn(FuegeHinzu(t,s)) = Wenn Leer(s), dann t; andernfalls Vorn(s) Entferne(NeueSchlange) = Fehler Entferne(FuegeHinzu(t,s)) = Wenn Leer(s), dann NeueSchlange; andernfalls FuegeHinzu(t,Entferne(s)) Der Abschnitt Typen zeigt die Datentypen der Spezifikation. Der 1. Typ ist der spezifizierte ADT. Von anderen Typen wird angenommen, daß sie an anderer Stelle definiert sind. Der Typ „Schlange<T>“ wird als generischer ADT bezeichnet, da er "übliche Schlangeneigenschaften" bereitstellt. Eigentliche Schlangentypen erhält man durch die Vereinbarung eines Exemplars des ADT, z.B.: Schlange<integer> Der Abschnitt Funktionen zeigt die auf Exemplare des Typs anwendbaren Funktionen: f : D1 , D2 ,...., Dn → D . Einer der Datentypen D1 , D2 ,...., Dn oder D muß der spezifizierte ADT sein. 67 Algorithmen und Datenstrukturen Die Funktionen können eingeteilt werden in: - Konstruktoren (constructor functions) (Der ADT erscheint nur auf der rechten Seite des Pfeils.) Sie liefern neue Elemente (Instanzen) des ADT. - Zugriffsfunktionen (accessor functions) (Der ADT erscheint nur auf der linken Seite des Pfeils.) Sie liefern Eigenschaften von existierenden Elementen des Typs (vgl. Die Funktion: Leer) - Umsetzungsfunktionen (transformer functions) (Der ADT erscheint links und rechts vom Pfeil.) Sie bilden neue Elemente des ADT aus bestehenden Elementen und (möglicherweise) anderen Argumenten (vgl. FuegeHinzu, Entferne). Der Abschnitt Axiome beschreibt die dynamischen Eigenschaften des ADT. 2. Bsp.: Die „algebraische Spezifikation“ des ADT Stapel ADT Stapel<T>, integer, boolean 1. Funktionen (Operationen, Protokoll) NeuerStapel PUSH POP Top Stapeltiefe Leer : : : : : T,Stapel<T> Stapel<T> Stapel<T> Stapel<T> Stapel<T> → → → → → → Stapel<T> Stapel<T> Stapel<T> T integer boolean 2. Axiome Für alle t:T und s:Stapel<T> gilt: (POP(PUSH(t,s)) = s Top(PUSH(t,s)) = t Stapeltiefe(NeuerStapel) = 0 Stapeltiefe(PUSH(i,s)) = Stapeltiefe + 1 Leer(NeuerStapel) = wahr ¬ Leer(PUSH(t,s) = wahr 3. Restriktionen (conditional axioms) Wenn Stapeltiefe(s) = 0, dann führt POP(s) auf einen Fehler Wenn Stapeltiefe(s) = 0, dann ist Top(s) undefiniert Wenn Leer(s) = wahr, dann ist Stapeltiefe(s) Null. Wenn Stapeltiefe(s) = 0, dann ist Leer(s) wahr. Für viele Programmierer ist eine solche Spezifikationsmethode zu abstrakt. Die Angabe von Axiomen, die widerspruchsfrei und vollständig ist, ist nicht möglich bzw. nicht nachvollziehbar. 3. Bsp.: Die algebraische Spezifikation für einen binären Baum ADT Binaerbaum<T>, boolean 1. Funktionen (Operationen, Protokoll) NeuerBinaerbaum -> bin : Binaerbaum<T>, T, Binaerbaum<T> → → links : Binaerbaum<T> → rechts : Binaerbaum<T> wert : Binaerbaum<T> -> 68 Binaerbaum<T> Binaerbaum<T> Binaerbaum<T> Binaerbaum<T> T Algorithmen und Datenstrukturen → boolea istLeer : Binaerbaum<T> 2. Axiome Für alle t:T und x:Binaerbaum<T>, y:Binaerbaum<T> gilt: links(bin(x,t,y)) = x rechts(bin(x,t,y)) = y wert(bin(x,t,y)) = t istLeer(NeuerBinaerbaum) = true istLeer(bin(x,t,y)) = false Der direkte Weg zur Deklaration von ADT im Rahmen der objektorientierten Programmierung ist noch nicht weit verbreitet. Der konventionelle Programmierstil, Daten und Algorithmen getrennt zu behandeln, bevorzugt den konstruktiven Aufbau der Daten aus elementaren Datentypen. Realisierung von abstrakten Datentypen Die folgenden Elemente müssen im Programm abgebildet werden: - Name des ADT wird üblicherweise der Klassenname Importierte ADTs werden sowohl zur Definition mit dem entsprechenden importierten Typ, als auch zu Importanweisungen innerhalb des Programms benutzt Objekterzeugende Operatoren: sog. Konstruktoren werden in den (meist speziellen) Klassenmethoden abgebildet, die ein neues Objekt des gewünschten Typ zurückliefern Lesende Operatoren: sog. Selektoren werden zu Methoden, die auf die Attribute nur lesend zugreifen. Scheibende Operatoren: sog. Manipulatoren werden zu Methoden, die den Zustand des Objekts verändern Axiome müssen sichergestellt sein. 1.3.5.3 Die konstruktive Methode Die Basis bilden hier die Datentypen. Jedem Objekt ist eine Typvereinbarung in der folgenden Form zugeordnet: X : T; X ... Bezeichner (Identifizierer) für ein Objekt T ... Bezeichner (Identifizierer) für einen Datentyp Einem Datentyp sind folgende Eigenschaften zugeordnet: 1. Ein Datentyp bestimmt die Wertmenge, zu der eine Konstante gehört oder die durch eine Variable oder durch einen Ausdruck angenommen werden kann oder die durch einen Operator oder durch eine Funktion berechnet werden kann. 2. Jeder Operator oder jede Funktion erwartet Argumente eines bestimmten Typs und liefert Resultate eines bestimmten Typs. Bei der konstruktiven Methode erfolgt die Definition von Datenstrukturen mit Hilfe bereits eingeführter Datentypen. Die niedrigste Stufe bilden die einfachen Datentypen. Basis-Datentypen werden in den meisten Programmiersprachen zur Verfügung gestellt und sind eng mit dem physikalischen Wertevorrat einer DVAnlage verknüpft (Standard-Typen). Sie sind die „Atome“ auf der niedrigsten Betrachtungsebene. Neue "höherwertige" Datentypen werden aus bereits definierten „niederstufigen“ Datentypen definiert. 69 Algorithmen und Datenstrukturen 1.3.5.4 Die objektorientierte Modellierung abstrakter Datentypen Die Spezifikation abstrakter Datentypen Im Mittelpunkt dieser Methode steht die Definition von Struktur und Wertebereich der Daten bzw. eine Sammlung von Operationen mit Zugriff auf die Daten. Jede Aufgabe aus der Datenverarbeitung läßt sich auf ein solches Schema (Datenabstraktion) zurückführen. Zur Beschreibung des ADT dient das folgende Format: ADT Name Daten Beschreibung der Datenstruktur Operationen Konstruktor Intialisierungswerte: Daten zur Initialisierung des Objekts Verarbeitung: Initialisierung des Objekts Operation1 Eingabe: Daten der Anwendung dieser Methode Vorbedingung: Notwendiger Zustand des Systems vor Ausführung einer Operation Verarbeitung: Aktionen, die an den Daten ausgeführt werden Ausgabe: Daten (Rückgabewerte) an die Anwendung dieser Methode Nachbedingung: Zustand des Systems nach Ausführung der Operation Operation2 ......... Operationn ......... Bsp.: Anwendung dieser Vorlage zur Beschreibung des ADT Stapel ADT Stapel Daten Eine Liste von Datenelementen mit einer Position „top“, sie auf den Anfang des Stapels verweist. Operationen Konstruktor: Initialisierungswerte: keine Verarbeitung: Initialisiere „top“. Push Eingabe: Ein Datenelement zur Aufnahme in den Stapel Vorbedingung: keine Verarbeitung: Speichere das Datenelement am Anfang („top“) des Stapel Ausgabe: keine Nachbedingung: Der Stapel hat ein neues Datenelement an der Spitze („top“). Pop Eingabe: keine Vorbedingung: Der Stapel ist nicht leer Verarbeitung: Das Element an der Spitze („top“) wird entfernt. Ausgabe: keine Peek bzw. Top Eingabe: keine Vorbedingung: Stapel ist nicht leer Verarbeitung: Bestimme den Wert des Datenelements an der Spitze („top“ des Stapel. Ausgabe: Rückgabe des Datenwerts, der an der Spitze („top“) des Stapel steht. 70 Algorithmen und Datenstrukturen Nachbedingung: Der Stapel bleibt unverändert. Leer Eingabe: keine Vorbedingung: keine Verarbeitung: Prüfe, ob der Stapel leer ist. Ausgabe: Gib TRUE zurueck, falls der Stapel leer ist; andernfalls FALSE. Nachbedingung: keine bereinigeStapel Eingabe: keine Vorbedingung: keine Verarbeitung: Löscht alle Elemente im Stapel und setzt die Spitze („top“) des Stapels zurück. Ausgabe: keine Klassendiagramme der Unified Modelling Language Visualisierung und Spezifizierung objektorientierter Softwaresysteme erfolgt mit der Unified Modelling Language (UML). Zur Beschreibung abstrakter Dytentypen dient das wichtigste Diagramm der UML: Das Klassendiagramm. Das Klassendiagramm beschreibt die statische Struktur der Objekte in einem System sowie ihre Beziehungen untereinander. Die Klasse ist das zentrale Element. Klassen werden durch Rechtecke dargestellt, die entweder den Namen der Klasse tragen oder zusätzlich auch Attribute und Operationen. Klassenname, Attribute, Operationen (Methoden) sind jeweils durch eine horizontale Linie getrennt. Klassennamen beginnen mit Großbuchstaben und sind Substantive im Singular. Ein strenge visuelle Unterscheidung zwischen Klassen und Objekten entfällt in der UML. Objekte werden von den Klassen dadurch unterschieden, daß ihre Bezeichnung unterstrichen ist. Haufig wird auch dem Bezeichner eines Objekts ein Doppelpunkt vorangestellt.. Auch können Klassen und Objekte zusammen im Klassendiagramm auftreten. Klasse Objekt Wenn man die Objekt-Klassen-Beziehung (Exemplarbeziehung, Instanzbeziehung) darstellen möchte, wird zwischen einem Objekt und seiner Klasse ein gestrichelter Pfeil in Richtung Klasse gezeichnet: Klasse Objekt Die Definition einer Klasse umfaßt die „bedeutsamen“ Eigenschaften. Das sind: - Attribute d.h.: die Struktur der Objekte: ihre Bestandteile und die in ihnen enthaltenen Informationen und Daten.. Abhängig von der Detaillierung im Diagramm kann die Notation für ein Attribut den Attributnamen, den Typ und den voreingestellten Wert zeigen: Sichtbarkeit Name: Typ = voreingestellter Wert - Operationen d.h.: das Verhalten der Objekte. Manchmal wird auch von Services oder Methoden gesprochen. Das Verhalten eines Objekts wird beschrieben durch die möglichen Nachrichten, die es verstehen kann. Zu jeder Nachricht benötigt das Objekt entsprechende Operationen. Die UML-Syntax für Operationen ist: Sichtbarkeit Name (Parameterliste) : Rückgabetypausdruck (Eigenschaften) 71 Algorithmen und Datenstrukturen Sichtbarkeit ist + (öffentlich), # (geschützt) oder – (privat) Name ist eine Zeichenkette Parameterliste enthält optional Argumente, deren Syntax dieselbe wie für Attribute ist Rückgabetypausdruck ist eine optionale, sprachabhängige Spezifikation Eigenschaften zeigt Eigenschaftswerte (über String) an, die für die Operation Anwendung finden - Zusicherungen Die Bedingungen, Voraussetzungen und Regeln, die die Objekte erfüllen müssen, werden Zusicherungen genannt. UML definiert keine strikte Syntax für die Beschreibung von Bedingungen. Sie müssen nur in geschweifte Klammern ({}) gesetzt werden. Idealerweise sollten Regeln als Zusicherungen (engl. assertions) in der Programmiersprache implementiert werden können. Attribute werden mindestens mit ihrem Namen aufgeführt und können zusätzliche Angaben zu ihrem Typ (d.h. ihrer Klasse), einen Initialwert und evtl. Eigenschaftswerte und Zusicherungen enthalten. Attribute bilden den Datenbestand einer Klasse. Operationen (Methoden) werden mindestens mit ihrem Namen, zusätzlich durch ihre möglichen Parameter, deren Klasse und Initialwerte sowie evtl. Eigenschaftswerte und Zusicherungen notiert. Methoden sind die aus anderen Sprachen bekannten Funktionen. Klassenname attribut:Typ=initialerWert operation(argumentenliste):rückgabetyp Abb.: Bsp.: Die Klasse Object aus dem Paket java.lang Object +equals(obj :Object) #finalize() +toString() +getClass() #clone() +wait() +notify() ........ Sämtliche Java-Klassen bilden eine Hierarchie mit java.lang.Object als gemeinsame Basisklasse. Assoziationen repräsentieren Beziehungen zwischen Instanzen von Klassen. Mögliche Assoziationen sind: - einfache (benannte) Assoziationen 72 Algorithmen und Datenstrukturen - Assoziation mit angefügten Attributen oder Klassen - Qualifzierte Assoziationen - Aggregationen - Assoziationen zwischen drei oder mehr Elementen - Navigationsassoziationen - Vererbung Attribute werden von Assoziationen unterschieden: Assoziation: Beschreibt Beziehungen, bei denen beteiligte Klassen und Objekte von anderen Klassen und Objekten benutzt werden können. Attribut: Beschreibt einen privaten Bestandteil einer Klasse oder eines Objekts, welcher von außen nicht sichtbar bzw. modifizierbar ist. Grafisch wird eine Assoziation als durchgezogene Line wiedergegeben, die gerichtet sein kann, manchmal eine Beschriftung besitzt und oft noch weitere Details wie z.B. Muliplizität (Kardinalität) oder Rollenanmen enthält, z.B.: Arbeitet für 0..1 Arbeitgeber Arbeitnehmer Eine Assoziation kann einen Namen zur Beschreibung der Natur der Beziehung („Arbeitet für“) besitzen. Damit die Bedeutung unzweideutig ist, kann man dem Namen eine Richtung zuweisen: Ein Dreieck zeigt in die Richtung, in der der Name gelesen werden soll. Rollen („Arbeitgeber, Arbeitnehmer) sind Namen für Klassen in einer Relation. Eine Rolle ist die Seite, die die Klasse an einem Ende der Assoziation der Klasse am anderen Ende der Assoziation zukehrt. Die Navigierbarkeit kann durch einen Pfeil in Richtung einer Rolle angezeigt werden. Rolle1 Rolle2 K1 K2 1 0..* Abb.: Binäre Relation R = C1 x C2 Rolle1 K1 K2 Rollen ... Kn Abb.: n-äre Relation K1 x K2 x ... x Kn In vielen Situationen ist es wichtig anzugeben, wie viele Objekte in der Instanz einer Assoziation miteinander zusammenhänen können. Die Frage „Wie viele?“ bezeichnet man als Multiplizität der Rolle einer Assoziation. Gibt man an einem Ende der Assoziation eine Multiplizität an, dann spezifiziert man dadurch: Für jedes Objekt am entgegengesetzten Ende der Assoziation muß die angegebene Anzahl von Objekten vorhanden sein. 73 Algorithmen und Datenstrukturen Ein A ist immer mit Ein A ist immer mit Ein A ist mit einem B assoziiert einem oder mehre- keinem oder ren B assoziiert einem B assoziiert Ein A ist mit keinem, einem oder mehreren B assoziiert Unified A 1 B A 1..* B A 0..1 B A * B 1:1 1..* 1:1..n 0..* 2..6 0..* * 17 4 n m 0..n:2..6 0..n:0..n 17:4 ? Abb.: Kardinalitäten für Beziehungen Pfeile in Klassendiagrammen zeigen Navigierbarkeit an. Wenn die Navigierbarkeit nur in einer Richtung existiert, nennt man die Assoziation eine gerichtete Assoziation (uni-directional association). Eine ungerichtete (bidirektionale) Assoziation enthält Navigierbarkeiten in beiden Richtungen. In UML bedeuten Assoziationen ohne Pfeile, daß die Navigierbarbeit unbekannt oder die Assoziation ungerichtet ist. Ungerichtete Assoziationen enthalten eine zusätzliche Bedingung: Die zu dieser Assoziation zugehörigen zwei Rollen sind zueinander invers. Abhängigkeit (dependency): Manchmal nutzt eine Klasse eine andere. Die UMLNotation ist dafür oft eine gestrichelte Linie mit einem Pfeil, z.B.: Applet WillkommenApplet paint() Graphics Abb. : WillkommenApplet nutzt die Klasse Graphics über paint() Reflexive Assoziation: Manchmal ist auch eine Klasse mit sich selbst assoziiert. Das kann bspw. der fall sein, wenn eine Klasse Objekte hat, die mehrere Rollen spielen, z.B.: 74 Algorithmen und Datenstrukturen Fahrzeuginsasse 1 fahrer fährt 0..4 beifahrer Ein Fahrzeuginsasse kann entweder ein Fahrer oder ein Beifahrer sein. In der Rolle des Fahrers fährt ein Fahrzeuginsasse null oder mehr Fahrzeuginsassen, die die Rolle von Beifahrern spielen. Abb.: Bei einer reflexiven Assoziation zieht man eine Linie von der Klasse aus zu dieser zurück. Man kann die Rollen sowie die Namen, die Richtung und die Multiplizität der Assoziation angeben. Eine Aggregation ist eine Sonderform der Assoziation. Sie repräsentiert eine (strukturelle) Ganzes/Teil-Beziehung. Zusätzlich zu einfacher Aggregation bietet UML eine stärkere Art der Aggregation, die Komposition genannt wird. Bei der Komposition darf ein Teil-Objekt nur zu genau einem Ganzen gehören. Teil Ganzes Existenzabhängiges Teil Abb.: Aggregation und Komposition Eine Aggregation wird durch eine Raute dargestellt. Die Komposition wird durch eine ausgefüllte Raute dargestellt und beschreibt ein „physikalisches Enthaltensein“. Die Vererbung (Spezialisierung bzw. Generalisierung) stellt eine Verallgemeinerung von Eigenschaften dar. Eine Generalisierung (generalization) ist eine Beziehung zwischen dem Allgemeinen und dem Speziellen, in der Objekte des speziellen Typs (der Subklasse) durch Elemente des allgemeinen Typs (der Oberklassse) ersetzt werden können. Grafisch wird eine Generalisierung als durchgezogene Linle mit einer unausgefüllten, auf die Oberklasse zeigenden Pfeilspitze wiedergegeben, z.B.: Supertyp Subtyp 1 Subtyp 2 75 Algorithmen und Datenstrukturen Bsp.: Vererbungshierarchie und wichtige Methoden der Klasse Applet Panel Applet +init() +start() +paint(g:Graphics) {geerbt} +update(g:Graphics) {geerbt} +repaint() +stop() +destroy() +getParameter(name:String); +getParameterInfo() +getAppletInfo() Abb.: Schnittstellen und abstrakte Klassen: Eine Schnittstelle (Interface) ist eine Ansammlung von Operationen, die eine Klasse ausführt. Programmiersprachen (z.. B. Java) benutzen ein einzelnes Konstrukt, die Klasse, die sowohl Schnittstelle als auch deren Implementierung enthält. Bei der Bildung einer Unterklasse wird beides vererbt. Eine reine Schnittstelle (wie bspw. in Java) ist eine Klasse ohne Implementierung und besitzt daher nur Operationsdeklarationen. Schnittstellen werden oft mit Hilfe abstrakter Klassen deklariert. Bei abstrakten Klassen oder Methoden wird der Name des abstrakten Gegenstands in der UML kursiv geschrieben. Ebenso kann man die Bedingung {abstract} benutzen. <<interface>> InputStream DataInput OrderReader {abstract} Abhängigkeit Generalisierung Verfeinerung DataInputStream Irgendeine Klasse, z.B. „OrderReader“ benötigt die DataInput-Funktionalität. Die Klasse DataInputStream implementiert DataInput und InputStream. Die Verbindung zwischen DataInputStream und DataInput ist eine „Verfeinerung (refinement)“. Eine Verfeinerung ist in UML ein allgemeiner Begriff zur Anzeige eines höheren Detaillierungsniveaus. Die Objektbeziehung zwischen OrderReader und DataInput ist eine Abhängigkeit. Sie zeigt, daß „OrderReader“ die Schnittstelle „DataInput für einige Zwecke benutzt. Abb.: Schnittstellen und abstrakte Klassen: Ein Beispiel aus Java Abstrakte Klassen und Schnittstellen ermöglichen die Definition einer Schnittstelle und das Verschieben ihrer Implementierung auf später. Jedoch kann die abstrakte 76 Algorithmen und Datenstrukturen Klasse schon die Implementierung einiger Methoden enthalten, während die Schnittstelle die Verschiebung der Definition aller Methoden erzwingt. Eine Schnittstelle modelliert man in Form einer gestrichelten Linie mit einem großen, unausgefüllten Dreieck, das neben der Schnittstelle steht und auf diese zeigt. Eine andere verkürzte Darstellung einer Klasse und einer Schnittstelle besteht aus einem (kleinen Kreis), der mit der Klasse durch eine Linie verbunden ist, z.B.: Object Component ImageObserver Container Panel Applet Abb.: Vererbungshierarchie von Applet 77 Algorithmen und Datenstrukturen 2. Graphen und Graphenalgorithmen 2.1 Einführung 2.1.1 Grundlagen Viele Objekte und Vorgänge in verschiedenen Bereichen besitzen den Charakter eines Systems, d.h.: Sie setzen sich aus einer Anzahl von Bestandteilen, Elementen zusammen, die in gewisser Weise in Beziehung stehen. Sollen an einem solchen System Untersuchungen durchgeführt werden, dann ist es oft zweckmäßig, den Gegenstand der Betrachtungen durch ein graphisches Schema (Modell) zu veranschaulichen. Dabei stehen grundsätzlich immer 2 Elemente untereinander in Beziehung, d.h.: Die Theorie des graphischen Modells ist ein Teil der Mengenlehre, die binäre Relationen einer abzählbaren Menge mit sich selbst behandelt. Bsp.: Es ist K = {A, B, C, D} eine endliche Menge. Es ist leicht die Menge aller geordneten Paare von K zu bilden: K × K = {(A,A),(A,B),(A,C),(A,D),(B,A),(B,B),(B,C),(B,D),(C,A),(C,B),(C,C),(C,D),(D,A),(D,B),D,C),(D,D)} Gegenüber der Mengenlehre ist die Graphentheorie nicht autonom. Die Graphentheorie besitzt ein eigenes, sehr weites und spezifisches Vokabular. Sie umfaßt viele Anwendungsungsmöglichkeiten in der Physik, aus dem Fernmeldewesen und dem Operations Research (OR). Im OR sind es vor allem Organisations- bzw. Verkehrs- und Transportprobleme, die mit Hilfe von Graphenalgorithmen untersucht und gelöst werden. Generell dienen Graphenalgorithmen in der Praxis zum Lösen von kombinatorischen Problemen. Dabei geht man folgendermaßen vor: 1. Modelliere das Problem als Graph 2. Formuliere die Zielfunktion als Eigenschaft des Graphen 3. Löse das Problem mit Hilfe eines Graphenalgorithmus Bsp.: Es ist K = {A,B,C,D} ein endliche Menge. Es ist leicht die Menge aller geordneten Paare von K zu bilden: K × K = {(A,A),(A,B),(A,C),(A,D),(B,A),(B,B),(B,C),(B,D),(C,A),(C,B),(C,C),(C,D),(D,A),(D,B),D,C),(D,D)} Die Menge dieser Paare kann auf verschiedene Arten dargestellt werden: 78 Algorithmen und Datenstrukturen 1. Koordinatendarstellung A B C D A B C D Abb.: 2. Darstellung durch Punkte (Kreise) und Kanten (ungerichteter Graph) A B C D Abb.: ungerichteter Graph Eine Kante (A,A) nennt man Schlinge. Ein schlingenfreier Graph heißt schlicht. Ein ungerichteter Graph G = (V , E ) besteht aus - einer endlichen Knotenmenge (vertices) V und - einer endlichen Kantenmenge (edges) E 3. Darstellung durch Punkte (Kreise) und Pfeile (gerichteter Graph) A B D C Abb.: 79 Algorithmen und Datenstrukturen Einen Pfeil (A,A) nennt man eine Schlinge. Zwei Pfeile mit identischem Anfangsund Endknoten nennt man parallel. Analog lassen sich parallele Kanten definieren. Ein Graphen ohne parallele Kanten bzw. Pfeile und ohne Schlingen bezeichnet man als schlichte Graphen. ( ) 4. G = (V , E , φ ), φ : E → ℜ meist φ : E → ℜ + heißt bewerteter (weighted) Graph mit Bewertung φ (Bewertungen geben z.B. Abstände, Kosten, Kapazitäten oder Wahrscheinlichkeiten an. 5. Darstellung durch paarweise geordnete Paare A A B B C C D D Abb Ein bipartiter Graph ist ein Graph, dessen Knoten so in zwei Mengen zerteilt werden können, dass jede Kante je einen Knoten aus beiden Mengen verbindet. Probleme: 1. Herausfinden, ob ein Graph bipartit ist 2. Welches sind die Partitionen 80 Algorithmen und Datenstrukturen Bipartites Matching: Bipartite Graphen dienen häufig zur Lösung Zuordnungsproblemen, z.B. für Männer und Frauen in einem Tanzkurs. Heini von Eva Martin Klaus Maria Pia gematcht Lilo Uwe Abb.: Jeder Teilnehmer im Tanzkurs ist ein Knoten im Graphen zugeordnet, jede Kante beschreibt mögliche Tanzpartner. Drei Paare sind gefunden, aber nicht jeder Knoten hat einen Partner, und es sind keine weiteren Paarungen möglich. 6. Darstellung mit Hilfe einer Matrix A 1 1 1 1 A B C D B 1 1 1 1 C 1 1 1 1 D 1 1 1 1 Einige der geordneten Paare aus der Produktmenge K × K sollen eine bestimmte Eigenschaft haben, während die anderen sie nicht besitzen. Eine solche Untermenge von K × K ist : G = {(A,B),(A,D),(B,B),(B,C),(B,D),(C,C),(D,A),(D,B),(D,C),(D,D)} Üblicherweise wird diese Untermenge (Teilgraph) so dargestellt: A B D C Abb.: Betrachtet man hier die Paare z.B. (A,B) bzw. (A,D), so kann man feststellen: Von A erreicht man, den Pfeilen folgend, direkt B oder D. B und D heißt auch die "Inzidenzabbildung" von A und {B,D} das volle Bild von A. Verwendet man das Symbol Γ zur Darstellung des vollen Bilds, dann kann man das vorliegende Beispiel (vgl. Abb.:) so beschreiben: Γ ( A) = ( B, D ) Γ ( B ) = ( B, C, D ) Γ( C ) = C Γ ( D ) = ( A, B, C, D ) 81 Algorithmen und Datenstrukturen Zwei Kanten (Pfeile) werden benachbart oder adjazent genannt, wenn es einen Knoten gibt, der Endknoten einen Kante und Anfangsknoten der anderen Kante ist. Zwei Knoten heißen benachbart oder adjazent, wenn sie durch einen Kante (Pfeil) unmittelbar verbunden sind. Kanten (Pfeile), die denselben Anfangs- und Endknoten haben, heißen parallel. 2.1.2 Definitionen Gegeben ist eine endliche (nicht leere) Menge K 53. Ist G eine Untermenge der Produktmenge K × K , so nennt man ein Element der Menge K einen Knoten von G. Die Elemente der Knotenmenge K können auf dem Papier durch Punkte (Kreise) markiert werden. A B D C Abb.: Ein Element von G selbst ist eine (gerichtete) Kante. Im vorstehenden Bsp 54. sind (A,B), (A,D), (B,B), (B,C), (B,D), (C,C), (D,A), (D,B), (D,C), (D,D) (gerichtete) Kanten. Ein Graph wird durch die Menge seiner Knoten K und die seiner Inzidenzabbildungen beschrieben: G=(K, Γ ) Ein Graph kann aber auch folgendermaßen beschrieben werden: G = (K,E) bzw. G = (V , E ) . E ist die Menge der Kanten (gerichtet, ungerichtet, gewichtet). In gewichteten Graphen werden jeder Kante ganze Zahlen (Gewichte, z.B. zur Darstellung von Entfernungen oder Kosten) zugewiesen. Gewichtete gerichtete Graphen werden auch Netzwerke genannt. Falls die Anzahl der Knoten in einem Graphen "n" ist, dann liegt die Anzahl der n ⋅ ( n − 1) Kanten zwischen 0 und im ungerichten Graphen. Ein gerichteter Graph 2 kann bis zu n ⋅ ( n − 1) Pfeile besitzen. In einem vollständigen Graphen existiert zwischen jedem Knotenpaar eine Kante. 53 54 Anstatt K schreibt man häufig auch V (vom englischen Wort Vertex abgeleitet) vgl. 2.1.1 82 Algorithmen und Datenstrukturen Abb. Vollständiger Graph Ein Graph G = (V , E ) heißt bipartit, wenn 2 disjunkte Knotenmengen V1 , V2 ⊆ V gibt, so dass E ⊆ {{v1 , v 2 }v1 ∈ V1 , v 2 ∈ V2 } gilt. Abb. Ein bipartiter Graph Der Grad eines Knoten bezeichnet die Zahl der Kanten, die in Knoten enden. Eingangsgrad: Zahl der ankommenden Kanten. 1 3 2 1 2 1 1 2 0 Abb.: Eingangsgrad Ausgangsgrad: Zahl der abgehenden Kanten 2 1 0 1 2 0 1 2 3 Abb.: Ausgangsgrad 83 Algorithmen und Datenstrukturen Bei ungerichteten Graphen ist der Ausgangsgrad gleich dem Eingangsgrad. Man spricht dann nur von Grad. Ein Pfad vom Knoten k1 zum Knoten kk ist eine Folge von Knoten k1, k2, ... , kk, wobei (k1,k2), ... ,(kk-1,kk) Kanten sind. Die Länge des Pfads ist die Anzahl der Kanten im Pfad. Auch Pfade können gerichtet oder ungerichtet sein. kk k1 Abb.: Ein Graph ist zusammenhängend, wenn von jedem Knoten zu jedem anderen Knoten im Graph ein Weg (Pfad) existiert. X1 X5 X2 X4 X3 Abb.: Dieser Graph ist streng zusammenhängend. Man kann sehen, daß es zwischen je 2 Knoten mindestens einen Weg gibt. Dies trifft auf den folgenden Grafen nicht zu: X1 X6 X2 X3 X5 X4 Abb.: Hier gibt es bspw. keinen Weg von X4 nach X1. Ein Graph, der nicht zusammenhängend ist, setzt sich aus zusammenhängenden Komponenten zusammen. Ein Knoten in einem zusammenhängenden Netzwerk heißt Artikulationspunkt, wenn durch sein Entfernen der Graph zerfällt, z.B. 84 Algorithmen und Datenstrukturen Artikulationspunkte sind dunkel eingefärbt. Abb.: Erreichbarkeit: Der Knoten B ist in dem folgenden Graphen erreichbar vom Knoten G, wenn es einen Pfad von G nach B gibt. C E B D F I G A H Abb.: Knoten B ist erreichbar von Knoten G Ein Graph heißt Zyklus, wenn sein erster und letzter Knoten derselbe ist. Abb.: Zyklus (manchmal auch geschlossener Pfad genannt) Ein Zyklus ist ein einfacher Zyklus, wenn jeder Knoten (außer dem ersten und dem letzten) nur einmal vorkommt. 85 Algorithmen und Datenstrukturen Abb.: Einfacher Zyklus (manchmal auch geschlossener Pfad genannt) Ein gerichteter Graph heißt azyklisch, wenn er keine Zyklen enthält. Ein azyklischer Graph kann in Schichten eingeteilt werden (Stratifikation). Bäume sind Graphen, die keine Zyklen enthalten. Graphen, die keine Zyklen enthalten heißen Wald. Zusammenhängende Graphen, die keine Zyklen enthalten, heißen Bäume. Wenn ein gerichteter Graph ein Baum ist und genau einen Knoten mit Eingangsgrad 0 hat, heißt der Baum Wald. Ein spannender Baum (Spannbaum) eines ungerichteten Graphen ist ein Teilgraph des Graphen, und ist ein Baum der alle seine Knoten enthält. Abb. Einen spannenden Baum mit minimaler Summe der Kantenbewegungen bezeichnet man als minimalen spannenden Baum. Zu dem folgenden Graphen 3 4 2 5 4 3 4 4 5 6 gehört der folgende minimale spannende Baum Abb.: Minimaler spannender Baum 86 Algorithmen und Datenstrukturen Ein Pfad wird als Circuit (Rundgang) bezeichnet, wenn der erste und letzte Knoten des Pfads identisch sind. Ein Circuit wird als einfach (oder als Kreis) bezeichnet, falls alle Knoten außer dem ersten und letzten genau einmal auftreten Eulersche Pfade bzw. Eulerscher Kreis: Ausgangspunkt dieses Problems ist das sog. Königsberger Brückenproblem, das Leonard Euler 1736 gelöst hat. Euler interpretierte dabei die Brücken über den Fluß Pregel in Königsberg als Kanten und Ufer bzw. Inseln als Knoten. neuer Pregel Pregel alter Pregel Abb.: Königsberger Brückenproblem mit Darstellung als Graph Königsberger Brückenproblem: Existiert ein Eulerscher Pfad? Lösung: Da man, wenn man in einen Knoten hineinkommt, auf anderem Weg wieder herauskommen muß, gilt als Bedingung: Der Grad jedes Knoten muß durch 2 teilbar sein. Neuformulierung des Problems: Gibt es einen Zyklus im Graphen, der alle Kanten genau einmal enthält (Eulerkreis) 55. Bedingung für die Existenz eines Eulerkreises: Der Grad jedes Knoten muß durch 2 teilbar und zusammenhängend sein. Das Königsberger Brückenproblem stellt offenbar keinen Eulerkreis dar. Bekanntes Bsp.: Kann das Häuschen der folgenden Abbildung in einem Strich gezeichnet werden? Abb. Haus des Nikolaus 55 falls JA, wird der Graph eulersch genannt. 87 Algorithmen und Datenstrukturen Hamiltonsche Pfade: Gegeben ist eine Landkarte mit Orten und Verbindungen. Gesucht ist ein Rundgang einmal durch jeden Ort. Abb.: Hamiltonscher Kreis Verschärfung: jede Verbindung ist mit Kosten gewichtet. Gesucht ist der billigste Rundgang. Ein Hamiltonscher Pfad ist ein einfacher Zyklus, der jeden Knoten eines Graphen enthält. Ein Algorithmus für das Finden eines Hamiltonschen Graphen ist relativ einfach (modifizierte Tiefensuche) aber sehr aufwendig. Bis heute ist kein Algorithmus bekann, der eine Lösung in polynomialer Zeit findet. 2.1.3 Darstellung in Rechnerprogrammen 1. Der abstrakte Datentyp (ADT) für gewichtete Graphen Ein gewichteter Graph besteht aus Knoten und gewichteten Kanten. Der ADT beschreibt die Operationen, die einem solchen gewichteten Graphen Datenwerte hinzufügen oder löschen. Für jeden Knoten Ki definiert der ADT alle benachbarten Knoten, die mit Ki durch eine Kante E(Ki,Kj) verbunden sind. ADT Graph Daten Sie umfassen eine Menge von Knoten {Ki} und Kanten {Ei}. Eine Kante ist ein Paar (Ki, Kj), das anzeigt: Es gibt eine Verbindung vom Knoten Ki zum Knoten Kj. Verbunden ist mit jeder Kante die Angabe eines Gewichts. Es bestimmt den Aufwand, um entlang der Kante vom Knoten Ki nach dem Knoten Kj zu kommen. Operationen Konstruktor Eingabe: keine Verarbeitung: Erzeugt den Graphen als Menge von Knoten und Kanten Einfuegen_Knoten Eingabe: Ein neuer Knoten Vorbedingung: keine Verarbeitung: Füge den Knoten in die Menge der Knoten ein Ausgabe: keine Nachbedingung: Die Knotenliste nimmt zu Einfügen_Kante Eingabe: Ein Knotenpaar Ki und Kj und ein Gewicht Vorbedingung: Ki und Kj sind Teil der Knotenmenge Verarbeitung: Füge die Kante (Ki,Kj) mit dem gewicht in die Menge der Kanten ein. Ausgabe: keine 88 Algorithmen und Datenstrukturen Nachbedingung: Die Kantenliste nimmt zu Loesche_Knoten Eingabe: Eine Referenz für den Knoten Kl Vorbedingung: Der Eingabewert muß in der Knotenmenge vorliegen Verarbeitung: Lösche den Knoten aus der Knotenliste und lösche alle Kanten der Form (K,Kl) bzw. (Kl,K), die eine Verbindung mit Knoten Kl besitzen Loesche_Kante Eingabe: Ein Knotenpaar Ki und Kj Vorbedingung: Der Eingabewert muß in der Kantenliste vorliegen Verarbeitung: Falls (Ki,Kj) existiert, loesche diese Kante aus der Kantenliste Ausgabe: keine Nachbedingung: Die Kantenmenge wird modifiziert Hole_Nachbarn: Eingabe: Ein Knoten K Vorbedingung: keine Verarbeitung: Bestimme alle Knoten Kn, so daß (K,Kn) eine Kante ist Ausgabe: Liste mit solchen Kanten Nachbedingung: keine Hole_Gewichte Eingabe: Ein Knotenpaar Ki und Kj Vorbedingung: Der Eingabe wert muß zur Knotenmenge gehören Verarbeitung: Beschaffe das Gewicht der Kante (Ki, Kj), falls es existiert Ausgabe: Gib das Gewicht dieser Kante aus (bzw. Null, falls die Kante nicht existiert Nachbedingung: keine 2. Abbildung der Graphen Es gibt zahlreiche Möglichkeiten zur Abbildung von Knoten und Graphen in einem Rechnerprogramm. Eine einfache Abbildung speichert die Knoten in einer sequentiellen Liste. Die Kanten werden in einer Matrix beschrieben (Adjazenzmatrix), in der Zeile i bzw. Spalte j den Knoten Ki und Kj zugeordnet sind. Jeder Eintrag in der Matrix gibt das Gewicht der Kante Eij = (Ki,Kj) oder den Wert 0 an, falls die Kante nicht existiert. In ungewichteten, gerichteten Graphen hat der Eintrag der (booleschen) Wert 0 oder 1, je nachdem, ob die Kante zwischen den Knoten existiert oder nicht, z.B.: 89 Algorithmen und Datenstrukturen 2 A B 3 5 1 4 E C 7 D 0 2 1 0 0 0 0 5 0 0 0 4 0 0 0 0 0 7 0 0 0 3 0 0 0 B A C D E 0 1 1 1 0 1 0 1 0 0 1 0 0 0 0 0 0 0 0 1 0 0 1 0 0 Abb.: Besonders einfach kann einer Adjazenzmatrix A[i,j] geprüft werden, ob es eine Kante von i nach j gibt. Die Laufzeit für diese Operation ist O(1). Will man alle Nachbarn eines Knoten i in einem ungerichteten Graphen ermitteln, muß man hingegen alle Einträge der i-ten Zeile oder der i-ten Spalte überprüfen (n Schritte). Bei gerichteten Graphen findet man in der i-ten Zeile die Knoten, die von i aus erreichbar sind (Nachfolger), in der j-ten Spalte hingegen die Knoten, von denen aus eine Kante nach i führt (Vorgänger). Der Speicherplatzbedarf für eine Adgazenzmatrix ist –unabhängig von der Anzahl der Kanten – immer n2. 56 Gibt es wenige Kanten im Graphen, enthält die zugehörige Adjazenzmatrix hauptsächlich Nullen. Ungerichtete Graphen können etwas effizienter gespeichert werden, da ihre Matrix symmetrisch ist, somi müssen die Einträge nur n ⋅ ( n − 1) oberhalb der Diagonalen gespeichert werden. Dafür werden Speicherplätze 2 56 kompakt bei dichten Graphen, also Graphen mit vielen Kanten 90 Algorithmen und Datenstrukturen benötigt, wenn es keine Schlingen gibt. n ⋅ (n + 1) Speicherplätze benötigt man. Wenn 2 es Schlingen gibt 57. In der Darstellungsform „Adjazenzliste“ werden für jeden Knoten alle mit ihm verbundenen Knoten in eine Adjazenzliste für diese Knoten aufgelistet. Das läßt sich leicht über verkettete Listen realisieren. In einem gewichteten Graph kann zu jedem Listenelement ein Feld für das Gewicht hinzugefügt werden, z.B.: 2 A B 3 5 1 4 E C 7 Knoten: 57 D Liste der Nachbarn: A B 2 B C 5 C B 4 D C 7 E B 3 C 1 in diesem Fall muß die Diagonale mitgespeichert werden. 91 Algorithmen und Datenstrukturen B A C D E A B C B A C C A D E E C D Abb.: Adjazenzlisten verbrauchen nur linear viel Speicherplatz, was insbesonders bei dünnen Grafen (also Graphen mit wenig Kanten) von Vorteil ist. Viele graphentheoretischen Probleme lassen sich mit Adjazenzlisten in linearer Zeit lösen. Für einen gerichteten Graphen benötigt eine Adjazenzliste n+m Speicherplätze, für einen ungerichteten Gaphen n+2m mit n als Knotenanzahl und m als als Kantenanzahl. 3. Lösungsstrategien Für die Lösung der Graphenprobleme stattet man die Algorithmen mit verschiedenen Strategien aus: - Greedy (sukzessive bestimmung der Lösungsvariablen) - Divide and Conquer (Aufteilen, Lösen, Lösungen vereinigen) - Dynamic Programming (Berechne Folgen von Teillösungen) - Enumeration (Erzeuge alle Permutationen und überprüfe sie) - Backtracking (Teillösungen werden systematisch erweitert) - Branch and Bound (Erweitere Teillösungen an der vielversprechenden Stelle) 92 Algorithmen und Datenstrukturen 2.2 Durchlaufen von Graphen Für manche Probleme ist es wichtig, alle Knoten in einem Graphen zu betrachten. So kann man etwa einer in einem Labyrinth eingeschlossenen Person nachfühlen, dass sie sämtliche Kreuzungen von Gängen in Augenschein nehmen will. Die Gänge des Labyrinths sind hier die Kanten des Graphen, und Kreuzungen sind die Knoten. Es gibt zwei Suchstrategien für Graphen: Tiefensuche und Breitensuche. Diese Verfahren bilden die Grundlage für graphentheoretische Algorithmen, in denen alle Ecken oder Kanten eines Graphen systematisch durchlaufen werden müssen. Für die meisten Suchverfahren gilt folgendes algorithmisches Grundgerüst (Markierungsalgorithmus): 1. Markiere den Startknoten 2. Solange es noch Kanten von markierten zu unmarkierten Knoten gibt, wähle eine solche Kante und markiere deren Endknoten. Die beiden hier angegebenen Verfahren (Tiefensuche unterscheiden sich in der Auswahl der Kanten in Schritt 2. und Breitensuche) 2.2.1 Tiefensuche (depth-first search) Bei der Tiefensuche (DFS) bewegt man sich möglichst weit vom Startknoten weg, bevor man die restlichen Knoten besucht. Trifft man auf einen Knoten, der keine unbesuchten Nachbarn hat, so erfolgt "backtracking", d.h. die Suche wird beim Vorgänger fortgesetzt. Dadurch werden alle vom Startknoten erreichbaren Knoten gefunden. 2.2.1.1 Algorithmus Als Eingabe benötigt der Algorithmus einen Graphen und einen Startknoten. - color[v]: repräsentiert den aktuellen Bearbeitungsstatus weiß = unbesucht/unbearbeitet schwarz = abgearbeitet (v und alle Nachbarn von v wurden besucht). grau = in Bearbeitung (v wurde besucht, kann aber noch unbesuchte Nachbarn haben) - p[v]: Vorgänger (predecessor) von v - b[v]: Beginn der Suche (Einfügen des Knotens in den Stack bzw. Zeitpunkt des rekursiven Aufrufs) - f[v]: Ende der Suche (Löschen des Knotens aus dem Stack bzw. Ende des rekursiven Aufrufs) Die Knoten, die in Bearbeitung sind, werden in einem Stack K (LIFO) verwaltet. for each vertex u ∈ V [G ] − {s} color[u ] ← WHITE b[u ] ← ∞ f [u ] ← ∞ p[u ] ← NIL time ← 1 color[ s ] ← GRAY PUSH ( K , s ) b[ s] ← time do 93 Algorithmen und Datenstrukturen p[ s] ← NIL while K ≠ 0 do u ← TOP( K ) if ∃v ∈ Adj[u ] : color[v] = WHITE then color[v ] ← GRAY PUSH ( K , v) b[v] ← time ← time + 1 else POP( K ) color[u ] ← BLACK f [u ] ← time ← time + 1 Komplexität: Das Initialisieren des Graphen dauert O( | V | ) Zugriffe auf den Stack und die "Arrays" brauchen konstante Zeit (insgesamt O( | V | )), die Adjazenzliste wird genau einmal durchlaufen (O( | E | ). Damit ergibt sich eine Gesamtlaufzeit von O( | V | + | E | ). Bsp.: Tiefensuche in ungerichteten Graphen Anfangsschritt: für alle v ∈ V : color[v] ← WHITE , b[v] ← ∞ , f [v] ← ∞ , p[v] ← NIL Stack 1.Schritt: u v w x y z b[u ] = 1 Stack u v w x y z u 2. Schritt: b[v ] = 2 Stack u v w x y z v u 94 Algorithmen und Datenstrukturen 3. Schritt: b[ w] = 3 Stack u v w w x y v u z 4. Schritt: b[ y ] = 4 Stack u v w y w x y v u z 5. Schritt: b[ x] = 5 Stack x u v w y w x y v u z 6. Schritt: f [ x] = 6 , back edge zu u und v Stack u v w y w x 7. Schritt: backtracking zu y, y v u z f [ y] = 7 Stack u v w w x y v u z 95 Algorithmen und Datenstrukturen 8. Schritt: backtracking zu w, f [ z] = 8 Stack u v w z w x y v u z 9. Schritt: f [ z ] = 9 Stack u v w w x y v u z 10. Schritt: backtracking zu w, f [ w] = 10 Stack u v w x y z v u 11. Schritt: backtracking zu v, f [ w] = 10 Stack u v w x y z 12. Schritt: backtracking zu w, u f [u ] = 10 Stack u v w x y z 96 Algorithmen und Datenstrukturen 2.2.1.2 Eigenschaften von DFS Laufzeit Die Laufzeit von DFS für einen Graphen G = (V , E ) mit n Knoten und m Kanten ist O ( n + m) = O ( V + O E ) . Predecessor-Graph Gegeben ist G = (V , E ) . Der Predecessor-Graph von G wird definiert zu G p = (V , E p ) mit E p = {( parent[ s ], s ) s ∈ V ∧ parent[ s ] ≠ nil }. Der Predecessor-Graph von DFS bildet einen „Depth-First Forest“, der sich aus mehreren „Depth-First Trees“ zusammensetzen kann. Die Kanten von E p nennt man die Baumkanten. Im Algorithmus zur Tiefensuche 58 ist dafür eine Zeitmessung eingeführt: - b[v]: Beginn der Suche - f[v]: Ende der Suche Nach Anwendung von DFS auf einen Graphen G gilt für 2 beliebige Knoten u und v eine der 3 Bedingungen. 1. Die Zeitintervalle b[u] … f[u] und b[v] … f[v] sind disjunkt und weder v noch u sind Nachfahren des jeweils anderen Knoten im DF Forest 2. Ein Intervall b[u] … f[u] ist vollständig im Intervall b[v] … f[v] enthalten, und u ist ein Nachfahre von v in einem Baum des DF Forest. 3. Ein Intervall b[v] … f[v] ist vollständig im Intervall b[u] … f[u] enthalten, und v ist ein Nachfahre von v in einem Baum des DF Forest. Darau folgt direkt Knoten v ist genau dann ein Nachfahre von Knoten u im DF Forest, wenn gilt: f[u] < f[v] < b[v] < b[u] Im DF Forest eines gerichteten oder ungerichteten Graphen G = (V , E ) ist ein Knoten v genau dann ein Nachfahre von Knoten u, falls zu der Zeit, wenn er entdeckt wird (b[u]), ein Pfad von u nach v existiert, der ausschließlich unentdeckte Knoten enthält. Falls der Graph nicht zusammenhängend ist, dann erfordert die Verarbeitung aller Knoten (und Kanten) einige Aufrufe von DFS, jeder Aufruf erzeugt einen Baum. Die ganze Sammlung ist ein depth first spanning forest. 58 vgl. 2.2.1.1 97 Algorithmen und Datenstrukturen 2.2.1.3 Kantenklassenfikation mit DFS Die Tiefensuche kann für eine Kantenklassifikation eines Graphen G = (V , E ) verwendet werden, mit der wichtige Informationen über G gesammelt werden können. Defintion von 4 Kantentypen (, die bei einem DFS-Durchlauf für G produziert werden): 1. Tree Edges (Baumkanten) sind Kanten des DF Forest G p . Die Kante (u,v) ist eine Baumkante, falls v über die Kante (u,v) entdeckt wurde. 2. Back Edges (Rückwärtskanten) sind Kanten (u,v), die einen Knoten u mit einem Vorfahren v in einem DF Forest verbinden. Rückwärtskanten verbinden mit den Vorfahren 3. Forward Edges (Vorwärtskanten) sind Kanten (u,v), die nicht zum DF Forest gehören und einen Knoten u mit einem Nachfolger v verbinden. Vorwärtskanten verbinden mit den Nachkommen. 4. Cross Edges (Querkanten) sind die anderen (nicht direkt verwandten) Kanten. Der angegebene DFS Algorithmus kann so modifiziert werden, dass die Kanten entsprechend der vorstehenden Aufzählung klassifiziert werden. Bsp. 1. Gegeben ist folgender ungerichteter Graph A B D E C Der Graph wird mit folgendem Algorithmus zur Tiefensuche untersucht: void dfs( Vertex v) { v.visited = true; for each Vertex w adjacent to v if (!w.visited) dfs(w); } Abb.: Schablone zu depth-first-search in Pseudocode Für jeden Knoten ist das Feld visited mit false initialisiert. Bei den rekursiven Aufrufen werden nur nicht besuchte Knoten aufgesucht. Start: Knoten A, der als besucht markiert wird. Rekursiver Aufruf dfs(B), B wird als „besucht“ markiert. Rekursiver Aufruf dfs(C), C wird als „besucht“ markiert. Rekursiver Aufruf dfs(D), A und B sind markiert, C ist benachbart aber markiert, Rückkehr zu dfs(C), B ist nun Nachbar aber markiert, nicht besucht (von C aus) ist der Nachbar E. 98 Algorithmen und Datenstrukturen Rekursiver Aufruf dfs(E), E wird als „besucht“ markiert. A und C werden ignoriert, Rückkehr zu dfs(C), Rückkehr zu dfs(B), A und B werden ignoriert, dfs(A) ignoriert D und E und kehrt zurück. Diese Schritte kann man graphisch mit einem „depth-first spanning treee“ dokumentieren. A B C D E Jede Kante (v,w) im Graph ist im Baum present. Falls ((v,w) bearbeitet wird und w nicht markiert bzw. (w,v) bearbeitet wird und v ist nicht markiert, dann wird das mit einer Baumkante markiert Falls (v,w) bearbeitet wird und w ist schon markiert bzw. (w,v) wird bearbeitet und v ist schon markiert, dann wird eine gestrichelte Linie aufgezeichnet (Rückwärtskante 59). Der Baum simuliert die Präorder Traverse. Die DFS-Klassifizierung eines ungerichteten Graphen G = (V , E ) ordnet jeder Kante zu G entweder in die Klasse Baumkante oder in die Klasse Rückwärtskante ein. 2. Gegeben ist der folgende gerichtete Graph A B D C E G F J I Start: Knoten B Von B aus Besuch der Knoten B, C, A, D, E und F Restart aus einem noch nicht besuchten Knoten, z.B. H Rückwärtskanten: (A,B) (I,H) forward edges: (C,D) (C,E) 60 cross edges: (F,C) (G,F) 59 60 H kein Bestandteil des Baums führen von einem Baumknoten zu einem Nachfolger 99 Algorithmen und Datenstrukturen B H C G F A J I D E Abb. Tiefensuche Ein Nutzen der Tiefensuche ist die Überprüfungsmöglichkeit auf Zyklen. Gerichtete Graphen sind dann und nur dann azyklisch, wenn sie keine Rückwärtskanten besitzen. Der vorstehende Graph besitzt Rückwärtskanten und ist deshalb azyklisch. 100 Algorithmen und Datenstrukturen 2.2.1.4 Zusammenhangskomponenten 1. Connected Ein ungerichteter Graph G = (V , E ) heißt genau dann zusammenhängend (connected), wenn es für ein Knotenpaar (v, v' ) ∈ V einen Weg von v nach v' gibt. Ein gerichteter Graph G = (V , E ) heißt stark zusammenhängend, wenn es einen Weg von jedem Knoten zu jedem anderen Knoten im Graphen gibt. Eine Komponente eines ungerichteten Graphen ist ein maximaler Teilgraph in dem jeder Knoten von jedem anderen Knoten aus, erreichbar ist. Die Komponenten können beim Traversieren mittels Tiefen- oder Breitensuche ermittelt werden. Ein ungerichteter Graph G = (V , E ) heißt zweifach zusammenhängend (biconnected), wenn nach dem Entfernen eines beliebigen Knoten v aus G der verbliebene Graph G − v zusammenhängend ist. Eine zweifache Zusammenhangskomponente (biconnected component) eines ungerichteten Graphen ist ein maximaler, zweifach zusammenhängender Untergraph. In einem zweifach zusammenhängenden Graphen kann man einen beliebigen Knoten samt allen inzidenten Kanten entfernen, ohne dass der Graph zerfällt. 2. Artikulationspunkte Falls ein Graph nicht zweifach zusammenhängend ist, werden die Knoten, deren Entfernung den Graphen trennen würden, Artikulationspunkte genannt. B A C D F G E Die Entfernung vom Knoten C trennt den Knoten G vom Graphen Die Entfernung vom Knoten D trennt die Knoten E und F vom Graphen Abb.: Ein Graph mit den Artikulationspunkten C und D Kritische Knoten und kritische Kanten Kanten und Knoten eines ungerichteten Graphen sind dann kritisch, wenn sich bei ihrer Entfernung die Anzahl der Komponenten des Graphen erhöht. 101 Algorithmen und Datenstrukturen a b a c b d c d kritische Kante kritische Knoten e f e g f g Abb.: Zur Bestimmung der Artikulationspunkte werden die Knoten des Graphen während der Tiefensuche in „preorder“ Reihenfolge durchnummeriert und bei der Rückkehr aus der Tiefensuche jeder Kante die kleinste Nummer aller über die Kante erreichbaren Knoten zugewiesen. Bsp. a b c d e f g Eine Kante ist genau dann kritisch, wenn die kleinste über sie erreichbare Knotennummer größer ist als die Nummer des Knotens von dem aus si während der Tiefensuche traversiert wird. Ein Knoten (mit Ausnahme des Startknoten) ist genau dann kritisch, wenn bei der Tiefensuche für mindestens eine der von ihm ausgehenden Kanten die kleinste über diese während der Tiefensuche erreichbare Knotennummer größer oder gleich der Nummer dieses Knoten ist, Der Startknoten der Tiefensuche ist genau dann kritisch, wenn von der Wurzel des bei der Tiefensuche generierten spannenden Baums mehr als eine Kante ausgeht. 102 Algorithmen und Datenstrukturen Algorithmus Mit Hilfe der DFS kann ein Algorithmus (mit linearer Laufzeit) zur Bestimmung aller Artikulationspunkte in einem zusammenhängenden Graphen gefunden werden. 1. Start mit irgendeinem Knoten und Ausführen der Tiefensuche, Durchnummerieren der Knoten, wie sie bei der Suche anfallen (preorder-number Num(v). 2. Für jeden Knoten im „depth-first search spanning tree“, der von v mit 0 oder mehr Baumkanten und dann möglicherweise über eine Rückwärtskante erreichbar ist, Berechnung des am niedrigsten nummerierten Knoten ( Low(v) ). 3. Low(v ) ist das Minimum von 1. Num(v) 2. das niedrigste Num(w) unter allen Rückwärtskanten (v,w) 3. das niedrigste Low( w) unter allen Baumkanten (v,w). Low kann nur bewertet werden, wenn alle Kinder von v bei der Berechnung von Low(v) berücksichtigt sind, d.h. eine Postorder-Traverse ist nötig. Für jede Kante (v,w) kann bestimmt werden, ob eine Baumkante oder eine Rückwärtskante vorliegt ( Num(v ) bzw. Num(w) ). Low(v ) kann also leicht über das Durchlaufen der Adjazenzliste von v über das Feststellen des Minimums errechnet werden. Die Laufzeit liegt bei O ( E + V ). A 1/1 B 2/1 C 3/1 D 4/1 G 7/7 E 5/4 F 6/4 Abb.: DFS-Baum mit Num und Low Bestimmen der Artikulationspunkte - die Wurzel ist dann und nur dann ein Artikulationspunkt, wenn sie mehr als ein Kind besitzt. Das Entfernen der Wurzel im Bsp. kettet lediglich die Wurzel aus. - irgendein anderer Knoten v ist dann und nur dann Artikulationspunkt, wenn v ein Kind w hat, so dass Low( w) ≥ Num(v) . Im Bsp. bestimmt der Algorithmus zu Artikulationspunkten C und D. Low( E ) ≥ Num( D) . Es gibt nur einen Weg von E aus, das ist der Weg durch D - C ist ein Artikulationspunkt, weil Low(G ) ≥ Num(C ) ist. - D hat Kind E, 103 Algorithmen und Datenstrukturen C 1/1 D 2/1 E 3/2 G 7/7 A 5/1 F 4/2 B 6/1 Abb.: depth-first Baum, falls Start der DFS im Punkt C Der Algorithmus kann implementiert werden durch 1. Ausführen einer Preorder-Traverse zur Berechnung von Num 2. Ausführen einer Postorder-Traverse zur Berechnung von Low 3. Überprüfen, welche Knoten Artikulationspunkte sind. Der erste Durchgang wird beschrieben durch folgenden Algorithmus: void assignNum(Vertex v) { v.num = counter++; v.visited = true; for each Vertex w adjacent to v if (!w.visited) { w.parent = v; assignNum(w); } } Abb.: Pseudocode für Zuweisen Num 61 Der zweite und dritte Durchgang mit Postorder-Traversen nimmt dann folgende Gestalt an: void assignLow( Vertex v) { v.low = v.num; // Regel 1 for each Vertex w adjacent to v { if (w.num > v.num) // Vorwärts-Kante { assignLow(w); if (w.low >= v.num ) System.out.println(v + “ ist ein Artikulationspunkt”); v.low = min(v.low,w.low); // Regel 3 } else if (v.parent != w) // Rückwärtskante v.low = min(v.low, w.num); // Regel 2 } } Abb.: Pseudocode für Berechnung von Low und Test auf Artikulationspunkte 62 61 62 vgl. Weiss, Mark Allen: Data Structures and Algorithms Analysis in Java, Second Edition, Seite 361 vgl. Weiss, Mark Allen: Data Structures and Algorithms Analysis in Java, Second Edition, Seite 362 104 Algorithmen und Datenstrukturen 3. Starke Zusammenhangskomponenten 1. Zwei Knoten v, w eines gerichteten Graphen G = (V , E ) heißen stark verbunden, falls es einen Weg von v nach w und von w nach v gibt. 2. Eine starke Zusammenhangskomponente ist ein Untergraph von G mit maximaler Knotenzahl, in der alle Paare von Knoten stark verbunden sind 3. Eine starke Zusammenhangskomponente (strongly connected components) eines gerichteten Graphen G = (V , E ) ist eine maximale Knotenmenge , so dass für jedes Paar gilt: Der Knoten u kann von v aus über einen Pfad, der vollständig zu C gehört, erreicht werden. 4. Ein gerichteter Graph wird als stark zusammenhängend bezeichnet, wenn er aus einer einzigen starken Zusammenhangskomponenten besteht. Besitzt G genau eine starke Zusammenhangskomponente, so ist G stark verbunden. Algorithmus für Strongly-Connected Components (SCC(G)) 1. Berechne die “finishing time” f für jeden Knoten mit DFS(G). ( Berechne den T transponierten Graphen GT = V , ET ) G = (V , E ) , wobei E = {(v, u ) | (u , v ) ∈ E}. E besteht also aus den umgedrehten Kanten von G . G und G T haben 2. von T die gleichen starken Zusammenhangskomponenten. T 3. Berechne dfs (G ) , wobei die Knoten in der Reihenfolge ihrer „finishing-time“-Einträge aus der dfs(G ) -Berechnung in Schritt 1 (fallend) in der Hauptschleife von dfs (G T ) abgearbeitet wurden. Bsp.: Gegeben ist A B D C E G F J H I Abb.: Ein gerichteter Graph G 105 Algorithmen und Datenstrukturen A, 3 B, 6 D, 2 C, 4 G, 10 F, 5 E, 1 Abb.: G T H, 9 J, 8 I, 7 I durchnummeriert in Postorder-Traversal von G Eine Tiefensuche zu G T wird mit dem Knoten begonnen, der die größte Nummer besitzt (Knoten G). Das führt aber nicht weiter, die nächste Suche wird bei H gestartet und I bzw. J aufgesucht. Der nächste Aufruf startet bei B, besucht A, C und F. Danach werden dfs (D) und schließlich dfs (E ) aufgerufen. Es ergibt sich folgender depth-first spanning forest. G H B D E A I C J F Abb.: Tiefensuche mit G - starke Komponenten sind {G}, {H , I , J }, {B, A, C , F }, {D}, {E} T Jeder dieser Bäume im depth-first zusammenhängende Komponente spanning tree bildet eine stark Komponenten-Graph Der Komponenten-Graph G SCC = (V SCC , E SCC ) wird folgendermassen aufgebaut: 4. Schwach zusammenhängende Komponenten eines gerichteten Graphen (weakly connected components) Die schwach zusammenhängenden Komponenten eines gerichteten Graphen entsprechen den Komponenten jenes Graphen der entsteht, wenn sämtliche gerichtete Kanten durch ungerichtete Kanten ersetzt werden. 106 Algorithmen und Datenstrukturen 5. Erreichbarkeit (reachability) Für jedes Paar Knoten in einem Graph, z.B. (vi , v j ) , ist v j erreichbar von vi , wenn ein direkter Pfad von vi nach v j besteht. Dies definiert die Erreichbarkeitsrelation R . Für jeden Knoten vi bestimmt die Tiefensuche die Liste aller Knoten, die von vi aus erreichbar sind. Wendet man die Tiefensuche auf jeden Knoten des Graphen an, gibt es eine Reihe von Erreichbarkeitslisten, die die Relation R ausmachen. Diese Relation kann mit Hilfe einer (n × n ) -Erreichbarkeitsmatrix beschrieben werden, die eine 1 an der Stelle (i, j ) hat (vorgesehen für vi Rv j ). Bsp.: Für den Graphen A B C D sind Erreichbarkeitsliste bzw. Erreichbarkeitsmatrix A: B: C: D: A B C B B D C B D D 1 0 0 0 1 1 1 0 1 0 1 0 1 1 1 1 Die Erreichbarkeitsmatrix dient zur Bestimmung, ob es einen Pfad zwischen 2 Knoten gibt. Tiefensuche-Algorithmus für das Verbindungsproblem Der folgende Algorithmus 63 bestimmt alle Knoten, die mit einem gegebenen verbunden sind: typedef vertex<string> node; typedef node::vertex_list nodeList; // Knotenliste void findReachable(node& quelle, nodeList& reachable) { // finde alle Knoten, die von quelle aus erreichbar sind // mit Hilfe der Tiefensuche reachable.insert(&quelle); nodeList::iterator itr = quelle.neighbors().begin(), stop = quelle.neighbors().end(); for( ; itr != stop; ++itr) if (reachable.count(*itr) == 0) findReachable(**itr, reachable); } 63 /pgc/pr52_144/erreichbar.cpp 107 Algorithmen und Datenstrukturen 2.2.1.5 Topologisches Sortieren mittels Tiefensuche Gegeben: Ein gerichteter, azyklischer Graph (directed acyclic graph, DAG)). Eine topologische Sortierung eines DAG ist eine (lineare) Ordnung aller Knoten, so dass für alle Kanten (u , v) des Graphen gilt: Der knoten (u ) erscheint in der Ordnung vor v . Eine topologische Sortierung eines gerichteten azyklischen Graphen kann man sich als Aufreihung aller seiner Knoten entlang einer horizontalen Linie vorstellen, wobei alle gerichteten Kanten von links nach rechts führen. Bsp.: Gegeben ist der folgende, gerichtete und azyklische Graph: 2 10 1 4 9 6 8 3 7 5 Im vorliegenden Fall zeigt die Ausgabe 7 9 1 2 4 6 3 5 8 10 an, daß eine lineare Ordnung erreicht wurde. Abb.: Man kann feststellen, daß das vorliegende Ergebnis dem Eintragen der vorgängerlosen Elemente in einen Stapel entspricht. Allerdings muß der Stapel in umgekehrter Reihenfolge für den Erhalt der linearen Ordnung interpretiert werden. Der Algorithmus, der zum topologischen Sortieren führt, ist offensichtlich rekursiv. Der folgende Pseudo-Code berechnet eine topologische Sortierung für einen DAG: 1. Starte DFS und berechne die „finishing time“ (f) für alle Knoten 2. Wenn ein Knoten abgearbeitet ist, dann füge ihn am Anfang der (Ordnungs-) Liste ein. 3. Gib die Liste zurück 108 Algorithmen und Datenstrukturen Bsp.: Gegeben ist der folgende gerichtete Graph 0 1 2 3 4 5 6 7 Gib die topologische Sortierung mit Hilfe der Tiefensuche für diesen Graphen an. Jeder Knoten erhält eine Farbe 64. 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 5 6 7 8 4 64 - weiße Knoten wurden noch nicht behandelt - schwarze Knoten wurden vollständig abgearbeitet - Graue Knoten sind noch in Bearbeitung 109 Algorithmen und Datenstrukturen 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 7 8 7 8 7 8 7 8 7 6 8 4 5 6 110 7 Algorithmen und Datenstrukturen 0 1 2 7 3 6 8 4 5 6 7 0 1 2 3 7 6 8 4 5 6 7 0 1 2 3 7 6 8 4 5 6 7 0 1 2 3 6 5 7 8 4 5 6 7 0 1 2 3 6 5 7 8 4 4 4 5 6 111 7 Algorithmen und Datenstrukturen 0 1 2 3 7 6 5 8 3 4 4 5 6 7 0 1 2 3 6 5 7 8 2 3 4 4 5 6 7 0 1 2 3 7 1 6 5 8 2 3 4 4 5 6 7 Eine umgekehrte postorder-Beziehung entspricht einer topologischen Sortierung. Bsp.: a b c d e f g h i Eine in a beginnende Tiefensuche liefert die „postorder“-Reihenfolge: i h e f d a g b c . Die umgekehrte Reihenfolge c b g a d f c h i ist topologisch sortiert. Eine bei c brginnrnde Tiefensuche liefert die „postorder“-Reihenfolge i g c h e f d b a. Die umgekehrte Reihenfolge a b d f e h c g a ist ebenfalls topologisch sortiert. 112 Algorithmen und Datenstrukturen 2.2.2 Breitensuche (breadth-first search) Die Suche beginnt beim Startknoten, danach werden die Nachbarn der Startknoten besucht, danach die Nachbarn der Nachbarn usw. Dadurch kann die Knotenmenge – entsprechend ihrer minimalen Anzahl von Kanten zum Startknoten – in Level unterteilt werden. In Level 0 befindet sich nur der Startknoten, Level 1 besteht aus allen Nachbarn des Startknoten, usw. Algorithmus Eingaben sind ein Graph G=(V,E) und ein Startknoten s. Zu jedem Knoten speichert man einige Daten: color[v]: repräsentiert den aktuellen Bearbeitungsstatus weiß = unbearbeitet / unbesucht schwarz = abgearbeitet (v und alle Nachbarn von v wurden besucht) grau = in Bearbeitung (v wurde besucht, kann aber noch unbesuchte Nachbarn haben p[v]: Vorgänger (predecessor) von v d[v]: Distanz zum Startknoten bzgl. der minimalen Kantenzahl Zum Speichern der Knoten, die in Bearbeitung sind, wird eine Warteschlange Q (FIFO) verwendet. u ∈ V [G ] − {s} do color[u ] ← WHITE d [u ] ← ∞ p[u ] ← NIL color[ s ] ← GRAY d [ s] ← 0 p[ s ] ← NIL Q←s while Q ≠ 0 // u ist erstes Element in Q do u ← first[Q ] // Nachbarn von u for v ∈ Adj[u ] do if color[v] ← WHITE then color[v ] ← GRAY d [v] ← d [u ] + 1 p[v] ← u for each vertex ENQUEUE(Q,v) DEQUE(Q) color[v] ← BLACK // füge in Q ein // lösche erstes Element aus Q Komplexität Das Initialisieren der Arrays dauert insgesamt O( | V | ). Die Operationen auf der Liste (Einfügen und Löschen) und den Arrays brauchen konstante Zeit, insgesamt O( | V | ). Das Durchsuchen der Adjazensliste dauert O( | E | ). Damit ergibt sich eine Gesamtlaufzeit von O( | V | + | E | ). 113 Algorithmen und Datenstrukturen BFS-Baum Die Breitensuche konstruiert einen Baum, der die Zusammenhangskomponente des Startknotens aufspannt. Der Weg im Baum vom Startknoten (Wurzel) zu den Nachfolgern entspricht dem kürzesten Weg bzgl. der Kantenzahl im Graphen. Die Levelnummer des Knotens entspricht der Höhe im Baum. Bsp.: Breitensuche im ungerichteten Graphen Anfangsschritt: für alle v ∈ V : color[v] ← WHITE , d [v ] = ∞ , p[v] ← NIL r s t u v w x y 1. Schritt: Startknoten wird grau markiert, Q = (s), d[s] = 0 r s t u v w x y 2. Schritt: Q = (w,r), Level 0 abgearbeitet r s t u v w x y r s t u v w x y t u 3. Schritt: Q = (r,t,x) 4. Schritt: Q = (t,x,v), Level 1 abgearbeitet r s v w x y 114 Algorithmen und Datenstrukturen 5. Schritt: Q = (x,v,u) r s v w t u x y 6. Schritt: Q = (v,u,y) r s v w t u x y 7. Schritt: Q = (u,y) r s v w r s v w t u x y 8. Schritt: Q = (y) t u x y 9. Schritt: Q = (), Level 3 abgearbeitet r s v w t u x y 115 Algorithmen und Datenstrukturen 10. Schritt: 1 0 2 3 r s t u v w x y 2 1 2 3 2.2.3 Implementierung In der Klasse Graph sind Tiefensuche (Methode traverseDFS) und Breitensuche (traverseBFS) implementiert 65. import java.util.*; /** Graphrepräsentation. */ /** Repräsentiert einen Knoten im Graphen. */ class Vertex { Object key = null; // Knotenbezeichner LinkedList edges = null; // Liste ausgehender Kanten /** Konstruktor */ public Vertex(Object key) { this.key = key; edges = new LinkedList(); } /** Ueberschreibe Object.equals-Methode */ public boolean equals(Object obj) { if (obj == null) return false; if (obj instanceof Vertex) return key.equals(((Vertex) obj).key); else return key.equals(obj); } /** Ueberschreibe Object.hashCode-Methode */ public int hashCode() { return key.hashCode(); } } /** Repraesentiert eine Kante im Graphen. */ class Edge { Vertex dest = null; // Kantenzielknoten int weight = 0; // Kantengewicht /** Konstruktor */ public Edge(Vertex dest, int weight) { this.dest = dest; this.weight=weight; } } class GraphException extends RuntimeException { public GraphException( String name ) { super( name ); } } public class Graph 65 vgl. pr52220 116 Algorithmen und Datenstrukturen { protected Hashtable vertices = null; // enthaelt alle Knoten des Graphen /** Konstruktor */ public Graph() { vertices = new Hashtable(); } /** Fuegt einen Knoten in den Graphen ein. */ public void addVertex(Object key) { if (! vertices.containsKey(key)) // throw new GraphException("Knoten existiert bereits!"); vertices.put(key, new Vertex(key)); } /** Fuegt eine Kante in den Graphen ein. */ public void addEdge(Object src, Object dest, int weight) { Vertex vsrc = (Vertex) vertices.get(src); Vertex vdest = (Vertex) vertices.get(dest); if (vsrc == null) throw new GraphException("Ausgangsknoten existiert nicht!"); if (vdest == null) throw new GraphException("Zielknoten existiert nicht!"); vsrc.edges.add(new Edge(vdest, weight)); } /** Liefert einen Iterator ueber alle Knoten. */ public Iterator getVertices() { return vertices.values().iterator(); } /** Liefert den zum Knotenbezeichner gehoerigen Knoten. */ public Vertex getVertex(Object key) { return (Vertex) vertices.get(key); } /** Liefert die Liste aller erreichbaren Knoten in Breitendurchlauf. */ public List traverseBFS(Object root) { LinkedList list = new LinkedList(); Hashtable d = new Hashtable(); Hashtable pred = new Hashtable(); Hashtable color = new Hashtable(); Integer gray = new Integer(1); Integer black = new Integer(2); Queue q = new Queue(); Vertex v, u = null; Iterator eIter = null; //v = (Vertex)vertices.get(root); color.put(root, gray); d.put(root, new Integer(0)); q.enqueue(root); while (! q.isEmpty()) { v = (Vertex) vertices.get(((Vertex)q.firstEl()).key); eIter = v.edges.iterator(); while(eIter.hasNext()) { u = ((Edge)eIter.next()).dest; // System.out.println(u.key.toString()); if (color.get(u) == null) { color.put(u, gray); d.put(u, new Integer(((Integer)d.get(v)).intValue() + 1)); pred.put(u, v); q.enqueue(u); } } q.dequeue(); list.add(v); color.put(v, black); } 117 Algorithmen und Datenstrukturen return list; } /** Liefert die Liste aller erreichbaren Knoten im Tiefendurchlauf. */ public List traverseDFS(Object root) { // Loesungsvorschlag: H. Auer LinkedList list = new LinkedList(); // Hashtable d = new Hashtable(); // Hashtable pred = new Hashtable(); Hashtable color = new Hashtable(); Integer gray = new Integer(1); Integer black = new Integer(2); Stack s = new Stack(); Vertex v, u = null; Iterator eIter = null; //v = (Vertex)vertices.get(root); color.put(root, gray); // d.put(root, new Integer(0)); s.push(root); while (! s.empty()) { v = (Vertex) vertices.get(((Vertex)s.peek()).key); eIter = v.edges.iterator(); u = null; Vertex w; while(eIter.hasNext()) { w = ((Edge)eIter.next()).dest; // System.out.println(u.key.toString()); if (color.get(w) == null) { u = w; break; } } if (u != null) { color.put(u, gray); s.push(u); } else { v = (Vertex) s.pop(); list.add(v); color.put(v, black); } } return list; } } Anstatt einen Stapel explizit in die Tiefensuche einzubeziehen, kann man Tiefensuche rekursiv so formulieren: LinkedList liste = new LinkedList(); Hashtable color = new Hashtable(); Integer gray = new Integer(1); Integer black = new Integer(2); // Iterator eIter = null; public List traverseDFSrek(Object root) { // LinkedList list = new LinkedList(); // Hashtable d = new Hashtable(); // Hashtable pred = new Hashtable(); // Hashtable color = new Hashtable(); // Integer gray = new Integer(1); // Integer black = new Integer(2); // Stack s = new Stack(); Vertex v = (Vertex) root; Vertex u = null; Iterator eIter = null; //v = (Vertex)vertices.get(root); color.put(root, gray); // d.put(root, new Integer(0)); // s.push(root); // while (! s.empty()) // { 118 Algorithmen und Datenstrukturen // v = (Vertex) vertices.get(((Vertex)s.pop()).key); // liste.add(v); // color.put(v,black); eIter = v.edges.iterator(); while(eIter.hasNext()) { u = ((Edge)eIter.next()).dest; // System.out.println(u.key.toString()); if (color.get(u) == null) { color.put(u, gray); traverseDFSrek(u); } } liste.add(v); // s.pop(); // list.add(v); color.put(v, black); //} return liste; } Abb.: Durchläufe zur Breiten- bzw. Tiefensuche 119 Algorithmen und Datenstrukturen 2.3 Topologischer Sort Sortieren bedeutet Herstellung einer totalen (vollständigen) Ordnung. Es gibt auch Prozesse zur Herstellung von teilweisen Ordnungen 66, d.h.: Es gibt eine Ordnung für einige Paare dieser Elemente, aber nicht für alle. Die Kanten eines gerichteten Graphen bilden eine Halbordnung (die Ordnungsrelation ist nur für solche Knoten definiert, die auf dem gleichen Pfad liegen). y x< y y < z y<z x z Eine strenge Halbordnung ist irreflexiv und transitiv ( x < y ∧ y < z ⇒ x < z ). Topologisches Sortieren bringt die Kanten eines gerichteten, zyklenfreien Graphen in eine Reihenfolge, die mit der Halbordnung verträglich ist. In Graphen für die Netzplantechnik ist die Feststellung partieller Ordnungen zur Berechnung der kürzesten (und längsten) Wege erforderlich. Bsp.: Die folgende Darstellung zeigt einen Netzplan zur Ermittlung des kritischen Wegs. Die einzelnen Knoten des Graphen sind Anfangs- und Endereignispunkte der Tätigkeiten, die an den Kanten angegeben sind. Die Kanten (Pfeile) beschreiben die Vorgangsdauer und sind Abbildungen binärer Relationen. Zwischen den Knoten liegt eine partielle Ordnungsrelation. Bestelle A 50 Tage Baue B 1 Teste B 4 20 Tage Korrigiere Fehler 2 25 Tage 15 Tage 3 Handbucherstellung 60 Tage Abb. : Ein Graph der Netzplantechnik Zur Berechnung des kürzesten Wegs sind folgende Teilfolgen, die partiell geordnet sind, nötig: 1 -> 3: 50 Tage 1->4->2->3: 60 Tage 1->4->3: 80 Tage (kürzester Weg) 66 vgl. 1.2.2.2 120 Algorithmen und Datenstrukturen Eindeutig ist das Bestimmen der topologischen Folgen nicht. Zu dem folgenden Graphen 2 1 4 3 kann es mehrere topologische Folgen geben.Zwei dieser topologischen Folgen sind 1 2 1 3 3 4 2 4 Abb.: Bezugspunkt zur Ableitung eines Algorithmus für den topologischen Sort ist ein gerichteter, azyklischer Graph, z.B. 0 1 1 2 2 3 1 3 4 5 3 2 6 7 Über der Knotenidentifikationen ist zusätzlich die Anzahl der Vorgänger vermerkt. Dieser Zähler wird in die Knotenbeschreibung aufgenommen. Der Zähler soll festhalten, wie viele unmittelbare Vorgänger der Knoten hat. Hat ein Knoten keine Vorgänger, dann wird der Zähler auf 0 gesetzt. Alle Knoten, die den Eingangsgrad 0 aufweisen, werden in einem Stapel oder in einer Schlange abgelegt. Ist z.B. die Schlange nicht leer, wird der Knoten, z.b. v, entfernt. Die Eingangsgrade aller Knoten, die zu v benachbart sind, werden um eine Einheit erniedrigt.. Sobald Eingangsgrade zu 0 werden, werden sie in die Schlange aufgenommen. Die topologische Sortierung ist dann so gestaltet, wie die Knoten aus der Schlange entfernt werden. Damit kann der Algorithmus 67 durch folgende Pseudocode-Darstellung beschrieben werden. 67 Der hier angegebene Algorithmus setzt voraus, dass der Graph in einer Adjazenzliste abgebildet ist, Eingangsgrade berechnet wurdem und zusammen mit den Knoten abgespeichert wurden. 121 Algorithmen und Datenstrukturen void topsort() { Queue<Vertex> q = new Queue<Vertex>(); int zaehler = 0; Vertex v, w; for each v if (v.indegree 68 == 0) q.enqueue(v); while (!q.isEmpty()) { v = q.dequeue(); zaehler++; for each w adjacent to v if (--w.indegree == 0) q.enqueue(w); } if (zaehler >= anzahlKnoten) throw new CycleFoundException; } Abb.: Pseudocode zur Durchführung einer topologischen Sortierung Zur Bestimmung der gewünschten topologischen Folge wird mit den Knotenpunktnummern begonnen, deren Zähler den Wert 0 enthalten. Sie verfügen über keinen Vorgänger und erscheinen in der topologischen Folge an erster Stelle. Schreibtischtest. Die folgende Tabelle soll anhand des folgenden Graphen 1 2 3 4 5 6 7 die Veränderung des Zählers für unmittelbare Vorgänger zeigen und über die Knotenidentifikationen das Ein- bzw. Ausgliedern aus der Schlange (Queue) q. Vertex 1 2 3 4 5 6 7 Enqueue Dequeue 1 0 1 2 3 1 3 2 1 1 2 0 0 1 2 1 3 2 2 2 3 0 0 1 1 0 3 2 5 5 4 0 0 1 0 0 3 1 4 4 5 0 0 0 0 0 2 0 3,7 3 Abschätzung der Laufzeit (Komplexität): O( E + V 6 0 0 0 0 0 1 0 7 ) 7 0 0 0 0 0 0 0 6 6 (, falls Adjazenzlisten benutzt werden. Das ist einleuchtend, da man davon ausgehen kann 68 indegree (Eingangsgrad) ist der Zähler für die jeweilige Anzahl von Vorgängerknoten 122 Algorithmen und Datenstrukturen - der Schleifenkörper wird einmal je Kante ausgeführt - die Schlangenoperationen werden meistens einmal je Knoten ausgeführt - der Zeitbedarf für die Initialisierung ist proportional zur Größe des Graphen 2.4 Transitive Hülle Welche Knoten sind von einem gegeben Knoten aus erreichbar? Gibt es Knoten, von denen aus alle anderen Knoten erreicht werden können? Die Bestimmung der transitiven Hülle ermöglicht die Beantwortung solcher Fragen. S. Warshall hat 1962 einen Algorithmus entwickelt, der die Berechnung der transitiven Hülle über seine Adjazenzmatrix ermöglicht und nach folgenden Regeln arbeitet: Falls ein Weg existiert, um von einem Knoten x nach einem Knoten y zu gelangen, und ein Weg, um vom Knoten y nach z zu gelangen, dann existiert auch ein Weg, um vom Knoten x nach dem Knoten z zu gelangen. Bsp.: Der folgende Graph enthält gestrichelte Kanten, die die Erreichbarkeit markieren A B C D E Die zu diesem Graphen errechnete transitive Hülle beschreibt die folgende Erreichbarkeitsmatrix (Wegematrix): 1 0 0 1 0 1 1 0 1 0 1 1 1 1 1 0 0 0 1 0 1 1 0 1 1 2.4.1 Berechnung der Erreichbarkeit mittels Matrixmultiplikation Eine häufig vorkommende Frage ist die nach dem Zusammenhang zweier Knoten. Das kann man aus der Wegematrix 69 sofort ablesen. Die Wegematrix kann aus Adjazenzmatrix und Kantenfolgen mit Matrixoperationen leicht bestimmt werden: - In einem unbewerteten Graphen mit Adjazenzmatrix aij (r ) : Anzahl der Kantenfolgen von xi nach x j der Länge nach Definition des Matrixprodukts) 69 A beschreibt A = A ⋅ A ⋅ A ⋅ ... ⋅ A = (aij ) vgl. 2.2.1.4 123 (r ) r (Beweis über vollständige Induktion r Algorithmen und Datenstrukturen - Ein gerichteter Graph ist genau dann azyklisch, wenn A r = 0 für ein geeignetes r ≤ n = V , denn in einem zyklischen Graphen gibt es Kantenfolgen beliebiger Länge, also A ≠ 0 und in einem r ayklischen Graphen hat eine Kantenfolge höchstens die Länge n − 1 also - Die Wegematrix W ergibt sich aus der Adjazenzmatrix n −1 ∑A r A r = 0 für r ≥ n . A , indem man in = A 0 + A1 + ... + A n −1 alle von 0 verschiedene Elemente setzt. A 0 ist die Einheitsmatrix. r =0 Folgerung: Anstatt der Tiefensuche zur Ermittlung der Erreichbarkeitsmatrix kann man den bekannten Algorithmus von Stephan Warshall benutzen. Bsp.: Gegeben ist A B C D Bestimme W ⎛0 ⎜ ⎜0 A=⎜ 0 ⎜ ⎜0 ⎝ 1 0 1 0 1 0 0 0 0⎞ ⎟ 1⎟ 0⎟ ⎟ 0 ⎟⎠ ⎛0 ⎜ ⎜0 2 A =⎜ 0 ⎜ ⎜0 ⎝ 1 0 1 0 1 0 0 0 0⎞ ⎛0 ⎟ ⎜ 1⎟ ⎜0 ⋅ 0⎟ ⎜0 ⎟ ⎜ 0 ⎟⎠ ⎜⎝ 0 1 0 1 0 1 0 0 0 0⎞ ⎛0 ⎟ ⎜ 1⎟ ⎜0 = 0⎟ ⎜0 ⎟ ⎜ 0 ⎟⎠ ⎜⎝ 0 0 0 0 0 0 0 0 0 1⎞ ⎟ 0⎟ 1⎟ ⎟ 0 ⎟⎠ ⎛0 ⎜ ⎜0 3 A =⎜ 0 ⎜ ⎜0 ⎝ 1 0 1 0 1 0 0 0 0⎞ ⎛0 ⎟ ⎜ 1⎟ ⎜0 ⋅ 0⎟ ⎜0 ⎟ ⎜ 0 ⎟⎠ ⎜⎝ 0 0 0 0 0 0 0 0 0 1⎞ ⎛0 ⎟ ⎜ 0⎟ ⎜0 = 1⎟ ⎜0 ⎟ ⎜ 0 ⎟⎠ ⎜⎝ 0 0 0 0 0 0 0 0 0 1⎞ ⎟ 0⎟ 0⎟ ⎟ 0 ⎟⎠ ⎛0 ⎜ ⎜0 4 A =⎜ 0 ⎜ ⎜0 ⎝ 1 0 1 0 1 0 0 0 0⎞ ⎛0 ⎟ ⎜ 1⎟ ⎜0 ⋅ 0⎟ ⎜0 ⎟ ⎜ 0 ⎟⎠ ⎜⎝ 0 0 0 0 0 0 0 0 0 1⎞ ⎛0 ⎟ ⎜ 0⎟ ⎜0 = 0⎟ ⎜0 ⎟ ⎜ 0 ⎟⎠ ⎜⎝ 0 0 0 0 0 0 0 0 0 0⎞ ⎟ 0⎟ 0⎟ ⎟ 0 ⎟⎠ 124 Algorithmen und Datenstrukturen ⎛1 ⎜ ⎜0 W = A 0 + A1 + A 2 + A 3 = ⎜ 0 ⎜ ⎜0 ⎝ 1 1 1 0 1 0 1 0 1⎞ ⎟ 1⎟ 1⎟ ⎟ 1⎟⎠ 2.4.2 Warshalls Algorithmus zur Bestimmung der Wegematrix Ist man an der Wegematrix interessiert, so kann man die Zahlen ungleich 0 durch 1 zusammenfassen, indem man die Adjazenzmatrix als logische Matrix auffasst und die Addition und Multiplikation als logische Operatoren ∨ und ∧ 70. void warshall :: transitive() { int i, j, k, n; n = get_n(); for (k = 1; k <= n; k++) for (i = 1; i <= n; i++) for (j = 1; j <= n; j++) adj[i][j] = (adj[i][j] || (adj[i][k] && adj[k][j])); } Durch Umordnung der Schleifenreihenfolge, Initialisierung der Diagonale von der Ergebnismatrix C mit 1 (entspricht der Einheitsmatrix A 0 ) und der Überlagerung von n −1 Ein- und Ausgabematrizen wird direkt ∑A r = A 0 + A1 + ... + A n −1 , die Potenzierung r =0 und die Addition der Potenzen also vermieden. void warshall (Bitmat a) { for (int k = 0; k < n; k++) { // Für alle I und j a[i][j] = 1, falls ein Pfad von I nach j // existiert, der nicht durch // irgendeinen Knoten >= k geht. // beachte, ob es einen Pfad vom Knoten i nach j durch k gibt a[k][k] = 1; // ein Knoten ist von sich selbst erreichbar for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) a[i][j] |=a[i][k] & a[k][j]; // a[i][j] = 1, es gibt einen Pfad von i nach j, der nicht // durch irgendeinen Knoten > k geht } } Der Warshall-Algorithmus sucht nach allen möglichen Tripeln durch Erzeugen von 3 verschachtelten Schleifen mit den Laufvariablen i, j und k. Für jedes Paar (i, j ) wird eine Kante (vi , v j ) hinzugefügt, falls es einen Knoten v k gibt, so dass (vi , v k ) und (v k , v j ) in dem erweiterten Graphen sind. Bsp.: Nimm an die Knoten v und w sind erreichbar über einen direkten Weg eines gerichteten Pfads von 5 Knoten: v = x1 , x 2 , x3 , x 4 , x5 = w . Mit dreifach verschachtelten Schleifen werden alle möglichen Knoten-Tripel betrachtet. Falls die Knoten x1 ...x5 in 70 in C++: | und &, vgl.pr54010.cpp in pr54_1 125 Algorithmen und Datenstrukturen der angegebenen Reihenfolge erscheinen, dann ist x 2 identifiziert als der Knoten x1 , der x1 und x3 verbindet. Das führt zu der neuen Kante (x1 , x3 ) . x1 und x 4 haben x3 als verbundene Knoten, da der Verbindungsweg x1 und x3 in einem früheren Stadium der Iteration gefunden wurde. So wird ( x1 , x 4 ) hinzugefügt, danach x1 und x5 über x 4 mit ( x1 , x5 ) ergänzt. 2.4.3 Floyds Algorithmus zur Bestimmung der Abstandsmatrix Falls in Warhalls Algorithmus die Diagonale mit 0 (Abstand eines Knoten zu sich selbst) initialisiert wird, bei der Verkettung zweier Pfade & durch + (Summe der Pfadlängen) und beim Finden eines neuen Pfads | durch min (Minimum vom neuen und alten Abstand) ersetzen, erhält man Floyds Algorithmus zur Bestimmung der Abstandsmatrix. void floyd :: floydAl() { int i, j, k, n, dist = 0; n = get_n(); for (k = 0; k < n; k++) { adj[k][k] = 0; for (i = 0; i < n; i++) for (j = 0; j < n; j++) // min(adj[i][j],adj[i][k] + adj[k][j]); if ((adj[i][k] != INFINITY) && (adj[k][j] != INFINITY)) if (adj[i][j] > (adj[i][k] + adj[k][j])) { adj[i][j] = (adj[i][k] + adj[k][j]); } else; } } 126 Algorithmen und Datenstrukturen 2.5 Kürzeste Wege 2.5.1 Die Datenstrukturen Graph, Vertex, Edge für die Berechnung kürzester Wege Repräsentation von Knoten 71 // Basic info for each vertex. struct Vertex { string name; // Vertex name vector<Edge> adj; // Adjacent vertices (and costs) double dist; // Cost Vertex *prev; // Previous vertex on shortest path int scratch; // Extra variable used in algorithm // Konstruktor Vertex( const string & nm ) : name( nm ) { reset( ); } void reset( ) { dist = INFINITY; prev = NULL; scratch = 0; } }; Über die Instanzvariable adj wird die Liste der benachbarten Knoten geführt, dist enthält die Kosten, path den Vorgängerknoten vom kürzsten Pfad. Identifiziert wird der Knoten durch einen Namen (Typ: string). Repräsentation von Kanten Die Kanten eines Graphen können Distanzen, Entfernungen, Gewichte, Kosten aufnehmen. Jede Kante eines Graphen wird beschrieben über den Zielknoten und das der Kante zugeordnete Gewicht. struct Edge { // First vertex in edge is implicit Vertex *dest; // Second vertex in edge double cost; // Edge cost // Konstruktor Edge( Vertex *d = 0, double c = 0.0 ) : dest( d ), cost( c ) { } }; Die Klasse Graph zur Aufnahme von Algorithmen zur Berechnung kürzester Pfade class Graph { private: Vertex * getVertex( const string & vertexName ); void printPath( const Vertex & dest ) const; void clearAll( ); typedef map<string,Vertex *,less<string> > vmap; Graph( const Graph & rhs ) { } const Graph & operator= ( const Graph & rhs ) { return *this; } vmap vertexMap; public: 71 vgl. pr22859, pr55_1 127 Algorithmen und Datenstrukturen Graph( ) { } ~Graph( ); void addEdge( const string & sourceName, const string & destName, double cost ); void printPath( const string & destName ) const; void unweighted( const string & startName ); void dijkstra( const string & startName ); void negative( const string & startName ); void acyclic( const string & startName ); }; 2.5.2 Kürzeste Pfade in gerichteten, ungewichteten Graphen. Lösungsbeschreibung. Die ungewichteten Graphen G: folgende Abbildung einen gerichteten, k2 k1 k4 k3 zeigt k5 k7 k6 Abb.: Ausgangspunkt ist ein Startknoten s (Eingabeparameter). Von diesem Knoten aus soll der kürzeste Pfad zu allen anderen Knoten gefunden werden. Es interessiert nur die Anzahl der Kanten, die in dem Pfad enthalten sind. Falls für s der Knoten k3 gewählt wurde, kann zunächst am Knoten k3 der Wert 0 eingetragen werden. Die „0“ wird am Knoten k3 vermerkt. k2 k1 k4 k3 k5 0 k6 k7 Abb.: Der Graph nach Markierung des Startknoten als erreichbar Danach werden alle Knoten aufgesucht, die „eine Einheit“ von s entfernt sind. Im vorliegenden Fall sind das k1 und k6. Dann werden die Knoten aufgesucht, die von s zwei Einheiten entfernt sind. Das geschieht über alle Nachfolger von k1 und k6. Im vorliegenden Fall sind es die Knoten k2 und k4. Aus den benachbarten Knoten von k2 und k4 erkennt man, dass k5 und k7 die kürzesten Pfadlängen von drei Knoten besitzen. Da alle Knoten nun bewertet sind ergibt sich folgenden Bild: 128 Algorithmen und Datenstrukturen k2 k1 1 2 k4 k3 k5 0 2 1 k7 k6 Abb.: Graph nach Ermitteln aller Knoten mit der kürzeszen Pfadlänge 2 Die hier verwendete Strategie ist unter dem Namen „breadth-first search“ 72 bekannt. Die „Breitensuche zuerst“ berücksichtigt zunächst alle Knoten vom Startknoten aus, die am weitesten entfernt liegenden Knoten werden zuerst ausgerechnet. Übertragen der Lösungsbeschreibung in Quellcode. Zu Beginn sollte eine Tabelle mit folgenden Einträgen vorliegen: k k1 k2 k3 k4 k5 k6 k7 bekannt false false false false false false false dk ∞ ∞ 0 ∞ ∞ ∞ ∞ pk 0 0 0 0 0 0 0 Die Tabelle überwacht den Fortschritt beim Ablauf des Algorithmus und führt Buch über gewonnene Pfade. Für jeden Knoten werden 3 Angaben in der Tabelle verwaltet: - die Distanz dk des jeweiligen Knoten zu dem Startknoten s. Zu Beginn sind alle Knoten von s aus unerreichbar ( ∞ ). Ausgenommen ist natürlich s, dessen Pfadlänge ist 0 (k3). - Der Eintrag pk ist eine Variable für die Buchführung (und gibt den Vorgänger im Pfad an). - Der Eintrag unter „bekannt“ wird auf „true“ gesetzt, nachdem der zugehörige Knoten erreicht wurde. Zu Beginn wurden noch keine Knoten erreicht. Das führt zu der folgenden Knotenbeschreibung: struct Vertex { string name; // vector<Edge> adj; // int dist; // Vertex *prev; // Vertex( const string & nm { reset( ); } void reset( ) { dist = INFINITY; prev = Vertex name Adjacent vertices (and costs) Cost Previous vertex on shortest path ) : name( nm ) NULL;} }; Die Grundlage des Algorithmus kann folgendermaßen (in Pseudocode) beschrieben werden: void ungewichtet(Vertex s) 72 vgl. 2.2.2 129 Algorithmen und Datenstrukturen /* /* /* /* 1 2 3 4 */ */ */ */ /* 5 */ /* 6 */ /* 7 */ /* 8 */ /* 9 */ { Vertex v, w; s.dist = 0; for (int aktDist = 0; aktDist < ANZAHL_KNOTEN; aktDist++) for each v if (!v.bekannt && v.dist == aktDist) { v.bekannt = true; for each w benachbart_zu v if (w.dist == INFINITY) { w.dist = aktDist + 1; w.path = v; } } } Der Algorithmus deklariert schrittweise je nach Distanz (d = 0, d = 1, d= 2 ) die Knoten als bekannt und setzt alle benachbarten Knoten von d w = ∞ auf die Distanz d w = d + 1. 2 Die Laufzeit des Algorithmus liegt bei O( V ) . 73 Die Ineffizienz kann beseitigt werden: Es gibt nur zwei unbekannte Knotentypen mit d v ≠ ∞ . Einigen Knoten wurde dv = aktDist zugeordnet, der Rest zeigt dv = aktDist + 1. Man braucht daher nicht die ganze Tabelle, wie es in Zeile 3 und Zeile 4 beschrieben ist, nach geeigneten Knoten zu durchsuchen. Am einfachsten ist es, die Knoten in zwei Schachteln einzuordnen. In die erste Schachtel kommen Knoten, für die gilt: dv = aktDist. In die zweite Schachtel kommen Knoten, für die gilt: dv = aktDist + 1. In Zeile 3 und Zeile 4 kann nun irgendein Knoten aus der ersten Schachtel herausgegriffen werden. In Zeile 9 kann w der zweiten Schachtel hinzugefügt werden. Wenn die äußere for-Schleife terminiert ist die erste Schachtel leer, und die zweite Schachtel kann nach der ersten Schachtel für den nächsten Durchgang übertragen werden. Durch Anwendung einer Schlange (Queue) kann das Verfahren verbessert werden. Am Anfang enthält diese Schlange nur Knoten mit Distanz aktDist. Benachbarte Knoten haben die Distanz aktDist + 1 und werden „hinten“ an die Schlange angefügt. Damit wird garantiert, daß zuerst alle Knoten mit Distanz aktDist bearbeitet werden. Der verbesserte Algorithmus kann in Pseudocode so formuliert werden: /* 1 */ /* 2 */ /* 3 */ /* /* /* /* 4 5 6 7 */ */ */ */ /* 8 */ /* 9 */ /*10 */ 73 void ungewichtet(Vertex s) { Queue q; Vertex v, w; q = new Queue(); q.enqueue(s); s.dist = 0; while (!q.isEmpty()) { v = q.dequeue(); v.bekannt = true; // Wird eigentlich nicht mehr benoetigt for each w benachbart_zu v if (w.dist == INFINITY) { w.dist = v.dist + 1; w.path = v; q.enqueue(w); } } } wegen der beiden verschachtelten for-Schleifen 130 Algorithmen und Datenstrukturen Die folgende Tabelle zeigt, wie sich die Daten der Tabelle während der Ausführung des Algorithmus ändern: Anfangszustand k bekannt dk k1 false ∞ k2 false ∞ k3 false 0 k4 false ∞ k5 false ∞ k6 false ∞ k7 false ∞ Q: k3 pk 0 0 0 0 0 0 0 k3 aus der Schlange bekannt dk pk false 1 k3 false 0 ∞ true 0 0 false 0 ∞ false 0 ∞ false 1 k3 false 0 ∞ Q: k1, k6 k1 aus der Schlange bekannt dk pk true 1 k3 false 2 k1 true 0 0 false 2 k1 false 0 ∞ false 1 k3 false 0 ∞ Q: k6, k2, k4 k6 aus der Schlange bekannt dk pk true 1 k3 false 2 k1 true 0 0 false 2 k1 false 0 ∞ true 1 k3 false 0 ∞ Q: k2, k4 k2 aus der Schlange k bekannt dk k1 true 1 k2 true 2 k3 true 0 k4 false 2 k5 false 3 k6 true 1 k7 false ∞ Q: k4, k5 pk k3 k1 0 k1 k2 k3 0 k4 aus der Schlange bekannt dk pk true 1 k3 true 2 k1 true 0 0 true 2 k1 false 3 k2 true 1 k3 false 3 k4 Q: k5, k7 k5 aus der Schlange bekannt dk pk true 1 k3 true 2 k1 true 0 0 true 2 k1 true 3 k2 true 1 k3 false 3 k4 Q: k7 K7 aus der Schlange bekannt dk pk true 1 k3 true 2 k1 true 0 0 true 2 k1 true 3 k2 true 1 k3 true 3 k4 Q: leer Abb.:Veränderung der Daten während der Ausführung des Algorithmus zum kürzesten Pfad Implementierung 74. Die Klasse Graph implementiert die Methode unweighted(). Die Schlange in dieser Liste wird über eine LinkedList mit den Methoden removeFirst() und addLast() simuliert. // Single-source unweighted shortest-path algorithm. void Graph::unweighted( const string & startName ) { vmap::iterator itr = vertexMap.find( startName ); if( itr == vertexMap.end( ) ) throw GraphException( startName + " is not a vertex in this graph" ); clearAll( ); Vertex *start = (*itr).second; list<Vertex *> q; // Schlange q.push_back( start ); start->dist = 0; while( !q.empty( ) ) { Vertex *v = q.front( ); q.pop_front( ); for( int i = 0; i < v->adj.size( ); i++ ) { Edge e = v->adj[ i ]; Vertex *w = e.dest; if( w->dist == INFINITY ) { w->dist = v->dist + 1; w->prev = v; q.push_back( w ); } } } } 74 vgl.: pr22859 131 Algorithmen und Datenstrukturen 2.5.3 Berechnung der kürzesten Pfadlängen in gewichteten Graphen (Algorithmus von Dijkstra) Gegeben ist ein gerichteter Graph G mit Knotenmenge V und Kantenmenge E. Jede Kante e hat eine nichtnegative Länge, Außerdem ist ein Knoten s (Standort) gegeben. Gesucht ist der kürzeste Weg von s nach v für jeden Knoten v ∈ V des Graphen. Vorausgesetzt ist, dass jeder Knoten v ∈ V durch wenigstens einen Weg von s aus erreichbar ist. Für den kürzesten Weg soll die Länge ermittelt werden. Lösungsbeschreibung. Die Lösung stützt sich auf die Berechnung der kürzesten Pfadlängen in ungewichteten Graphen 75 ab. Im Algorithmus von Dijkstra werden auch die Daten über „bekannt“, dv (kürzeste Pfadlänge) und pv (letzter Knoten, der eine Veränderung von dv verursacht hat) verwaltet. Es wird eine Menge S von Knoten betrachtet und schrittweise vergrößert, für die der kürzeste Weg von s aus bereits bekannt ist. Jedem Knoten v ∈ V wird ein Distanz d(v) zugeordnet. Anfangs ist d(s) = 0 und für alle von s verschiedenen Knoten v ∈ V ist d v = ∞ , und S ist leer. Dann wird S nach dem Prinzip "Knoten mit kürzester Distanz von s zuerst" schrittweise folgendermaßen vergrößert, bis S alle Knoten V des Graphen enthält: 1. Wähle Knoten v ∈ V S mit minimaler Distanz 2. Nimm v zu S hinzu 3. Für jede Kante vw von einem Knoten v zu einem Knoten w ∉ S , ersetze d(w) durch min({d ( w), d (v) + c( w, v)}) Der folgende Graph 2 k2 k1 4 1 3 10 2 2 k4 k3 5 8 k5 4 6 k7 k6 1 Abb.: Graph nach Ermitteln aller Knoten mit der kürzeszen Pfadlänge 2 mit der Knotenbeschreibung struct Vertex { string name; vector<Edge> adj; double dist; Vertex *prev; int scratch; // Konstruktor Vertex( const string & { reset( ); } 75 // // // // // Vertex name Adjacent vertices (and costs) Cost Previous vertex on shortest path Extra variable used in algorithm nm ) : name( nm ) vgl. 2.5.2 132 Algorithmen und Datenstrukturen void reset( ) { dist = INFINITY; prev = NULL;/* pos = NULL;*/ scratch = 0; } }; führt zu der folgende Initialisierung: k k1 k2 k3 k4 k5 k6 k7 bekannt false false false false false false false dk 0 ∞ ∞ ∞ ∞ ∞ ∞ pk null null null null null null null Abb.: Anfangszustand der Tabelle mit den Daten für den Algorithmus von Dijkstra Der erste Knoten (Start) ist der Knoten k1 mit Pfadlänge 0. Nachdem k1 bekannt ist, ergibt sich folgendes Bild: k k1 k2 k3 k4 k5 k6 k7 bekannt true false false false false false false dk 0 2 ∞ 1 ∞ ∞ ∞ pk null k1 null k1 null null null Abb.: Zustand der Tabelle nach „k1 ist bekannt“ „k1“ besitzt die Nachbarknoten: k2 und k4. „k4“ wird gewählt und als bekannt markiert. Die Knoten k3, k5, k6 und k7 sind jetzt die benachbarten Knoten. k k1 k2 k3 k4 k5 k6 k7 bekannt true false false true false false false dk 0 2 3 1 3 9 5 pk null k1 k4 k1 k4 k4 k4 Abb.: Zustand der Tabelle nach „k4 ist bekannt“ „k2“ wird gewählt. „k4“ ist benachbart, aber schon bekannt. „k5“ ist ebenfalls benachbart, wir aber nicht ausgerichtet, da die Kosten von „k2“ aus 2 +10 = 12 sind und ein Pfad der Länge 3 schon bekannt ist k k1 k2 k3 k4 k5 k6 k7 bekannt true true false true false false false dk 0 2 3 1 3 9 5 pk null k1 k4 k1 k4 k4 k4 Abb.: Zustand der Tabelle nach „k2 ist bekannt“ 133 Algorithmen und Datenstrukturen Der nächste ausgewählte Knoten ist „k5“ (ohne Ausrichtungen), danach wird k3 gewählt. Die Wahl von „k3“ bewirkt die Ausrichtung von „k6“ k k1 k2 k3 k4 k5 k6 k7 bekannt true true true true true false false dk 0 2 3 1 3 8 5 pk null k1 k4 k1 k4 k3 k4 Abb.: Zustand der Tabelle „k5 ist bekannt“ und (anschließend) „k3 ist bekannt“. „k7“ wird gewählt. Daraus resultiert folgende Tabelle: k k1 k2 k3 k4 k5 k6 k7 bekannt true true true true true false true dk 0 2 3 1 3 6 5 pk null k1 k4 k1 k4 k7 k4 Abb.: Zustand der Tabelle „k7 ist bekannt“. Schließlich bleibt nur noch k6 übrig. Das ergibt dann die folgende Abschlußtabelle: k k1 k2 k3 k4 k5 k6 k7 bekannt true true true true true true true dk 0 2 3 1 3 6 5 pk null k1 k4 k1 k4 k7 k4 Abb.: Zustand der Tabelle nach „k6 ist bekannt“. Der Algorithmus, der diese Tabellen folgendermaßen beschrieben werden: berechnet, kann void dijkstra(Vertex s) { Vertex v, w; /* 1 */ s.dist = 0; /* 2 */ for(; ;) { /* 3 */ v = kleinster_unbekannter_Distanzknoten; /* 4 */ if (v == null) /* 5 */ break; /* 6 */ v.bekannt = true; /* 7 */ for each w benachbart_zu v /* 8 */ if (!w.bekannt) /* 9 */ if (v.dist + cvw < w.dist) { /* 10 */ w.dist = v.dist + cvw; /* 11 */ w.pfad = v; 134 (in Pseudocode) Algorithmen und Datenstrukturen } } } Die Laufzeit des Algorithmus resultiert aus dem Aufsuchen aller Knoten (in den beiden for-Schleifen) und im Aufsuchen der Kanten (c(vw)) (in der inneren forSchleife): O(|E| + |V|2) = O(|V|2). Ein Problem des vorstehenden Agorithmus ist das Durchsuchen der Knotenmenge nach der kleinsten Distanz 76. Man kann das wiederholte Bestimmen der kleinsten Distanz einer prioritätsgesteuerten Warteschlange übertragen. Der Leistungsaufwand beträgt dann O(|E| log(|V)+|V| log(|V|)). Der Algorithmus (in Pseudocode) könnte so aussehen: /* 1 */ for_all v ∈ V do d (v ) = ∞ /* 2 */ d ( s ) = 0 ; S = 0 /* 3 */ pq = new PriorityQueue(); // Vorrangwarteschlange für Knoten in V /* 4 */ while pq ≠ 0 do /* pq = V S */ /* 5 */ pq.delete _ min() /* 6 */ S = S ∪ {v} /* 7 */ for _ all (v, w) ∈ E do /* 8 */ if d (v) + c (v, w) < d ( w) pq.decrease _ key ( w, d (v) + c(v, w)) /* 9 */ Entferne (v, w) aus E /*10*/ end while Der “update” wird hier durch eine Operation für eine Priority Queue “decrease_key, Herabsetzen eines Schlüssels um einen vorgegebenen Wert” vollzogen. „decrease_key“ schränkt die Zeitbestimmung für das Minimum auf O(log V ) ein. Damit ergibt sich eine Laufzeit von O( V log V + E log V ) = O( E log V ) prioritätsgesteuerte Warteschlange: Binary-Heaps sind eine mögliche Implementation für Priority Queues. Ein Heap mit N Schlüsseln erlaubt das Einfügen eines neuen Elements und das Entfernen des Minimums in O(log N ) Schritten. Da das Minimum stets am Anfang des Heap steht, kann der Zugriff auf das kleinste Element stets in konstanter Zeit ausgeführt werden. In Java (seit der Version 1.5) und C++ gibt es die Klasse PriorityQueue, die sich auf einen Binary Heap abstützt. In der Priority-Queue werden für den Algorithmus von Dijkstra Datensätze von folgendem Typ abgelegt. // Structure stored in priority queue for Dijkstra's algorithm. struct Path { Vertex *dest; // w double cost; // d(w) Path( Vertex *d = 0, double c = 0.0 ) : dest( d ), cost( c ) { } bool operator> ( const Path & rhs ) const { return cost > rhs.cost; } bool operator< ( const Path & rhs ) const { return cost < rhs.cost; } }; 76 v = kleinster_unbekannter_Distanzknoten 135 Algorithmen und Datenstrukturen Implementierung. 77 void Graph::dijkstra( const string & startName ) { priority_queue<Path, vector<Path>, greater<Path> > pq; Path vrec; // Stores the result of a deleteMin vmap::iterator itr = vertexMap.find( startName ); clearAll( ); if( itr == vertexMap.end( ) ) throw GraphException( startName + " is not a vertex in this graph" ); Vertex *start = (*itr).second; pq.push( Path( start, 0 ) ); start->dist = 0; for( int nodesSeen = 0; nodesSeen < vertexMap.size( ); nodesSeen++ ) { do // Find an unvisited vertex { if( pq.empty( ) ) return; vrec = pq.top( ); pq.pop( ); } while( vrec.dest->scratch != 0 ); Vertex *v = vrec.dest; v->scratch = 1; for( int i = 0; i < v->adj.size( ); i++ ) { Edge e = v->adj[ i ]; Vertex *w = e.dest; double cvw = e.cost; if( cvw < 0 ) throw GraphException( "Graph has negative edges" ); if( w->dist > v->dist + cvw ) { w->dist = v->dist + cvw; w->prev = v; pq.push( Path( w, w->dist ) ); } } } } Der Dijkstra-Algorithmus kann mit unterschiedlichen Datenstrukturen eingesetzt warden, z.B. auch mit Fibonacci-Heaps. Die Laufzeit lässt sich dadurch auf O(V log V ) einschränken. Da Fibonacci-Heaps einen gewissen Betrag an „Overhead“ ergeben, ist erreichte Vorteil zweifellhaft. Nachteile. Der Algorithmus von Dijkstra hat zwei Nachteile: Es wurden nur die kürzesten Verbindungen von einem ausgezeichneten Startknoten zu einem anderen Knoten bestimmt. Die Gewichte aller Kanten müssen positiv sein 77 vgl. pr22859, pr55_1 136 Algorithmen und Datenstrukturen 2.5.4 Berechnung der kürzesten Pfadlängen in gewichteten Graphen mit negativen Kosten Falls ein Graph Kanten mit negativen Kosten enthält, arbeitet der DijkstraAlgorithmus nicht korrekt. Das Problem ist, falls ein Knoten u als bekannt dekariert ist, die Möglichkeit besteht, dass es einen Weg zurück nach u von einem Knoten v mit negativem Resultat gibt. Bsp.: Der folgende Graph besitzt einen negativen Zyklus: 2 k2 k1 4 1 3 -10 1 k4 k3 k5 2 2 6 6 k7 k6 1 Bei der Berechnung der Kosten von k5 nach k4 besitzen die direkt angegebenen Kosten den Wert 1. Es existiert aber noch ein kürzerer Pfad k5, k4, k2, k5, k4 mit Kostenwert -5. Offensichtlich gelangt man hier in einen (negativen Kosten-) Zyklus, der sogar mehrfach durchlaufen werden kann. So entstehen dann immer noch weitere kürzere Pfade. Ein kürzester Pfad zwischen k4 und k5 ist nicht definiert. Abb.: Graph mit negativem Zyklus Eine mögliche, aber umständliche Lösung ist: Addition einer Konstanten Δ zu jedem Kantengewicht. Die Konstante wird so groß gewählt, dass keine negativen Kanten nach der Addition vorliegen. Besser ist der folgende Algorithmus (in Pseudocode): void negativ_gewichtet(Vertex s) { Queue q; Vertex v, w; /* 1*/ q = new Queue(); // for each v v.dist = INFINITY; s.dist = 0; /* 2*/ q.enqueue(s); // Einreihen des Startknoten s /* 3*/ while (!q.isEmpty()) { /* 4 */ v = q.dequeue(); /* 5 */ for each w adjazent to v /* 6 */ if (v.dist + cvw < w.dist) { // Update w /* 7 */ w.dist = v.dist + cvw; /* 8 */ w.path = v; /* 9 */ if (w ist nicht in q) /* 10*/ q.enqueue(w); } } } 137 Algorithmen und Datenstrukturen Komplexität. Jeder Knoten kann etwa |V| mal aus der Warteschlange entnommen werden, die Laufzeit ist somit O( | E | ⋅ | V | ) (Anstieg gegenüber dem Djikstra Algorithmus), falls Adjazenslisten benutzt werden. Falls negative "Kosten-Zyklen" vorliegen, dann gelangt der Algorithmus in eine Endlosschleife. Implementierung 78 void Graph::negative( const string & startName ) { vmap::iterator itr = vertexMap.find( startName ); if( itr == vertexMap.end( ) ) throw GraphException( startName + " is not a vertex in this graph" ); clearAll( ); Vertex *start = (*itr).second; list<Vertex *> q; q.push_back( start ); start->dist = 0; start->scratch++; while( !q.empty( ) ) { Vertex *v = q.front( ); q.pop_front( ); if( v->scratch++ > 2 * vertexMap.size( ) ) throw GraphException( "Negative cycle detected" ); for( int i = 0; i < v->adj.size( ); i++ ) { Edge e = v->adj[ i ]; Vertex *w = e.dest; double cvw = e.cost; if( w->dist > v->dist + cvw ) { w->dist = v->dist + cvw; w->prev = v; // Enqueue only if not already on the queue if( w->scratch++ % 2 == 0 ) q.push_back( w ); else w->scratch--; // undo the push } } } } 2.5.5 Berechnung der kürzesten Pfadlängen in gewichteten, azyklischen Graphen Falls bekannt ist, dass der Graph azyklisch ist, kann der Dijkstra-Algorithmus verbessert werden: Die Knoten des Graphen werden in topologischer Reihenfolge (partielle Ordnung 79) ausgewählt. Die Auswahl der Knoten in topologischer Folge garantiert: die Distanz dv kann nicht weiter erniedrigt werden. void Graph::acyclic( const string & startName ) { vmap::iterator itr = vertexMap.find( startName ); if( itr == vertexMap.end( ) ) throw GraphException( startName + " is not a vertex in this graph" ); clearAll( ); Vertex *start = (*itr).second; list<Vertex *> q; start->dist = 0; 78 79 vgl. pr22859, pr55_1 vgl. 1.3.3 138 Algorithmen und Datenstrukturen // Compute the indegrees for( itr = vertexMap.begin( ); itr != vertexMap.end( ); ++itr ) { Vertex *v = (*itr).second; for( int i = 0; i < v->adj.size( ); i++ ) v->adj[ i ].dest->scratch++; } // Enqueue vertices of indegree zero for( itr = vertexMap.begin( ); itr != vertexMap.end( ); ++itr ) { Vertex *v = (*itr).second; if( v->scratch == 0 ) q.push_back( v ); } int iterations; for( iterations = 0; !q.empty( ); iterations++ ) { Vertex *v = q.front( ); q.pop_front( ); for( int i = 0; i < v->adj.size( ); i++ ) { Edge e = v->adj[ i ]; Vertex *w = e.dest; double cvw = e.cost; if( --w->scratch == 0 ) q.push_back( w ); if( v->dist == INFINITY ) continue; if( w->dist > v->dist + cvw ) { w->dist = v->dist + cvw; w->prev = v; } } } if( iterations != vertexMap.size( ) ) throw GraphException( "Graph has a cycle!" ); } Eie bedeutende Anwendung azyklischer Graphen ist die „critical path analysis“. Benutzt werden solche Graphen für die Zeitplanung bzw. Kapazitätsülanung in Projekten. Man verwendet Aktivitätsgraphen (activity node graph, Kosten sind den Knoten zugeordnet, Kanten zeigen Anordnungsbeziehungen) und ereignisorientierte Graphen. 139 Algorithmen und Datenstrukturen 2.5.6 All pairs shorted Path Der Algorithmus von Floyd berechnet die kürzesten Verbindungen von allen Knoten zu allen anderen Knoten Zugrundeliegende Idee: Es werden alle direkten Verbindungen zweier Knoten als die "billigste" Veränderung der beiden Knoten verwendet. Die billigste Verbindung ist entweder die direkte Verbindung oder aber zwei Wege über einen Mittelknoten. Die Komplexität des Verfahrens von Floyd beträgt O( | V 3 | ). Implementierung 80: public static final int NO_EDGE = 0; public int [][] floyd(int [][] a, int start) { if (a == null || a.length == 0) return null; // Matrix ist leer int i, j, x, n = a.length; // i = Start, j = Ende, x = Zwischenknoten // Anlegen einer neuen Adjazenzmatrix int [][] c = new int[n][n]; // Kopiere alle Werte aus der Matrix: Am Anfang ist die // direkte Verbindung die einzige und daher auch die // billigste for (i = 0; i < n; i++) for (j = 0; j < n; j++) { c[i][j] = a[i][j]; // direkte Kanten kopieren } // Suche fuer Knoten x nach Wegen ueber x, d.h. i -> x, x -> j for (x = 0; x < n; x++) for (i = 0; i < n; i++) if (c[i][x] != NO_EDGE) // gibt es einen Weg i -> x for (j = 0; j < n; j++) if (c[x][j] != NO_EDGE) if (c[i][j] == NO_EDGE // noch kein Weg i -> j || (c[i][x] + c[x][j] < c[i][j])) // i->x->j billiger { if (i == j) continue; c[i][j] = c[i][x] + c[x][j]; } return c; } Der Test dieses Algorithmus führt zu folgendem Resultat: 80 pr52221 bzw. pr54_1, pr54020.cpp 140 Algorithmen und Datenstrukturen 141 Algorithmen und Datenstrukturen 2.6 Minimale Spannbäume Anwendung. Minimale spannende Bäume sind z.B. für folgende Fragestellung interessant: "Finde die billigste Möglichkeit alle Punkte zu verbinden". Diese Frage stellt sich bspw. für elektrische Schaltungen, Flugrouten und Autostrecken. Problemstellung. Zu einem zusammenhängenden Graphen soll ein Spannbaum (aufspannender Baum) mit minimalem Kantengewicht (minimale Gesamtlänge) bestimmt werden. Der minimale Spannbaum muß nicht eindeutig sein, zu jedem gewichteten Graphen gibt es aber mindestens einen minimalen spannenden Baum. 2.6.1 Der Algorithmus von Prim Das einfachste Verfahren zur Erzeugung eines minimale spannenden Baums stammt von Prim aus dem Jahre 1952. In diesem Verfahren wird zu dem bereits vorhandenen Teilgraph immer die billigste Kante hinzugefügt, die den Teilgraph mit einem bisher noch nicht besuchten Knoten verbindet. Aufgabe. Berechne einen spannenden Baum mit minimalen Kosten (minimum spanning tree). Lösungsbeschreibung. Der folgende Graph 2 k2 k1 4 1 3 10 2 7 k4 k3 5 8 k5 4 6 k7 k6 1 besitzt folgenden minimale Spannbaum: 2 k2 k1 1 2 k4 k3 k5 4 6 k7 k6 1 Abb.: Die Anzahl der Kanten in einem minimal spannenden Baum ist |V| - 1 (Anzahl der Knoten – 1). Der minimal spannende Baum ist 142 Algorithmen und Datenstrukturen - ein Baum, der keine Zyklen besitzt. - spannend, da er jeden Knoten abdeckt. - ein Minimum. Der Algorithmus von Prim arbeitet stufenweise. Auf jeder Stufe wird ein Knoten ausgewählt. Die Kanten auf seine nachfolgenden Knoten werden dann untersucht. Die Untersuchung folgt nach den Vorschriften des Dijkstra-Algorithmus. Es gibt nur eine Ausnahme hinsichtlich der Ermittlung der Distanz: d w = min(d v , c vw ) Die Ausgangssituation zeigt folgende Tabelle: k k1 k2 k3 k4 k5 k6 k7 bekannt false false false false false false false dv 0 ∞ ∞ ∞ ∞ ∞ ∞ pv null null null null null null null Abb.: Ausgangssituation „k1“ wird ausgewählt, „k2, k3, k4 sind zu k1 benachbart“. Das führt zur folgenden Tabelle: k k1 k2 k3 k4 k5 k6 k7 bekannt true false false false false false false dv 0 2 4 1 ∞ ∞ ∞ pv null k1 k1 k1 null null null Abb.: Die Tabelle im Zustand „k1 ist bekannt“ Der nächste Knoten, der ausgewählt wird ist k4. Jeder Knoten ist zu k4 benachbart. Ausgenommen ist k1, da dieser Knoten „bekannt“ ist. k2 bleibt unverändert, denn die „Kosten“ von k4 nach k2 sind 3, bei k2 ist 2 eingetragen. Der Rest wird, wie die folgende Tabelle zeigt, verändert: k k1 k2 k3 k4 k5 k6 k7 bekannt true false false true false false false dv 0 2 2 1 7 8 4 pv null k1 k4 k1 k4 k4 k4 Abb.: Die Tabelle im Zustand „k4 ist bekannt“ Der nächste Knoten, der ausgewählt wird, ist k2. Das zeigt keine Auswirkungen. Dann wird k3 gewählt. Das bewirkt eine Veränderung der Distanz zu k6. k k1 k2 bekannt true true dv 0 2 pv null k1 143 Algorithmen und Datenstrukturen k3 k4 k5 k6 k7 true true false false false 2 1 7 5 4 k4 k1 k4 k3 k4 Abb.: Tabelle mit Zustand „k2 ist bekannt“ und (anschließend) mit dem Zustand „k3 ist bekannt“ Es folgt die Wahl des Knoten k7, was die Ausrichtung von k6 und k5 bewirkt: k k1 k2 k3 k4 k5 k6 k7 Bekannt true true true true false false true dv 0 2 2 1 6 1 4 pv null k1 k4 k1 k7 k7 k4 Abb.: Tabelle mit Zustand „k7 ist bekannt“ Jetzt werden noch k6 und dann k5 bestimmt. Die Tabelle nimmt danach folgende Gestalt an: k k1 k2 k3 k4 k5 k6 k7 Bekannt true true true true true true true dv 0 2 2 1 6 1 4 pv null k1 k4 k1 k7 k7 k4 Abb.: Tabelle mit Zustand „k6 ist bekannt“ und (anschließend) „k5 ist bekannt“ Die Tabelle zeigt, daß folgende Kanten den minimal spannenden Baum bilden: (k2,k1),k3,k4)(k4,k1),(k5,k7),(k6,k7),(k7,k4) Der Algorithmus von Prim zeigt weitgehende Übereinstimmung mit dem Algorithmus von Dijkstra 81. Komplexität: Die Laufzeit ist O(|V|2) Implementierung. MinimalSpanningTree.java 82 81 82 vgl. pr53330 144 Algorithmen und Datenstrukturen Abb. 2.6.2 Der Algorithmus von Kruskal Beschreibung des Algorithmus. 1. Markiere alle Knoten als nicht besucht. 2. Erstelle eine neue Adjazenzmatrix, in der die tatsächlich verwendeten Kanten eingetragen werden. Zu Beginn sind alle Elemente 0. 3. Bestimme die billigste Kante von einem Knote i zu einem Knoten j, die entweder zwei bisher nicht erreichte Knoten verbindet, einen nicht nicht erreichten mit einem erreichten oder zwei bisher unverknüpfte Teilgraphen verbindet. Falls beide Knoten bereits erreicht wurden, kann diese Kante ignoriert werden, da durch sie ein Zyklus entstehen würde. 4. Markiere i und j als erreicht und setze minimalTree[i][j]= g, wobei g das Gewicht der Kante (i,j) ist. 5. Fahre mit Schritt 3 fort, bis alle Knoten erreicht sind Bsp.: Gegeben ist 2 k2 k1 4 1 3 10 2 7 k4 k3 5 8 k5 4 6 k7 k6 1 Bestimme den minimale spannenden Baum nach dem Algorithmus von Kruskal: 145 Algorithmen und Datenstrukturen 1. Schritt k2 k1 1 k4 k3 k5 k7 k6 2. Schritt k2 k1 1 k4 k3 k5 k7 k6 1 3. Schritt 2 k2 k1 1 k4 k3 k5 k7 k6 1 4. Schritt 2 k2 k1 1 2 k4 k3 k5 k7 k6 1 146 Algorithmen und Datenstrukturen 5. Schritt 2 k2 k1 1 2 k4 k3 k5 4 k7 k6 1 6. Schritt 2 k2 k1 1 2 k4 k3 k5 4 6 k7 k6 1 Abb. Lösungsschritte zum Demonstrationsbeispiel Prinzip. Auswahl der Kanten in der Reihenfolge kleinster Gewichte mit Aufnahme einer Kante, falls sie nicht einen Zyklus verursacht. Implementierung. MinimalSpanningTree.java 83 83 vgl. pr53331 147 Algorithmen und Datenstrukturen 2.7 Netzwerkflüsse 2.7.1 Maximale Flüsse 27.1.1 Netzwerk und maximaler Fluß Ein Netzwerk N = (V , E , s, t , c ) ist - - ein gerichteter Graph G = (V , E ) ohne Mehrfachkanten mit zwei ausgezeichneten Knoten (Quelle und Senke) o Quelle s aus V . Die Quelle ist ein Knoten mit Eingangsgrad 0. o Senke t aus V . Die Senke ist ein Knoten mit Ausgangsgrad 0. Mit einer Kapazitätsfunktion c , die jeder Kante e aus E eine nicht-negative reelwertige Kapazität c(e) zuweist Ein Restnetzwerk (Residualnetzwerk) von N ist ein Netzwerk N f = (V , E , s, t , c f ) in dem die Kapazitäten um den Fluß durch diese Kanten vermindert werden. Jedes Restnetzwerk ist ein Teilgraph G f des Netzwerks. c=1 c=1 c=2 c=3 s t c=4 c=0 c=3 c=2 Ein s-t-Fluß ist eine Funktion f , die jeder Kante e im Netzwerk einen nichtnegativen, reellen Flusswert f (e) zuweist, der einer Reihe von Bedingungen genügt. c=1 f=0 c=1 f=0 c=2 f=0 c=3 f=1 s t c=4 c=0 f=1 c=3 f=2 c=2 f=1 0 ≤ f (e) ≤ c(e), ∀e ∈ E f (e) − ∑ f (e) - Kapazitätsbeschränkung - Flusserhaltung ∑ e inc v e aus v Wert vom Fluß im Netzwerk: val ( f ) = ∑ f (s, y) − ∑ f ( y, s) . Der Wert eines Flusses y∈s + y∈s − ist die Summe der lokalen Flüsse aus der Quelle (= Summe aller lokalen Flüsse in die Senke) 148 Algorithmen und Datenstrukturen Der Fluß mit maximalem Wert heißt maximaler Fluß: Gegeben ist ein gerichteter, gewichteter Graph. Ein Fluß ist in diesem Graphen eine Funktion f : E → ℜ mit 0 ≤ f (i, j ) ≤ c(i, j ) 2. Für alle i ∈ V {s, t} gilt 1. ∑ f (a, i) = ∑ f (i, b) a∈V ( i ) b∈N ( i ) V (i ) : alle Vorgänger von i N (i ) : alle Nachfolger von i Gesucht ist der maximale Gesamtfluß: F = ∑ f ( s, b) = ∑ f ( a, t ) Der maximale Fluß im Netzwerk hat genau einen Wert: den minimalen Schnitt. Die folgenden Aussagen sind äquivalent: f ist der maximale Fluß in N bzw. G - das Restnetzwerk N f bzw. G f enthält keinen Verbesserungspfad - - f = c (S , T ) gilt für irgendeinen Schnitt (S , T ) 149 Algorithmen und Datenstrukturen 2.7.1.2 Optimieren und Finden augmentierender Pfade (Erweiterter Weg) Ein augmentierender Pfad bzgl. eines Flusses f ist ein Pfad von der Quelle zur Senke ohne Berücksichtigung der Kantenrichtung wobei für jede Kante (v,w) gilt: entweder Vorwärtskante: (v, w) ∈ E und f (v, w) < c(v, w) oder Rückwärtskante: ( w, v ) ∈ E und f ( w, v) > 0 Längs eines so erweiternden (augmentierenden) Wegs kann der Fluß vergrößert werden, indem man durch Vorwärtskanten zusätzliche Einheiten fließen lässt oder den Fluß durch Rückwärtskanten verringert. Beides ist nach der Definition des erweiternden Wegs möglich. Bsp. für Flussoptimierung mit einem augmentierenden Pfad 4/3 5/5 3/2 3/3 u 7/3 4/2 6/5 1/1 7/7 4/3 6/3 5/5 w Auf dem augmentierenden Pfad wäre bis zum Knoten u noch Platz für 2 Einheiten (Restkapazität). Wird der „Hahn“ von w nach u etwas zugedreht, fließen insgesamt 2 Einheiten mehr 4/3 5/5 3/2 3/3 u 7/5 4/4 6/5 1/1 7/7 4/1 6/5 5/5 w Optimierung am augmentierenden Pfad ci −1 / f i −1 ci / f i ci +1 / f i +1 nur Vorwärtskanten: Erhöhe Fluß um minimale Restkapazität (slack) min(c k − f k ) über alle Pfadkanten k. 150 Algorithmen und Datenstrukturen Wenn Rückwärtskanten vorkommen, dann: Vorher cd / f d ca / f a cb / f b Nachher cd / f d ca + x / f a cb − x / f b cc / f c ce / f e cc + x / f c cb / f b Hier gilt: x = min (c v − f v ) über alle Vorwärtskanten v v oder x = min c r über alle Rückwärtskanten r (das kleinere) r Finden eines augmentierenden Pfads Markiere Quelle s wiederhole wenn (v, w) existiert mit v markiert und f (v, w) < c(v, w) markiere w wenn (w, v ) existiert mit v markiert und f ( w, v) > 0 markiere w solange in der Schleife neue Knoten markiert werden. Der Algorithmus markiert genau die Knoten, die von der Quelle aus mit augmentierenden Pfaden erreichbar sind. Ist am Ende die Senke markiert, dann ist ein augmentierender Pfad durch das Netzwerk gefunden. Längs eines so erweiterten Wegs kann der Fluß vergrößert werden, indem man durch Vorwärtskanten zusätzliche Einheiten fließen lässt oder man den Fluß in den Rückwärtskanten verringert. Beides ist nach der Definition des 151 Algorithmen und Datenstrukturen 2.7.1.2 Algorithmus für optimalen Fluss Ford-Fulkerson-Algorithmus Gegeben: ein Netzwerk mit den Kapazitäten c : E → ℜ ≥0 und 2 Knoten s, t 1. Initialisiere f mit 0 2. solange ein augmentierender Weg P von s nach t im Restnetzwerk G f existiert 2a. Konstruktion bzw. Aktualisierung des Restnetzes G f 2b. Finden eines augmentierenden Wegs 3. für jede Kante e auf P erhöhe den Fluß um c f (P ) Bsp. zum Ford-Fulkerson-Algorithmus: (a) Gegeben 12 16 20 s t 10 4 7 13 9 4 14 (b) augmentierender Weg mit Kapazität 7 12/7 16/7 20/7 s t 10/7 4 7/7 13 9/7 4 14/7 (c) Restnetz nach Schritt 1, augmentierender Weg mit Kapazität 4 12 9 7 7 13 s t 3 13 11 7 0 9 4 7 7 152 Algorithmen und Datenstrukturen (d) Restnetz nach Schritt 2, augmentierender Weg mit Kapazität 5 8 4 5 7 11 13 s 4 3 t 11 13 7 5 4 0 3 11 (e) Restnetz nach Schritt 3, augmentierender Weg mit Kapazität 4 3 9 12 16 8 s 4 3 t 11 13 7 5 3 4 11 (f) Restnetz nach Schritt 4, augmentierender Weg mit Kapazität 3 3 9 16 16 4 s t 3 9 11 4 7 9 4 3 11 (g) Restnetz nach Schritt 5, kein augmentierender Weg 12 12 19 16 1 s t 6 6 7 8 7 9 4 3 11 153 Algorithmen und Datenstrukturen (h) Maximaler Fluß und minimaler Schnitt, beide mit Wert 23 12/12 16/16 20/19 s t 10/4 4/0 13/7 7/7 9/0 4/4 14/11 Analyse der Laufzeit Schritt 1: O( E ) Schritt 2a: O( E ) je Durchlauf Schritt 3: O( E ) je Durchlauf, wenn z.B. Tiefen- oder Breitensuche benutzt wird Wieviel Durchläufe gibt es? Falls die Kapazitäten ganze Zahlen sind, erhöht sich jeder Durchlauf den Fluß um mindestens eins, also gibt es bis zu f ( Laufzeit ist dann insgesamt O f ( * ) * Durchläufe, wobei f * der maximale Fluß ist. Die ⋅E . ) Die Laufzeit O f * ⋅ E ist nicht befriedigend, da f * evtl. exponentiell in der Größe der Eingabe ist. Das folgende Bsp. zeigt die Möglichkeit, dass tatsächlich einmal f * Durchläufe ausgeführt werden: Bsp.: 1000 1000 999 1000 1 1 1 1000 1 1000 1000 (a) Netz 999 (b) 1. Schritt Bei ungeschickter Wahl des 1. erweiternden Wegs, nämlich s, 1, 2, t ist in der 1. Iteration (1. Schritt) lediglich eine Erhöhung um eine Einheit möglich. Saturiert wird nur die Kante (1,2). QAls nächstes kann s, 2, 1, t gewählt werden, wobei wieder nur eine Erhöhung um eine Einheit zu erreichen ist. 154 Algorithmen und Datenstrukturen 999 999 1 1 1 1 1 999 999 (c) 2. Schritt Abb.: Bestimmen des augmentierenden Weges mit Tiefensuche Hier werden die augmentierenden Pfade mit Tiefensuche gefunden. In jedem Schritt wird der Fluß um 1 erhöht und in den (b) und (c) dargestellten Schritte werden f * = 2000 mal wiederholt bis der maximale Fluß erreicht ist Wenn die augmentierenden Wege mit der Breitensuche bestimmt werden, dann werden 2 Durchläufe benötigt. 1000 0 0 1000 1000 1000 1 1 1000 1000 1000 1000 1000 1000 0 0 1000 1000 Abb.: Bestimmen augmentierender Wege mit Breitensuche Edmonds-Karp-Algorithmus Es ist ersichtlich aus den vorstehenden Beispielen, dass die Breitensuche Vorteile hat gegenüber der Tiefensuche. Genutzt wird die Breitensuche durch den Algorithmus von Edmonds und Karp 1. Initialisiere f mit 0 2. solange ein augmentierender Weg P von s nach t im Restnetzwerk G f existiert 2a. Konstruktion bzw. Aktualisierung des Restnetzes G f 155 Algorithmen und Datenstrukturen 2b. Finden eines augmentierenden Wegs mit Breitensuche 3. für jede Kante e auf P erhöhe den Fluß um c f (P ) Bei diesem Algorithmus wird der kürzesze augmentierende Weg bzgl. der Kantenzahl ausgewählt. Falls δ f (u, v) ) der Abstand zwischen u und v im Restnetz ist, also die Anzahl der Kanten auf dem kürzesten Weg von u nach v , gilt: Beim Edmonds-Karp-Algorithmus gilt für alle Knoten v (ausgenommen s , t ): Während des Ablaufs des Algorithmus ist δ f (u , v) ) monoton wachsend 156 Algorithmen und Datenstrukturen 2.7.1.4 Schnitte und das Max-Flow-Min-Cut Problem Eine Unterteilung eines Netzwerks in eine Knotenmenge A und eine Knotenmenge B heißt Schnitt. A B w Die Kapazität c( A, B) eines Schnitts A / B ist die Summe der Kapazitäten aller Kanten von A nach B. Der Wert eines Flusses (der Gesamtfluß) ist nie größer als die Kapazität eines beliebigen Schnitts (irgendwie muß es ja durch). D.h. w( f ) ≤ min c( A, B) Schnitt AB Max-Flow-Min-Cut-Theorem w( f ) , der Wert von f ist maximal ⇔ es gibt keinen augmentierenden Pfad von Quelle zur Senke Dann enden alle von s ausgehenden erweiternden Wege (- genauer gesagt deren Anfangsstücke -)entweder bei einer saturierten Vorwärtskante oder bei einer Rüpckwärtskante mit Fluß 0. Durch diese Kanten wird ein Cut impliziert, dessen Kapazität gleich dem momentanen Fluß ist. ⇔ w( f ) ≤ min c( A, B) Schnitt AB Beweis „ ⇒ “: durch Angeben der Optimierungsregel Beweis „ ⇐ “: Definiere Schnitt A / B , so dass A = alle Knoten, die von der Quelle aus mit augmentierenden Pfad erreichbar sind. Für alle v ∈ A , w ∈ B gilt f (v, w) = c(v, w) , da sonst w auch mit augmentierendem Pad erreichbar. Also ist w( f ) = c( A, B ) 1 2 3 max flow 123 min cut 157 Algorithmen und Datenstrukturen Bsp.: Flussgraph: 12/12 a b 16/11 20/15 s t 10 4/1 13/7 7/7 9/4 c 4/4 d 14/11 Schnitt: - cut ({s, a, c}, {b, d , t}) - Netto-Fluss. f (a, b ) + f (c, d ) + f (b, c ) = 19 . Netto-Fluss ist in allen Schnitten gleich Eine interessante Eigenschaft des Netzwerks mit gannzahliger Kapazitäz ist, dass auch die maximalen Flüsse in solchen Netzwerken immer ganzzahlig sind, da der vorstehende Algorithmus nur ganzzahlige Erhöhungen durchführt. Das Integral-Flow-Theorem: Wenn in einem Netzwerk alle Kapazitäten ganzzahlige Werte sind, dann ist der maximale Fluß auch ganzzahlig. Beweis: Verwende den vorstehenden Algorithmus. Am Anfang ist der Fluß 0. In jedem Schritt wird er um die Restkapazität eines augmentierenden Pfads erhöht Da alter Fluß ganzzahlig und Kapazität ganzzahlig, ist auch die Restkapazität ganzahlig und neuer Fluß auch 158 Algorithmen und Datenstrukturen 2.7.2 Konsteminimale Flüsse Durch ein Netzwerk wird häufig nicht ein maximaler Fluß gesendet, sondern ein Fluß mit vorgegebenem Wert, der bzgl. eines Kostenkriteriums minimale Kosten verursacht. Hier bestimmt man zunächst den maximalen Fluß ohne Rücksicht auf die Kosten und steuert anschließend die einfachen Flüsse so um, bis das Kostenminmum erreicht ist. Bsp.: Gegeben ist das Verkehrsnetz 2 7(6) 4(3) 4(4) 1 4 2(2) 3(8) 3 Abb.: Jede Strecke des Netzes (Kante des Graphen) hat eine begrenzte Kapazität (bezeichnet durch die 1. Zahl an den Kanten). Die Zahl in den Klammern an den Kanten gibt die Kosten des Transports (je Einheit) an. Gesucht ist der maximale Fluß durch das Netz vom Knoten 1 zum Knoten 4, wobei die Kosten möglichst niedrig sein sollen. 1. Berechnung des maximalen Flusses ohne Berücksichtigung der Kosten 2 7 7(6) 1 4(4) 4(3) 1 4 1 2(2) 2 3(8) 3 3 Abb.: Der berechnete maximale Fluß besteht aus den Einzelflüssen: 7 (Einheiten) von 1 nach 2 3 (Einheiten) von 2 nach 3 4 (Einheiten) von 2 nach 4 3 (Einheiten) von 3 nach 4 Die Kosten betragen 91 [Kosteneinheiten]. Die Lösung ist nicht kostenminimal. 2. Kostenoptimale Lösung 159 Algorithmen und Datenstrukturen Zwischen Knoten 1 und 3 bestehen 2 Alternativwege (1 - 2- 3 - 2) und (1 - 3). (1 - 3) wird nicht benutzt. Dort betragen die Kosten nur 2 [Kosteneinheiten]. Eine Umverteilung von 2 [Mengeneinheiten] führt hier zur Verbesserung. Man erhält die Optimallösung mit 77 [Kosteneinheiten]. 160 Algorithmen und Datenstrukturen 2.8 Matching 2.8.1 Ausgangspunkt, Motivierendes Beispiel, Definitionen, maximales Matching Ausgangspunkt Zuordnungsprobleme (verschiedene Dinge einander zuordnen) - Männer / Frauen im Tanzkurs Arbeiten / Arbeitskräfte Koffer / Schließfächer Gegeben: Ein ungerichteter G = (V , E ) . Die Kanten symbolisieren hier mögliche Zuordnungen. Gesucht: Eine Zuordnung M (Matching), d.h. eine unabhängige Kantenmenge M . Unabhängig bedeutet, es gilt: (i, j ), (i ' , j ' ) ∈ M ⇒ i ≠ i ' , j ≠ j ' , i ≠ j ' , j ≠ i ' Keine der zwei Kanten in M haben die gleiche Zuordnung. M ist die Anzahl der Kanten in M . Motivierendes Beispiel Gegeben Tanzkurs: Jeder Teilnehmer (Knoten) weiß, mit wen ergerne tanzt. (Kante). Gesucht: Mögliche Paarungen (vgl. rot gefärbte Kanten). Eva Heino Martin Klaus Maria Pia Uwe Lilo 3 Paare sind gefunden, aber nicht jeder Knoten hat einen Partner. Es sind keine weiteren Paarungen möglich. Frage: Wie kriegt man eine optimale Paarbildung zustande? Es sind ja noch ein Herr und eine Dame übrig geblieben! Definitionen - - Zwei Kanten (u, v ) und ( x, y ) (x,y) heißen unabhängig, wenn u , v, x, y vier verschiedene Knoten sind. Wenn u = x oder u = y oder v = x oder v = y , dann heißen die Kanten benachbart (oder verbunden oder adjazent) Die Kantenmenge M heißt unabhängig, wenn alle ihre Elemente paarweise unabhägig sind. Solche Untermengen heißen auch Matching (Zuordnung). 161 Algorithmen und Datenstrukturen - Ein Knoten heißt frei bzgl. eines Matchings, wenn er keine Kante des Matchings hat, sonst heißt er gematcht. - Ein Matching M heißt perfekt, wenn es alle Knoten des Graphen überdeckt. - Ein Matching M heißt maximal (nicht erweiterbar), wenn es um keine Kante erweitert werden kann - Ein Matching M heißt Maximum, wenn es kein Matching mit mehr Kanten gibt, d.h. |M| ist maximale Größe. 162 Algorithmen und Datenstrukturen - Ein Matching M bei dem nur ein Knoten frei bleibt, heißt fast perfekt. gematcht frei Bsp.: Ein gerader Kreis hat 2 perfekte Matchings Ein gieriger Algorithmus - Gegeben: Graph G - Gesucht Matching M Solange eine unmarkierte Kante (u,v) in G existiert Markiere (u,v) Markiere alle benachbarten Kanten Übertnehme (u,v) nach M 163 Algorithmen und Datenstrukturen - Algorithmus liefert - ein maximales Matching - aber kein (fast) perfektes Matching Beobachtung Im folgenden Graphen sind Eva Heino Martin Klaus Maria Pia Uwe Lilo Knoten in zwei Gruppen aufteilbar, Kanten jeweils zwischen diesen Gruppen Klaus Heino Uwe Martin Maria Lilo Pia Eva 164 Algorithmen und Datenstrukturen 2.8.2 Bipartiter Graph Am häufigsten werden Matching-Probleme in bipartiten Graphen betrachtet. Ungerichteter Graph G = ( X ∪ Y , E ) mit X ∩ Y = ∅ und nur Kanten (xi , y j ) ∈ E mit xi ∈ X , y j ∈ Y oder umgekehrt. Gegeben ist der folgende bipartite Graph G = ( X ∪ Y , E ) mit X = {x1 , x 2 ,..., x 6 } und Y = {y1 , y 2 ,..., y 6 } x1 x2 x3 x4 y1 y2 y3 y4 x5 y5 x6 y6 Zuordnung nicht maximal: Mehr Kanten bspw, wenn (x1 , y1 ) und (x3 , y 2 ) (x3,y2) verwendet werden, statt (x1 , y 2 ) x1 x2 x3 x4 y1 y2 y3 y4 x5 y5 x6 y6 Formulierung als Flußproblem Vorgehen - Gegeben Graph G = ( X ∪ Y , E ) - Hinzufügen von zwei weiteren Knoten Quelle s und Senke t - Jede Kante (xi , y j ) von G = ( X ∪ Y , E ) wird in einem Graphen G ' = ( X ∪ Y ∪ {s, t}, E ') zu einem Pfeil von xi nach y j - In E ' existiert ein Pfeil von s zu jedem Knoten xi ∈ X und von jedem y j ∈ Y existiert ein Pfeil zu t - Es ist also E ' = E ∪ {(s, x ) : x ∈ X } ∪ {( y, t ) : y ∈ Y } - Jede Kante erhält die Kapazität 1 Maximale Zuordnung in G entspricht einem maximalen Fluß in G’ 165 Algorithmen und Datenstrukturen Bsp.: s x1 x2 x3 x4 y1 y2 y3 y4 x5 y5 x6 y6 t Ablösung von (x1,y2) durch (x1,y1) und (x3,y2) in G entspricht Erweiterungspfad e=[s,x3,y2,x1,y1,t] in G ' Jeder Fluß f in G ' entspricht einem Matching M = {( xi , y i ) | f ( xi , y i ) = 1} in G . Der maximale Fluß ordnet genau den Kanten des „Maximum Matchings“ (und denen von Quelle und Senke) 1.0 zu, sonst 0.0. Erweiternder Weg Erweiterungspfad in G’ - Vorwärtspfeil e mit Fluss f = 0 - Rückwärtspfeil e’ mit Fluss f(e’)=1 - Vorwärts- und Rückwärtspfeile wechseln sich ab - Pfad beginnt und endet mit einem Vorwärtspfeil Entsprechnug in G - Pfad, dessen Kanten abwechselnd zur Zuordnung gehören bzw. nicht zur Zuordnung gehören wird als alternierender Pfad bezeichnet, z.B. x2, y4,x5,y6 Vergrößerung der Zuordnung - Vergrößerung alternierender Pfad: Alternierender Pfad mit freien Endknoten, z.B.: y3,x2,y4,x4,y5,x6 - Freie Kante wird zu gebundener und umgekehrt. Für ein gegebenes Matching M nennt man jede für die Zuordnung verwendete Kante e ∈ M gebunden, jede Kante e'∈ E − M ist frei. Jeder Knoten, der eine gebundene Kante inzidiert, ist ein gebundener Knoten, jeder andere Knoten ist frei. Ein Weg in G dessen Kanten abwechselnd gebunden und frei sind, heißt alternierender Weg. Die Länge eines alternierenden wegs, ist die Anzahl der Kanten auf diesem Weg. Natürlich kann nicht jeder alternierende Weg zur Verrgrößerung der Zuordnung benutzt werden. Das geht nur dann, wenn die beiden Knoten an den 166 Algorithmen und Datenstrukturen Enden eines Wegs frei sind. Ein alternierender Weg mit zwei freien Knoten an beiden Enden heißt deshalb vergrößernd. Bsp.: Erweiternder Weg M ' ist das aus der Vergrößerung entstehende Matching. Falls es ein aus der Vergrößerung entstehendes Matching gibt, ist der Pfad M - M ' alternierend: M M M’ ⇒ M M’ M M’ Beachte: Ein Zyklus kann kein vergrößernd alternierender Pfad sein Es ist einleuchtend, dass sich ein Matching entlang eines solchen erweiternden Wegs um eine Kante erweitern lässt, indem man jede Matchingkante zu einer Nichtmatchingkante macht und umgekehrt. Maximum Matching Dabei gilt der folgende Satz: M ist Maximum Matching ⇔ Es gibt keinen erweiternden Weg bzgl. M . Beweis: ⇒ : trivial! ⇐: Es gibt keinen erweiternden Weg bzgl. M Annahme: Es gibt M ' mit M ' > M Betrachte nun M und M ' in G und entferne alle Kanten aus dem Rest von G, die abwechselnd in M ' und M liegen, wobei diese Folge mit einer Kante aus M ' beginnt und aufhört. Die Endpunkte dieser Folge von Kanten sind frei bzgl. M . Somit ist diese Folge von Kanten ein erweiternder Weg bzgl. M . Dies ist ein Widerspruch zur Voraussetzung. Falls M < M ' , M ist nicht Maximum. 167 Algorithmen und Datenstrukturen Bsp.: v v w v Kein Maximum Matching da vergrößernd alternierend Pfad zwischen nicht gematchten Knoten v, w v v w w Kein maximum Matching da vergrößert alternierender Pfad zwischen nicht gematchten Knoten Daraus folgt ein Ansatz zur Lösung des Problems, ein Maximum Matching zu bestimmen Benutze irgend einfachen Algorithmus um ein maximales Matching M zu finden, solange ein vergrößernd M-alternierender Pfad vorhanden ist Vertausche die M-Zugehörigkeit der Kanten auf diesem Pfad - Der Algorithmus 84 fügt in jedem Schritt eine Kante zu M hinzu. Da es nur endlich viele Kanten gibt, terminiert er. Wenn er terminiert, hat er ein Maximum Matching gefunden Noch offen: Wie findet man M-alternierende Pfade? (Erweiternder) alternierender Baum Bei bipartiten Graphen kann man für ein gegebenes Matching einen vergrößernden Weg finden, indem man mit der Suche bei einem freien Knoten beginnt und entlang eines bzg. M alternierenden Wegs fortschreitet. Sobald man bei einem freien Knoten angekommen ist, ist ein vergrößernder Weg gefunden. Zu einem freien Startknoten kann man einen entsprechenden Baum mit Hilfe der Breitensuche ermitteln. 84 abgeleitet aus dem Satz von Berge 168 Algorithmen und Datenstrukturen x1 x2 x3 x4 y1 y2 y3 y4 x5 x6 y5 y3 y6 freier Knoten freie Kante gebundene Kante y4 freie Kante x5 x4 gebundene Kante y5 y6 freie Kante x6 Abb.: Breitensuchbaum für die in der vorstehenden Abbildung gezeigte Zuordnung und den Startknoten y3 Maximal gewichtete Zuordnung (maximum weigtht matching) Für einen ungerichteten, bewerteten Graph G = (V , E ) mit Kantenbewertung w : E → ℜ ist das Gewicht einer Zuordnung M die Summe der Gewichte der Kanten von M. Von Interesse ist eine maximale gewichtete Zuordnung (maximum weight matching). Falls bspw. in einer Firma mit k Mitarbeitern m1,…,mk die k Tätigkeiten t1,…,tk auszuführen sind und eine Maßzahl w(mi,tj) für die Eignung eines Mitarbeiters mi für die Tätigkeit tj bekannt ist, sofern Mitarbeiter mi die Tätigkeit tj überhaupt ausführen kann, so kann eine maximale gewichtete Zuordnung von Mitarbeitern und Tätigkeit erwünscht sein. 169 Algorithmen und Datenstrukturen m1 m2 m3 m4 m5 m6 6 1 2 2 t1 2 t2 1 5 t3 6 6 t4 5 6 5 t5 7 t6 2.8.3 Maximale Zuordnung im allgemeinen Fall In allgemeinen Graphen kann man mit einer einfachen Breitensuche vergrößernde Wege nicht unbedingt finden finden. Bsp.: Gegeben ist 4 9 12 3 5 1 8 2 10 11 6 7 mit der Zuordnung M = {(6,7 ), (8,10 )} Finde den vergrößernden Weg vom freien Knoten 2 aus mit Hilfe eines alternierenden Baums. 2 freier Knoten freie Kante 6 gebundene Kante 7 freie Kante 8 10 gebundene Kante ? Abb.: Alternierender Baum (auf einen Teilgraph beschränkt) 170 Algorithmen und Datenstrukturen Die Breitensuche sorgt dafür, dass Knoten 10 besucht wird, bevor die Nachfolger von Knoten 8 im alternierenden Baum in Betracht gezogen werden. Wenn jeder Knoten, wie bei der Breitensuche üblich, nur einmal besucht werden darf, so verhindert das Finden des alternierenden Wegs 2, 6, 7, 10 der nicht mit einem freien Knoten endet, dass der alternierende Weg 2, 6, 7, 8, 10, 11 gefunden wird (, obwohl der mit einem freien Knoten enden würde. Die reine Breitensuche ist also hier nicht in der Lage, vergrößernde Wege auch wirklich zu finden. Ursache: Ein und derselbe Knoten kann auf mehreren, alternierenden Wegen in gerader und ungerader Entfernung vom Startknoten auftreten. Knoten 10 tritt auf dem alternierenden Weg 2, 6, 7, 10 in ungerader Entfernung vom Startknoten 2 auf, währen er bei dem alternierenden Weg 2, 6, 7, 8, 10 in gerader Entfernung vom Startknoten auftritt. Man kann aber nicht in eine Abänderung der reinen Breitensuche das 2malige Besuchen eines jeden Knoten erlauben, nämlich je einmal für die gerade und ungerade Entfernung vom Startknoten, dann können auch Knotenfolgen gefunden werden, die keinen vergrößernden Weg beschreiben, z.B. das Matching M = {(6,7 ), (8,10 )} kann für Startknoten 2 die Knotenfolge 2, 6, 7, 8, 10, 7, 6, 5 liefern. Überlegung: Das Finden eines vergrößernden Wegs von einem freien Knoten v aus ist nur dann schwierig, wenn es einen alternierednen Weg p von v zu einem Knoten v’ in jeder Entfernung von v gibt und wenn eine Kante v’ mit einer anderen v’’ verbindet, der auf dem Weg ebenfalls in gerader Entfernung von v liegt. v’ v v’’ j i Der Teil des Wegs p von v’’ nach v’ heißt zusammen mit der Kante (v’,v’’) Blüte. Eine Blüte ist also ein Zyklus ungerader Länge. Der Teil des Wegs p von v nach v’’ heißt Stiel der Blüte. In der vorstehenden Abb. gibt es sowohl einen alternierenden Weg von v nach i als auch einen von v nach j. Den Weg von v nach i erhält man, wenn man im Zaklus ungerader Länge im Uhrzeigersinn fortschreitet. Den Weg von v nach j erhält man durch Besuchen einiger Knoten des Zyklus entgegen dem Uhrzeigersinn. Diese beiden Wege kann man finden, wenn man die Blüte auf einen Knoten schrumpfen lässt, also den Zyklus ungerader Länge in einen Knoten kollabiert. Jede Kante, die vor dem Schrumpfen mit einem Knoten des Zyklus inzident war, ist nach dem Schrumpfen mit dem die Blüte repräsentierten Knoten inzident. v i j Abb.: Effekt des Schrumpfens der Blüte zur vorstehenden Abb. 171 Algorithmen und Datenstrukturen Wenn ein Graph G’ aus einem Graph G durch Schrumpfen einer Blüte entsteht, so gibt es in G’ genau dann einen vergrößernden Weg, wenn es einen solchen in G gibt. Blüte ⇒ frei Blüte in G = Knoten in G’ außen außen frei G Blüte (blossom): Kreis ungerader Länge Kante von „außen“ nach „außen ergibt Blüte. G’ Es gilt folgender Satz: G’ hat erweiternden Weg ⇔ G hat erweiternden Weg 172