Algorithmen und Datenstrukturen Prof. Jürgen Sauer Algorithmen und Datenstrukturen Skriptum zur Vorlesung im SS 2009 1 Algorithmen und Datenstrukturen 2 Algorithmen und Datenstrukturen Inhaltsverzeichnis Literaturverzeichnis .............................................................................................................................................. 7 1. GRUNDLEGENDE KONZEPTE ......................................................................................................... 9 1.1 Die zentralen Begriffe ..................................................................................................................................... 9 1.1.1 Datenstruktur und Algorithmus .................................................................................................................. 9 1.1.2 Ein einführendes Beispiel: Das Durchlaufen eines Binärbaums ............................................................... 10 1.1.2.1 Rekursive Problemlösung ...................................................................................................................... 12 1.1.2.2 Nichtrekursive Problemlösung .............................................................................................................. 13 1.2 Algorithmische Grundkonzepte ................................................................................................................... 17 1.2.1 Algorithmenbegriffe ................................................................................................................................. 17 1.2.2 Terminierung und Determinismus ............................................................................................................ 17 1.2.3 Algorithmenbausteine............................................................................................................................... 18 1.2.4 Paradigmen der Algorithmenbeschreibung .............................................................................................. 20 1.2.4.1 Applikative Algorithmen ................................................................................................................... 21 1.2.4.2 Imperative Algorithmen .................................................................................................................... 23 1.2.4.3 Objektorientierte Algorithmen .......................................................................................................... 23 1.2.4.4 Paradigmen und Programmiersprachen ............................................................................................. 26 1.2.5 Beschreibung von Algorithmen ................................................................................................................ 26 1.2.6 Formale Eigenschaften von Algorithmen ................................................................................................. 30 1.2.6.1 Korrektheit, Terminierung, Hoare-Kalkül, Halteproblem ................................................................. 30 1.2.6.1.1 Korrektheit, Terminierung .......................................................................................................... 30 1.2.6.1.2 Hoare-Kalkül .............................................................................................................................. 31 1.2.6.1.3 Halteproblem .............................................................................................................................. 31 1.2.6.2 Effizienz ............................................................................................................................................ 33 1.2.7 Komplexität .............................................................................................................................................. 35 1.2.7.1 Laufzeitberechnungen ....................................................................................................................... 37 1.2.7.1.1 Analyse der Laufzeit................................................................................................................... 37 1.2.7.1.2 Asymptotische Analyse der Laufzeit („Big-O“) ......................................................................... 38 1.2.7.2 O(logN)-Algorithmen ........................................................................................................................ 42 1.2.7.3 Berechnungsgrundlagen für rechnerische Komplexität ..................................................................... 43 1.2.7.3.1 System-Effizienz und rechnerische Effizienz ............................................................................. 43 1.2.7.3.2 P- bzw. NP-Probleme ................................................................................................................. 43 1.2.7.3.3 Grenzen der Berechenbarkeit ..................................................................................................... 44 1.3 Daten und Datenstrukturen ......................................................................................................................... 46 1.3.1 Datentyp ................................................................................................................................................... 46 1.3.2 Datenstruktur ............................................................................................................................................ 47 1.3.3 Relationen und Ordnungen ....................................................................................................................... 52 1.3.4 Klassifikation von Datenstrukturen .......................................................................................................... 56 1.3.4.1 Lineare Ordnungsgruppen ................................................................................................................. 56 1.3.4.2 Nichtlineare Kollektion ..................................................................................................................... 59 1.3.4.2.1 Hierarchische angeordnete Sammlung (Bäume)......................................................................... 59 1.3.4.2.2 Gruppenkollektionen .................................................................................................................. 64 1.3.4.3 Dateien und Datenbanken ................................................................................................................. 65 1.3.5 Definitionsmethoden für Datenstrukturen ................................................................................................ 68 1.3.5.1 Der abstrakte Datentyp ...................................................................................................................... 68 1.3.5.2 Die axiomatische Methode ................................................................................................................ 69 1.3.5.3 Die konstruktive Methode ................................................................................................................. 71 1.3.5.4 Die objektorientierte Modellierung abstrakter Datentypen ............................................................... 72 1.3.5.5 Die Implementierung abstrakter Datentypen in C++ ......................................................................... 80 1.3.5.5.1. Das Konzept für benutzerdefinierte Datentypen: class bzw. struct ........................................... 80 1.3.5.5.2. Generischer ADT ...................................................................................................................... 82 3 Algorithmen und Datenstrukturen 2. DATENSTRUKTUREN UND ALGORITHMEN IN C++ .................................................................... 84 2.1 Die C++-Standardbibliothek und die STL .................................................................................................. 85 2.2 Die Konzepte der STL .................................................................................................................................. 85 2.2.1 Container .................................................................................................................................................. 85 2.2.1.1 Sequentielle Container ...................................................................................................................... 88 2.2.1.2 Mengen und Abbildungen ................................................................................................................. 88 2.2.1.3 Adaptoren zu Sequenzen ................................................................................................................... 89 2.2.1.4 Beispiele für Container-Anwendungen ............................................................................................. 92 2.2.2 Iteratoren .................................................................................................................................................. 93 2.2.3 Algorithmen ............................................................................................................................................. 95 2.2.4 Funktionsobjekte ...................................................................................................................................... 96 2.3 Templates für Algorithmen und Datenstrukturen ..................................................................................... 98 2.3.1 Darstellung von Graphen mit sequentiell gespeicherte Listen .................................................................. 98 2.3.1.1 Die Datenstruktur Graph ................................................................................................................... 98 2.3.1.2 Die STL-Containerklasse vector zur Implemetierung einer Knotenliste für Graphen ..................... 100 2.3.1.3 Mehrdimensionale Felder ................................................................................................................ 104 2.3.1.4 Durchlaufen von Graphen mit Hilfe der STL-Containerklassen stack bzw. queue ......................... 106 2.3.1.4.1 Tiefensuche (First-Depth Search) ............................................................................................. 106 2.3.1.4.2 Breitensuche (Breadth-First Search) ........................................................................................ 109 2.3.1.5 Ermitteln der kürzesten Wege mit Hilfe der STL-Containerklasse priority_queue ......................... 110 2.3.1.6 Erreichbarkeit und der Algorithmus von Warshall .......................................................................... 113 2.3.1.6.1 Erreichbarkeit ........................................................................................................................... 113 2.3.1.6.2 Warshalls Algorithmus ............................................................................................................. 114 2.3.2 Darstellung von Graphen mit assoziativen Behälterklassen ................................................................... 116 2.3.2.1 Verbindungsproblem mit Kantenpräsentation durch die Containerklasse set .................................. 116 2.3.2.2 Algorithmus von Dijkstra mit Präsentation des Graphen durch die Containerklasse map............... 118 2.3.3 Darstellung von Graphen mit Hilfe der Klasse hash_map ...................................................................... 121 2.3.3.1 Topolgical Sorting........................................................................................................................... 121 2.3.3.2 Projektplanung mit der Critcal Path Method ................................................................................... 126 2.3.4 Klassenschablonen für verkettete Listen ................................................................................................ 134 2.3.4.1 Doppelt gekettete Listen .................................................................................................................. 134 2.3.4.2 Ringförmig geschlossene Listen ...................................................................................................... 134 3. ALGORITHMEN .............................................................................................................................. 143 3.1 Ausgesuchte algorithmische Probleme ...................................................................................................... 143 3.1.1 Spezielle Sortieralgorithmen .................................................................................................................. 143 3.1.1.1 Interne Sortierverfahren .................................................................................................................. 143 3.1.1.1.1 Quicksort .................................................................................................................................. 143 3.1.1.1.2 Heap-Sort ................................................................................................................................. 146 3.1.1.1.3 Sortieren durch Mischen .......................................................................................................... 147 3.1.1.2 Externe Sortierverfahren ................................................................................................................. 150 3.1.1.2.1 Direktes Mischsortieren ........................................................................................................... 150 3.1.1.2.2 Natürliches Mischen ................................................................................................................. 159 3.1.2 Suche in Texten ...................................................................................................................................... 162 3.1.2.1 String Pattern-Matching .................................................................................................................. 162 3.1.2.1.1 Ein einfacher Algorithmus zum Suchen in Zeichenfolgen........................................................ 162 3.1.2.1.2 Der Algorithmus von Knuth-Morris-Pratt ................................................................................ 163 3.1.2.1.3 Boyer / Moore - Suche ............................................................................................................. 167 3.1.2.2 Pattern-Matching mit regulären Ausdrücken ................................................................................... 174 3.1.2.2.1 Reguläre Ausdrücke ..................................................................................................................... 174 3.1.2.2.2 Überprüfung regulärer Ausdrücke mit endlichen Automaten ................................................... 177 3.1.2.2.3 Java 1.4 "regex" ....................................................................................................................... 181 3.2 Entwurfstechniken für Algorithmen (Einsatz von Algorithmen-Mustern)............................................ 184 3.2.1 Greedy Algorithmen ............................................................................................................................... 184 3.2.1.1 Greedy-Algorithmen für minimale Spannbäume ............................................................................. 185 4 Algorithmen und Datenstrukturen 3.2.1.2 Huffman Codes ............................................................................................................................... 188 3.2.2 Divide and Conquer ............................................................................................................................... 194 3.2.3 Induktiver Algorithmenentwurf und Dynamisches Programmieren ....................................................... 194 3.3 Rekursion ..................................................................................................................................................... 196 3.3.1 Linear rekursive Funktionen ................................................................................................................... 196 3.3.2 Nichtlineare rekursive Funktionen ......................................................................................................... 196 3.3.3 Primitive Rekursion................................................................................................................................ 197 3.3.4 Nicht primitive Rekursion ...................................................................................................................... 197 3.4 Backtracking-Algorithmen ......................................................................................................................... 198 3.5 Zufallsgesteuerte Algorithmen ................................................................................................................... 202 4. BÄUME ............................................................................................................................................ 203 4.1 Grundlagen .................................................................................................................................................. 203 4.1.1 Grundbegriffe und Definitionen ............................................................................................................. 203 4.1.2 Darstellung von Bäumen ........................................................................................................................ 204 4.1.3 Berechnungsgrundlagen ......................................................................................................................... 205 4.1.4 Klassifizierung von Bäumen .................................................................................................................. 207 4.2 Freie Binäre Intervallbäume ...................................................................................................................... 210 4.2.1 Ordnungsrelation und Darstellung.......................................................................................................... 210 4.2.2 Operationen ............................................................................................................................................ 214 4.2.2.1 Generieren eines Suchbaums ........................................................................................................... 214 4.2.2.2 Suchen und Einfügen ....................................................................................................................... 216 4.2.2.3 Löschen eines Knoten ..................................................................................................................... 218 4.2.3 Ordnungen und Durchlaufprinzipien ...................................................................................................... 226 4.3 Balancierte Bäume ...................................................................................................................................... 230 4.3.1 Statisch optimierte Bäume ...................................................................................................................... 233 4.3.2 AVL-Baum ............................................................................................................................................. 234 4.3.3 Splay-Bäume .......................................................................................................................................... 246 4.3.4 Rot-Schwarz-Bäume............................................................................................................................... 253 4.4 Bayer-Bäume ............................................................................................................................................... 262 4.4.1 Grundlagen und Definitionen ................................................................................................................. 262 4.4.1.1 Ausgeglichene T-äre Suchbäume (Bayer-Bäume) ........................................................................... 262 4.4.1.2 (a,b)-Bäume..................................................................................................................................... 264 4.4.2 Darstellung von Bayer-Bäumen ............................................................................................................. 265 4.4.3 Suchen eines Schlüssels ......................................................................................................................... 267 4.4.4 Einfügen ................................................................................................................................................. 268 4.4.5 Löschen .................................................................................................................................................. 271 4.4.6 Auf Platte/ Diskette gespeicherte Datensätze ......................................................................................... 276 4.4.7 B*-Bäume .............................................................................................................................................. 278 4.5 Digitale Suchbäume..................................................................................................................................... 281 4.5.1 Grundlagen und Definitionen ................................................................................................................. 281 4.5.2 Tries ....................................................................................................................................................... 282 4.5.3 Binäre Tries ............................................................................................................................................ 285 4.5.4 Patricia Bäume (Compressed Tries) ....................................................................................................... 285 4.5.5 Suffix Tries............................................................................................................................................. 286 4.5.6 Dateikompression mit dem Huffman-Algorithmus................................................................................. 287 5. GRAPHEN UND GRAPHENALGORITHMEN ................................................................................ 288 5.1 Einführung ................................................................................................................................................... 288 5.1.1 Grundlagen ............................................................................................................................................. 288 5 Algorithmen und Datenstrukturen 5.1.2 Definitionen............................................................................................................................................ 292 5.1.3 Darstellung in Rechnerprogrammen ....................................................................................................... 298 5.2 Durchlaufen von Graphen .......................................................................................................................... 303 5.2.1 Tiefensuche (depth-first search) ............................................................................................................. 303 5.2.1.1 Algorithmus ..................................................................................................................................... 303 5.2.1.2 Eigenschaften von DFS ................................................................................................................... 307 5.2.1.3 Kantenklassenfikation mit DFS ....................................................................................................... 308 5.2.1.4 Zusammenhangskomponenten......................................................................................................... 311 5.2.1.5 Topologisches Sortieren mittels Tiefensuche .................................................................................. 318 5.2.2 Breitensuche (breadth-first search)......................................................................................................... 323 5.2.3 Implementierung .................................................................................................................................... 326 5.3 Topologischer Sort ...................................................................................................................................... 330 5.4 Transitive Hülle ........................................................................................................................................... 333 5.4.1 Berechnung der Erreichbarkeit mittels Matrixmultiplikation ................................................................. 333 5.4.2 Warshalls Algorithmus zur Bestimmung der Wegematrix ..................................................................... 335 5.4.3 Floyds Algorithmus zur Bestimmung der Abstandsmatrix ..................................................................... 336 5.5 Kürzeste Wege ............................................................................................................................................. 337 5.5.1 Die Datenstrukturen Graph, Vertex, Edge für die Berechnung kürzester Wege .................................... 337 5.5.2 Kürzeste Pfade in gerichteten, ungewichteten Graphen. ........................................................................ 338 5.5.3 Berechnung der kürzesten Pfadlängen in gewichteten Graphen (Algorithmus von Dijkstra) ................. 342 5.5.4 Berechnung der kürzesten Pfadlängen in gewichteten Graphen mit negativen Kosten .......................... 347 5.5.5 Berechnung der kürzesten Pfadlängen in gewichteten, azyklischen Graphen ........................................ 348 5.5.6 All pairs shorted Path ............................................................................................................................. 350 5.6 Minimale Spannbäume ............................................................................................................................... 352 5.6.1 Der Algorithmus von Prim ..................................................................................................................... 352 5.6.2 Der Algorithmus von Kruskal ................................................................................................................ 355 5.7 Netzwerkflüsse ............................................................................................................................................. 358 5.7.1 Maximale Flüsse .................................................................................................................................... 358 5.7.1.1 Netzwerk und maximaler Fluß ........................................................................................................ 358 5.7.1.2 Optimieren und Finden augmentierender Pfade (Erweiterter Weg) ................................................ 360 5.7.1.2 Algorithmus für optimalen Fluss ..................................................................................................... 362 5.7.1.4 Schnitte und das Max-Flow-Min-Cut Problem................................................................................ 367 5.7.2 Konsteminimale Flüsse .......................................................................................................................... 369 5.8 Matching ...................................................................................................................................................... 371 5.8.1 Ausgangspunkt, Motivierendes Beispiel, Definitionen, maximales Matching ....................................... 371 5.8.2 Bipartiter Graph ..................................................................................................................................... 375 5.8.3 Maximale Zuordnung im allgemeinen Fall ............................................................................................. 380 6 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 7 Algorithmen und Datenstrukturen 8 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 9 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 B L, 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 ist2. 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 bereit3: #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 baumKnoten<T>* holeLinks(void) const; 2 3 vgl. Skriptum, 4.2.3 vgl. pr11_1, baumkno.h 10 Algorithmen und Datenstrukturen 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; baumKnoten<int> *lKind, *rKind, *z; lKind = new baumKnoten<int>(3); 11 Algorithmen und Datenstrukturen rKind = new z = new lKind = z; rKind = new z = new wurzel = z; baumKnoten<int>(4); baumKnoten<int>(2,lKind,rKind); baumKnoten<int>(5); baumKnoten<int>(1,lKind,rKind); } 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 B L und BR). Die Zeiger, die auf leere Bäume hinweisen, werden auf „NULL“ gestellt. 12 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: Zeiger auf Baumknoten Top-Element 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. 2.2 13 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.: 14 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. 15 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). 16 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 17 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 (1) Wähle zufällig eine natürliche Zahl zwischen 260 und 264 (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: 18 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) 19 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. 3.3 20 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 . Definitionen6 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 ,..., vn : Unbestimmte vom Typ 1 ,..., n , formale Parameter. ist dabei der Typ des Terms t (v1 ,..., vn ) t (v1 ,..., vn ) : 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) if g (1,2) then h(1 2) else h(1 2) fi if 1 2 or odd (2) then h(1 2) else h(1 2) fi if 1 2 or false then h(1 2) else h(1 2) fi if false or false then h(1 2) else h(1 2) fi if false then h(1 2) else h(1 2) fi h(1 2) h(1) j (1 1) j (1 1) j (0) j (1 1) j (0) j (1 1) j (0) j (2) j (2 0 3) j (2) (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 : konsekutive Ausführung mehrerer elementarer Termauswertungsgebiete 21 Algorithmen und Datenstrukturen 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 ) y für alle y f (1, y ) f (0, y ) 1 y 1 f (2, y ) f (1, y ) 1 y 1 1 y 2 …. f (n, y ) y n f (1, y ) f (1, y ) (1 y ) 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 ,..., an ) 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 Teiler9(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) ggT (15,39) ggT (15,24) ggT (15,9) ggT (9,15) ggT (9,6) ggT (6,9) ggT (6,3) ggT (3,6) ggT (3,3) 3 3. Fibonacci-Zahlen: f 0 f1 1, f i f i 1 f i 2 für i 0 fib( x) if ( x 0) or ( x 1) then1else 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 22 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 Datentypen11 Rechnergeeignete Modellierung der realen Welt (objektorientierte Analyse) Problemnaher Entwurf von Softwaresystemen (objektorientiertes Design) Problemnahe Implementierung (objektorientierte Programmierung Vgl. 1.3.5.1 23 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. 24 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 Objekten12. - 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 Polymorphismus13 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öglichen14. Mit Hilfe von Modifikatoren15 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, … 25 Algorithmen und Datenstrukturen konkreten Methoden, deren Deklaration auch Implementierungen besitzen16. 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++ objektorientiert18, 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 26 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 n2 Subscripts und andere mathematische Formulierungen sind erlaubt Pseudo-Code Elemente: Sequenz Verzeigung { Anweisung_1 Anweisung_2 … if Bedingung { Anweisung_1 Anweisung_2 27 Algorithmen und Datenstrukturen … Anweissung_n } else { Anweisung_m … Anweisung_k } Anweisung_n } 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-Struktur19 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; break; Der Ausdruck muß ganzzahlig sein. Das Programm wird an der case-Anweisung fortgesetzt., deren Wert dem Ausdruck entspricht. Falls Ausdruck keinem der Werte entspricht, geht es mit der Vgl. Skriptum zur Vorlesung im WS 2005 / 2006: Programmieren in Java, 2.4 28 Algorithmen und Datenstrukturen Mehrfachauswahl default: Anweisungn } 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 29 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 Korrektheitsformel21 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" 30 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?“ 31 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 32 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-Zeit22. #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 Literatur23 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 33 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 ) co 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 ) co 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 ) c2 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 TA (n) O( g ) gilt. Wenn nicht explizit anders beschrieben, ist TA (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 34 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 , a2 , a3 ,..., an Z Gesucht: Der Index i der (ersten) größten Zahl unter den a i (i=1,...,n) Lösung: max = 1; for (i=2;i<=n;i++) if (amax < ai) max = i 35 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: wenn ai das größte der Elemente 1 Tn n . „max = i“ wird genau dann ausgeführt, a1 , a 2 , a3 ,..., ai ist. Angenommen wird Gleichverteilung: Für jedes i = 1, ... , n hat jedes der Elemente a1 , a2 , a3 ,..., an 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) ist für genügend große n f (n) O( g (n)) : c, n0 n n0 : f (n) c g (n) , d.h. 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 36 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; Aufwand der Suche: Ergebnis hängt von der Eingabe ab, d.h. von n, a1, …, an und b 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 37 Algorithmen und Datenstrukturen N N N N N nn 1 n 1 1 2 ... n 1 2 ... n N n n n n n 2 2 M n 1 - für eine Suche S Schritte, also S im Mittel bei Gleichverteilung 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 existieren28, 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 i 1 C1 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: T min=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 f (n) O( g (n)) gilt 29 vgl. Skriptum, 1.2.7 38 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 C0 für n 0 Tn C1 T n 1) für n 0 Durch wiederholtes Einsetzen: T (n) C1 C1 ... C1 C0 O(n) 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 { // Teileschri tt /* teile x in 2Teilproble me x1und x 2 jeweils derGröße n / 2 * / (2) loe1 f ( x1, n / 2); loe 2 f ( x 2, n / 2); // Herrschesc hritt /* Setze Loesung loes für x aus loe1und loe 2 zusammen * / return loes ; (3) } } 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) C0 falls n 1 T (n) C1 n 2 T n / 2 3031 Durch Einsetzen ergibt sich: T (n) C1n 2C1 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 n und T (1) C0 erhält man: T (n) C1 n log 2 (n) C0 n Mit 2 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 39 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-Zahlen32. 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 3.2.3 40 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 log4 3 O(n 0.379 ). Somit ist f (n) (n log4 3 ) mit 0.2 . Weiterhin gilt für hinreichend große n: a f (n) 3 (n / 4) log( n / 4) 3 / 4n 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. 41 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. 42 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 überschreitet34. 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 Polynomialzeit-Algorithmus 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. NPvollstä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 43 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 sie aber leider nur durch die etwas schwächere exponentielle Explosion. Die Rechenzeit nimmt 36 Vorbild für viele Optimierungsaufgaben, wie sie vor allem im Operations Research immer wieder vorkommen. 44 Algorithmen und Datenstrukturen 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 n ... a 2 N 2 a1 N a 0 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 das Durchsuchen proportional N ( N 1) / 2 , d.h. O(N2), 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. 45 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.B38.: 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 46 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: 47 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: 48 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 geordnet (z.B. nach Namen) im Vorlesungsverzeichnis) 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 49 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 Bibliotheksverwaltung39 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 50 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. 51 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) 52 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) xM 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 53 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 54 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 55 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 Datenstrukturen40, 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 56 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 57 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 Java42 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 58 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 Schlange45 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 59 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 K i 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] wk8 [6] [10] wk9 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 60 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: 61 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 62 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 Sortieren48 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 63 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 Menge49. 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 64 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 Studenten50 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 65 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: 66 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 67 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 Datentyps51 - 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 ADT52 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 68 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. 69 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 -> Binaerbaum<T> bin : Binaerbaum<T>, T, Binaerbaum<T> Binaerbaum<T> Binaerbaum<T> links : Binaerbaum<T> Binaerbaum<T> rechts : Binaerbaum<T> wert : Binaerbaum<T> -> T boolea istLeer : Binaerbaum<T> 70 Algorithmen und Datenstrukturen 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. 71 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. 72 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) 73 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 - Assoziation mit angefügten Attributen oder Klassen 74 Algorithmen und Datenstrukturen - 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. 75 Algorithmen und Datenstrukturen Ein A ist immer mit Ein A ist immer mit Ein A ist mit Ein A ist mit keieinem B assoziiert einem oder mehre- keinem oder einem nem, einem oder ren B assoziiert B asso-ziiert 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.: 76 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 77 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 Klasse schon die Implementierung einiger Methoden enthalten, während die Schnittstelle die Verschiebung der Definition aller Methoden erzwingt. 78 Algorithmen und Datenstrukturen 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 79 Algorithmen und Datenstrukturen 1.3.5.5 Die Implementierung abstrakter Datentypen in C++ Klassen (Objekttypen) sind Realisierungen abstrakter Datentypen und umfassen: Daten und Methoden (Operationen auf den Daten). Das C++-Klassenkonzept (definiert über struct, union, class) stellt ein universell einsetzbares Werkzeug für die Erzeugung neuer Datentypen (, die so bequem wie eingebaute Typen eingesetzt werden können) zur Verfügung. Zu einer Klasse gehören Daten- und Verarbeitungselemente (d.h. Funktionen). Bestandteile einer Klasse können dem allgemeinen Zugriff entzogen sein (information hiding). Der Programmentwickler bestimmt die Sichtbarkeit eines jeden Elements. Einer Klasse ist ein Name (TypBezeichner) zugeordnet. Dieser Typ-Bezeichner kann zur Deklaration von Instanzen oder Objekten des Klassentyps verwendet werden. 1.3.5.5.1. Das Konzept für benutzerdefinierte Datentypen: class bzw. struct Definition einer Klasse Sie besteht aus 2 Teilen 1. dem Kopf, der neben dem Schlüsselwort class (bzw. struct, union) den Bezeichner der Klasse enthält 2. den Rumpf, der umschlossen von geschweiften Klammern, abgeschlossen durch ein Semikolon, die Mitglieder (member, Komponenten, Elemente) der Klasse enthält. Der Zugriff auf Elemente von Klassen wird durch 3 Schlüsselworte gesteuert: private: Auf Elemente, die nach diesem Schlüsselwort stehen, können nur Elementfunktionen zugreifen, die innerhalb derselben Klasse definiert sind. „class“-Komponenten sind standardmäßig „private“. protected: Auf Elemente, die nach diesem Schlüsselwort stehen, können nur Elementfunktionen zugreifen, die in derselben Klasse stehen und Elementfunktionen, die von derselben Klasse abgeleitet sind public: Auf Elemente, die nach diesem Schlüsselwort stehen, können alle Funktionen in demselben Gültigkeitsbereich, den die Klassendefinition hat, zugreifen. Der Geltungsbereich von Namen der Klassenkomponenten ist klassenlokal, d.h. die Namen können innerhalb von Elementfunktionen und im folgenden Zusammenhang benutzt werden: klassenobjekt.komponentenname zeiger_auf_klassenobjekt->komponentenname klassenname::komponentenname - Der Punktoperator „.“ wird benutzt, falls der Programmierer Zugriff auf ein Datenelement oder eine Elementfunktion eines speziellen Klassenobjekts wünscht. - Der Pfeil-Operator „->“ wird benutzt, falls der Programmierer Zugriff auf ein spezielles Klassenobjekt über einen Zeiger wünscht Klassen besitzen einen öffentlichen (public) und einen versteckten (private) Bereich. Versteckte Elemente (protected) sind nur den in der Klasse aufgeführten Funktionen zugänglich. Programmteile außerhalb der Klasse dürfen nur auf den mit public als öffentlich deklarierten Teil einer Klasse zugreifen. 80 Algorithmen und Datenstrukturen Eine mit struct definierte Klasse ist eine Klasse, in der alle Elemente gemäß Voreinstellung public sind. Die Elemente einer mit union definierten Klasse sind public. Dies kann nicht geändert werden. Die Elemente einer mit class definierten Klasse sind gemäß Voreinstellung private. Die Zugriffsebenen können verändert werden. Datenelemente und Elementfunktionen Datenelemente einer Klasse werden genau wie die Elemente einer Struktur angegeben. Eine Initialisierung der Datenelemente ist aber nicht erlaubt. Deshalb dürfen Datenelemente auch nicht mit const deklariert sein. Funktionen einer Klasse sind innerhalb der Klassendefinition deklariert. Wird die Funktion auch innerhalb der Klassendefinition definiert, dann ist diese Funktion inline. Elementfunktionen von Klassen unterscheiden sich von den gewöhnlichen Funktionen: - Der Gültigkeitsbereich einer Klassenfunktion ist auf die Klasse beschränkt (class scope). Gewöhnliche Funktionen gelten in der ganzen Datei, in der sie definiert sind - Eine Klassendefinition kann automatisch auf alle Datenelemente der Klasse zugreifen. Gewöhnliche Funktionen können nur auf die als "public" definierten Elemente einer Klasse zugreifen. Es gibt zwei Möglichkeiten (Schnittstellenfunktionen) in Klassen: zur Angabe von Elementfunktionen - Definition der Funktion innerhalb von Klassen - Deklaration der Funktion innerhalb, Definition der Funktion außerhalb der Klasse. Mit dem Scope-Operator :: wird bei der Defintion der Funktion außerhalb der Klasse dem Compiler mitgeteilt, wohin die Funktion gehört. Beim Aufruf von Elementfunktionen muß der Name des Zielobjekts angegeben werden: klassenobjektname.elementfunktionen(parameterliste) Die Funktionen einer Klasse haben die Aufgabe, alle Manipulationen an den Daten dieser Klasse vorzunehmen. Der „this“-Zeiger Jeder (Element-) Funktion wird ein Zeiger auf das Element, für das die Funktion aufgerufen wurde, als zusätzlicher (verborgener) Parameter übergeben. Dieser Zeiger heißt this, ist automatisch vom Compiler) deklariert und mit der Adresse der jeweiligen Instanz (zum Zeitpunkt des Aufrufs der Elementfunktion) besetzt. "this" ist als *const deklariert, sein Wert kann nicht verändert werden. Der Wert des Objekts (*this), auf den this zeigt, kann allerdings verändert werden. 81 Algorithmen und Datenstrukturen 1.3.5.5.2. Generischer ADT In den Erläuterungen zur axiomatischen Methode wurde der Typ Schlange<T> als generischer abstrakter Datentyp bezeichnet, da er „übliche Schlangeneigenschaften“ bereitstellt. Ein Typ Stapel<T> stellt dann übliche Stapeleigenschaften bereit. Der eigentliche benutzerdefinierte Datentyp bezeiht sich konkret auf einen speziellen Typ, z.B. Schlange<int>, Stapel<int>. In C++ kann mit Hilfe von templates (Schablonen) zur Übersetzungszeit eine neue Klasse bzw. auch eine neue Funktion erzeugt werden. Schablonen (Templates) können als "Meta-Funktionen" aufgefaßt werden, die zur Übersetzungszeit neue Klassen bzw. neue Funktionen erzeugen. Klassenschablonen Mit einem Klassen-Template (auch generische Klasse oder Klassengenerator) kann ein Muster für Klassendefinitionen angelegt werden. In einer Klassenschablone steht vor der eigentlichen Klassendefinition eine Parameterliste. Hier werden in allgemeiner Form „Datentypen“ bezeichnet, wie sie die Elemente einer Klasse (Daten und Funktionen) benötigen. Bsp.: Klassenschablone für einen Stapel53 template <class T> class Stapel { private: static const int stapelgroesse; T *inhalt; int nachf; void init() { inhalt = new T[stapelgroesse]; nachf = 0; } public: Stapel() { init(); } Stapel(T e) { init(); push(e); } Stapel(const Stapel&); ~Stapel() { delete [] inhalt; } // Destruktor Stapel& push(T w) { inhalt[nachf++] = w; return *this; } Stapel& pop() { nachf--; return *this; } T top() { return inhalt[nachf - 1]; } int stapeltiefe() { return nachf; } int istleer() { return nachf == 0; } long groesseSt() const { return sizeof(Stapel<T>) + stapelgroesse * sizeof(T); } Stapel& operator = (const Stapel<T>&); int operator == (const Stapel<T>&); friend ostream& operator << (ostream& o, const Stapel<T>& s); }; template <class T> const int Stapel<T> :: stapelgroesse = 100; // Kopierkonstruktor template <class T> Stapel<T> :: Stapel(const Stapel& s) { init(); // Anlegen eines neuen Felds nachf = s.nachf; for (int i = 0; i < nachf; i++) inhalt[i] = s.inhalt[i]; } // Operatorfunktion (Stapel mit eigenem Zuweisungsoperator) 53 PR12351.CPP 82 Algorithmen und Datenstrukturen template <class T> Stapel<T>& Stapel<T> :: operator = (const Stapel<T>& r) { nachf = r.nachf; for (int i = 0; i < nachf; i++) inhalt[i] = r.inhalt[i]; return *this; } // Funktionscablone zum Aufruf einer Methode, die den Platzbedarf // fuer Struktur und gestapelte Elemente ermitteln template <class T> long groesse(const Stapel<T>& s) { return s.groesseSt(); } // Operator <<() template <class T> ostream& operator << (ostream& o, const Stapel<T>& s) { o << "<"; for (int i = 0; i < s.nachf; i++) { if ((i <= s.nachf - 1) && (i > 0)) o << ", "; o << s.inhalt[i]; } return o << ">"; } 83 Algorithmen und Datenstrukturen 2. Datenstrukturen und Algorithmen in C++ Datenstrukturen und Algorithmen sind eng miteinander verbunden. Die Wahl der richtigen Datenstruktur entscheidet über effiziente Laufzeiten. Beide erfüllen nur alleine ihren Zweck. Leider ist die Wahl der richtigen Datenstruktur nicht so einfach. Eine Reihe von schwierigen Problenem in der Informatik wurden deshalb noch nicht gelöst, da eine passende Datenorganisation bis heute noch nicht gefunden wurde. Wichtige Datenstrukturen (Behälterklassen, Collection, Container) werden in C++ und Java bereitgestellt. Auch Algorithmen gehören dazu. Sammlungen (Kollektionen) sind geeignete Datenstrukturen (Behälter) zum Aufbewahren von Daten. Durch ihre vielfältigen Ausprägungen können sie zur Lösung unterschiedlicher Aufgaben herangezogen werden. Ein Lösungsweg umfaßt dann das Erzeugen eines solchen Behälters, das Einfügen, Modifizieren und Löschen der Datenelemente. Natürlich steht dabei im Mittelpunkt der Zugriff auf die Datenelemente, das Lesen der Dateninformationen und die aus diesen Informationen resultierenden Schlußfolgerungen. Verallgemeinert bedeutet dies: Das Suchen nach bestimmten Datenwerten, die in den Datenelementen der Datensammlung (Kollektion) gespeichert sind. Suchmethoden bestehen aus einer Reihe bestimmter Operationen. Im wesentlichen sind dies - Das Initialisieren der Kollektion ( die Bildung einer Instanz mit der Datenstruktur, auf der die Suche erfolgen soll - das Suchen eines Datenelements (z.B. eines Datensatzes oder mehrerer Datensätze) in der Datensammlung mit einem gegebenen Kriterium (z.B. einem identifizierenden Schlüssel) - das Einfügen eines neuen Datenelements. Bevor eingefügt werden kann, muß festgestellt werden, ob das einzufügende Element in der Kollektion schon vorliegt. - das Löschen eines Datenelements. Ein Datenelement kann nur dann gelöscht werden, falls das Element in der Kollektion vorliegt. Häufig werden Suchvorgänge in bestimmten Kollektionen (Tabellen) benötigt, die Daten über identifizierende Kriterien (Schlüssel) verwalten. Solche Tabellen können als Wörterbücher (dictionary) oder Symboltabellen implementiert sein. In einem Wörterbuch sind bspw. die „Schlüssel“ Wörter der deutschen Sprache und die Datensätze, die zu den Wörtern gehörenden Erläuterungen über Definition, Aussprache usw. Eine Symboltabelle beschreibt die in einem Programm verwendeten Wörter (symbolische Namen). Die Datensätze sind die Deklarationen bzw. Anweisungen des Programms. Für solche Anwendungen sind nur zwei weitere zusätzliche Operationen interessant: - Verbinden (Zusammenfügen) von Kollektionen, z.B. von zwei Wörterbüchern zu einem großen Wörterbuch - Sortieren von Sammlungen, z.B. des Wörterbuchs nach dem Schlüssel Die Wahl einer geeigneten Datenstruktur (Behälterklasse) ist der erste Schritt. Im zweiten Schritt müssen die Algorithmen implementiert werden. Die StandardBibliotheken der Programmiersprachen C++ und Java bieten Standardalgorithmen an. 84 Algorithmen und Datenstrukturen 2.1 Die C++-Standardbibliothek und die STL Der Entwurf der Standard-C++-Bibliothek54 wurde relativ spät um einen Teil erweitert, der Standard Template Librarary (STL) genannt wurde, da er u.a. viele Templates zur Verfügung stellt. Die STL erhöht die Einsetzbarkeit von C++ enorm, da wesentliche, immer wiederkehrende Templates dort festgelegt sind. In ihr befinden sich Klassen für Sammlungen, aber auch Klassen für Strings und Klassen, die sich mit Funktionsobjekten beschäftigen. Die STL benutzt Klassenschablonen, auch für die Klasse „string“55. Die wesentlichen Komponenten der STL sind - Algorithmen z.B. Sortieren - Container , wie bspw. Listen oder Mengen - Iteratoren , mit deren Hilfe Container traversiert werden können - Funktionsobjekte , die eine Funktion einkapseln, die von anderen Komponenten benutzt werden kann. - Adaptoren durch die eine andere Schnittstelle von einer Komponente gebildet werden kann. 2.2 Die Konzepte der STL 2.2.1 Container Ein Container ist ein Objekt56, das der Speicherung anderer Objekte dient, z.B. eine Liste, ein Array oder eine Schlange. Mit der Container-Klasse wird der Typ eines solchen Objekts definiert. Objekte, die ein Container speichert, sind die Elemente. Der Typ der Elemente ist der Elementtyp. Da Container-Klassen für einen beliebigen Elementtyp definiert werden sollen, sind in der Standard-C++-Bibliothek Templates für Container-Klassen definiert. Es wird unterschieden zwischen - Sequenzen, in denen jedes Element eine Position besitzt, wie z.B. Listen - Adaptoren für Sequenzen, die einer Sequenz eine andere Schnittstelle verleihen, wie z.B. ein Stapel - assoziative Container, die jeweils eine Menge von Elementen verwalten, auf die über ihren Inhalt (Wert) zugegriffen wird (z.B. set und map). Sequenzen Adaptoren von Sequenzen Template für Container-Klasse template <class T57> class vector template <class T> class list template <class T> class deque template <class T,classContainer=deque<T> > class stack 54 Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 5.1 Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 2.2.3.3 56 Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 5.2 57 T ist jeweils der Elementtyp 55 85 Algorithmen und Datenstrukturen Assoziative Container template <class T, class Container = deque<T> > class queue template <class T,class Container=deque<T>,class Compare= less<typname Container::value_type> > class priority_queue template <class Key58,class Compare59=less<Key> > class set template <class Key,class T,class Compare = less<Key> >class map Abb. 2.2-1: Templates von Container-Klassen Die Stärke der STL ist, dass, soweit möglich, alle Container die gleiche Schnittstelle besitzen. Die Schnittstelle wird (leider) nicht in einer abstrakten Klasse definiert sondern durch eine Tabelle, in der die gemeinsamen Eigenschaften aller Container festgelegt werden. Konstruktoren Destruktoren Zuweisung Traversieren Vergleiche Größe Typen X u; X() X(a) Xu=a (&a)->~X() r=a a.begin() a.end() a == b a != b a<b a>b a <= b a >= b a,size() a.empty() X::value_type X::iterator X::const_iterator X::size_type u wird leerer Container Leerer Container Neuer Container mit X(a) == a wird erzeugt X u; u = a; Der Inhalt des Containers a wird gelöscht Dem Container mit Referenz r wird er Container a zugewiesen liefert einen Iterator auf das erste Element des Containers zurück liefert einen Iterator auf den gedachten Nachfolger des letzten Elements des Containers a zurück Prüfen, ob die Container a und b elementweise gleich sind !(a == b) lexikographischer Vergleich der Elelente von a.begin() bis a.end() mit b.begin() bis b.end() b>a !(a>b) !(a<b) Anzahl der Elemente in a a.size() == 0 Elementtyp Typ eines Iterators, der auf ein Element zeigt Typ eines Iterators, der auf ein konstantes Element zeigt Typ nicht negativer Werte von bspw. Iteratordifferenzen und von size() Abb. 2.2-2: Wichtige Eigenschaften eines Containers Die für die verschiedenen Container-Klassen implementierten Schnittstellen sind so gehalten, dass wo immer möglich und sinnvoll gleichnamige Memberfunktionen mit gleichartiger Funktionalität existieren. So stehen u.a., für alle Container-Klassen gleichnamige Funktionen zum Ermitteln der Iteratorgrenzwerte, sowie die Vergleichsoperatoren und der Zuweisungsoperator zur Verfügung. Die verschiedenen Containerklassen lassen sich teilweise gleichzeitig verwenden. Anforderungen an den Elementtyp ("T"). Alle Sequenzen und auch die assoziativen Container setzen einige Eigenschaften vom Elementtyp ("T") voraus. Vom Elementtyp "T" muß definiert und öffentlich zugänglich sein: - Konstruktor ohne Argumente: T() - Kopierkonstruktor: T(const T&) - Destruktor: ~T() - Zuweisung: T& operator=(const T&) - static void operator new(size_t) 58 59 Key ist der Typ der Schlüssel der abgespeicherten Elemente Compare ist eine Vergleichsklasse 86 Algorithmen und Datenstrukturen Zu allen Container-Klassen (außer den Container-Adaptern) sind jeweils IteratorKlassen definiert. Iteratoren erlauben einen zeigerähnlichen Zugriff zu den Elementen. Alle Container der STL besitzen ein Wert-Semantik. Das bedeutet, dass die zu einem Container aufgenommenen Elemente als Kopie und nicht als Referenz (Pointer) abgelegt werden und auch Kopien der enthaltenen Elemente zurückgeliefert werden. Vorteile: Probleme, wie Verweise auf nicht mehr existierende Elemente können nicht mehr auftreten. Nachteile:: Das Kopien der Elemente dauert u.a. länger als das Kopieren der Adresssen Arbeiten mit String: Auch die Klasse string enthält die wesentlichen Eigenschaften einer Behälterklasse. Ein String kann als eine Art Vektor mit Zeichen verstanden werden, also ähnlich dem Typ vector<char>. Entsprechend gibt es für einen String auch die Iteratoren, die Anfang und Ende eines Strings bezeichnen. Da die meisten Algorithmen auf Iteratoren definiert sind, lassen sich diese auch für Strings aufrufen. Fehlerbehandlung in der STL: In der STL finden praktisch keine Überprüfungen auf Fehler wie - Überschreitungen von Bereichsgrenzen - Verwendung von falschen, fehlerhaften oder ungültigen Iteratoren - Zugriff zu nicht vorhandenen Elementen statt. Beim Auftritt derartiger Fehler ist das Verhalten der entsprechenden Bibliothekskomponenten und damit das Programm undefiniert. Lediglich 2 Memberfunktionen der Container-Klasse vector (at() und reverse()) sowie die Member-Funktion der Container-Klasse deque (at()) können eine Exception auslösen. Darüber hinaus können gegebenenfalls lediglich die von anderen – durch die STL benutzte Bibliothekskomponenten oder sonstigen aufgerufenen Funktionen erzeugten Exceptions auftreten (z.B. bad_alloc bei Allokationsfehlern). Die STL behandelt (fängt) diese Exceptions aber nicht. Tritt eine Exception auf, ist der Zustand aller beteiligten STL-Objekte undefiniert. Der Grund für die nicht vorhandene Fehlerprüfung liegt in dem Grundgedanken die STL mit optimalem Zeitverhalten zu implementieren. Fehlerprüfungen kosten Zeit. 87 Algorithmen und Datenstrukturen 2.2.1.1 Sequentielle Container Sie speichern die Elemente als geordnete Mengen, in denen jedes Element eine bestimmte Postion besitzt, die durch den Zeitpunkt und Ort des Einfügens festgelegt ist. Die enthaltenen Elemente sind damit linear angeordnet und können um ihre jeweilige Position angesprochen werden. Typischerweise erfolgt die Speicherung der Element in dynamischen Arrays bzw. Listen. Sequenzen besitzen im wesentlichen zusätzlich zu dem Eigenschaften aus der Abb. 2.2-2 Methoden zum Einfügen (insert()) und Löschen (erase()). Positionieren in Sequenzen erfolgt durch Iteratoren. Die Klassen für die sequentiellen Container besitzen 2 Template-Parameter <T, allocator<T> > - T ist der Datentyp der zuz verwaltenden Objekte (Elemente) - Allocator ist eine Allokator-Klasse, die das Speichermodell für die zu verwendende dynamische Speicherverwaltung definiert. Als Default ist die Standard-Allokator-Klasse allocator<T>, die new und delete zur Speicherallokation verwendet, festgelegt. Vektor: template <class T, class Allocator = allocator<T> > class vector; Deque (double ended queue): template <class T, class Allocator = allocator<T> > class deque; Liste: template <class T, class Allocator = allocator<T> > class list; Eine Listen-Container verwaltet seine Elemente in einer doppelt verketteten Liste. 2.2.1.2 Mengen und Abbildungen Assoziative Container dienen zur Verwaltung einer Menge von Elementen, auf die über den Inhalt (Wert) zugegriffen wird. Neben dem Elementtyp muß einem Template eines Container auch die Information mitgegeben werden, wann zwei Element gleich sind. Dies geschieht durch Übergabe eines Vergleichers, der der "Kleiner-Relation" entspricht. Ist der Vergleicher "kleiner", dann gelten zwei Elemente a und b als gleich, wenn weder "kleiner() (a,b)" noch "kleiner() (b,a)" gilt. Der Vergleicher muß eine totale Ordnung der Elemente festlegen. template <class T, class Compare = less<T>60 > class set Der Template set<T, Compare> dient der Verwaltung einer Menge von Elementen, wobei die Elemente entsprechend der Definition der Gleichheit nur einmal in der Menge vorkommen. template <class T, class Compare = less<T>61, class Allocator = allocator<T> > class set Ein Multiset ist eine Menge, in der Elemente mehrfach vorkommen können. Bei jedem Einfügen wird ein neues Element eingefügt. Beim Löschen mit "a.erase(t)" werden alle Elemente, die gleich "t" sind, gelöscht. Mit "a.erase(p)" wird nur das Element, auf das "p" zeigt. gelöscht. 60 61 Das Template less<T> ist in der STL unter <functional> definiert. Das Template less<T> ist in der STL unter <functional> definiert. 88 Algorithmen und Datenstrukturen Die beiden Container-Klassen sind als balancierte Binärbäume62 implementiert. Einfügen Löschen Suchen Zählen a.insert(t) a.erase(t) a,erase(p) a.find(t) a.count(t) fügt t in a ein löscht alle Elemente in a, die gleich t sind löscht ein Element in a, auf das der Iterator p zeigt liefert einen Iterator auf das Element aus a zurück, das gleich t ist liefert die Anzahl Elemente in a zurück, die gleich t sind Abb.: Wichtige Methoden von set und multiset, „a“ ist ein Mengenobjekt, „t“ jeweils ein Objekt auf einen Elementtyp und p ein Iterator. template <class Key, class T, class Compare = less<Key>63 > class map64 Inhaltsadressierte Container sind Abbildungen (maps). Ein Schlüssel wird dabei auf ein Datum abgebildet. Der eigentliche Elementtyp ist hier jeweils ein Paar (Tupel), zusammengesetzt aus Schlüssel und Datum. Ein Paar wird mit dem Konstruktor pair(key,t) erzeugt, der aus einem Template generiert wird, das in der Definitionsdatei <utility> zu finden ist. Auf die erste Komponente eines Paares "x" kann mit x.first und auf die zweite Komponente mit x.second zugegriffen werden. map<Key, Value, Compare> und multimap<Key, Value, Compare> haben folgende Template-Argumente: Key ist der Typ des Schlüssels Value ist der Typ der assoziierten Daten Compare definiert eine Ordnungsrelation auf Key map realisiert eine einwertige Abbildung der Schlüssel auf die Werte, multimap realisiert eine mengenwertige Abbildung der Schlüssel auf die Werte. Iteratoren über map oder multimap liefern Werte des Typs pair<Key,Value>. Einfügen Löschen Suchen a.insert(pair(k,t)) a.erase(k) a,erase(p) a.find(k) Zählen a.count(k) fügt pair(k,t) in a ein löscht alle Elemente in a, deren Schlüssel gleich k sind löscht das Element in a, auf das der Iterator p zeigt liefert einen Iterator auf das Element aus a zurück, dessen Schlüssel gleich k ist liefert die Anzahl Elemente in a zurück, deren Schlüssel gleich k sind Abb. 2.2-3: Wichtige Methoden von map und multimap 2.2.1.3 Adaptoren zu Sequenzen Ein Template, das einen Container (als Template-Parameter) mit einer in der Regel eingeschränkten Schnittstelle enthält, wird "Adapter der Sequenz" genannt. Es gibt Adaptoren für den Stapel (stack), für eine Warteschlange (queue) und für eine Warteschlange mit Priorität (priority_queue). Diese Container-Adapter passen die Container-Klassen vector, deque und list an spezielle Anforderungen an. template <class T, class Container = deque<T> > class stack 62 Der Standard legt nicht direkt fest, wie assoziative Container implementiert sind. Vielfach werden diese Bäume als "Red-Black-Trees" implementiert. 63 Das Template less<T> ist in der STL unter <functional> definiert. 64 Alternative hash_map, Implementierung durch Hashing, kein Standard 89 Algorithmen und Datenstrukturen Auf einem Stapel sind die Operationen void push(const value_type &) void pop() value_type& top() bzw. const value_type& top() bool empty() definiert. Der Template-Parameter von stack muß eine Sequenz sein. template <class T, class Container = deque<T> > class queue Es werden die gleichen Methoden wie beim Stapel zur Verfügung gestellt, nur das push() am Ende einhängt und pop() das erste Element löscht. front() liefert das erste und back() das letzte Element in der Warteschlange zurück. template <class T, class Container = deque<T>, class Compare = less<typname Container::value_type> > class priority_queue Neben der Sequenz muß als weiterer Template-Parameter eine Vergleichsklasse übergeben werden, nach der die Elemente in der Warteschlange 65 sortiert werden. Als Sequenz ist nur ein Typ wie ein vector oder deque erlaubt, die die Typen front(), push_back() und pop_back() zur Verfügung stellt. Der Zugriff auf das erste Element erfolgt mit top(). neue Einträge Eintrag Eintrag Eintrag Eintrag Eintrag pop() push() niedrigste ........... dritthöchste zweithöchste höchste Priorität Abb. 2.2-4: Eine Priority Queue Die "priority_queue" nutzt die "Container"-Klassen "vector" deque.Typische Deklarationen zu einer "priority_queue" sind: bzw. priority_queue< vector<node> > x; priority_queue< deque<string>,case_insensitive_compare > y; priority_queue< vector<int>, greater<int> > z; Die Implementierung der "priority-queue" in der STL-Version von Hewlett Packard zeigt folgende Aussehen: template <class Container, class Compare> class priority_queue { protected : Container c; Compare comp; … public : bool empty() const { return c.empty(); } size_type size() const { return c.size() } value_type& top() const { return c.front(); } void push(const value_type& x { c.push_back(x); push_heap(c.begin(), c.end(), comp); } void pop() 65 http://www.cplusplus.com/reference/stl/priority_queue/ 90 Algorithmen und Datenstrukturen { pop_heap(c.begin(), c.end(), comp); c.pop_back(); } } Die STL-Funktionen push_heap() und pop_heap() werden zur Implementierung herangezogen. push() fügt das neue Element an das Ende des Container, push_heap() bringt danach das Element an seinen richtigen Platz. pop() ruft zuerst pop_heap() auf. pop_heap() bringt das Element an das Ende des Container, pop_back() entfernt das Element und verkürzt den Container. Man kann aus dieser Anwendung ersehen, wie leicht es wurde, eine ContainerKlasse mit Hilfe der STL einzurichten. Container vector list deque stack queue priority_queue map multimap set multiset string C-array Feldzugriff [] Listen O(1) O(n)66 O(1) O(1) O(n) O(log(n)) O(1) O(1) vorne O(1) O(1) O(log(n))70 O(log(n)) O(log(n)) O(log(n)) O(n)71 O(n)72 hinten O(1)67 O(1) O(1) O(1)68 O(1)69 O(1) O(1)73 Abb. 2.2-5: Laufzeitverhalten und Iterator-Möglichkeiten der Container 66 67 68 69 70 71 72 73 O(x) * : zwischendurch langer Reorganisationsaufwand möglich O(x) * : zwischendurch langer Reorganisationsaufwand möglich O(x) * : zwischendurch langer Reorganisationsaufwand möglich O(x) * : zwischendurch langer Reorganisationsaufwand möglich O(x) * : zwischendurch langer Reorganisationsaufwand möglich O(x) * : zwischendurch langer Reorganisationsaufwand möglich O(x) * : zwischendurch langer Reorganisationsaufwand möglich O(x) * : zwischendurch langer Reorganisationsaufwand möglich 91 Iteratoren Random-Access Bidirektional Random-Access Bidirektional Bidirektional Bidirektional Bidirektional Random Access Random Access Algorithmen und Datenstrukturen 2.2.1.4 Beispiele für Container-Anwendungen Huffman Coding74 mit "priority_queue"-Container Gegeben ist eine Datei, z.B. mit folgendem Inhalt AAAAAAAAAAAAAA BBB C D E F GGG HHHH Gesucht ist eine geeignete Binärcodierung (Code variabler Länge), die die Häufigkeit des Auftretens der Zeichen berücksichtigt. Der Huffman-Algorithmus relisiert die Binärcodierung mit Hilfe eines Binärbaums, der folgendes Aussehen haben könnte: 28 0 1 14 14 0 1 A 6 8 0 1 0 3 3 4 1 4 0 B G 1 H 2 2 0 1 1 Abb. 2.2-6: Huffman-Codierungs-Baum E 1 0 1 1 C 1 F D Jeder Knoten hat ein Gewicht, der den Platz im Huffman-Codierungs-Baum festlegt. Die STL "priority_queue" wird zunächst zur Aufnahme der eingelesenen Zeichen benutzt. Je nach Häufigkeit des Vorkommens der Zeichen erfolgt die Einordnung. Danach werden die beiden Einträge (Knoten) mit der niedrigsten Priorität entfernt, ein neuer interner Knoten (Addition der Gewichte) gebildet. Der neue Knoten wird wiedern in die priority_queue gebracht. Das wird solange wiederholt bis nur ein einziger Knoten in der priority_queue vorliegt. 74 vgl. 3.2.1.2, test32_12 92 Algorithmen und Datenstrukturen 2.2.2 Iteratoren Bei allen Containern werden Iteratoren75 verwendet, um einen Container zu traversieren oder evtl. auch, um auf ein bestimmtes Element zu verweisen. Die einzelnen Container stellen bidirektionale Iteratoren oder Iteratoren zum wahlfreiem Zugriff bereit. vector<T>::iterator list<T>::iterator deque<T>::iterator set<T,Compare>::iterator multiset<T,Compare>::iterator76 map<Key,T,Compare77>::iterator multimap<Key,T,Compare>::iterator random_access_iterator bidirectional_iterator random_access_iterator bidirectional_iterator bidirectional_iterator bidirectional_iterator bidirectional_iterator - Iteratoren sind Verallgemeinerungen von Zeigern - Zeiger sind Iteratoren im Sinne der STL und können überall eingesetzt werden, wo Iteratoren benutzt werden - Vorwärtsiteratoren unterstützen Prä- und Postinkrement (++). - Rückwärtsiteratoren unterstützen Prä- und Postdekrement (--). Nicht alle Container bieten Rückwärtsiteratoren - Random-Access-Iteratoren unterstützen Zeiger-Arithmetik - Die Algorithmen der STL operieren auf Iteratoren - Alle Iterator-Klassen der STL stellen – unabhängig von der Container-Klasse, an die sie jeweils gebunden sind und unabhängig von ihrer eigenen Implementierung – eine einheitliche Schnittstelle zur Verfügung. Diese wird durch die Operator-Funktion gebildet, die die für Zeiger anwendbaren Operationen implementieren (wie Dereferenzierung, Incrementierung). - In jeder Container-Klasse – ausser den Container-Adaptern – sind die implementierungsabhängigen public Typen iterator und const_iterator definiert. Container-Elemente; die von IteratorObjekten des Typs const_iterator referiert werden, können nicht geändert werden. 75 Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 5.3 Iteratoren des Typs map bzw. multimap liefern Werte des Typs pair<Key,Value> 77 Compare definiert eine Ordnungsfunktion auf Key 76 93 Algorithmen und Datenstrukturen iterator iterator_category {abstract} ++r {abstract} r++ {abstract} *r++ {abstract} input iterator output iterator X(a) X u(a) X u = a; a == b a != b *a X(a) X u(a) X u = a; *a = b forward iterator Xu X() r1 = r2 bidirectional iterator --r; r--; random access iterator r += n r + n n + r r -= n r - n r2 – r1 r1 < r2 r1 >= r2 r1 <= r2 Abb.2.2-7: Vererbungshierarchie und Eigenschaften von Iteratoren 94 Algorithmen und Datenstrukturen 2.2.3 Algorithmen - Sammlung von für Container-Klassen relevante Algorithmen(-Muster)78 - Durch #include <algorithm> verfügbar - Ein besondere –sehr kleine – Gruppe von numerischen Algorithmen ist nicht in der STL, sondern in der numerischen Bibliothek enthalten. Sie sind in der Headerdatei <numeric> definiert. - Jeder Algorithmus ist eine (oder mehrere) Template-Funktion(en) - Diese Algorithmen sind generisch als freie Funktions-Templates implementiert. Sie greifen zu den Elemente der Container nur direkt über Container zu, d.h. sind unabhängig von speziellen Implementierungs-Details der verschiedenen ContainerKlassen. Die Trennung der Algorithmen von den Containern widerspricht zwar objektorientierten Grundkonzepten, hat aber den Vorteil einer größeren Flexibilität, effizienteren Implementierung79 und einfachereren Erweiterbarkeit (Anwendung auch für selbstdefinierte Container- und Iterator-Klassen). .- Alle Algorithmen bearbeiten einen oder mehrere Bereiche (ranges) von Elementen. Ein derartiger Bereich kann alle Elemente eines Containers umfassen oder nur aus einer Teilmenge desselben bestehen. Der zu bearbeitende Bereich wird durch zwei als Funktionsparameter zu übergebende Iteratoren festgelegt. Dabei referiert der erste Iterator das erste zu bearbeitende Element und der zweite Iterator das Element der auf das letzte zu bearbeitende unmittelbar folgt. Die zu bearbeitende Menge wird also immer als halboffende Menge angegeben: Sie besteht aus allen Elementen zwischen dem ersten (einschließlich) und dem letzten (ausschließlich) Element. Dabei führen Algorithmen keinerlei Bereichs- und Gültigkeitsprüfungen aus. - Bei den meisten Algorithmen, die mit mehreren Bereichen arbeiten, wird nur der erste Bereich direkt durch Anfangs- und End-Iterator festgelegt. Für die anderen Bereiche wird nur ein Anfangs-Iterator angegeben. Das Ende ergibt sich dann aus der Funktion und der Arbeitsweise des Algorithmus. - Sehr viele Algorithmen können alle aus Container-Klassen angewendet werden. U.a. bestimmt die vom Algorithmus verwendete Iterator-Kategorie die Anwendbarkeit. So lassen sich bspw. Algorithmen, die mit Random-AccessIteratoren arbeiten, nicht auf assoziative Container anwenden. Weiterhin können Algorithmen, die Elemente verändern, nicht auf Container angewendet werden, deren Elemente als konstant betrachtet werden (z.B. assoziative Container). - Die Anwendung der Algorithmen ist nicht auf Container-Klassen der STL beschränkt. Sie lassen sich vielmehr für alle Sequenzen von Daten, für die Iteratoren zum Durchlaufen existieren, einsetzen. Insbesondere arbeiten sie auch mit Ein-bzw. Ausgabe-Streams, den String-Klassen der Standard-Bibliothek sowie mit normale C-Arrays 78 79 Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 5.4 Algorithmus muß nicht für jede Container-Klasse gesondert als Member-Funktion realisiert werden 95 Algorithmen und Datenstrukturen Nicht modifizierende Sequenzoperationen Modifizierende Sequenzoperationen Sortierte Sequenzen Mengenalgorithmen80 Heap-Oprationen Miminimum, Maximum Permutationen for_each, find, find_if, find_first_if, adjacent_find, cont, count_if, mismatch, equal, search, find_end, search_n transform, copy, copy_backwards, swap, iter_swap, swap_ranges, replace, replace_if, replace_copy, fill, fill_in, generate, generate_n, remove, remove_if, remove_copy, remove_copy_if, unique, unique_copy, reverse, reverse_copy,rotate, rotate_copy,random_shuffle sort, stable_sort, partial_sort, partial_sort_copy, nthelement, lower_bound, upper_bound, equal_range, binary_search, mege, inplace_merge, partition, stable_partition includes, set_union, set_intersection, set_difference, set_symmetric_difference make_heap, push_heap, pop_heap, sort_heap min, max, min_element, max_element, lexicographical-compare next_permutation, prev_permutation Abb. 2.2-8: Algorithmenübersicht 2.2.4 Funktionsobjekte Definition - Klasse, die als Funktion benutzt werden kann - muß den Operator () überladen81 Bsp.: class meinSpeziellerFunktor { public: void operator()82(…) { …. } }; … meinSpeziellerFunktor f; f(…); // ruft meinSpeziellerFunktor::operator() auf Vorteile: - ein Funktor kann einen eigenen Zustand haben -- als Member-Variable der Klasse -- es kann mehrere Instanzen eines Funktors geben - Jeder Funktor ist sein eigener Typ, z.B. Container mit spezieller Sortierung - Funktoren sind oft schneller als Funktionen -- Der Compiler kann bei der Template-Instanziierung optimieren Vordefinierte Funktionsobjekte - Standard-Funktionsobjekte83 80 Diese Algorithmen arbeiten mit sortierten Bereichen vgl. pr54010, Programmieren in C++ 82 operator() kann beliebige Parameter haben 81 96 Algorithmen und Datenstrukturen - vordefiniert in <functional> - Prädikate: equal_to, greater, … ; geben bool zurück - Funktionen: plus, minus, multiplies, divides, modulus, negate geben Wertetyp T zurück. - STL bietet alle normale C++-Operatoren als Funktoren-Templates an -- plus, minus, multiplies, divides, modulus, negate -- equal_to, not_equal_to, greater, greater_equal -- logical_and, logical_or, logical_not - Spezielle Templates zum partiellen Instanziieren 84: - bind1st, bind2nd um ersten bzw. 2. Parameter fest zu halten. Bsp.: bind2nd(greater<int>, 42) - instanziiert einen Funktor greater der 2 Argumente nimmt und den Operator > auf diese anwendet. - instanziiert einen Funktor bind2nd mit 2 Template-Argumenten: Funktor-Objekt greater, 42 - wann immer operator() von bind2nd mit diesem Argument aufgerufen wird, ruft dieser greater mit diesem Argument und der Konstante 42 auf Funktoren-Adapter - bind1st -- bind1st::operator()(arg) -> op(value,arg) -> op(arg,value) -> !op(arg) -> !op(arg1,arg2) - bind2nd -- bind2nd::operator()(arg) - not1 -- not1::operator()(arg) - not2 -- not2::operator()(arg1,arg2) Funktoren-Adapter für Funktionen - ptr_fun(fct) Funktoren-Adapter für Member-Funktionen - benutzt Pointer auf Member-Funktionen -- Notation: &class::member_function -- erlaubt das Aufrufen einer Member-Funktion ohne den Namen fest zu codieren - mem_fun_ref(fct) - mem_fun(fct) 83 84 Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 1.7.9 Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 5.1.2.2 97 Algorithmen und Datenstrukturen 2.3 Templates für Algorithmen und Datenstrukturen 2.3.1 Darstellung von Graphen mit sequentiell gespeicherte Listen 2.3.1.1 Die Datenstruktur Graph Grundlagen Graphen werden häufig zur Darstellung von Problemsituationen herangezogen. So zeigt der folgende (ungerichtete) Graph die Verkehrsverbindungen zwischen Städten: 752 604 763 648 504 432 355 Abb. Ein (ungerichteter) Graph Ein (ungerichteter) Graph besteht aus Knoten (Vertices, Nodes) und Kanten (Edges). Knoten tragen in der Regel zur Identifikation eine Knoten-Identifizierung. Kanten können gewichtet sein (z.B. mit Entfernungsangaben). Beim gerichteten Graphen ersetzen Pfeile die Kanten. Daduch ist die ein Fluß in Richtung des Pfeils bestimmt. Im gerichteten Graphen wird die Kante durch ein Paar (Vi,Vj) beschrieben. Vi ist der Start- und Vj der Endknoten. Ein Pfad P(VS,VE) ist eine Folge von Knoten: VS ist der Startknoten, VE ist der Endknoten und jedes in dem Pfad aufgeführte Paar ist eine Kante. Darstellung Es gibt eine Reihe gebräuchlicher Darstellungen. Am häufigsten sind Adjazenzmatrix und Adjazenzliste85. 85 vgl. 5.1.3, 2. 98 Algorithmen und Datenstrukturen 2 A B 1 B 4 3 A 5 C C E 7 D E D Adjazenz-Matrizen: 0 0 0 0 0 2 0 4 0 3 1 5 0 7 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 1 0 0 0 0 1 1 0 0 1 1 0 0 0 0 0 0 0 1 0 Adjazenzlisten A B 2 B C C C 1 A B C 5 B A C B 4 C A D C 7 E B 3 D E Abb.: Adjazensmatrizen und Adjazenzlisten 99 E C D Algorithmen und Datenstrukturen 2.3.1.2 Die STL-Containerklasse vector zur Implemetierung einer Knotenliste für Graphen Anstelle des veralteten C-Array stellt die STL eine Klassenschablone (template class T> vector)86 zur Aufnahme und Verwaltung sequentiell gespeicherter Daten bereit. Diese STL-Containerklasse wird zur Darstellung eines Graphen (Knoten in einer sequentiellen Liste) benutzt. #include #include #include #include #include <iostream> <fstream> <vector> <stack> <queue> using namespace std; const int maxGraphSize = 25; template <class T> class Graph { private: // Schluesseldaten mit einer Knotenliste, Adjazenzmatrix // und aktueller Groesse (Knotenanzahl) des Graphen vector<T> vertexList; int edge [maxGraphSize][maxGraphSize]; int graphsize; // Methoden zum Finden eines Knoten und Identifizieren // seiner Position in der sequentiellen Liste bool findVertex(vector<T> &L, const T& vertex); int getVertexPos(const T& vertex); public: // Konstruktor Graph(void); // Testmethoden int graphEmpty(void) const; int graphFull(void) const; // Zugriffsmethoden int numberOfVertices(void) const; int getWeight(const T& vertex1, const T& vertex2); vector<T>& getKnoten(); vector<T>& getNachbarn(const T& vertex); // Modifikationsmethoden void insertVertex(const T& vertex); void insertEdge(const T& vertex1, const T& vertex2, int weight); /* void DeleteVertex(const T& vertex); void DeleteEdge(const T& vertex1, const T& vertex2); */ // Anwendungsmethoden void readGraph(char *filename); int minimumPath(const T& sVertex, const T& eVertex); vector<T>& depthFirstSearch(const T& beginVertex); vector<T>& breadthFirstSearch(const T& beginVertex); }; 86 vgl. 2.2.1.1 100 Algorithmen und Datenstrukturen Daten zum Graphen werden aufgenommen in: - einer sequentiellen Liste: vector<T> vertexList - in einer zweidimensionalen Matrix: int edge [maxGraphSize][maxGraphSize] Methoden im Graphen dienen - zum Einlesen der Knotenidentifikationen und Kantenbewertungen aus einer Datei: void readGraph(char *filename);. Der Parameter dieser Methode beschreibt einen Dateinamen. Die zugehörige Datei wird mit der Methode readGraph eingelesen. Die Datei hat bspw. folgenden Aufbau: 5 A B C D E 14 A B A C A D B C D C D E E C B A C A D A C B C D E D C E 604 604 648 648 752 752 432 432 763 763 504 504 355 355 Diese Datei ist folgendem Graphen zugeordnet: A 752 604 D B 648 504 432 763 E 355 C - zum Auffinden eines Knoten in der Knotenliste bool findVertex(vector<T> &L, const T& vertex) Implementierung // Konstruktor zum Initialisieren der Eintraege in der Adjazenzmatrix // mit 0, Setzen der Groesse des Graphen auf 0 template <class T> Graph<T>::Graph(void) 101 Algorithmen und Datenstrukturen { for (int i = 0; i < maxGraphSize; i++) for (int j = 0; j < maxGraphSize; j++) edge[i][j] = 0; graphsize = 0; } // Zählen der Komponenten template <class T> int Graph<T>::numberOfVertices(void) const { return graphsize; } // Test, ob der Graph leer ist template <class T> int Graph<T>::graphEmpty(void) const { return graphsize == 0; } // Durchlaufen der Knotenliste und Ermitteln der Position des im Parameter der Funktion // angegebenen Knotens in der Knotenliste template <class T> int Graph<T>::getVertexPos(const T& vertex) { int pos = 0; int i = 0; for (i = 0; i < vertexList.size(); i++) { if (vertexList[i] == vertex) { pos = i; break; } } if (i == vertexList.size()) { cerr << "getVertex: Der Knoten ist nicht im Graph." << endl; pos = -1; } // cout << pos; return pos; } // Ermitteln der Gewichte (Kantenbewertungen) der Kante zwischen den // in der Parameterliste der Funktion getWeight() angegebenen Knoten vertex1 und vertex2. template <class T> int Graph<T>::getWeight(const T& vertex1, const T& vertex2) { int pos1 = getVertexPos(vertex1), pos2 = getVertexPos(vertex2); if (pos1 == -1 || pos2 == -1) { cerr << "getWeight: Ein Knoten ist nicht im Graph." << endl; return -1; } return edge[pos1][pos2]; } // Rueckgabe einer Liste mit allen Knoten der Knotenliste template <class T> vector<T>& Graph<T>::getKnoten() { vector<T> *l; l = new vector<T>; 102 Algorithmen und Datenstrukturen for (int i = 0;i < vertexList.size();i++) l -> push_back(vertexList[i]); return *l; } // Rueckgabe einer Liste mit allen benachbarten Knoten template <class T> vector<T>& Graph<T>::getNachbarn(const T& vertex) { vector<T> *l; // Zuweisen leere Liste l = new vector<T>; // Positionsbestimmung zur Zeilen-Identifikation in der // Adjazenzmatrix int pos = getVertexPos(vertex); // cout << pos << endl; // Terminiere, falls der Knoten nicht in der Knotemliste ist if (pos == -1) { cerr << "getNachbarn: Der Knoten ist nicht im Graph." << endl; return *l; // Rueckgabe leere Liste } // Durchlaufe die Zeilen der Adjazenzmatrix und erfasse // alle Knoten einer nicht mit Null bewerteten Kante for (int i = 0; i < graphsize; i++) { // cout << edge[pos][i]; if (edge[pos][i] > 0) { l->push_back(vertexList[i]); // cout << endl; // cout << vertexList[i] << endl; } } return *l; } // Einfügen eines Knoten template <class T> void Graph<T>::insertVertex(const T& vertex) { if (graphsize + 1 > maxGraphSize) { cerr << "Graph ist voll" << endl; exit (1); } vertexList.push_back(vertex); graphsize++; } // Einfügen einer Kante template <class T> void Graph<T>::insertEdge(const T& vertex1, const T& vertex2, int weight) { int pos1 = getVertexPos(vertex1), pos2 = getVertexPos(vertex2); if (pos1 == -1 || pos2 == -1) { cerr << "insertEdge: ein Knoten ist nicht im Graph." << endl; return; } edge[pos1][pos2] = weight; } 103 Algorithmen und Datenstrukturen template <class T> bool Graph<T>::findVertex(vector<T> &L, const T& vertex) { vector<T>::iterator vecIter; bool ret = false; for (vecIter = L.begin(); vecIter != L.end(); vecIter++) { if (*vecIter == vertex) { ret = true; break; } } return ret; } // Einlesen der Daten in den Graphen template <class T> void Graph<T>::readGraph(char *filename) { int i, nvertices, nedges; T s1, s2; int weight; ifstream f; f.open(filename, ios::in | ios::nocreate); if(!f) { cerr << "Nicht zu oeffnen " << filename << endl; exit(1); } f >> nvertices; for (i = 0; i < nvertices; i++) { f >> s1; insertVertex(s1); } f >> nedges; for (i = 0; i < nedges; i++) { f >> s1; // cout << s1 << endl; f >> s2; f >> weight; insertEdge(s1, s2, weight); } f.close(); } 2.3.1.3 Mehrdimensionale Felder Matrix auf der Basis von vector Ein zweidimensionales Feld wurde zur Speicherung der Bewertungen der Kanten im ADT Graph benutzt: #include <vector> using namespace std; template <class Object> class matrix { 104 Algorithmen und Datenstrukturen private: vector< vector<Object> > array; public: matrix( int rows, int cols ) : array( rows ) { for( int i = 0; i < rows; i++ ) array[ i ].resize( cols ); } const vector<Object> & operator[]( int row ) const { return array[ row ]; } vector<Object> & operator[]( int row ) { return array[ row ]; } int numrows( ) const { return array.size( ); } int numcols( ) const { return numrows( ) ? array[ 0 ].size( ) : 0; } }; All pairs shortest path (Berechnung kürzester Pfade zu jedem Knotenpaar87) d k ,i , j seien die Kosten des kürzesten Pfads von vi nach vj mit den Zwischenstationen v1 , v2 ,..., vk . Für d k ,i , j kann man angeben: Der kürzeste Pfad von vi nach v j , der nur v1 , v2 ,..., vk als Zwischenstationen vorsieht, benutzt vk als Zwischenstaion oder nicht. Wird vk als Zwischenstation benutzt gibt es zusätzlich zwei Pfade vi vk und v k v j . Das führt zu der Formel: d k ,i , j min d k 1,i . j , d k 1,i , k , d k 1, k , j 88 entweder #include <iostream> #include "matrix.h" using namespace std; const int NOT_A_VERTEX = -1; /* * Compute all-shortest paths. * a contains the adjacency matrix with * a[ i ][ i ] presumed to be zero. * d contains the values of the shortest path. * Vertices are numbered starting at 0; all arrays * have equal dimension. A negative cycle exists if * d[ i ][ i ] is set to a negative value. * Actual path can be computed using path[ ][ ]. * NOT_A_VERTEX is -1 */ void allPairs( const matrix<int> & a, matrix<int> & d, matrix<int> & path ) { int n = a.numrows( ); // Initialize d and path for( int i = 0; i < n; i++ ) for( int j = 0; j < n; j++ ) { d[ i ][ j ] = a[ i ][ j ]; path[ i ][ j ] = NOT_A_VERTEX; } for( int k = 0; k < n; k++ ) // Consider each vertex as an intermediate for( int i = 0; i < n; i++ ) for( int j = 0; j < n; j++ ) 87 88 Algorithmus nach Floyd vgl. pr23_13, pr23131.cpp 105 Algorithmen und Datenstrukturen if( d[ i ][ k ] + d[ k ][ j ] < d[ i ][ j ] ) { // Update shortest path d[ i ][ j ] = d[ i ][ k ] + d[ k ][ j ]; path[ i ][ j ] = k; } } int main( ) { matrix<int> a(5,5); a[ 0 ][0 ] = 0; a[ 0 ][ 1 ] = 604; a[ 0 ][ 2 ] = 648; a[ 0 ][ 3 ] = 752; a[ 0 ][ 4 ] = 10000; a[ 1 ][ 0 ] = 604; a[ 1 ][ 1 ] = 0; a[ 1 ][ 2 ] = 432; a[ 1 ][ 3 ] = 10000; a[ 1 ][ 4 ] = 10000; a[ 2 ][ 0 ] = 648; a[ 2 ][ 1 ] = 432; a[ 2 ][ 2 ] = 0; a[ 2 ][ 3 ] = 763; a[ 2 ][ 4 ] = 355; a[ 3 ][ 0 ] = 752; a[ 3 ][ 1 ] = 10000; a[ 3 ][ 2 ] = 763; a[ 3 ][ 3 ] = 0; a[ 3 ][ 4 ] = 504; a[ 4 ][ 0 ] = 10000; a[ 4 ][ 1 ] = 10000; a[ 4 ][ 2 ] = 355; a[ 4 ][ 3 ] = 504; a[ 4 ][ 4 ] = 0; matrix<int> d( 5, 5 ); matrix<int> path( 5, 5 ); allPairs( a, d, path ); int i; cout << "Kuerzeste Pfade:" << endl; for( i = 0; i < d.numrows( ); i++ ) { for( int j = 0; j < d.numcols( ); j++ ) cout << d[ i ][ j ] << " " ; cout << endl; } cout << "Pfadindizes: " << endl; for( i = 0; i < path.numrows( ); i++ ) { for( int j = 0; j < path.numcols( ); j++ ) cout << path[ i ][ j ] << " " ; cout << endl; } char zeichen; cin >> zeichen; return 0; } Laufzeit: O V 3 2.3.1.4 Durchlaufen von Graphen mit Hilfe der STL-Containerklassen stack bzw. queue 2.3.1.4.1 Tiefensuche (First-Depth Search) Bei der Verarbeitung von Graphen treten häufig folgende Fragen auf: - Ist der Graph zusammenhängend? - Wenn nicht, was sind seine zusammenhängenden Komponenten? - Enthält der Graph einen Zyklus? Diese und viele andere Probleme können mit einer Methode gelöst werden, die Tiefensuche genannt wird und einen natürlichen Weg darstellt, wie im Graphen systematisch jeder Knoten "besucht" und jede Kante geprüft werden kann. 106 Algorithmen und Datenstrukturen Suchalgorithmus zur Tiefensuche:89 Er benutzt eine Liste für die Verwaltung aufgesuchter Knoten eine Liste und einen Stapel (stack der STL-Containerklasse). Bsp.: Gegeben ist ein gerichteter Graph A B G C E F D Der Suchalgorithmus zur Tiefensuche verwendet eine Liste L für die Sammlung aufgesuchted Knoten und einen Stapel S zur Speicherung der benachbarten Knoten. Nach der Ablage der initialen Knoten im Stapel beginnt ein iterativer Prozess, der einen Knoten aus dem Stapel holt und danach den Knoten aufsucht. Falls der Stapel leer ist, erfolgt Terminierung mit Rückgabe der aufgesuchten Knoten. Je Schritt wird folgende Strategie angewendet Entferne einen Knoten V aus dem Stapel und überprüfe ihn anhand der Liste L, ob er schon besucht wurde. Falls nicht, suche einen neuen Knoten auf und beschaffe eine Liste seiner Nachbarn. Danach wird er in L abgelegt. Die Nachbarknoten von V, die noch nicht im Stapel abgelegt sind, kommen in den Stapel Test: Startknoten ist der Knoten A aus dem vorliegenden Bsp. Bearbeiten von Knoten A: Stapel Liste L G B A Bearbeiten von Knoten G: F B A G B A G F E D A G F Bearbeiten von Knoten F: Bearbeiten von Knoten B C Bearbeiten von Knoten E: D D 89 A G vgl. Skriptum, 5.2.1 bzw. 5.2.1.1 107 F B C E Algorithmen und Datenstrukturen Berabeiten von Knoten D A G F B C E D Die Suche wird abgeschlossen nach Besuch von Knoten D. Es wird festgestellt , dass der Knoten D schon in der Liste L ist. Implementierung90 // from a starting vertex, return depth first scanning list template <class T> vector<T> & Graph<T>::depthFirstSearch(const T& beginVertex) { // stack zur voruebergehenden Aufbewahrung der Knoten stack<T> s; // L ist eine Liste mit Knoten der Tiefensuche; adjL umfasst // die Nachbarn des aktuell behandelten Knotens. L vird im // dynamischen Speicherbereich erzeugt. So kann eine Referenz reference // auf diese Liste zurueckgegeben werden vector<T> *L, adjL; // iteradjL wird zum Durchlauf der Liste mit den Nachbarn benutzt vector<T>::iterator iteradjL; T vertex; // Initialisieung der Rueckgabeliste; push Anfangsknoten auf den Stack L = new vector<T>; s.push(beginVertex); // Bearbeitung bis der Stack leer ist while (!s.empty()) { // pop des naechsten Knoten vertex = s.top(); s.pop(); // Pruefen, ob der Knoten schon in L ist if (!findVertex(*L,vertex)) { // falls nicht, Ausgabe des Knoten in die Liste und Beschaffen // aller benachbarten Knoten (*L).push_back(vertex); adjL = getNachbarn(vertex); // Durchlauf der Liste mit den Nachbarn; Falls nicht in der Liste , // Aufnahme in den Stack for(iteradjL = adjL.begin(); iteradjL != adjL.end(); iteradjL++) if (!findVertex(*L,*iteradjL)) s.push(*iteradjL); } } // Rueckgabe der Liste mit den Knoten der Tiefensuche return *L; // return list } 90 pr23_1, pr41151.cpp 108 Algorithmen und Datenstrukturen 2.3.1.4.2 Breitensuche (Breadth-First Search) Benutzt man zur Speicherung der Knoten, die bei der Suche im Graphen durchlaufen werden, anstatt eines Stapels eine Schlange (z.B. den STL-Container queue), dann führt das zu einem weiteren Algorithmus für die Traversierung in Graphen, die Breitensuche91 genannt wird. Der Suchalgorithmus zur Breitensuche verwendet eine Schlange, in die die Knoten aufgenommen werden. Ein iterativer Prozeß entfernt solange Knoten aus der Schlange, bis die Schlange geleert ist. Bsp.: Für den vorliegenden Beispielgraphen92 werden die Knoten in folgender Reihenfolge aufgesucht: A B G C F D E Implementierung93: template <class T> vector<T> & Graph<T>::breadthFirstSearch(const T& beginVertex) { queue<T> q; vector<T> *L, adjL; vector<T>::iterator iteradjL; T vertex; L = new vector<T>; q.push(beginVertex); // initialize the queue while (!q.empty()) { // remove a vertex from the queue vertex = q.front(); q.pop(); // if vertex is not in L, add it if (!findVertex(*L,vertex)) { (*L).push_back(vertex); // get list of neighbors of vertex adjL = getNachbarn(vertex); // insert all neighbors of vertex into the queue // if they are not already there for(iteradjL=adjL.begin(); iteradjL!=adjL.end(); iteradjL++) { if (!findVertex(*L,*iteradjL)) q.push(*iteradjL); } } } return *L; } 91 vgl. Skriptum, 5.2.2 2.3.1.4.1. 93 pr23_1, pr41151.cpp 92 109 Algorithmen und Datenstrukturen 2.3.1.5 Ermitteln der kürzesten Wege mit Hilfe der STL-Containerklasse priority_queue Erstellen einer neuen Klasse (struct) mit dem Namen PathInfo Objekte dieser Klasse spezifizieren Pfade, die zwei Knoten über eine Kante oder mehrere Kanten verbinden. Die Gewichte der zwischen den Knoten befindlichen Kanten werden aufsummiert. Die Pfad-Informationen werden in eine Priority-Queue der STL-Containerklasse priority_queue94 abgelegt. Dadurch ist Direktzugriff auf das Pfadobjekt mit den geringsten Kosten möglich: template <class T> { T startV, endV; int cost; bool operator < { return cost < bool operator > { return cost > }; struct PathInfo (const PathInfo<T> a) const a.cost; } (const PathInfo<T> a) const a.cost; } Algorithmus Gegeben ist der folgende Graph 4 E 4 A B 2 8 6 10 4 12 6 6 12 F D C 20 14 Abb.: Startknoten (Ausgangspunkt startV) ist der Knoten A. Das Ziel ist der Endknoten D endV). Dazwischen soll der kürzeste Weg berechnet werden. Die Arbeitsweise des Algorithmus soll unter diesen Bedingungen beschrieben werden: Begonnen wird mit Knoten A. Dem Weg von A nach A wird der Kostenbetrag 0 zugeordnet. In der Priority-Queue wird eingetragen: "A nach A 0". Es folgt ein iterativer Prozeß, der von A aus Nachfolgeknoten untersucht, bis der Endknoten endV erreicht ist. Durch das Einbringen aller der auf dem Weg zum Endknoten liegenden Knoten (einschl. der Pfadlängen) in die Priority-Queue, kann der kürzeste Pfad aus der Priority-Queue ausgelesen werden. Der Wert "A" für endV wird gelöscht, betrachtet werden die Nachbarn von A "B, C, E" als neue Endknoten. Das ergibt folgende Pfadobjekte: PfadInfo-Objekte OA,B OA,C OA,E startV A A A endV B C E Kosten 4 12 4 Diese Objekte werden in folgender Reihenfolge in die Priorirty-Queue eingeordnet: "A nach B 4", "A nach E" 4, "A nach C 12". Im nächsten Schritt wird das PfadInfo-Objekt OA,B aus der Priority-Queue 94 http://www.cplusplus.com/reference/stl/priority_queue/ 110 Algorithmen und Datenstrukturen gelöscht. Der zugehörige Enknoten ("B") wird in die Liste "l" der bereits berücksictigten Knoen aufgenommen, falls er sich nicht in "l" befindet. Die Nachbarn von B "A", "C" und "D" werden bestimmt. "A" befindet sich bereits in "l", PfadInfo-Objekte werden zu "C" und "D" ermittelt. PfadInfo-Objekte OB,C OB,D startV B B endV C D Kosten 10 = 6 + 4 12 = 4 + 8 Die Priority-Queue umfasst nun 4 Elemente: "A nach E 4", "A nach C 12", "B nach C 10", "B nach D 12". Der Eintrag, der mit der kleinste Pfadlänge verbunden ist, unfaßt: "A – B – C". Die Priority-Queue enthält folgende Elemente: "A nach E 4", "A nach C 12", "B nach C 10", "B nach D 12". Betrachtet wird das PfadInfo-Objekt OA,E. Es wird gelöscht, die zugehörigen 4 Kosteneinheiten werden in das erzeugende PfadInfoObjekt OE,D übernommen (4 + 10 = 14). Die Priority-Queue enthält die Einträge: "B nach C 10", "A nach 10" 12", "B nach D 12", "E nach D 14". Im nächsten Löschvorgang ist das Objekt OB,C der kleinste Wert. "C" kann nun "l" hinzugefügt werden, 10 Kosteneinheiten betragen die kleinsten Kosten von "A" nach "C". Die benachbarten Knoten von "C" sind "B" und "D". "B" wurde schon behandelt, die Priority-Queue besitzt noch 3 Elemente: "B nach D 12", "E nach D 14", "C nach D 24". Das Entfernen von OB,D aus der Priority-Queue führt auf den kleinsten Pfad von A nach D mit 12 Kosteneinheiten. Implementierung template <class T> int Graph<T>::minimumPath(const T& sVertex, const T& eVertex) { // priority queue mit Informationen ueber die Kosten auf dem Pfad // vom Startknoten priority_queue< PathInfo<T>, vector<PathInfo<T> >, greater<PathInfo<T> > > pq; // wird benutzt, wenn Pfadinformationen in die // priority queue eingefuegt oder geloescht werden PathInfo<T> pathData; // l ist eine Liste aller Knoten , die von sVertex aus erreichbar sind // adjL ist die Liste aller Nachbarn, die besucht werden. // adjLiter wird zum Durchlaufen von adjL benutzt vector<T> l, adjL; vector<T>::iterator adjLiter; T sv, ev; int mincost; // Angabe der ersten Eintraege pathData.startV = sVertex; pathData.endV = sVertex; // Kosten von sVertex nach sVertex betragen 0 pathData.cost = 0; pq.push(pathData); // Bearbeite Knoten bis ein kuerzester Weg zum // Zielknoten gefunden ist oder die priority queue leer ist while (!pq.empty()) { // delete a priority queue entry, and record its // ending vertex and cost from sVertex. pathData = pq.top(); pq.pop(); ev = pathData.endV; mincost = pathData.cost; // Falls der Zielknoten erreicht wurde, wurde der // kuerzeste Weg vom Start- zum Zielknoten gefunden if (ev == eVertex) break; // Falls der Endknoten schon in l ist, soll er nicht // weiter betrachtet werden if (!findVertex(l,ev)) { 111 Algorithmen und Datenstrukturen // Einfuegen ev in l l.push_back(ev); // Bestimme alle Nachbarn des aktuellen Knoten ev, fuer // jeden Nachbarn der nicht in l ist, erzeuge einen // Eintrag und fuege ihn ein in die priority queue // mit Startknoten ev sv = ev; adjL = getNachbarn(sv); // adjLiter durchlaeuft die neue Liste adjL for(adjLiter = adjL.begin(); adjLiter != adjL.end(); adjLiter++) { ev = *adjLiter; if (!findVertex(l,ev)) { // Erzeuge neuen Eintrag fuer the priority queue pathData.startV = sv; pathData.endV = ev; // cost enthalt aktuelle minimale Kosten, hinzu kommen // die Kosten vom Start- zum Zielknoten pathData.cost = mincost + getWeight(sv,ev); pq.push(pathData); } } } } // Ruechgabe: Erfolg bzw. kein Erfolg if (ev == eVertex) return mincost; else return -1; } Test Der Aufruf der vorliegenden Methode minimumPath() aus der folgenden main()Routine int main(int argc, char *argv[]) { // Knoten des Graphen werden über Grossbuchstaben bezeichnet Graph<char> g; char dName[50]; cout << "Eingabe-Datei: "; cin >> setw(50) >> dName; char s; // Eingabe der Knoten g.readGraph(dName); // Prompt fuer den Startknoten cout << "Berechne den kuerzesten Weg vom Startknoten "; cin >> s; vector<char> v = g.getKnoten(); // Kontrolle vector<char>::iterator vecIter; for (vecIter = v.begin();vecIter != v.end(); vecIter++) // cout << " " << *vecIter; cout << "Kuerzester Weg von " << s << " nach " << *vecIter << " ist " << g.minimumPath(s,*vecIter) << endl; system("PAUSE"); return 0; } , das Einlesen der Knoten und Kanten von folgenden Graphen A 752 112 Algorithmen und Datenstrukturen 604 D B 648 504 432 763 E 355 C führt zu der folgende Ausgabe: Berechne den kuerzesten Weg Kuerzester Weg von A nach A Kuerzester Weg von A nach B Kuerzester Weg von A nach C Kuerzester Weg von A nach D Kuerzester Weg von A nach E vom ist ist ist ist ist Startknoten A 0 604 648 752 1003 2.3.1.6 Erreichbarkeit und der Algorithmus von Warshall 2.3.1.6.1 Erreichbarkeit Für jedes Knotenpaar in einem Graphen gilt: v j ist dann und nur dann von vi aus erreichbar, falls es einen gerichteten Pfad von vi nach v j gibt. Für jeden Knoten vi definiert die Tiefensuche die Liste aller Knoten, die von vi aus erreichbar sind. Benutzt man die Tiefensuche für jeden Knoten im Graphen, erhält man eine Reihe von Erreichbarkeitslisten (, die die Relation R ergeben): v1 : Erreichbarkeitsliste für v1 v2 : Erreichbarkeitsliste für v2 … vn : Erreichbarketsliste für vn Dieselbe Relation kann auch über eine n n Erreichbarkeitsmatrix beschrieben werden, die eine 1 an der Stelle (i,j) (vorgesehen für vi Rv j ) besitzt. Bsp.: A B C D Erreichbarkeitsliste (Reachability Lists) A: A B C D B: B D Erreichbarkeitsmatrix (Reachablity Matrix) 1 1 1 1 0 1 0 1 113 Algorithmen und Datenstrukturen C: C B D D: D 0 1 1 1 0 0 0 1 2.3.1.6.2 Warshalls Algorithmus Die Erreichbarkeitsmatrix eines Graphen kann durch einen Prozeß erzeugt werden, der eine 1 in der Matrix für jedes Knotenpaar zuweist, das durch einen Knoten verbunden ist. Bsp.: k i j Falls R[i][k] = 1 und R[k][j] = 1, setze R[i][j] = 1. Der Warshall-Algorithmus untersucht alle möglichen Tripel (durch eine dreifach geschachtelte Schleife mit den Schleifenvariablen i, j, k). Für jedes Paar (i,j) wird eine Kante v k , v j hinzugefügt, falls es einen Knoten vk gibt, so dass E (vi , vk ) und E (v k , v j ) in dem erweiterten Graphen liegen. Bsp.: Knoten v und w sind durch die Knoten x1, … , x5 verbunden. v x1 x2 x3 x4 x5 w x2 ist ein Knoten, der x1 und x3 verbindet. Das ergibt nach Warshall die Kante (x1,x3). Das nächste Paar x1 und x4 nutzt diese Verbindung, die Kante (x1,x4) wird erzeugt. Knoten x 4 ist dann ein Knoten der x1 und x5 verbindet. Hinzugefügt wird (x1,x5) und R[1][5] = 1 zugewiesen. Implementierung95 template <class T> void warshall(Graph<T> g) { vector<T> vList = g.getKnoten(); vector<T>::iterator vi, vj; int i, j, k; int wsm[maxGraphSize][maxGraphSize]; // Warshall Matrix int n = g.numberOfVertices(); // Erzeugen der initialen Matrix for (vi = vList.begin(), i = 0; vi != vList.end(); vi++, i++) for (vj = vList.begin(), j = 0; vj != vList.end(); vj++, j++) if (i == j) wsm[i][i] = 1; else wsm[i][j] = g.getWeight(*vi,*vj); // beachte die Tripel // Zuweisen von 1, falls eine Kante von vi nach vj // existiert oder es gibt ein Tripel vi - vj - vk, // das vi und vj verbindet for (i=0; i<n; i++) for (j = 0; j < n; j++) for (k=0; k < n; k++) wsm[i][j] |= wsm[i][k] & wsm[k][j]; 95 pr23_1, pr41153.cpp 114 Algorithmen und Datenstrukturen // Ausgabe von jedem Knoten und seiner Zeile mit // der Erreichbarkeitsmatrix for (vi = vList.begin(), i =0; vi != vList.end(); vi++, i++) { cout << *vi << ": "; for (j=0; j < n; j++) cout << wsm[i][j] << " "; cout << endl; } } Test int main() { // Knoten des Graphen werden über Grossbuchstaben bezeichnet Graph<char> g; char dName[50]; cout << "Eingabe-Datei: "; cin >> setw(50) >> dName; char s; // Eingabe der Knoten g.readGraph(dName); // Prompt fuer den Startknoten cout << "Erreichbarkeitsmatrix " << endl; // warshall(g); system("PAUSE"); return 0; } /* Test Erreichbarkeitsmatrix A: 1 1 1 0 1 B: 0 1 1 0 1 C: 0 0 1 0 0 D: 1 1 1 1 1 E: 0 0 1 0 1 */ Die Ausgabe des Programms bezieht sich auf den Folgenden Graphen: A B A C D C E Orginal B D E Transitive Closure 115 Algorithmen und Datenstrukturen 2.3.2 Darstellung von Graphen mit assoziativen Behälterklassen Bei fester Knotenzahl liegt eine Vektordarstellung der Knotenmenge nahe, sonst nimmt man einen Assoziativen Container (map bzw. set 96) 2.3.2.1 Verbindungsproblem mit Kantenpräsentation durch die Containerklasse set Datenstruktur97 // file vertex.h #include <set> // KLasse vertex template <class T> class vertex { public: typedef T value_type; typedef set<vertex*> vertex_list; // Konstruktoren vertex() {} vertex(value_type v) : wert(v) { } // Zugriffsfunktionen vertex_list& neighbors() { return edges; } value_type& value() { return wert; } // Modifikation void addEdge(vertex& v) { edges.insert(&v); // Hinzufuegen einer neuen Kante } private: value_type wert; vertex_list edges; }; Die Klasse Vertex dient zur Beschreibung eines einfachen, ungewichteten Graphen Tiefensuche-Algorithmus für das Verbindungsproblem Der Algorithmus bestimmt alle Knoten, die mit einem gegebenen verbunden sind: typedef vertex<string> node; typedef node::vertex_list nodeList; void findReachable(node& quelle, nodeList& reachable) { // finde alle Knoten, die erreichbar von quelle 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); } 96 set is a map where the values of the stored (key, value)-pairs are irrelevant http://www.cplusplus.com/reference/stl/set/ 97 vgl. pr52_144, vertex.h 116 Algorithmen und Datenstrukturen Test Gegeben ist der folgende Graph X0 X2 X1 X4 X3 , der in der folgenden main()-Routine abgebildet und berechnet wird: int main(void) { nodeList reachable; node x0("x0"), x1("x1"), x2("x2"), x3("x3"), x4("x4"); x0.addEdge(x1); x0.addEdge(x2); x1.addEdge(x4); x2.addEdge(x4); x2.addEdge(x3); x3.addEdge(x4); findReachable(x2,reachable); nodeList::iterator itr = reachable.begin(), stop = reachable.end(); for (; itr != stop; ++itr) cout << (*itr)->value() << ' '; cout << endl; char zeichen; cin >> zeichen; return 0; } Ausgabe: x4 x3 x2 117 Algorithmen und Datenstrukturen 2.3.2.2 Algorithmus von Dijkstra mit Präsentation des Graphen durch die Containerklasse map Kantenrepräsentation für bewertete Graphen Die Liste der Nachbarkanten eines Knoten muß neben dem Zielknoten noch die Gewichte aufnehmen. Es muß also ein Container von Paaren (Zielknoten, Gewicht) sein, in dem man schnell den zu einem Knoten gehörenden Eintrag findet. Dazu eignet sich ein Wörterbuch (dictionary, map98). Es kann wie ein Vektor mit Knoten als Indizes und Abständen als Werte genutzt werden. Darstellung der Knoten als Paare typedef string knotenLabel; // Tabelle der Distanzen, indiziert durch Knotenidentifikation typedef map<knotenLabel,float> knotenListe; // Knotenbezeichner, Distanzen zu Nachbarknoten typedef pair<knotenLabel,knotenListe> knoten; Adjazenzmatrix (dünn besetzter Vektor von Knoten) typedef map<knotenLabel,knotenListe> Graph; main() int main(void) { // Verbindungsgraph Graph xnnMap; xnnMap["x0"]["x1"] = 400; xnnMap["x0"]["x2"] = 300; xnnMap["x1"]["x4"] = 200; xnnMap["x2"]["x4"] = 400; xnnMap["x2"]["x3"] = 600; xnnMap["x3"]["x4"] = 400; // erreichbare Knoten von einem Knoten knotenListe erreichbar; // Berechnung der Entfernungen dijkstra(xnnMap,"x0",erreichbar); // Ausgabe cout << "Distanz " << "x0"; knotenListe::iterator itr = erreichbar.begin(), stop = erreichbar.end(); for (; itr != stop; ++itr) cout << " ->" << (*itr).first << ": " << (*itr).second << ", "; cout << endl; char zeichen; cin >> zeichen; return 0; } In main() wurde der folgende Verbindungsgrah abgebildet 98 http://www.cplusplus.com/reference/stl/map/ 118 Algorithmen und Datenstrukturen X0 300 400 X2 X1 200 400 600 X4 400 X3 Die von einem Knoten erreichbaren anderen Knoten des Graphen bilden die knotenListe erreichbar; die durch den Funktionsaufruf dijkstra(xnnMap,"x0",erreichbar); bestimmt wird. Zur Ausgabe kann derreichbar durchlaufen werden: Abb.: Dijkstras Algorithmus zur Bestimmung der kürzesten Pfade Mit99 typedef pair<float, knotenLabel> kante; bool operator >(const kante l, const kante r) { return l.first > r.first; } nimmt der Algorithmus folgende Gestalt an: void dijkstra(Graph& graph, knotenLabel quelle, knotenListe& distanzen) {// leere die Abstandstabelle der schon bearbeitenten Knoten distanzen.clear(); // Bearbeite eine Priority-Queue mit Distanzen zur Quelle priority_queue<kante, vector<kante>, greater<kante> > pQueue; // Entfernung der Quelle zu sich selbst ist 0 pQueue.push(kante(0,quelle)); // // Fortgesetztes Entfernen der Elemente mit der kuerzesten Distanz // aus der Priority Queue while (!pQueue.empty()) { kante nachbar = pQueue.top(); pQueue.pop(); knotenLabel nachbarKnoten = nachbar.second; 99 vgl. 5.5.3 119 Algorithmen und Datenstrukturen // Falls der Nachbar noch nicht aufgesucht ist if (distanzen.count(nachbarKnoten) == 0) { float dist = nachbar.first; // Einfuegen nachbarKnoten in Tabelle distanzen distanzen[nachbarKnoten] = dist; knotenListe nachbarnausNachbarschaft = graph[nachbarKnoten]; knotenListe::iterator itr = nachbarnausNachbarschaft.begin(), stop = nachbarnausNachbarschaft.end(); for(; itr != stop; ++itr) { float zielDistanz = (*itr).second; knotenLabel zielKnoten = (*itr).first; // Nachbarn werden mit des aktuellen Pfads in die // Prioritaetswarteschlange eingetragen pQueue.push(kante(dist + zielDistanz, zielKnoten)); } } } } Grundidee: Man verwendet eine Abstandstabelle distanzen der Knoten, für die der kürzeste Abstand zum Startknoten schon bestimmt ist, und eine Tabelle pQueue (Priority Queue) aller ihrer Nachbarn mit ihren bisherigen Abständen zum Startknoten. In der Tabelle kann ein Knoten durchaus mit verschiedenen Abständen eingetragen sein, wenn mehrere Pfade zu ihm führen. Nur der Eintrag mit dem kleinsten Abstand wird berücksichtigt, da die Tabelle als Prioritätswarteschlange ausgelegt ist. 120 Algorithmen und Datenstrukturen 2.3.3 Darstellung von Graphen mit Hilfe der Klasse hash_map 2.3.3.1 Topolgical Sorting Topologische Folge In vielen Anwendungen braucht man sich nur mit sog. azyklischen Graphen zu beschäftigen, die keine Ringstruktur (Zyklus) aufweisen. Für azyklische Graphen kann man die Knoten als Folge schreiben und zwar so, dass für eine Kante (i,j) die KInotennummer i in der Folge vor der Knotennummer j erscheint. Eine solche Folge wird topolgisch genannt. Für einen Graphen kann es mehrere topologische Folgen geben. Soblidet bspw. jede der Sequenzen 1, 2, 3, 4 und 1, 3, 2, 4 eine topolgische Folge des in der folgenden Abbildung dargestellten Graphen: 2 1 4 3 Abb.: Azyklischer Graph mit 2 topologischen Folgen Algorithmus Begonnen wird mit den Knoten, deren Zählfelder100 den Wert 0 enthalten. Ihr Niveau ist Null, sie verfügen über keinen Vorgänger, so dass sie in der topologischen Folge an erster Stelle erscheinen. Der Algorithmus stapelt101 die Knoten ohne Vorgänger. Die Knoten ohne Vorgänger werden bearbeitet und dann gelöscht. Bei einem azyklischen Graphen bleiben bei dieser Vorgehensweise keine Knoten mehr übrig. Lassen sich auf diese Weise nicht alle Knoten löschen und sind zu einem bestimmten Zeitpunkt noch Knoten mit jeweils einem Vorgänger vorhanden, so bildet der Graph einen Zyklus (Ring). Die Liste der Knoten ohne Vorgänger wird als Stapel genutzt. Der Stapel wächst und schrumpft während des gesamten Zeitraums. Löscht man einen Knoten, so werden alle unmittelbaren Nachfolger auf den Stapel geschoben, wenn es sich bei ihnen – nach dem Löschen – um Knoten ohne Vorgänger handelt 100 101 vgl.: struct vertex auf der umstehenden Seite vgl. class Graph auf der umstehenden Seite 121 Algorithmen und Datenstrukturen Bsp. Gegeben ist der folgende, gerichtete und azyklische Graph: 2 10 1 4 9 6 8 3 7 5 Abb.: Dieser Graph läßt sich über eine Adjazenzliste folgendermaßen beschreiben: [1] [2] [3] [4] [5] 2 4 5 6 8 3 10 [6] [7] 3 8 [8] 5 4 9 10 Abb.: Damit ergeben sich folgende Datenstrukturen struct edge { int num; }; // verankert in der Knotenliste // Nummer des Knoten struct vertex { int zaehler; // Zaehlfeld list<edge>* kette; // Verkettete Liste fuer die Nachbarn vertex(int z = 0, list<edge>* verk = 0) { zaehler = z; kette = verk; } }; class Graph { private: // Hash-Tabelle zur Verankerung der Adjazenzliste102 hash_map<int,vertex> h; stack<int> s; 102 http://www.sgi.com/tech/stl/hash_map.html 122 [9] [10] Algorithmen und Datenstrukturen public: void erzAdjTab(const char *dName); void startKnoten(); void topSort(); }; Für das topologische Sortieren ist die Aufnahme eines Zählers in die Knotenbeschreibung zweckmäßig. 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. Zaehler [1] 0 [2] 1 [3] 2 2 4 3 10 5 [4] 2 [5] 2 6 [6] 1 8 8 3 [7] 0 [8] 2 4 9 10 Implementierung103 void Graph::erzAdjTab(const char *dName) { int i, j; list<edge> *z; ifstream datei(dName, ios::in | ios::nocreate); for (;;) { datei >> i >> j; if (datei.fail()) break; if (h[i].kette == 0) { z = new list<edge>; h[i].kette = z; } else z = h[i].kette; edge k; k.num = j; z->push_front(k); // h[i].kette = z; h[j].zaehler++; } datei.close(); // Aufbau und Ausgabe der Adjazenzliste hash_map<int,vertex>::iterator hpIter; for (hpIter=h.begin();hpIter != h.end(); hpIter++) { cout << " " << hpIter->first << ": "; z = h[hpIter->first].kette; if ( z != 0) { list<edge>::iterator lpIter; for (lpIter=z->begin();lpIter != z->end(); lpIter++) cout << " " << (*lpIter).num; } cout << endl; pr53_2, pr34111.cpp 123 [10] 2 5 Abb.: 103 [9] 1 Algorithmen und Datenstrukturen } } 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. void Graph::startKnoten() { hash_map<int,vertex>::iterator hpIter; for (hpIter=h.begin();hpIter != h.end(); hpIter++) { if(h[hpIter->first].zaehler == 0) { s.push(hpIter->first); // cout << " " << hpIter->first; } } } In der anschließenden Prozedur topSort wird der Graph allmählich verkleinert, da hier alle Knoten gelöscht werden, die keine Vorgänger haben. void Graph::topSort() { int j, k; list<edge>* z; cout << "Topologische Ordnung:\n"; while (!s.empty()) { /* Nimm einen Knoten aus dem Stapel, gib ihn aus und verringere die Zaehler ( gezaehlt werden hier die Vorgaenger) seiner unmittelbaren Nachfolger um 1. Sobald dier Zaehler 0 ist, plaziere den zugehoerigen Knoten auf einem Stapel und entferne den Knoten (Kante) aus der Adjazenzliste. */ j = s.top(); s.pop(); cout << j << " "; /* if (z == 0) { cout << "Es gibt einen Zyklus.\n"; return; } */ z = h[j].kette; if (z != 0) { list<edge>::iterator lpIter; for (lpIter = z->begin();lpIter!=z->end();lpIter++) { k = (*lpIter).num; // z->pop_front(); h[k].zaehler--; if (h[k].zaehler == 0) s.push(k); } delete z; } } cout << endl; } 124 Algorithmen und Datenstrukturen Ausgabe Bei einem azyklischen Graphen bleibt bei dieser Vorgehensweise kein Knoten mehr übrig (sonst existiert ein Ring). Im vorliegenden Fall zeigt die Ausgabe 7 9 1 2 4 6 3 5 8 10 Abb.: an, daß eine lineare Ordnung erreicht wurde. 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. Leistungsaufwand Insgesamt wird jeder Knoten und jede Kante zur Bestimmung der topologischen Sortierung aufgesucht, so daß sich in der Summe eine Laufzeit von O(Anzahl der Knoten + Anzahl der Kanten) ergibt. In dieser Zeit O(Anzahl der Knoten + Anzahl der Kanten) kann ein gerichteter Graph auf Zyklenfreiheit getestet werden. 125 Algorithmen und Datenstrukturen 2.3.3.2 Projektplanung mit der Critcal Path Method Projektplanung für Tätigkeiten und Ergebnisse in einem Netzplan104 Die Projektplanung bildet eine besonders wichtiges Anwendungsgebiet von Graphen. Jedes Projekt besteht aus einer Vielzahl von Tätigkeiten (Aktivitäten), von denen einige aufeinander bezogen sein können. Die Tätigkeiten lassen sich in einem Graphen durch die Kanten darstellen. Neben dem Knotenpaar (i,j) verfügt jede Tätigkeit über eine bestimmte Dauer und eine Tätikeitsbeschreibung. Im Rahmen der Projektplanung werden die Begriffe Tätigkeit und Ereignis statt Kante und Knoten verwendet105. Die folgende Darstellung zeigt einen Netzplan mit für ein einfaches Projekt erforderlichen Tätigkeiten Bestelle A 50 Tage Baue B Teste B 4 1 20 Tage Korrigiere Fehler 2 25 Tage 15 Tage 3 Handbucherstellung 60 Tage Abb. : Ein Graph der Netzplantechnik Die Ereignisse 1 und 3 bezeichnen Anfang und Ende des Gesamtprojekts. Beschreibung der Tätigkeiten (Aktivitäten Die Projekttätigkeiten sind in einer Datei beschrieben. Die Datei enenthält die beiden Ereignis-Nummern, die Dauer der Tätigkeit und Tätigkeitsbeschreibung. Für den vorstehenden Netzplan besteht die Datei aus folgenden Datrensätzen: 1 1 4 2 4 3 4 2 3 3 50 20 25 15 60 Bestelle Hardware Erstelle Software Teste Software Korrigiere Fehler Handbucherstellung Derartige Datensätze sind durch das folgende Format festgelegt: EreignisAnfang EreignisEnde Dauer Tätigkeit Tätigkeitsbeschreibung Vereinbarungen zu Datenstrukturen struct aktivitaet { int nummer; int dauer; }; // // Nummer des Knoten // Taetigkeitsdauer struct ereignis { 104 105 vgl. Skriptum Operations Research, 4.2.2 Netzplantechnik bzw. 4.2 http://fbim.fh-regensburg.de/~saj39122/or/index.html 126 Algorithmen und Datenstrukturen int zaehler, ft, st; // fruehester bzw. spaetester Termin list<aktivitaet>* kette; ereignis(int z = 0, list<aktivitaet>* verk = 0) { zaehler = z; kette = verk; } }; class Projekt { private: // Hash-Tabelle zur Verankerung der Adjazenzliste hash_map<int,ereignis> h; stack<int> s; // Stack zur Ablage der vorgängerlosen Knoten int zMax; void ueberleseRestZeile(ifstream &datei); public: void erzAdjTab(const char *dName); void startKnoten(); void fzBest(); void erzInvAdjTab(const char *dName); void endKnoten(); void szBest(); void ausgabe(const char *dName); }; Aufbau der Adjazenzlisten Jede Tätigkeit (d.h. jede im Tätigkeitsnetz abgelegte Kante) entspricht einem Knoten in der Adjazenzliste void Projekt::erzAdjTab(const char *dName) { int i, j, d; list<aktivitaet> *z; ifstream datei(dName, ios::in | ios::nocreate); for (;;) { datei >> i >> j >> d; ueberleseRestZeile(datei); if (datei.fail()) break; if (h[i].kette == 0) { z = new list<aktivitaet>; h[i].kette = z; } else z = h[i].kette; // h[i].ft = 0; aktivitaet k; k.nummer = j; k.dauer = d; z->push_front(k); // h[i].kette = z; h[j].zaehler++; } datei.close(); // Aufbau und Ausgabe der Adjazenzliste hash_map<int,ereignis>::iterator hpIter; for (hpIter=h.begin();hpIter != h.end(); hpIter++) { zMax = 0; } } zMax = 0; } } 127 Algorithmen und Datenstrukturen [1] zaehler ft, st kette [2] 0 0 [3] [4] 1 3 1 0 0 0 0 0 0 3 15 0 nummer dauer 4 20 3 60 nummer dauer 3 2 50 25 Abb.: Speicherstruktur des Graphen nach dem Erzeugen der Adjazenzliste Mit void Projekt :: startKnoten() { hash_map<int,ereignis>::iterator hpIter; for (hpIter=h.begin();hpIter != h.end(); hpIter++) { if(h[hpIter->first].zaehler == 0) { s.push(hpIter->first); } } } erfolgt die Aufnahme der vorgängerlosen Elemente (zaehler == 0) in den Stapel s. Berechnung der frühest möglichen Termine Sofern alle Tätigkeiten möglichst früh einsetzen, tritt jedes Ereignis i an seinem frühesten Ereigniszeitpunkt auf (ft(i)). Im vorliegenden Bsp. ergibt sich: ft(1) = 0, ft(2) = 45, f(t3) = 80, ft(4) = 20. Es kann nicht jeder Pfad von Ereignispunkt 1 zu Ereignispunkt 3 genutzt werden, sondern man muß den längsten oder auch kritischen Pfad wählen, der aus den Aktivitäten (1,4) und (4.3) besteht. Allgemein ergibt sich ft(i) = 0 für alle Ereignisse, die keinen Vorgänger haben. Hat ein Ereignis j einen oder mehrere Vorgänger, so ist sein frühester Ereigniszeitpunkt der maximale Wert von ft(i) + Dauer von (i,j) Hier müssen alle unmittelbaren Vorgänger i von Ereignis j berücksichtigt werden. Zur effizienten Berechnung dieser Werte werden alle Ereignisse in topologischer Reihenfolge durchlaufen (hier: 1, 4, 2, 3) 128 Algorithmen und Datenstrukturen void Projekt :: fzBest() { int j, k, t; list<aktivitaet>* z; cout << "Topologische Ordnung:\n"; while (!s.empty()) { /* Nimm einen Knoten aus dem Stapel, gib ihn aus und verringere die Zaehler ( gezaehlt werden hier die Vorgaenger) seiner unmittelbaren Nachfolger um 1. Sobald dier Zaehler 0 ist, plaziere den zugehoerigen Knoten auf einem Stapel und entferne den Knoten (Kante) aus der Adjazenzliste. */ j = s.top(); s.pop(); cout << j << " "; z = h[j].kette; if (z != 0) { list<aktivitaet>::iterator lpIter; for (lpIter = z->begin();lpIter!=z->end();lpIter++) { k = (*lpIter).nummer; // z->pop_front(); h[k].zaehler--; t = h[j].ft + (*lpIter).dauer; if (t > h[k].ft) h[k].ft = t; if (t > zMax) zMax = t; if (h[k].zaehler == 0) s.push(k); } // h[j].kette = z->nachf; delete z; } } cout << endl; } [1] zaehler ft, st kette 0 0 0 [2] [3] [4] 1 3 1 0 0 0 0 0 3 15 0 nummer dauer 4 20 3 60 nummer dauer 3 2 50 25 Abb.: Speicherstruktur des Graphen nach dem Ermitten der frühest möglichen Termine Die Ereignisse werden unter Verwendung eines verknüpften Stapels durchlaufen. Alle Ereignisse ohne Vorgänger werden gelöscht. Beim Löschen der Ereignisse werden die Felder ft nicht entfernt. Aufbau der invertierten Adjazenzlisten 129 Algorithmen und Datenstrukturen Auf der Grundlage der Ereignisse anstelle der Tätigkeiten lassen sich die spätesten Termine berechnen. Der Minimalwert von st(i) ist st(j) – Dauer von (i,j). Hier müssen alle unmittelbaren Nachfolger j des Ereignisses i berücksichtigt werden. Die spätesten Ereigniszeitpunkte werden über eine rückwärtige Überprüfung berechnet106. void Projekt::erzInvAdjTab(const char *dName) { int i, j, d; list<aktivitaet> *z; hash_map<int,ereignis>::iterator hpIter; for (hpIter = h.begin();hpIter != h.end(); hpIter++) { // Löschen der Knoten der alten Adjazenzliste h[hpIter->first].st = zMax; h[hpIter->first].zaehler = 0; h[hpIter->first].kette = 0; } ifstream datei(dName, ios::in | ios::nocreate); for (;;) { datei >> i >> j >> d; ueberleseRestZeile(datei); if (datei.fail()) break; if (h[j].kette == 0) { z = new list<aktivitaet>; h[j].kette = z; } else z = h[j].kette; aktivitaet k; k.nummer = i; k.dauer = d; z->push_front(k); h[i].zaehler++; } datei.close(); // Aufbau und Ausgabe der invertierten Adjazenzliste for (hpIter=h.begin();hpIter != h.end(); hpIter++) { z = h[hpIter->first].kette; … } } Die Knoten der alten Adjazenzliste wurden nach der Berechnung der frühesten Termine gelöscht. Die früheste Endzeitpunkt wurde allerdings gespeichert (zMax) und somit nicht entfernt. Jedes Feldelment der Hashmap enthält die Anfangszeiger einer Liste, in der Vorgänger i des Ereigisses j gespeichert sind. 106 analog zur Überprüfung in Vorwärtsrichtung, bei der die frühesten Ereigniszeitpunkte berechnet werden 130 Algorithmen und Datenstrukturen [1] zaehler ft, st kette [2] 2 0 [3] 1 80 0 [4] 0 45 80 2 80 0 80 20 80 0 0 nummer 4 4 1 dauer 25 60 20 nummer 2 15 dauer nummer 1 dauer 50 Abb.: Speicherstruktur des Graphen nach dem Erzeugen der Adjazenzliste Mit void Projekt::endKnoten() { hash_map<int,ereignis>::iterator hpIter; for (hpIter=h.begin();hpIter != h.end(); hpIter++) { if(h[hpIter->first].zaehler == 0) { s.push(hpIter->first); } } } erfolgt die Aufnahme der vorgängerlosen Elemente (zaehler == 0) in den Stapel s. Berechnung der spätest zulässigen Termine Alle Felder mit den spätest zulässigen Termine werden auf den Wert der Gesamtprojektdauer (zMax) gesetzt. Aus allen Elementen ohne Nachfolger wird ein Stapel gebildet. Mit dem Stapel wird wie bei der Vorwärtsverkettung verfahren, so dass alle Ereignisse in umgekehrt topologischer Folge bearbeitet werden. Bzgl. Ereignis j wird der spätest zulässige Ereigniszeitpunkt seiner Vorgänger i folgendermaßen aktualisiert. Ist die Differenz st(j) – Dauer von (i,j) kleiner als der aktuelle Wert von st(i), so wird die Differenz st(i) zugewiesen Danach liegt die Berechnung der frühesten und spätesten Ereigniszeitpunkte vor. void Projekt::szBest() 131 Algorithmen und Datenstrukturen { int i, k, t; list<aktivitaet> *z; cout << "Invertierte Topologische Ordnung:\n"; while(!s.empty()) { i = s.top(); s.pop(); cout << i << " "; z = h[i].kette; if (z != 0) { list<aktivitaet>::iterator lpIter; for (lpIter = z->begin(); lpIter != z->end(); lpIter++) { k = (*lpIter).nummer; h[k].zaehler--; t = h[i].st - (*lpIter).dauer; if (t < h[k].st) h[k].st = t; if (h[k].zaehler == 0) s.push(k); } delete z; } } cout << endl; } Ausgabe Früheste und späteste Zeitpunkte befinden sich in der Hashmap h. Bei der Programmausgabe sollen die Ergebnisse zusammen mit den zugeordneten Tätigkeiten erscheinen. Für jede Tätigkeit (I,J) ist der früheste Anfangszeitpunkt FAZ(I,J), der früheste Endezeitpunkt FEZ(I,J) , der späteste Anfangszeitpunkt SAZ(I,J) und der späteste Endezeitpuinkt SEZ(I,J) nach folgenden Formeln zu berechnen: FAZ(I,J) FEZ(I,J) SAZ(I,J) SEZ(I,J) = = = = FZ(I) FZ(I) + DAUER(I,J) SZ(J) - DAUER(I,J) SZ(J) Entspricht bei einer Tätigkeit der frühest mögliche dem spätest zulässigen Anfangszeitpunkt, so liegt die Tätigkeit auf dem sogenannten kritischen Pfad. Das Zeitintervall, in der sich der Beginn der Projektphase ohne Auswirkung auf den Beendigungszeitpunkt verschieben darf, ist nach folgender Formel zu berechnen: LUFT(I,J) = SAZ(I,J) - FAZ(I,J) void Projekt :: ausgabe(const char *dName) { char beschreibung[80]; int i, j, d, faz, sez, fez, saz, luft; cout << "Ausgabe:\n\n"; cout << " I J DAUER FAZ SAZ FEZ SEZ "LUFT BESCHREIBUNG\n\n"; ifstream datei(dName, ios::in | ios::nocreate); for (;;) { datei >> i >> j >> d; if (datei.fail()) break; datei.getline(beschreibung, 80); faz = h[i].ft; // Fruehester Beginn sez = h[j].st; // Spaetestes Ende fez = faz + d; // Fruehestes Ende saz = sez - d; // Spaetester Beginn luft = saz - faz; cout << setw(3) << i << " " 132 " Algorithmen und Datenstrukturen << setw(3) << j << " " << setw(4) << d << " " << setw(5) << faz << " " << setw(4) << saz << " " << setw(5) << fez << " " << setw(4) << sez << " " << setw(6) << luft << " " << (luft ? " " : "<--") << beschreibung << endl; } datei.close(); } Test107 int main(int argc, char *argv[]) { Projekt g; char dName[50]; cout << "Eingabe-Datei: "; cin >> setw(50) >> dName; g.erzAdjTab(dName); // Erzeuge eine Adjazenzliste fuer den Graphen g.startKnoten(); // Bestimme Stapel mit Startknoten g.fzBest(); // Bestimme den fruehest moeglichen Termin g.erzInvAdjTab(dName); // g.endKnoten(); // bestimme Stapel mit Endknoten g.szBest(); // Bestimme die spaetest zulaessigen Ereignisse g.ausgabe(dName); // Ausgabe der Ergebnistabelle system("PAUSE"); return 0; } 107 vgl. pr53_2, pr34125.cpp 133 Algorithmen und Datenstrukturen 2.3.4 Klassenschablonen für verkettete Listen 2.3.4.1 Doppelt gekettete Listen vgl. 108 2.3.4.2 Ringförmig geschlossene Listen 1. Einfach verkettete, ringförmig geschlossene Liste Aufbau einfach geketteter Ringstrukturen BASIS "Leerer Ring" BASIS Abb.: Eine leere ringförmig verkettete Liste enthält eine Listenknoten und ein nicht initialisiertes Datenfeld. Der Zeiger auf diesen Listenknoten zeigt auf sich selbst. Ein „Null“-Zeiger existiert in ringförmig verketteten Listen nicht. Gegeben ist folgende Listenstruktur ZGR ZGR1 type Zeiger = record ............ ............ Nachf : ^Zeiger; end; Abb.: Eine ringförmige Datenstruktur kann durch die Anweisung ZGR1^.Nachf := ZGR; erreicht werden. In der Regel zeigt der letzte Knoten in der verkettet gespeicherten Liste auf den Listenanfang. Ringe können auch folgenden Aufbau besitzen: 108 Programmieren in C++, Skriptum zur Vorlesung im SS 2006, 5.2.3 bzw. 134 Algorithmen und Datenstrukturen Abb.: Die Klasse „einfach verketteter Ringknoten“ in C++109 // Deklaration Listenknoten template <class T> class ringKnoten { private: // ringfoermige Verkettung auf den naechsten Knoten ringKnoten<T> *nachf; public: // "daten" im oeffentlichen Zugriffsbereich T daten; // Konstruktoren ringKnoten(void); ringKnoten (const T& merkmal); // Listen-Modifikationsmethoden void einfuegenDanach(ringKnoten<T> *z); ringKnoten<T> *loeschenDanach(void); // beschafft die Adresse des (im Ring) folgenden Knoten ringKnoten<T> *nachfKnoten(void) const; }; Die Struktur einer einfach veketteten, ringförmig geschlossenen Liste kann so dargestellt werden: daten: nachf: Abb.: leere Liste daten: nachf: Abb.: Liste mit Knoten // Schnittstellenfunktionen Der Konstruktor initialisiert einen Knoten, der einen Zeiger enthält, der auf diesen Knoten zurück verweist. So kann jeder Knoten den Anfang einer leeren Liste repräsentieren. Das Datenfeld des Knoten bleibt in diesem Fall unbesetzt. // Konstruktor der eine Liste deklariert und "daten" // uninitialisiert laesst. template <class T> ringKnoten<T>::ringKnoten(void) { // initialisiere den Knoten, so dass er auf sich selbst zeigt 109 vgl. ringkno.h 135 Algorithmen und Datenstrukturen nachf = this; } // Konstruktor der eine leere Liste erzeugt und "daten" // initialisiert template <class T> ringKnoten<T>::ringKnoten(const T& merkmal) { // setze den Knoten so, dass er auf sich selbst zeigt // und initialisiere "daten" nachf = this; daten = merkmal; } Die Methode nachfKnoten() ermittelt einen Verweis auf den nächsten in der einfach verketteten, ringförmig geschlossenen Liste. Die Methode soll das Durchlaufen der Liste erleichtern. // Rueckgabe des Zeiger auf den naechsten Knoten template <class T> ringKnoten<T> *ringKnoten<T>::nachfKnoten(void) const { return nachf; } Die Methoden zur Modifikation der Liste einfuegenDanach(ringKnoten<T> *z); fügt die Listenknoten unmittelbar nach dem Anfangsknoten (der die leere Liste definiert) ein. vor dem Einfügen: nach dem Einfügen: daten: nachf: z Abb.: Einfügen des Knoten „z“ in eine leere Liste vor dem Einfügen: nach dem Einfügen: daten: nachf: Z Abb.: Einfügen des Knoten „z“ in ein einfach gekettete, ringförmig geschlossene Liste mit Listenknoten // Einfuegen eines Knoten z nach dem aktuellen Knoten template <class T> void ringKnoten<T>::einfuegenDanach(ringKnoten<T> *z) { // z zeigt auf den Nachfolger des aktuellen Knoten, // der aktuellen Knoten zeigt auf z z->nachf = nachf; nachf = z; } Die Methode loeschenDanach() löscht den Listenknoten unmittelbar nach dem aktuellen Knoten. // Loesche den Knoten, der dem aktuellen Knoten folgt und gib seine // Adresse zurueck 136 Algorithmen und Datenstrukturen template <class T> ringKnoten<T> *ringKnoten<T>::loeschenDanach(void) { // Sichere die Adresse des Knoten, der geloescht werden soll ringKnoten<T> *tempZgr = nachf; // Falls "nachf" mit der Adresse des aktuellen Objekts (this) ueberein// stimmt, wird auf sich selbst gezeigt. Hier darf nicht geloescht werden // (Rueckgabewert NULL) if (nachf == this) return NULL; // Der aktuelle Knoten zeigt auf denNachfolger von tempZgr. nachf = tempZgr->nachf; // Gib den Zeiger auf den ausgeketteten Knoten zurueck return tempZgr; } Anwendung: Das „Josephus-Problem“ Aufgabenstellung: Ein Reisebüro verlost eine Weltreise unter „N“ Kunden. Dazu werden die Kunden von 1 bis N durchnummeriert, eine Bediensteter des Reisebüros hat in einem Hut N Lose untergebracht. Ein Los wird aus dem Hut gezogen, es hat die Nummer M (1 <= M <= N). Zur Auswahl des glücklichen Kunden stellt man sich dann folgendes vor: Die Kunden (identifiziert durch die Nummern 1 bis N) werden in einem Kreis angeordnet und mit Hilfe der gezogenen Losnummer aus diesem Kreis entfernt. Bei bspw. 8 Kunden und der gezogenen Losnummer 3 werden, da das Abzählen bzw. Entfernen im Uhrzeigersinn erfolgt, folgende Nummern aus dem Kreis entfernt: 3, 6, 1, 5, 2, 8, 4. Die Person 7 gewinnt die Reise. Lösung110: #include <iostream.h> #include <stdlib.h> #include "ringkno.h" // Erzeuge eine ringfoermig verkettete Liste mit gegebenem Anfang void erzeugeListe(ringKnoten<int> *anfang, int n) { // Beginn des Einfuegevorgangs ringKnoten<int> *aktZgr = anfang, *neuerKnotenWert; int i; // Erzeuge die n Elemente umfassende ringfoermige Liste for(i=1;i <= n;i++) { // Belege den Knoten mit Datenwert neuerKnotenWert = new ringKnoten<int>(i); // Einfuegen am Listenende aktZgr->einfuegenDanach(neuerKnotenWert); aktZgr = neuerKnotenWert; } } // Gegeben ist eine n Elemente umfassende, ringfoermige Liste; loese das // Josephus-Problem durch Loeschen jeder m. Person bis nur // eine Person uebrig bleibt void Josephus(ringKnoten<int> *liste, int n, int m) { // vorgZgr bewegt aktZgr durch die Liste ringKnoten<int> *vorgZgr = liste, *aktZgr = liste->nachfKnoten(); ringKnoten<int> *geloeschterKnotenZgr; // Loesche alle bis auf eine Person aus der Liste 110 PR22221.CPP 137 Algorithmen und Datenstrukturen for(int i=0;i < n-1;i++) { // Zaehle die Personen jeweils an der aktuelle Stelle // Suche m Personen auf for(int j=0;j < m-1;j++) { // Ausrichten der Zeiger vorgZgr = aktZgr; aktZgr = aktZgr->nachfKnoten(); // Falls "aktZgr am Anfang steht, bewege die Zeiger weiter if (aktZgr == liste) { vorgZgr = liste; aktZgr = aktZgr->nachfKnoten(); } } cout << "Loesche Person " << aktZgr->daten << endl; // Ermittle den zu loeschenden Knoten und aktualisiere aktZgr geloeschterKnotenZgr = aktZgr; aktZgr = aktZgr->nachfKnoten(); // loesche den Knoten aus der Liste vorgZgr->loeschenDanach(); delete geloeschterKnotenZgr; // Falls aktZgr am Anfang steht, bewege Zeiger weiter if (aktZgr == liste) { vorgZgr = liste; aktZgr = aktZgr->nachfKnoten(); } } cout << endl << "Ausgezaehlt wurde " << aktZgr->daten << endl; // Loesche den uebrig gebliebenen Knoten geloeschterKnotenZgr = liste->loeschenDanach(); delete geloeschterKnotenZgr; } void main(void) { // Liste mit Personen ringKnoten<int> liste; // n ist die Anzahl der Personen, m ist die Abzaehlgroesse int n, m; cout << "Anzahl Bewerber? "; cin >> n; // Erzeuge eine ringfoermig gekettete Liste mit Personen 1, 2, ... n erzeugeListe(&liste,n); // Zufallswert: 1 <= m <= n randomize(); m = 1 + random(n); cout << "Erzeugte Zufallszahl " << m << endl; // loese das Josephus Problem und gib den Gewinner aus Josephus(&liste,n,m); } /* <Ablauf des Programms> Anzahl der Bewerber? 10 Erzeugte Zufallszahl 5 Loesche Person 5 Loesche Person 10 Loesche Person 6 Loesche Person 2 Loesche Person 9 Loesche Person 8 Loesche Person 1 Loesche Person 4 Loesche Person 7 Person 3 gewinnt. */ 138 Algorithmen und Datenstrukturen 2. Doppelt verkettete, ringförmig geschlossene Liste Basis Abb.: Doppelt gekettete Ringstruktur Leerer Ring Basis Abb.: Der leere Ring in einer doppelt geketteten Ringstruktur Doppelt verkettete Listen erweitern den durch ringförmig verkettete Listen bereitgestellten Leistungsumfang beträchtlich. Sie erleichtern das Einfügen und das Löschen durch Zugriffsmöglichkeinten in zwei Richtungen: links daten rechts ...... ..... 4 1 2 3 Abb.: Klassenschablone „doppelt verketteter RingKnoten“111 template <class T> class dkringKnoten { private: // ringfoermig angeornete Verweise nach links und rechts dkringKnoten<T> *links; dkringKnoten<T> *rechts; public: // daten steht unter oeffentlichem Zugriff T daten; // Konstruktoren: dkringKnoten(void); dkringKnoten (const T& merkmal); // Modifikation der Listen 111 dringkn.h 139 Algorithmen und Datenstrukturen void einfuegenRechts(dkringKnoten<T> *z); void einfuegenLinks(dkringKnoten<T> *z); dkringKnoten<T> *loescheKnoten(void); // Beschaffen der Adressen der nachfolgenden Knoten auf der // linken und rechten Seite dkringKnoten<T> *nachfKnotenRechts(void) const; dkringKnoten<T> *nachfKnotenLinks(void) const; }; Methoden für doppelt verketteten Listenknoten einer ringförmig geschlossenen Liste Konstruktoren // Konstruktor: erzeugt eine leere Liste, das Datenfeld bleibt // ohne Initialisierung; wird zur Definition des Listenanfangs benutzt template <class T> dkringKnoten<T>::dkringKnoten(void) { // ínitialisiert den Knoten mit einem Zeiger, der auf den // Knoten zeigt links = rechts = this; } // Konstruktor: erzeugt eine leere Liste und intialisierte das Feld daten template <class T> dkringKnoten<T>::dkringKnoten(const T& merkmal) { // initialisiert den Knoten mit einem Zeiger der // auf den Knoten zeigt und initialisiert das Datenfeld links = rechts = this; daten = merkmal; } Einfügen eines Knoten // Fuege einen Knoten z rechts zum aktuellen Knoten ein template <class T> void dkringKnoten<T>::einfuegenRechts(dkringKnoten<T> *z) { // kette z zu seinem Nachfolger auf der rechten Seite ein z->rechts = rechts; rechts->links = z; // verkette z mit dem aktuellen Knoten auf seiner linkten Seite z->links = this; rechts = z; } // Fuege einen Knoten z links zum aktuellen Knoten ein template <class T> void dkringKnoten<T>::einfuegenLinks(dkringKnoten<T> *z) { // kette z zu seinem Nachfolger auf der linken Seite ein z->links = links; links->rechts = z; // verkette z mit dem aktuellen Knoten auf seiner rechten Seite z->rechts = this; links = z; } Löschen // Ausketten des aktuellen Knoten aus der Liste template <class T> dkringKnoten<T> *dkringKnoten<T>::loescheKnoten(void) { // Knotenverweis "links" muss verkettet werden mit dem // Verweis des aktuellen Knoten nach rechts links->rechts = rechts; // Knotenverweis "rechts" muss verkettetet werden mit dem 140 Algorithmen und Datenstrukturen // Verweis des aktuellen Knoten nach links rechts->links = links; // Rueckgabe der Adresse vom aktuellen Knoten return this; } Bestimmen der nachfolgenden Knoten // Rueckgabe Zeiger zum naechsten Knoten auf der rechten Seite template <class T> dkringKnoten<T> *dkringKnoten<T>::nachfKnotenRechts(void) const { return rechts; } // Rueckgabe Zeiger zum naechsten Knoten auf der linken Seite // return pointer to the next node on the left template <class T> dkringKnoten<T> *dkringKnoten<T>::nachfKnotenLinks(void) const { return links; } Anwendung: Einfügen eines doppelt verketteten Listenknoten in eine geordnete Fole von Listenknoten112 Falls der Aufbau einer geordneten Folge von doppelt verketteten Listenknoten im Rahmen einer ringförmig verketteten Liste gelingt, kann die Liste in Vorwärtsrichtung (links) durchlaufen bzgl. der in den Listenknoten gespeicherten Daten eine aufsteigende Sortierung zeigen und ,in Rückwärtsrichtung (rechts) durchwandert, eine absteigende Sortierung aufweisen. Mit zwei Funktionsschablonen einfuegenKleiner() und einfuegenGroesser() soll dies erreicht werden. Zum Aufbau der ringförmig, doppelt verketteten Liste wird die Funktionsschablone DverkSort() herangezogen, die zum geordneten Einfügen die Funktionsschablone einfuegenKleiner() und einfuegenGroesser() benutzt und den Anfangszeiger „dkAnfang“ verwaltet. template <class T> void einfuegenKleiner(dkringKnoten<T> *dkAnfang, dkringKnoten<T>* &aktZgr, T merkmal) { dkringKnoten<T> *neuerKnoten= new dkringKnoten<T>(merkmal), *z; // Bestimme den Einfuegepunkt z = aktZgr; while (z != dkAnfang && merkmal < z->daten) z = z->nachfKnotenLinks(); // Einfuegen des Knotens mit dem Datenelement z->einfuegenRechts(neuerKnoten); // Ruecksetzen aktZgr auf den neuen Knoten aktZgr = neuerKnoten; } template <class T> void einfuegenGroesser(dkringKnoten<T>* dkAnfang, dkringKnoten<T>* & aktZgr, T merkmal) { dkringKnoten<T> *neuerKnoten= new dkringKnoten<T>(merkmal), *z; // Bestimmen des Einfuegepunkts z = aktZgr; while (z != dkAnfang && z->daten < merkmal) z = z->nachfKnotenRechts(); // Einfuegen des Datenelements z->einfuegenLinks(neuerKnoten); 112 PR22225.CPP 141 Algorithmen und Datenstrukturen // Ruecksetzen des aktuellen Zeigers auf neuerKnoten aktZgr = neuerKnoten; } template <class T> void DverkSort(T a[], int n) { // Die doppelt verkettete Liste soll Feld-Komponenten aufnehmen dkringKnoten<T> dkAnfang, *aktZgr; int i; // Einfuegen des ersten Elements in die doppelt verkettete Liste dkringKnoten<T> *neuerKnoten = new dkringKnoten<T>(a[0]); dkAnfang.einfuegenRechts(neuerKnoten); aktZgr = neuerKnoten; // Einbrigen weiterer Elemente in die doppelt verkettete Liste for (i = 1; i < n; i++) if (a[i] < aktZgr->daten) einfuegenKleiner(&dkAnfang,aktZgr,a[i]); else einfuegenGroesser(&dkAnfang,aktZgr,a[i]); // Durchlaufe die Liste und kopiere die Datenwerte zurueck in den "array" aktZgr = dkAnfang.nachfKnotenRechts(); i = 0; while(aktZgr != &dkAnfang) { a[i++] = aktZgr->daten; aktZgr = aktZgr->nachfKnotenRechts(); } // Loesche alle Knoten in der Liste while(dkAnfang.nachfKnotenRechts() != &dkAnfang) { aktZgr = (dkAnfang.nachfKnotenRechts())->loescheKnoten(); delete aktZgr; } } Der folgende Hauptprogrammabschnitt ruft die vorliegende Funktionsschablone zum Sortieren eines Arbeitsspeicherfelds auf void main(void) { // Ein initialisierter "array" mit 10 Ganzzahlen int A[10] = {82,65,74,95,60,28,5,3,33,55}; DverkSort(A,10); // sortiere "array" cout << "Sortiertes Feld: "; for(int i=0;i < 10;i++) cout << A[i] << " "; cout << endl; } 142 Algorithmen und Datenstrukturen 3. Algorithmen 3.1 Ausgesuchte algorithmische Probleme 3.1.1 Spezielle Sortieralgorithmen Sortieren bedeutet: Anordnen einer gegebenen Menge von Datenelementen in einer bestimmten Ordnung113. Danach sind Suchvorgänge nach diesen Elementen wesentlich vereinfacht. Da es nur wenige Programmierprobleme gibt, die ohne Sortieren auskommen, ist die Vielfalt der dafür vorhandenen Algorithmen fast unüberschaubar. Alle verfolgen den gleichen Zweck, viele sind in gewisser Hinsicht optimal, und die meisten Algorithmen haben unter gewissen Bedingungen auch Vorteile gegenüber anderen. Eine Leistungsanalyse der Algorithmen kann diese Vorteile herausstellen. Selbstverständlich hängt auch beim Sortieren die Wahl des Algorithmus von der Struktur der zu bearbeitenden Daten ab. Die Sortiermethoden teilen sich hier grundsätzlich bereits in zwei Gruppen: - Sortieren von Feldern (internes Sortieren) Felder befinden sich auf direkt zugreifbaren, internen Speicherbereichen. - Sortieren von (sequentiellen) Dateien (externes Sortieren). Dateien sind auf externen Speichern (Bänder, Platten) untergebracht. Daten liegen hier im Format eines sequentiellen File vor. Dadurch ist zu jeder Zeit nur eine Komponente im direkten Zugriff. Diese Einschränkungen gegenüber Feldstrukturen bedeutet, daß andere Techniken zum Sortieren herangezogen werden müssen. 3.1.1.1 Interne Sortierverfahren 3.1.1.1.1 Quicksort Beschreibung. Beim Quicksort-Verfahren wird in jedem Schritt ein Element x der zu sortierenden Folge als Pivot-Element ausgewählt. Dann wird die zu sortierende Folge so umgeordnet, dass eine Teilfolge links von x entsteht, in die alle Werte der Elemente kommen, die nicht größer als x sind. Rechts von x entsteht eine Teilfolge, in der alle Werte der Elemente kommen, die größer sind als das Pivot-Element x. Diese Teilfolgen werden dann selbst wieder nach dem gleichen Verfahren rekursiv zerlegt und umsortiert. Dies geschieht so lange, bis die Teilfolgen die Länge 1 besitzen und damit bereits sortiert sind, so dass man am Ende eine vollständig sortierte Folge enthält. 113 1.3.3 143 Algorithmen und Datenstrukturen Abb.: Implementierung. // Tauschen den Werte von x und y template <class T> void tausche (T &x, T &y) { T temp = x; x = y; y = temp; } // quickSort template <class T> void quickSort(T A[], int links, int rechts) { T pivot; int I, J; int mitte; // Falls der Bereich nicht mehr zwei Elemente umfasst: Ruecksprung if (rechts - links <= 0) return; else // Falls die Teilliste nur aus zwei Elementen besteht, // vergleiche die beiden Elemente und tausche gegebenenfalls // die Werte if (rechts - links == 1) { if (A[rechts] < A[links]) tausche(A[links], A[rechts]); return; } // Berechne den mittleren Index, der an dieser Stelle vorkommende // Datenwert erhaelt das Pivot-Element zugeordnet 144 Algorithmen und Datenstrukturen mitte = (links + rechts)/2; pivot = A[mitte]; // Tausche das Pivot-Element mit dem linken Element aus dem Bereich // Initialisiere I und J tausche(A[mitte], A[links]); I = links + 1; J = rechts; // Bestimme die Indexe zur Lokalisierung der Elemente, die // in der falschen Teilliste sind do { // Bearbeite die linke Teilliste; hoere damit auf, falls // I in die rechte Teilliste hineinkommt oder ein Element, // das groesser als das Pivot-Element ist, erreicht wird while (I <= J && A[I] <= pivot) I++; // Berbeite die rechte Teilliste; hoere damit auf, wenn ein // Element kleiner/gleich dem Pivot-Element ist while (pivot < A[J]) J--; // Falls die Indexe noch innerhalb der Teillisten sind, // bestimmen sie Elemente, die nicht in diese Teillisten // gehoeren. Sie sind zu tauschen if (I < J) tausche(A[I], A[J]); } while (I < J); // Kopiere das Pivot-Element an eine Indexpostion(J), die // die beiden Teillisten trennt A[links] = A[J]; A[J] = pivot; // Falls die kleinere Teilliste zwei oder mehr Elemente // umfasst: rekursiver Auiifruf if (links < J-1) quickSort(A, links, J-1); //´Falls die groessere Teilliste zwei oder mehr Elemente // umfasst: rekursiver Aufruf if (J+1 < rechts) quickSort(A, J+1, rechts); } N Aufwand. Maximal werden zum Sotieren des Felds (Array) der Länge N 2 Vergleiche benötigt. Besonders ungünstig ist eine bereits sortierte Liste. Wird der Quicksort auf eine solche Liste angesetzt und ist die Wahl des Pivot-Elements auf das erste bzw. letze Element gefallen, dann läuft in diesem Fall das „Divide and Conquer“-Verfahren komplett ins Leere114. In diesem Fall benötigt der Quicksort N N n i = Vergleiche. i 1 2 Durchschnittlich benötigt der Quicksort zum Sortieren eines Felds der Länge N 2 ln( 2) N log( N ) O( N ) Vergleiche. Entscheidend für die Laufzeit vom Quicksort ist hierbei die gute Wahl des Pivotelements: - Fällt die Wahl auf das letzte Element, dann ist das schlecht bei vorsortierten Arrays. - Bei einer zufälligen Wahl liegt besseres Verhalten vor bei vorsortierten Arrays. Nachteilig ist der zusätzliche Aufwand für die Randomisierung. - Meistens entscheidet man sich für die Wahl des Median: Das mittlere Element des ersten, mittleren und letzten Elements des Array. 114 Eine der entstehenden Teilfoge ist leer, die andere enthält alle restlichen Elemente. 145 Algorithmen und Datenstrukturen 3.1.1.1.2 Heap-Sort Beschreibung. Der Algorithmus zum Heap-Sort untergliedert sich in zwei Phasen: - In der ersten Phase wird aus der unsortierten Folge von N Elementen ein Heap aufgebaut. - In der zweiten Phase wird der Heap ausgegeben, d.h. ihm wird jeweils das größte Element entnommen (das ja an der Wurzel steht). Dieses Element wird in die zu sortierende Folge aufgenommen und die Heap-Eigenschaften werden anschließend wieder hergestellt. Implementierung115. // Heap-Sort template <class T> static void sinken(T *a, int k, int n) { int i, j; T x; i = k; x = a[i]; while ((j = 2 * i + 1) < n) { if (j < n - 1 && a[j] < a[j+1]) j++; if (x >= a[j]) break; a[i] = a[j]; i = j; } a[i] = x; } template <class T> void heapSort(T *a, int n) { T x; for (int k=n/2-1; k>=0; k--) sinken(a, k, n); while (--n > 0) { x = a[0]; a[0] = a[n]; a[n] = x; sinken(a, 0, n); } } Leistungsaufwand. Mit dem Heap-Sort kann ein Feld der Länge N mit höchstens 2 N log( N ) O( N ) vielen Vergleichen sortiert werden. Ein Heap mit l Stufen (Level) verfügt höchstens über 2 l-1 Knoten. Beim Heap-Sort ist die Anzahl der Vergleiche kleiner als die Anzahl der Vergleiche zum Erzeugen eines Heap für N beliebige Elemente addiert mit der Summe der Vergleiche bei allen „Löschungen des Größtwerts“. Anzahl der Vergleiche zum Erzeugen eines Heap für N beliebige Elemente: l 1 2 l 1 0 2 l 2 2 1 ... 2 2 (l 2) 1 2 (l 1) 2 i 2 (l 1 i ) 2 2 l 2 l 2 i 0 Anzahl der Vergleiche zum Löschen des Maximums: Spätestens nach dem Löschen von 2l-1 Elementen nimmt die Anzahl der Levels des Heap um 1 ab, nach weiteren 2 l2 Elementen wieder um 1, usw. Damit gilt für die Anzahl der Vergleiche l 1 2 l 1 2 (l 1) 2 l 2 2 (l 2) ... 2 (2 1) 2 i 2 i 2 ((l 2) 2 l 2) . i 1 Für die Anzahl der Vergleiche beim Heap-Sort ergibt sich damit: 2 2 l 2 l 2 2 ((l 2) 2 l 2) 2 l 2 l 2 l 6 Da der Heap-Sort auf einem N Elemente umfassenden Array ausgeführt wird, ergibt sich die Höhe l zu log 2 ( N 1) . die Anzahl der Vergleiche ist dann beim Heap-Sort bestimmt durch: 2 N log 2 ( N ) O( N ) 115 pr13228 146 Algorithmen und Datenstrukturen 3.1.1.1.3 Sortieren durch Mischen 1. Einführung Aus 2 (2-Weg-Mischen) oder mehr (n-Weg-Mischen) bereits sortiert vorliegenden Teillisten ist durch geeignetes Zusammenfügen eine einzige sortierte Teilliste zu erzeugen. Auf diese Weise sollen aus kleinen Teillisten (zu Beginn: Länge = 1) immer größere produziert werden, bis schließlich nur noch eine einzige sortierte Liste übrig bleibt. 2. Verschmelzen von Feldern Kern dieses Mischverfahrens ist das wiederholte Verschmelzen sortierter Teillisten. Bsp.: 17 11 Vergleiche: 17 - 11 23 37 68 45 78 67 17 - 37 23 - 37 37 - 68 45 - 68 67 - 68 Abb.: Der soeben beschriebene Mischungsvorgang findet häufig auch bei Dateien Anwendung116. 3. 2-Wege-Mischsortieren Eine Folge von Schlüsseln wird sortiert, indem bereits sortiert vorliegende Teilfolgen zu immer längeren Teilfolgen verschmolzen werden. Zu Beginn ist jeder Schlüssel eine sortierte Teilfolge. In einem Durchgang werden jeweils zwei benachbarte Teilfolgen zu einer Folge verschmolzen. Ist die Anzahl der Schlüssel eine Potenz von 2, dann ist das paarweise Zusammenmischen, ohne daß eine Teilfolge übrig bleibt, immer gewährleistet, z.B.: 27 18 33 55 68 12 16 08 87 95 63 37 45 52 11 116 18 27 33 55 12 68 08 16 87 95 37 63 45 52 11 18 27 33 55 08 12 16 68 37 63 87 95 11 16 45 08 12 16 18 27 33 55 68 11 19 37 45 52 63 87 08 11 12 16 18 19 27 33 37 45 52 55 63 68 87 vgl. Sequential Update Problem 147 Algorithmen und Datenstrukturen 19 19 52 95 95 Bei jedem Durchgang verdoppelt sich die Länge der Teilfolgen. Falls die Anzahl der Schlüssel keine Zweierpotenz ist, bleibt am Ende eines Durchgangs eine Teilfolge übrig, z.B.: 27 18 33 55 68 12 16 8 87 95 63 18 27 33 55 12 68 8 16 87 95 63 18 27 33 55 8 12 16 68 63 87 95 8 12 16 18 27 33 55 68 63 87 95 8 12 16 18 27 33 55 63 68 87 95 Vollständig ist die Schlüsselfolge sortiert, falls in einem Durchgang nur noch zwei Teilfolgen verschmelzen. Leistungsanalyse. Durch das Umspeichern geht jeder Schlüssel in jedem Durchlauf in eine Elementaroperation ein. Neben dem Transport findet auch ein Vergleich statt (mit Ausnahme der Restliste). Da es bei N = 2n Schlüsseln n = ldN Durchläufe gibt, ist der Gesamtaufwand: Z = NldN Der Speicheraufwand ist: S = 2N 4. Rekursives Mischsortieren Das Prinzip des 2-Wege-Mischsortierverfahrens beruht in der Aufteilung. Eine Teilfolge ist einfacher zu sortieren als die vollständige Folge. Diese Folge wird deshalb zunächst einmal geteilt, da die beiden Hälften einfacher durch das Mischsortieren zu behandeln sind. Die sortierte Folge ergibt sich dann durch Verschmelzen der beiden sortierten Teilfolgen. Nutzt man dieses Prinzip vollständig aus, dann ist das Teilen schließlich so weit durchzuführen bis Teilfolgen vorliegen, die bereits sortiert sind. Eine Folge, die nur aus einem Schlüssel besteht ist immer sortiert und besimmt damit eindeutig das Ende des Teilungsprozesses. Das Mischsortieren ist damit eindeutig durch ein rekursives Verfahren lösbar. // Rekursives Mischsortieren in C++ template <class T> void mische(const T* a, int na, const T* b, int nb, T* c) { int ia = 0, ib = 0, ic = 0; while (ia < na && ib < nb) c[ic++] = (a[ia] < b[ib] ? a[ia++] : b[ib++]); while(ia < na) c[ic++] = a[ia++]; while(ib < nb) c[ic++] = b[ib++]; } // Die vorliegende Funktion dient als Basis fuer ein einfaches und // schnelles Sortierverfahren. Nachteilig: Ein zusaetzliches "Array" // ist noetig template <class T> void mischSort(T* a, int n) { if (n < 2) return; int nLinks = n / 2, nRechts = n - nLinks; mischSort(a,nLinks); mischSort(a+nLinks,nRechts); T* z = new T[n]; mische(a,nLinks,a + nLinks,nRechts,z); 148 Algorithmen und Datenstrukturen for (int i = 0; i < n; i++) a[i] = z[i]; delete [] z; } 5. Natürliches 2-Wege-Mischen Das Verschmelzen von nur aus einem Element bestehenden Teilfolgen kann häufig durch längere, bereits sortiert vorliegende Teilfolgen verbessert werden. Man versucht, eine natürliche, in der gegebenen Folge bereits enthaltene Vorsortierung auszunutzen. So zeigt bspw. das folgende Feld 27 18 37 55 68 12 16 8 87 95 63 sechs bereits sortiert vorliegende Teilfolgen: 27 18 37 12 16 8 87 55 68 95 63 Abb.: Die Teilfolgen können ermittelt und anschließend zusammengemischt werden: 18 27 37 55 68 8 12 16 87 95 63 95 63 Der Vorgang kann wiederholt werden. Das führt zur Folge 8 12 16 18 27 37 55 68 87 , die schließlich zu einer vollständig sortierten Folge umgestellt werden kann: 8 12 16 18 27 37 55 149 63 68 87 95 Algorithmen und Datenstrukturen 3.1.1.2 Externe Sortierverfahren Generell ist hier die zu sortierende Datenmenge so groß, daß sie nicht mehr vollständig im Arbeitsspeicher Platz findet. Die Daten sind in einem peripheren und sequentiellen Speichermedium (Band, Platte) enthalten. Die Daten sind grundsätzlich sequentielle Dateien (Files) mit der Eigenschaft, daß zu jeder Zeit genau eine Komponente zugreifbar ist. Diese Einschränkung verlangt die Verwendung anderer Techniken zum Sortieren. Am bedeutendsten ist hier: Sortieren durch Mischen 117. 3.1.1.2.1 Direktes Mischsortieren 1. 2-Wege-Mischsortierverfahren Grundlagen. Das direkte Mischsortieren kann auf sequentielle „Files“ folgendermaßen angewandt werden: 1. Zerlegung einer gegebenen Sequenz (z.B. A) in 2 Hälften (z.B. B und C). 2. Mischen von B und C durch Kombination einzelner Elemente zu geordneten Paaren 3. Die gemischte Sequenz ist A. Wiederholung der Schritte 1 und 2, wobei die geordneten Paare nun zu Quadrupeln zusammenzufassen sind. 4. Wiederholung der vorhandenen Schritte, in der jedes Mal die Länge der gemischten Sequenzen verdoppelt werden, bis die ganze Sequenz geordnet ist. Bsp.: Gegeben ist die Sequenz A: 44 1. Schritt 2. Schritt 3. Schritt 4. Schritt 5. Schritt 6. Schritt B: C: 44 94 55 18 12 06 42 67 A: 44 94 18 55 B: C: 44 06 94 12 18 42 55 67 A: 06 12 44 94 55 12 06 18 B: C: 06 18 12 42 44 55 94 67 A: 06 12 18 42 12 42 44 42 42 55 55 94 18 06 67 67 67 67 94 Begriffe: - Phase: Jede Operation, die die ganze Menge der Daten einmal behandelt. - Durchlauf, Arbeitsgang: Der kleinste Teilprozess, dessen Wiederholung den Sortierprozess ergibt. Im Bsp. umfaßt das Sortieren 3 Durchläufe. Jeder Durchlauf besteht aus einer Zerlegungs- und Mischphase. Zum Sortieren werden 3 Bänder (sequentielle Files) benötigt, der Prozeß heißt 3-Band-Mischen. 117 vgl. 3.1.1.1.3 Sortieren durch Mischen 150 Algorithmen und Datenstrukturen Das direkte Mischsortierverfahren verwendet Teillisten fester Länge. Das Verfahren besteht aus einer Reihe von (Durch-) Läufen, die mit nur ein Datenelement umfassenden Teillisten beginnen. Jeder Lauf verdoppelt die Größe der Teillisten. Sortiert ist dann, wenn nur eine Teilliste mit allen Datenelementen in sortierter Folge vorliegt. Verfahrensaufwand. Sortiert ist dann, wenn nur eine Teilliste mit allen Datenelementen in sortierter Folge vorliegt. Erforderlich sind bei N Datenelementen ldN verschiedene Läufe, wobei alle N Datenelemente auf temporären Dateien und anschließend wieder zurück auf das Original kopiert werden. Das führt zu 2 N ldN Zugriffe. Algorithmus. Umfaßt das sequentielle „File“ N zu sortierende Datensätze, dann teilt man diese Datensätze in N/I Teilfolgen. Die Teilfolgen enthalten demnach höchstens I Datensätze. I ist die Anzahl der Datensätze, die (höchstens) in den Hauptspeicher passen. Man liest eine derartige Teilfolge (ein Intervall mit I Datensätzen) in den Internspeicher ein, sortiert sie mit einem der bekannten Arbeitsspeicher-Sortierverfahren und schreibt die sortierte Teilfolge zurück auf den Externspeicher, d.h. auf diverse sequentielle „Files“. Zu Beginn muß man diverse Datensätze auf dem „Eingabe-File“ auf mehrere „Files“ aufteilen (z.B. 2), dann verschmilzt man die inzwischen sortierten Teilfolgen. Die so entstandene Folge muß wieder aufgeteilt werden, bis man schließlich eine vollständig sortierte Folge erzeugt hat. Der Wechsel für Verteilungs- und Mischphase ist charakteristisch. Ausgeglichenes direktes Mischsortieren118. Der bisher beschriebene und implementierte Verfahrensablauf wurde mit Hilfe von drei sequentiellen „Files“ realisiert. Nimmt man noch ein viertes File hinzu, dann kann die Verteilungs- und Mischphase zusammengefaßt werden (ausgeglichenes direktes Mischsortieren). Verfahrensbeschreibung: 4 Dateien d1, d2, d3, d4 sind gegeben. Eingabedatei ist d1. Es werden wiederholt eine bestimmte Anzahl (A) Datensätze eingelesen, intern sortiert und abwechselnd solange auf d3 und d4 geschrieben bis d1 erschöpft ist. Sortierte Teilfolgen (sog. Läufe, Runs) der Länge I stehen dann auf d 3 und d4. Diese Läufe werden anschließend verschmolzen. Dabei entstehen Läufe mit 2*I langen Teilfolgen, die abwechselnd auf d1 und d2 verteilt werden. Nach jeder Aufteilungsund Verschmelzungsphase hat sich die Run-Länge verdoppelt und die Anzahl der Läufe etwa halbiert. Das Verfahren besteht also aus - dem Verschmelzen der Läufe von zwei Dateien und abwechselndem Verteilen aus den beiden anderen Dateien - dem (logischen) Vertauschen der Dateien bis schließlich nur ein Lauf auf einer der Dateien übrig bleibt. Bsp.: Der Hauptspeicher des Rechner faßt 3 Datensätze (I = 3). Die Dateien enthalten folgende Schlüssel: d1: 12, 2, 5, 15, 13, 6, 14, 1, 4, 9, 10, 3, 11, 7, 8 d2: A Datensätze werden jeweils von d1 gelesen, intern sortiert und auf d3 und d4 aufgeteilt: d3: 2, 5, 12, 1, 4, 14, 7, 8, 11 d4: 6, 13, 15, 3, 9, 10 118 vgl. PR33116.CPP 151 Algorithmen und Datenstrukturen d3 und d4 werden gelesen, d1 und d2 beschrieben: d1: 2, 5, 6, 12, 13, 15, 7, 8, 11 d2: 1, 3, 4, 9, 10, 14 Es folgt: d3: 1, 2, 3, 4, 5, 6, 9, 10, 12, 13, 14, 15 d4: 7, 8, 11 und schließlich d1: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 d1 enthält zufälligerweise die sortierte Folge. Generell kann sie auf d1 oder d3 entstehen, wenn von den beiden Dateien d1 und d2 (bzw. d3 und d4) entstehende Läufe zuerst auf d1 und danach auf d2 (bzw. zuerst auf d3 und danach auf d4) geschrieben werden119. Verfahrensaufwand: Nach jeder Verschmelzungs- und Verteilungsphase hat sich die Anzahl der Läufe etwa halbiert. Zu Beginn wurde aus N Datensätzen über ein internes Arbeitsspeichersortierverfahren N/I Läufe hergestellt. Damit ergibt sich nach log(N/I) Durchgängen ein einziger sortierter Lauf. Implementierung in C++: const int I = 10; // Anzahl Datensaetze, die im Arbeitssp. Platz haben // Klasse Bal2WegSort zum Sortieren externer Daten class Bal2WegSort { private: // Datenelemente int* A; // Array fuer Arbeitsspeichersortieren int l1, l2, aus; fstream* Datei; // Array zur Aufnahme der Dateien int ind1l, ind2l, ind1s, ind2s, inds; // Methoden void init(); void OeffneDat(int); public: Bal2WegSort(); // Konstruktor ~Bal2WegSort(); // Destruktor void Sort(); }; // Schnittstellenfunktionen void Bal2WegSort::init() { Datei[0].open("t1.dat", ios::in); Datei[2].open("t3.dat", ios::out | ios::trunc); Datei[3].open("t4.dat", ios::out | ios::trunc); int i = 0, index = 2; // Herleiten der Anfangsverteilung while(!Datei[0].eof()) { if(i == I) { // Sortieren von Teilfolgen der Laenge I BubbleSort(A,I); // Sortierte Teilfolgen werden abwechselnd nach Datei[2] 119 vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 142 152 Algorithmen und Datenstrukturen // bzw. Datei[3] gespeichert, begonnen wird mit Datei[2] for(int j=0; j < I; j++) Datei[index] << A[j] << " " << flush; index = (index == 2) ? 3 : 2; i = 0; } Datei[0] >> A[i++]; } BubbleSort(A,i); for(int j=0; j < i-1; j++) Datei[index] << A[j] << " " << flush; // Dateien schliessen Datei[0].close(); Datei[2].close(); Datei[3].close(); } // oeffnet die 4 Dateien entsprechend void Bal2WegSort::OeffneDat(int flag) { if(flag) { Datei[0].open("t1.dat", ios::out | ios::trunc); Datei[1].open("t2.dat", ios::out | ios::trunc); Datei[2].open("t3.dat", ios::in); Datei[3].open("t4.dat", ios::in); ind1l = 2; ind2l = 3; ind1s = 0; ind2s = 1; } else { Datei[0].open("t1.dat", ios::in); Datei[1].open("t2.dat", ios::in); Datei[2].open("t3.dat", ios::out | ios::trunc); Datei[3].open("t4.dat", ios::out | ios::trunc); ind1l = 0; ind2l = 1; ind1s = 2; ind2s = 3; } inds = ind1s; } // public: // Konstruktor Bal2WegSort::Bal2WegSort() { Datei = new fstream[4]; // Container fuer Dateien A = new int[I]; // Container zum Herstellen der Laeufe init(); } // Destruktor Bal2WegSort::~Bal2WegSort() { delete [] Datei; delete [] A; } // eigentliches Sortieren void Bal2WegSort::Sort() { int flag = 1; // Flag bestimmt, welche Dateien lesbar, schreibbar OeffneDat(flag); Datei[ind2l] >> l2; //l2 ist aktuelle gelesene Zahl der 2. Datei Datei[ind1l] >> l1; //l1 ist aktuelle gelesene Zahl der 1. Datei if (l1 <= l2) aus = l1; //aus ist letzte Zahl der geschriebenen Datei else aus = l2; while(!Datei[ind2l].eof()) // Schleife, bis sortiert { while(!Datei[ind1l].eof() && !Datei[ind2l].eof()) // Schleife eines Durchgangs { if(l1 <= l2) 153 Algorithmen und Datenstrukturen if(l1 >= aus) { Datei[inds] << l1 << " "; aus = l1; Datei[ind1l] >> l1; } else if(l2 >= aus) { Datei[inds] << l2 << " "; aus = l2; Datei[ind2l] >> l2; } else { // inds wechseln inds = (inds == ind1s) ? ind2s : ind1s; Datei[inds] << l1 << " "; aus = l1; Datei[ind1l] >> l1; } else if(l2 >= aus) { Datei[inds] << l2 << " "; aus = l2; Datei[ind2l] >> l2; } else if(l1 >= aus) { Datei[inds] << l1 << " "; aus = l1; Datei[ind1l] >> l1; } else { // inds wechseln inds = (inds == ind1s) ? ind2s : ind1s; Datei[inds] << l2 << " "; aus = l2; Datei[ind2l] >> l2; } } // Kopiere Rest, waehle richtige Datei aus ind1l = (Datei[ind1l].eof()) ? ind2l : ind1l; l1 = (ind1l==ind2l) ? l2 : l1; while(!Datei[ind1l].eof()) { if(l1 >= aus) { Datei[inds] << l1 << " "; aus = l1; } else { // inds wechseln inds = (inds == ind1s) ? ind2s : ind1s; Datei[inds] << l1 << " "; aus = l1; } Datei[ind1l] >> l1; } // Alle Dateien schließen Datei[0].close(); Datei[1].close(); Datei[2].close(); Datei[3].close(); // Wechsle das Lesen und das Schreiben 154 Algorithmen und Datenstrukturen OeffneDat(flag=(++flag) % 2); Datei[ind1l] >> l1; Datei[ind2l] >> l2; if (l1 <= l2) aus=l1; else aus =l2; } cout << "\nDie sortierte Datei ist auf Band " << ind1l << endl; } 2. Mehrwege-Mischsortierverfahren Verfahrensbeschreibung: Ausgangspunkt sind 2k sequentielle Files (Bänder): d 1, d2,...,d2k. Es werden wiederholt I Datensätze von d1 gelesen und abwechselnd aud dk+1,dk+2,...,d2k geschrieben bis d1 erschöpft ist. Dann stehen N/(k-I) Läufe der Länge I auf di (k + 1 <= i <= 2k) Die k Files (Bänder) dk+1,...,d2k sind jetzt Eingabebänder für ein k-Wege-Mischen, die k Files d1,...,dk sind die Ausgabebänder. Die ersten Läufe der Eingabebänder werden zu einem Lauf der Länge k - l verschmolzen und auf das Ausgabeband d1 geschrieben. Danach werden die nächsten k Läufe der Eingabebänder verschmolzen und nach d 2 geschrieben. So werden der Reihe nach Läufe der Länge k – l auf die Ausgabedateien geschrieben, bis die Eingabedateien erschöpft sind. Nach dieser Verschmelzungs- und Aufteilungsphase tauschen die Eingabe- und Ausgabedateien ihre Rollen. Das k-Wege-Verschmelzen und k-WegeVerteilen wird solange fortgesetzt, bis die gesamte Folge der Datensätze als ein Lauf auf einer der Dateien steht. Verfahrensaufwand120: Zu Beginn werden mindestens ein Lauf, höchstens (N/I) Läufe und im Mittel (N/(2*I)) Läufe hergestellt. Nach jeder Verschmelzungs- und Verteilungsphase hat sich die Anzahl der Läufe um das 1/k-Fache vermindert. Implementierung121in C++: class balMWegSort { private: int K; int I; fstream* datei; int* A; int merkmal; // // // // // // int endeKZ; // int bandNr; // // Private Methoden void oeffnen(); void schliessen(); void verteile(); // void mische(); public: balMWegSort (int K, ~balMWegSort (); Anzahl der Dateien (Baender) Max. Anzahl der Elemente im Speicher Liste ("Array") der Streams Internspeicher ("Array") 1 => [0, K] Eingabe- [K+1, 2*K] Ausgabebaender 0 => [0, K] Ausgabe- [K+1, 2*K] Eingabebaender TRUE (=1) sobald nur noch auf ein Ausgabeband Nummer des aktuellen Ausgabebandes Anfangsverteilung erzeugen int I); // Konstruktor // Destruktor int ergebnisBand(); void mischSort(); }; // Schnittstellenfunktionen // private Methoden 120 121 vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 147 vgl. PR33118.CPP 155 Algorithmen und Datenstrukturen void balMWegSort::oeffnen() { char Name1[13]; char Name2[13]; for (int index = 0; index < K; index++) { sprintf (Name1, "%d", index); sprintf (Name2, "%d", index + K); strcat (Name1, ".dat"); strcat (Name2, ".dat"); remove ((merkmal ? Name2 : Name1)); // loescht die durch Dateinamen spezififizierte Datei datei[index].open (Name1, merkmal ? ios::in : ios::out); datei[index + K].open (Name2, merkmal ? ios::out : ios::in); } merkmal = !merkmal; } void balMWegSort::schliessen () { for (int index = 0; index < K; index++) { datei[index].close (); datei[index + K].close (); } } void balMWegSort::verteile() { int elem, letztesElem, normal, index = 0; bandNr = K + 1; datei[0] >> elem; while (!datei[0].eof ()) { for (index = 0; index < I && !datei[0].eof (); index++) { A[index] = elem; datei[0] >> elem; } BubbleSort(A,index); if (normal && A[0] < letztesElem) bandNr++; if (bandNr > 2 * K) bandNr = K + 1; letztesElem = A[index - 1]; normal = 1; for (int index2 = 0; index2 < index; index2++) datei[bandNr - 1] << A[index2] << endl; } } void balMWegSort::mische() { int index, gefunden, smInd, letztesElem, normal = 0; endeKZ = 1; bandNr = (merkmal ? 0 : K); for (index = 0; index < K; index++) datei[(merkmal ? index + K : index)] >> A[index]; do { gefunden = 0; for (index = 0; index < K; index++) if (!datei[(merkmal ? index + K : index)].eof () && 156 Algorithmen und Datenstrukturen (!gefunden || A [index] < A[smInd])) { smInd = index; gefunden = 1; } if (gefunden) { if (normal && A[smInd] < letztesElem) { bandNr++; endeKZ = 0; } if (bandNr >= (merkmal ? K : 2 * K)) bandNr = (merkmal ? 0 : K); letztesElem = A[smInd]; normal = 1; datei[bandNr] << A[smInd] << endl; if (!datei[(merkmal ? smInd + K : smInd)].eof()) datei[(merkmal ? smInd + K : smInd)] >> A[smInd]; } } while (gefunden); } // Konstruktoren - Destruktoren balMWegSort::balMWegSort (int K, int I): K(K), I(I), merkmal(1), endeKZ(0) { datei = new fstream[2 * K]; A = new int[I]; } balMWegSort::~balMWegSort () { delete []datei; delete []A; } // Oeffentliche Methoden void balMWegSort::mischSort () { oeffnen(); verteile(); schliessen(); while (!endeKZ) { oeffnen(); mische(); schliessen(); } } int balMWegSort::ergebnisBand () { return (bandNr); } 3. Mehrphasen-Mischsortieren Beim ausgeglichenen Mehrwege-Mischsortieren werden alle Eingabedateien benutzt, aber nur auf eine der Ausgabedateien geschrieben. Die anderen Ausgabedateien sind temporär nutzlos. Da normalerweise die Zahl der Dateien viel kleiner ist als die Anzahl der Elemente (I) in einem Lauf der Anfangsverteilung wäre es wünschenswert, diese Dateien zu Eingabedateidateien heranzuziehen. Man will aber nicht alle Läufe auf ein einzige Ausgabedatei speichern, weil man sonst vor der nachfolgenden Verschmelzungsphase noch eine zusätzliche Verteilungsphase einschieben müßte. 157 Algorithmen und Datenstrukturen Beim Mehrphasen-Mischsortieren122 (polyphase mergesort)123 arbeitet man mit (k+1) Dateien, von denen zu jedem Zeitpunkt k Eingabedateien sind und eine Ausgabedatei ist. Man schreibt solange alle entstehenden Läufe auf die Ausgabedatei bis eine der Dateien erschöpft ist124. Dann wird die leere Eingabedatei zur Ausgabedatei, die bisher vorliegende Ausgabedatei dient als Eingabedatei. Damit besitzt man wieder k Eingabedateien und eine (andere) Ausgabedatei. Der Sortiervorgang ist beendet, falls die Eingabedateien erschöpft sind. Das Verfahren funktioniert nur dann, wenn zu jedem Zeitpunkt (außer am Schluß) nur ein Eingabeband leer wird. Bsp.: In einer Anfangsverteilung sind 13 Läufe l1 l2 l3 l4 l5 l6 l7 l8 l9 l10 l11 l12 l13 auf die Beiden dateien d1 und d2 folgendermaßen verteilt (d3 ist leer): d1 l1 l2 l3 l4 l5 l6 l7 l8 d2 l9 l10 l11 l12 l13 d3 leer Danach werden jeweils die nächsten Läufe von d 1 und d2 verschmolzen und auf das Ausgabeband d3 geschrieben, bis d2 erschöpft ist. d1 l6 l7 l8 d2 leer d3 l1,9 l2,10 l3,11 l4,12 l5,13 Die erste Phase ist damit abgeschlossen. Die jeweils nächsten Läufe von d 1 und d3 werden verschmolzen, bis d1 leer ist d1 d2 l6,1,9 l7,2,10 l8,3,11 d3 l4,12 l5,13 In den folgenden Phasen resultieren folgende Verteilungen: d1 l6,1,9,4,12 l7,2,10,5,13 l7,2,10,5,13 leer d2 l8,3,11 leer l7,2,10,5,13,6,1,9,4,12,8,3,11 d3 leer l6,1,9,4,12,8,3,11 leer Am Ende muß genau ein Lauf auf einer Datei stehen, der aus 2 Läufen resultiert, die jeweils auf zwei Dateien stehen. 122 vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 148 vgl. Wirth, N.: Algorithmen und Datenstrukturen, Stuttgart 1979, S. 153 124 Das ist eine Phase. 123 158 Algorithmen und Datenstrukturen 3.1.1.2.2 Natürliches Mischen Zwei-Wege Mischsortieren Einführung. Beim direkten Mischen wird aus einer anfangs vorhandenen, teilweisen Ordnung kein Nutzen gezogen. Tatsächlich können 2 geordnete Teilsequenzen mit den Längen M und N direkt in eine Sequenz mit M + N Elementen eingemischt werden. Ein Mischsortieren, das immer die längsten möglichen Teilfolgen mischt, ist das natürliche Mischen. Vereinbarung: Eine Teilsequenz X[I] .. X[J] heißt (maximaler) Lauf, wenn folgende Bedingungen erfüllt sind: 1. X[K] <= X[K + 1] 2. X[I - 1] > X[I] 3. X[J] > X[J + 1] (für K = I .. J - 1) Die natürliche Mischsortierung mischt (maximale) Läufe statt fester Sequenzen mit vorbestimmter Länge. Jede Folge von natürlichen Zahlen zerfällt in eine solche Folge, so z.B.: 5 3 2 7 10 4 1 7 3 6 8 2 geordnete Sequenzen sind dann jeweils zu einer einzigen, geordneten Sequenz zu vereinigen. Verfahrensaufwand. Er wird danach gemessen, wie oft Läufe in die Betrachtung eingehen. Läufe haben die Eigenschaft, daß beim Mischen von 2 Sequenzen mit L Läufen eine einzige Sequenz mit genau L Läufen entsteht. So ergibt sich für die folgenden beiden Sequenzen 8 7 6 5 4 3 2 1 mit jeweils 4 Läufen die Sequenz 7 8 5 6 3 4 1 2 , die genau 4 Läufe besitzt. Die Zahl der Läufe wird in jedem Durchlauf halbiert. Im schlimmsten Fall ergibt sich dann die Anzahl der Bewegungen zu: L ld(L). Der Algorithmus. Ablauf: Die zu sortierenden Daten liegen im File F vor und sollen am Schluß in sortierter Form unter demselben Namen zurückgegeben werden. Die beiden Hilfsfiles sind G1 und G2. Jeder Durchlauf umfaßt eine Verteilungsphase (distribution), die Läufe gleichmäßig von F auf G1 und G2 verteilt, und eine Mischphase, die Läufe von G1 und G2 auf F mischt. 159 Algorithmen und Datenstrukturen G1 F G1 G1 F F F F ....... G2 G2 G2 Mischphase Verteilungsphase Abb.: Das Sortieren ist beendet sobald F nur noch ein Lauf ist. Für die Definition des momentanen Zustands eines Files stellt man sich am besten einen Positionszeiger vor. Er wird beim Schreiben um je eine Einheit vorwärts geschoben. Beschreibung: Sie erfolgt nach der Methode „stepwise refinement“. Grobstruktur des Prozesses: Wiederhole Setze die Zeiger aller 3 Files auf den Anfang; Verteile; Setze die Zeiger aller 3 Files auf den Anfang; Mische; bis L = 1; (* L ist die Anzahl der Läufe auf dem File F *) Verfeinerungsschritt: Verteile Wiederhole Kopiere ein Lauf F auf G1 Falls noch nicht eof(F), kopiere einen Lauf von F auf G2; bis Ende von F erreicht; Mische 160 Algorithmen und Datenstrukturen Setze L = 0; Solange weder eof(G1) noch eof(G2) fuehre aus: Mische je einen Lauf von G1 und G2 auf F; Erhoehe L um 1; Solange eof(G1) noch nicht erreicht, fuehre aus: Kopiere einen Lauf von G1 auf F; Erhöhe L um 1; Solange eof(G2) noch nicht erreicht, fuehre aus: Kopiere einen Lauf von G2 auf F; Erhöhe L um 1; Implementierung125 125 pr33_122 161 Algorithmen und Datenstrukturen 3.1.2 Suche in Texten 3.1.2.1 String Pattern-Matching 3.1.2.1.1 Ein einfacher Algorithmus zum Suchen in Zeichenfolgen 1. Problem Finden eines Teilstrings (Muster / Pattern) in einem anderen String. Suche erstes Vorkommen der Muster-Zeichenfolge pattern[0..m-1] im TextZeichenfolgen text[0...n-1] 2. Straightforward Lösung - Algorithmische Idee Starte beim ersten Zeichen im Text126 und prüfe, ob die Zeichenfolge des Patterns mit der anfänglichen Zeichenfolge im Text übereinstimmt. Falls ja: Erfolg anderenfalls: Starte neu beim zweiten Zeichen im Text etc. Nicht gerade effizient: Anzahl von Zeichen-Vergleichen ist O(n*m) (m: Länge des Pattern, n: Länge des Texts) - Struktogramm Ansatz: Ab jeder Position i prüfen, ob der Test text[i..i + (m – 1)] mit dem Muster pattern übereinstimmt. letztes-bearbeitungswürdiges-Zeichen = textLaenge - musterLaenge Schleife über alle bearbeitungswürdigen Zeichenpositionen i im Text String substr = Substring im Text von Postion i bis i + Musterlänge substr stimmt mit Muster überein return: i return: -1 Implementierung mit charAt() bzw. substr() 126 Texte sind nicht weiter strukturierte Folgen beliebiger Zeichenketten aus einem Alphabet 162 Algorithmen und Datenstrukturen public static int search(String text, String pattern) { final int last = text.length() - pattern.length(); for (int i = 0; i <= last; i++) { int j; for (j = 0; j < pattern.length(); j++) { if (pattern.charAt(j) != text.charAt(i++)) break; } if (j == pattern.length()) return i; } return -1; } public static int search(String text, String pattern) { final int last = text.length() - pattern.length(); for (int i = 0; i <= last; i++) { String substr = text.substring(i,i+pattern.length()); if (substr.equals(pattern)) return i; } return -1; } 3. String-Matching mit endlichen Automaten Idee: Repräsentation eines Pattern als endlicher Automat. (Text als Input in den Automaten, Terminierung in Endzustand, wenn Pattern entdeckt.) Leider ist das Erstellen eines endlichen Automaten (die entsprechende Übergangstabelle) sehr aufwendig. Der Knuth-Morris-Pratt-Algorithmus arbeitet mit einer einfacheren Variante zum endlichen Automaten- einer sog. Verschiebetabelle, die in linearer Zeit in der Pattern-Länge konstruiert werden kann. 3.1.2.1.2 Der Algorithmus von Knuth-Morris-Pratt Idee: Bisherige Teilsuchergebnisse nutzen zur Verschiebung der Muster um mehr als eine Stelle. Tritt ein Missmatch an Position i des Patterns auf, so kann man a) das Pattern u.U. um mehr als eine Position weiterschieben b) braucht man die Position vor dem Missmatch nicht nochmals zu vergleichen. text b a c d b a b b a a a a b f a a a a pattern b a b b a f e a b a b b a f e a 163 Algorithmen und Datenstrukturen Definitionen: - pattern[0..k] (k <= m-1) heißt Präfix vom pattern[0..m-1] - pattern[l...m-1] (l >= 0) heißt Suffix von pattern [0...m-1] - Man spricht von echtem Praefix / Suffix, wenn k < m-1 bzw. l > 0 - Eigentlicher Rand von pattern ist der längste Teilstring von pattern, der sowohl echtes Präfix als auch echtes Suffix von pattern ist. Bsp.: UNGLEICHUNG hat eigentlichen Rand UNG Ränder können sich überlappen. Bsp.: Der eigentliche Rand von aabaabaa ist aabaa. aa ist ein Rand aber nicht der eigentliche Rand. Vorarbeit zur KMP-Suche: Zunächst für alle Teilmuster pattern[0..j-1]: Länge des eigentlichen Rands in der Shift-Tabelle next[j] speichern. Bsp.: text ...UNGLEICHUNGSTEIL... i-j pattern i UNGLEICHUNGEN next[j] j UNGLEICHUNGEN next[j] - 1. Mismatch (von links) bei text[i] und pattern[j] => text[i-j...i-1] == pattern[0...j-1] und text[i] != pattern[j} - Zurücksetzen von j auf j' = next[j] - Trick: Vergleichen beginnt zwischen text[i] und pattern[j] Definition der Shift-Tabelle j0 1 next[ j ] ( s0 ...s j 1 ) j 1 (s ) bezeichnet den eigentlichen Rand vom pattern. Das Teilwort s stimmt ab der Position i mit dem zugehörigen Teilwort von text[i] bzw. s0 mit text[i+j-1] bzw. s j 1 überein, d.h. text[i+j] <> s j . Der zum Teilwort s 0 ...s j 1 gehörende eigentliche Rand hat laut Definition die Länge next[j]. Verschiebt man s um j – next[j] nach rechts, so kommt der rechte Rand des Teilworts s 0 ...s j 1 von s auf dem linken Rand zu liegen, d.h. man schiebt Gleiches auf Gleiches. Da es keinen längeren Rand von s 0 ...s j 1 als diesen gibt, der ungleich s 0 ...s j 1 ist dieser Shift sicher. 164 Algorithmen und Datenstrukturen text [0] [i] pattern [i+j] [0] [j] a b a a b a a b a a b a j-next[j] next[j] Eigentlicher Rand von s 0 ...s j 1 a b a Abb.: Shift um j-next[j] Implementierung der Shift-Tabelle In der Tabelle next[] wird für jedes Präfix s 0 ...s j 1 die Länge 0...m des Suchstrings pattern der Länge m gespeichert, wie groß dessen eigentlicher Rand ist. Initialisierung: next[0] = -1, next[1] = 0 Annahme: next[0]...next[j-1] seien bereits berechnet. Ziel: Berechnung von next[j]=Länge des eigentlichen Rands eines Suffixes der Länge j Ist s next[ j 1] s j 1 , dann ist next[j]=next[j-1]+1. Anderenfalls müsste man ein kürzeres Präfix von s 0 ...s j 2 finden, das auch ein Suffix von s 0 ...s j 2 ist. Der nächsthöhere Rand ist iffensichtlich der eigentliche Rand des zuletzt betrachteten Rands des Worts: Nach der Konstruktion der Tabelle next ist das nächstkürzere Präfix mit dieser Eigenschaft das der Länge next[next[j-1]]. Es folgt der Test, ob sich dieser Rand von s 0 ...s j 2 zu einem eigentlichen Rand von s 0 ...s j 1 erweitern lässt. Das wird solange wiederholt, bis ein Rand gefunden ist, der sich zu einem Rand von s 0 ...s j 1 erweitern lässt. Falls sich kein Rand von s 0 ...s j 2 zu einem Rand von s 0 ...s j 1 erweitern lässt, dann ist der eigentliche Rand von s 0 ...s j 1 das leere Wort und man setzt next[j] = 0. Der Inhalt der Tabelle next[] kann durch Vergleichen der Teilmuster aus pattern mit sich selbst, um 1 verschoben, ermittelt werden. public static int[] initNext(String pattern) { final int m = pattern.length(); int next[] = new int[m]; int i = 0; int j = -1; next[0] = -1; while (i < (m - 1)) { while(j >= 0 && (pattern.charAt(i) != pattern.charAt(j))) j = next[j]; ++i; ++j; next[i] = j; } return next; } 165 Algorithmen und Datenstrukturen Bsp. und Test: next[j] j=1 A B C A A B 2 A B A C B A C A A B A A B C A A B A C B A A B C A A A B B C A A B A A A B A B C B A C A A B A 3 A 4 A 5 B A B C = 0 0 0 1 B 1 Abb.: Die Tabelle next[] für das Pattern ABCAAB Implementierung KMP-Suche Mit der aus dem letzten Beispiel bestimmten Shift-Tabelle j next[j] 0 -1 1 0 2 0 3 0 4 1 5 1 ergeben sich für den folgenden Text folgende Vergleiche: text [0] A Vergleichsrichtung B C A B A pattern [0] [1] [2] [3] [4] [5] B B A B C A A 1 2 3 4 5 j = 4, next[4] = 1 A B 6 C A B A A B B C A B A A A B B 7 j = 2, next[j] = 0 A B C 8 9 10 j = 2, next[j] = 0 A A B C A 11 j = 0, next[j] = -1 => i++, j = 0 A B C A A B 12 13 14 15 16 17 Das führt zu folgenden Quellcodeanweisungen: // Knuth-Morris-Pratt-Methode 166 B Algorithmen und Datenstrukturen public static int search(String text, String pattern) { int next[] = initNext(pattern); final int n = text.length(); final int m = pattern.length(); int j = 0; // Position in pattern int i = 0; // Position in text while (i < n) { while (j >= 0 && (pattern.charAt(j) !=text.charAt(i))) { j = next[j]; } ++i; // im Text eine Position weitergehen ++j; if (j == m) { return (i - m); // Muster ganz gefunden } } return -1; } Komplexität von KMP-Textsuche - Such-Phase: In jedem Schritt entweder im Text um ein Zeichen weitergehen (++i) oder Muster um mindestens 1 Zeichen weiter rechts anlegen j = next[j] Aufwand O(n). - Initialisieren der next-Tabelle (läuft genau wie Suche ab): Aufwand O(m) - KMP-Algorithmen insgesamt: O(m+n) - Zusätzlicher Platzbedarf O(m) 3.1.2.1.3 Boyer / Moore - Suche Neuer Ansatzpunkt zum Suchen in Texten: Zu suchendes Wort in einem Textstück nicht mehr von links nach rechts sondern von rechts nach links vergleichen. Ein Vorteil dieser Vorgehensweise ist, dass man in Alphabeten mit vielen unterschiedlichen Symbolen bei einem Mismatch an der Position i + j im Textstück , i auf i + j + 1 setzen kann, falls das im Textstück gesuchte Zeichen an der Position i + j nicht im gesuchten Wort vorkommt. 1. Simple BM127 Grundüberlegung: - Muster an Position i am text "anlegen" und von rechts nach links mit Text vergleichen. Falls: - 1.Mismatch zwischen pattern[j] und text[i+j] (tij) - und tij kommt irgendwo im Muster vor Muster um j+i Zeichen verschieben 127 In der Literatur gibt es verschiedene Varianten unter dem Namen "Boyer/Moore"-Algorithmus. Die hier beschriebene Variante entspricht dem sog. "bad character"-Teil des Algorithmus. 167 Algorithmen und Datenstrukturen => im günstigsten Fall (Alphabet groß genug, nur wenig verschiedene Zeichen im Muster) kann oft um gesamte Musterlänge verschoben werden bad-character Heuristik Fall a): Zeichen im text, an dem ein Mismatch auftritt, kommt im Muster nicht vor => Muster so weit verschieben, dass es hinter Mismatch-Position anfängt. A C B B D A A B C A B A A A B C A B B C A B A A .. Vergleichsrichtung Fall b.1) Zeichen im text, an dem Mismatch auftritt, kommt im Muster vor, und zwar nur vor aktueller Stelle im Muster => Muster so weit verschieben, dass diese Musterposition an der Mismatch-Position im text anliegt. A C B B D A A B D A B A A B D A A B B A C A B A .. Vergleichsrichtung Fall b.2) Zeichen im text, an dem Mismatch auftritt, kommt im Muster vor, und zwar hinter der aktuellen Stelle im Muster => Muster um eine Position weiterschieben A C B C B A B A D C B A B A D C B A B C A B A .. A Vergleichsrichtung Bsp.: A A A B B C B A A C B A C A B A B A C C B A C C B C B B B A B C A A C D A C A B A B C B A A B A B C B A C B A B C B A "last"-Tabelle enthält zu jedem Zeichen des Zeichensatzes die Position des letzten Vorkommen im Muster (oder "-1", falls es nicht vorkommt). Implementierung z.B. als Array indiziert mit (Unicode)-Zeichensatz: 'A' auf Index 65, 'B' auf 66 usw., 'a' auf 97, 'b' auf 98 usw. 168 Algorithmen und Datenstrukturen Bsp.: "last"-Tabelle des Muster "bananas" pattern Index im Muster Unicode-Index "last"-Wert ... 0-96 -1 a 97 5 b 0 98 0 b 98 0 a 1 97 5 n 2 110 4 a 3 97 5 ... 99-109 -1 n 4 110 4 n 110 4 a 5 97 5 s 6 115 6 ... 111-114 -1 … 116-127 -1 s 115 6 Implementierung "last"-Tabelle private static int[] initLast(String pattern) { // Platz fuer ASCII-Zeichen (Unicode 0 - 255) int [] last = new int[256]; int i; for (i = 0; i < 255; i++) last[i] = -1; // nach vorbelegen mit -1 fuellen for (i = 0; i < pattern.length(); i++) last[pattern.charAt(i)] = i; return last; } Implementierung Simple BM Die Anwendung der vorliegenden "last"-Tabelle auf folgenden text o r a n g e s b a n a n a s b , a a n n a n a s b a n a n b a a n n a a s a n a n d b a n a n a s s a s b a n a n a s b a n a n a s b a n a n Verschiebedistanz = max(1,,j – last[text[i+j]]) Vergleichsrichtung führen zu dem Simple-BM-Algorithmus public static int search(String text, String pattern) { int last[] = initLast(pattern); final int lastidx = text.length() - pattern.length(); int i = 0; while (i <= lastidx) { // pattern vergleich ab [i] // suche nach mismatch von rechts nach links int j = pattern.length() - 1; while (j >= 0 && pattern.charAt(j) == text.charAt(i+j)) --j; // match, dann naechstes Zeichen if (j < 0) // Muster ganz gefunden return i; 169 a s Algorithmen und Datenstrukturen else // sonst: pattern verschieben i += Math.max(1,j - last[text.charAt(i+j)]); } return -1; // Muster nicht gefunden } Laufzeitverhalten (unter Annnahme n>>m). - - O(n*m) im schlimmsten Fall. Zutreffend für ungünstige Text/Pattern Kombination, z.B. Text: "a*", Muster: "baaaaaa" Im Normalfall (Text und Muster sind sich sehr unähnlich) geht es aber viel schneller. Wenn Alphabet des Textes groß im Vergleich zu m: nur etwa (O(n/m) Vergleiche. Nur sinnvoll, wenn das Alphabet groß ist (z.B. ASCII/Unicode). Für Bitstrings ist dieses Verfahren weniger gut geeignet. 2. BM Der von R.S. Boyer und J.S. Moore vorgeschlagene Algorithmus unterscheidet sich von den vorstehenden naiven Ansätzen in der Ausführung größerer Shifts, die in einer Shift-Tabelle gespeichert sind. Bestimmen der shift-Tabelle. Zu Beginn wird die Shift-Tabelle an allen Stellen mit der Länge des Suchstrings initialisiert. Im wesentlichen entsprechen mögliche Shifts der Bestimmung von Rändern von Teilwörtern des gesuchten Worts. Im Unterschied zum KMPAlgorithmus wird zusätzlich zu den Rändern von Präfixen des Suchworts auch nach Rändern von Suffixen des Suchworts gesucht. Mögliche Shifts beim Boyer-Moore-Algorithmus. text i i+j [0] j n-1 m-1 pattern j- j m-1 m- m- Abb.: Zulässige Shifts bei Boyer-Moore Prinzipiell gibt es 2 mögliche Arten eines "vernünftigen" Shifts. Im oberen Teil ist ein "kurzer" Shift angegeben, bei dem im grünen Bereich die Zeichen nach dem Shift weiter übereinstimmen. Das rote Zeichen im text, das den Mismatch ausgelöst hat, wird nach dem Shift auf ein anderes Zeichen im pattern treffen, damit überhaupt die Chance auf Übereinstimmung besteht. Im unteren Teil ist ein "langer" Shift 170 Algorithmen und Datenstrukturen angegeben, bei dem die Zeichenreihe von pattern so weit verschoben wird, so dass an der Position des Mismatch im text gar kein Vergleich mehr entsteht. Allerdings soll auch hier im schraffierten Bereich wieder Übereinstimmung mit den bereits verglichenen Zeichen im text herrschen. Die Kombination der beiden Regeln nennt man die Good-Suffix-Rule, da man darauf achtet, die Zeichenreihe so zu verschieben, dass im letzten übereinstimmenden Bereich wieder Übereinstimmung herrscht. Ermitteln der in der Shift-Tabelle zu speichernden Werte: Der erste Mismatch soll im zu durchsuchenden Text an der Stelle i+j auftreten. Da der Boyer-MooreAlgorithmus das Suchwort von hinten nach vorn vergleicht, ergibt sich folgende Voraussetzung: s j 1 ...s m1 t i j 1 ...t i m1 s j ti j Um einen nicht nutzlosen Shift um Positionen zu erhalten, muß gelten: s j 1 ...s m1 t i j 1 ...t i m1 s j 1 ...s m 1 s j s j Diese Bedingung ist nur für "kleine" Shift mit j sinnvoll128. Für größere Shift j muß gelten, dass das Suffix des übereinstimmenden Bereichs mit dem Präfix des Suchworts übereinstimmt, d.h. s 0 ...s m1 ...s m 1 t i ...t i m 1 s j 1 ...s m 1 Bsp.: Shift-Tabelle für den Suchstring "ababbababa" Teil 1: j [0] a [1] b [2] a [3] b [4] b [5] a [6] b [7] a [8] b [9] a b a a b shift[9] = 1 a = b a b a a b a a b a = a b = b a b = 128 Ein solcher Shift ist im obigen Bild als erster Shift zu finden. 171 Algorithmen und Datenstrukturen b b a b a b a a b a b a b b b b shift[5] = 2 a b a b a b b shift[7] = 4 a b a b a b a b shift[9] = 6129 b a b a b a b b a b a b a b b a b a b a = a b a = a b a Teil 2: j a b a b b a b 7 a b a a b a b b a b a b a bei Mismatch an Position j [0 : 6] a b shift[j] = 7 a b b a b a b a a b a b b a b a b a 9 bei Mismatch an Position j [7 : 8] a b shift[j] = 9 a b b a b a b a a b a b b a b a b a 10 bei Mismatch an Position j [9] shift[j] = 10 Zusammenfassung: shift [0] 7 [1] 7 [2] 7 [3] 7 [4] 7 [5] 2 Implementierung der Shift-Tabelle130 private { int m int[] int[] 129 130 static int[] initShift(String pattern) = pattern.length(); shift = new int[m+1]; suffix = new int[m+1]; Dieser Wert nicht gespeichert, da s[9] schon mit 1 belegt ist. pr31200 172 [6] 7 [7] 4 [8] 9 [9] 1 Algorithmen und Datenstrukturen int i, j, h = 0; suffix[m-1] = m; j = m-1; for(i = m-2; i >= 0; --i) { if (i > j && suffix[i+m-1-h] < i - j) suffix[i] = suffix[i+m-1-h]; else { if ( i < j ) j = i; h = i; while( j >= 0 && pattern.charAt(j) == pattern.charAt(j+m-1-h) ) --j; suffix[i] = h - j; } } for (i = 0;i < m; ++i) shift[i] = m; j=0; for(i = m-1; i >= -1; --i) if(i==-1 || suffix[i]==i+1) while( j < m-1-i) { if(shift[j] == m) shift[j] = m-1-i; ++j; } for(i = 0;i < m-1; ++i) shift[m-1-suffix[i]] = m-i-1; return shift; } // Ende initShift() Implementierung Boyer-Moore (Suchfunktion) public static int search(String text, String pattern) { int i = 0; int last[] = initLast(pattern); // last-Tabelle int shift[] = initShift(pattern); // shift-Tabelle while(i<=text.length()-pattern.length()) { // pattern-Vergleich ab [i] int j = pattern.length() - 1; // suche nach mismatch von rechts nach links while(j >= 0 && pattern.charAt(j) == text.charAt(i+j)) --j; if(j < 0) return i; else i += Math.max(shift[j], j-last[text.charAt(i+j)]); } return -1; } Komplexität: In der Vorbereitungsphase beträgt der Aufwand für die Erstellung der Shift-Tabelle O (m) , für die Erstellung der last-Tabelle O(m ) . Unter der Voraussetzung, dass das Muster nicht oder wenige Male im Text vorkommt, werden im schlechtesten Fall O (n) Vergleiche benötigt. Bei einem im Vergleich zur Länge des Musters großen Alphabet beträgt die Anzahl der Vergleiche sogar nur O(n / m) . In den eher seltenen Fällen, wo ein Suffix des Musters sehr häufig im Text vorkommt (z.B. beim Suchen des Musters AB m1 im Text B n ist der Aufwand O (nm) . 173 Algorithmen und Datenstrukturen 3.1.2.2 Pattern-Matching mit regulären Ausdrücken Bisher wurde eine konkrete Zeichenfolge gesucht. Jetzt soll die Suche nach einem allgemeinen Muster erfolgen. Dazu wird benötigt: - Notation zur Beschreibung allgemeiner Ausdrücke => reguläre Ausdrücke Mechanismus zur Erkennung solcher Muster => endliche Automaten Mit Automaten definiert man die Worte einer Programmiersprache. Reguläre Ausdrücke können aus regulären Sprachen abgeleitet werden. Eine Sprache L wird über dem Alphabet ( d.h. eine endlichen Menge von Zeichen) gebildet: L * . * ist die Menge aller Wörter * { , a, b, aa, ab, ba, aaa,...} - Ein Wort über dem Alphabet ist - eine endliche Menge von Zeichen aus Spezialfall: leeres Wort über . Falls bspw. {a, b} ist, dann ist Ein Sonderfall bilden reguläre Sprachen. Für sie gilt folgende rekursive Definition: ist eine reguläre Sprache über 2. . Die Menge ist eine reguläre Sprache über 1. Die Menge L, L1 , L2 * regulär sind, dann sind es auch folgende Sprachen Vereinigungsmenge L1 L2 w | w L1 w L2 Konkatenation L1 L2 w1 w2 | w1 L1 w2 L2 Wiederholung L* L L L L L L L ... 3. Falls 3.1.2.2.1 Reguläre Ausdrücke Reguläre Ausdrücke können aus regulären Sprachen abgeleitet werden: 1. die leere Zeichenkette ist ein regulärer Ausdruck mit ist ein regulärer Ausdruck mit L( ) 3. Reguläre Ausdrücke R, S mit L( R) LR , L( ) 2. L ( S ) LS können mit den folgenden Verknüpfungsoperationen komplexe Ausdrücke bilden: Klammerung T (R) mit L(T ) LR T RS mit L(T ) LR LS Oder T R | S mit L(T ) LR LS Hüllenbildung T R * mit L(T ) LR * Verkettung Für Pattern-Matching mit regulären Ausdrücken existieren Pattern-Matcher. Diese Matcher basieren typischerweise auf endlichen Automaten. Das grep-Kommando implementiert einen Matcher für reguläre Ausdrücke. Die Programmiersprache Perl ist im wesentlichen ein Matcher für reguläre Ausdrücke. Natürlich können Matcher für reguläre Ausdrücke auch String-Pattern-Matching, konstante Strings sind spezielle reguläre Ausdrücke. Neben den standardmäßig zur Beschreibung regulärer Ausdrücke verwendbaren Konstrukte werden einige Zusatzkonstrukte erlaubt, die es einfacher machen 174 Algorithmen und Datenstrukturen reguläre Ausdrücke aufzuschreiben. Die folgende Tabelle zeigt dafür einen Überblick: Zeichen x Zeichen x \\ Backslash \t Tab \n Newline \cx Control-Zeichen x ... Zeichenklassen [abc] einfache Klasse [ˆabc] Negation [a-zA-Z] inklusiver Bereich [a-z-[bc]] Subtraktion [a-z-[m-p]] Subtraktion mit inkl. Bereich [a-z-[ˆdef]] Vordefinierte Zeichenklassen Beliebiges Zeichen . eine Ziffer \d keine Ziffer \D Trenner (whitespace) \s kein Trenner \S Textzeichen \w kein Textzeichen \W Begrenzer Zeilenanfang ˆ Zeilenende $ Wortgrenze \b keine Wortgrenze \B ... Greedy Quantoren 0 bis n des vorangestellten Zeichens * Kleene-Stern 1 bis n des vorangestellten Zeichens + das vorangestellte Zeichen 0- oder ? 1-mal ... Weitere Quantoren ... Quotation markiert das folgende Zeichen als \ “wörtlich zu nehmen” Logische Operation XY Sequenz (X gefolgt von Y) X|Y Oder (a, b oder c) (ein beliebiges Zeichen ausser a, b, oder c) (alle Groß- und Kleinbuchstaben) (a bis Z ausser b und c) (a bis z ausser m bis p) (d, e, oder f) [0-9] [ˆ0-9] [ \t\n\x0B\f\r] [a-zA-Z_0-9] weitere Verwendung: Negation Abb.: Konstruktion regulärer Ausdrücke Bsp.: Der reguläre Ausdruck "[a-z]+" deckt eine Folge von einem oder mehreren Kleinbuchstaben ab. [a-z] bedeutet: irgendein Zeichen von a bis z, zusätzlich bedeutet + "ein" oder mehrere. Angenommen wird, dass dieses Muster auf den String "Now in the time" angewendet wird. Es gibt 3 Wege, wie dieses Muster angewendet werden kann: 175 Algorithmen und Datenstrukturen - auf den vollständigen String: Es führt zur Fehlanzeige, weil der String auch andere Buchstaben benutzt als Kleinbuchstaben. auf den Anfang der Zeichenkette: Es führt zur Fehlanzeige, weil der String nicht mit einem Kleinbuchstaben anfängt. auf Suche im String: Es ist erfolgreich und passt auf ow. Falls wiederholt angewendet, findet man is, the, time. Danach gibt es eine Fehlanzeige. Kann das in diesem Bsp. enthaltene Problem in Java gelöst werden? Vor Java 1.4 konnte Pattern-Matching nur umständlich mit Hilfe der StringTokenizer- und charAt()-Methoden realisiert werden. Mit Java 1.4 gibt es folgende Lösungsmöglichkeit: - Zuerst muß das pattern kompiliert werden import java.util.regex.*; Pattern p = Pattern.compile("[a-z]+"); - Dann muß ein Matcher für ein bestimmtes Stück Text erzeugt werden, in dem eine Nachricht an das Muster (pattern) gesendet wird: Matcher m = p.matcher("Now is the time"); Vollständiges Bsp.131: import java.util.regex.*; public class RegexTest { public static void main(String args[]) { String pattern = "[a-z]+"; String text = "Now is the time"; Pattern p = Pattern.compile(pattern); Matcher m = p.matcher(text); while (m.find()) { System.out.print(text.substring(m.start(),m.end())+"*"); } } } // Ausgabe: ow*is*the*time* Allgemein können reguläre Ausdrücke durch Strings über Variablen ersetzt werden. Hierzu werden in regulären Ausdrücken Gruppen gebildet, z.B.: - ([a-zA-Z]*)([0-9]*) führt zur Übereinstimmung mit einer Anzahl Buchstaben, gefolgt von einer Anzahl Ziffern - \[([a-z])\.([A-Z])\] kann durch (\1,\2) ersetzt werden. Gruppen werden durch Klammern eingeschlossen. Capturing groups werden nummeriert durch Zählen der öffnenden Klammern von links nach rechts, z.B.: ((A)(B(C))) 12 3 4 \0=\1=((A)(B(C))), \2=(A), \3=(B(C)), \4=(C) 131 vgl. pr31250 176 Algorithmen und Datenstrukturen 3.1.2.2.2 Überprüfung regulärer Ausdrücke mit endlichen Automaten Definition eines nichtdeterministischen, endlichen Automaten (NEA) Ein nichtdeterministischer endlicher Automat (NEA) ist definiert durch ein Quintupel mit 1. einer endlichen Zustandsmenge S 2. einer Menge (oder auch Alphabet) von Eingabesymbolen 3. einem Zustand s0, der als Start- oder Endzustand bezeichnet wird 4. eine Menge F von Zuständen, die als akzeptierte oder Endzustände bezeichnet werden 5. einer Übergangsfunktion : S . Ein endlicher Automat lässt sich als markierter, endlicher Graph darstellen, wobei - Knoten die Zustände markierte Kanten die Übergangsfunktion repräsentieren. Ein endlicher Automat akzeptiert eine Zeichenfolge genau dann, wenn es einen Pfad vom Startzustand zu einem Endzustand gibt und die Markierungen der Kanten des Pfads genau diese Zeichenfolge bilden132. Da die Übergangsfunktion eine Menge von Zuständen (Potenzmenge, Folgezustand nicht eindeutig) liefert, handelt es sich um einen nichtdeterministischen endlichen Automaten (NEA). Dies ist für die Erkennung regulärer Ausdrücke sinnvoll, weil durch die Oder- oder Hüllenoperationen mehrere (d.h. zwei) Übergänge aus einem Zustand möglich sind. Bsp.: A A 0 B 1 A 2 3 B Abb.: Beispiel für einen nichtdeterministischen endlichen Automaten für die Erkennung des Ausdrucks A(A+B)*BA, Zustandsmenge S={0,1,2,3}, Alphabet = {A,B} Satz von Kleene - Zu jedem regulären Ausdruck r gibt es einen NEA M, der die von r beschriebene Sprache L(r) akzeptiert - Zu jedem NEA M gibt es einen regulären Ausdruck, der die von M akzeptierte Sprache L(M) beschreibt In beiden Fällen ist L(r) = L(M). Es kann also folgendermaßen überprüft werden, ob eine Zeichenfolge w zu einem regulären Ausdruck passt: 1. Konstruktion eines NEA M zu r 132 Beim KMP-Algorithmus wird die Kodierung eines endlichen Automaten als "next"-Tabelle benutzt. 177 Algorithmen und Datenstrukturen 2. Prüfen, ob M die Zeichenfolge w akzeptiert, d.h. Simulation von M (Breitensuche im Zustandsdiagramm) Konstruktion eines NEA zu einem regulären Ausdruck - Eingabe des regulären Ausdrucks r über - Ausgabe: NEA M, der L(r) akzeptiert - Algorithmus: -- Zerlege r in Bestandteile -- Erzeuge für jeden Term in r einen regulären Ausdruck und konstruiere entsprechend der rekursiven Definition von regulären Ausdrücken aus diesen NEAs einen NEA, der L(r ) akzeptiert a) zu jedem atomaren Symbol aus b) zu jeder Verkettung c) zu jeder Oder-Verknüpfung d) zu jeder Hüllenbildung für Klammerung (s) nehme Ms a) Zu jedem atomaren Symbol oder (dem leeren String) wird ein eigener Automat erstellt, der aus einem Startzustand, einen Endzustand sowie einer Kante besteht, die beide Zustände verbindet und mit dem jeweiligen Symbol markiert ist. Start Start Abb.: NEA zu jedem Buchstaben (der im regulären Ausdruck vorkommt) bzw. Die einzelnen Automaten werden anschließend entsprechend der definierten Operationen verknüpft: Ms Start Mt Abb.: NEA für Oder-Verknüpfung s + t bzw. s | t Start Ms Mt Abb.: NEA für Verketttung st 178 Algorithmen und Datenstrukturen Start Ms Abb.: NEA für Hüllenbildung s* Start 0 A 1 B 2 A 3 4 5 Abb.: Aus A*BA konstruierter NEA Die Repräsentation der Zustände und Übergänge erfolgt durch eine Tabellendarstellung, wobei für jeden Zustand (state) die mit einem gelesenen Symbol (symbol) durchzuführenden Übergänge (next) zu den Folgezuständen angegeben sind. Da es für die speziell nach den vorstehenden Regeln konstruierten Regeln bis zu 2 Folgezustände geben kann, werden für die Übergänge entsprechend 2 Folgezustände next1 und next2 benötigt. state symbol next1 next2 0 1 3 1 A 2 2 2 3 B 4 4 3 1 4 A 5 5 5 0 0 Abb.: Repräsentation eines NEA, der aus A*BA konstruiert ist Simulation eines NEA Alle Zustände, die während des Verarbeitens eines bestimmten Symbols eingenommen werden können, sind zu sichern und später der Reihe nach abzuarbeiten. Die Implementierung nutzt die Datenstruktur Deque (Kombination von Warteschlange und Kellerspeicher). Die Warteschlange wird benötigt, weil erst alle Zustände des aktuellen Zeichen untersucht werden müssen, bevor mit dem nächsten Zeichen fortgefahren wird. Der neue Zustand wird am Ende der Warteschlange eingeordnet. Ein Kellerspeicher ist zur Verarbeitung von Nullzuständen mit Übergängen notwendig, da diese zur sofortigen Untersuchung als erste Elemente eingeordnet werden sollen. import java.util.LinkedList; public class NEA { public static final int SCAN = -1; // Klasse zur Repräsentation der Zustände static class State 179 Algorithmen und Datenstrukturen { public State(char s, int n1, int n2) { symbol = s; next1 = n1; next2 = n2; } char symbol; // zu akzeptierendes Symbol int next1, next2; // Nachfolgezustaende } // "Programm" des NEA State[] states = { new State(' ', 1, 3), new State('A', 2, 2), new State(' ', 3, 1), new State('B', 4, 4), new State('A', 5, 5), new State(' ', 0, 0) }; public NEA() {} public boolean match(String s) { /* Passt die uebergegebene Zeichenkette s zum Muster des Automaten */ LinkedList deque = new LinkedList(); // Initialisierung int j = 0, state = states[0].next1; deque.addLast(new Integer(SCAN)); // Ablauftabelle - Ueberschrift while (state != 0) { if (state == SCAN) { j++; deque.addLast(new Integer(SCAN)); } else if (states[state].symbol == ' ') { // "leeres" Zeichen -> Nullzustand int n1 = states[state].next1; int n2 = states[state].next2; deque.addFirst(new Integer(n1)); if (n1 != n2) deque.addFirst(new Integer(n2)); } else if (states[state].symbol == s.charAt(j)) // Zeichen akzeptiert deque.addLast(new Integer(states[state].next1)); if (deque.isEmpty() || j > s.length()) // kein Endzustand erreicht -> Fehler return false; // (!) neuen Zustand einnehmen state = ((Integer) deque.removeFirst()).intValue(); } // Endzustand: Eingabe akzeptieren System.out.println(); return true; } public static void main(String[] args) { NEA nea = new NEA(); System.out.println("accept = " + nea.match("AABA")); } } 180 Algorithmen und Datenstrukturen Abb.: Ablauf bei der Erkennung von AABA 3.1.2.2.3 Java 1.4 "regex" Pattern und Matcher sind im Paket java.util.regex. Weder Pattern noch Matcher besitzen Konstruktoren, Instanzen werden über Methoden der Klasse Pattern gebildet. Der Matcher umfasst Informationen, wie das pattern anzuwenden ist und den text, auf den es angewendet wird. Die Pattern-Klasse Die Pattern-Klasse repräsentiert einen regulären Ausdruck, der als String spezifiziert wurde. Mit der Klassenmethode Pattern.compile(string) wird das Pattern in eine effiziente interne Repräsentation umgewandelt. So erzeugt Pattern p = Pattern.compile("[,\\s]+"); ein Pattern für Trennung durch Komma oder Whitespace Weitere Methoden: - static Pattern compile(String regex, int flags): Übersetzt den regulären Ausdruck in ein Pattern Objekt mit Flags. Als Flags sind erlaubt: CASE_INSENSITIVE, MULTILINE, DOTALL, UNICODE_CASE und CANON_EQ. - int flags() liefert die Flags, nach denen geprüft wird. - String split(Charsequene input): Zerlegt die Zeichenfolge in Teilzeichenketten, wie es das aktuelle Pattern-Objekt befiehlt. - String split(Charsequene input, int limit): wie split(CharSequence), nur durch limit begrenzt viele Zeichenkettem. Mit split() aus Pattern (oder String) kann eine Trennfolge definiert werden, die eine Zeichenfolge in Teilzeichenketten zerlegt, ähnlich wie es der StringTokenizer macht. Der StringTokenizer ist jedoch beschränkt auf einzelne Zeichen als Trennsymbole, während die Methode split() einen regulären Ausdruck zur Beschreibung verwendet. 181 Algorithmen und Datenstrukturen - Matcher matcher(CharSequence input): erzeugt einen Matcher, der gegebenen Input gegen das Pattern vergleicht, z.B.: Matcher m = p.matcher("onetwothree four fivesix”); - static boolean matches(String regex, CharSequence input) liefert true, wenn der reguläre Ausdruck regex auf die Eingabe paßt. - String pattern() liefert den regulären Ausdruck, den das Pattern repräsentiert. Im Prinzip besteht das Erkennen eines Musters immer aus dem Aufbau eines Pattern-Objekts mit regulärem Ausdruck und Prüfung: Pattern p = Pattern.compile("a*b"); Matcher m = p.matcher("aaaaab"); boolean b = m.matches(); Die drei letzten Zeilen lassen sich zusammenfassen zu: boolean b = Pattern.matches("a*b","aaaaab"); Die Matcher-Klasse Die Eingabe an einen Matcher muss dem Interface CharSequence genügen. Im obigen Beispiel wurde ein String-Objekt übergeben. Beispielsweise implementieren String, StringBuffer und CharBuffer dieses Interface. Wichtige Methoden: – boolean matches(): Vergleicht den gesamten Input gegen das Pattern und liefert true, bei ¨ Übereinstimmung, false sonst. – boolean lookingAt() gibt true zurück, wenn das Muster auf den Anfang des Textstrings passt, anderenfalls false. – boolean find(): Scanned die eingegebene Zeichenfolge und sucht die nächste Teilfolge, die mit dem Pattern übereinstimmt. – boolean find(int start): Setzt den Matcher und versucht die nächste Teilzeichenfolge der Eingabesequenz zu finden, die zum Muster passt. Start ist der spezifizierte Index. – int start() gibt nach einem erfolgreichen Match den Index des ersten Zeichens zurück, das gematcht wurde – int start(group) gibt den Startindex der Teilzeichenfolge zurück, die durch die gegebene Gruppe während der vorhergehenden Operation eingefangen wurde – int end() gibt nach einem erfolgreichen Match den Index des letzten Zeichens zurück, das gematcht wurde. Falls kein Match versucht wurde bzw. kein Match erfolgreich war, werfen start() und end() eine IllegalException aus. Dies ist eine RunTimeException, die nicht aufgefangen werden kann. – int end(int group) gibt das Offset nach dem letzten Zeichen der Teilzeichenfolge zurück, die von der gegebenen Gruppe während der vorhergehenden Operation eingefangen wurde. – Matcher appendReplacement(StringBuffer sb,String replacement): implementiert einen nicht begrenzten "anhängen-und-ersetzen"-Schritt. – String replaceAll(replacement): ersetzt jede Teilzeichenfolge der Eingabe, die das Pattern durch das gegebene replacement ersetzt – replaceFirst(replacement): ersetzt die erste Teilzeichenfolge der Eingabe, die mit dem Muster übereinstimmt, durch pattern. Bsp.133: import java.util.regex.*; public class Replacement { public static void main(String[] args) throws Exception { 133 pr31250 182 Algorithmen und Datenstrukturen // Create a pattern to match cat Pattern p = Pattern.compile("cat"); // Create a matcher with an input string Matcher m = p.matcher("one cat," + " two cats in the yard"); StringBuffer sb = new StringBuffer(); boolean result = m.find(); // Loop through and create a new String // with the replacements while(result) { m.appendReplacement(sb, "dog"); result = m.find(); } // Add the last segment of input to // the new String m.appendTail(sb); System.out.println(sb.toString()); } } – String group(): liefert die Eingabefolge (String), die von dem vorhergehenden Match bestimmt wurde. – String group(int group): liefert die Eingabefolge (String) zurück, die von der angebenen Gruppe während der vorhergehenden Operation eingefangen wurde. 183 Algorithmen und Datenstrukturen 3.2 Entwurfstechniken Algorithmen-Mustern) für Algorithmen (Einsatz von Wie findet man für einen gegebenen Problem einen Algorithmus bzw. einen guten Algorithmus? Für dieses Problem gibt es leider keinen allgemeingültigen Algorithmus. Manche Algorithmen sind aber von ihrer Grundkonzeption her ähnlich. Man betrachtet eine Auswahl solcher häufig wiederkehrender Techniken für den Algorithmenentwurf und versucht folgende Idee zu realisieren: Anpassung von generischen Algorithmenmustern für bestimmte Problemklassen an eine konkrete Aufgabe. 3.2.1 Greedy Algorithmen Ein greedy algorithm ist ein Algorithmus, der sich in jedem Schritt ein lokales Optimum aussucht, was dann im Endeffekt zum globalen Optimum führt (und damit zum Erfolg des Algorithmus). Ein einführendes Beispiel. Auf Geldbeträge unter 1 DM soll Wechselgeld herausgegeben werden. Zur Verfügung stehen ausreichend Münzen mit den Werten 50, 10, 5, 2, 1 Pfennig. Das Wechselgeld soll aus so wenig Münzen wie möglich bestehen. Also: 50 + 2 * 10 + 5 + 2 + 1. Der Greedy-Algorithmus bestimmt: Nimm jeweils die größte Münze unter Zielwert, und ziehe sie von diesem ab. Verfahre derart bis Zielwert gleich Null. Greedy-Algorithmen berechnen lokales Optimum, z.B.: Münzen 11, 5 und 1; Zielwert 15. Greedy: 11 + 1 + 1 + 1 + 1 Optimum: 5 + 5 + 5 Aber in vielen Fällen entsprechen lokale Optima den globalen bzw. reicht ein lokales Optimum aus. Gierige Algorithmen. Das (Optimierungs-) Problem wird in einzelne Schritte zerlegt. In jedem Schritt wird "gierig" die kurzfristig (lokal) optimale Lösung gewählt. Aus diesen Einzelschritten ergibt sich die Gesamtlösung134. Bsp. für gierige Algorithmen: Algorithmus von Dijkstra135, Minimaler spannender Baum (Algorithmus von Prim)136. Eigenschaften von Greedy-Algorithmen: 1. Gegebene Menge Werte von Eingabewerten 2. Menge von Lösungen, die aus Eingabewerten aufgebaut sind 3. Lösungen lassen sich schrittweise aus partiellen Lösungen, beginnend bei der leeren Lösung, durch Hinzunahme von Eingabewerten aufbauen 134 Nicht jedes Problem lässt sich mit einem gierigen Algorithmus lösen. Lösungen, bei denen zunächst ein (kleiner) Nachteil in Kauf genommen werden muß, um später einen (größeren) Vorteil zu erlangen, findet man nicht mit einem gierigen Algorithmus 135 In jedem Schritt wird gierig aus den noch nicht besuchten Knoten jener augewählt, der die geringste Entfernung zum Startknoten besitzt. 136 In jedem Schritt wird gierig die kürzeste Kante vom Baum zu einem Knoten gewählt, der sich noch nicht im Baum befindet. 184 Algorithmen und Datenstrukturen 4. Bewertungsfunktion für partielle und vollständige Lösungen 5. Gesucht wird die / eine optimale Lösung 3.2.1.1 Greedy-Algorithmen für minimale Spannbäume Gegeben: Ungerichteter, zusammenhängender kantenbewerteter Graph G (V , E ) Gesucht minimaler Spannbaum (minimum spanning tree) des ungerichteten Graphen mit - T E (der minimale Spannbaum ist Teilgraph von G - (V , T ) ist ungerichteter Baum (zusammenhängend, kreisfrei) - Summe der Kantenlängen von (V , T ) hat den kleinstmöglichen Wert. Demonstrationsgraph: 1 2 1 4 2 5 3 4 4 5 6 5 6 3 8 7 3 4 7 1. Algorithmus von Prim Zugrundeliegende (algorithmische) Idee: Minimaler Spannbaum wird beginnend mit willkürlich gewählter Wurzel zusammenhängend aufgebaut. Prim-Algorithmus in Struktogrammdarstellung 185 Algorithmen und Datenstrukturen Eingabe Knoten, Kanten, Gewichte Initialisiere Menge B mit einem beliebig gewählten Element von V (B: Wurzelknoten des aufzubauenden minimalen Spannbaums) T=0 (u,v): kürzeste noch verfügbare Kante mit u V\B und v B (greedy!) T = T {(u,v)} B = B {u} Lösche (u,v) B == V Ausgabe: T Abarbeitungsprotokoll des Prim-Algorithmus: Greedy-Schritt 0 1 2 3 4 5 6 V\B {2,3,4,5,6,7} {3,4,5,6,7} {4,5,6,7} {5,6,7} {6,7} {6} 0 B137 {1} {1,2} {1,2,3} {1,2,3,4} {1,2,3,4,5} {1,2,3,4,5,7} {1,2,3,4,5,6,7} (u,v) (2,1) (3,2) (4,1) (5,4) (7,4) (6,7) T 0 {(2,1)} {(2,1),(3,2)} {(2,1),(3,2),(4,1)} {(2,1),(3,2),(4,1),(5,4)} {(2,1),(3,2),(4,1)},(5,4),(7,4)} {(2,1),(3,2),(4,1),(5,4),(7,4),(6,7)} 2. Algorithmus von Kruskal Zugrundeliegende (algorithmische) Idee: - - 137 Alle ungerichteten Kanten des gegebenen Graphen werden nach aufsteigender Reihenfolge geordnet. Der minimale Spannbaum wird sequentiell aus Teilbäumen aufgebaut. Begonnen wird mit Startbäumen, die jeweils aus einem Knoten bestehen. Zwei Startbäume, die mit dem Endknoten einer kürzesten Kante (greedy!) übereinstimmen, werden mit den Kanten zu einem Teilbaum (minmaler Länge) über zwei Knoten verbunden. In den nachfolgenden Verbindungsschritten werden jeweils zwei der vorhandenen Teilbäume durch eine noch nicht eingebaute kürzeste Kante (greedy) zu einem Teilbaum (minimaler Länge) mit größerer Knotenanzahl verbunden, wenn der Endknoten dieser Kante in verschiedenen Teilbäumen liegen. Ist das nicht der Fall, scheidet diese Kante endgültig aus dem Verfahren aus (Kreisfreiheit!). Die Vorgehensweise wird erschöpfend angewandt. Das Verfahren hält an, wenn so alle Knoten zu einem Baum verbunden sind. Dieser ist dann ein minimaler Spannbaum des gegebenen Graphen. Menge der besuchten Knoten 186 Algorithmen und Datenstrukturen Kruskal-Algorithmus in Struktogrammdarstellung Eingabe: Knoten und Kanten einschl. Gewichte Ordne alle Kanten nach aufsteigender Länge Initialisiere n Mengen (Knotenmenge er Teilbäume) mit je einem Element von V. T=0 (u,v): kürzeste noch verfügbare Kante ucomp: Menge, die u enthält vcomp: Menge, die v enthält nein vcomp == ucomp ja mische(ucomp,vcomp) { vereinige die disjunkten Mengen ucomp und vcomp lösche danach ucomp und vcomp } T = T {(u,v)} Lösche (u,v) card(T) = n138 - 1 Ausgabe: T Abarbeitungsprotokoll des Kruskal-Algorithmus139: GreedySchritt 0 (Init.) 1 2 3 4 5 6 7 Mengen140 (u,v) ucomp vcomp T {1},{2},{3},{4},{5},{6},{7} {1,2},{3},{4},{5},{6},{7} {1,2,3},{4},{5},{6},{7} {1,2,3},{4},{5},{6},{7} {1,2,3},{4,5},{6,7} {1,2,3,4,5},{6,7} {1,2,3,4,5},{6,7} {1,2,3,4,5,6,7} (1.2) (2,3) (4,5) (6,7) (1,4) (2,5) (4,7) {2} {3} {5} {7} {4,5} {1,2,3,4,5} {6,7} 0 {(1,2)} {(1,2),(2,3)} {(1,2),(2,3),(4,5)} {(1,2),(2,3),(4,5),(6,7)} {(1,2),(2,3),(4,5),(6,7),(1,4)} {(1,2),(2,3),(4,5),(6,7),(1,4)} {(1,2),(2,3),(4,5),(6,7),(1,4),(4,7)} {1} {1,2} {4} {6} {1,2,3} {1,2,3,4,5}141 {1,2,3,4,5} Implementierung: vgl. Kruskal.java142 138 Anzahl der Knoten im Graphen vgl. 5.6.2 140 Knotenmenge der Teilbäume 141 nicht disjunkt 142 pr56200 139 187 Algorithmen und Datenstrukturen 3.2.1.2 Huffman Codes Definition. Der Huffman-Algorithmus erzeugt einen Binärcode für eine gegebene Zeichenmenge gemäß der Häufigkeit jedes einzelnen Zeichens in einem Text. Je öfter ein Zeichen auftritt, desto kürzer ist die ihm entsprechende Bitfolge. In diesem Sinne liefert der Algorithmus einen optimalen Code. Bsp.: Codeworte fester Länge bei der Binärcodierung von Zeichen. In einer Textdatei befinden sich bspw. die Zeichen „a“, „e“, „i“, „s“, „t“ und die Zeichen „space“ bzw. „newline“. Die folgende Tabelle zeigt, in welcher Häufigkeit diese Zeichen in der Textdatei vorkommen: Zeichen (Character) a e i s t space newline Häufigkeit 10 15 12 3 4 13 1 Würde man diese Zeichen mit einem Binärcode fester Länge verschlüsseln, dann könnte dies auf folgende Weise bspw. geschehen: Zeichen (Character) a e i s t space newline Summe Code 000 001 010 011 100 101 110 Häufigkeit 10 15 12 3 4 13 Anzahl Bits 30 45 36 9 12 39 3 174 Abb. Standard zur Binärcodierung von Zeichen Bei größeren Dateien führt diese Art der Binärcodierung zur erheblichem Platzbedarf. Besser wäre eine Codierung, die angepasst an die Häufigkeit von Vorkommen der Zeichen, für die einzelnen Zeichen Codeworte variabler Länge vorsieht. Ein besonders häufig vorkommendes Zeichen, erhält ein kurzes Codewort. Ein weniger häufig vorkommendes Zeichen erhält ein längeres Codewort zugeordnet. Der Algorithmus von Huffman. Zu Beginn liegen die einzelnen Zeichen mit ihren Häufigkeiten in der folgenden Form vor: 10 a 15 e 12 i 3 s 4 t 13 sp 1 nl Die beiden Knoten(Bäume) mit den niedigsten Gewichtswerten (Häufigkeiten) werden zusammengefasst: 188 Algorithmen und Datenstrukturen 10 a 15 12 e i 4 13 t 4 sp T1 s nl Abb. Huffman Algorithmus nach dem ersten Mischen Die beiden Bäume mit dem kleinsten Gewicht wurden zusammengemischt. Das Gewicht des neuen Baums entspricht der Summe der Gewichte der alten Bäume. 10 a 15 12 e i 13 8 sp T2 T1 s t nl Abb. Huffman Algorithmus nach dem zweiten Mischen 15 e 12 i 13 18 sp T3 T2 T1 s a t nl Abb. Huffman Algorithmus nach dem dritten Mischen Es gibt jetzt vier Bäume, aus denen die beiden Bäume mit dem kleinsten Gewicht gewählt werden. 189 Algorithmen und Datenstrukturen 15 25 e 18 T4 i T3 sp T2 T1 s a t nl Abb. Huffman Algorithmus nach dem vierten Mischen Im fünften Schritt werden die Bäume mit den Wurzeln „e“ und „T 3“ gemischt, da sie jetzt die kleinsten Gewichte haben. 25 33 T4 i T5 sp T3 T2 T1 s Abb. Huffman Algorithmus nach dem fünften Mischen 190 a t nl e Algorithmen und Datenstrukturen 58 T6 T5 T4 T3 e T2 sp a T1 s i t nl Abb. Huffman Algorithmus nach Erreichen des optimalen Baums Für die Codierung der Zeichen steht der folgende optimale Präfix-Code bereit: 0 0 1 1 0 e 0 i 1 sp 1 a 0 1 t 0 s 1 nl Abb. Optimaler Präfix-Code Die Tabelle für die Codierung der Zeichen im vorliegenden Beispiel hat folgende Gestalt: 191 Algorithmen und Datenstrukturen Zeichen A E I S T Space Newline Summe Code 001 01 10 00000 0001 11 00001 Häufigkeit 10 15 12 3 4 13 1 Anzahl Bits 30 30 24 15 16 26 5 146 Abb. Optimaler Präfix-Code für das vorliegende Beispiel Die Codierung benutzt nur 146 Bits. Der Huffman-Algorithmus ist ein Greedy-Algorithmus. Auf jeder Ebene wird ein Mischvorgang ausgeführt ohne Rücksicht auf globale Betrachtungen. Gemischt werden lokal die Bäume mit den kleinsten Gewichtswerten. Das Gewicht eines Baums ist gleich der Summe der Häufigkeiten von seinen Blättern bzw. Teilbäumen. Damit kann der Algorithmus folgendermaßen formuliert werden: - Wähle die beiden Bäume T1 und T2 mit dem kleinsten Gewicht aus und bilde daraus einen neuen Baum mit den Teilbäumen T1 und T2. - Zu Beginn gibt es nur Bäume, die aus einzelnen Knoten bestehen. Jeder Knoten besteht steht für ein einzelnes Zeichen. - Am Ende gibt es nur einen einzigen Baum und das ist der optimale Baum mit dem Huffman-Code. Aufwand. Werden die Bäume in einer Priority-Queue verwaltet, dann beträgt die Laufzeit O(C143logC). Wird die Priority-Queue einfach in einer verketteten Liste implementiert, dann beträgt die Laufzeit O(C2) Zu dieser Aufwandsgröße muß noch der Aufwand zur Ermittlung der Häufigkeit der Zeichen addiert werden. Java-Implementierung. Der Huffman-Algorithmus kann zur Verdichtung bzw. Kompression von zu speichernden Daten verwendet werden. Für diese Aufgaben existieren in Java die Klassen - Deflater: zur Kompression eines Datenstroms nach einem wählbaren Verfahren - Inflater: zur Dekompression eines Datenstroms der zuvor mit Deflater komprimiert wurde unter Verwendung desselben Ver-/Entschlüsselungsverfahrens. Die Deflater-Klasse hat folgende Methoden: deflate() end() finalize() finish() finished() needsInput() reset() getAdler() getTotalIn() getTotalOut() 143 liefert den komprimierten Datenstrom zurück gibt alle intern benötigten Ressourcen frei schließt die Deflater-Zustände ab und wird durch die JVM aufgerufen zeigt das Ende dernoch nicht komprimierten daten an bestimmt, ob noch nicht komprimierte Daten verfügbar sind prüft, ob der Eingabepuffer leer ist stellt den Deflater wieder auf den Anfangszustand gibt die Checksumme der nicht komprimierten Daten an gibt die Anzahl (noch) nicht komprimierter Daten in Bytes an gibt die Anzahl komprimierter Daten in Bytes an C ist die Anzahl der Zeichen. 192 Algorithmen und Datenstrukturen setInput() setLevel() setStrategy() setDictionary stellt die zu komprimierenden Daten bereit legt das Komprimierungsniveau fest setzt die Komprimierungsstrategie fest definiert ein Dictionary-feld zur Unterstütung der Kompression Darüber hinau besitzt die Deflater-Klasse "Field-Größen" zur Festlegung der Komprimierungen: BEST_COMPRESSION, BEST_SPEED, FILTERED, HUFFMAN_ONLY DEFAULT_COMPRESSION, NO_COMPRESSION, Bsp.144: Kompremierung einer Datei mit Anzeige des Komprimierungsergebnisses (Byteanzahl, Checksumme) 144 vgl.pr33120 193 Algorithmen und Datenstrukturen 3.2.2 Divide and Conquer Typisches Beispiel. Quicksort Prinzip. Rekursive Rückführung auf identisches Problem mit kleiner Eingabemenge. Divide-and-Conquer-Algorithmen arbeiten grundsätzlich so: Teile das gegebene Problem in mehrere getrennte Teilprobleme auf, löse diese einzeln und setze die Lösungen des ursprünglichen Problems aus den Teillösungen zusammen. Wende dieselbe Technik auf jedes der Teilprobleme an, dann auf deren Teilprobleme usw. bis die Teilprobleme klein genug sind, dass man eine Lösung explizit angeben kann. Trachte danach, dass jedes Teilproblem derselben Art ist wie das ursprüngliche Problem, so dass es mit demselben Algorithmus gelöst werden kann. Weitere Beispiele für Divide and Conquer. 1. Merge-Sort 2. Türme von Hanoi 3.2.3 Induktiver Algorithmenentwurf und Dynamisches Programmieren Induktiver Algorithmenentwurf - Für kleinere Problemgrößen erfolgt eine direkte Problemlösung Zur Lösung eines Problem der Größe n geht man davon aus: Die Lösung des Problems der Größe n – 1 ist bekannt. Alternative: Zur Lösung eines Problems der Größe n, geht man davon aus, dass Lösungen von Problemen der Größe 1, 2, 3, ... , n-1 bekannt sind. Bsp.: Berechnung von Fibonacci-Zahlen (Ineffizienter rekursiver Algorithmus145). unsigned long fib(short n) { if (n == 0) return 0; else if (n == 1) return 1; else return(fib(n-1) + fib(n-2)); } Diese so elegant aussehende Lösung ist furchtbar schlecht. Sie benötigt eine Laufzeit T(N) >= T(N-1) + T(N-2). Da T(N) die gleiche rekurive Beziehung besitzt wie Fibonacci-Zahlen, wächst T(N) genauso wie Fibonacci-Zahlen wachsen, d.h. exponentiell. Eine Protokollierung der rekursiven Aufrufe für fibrek(6) zeigt dies: 145 vgl. Programmieren in C++, Skriptum SS 06, 1.7.4 194 Algorithmen und Datenstrukturen fibrek(6) fibrek(5) fibrek(4) fibrek(4) fibrek(3) fibrek(3) fibrek(2) fibrek(3) fibrek(2) fibrek(1) fibrek(2) fibrek(2) fibrek(1) fibrek(1) fibrek(0) fibrek(2) fibrek(1) fibrek(1) fibrek(0) fibrek(1) fibrek(0) fibrek(1) fibrek(0) fibrek(1) fibrek(0) Dynamisches Programmieren Schrittweise werden alle Problemgrößen von der kleinsten bis zur gesuchten gelöst. Die Lösungen der kleineren Pobleme werden zwischengespeichert, falls man die Lösung eines kleineren Problems zur Lösung eines größeren benötigt, wird die bereits ermittelte Lösung einfach nachgeschlagen. Bsp.: Man beobachtet bei der Berechnung der Fibonacci-Zahlen: FN-1 wird nur einmal aufgerufen, aber FN-2 wird zweimal, FN-3 wird dreimal, FN-4 wird viermal, FN-5 wird fünfmal berechnet, usw. Zur Berechnung von FN wird aber lediglich die Berechnung von FN-1 und FN-2 benötigt. Man braucht sich dabei nur auf die aktuell errechneten Werte zu beziehen. Das führt zu dem folgenden Algorithmus146 mit dem Aufwand O(N). unsigned long fibIt(short n) { unsigned long x, // aktuelle Fibonacci-Zahl vx = 1, // Vorgaenger fib(n - 1) bzw. vvx = 0; // fib(n - 2) for (short i = 1; i < n; i++) { x = vx + vvx; vvx = vx; vx = x; } return(x); } Generell bietet sich dynamisches Programmieren an, wenn man zur Lösung eines Problems das Problem in Teilprobleme zerlegt, um die Gesamtlösung aus den Teillösungen zusammenzusetzen, und die Teilprobleme nicht wie bei divide-andconquer unabhängig sind. Bei der dynamischen Programmierung speichert man bereits berechnete Lösungen in einer Tabelle ab, um Mehrfachberechnungen zu vermeiden. Das erhöht den Speicheraufwand. 146 vgl. Programmieren in C++, Skriptum SS 06, 1.7.4 195 Algorithmen und Datenstrukturen 3.3 Rekursion 3.3.1 Linear rekursive Funktionen Eine Funktion heißt linear rekursiv, wenn die Ausführung der Funktion zu höchstens einem rekursiven Aufruf der Funktion führt. Linear rekursive Funktionen können endrekursiv sein. Endrekursive Funktionen: Eine rekursive Funktion heißt endrekursiv, wenn sie linear ist und jede Ausführung der Funktion entweder nicht zu einem rekursiven Aufruf führt oder das Ergebnis des rekursiven Aufrufs gleich dem Ergebnis der Funktion ist. Eine endrekursive Funktion heißt schlicht, wenn der rekursive Aufruf direkt (ohne nachfolgende Operation) den Funktionswert liefert. Eine rekursive Funktion ist endrekursiv, wenn alle rekursiven Aufrufe schlicht sind. Bsp.: Die Fakultätsfunktion ist endrekursiv. Schema zur Entrekusivierung endrekursiver Funktionen. Prinzipiell gilt: Alle Aufgaben, die mit einer Iteration lösbar sind, sind auch ohne Schleifen durch Rekursion lösbar. Rekursionen kann man meistens mit Hilfe von Iterationen simulieren. Oft sind rekursive Lösungen einfacher zu verstehen, verbrauchen aber für die Abarbeitung mehr Speicherplatz. endrekuriv iterativ einTyp pRekursiv(... x ...) { .... if (condition(x)) { /* S1; */ // Anweisungen return pRekursiv(f(x)); } else { /* S2; */ // Anweisungen return g(x); } } einTyp pIterativ(… x …) { ... while(condition(x)) { /* S1; */ x = f(x); } /* S2; */ // Anweisungen return g(x); } 3.3.2 Nichtlineare rekursive Funktionen Eine rekursive Funktion heißt nichtlinear rekursiv, wenn die Ausführung der Funktion zu mehr als einem rekursiven Aufruf führt. Bsp.: Die Fibonacci-Funktion147 ist nichtlinear rekursiv. 147 vgl. 1.2.4.1 bzw. 3.2.3 196 Algorithmen und Datenstrukturen 3.3.3 Primitive Rekursion Eine rekursive Funktion ist primitiv rekursiv, wenn der rekursive Aufruf nicht geschachtelt ist 3.3.4 Nicht primitive Rekursion Eine rekursive Funktion ist nicht primitiv rekursiv, wenn der rekursive Aufruf geschachtelt ist. Bsp.: Die Ackermann-Funktion Die Rekursion eignet sich für die Definition von Funktionen, die sehr schnell wachsen. So ist die Ackermann-Funktion148 ein Beispiel für eine Rekursion, die nicht (oder zumindest nicht direkt) durch Iteration ersetzt werden kann. Die Werte der Ackermann-Funktion (mit zwei Argumenten n und m) sind durch die folgenden Formeln definiert: a m0 m 1 a 0n a1n 1 a mn a ann1 m 1 Die Ackermann-Funktion wächst stärker als jede primitiv rekursive Funktion. /* * Acker.java */ public class Acker { static int a(int x, int { if (x == 0) return y + if (y == 0) return a(x return a(x - 1,a(x,y } y) 1; - 1,1); 1)); public static void main(String args[]) { int x = Integer.parseInt(args[0]); int y = Integer.parseInt(args[1]); System.out.println(a(x,y)); } } 148 Benannt nach dem Mathematiker F.W. Ackermann, 1896 - 1962 197 Algorithmen und Datenstrukturen 3.4 Backtracking-Algorithmen Bei manchen Problem kann nicht mit Sicherheit bestimmt werden, welcher der möglichen nächsten Schritte zum (optimalen) Ziel führt. Prinzipiell muß man alle möglichen Schritte der Reihe nach ausprobieren, um festzustellen, ob sie zu einer Lösung (zur optimalen Lösung) führen. Falls erkannt wird, dass ein Schritt nicht zielführend ist, wird er rückgängig gemacht. Backtracking ist eine systematische Art der Suche (Tiefensuche) in einem vorgebenen Suchraum. Wenn eine Teillösung in eine Sackgasse führt, dann wird jeweils der letzte Schritt rückgängig gemacht. Das Rückgängigmachen eines Schritts nennt man Back-tracking, daher der Name Backtracking. Allgemeiner Backtracking Algorithmus boolean findeLoesung(int index, Lsg loesung, ...) { // index ist die aktuelle Schrittzahl // Teilloesungen werden als Referenz uebergeben 1. Solange es noch neue Teil-Loesungsschritte gibt: a) Waehle einen neuen Teil-Lösungsschritt b) Falls schritt gueltig ist I) Erweitere loesung um schritt II) Falls loesung vollstaendig ist, return true, sonst: if (findeLoesung(index+1,loesung) { return true; // Loesung gefunden } else { // Sackgasse Mache schritt rueckgaengig; // Backtracking } 2. Gibt es keinen neuen Teil-Loesungsschritt mehr, so: return false; } In Java-Pseudocode lässt sich der allgemeine Backtracking Algorithmus so formulieren: boolean findeLoesung(int index, Lsg loesung, ...) { // index ... Schrittzahl // loesung . Referenz auf Teilloesung while (es_gibt_noch_neue_Teil-Loesungsschritte) { waehle_einen_neuen_Teilloesungsschritt; if (schritt_ist_gueltig) { erweitere_loesung_um_schritt if (loesung_noch_nicht_vollstaendig) { // rekursiver Aufruf von findeLoesung if (findeLoesung(index + 1,loesung,...) return true; // Loesung gefunden else { // Sackgasse mache_Schritt_rueckgaengig; // Backtracking } } else return true; // Loesung gefunden -> fertig } } return false; } // falls true Rueckgabewert steht die Loesung in loesung 198 Algorithmen und Datenstrukturen Backtracking ist Tiefensuche, z.B. in einem Labyrinth. Der Lösungsprozeß wird in einzelne Schritte aufgeteilt. In jedem Schritt öffnet sich eine endliche Zahl von Alternativen. Einige führen in eine Sackgasse, andere werden nach demselben Verfahren überprüft. Ergebnisse können Erfolg und Misserfolg zeigen (Trial and Error). Die Lösungsstrategie führt zwar theoretisch zum Ziel, allerdings kann die Anzahl der zu untersuchenden Alternativen derart groß werden, dass die Lösungen nicht meht in zumutbarer Zeit ermittelt werden können. Die folgende Darstellung zeigt ein Labyrinth. In diesem Labyrinth soll ein Weg vom Start- zum Zielpunkt gefunden werden. Die Lösung dieses Problems ist eine Anwendung der vorstehenden Prozedur in Java-Pseudocode boolean findeLoesung(int index,Lsg loesung, int aktX, int aktY) mit [aktX][aktY] zur Angabe der aktuellen Feldposition. „findeLoesung() 149“ findet in einem Labyrinth mit K x L Feldern eine Weg vom Start zum Ziel und benutzt dabei die folgende Lösungsstrategie: - Systematische Suche vom aktuellen Feld im Labyrinth 1. oben, 2. rechts, 3. unten, 4. links - Markierung besuchter Felder - Zurücknahme der Züge in Sackgassen (Backtracking) Das Resultat soll in einem zweidimensionalen Array loesung.feld[][] stehen. Den Feldwert loesung.feld[x][y] an der Position [x][y] definiert man als -1, wenn das Feld als Sackgasse erkannt wurde. 0, wenn das Feld besucht wurde > 0, wenn das Feld zum Lösungsweg gehört. 149 vgl. pr32410, Labyrinth 199 Algorithmen und Datenstrukturen Abb. Die Feldwerte des Lösungsweges geben die Besuchsreihenfolge wieder. 200 Algorithmen und Datenstrukturen [6][2] [2][5] [6][3] [5][1] [5][3] [6][4] [6][1] [5][4] [6][5] [6][0] [5][5] [6][6] [5][0] [5][6] [4][0] [4][6] [4][1] [3][6] [2][6] [1][6] [0][6] [1][5] [0][5] [2][5] [0][4] [2][4] [0][3] [1][4] [0][2] [1][3] [0][1] [1][2] [2][2] [2][1] [1][1] [0][1] [0][0] Abb. Wege im Labyrinth (Durchlauf durch das Labyrinth) Laufzeit. Bei der Tiefensuche werden bei -max k möglichen Verzweigungen von jeder Teillösung aus und - einem Lösungsbaum mit maximaler Tiefe von n im schlechtesten Fall O(kn) Knoten im Lösungsbaum erwartet. Tiefensuche und somit auch Backtracking haben im schlechtesten Fall mit O(k n) eine exponentielle Laufzeit. Aus der Komplexitätstheorie ist bekannt: Algorithmen mit nicht polynomialer Laufzeit sind zu langsam. Bei Problemen mit großer Suchtiefe wird Backtracking deshalb zu lange brauchen. 201 Algorithmen und Datenstrukturen Backtracking - ist eine systematische Suchstrategie und findet deshalb immer eine optimale Lösung, sofern vorhanden, und sucht höchstens einmal in der gleichen Sackgasse. - ist einfach zu implementieren mit Rekursion. - macht Tiefensuche im Lösungsraum. - hat im schlechtesten Fall eine exponentielle Laufzeit O(k n) und ist deswegen primär nur für kleine Probleme geeignet. - erlaubt Wissen über ein Problem in Form einer Heuristik150 zu nutzen, um den Suchraum einzuschränken und die Suche dadurch zu beschleunigen. Typische Einsatzfelder des Backtracking: - Spielprogramme (Schach, Dame) - Erfüllbarkeit von logischen Aussagen (logische Programmierspachen) - Planungsprobleme, Konfigurationen. 3.5 Zufallsgesteuerte Algorithmen Zufallsgesteuerte Algorithmen verwenden zufällige Daten, um das Laufzeitverhalten zu verringern. Zufallsgesteuerte Algorithmen sind nicht deterministisch. 150 Damit nicht alle Lösungswege ausprobiert werden müssen, werden Heuristiken verwendet. Das sind Strategien, die mit hoher Wahrscheinlichkeit (jedoch ohne Garantie) das auffinden einer Lösung beschleunigen sollen. 202 Algorithmen und Datenstrukturen 4. Bäume 4.1 Grundlagen 4.1.1 Grundbegriffe und Definitionen Bäume sind eine Struktur zur Speicherung von (meist ganzahligen) Schlüsseln. Die Schlüssel werden so gespeichert, daß sie sich in einem einfachen und effizienten Verfahren wiederfinden lassen. Neben dem Suchen sind üblicherweise das Einfügen eines neuen Knoten (mit gegebenem Schlüssel), das Entfernen eines Knoten (mit gegebenem Schlüssel), das Durchlaufen aller Knoten eines Baums in bestimmter Reihenfolge erklärte Operationen. Weitere wichtige Verfahren sind: - Das Konstruieren eines Baums mit bestimmten Eigenschaften - Das Aufspalten eines Baums in mehrere Teilbäume - Das Zusammenfügen mehrere Bäume zu einem Baum Definitionen Eine Datenstruktur heißt "t-ärer" Baum, wenn zu jedem Element höchstens t Nachfolger (t = 2,3,.... ) festgelegt sind. "t" bestimmt die Ordnung des Baumes (z.B. "t = 2": Binärbaum, "t = 3": Ternärbaum). Die Elemente eines Baumes sind die Knoten (K), die Verbindungen zwischen den Knoten sind die Kanten (R). Sie geben die Beziehung (Relation) zwischen den Knotenelementen an. Eine Datenstruktur D = (K,R) ist ein Baum, wenn R aus einer Beziehung besteht, die die folgenden 3 Bedingungen erfült: 1. Es gibt genau einen Ausgangsknoten (, das ist die Wurzel des Baums). 2. Jeder Knoten (mit Ausnahme der Wurzel) hat genau einen Vorgänger 3. Der Weg von der Wurzel zu jedem Knoten ist ein Pfad, d.h.: Für jeden von der Wurzel verschiedenen Knoten gibt es genau eine Folge von Knoten k 1, k2, ... , kn (n >= 2), bei der k i der Nachfolger von ki-1 ist. Die größte vorkommende Pfadlänge ist die Höhe eines Baums. Knoten, die keinen Nachfolger haben, sind die Blätter. Knoten mit weniger als t Nachfolger sind die Randknoten. Blätter gehören deshalb mit zum Rand. Haben alle Blattknoten eines vollständigen Baums die gleiche Pfadlänge, so heißt der Baum voll. Quasivoller Baum: Die Pfadlängen der Randknoten unterscheiden sich höchstens um 1. Bei ihm ist nur die unterste Schicht nicht voll besetzt. Linksvoller Baum: Blätter auf der untersten Schicht sind linksbündig dicht. Geordneter Baum: Ein Baum der Ordnung t ist geordnet, wenn für jeden Nachfolger k' von k festgelegt ist, ob k' der 1., 2., ... , t. Nachfolger von k ist. Dabei handelt es sich um eine Teilordnung, die jeweils die Söhne eines Knoten vollständig ordnet. 203 Algorithmen und Datenstrukturen Speicherung von Bäumen Im allg. wird die der Baumstruktur zugrundeliegende Relation gekettet gespeichert, d.h.: Jeder Knoten zeigt über einen sog. Relationenteil (mit t Komponenten) auf seine Nachfolger. Die Verwendung eines Anfangszeigers (, der auf die Wurzel des Baums zeigt,) ist zweckmäßig. 4.1.2 Darstellung von Bäumen In der Regel erfolgt eine grafische Darstellung: Ein Nachfolgerknoten k' von k wird unterhalb des Knoten k gezeichnet. Bei der Verbindung der Knotenelemente reicht deshalb eine ungerichte Linie (Kante). Abb.: Bei geordneten Bäumen werden die Nachfolger eines Knoten k in der Reihenfolge "1. Nachfolger", "2. Nachfolger", .... von links nach rechts angeordnet. Ein Baum ist demnach ein gerichteter Graf mit der speziellen Eigenschaft: Jeder Knoten (Sohnknoten) hat bis auf einen (Wurzelknoten) genau einen Vorgänger (Vaterknoten). Ebene 1 linker Teilbaum von k Ebene 2 k Ebene 3 Weg, Pfad Ebene 4 Randknoten oder Blätter 204 Algorithmen und Datenstrukturen Abb. 4.1-2: 4.1.3 Berechnungsgrundlagen Die Zahl der Knoten in einem Baum ist N. Ein voller Baum der Höhe h enthält: h (1) N t i 1 i 1 th 1 t 1 Bspw. enthält ein Binärbaum N 2 h 1 Knoten. Das ergibt für den Binärbaum der Höhe 3 sieben Knoten. Unter der Pfadsumme Z versteht man die Anzahl der Knoten, die auf den unterschiedlichen Pfaden im vollen t-ären Baum auftreten können: h (2) Z i t i 1 i 1 Die Summe kann durch folgende Formel ersetzt werden: h th th 1 Z t 1 ( t 1) 2 t und h können aus (1) bestimmt werden: t h N ( t 1) 1 h log t ( N ( t 1) 1) Mit Gleichung (1) ergibt sich somit für Z Z log t ( N ( t 1) 1) ( N ( t 1) 1) N ( t 1) ( N ( t 1) 1) log t ( N ( t 1) 1) N t 1 t 1 ( t 1) 2 Für t = 2 ergibt sich damit: Z h 2 h (2 h 1) bzw. Z ( N 1) ld ( N 1) N Die mittlere Pfadlänge ist dann: (3) Z mit Z ( N ( t 1) 1) log t ( N ( t 1) 1) N ( N ( t 1) 1) log t ( N ( t 1) 1) 1 N N ( t 1) N ( t 1) t 1 205 Algorithmen und Datenstrukturen Für t = 2 ist (4) Z mit Z N 1 ld ( N 1) 1 N N Die Formeln unter (3) und (4) ergeben den mittleren Suchaufwand bei gleichhäufigem Aufsuchen der Elemente. Ist dies nicht der Fall, so ordnet man den Elementen die relativen Gewichte g i (i = 1, 2, 3, ... , N) bzw. die Aufsuchwahrscheinlichkeiten zu: pi N gi , G gi G i 1 N Man kann eine gewichtete Pfadsumme Zg gi hi i 1 Suchaufwand ( Z g ) mit Zg G N p i h i berechnen. i 1 206 bzw. einen mitlleren Algorithmen und Datenstrukturen 4.1.4 Klassifizierung von Bäumen Wegen der großen Bedeutung, die binäre Bäume besitzen, ist es zweckmäßig in Binär- und t-äre Bäume zu unterteilen. Bäume werden oft verwendet, um eine Menge von Daten festzulegen, deren Elemente nach einem Schlüssel wiederzufinden sind (Suchbäume). Die Art, nach der beim Suchen in den Baumknoten eine Auswahl unter den Nachfolgern getroffen wird, ergibt ein weiteres Unterscheidungsmerkmal für Bäume. Intervallbäume In den Knoten eines Baumes befinden sich Daten, mit denen immer feinere Teilintervalle ausgesondert werden. Bsp.: Binärer Suchbaum Die Schlüssel sind nach folgendem System angeordnet. Neu ankommende Elemente werden nach der Regel "falls kleiner" nach links bzw. "falls größer" nach rechts abgelegt. 40 30 50 20 11 39 24 37 60 44 40 41 45 62 65 Es kann dann leicht festgestellt werden, in welchem Teilbereich ein Schlüsselwort vorkommt. Selektorbäume (Entscheidungsbäume) Der Suchweg ist hier durch eine Reihe von Eigenschaften bestimmt. Beim Binärbaum ist das bspw. eine Folge von 2 Entscheidungsmöglichkeiten. Solche Entscheidungsmöglichkeiten können folgendermaßen codiert sein: - 0 : Entscheidung für den linken Nachfolger - 1 : Entscheidung für den rechten Nachfolger Die Folge von Entscheidungen gibt dann ein binäres Codewort an. Dieses Codewort kann mit einem Schlüssel bzw. mit einem Schlüsselteil übereinstimmen. Bsp.: "Knotenorientierter binärer Selektorbaum" Folgende Schlüsselfolge wird zum Erstellen des Baums herangezogen: 1710 = 0 1 0 0 0 12 207 Algorithmen und Datenstrukturen 3810 6310 1910 3210 2910 4410 2610 5310 = = = = = = = = 1 1 0 1 0 1 0 1 0 1 1 0 1 0 1 1 0 1 0 0 1 1 1 0 1 1 0 0 1 1 0 1 1 1 1 0 0 0 1 1 02 12 12 02 12 02 02 12 Der zugehörige Binärbaum besitzt dann folgende Gestalt: 17 19 0_ 38 39 01_ 32 1_ 63 10_ 11_ 101_ 40 011_ 44 53 110_ In den Knoten dient demnach der Wertebereich einer Teileigenschaft zur Auswahl der nächsten Untergruppe. Knotenorientierte und blattorientierte Bäume Zur Unterscheidung von Bäumen kann auf die Aufbewahrungsstelle der Daten zurückgegriffen werden: 1. Knotenorientierte Bäume Daten befinden sich hier in allen Baumknoten 2. Blattorientierte Bäume Daten befinden sich nur in den Blattknoten Optimierte Bäume Man unterscheidet statisch und dynamisch optimierte Bäume. In beiden Fällen sollen entartete Bäume (schiefe Bäume, Äste werden zu linearen Listen) vermieden werden. Statische Optimierung bedeutet: Der Baum wird neu (oder wieder neu) aufgebaut. Optimalität ist auf die Suchoperation bezogen. Es interessiert dabei das Aussehen des Baums, wenn dieser vor Gebrauch optimiert werden kann. Bei der dynamischen Optimierung wird der Baum während des Betriebs (bei jedem Ein- und Ausfügen) optimiert. Ziel ist also hier: Eine günstige Speicherstruktur zu erhalten. Diese Aufgabe kann im allg. nicht vollständig gelöst werden, eine Teiloptimierung (lokale Optimierung) reicht häufig aus. Werden die Operationen "Einfügen", "Löschen" und "Suchen" ohne besondere Einschränkungen oder Zusätze angewendet, so spricht man von freien Bäumen. 208 Algorithmen und Datenstrukturen Strukturbäume Sie dienen zur Darstellung und Speicherung hierarchischer Zusammenhänge. Bsp.: "Darstellung eines arithmetischen Ausdrucks" Operationen in einem arithmetischen Ausdruck sind zweiwertig (, die einwertige Operation "Minus" kann als Vorzeichen dem Operanden direkt zugeordnet werden). Zu jeder Operation gehören demnach 2 Operanden. Somit bietet sich die Verwendung eines binären Baumes an. Für den arithmetischen Ausdruck (A B / C) ( D E F) ergibt sich dann folgende Baumdarstellung: * + - A / B D * E C Abb.: Ein derartiger Baum heißt Kantorowitsch-Baum. 209 F Algorithmen und Datenstrukturen 4.2 Freie Binäre Intervallbäume 4.2.1 Ordnungsrelation und Darstellung Freie Bäume sind durch folgende Ordnungsrelation bestimmt: In jedem Knoten eines knotenorientierten, geordneten Binärbaums gilt: Alle Schlüssel im rechten (linken) Unterbaum sind größer (kleiner) als der Schlüssel im Knoten selbst. Mit Hilfe dieser Ordnungsrelation erstellte Bäume dienen zum Zugriff auf Datenbestände (Aufsuchen eines Datenelements). Die Daten sind die Knoten (Datensätze, -segmente, -elemente). Die Kanten des Zugriffsbaums sind Zeiger auf weitere Datenknoten (Nachfolger). Dateninformation Schluessel Datenteil Knotenzeiger LINKS RECHTS Zeiger zum linken Sohn Zeiger zum rechten Sohn Abb.: Das Aufsuchen eines Elements im Zugriffsbaum geht vom Wurzelknoten über einen Kantenzug (d.i. eine Reihe von Zwischenknoten) zum gesuchten Datenelement. Bei jedem Zwischenknoten auf diesem Kantenzug findet ein Entscheidungsprozeß über die folgenden Vergleiche statt: 1. Die beiden Schlüssel sind gleich: Das Element ist damit gefunden 2. Der gesuchte Schlüssel ist kleiner: Das gesuchte Element kann sich dann nur im linken Unterbaum befinden 3. Der gesuchte Schlüssel ist größer: Das gesuchte Element kann sich nur im rechten Unterbaum befinden. Das Verfahren wird solange wiederholt, bis das gesuchte (Schlüssel-) Element gefunden ist bzw. feststeht, daß es in dem vorliegenden Datenbestand nicht vorhanden ist. Struktur und Wachstum binärer Bäume sind durch die Ordnungsrelation bestimmt: Aufgabe: Betrachte die 3 Schlüssel 1, 2, 3. Diese 3 Schlüssel können durch verschieden angeordnete Folgen bei der Eingabe unterschiedliche binäre Bäume erzeugen. Stellen Sie alle Bäume, die aus unterschiedlichen Eingaben der 3 Schlüssel resultieren, dar! 210 Algorithmen und Datenstrukturen 1, 2, 3 1, 3, 2 2, 1, 3 1 1 2 3 2 3 1 2 2, 3, 1 3, 1, 2 3, 2, 1 2 3 3 1 3 2 1 1 Es gibt also: Sechs unterschiedliche Eingabefolgen und somit 6 unterschiedliche Bäume. Allgemein können n Elemente zu n! verschiedenen Anordnungen zusammengestellt werden. Suchaufwand Der mittlere Suchaufwand für einen vollen Baum beträgt Z mit N 1 ld ( N 1) 1 N Zur Bestimmung des Suchaufwands stellt man sich vor, daß ein Baum aus dem leeren Baum durch sukzessives Einfuegen der N Schlüssel entstanden ist. Daraus resultieren N! Permutationen der N Schlüssel. Über all diese Baumstrukturen ist der Mittelwert zu bilden, um schließlich den Suchaufwand festlegen zu können. Aus den Schlüsselwerten 1, 2, ... , N interessiert zunächst das Element k, das die Wurzel bildet. k Hier gibt es: (k-1)! Unterbäume Schlüsseln 1..N Hier gibt es: (N-k)! Unterbäume mit den Schlüsseln k+1, ... , N 211 Algorithmen und Datenstrukturen Der mittlere Suchaufwand im gesamten Baum ist: ZN 1 ( N Z k 1 ( k 1) Z N k ( N k )) N Zk-1: mittlerer Suchaufwand im linken Unterbaum ZN-k: mittlerer Suchaufwand im rechten Unterbaum Zusätzlich zu diesen Aufwendungen entsteht ein Aufwand für das Einfügen der beiden Teilbäume an die Wurzel. Das geschieht (N-1)-mal. Zusammen mit dem dem Suchschritt selbst ergibt das N-mal. Der angegebene Suchaufwand gilt nur für die Wurzel mit dem Schlüssel k. Da alle Werte für k gleichwahrscheinlich sind, gilt allgemein: ZN (k) 1 N 2 N ( N Z ( k 1 ) Z ( N k )) Z 1 (Z k 1 ( k 1) bzw. k 1 N k N N 2 k 1 N 2 k 1 N 1 2 (Z k 1 ( k 1) ( N 1) 2 k 1 N 1 2 N 2 2 Z k 1 ( k 1) Z k 1 ( k 1) N k 1 ( N 1) 2 k 1 bzw. für N - 1: Z N 1 1 Z N Z N 1 Es läßt sich daraus ableiten: Mit der YN YN 1 Ersatzfunktion N N 1 2N 1 ZN Z N 1 N 1 N N ( N 1) YN N ZN N 1 folgt die Rekursionsformel: N N 2i 1 1 N 2N 1 2 3 bzw. nach Auflösung151 YN N 1 N ( N 1) i 1 i ( i 1) i 1 i Einsetzen ergibt: ZN 2 N 1 N 1 HS N 3 2 ( HS N 1 1) 1 N N N "HS" ist die harmonische Summe: HS N i 1 1 N Sie läßt sich näherungsweise mit ln( N ) 0.577 (ln( N )) 0.693 ld ( N ) . Damit ergibt . ld ( N 1) 2 sich schließlich: Z mit 14 Darstellung Jeder geordnete binäre Baum ist eindeutig durch folgende Angaben bestimmt: 1. Angabe der Wurzel 151 vgl. Wettstein, H.: Systemprogrammierung, 2. Auflage, S.291 212 Algorithmen und Datenstrukturen 2. Für jede Kante Angabe des linken Teilbaums ( falls vorhanden) sowie des rechten Teilbaums (falls vorhanden) Die Angabe für die Verzweigungen befinden sich in den Baumknoten, die die zentrale Konstruktionseinheit für den Aufbau binärerer Bäume sind. Die Klassenschablone „Baumknoten“ in C++152 // Schnittstellenbeschreibung // Die Klasse binaerer Suchbaum binSBaum benutzt die Klasse baumKnoten template <class T> class binSBaum; // 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 baumKnoten<T>* holeLinks(void) const; baumKnoten<T>* holeRechts(void) const; // Die Klasse binSBaum benoetigt den Zugriff auf // "links" und "rechts" friend class binSBaum<T>; }; // 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> 152 vgl. baumkno.h 213 Algorithmen und Datenstrukturen baumKnoten<T>::~baumKnoten(void) {} 4.2.2 Operationen 4.2.2.1 Generieren eines Suchbaums Bsp.: Gestalt des durch die Prozedur ERSTBAUM erstellten Binärbaums nach der Eingabe der Schlüsselfolge (12, 7, 15, 5, 8, 13, 2, 6, 14). Schlüssel 12 LINKS RECHTS 7 5 2 15 8 13 6 14 Abb.: Erzeugen eines Binärbaumknotens bzw. eines binären Baums in C++ Zum Erzeugen eines Binärbaumknotens kann folgende Funktionsschablone herangezogen werden: template <class T> baumKnoten<T>* erzeugebaumKnoten(T merkmal, baumKnoten<T>* lzgr = NULL, baumKnoten<T>* rzgr = NULL) { baumKnoten<T> *z; // Erzeugen eines neuen Knoten z = new baumKnoten<T>(merkmal,lzgr,rzgr); if (z == NULL) { cerr << "Kein Speicherplatz!\n"; exit(1); } return z; // Rueckgabe des Zeigers 214 Algorithmen und Datenstrukturen } Der durch den Baumknoten belegte Funktionsschablone freigegeben werden: Speicherplatz kann über folgende template <class T> void gibKnotenFrei(baumKnoten<T>* z) { delete z; } Der folgende Hauptprogrammabschnitt erzeugt einen binären Baum folgende Gestalt: ‘A’ ‘B’ ‘C’ ‘D’ ‘E’ Abb:: void main(void) { baumKnoten<char> *a, *b, *c, *d, *e; d = new baumKnoten<char>('D'); e = new baumKnoten<char>('E'); b = new baumKnoten<char>('B',NULL,d); c = new baumKnoten<char>('C',e); a = new baumKnoten<char>('A',b,c); } 215 Algorithmen und Datenstrukturen 4.2.2.2 Suchen und Einfügen Vorstellung zur Lösung: 1. Suche nach dem Schlüsselwert 2. Falls vorhanden kein Einfügen 3. Bei erfolgloser Suche Einfügen als Sohn des erreichten Blatts Implementierung in C++ Das „Einfügen“153 ist eine Methode in der Klassenschablone für einen binären Suchbaum binSBaum. Zweckmäßigerweise stellt diese Klasse Datenelemente unter protected zur Verfügung. #include "baumkno.h" // Schnittstellenbeschreibung template <class T> class binSBaum { protected: // Zeiger auf den Wurzelknoten und den Knoten, auf den am // haeufigsten zugegriffen wird baumKnoten<T> *wurzel; baumKnoten<T> *aktuell; // Anzahl Knoten im Baum int groesse; // Speicherzuweisung / Speicherfreigabe // Zuweisen eines neuen Baumknoten mit Rueckgabe // des zugehoerigen Zeigerwerts baumKnoten<T> *holeBaumKnoten(const T& merkmal, baumKnoten<T> *lzgr,baumKnoten<T> *rzgr) { baumKnoten<T> *z; // Datenfeld und die beiden Zeiger werden initialisiert z = new baumKnoten<T> (merkmal, lzgr, rzgr); if (z == NULL) { cerr << "Speicherbelegungsfehler!\n"; exit(1); } return z; } // gib den Speicherplatz frei, der von einem Baumknoten belegt wird void freigabeKnoten(baumKnoten<T> *z) // wird vom Kopierkonstruktor und Zuweisungsoperator benutzt { delete z; } // Kopiere Baum b und speichere ihn im aktuellen Objekt ab baumKnoten<T> *kopiereBaum(baumKnoten<T> *b) // wird vom Destruktor, Zuweisungsoperator und bereinigeListe benutzt { baumKnoten<T> *neulzgr, *neurzgr, *neuerKnoten; // Falls der Baum leer ist, Rueckgabe von NULL if (b == NULL) return NULL; // Kopiere den linken Zweig von der Baumwurzel b und weise seine // Wurzel neulzgr zu if (b->links != NULL) neulzgr = kopiereBaum(b->links); else neulzgr = NULL; // Kopiere den rechten Zweig von der Baumwurzel b und weise seine // Wurzel neurzgr zu if (b->rechts != NULL) neurzgr = kopiereBaum(b->rechts); 153 vgl. bsbaum.h 216 Algorithmen und Datenstrukturen else neurzgr = NULL; // Weise Speicherplatz fuer den aktuellen Knoten zu und weise seinen // Datenelementen Wert und Zeiger seiner Teilbaeume zu neuerKnoten = holeBaumKnoten(b->daten, neulzgr, neurzgr); return neuerKnoten; } // Loesche den Baum, der durch im aktuellen Objekt gespeichert ist void loescheBaum(baumKnoten<T> *b) // Lokalisiere einen Knoten mit dem Datenelementwert von merkmal // und seinen Vorgaenger (eltern) im Baum { // falls der aktuelle Wurzelknoten nicht NULL ist, loesche seinen // linken Teilbaum, seinen rechten Teilbaum und dann den Knoten selbst if (b != NULL) { loescheBaum(b->links); loescheBaum(b->rechts); freigabeKnoten(b); } } // Suche nach dem Datum "merkmal" im Baum. Falls gefunden, Rueckgabe // der zugehoerigen Knotenadresse; andernfalls NULL baumKnoten<T> *findeKnoten(const T& merkmal, baumKnoten<T>* & eltern) const { // Durchlaufe b. Startpunkt ist die Wurzel baumKnoten<T> *b = wurzel; // Die "eltern" der Wurzel sind NULL eltern = NULL; // Terminiere bei einen leeren Teilbaum while(b != NULL) { // Halt, wenn es passt if (merkmal == b->daten) break; else { // aktualisiere den "eltern"-Zeiger und gehe nach rechts // bzw. nach links eltern = b; if (merkmal < b->daten) b = b->links; else b = b->rechts; } } // Rueckgabe des Zeigers auf den Knoten; NULL, falls nicht gefunden return b; } public: // Konstruktoren, Destruktoren binSBaum(void); binSBaum(const binSBaum<T>& baum); ~binSBaum(void); // Zuweisungsoperator binSBaum<T>& operator= (const binSBaum<T>& rs); // Bearbeitungsmethoden int finden(T& merkmal); void einfuegen(const T& merkmal); void loeschen(const T& merkmal); void bereinigeListe(void); int leererBaum(void) const; int baumGroesse(void) const; // baumspezifische Methoden void aktualisieren(const T& merkmal); baumKnoten<T> *holeWurzel(void) const; }; 217 Algorithmen und Datenstrukturen Die Schnittstellenfunktion void einfuegen(const T& merkmal); besitzt folgende Definition154: // Einfuegen "merkmal" in den Suchbaum template <class T> void binSBaum<T>::einfuegen(const T& merkmal) { // b ist der aktuelle Knoten beim Durchlaufen des Baums baumKnoten<T> *b = wurzel, *eltern = NULL, *neuerKnoten; // Terminiere beim leeren Teilbaum while(b != NULL) { // Aktualisiere den zeiger "eltern", // dann verzweige nach links oder rechts eltern = b; if (merkmal < b->daten) b = b->links; else b = b->rechts; } // Erzeuge den neuen Blattknoten neuerKnoten = holeBaumKnoten(merkmal,NULL,NULL); // Falls "eltern" auf NULL zeigt, einfuegen eines Wurzelknoten if (eltern == NULL) wurzel = neuerKnoten; // Falls merkmal < eltern->daten, einfuegen als linker Nachfolger else if (merkmal < eltern->daten) eltern->links = neuerKnoten; else // Falls merkmal >= eltern->daten, einfuegen als rechter Nachf. eltern->rechts = neuerKnoten; // Zuweisen "aktuell": "aktuell" ist die Adresse des neuen Knoten aktuell = neuerKnoten; groesse++; } 4.2.2.3 Löschen eines Knoten Es soll ein Knoten mit einem bestimmten Schlüsselwert entfernt werden. Fälle A) Der zu löschende Knoten ist ein Blatt Bsp.: vorher nachher Abb.: Das Entfernen kann leicht durchgeführt werden 154 vgl. bsbaum.h 218 Algorithmen und Datenstrukturen B) Der zu löschende Knoten hat genau einen Sohn nachher vorher Abb.: C) Der zu löschende Knoten hat zwei Söhne nachher vorher Abb.: Der Knoten k wird durch den linken Sohn ersetzt. Der rechte Sohn von k wird rechter Sohn der rechtesten Ecke des linken Teilbaums. Der resultierende Teilbaum T' ist ein Suchbaum, häufig allerdings mit erheblich vergrößerter Höhe. Aufgaben: 1. Gegeben ist ein binärer Baum folgender Gestalt: k k1 k2 k3 219 Algorithmen und Datenstrukturen Die Wurzel wird gelöscht. Welche Gestalt nimmt der Baum dann an: k1 k3 k2 Abb.: Es ergibt sich eine Höhendifferenz H , die durch folgende Beziehung eingegrenzt ist: 1 H H (TL ) H ( TL ) ist die Höhe des linken Teilbaums. 2. Gegeben ist die folgende Gestalt eines binären Baums 12 7 15 5 13 2 6 14 Welche Gestalt nimmt dieser Baum nach dem Entfernen der Schlüssel mit den unter a) bis f) angegebenen Werten an? a) 2 b) 6 12 7 5 15 13 14 220 Algorithmen und Datenstrukturen c) 13 12 7 15 5 14 d) 15 12 7 14 5 e) 5 12 7 14 f) 12 7 14 Schlüsseltransfer Der angegebene Algorithmus zum Löschen von Knoten kann zu einer beträchtlichen Vergrößerung der Baumhöhe führen. Das bedeutet auch eine beträchtliche Steigerung des mittleren Suchaufwands. Man ersetzt häufig die angegebene Verfahrensweise durch ein anderes Verfahren, das unter dem Namen Schlüsseltransfer bekannt ist. Der zu löschende Schlüssel (Knoten) wird ersetzt durch den kleinsten Schlüssel des rechten oder den größten Schlüssel des linken Teilbaums. Dieser ist dann nach Fall A) bzw. B) aus dem Baum herauszunehmen. Bsp.: 221 Algorithmen und Datenstrukturen Abb.: Test der Verfahrensweise "Schlüsseltransfer": 1) Der zu löschende Baumknoten besteht nur aus einem Wurzelknoten, z.B.: Schlüssel LINKS 12 RECHTS Ergebnis: Der Wurzelknoten wird gelöscht. 2) Vorgegeben ist Schlüssel LINKS 12 RECHTS 7 5 8 Abb.: Der Wurzelknoten wird gelöscht. Ergebnis: 222 Algorithmen und Datenstrukturen 7 5 8 Abb.: 3) Vorgegeben ist Schlüssel 12 LINKS RECHTS 7 5 15 8 13 14 Abb.: Der Wurzelknoten wird gelöscht. Ergebnis: 223 Algorithmen und Datenstrukturen Schlüssel LINKS 13 RECHTS 7 5 15 8 14 Abb.: a) Implementierung der Verfahrenweise Schlüsseltransfer Baumknoten in einem binären Suchbaum in C++ zum Löschen von // Falls "merkmal" im Baum vorkommt, dann loesche es template <class T> void binSBaum<T>::loeschen(const T& merkmal) { // LKnoZgr: Zeiger auf Knoten L, der geloescht werden soll // EKnoZgr: Zeiger auf die "eltern" E des Knoten L // ErsKnoZgr: Zeiger auf den rechten Knoten R, der L ersetzt baumKnoten<T> *LKnoZgr, *EKnoZgr, *ErsKnoZgr; // Suche nach einem Knoten, der einen Knoten enthaelt mit dem // Datenwert von "merkmal". Bestimme die aktuelle Adresse diese Knotens // und die seiner "eltern" if ((LKnoZgr = findeKnoten (merkmal, EKnoZgr)) == NULL) return; // Falls LKnoZgr einen NULL-Zeiger hat, ist der Ersatzknoten // auf der anderen Seite des Zweigs if (LKnoZgr->rechts == NULL) ErsKnoZgr = LKnoZgr->links; else if (LKnoZgr->links == NULL) ErsKnoZgr = LKnoZgr->rechts; // Beide Zeiger von LKnoZgr sind nicht NULL else { // Finde und kette den Ersatzknoten fuer LKnoZgr aus. // Beginne am linkten Zweig des Knoten LKnoZgr, // bestimme den Knoten, dessen Datenwert am groessten // im linken Zweig von LKnoZgr ist. Kette diesen Knoten aus. // EvonErsKnoZgr: Zeiger auf die "eltern" des zu ersetzenden Knoten baumKnoten<T> *EvonErsKnoZgr = LKnoZgr; // erstes moegliches Ersatzstueck: linker Nachfolger von L ErsKnoZgr = LKnoZgr->links; // steige den rechten Teilbaum des linken Nachfolgers von LKnoZgr hinab, // sichere den Satz des aktuellen Knoten und den seiner "Eltern" // Beim Halt, wurde der zu ersetzende Knoten gefunden while(ErsKnoZgr->rechts != NULL) { EvonErsKnoZgr = ErsKnoZgr; ErsKnoZgr = ErsKnoZgr->rechts; } if (EvonErsKnoZgr == LKnoZgr) // Der linke Nachfolger des zu loeschenden Knoten ist das // Ersatzstueck // Zuweisung des rechten Teilbaums ErsKnoZgr->rechts = LKnoZgr->rechts; 224 Algorithmen und Datenstrukturen else { // es wurde sich um mindestens einen Knoten nach unten bewegt // der zu ersetzende Knoten wird durch Zuweisung seines // linken Nachfolgers zu "Eltern" geloescht EvonErsKnoZgr->rechts = ErsKnoZgr->links; // plaziere den Ersatzknoten an die Stelle von LKnoZgr ErsKnoZgr->links = LKnoZgr->links; ErsKnoZgr->rechts = LKnoZgr->rechts; } } // Vervollstaendige die Verkettung mit den "Eltern"-Knoten // Loesche den Wurzelknoten, bestimme eine neue Wurzel if (EKnoZgr == NULL) wurzel = ErsKnoZgr; // Zuweisen Ers zum korrekten Zweig von E else if (LKnoZgr->daten < EKnoZgr->daten) EKnoZgr->links = ErsKnoZgr; Else EKnoZgr->rechts = ErsKnoZgr; // Loesche den Knoten aus dem Speicher und erniedrige "groesse" freigabeKnoten(LKnoZgr); groesse--; } 225 Algorithmen und Datenstrukturen 4.2.3 Ordnungen und Durchlaufprinzipien Das Prinzip, wie ein geordneter Baum durchlaufen wird, legt eine Ordnung auf der Menge der Knoten fest. Es gibt 3 Möglichkeiten (Prinzipien), die Knoten eines binären Baums zu durchlaufen: 1. Inordnungen LWR-Ordnung (1) Durchlaufen (Aufsuchen) des linken Teilbaums in INORDER (2) Aufsuchen der BAUMWURZEL (3) Durchlaufen (Aufsuchen) des rechten Teilbaums in INORDER RWL-Ordnung (1) Durchlaufen (Aufsuchen) des rechten Teilbaums in INORDER (2) Aufsuchen der BAUMWURZEL (3) Durchlaufen (Aufsuchen) des Teilbaums in INORDER Der LWR-Ordnung und die RWL-Ordnung sind zueinander invers. Die LWR Ordnung heißt auch symmetrische Ordnung. 2. Präordnungen WLR-Ordnung (1) Aufsuchen der BAUMWURZEL (2) Durchlaufen (Aufsuchen) des linken Teilbaums in PREORDER (3) Durchlaufen (Aufsuchen) des rechten Teilbaums in PREORDER WRL-Ordnung (1) Aufsuchen der BAUMWURZEL (2) Durchlaufen (Aufsuchen) des rechten Teilbaums in PREORDER (3) Durchlaufen (Aufsuchen) des linken Teilbaums in PREORDER Es wird hier grundsätzlich die Wurzel vor den (beiden) Teilbäumen durchlaufen. 3. Postordnungen LRW-Ordnung (1) Durchlaufen (Aufsuchen) des linken Teilbaums in POSTORDER (2) Durchlaufen (Aufsuchen) des rechten Teilbaums in POSTORDER (3) Aufsuchen der BAUMWURZEL Zunächst werden die beiden Teilbäume und dann die Wurzel durchlaufen. RLW-Ordnung (1) Durchlaufen (Aufsuchen) des rechten Teilbaums in POSTORDER (2) Durchlaufen (Aufsuchen) des linken Teilbaums in POSTORDER (3) Aufsuchen der BAUMWURZEL Zunächst werden die beiden Teilbäume und dann die Wurzel durchlaufen. 226 Algorithmen und Datenstrukturen Funktionsschablonen für das Durchlaufen binärer Bäume in C++ // Funktionsschablonen fuer Baumdurchlaeufe template <class T> void inorder(baumKnoten<T>* b, void aufsuchen(T& merkmal)) { if (b != NULL) { inorder(b->holeLinks(),aufsuchen); aufsuchen(b->daten); inorder(b->holeRechts(),aufsuchen); } } template <class T> void postorder(baumKnoten<T>* b, void aufsuchen(T& merkmal)) { if (b != NULL) { postorder(b->holeLinks(),aufsuchen); // linker Abstieg postorder(b->holeRechts(),aufsuchen); // rechter Abstieg aufsuchen(b->daten); } } Aufgaben: Gegeben sind eine Reihe binärer Bäume. Welche Folgen entstehen beim Durchlaufen der Knoten nach den Prinzipien "Inorder (LWR)", "Praeorder WLR" und "Postorder (LRW)". 1. A B C E D F I G J H K L "Praeorder": A B C E I F J D G H K L "Inorder": EICFJBGDKHLA "Postorder": I E J F C G K L H D B A 227 Algorithmen und Datenstrukturen 2. + * A + B * C E D "Praeorder": + * A B + * C D E "Inorder": A*B+C*D+E "Postorder": A B * C D * E + + Diese Aufgabe zeigt einen Strukturbaum (Darstellung der hierarchischen Struktur eines arithmetischen Ausdrucks). Diese Baumdarstellung ist besonders günstig für die Übersetzung eines Ausdrucks in Maschinensprache. Aus der vorliegenden Struktur lassen sich leicht die unterschiedlichen Schreibweisen eines arithmetischen Ausdrucks herleiten. So liefert das Durchwandern des Baums in "Postorder" die Postfixnotation, in "Praeorder" die Praefixnotation". 3. + A * B C "Praeorder": + A * B C "Inorder": A+B*C "Postorder": A B C * + 4. * + A C B "Praeorder": * + A B C "Inorder": A+B*C "Postorder": A B + C * 228 Algorithmen und Datenstrukturen Anwendungen der Durchlaufprinzipien Mit Hilfe der angegebenen Ordnungen bzw. Durchlaufprinzipien lassen sich weitere Operationen auf geordneten Wurzelbäumen bestimmen: 1. Bestimmen der Anzahl Blätter im Baum // Anzahl Blätter template <class T> void anzBlaetter(baumKnoten<T>* b, int& zaehler) { // benutze den Postorder-Durchlauf if (b != NULL) { anzBlaetter(b->holeLinks(), zaehler); anzBlaetter(b->holeRechts(), zaehler); // Pruefe, ob der erreichte Knoten ein Blatt ist if (b->holeLinks() == NULL && b->holeRechts() == NULL) zaehler++; } } 2. Ermitteln der Höhe des Baums // Hoehe des Baums template <class T> int hoehe(baumKnoten<T>* b) { int hoeheLinks, hoeheRechts, hoeheWert; if (b == NULL) hoeheWert = -1; else { hoeheLinks = hoehe(b->holeLinks()); hoeheRechts = hoehe(b->holeRechts()); hoeheWert = 1 + (hoeheLinks > hoeheRechts ? hoeheLinks : hoeheRechts); } return hoeheWert; } 3. Kopieren des Baums // Kopieren eines Baums template <class T> baumKnoten<T>* kopiereBaum(baumKnoten<T>* b) { baumKnoten<T> *neuerLzgr, *neuerRzgr, *neuerKnoten; // Rekursionsendebedingung if (b == NULL) return NULL; if (b->holeLinks() != NULL) neuerLzgr = kopiereBaum(b->holeLinks()); else neuerLzgr = NULL; if (b->holeRechts() != NULL) neuerRzgr = kopiereBaum(b->holeRechts()); else neuerRzgr = NULL; // Der neue Baum wird von unten her aufgebaut, // zuerst werden die Nachfolger bearbeitet und // dann erst der Vaterknoten neuerKnoten = erzeugebaumKnoten(b->daten, neuerLzgr, neuerRzgr); // Rueckgabe des Zeigers auf den zuletzt erzeugten Baumknoten return neuerKnoten; } 229 Algorithmen und Datenstrukturen 4. Löschen des Baums // Loeschen des Baums template <class T> void loescheBaum(baumKnoten<T>* b) { if (b != NULL) { loescheBaum(b->holeLinks()); loescheBaum(b->holeRechts()); gibKnotenFrei(b); } } 4.3 Balancierte Bäume Hier geht es darum, entartete Bäume (schiefe Bäume, Äste werden zu linearen Listen, etc.) zu vermeiden. Statische Optimierung heißt: Der ganze Baum wird neu (bzw. wieder neu) aufgebaut. Bei der dynamischen Optimierung wird der Baum während des Betriebs (bei jedem Ein- und Ausfügen) optimiert. Perfekt ausgeglichener, binärer Suchbaum Ein binärer Suchbaum sollte immer ausgeglichen sein. Der folgende Baum 1 2 3 4 5 ist zu einer linearen Liste degeneriert und läßt sich daher auch nicht viel schneller als eine lineare Liste durchsuchen. Ein derartiger binärer Suchbaum entsteht zwangsläufig, wenn die bei der Eingabe angegebene Schlüsselfolge in aufsteigend sortierter Reihenfolge vorliegt. Der vorliegende binäre Suchbaum ist selbstverständlich nicht ausgeglichen. Es gibt allerdings auch Unterschiede bei der Beurteilung der Ausgeglichenheit, z.B.: 230 Algorithmen und Datenstrukturen Die vorliegenden Bäume sind beide ausgeglichen. Der linke Baum ist perfekt ausbalanciert. Jeder Binärbaum ist perfekt ausbalanciert, falls jeder Knoten über einen linken und rechten Teilbaum verfügt, dessen Knotenzahl sich höchstens um den Wert 1 unterscheidet. Der rechte Teilbaum ist ein in der Höhe ausgeglichener (AVL 155-)Baum. Die Höhe der Knoten zusammengehöriger linker und rechter Teilbäume unterscheiden sich höchstens um den Wert 1. Jeder perfekt ausgeglichene Baum ist gleichzeitig auch ein in der Höhe ausgeglichener Binärbaum. Der umgekehrte Fall trifft allerdings nicht zu. Es gibt einen einfachen Algorithmus zum Erstellen eines pefekt ausgeglichenen Binärbaums156, falls (1) die einzulesenden Schlüsselwerte sortiert in aufsteigender Reihenfolge angegeben werden (2) bekannt ist, wieviel Objekte (Schlüssel) werden müssen. #include <iostream.h> #include <iomanip.h> struct knoten { int info; knoten *zLinks, *zRechts; }; class pbbBaum { private: knoten *wurzel; void pr(const knoten *p, int nLeer)const; public: pbbBaum(int n); void ausgabe() const { pr(wurzel, 0); } }; pbbBaum::pbbBaum(int n) { if (n == 0) wurzel = NULL; else { int nLinks = (n - 1)/2, nRechts = n - nLinks - 1; wurzel = new knoten; wurzel->zLinks = pbbBaum(nLinks).wurzel; cin >> wurzel->info; wurzel->zRechts = pbbBaum(nRechts).wurzel; } } void pbbBaum::pr(const knoten *p, int nLeer)const { if (p) { pr(p->zRechts, nLeer += 6); cout << setw(nLeer) << p->info << endl; 155 156 nach den Anfangsbuchstaben der Namen seiner Entdecker: Adelson, Velskii u. Landes pr43_1 231 Algorithmen und Datenstrukturen pr(p->zLinks, nLeer); } } void main(void) { cout << "Enter n, followed by n integers in ascending order:\n"; int n; cin >> n; pbbBaum b(n); cout << "\nHier ist der resultierende,\n" "ausbalancierte binaere Suchbaum\n" "Zur standardmaessigen Ausgabe mit der Wurzel\n" "an der Spitze drehe die folgende Ausgabe im\n" "Urzeigersinn um 90 Grad:\n"; b.ausgabe(); char zeichen; cin >> zeichen; } Schreibtischtest: Wird mit n = 10 aufgerufen, dann wird anschließend die Anzahl der Knoten berechnet, die sowohl in den linken als auch in den rechten Teilbaum eingefügt werden. Da der Wurzelknoten keinem Teilbaum zugeordnet werden kann, ergeben sich für die beiden Teilbäume (10 – 1) Knoten. Das ergibt nLinks = 4, nRechts = 5. Anschließend wird der Wurzelknoten erzeugt. Es folgt der rekursive Aufruf wurzel.links = new PBB(nLinks).wurzel; mit nLinks = 4. Die Folge davon ist: Einlesen von 4 Zahlen und Ablage dieser Zahlen im linken Teilbaum. Die danach folgende Zahl wird im Wurzelknoten abgelegt. Der rekursive Aufruf wurzel.rechts = new PBB(nRechts).wurzel; mit nRechts = 5 verarbeitet die restlichen 5 Zahlen und erstellt damit den rechten Teilbaum. Durch jeden rekursiven Aufruf wird ein Baum mit zwei ungefähr gleich großen Teilbäumen erzeugt. Da die im Wurzelknoten enthaltene Zahl direkt nach dem erstellen des linken Teilbaum gelesen wird, ergibt sich bei aufsteigender Reihenfolge der Eingabedaten ein binärer Suchbaum, der außerdem noch perfekt balanciert ist. Abb.: 232 Algorithmen und Datenstrukturen 4.3.1 Statisch optimierte Bäume Der Algorithmus zum Erstellen eines perfekt ausgeglichenen Baums kann zur statischen Optimierung binärer Suchbäume verwendet werden. Das Erstellen des binären Suchbaums erfolgt dabei nach der bekannten Verfahrensweise. Wird ein solcher Baum in Inorder-Folge durchlaufen, so werden die Informationen in den Baumknoten aufsteigend sortiert. Diese sortierte Folge ist Eingangsgröße für die statische Optimierung. Es wird mit der sortiert vorliegende Folge der Schlüssel ein perfekt ausgeglichener Baum erstellt. Bsp.: Ein Java-Applet zur Demonstration der statischen Optimierung.157 Zufallszahlen werden generiert und in einen freien binären Intervallbaum aufgenommen, der im oberen Bereich des Fensters gezeigt werden. Über Sortieren erfolgt ein Inorder-Durchlauf des binären Suchbaums, über „Perfekter BinaerBaum“ Erzeugen und Darstellen des perfekt ausgeglichen binären Suchbaums. Abb.: 157 vgl. pr43205, ZPBBApplet.java und ZPBBApplet.html 233 Algorithmen und Datenstrukturen 4.3.2 AVL-Baum Der wohl bekannteste dynamisch ausgeglichene Binärbaum ist der AVL-Baum, genannt nach dem Anfangsbuchstaben seiner Entdecker (Adelson, Velskii und Landis). Ein Baum hat die AVL-Eigenschaft, wenn in jedem Knoten sich die Höhen der beiden Unterbäume höchstens um 1 (|HR - HL| <= 1) unterscheiden. Die Last ("Balance") muß in einem Knoten mitgespeichert sein. Es genügt aber als Maß für die Unsymmetrie die Höhendifferenz H festzuhalten, die nur die Werte -1 (linkslastig), 0 (gleichlastig) und +1 (rechtslastig) annehmen kann. 1. Einfügen Beim Einfügen eines Knoten können sich die Lastverhältnisse nur auf dem Wege, den der Suchvorgang in Anspruch nimmt, ändern. Der tatsächliche Ort der Änderung ist aber zunächst unbekannt. Der Knoten ist deshalb einzufügen und auf notwendige Berichtigungen zu überprüfen. Bsp.: Gegeben ist der folgende binäre Baum 8 4 2 10 6 Abb.: 1) In diesen Baum sind die Knoten mit den Schlüsseln 9 und 11 einzufügen. Die Gestalt des Baums ist danach: 8 4 2 10 6 9 Abb.: Die Schlüsel 9 und 11 können ohne zusätzliches Ausgleichen eingefügt werden. 234 11 Algorithmen und Datenstrukturen 2) In den gegebenen Binärbaum sind die Knoten mit den Schlüsseln 1, 3, 5 und 7 einzufügen. Wie ist die daraus resultierende Gestalt des Baums beschaffen? 8 4 2 -1 -2 10 6 1 Abb.: Schon nach dem Einfügen des Schlüsselwerts „1“ ist anschließendes Ausgleichen unbedingt erforderlich. 3) Wie könnte das Ausgleichen vollzogen werden? Eine Lösungsmöglichkeit ist hier bspw. eine einfache bzw. eine doppelte Rotation. 4 2 8 1 6 10 Abb.: Gestalt des Baums nach „Rotation“ b) Beschreibe den Ausgleichsvorgang, nachdem die Schlüssel 3, 5 und 7 eingefügt wurden! 4 2 1 8 3 6 5 10 7 Abb.: Das Einfügen der Schlüssel mit den Werten „3“, „5“ und „7“ verletzt die AVL-Eigenschaft nicht Nachdem ein Knoten eingefügt ist, ist der Pfad, den der Suchvorgang für das Einfügen durchlaufen hat, aufwärts auf notwendige Berichtigungen zu überprüfen. Bei dieser Prüfung wird die Höhendifferenz des linken und rechten Teilbaums bestimmt. Es können generell folgende Fälle eintreten: 235 Algorithmen und Datenstrukturen (1) H = +1 bzw. -1 Eine Verlängerung des Baums auf der Unterlastseite gleicht die Last aus, die Verlängerung wirkt sich nicht weiter nach oben aus. Die Prüfung kann abgebrochen werden. (2) H = 0 Das bedeutet: Verlängerung eines Teilbaums Hier ist der Knoten dann ungleichlastig ( H = +1 bzw. -1), die AVL-Eigenschaft bleibt jedoch insgesamt erhalten. Der Baum wurde länger. (3) H = +1 bzw. -1 Das bedeutet: Verlängerung des Baums auf der Überlastseite. Die AVL-Eigenschaft ist verletzt, wenn H = +2 bzw. -2. Sie wird durch Rotationen berichtigt. Die Information über die Ausgeglichenheit steht im AVL-Baumknoten, z.B.: struct knoten { int num, // Schluessel bal; // Ausgleichsfaktor struct knoten *zLinks, *zRechts; }; Abb.: AVL-Baumknoten mit Ausgleichfaktor in C++ In der Regel gibt es folgende Faktoren für die "Ausgeglichenheit" je Knoten im AVLBaum: "-1": Höhe des linken Teilbaums ist um eine Einheit (ein Knoten) größer als die Höhe im rechten Teilbaum. "0": Die Höhen des linken und rechten Teilbaums sind gleich. "1": Die Höhe des linken Teilbaums ist um eine Einheit (ein Knoten) kleiner als die Höhe des rechten Teilbaums. Bsp.: Die folgende Darstellung zeigt den Binärbaum unmittelbar nach dem Einfügen eines Baumknoten. Daher kann hier der Faktor für Ausgeglichenheit -2 bis 2 betragen. 12 +1 7 17 +1 +2 5 0 9 14 -1 0 8 24 +2 25 0 +1 30 0 236 Algorithmen und Datenstrukturen Nach dem Algorithmus für das Einfügen ergibt sich folgender AVL-Baum: 12 0 7 17 +1 +1 5 9 14 25 -1 0 8 24 0 30 0 0 Es gibt 4 Möglichkeiten die Ausgeglichenheit, falls sie durch Einfügen eines Baumknoten gestört wurde, wieder herzustellen. A A B b a A a B c c 1a B b b 1b A a a B c 2a c b 2b Abb.: Die vier Ausgangssituationen bei der Wiederherstellung der AVL-Eigenschaft Von den 4 Fällen sind jeweils die Fälle 1a, 1b und die Fälle 2a, 2b zueinander symmetrisch. Für den Fall 1a kann durch einfache Rotation eine Ausgeglichenheit erreicht werden. B 0 b A 0 c a Im Fall 1b muß die Rotation nach links erfolgen. 237 Algorithmen und Datenstrukturen Für die Behandlung von Fall 2a der Abb. 1 wird der Teilbaum c aufgeschlüsselt in dessen Teilbäume c1 und c2: A -2 B a 2 b C -1 c1 c2 Abb.: Durch zwei einfache Rotationen kann der Baum ausgeglichen werden: 1. Rotation 2. Rotation A C -2 C 0 a B -2 B A +1 c2 b +1 c1 c2 +1 b c1 Abb.: Mit der unter 1. festgestellten Verfahrensweise soll eine Klasse AvlTree bestimmt werden, die die Knoten der zuvor angegebenen Struktur so in einen Binärbaum einfügt, daß die AVL-Eigenschaft gewährleistet bleibt. template <typename Comparable> class AvlTree { private: AvlNode *root; ... public: AvlTree( ) : root( NULL ) { } AvlTree( const AvlTree & rhs ) : root( NULL ) { 238 a Algorithmen und Datenstrukturen *this = rhs; } // Destruktur ~AvlTree( ) { makeEmpty( ); } /* Ermitteln des kleinsten Elements im Baum */ const Comparable & findMin( ) const { if( isEmpty( ) ) throw UnderflowException( ); return findMin( root )->element; } /* Ermitteln des groessten Elements im Baum */ const Comparable & findMax( ) const { if( isEmpty( ) ) throw UnderflowException( ); return findMax( root )->element; } /* Rueckgabe von true, falls x im Baum gefunden wurde */ bool contains( const Comparable & x ) const { return contains( x, root );} /* Test, ob der Baum leer ist */ bool isEmpty( ) const { return root == NULL; } /*Ausgabe, ob der binaere Suchbaum wohl geordnet ist */ void printTree( ) const { if( isEmpty( ) ) cout << "Empty tree" << endl; else printTree( root ); } /* leer machen des Baums */ void makeEmpty( ) { makeEmpty( root ); } /* Einfuegen von x in den Baum, Duplikate sind zu ignorieren */ void insert( const Comparable & x ) { insert( x, root ); } /* Entfernen von x aus dem Baum */ void remove( const Comparable & x ) { cout << "Sorry, remove unimplemented; " << x << " still present" << endl; } }; Rotationen Die AVL-Eigenschaft ist verletzt, wenn die Höhendifferenz +2 bzw. –2 ist. Der Knoten, der diesen Wert erhalten hat, ist der Knoten „alpha“, dessen Unausgeglichenheit auf einen der folgenden 4 Fälle zurückzuführen ist: 1. Einfügen in den linken Teilbaum, der vom linken Nachkommen des Knoten „alpha“ bestimmt ist. 2. Einfügen in den rechten Teilbaum, der vom linken Nachkommen des Knoten „alpha“ bestimmt ist. 3. Einfügen in den linken Teilbaum, der vom rechten Nachkommen des Knoten „alpha“ bestimmt ist. 4. Einfügen in den rechten Teilbaum, der vom rechten Nachkommen des Knoten „alpha“ bestimmt ist Fall 1 und Fall 4 bzw. Fall 2 und Fall 3 sind Spiegelbilder, zeigen also das gleiche Verhalten. Fall 1 kann durch einfache Rotation behandelt werden und ist leicht zu bestimmen, daß das Einfügen „außerhalb“ (links – links bzw. rechts – rechts im Fall 4 stattfindet. 239 Algorithmen und Datenstrukturen Fall 2 kann durch doppelte Rotation behandelt werden und ist ebenfalls leicht zu bestimmen, da das Einfügen „innerhalb“ (links –rechts bzw. rechts – links) erfolgt. Die einfache Rotation: Die folgende Darstellung beschreibt den Fall 1 vor und nach der Rotation: k2 k1 k1 k2 Z Y X Y Z X Abb.: Die folgende Darstellung beschreibt Fall 4 vor und nach der Rotation: k1 k2 k2 k1 X Y X Y Z Z Abb.: Doppelrotation: Die einfache Rotation führt in den Fällen 2 und 3 nicht zum Erfolg. Fall 2 muß durch eine Doppelrotation (links – rechts) behandelt werden. k3 k2 k1 k1 k3 D k2 B A A B C Abb.: 240 C D Algorithmen und Datenstrukturen Auch Fall 3 muß durch Doppelrotation behandelt werden k1 k3 k2 A k1 k2 k3 D B C A B D C Abb.: Implementierung Zum Einfügen eines Knoten mit dem Datenwert „x“ in einen AVL-Baum, wird „x“ rekursiv in den betoffenen Teilbaum eingesetzt. Falls die Höhe dieses Teilbaums sich nicht verändert, ist das Einfügen beendet. Liegt Unausgeglichenheit vor, dann ist einfache oder doppelte Rotation (abhängig von „x“ und den Daten des betroffenen Teilbaums) nötig. Avl-Baumknoten158 Er enthält für jeden Knoten eine Angabe zur Höhe(ndifferenz) seiner Teilbäume. // Baumknoten fuer AVL-Baeume struct AvlNode { Comparable element; AvlNode *left; AvlNode *right; int height; // Konstruktor AvlNode( const Comparable & theElement, AvlNode *lt, AvlNode *rt, int h = 0 ) : element( theElement ), left( lt ), right( rt ), height( h ) { } }; Der Avl-Baum159 Bei jedem Schritt ist festzustellen, ob die Höhe des Teilbaums, in dem ein Element eingefügt wurde, zugenommen hat. /* Rückgabe der Hoehe des Knotens t oder -1, wenn NULL */ int height( AvlNode *t ) const { return t == NULL ? -1 : t->height; } Die Methode „insert“ führt das Einfügen eines Baumknoten in den Avl-Baum aus: /* * Interne Methode zum Einfuegen eines Baumknoten in einen Teilbaum. * x ist das einzufuegende Datenelement. * t ist der jeweilige Wurzelknoten. 158 159 vgl. pr43_2, http://fbim.fh-regensburg.de/~saj39122/bruhi/index.html vgl. pr43_2 241 Algorithmen und Datenstrukturen * Rueckgabe der neuen Wurzel des jeweiligen */ void insert( const Comparable & x, AvlNode * { if( t == NULL ) t = new AvlNode( x, NULL, NULL ); else if( x < t->element ) { insert( x, t->left ); if( height( t->left ) - height( t->right ) if( x < t->left->element ) rotateWithLeftChild( t ); else doubleWithLeftChild( t ); } else if( t->element < x ) { insert( x, t->right ); if( height( t->right ) - height( t->left ) if( t->right->element < x ) rotateWithRightChild( t ); else doubleWithRightChild( t ); } else ; // Duplikat, ueber gehen t->height = max( height( t->left ), height( } Teilbaums. & t ) == 2 ) == 2 ) t->right ) ) + 1; Rotationen /* * Rotation Binaerbaumknoten mit linkem Nachfolger. * Fuer AVL-Baeume ist dies eine einfache Rotation (Fall 1). * Aktualisiert Angaben zur Hoehe, dann Rueckgabe der neuen Wurzel. */ void rotateWithLeftChild( AvlNode * & k2 ) { AvlNode *k1 = k2->left; k2->left = k1->right; k1->right = k2; k2->height = max( height( k2->left ), height( k2->right ) ) + 1; k1->height = max( height( k1->left ), k2->height ) + 1; k2 = k1; } /* * Rotation Binaerbaumknoten mit rechtem Nachfolger. * Fuer AVL-Baeume ist dies eine einfache Rotation (Fall 4). * Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel. */ void rotateWithRightChild( AvlNode * & k1 ) { AvlNode *k2 = k1->right; k1->right = k2->left; k2->left = k1; k1->height = max( height( k1->left ), height( k1->right ) ) + 1; k2->height = max( height( k2->right ), k1->height ) + 1; k1 = k2; } /* * Doppelrotation der Binaerbaumknoten: : erster linker Nachfolgeknoten * mit seinem rechten Nachfolger; danach Knoten k3 mit neuem linken * Nachfolgerknoten. * Fuer AVL-Baeume ist dies eine doppelte Rotation (Fall 2) * Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel. */ void doubleWithLeftChild( AvlNode * & k3 ) { rotateWithRightChild( k3->left ); 242 Algorithmen und Datenstrukturen rotateWithLeftChild( k3 ); } /* * Doppelrotation der Binaerbaumknoten: : erster linker Nachfolgeknoten * mit seinem rechten Nachfolger; danach Knoten k3 mit neuem linken * Nachfolgerknoten. * Fuer AVL-Baeume ist dies eine doppelte Rotation (Fall 2) * Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel. */ void doubleWithRightChild( AvlNode * & k1 ) { rotateWithLeftChild( k1->right ); rotateWithRightChild( k1 ); } 2. Löschen Man kann folgende Fälle unterscheiden: (1) H = +1 bzw. -1 (Verkürzung des Teilbaums auf der Überlastseite) (2) H = 0 (Verkürzung eines Unterbaums) Der Knoten ist jetzt ungleichlastig ( H = +1 bzw. -1), bleibt jedoch im Rahmen der AVL-Eigenschaft. Der Baum hat seine Höhe nicht verändert, die Berichtigung kann abgebrochen werden. (3) H = +1 bzw. -1 (Verkürzung eines Baums auf der Unterlastseite) Die AVL-Eigenschaft ist verletzt, falls H = +2 bzw. -2. Sie wird durch eine Einfachbzw. Doppelrotation wieder hergestellt. Dadurch kann sich der Baum verkürzen, so daß Lastreduzierungen an den Vorgänger weiterzugeben sind. Es können aber auch Lastsituationen mit dem Lastfaktor 0 auftreten. Bsp.: Spezialfälle zum Lastausgleich in einem AVL-Baum k k' H+2 H+1 k' a k H+1 H+1 b c c H H+1 a b 243 Algorithmen und Datenstrukturen k k'' H+1 H+2 k' a k k'' H H a b d H H Abb.: Löschen kann in der Regel nicht mit einer einzigen Rotation abgeschlossen werden. Im schlimmsten Fall erfordern alle Knoten im Pfad der Löschstelle eine Rekonfiguration. Experimente zeigen allerdings, daß beim Einfügen je Operation mehr Rotationen durchzuführen sind als beim Löschen. Offenbar existieren beim Löschen durch den inneren Bereich des Baums mehr Knoten, die ohne weiteres eliminiert werden können. Aufgabe 1. Gegeben ist die Schlüsselfolge 7, 6, 8, 5, 9, 4. Ermittle, wie sich mit dieser Schlüsselfolge einen AVL-Baum aufbaut. Schlüssel 7 BALANCE 0 LINKS, RECHTS 4 0 5 8 0 -1 6 0 9 0 Abb.: 2. Aus dem nun vorliegenden AVL-Baum sind die Knoten mit den Schlüsselwerten 9 und 8 zu löschen. Gib an, welche Gestalt der AVL-Baum jeweils annimmt. 244 Algorithmen und Datenstrukturen Schlüssel 5 BALANCE 1 LINKS, RECHTS 7 4 0 -1 6 0 Abb.: 245 Algorithmen und Datenstrukturen 4.3.3 Splay-Bäume Splay-Bäume160 sind selbstanordnende binäre Suchbäume. Eine Anfrage im SplayBaum zieht immer eine weitere Operation auf sich, das Splaying. Dabei wird der Baum so arrangiert, dass das aktuelle Element an die Wurzel platziert wird. Das wird durch Baumrotationen erreicht, die vom AVL-Baum her bekannt sind. Ein Nachteil ist, dass der Baum komplett unbalanciert sein kann, die amortisierte Analyse zeigt jedoch O(log n) Zeit für Einfüge-, Such- und Löschoperationen. Zugrundeliegende Idee161 Nachdem auf einen Baumknoten zugegriffen wurde, wird dieser Knoten über eine Reihe von AVL-Rotationen zur Wurzel. Bis zu einem gewissen Grade führt das zur Ausbalancierung. Bsp.: 1. y x x C A A y B 2. B e C e d F e d F d F c E c E b A a a a D b A b c C B B 160 161 E C C http://en.wikipedia.org/wiki/Splay_tree http://techunix.technion.ac.il/~itai/ 246 D A B D Algorithmen und Datenstrukturen e a a F c c e d d A B F b A b B E E C C D D Splaying-Operationen Der Knoten „x“ im Splay-Baum bewegt sich über einfache und doppelte Rotationen zur Wurzel. Man unerscheidet folgende Fälle: 1. (zig): x ist ein Kind der Wurzel von einem Splay-Baum, einfache Rotation 2. (zig-zig): x hat den Großvater g(x) und den Vater p(x), x und p(x) sind jeweils linke (bzw. rechte) Kinder ihres Vaters. g(x)=p(y) g(x) bzw. y = p(x) p(x) D A x x C A B B C x y A z B C D 247 D Algorithmen und Datenstrukturen 3. (zig-zag): x hat Großvater g(x) und Vater p(x), x ist linkes (rechtes) Kind von p(x), p(x) ist rechtes (linkes) Kind von g(x) z = g(x) x y=p(x) y z D x A B C A B C 4. zag, zag-zag, zag-zig: Analog zu den obengenannten, einfach spiegelverkehrt. Implementierung162 // // // // // // // // // // // // // // // SplayTree class CONSTRUCTION: with no parameters ******************PUBLIC OPERATIONS********************* void insert( x ) --> Insert x void remove( x ) --> Remove x bool contains( x ) --> Return true if x is present Comparable findMin( ) --> Return smallest item Comparable findMax( ) --> Return largest item bool isEmpty( ) --> Return true if empty; else false void makeEmpty( ) --> Remove all items void printTree( ) --> Print tree in sorted order ******************ERRORS******************************** Throws UnderflowException as warranted template <typename Comparable> class SplayTree { public: // Konstruktoren SplayTree( ) { nullNode = new BinaryNode; nullNode->left = nullNode->right = nullNode; root = nullNode; } SplayTree( const SplayTree & rhs ) { nullNode = new BinaryNode; nullNode->left = nullNode->right = nullNode; root = nullNode; *this = rhs; } // Destruktor ~SplayTree( ) { makeEmpty( ); delete nullNode; } /* * Find the smallest item in the tree. 162 pr43_3 248 D Algorithmen und Datenstrukturen * Not the most efficient implementation (uses two passes), but has * correct amortized behavior. * A good alternative is to first ca ll find with parameter * smaller than any item in the tree, then call findMin. * Return the smallest item or throw UnderflowException if empty. */ const Comparable & findMin( ) { if( isEmpty( ) ) throw UnderflowException( ); BinaryNode *ptr = root; while( ptr->left != nullNode ) ptr = ptr->left; splay( ptr->element, root ); return ptr->element; } /* * Find the largest item in the tree. * Not the most efficient implementation (uses two passes), but has * correct amortized behavior. * A good alternative is to first call find with parameter * larger than any item in the tree, then call findMax. * Return the largest item or throw UnderflowException if empty. */ const Comparable & findMax( ) { if( isEmpty( ) ) throw UnderflowException( ); BinaryNode *ptr = root; while( ptr->right != nullNode ) ptr = ptr->right; splay( ptr->element, root ); return ptr->element; } bool contains( const Comparable & x ) { if( isEmpty( ) ) return false; splay( x, root ); return root->element == x; } bool isEmpty( ) const { return root == nullNode; } void printTree( ) const { if( isEmpty( ) ) cout << "Empty tree" << endl; else printTree( root ); } void makeEmpty( ) { while( !isEmpty( ) ) { findMax( ); // Splay max item to root remove( root->element ); } } void insert( const Comparable & x ) { static BinaryNode *newNode = NULL; 249 Algorithmen und Datenstrukturen if( newNode == NULL ) newNode = new BinaryNode; newNode->element = x; if( root == nullNode ) { newNode->left = newNode->right = nullNode; root = newNode; } else { splay( x, root ); if( x < root->element ) { newNode->left = root->left; newNode->right = root; root->left = nullNode; root = newNode; } else if( root->element < x ) { newNode->right = root->right; newNode->left = root; root->right = nullNode; root = newNode; } else return; } newNode = NULL; // So next insert will call new } void remove( const Comparable & x ) { BinaryNode *newTree; // If x is found, it will be at the root splay( x, root ); if( root->element != x ) return; // Item not found; do nothing if( root->left == nullNode ) newTree = root->right; else { // Find the maximum in the left subtree // Splay it to the root; and then attach right child newTree = root->left; splay( x, newTree ); newTree->right = root->right; } delete root; root = newTree; } const SplayTree & operator=( const SplayTree & rhs ) { if( this != &rhs ) { makeEmpty( ); root = clone( rhs.root ); } return *this; } // Binaerbaumknoten private: struct BinaryNode { Comparable element; BinaryNode *left; 250 Algorithmen und Datenstrukturen BinaryNode *right; // Konstruktor BinaryNode( ) : left( NULL ), right( NULL ) { } BinaryNode( const Comparable & theElement, BinaryNode *lt, BinaryNode *rt ) : element( theElement ), left( lt ), right( rt ) { } }; // Wurzel BinaryNode *root; BinaryNode *nullNode; // Sentinel /* * Internal method to reclaim internal nodes in subtree t. * WARNING: This is prone to running out of stack space. */ void reclaimMemory( BinaryNode * t ) { if( t != t->left ) { reclaimMemory( t->left ); reclaimMemory( t->right ); delete t; } } /* * Internal method to print a subtree t in sorted order. */ void printTree( BinaryNode *t ) const { if( t != t->left ) { printTree( t->left ); cout << t->element << endl; printTree( t->right ); } } /* * Internal method to clone subtree. */ BinaryNode * clone( BinaryNode * t ) const { if( t == t->left ) // Cannot test against nullNode!!! return nullNode; else return new BinaryNode( t->element, clone( t->left ), clone( t->right ) ); } // Tree manipulations void rotateWithLeftChild( BinaryNode * & k2 ) { BinaryNode *k1 = k2->left; k2->left = k1->right; k1->right = k2; k2 = k1; } void rotateWithRightChild( BinaryNode * & k1 ) { BinaryNode *k2 = k1->right; k1->right = k2->left; k2->left = k1; k1 = k2; } /* * Internal method to perform a top-down splay. * The last accessed node becomes the new root. * This method may be overridden to use a different * splaying algorithm, however, the splay tree code * depends on the accessed item going to the root. 251 Algorithmen und Datenstrukturen * x is the target item to splay around. * t is the root of the subtree to splay. */ void splay( const Comparable & x, BinaryNode * & t ) { BinaryNode *leftTreeMax, *rightTreeMin; static BinaryNode header; header.left = header.right = nullNode; leftTreeMax = rightTreeMin = &header; nullNode->element = x; // Guarantee a match for( ; ; ) if( x < t->element ) { if( x < t->left->element ) rotateWithLeftChild( t ); if( t->left == nullNode ) break; // Link Right rightTreeMin->left = t; rightTreeMin = t; t = t->left; } else if( t->element < x ) { if( t->right->element < x ) rotateWithRightChild( t ); if( t->right == nullNode ) break; // Link Left leftTreeMax->right = t; leftTreeMax = t; t = t->right; } else break; leftTreeMax->right = t->left; rightTreeMin->left = t->right; t->left = header.right; t->right = header.left; } }; 252 Algorithmen und Datenstrukturen 4.3.4 Rot-Schwarz-Bäume Zum Ausschluß des ungünstigsten Falls bei binären Suchbäumen ist eine gewisse Flexibilität in den verwendeten Datenstrukturen nötig. Das kann bspw. durch Aufnahme von mehr als einem Schlüssel in Baumknoten erreicht werden. So soll es 3-Knoten bzw. 4-Knoten geben, die 2 bzw. 3 Schlüssel enthalten können: - Ein 3-Knoten besitzt 3 von ihm ausgehende Verkettungen -- eine für alle Datensätze mit Schlüsseln, die kleiner sind als seine beiden Schlüssel -- eine für alle Datensätze, die zwischen den beiden Schlüsseln liegen -- eine für alle Datensätze mit Schlüsseln, die größer sind als seine beiden Schlüssel. - Ein 4-Knoten besitzt vier von ihm ausgehende Verkettungen, nämlich eine Verkettung für jedes der Intervalle, die durch seine drei Schlüssel definiert werden. Es ist möglich 2-3-4-Bäume als gewöhnliche binäre Bäume (mit nur zwei Knoten) darzustellen, wobei nur ein zusätzliches Bit je Knoten verwendet wird. Die Idee besteht darin, 3-Knoten und 4-Knoten als kleine binäre Bäume darzustellen, die durch „rote“ Verkettungen miteinander verbunden sind, im Gegensatz zu den schwarzen Verkettungen, die den 2-3-4-Baum zusammenhalten: oder 4-Knoten werden als 2-Knoten dargestellt, die mittels einer roten Verkettung verbunden sind. 3-Knoten werden als 2–Knoten dargestellt, die mit einer roten Markierung verbunden sind. Abb.: Rot-schwarze Darstellung von Bäumen Zu jedem 2-3-4-Baum gibt es viele ihm entsprechende Rot-Schwarz-Bäume. Diese Bäume haben eine Reihe von Eigenschaften, die sich unmittelbar aus ihrer Definition ergeben, z.B.: - Alle Pfade von der Wurzel zu einem Blatt haben dieselbe Anzahl von schwarzen Kanten. Dabei werden nur die Kanten zwischen inneren Knoten gezählt. - Längs eines beliebigen Pfads treten niemals zwei rote Verkettungen nacheinander auf.- Rot-Schwarz-Bäume erlauben es, AVL-Bäume, perfekt ausgeglichene Bäume und viele andere Klassen binärer Bäume einheitlich zu repräsentieren und zu implementieren. 253 Algorithmen und Datenstrukturen Eine Variante zu Rot-Schwarz-Bäumen163 Definition. Ein Rot-Schwarz-Baum ist ein Binärbaum mit folgenden Farbeigenschaften: 1. Jeder Knoten ist entweder rot oder schwarz gefärbt. 2. der Wurzelknoten ist schwarz gefärbt. 3. Falls ein Knoten rot gefärbt ist, müssen seine Nachfolger schwarz gefärbt sein. 4. Jeder Pfad von einem Knoten zu einer „Null-Referenz“ muß die gleiche Anzahl von schwarzen Knoten enthalten. Höhe. Eine Folgerung dieser Farbregeln ist: Die Höhe eines Rot-Schwarz-Baums ist etwa 2 log( N 1) . Suchen ist garantiert unter logN erfolgreich. Aufgabe: Ermittle164, welche Gestalt jeweils ein nach den vorliegenden Farbregeln erstellte Rot-Schwarz-Baum beim einfügen folgenden Schlüssel „10 85 15 70 20 60 30 50 65 80 90 40 5 55“ annimmt 10 10 85 10 85 15 15 10 85 70 15 10 85 70 20 15 10 70 20 163 164 vgl. pr43_4 vgl. http://fbim.fh-regensburg.de/~saj39122/gikasch/start.html 254 85 Algorithmen und Datenstrukturen 60 15 10 70 20 85 60 30 15 10 70 30 85 20 60 50 30 15 10 70 20 60 85 50 65 30 15 10 70 20 60 85 50 65 80 30 15 10 70 20 60 50 85 65 255 80 Algorithmen und Datenstrukturen 90 30 15 10 70 20 60 85 50 65 80 90 40 30 15 10 70 20 60 85 50 65 80 90 40 5 30 15 10 70 20 60 5 85 50 65 80 90 40 55 30 15 10 70 20 60 5 50 40 85 65 55 9 85 80 90 Die Abbildungen zeigen, daß im durchschnitt der Rot-Schwarz-Baum ungefähr die Tiefe eines AVLBaums besitzt. Der Vorteil von Rot-Schwarz-Bäumen ist der geringere Overhead zur Ausführung von Einfügevorgängen und die geringere Anzahl von Rotationen. 256 Algorithmen und Datenstrukturen „Top-Down“ Rot-Schwarz-Bäume. Kann auf dem Weg nach unten festgestellt werden, daß ein Knoten X zwei rote Nachfolgeknoten hat, dann wird X rot und die beiden „Kinder“ schwarz: X c1 X c2 c1 c2 Das führt zu einem Verstoß gegen Bedingung 3, falls der Vorgänger von X auch rot ist. In diesem Fall können aber geeignete Rotationen herangezogen werden: G P P S X C X A G A B B S C G P X S X P C A A B1 G B1 B2 B2 S C Der folgende Fall kann nicht eintreten: Der Nachbar vom Elternknoten ist rot gefärbt. Auf dem Weg nach unten muß der Knoten mit zwei roten Nachfolgern einen schwarzen Großvaterknoten haben. Methoden zur Ausführung der Rotation165. /* * * * * * Interne Routine, die eine doppelte bzw. einfache Rotation ausfuehrt Da das Ergebnis an "parent" geheftet wird, gibt es vier Faelle. Aufruf durch handleReorient() "item" ist das Datenelement in handleReorient(). "parent" ist "parent" von der wurzel des rotierenden Teilbaums. * Rueckgabe: Wurzel des rotierenden Teilbaums. */ RedBlackNode * rotate( const Comparable & item, RedBlackNode *theParent ) { if( item < theParent->element ) { item < theParent->left->element ? rotateWithLeftChild( theParent->left ) : // LL rotateWithRightChild( theParent->left ) ; // LR return theParent->left; } else { item < theParent->right->element ? 165 pr43_4 257 Algorithmen und Datenstrukturen rotateWithLeftChild( theParent->right ) : rotateWithRightChild( theParent->right ); return theParent->right; // RL // RR } } // Rotation Binaerbaumknoten mit linkem Nachfolger. void rotateWithLeftChild( RedBlackNode * & k2 ) { RedBlackNode *k1 = k2->left; k2->left = k1->right; k1->right = k2; k2 = k1; } // Rotation Binaerbaumknoten mit rechtem Nachfolger void rotateWithRightChild( RedBlackNode * & k1 ) { RedBlackNode *k2 = k1->right; k1->right = k2->left; k2->left = k1; k1 = k2; } Implementierung166 Sie wird dadurch erschwert, daß einige Teilbäume (z.B. der rechte Teilbaum des Knoten 10 im vorliegenden Bsp.) leer sein können, und die Wurzel des Baums (, da ohne Vorgänger, ) einer speziellen Behandlung bedarf. Aus diesem Grund werden hier „Sentinel“-Knoten verwendet: - eine für die Wurzel. Dieser Knoten speichert den Schlüssel und einen rechten Link zu dem realen Knoten - einen Nullknoten (nullNode), der eine Null-Referenz anzeigt. struct RotSchwarzknoten private: struct RedBlackNode { Comparable element; RedBlackNode *left; RedBlackNode *right; int color; // Konstruktor RedBlackNode( const Comparable & theElement = Comparable( ), RedBlackNode *lt = NULL, RedBlackNode *rt = NULL, int c = BLACK ) : element( theElement ), left( lt ), right( rt ), color( c ) { } }; Das Gerüst der Klasse RotSchwarzBaum und Initialisierungsroutinen // // Konstruktion: mit einem "negative infinity sentinel" // template <typename Comparable> class RedBlackTree { public: /* * Konsruktor * negInf ist unter allen anderen ein kleinstmöglicher Wert */ 166 vgl. pr43_4 258 Algorithmen und Datenstrukturen explicit RedBlackTree( const Comparable & negInf ) { nullNode = new RedBlackNode; nullNode->left = nullNode->right = nullNode; header = new RedBlackNode( negInf ); header->left = header->right = nullNode; } // RedBlackTree( const RedBlackTree & rhs ) { nullNode = new RedBlackNode; nullNode->left = nullNode->right = nullNode; header = new RedBlackNode( rhs.header->element ); header->left = header->right = nullNode; *this = rhs; } // Destruktor ~RedBlackTree( ) { makeEmpty( ); delete nullNode; delete header; } // const Comparable & findMin( ) const { if( isEmpty( ) ) throw UnderflowException( ); RedBlackNode *itr = header->right; while( itr->left != nullNode ) itr = itr->left; return itr->element; } // const Comparable & findMax( ) const { if( isEmpty( ) ) throw UnderflowException( ); RedBlackNode *itr = header->right; while( itr->right != nullNode ) itr = itr->right; return itr->element; } // bool contains( const Comparable & x ) const { nullNode->element = x; RedBlackNode *curr = header->right; for( ; ; ) { if( x < curr->element ) curr = curr->left; else if( curr->element < x ) curr = curr->right; else return curr != nullNode; } } // bool isEmpty( ) const 259 Algorithmen und Datenstrukturen { return header->right == nullNode; } … }; Die Methode „insert“ private: // Fuer insert und zugehoerige unterstuetzende Routinen RedBlackNode *current; RedBlackNode *parent; RedBlackNode *grand; RedBlackNode *great; public: /* *Einfügen von Element x in den Baum. Tue nichts, falls x schon da ist. */ void insert( const Comparable & x ) { current = parent = grand = header; nullNode->element = x; while( current->element != x ) { great = grand; grand = parent; parent = current; current = x < current->element ? current->left : current->right; // Check if two red children; fix if so if( current->left->color == RED && current->right->color == RED ) handleReorient( x ); } // Insertion fails if already present if( current != nullNode ) return; current = new RedBlackNode( x, nullNode, nullNode ); // Attach to parent if( x < parent->element ) parent->left = current; else parent->right = current; handleReorient( x ); } private: /* * Interne Routine, die waehrend eines Einfuegevorgangs aufgerufen wird * Falls ein Knoten zwei rote Kinder hat, fuehre Tausch der Farben aus * und rotiere. * item enthaelt das einzufuegende Datenelement. */ void handleReorient( const Comparable & item ) { // Do the color flip current->color = RED; current->left->color = BLACK; current->right->color = BLACK; if( parent->color == RED ) // Rotation erforderlich { grand->color = RED; if( item < grand->element != item < parent->element ) parent = rotate( item, grand ); // Start Doppelrotation current = rotate( item, great ); current->color = BLACK; } header->right->color = BLACK; // mache Wurzel schwarz } 260 Algorithmen und Datenstrukturen Bsp.: Java-Applet167 zur Darstellung eines Rot-Schwarz-Baums Abb.: 167 vgl. pr43222 261 Algorithmen und Datenstrukturen 4.4 Bayer-Bäume 4.4.1 Grundlagen und Definitionen 4.4.1.1 Ausgeglichene T-äre Suchbäume (Bayer-Bäume) Bayer-Bäume sind für die Verwaltung der Schlüssel zu Datensätzen in umfangreichen Dateien vorgesehen. Der binäre Baum ist für die Verwaltung solcher Schlüssel nicht geeignet, da er nur jeweils einen Knoten mit einem einzigen Datensatz adressiert. Die Daten (Datensätze) stehen blockweise zusammengefaßt auf Massenspeichern, der Binärbaum müßte Knoten für Knoten auf einen solchen Block abgebildet werden. Jeder Zugriff auf den Knoten des Baums würde ein Zugriff auf den Massenspeicher bewirken. Da ein Plattenzugriff relativ zeitaufwendig ist, hätte man die Vorteile der Suchbäume wieder verloren. In einen Knoten ist daher nicht nur ein Datum aufzunehmen, sondern maximal (T - 1) Daten. Ein solcher Knoten hat T Nachfolger. Die Eigenschaften der knotenorientierten "T-ären" Intervallbäume sind : - Jeder Knoten enthält max. (T - 1) Daten - Die Daten in einem Knoten sind aufsteigend sortiert - Ein Knoten enthält maximal T Teilbäume - Die Daten (Schlüssel) der linken Teilbäume sind kleiner als das Datum der Wurzel. - Die Daten der rechten Teilbäume sind größer als das Datum der Wurzel. - Alle Teilbäume sind T-äre Suchbäume. Durch Zusammenfassen mehrerer Knoten kommt man so vom binären zum "T-ären" Suchbaum, z.B.: 8 4 12 2 1 3 5 2 3 11 9 7 4 1 14 10 6 5 6 8 Abb.: 262 15 12 9 7 13 10 11 13 14 15 Algorithmen und Datenstrukturen T-äre Bäume haben aber noch einen schwerwiegenden Nachteil. Sie können leicht zu entarteten Bäumen degenerieren. Bsp.: Ein entarteter 5-ärer Baum enthält durch Eingabe (Einfügen) der Elemente 1 bis 16 in aufsteigender Folge folgende Gestalt: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Abb.: Durch Einfügen und Löschen können sich beliebig unsymetrische Strukturen herausbilden. Algorithmen zum Ausgleichen, vegleichbar mit den AVL-Rotationen sind jedoch nicht bekannt. Zunehmend an Bedeutung gewinnt ein höhengleicher Baum (B-Baum), der von R. Bayer eingeführt wurde. Höhengleichheit kann erreicht werden, wenn folgende Eigenschaften eingehalten werden: - Ein Knoten enthält höchstens 2 M Schlüssel ( 2 M 1 -ärer Baum). Jeder Bayer-Baum (B-Baum) besitzt eine Klasse, im vorliegenden Fall die Klasse M. M heißt Ordnung des Baums168. - Jeder Knoten (Ausname: Wurzel) enthält mindestens M Schlüssel, höchstens 2 M Schlüssel. - Jeder Nichtblattknoten hat daher zwischen ( M 1 ) und( 2 M 1 ) Nachfolger. - Alle Blattknoten liegen auf der gleichen Stufe. - B-Bäume sind von (a,b)-Bäumen abgeleitet. 168 In einigen Büchern wird als Ordnung des Baums der Verzweigungsgrad bezeichnet, hier also 2M+1 263 Algorithmen und Datenstrukturen 4.4.1.2 (a,b)-Bäume Ein (a,b)-Baum ist ein (externer) Suchbaum, für den gilt: - Alle Blätter haben die gleiche Tiefe - Schlüssel sind nur in den Blättern gespeichert169. - Für alle Knoten k (außer Wurzeln und Blättern) gilt a Anzahl _ der _ Kinder (k ) b - b 2 a 1 - Für alle inneren Knoten gilt: Hat k l Kinder, so sind in k l-1 Werte k1, ..., ki-1 gespeichert und es gilt: k i 1 key( w) k i für alle Knoten w im i-ten Unterraum von k . n - Falls B ein (a.b)-Baum mit n Blättern ist, dann gilt log b ( n) Höhe ( B ) 1 log a ( ) . Der rechte 2 Teile der Ungleichung resultiert daraus, daß bei Bäumen mit Tiefe größer als 1 die Wurzel wenigstens zwei Kinder hat, eines der Kinder hat maximal n/2 Blätter und minimalen Verzweigungsgrad a. - B-Bäume sind Spezialfälle von (a.b)-Bäumen mit b 2 a 1 Bsp.: 21, 39 7,15 32 1,4 1 9 4 7 9 15 17 17 21 52, 62 24 24 32 35 35 39 43,47 43 47 52 53,56 53 56 Abb.: (2,3) - Baum 2 1 1 3.4.5 2 3 4 5 Abb.: (2,4) - Baum 169 In dieser Sicht unterscheiden sich (a,b)-Bäume von den hier angesprochenen B-Bäumen. 264 62 67 67 71 Algorithmen und Datenstrukturen 4.4.2 Darstellung von Bayer-Bäumen +-----------------------------------+ | | | Z0S1Z1S2Z2S3 ........... ZN-1SNZN | | | +-----------------------------------+ Zl ... Zeiger Sl ... Schluessel Alle Schlüssel in einem Teilbaum, auf den durch Z l-1 verwiesen wird, sind kleiner als Sl. Alle Schlüssel in einem Unterbaum, auf den durch Z l verwiesen wird, sind größer als Sl. In einem B-Baum der Höhe H befinden sich daher zwischen N min 2 ( M 1) H 1 1 und N max 2 ( M 1) H 1 Schlüssel. Neue Schlüssel werden stets in den Blättern zugefügt. Aufgabe: Gegeben ist die folgende Schlüsselfolge: „1, 7, 6, 2, 11, 4, 8, 13, 10, 5, 19, 9, 18, 24, 3, 12, 14, 20, 21, 17“. Bestimme die zugehörigen Strukturen eines 5-ären Baumes. 1) 1, 7, 6, 2 1 6 2 7 2) 11 6 1 2 7 11 7 8 3) 4, 8, 13 6 1 2 4 11 13 11 13 4) 10 6 1 2 4 10 7 8 5) 5, 19, 9, 18 265 Algorithmen und Datenstrukturen 6 1 2 4 5 10 7 8 9 11 13 18 19 11 13 19 24 13 14 19 20 6) 24 6 1 2 4 5 10 7 8 18 9 7) 3, 12, 14, 20, 21 1 2 4 5 3 6 7 8 10 9 18 11 12 21 24 Abb.: Zeiger ZI und Schlüssel SI eines jeden Knoten sind folgendermaßen angeordnet: +----------------------------- ------+ | | | Z0S0Z1S1Z2S2 ........... ZN-1SN-1ZN | | | +-------------------------------------+ Das führt zu der folgenden Beschreibung des B-Baums der Ordnung 2 (mit 5 Kettengliedern je Knoten). // // B-Baum mit bis zu MAERER Verkettungen (mit Knoten die bis zu MAERER Verkettungen enthalten) #include <iostream.h> #include <iomanip.h> #include <ctype.h> #define MAERER 5 // Anzahl Verkettungen im B-Baum: // MAERER Verkettungsfelder je Knoten typedef int dtype; enum status {unvollstaendigesEinfuegen, erfolgreich, doppelterSchluessel, Unterlauf, nichtGefunden}; struct knoten { int n; // Anzahl der Elemente, die in einem Knoten // gespeichert sind (n < MAERER) dtype s[MAERER-1]; // Datenelemente (aktuell sind n) knoten *z[MAERER]; // Zeiger auf andere Knoten (aktuell sind n+1) }; // Logische Ordnung: // z[0], s[0], z[1], s[1], ..., z[n-1], s[n-1], z[n] 266 Algorithmen und Datenstrukturen class Bbaum { private: knoten *wurzel; status einf(knoten *w, dtype x, dtype &y, knoten* &q); void ausg(const knoten* w, int nLeer)const; int knotenSuche(dtype x, const dtype *a, int n)const; status loe(knoten *w, dtype x); public: Bbaum(): wurzel(NULL){} void einfuegen(dtype x); void gibAus()const{cout << "Dateninhalt:\n"; ausg(wurzel, 0);} void loeschen(dtype x); void zeigeSuche(dtype x)const; }; 4.4.3 Suchen eines Schlüssels Gegeben ist der folgende Ausschnitt eines B-Baums mit N Schlüsseln: +----------------------------- ------+ | | | Z0S0Z1S1Z2S2 ........... ZN-1SN-1ZN | | | +-------------------------------------+ S0<S1<S2<.....<SN-1 Handelt es sich beim Knoten um ein Blatt, dann ist der Wert eines jeden Zeigers Z I im Knoten NULL. Falls der Knoten kein Blatt ist, verweisen einige der (N+1) Zeiger auf andere Knoten (Kinder des aktuellen Knoten). I > 0: Alle Schlüssel im betreffenden Kind-Knoten, auf den ZI zeigt, sind größer als SI-1 I < N: Alle Schlüssel im betreffenden Kind, auf das Z I zeigt, sind kleiner als SN. Hat einer der angegebenen Zeiger den Wert „null“, dann existiert in dem vorliegenden Baum kein Teilbaum, der diesen Schlüssel enthält (d.h. die Suche ist beendet). int Bbaum::knotenSuche(dtype x, const dtype *a, int n)const { int i=0; while (i < n && x > a[i]) i++; return i; } void Bbaum::zeigeSuche(dtype x)const { cout << "Suchpfad:\n"; int i, j, n; knoten *w = wurzel; while (w) { n = w->n; for (j = 0; j < w->n; j++) cout << " " << w->s[j]; cout << endl; i = knotenSuche(x, w->s, n); if (i < n && x == w->s[i]) { cout << "Schluessel " << x << " wurde in Position " << i << " vom zuletzt angegebenen Knoten gefunden.\n"; return; } w = w->z[i]; } 267 Algorithmen und Datenstrukturen cout << "Schluessel " << x << " wurde nicht gefunden.\n"; } 4.4.4 Einfügen Bsp.: Der Einfügevorgang in einem Bayer-Baum der Klasse 2 1) Aufnahme der Schlüssel 1, 2, 3, 4 in den Wurzelknoten 1 3 2 4 2) Zusätzlich wird der Schlüssel mit dem Wert 5 eingefügt 1 3 2 4 5 Normalerweise würde jetzt rechts von "4" ein neuer Knotem erzeugt. Das würde aber zu einem Knotenüberlauf führen. Nach dieser Erweiterung enthält der Knoten eine ungerade Zahl von Elementen ( 2 M 1 ). Dieser große Knoten kann in 2 Söhne zerlegt werden, nur das mittlere Element verbleibt im Vaterknoten. Die neuen Knoten genügen wieder den B-Baum-Eigenschaften und könnem weitere Daten aufnehmen. 3 1 2 4 5 Abb.: Beschreibung des Algorithmus für das Einfügen Ein neues Element wird grundsätzlich in einen Blattknoten eingefügt. Ist der Knoten mit 2 M Schlüsseln voll, so läuft bei der Aufnahme eines weiteren Schlüssels der Knoten über. +---------------------------------------------+ | | | ........... SX-1ZX-1SXZX ..... | | | +---------------------------------------------+ +----------------------------------------------+ | | | Z0S1Z1 .... ZM-1SMZMSM+1 ....... Z2MS2M+1 | | Überlauf | +----------------------------------------------+ Abb.: Der Knoten wird geteilt: Die vorderen Schlüssel verbleiben im alten Knoten, der Schlüssel mit der Nummer M+1 gelangt als Trennschlüssel in den Vorgängerknoten. Die M Schlüssel mit den Nummern M+2 bis 2 M 1 kommen in den neuen Knoten. +-----------------------------+ 268 Algorithmen und Datenstrukturen | ....SX-1ZX-1SM+1ZYSXZX .... | +-----------------------------+ +------------------+ |Z0S1 .... ZM-1SMZM| +------------------+ +-----------------------------+ |ZM+1SM+2ZM+2 ..... S2M+1Z2M+1| +-----------------------------+ Abb.: Die geteilten Knoten enthalten genau M Elemente. Das Einfügen eines Elements in der vorangehenden Seite kann diese ebenfalls zum Überlaufen bringen und somit die Aufteilung fortsetzen. Der B-Baum wächst demnach von den Blättern bis zur Wurzel. a) Methoden zum Einfügen der Klasse Bbaum Zwei Funktionen (Methoden der Klasse Bbaum) „einfuegen“ und „einf“ teilen sich die Arbeit. Der Aufruf dieser Funktionen erfolgt bspw. über b.einfuegen(x)170; bzw. in der Methode einfuegen() über status code = einf(wurzel, x, xNeu, zNeu);. xNeu, zNeu sind lokale Variable in einfuegen(). Die private Methode einf() fiefert einen „code“ zurück: unvollstaendigesEinfuegen , falls (insgesamt gesehen) der Einfügevorgang noch nicht vollständig abgeschlossen wurde. erfolgreich , falls das Einfügen des Schlüssels x erfolgreich war doppelterSchluessel , falls x bereits im Bayer-Baum ist. In den „code“-Fällen „erfolgreich“ bzw. „doppelterSchluessel“ haben xNeu und zNeu keine Bedeutung. Im Fall „unvollstaendigesEinfuegen“ sind noch weitere Vorkehrungen zu treffen: - Falls überhaupt noch kein Bayer-Baum vorliegt (wurzel == NULL) ist ein neuer Knoten zu erzeugen und der Wurzel zuzuordnen. In diesem Fall enthält der Wurzelknoten dann einen Schlüssel. - In der Regel tritt unvollständiges Einfügen auf, wenn der Knoten, in den der Schlüssel x eingefügt werden soll, keinen Platz mehr besitzt. Ein Teil der Schlüssel kann im alten Knoten verbleiben, der andere Teil muß in einen neuen Knoten untergebracht werden. void Bbaum::einfuegen(dtype x) { knoten *zNeu; dtype xNeu; status code = einf(wurzel, x, xNeu, zNeu); if (code == doppelterSchluessel) cout << "Doppelte Schluessel werden ignoriert.\n"; if (code == unvollstaendigesEinfuegen) { knoten *wurzel0 = wurzel; wurzel = new knoten; wurzel->n = 1; wurzel->s[0] = xNeu; wurzel->z[0] = wurzel0; wurzel->z[1] = zNeu; } } status Bbaum::einf(knoten *w, dtype x, dtype &y, knoten* &q) { // Fuege x in den aktuellen Knoten, adressiert durch *this ein. // Falls nicht voll erfolgreich, sind noch Ganzzahl y und Zeiger q // einzufuegen. // Rueckgabewert: // erfolgreich, doppelterSchluessel oder unvollstaendigesEinfuegen. knoten *zNeu, *zFinal; 170 „b“ ist eine Instanz von Bbaum 269 Algorithmen und Datenstrukturen int i, j, n; dtype xNeu, sFinal; status code; if (w == NULL){q = NULL; y = x; return unvollstaendigesEinfuegen;} n = w->n; i = knotenSuche(x, w->s, n); if (i < n && x == w->s[i]) return doppelterSchluessel; code = einf(w->z[i], x, xNeu, zNeu); if (code != unvollstaendigesEinfuegen) return code; // Einfuegen im untergeordneten Baum war nicht voll erfolgreich; // Versuch zum Einfuegen in xNeu und zNeu vom aktuellem Knoten: if (n < MAERER - 1) { i = knotenSuche(xNeu, w->s, n); for (j=n; j>i; j--) { w->s[j] = w->s[j-1]; w->z[j+1] = w->z[j]; } w->s[i] = xNeu; w->z[i+1] = zNeu; ++w->n; return erfolgreich; } // Der aktuelle Knoten ist voll (n == MAERER - 1) und muss gesplittet // werden. // Reiche das Element s[h] in der Mitte der betrachteten Folge zurueck // ueber Parameter y, damit es aufwaerts im Baum plaziert werden kann. // Reiche auch einen Zeiger zum neu erzeugten Knoten zurueck (als // Verweis) ueber den Parameter q: if (i == MAERER - 1) {sFinal = xNeu; zFinal = zNeu;} else { sFinal = w->s[MAERER-2]; zFinal = w->z[MAERER-1]; for (j=MAERER-2; j>i; j--) { w->s[j] = w->s[j-1]; w->z[j+1] = w->z[j]; } w->s[i] = xNeu; w->z[i+1] = zNeu; } int h = (MAERER - 1)/2; y = w->s[h]; // y und q werden zur naechst hoeheren Stufe q = new knoten; // im Baum weitergereicht // Die Werte z[0],s[0],z[1],...,s[h-1],z[h] gehoeren zum linken Teil von // s[h] and werden gehalten in *w: w->n = h; // z[h+1],s[h+1],z[h+2],...,s[MAERER-2],z[MAERER-1],sFinal,zFinal // gehoeren zu dem rechten Teil von s[h] und werden nach *q gebracht: q->n = MAERER - 1 - h; for (j=0; j < q->n; j++) { q->z[j] = w->z[j + h + 1]; q->s[j] = (j < q->n - 1 ? w->s[j + h + 1] : sFinal); } q->z[q->n] = zFinal; return unvollstaendigesEinfuegen; } 270 Algorithmen und Datenstrukturen 4.4.5 Löschen Grundsätzlich ist zu unterscheiden: 1. Das zu löschende Element ist in einem Blattknoten 2. Das Element ist nicht in einem Blattknoten enthalten. In diesem Fall ist es durch eines der benachbarten Elemente zu ersetzen. Entlang des rechts stehenden Zeigers Z ist hier zum Blattknoten hinabzusteigen und das zu löschende Element durch das äußere linke Element von Z zu ersetzen. Auf jeden Fall darf die Anzahl der Schlüssel im Knoten nicht kleiner als M werden. Ausgleichen Die Unterlauf-Gegebenheit (Anzahl der Schlüssel ist kleiner als M) ist durch Ausleihen oder "Angliedern" eines Elements von einem der benachbarten Knoten abzustellen. Zusammenlegen Ist kein Element zum Angliedern übrig (, der benachbarte Knoten hat bereits die minimale Größe erreicht), dann enthalten die beiden Knoten je 2 M 1 Elemente. Beide Knoten können daher zusammengelegt werden. Das mittlere Element ist dazu aus den dem Knoten vorausgehenden Knoten zu entnehmen und der NachbarKnoten ist ganz zu entfernen. Das Herausnehmen des mittleren Schlüssels in der vorausgehenden Seite kann nochmals die Größe unter die erlaubte Grenze fallen lassen und gegebenenfalls auf der nächsten Stufe eine weitere Aktion hervorrufen. Bsp.: Gegeben ist ein 5-ärer B-Baum (der Ordnung 2) in folgender Gestalt: 50 30 10 20 35 40 60 42 38 44 46 56 58 65 80 70 90 95 Abb.: 1) Löschen der Schlüssel 44, 80 50 30 10 20 35 40 38 90 60 42 56 46 Abb.: 271 58 65 70 95 96 96 Algorithmen und Datenstrukturen 2) Einfügen des Schlüssels 99, Löschen des Schlüssels 70 mit Ausgleichen 50 30 40 10 20 35 38 60 42 46 56 58 95 65 90 96 99 65 90 96 99 65 90 96 99 Abb.: 3) Löschen des Schlüssels 35 mit Zusammenlegen 50 40 10 20 30 38 60 42 46 56 58 95 Abb.: 40 10 20 30 38 42 46 50 60 56 58 95 Abb.: Implementierung Löschen eines Schlüssels im Blattknoten 1. Fall: Im Blattknoten befinden sich mehr als die kleinste zulässige Anzahl von Schlüsselelementen. Der Schlüssel kann einfach entfernt werden, die rechts davon befindlichen Elemente werden einfach eine Position nach links verschoben. 2. Fall: Das Blatt enthält genau nur noch die kleinste zulässige Anzahl von Schlüsselelementen, Nachbachknoten auf der Ebene der Blattknoten enthalten mehr als die kleinste zulässige Anzahl von Schlüsselelementen. Der Schlüssel wird gelöscht, im Blatt liegt dann ein „Unterlauf“ vor. Man versucht aus den linken oder rechten Nachbarknoten ein Element zu besorgen, z.B.: Es liegt im Anschluß an einen (rekursiven) Aufruf, der in einem Blattknoten einen Schlüssel entfernt hat, folgende Situation vor: 272 Algorithmen und Datenstrukturen 20 10 12 30 15 40 25 33 34 36 46 48 Abb.: Die Entnahme geeigneter Schlüssel kann hier aus dem linken bzw. aus dem rechten Nachbarn vom betroffenen Knoten erfolgen: 20 10 12 33 15 40 25 30 34 36 46 48 Abb.: Falls vorhanden, soll immer der rechte Nachbarknoten gewählt werden. Im vorliegenden Beispiel ist das nicht möglich beim Löschen der Schlüsselwerte „46“ bzw. „48“. In solchen Fällen wird dem Linken Knoten ein Element entnommen. 3. Fall: Das Blatt enthält genau die kleinste mögliche Anzahl an Elementen, Nachbarknoten auf der Ebene der Blattknoten enthalten auch nur genau die kleinste mögliche Anzahl an Elementen. In diesem Fall müssen die betroffenen Knoten miteinander verbunden werden, z.B. liegt im Anschluß an einen Aufruf, der in einem Blattknoten einen Schlüssel entfernt hat, folgende Situation vor: 20 10 12 15 30 40 25 34 36 46 48 Die Verbindung zu einem Knoten mit zulässiger Anzahl von Schlüsselelementen kann so vollzogen werden: 273 Algorithmen und Datenstrukturen 20 10 12 40 15 25 30 34 36 46 48 Abb.: Löschen eines Schlüssels in einem inneren Knoten Solches Löschen kann aus einem Löschvorgang in einem Blattknoten resultieren. Bsp.: 15 3 1 2 4 6 5 20 10 12 18 19 60 22 30 70 80 22 70 80 Abb.: Das Löschen vom Schlüssel mit dem Wert 1 ergibt: 6 2 3 4 5 10 15 12 20 60 18 19 30 Abb.: Im übergeordneten Knoten kann es erneut zu einer Unterlauf-Gegebenheit kommen. Es ist wieder ein Verbinden bzw. Borgen mit / von Nachbarknoten erforderlich, bis man schließlich an der Wurzel angelangt ist. Im Wurzelknoten kann nur ein Element sein. Wird dieses Element in den Verknüpfungsvorgang der beiden unmittelbaren Nachfolger einbezogen, dann wird der Wurzelknoten gelöscht, die Höhe des Baums nimmt ab. Eine Löschoperation kann auch direkt in einem internen Knoten beginnen, z.B. wird im folgenden Bayer-Baum im Wurzelknoten der Schlüssel mit dem Wert „15“ gelöscht. 274 Algorithmen und Datenstrukturen 15 3 1 2 4 6 5 20 10 12 18 19 60 22 30 70 80 Abb.: Zuerst wird zum linken Nachfolger gewechselt, anschließend wird der Baum bis zum Blatt nach rechts durchlaufen. In diesem Blatt wird dann das am weitesten rechts stehenden Datum aufgesucht und mit dem zu löschenden Element im Ausgangsknoten getauscht. 12 3 1 2 4 6 5 20 10 15 18 19 60 22 30 70 80 Abb.: Der Schlüssel mit dem Wert „15“ kann jetzt nach einer bereits beschriebenen Methode gelöscht werden. 275 Algorithmen und Datenstrukturen 4.4.6 Auf Platte/ Diskette gespeicherte Datensätze Der Ausgangspunkt zu B-Bäumen war die Verwaltung der Schlüssel zu Datensätzen in umfangreichen Dateien. In der Regel will man ja nicht nur einfache Zahlen (d.h. einzelne Daten), sondern ganze Datensätze speichern. Eine größere Anzahl von Datensätzen einer solchen Datei ist aber im Arbeitsspeicher (, der ja noch Teile des Betriebssystems, das Programm etc. enthalten muß,) nicht unterzubringen. Notwendig ist die Auslagerung von einem beträchtlichen Teil der Datensätze auf einen externen Speicher. Dort sind die Datensätze in einer Datei gespeichert und in "Seiten" zusammengefaßt. Eine Seite umfaßt die im Arbeitsspeicher adressierbare Menge von Datensätzen (Umfang entspricht einem Bayer-Baumknoten). Aus Vergleichsgründen soll hier die Anzahl der aufgenommenen bzw. aufzunehmenden Datensätze die Zahl M = 2 nicht überschreiten. Es werden also mindestens 2, im Höchstfall 4 Datensätze in eine Seite aufgenommen. Allgemein gilt: Je größer M gewählt wird, umso mehr Arbeitsspeicherplatz wird benötigt, um so größer ist aber auch die Verarbeitungsleistung des Programms infolge der geringeren Anzahl der (relativ langsamen) Zugriffsoperationen auf externe Speicher. Die 1. Seite der Datei (Datenbank) enthält Informationen für die Verwaltung. Implementierung Die Verwaltung eines auf einer Datei hinterlegten Bayer-Baums übernimmt die folgende Klasse BBaum: #define MAERER 5 // Anzahl Verkettungen im Bayer-Baum Knoten enum status {unvollstaendigesEinfuegen, erfolgreich, doppelterSchluessel, Unterlauf, nichtGefunden}; typedef int dtype; // Knoten eines auf einer Datei hinterlegten Bayer-Baums. struct knoten { int n; // Anzahl der Elemente, die in einem Knoten // Knoten gespeichert sind (n < MAERER) dtype s[MAERER-1]; // Datenelemente (aktuell sind n) long z[MAERER]; // 'Zeiger' auf andere Knoten (aktuell sind n+1) }; // Logische Ordnung: // z[0], s[0], z[1], s[1], ..., z[n-1], s[n-1], z[n] // Die Klasse zum auf einer Datei hinterlegten Bayer-Baum class BBaum { private: enum {NIL=-1}; long wurzel, freieListe; knoten wurzelKnoten; fstream datei; status einf(long w, dtype x, dtype &y, long &u); void ausg(long w, int nLeer); int knotenSuche(dtype x, const dtype *a, int n)const; status loe(long w, dtype x); void leseKnoten(long w, knoten &Knoten); void schreibeKnoten(long w, const knoten &Knoten); void leseStart(); long holeKnoten(); 276 Algorithmen und Datenstrukturen void freierKnoten(long w); public: BBaum(const char *BaumDateiname); ~BBaum(); void einfuegen(dtype x); void einfuegen(const char *eingabeDateiname); void gibAus(){cout << "Dateninhalt:\n"; ausg(wurzel, 0);} void loeschen(dtype x); void zeigeSuche(dtype x); }; Konstruktoren Zur Verwaltung des Bayer-Baums in einer Datei ist besonders wichtig: - Die Wurzel wurzel (d. h. die Position des Wurzelknotens - eine Liste mit Informationen über den freien Speicherplatz in der Datei (adressiert über freieListe). Zu solchen freien Speicherplätzen kann es beim Löschen von Schlüsselelementen kommen. Zweckmäßigerweise wird dann dieser freie Speicherbereich nicht aufgefüllt, sondern in einer Liste freier Speicherbereiche eingekettet. freieListe zeigt auf das erste Element in dieser Liste. Solange das Programm läuft sind „wurzel“ und „freieListe“ Datenelemente der Klasse BBaum (in einer Datei abgelegter Bayer-Baum). Für die Belegung dieser Dateielemente wird am Dateianfang (1.Seite) Speicherplatz reserviert. Am Ende der Programmausführung werden die Werte zu „wurzel“ bzw. „freieListe“ in der Datei abgespeichert. Bsp.: Der folgende Bayer-Baum 20 10 15 60 80 10 wurzel 15 60 80 20 freieListe Abb.: Zeiger haben hier ganzzahlige Werte des Typs long (mit -1L als NIL), die für die Positionen und Bytenummern stehen. 277 Algorithmen und Datenstrukturen 4.4.7 B*-Bäume Der B*-Baum entspricht einer geketteten sequentiellen Datei von Blättern, die einen Indexteil besitzt, der selbst ein B-Baum ist. Im Indexteil werden insbesondere beim Split-Vorgang die Operationen des B-Baums eingesetzt. Hauptunterschied zu B-Bäumen: im inneren Knoten wird nur die WegweiserFunktion ausgenutzt: - innere Knoten führen nur (Si,Zi) als Einträge. - Information (Si,Di) wird in den Blattknoten abgelegt. Dabei werden alle Schlüssel mit ihren zugehörigen Daten in Sortierreihenfolge in den Blättern abgelegt. - Für einige Si ergibt sich redundante Speicherung. Die inneren Knoten bilden einen Index, der einen schnellen direkten Zugriff zu den Schlüsseln ermöglicht. - Durch Verkettung aller Blattknoten lässt sich eine effiziente sequentielle Verarbeitung erreichen . 12 . .2.5.9. 1 2 3 4 5 6 7 8 9 Abb.: B*-Baum der Klasse . 15 . 18 . 20 . 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ( 2,2,3) Definition. k, k* >0 und h* >= 0 sind ganze Zahlen. Ein B*-Baum der Klasse (k , k *, h*) ist entweder ein leerer Baum oder ein geordneter Baum, für den gilt: 1. Jeder Pfad von der Wurzel zu einem Blatt besitzt die gleiche Länge h* - 1. 2. Jeder Knoten außer der Wurzel und den Blättern hat mindestens k + 1 Söhne, die Wurzel hat mindestens 2 Söhne, außer wenn sie ein Blatt ist. 3. Jeder innere Knoten hat höchstens 2k + 1 Söhne. 4. Jeder Blattknoten mit Ausnahme der Wurzel als Blatt hat mindestens k* und höchstens 2k* Einträge Unterscheidung zwischen zwei Knotenformaten: l innere Knoten k <=b <=2k M . S1 . Z0 Blattknoten k*<=m<=2k* ......... Z1 Zb M . . S1D1 S2D2 Zp Sb . freier Platz .... SmDm freier Platz Zn M: enthält Kennung des Seitentyps sowie Zahl der aktuellen Einträge Abb. Knotenformate im B*-Baum 278 Algorithmen und Datenstrukturen Bestimmen von k bzw. k* l l M l z 2 k (l z l S ) , k l lM l z 2 (l S l z ) l l M 2 l z 2 k * (l S l D ) ; k* Höhe des B*-Baums: 1 log 2 k 1 ( l lM 2 l z 2 (l S l D ) n n ) h* 2 log k 1 ( ) für h* >= 2 2k * 2k * Minimale bzw. maximale Anzahl von Knoten. nmin 2 k * (k 1) h*2 nmax 2 k * (2k 1) h*1 Operationen. B*-Baum entspricht einer geketteten sequentiellen Datei von Blättern, die einen Indexteil besitzt, der selbst ein B-Baum ist. Im Indexteil werden insbesondere beim Split-Vorgang, die Operationen des B-Baums eingesetzt. Grundoperationen beim B*-Baum. (1) Direkte Suche: Da alle Schlüssel in den Blättern sind, kostet jede direkte Such h* Zugriffe. h* ist jedoch im Mittel kleiner als h in B-Bäumen. (2) Sequentielle Suche: Sie erfolgt nach Aufsuchen des Linksaußen der Struktur unter Ausnutzung der Verkettung der Blattseiten. Es sind zwar gegebenenfalls mehr Blätter als beim B-Baum zu verarbeiten, doch da nur h*-1 innere Knoten aufzusuchen sind, wird die sequentielle Suche effizienter ablaufen. (3) Einfügen: Von Durchführung und Leistungsverhalten dem Einfügen von BBäumen sehr ähnlich. Bei inneren Knoten wird die Spaltung analog zum B-Baum durchgeführt. Beim Split-Vorgang einer Basis-Seite muß gewährleistet sein, dass jeweils der höchste Schlüssel einer Seite als Wegweiser in den Vaterknoten kopiert werden. S2k* S1D1 .... Sk*Dk* Sk*+1Dk*+1 ... S2k*D2k* SD Sk* S2k* S1D1 … Sk*Dk* Sk*+1Dk*+1 … SD … S2k*D2k* Bsp.: In den folgenden B*-Baum soll der Schlüssel 45 (einschl. Datenteil) eingefügt werden. 279 Algorithmen und Datenstrukturen 12 28 46 67 1 5 9 12 15 19 28 33 37 41 46 53 59 67 71 83 99 41 12 28 1 5 9 12 15 19 46 67 28 33 37 Abb.: Einfügen in einen B*-Baum der Klasse 41 45 46 53 59 67 71 83 99 ( 2,2,3) (4) Löschen: Datenelemente werden immer von einem Blatt entfernt (keine komplexe Fallunterscheidung wie beim B-Baum). Weiterhin muß beim Löschen eines Schlüssels aus einem Blatt dieser Schlüssel nicht aus dem Indexteil entfernt werden, er behält seine Funktion als Wegweiser. Bsp.: Löschen der Schüssel 28, 41, 46 (einschl. der zugehörigen Datenteile) im zuletzt angegebenen B*-Baum der Klasse ( 2,2,3) 41 12 28 1 5 9 12 15 19 53 67 28 33 37 Abb.: Löschen in einen B*-Baum der Klasse 45 53 ( 2,2,3) 280 59 67 71 83 99 Algorithmen und Datenstrukturen 4.5 Digitale Suchbäume Bei B-Bäumen führen variabel lange Zeichenketten zu Problemen. Falls der im Baum zu verteilende Inhalt eine insgesamt relativ statische Struktur hat, dann kann diese Struktur selbst die Schlüsselwerte (z.B. als Präfixfolge) bilden. Die dabei entstehende Baumstruktur ist wiederun ein Mehrwegbaum, der aufgrund einer möglichen Schlüsselwertunterteilungsform digital genannt wird. 4.5.1 Grundlagen und Definitionen Ein digitaler Suchbaum (Digital Search Tree) ist eine Baumstruktur für die Datenspeicherung und –suche, bei der die Schlüsselwerte die Anfangswertteile der Daten selbst darstellen. Diese Datenteile werden im allg. als Kantenbewegungen visualisiert und implementiert. Konkrete Formen digitaler Bäume sind der Trie und der Patricia-Baum. Der Trie leitet seine Bezeichnung von Information Retrieval ab171. Diese Baumstruktur eignet sich insbesondere für eine effiziente Suche in Zeichenketten, bei dem die ersten Zeichen den jeweiligen Suchbegriff bzw. Schlüsselwert darstellen. Die Konkatenation der jeweiligen Schlüsselwerte ergibt dann den Präfix der gesuchten Zeichenkette. Der Patricia-Baum hat seine Bezeichnung von dem Akronym "Practical Algorithm To Retrieve Information Coded in Alphanumeric". Sein Prinzip ist die Möglichkeit des "Überspringens" von Teilworten im Suchbaum. Das wird dadurch erreicht, dass Präfixinhalte in den Suchknoten selbst gespeichert werden. Das Prinzip digitaler Suchbäume ist - Zerlegung des Schlüssels – bestehend aus Zeichen eines Alphabets – in Teile - Aufbau des Baums nach Schlüsselteilen - Suche im Baum durch Vergleich von Schlüsselteilen - Jede unterschiedliche Folge von Teilschlüsseln ergibt eigenen Suchweg im Baum - Alle Schlüssel mit dem gleichen Präfix haben in der Länge des Präfix den gleichen Suchweg. - vorteilhaft u.a. bei variabel langen Schlüsseln, z.B. Strings Schlüssselteile können gebildet werden durch Elemente (Bits, Zeichen, Ziffern) eines Alphabets oder durch Zusammenfassungen dieser Grundelemente (z.B. Silben der Länge k). l 1 , wenn l die maximale Die Höhe des Baums (z.B. Silben der Länge k) ist k Schlüssellänge und k die Schlüsselteillänge ist. 171 wird aber wie "try" gesprochen 281 Algorithmen und Datenstrukturen 4.5.2 Tries Ein Trie ist eine auf Bäumen basierte Datenstruktur, um Worte (strings) zu speichern. Auf Tries lässt sich schnelles pattern matching anwenden. Darau ergibt sich die Hauptanwendung von Tries, das Wiedererlangen (retrieval) von Informationen. Tries sind spezielle m-Wege-Bäume, wobei Kardinalität und Länge k der Schlüsselteile den Grad m festlegen. Standard-Tries Definition: Falls S sine Menge von k Strings ist im Alphabet ist, dann ist eine Standard Trie ein geordneter Baum T mit folgenden Eigenschaften: 1. Jeder Knoten von T – mit Ausnahne der Wurzel – ist mit einem Zeichen von versehen. 2. Kinder eines internen Knotens sind kanonisch angeordnet. 3. Kein String in S ist Präfix eines anderen Strings. 4. T besitzt k externe Knoten, die jeweils einen String von S repräsentieren. Die Aneinanerreihung der Knotenbezeichnungen auf dem Weg von der Wurzel zu einem externen Knoten v von T ergibt den String von S, den v repräsentiert. Bsp.: Standard-Trie für die Strings {ANGEL, ART, AUTO, BUS, BUSCH} (Alphabet = Großbuchstaben A ... Z. A B R N U U T G T S O C E S L T H Zwar scheinen im obigen Bsp. die Punkte 3 (da {BUS , BUSCH } S ) und 4 (da k = 6, aber T nur 5 externen Knoten besitzt) aus der Definition verletzt zu sein, jedoch kann der Trie auch dargestellt werden, indem jedem String aus S ein zusätzliches, nicht in enthaltendes Zeichen - z.B. $ hinzugefügt wird, das das Ende des Strings repräsentiert. Im obigen Bsp. ist ein Knoten, an dem ein String endet, als eckiger Knoten dargestellt172. Einfügen (insert()) für einen String s[1..n] in einen Trie: public void insert(Trie t, String s) // der aktuelle Knoten Ist die Wurzel von t) { // Ueberpruefe fuer alle s[i] ... for (int n = 0; n < s.length(); n++) { int index = s.charAt(n) – 'a'; // .. ob der aktuelle Knoten einen 172 entspricht in der Implementierung dem Setzen eines Flags isWord 282 Algorithmen und Datenstrukturen if (t.next[index] == null) { t.next[index] = new Trie(); } t = t.next[index]; } t.isWord = true; // // // // // // // Zeiger auf Knoten besitzt, der s[i] repraesentiert. Falls nicht fuege diesen Knoten ein Setze den aktuellen Knoten auf eben dieses Kind des aktuellen Knotens Setze beim Knoten, der s[n] repraesentiert, das Flag isWord. } insert() besitzt eine Laufzeit O(n), wobei n die Länge des einzufügenden String ist. Suchen (search()) nach dem Vorhandensein eines Strings s[1..n]: public boolean search(Trie t, String s) // der aktuelle Knoten Ist die Wurzel von t) { for (int n = 0; n < s.length();n++) // Ueberpruefe fuer alle s[j] .. { int index = s.charAt(n) – 'a'; // .. ob der aktuelle Knoten einen if (t.next[index] == null) // Zeiger auf Knoten besitzt, der { // s[i] repraesentiert. Falls nicht, return false; // gib false zurueck } // Setze den aktuellen Knoten auf t = t.next[index]; // eben dieses Kind des aktuellen Knotens } // Gib beim Knoten, der s[n] repraesent.isWord = true; // tiert, das Flag isWord zurueck. } Das Suchen eines Strings s der Länge n in Standard Tries lässt sich in O(n) realisieren Präfix-Suche (isPrefix()) zur Überprüfung auf das Vorhandensein eines Präfix: Die Präfix-Suche entspricht der Implementierung des search()-Algorithmus mit dem einzigen Unterschied, dass die Rückgabe von isWord durch return true ersetzt wird. Überprüfen, ob ein Knoten t eines Standard Tries extern173 ist, durch isEmpty(): public boolean isEmpty(Trie t) { for (int d = 0; d < ALPH – 1; d++) // Ueberpruefe fuer alle ALPH Zeiger, ob diese auf null ges. sind. if (t.next[d] != null) return false; // Falls nicht, gebe false … return true; // sonst true zurueck } Falls d die Länge des zugrundeliegenden Alphabets ist, besitzt isEmpty() eine Laufzeit von O(d). Löschen (delete()) eines Strings s aus einem Standard Trie t: public void delete(Trie t,String s) { if (!search(s)) // Ueberpruefe zunaechst, ob der zu loeschende String { // im Trie vorhanden ist return; // falls nicht, rufe doDelete auf } doDelete(s,0,t,t.next[charAt(0) – 'a']); } void doDelte(String s, int n, Trie prev, Trie current) { if (n < s.length() – 1)) // gehe zuerst rekursiv zum Knoten, der s[n] { // repraesentiert 173 d.h. alle Zeiger dieses Knotens sind auf Null gesetzt. 283 Algorithmen und Datenstrukturen doDelete(s,(n+1),current,current.next[s.charAt(n+1)-'a']); } if (n == s.length() – 1) current.isWord = false; // Loesche an diesem Knoten das Flag if (current.isEmpty() && (current.isWord == false)) // Ist dieser Knoten prev.next[s.charAt(k)-'a'] = null; // extern und repraesentiert keinen } // keinen String aus s,loesche ihn und verfahre dann analog mit den // darueberliegenden Knoten. delete() besitzt eine Laufzeit von O(d – n), wobei n die Länge von s und d die Länge von ist. m-äre Tries Definition: Ein m-ärer Trie ist ein spezieller m-Wege-Baum, wobei Kardinalität des Alphabets und Länge k der Schlüsselteile den Grad festlegen - bei Ziffern: m = 10 - bei Alpha-Zeichen: m = 26; bie alphanumerischen Zeichen: m = 36 - bei Schlüsselteilen der Länge k potenziert sich der Grad, d.h. als Grad ergibt sich mk. Darstellung - Jeder Knoten eines Tries vom Grade m ist im Prinzip ein eindimensionale Vektor mit m Zeigern - Jedes Element im Vektor ist einem Zeichen (bzw. Zeichenkombination) zugeordnet. Auf diese Weise wird ein Schlüsselteil (Kante) implizit durch die Vektorposition ausgedrückt m=10 k=1 P0 P1 P2 P3 P4 P5 P6 P7 P8 P9 Abb.: Knoten eines 10-ären Tries mit Ziffern als Schlüsselteilen - implizite Zuordnung von Ziffer / Zeichen zu Zeiger (Referenz) Pi gehört zur Ziffer i. Tritt Ziffer i in der betreffenden Position auf, so verweist Pi auf den Nachfolgerknoten. Kommt i in der betreffenden Position nicht vor, so ist Pi mit NULL belegt. - Falls der Knoten auf der j-ten Stufe eines 10-ären Tries liegt, dann zeigt Pi auf einen Unterbaum, der nur Schlüssel enthält, die in der j-ten Position die Ziffer i besitzen. Bsp.: Trie für Schlüssel aus einem auf A – E beschränkten Alphabet $174 A B C D E m=6 k=1 * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Abb. Trie für Schlüssel aus einem auf A … E beschränkten Alphabet. Grundoperationen. 174 Trennzeichen: kennzeichnet Schlüsselende 284 * * * * * * * * * * * * Algorithmen und Datenstrukturen Direkte Suche: In der Wurzel wird nach dem 1. Zeichen des Suchschlüssels verglichen. Bei Gleichheit wird der zugehörige Zeiger verfolgt. Im gefundenen Knoten wird nach dem 2. Zeichen verglichen usw. Aufwand bei erfolgreicher Suche: li k . Löschen: Nach dem Aufsuchen des richtigen Knoten wird ein *-Zeiger auf NULL gesetzt. Besitzt daraufhin der Knoten nur NULL-Zeiger, wird er aus dem Baum entfernt 4.5.3 Binäre Tries Eine spezielle Form von Tries sind binäre Tries, die sich auf ein binäres Alphabet mit den Zeichen {0,1} abstützen. Die Daten werden als Bitfolgen interpretiert, d.h. die Verzweigung im Baum erfolgt in Abhängigkeit vom Wert der betrachteten Bitposition 4.5.4 Patricia Bäume (Compressed Tries) Grundidee Teile der Zeichenketten, die für den Vergleich bzw. das Verzweigen irrelevant sind, werden übersprungen. Dies wird erreicht, in dem jeder Knoten die Anzahl der zu überspringenden Bits bzw. Zeichen enthält. So lässt sich die Position in der Zeichenkette bestimmen, die für die Entscheidung über den weiter zu verfolgenden Pfad zu testen ist. Im ursprünglichen Verfahren wurde ein binärer Baum mit Bitfolgen verwendet, es lässt sich jedoch auch ein Alphabet nutzen. Merkmale. - Speicherung aller Schlüssel in den Blättern - innere Knoten speichern, wie viel Zeichen (Bits) beim Test zur Wegeauswahl zu überspringen sind - Vermeidung von Einwegverzweigungen, in dem nur bei einem verbleibenden Schlüssel direkt auf entsprechendes Blatt verwiesen wird. 3 a Database e e u 5 m Datum s Datenbanken Datenbankmodell Datenbanksystem Bewertung - speichereffizient. Gegenüber den einfachen Tries ergibt sich eine deutlich komprimierte Darstellung. Auch der Suchaufwand kann bei sehr langen und wenigen häufigen Worten reduziert werden. - sehr gut geeignet für variable lange Schlüssel und (sehr lange) Binärdarstellungen von Schlüsselwerten - bei jedem Teilschlüssel muß die Testfolge von der Wurzel beginnend ganz ausgeführt werden, bevor über Erfolg oder Misserfolg der Suche entschieden werden kann. - Erfolgreiche und erfolglose Suche endet in einem Blattknoten, z.B. -- Erfolgreiche Suche nach dem Schlüssel Heinz X'10010001000101100110011101011010' -- Erfolglose Suche nach dem Schlüssel Abel X'1000001100001010001011001100' 285 Algorithmen und Datenstrukturen 9 0 25 0 11 H A R A L D H O L G E R 6 H A R T M U T 9 H E I N 6 H U B E R T H E L M U T 2 H E I N R I C H H U B E R H E I N Z n H U B E R T U S Anzahl zu überspringenden Bits Schlüssel Abb.: Präfix- bzw. Radix-Baum: Häufig benutzt man folgende Variante des Patricia-Baums: - Speicherung variable langer Schlüsselteile in den inneren Knoten, sobald sie sich als Präfixe für die Schlüssel des zugehörigen Unterbaums abspalten lassen - erfolglose Suche lässt sich schon oft in einem inneren Knoten abbrechen. Dat 3 a e base u nbank 5 e m n odell m s ystem Abb.: Praefix-Baum 4.5.5 Suffix Tries Ein Suffix Trie ist ein Compressed Trie, der aus allen Suffixes eines String s gebildet 286 Algorithmen und Datenstrukturen 4.5.6 Dateikompression mit dem Huffman-Algorithmus Eine spezielle Form eines Trie ist der optimale Präfix-Baum, der mit Hilfe des Huffman-Algorithmus175 bestimmt wird. Bsp.: 0 0 1 1 0 e 0 i 1 sp 1 a 0 1 t 0 s 1 nl Abb. Optimaler Präfix-Code Allgemeine Formulierung des Huffman-Algorithmus: Die Anzahl der Zeichen betrage C. Daraus werden C Einzelbäume erstellt, deren Gewicht jeweils die Summe der Häufigkeiten der Blätter darstellt (zu Beginn sind dies nur Einzelknoten). Dann werden (C – 1)-mal jeweils zwei Bäume mit den geringsten Gewichten zu einem neuen Baum zusammen, bis der optimale Code vorliegt. Dateikompression. Der Huffman-Algorithmus kann zur Verdichtung bzw. Kompression von zu speichernden Daten verwendet werden. 175 vgl. 3.2.1.2 287 Algorithmen und Datenstrukturen 5. Graphen und Graphenalgorithmen 5.1 Einführung 5.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: 288 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.: 289 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 290 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 ) 291 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. 5.1.2 Definitionen Gegeben ist eine endliche (nicht leere) Menge K 176. 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 177. 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. 176 177 Anstatt K schreibt man häufig auch V (vom englischen Wort Vertex abgeleitet) vgl. 5.1.1 292 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 , v2 v1 V1 , v2 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 293 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. 294 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. 295 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 296 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)178. 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 178 falls JA, wird der Graph eulersch genannt. 297 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. 5.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 K i 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 Nachbedingung: Die Kantenliste nimmt zu 298 Algorithmen und Datenstrukturen 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 K i und Kj zugeordnet sind. Jeder Eintrag in der Matrix gibt das Gewicht der Kante E ij = (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.: 299 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.179 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 n ( n 1) Einträge nur oberhalb der Diagonalen gespeichert werden. Dafür werden 2 179 kompakt bei dichten Graphen, also Graphen mit vielen Kanten 300 Algorithmen und Datenstrukturen Speicherplätze benötigt, wenn es keine Schlingen gibt. n (n 1) Speicherplätze 2 benötigt man. Wenn es Schlingen gibt180. 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: 180 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. 301 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) 302 Algorithmen und Datenstrukturen 5.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) 5.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. 5.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} do color[u ] WHITE b[u ] f [u ] p[u ] NIL time 1 color[ s] GRAY PUSH ( K , s) b[ s ] time 303 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 u v w x y z 1.Schritt: 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 304 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 y v u z 7. Schritt: backtracking zu y, f [ y ] 7 Stack u v w w x y v u z 305 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 u 12. Schritt: backtracking zu w, f [u ] 10 Stack u v w x y z 306 Algorithmen und Datenstrukturen 5.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 Tiefensuche181 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. 181 vgl. 5.2.1.1 307 Algorithmen und Datenstrukturen 5.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. 308 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ärtskante182). 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)183 cross edges: (F,C) (G,F) 182 183 H kein Bestandteil des Baums führen von einem Baumknoten zu einem Nachfolger 309 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. 310 Algorithmen und Datenstrukturen 5.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. G (V , E ) Ein ungerichteter Graph 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. 311 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. 312 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. - D hat Kind E, 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. 313 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 184 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 Artikulationspunkte185 184 185 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 314 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 genau eine starke G 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). 2. Berechne den transponierten Graphen GT V , E T von E v, u | u, v E. E besteht also aus den umgedrehten Kanten von die gleichen starken Zusammenhangskomponenten. T T G (V , E ) , wobei T G . G und G haben dfs(G T ) , 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. 3. Berechne Bsp.: Gegeben ist A B D C E Abb.: Ein gerichteter Graph G F J H I G 315 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 T Abb.: Tiefensuche mit G - starke Komponenten sind Jeder dieser Bäume im depth-first zusammenhängende Komponente G, H , I , J , B, A, C, F, D, E 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. 316 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 Algorithmus186 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); } 186 /pgc/pr52_144/erreichbar.cpp 317 Algorithmen und Datenstrukturen 5.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 318 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 Farbe187. 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 187 - weiße Knoten wurden noch nicht behandelt - schwarze Knoten wurden vollständig abgearbeitet - Graue Knoten sind noch in Bearbeitung 319 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 320 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 321 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. 322 Algorithmen und Datenstrukturen 5.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. for each vertex u V [G ] {s} do color[u ] WHITE d [u ] p[u ] NIL color[ s] GRAY d [s] 0 p[ s] NIL Qs while Q 0 do u first [Q ] // u ist erstes Element in Q for v Adj[u ] // Nachbarn von u do if color [v] WHITE then color [v] GRAY d [v] d [u ] 1 p[v] u 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 | ). 323 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 324 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 325 Algorithmen und Datenstrukturen 10. Schritt: 1 0 2 3 r s t v w x y 2 1 2 3 u 5.2.3 Implementierung In der Klasse Graph sind Tiefensuche (Methode traverseDFS) und Breitensuche (traverseBFS) implementiert188. 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 { 188 vgl. pr52220 326 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); } return list; } 327 Algorithmen und Datenstrukturen /** 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()) // { // v = (Vertex) vertices.get(((Vertex)s.pop()).key); // liste.add(v); // color.put(v,black); eIter = v.edges.iterator(); 328 Algorithmen und Datenstrukturen 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 329 Algorithmen und Datenstrukturen 5.3 Topologischer Sort Sortieren bedeutet Herstellung einer totalen (vollständigen) Ordnung. Es gibt auch Prozesse zur Herstellung von teilweisen Ordnungen189, 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) 189 vgl. 1.2.2.2 330 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 Algorithmus190 durch folgende Pseudocode-Darstellung beschrieben werden. 190 Der hier angegebene Algorithmus setzt voraus, dass der Graph in einer Adjazenzliste abgebildet ist, Eingangsgrade berechnet wurdem und zusammen mit den Knoten abgespeichert wurden. 331 Algorithmen und Datenstrukturen void topsort() { Queue<Vertex> q = new Queue<Vertex>(); int zaehler = 0; Vertex v, w; for each v if (v.indegree191 == 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 6 0 0 0 0 0 1 0 7 7 0 0 0 0 0 0 0 6 6 Abschätzung der Laufzeit (Komplexität): O E V (, falls Adjazenzlisten benutzt werden. Das ist einleuchtend, da man davon ausgehen kann - der Schleifenkörper wird einmal je Kante ausgeführt 191 indegree (Eingangsgrad) ist der Zähler für die jeweilige Anzahl von Vorgängerknoten 332 Algorithmen und Datenstrukturen - die Schlangenoperationen werden meistens einmal je Knoten ausgeführt - der Zeitbedarf für die Initialisierung ist proportional zur Größe des Graphen 5.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 5.4.1 Berechnung der Erreichbarkeit mittels Matrixmultiplikation Eine häufig vorkommende Frage ist die nach dem Zusammenhang zweier Knoten. Das kann man aus der Wegematrix192 sofort ablesen. Die Wegematrix kann aus Adjazenzmatrix und Kantenfolgen mit Matrixoperationen leicht bestimmt werden: - In einem unbewerteten Graphen mit Adjazenzmatrix A beschreibt A A A A ... A (aij ) (r ) (r ) aij : Anzahl der Kantenfolgen von xi nach x j der Länge r (Beweis über vollständige Induktion r nach Definition des Matrixprodukts) - Ein gerichteter Graph ist genau dann azyklisch, wenn A 0 für ein geeignetes r 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 192 vgl. 5.2.1.4 333 n 1 also A r 0 für r n . Algorithmen und Datenstrukturen - Die n 1 A r Wegematrix W ergibt sich aus der Adjazenzmatrix A, indem man in A 0 A1 ... A n 1 alle von 0 verschiedene Elemente setzt. A0 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 1 0 0 0 1 1 0 0 0 0 0 0 0 A2 0 0 1 1 0 0 0 0 1 0 1 0 0 0 0 0 0 0 1 1 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 3 A 0 0 1 1 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 4 A 0 0 1 1 0 0 0 0 1 0 1 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 0 0 0 0 0 0 1 0 0 1 2 3 W A A A A 0 0 1 1 1 1 0 1 1 1 1 0 0 1 334 Algorithmen und Datenstrukturen 5.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 193. 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 A0 ) 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 vk 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 , x2 , x3 , x4 , x5 w . Mit dreifach verschachtelten Schleifen werden alle möglichen Knoten-Tripel betrachtet. Falls die Knoten x1 ...x5 in 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 193 in C++: | und &, vgl.pr54010.cpp in pr54_1 335 Algorithmen und Datenstrukturen Stadium der Iteration gefunden wurde. So wird x1 , x4 hinzugefügt, danach x1 und x5 über x 4 mit x1 , x5 ergänzt. 5.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; } } 336 Algorithmen und Datenstrukturen 5.5 Kürzeste Wege 5.5.1 Die Datenstrukturen Graph, Vertex, Edge für die Berechnung kürzester Wege Repräsentation von Knoten194 // 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: Graph( ) { } 194 vgl. pr22859, pr55_1 337 Algorithmen und Datenstrukturen ~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 ); }; 5.5.2 Kürzeste Pfade in gerichteten, ungewichteten Graphen. Lösungsbeschreibung. Die ungewichteten Graphen G: folgende k1 k3 Abbildung zeigt einen gerichteten, k2 k4 k6 k5 k7 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. k1 k3 k2 k4 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 k 1 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: 338 Algorithmen und Datenstrukturen k1 k2 1 k3 2 k4 k5 0 2 1 k6 k7 Abb.: Graph nach Ermitteln aller Knoten mit der kürzeszen Pfadlänge 2 Die hier verwendete Strategie ist unter dem Namen „breadth-first search“195 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 (k 3). - 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) { 195 vgl. 5.2.2 339 Algorithmen und Datenstrukturen /* /* /* /* 1 2 3 4 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; } } */ */ */ */ /* 5 */ /* 6 */ /* 7 */ /* 8 */ /* 9 */ } 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 ) .196 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 */ 196 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 340 Algorithmen und Datenstrukturen Die folgende Tabelle zeigt, wie sich die Daten der Tabelle während der Ausführung des Algorithmus ändern: Anfangszustand k bekannt k1 false k2 false k3 false k4 false k5 false k6 false k7 false Q: k3 dk 0 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 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 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 Implementierung197. 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 ); } } } } 197 vgl.: pr22859 341 Algorithmen und Datenstrukturen 5.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 Graphen198 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 min({ d ( w), d (v) c( w, v)}) w S , ersetze d(w) durch Der folgende Graph 2 k1 k2 4 1 3 10 2 2 k3 k4 5 8 k5 4 k6 6 k7 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( ); } 198 // // // // // Vertex name Adjacent vertices (and costs) Cost Previous vertex on shortest path Extra variable used in algorithm nm ) : name( nm ) vgl. 5.5.2 342 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 „k 2“ 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“ 343 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) „k 3 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; } 344 (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 Distanz199. 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: v V do d (v) /* 2 */ d ( s ) 0 ; S 0 /* 1 */ for_all /* 3 */ pq = new PriorityQueue(); // Vorrangwarteschlange für Knoten in V /* 4 */ while pq 0 do /* pq V S */ /* 5 */ pq.delete _ min() 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 /* 6 */ /*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; } }; Implementierung.200 199 200 v = kleinster_unbekannter_Distanzknoten vgl. pr22859, pr55_1 345 Algorithmen und Datenstrukturen 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 OV 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 346 Algorithmen und Datenstrukturen 5.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 k1 4 k2 1 3 -10 1 k3 k4 k5 2 2 6 6 k6 k7 1 Bei der Berechnung der Kosten von k 5 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); } } } 347 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. Implementierung201 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 } } } } 5.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 Ordnung202) 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; 201 202 vgl. pr22859, pr55_1 vgl. 1.3.3 348 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. 349 Algorithmen und Datenstrukturen 5.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 | ). Implementierung203: 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: 203 pr52221 bzw. pr54_1, pr54020.cpp 350 Algorithmen und Datenstrukturen 351 Algorithmen und Datenstrukturen 5.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. 5.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 k1 k2 4 1 3 10 2 7 k3 k4 5 8 k5 4 k6 6 k7 1 besitzt folgenden minimale Spannbaum: 2 k1 k2 1 2 k3 k4 k5 4 k6 6 k7 1 Abb.: Die Anzahl der Kanten in einem minimal spannenden Baum ist |V| - 1 (Anzahl der Knoten – 1). Der minimal spannende Baum ist 352 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 , cvw ) 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 k3 bekannt true true true dv 0 2 2 pv null k1 k4 353 Algorithmen und Datenstrukturen k4 k5 k6 k7 true false false false 1 7 5 4 k1 k4 k3 k4 Abb.: Tabelle mit Zustand „k2 ist bekannt“ und (anschließend) mit dem Zustand „k 3 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 Dijkstra204. Komplexität: Die Laufzeit ist O(|V|2) Implementierung. MinimalSpanningTree.java205 204 205 vgl. 2.2.11.2 vgl. pr53330 354 Algorithmen und Datenstrukturen Abb. 5.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 k1 k2 4 1 3 10 2 7 k3 k4 5 8 k5 4 k6 6 k7 1 Bestimme den minimale spannenden Baum nach dem Algorithmus von Kruskal: 355 Algorithmen und Datenstrukturen 1. Schritt k1 k2 1 k3 k4 k6 k5 k7 2. Schritt k1 k2 1 k3 k4 k6 k5 k7 1 3. Schritt 2 k1 k2 1 k3 k4 k6 k5 k7 1 4. Schritt 2 k1 k2 1 2 k3 k4 k6 k5 k7 1 356 Algorithmen und Datenstrukturen 5. Schritt 2 k1 k2 1 2 k3 k4 k5 4 k6 k7 1 6. Schritt 2 k1 k2 1 2 k3 k4 k5 4 6 k6 k7 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 206 206 vgl. pr53331 357 Algorithmen und Datenstrukturen 5.7 Netzwerkflüsse 5.7.1 Maximale Flüsse 5.7.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 - Kapazitätsbeschränkung 0 f (e) c(e), e E - Flusserhaltung f (e) f (e) e inc v e aus v Wert vom Fluß im Netzwerk: val( f ) f (s, y) f ( y, s) . Der Wert eines Flusses ys ys ist die Summe der lokalen Flüsse aus der Quelle (= Summe aller lokalen Flüsse in die Senke) 358 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 1. 0 f (i, j ) c(i, j ) 2. Für alle i V {s, t} gilt f (a, i) f (i, b) aV ( i ) bN ( 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 cS , T gilt für irgendeinen Schnitt S, T 359 Algorithmen und Datenstrukturen 5.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( ck f k ) über alle Pfadkanten k. 360 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 (cv f v ) über alle Vorwärtskanten v v oder x min cr ü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 wenn w, v existiert mit v markiert und f (v, w) c(v, w) markiere w 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 361 Algorithmen und Datenstrukturen 5.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 362 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 363 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 7/7 13/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. 364 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 365 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 des Algorithmus ist f u , v) monoton wachsend 366 v (ausgenommen s , t ): Während des Ablaufs Algorithmen und Datenstrukturen 5.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 ) max flow min cut 367 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 368 Algorithmen und Datenstrukturen 5.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 369 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]. 370 Algorithmen und Datenstrukturen 5.8 Matching 5.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). 371 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. 372 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 373 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 374 Algorithmen und Datenstrukturen 5.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 , x2 ,..., x6 und Y y1 , y 2 ,..., y6 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’ 375 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 , yi | f xi , yi 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 376 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. 377 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 Algorithmus207 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. 207 abgeleitet aus dem Satz von Berge 378 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 x4 x5 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 m 1,…,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. 379 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 5.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) 380 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. 381 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 382 Algorithmen und Datenstrukturen Vergrößernde Wege im allgemeinen Graphen - Nicht nur wie bisher in bipartiten Graphen Matching für beliebige Graphen Definition: Ein bzgl. eines Matching alternierender Pfad beginnt im nicht gematchten Knoten v und endet im nicht gematchten Knoten w, wobei jede zweite Kante zum Matching gehört. V w Satz von Berge Ein Matching M in einem Graphen G ist das Maximum gdw. G keinen bzgl. M vergrößernd alternierenden Pfad enthält. Beweis „=>“: Angenommen, M ist Maximum Matching. Gebe es einen vergrößernd alternierenden Pfad von v nach w, z.B: V w 383 Algorithmen und Datenstrukturen Vergrößerung durch Vertauschen der Matchingzugehörigkeit aller Kanten auf dem Pfad. V w Das ist immer noch ein Matching, jetzt ein größeres als M. Widerspruch! Beweis „<=“: Betrachte Matching M mit vergrößernd alternierendem Pfad, M’ sei das aus der Vergrößerung entstehende Matching. Dann ist der Pfad M-M’ alternierend: M M M’ M M’ M M’ Nach der Definition von Matching ist der Grad jedes Knoten höchstens 2 Beachte: Ein Zyklus kann kein vergrößernd alternierender Pfad sein. Jeder alternierend Zyklus hat eine gerade Anzahl Knoten (also gleich viele aus M und M’), und wenn M nicht Maximium also |M|<|M’|, gibt es einen alternierenden Pfad mit mehr Knoten aus M’ als aus M Algorithmus Aus dem Satz von Berge kann man einen Algorithmus ableiten: 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 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 zweites Matching gefunden Noch offen: Wie findet man M-alternierende Pfade. 384 Algorithmen und Datenstrukturen Finden alternierender Pfade Finden eines alternierenden Pfads geht einfach in bipartiten Graphen G mit 2 Klassen A und B. Das folgende Matching sei gegeben: }A }B Definiere G’ so, dass alle Matching-Kanten von A nach B und alle anderen von B nach A gerichtet sind }A }B Starte Tiefensuche (oder Breitensuche) im ungematchten Knoten Jeder Pfad wechselt zwischen M’ und G’/M’ Fertig, sobald ein ungematchter Knoten w erreicht }A }B Vergrößerung }A }B 385 Algorithmen und Datenstrukturen 386