Algorithmen und Datenstrukturen Prof. Jürgen Sauer Algorithmen und Datenstrukturen Skriptum zur Vorlesung im WS 2004 / 2005 1 Algorithmen und Datenstrukturen Inhaltzverzeichnis 1. OBJEKTORIENTIERTE PROGRAMMIERUNG MIT DATENSTRUKTUREN UND ALGORITHMEN ................................................................................................................................................................7 1.1 Ein einführendes Beispiel: Das Durchlaufen eines Binärbaums............................................................... 7 1.1.1 Rekursive Problemlösung ........................................................................................................................ 9 1.1.2 Nichtrekursive Problemlösung............................................................................................................... 10 1.1.3 Verallgemeinerung................................................................................................................................. 12 1.2 Begriffe und Erläuterungen zu Datenstrukturen und Programmierverfahren .................................... 16 1.2.1 Algorithmus (Verarbeitung von Daten) ................................................................................................. 16 1.2.1.1 Datenstruktur und Programmierverfahren ...................................................................................... 16 1.2.1.2 Deterministische und nicht deterministische Algorithmen ............................................................. 16 1.2.1.3 Bausteine für Algorithmen.............................................................................................................. 17 1.2.1.4 Formale Eigenschaften von Algorithmen ....................................................................................... 19 1.2.1.4.1 Korrektheit und Terminierung ................................................................................................. 19 1.2.1.4.2 Effizienz ................................................................................................................................... 20 1.2.1.5 Komplexität..................................................................................................................................... 21 1.2.1.5.1 Laufzeitberechnungen („Big-O“-) ........................................................................................... 23 1.2.1.5.2 O(logN)-Algorithmen .............................................................................................................. 27 1.2.1.5.3 Effizienz und Berechnungsgrundlagen für rechnerische Komplexität..................................... 28 1.2.2 Daten und Datenstrukturen .................................................................................................................... 31 1.2.2.1 Der Begriff Datenstruktur ............................................................................................................... 31 1.2.2.2 Relationen und Ordnungen ............................................................................................................. 36 1.2.2.3 Klassifikation von Datenstrukturen ................................................................................................ 41 1.2.3 Definitionsmethoden für Datenstrukturen.............................................................................................. 46 1.2.3.1 Der abstrakte Datentyp.................................................................................................................... 46 1.2.3.2 Die axiomatische Methode.............................................................................................................. 47 1.2.3.3 Die konstruktive Methode............................................................................................................... 49 1.2.3.4 Die objektorientierte Modellierung abstrakter Datentypen............................................................. 50 1.2.3.5 Die Implementierung abstrakter Datentypen in C++ ...................................................................... 58 1.2.3.5.1. Das Konzept für benutzerdefinierte Datentypen: class bzw. struct......................................... 58 1.2.3.5.2. Generischer ADT .................................................................................................................... 60 1.2.3.6 Die Implementierung abstrakter Datentypen in Java ...................................................................... 62 1.2.3.6.1 Klassen und Schnittstellen (Referenzdatentypen).................................................................... 62 1.2.3.6.2 Generische Typen .................................................................................................................... 67 1.3 Sammeln (über Container) und Ordnen................................................................................................... 72 1.3.1 Ausgangspunkt: Das Konzept zum Sammeln in Smalltalk.................................................................... 72 1.3.2 Behälter-Klassen .................................................................................................................................... 74 1.3.2.1 Lineare Kollektionen....................................................................................................................... 75 1.3.2.2 Nichtlineare Kollektionen ............................................................................................................... 77 2. DATENSTRUKTUREN UND ALGORITHMEN IN C++ UND JAVA ...............................................83 2.1 Datenstrukturen und Algorithmen in C++............................................................................................... 84 2.1.1 Die C++-Standardbibliothek und die STL ............................................................................................. 84 2.1.2 Container................................................................................................................................................ 84 2.1.2.1 Grundlagen...................................................................................................................................... 84 2.1.2.2 STL-Container-Anwendungen........................................................................................................ 86 2.1.2.3 Adaptoren zu Sequenzen................................................................................................................. 86 2.1.2.4 Mengen und Abbildungen............................................................................................................... 87 2.1.2.5 STL-Container-Anwendungen........................................................................................................ 88 2.1.3 Iteratoren ................................................................................................................................................ 90 2.1.4 Algorithmen ........................................................................................................................................... 90 3 Algorithmen und Datenstrukturen 2.2 Datenstrukturen und Algorithmen in Java .............................................................................................. 92 2.2.1 Durchwandern von Daten mit Iteratoren ............................................................................................... 92 2.2.2 Die Klasse Vector .................................................................................................................................. 92 2.2.3 Die Klasse Stack .................................................................................................................................... 95 2.2.4 Die Klasse BitSet für Bitmengen ........................................................................................................... 97 2.2.5 Die Klasse Hashtable und assoziative Speicher..................................................................................... 98 2.2.6 Die abstrakte Klasse Dictionary........................................................................................................... 101 2.2.7 Die Klasse Properties ........................................................................................................................... 101 2.2.8 Collection API ..................................................................................................................................... 102 2.2.8.1 Die Schnittstellen Collection, Iterator, Comparator...................................................................... 102 2.2.8.2 Die Behälterklassen und Schnittstellen des Typs List................................................................... 105 2.2.8.3 Behälterklassen des Typs Set ........................................................................................................ 109 2.2.8.4 Behälterklassen des Typs Map...................................................................................................... 111 2.2.9 Algorithmen ......................................................................................................................................... 115 2.2.9.1 Datenmanipulation ........................................................................................................................ 115 2.2.9.2 Größter und kleinster Wert einer Collection................................................................................. 116 2.2.9.3 Sortieren........................................................................................................................................ 116 2.2.9.4 Suchen von Elementen.................................................................................................................. 118 2.2.10 Generics ............................................................................................................................................. 119 2.2.10.1 Sammlungsklassen ...................................................................................................................... 119 2.2.10.2 Iteration ....................................................................................................................................... 119 2.2.10.2.1 Erweiterte for-Schleife ......................................................................................................... 119 2.2.10.2.2 Das Interface Iterable ........................................................................................................... 121 2.2.11 Implementierung von Graphen-Algorithmen zur Berechnung kürzester Wege mit Behälterklassen 123 2.2.11.1 Die Datenstrukturen Graph, Vertex, Edge zur Implementierung von Graphenalgorithmen für die Berechnung kürzester Wege ..................................................................................................................... 124 2.2.11.2 Kürzeste Pfade in gerichteten, ungewichteten Graphen.............................................................. 126 2.2.11.3 Berechnung der kürzesten Pfadlängen in gewichteten Graphen (Algorithmus von Dijkstra) .... 130 2.2.11.4 Berechnung der kürzesten Pfadlängen in gewichteten Graphen mit negativen Kosten.............. 139 2.2.11.5 Berechnung der kürzesten Pfadlängen in gewichteten, azyklischen Graphen ............................ 140 3. ALGORITHMEN............................................................................................................................ 141 3.1 Ausgesuchte algorithmische Probleme.................................................................................................... 141 3.1.1 Spezielle Sortieralgorithmen................................................................................................................ 141 3.1.1.1 Interne Sortierverfahren ................................................................................................................ 141 3.1.1.1.1 Quicksort................................................................................................................................ 141 3.1.1.1.2 Heap-Sort ............................................................................................................................... 143 3.1.1.1.3 Sortieren durch Mischen ........................................................................................................ 145 3.1.1.2 Externe Sortierverfahren ............................................................................................................... 148 3.1.1.2.1 Direktes Mischsortieren ......................................................................................................... 148 3.1.1.2.2 Natürliches Mischen .............................................................................................................. 157 3.1.2 Suche in Texten.................................................................................................................................... 160 3.1.2.1 Ein einfacher Algorithmus zum Suchen in Zeichenfolgen ........................................................... 162 3.1.2.2 Das Verfahren von Knuth-Morris-Pratt ........................................................................................ 164 3.1.2.3 Das Verfahren von Boyer-Moore ................................................................................................. 166 3.2 Entwurfstechniken für Algorithmen (Einsatz von Algorithmen-Mustern)......................................... 169 3.2.1 Greedy Algorithmen ............................................................................................................................ 169 3.2.1.1 Greedy-Algorithmen für minimale Spannbäume .......................................................................... 170 3.2.1.2 Huffman Codes ............................................................................................................................. 173 3.2.2 Divide and Conquer ............................................................................................................................. 179 3.2.3 Induktiver Algorithmenentwurf und Dynamisches Programmieren .................................................... 179 3.3 Rekursion................................................................................................................................................... 181 3.3.1 Linear rekursive Funktionen ................................................................................................................ 181 3.3.2 Nichtlineare rekursive Funktionen....................................................................................................... 181 3.3.3 Primitive Rekursion ............................................................................................................................. 182 3.3.4 Nicht primitive Rekursion.................................................................................................................... 182 3.3.4 Rekursive Kurven ................................................................................................................................ 183 4 Algorithmen und Datenstrukturen 3.4 Backtracking-Algorithmen ...................................................................................................................... 188 3.5 Zufallsgesteuerte Algorithmen................................................................................................................. 192 4. BÄUME ......................................................................................................................................... 193 4.1 Grundlagen................................................................................................................................................ 193 4.1.1 Grundbegriffe und Definitionen .......................................................................................................... 193 4.1.2 Darstellung von Bäumen...................................................................................................................... 194 4.1.3 Berechnungsgrundlagen....................................................................................................................... 195 4.1.4 Klassifizierung von Bäumen................................................................................................................ 197 4.2 Freie Binäre Intervallbäume.................................................................................................................... 200 4.2.1 Ordnungsrelation und Darstellung ....................................................................................................... 200 4.2.2 Operationen.......................................................................................................................................... 204 4.2.3 Ordnungen und Durchlaufprinzipien ................................................................................................... 222 4.3 Balancierte Bäume .................................................................................................................................... 227 4.3.1 Statisch optimierte Bäume ................................................................................................................... 230 4.3.2 AVL-Baum .......................................................................................................................................... 231 4.3.3 Splay-Bäume........................................................................................................................................ 243 4.3.4 Rot-Schwarz-Bäume ............................................................................................................................ 251 4.3.5 AA-Bäume ........................................................................................................................................... 261 4.4 Bayer-Bäume ............................................................................................................................................. 269 4.4.1 Grundlagen und Definitionen .............................................................................................................. 269 4.4.1.1 Ausgeglichene T-äre Suchbäume (Bayer-Bäume) ........................................................................ 269 4.4.1.2 (a,b)-Bäume .................................................................................................................................. 271 4.4.2 Darstellung von Bayer-Bäumen........................................................................................................... 272 4.4.3 Suchen eines Schlüssels ....................................................................................................................... 275 4.4.4 Einfügen............................................................................................................................................... 276 4.4.5 Löschen ................................................................................................................................................ 282 4.4.6 Auf Platte/ Diskette gespeicherte Datensätze....................................................................................... 287 4.4.7 B*-Bäume ............................................................................................................................................ 289 4.5 Digitale Suchbäume .................................................................................................................................. 292 4.5.1 Grundlagen und Definitionen .............................................................................................................. 292 4.5.2 Tries ..................................................................................................................................................... 293 4.5.3 Patricia Bäume (Compressed Tries)..................................................................................................... 296 4.5.4 Suffix Tries .......................................................................................................................................... 297 4.5.5 Dateikompression mit dem Huffman-Algorithmus.............................................................................. 298 5. GRAPHEN UND GRAPHENALGORITHMEN.............................................................................. 299 5.1 Einführung ................................................................................................................................................ 299 5.1.1 Grundlagen........................................................................................................................................... 299 5.1.2 Definitionen ......................................................................................................................................... 302 5.1.3 Darstellung in Rechnerprogrammen .................................................................................................... 306 5.2 Durchlaufen von Graphen ....................................................................................................................... 310 5.2.1 Tiefensuche (depth-first search)........................................................................................................... 310 5.2.2 Breitensuche (breadth-first search) ...................................................................................................... 314 5.2.3 Implementierung .................................................................................................................................. 317 5.3 Topologischer Sort .................................................................................................................................... 321 5.4 Transitive Hülle......................................................................................................................................... 324 5.5 Kürzeste Wege........................................................................................................................................... 325 5 Algorithmen und Datenstrukturen 5.5.1 Der Algorithmus von Dijkstra.............................................................................................................. 325 5.5.2 Der Algorithmus von Floyd ................................................................................................................. 327 5.6 Minimale Spannbäume............................................................................................................................. 329 5.6.1 Der Algorithmus von Prim................................................................................................................... 329 5.6.2 Der Algorithmus von Kruskal.............................................................................................................. 332 6 Algorithmen und Datenstrukturen 1. Objektorientierte Programmierung Datenstrukturen und Algorithmen mit 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 Algorithmen1, 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. Die Datenorganisation oder Datenstruktur und die zugehörigen Algorithmen sind demnach ein entscheidender Bestandteil eines leistungsfähigen Programms. Ein einführendes Beispiel soll diesen Sachverhalt vertiefen. 1.1 Ein einführendes Beispiel: Das Durchlaufen eines Binärbaums Das ist eine Grundaufgabe zur Behandlung von Datenstrukturen. Ein binärer Baum B ist entweder leer, oder er besteht aus einem linken Baum BL, einem Knoten W und einem rechten Teilbaum BR. Diese Definition ist rekursiv. Den Knoten W eines nichtleeren Baumes nennt man seine Wurzel. Beim Durchlaufen des binären Baumes sind alle Knoten aufzusuchen (, z. B. in einer vorgegebenen „von links nach rechts"-Reihenfolge,) mit Hilfe eines systematischen Weges, der aus Kanten aufgebaut ist. 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 bereit2: #ifndef BAUMKNOTEN #define BAUMKNOTEN #ifndef NULL const int NULL = 0; #endif // NULL // Deklaration eines Binaerbaumknotens fuer einen binaeren Baum 1 2 D. E. Knuth hat einen großen Teil dieses Wissens in "The Art of Computer Programming" zusammengefaßt vgl. pr11_1, baumkno.h 7 Algorithmen und Datenstrukturen 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" }; // 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: 8 Algorithmen und Datenstrukturen 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); rKind = new baumKnoten<int>(4); z = new baumKnoten<int>(2,lKind,rKind); lKind = z; rKind = new baumKnoten<int>(5); z = new baumKnoten<int>(1,lKind,rKind); wurzel = z; } 1.1.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 9 Algorithmen und Datenstrukturen int main() { int zahl; baumKnoten<int> *wurzel; baumKnoten<int> *lKind, *rKind, *z; lKind = new baumKnoten<int>(3); rKind = new baumKnoten<int>(4); z = new baumKnoten<int>(2,lKind,rKind); lKind = z; rKind = new baumKnoten<int>(5); z = new baumKnoten<int>(1,lKind,rKind); wurzel = z; cout << "Inorder: " << endl; ausgBaum(wurzel,0); wlr(wurzel); // Rekursive Problemlösung cout << endl; // Nichtrekursive Problemlösung wlrnr(wurzel); cout << endl; } Das Durchlaufen geht offensichtlich von der Wurzel aus, ignoriert zuerst die rechten Teilbäume, bis man auf leere Bäume stößt. Dann werden die Teiluntersuchungen abgeschlossen und beim Rückweg die rechten Bäume durchlaufen. Jeder Baumknoten enthält 2 Zeiger (Adressen von BL und BR). Die Zeiger, die auf leere Bäume hinweisen, werden auf „NULL“ gestellt. 1.1.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: 10 Algorithmen und Datenstrukturen Top-Element Zeiger auf Baumknoten Zeiger auf Baumknoten Zeiger auf Baumknoten nil nil Abb. 1.1-2: Aufbau eines Stapels Der nicht rekursive Baumdurchlauf-Algorithmus läßt sich mit Hilfe der Stapelprozeduren der Containerklasse Stack der Standard Template Library (STL)3 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(); } } } Dieser Algorithmus ist zu überprüfen mit Hilfe des folgenden binären Baumes 3 vgl. 2.1.2.3 11 Algorithmen und Datenstrukturen 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 1.1.3 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.: -- das 1-dimensionale Feld F 12 Fritz Algorithmen und Datenstrukturen 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) 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. 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 13 Algorithmen und Datenstrukturen 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). Die Java Foundation Classes (JFC) enthalten eine Klasse JTree aus dem Paket javax.swing, die eine Baumstruktur darstellt4 und zur grafischen Darstellung von Bäumen herangezogen wird. Die Basis bildet das Interface TreeNode, das Methoden vorschreibt, die ein Knoten beherrschen muß, wenn eine grafische Darstellung erfolgen soll (z.B. isLeaf(), getParent()). Eine Erweiterung ist das Interface MutableTreeNode, das vorgibt, wie Methoden heißen sollen, die einen Knoten verändern (z.B. insert(), remove()). Falls man diese Interfaces implementiert, dann kann man eigene Bäume aufbauen. Fertig vorgegeben ist die Klasse DefaultMutableTreeNode, die diese Interfaces implementiert und eine Fülle weiterer Methoden zur Verfügung stellt. Bsp.: Darstellung einer Verzeichnisstruktur Mit der Klasse FileTree wird eine Verzeichnisstruktur aufgebaut import import import import import import java.io.*; java.util.*; java.awt.*; javax.swing.*; javax.swing.event.*; javax.swing.tree.*; public class FileTree { // Instanzvariable private DefaultMutableTreeNode root; //Konstruktor public FileTree(String dateiName) { File file = new File(dateiName); if (!file.isDirectory()) file = new File("."); root = maketree(file); } // Methoden private DefaultMutableTreeNode maketree(File f) { DefaultMutableTreeNode tn = new DefaultMutableTreeNode(f.getName()); if (f.isDirectory()) { String[] ls = f.list(); for (int i = 0; i < ls.length; i++) tn.add(maketree(new File(f,ls[i]))); } return tn; } public void print() { print(root); } private void print(DefaultMutableTreeNode tn) { if (!tn.isLeaf() ) { System.out.println(tn + ":"); System.out.println(); for (Enumeration e = tn.children(); e.hasMoreElements();) print((DefaultMutableTreeNode) e.nextElement()); 4 vgl. pr13229 14 Algorithmen und Datenstrukturen System.out.println(); } else System.out.println(tn); } } public DefaultMutableTreeNode getRoot() { return root; } Mit der Klasse ErsterDateiBaum wird eine Verzeichnisstruktur als Baum dargestellt import java.awt.*; import javax.swing.*; import javax.swing.tree.*; public class ErsterDateibaum extends JFrame { //Konstruktor public ErsterDateibaum(FileTree ft) { DefaultTreeModel treemodel = new DefaultTreeModel(ft.getRoot()); JTree tree = new JTree(treemodel); this.getContentPane().add(new JScrollPane(tree)); } // Methoden public static void main(String[] args) { String dir; if (args.length == 0) dir = "."; else dir = args[0]; FileTree eb = new FileTree(dir); eb.print(); JFrame f = new ErsterDateibaum(new FileTree(dir)); f.pack(); f.setVisible(true); } } Im Hauptprogrammabschnitt dieser Klasse wird ein Dateibaum erzeugt und an den Konstruktor der Klasse ErsterDateiBaum übergeben. Dort bestimmt man den Wurzelknoten und übergibt ihn an ein TreeModel. Dieses Interface stellt die Verbindung zwischen der eigentlichen Basis und der Baumansicht (View) dar. Implementiert wird es durch DefaultTreeModel. Danach wird ein JTree mit Hilfe des Datenmodells erzeugt. JTree stellt die eigentliche grafische Komponente zur Darstellung eines Baums bereit. Ein Objekt vom Typ JTree sollte inder Regel in eine JScrollPane eingebettet sein. Deshalb implementiert JTree das Interface Scrollable. Danach wird JScrollPane dem Fenster hinzugefügt und die Darstellung als Baum erscheint. 15 Algorithmen und Datenstrukturen 1.2 Begriffe und Erläuterungen zu Datenstrukturen und Programmierverfahren 1.2.1 Algorithmus (Verarbeitung von Daten) 1.2.1.1 Datenstruktur und Programmierverfahren Datenorganisation heißt: Daten zweckmäßig so einrichten, daß eine möglichst effektive Verarbeitung erreicht werden kann. So wurde im einführenden Beispiel die Bearbeitung rekursiver Strukturen (Bäume) mit Hilfe der Datenstruktur Stapel und den dazugehörigen Programmierverfahren (PUSH, POP) ermöglicht. Das braucht aber immer nicht „von Anfang an“ untersucht bzw. implementiert zu werden. Bereits vorhandenes Wissen, z.B. über Datenstrukturen und dazugehörige Programmierverfahren, ist zu nutzen. Das Wissen über den Stapel und seine Programmierung kann allgemein zur Bearbeitung der Rekursion auf einem Digitalrechner benutzt werden und ist nicht nur auf die Bearbeitung von Baumstrukturen beschänkt. Datenstruktur und Programmierverfahren bilden (, wie das einführende Beispiel zeigt,) eine Einheit. Bei der Formulierung des Lösungswegs ist man auf eine bestimmte Darstellung der Daten festgelegt. Rein gefühlsmäßig könnte man sogar sagen: Daten gehen den Algorithmen voran. Programmieren führt direkt zum Denken in Datenstrukturen, um Datenelemente, die zueinander in bestimmten Beziehungen stehen, zusammenzufassen. 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. Die Programmierverfahren sind durch Algorithmen festgelegt. 1.2.1.2 Deterministische und nicht deterministische Algorithmen Algorithmen spielen auch im täglichen Leben eine Rolle, z.B. in - Bedienungsanleitungen - Gebrauchsanleitungen - Bauanleitungen Man kann deshalb zunächst einmal den Begriff Algorithmus intuitiv so festlegen: Ein Algorithmus ist eine präzise (d.h. in einer festgelegten Sprache abgefasste) endliche Beschreibung eines allgemeinen Verfahrens unter Angabe ausführbarer (Verarbeitungs-) Schritte. Bei der Konzeption von Algorithmen spielen die Begriffe Terminierung und Determinismus eine Rolle: Ein Algorithmus heißt terminierend, wenn er (bei jeder erlaubten Eingabe von Parameterwerten) nach endlich vielen Schritten abbricht. bzw. Ein Algorithmus terminiert, falls er für alle Eingaben nach endlich vielen Schritten ein Resultat liefert. 16 Algorithmen und Datenstrukturen Ein deterministischer Ablauf ist bestimmt durch die eindeutige Vorgabe der Schrittfolge. Ein determiniertes Ergebnis wird eindeutig erreicht nach vorgegebener Eingabe. Nicht determiniert ist bspw. die zufällige Wahl einer Karte aus einem Kartenstapel. Ein Algorithmus heißt determiniert, falls er bei gleichen Eingaben und Startbedingungen stets dasselbe Ergebnis liefert. Ein Algorithmus heißt deterministisch, wenn zu jedem Zeitpunkt seiner Ausführung höchstens eine Möglichkeit der Fortsetzung besteht; anderenfalls heißt er „nicht deterministisch“. 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 x Oder: Subtrahiere 4 von x und subtrahiere das Ergebnis von x 3. Schreibe das Ergebnis auf 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.1.3 Bausteine für Algorithmen 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: Ö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. 17 Algorithmen und Datenstrukturen 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 jeweils den Programmiersprachen- repeat ... until … do … while ... while … do ... while ( ... ) ... for each ... do … for ... do … for ( ... ) ... - Unterprogramm - 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 keine weiterer Aufruf erforderlich ist und die Rekursion abbrechen kann. 18 Algorithmen und Datenstrukturen 1.2.1.4 Formale Eigenschaften von Algorithmen 1.2.1.4.1 Korrektheit und 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 nachweisen5: 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. /* P */ while (b) { /* P && b */ … /* P */ } /* P && !b */ 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 Korrektheitsformel6 ableiten: { P } A { Q } - P und Q sind Zusicherungen P ist die pre-condition, beschreibt die Bedingungen (constraints). Q ist die post-condition, 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. 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 und Terminierung führen zur totale Korrektheit. Totale Korrektheit ist eine stärkere Anforderung an das Programm. Da das Halteproblem bekanntlich unentscheidbar ist, kann im allgemeinen die totale Korrektheit nicht entschieden werden. 5 E. Dijkstra formulierte das so: Man kann durch Testen die Anwesenheit von Fehlern, aber nicht die Abwesenheit von Fehlern nachweisen. 6 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" 19 Algorithmen und Datenstrukturen 1.2.1.4.2 Effizienz Die zweite wichtige Eigenschaft eines Algorithmus ist seine Effizienz. Die wichtigsten Maße für die Effizienz sind der zur Ausführung des Algorithmus benötigte Speicherplatz und die benötigte Rechenzeit (Laufzeit): 1. Man kann die Laufzeit durch Implementierung des Algorithmus in einer Programmiersprache (z.B. C++) auf einem konkreten Rechner für eine Menge repräsentativer Eingaben messen. Bsp.: Implementierung eines einfachen Sortieralgorithmus in C++ mit Messen der CPU-Zeit. #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 Literatur7 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 Parameter8. Laufzeit und Speicherbedarf eines Algorithmus hängen in der Regel von der Größe der Eingabe ab9. 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. Statt „für die Laufzeit T(N) eines Algorithmus gilt für alle N: T ( N ) ≤ c1 ⋅ N + c 2 mit 2 Konstanten c1 und c2“ sagt man „T(N) ist von der Größenordnung N“ oder „T(N) ist O(N)“ oder „T(N) ist ein O(N)“ und schreibt T ( N ) ∈ O ( N ) . Ziel der Charakterisierung einer Laufzeit T(N) ist es, eine möglichst einfache Funktion g(N) zu finden. Die weitaus häufigsten und wichtigsten Funktionen zur Messung der Effizienz von Algorithmen in Abhängigkeit von der Problemgröße sind: 7 Vgl. Aho, Hopcroft, Ullman: The Design and Analysis of Computer Algorithms, Addison-Wesley Publishing Company 8 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. 9 die im Einheitskostenmaß oder im logarithmischen Kostenmaß gemessen wird 20 Algorithmen und Datenstrukturen Logarithmisches Wachstum N ⋅ log N -Wachstum log N N N ⋅ log N Quadratisches, kubisches, ... Wachstum N 2 , N 3 , ... Exponentielles Wachstum 2 N , 3 N , ... Lineares Wachstum Abb.: 3. 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 überschreitet10. Viele wichtige Problemlösungsverfahren liegen in dem Bereich zwischen leichten und harten Problemen.. Man kann nicht zeigen, dass diese Probleme leicht sind, denn es gibt für sie keinen Polynomialzeit-Algorithmus. Umgekehrt kann man auch nicht sagen, dass es sich um harte Probleme handelt. Der Fakt, dass kein PolynomialzeitAlgorithmus gefunden wurde, schließt die Existenz eines solchem Algorithmus nicht aus. Möglicherweise hat man sich bei der Suche danach bisher noch nicht klug genug angestellt. Es wird dann nach seit Jahrzenten erfogloser Forschung angenommen, dass es für diese Probleme keine polynomiellen Algorithmen gibt. Man spricht in diesem Fall von der Klasse der sog. NP-vollständigen Probleme. 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. 1.2.1.5 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 f ( N ) → N 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. 10 vgl. Abb. 1.2-2 21 Algorithmen und Datenstrukturen Bsp.: Wie oft wird die Wertzuweisung „x = x + 1“ in folgenden Anweisungen ausgeführt? 1. x = x + 1; ............ ..1-mal 2. for (i=1; i <= n; i++) x = x + 1;.. ..n-mal 3. for (i=1; i <= n; i++) for (j = 1; j <= n; j++) x = x + 1;................................... ......... n2-mal Die Aufwandfunktion läßt sich in den wenigsten Fällen exakt bestimmen. Vorherrschende Analysemethoden sind: - Abschätzungen des Aufwands im schlechtesten Fall - Abschätzungen des Aufwands im Mittel Selbst hierfür lassen sich im Allg. keine exakten Angaben machen. Man beschränkt sich dann auf „ungefähres Rechnen in Größenordnungen“. Bsp.: Gegeben: n ≥ 0 a1 , a 2 , a3 ,..., a n ∈ Z Gesucht: Der Index i der (ersten) größten Zahl unter den ai (i=1,...,n) Lösung: max = 1; for (i=2;i<=n;i++) if (amax < ai) max = i Wie oft wird die Anweisung „max = i“ im Mittel ausgeführt (abhängig von n)? Die gesuchte mittlere Anzahl sei Tn. Offenbar gilt: 1 ≤ Tn ≤ n . „max = i“ wird genau dann ausgeführt, wenn ai das größte der Elemente a1 , a 2 , a3 ,..., ai ist. Angenommen wird Gleichverteilung: Für jedes i = 1, ... , n hat jedes der Elemente a1 , a 2 , a3 ,..., a n die gleiche Chance das größte zu sein, d.h.: Bei N Durchläufen wird N/n-mal die Anweisung „max = i“ ausgeführt. Daraus folgt für N ⋅ Tn (Aufwendungen bei N Durchläufen vom „max = i“): N ⋅ Tn = N + N N N 1 1 1 + + ... + = N (1 + + + ... + ) 2 3 n 2 3 n Dies ist Hn, die n-te harmonische Zahl. Für Hn ist keine geschlossene Formel bekannt, jedoch eine ungefähre Abschätzung: Tn = H n ≈ ln n + γ 11. 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. O-Notation läßt sich mathematisch exakt definieren: f ( n) ist für g ( n) genügend große 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 f : Ν → Ν durch Eingabe einer einfachen Vergleichsfunktion g : Ν → Ν 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: f (n) = O( g (n)) :⇔ ∃c, n0 ∀n ≥ n0 : f (n) ≤ c ⋅ g (n) mit f , g : N → N , d.h. 11 Eulersche Konstante γ = 0.57721566 22 Algorithmen und Datenstrukturen 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!) Abb.: Zur Veranschaulichung des Wachstums konnen die folgende Tabellen betrachtet werden: f(N) ldN N N ⋅ ldN N2 N3 2N N=2 1 2 2 4 8 4 24=16 4 16 64 256 4096 65536 25=256 8 256 1808 65536 16777200 ≈ 1077 210 10 1024 10240 1048576 ≈ 109 ≈ 10308 220 20 1048576 20971520 ≈ 1012 ≈ 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.1.5.1 Laufzeitberechnungen („Big-O“-) Ein einfaches Beispiel: Gegeben ist die folgende Funktion zur Berechnung von N ∑i i =1 public static int sum(int n) { /* 1 */ int teilSumme; /* 2 */ teilSumme = 0; /* 3 */ for (int i = 1; i <= n; i++) /* 4 */ teilSumme += i * i * i; /* 5 */ returm teilSumme; } Analyse zur Effizienz: 23 3 Algorithmen und Datenstrukturen Zeile 1 und Zeile 2 zählen je einmal. Zeile 4 zählt viermal (2 Multiplikationen, Zuweisung und 1. Additition) und wird N-mal ausgeführt. Das ergibt 4N. Zeile 3 zeigt die Deklaration und Initialisierung von i (zählt zweimal), den Test i <= N und das Inkrementieren i++ (zählt jeweils N-mal). Insgesamt führt das zu 2N + 2. Ignoriert man Aufruf und Rückkehranweisung der Funktion erhält man 6N + 4. Man sagt dazu: Die Funktion besitzt ein Leistungsverhalten von O(N). Einiges kann bei der Abschätzung offensichtlich beschleunigt werden. In Zeile 3 steht bspw. eine O(1)-Anweisung. Es ist egal (für die Abschätzung der Laufzeitberechnung), ob bei der Ausführung diese Anweisung 2fach oder 3fach gezählt wird. Auch bzgl. der Schleife ist der Faktor 2 und die Addition von 2 unerheblich. Das führt zu folgenden Regeln zur Abschätzung des Leistungsverhaltens nach der „Big-O“-Notation: 1. Regel (für Schleifen): Die Laufzeit einer Schleife ist im wesentlichen bestimmt durch die Anzahl der Anweisungen innerhalb des Schleifenkörpers multipliziert mit der Anzahl der Iterationen. 2. Regel (für verschachtelte Schleifen): Die Laufzeit einer Anweisung innerhalb einer Gruppe verschachtelter Schleifen ist bestimmt durch die Laufzeit der Anweisung multipliziert mit dem Produkt aller Schleifengrößen. Bsp.: for (i=1; i <= n; i++) for (j = 1; j <= n; j++) k++; ist einzuordnen unter O(N2). 3. Regel (aufeinanderfolgende Anweisungen): Der größte Wert zählt für das Leistungsverhalten. Bsp.: for (int i=1; i <= n; i++) a[i] = 0; // O(N) for (int i=1; i <= n; i++) for (int j=1; j <= n; j++) a[i] += i + j; // O(N2) Insgesamt ergibt sich das Leistungsverhalten O(N2). 4. Regel: Die Laufzeit einer „if“-Anweisung ist niemals größer als die Laufzeit des Tests plus der größeren Laufzeit vom „ja“- bzw. „nein“-Zweig. Rekursionen können häufig auf einfache Schleifen mit dem Leistungsverhalten O(N) zurückgeführt werden, z.B.: public static long fakultaet(int n) { if (n <= 1) return 1; else return n * fakultaet(n-1); Liegen in einer Funktion mehrere rekursive Aufrufe vor, dann ist die Umsetzung in eine einfache Schleifenstruktur nicht so einfach. Bsp.: public static long fib(int n) { /* 1 */ if (n <= 1) 24 Algorithmen und Datenstrukturen /* 2 */ return 1; else /* 3 */ returm fib(n-1) + fib(n-2); } Die Analyse ergibt unter der Annahme, daß T(N) die Laufzeit nach einem Aufruf von fib(n) ist: Für N = 0, N = 1 ist T(0) = T(1) = 1 (irgendein konstanter Wert. In Zeile 3 wird fib(N-1) aufgerufen, was eine Laufzeit von T(N-1) bewirkt. Anschließend wird fib(N-2) aufgerufen, was eine Laufzeit von T(N-2) bewirkt. Zusammengezählt ergibt das: T(N) = T(N-1) + T(N-2) + 2. Da fib(N) = fib(N-1) + fib(N-2) ist, kann leicht gezeigt werden, daß T(N) >= fib(N) ist. 5 Man kann zeigen: fib( N ) < ( ) N . Das bedeutet: Die Laufzeit dieses Programms 3 wächst exponentiell (schlechter geht es nicht mehr). Man kann häufig dasselbe Problem mit verschieden Algorithmen lösen. Das Ziel ist natürlich, den für das Problem besten Algorithmus zu finden bzw. zu implementieren: Bsp.: Das Maximum-Subarray-Problem Gegeben ist eine Folge X von N ganzen Zahlen in einem Array. Gesucht ist die maximale Summe aller zusammenhängenden Teilfolgen. Sie wird als maximale Teilsumme bezeichnet. So ist für die Eingabefolge X[0] 31 X[1] -41 X[2] 59 X[3] 26 X[4] -53 X[5] 58 X[6] 97 X[7] -93 X[8] -23 X[9] 84 die Summe der Teilfolgen X[2] + X[3] + X[4] + X[5] + X[6] mit dem Wert (59 + 26 – 53 + 58 + 97) = 187 die Lösung des Problems. Lösungen zu diesem Problem können auf verschiedene Weise erreicht werden: 1. Lösung public { /* 1 /* 2 /* 3 /* /* /* /* /* 4 5 6 7 8 /* 9 } static int maxSubsum1(int a[]) */ int maxSumme = 0; */ for (int i = 0; i < a.length;i++) */ for (int j = i; j < a.length; j++) { */ int summe = 0; */ for (int k = i; k <= j; k++) */ summe += a[k]; */ if (summe > maxSumme) */ maxSumme = summe; } */ return maxSumme; N Die Analyse des Leistungsverhaltens wird bestimmt durch N j ∑∑∑1 = O( N i =1 j =i k =i berechnet, wieviele Male Zeile 6 ausgeführt wird. 2. Lösung public static int maxSubsum2(int a[]) { /* 1 */ int maxSumme = 0; /* 2 */ for (int i = 0; i < a.length;i++) { /* 3 */ int summe = 0; 25 3 ) . Diese Summe Algorithmen und Datenstrukturen /* 4 */ /* 5 */ /* 6 */ /* 7 */ /* 8 */ } for (int j = i; j < a.length; j++) { summe += a[j]; if (summe > maxSumme) maxSumme = summe; } } return maxSumme; In dieser Lösung ist das Leistungsverhalten auf O(N2) reduziert. 3. Lösung Diese Lösung folgt der „Divide-and-Conquer“-Strategie, die ein sehr allgemeines und mächtiges Prinzip zur algorithmischen Lösung von Problemen darstellt. Das zugehörige Problemlösungsschema kann allg. so formuliert werden: 1. Divide: Teile das Problem der Größe N in (wenigstens) 2 annähernd gleich große Teilprobleme, wenn N > 1 ist, sonst löse das Problem der Größe 1 direkt. 2. Conquer: Löse die Teilprobleme auf dieselbe Art. 3. Merge: Füge die Teillösungen zur Gesamtlösung zusammen. Abb.: Divide and Conquer-Verfahren zur Lösung eines Problems der Größe N Bei der Anwendung dieses Algorithmus auf das vorliegende Problem bewirkt das Teilen der Folge in Teilfolgen evtl. das Trennen der Teilfolge mit der größten Teilsumme, z.B.: Bei der Vorgabe a[0] 4 a[1] -3 a[2] 5 1. Häfte a[3] -2 a[4] -1 a[5] 2 a[6] 6 2. Hälfte a[7] -2 ist die größte Teilsumme in der ersten Teilhälfte 6 (a[0] + a[1] + a[2]), die größte Teilsumme in der zweiten Teilhälfte ist 8 (a[5] + a[6]). Die maximale Summe in der 1. Hälfte, die das letzte Element in der 1. Hälfte mit einschließt (a[0] + a[1] + a[2] + a[3]) ist 4. Die maximale Summe in der 2. Hälfte, die das erste Element in der 2. Hälfte einschließt ist 7. Die maximale Summe, die beide Hälften überspannt ist 4 + 7 = 11. Der Algorithmus muß demnach Teilsummenbildungen über die jeweilige Teilhälften berücksichtigen. private static int maxSubsum(int a[], int links, int rechts) { /* 1 */ if (links == rechts) /* 2 */ if (a[links] > 0) // Falls dieses Element positiv ist /* 3 */ return a[links]; // dann ist es die max.Teilsumme /* 4 */ else return 0; /* 5 */ int mitte = (links + rechts) / 2; /* 6 */ int maxLinkeSumme = maxSubsum(a, links, mitte); /* 7 */ int maxRechteSumme = maxSubsum(a, mitte + 1, rechts); /* 8 */ int maxLinkeGrenzSumme = 0, linkeGrenzsumme = 0; /* 9 */ /*10 */ /*11 */ /*12 */ /*13 */ /*14 */ /*15 */ /*16 */ /*17 */ /*18 */ for (int i = mitte; i >= links; i--) { linkeGrenzsumme += a[i]; if (linkeGrenzsumme > maxLinkeGrenzSumme) maxLinkeGrenzSumme = linkeGrenzsumme; } int maxRechteGrenzSumme = 0, rechteGrenzsumme = 0; for (int i = mitte + 1; i <= rechts; i++) { rechteGrenzsumme += a[i]; if (rechteGrenzsumme > maxRechteGrenzSumme) maxRechteGrenzSumme = rechteGrenzsumme; } if (maxLinkeSumme > maxRechteSumme) 26 Algorithmen und Datenstrukturen } if (maxLinkeSumme > (maxRechteGrenzSumme + maxLinkeGrenzSumme)) return maxLinkeSumme; else return (maxRechteGrenzSumme + maxLinkeGrenzSumme); else if (maxRechteSumme > (maxRechteGrenzSumme + maxLinkeGrenzSumme) ) return maxRechteSumme; else return (maxRechteGrenzSumme + maxLinkeGrenzSumme); public static int maxSubsum3(int a[]) { return maxSubsum(a, 0, a.length - 1); } Die Anwendung des Lösungsverfahrens auf das Implementierung mit dem Leistungsverhalten O(NlogN). Max-Subarray-Problem führt zu einer 4. Lösung: Implementierung mit dem Leistungsverhalten O(N) Die Positionen 0,..,N-1 der Eingabefolge bilden eine aufsteigend sortierte, lineare Folge von Inspektionsstellen (oder: Ereignispunkten). Man durchläuft die Eingabe in der durch die Inspektionsstelleb vorgegebenen Reihenfolge und führt zugleich eine vom jeweiligen Problem abhängige, dynamisch veränderliche, d.h. an jeder Informationsstelle gegebenenfalls zu korrigierende Information mit. Im vorliegenden Fall ist das die maximale Summe einer Teilfolge (maxSumme) im gesamten bisher inspizierten Anfangsteil und das an der Inspektionstelle endende rechte Randmaximum (summe) des bisher inspizierten Anfangsstücks. public static int maxSubsum4(int a[]) { /* 1 */ int maxSumme = 0, summe = 0; /* 2 */ for (int j = 0; j < a.length; j++) { /* 3 */ summe += a[j]; /* 4 */ if (summe > maxSumme) /* 5 */ maxSumme = summe; /* 6 */ else if (summe < 0) /* 7 */ summe = 0; } /* 8 */ return maxSumme; } Das ist ein Algorithmus, der in linearer Zeit ausführbar ist. Zur Bestimmung der maximalen Teilfolge müssen alle Folgeelemente wenigstens einmal betrachtet werden. Das sind insgesamt N Schritte. 1.2.1.5.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 27 Algorithmen und Datenstrukturen 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. 1.2.1.5.3 Effizienz und Berechnungsgrundlagen für rechnerische Komplexität 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. Berechnungsgrundlagen für rechnerische Komplexität 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. 28 Algorithmen und Datenstrukturen Ein anderes klassisches Problem ist dagegen das Problem des Handlungsreisenden12 (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 exponetiell, d.h. mit aN für irgendeinen problemspezifischen Wert zu. Im vorliegenden Fall ist a etwa 1.26. - polynomiales Zeitverhalten In der Regel ist polynomiales Zeitverhalten das beste, auf das man hoffen kann. Hiervon redet man, wenn man die benötigte Rechenzeit durch ein Polynom T = a n N + ... + a 2 N + a1 N + a 0 n 2 ausgedrückt werden. „N“ ist bestimmt durch die zu suchenden problemspezifischen Werte, n beschreibt den Exponenten. Da gegen das erste Glied mit der höchsten Potenz bei größeren Objektzahlen alle anderen Terme des Ausdrucks vernachlässigt werden können, klassifiziert man das polynomiale Zeitverhalten nach dieser höchsten Potenz. Man sagt, ein Verfahren zeigt polynomiales 12 Vorbild für viele Optimierungsaufgaben, wie sie vor allem im Operations Research immer wieder vorkommen. 29 Algorithmen und Datenstrukturen 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. 30 Algorithmen und Datenstrukturen 1.2.2 Daten und Datenstrukturen 1.2.2.1 Der Begriff Datenstruktur 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: 31 Algorithmen und Datenstrukturen Josef Juergen Liesel Abb. 1.2-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: 32 Algorithmen und Datenstrukturen STUDENT FACH DATEN DOZENT DATEN DATEN DATEN DATEN DATEN DATEN DATEN DATEN DATEN geordnet (z.B. nach Matrikelnummern) geordnet (z.B. nach Titel im Vorlesungsverzeichnis) geordnet (z.B. nach Namen) Abb.: 1.2-2: 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.2-3: Darstellung der Zusammensetzung eines Geräts 33 Algorithmen und Datenstrukturen 2 Relationen können hier unterschieden werden: 1) Beziehungsverhältnisse eines Knoten zu seinen unmittelbaren Nachfolgeknoten. Die Relation Analyse beschreibt den Aufbau eines Gerätes 2) Die Relation Vorrat gibt die Knoten mit w2k <= 3 an. Die Beschreibung eines Geräts erfordert in der Praxis eine weit komplexere Datenstruktur (größere Knotenzahl, zusätzliche Relationen). 3. Eine Bibliotheksverwaltung 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.2-4: 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: 34 Algorithmen und Datenstrukturen Person Person „Juergen“ Person „Josef“ Buch Buch „Stroustrup“ „Goldberg“ Buch „Lippman“ Abb. 1.2-5: 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.2-6: 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. 35 Algorithmen und Datenstrukturen 1.2.2.2 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.2-7: 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) 36 Algorithmen und Datenstrukturen Relationen sind umkehrbar. Die Beziehungen zwischen 2 Grössen x und y können auch als Beziehung zwischen y und x dargestellt werden, z.B.: Aus „x ist Vater von y“ wird durch Umkehrung „y ist Sohn von x“. Allgemein gilt: R-1 = { (y,x) | ( x , y ) ∈ R } 3. Reflexive Relation ∀ ( x , x ) ∈ R (Für alle Elemente x aus M gilt, x steht in Relation zu x) x ∈M Beschreibt man bspw. die Relation "... ist Teiler von ..." für die Menge M = {2,4,6,12} in einem Grafen, so erhält man: 12 4 6 2 Abb.1.2-8: 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 37 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: (1) M x ∩ M y = 0 (2) M1 ∪ M 2 ∪....∪ M y = M (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 38 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.2-9: Totale und partielle Ordnungsrelationen 5. „Natürliche Ordnungsbeziehungen“ in Java 39 c Algorithmen und Datenstrukturen Das Comparable Interface aus dem Paket java.lang dient zum Herstellen „natürlicher Ordnungsbeziehungen“: /**************** Comparable.java ***************************** /** Das Interface deklariert eine Methode anhand der sich das * das aufgerufene Objekt mit dem uebergebenen vergleicht. * Fuer jeden vom Objekt abgeleiteten Datentyp muss eine solche * Vergleichsklasse implementiert werden. * Die Methode erzeugt eine Fehlermeldung, wenn "a" ein Objekt * einer anderen Klasse als dieses Objekt ist. * * int compareTo(Comparable a) * liefert 0, wenn this == 0 * liefert < 0, wenn this < a * liefert > 0, wenn this > a * */ public interface Comparable { public int compareTo(Comparable a); } Seit dem JDK 1.2 wird das Comparable Interface bereits von vielen eingebauten Klassen implementiert, etwa von String, Character, Double, usw. Die natürliche Ordnung ergibt sich, indem man alle Elemente paarweise miteinander vergleicht und dabei jeweils das kleinere vor der größere Element stellt. Besitzt eine Klasse das Interface Comparable nicht, dann kann auch eine Implementierung des Interface Comparator vorgesehen werden. /**************** Comparator.java ***************************** /** Das Interface deklariert zwei Methoden zur Durchfuehrung von * Vergleichen. * Die Methode equals() führt auf den Rückgabewert true bzw. false, * je nachdem ob this == o ist. * compare() hat folgende Rückgabewerte: * Falls das erste Element vor dem zweiten Element kommt, * ist der Rückgabewert negativ. * Falls das erste Element nach dem zweiten Element kommt, * ist der Rückgabewert positiv * Der Rückgabewert 0 signalisiert, dass die beiden Elemente an * der gleichen Ordnungsposition eingeordnet werden. */ public interface Comparator { public int compare(Object element1, Object element2); public boolean equals(Object o); } 40 Algorithmen und Datenstrukturen 1.2.2.3 Klassifikation von Datenstrukturen Eine Datenstruktur ist ein Datentyp mit folgenden Eigenschaften 1. Die 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: Eine Datenstruktur ist durch Anzahl und Eigenschaften der Relationen bestimmt. Obwohl sehr viele Relationstypen denkbar sind, gibt es nur 4 fundamentale Datenstrukturen13, 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. 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. 2. Bäume Sie sind im wesentlichen durch die Äquivalenzrelation bestimmt. 13 nach: Rembold, Ulrich (Hrsg.): "Einführung in die Informatik", München/Wien, 1987 41 Algorithmen und Datenstrukturen 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.: 1.2-10: 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. ........ Die Definitionen eines Baums mit Hilfe von Äquivalenzrelationen regelt ausschließlich "Vorgänger/Nachfolger" - Beziehungen (in vertikaler Richtung) zwischen den Knoten eines Baums. Ein Baum ist auch hinsichtlich der Baumknoten in der horizontalen Ebene geordnet, wenn zur Definition des Baums neben der Äquivalenzrelation auch eine partielle Ordnung (Knoten Ki kommt vor Knoten Kj, z.B. Kapitel1 kommt vor Kapitel 2) eingeführt wird. 3. Graphen In seiner einfachsten Form besteht eine Verkörperung dieser Datenstruktur aus einer Knotenmenge K (Objektmenge) und einer festen aber beliebigen Relation R über dieser Menge14. 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.2-11: Ein Graph der Netzplantechnik 14 vgl. 1.2.2.2, Abb. 1.2-7 42 Algorithmen und Datenstrukturen 4. Dateien 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 Studenten15 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. 5. Datenbanken Eine Datenbank ist die Sammlung von verschiedenen Datensatz-Typen. Die Datensätze sind in einer Codasyl-Datenbank16 untereinander verbunden, z.B. alle Studenten im Fachbereich „Informatik und Mathematik“ der Fachhochschule Regensburg, z.B.: Fachbereich Informatik Student_1 Student_2 .... Student_n Abb.: 1.2-12: Erscheinungsbild der Datensätze „Fachbereich“ und „Student“ Der letzte Studentensatz zeigt auf den Satz „Fachbereich Informatik und Mathematik“ zurück. Diese detaillierte Darstellung der physischen Struktur kann auf folgende Beschreibung der logischen Datenstruktur zurückgeführt werden: Fachbereich betreut Student Abb.: 1.2-13: Logische Datenstruktur 15 16 vgl. 1.2.2.1 Datenbank der Data Base Task Group der Conference on Data Systems Languages (CODASYL) 43 Algorithmen und Datenstrukturen 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: Fachbereich 1 betreut M Student Abb. 1.2-14: „ER“-Diagramm zur Darstellung der Beziehung „Fachbereich-Student“ 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.: Person 1 1 Heirat Abb.: 1.2-15: Bezugnahme auf den gleiche Entitätstyp „Person“ Die folgende Darstellung einer Datenbank in einem ER-Diagramm Abt_ID Bezeichnung Job_ID Titel Abteilung Job Abt-Ang Job-Ang Angestellte Ang_ID Gehalt Name GebJahr Abb. 1.2-16: ER-Diagramm zur Datenbank Personalverwaltung 44 Qualifikation Algorithmen und Datenstrukturen 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: 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 JOB_ID SY PR OP IN SY PR KA PR OP TA IN SY 45 Algorithmen und Datenstrukturen A10 A11 A12 A13 A14 IN KA SY IN KA Abb. 1.2-17: Tabellen zur relationalen Datenbank Die relationale Datenbank besteht, wie die vorliegende Darstellung zeigt aus einer Datenstruktur, die Dateien (Tabellen) vernetzt. Die Verbindung besorgen Referenzen (Fremdschlüssel). Die vorliegende Einteilung der Datenstrukturen zeigt: Sammeln und Ordnen der durch die reale Welt vorgegebenen Objekte ist eine der wichtigsten und häufigsten Anwendungen in der Datenverarbeitung. Leider unterstützen herkömmliche Programmiersprachen nicht umfassend genug diese Möglichkeiten. Erst die objektorientierte Programmierung (vor allem Smalltalk) haben hier den Ansatz zu einer umfassenden Implementierung (Collection, Container) gezeigt. 1.2.3 Definitionsmethoden für Datenstrukturen 1.2.3.1 Der abstrakte Datentyp 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). Der ADT ist eine Kapsel, der die gemeinsame Deklaration von Daten und Algorithmen zu einem Typ zusammenfaßt. Datenkapseln sind elementare Bausteine, die den konventionellen Programmierstil (Programm = Algorithmus + Daten) auf ein anspruchvolleres Niveau anheben. Die Datenkapsel betrachtet Daten und Rechenvorschrift als eine Einheit. Das bedeutet aber auch: Für die Ausführung einer Aufgabe ist die Datenkapsel selbst verantwortlich. Der Anwender hat damit nichts zu tun. Er teilt dem durch die Datenkapsel bestimmten Objekt lediglich über eine Botschaft mit, daß er eine spezielle Funktion ausgeführt haben möchte. Das Empfangsobjekt wählt daraufhin eine ihm bekannte Methode aus und führt die dazugehörige Prozedur aus. Das Ergebnis der Methode wird vom Objekt an den den Sender der Botschaft wieder zurückgeschickt. Die durch die Datenkapsel realisierte Einheit hat einen speziellen Namen: Objekt. Den zugehörigen Programmierstil nennt man: objektorientierte Programmierung. Die objektorientierte Sichtweise faßt Daten, Prozeduren und Funktionen zu möglichst realistischen Modellen (Objekten) der Wirklichkeit zusammen. Zugriff auf objektorientierte Modelle ist nur den Methoden (Prozeduren und Funktionen) erlaubt. Eine Methode gehört zu einem Objekt mit dem Zweck die Daten des Oblekts zu bearbeiten. Nachrichen (Botschaften) sind neben den Objekten das 2. wesentliche Element in objektorientierten Programmiersprachen. Objekte machen nur dann etwas, wenn sie eine Nachricht empfangen und für diese Nachricht eine Methode haben. Andernfalls geben sie die Nachricht an die Klasse weiter, der das Objekt angehört. Klassen (Objekttypen) sind Realisierungen abstrakter Datentypen und umfassen: Attribute (Eigenschaften) , Methoden, Axiome. Sie beschreiben Zustand und Verhalten gleichartiger Objekte. 46 Algorithmen und Datenstrukturen Generell gilt: Ein neues Objekt (Instanz, Exemplar) einer Klasse erbt alle Eigenschaften der Klasse. Man kann aber diesem Erbe Eigenschaften hinzufügen bzw. Methoden streichen bzw. modifizieren. Falls der Erbe selbst Nachkommen erhält, dann geschieht folgendes: 1. Die Instanz wird zur Klasse 2. Die Nachkommen erben die Eigenschaftten der Vorfahren Jede Klasse in einem objektorientierten Programmiersystem (OOP) hat einen Vorfahren Eine unmittelbare Implementierung der objektorientierten Programmierung (und damit von ADT) gibt es erst seit 1981 (Smalltalk-80)17. In der Praxis ist dieser Programmierstil erst seit 1980 verbreitet. Aktuell ist die objektorientierte Programmierung vor allem durch die inzwischen weit bekannte Programmier-sprache C++ bzw. Java. 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 ADT18 bereitgestellt. Damit sollte dem Programmierer wenigstens durch die Spezifikation die Einheit von Daten und zugehörigen Operationen vermittelt werden. 1.2.3.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. 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 17 18 → Schlange<T> vgl. BYTE, Heft August 1981 vgl. Guttag, John: "Abstract Data Types and the Development of Data Structures", CACM, June 1977 47 Algorithmen und Datenstrukturen FuegeHinzu Vorn Entferne Leer : : : : T,Schlange<T> → Schlange<T> → T Schlange<T> Schlange<T> → Schlange<T> Schlange<T> → boolean 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. 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 48 Algorithmen und Datenstrukturen 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> links : Binaerbaum<T> → Binaerbaum<T> rechts : Binaerbaum<T> → Binaerbaum<T> wert : Binaerbaum<T> -> T istLeer : Binaerbaum<T> → boolea 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. 1.2.3.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. 49 Algorithmen und Datenstrukturen 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. 1.2.3.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“, Anfang des Stapels verweist. Operationen Konstruktor: Initialisierungswerte: keine Verarbeitung: Initialisiere „top“. Push Eingabe: Ein Datenelement zur Aufnahme in den Stapel 50 sie auf den Algorithmen und Datenstrukturen 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. 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: 51 Algorithmen und Datenstrukturen 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) 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 52 Algorithmen und Datenstrukturen 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 - 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. 53 Algorithmen und Datenstrukturen 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. Ein A ist immer mit Ein A ist immer mit Ein A ist mit einem B assoziiert einem oder mehre- keinem oder ren B assoziiert einem B assoziiert Ein A ist mit keinem, einem oder mehreren B assoziiert Unified A 1 B A 1..* B A 0..1 B A * B 1:1 1..* 1:1..n 0..* 2..6 0..* * 17 4 n m 0..n:2..6 0..n:0..n 17:4 ? Abb.: Kardinalitäten für Beziehungen Pfeile in Klassendiagrammen zeigen Navigierbarkeit an. Wenn die Navigierbarkeit nur in einer Richtung existiert, nennt man die Assoziation eine gerichtete Assoziation (uni-directional association). Eine ungerichtete (bidirektionale) Assoziation enthält Navigierbarkeiten in beiden Richtungen. In UML bedeuten Assoziationen ohne Pfeile, daß die Navigierbarbeit unbekannt oder die Assoziation ungerichtet ist. Ungerichtete Assoziationen enthalten eine zusätzliche Bedingung: Die zu dieser Assoziation zugehörigen zwei Rollen sind zueinander invers. Abhängigkeit (dependency): Manchmal nutzt eine Klasse eine andere. Die UMLNotation ist dafür oft eine gestrichelte Linie mit einem Pfeil, z.B.: 54 Algorithmen und Datenstrukturen 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.: 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 55 Algorithmen und Datenstrukturen 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 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. 56 Algorithmen und Datenstrukturen <<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. 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 57 Algorithmen und Datenstrukturen 1.2.3.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.2.3.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. 58 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. 59 Algorithmen und Datenstrukturen 1.2.3.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 Stapel19 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) 19 PR12351.CPP 60 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 << ">"; } 61 Algorithmen und Datenstrukturen 1.2.3.6 Die Implementierung abstrakter Datentypen in Java 1.2.3.6.1 Klassen und Schnittstellen (Referenzdatentypen) 1. Klassen Top-level Klassen Die Verwendung von Klassen ist in Java, im Gegensatz zu C++, zwingend. Jede Klasse bildet einen abgeschlossenen Sichtbarkeitsbereich für die darin definierten Attribute und Methoden. Eine Java-Klasse besteht aus der in der folgenden Abbildung dargestellten Bestandteilen: public abstract final class KlassenName extends KlassenName implements InterfaceListe { // Attribute type attributName; // Konstruktor public KlassenName() { // ... } // Methoden public xyz( ... ) { ... } ...... } Abb.: Bestandteile von Java-Klassen Am Anfang steht die Klassendeklaration. Sie benennt die Klasse eindeutig, und legt die allgemeinen Merkmale der Klasse fest: public abstract final class KlassenName extends KlassenName implements InterfaceListe Die Klasse ist allgemein sichtbar. Vorgabegemäß kann eine Klasse nur von Klassen desselben Packages genutzt werden. Durch das Schlüsselwort public wird sie auch außerhalb des umgebenden Packages sichtbar und zugreifbar. Die namensgebende Klasse einer Java-Quellcode-Datei, due auch die main()-Methode enthalten kann, muß zwingend als public erklärt sein. Abstrakte Klassen dienen zur Strukturierung des Entwurfs, um gemeinsame Merkmale verschiedener Klassen zentralisiert ausdrücken zu können. Von einer abstrakten Klasse kann nicht geerbt werden. So können Vererbungsgäste ausgeschlossen werden.An dieser Stelle kann keine Weiterentwicklung erfolgen. Das Schlüsselwort verhindert einen entsprechende Versuch zum Übersetzungszeitpunkt. Name der Klasse Die Klasse erbt von der Klasse KlassenName. In Java ist nur einfache Vererbung zugelassen, daher kann an dieser Stelle auch maximal nur eine Superklasse spezifiziert werden. Die Klasse implementiert die in InterfaceLIste aufgeführten Schnittstellen. Die Schnittstellennamen werden in der Liste durch 62 Algorithmen und Datenstrukturen { // Klassenkörper } Kommata getrennt. Ausprogrammierter Klassenrumpf Objekterzeugung: Die Erzeugung konkreter Ausprägungen (auch Instanzen oder Objekte) einer Klasse geschieht üblicherweise durch den new-Operator: Type variable = new Type(parameter) Innere Klassen Lokale Klassen: Klassen können innerhalb von Klassen definiert werden, z.B. class Aussen { ... class Innen { ... } ... } - Objekte der inneren Klassen können auf private Daten des erstellenden Objekts zugreifen Innere Klassen unterliegen der Sichtbarkeit der umgebenden Klasse, d.h. sie sind vor Zugriffen anderer Klassen geschützt. Für innere Klassen wird eine eigenständige class-Datei mit der Namenskonvention UmgebendeKlasse$innereKlasse erzeugt. Anonyme Klassen: Die eingebettete Klasse kann anonym definiert sein, z.B. new Basisklasse(parameter) { // Datenelemente und Methoden der inneren Klasse }; Java setzt für anonyme Klassen den Namen aus dem Namen der umgebenden Klasse, dem Zeichen $ und einer fortlaufenden Nummer zusammen. Wrapper-Klassen Korrespondierend zu jedem primitiven Datentyp in Java gibt es einen Wrapper-Typ. Sie kapselt den zugrundeliegenden Primitivtyp in eine eigene Klasse und stellt einige Servicemethoden bereit. Primitivtyp boolean byte short int long float double void20 20 Wrapper-Typ Boolean Byte Short Integer Long Float Double Void21 kann nur als Rückgabetyp von Operationen spezifiziert werden 63 Algorithmen und Datenstrukturen Alle Wrapper-Typen bieten bestimmte Service-Methoden an. - - Alle Wrapper-Typen sind final deklariert und können nicht weiter spezialisiert werden. ...Value(), z.B. booleanValue(), byteValue(), shortValue(), intValue(), longValue(), floatValue(), doubleValue(). All diese Methoden geben den intern gekapselten Wert unverändert zurück. Darüber hinaus existieren Methoden zur Ausgabe eines numerischen Wrapper-Typs in einen beliebigen numerischen Primitivtyp22. Konstruktoren erlauben die Erzeugung eines Wrappertyps aus einem Ausdruck vom Typ des gekapselten Typs oder aus einem String. compareTo() vergleicht den gekapselten zweier Wrappertyp-Objekte. parse...() Methoden, z.B. parseByte(String s), parseByte(String s, int radix), parseInt(String s), parseInt(String s, int radix), erzeugen aus Zeichenketten den jeweiligen Primitivtyp. valueOf(). Für alle Wrapper-Typen integraler Primitivtypen (byte, char, long, int, short) ist mit der valueOf()-Methode eine (Factory-) Methode zur Erzeugung eines neuen WrapperObjekts aus einer Zeichenkette definiert. die üblichen arithmetischen Operationen stehen zur Verknüpfung von Wrapperobjekten nicht zur Verfügung (kein Operator overloading). Boxing / Unboxing: Mit Java 1.5 eingeführte dynamische Umwandlung zwischen primitiven Werten und den zugehörigen Wrapper-Klassen. Boxing: automatische Wrapper-Erzeugung Unboxing: Wandlung eines objektgekapselten Werts in einen Primitivwert. Die automatische Typumwandlung wird mit Java 1.5 ausschließlich für Übergabeparameter angeboten. Bsp.23: public class BUBTest1 { public static void main(String[] args) { BUBTest1 obj = new BUBTest1(); obj.boxIt( new Integer(42) ); obj.boxIt( 42 ); //dynamic boxing obj.unBoxIt( 42 ); //nothing new obj.unBoxIt( new Integer(42) ); //dynamic unboxing } //main() public void boxIt(Integer i) { System.out.println("value="+i); } //boxIt public void unBoxIt(int i) { System.out.println("value="+i); } //unBoxIt } //class BUBTest1 Der Boxingvorgang kann außer für Wrapper-Typen auch für Objekte der Klasse Object durchgeführt werden. Da diese als Superklasse aller Wrapper-Typen angelegt ist, ist diese Typkonversion typkonform. Bsp.24: public class BUBTest2 { 21 kann nicht instanziiert werden ersetzen die explizite Typumwandlung 23 pr12361 24 pr12361 22 64 Algorithmen und Datenstrukturen public static void main(String[] args) { BUBTest2 obj = new BUBTest2(); obj.boxIt( new Integer(42) ); obj.boxIt( 42 ); //dynamic boxing } //main() public void boxIt(Object i) { System.out.println("value=" + i); } //boxIt } //class BUBTest2 Die umgekehrte Richtung, d.h. die Verwendung einer Ausprägung von Object an einer Stelle an der eine konkrete Zahl (z.B. int-Zahl) erwartet wird ist – nach Maßgabe der nicht typsicher möglichen Konversion entgegen der Vererbungsrichtung - nicht möglich. Aufzählungstypen Im einfachsten Anwendungsfall besteht die Definition von Aufzählungstypen aus der durch Kommata voneinander getrennten, vollständigen Aufzählung aller Werte. Bsp.25: Aufzählungstyp jahreszeiten, der durch die zulässigen Werte winter, fruehling, sommer, herbst festgelegt ist. public class EnumTest1 { enum jahreszeiten { winter, fruehling, sommer, herbst } public static void main(String args[]) { jahreszeiten jz1 = jahreszeiten.winter; if (jz1 == jahreszeiten.winter) System.out.println("Es ist " + jz1); } } Die Verwendung von Aufzählungstypen entspricht der von Primitivtypen. Aus diesem Grund ist keine Anforderung von Speicherplatz durch das new-Schlüsselwort26 notwendig. Aufzählungstypen lassen sich auch "klassenartig" ausbauen. Diese Erweiterung erlaubt es die Elemente des Aufzählungstypes wahlfrei an selbstdefinierte Eigenschaften zu binden. Konzeptionell werden diese Eigenschaften dabei als Attribute eines Aufzählungstyps aufgefasst, die durch einen vom Programmierer bereitzustellenden Konstruktor zugewiesen werden. Der Konstruktoraufruf erfolgt dabei automatisch durch das Laufzeitsystem zum Definitionszeitpunkt eines Aufzählungstyps für alle konstituierenden Werte. Bsp.27: public class EnumTest2 { public enum Muenzen { einCent(1), zweiCent(2), fuenfzent(5), zehnCent(10), zwanzigCent(20), fuenfzigCent(50); 25 pr12361 Der Übersetzer verhindert sogar aktiv die Instanziierung eines Aufzählungstyps über new und bricht mit einer Fehlermeldung ab 27 pr12361 26 65 Algorithmen und Datenstrukturen } private int wert; Muenzen(int wert) { this.wert = wert; } public int wert() { return wert; } } public static void main(String args[]) { Muenzen m = Muenzen.fuenfzigCent; System.out.println(m.wert()); } 2. Schnittstellen (Interfaces) Java erlaubt es nicht, dass eine Klasse mehr als eine Superklasse hat. Mehrfachvererbung ist daher in Java nicht ohne Weiters möglich. Eine Klasse kann aber beliebig viele Interfaces implementieren. Java-Schnittstellen umfassen im wesentlichen Operationen, d.h. Methodensignaturen ohne eigene Implementierung. Ein Interface ist ein ReferenzTyp (, ebenso wie eine Klasse eine Klasse) und gibt Signaturen – Namen und Typen von Methoden (und Konstanten) vor. Interface sind reine Spezifikationen: - - ein Interface wird mit dem Schlüsselwort interface deklariert. ein Interface enthält keinerlei Methoden-Implementation. Alle Methoden sind implizit abstrakt, ( auch wenn ohne diesen Modifikator deklariert). Ein Interface kann nur Instanz-Methoden enthalten. Ein Interface ist ohne Sichbarkeitsmodifikator paketsicher. Als einziger Sichtbarkeitsmodifikator darf public angegeben werden. Alle Methoden sind implizit public, auch wenn der Modifikator nicht explizit angegeben ist. Es ist ein Fehler protected oder private in einem Interface zu deklarieren. Ein Interface kann keine Instanz-Felder deklarieren, aber als static oder final deklarierte Konstanten. Da ein Interface nicht instanziiert werden kann, definiert es keinen Konstruktor. Benutzung: Wenn eine Klasse ein Interface implementiert, können Objekte dieser Klasse Variable vom Typ des Interfaces zugewiesen werden. 66 Algorithmen und Datenstrukturen 1.2.3.6.2 Generische Typen Typvariable Mit Java 1.5 wird das Typsystem auf generische Typen erweitert. Die Idee für generische Typen ist es eine Klasse zu schreiben, die für verschiedene Typen als Inhalt zu benutzen ist. Die Speicherung beliebiger Objekte kann Java bis zur Version 1.4 nur in Feldern vom Typ Object vollziehen. Allerdings geht damit die typische statische Typinformation verloren. Dynamische Typzusicherung ist in diesem Fall für weitere sinnvolle Nutzung der Objekte unerläßich. Dynamische Typzusicherung kann aber zu Laufzeitfehlern führen: Der folgende generische Binärbaumknoten28 besitzt einen entscheidenden Nachteil, den eine Übersetzung mit Java 1.5 aufdeckt: // Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist class BinaerBaumknoten { // Instanzvariable protected BinaerBaumknoten links; protected BinaerBaumknoten rechts; public Comparable daten; // linker Teilbaum // rechter Teilbaum // Dateninhalt der Knoten // Konstruktor public BinaerBaumknoten(Comparable datenElement) { this(datenElement, null, null ); } public BinaerBaumknoten(Comparable datenElement, BinaerBaumknoten l, BinaerBaumknoten r) { daten = datenElement; links = l; rechts = r; } public void insert (Comparable x) { if (x.compareTo(daten) > 0) // dann rechts { if (rechts == null) rechts = new BinaerBaumknoten(x); else rechts.insert(x); } else // sonst links { if (links == null) links = new BinaerBaumknoten(x); else links.insert(x); } } public BinaerBaumknoten getLinks() { return links; } public BinaerBaumknoten getRechts() { return rechts; } } 28 pr12362 67 Algorithmen und Datenstrukturen Beim Übersetzen mit Java 1.5 erhält man folgende Hinweise: Note: BinaerBaumknoten.java uses unchecked or unsafe operations Note: Recompile with –Xlint:unchecked for details Führt man den letzten Hinweis aus, dann zeigt der Compiler das Problem an: Mit Java 1.5 wird das Typsystem auf generische Typen erweitert. Eine generische Klasse, die statische Typsicherheit garantiert, muß jedes Auftreten des Typs Object (im vorliegenden Fall des Referenzdatentyps Comparable) durch einen Variablennamen ersetzen. Die Variable ist eine Typvariable, die für einen beliebigen Typ steht. Dem Klassennamen wird zusätzlich im Rahmen der Klassendefinition - in spitzen Klammern eingeschlossen - hinzugefügt, dass diese Klasse eine Typvariable benutzt. Einschränkung der Typvariablen: Bei der Definition der Schablone kann eingeschränkt werden, dass eine Typvariable nur für bestimmte Typen ersetzt werden darf. Es kann vorgeschrieben werden, dass der Typ eine bestimmte konkrete Schnittstelle implementieren muß. In der angegebenen Lösung wird der Typ Comparable durch eine Typvariable ersetzt. Diese Variable steht für alle Typen, die SubTypen der Schnittstelle Comparable sind. Ersetze29 in der unter 2. angegebenen Lösung den Typ Comparable durch eine Typvariable mit der Angabe, dass diese Variable für alle Typen steht, die SubTypen der Schnittstelle Comparable sind. // Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist class BinaerBaumknoten<T extends Comparable> { // Instanzvariable protected BinaerBaumknoten<T> links; protected BinaerBaumknoten<T> rechts; public T daten; // Konstruktor public BinaerBaumknoten(T datenElement) { this(datenElement, null, null ); } public BinaerBaumknoten(T datenElement, BinaerBaumknoten<T> l, BinaerBaumknoten<T> r) { daten = datenElement; links = l; 29 vgl. GenericBinaerBaumknoten.java in pr12362 68 // linker Teilbaum // rechter Teilbaum // Dateninhalt der Knoten Algorithmen und Datenstrukturen rechts = r; } public void insert (T x) { if (x.compareTo(daten) > 0) // dann rechts { if (rechts == null) rechts = new BinaerBaumknoten<T>(x); else rechts.insert(x); } else // sonst links { if (links == null) links = new BinaerBaumknoten<T> (x); else links.insert(x); } } public BinaerBaumknoten<T> getLinks() { return links; } public BinaerBaumknoten<T> getRechts() { return rechts; } } Vererbung: Von generischen Klassen lassen sich Subklassen definieren. Diese Subklassen können, müssen aber nicht selbst generische Klassen sein Generische Methoden Generische Typen sind nicht an einen objektorientierten Kontext gebunden. In Java verläßt man den objektorientierten Kontext in statischen Methoden. Statische Methoden sind nicht an ein Objekt gebunden und lassen sich daher in Java definieren. Hierzu ist vor die Methodensignatur in spitzen Klammern eine Liste der für statische Methoden benutzten Typvariablen anzugeben. Bsp.30: Generische Methoden der Klasse TestGenericBinaerBaumKnoten public class TestGenericBinaerBaumKnoten { public static void main (String args[]) { BinaerBaumknoten<Integer> baum = null; /* for (int i = 0; i < 20; i++) // 20 Zusfallsstrings speichern { String s = "Zufallszahl " + (int)(Math.random() * 100); if (baum == null) baum = new BinaerBaumknoten(s); } print(baum); // Sortiert wieder ausdrucken */ for (int i = 0; i < 10; i++) { // Erzeuge eine Zahl zwischen 0 und 100 Integer r = new Integer((int)(Math.random()*100)); if (baum == null) baum = new BinaerBaumknoten<Integer>(r); else baum.insert(r); } System.out.println("Inorder-Durchlauf"); print(baum); 30 vgl. pr12362 69 Algorithmen und Datenstrukturen System.out.println(); System.out.println("Baumdarstellung um 90 Grad versetzt"); ausgBinaerBaum(baum,0); System.out.print("Kleinster Wert: "); System.out.print(((Integer)(findeMin(baum))).intValue()); System.out.println(); System.out.print("Groesster Wert: "); System.out.print(((Integer)(findeMax(baum))).intValue()); System.out.println(); } // Generische Methoden public static <T extends Comparable> void print (BinaerBaumknoten<? extends T> baum) // Rekursive Druckfunktion { if (baum == null) return; print(baum.getLinks()); System.out.print(baum.daten + " "); print(baum.getRechts()); } public static <T extends Comparable> void ausgBinaerBaum(BinaerBaumknoten<T> b, int stufe) { if (b != null) { ausgBinaerBaum(b.getRechts(), stufe + 1); for (int i = 0; i < stufe; i++) { System.out.print(" "); } System.out.println(b.daten); ausgBinaerBaum(b.getLinks(), stufe + 1); } } public static <T extends Comparable> T findeMin(BinaerBaumknoten<T> b) { return datenZugriff( findMin(b) ); } public static <T extends Comparable> T findeMax(BinaerBaumknoten<T> b) { return datenZugriff( findMax(b) ); } public static <T extends Comparable> T datenZugriff(BinaerBaumknoten<T> b) { return b == null ? null : b.daten; } public static <T extends Comparable> BinaerBaumknoten<T> findMin(BinaerBaumknoten<T> b) { if (b == null) return null; else if (b.getLinks() == null) return b; return findMin(b.getLinks()); } public static <T extends Comparable> BinaerBaumknoten<T> findMax(BinaerBaumknoten<T> b) { if (b != null) while (b.getRechts() != null) b = b.getRechts(); return b; } } 70 Algorithmen und Datenstrukturen Generische Schnittstellen Generische Typen erlauben es, den Typ Object in Typsignaturen zu eliminieren. Der Typ Object ist als schlecht anzusehen, denn er ist gleichbedeutend damit, dass keine Information über einen konkreten Typ während der Übersetzungszeit zur Verfügung steht. In herkömmlichen Java ist in APIs von Bibliotheken der Typ Object allgegenwärtig. Sogar in der Klasse Object selbst findet man diesen Typ in Signaturen, z.B. in der Methode equals(). Prinzipiell kann deshalb ein Objekt mit Objekten jedes beliebigen Typs verglichen werden. Häufig will man aber nur gleiche Typen miteinander vergleichen. Generische Typen erlauben es, allgemein eine Gleichheitsmethode zu definieren, in der nur Objekte gleichen Typs miteinander verglichen werden können. Generische Typen erweitern sich ohne Umstände auf Schnittstellen. 71 Algorithmen und Datenstrukturen 1.3 Sammeln (über Container) und Ordnen 1.3.1 Ausgangspunkt: Das Konzept zum Sammeln in Smalltalk Gefordert ist eine allgemeines Konzept zum Sammeln und Ordnen von Objekten (Behälter, Container, Collection). Der Entwurf solcher Konzepte bzw. Containerklassen ist Gegenstand objektorientierter Programmiersprachen. Ein einziger Containertyp, der allen Anforderungen gerecht wird, wäre sicherlich die beste Lösung. Dem stehen verschiedene Schwierigkeiten entgegen: - Gegensätzliche Ordnungskriterien (z.B. in Schlangen, Stapeln) - Unterschiedliche Forderungen (z.B. bzgl. des Begriffs "Enthalten" in einer Menge (set) oder in einer Sammlung (bag) - Identifikation der Objekte über Wert oder Schlüssel oder Position (Index) - Unterschiedliche Anforderungen der Zugriffs-Effizienz (z.B. wahlfrei-berechenbar, mengenmäßig eingeschränkt, Zugriff über "keys" in einem "dictionary31") Unter einem Container (bzw. Collection bzw. Ansammlung) versteht man eine allgemeine Zusammenfassung von Objekten in irgendeiner, nicht näher spezifizierten Organisationsstruktur. Ordnung kann in einer (An-)Sammlung viel bedeuten, z.B.: - in einem "array" die Ablage in irgendeiner Reihenfolge unter einem automatisch fortgeschriebenen Index - in einer hierarchischen Struktur (Baum-) ein Container (z.B. Liste), bei dem die enthaltenen Elemente selbst (Listen) sein dürfen. In Smalltalk/V realisiert die abstrakte Klasse "Collection" zusammen mit ihren Unterklassen ein leistungsfähiges Konzept zum Sammeln und Ordnen von Objekten. Die Klasse Collection beschreibt die gemeinsamen Eigenschaften aller ObjektAnsammlungen. Da es keine allgemeinen Ansammlungen gibt, kann man auch keine Instanzen der Klasse Collection bilden (abstrakte Oberklasse). Objekteigenschaften, die unabhängig von der Zugehörigkeit zu spezifischen Unterklassen sind, können in der Oberklasse spezifiziert werden. Collection ist eine direkte Unterklasse der allgemeinsten Klasse Object. Object Collection Bag IndexedCollection FixedSizedCollection Array Bitmap ByteArray Interval String Symbol OrderedCollection Set Dictionary Identity Dictionary System Dictionary Abb. 1.3-1: Klassen zum Sammeln und Ordnen von Objekten in Smalltalk/V 31 Tabellen, vgl. 1.3.2 72 Algorithmen und Datenstrukturen „Collection“ selbst nimmt keinen Bezug auf irgendein Ordnungsprinzip, nach dem seine Elemente abgelegt sind. Die Subklassen von Collection werden so organisiert, daß sie häufig auftretende Ordnungsprinzipien unterstützen. Das erste Unterscheidungsmerkmal betrifft die Indizierbarkeit der Sammlung (IndexedCollection). Alle anderen (nicht indizierten) Sammlungen teilen sich sich in Bag (mehrfache Einträge sind erlaubt) und Set (mehrfache Einträge sind nicht erlaubt). IndexedCollection wird unterteilt in Sammlungen mit einer festen Anzahl von Elementen (FixedCollection) oder mit variabler Anzahl von Elementen (OrderedCollection, paßt die Größe automatisch dem Bedarf an und ermöglicht den Aufbau üblicher dynamischer Datenstrukturen: Stacks, Fifos, etc.). In der Subklasse SortedCollection kann die Reihenfolge der Elemente durch eine Sortiervorschrift festgelegt werden. Bei nicht indizierten Sammlungen wird nur die Klasse Set weiter spezialisiert. Ihre Subklasse Dictionary" kann auf die Elemente einer Sammlung über Schlüsselwörter zugreifen. Allen Sammlungen ist gemeinsam: Sie enthalten Nachrichten zum Hinzufügen und Entfernen von Objekten, zum Test auf das Vorhandensein von Elementen und zum Aufzählen der Elemente. Beschreibungen von Reaktionen auf Nachrichten eines Objekts heißen Methoden. Jede Methode ist mit einer Nachrichtenkennung versehen und besteht aus Smalltalk Anweisungen: Eigenschaft der Methode: Hinzufügen Smalltalk-Anweisung add:anObject addALL:aCollection Entfernen remove:anObject removeALL Testen includes:anObject isEmpty occurencesOf:anObject Aufzählen do:aBlock reject:aBlock collect:aBlock Bedeutung: füge ein Objekt hinzu füge alle Elemente von aCollection hinzu entferne ein Objekt entferne alle Elemente von aCollection gib true zurück, falls die Anwendung leer ist, sonst false gib true zurück, falls die Anwendung leer ist, sonst false gib zurück, wie oft ein Objekt vorkommt gib eine Ansammlung mit den Elementen zurück, für die die Auswertung von aBlock true ergibt gib eine Ansammlung mit den Elementen zurück, für die Auswertung von aBlock false ergibt führe aBlock für jedes Element aus und gib eine Ansammlung mit den Ergebnisobjekten als Element zurück Abb. 1.3-2: Ein Auszug der Instanzmethoden zu Smalltalk-Anweisungen Die Zuordnung von Nachrichtenkennungen zu Methoden erfolgt dynamisch beim Senden der Nachricht. Gibt es in der Klasse des Empfängers eine Methode mit der Nachrichtenkennung, so wird diese Methode ausgeführt. Andernfalls wird die Methodensuche in der Oberklasse fortgesetzt und solange in der Klassenhierarchie nach oben gegangen, bis eine Methode mit der gewünschten Nachrichtenkennung 73 Algorithmen und Datenstrukturen gefunden wird. Gibt es keine solche Methode, dann setzt eine Ausnahmebehandlung an. 1.3.2 Behälter-Klassen Kollektionen Linear Allgemein indexiert DirektZugriff Nichtlinear Sequentieller Zugriff Hierarchische Sammlung Baum Dictionary HashTabelle Liste „array“ GruppenKollektionen Heap Set Stapel Schlange Graph prioritätsgest. Schl. „record“ „file“ Abb. 1.3-4: Hierarchischer Aufbau der Klasse Kollektion Die Abbildung zeigt unterschiedliche, benutzerdefinierte Datentypen. Gemeinsam ist diesen Klassen nur die Aufnahme und Berechnung der Daten durch ihre Instanzen. Kollektionen können in lineare und nichlineare Kategorien eingeteilt werden. Eine lineare Kollektion enthält eine Liste mit Elementen, die durch ihre Stellung (Position) geordnet sind, Es gibt ein erstes, zweites, drittes Element etc. 74 Algorithmen und Datenstrukturen 1.3.2.1 Lineare Kollektionen 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) ist in der Regel eine Zusammenfassung von Datenbehältern unterschiedlichen Typs: „record“-Kollektion 75 Algorithmen und Datenstrukturen Daten 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“) 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 Java32 dient zum Zugriff auf RandomAccess-Dateien. 2. Sammlungen mit sequentiellem Zugriff Darunter versteht man lineare Listen (linear list), 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) 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. „Schlange“-Kollektion 32 Implementiert das Interface DataInput und DataOutput mit eigenen Methoden. 76 Algorithmen und Datenstrukturen 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 Ende der Liste entfernt. Eine Schlange 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). 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.2.2 Nichtlineare Kollektionen 1. Hierarchische Sammlung Eine hierarchisch angeordnete Sammlung von Datenbehältern ist gewöhlich 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: 77 Algorithmen und Datenstrukturen [1] wk1 [2] [3] wk2 wk3 [4] [5] wk5 wk4 [8] [9] wk8 [0] [6] [10] wk9 wk6 [11] wk10 [7] [12] wk11 wk12 wk7 [13] [14] [15] wk13 wk14 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 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 78 Algorithmen und Datenstrukturen 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: 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: 79 Algorithmen und Datenstrukturen X[1] 10 X[2] X[3] 15 30 X[4] 40 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 Java33. // Erzeugung mit optionaler Angabe zur Kapazitaet (Defaultwert: 100) // // ******************PUBLIC OPERATIONEN********************************** // void insert( x ) --> Einfuegen x // Comparable loescheMin( )--> Rueckgabe und entfernen des kleinsten // Elements // Comparable findMin( ) --> Rueckgabe des kleinsten Elements // boolean isEmpty( ) --> Rueckgabe: true, falls leer; anderenfalls // false // boolean isFull( ) --> Rueckgabe true, falls voll; anderenfalls // false // void makeEmpty( ) --> Entfernen aller Elemente Anwendung Der Binary Heap kann zum Sortieren herangezogen werden. Ein Heap kann aber auch in Simulationsprogrammen und vor allem zur Implementierung von Priority Queues verwendet werden. Hier wird vielfach der 33 Vgl. pr13228 80 Algorithmen und Datenstrukturen einfache Heap durch komplexere Datenstrukturen ( Binomial Heap, Fibonacci Heap) ersetzt. 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. „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. 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. 81 Algorithmen und Datenstrukturen 2. Datenstrukturen und Algorithmen in C++ und Java 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 befinden sich in diesen „Sammlungen“. 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. 83 Algorithmen und Datenstrukturen 2.1 Datenstrukturen und Algorithmen in C++ 2.1.1 Die C++-Standardbibliothek und die STL Der Entwurf der Standard-C++-Bibliothek 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. 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.1.2 Container 2.1.2.1 Grundlagen Ein Container ist ein Objekt, 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 Assoziative Container 34 Template für Container-Klasse template <class T34> class vector template <class T> class list template <class T> class deque template <class T,classContainer=deque<T> > class stack 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 Key35,class Compare36=less<Key> > class set T ist jeweils der Elementtyp 84 Algorithmen und Datenstrukturen template <class Key,class T,class Compare = less<Key> >class map Abb. 2.1-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) 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.1-2: Wichtige Eigenschaften eines Containers 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) 35 36 Key ist der Typ der Schlüssel der abgespeicherten Elemente Compare ist eine Vergleichsklasse 85 Algorithmen und Datenstrukturen 2.1.2.2 STL-Container-Anwendungen Sequenzen besitzen im wesentlichen zusätzlich zu dem Eigenschaften aus der Abb. 2.1-2 Methoden zum Einfügen (insert()) und Löschen (erase()). Positionieren in Sequenzen erfolgt durch Iteratoren. 2.1.2.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 Warteschlänge (queue) und für eine Warteschlange mit Priorität (priority_queue). template <class T, class Container = deque<T> > class stack 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 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.: Eine Priority Queue Die "priority_queue" nutzt die "Container"-Klassen "vector" deque.Typische Deklarationen zu einer "priority_queue" sind: priority_queue< vector<node> > x; priority_queue< deque<string>,case_insensitive_compare > y; priority_queue< vector<int>, greater<int> > z; 86 bzw. Algorithmen und Datenstrukturen 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() { 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. 2.1.2.4 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>37 > 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>38, 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)" 37 38 Das Template less<T> ist in der STL unter <functional> definiert. Das Template less<T> ist in der STL unter <functional> definiert. 87 Algorithmen und Datenstrukturen werden alle Elemente, die gleich "t" sind, gelöscht. Mit "a.erase(p)" wird nur das Element, auf das "p" zeigt. gelöscht. Die beiden Container-Klassen sind als balancierte Binärbäume39 implementiert. Einfüge a.insert(t) n Löschen a.erase(t) a,erase(p) Suchen a.find(t) Zählen 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 template <class Key, class T, class Compare = less<Key>40 > class map 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üge a.insert(pair(k,t) n ) Löschen a.erase(k) a,erase(p) Suchen 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.: Wichtige Methoden von map und multimap 2.1.2.5 STL-Container-Anwendungen Huffman Coding41 mit "priority_queue"-Container 39 Der Standard legt nicht direkt fest, wie assoziative Container implementiert sind. Vielfach werden diese Bäume als "Red-Black-Trees" implementiert. 40 Das Template less<T> ist in der STL unter <functional> definiert. 41 vgl. 3.1.2.2 88 Algorithmen und Datenstrukturen 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 Abb.: Huffman-Codierungs-Baum 1 1 1 C F 0 1 1 D 1 E 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. 89 Algorithmen und Datenstrukturen 2.1.3 Iteratoren Bei allen Containern werden Iteratoren 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>::iterator42 map<Key,T,Compare43>::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 2.1.4 Algorithmen Algorithmen operieren auf Containern oder Iteratoren. Ihr Verhalten kann über Template-Argumente gesteuert werden. 42 43 Iteratoren des Typs map bzw. multimap liefern Werte des Typs pair<Key,Value> Compare definiert eine Ordnungsfunktion auf Key 90 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.: Vererbungshierarchie und Eigenschaften von Iteratoren 91 Algorithmen und Datenstrukturen 2.2 Datenstrukturen und Algorithmen in Java 2.2.1 Durchwandern von Daten mit Iteratoren Bei Datenstrukturen gibt es eine Möglichkeit, gespeicherte Daten unabhängig von der Implementierung immer mit der gleichen Technik abzufragen. Bei Datenstrukturen handelt es sich meistens um Daten in Listen, Bäumen oder ähnlichem und oft wird nur die Frage nach der Zugehörigkeit eines Worts zum Datenbestand gestellt (z.B. „Gehört das Wort dazu?“). Auch die Möglichkeit Daten in irgendeiner Form aufzuzählen, ist eine häufig gestellte Aufgabe. Hierfür bieten sich Iteratoren an. In Java umfaßt das Interface Enumeration die beiden Funktionen hasMoreElements() und nextElement(), mit denen durch eine Datenstruktur iteriert werden kann. public interface Enumeration { public boolean hasMoreElements(); // Test, ob noch ein weiteres Element aufgezählt werden kann public Object nextElement() throws NoSuchElementException; /* setzt den internen Zeiger auf das nächste Element, d. h. liefert das das nächste Element der Enumertion zurück. Diese Funktion kann eine NoSuchException auslösen, wenn nextElement() aufgerufen wird, obwohl hasMoreElements() unwahr ist */ } Die Aufzählung erfolgt meistens über for (Enumeration e = ds.elements(); e.hasMoreElements(); ) System.out.println(e.nextElements()); Die Datenstruktur ds besitzt eine Methode elements(), die ein Enumeration-Objekt zurückgibt, das die Aufzählung erlaubt. 2.2.2 Die Klasse Vector class java.util.Vector extends AbstractList implements List, Cloneable, Serializable Die Klasse Vector beschreibt ein Array mit variabler Länge. Objekte der Klasse Vector sind Repräsentationen einer linearen Liste. Die Liste kann Elemente beliebigen Typs enthalten, ihre Länge ist zur Laufzeit veränderbar (Array mit variabler Länge). Vector erlaubt das Einfügen von Elementen an beliebiger Stelle, bietet sequentiellen und wahlfreien Zugriff auf die Elemente. Das JDK realisiert Vector als Array von Elementen des Typs Object. Der Zugriff auf Elemente erfolgt über Indizes. Es wird dazu aber kein Operator [], sondern es werden Methoden benutzt, die einen Index als Parameter annehmen. Anlegen eines neuen Vektors (Konstruktor): public Vector() public Vector(int initialCapacity,int capacityIncrement) // Ein Vector vergrößert sich automatisch, falls mehr Elemente aufgenommen werden, als // ursprünglich vorgesehen (Resizing). Dabei sollen initialCapacity und capacityIncrement 92 Algorithmen und Datenstrukturen // passend gewählt werden. Einfügen von Elementen: public void addElement(Object obj) // Anhängen an des Ende der bisher vorliegenden Liste von Elementen Eigenschaften: public final boolean isEmpty() // Prüfen, ob der Vektor leer ist public final int size() // bestimmt die Anzahl der Elemente public final int capacity() // bestimmt die interne Größe des Arrays. Sie kann mit ensureCapacity() geändert // werden Einfügen an beliebiger Stelle innerhalb der Liste: public void insertElementAt(Object obj, int index) throws ArrayIndexOutOfBoundsException // fügt obj an die Position index in den „Vector“ ein. Zugriff auf Elemente: Für den sequentiellen Zugriff steht ein Iterator zur Verfügung. Wahlfreier Zugriff erfolgt über: public Object firstElement() throws ArrayIndexOutOfBoundException; public Object lastElement() throws ArrayIndexOutOfBoundException; public Object elementAt(int index) throws ArrayIndexOutOfBoundException; firstElement() liefert das erste, lastElement() das letzte Element- Mit elementAt() wird auf das Element an der Position index zugegriffen. Alle 3 Methoden verursachen eine Ausnahme, wenn das gewünschte Element nicht vorhanden ist. Arbeitsweise des internen Arrays. Der Vector vergrößert sich automatisch, falls mehr Elemente aufgenommen werden. Die Operation heißt Resizing. Die Größe des Felds. Mit capacity() erhält man die interne Größe des Arrays. Sie kann mit ensureCapacity() geändert werden. ensureCapacity(int minimumCapacity) bewirkt bei einem Vector, daß er mindestens minCapacity Elemente aufnehmen soll. Der Vektor verkleinert nicht die aktuelle Kapazität, falls sie schon höher als minCapacity ist. Zur Veränderung dieser Größe, dient die Methode trimToSize(). Sie reduziert die Kapazität des Vectors auf die Anzahl der Elemente, die gerade im Vector sind. Die Anzahl der Elemente kann über die Methode size() erfragt werden. Sie kann über setSize(int newSize) geändert werden. Ist die neue Größe kleiner als die alte, so werden die Elemente am Ende des Vectors abgeschnitten. Ist newSize größer als die alte Größe, werden die neu angelegten Elemente mit null initialisiert. Bereitstellen des Interface Enumerartion. In der Klasse Vector liefert die Methode public Enumeration elements() einen Enumerator (Iterator) für alle Elemente, die sich in Vector befinden. 93 Algorithmen und Datenstrukturen Vector << Konstruktoren >> public Vector() // Ein Vector in der Anfangsgröße von 10 Elementen wird angelegt public Vector(int startKapazitaet) // Ein Vector enthält Platz für startKapazitaet Elemente public Vector(int startKapazitaet, int kapazitaetsSchrittweite) << Methoden >> public final synchronized Object elementAt(int index) // Das an der Stelle index befindliche Objekt wird zurückgegeben public final int size() public final synchronized Object firstElement() public final synchronized Object lastElement(); public final synchronized void insertElementAt(Object obj, int index) // fügt Object obj an index ein und verschiebt die anderen Elemente public final synchronized void setElementAt(Object obj, int index) public final synchronized copyInto(Object einArray[]) // kopiert die Elemente des Vektors in das Array einArray // Falls das bereitgestellte Objektfeld nicht so groß ist wie der Vektor, // dann tritt eine ArrayIndexOutOfBounds Exception public final boolean contains(Object obj) // sucht das Element, liefert true zurück wenn o im Vector vorkommt public final int indexOf(Object obj) // sucht im Vector nach dem Objekt obj. Falls obj nicht in der Liste ist, wird // -1 übergeben public final int lastIndexOf(Object obj) public final synchronized boolean removeElement(Object obj) // entfernt obj aus der Liste. Konnte es entfernt werden, wird true // zurückgeliefert public final synchronized void removeElementAt(int index) // entfernt das Element an Stelle index public final synchronized void removeAllElements() // löscht alle Elemente public final int capacity() // gibt an, wieviel Elemente im Vektor Patz haben // (ohne automatische Größenanpassung) public synchronized Object clone() // Implementierung der clone()-Methode von Object // das kopierte Feld wird zurückgegeben. Die Kopie ist flach. public final synchronized String toString() Abb.: Die Klasse Vector 94 Algorithmen und Datenstrukturen 2.2.3 Die Klasse Stack class java.util.Stack extends Vector Ein Stack ist eine nach dem LIFO-Prinzip arbeitende Datenstruktur. Elemente werden vorn (am vorderen Ende der Liste) eingefügt und von dort auch wieder entnommen. In Java ist ein Stack eine Ableitung von Vector mit neuen Zugriffsfunktionen für die Implementierung des typischen Verhaltens von einem Stack. Konstruktor: public Stack(); Hinzufügen neuer Elemente: public Object push(Object item); Zugriff auf das oberste Element: public Object pop(); // Zugriff und Entfernen des obersten Element public Object peek() // Zugriff auf das oberste Element Suche im Stack: public int search(Object o) // Suche nach beliebigem Element, // Rueckgabewert: Distanz zwischen gefundenem und // obersten Stack-Element bzw. –1, // falls das Element nicht da ist. Test: public boolean empty() // bestimmt, ob der Stack leer ist Vector Stack public Stack() public Object push(Object obj) public synchronized Object pop() public synchronized Object peek() public synchronized int search(Object obj) public boolean empty() Abb.: Die Klasse Stack Anwendungen: 1. Umrechnen von Dezimalzahlen in andere Basisdarstellungen Aufgabenstellung: Defaultmäßig werden Zahlen dezimal ausgegeben. Ein Stapel, der Ganzzahlen aufnimmt, kann dazu verwendet werden, Zahlen bezogen auf eine andere Basis als 10 darzustellen. Die Funktionsweise der Umrechnung von Dezimalzahlen in eine Basis eines anderen Zahlensystem zeigen die folgenden Beispiele: 95 Algorithmen und Datenstrukturen 2810 = 3 ⋅ 8 + 4 = 34 8 72 10 = 1 ⋅ 64 + 0 ⋅ 16 + 2 ⋅ 4 + 0 = 1020 4 5310 = 1 ⋅ 32 + 1 ⋅ 16 + 0 ⋅ 8 + 1 ⋅ 4 + 0 ⋅ 2 + 1 = 1101012 Mit einem Stapel läßt sich die Umrechnung folgendermaßen unterstützen: 6 leerer Stapel n = 355310 7 7 4 4 4 1 1 1 1 n%8=1 n/8=444 n%8=4 n/8=55 n = 444 10 n%8=7 n/8=6 n = 5510 n = 610 n%8=6 n/6=0 n = 010 Abb.: Umrechnung von 355310 in 67418 mit Hilfe eines Stapel Algorithmus zur Lösung der Aufgabe: 1) Die am weitesten rechts stehende Ziffer von n ist n%b. Sie ist auf dem Stapel abzulegen. 2) Die restlichen Ziffern von n sind bestimmt durch n/b. Die Zahl n wird ersetzt durch n/b. 3) Wiederhole die Arbeitsschritte 1) und 2) bis keine signifikanten Ziffern mehr übrig bleiben. 4) Die Darstellung der Zahl in der neuen Basis ist aus dem Stapel abzulesen. Der Stapel ist zu diesem Zweck zu entleeren. Implementierung: Das folgende kleine Testprogramm44 realisiert den Algorithmus und benutzt dazu eine Instanz von Stack. import java.util.*; public class PR61210 { public static void main(String[] args) { int zahl = 3553; // Dezimalzahl int b = 8; // Basis Stack s = new Stack(); // Stapel do { s.push(new Integer(zahl % b)); zahl /= b; } while (zahl != 0); while (!s.empty()) { System.out.print(s.pop()); } System.out.println(); } } 44 pr61210 96 Algorithmen und Datenstrukturen Ein Stack ist ein Vector. Die Vector-Klasse wird von der Klasse Stack erweitert. Das ist sicherlich nicht immer besonders sinnvoll. Funktionen, die im Gegensatz zur Leistungsfähigkeit eines Stapels stehen sind add(), addAll(), addElement(), capacity(), clear(), clone(), contains(), copyInto(), elementsAt(), .... . 2.2.4 Die Klasse BitSet für Bitmengen class java.util.BitSet implements Cloneable, Serializable Die Klasse Bitset bietet komfortable Möglichkeiten zur bitweisen Manipulation von Daten. Bitset anlegen und füllen. Mit zwei Methoden lassen sich die Bits des Bitsets leicht ändern: set(bitNummer) und clear(bitNummer). Mengenorintierte Operationen. Das Bitset erlaubt mengenorientierte Operationen mit einer weiteren Menge. BitSet public void and(BitSet bs) public void or(BitSet bs) public void xor(BitSet bs) public void andNot(Bitset set) // löscht alle Bits im Bitset, dessen Bit in set gesetzt sind public void clear(int index) // Löscht ein Bit. Ist der Index negativ, kommt es // zur Auslösung von IndexOutOfBoundsException public void set(int index) // Setzt ein Bit. Ist der Index negativ, kommt es // zur Auslösung von IndexOutOfBoundsException public boolean get(int index) // liefert den Wert des Felds am übergebenen Index, // kann IndexOutOfBoundsException auslösen. public int size() public boolean equals(Object o) // Vergeicht sich mit einem anderen Bitset-Objekt o. Abb.: Die Klasse BitSet 97 Algorithmen und Datenstrukturen 2.2.5 Die Klasse Hashtable und assoziative Speicher Eine Hashtabelle (Hashtable) ist ein assoziativer Speicher, der Schlüssel (keys) mit Werten verknüpft. Die Datenstruktur ist mit einem Wörterbuch vergleichbar. Die Hashtabelle arbeitet mit Schlüssel/Werte Paaren. Aus dem Schlüssel wird nach einer Funktion – der sog. Hashfunktion – ein Hashcode berechnet. Dieser dient als Index für ein internes Array. Dieses Array hat zu Anfang ein feste Grösse. Leider hat dieses Technik einen entscheidenden Nachteil. Besitzen zwei Wörter denselben Hashcode, dann kommt es zu einer Kollision. Auf ihn muß die Datenstruktur vorbereitet sein. Hier gibt es verschiedene Lösungsansätze. Die unter Java implementierte Variante benutzt eine verkettete Liste (separate Chaining). Falls eine Kollision auftritt, so wird der Hashcode beibehalten und der Schlüssel bzw. Wert in einem Listenelement an den vorhandenen Eintrag angehängt. Wenn allerdings irgendwann einmal eine Liste durchsucht werden muß, dann wird die Datenstruktur langsam. Ein Maß für den Füllgrad ist der Füllfaktor (Load Factor). Dieser liegt zwischen 0 und 100 %. 0 bedeutet: kein Listenelement wird verwendet. 100 % bedeutet: Es ist kein Platz mehr im Array und es werden nur noch Listen für alle zukommenden Werte erweitert. Der Füllfaktor sollte für effiziente Anwendungen nicht höher als 75% sein. Ist ein Füllfaktor nicht explizit angegeben, dann wird die Hashtabelle „rehashed“, wenn mehr als 75% aller Plätze besetzt sind. class java.util.Hashtable extends Dictionary implements Map, Cloneable, Serializable Erzeugen von einem Objekt der Klasse HashTable: public Hashtable() /* Die Hashtabelle enthält eine Kapazität von 11 Einträgen und einen Füllfaktor von 75 % */ public HashTable(int initialCapacity) /* erzeugt eine Hashtabelle mit einer vorgebenen Kapazität und dem Füllfaktor 0.75 */ public HashTable(int initialCapacity, float loadFactor) /* erzeugt eine Hashtabelle mit einer vorgebenen Kapazität und dem angegebenen Füllfaktor */ Daten einfügen: public Object put(Object key, Object value) /* speichert den Schlüssel und den Wert in der Hashtabelle. Falls sich zu dem Schlüssel schon ein Eintrag in der Hashtabelle befand, so wird dieser zurückgegeben. Anderenfalls ist der Rückgabewert null. Die Methode ist vorgegeben vom Interface Map. Es überschreibt die Methode von der Superklasse Dictionary. */ Daten holen: public Object get(Object key) Schlüssel entfernen. public Object remove(Object key) Löschen der Werte. public void clear() Test. public boolean containsKey(Object key) // Test auf einen bestimmten Schlüssel public boolean containsValue(Object value) // Test auf einen bestimmten Wert Aufzählen der Elemente. Mit keys() und elements() bietet die Hashtabelle zwei Methoden an, die eine Aufzählung zurückgeben: public Enumeration keys() // liefert eine Aufzählung aller Schlüssel, überschreibt keys() in Dictionary. public Enumeration elements() // liefert eine Aufzählung der Werte, überschreibt elements() in Dictionary 98 Algorithmen und Datenstrukturen Wie üblich liefern beide Iteratoren ein Objekt, welches das Interface Enumeration implementiert. Der Zugriff erfolgt daher mit Hilfe der Methoden hasMoreElements() und nextElement(). Dictionary {abstract} public abstract Object put (Object key, Object value) publicc abstract Object get(Object key) public abstract Enumeration elements() public abstract Enumeration keys() public abstract int size() public absztract boolean isEmpty() public abstract Object remove(Object key) Hashtable Map << Konstruktor >> public Hashtable(int initialKapazitaet) public Hashtable(int initialKapazitaet, float Ladefaktor) public Hashtable() << Methoden >> public synchronized boolean contains(Object wert) public synchronized boolean containsKey(Object key) public synchronized void clear() public synchronized Object clone() protected void rehash() public synchronized String toString() Properties << Konstruktor >> public Properties() // legt einen leeren Container an public Properties(Properties defaults) // füllt eine Property-Liste mit den angegebenen Default-Werten << Methoden >> public String getProperty(String key) public String getProperty(String key, String defaultKey) public synchronized void load(InputStream in) throws IOException // Hier muß ein InputStream übergeben werden, der die daten der // Property-Liste zur Verfügung stellt. public synchronizred void save(OutputStream out, String header) public void list(PrintStream out) public void list(PrintWriter out) public Enumeration propertyNames() // beschafft ein Enumerations-Objekt mit denen Eigenschaften // der Property-Liste aufgezählt werden können Abb.: Die Klassen Hashtable und Properties 99 Algorithmen und Datenstrukturen Die Klasse Hashtable ist eine Konkretisierung der abstrakten Klasse Dictionary. Diese Klasse beschreibt einen assoziativen Speicher, der Schlüssel auf Werte abbildet und über den Schlüsselbegriff einen effizienten Zugriff auf den Wert ermöglicht. Einfügen und der Zugriff auf Schlüssel erfolgt nicht auf der Basis des Operators „==“, sondern mit Hilfe der Methode „equals“. Schlüssel müssen daher lediglich inhaltlich gleich sein, um als identisch angesehen zu werden. Bsp.45: Hashtabelle zum Test der von Zufallszahlen der Methode Math.random(). import java.util.*; class Zaehler { int i = 1; public String toString() { return Integer.toString(i); } } public class Statistik { public static void main(String args[]) { Hashtable h = new Hashtable(); for (int i = 0; i < 10000; i++) { // Erzeuge eine Zahl zwischen 0 und 20 Integer r = new Integer((int)(Math.random() * 20)); if (h.containsKey(r)) ((Zaehler) h.get(r)).i++; else h.put(r,new Zaehler()); } System.out.println(h); } } Die Klasse Hashtable benutzt das Verfahren der Schlüsseltransformation (HashFunktion) zur Abbildung von Schlüsseln auf Indexpostionen eines Arrays. Die Kapazität der Hash-Tabelle gibt die Anzahl der Elemente an, die insgesamt untergebracht werden können. Der Ladefaktor zeigt an, bei welchem Füllungsgrad die Hash-Tabelle vergrößert werden muß. Das Vergrößern erfolgt automatisch, falls die Anzahl der Elemente innerhalb der Tabelle größer ist als das Produkt aus Kapazität und Ladefaktor. Seit dem JDK 1.2 darf der Ladefaktor auch größer als 1 sein. In diesem Fall wird die Hash-Tabelle erst dann vergrößert, wenn der Füllungsgrad größer als 100% ist und bereits ein Teil der Elemente in den Überlaufbereichen untergebracht wurde. Die Klasse Hashtable ist eine besondere Klasse für Wörterbücher. Ein Wörterbuch ist eine Datenstruktur, die Elemente miteinander assoziiert. Das Wörterbuchproblem ist das Problem, wie aus dem Schlüssel möglichst schnell der zugehörige Wert konstruiert wird. Die Lösung des Problems ist: Der Schlüssel wird als Zahl kodiert (Hashcode) und dient in einem Array als Index. An einem Index hängen dann noch die Werte mit gleichem Hashcode als Liste an. 45 vgl. pr13215 100 Algorithmen und Datenstrukturen 2.2.6 Die abstrakte Klasse Dictionary Die Klasse Dictionary ist eine abstrakte Klasse, die Methoden anbietet, wie Objekte (also Schlüssel und Wert) miteinander assoziiert werden: public abstract Object put(Object key,Object value) // fügt den Schlüssel key mit dem verbundenen Wert value in das Wörterbuch // ein public abstract Object get(Object key) // // // // liefert das zu key gehörende Objekt zurück. Falls kein Wert mit dem Schlüssel verbunden ist, so liefert get() eine null. Eine null als Schlüssel oder Wert kann nicht eingesetz werden. In put() würde das zu einer NullPointerException führen. public abstract Object remove(Object key) // entfernt ein Schlüssel/Wertepaar aus dem Wörterbuch. Zurückgegeben wird // der assoziierte Wert. public abstract boolean isEmpty() // true, falls keine Werte im Wörterbuch public int size() gibt zurück, wie viele Elemente aktuell im Wörterbuch sind. public abstract Enumeration keys() // liefert eine Enumeration für alle Schlüssel public abstract Enumeration elements() // liefert eine Enumeration über alle Werte. 2.2.7 Die Klasse Properties Die Properties Klasse ist eine Erweiterung von Hashtable. Ein Properties Objekt erweitert die Hashtable um die Möglichkeit, sich unter einem wohldefinierten Format über einen Strom zu laden und zu speichern. Erzeugen. public Properties() // erzeugt ein leeres Propertes Objekt ohne Worte. public Properties(Properties p) // erzeugt ein leeres Properties Objekt mit Standard-werten aus den // übergebenen Properties Die Methode getProperty(). public String getProperty(String s) // sucht in den Properties nach der Zeichenkette public String getProperty(String key, String default) // sucht in den Properties nach der Zeichenkette key. Ist dieser nicht // vorhanden, wird der String default zurückgegeben Eigenschaften ausgeben. Die Methode list() wandert durch die Daten und gibt sie auf einem PrintWriter aus: public void list(PrintWriter pw) // listet die Properties auf dem PrintWriter aus. 101 Algorithmen und Datenstrukturen 2.2.8 Collection API Die Java 2 Plattform hat Java erweitert um das Collection API. Anstatt Collection kann man auch Container (Behälter) sagen. Ein Container ist ein Objekt, das wiederum Objekte aufnimmt und die Verantwortung für die Elemente übernimmt. Im „util“-Paket befinden sich sechs Schnittstellen, die grundlegende Eigenschaften der Containerklassen definieren. Das in Java 1.2 enthaltene Collections Framework beinhaltet im Wesentlichen drei Grundformen: Set, List und Map. Jede dieser Grundformen ist als Interface implementiert. Die Interfaces List und Set sind direkt aus Collection abgeleitet. Es gibt auch noch eine abstrakte Implementierung des Interface, mit dessen Hilfe das Erstellen eigener Collections erleichtert wird. Bei allen Collections, die das Interface Collection implementieren, kann ein Iterator zum Durchlaufen der Elemente mit der Methode „iterator()“ beschafft werden. Zusätzlich fordert die JDK 1.2-Spezifikation für jede Collection-Klasse zwei Konstruktoren: - Einen parameterlosen Konstruktor zum Anlegen einer neuen Collection. - Ein mit einem einzigen Collection-Argument ausgestatteter Konstruktor, der eine neue Collection anlegt und mit den Elementen der als Argument übergebenen Collection auffüllt. 2.2.8.1 Die Schnittstellen Collection, Iterator, Comparator Das Interface Collection bildet die Basis der Collection-Klasse und –Interfaces des JDK 1.2. Alle Behälterklassen implementieren das Collection Interface und geben den Klassen damit einen äußeren Rahmen. 102 Algorithmen und Datenstrukturen Das Interface Collection << interface >> Collection public void clear(); // Optional: Löscht alle Elemente in dem Container. Eird dies vom Container nicht unterstützt, // kommt es zur UnSupportedOperationException public boolean add(Object o); // Optional: Fügt ein Objekt dem Container hinzu und gibt true zurück, falls sich das Element // einfügen läßt. Gibt false zurück, falls schon ein Objektwert vorhanden ist und doppelte Werte // nicht erlaubt sind. public boolean addAll(Collection c); // fügt alle Elemente der Collection c dem Container hinzu public boolean remove(Object o); // Entfernen einer einzelnen Instanz. Rückgabewert ist true, wenn das Element gefunden und // entfernt werden konnte public boolean removeAll(Collection c); // Oprtional: Entfernt alle Objekte der Collection c aus dem Container public boolean contains(Object o); // liefert true, falls der Container das Element enthält // Rückgabewert ist true, falls das vorgegebene Element gefunden werden konnte public boolean containsAll(Collection c); // liefert true, falls der Container alle Elemente der Collection c enthält. public boolean equals(Object o); // vergleicht das angegebene Objekt mit dem Container, ob die gleichen Elemente vorkommen. public boolean isEmpty(); // liefert true, falls der Container keine Elemente enthält public int size(); // gibt die Größe des Containers zurück public boolean retainAll(Collection c); public Iterator iterator(); publicObject [] toArray(); // gibt ein Array mit Elementen des Containers zurück public Object [] toArray(Object [] a); public int hashCode(); // liefert den Hashwert des Containers public String toString() // Rückgabewert ist die Zeichenketten-Repräsentation der Kollektion. Abb.: Das Interface Collection Die abstrakte Basisklasse AbstractCollection implementiert die Methoden des Interface Collection (ohne iterator() und size()). AbstractCollection ist die Basisklasse von AbstractList und AbstractSet. 103 Algorithmen und Datenstrukturen Das Interface Iterator << interface >> Iterator public boolean hasNext(); // gibt true zurück, wenn der Iterator mindestens ein weiteres Element enthält. public Object next(); // liefert das nächste Element bzw. löst eine Ausnahme des Typs NoSuchElementException // aus, wenn es keine weiteren Elemente gibt public void remove(); // entfernt das Element, das der Iterator bei next() geliefert hat. Abb.: Bei allen Collections, die das Interface Collection implementieren, kann ein Iterator zum Durchlaufen der Elemente mit der Methode „iterator()“ beschafft werden. Das Interface Comparator Vergleiche zwischen Objekten werden mit speziellen Objekten vorgenommen, den Comparatoren. Ein konkreter Comparator implementiert die folgende Schnittstelle. << interface >> java.util.Comparator public int compare(Object o1, Object o2) // vergleicht 2 Argumente auf ihre Ordnung public boolean equals(Object arg) // testet, ob zwei Objekte bzgl. des Comparator-Objekts gleich sind Abb. 104 Algorithmen und Datenstrukturen 2.2.8.2 Die Behälterklassen und Schnittstellen des Typs List Behälterklassen des Typs List fassen eine Menge von Elementen zusammen, auf die sequentiell oder über Index (-positionen) zugegriffen werden kann. Wie Vektoren der Klasse Vector46 hat das erste Element den Index 0 und das letzte den Index „size() – 1“. Es ist möglich an einer beliebigen Stelle ein Element einzufügen oder zu löschen. Die weiter hinten stehenden Elemente werden dann entsprechend weiter nach rechts bzw. nach links verschoben. Das Interface List47 << interface >> List public void add(int index, Object element); // Einfügen eines Elements an der durch Index spezifizierten Position public boolean add(Object o); // Anhängen eines Elements ans Ende der Liste // Rückgabewert ist true, falls die Liste durch den Aufruf von add verändert wurde. Er ist false, // wenn die Liste nicht verändert wurde. Das kann bspw. der Fall sein, wenn die Liste keine // Duplikate erlaubt und ein bereits vorhandenes Element noch einmal eingefügt werden soll. // Konnte das Element aus einem anderen Grund nicht eingefügt werden, wird eine Ausnahme // des Typs UnsupportedOperationException, CallsCastException oder IllegalArgumentException // ausgelöst public boolean addAll(Collection c); // Einfügen einer vollständigen Collection in die Liste. Der Rückgabewert ist true, falls die Liste // durch den Ausfruf von add veränder wurde public boolean addAll(int index, Collection c) public void clear(); public boolean equals(Object object); public boolean contains(Object element); public boolean containsAll(Collection collection); public Object remove(int index) public boolean remove(Object element); public boolean removeAll(Collection c); // Alle Elemente werden gelöscht, die auch in der als Argument angebenen // Collection enthalten sind. public boolean retainAll(Collection c); // löscht alle Elemente außer den in der Argument-Collection enthaltenen public Object get(); public int hashCode(); public Iterator iterator(); public ListIterator listIterator(): public ListIterator listIterator(int startIndex); public Object set(int index, Obeject element); public List subList(int fromIndex, int toIndex); public Object [] toArray(); public Object [] toArray(Object [] a); Abb.: 46 seit Java 1.2 implementiert die Klasse Vector die Schnittstelle List Da ebenfalls das AWT-Paket eine Klasse mit gleichen namen verwendet, muß der voll qualifizierte Name in Anwendungen benutzt werden. 47 105 Algorithmen und Datenstrukturen Auf die Elemente einer Liste läßt sich mit einem Index zugreifen und nach Elementen läßt sich mit linearem Aufwand suchen. Doppelte Elemente sind erlaubt. Die Schnittstelle List, die in ArrayList und LinkedList eine Implementierung findet, erlaubt sequentiellen Zugriff auf die gespeicherten gespeicherten Elemente. Das Interface List wird im JDK von verschiedenen Klassen implementiert: AbstractList ist eine abstrakte Basisklasse (für eigene List-Implementierungen), bei der alle Methoden die Ausnahme UnsupportedException auslösen und diverse Methoden abstract deklariert sind. Die direkten Subklassen sind AbstractSequentialList, ArrayList und java.util.Vector. AbstractList implementiert bereits viele Methoden für die beiden Listen-Klassen48: abstract class AbstractList extends AbstractCollection implements List << Methoden >> public void add(int index, Object element) // Optional: Fügt ein Objekt an der spezifizierten stelle ein public boolean add(Object o) // Optional: Fügt das Element am Ende an public boolean addAll(int index, Collection c) // Optional: Fügt alle Elemente der Collection ein public void clear() // Optional: Löscht alle Elemente public boolean equals(Object o) // vergleicht die Liste mit dem Objekt public abstract Object get(int index) // liefert das Element an dieser Stelle int hashCode() // liefert HashCode der Liste int indexOf(Object o) // liefert Position des ersten Vorkommens für o oder –1, // wenn das Element nicht existiert. Iterator iterator() // liefert den Iterator. Überschreibt die Methode AbstractCollection, // obwohl es auch listIterator() für die spezielle Liste gibt. Die Methode // ruft aber listIterator() auf und gibt ein ListIterator-Objekt zurück Object remove(int index) // löscht ein Element an Position index. protected void removeRange(int fromIndex, int toIndex) // löscht Teil der Liste von fromIndex bis toIndex. fromIndex wird mitgelöscht, // toIndex nicht. public Object set(int index, Object element) // Optional. Ersetzt das Element an der Stelle index mit element. public List subList(int fromIndex, int toIndex) // liefert Teil einer Liste fromIndex (einschließlich) bis toIndex (nicht mehr dabei) Abb.: AbstractSequentialList bereitet die Klasse LinkedList darauf vor, die Elemente in einer Liste zu verwalten und nicht wie ArrayList in einem internen Array. LinkedList realisiert die doppelt verkettete, lineare Liste und implementiert List. 48 Beim Aufruf einer optionalen Methode, die von der Subklasse nicht implementiert wird, führt zur UnsupportedOperationException. 106 Algorithmen und Datenstrukturen ArrayList implementiert die Liste als Feld von Elementen und implementiert List. Da Arraylist ein Feld ist, ist der Zugriff auf ein spezielles Element sehr schnell. Eine LinkedList muß aufwendiger durchsucht werden. Die verkettete Liste ist aber deutlich im Vorteil, wenn Elemente gelöscht oder eingefügt werden. << interface >> Collection << interface >> List LinkedList ArrayList << Konstruktor >> public LinkedList(); public LinkedList(Collection collection); << Konstruktor public ArrayList(); public ArrayList(Collection collection); public ArrayList(int anfangsKapazitaet); << Methoden >> public void addFirst(Object object); public void addLast(Object object); public Object getFirst(); public Object getLast(); public Object removeFirst(); public Object removeLast(); << Methoden >> protected void removeRange (int fromIndex, int toIndex) // löscht Teil der Liste von // fromIndex bis toIndex. fromIndex wird // mitgelöscht, toIndex nicht. Vector Abb. Das Interface ListIterator << interface >> ListIterator public boolean hasPrevious(); // bestimmt, ob es vor der aktuellen Position ein weiteres Element gibt, der Zugriff ist mit // previous möglich public boolean hasNext(); public Object next(); public Object previous(); public int nextIndex(); puplic int previousIndex(); public void add(Object o); // Einfügen eines neuen Elements an der Stelle der Liste, die unmittelbar vor dem nächsten // Element des Iterators liegt public void set(Object o); // erlaubt, das durch den letzten Aufruf von next() bzw. previous() beschaffene Element zu // ersetzen public void remove(); Abb.: 107 Algorithmen und Datenstrukturen ListIterator ist eine Erweiterung von Iterator. Die Schnittstelle fügt noch Methoden hinzu, damit an aktueller Stelle auch Elemente eingefügt werden können. Mit einem ListIterator läßt sich rückwärts laufen und auf das vorgehende Element zugreifen. Bsp.: 1. Simulation einer Schlange49 public class Queue { private java.util.LinkedList list = new java.util.LinkedList(); public Queue() { } public void clear() { list.clear(); } public boolean isEmpty() { return list.isEmpty(); } public Object firstEl() { return list.getFirst(); } public Object dequeue() { return list.removeFirst(); } public void enqueue(Object el) { list.add(el); } public String toString() { return list.toString(); } } Eine verkettete Liste hat neben den normalen Funktionen aus AbstractList noch weitere Hilfsmethoden zur Implementierung von einem Stack oder einer Schlange. Es handelt sich dabei um die Methoden addFirst(), addLast(), getFirst(), getLast() und removeFirst(). import java.util.*; public class ListBsp { public static void main(String [] args) { LinkedList schlange = new LinkedList(); schlange.add("Thomas"); schlange.add("Andreas"); schlange.add("Josef"); System.out.println(schlange); schlange.removeFirst(); schlange.removeFirst(); System.out.println(schlange); } } 2. Entfernen von Duplikaten50 import java.util.*; public class EntfDupl { public static void main(String args[]) { int [] a = { 1, 7, 7, 1, 5, 1, 2, 7, 2, 1, 6, 6, 3, 6, 7 }; LinkedList l = new LinkedList(); for (int i = 0; i < a.length; i++) { l.add(new Integer(a[i])); } System.out.println(l); int i = 0; do { int aktWert = ((Integer) l.get(i)).intValue(); 49 50 Vgl. pr22220 vgl. pr22220 108 Algorithmen und Datenstrukturen i++; if (i == l.size()) break; int j = i; do { int wert = ((Integer) l.get(j)).intValue(); if (wert == aktWert) l.remove(j); j++; } while (j < l.size()); System.out.println(l); } while (i < l.size()); } } 2.2.8.3 Behälterklassen des Typs Set Ein Set ist eine Menge, in der keine doppelten Einträge vorkommen können. Set hat die gleichen Methoden wie Collection. Standard-Implementierung für Set sind das unsortierte HashSet (Array mit veränderlicher Größe) und das sortierte TreeSet (Binärbaum). Bsp.51: import java.util.*; public class SetBeispiel { public static void main(String args []) { Set set = new HashSet(); set.add("Gerhard"); set.add("Thomas"); set.add("Michael"); set.add("Peter"); set.add("Christian"); set.add("Valentina"); System.out.println(set); Set sortedSet = new TreeSet(set); System.out.println(sortedSet); } } 51 Vgl. pr13230 109 Algorithmen und Datenstrukturen << interface >> Collection << interface >> Set public boolean add(Object element); public boolean addAll(Collection collection); public void clear(); public boolean equals(Object object); public boolean contains(Object element); public boolean containsAll(Collection collection); public int hashCode(); public Iterator iterator(); public boolean remove(Object element); public boolean removeAll(Collection collection); public boolean retainAll(Collection collection); public int size(); public Object[] toArray(); public Object[] toArray(Object[] a); HashSet << interface >> SortedSet public HashSet(); public HashSet(Collection collection); public HashSet(int anfangskaazitaet); public HashSet(int anfangskapazitaet, int ladeFaktor); public Object first(); public Object last(); public SortedSet headSet(Object toElement); public SortedSet subSet(Object fromElement, Object toElement); public SortedSet tailSet(Object fromElement); public Comparator comparator(); public Object fisrt(); public Object last(); TreeSet52 public TreeSet() public TreeSet(Collection collection); public TreeSet(Comparator vergleich); public TreeSet(SortedSet collection); Die Schnittstelle Set Ist eine im mathematischen Sinne definierte Menge von Objekten. Die Reihenfolge wird durch das Einfügen festgelegt. Wie von mathematischen Mengen bekannt, darf ein Set keine doppelten Elemente enthalten. Besondere Beachtung muß Objekten 52 implementiert die sortierte Menge mit Hilfe der Klasse TreeMap, verwendet einen Red-Black-Tree als Datenstruktur 110 Algorithmen und Datenstrukturen geschenkt werden, die ihren Wert nachträglich ändern. Die kann ein Set nicht kontrollieren. Eine Menge kann sich nicht selbst als Element enthalten. Zwei Klassen ergeben sich aus Set: die abstrakte Klasse AbstractSet und die konkrete Klasse HashSet. Die Schnittstelle SortedSet erweitert Set so, daß Elemente sortiert ausgelesen werden können. Das Sortierkriterium wird durch die Hilfsklasse Comparator gesetzt. 2.2.8.4 Behälterklassen des Typs Map Ein Map ist eine Menge von Elementen, auf die über Schlüssel zugegriffen wird. Jedem Schlüssel (key) ist genau ein Wert (value) zugeordnet. StandardImplementierungem sind HashMap, HashTable und TreeMap. 111 Algorithmen und Datenstrukturen Interface Map, SortedMap und implemetierende Klassen << interface >> Collection << interface >> Map public void clear(); public boolean containskey(Object key); public boolean containsValue(Object value); public Set entrySet(); public Object get(Object key); public boolean isEmpty(); public Set keySet(); public Object remove(Object key); public int size(); public Collection values(); HashMap public HashMap(); public Hashmap(Collection collection); public HashMap(int anfangskapazitaet); public HashMap(int anfangskapazitaet, int ladeFaktor); << interface >> SortedMap public Comparator comparator(); public Object firstKey(); public Object lastKey(); public SortedMap headmap(Object toKey); public SortedMap subMap(Object fromKey, Object toKey); public SortedMap tailMap(Object fromKey); Hashtable TreeMap public TreeMap(); public TreeMap(Map collection); public TreeMap(Comparator vergleich); public TreeMap(SortedMap collection); Abb.: Die Schnittstelle Map Eine Klasse, die Map implementiert, behandelt einen assoziativen Speicher. Dieser verbindet einen Schlüssel mit einem Wert. Die Klasse Hashtable erbt von Map. Map ist für die implementierenden Klassen AbstractMap, HashMap, Hashtable, RenderingHints, WeakHashMap und Attributes das, was die abstrakte Klasse Dictionary für die Klasse Hashtable ist. Die Schnittstelle SortedMap Eine Map kann mit Hilfe eines Kriteriums sortiert werden und nennt sich dann SortedMap. SortedMap erweitert direkt Map. Das Sortierkriterium wird mit einem speziellen Objekt, das sich Comparator nennt, gesetzt. Damit besitzt auch der 112 Algorithmen und Datenstrukturen assoziative Speicher über einen Iterator eine Reihenfolge. Nur die konkrete Klasse TreeMap implementiert bisher eine SortedMap. Die abstrakte Klasse AbstractMap implementiert die Schnittstelle Map. Die konkrete Klasse HashMap implementiert einen assoziativen Speicher, erweitert die Klasse AbstractMap und implementiert die Schnittstelle Map. Die konkrete Klasse TreeMap erweitert AbstractMap und implementiert SortedMap. Ein Objekt von TreeMap hält Elemente in einem Baum sortiert. Bsp.53: Aufbau und Anwendung einer Hash-Tabelle import java.io.*; import java.util.*; public class HashTabTest { public static void main(String [ ] args) { Map map = new HashMap(); String eingabeZeile = null; BufferedReader eingabe = null; try { eingabe = new BufferedReader( new FileReader("eing.txt")); } catch (FileNotFoundException io) { System.out.println("Fehler beim Einlesen!"); } try { while ( (eingabeZeile = eingabe.readLine() ) != null) { StringTokenizer str = new StringTokenizer(eingabeZeile); if (eingabeZeile.equals("")) break; String key = str.nextToken(); String daten = str.nextToken(); System.out.println(key); map.put(key,daten); } } catch (IOException ioe) { System.out.println("Eingefangen in main()"); } try { eingabe.close(); } catch(IOException e) { System.out.println(e); } System.out.println("Uebersicht zur Hash-Tabelle"); System.out.println(map); //h.printHashTabelle(); System.out.println("Abfragen bzw. Modifikationen"); // Wiederauffinden String eingabeKey = null; BufferedReader ein = new BufferedReader( new InputStreamReader(System.in)); System.out.println("Wiederauffinden von Elementen"); while (true) 53 vgl. pr23300 113 Algorithmen und Datenstrukturen { try { System.out.print("Bitte Schluessel eingeben, ! bedeutet Ende: "); eingabeKey = ein.readLine(); // System.out.println(eingabeKey); if (eingabeKey.equals("!")) break; String eintr = (String) map.get(eingabeKey); if (eintr == null) System.out.println("Kein Eintrag!"); else { System.out.println(eintr); System.out.println("Soll dieser Eintrag geloescht werden? "); String antwort = ein.readLine(); // System.out.println(antwort); if ((antwort.equals("j")) || (antwort.equals("J"))) { // System.out.println("Eintrag wird entfernt!"); map.remove(eingabeKey); } } } catch(IOException ioe) { System.out.println(eingabeKey + " konnte nicht korrekt eingelesen werden!"); } } System.out.println(map); System.out.println("Sortierte Tabelle"); Map sortedMap = new TreeMap(map); System.out.println(sortedMap); } } 114 Algorithmen und Datenstrukturen 2.2.9 Algorithmen Die Wahl einer geeigneten Datenstruktur ist der erste Schritt. Im zweiten Schritt müssen die Algorithmen implementiert werden. Die Java Bibliothek hilft mit einigen Standardalgorithmen weiter. Dazu zählen Funktionen zum Sortieren und Suchen in Containern und das Füllen von Containern. Zum flexiblen Einsatz dieser Funktionen haben die Java-Entwickler die Klasse Collections bereitgestellt. Collections bietet Algorithmen statischer Funktionen an, die als Parameter ein Collection Objekt erwarten. Leider sind viele Algorithmen nur auf List-Objekte definiert54, z.B. public static void shuffle(List list) // würfelt die Werte einer Liste durcheinander Bsp.55: import java.util.*; public class VectorShuffle { public static void main(String args[]) { Vector v = new Vector(); for (int i = 0; i < 10; i++) v.add(new Integer(i)); Collections.shuffle(v); System.out.println(v); } } public static void shuffle(List list, random rnd) // würfelt die werte der Liste durcheinander und benutzt dabei den Random Generator rnd. Nur die Methoden min() und max() arbeiten auf allgemeinen Collection-Objekten. 2.2.9.1 Datenmanipulation Daten umdrehen. Die Methode reverse() dreht die Werte einer Liste um. Die Laufzeit ist linear zu der Anzahl der Elemente. public static void reverse(List l) // dreht die Elemente in der Liste um Listen füllen. Mit der fill()-Methode läßt sich eine Liste in linearer Zeit belegen. Nützlich ist dies, wenn eine Liste mit Werten initialisiert werden muß. public static void fill (List l, Object o) // füllt eine Liste mit dem Element o Daten zwischen Listen kopieren. Die Methode copy(List quelle, List ziel) kopiert alle Elemente von quelle in die Liste ziel und überschreibt dabei Elemente, die evtl. an dieser Stelle liegen. public static void copy(List quelle, List ziel) // kopiert Elemente von quelle nach ziel. Ist ziel zu klein, gibt es eine IndexOutOfBoundsException 54 55 Nutzt die Collection Klasse keine List Objekte, arbeitet sie mit Iterator Objekten, um allgemein zu bleiben vgl. pr22122 115 Algorithmen und Datenstrukturen 2.2.9.2 Größter und kleinster Wert einer Collection Die Methoden min() und max() suchen das größte und kleinste Element einer Collection. Die Laufzeit ist linear zur Größe der Collection. Die Methoden machen keinen Unterschied, ob die Liste schon sortiert ist oder nicht. public static Object min(Collection c) // public static Object max(Collection c) /* Falls min() bzw. max() auf ein Collection-Objekt angewendet wird, erfolgt die Bestimmung des Minimums bzw. Maximums nach der Methode compareTo() der Comparable Schnittstelle. Byte, Character, Double, File, Float, Long, Short, String, Integer, BigInteger, ObjectStreamField, Date und Calendar haben diese Schnittstelle implementiert. Lassen sich die Daten nicht vergleichen, dann gibt es eine ClassCastException. */ public static Object min(Collection c, Comparator vergl) // public static Object max(Collection c, Comparator vergl) 2.2.9.3 Sortieren Die Collection Klasse bietet zwei sort() Methoden an, die die Elemente einer Liste stabil56 sortieren. Die Methode sort() sortiert die Elemente in ihrer natürlichen Ordnung57, z.B.: - Zahlen nach der Größe (13 < 40) - Zeichenketten alphanumerisch (Juergen < Robert < Ulli) Eine zweite überladene Form von sort() arbeitet mit einem speziellen Comparator Objekt, das zwei Objekte mit der Methode compare() vergleicht. public static void sort(List liste) // sortiert die Liste public static void sort(List liste,Comparator c) // sortiert die Liste mit dem Comparator c Die Sortierfunktion arbeitet nur mit List-Objekten. „sort()“ gibt es aber auch in der Klasse Arrays. Bsp.: Das folgende Programm sortiert eine Reihe von Zeichenketten in aufsteigender Folge. Es nutzt die Methode Arrays.asList() zur Konstruktion einer Liste aus einem Array58. import java.util.*; public class CollectionsSortDemo { public static void main(String args[]) { String feld[] = 56 Stabile Sortieralgorithmen beachten die Reihenfolge von gleichen Elementen, z.B. beim Sortieren von Nachrichten in einem Email-Programm, zuerst nach dem Datum und anschließend nach dem Sender, soll die Liste innerhalb des Datum sortiert bleiben. 57 Zu sortierende Elemente müssen das Interface Comparable implemetieren bzw. implementiert haben. Die Klassen String, Integer, Double, Float ... sind bereits Comparable, ebenso Date, File und einige andere. 58 Leider gibt es keinen Konstruktor für ArrayList, der einen Array mit Zeichenketten zuläßt. 116 Algorithmen und Datenstrukturen { "Regina","Angela","Michaela","Maria","Josepha", "Amalia","Vera","Valentina","Daniela","Saida", "Linda","Elisa" }; List l = Arrays.asList(feld); Collections.sort(l); System.out.println(l); } } Die Java Bibliothek bietet nicht viel zur Umwandlung von Feldern („Array“) in dynamische Datenstrukturen. Eine Ausnahme bildet die Hilfsklasse Arrays, die die Methode asList() anbietet. Die Behälterklassen ArrayList und LinkedList werden über asList() nicht unterstützt, d.h. Über asList() wird zwar eine interne Klasse ArrayList benutzt, die eine Erweiterung von AbstractList ist, aber nur das notwendigste implementiert. Sortieralgorithmus. Es handelt sich um einen optimierten „Merge-Sort“. Seine Laufzeit beträgt N ⋅ log( N ) . Die sort() Methode arbeitet mit der toArray() Funktion der Klasse List. Damit werden die Elemente der Liste in einem Feld (Array) abgelegt. Schließlich wird die sort() Methode der Klasse Arrays genutzt und mit einem ListIterator wieder in die Liste eingefügt. Daten in umgekehrter Reihenfolge sortieren. Das wird über ein spezielles Comparator-Objekt geregelt, das von Collections über die Methode reverseOrder() angefordert werden kann. Bsp.59: import java.util.*; public class CollectionsReverseSortDemo { public static void main(String args[]) { Vector v = new Vector(); for (int i = 0; i < 10; i++) { v.add(new Double(Math.random())); } Comparator comparator = Collections.reverseOrder(); Collections.sort(v,comparator); System.out.println(v); } } Eine andere Möglichkeit für umgekehrt sortierte Listen besteht darin, erst die Liste mit sort() zu sortieren und anschließend mit reverse() umzudrehen. 59 Vgl. pr22122 117 Algorithmen und Datenstrukturen 2.2.9.4 Suchen von Elementen Die Behälterklassen enthalten die Methode contains(), mit der sich Elemente suchen lassen. Für sortierte Listen gibt es eine wesentlich schnellere Suchmethode: binarySearch(): public static int binarySearch(List liste, Object key) // sucht ein Element in der Liste. Gibt die Position zurück oder ein Wert kleiner 0, // falls key nicht in der Liste ist. public static int binarySearch(List liste, Object key, Comparator c) // Sucht ein Element mit Hilfe des Comparator Objekts in der Liste. Gibt die Position zurück oder // einen Wert kleiner als 0, falls der key nicht in der Liste ist. Bsp.60: Das folgende Programm sortiert (zufällig ermittelte) Daten und bestimmt Daten in Listen mit Hilfe der binären Suche. import java.util.*; public class ListSort { public static void main(String [] args) { final int GR = 20; // Verwenden einer natuerliche Ordnung List a = new ArrayList(); for (int i = 0; i < GR; i++) a.add(new VglClass((int)(Math.random() * 100))); Collections.sort(a); Object finde = a.get(GR / 2); int ort = Collections.binarySearch(a,finde); System.out.println("Ort von " + finde + " = " + ort); // Verwenden eines Comparator List b = new ArrayList(); // Bestimmt zufaellig Zeichenketten der Laenge 4 for (int i = 0; i < GR; i++) b.add(Felder.randString(4)); // Instanz fuer den Comparator AlphaVgl av = new AlphaVgl(); // Sortieren Collections.sort(b,av); // Binaere Suche finde = b.get(GR / 2); ort = Collections.binarySearch(b,finde,av); System.out.println(b); System.out.println("Ort von " + finde + " = " + ort); } } 60 Vgl. pr22122 118 Algorithmen und Datenstrukturen 2.2.10 Generics Einen Algorithmus, der von einem Datentyp unabhängig programmiert werden kann, nennt man generisch und die Möglichkeit in Java mit generischen Typen zu arbeiten Generics. 2.2.10.1 Sammlungsklassen Mit Java 1.5 finden sich generische Versionen der bekannten Sammlungsklassen. Jetzt kann man angeben, was für einen Typ die Elemente einer Sammlungsklasse genau haben sollen. Die allgemeine Syntax der Generics erfordert die Nachstellung des durch spitze Winkelklammern eingeschlossenen Typnamen nach dem Namen der so typisierten Sammlung. So wird eine Liste, die ausschließlich Objekte des Typs String enthält als List<String> definiert. Als Resultat einer so definierten Sammlungsklasse wird bereits zum Übersetzungszeitpunkt die Konformität geprüft. Daher wird bereits bei jedem Versuch Daten in eine solche konkrete Sammlung einzufügen, geprüft, ob diese unter Beachtung der Typrestriktion kompatibel zum in der Deklaration angegebenen Inhaltstyp sind. Konventionsgemäß erlaubt Java ausschließlich die Festlegung von Klassen als Inhaltsypen von Objektsammlungen. Primitivtypen sind von der Verwendung ausgeschlossen. 2.2.10.2 Iteration 2.2.10.2.1 Erweiterte for-Schleife Typischerweise wird in Programmen über die Elemente einer Klasse iteriert oder über alle Elemente eines Array (einer Reihung). Hierzu kennt Java verschiedene Schleifenkonstrukte. Bsp.61: Iterieren über alle Elemente einer Reihe und Iterieren mit Hilfe eines Iteratorobjekts über alle Elemente eines Sammlungsobjekts. import java.util.List; import java.util.ArrayList; import java.util.Iterator; class AlteIteration { public static void main(String args []) { String [] ar = {"Brecht", "Goethe", "Shakespeare", "Schiller"}; List xs = new ArrayList(); for (int i = 0; i < ar.length; i++) { String s = ar[i]; xs.add(s); } 61 pr22102 119 Algorithmen und Datenstrukturen } } for (Iterator it = xs.iterator(); it.hasNext();) { String s = (String) it.next(); System.out.println(s.toUpperCase()); } Mit Java 1.5 gibt es zusätzlich ein eigendes Schleifenkonstrukt, das bequem die Iteration über die Elemente einer Sammlung ausdrücken kann: for (Type identifier : expr) { // body } Zu lesen ist dieses Konstrukt als: "Für jedes identifier des Typs Type in expr führe body aus". Bsp.62: import java.util.ArrayList; import java.util.Iterator; public class ForLoopTest { public static void main(String[] args) { double[] array = {2.5, 5.2, 7.9, 4.3, 2.0, 4.1, 7.3, 0.1, 2.6}; // Einfache Iteration vorwaerts durch die Schleife for(double d: array) { System.out.println(d); } System.out.println("---------------------"); // Das folgende arbeitet mit allem, das das Interface Iterable // implementiert, z.B mit Collections wie ArrayList ArrayList<Integer> list = new ArrayList<Integer>(); list.add(7); list.add(15); list.add(-67); for(Integer number : list) { System.out.println(number); } System.out.println("---------------------"); // Es unterstuetzt auch Autoboxing for(int item: list) { System.out.println(item); } System.out.println("---------------------"); } } Test: 62 pr22102 120 Algorithmen und Datenstrukturen Abb.: 2.2.10.2.2 Das Interface Iterable Das erweiterte for möchte links vom Doppelpunkt ein Feld oder ein Objekt, das vom Typ Iterable ist. Diese Schnittstelle schreibt nur die Existenz einer Funktion iterator() vor, die einen java.lang.SimpleIterator liefert. Der konkrete SimpleIterator muß nur die Methoden hasNext() und next() implementieren, um das nächste Element in der Aufzählung zu geben und das Ende anzuzeigen. Ein SimpleIterator ist ein Iterator63 ohne remove(). Bsp.64: import java.util.Iterator; import java.util.StringTokenizer; class WordIterable implements Iterable, Iterator { private StringTokenizer st; public WordIterable( String s ) { st = new StringTokenizer( s ); } // Methode vom Iterable public Iterator iterator() { return this; } // Methoden vom Iterator public boolean hasNext() { return st.hasMoreTokens(); } public Object next() { return st.nextToken(); } public void remove() { } } 63 64 vgl. 2.2.8.1 pr22102 121 Algorithmen und Datenstrukturen public class WordIterableDemo { public static void main( String args[] ) { String s = "Am Anfang war das Wort - am Ende die Phrase. (Stanislaw Jerzy Lec)"; for ( Object word : new WordIterable(s) ) System.out.println( word ); } } Praktisch ist, dass java.util.Iterator seit Java 1.5 den SimpleIterator implementiert. Das folgende bekannte Programmierer-Idiom for (Iterator i = c.iterator(); i.hasNext(); ) { Typ o = (Typ) i.next(); } kann durch das erweiterte for erfolgreich abgekürzt werden: for (i : c) { Typ o = (Typ) I; } 122 Algorithmen und Datenstrukturen 2.2.11 Implementierung von Graphen-Algorithmen zur Berechnung kürzester Wege mit Behälterklassen Die folgende Abbildung zeigt einen gerichteten Graphen ohne Gewichte: k1 k2 k4 k3 k5 k6 k7 Abb.: Ein derartiger Graph wird im Speicher eines Rechners entweder als Adjazensliste oder Adjazensmatrix abgebildet. Die Darstellung des vorliegenden Graphen in einer Adjazenzliste zeigt die folgende Abbildung: k1 k2 k4 k2 k4 k5 k3 k1 k6 k4 k3 k5 k5 k6 k7 k7 k6 k7 k6 Abb.: Zur Implementierung derartiger Adjazenzlisten wird benötigt: - eine Liste mit den Knoten. Da auf die Knoten direkt zugegriffen werden soll, ist eine Implementierung einer Knotenliste über eine Hashmap erwünscht. Zu jedem Knoten der Knotenliste gehört einer Liste mit den Nachbarn des betrachteten Knoten. Jeder Eintrag in die Knotenliste wird durch die Datenstruktur Vertex beschrieben. Vertex enthält einen Anker zur Aufnahme einer verketteten Liste mit den benachbarten Knoten. Die Implementierung erfolgt über die Collection-Klasse LinkedList. 123 Algorithmen und Datenstrukturen 2.2.11.1 Die Datenstrukturen Graph, Vertex, Edge zur Implementierung von Graphenalgorithmen für die Berechnung kürzester Wege Repräsentation von Knoten65 class Vertex { public String name; // Bezeichner des Knoten public List<Edge> adj; // Benachbarte Knoten public double dist; // Kosten public Vertex prev; // Vorgaengerknoten auf dem kuerzesten Pfad public int scratch; // Spez. Variable, die in den Alg. genutzt wird // Konstruktor public Vertex( String nm ) { name = nm; adj = new LinkedList<Edge>( ); reset( ); } // Methoden public void reset( ) { dist = Graph.INFINITY; prev = null; /* pos = 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. class Edge { public Vertex dest; // Zielknoten public double cost; // Gewicht // Konstruktor public Edge( Vertex d, double c ) { dest = d; cost = c; } } Die Klasse Graph zur Aufnahme von Algorithmen zur Berechnung kürzester Pfade public class Graph { public static final double INFINITY = Double.MAX_VALUE; protected Map<String,Vertex> vertexMap = new HashMap<String,Vertex>( ); /* * Hinzufuegen einer neuen Kante. */ public void addEdge( String sourceName, String destName, double cost ) { Vertex v = getVertex( sourceName ); Vertex w = getVertex( destName ); v.adj.add( new Edge( w, cost ) ); 65 vgl. pr22859 124 Algorithmen und Datenstrukturen } /* * Routine zur Bearbeitung von nicht Erreichbarem bzw. Ausgabe der Kosten. * Aufruf einer rekursiven Methode zur Ausgabe kuerzester Pfade zum * Zielknotem, nachdem ein kuezester_Pfad_Algorithmus gelaufen ist. */ public void printPath( String destName ) { Vertex w = (Vertex) vertexMap.get( destName ); if( w == null ) throw new NoSuchElementException( "Zielknoten wurde nicht gefunden" ); else if( w.dist == INFINITY ) System.out.println( destName + " ist unerreichbar" ); else { System.out.print( "(Kosten: " + w.dist + ") " ); printPath( w ); System.out.println( ); } } /* * Falls es den Knotennamen noch nicht gibt, * addiere ihn zur Map der Knoten. * Gib immer den Knoten zurueck. */ private Vertex getVertex( String vertexName ) { Vertex v = (Vertex) vertexMap.get( vertexName ); if( v == null ) { v = new Vertex( vertexName ); vertexMap.put( vertexName, v ); } return v; } /* * Rekursive Routine zur Ausgabe des kuerzesten Pfads zum Zielknoten, * nachdem der Algorithmus zum kuerzesten Pfad gelaufen ist. * Es ist bekannt, dass der Pfad existiert. */ private void printPath( Vertex dest ) { if( dest.prev != null ) { printPath( dest.prev ); System.out.print( " to " ); } System.out.print( dest.name ); } /* * Setzt den Graph auf die Ausgangsposition zur Bearbeitung * der Algorthmen fuer kuerzeste Pfade zurueck. */ private void clearAll( ) { for (Vertex v : vertexMap.values()) v.reset(); } /* * Graphenalgorithmen zur Berechnung kuerzester Pfade */ } 125 Algorithmen und Datenstrukturen 2.2.11.2 Kürzeste Pfade in gerichteten, ungewichteten Graphen. Lösungsbeschreibung. Die ungewichteten Graphen G: folgende k1 Abbildung zeigt einen gerichteten, k2 k3 k4 k5 k6 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 0 k5 k6 k7 Abb.: Der Graph nach Markierung des Startknoten als erreichbar Danach werden alle Knoten aufgesucht, die „eine Einheit“ von s entfernt sind. Im vorliegenden Fall sind das k1 und k6. Dann werden die Knoten aufgesucht, die von s zwei Einheiten entfernt sind. Das geschieht über alle Nachfolger von k1 und k6. Im vorliegenden Fall sind es die Knoten k2 und k4. Aus den benachbarten Knoten von k2 und k4 erkennt man, dass k5 und k7 die kürzesten Pfadlängen von drei Knoten besitzen. Da alle Knoten nun bewertet sind ergibt sich folgenden Bild: k1 k3 k2 1 k4 0 2 k5 2 1 k6 k7 Abb.: Graph nach Ermitteln aller Knoten mit der kürzeszen Pfadlänge 2 126 Algorithmen und Datenstrukturen Die hier verwendete Strategie ist unter dem Namen „breadth-first search“66 bekannt. Die „Breitensuche zuerst“ berücksichtigt zunächst alle Knoten vom Startknoten aus, die am weitesten entfernt liegenden Knoten werden zuerst ausgerechnet. Übertragen der Lösungsbeschreibung in Quellcode. Zu Beginn sollte eine Tabelle mit folgenden Einträgen vorliegen: k k1 k2 k3 k4 k5 k6 k7 bekannt false false false false false false false dk ∞ ∞ 0 ∞ ∞ ∞ ∞ pk 0 0 0 0 0 0 0 Die Tabelle überwacht den Fortschritt beim Ablauf des Algorithmus und führt Buch über gewonnene Pfade. Für jeden Knoten werden 3 Angaben in der Tabelle verwaltet: - die Distanz dk des jeweiligen Knoten zu dem Startknoten s. Zu Beginn sind alle Knoten von s aus unerreichbar ( ∞ ). Ausgenommen ist natürlich s, dessen Pfadlänge ist 0 (k3). - Der Eintrag pk ist eine Variable für die Buchführung (und gibt den Vorgänger im Pfad an). - Der Eintrag unter „bekannt“ wird auf „true“ gesetzt, nachdem der zugehörige Knoten erreicht wurde. Zu Beginn wurden noch keine Knoten erreicht. Das führt zu der folgenden Knotenbeschreibung: class Vertex { public String public LinkedList public boolean public int public Vertex ..... } name; adj; bekannt; dist; path; // Name des Knoten // Benachbarte Knoten // Kosten // Vorheriger Knoten auf dem kuerzesten Pfad Die Grundlage des Algorithmus kann folgendermaßen (in Pseudocode) beschrieben werden: /* /* /* /* 1 2 3 4 */ */ */ */ /* 5 */ /* 6 */ /* 7 */ /* 8 */ /* 9 */ 66 void ungewichtet(Vertex s) { 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; } } } vgl. 5.2.2 127 Algorithmen und Datenstrukturen 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 ) .67 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 */ 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); } } } Die folgende Tabelle zeigt, wie sich die Daten der Tabelle während der Ausführung des Algorithmus ändern: Anfangszustand k bekannt dk k1 false ∞ k2 false ∞ k3 false 0 k4 false ∞ k5 false ∞ k6 false ∞ k7 false ∞ Q: k3 67 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 wegen der beiden verschachtelten for-Schleifen 128 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 Algorithmen und Datenstrukturen k2 aus der Schlange k bekannt dk k1 true 1 k2 true 2 k3 true 0 k4 false 2 k5 false 3 k6 true 1 k7 false ∞ Q: k4, k5 pk k3 k1 0 k1 k2 k3 0 k4 aus der Schlange bekannt dk pk true 1 k3 true 2 k1 true 0 0 true 2 k1 false 3 k2 true 1 k3 false 3 k4 Q: k5, k7 k5 aus der Schlange bekannt dk pk true 1 k3 true 2 k1 true 0 0 true 2 k1 true 3 k2 true 1 k3 false 3 k4 Q: k7 K7 aus der Schlange bekannt dk pk true 1 k3 true 2 k1 true 0 0 true 2 k1 true 3 k2 true 1 k3 true 3 k4 Q: leer Abb.:Veränderung der Daten während der Ausführung des Algorithmus zum kürzesten Pfad Implementierung68. 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. */ public void unweighted( String startName ) { clearAll( ); Vertex start = (Vertex) vertexMap.get( startName ); if( start == null ) throw new NoSuchElementException( "Startknoten nicht gefunden" ); LinkedList q = new LinkedList( ); q.addLast( start ); start.dist = 0; while( !q.isEmpty( ) ) { Vertex v = (Vertex) q.removeFirst( ); for (Edge e : v.adj) { Vertex w = e.dest; if( w.dist == INFINITY ) { w.dist = v.dist + 1; w.prev = v; q.addLast( w ); } } } } 68 vgl.: pr22859 129 Algorithmen und Datenstrukturen 2.2.11.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 kat 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 Graphen69 ab. Im Algorithmus von Dijkstra werden auch die Daten über „bekannt“, dv (kürzeste Pfadlänge) und pv (letzter Knoten, der eine Veränderung von dv verursacht hat) verwaltet. Es wird eine Menge S von Knoten betrachtet und schrittweise vergrößert, für die der kürzeste Weg von s aus bereits bekannt ist. Jedem Knoten v ∈ V wird ein Distanz d(v) zugeordnet. Anfangs ist d(s) = 0 und für alle von s verschiedenen Knoten v ∈ V ist d v = ∞ , und S ist leer. Dann wird S nach dem Prinzip "Knoten mit kürzester Distanz von s zuerst" schrittweise folgendermaßen vergrößert, bis S alle Knoten V des Graphen enthält: 1. Wähle Knoten v ∈ V S mit minimaler Distanz 2. Nimm v zu S hinzu 3. Für jede Kante vw von einem Knoten v zu einem Knoten w ∉ S , ersetze d(w) durch min({d (v), d (v) + c(v, w)}) Der folgende Graph 2 k1 4 k2 1 3 10 2 2 k3 k4 5 8 k6 k5 4 1 6 k7 Abb.: Graph nach Ermitteln aller Knoten mit der kürzeszen Pfadlänge 2 mit der Knotenbeschreibung class Vertex { .... public LinkedList public boolean public DistType70 public Vertex ... } 69 70 adj; // Benachbarte Knoten bekannt; // dist; // Kosten path; // Vorheriger Knoten auf dem kuerzesten Pfad vgl. 1. DistType ist wahrscheinlich int 130 Algorithmen und Datenstrukturen führt zu der folgende Initialisierung: k k1 k2 k3 k4 k5 k6 k7 bekannt false false false false false false false dk 0 ∞ ∞ ∞ ∞ ∞ ∞ pk null null null null null null null Abb.: Anfangszustand der Tabelle mit den Daten für den Algorithmus von Dijkstra Der erste Knoten (Start) ist der Knoten k1 mit Pfadlänge 0. Nachdem k1 bekannt ist, ergibt sich folgendes Bild: k k1 k2 k3 k4 k5 k6 k7 bekannt true false false false false false false dk 0 2 ∞ 1 ∞ ∞ ∞ pk null k1 null k1 null null null Abb.: Zustand der Tabelle nach „k1 ist bekannt“ „k1“ besitzt die Nachbarknoten: k2 und k4. „k4“ wird gewählt und als bekannt markiert. Die Knoten k3, k5, k6 und k7 sind jetzt die benachbarten Knoten. k k1 k2 k3 k4 k5 k6 k7 bekannt true false false true false false false dk 0 2 3 1 3 9 5 pk null k1 k4 k1 k4 k4 k4 Abb.: Zustand der Tabelle nach „k4 ist bekannt“ „k2“ wird gewählt. „k4“ ist benachbart, aber schon bekannt. „k5“ ist ebenfalls benachbart, wir aber nicht ausgerichtet, da die Kosten von „k2“ aus 2 +10 = 12 sind und ein Pfad der Länge 3 schon bekannt ist k k1 k2 k3 k4 k5 k6 k7 bekannt true true false true false false false dk 0 2 3 1 3 9 5 pk null k1 k4 k1 k4 k4 k4 Abb.: Zustand der Tabelle nach „k2 ist bekannt“ 131 Algorithmen und Datenstrukturen Der nächste ausgewählte Knoten ist „k5“ (ohne Ausrichtungen), danach wird k3 gewählt. Die Wahl von „k3“ bewirkt die Ausrichtung von „k6“ k k1 k2 k3 k4 k5 k6 k7 bekannt true true true true true false false dk 0 2 3 1 3 8 5 pk null k1 k4 k1 k4 k3 k4 Abb.: Zustand der Tabelle „k5 ist bekannt“ und (anschließend) „k3 ist bekannt“. „k7“ wird gewählt. Daraus resultiert folgende Tabelle: k k1 k2 k3 k4 k5 k6 k7 bekannt true true true true true false true dk 0 2 3 1 3 6 5 pk null k1 k4 k1 k4 k7 k4 Abb.: Zustand der Tabelle „k7 ist bekannt“. Schließlich bleibt nur noch k6 übrig. Das ergibt dann die folgende Abschlußtabelle: k k1 k2 k3 k4 k5 k6 k7 bekannt true true true true true true true dk 0 2 3 1 3 6 5 pk null k1 k4 k1 k4 k7 k4 Abb.: Zustand der Tabelle nach „k6 ist bekannt“. Der Algorithmus, der diese Tabellen folgendermaßen beschrieben werden: berechnet, kann void dijkstra(Vertex s) { Vertex v, w; /* 1 */ s.dist = 0; /* 2 */ for(; ;) { /* 3 */ v = kleinster_unbekannter_Distanzknoten; /* 4 */ if (v == null) /* 5 */ break; /* 6 */ v.bekannt = true; /* 7 */ for each w benachbart_zu v /* 8 */ if (!w.bekannt) /* 9 */ if (v.dist + cvw < w.dist) { /* 10 */ w.dist = v.dist + cvw; /* 11 */ w.pfad = v; } 132 (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 Distanz71. Man kann das wiederholte Bestimmen der kleinsten Distanz einer prioritätsgesteuerten Warteschlange übertragen. Der Leistungsaufwand beträgt dann O(|E| log(|V)+|V| log(|V|)). Der Algorithmus (in Pseudocode) könnte so aussehen: /* 1 */ for_all v ∈ V do d (v ) = ∞ /* 2 */ d ( s ) = 0 ; S = 0 /* 3 */ pq = new PriorityQueue(); // Vorrangwarteschlange für Knoten in V /* 4 */ while pq ≠ 0 do /* pq = V S */ /* 5 */ pq.delete _ min() /* 6 */ S = S ∪ {v} /* 7 */ for _ all (v, w) ∈ E do /* 8 */ if d (v ) + c(v, w) < d ( w) pq.decrease _ key ( w, d (v) + c(v, w)) /* 9 */ Entferne (v, w) aus E /*10*/ end while prioritätsgesteuerte Warteschlange: 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 stes in konstanter Zeit ausgeführt werden. Das Interface PriorityQueue für die Implementierung in einem Binary Heap // // // // // // // // // // // PriorityQueue interface ******************PUBLIC OPERATIONS********************* void insert( x ) --> Insert x Comparable deleteMin( )--> Return and remove smallest item Comparable findMin( ) --> Return smallest item boolean isEmpty( ) --> Return true if empty; else false void makeEmpty( ) --> Remove all items int size( ) --> Return size ******************ERRORS******************************** Throws UnderflowException for findMin and deleteMin when empty /** * PriorityQueue interface. * Note that all "matching" is based on the compareTo method. * @author Mark Allen Weiss, Juergen Sauer */ public interface PriorityQueue { /** * Insert into the priority queue, maintaining heap order. * Duplicates are allowed. * @param x the item to insert. * @return may return a Position useful for decreaseKey. */ 71 v = kleinster_unbekannter_Distanzknoten 133 Algorithmen und Datenstrukturen void insert( Comparable x ); /** * Find the smallest item in the priority queue. * @return the smallest item. * @throws UnderflowException if empty. */ Comparable findMin( ); /** * Remove the smallest item from the priority queue. * @return the smallest item. * @throws UnderflowException if empty. */ Comparable deleteMin( ); /** * Test if the priority queue is logically empty. * @return true if empty, false otherwise. */ boolean isEmpty( ); /** * Make the priority queue logically empty. */ void makeEmpty( ); } /** * Returns the size. * @return current size. */ int size( ); Dieses Interface wird in einem Heap (Klasse BinaryHeap) implementiert: // // // // // // // // // // // // // BinaryHeap class Konstruktor: leer oder mit initialisierendem Array. ******************PUBLIC OPERATIONS********************* void insert( x ) --> Einfuegen x Comparable deleteMin( )--> Rueckgabe und Entfernen kleinstes Element Comparable findMin( ) --> Rueckgabe kleinstes Element boolean isEmpty( ) --> Rueckgbae true, falls leer; anderenfalls false void makeEmpty( ) --> Entferne alle Elemente ******************ERRORS******************************** Throws UnderflowException fuer findMin() and deleteMin(), wenn leer /* * Implementiert einen binary heap. * "matching" basiert auf compareTo(). */ public class BinaryHeap implements PriorityQueue { private static final int DEFAULT_CAPACITY = 100; private int currentSize; // Anzahl Elemente im Heap private Comparable [ ] array; // Heap-Feld /* * Konstruktor the binary heap. */ public BinaryHeap( ) { currentSize = 0; array = new Comparable[ DEFAULT_CAPACITY + 1 ]; } /* * Konstruktor fuer einen Heap aus einem Array. 134 Algorithmen und Datenstrukturen * Parameter items enthaelt die initialisierenden items des Heap. */ public BinaryHeap( Comparable [ ] items ) { currentSize = items.length; array = new Comparable[ items.length + 1 ]; for( int i = 0; i < items.length; i++ ) array[ i + 1 ] = items[ i ]; buildHeap( ); } /* * Insert in die Priority Queue. * Duplikate sind erlaubt. * Parameter x enthaelt das einzufuegende Element. * Rueckgabe null. */ public void insert( Comparable x ) { if( currentSize + 1 == array.length ) doubleArray( ); // Percolate up int hole = ++currentSize; array[ 0 ] = x; for( ; x.compareTo( array[ hole / 2 ] ) < 0; hole /= 2 ) array[ hole ] = array[ hole / 2 ]; array[ hole ] = x; } /* * Finde das kleinste Element in der Priority Queue. * Rueckgabe: kleinstes Element. */ public Comparable findMin( ) { if( isEmpty( ) ) throw new UnderflowException( "Leerer binary heap" ); return array[ 1 ]; } /* * Remove the smallest item from the priority queue. * return the smallest item. * throws UnderflowException if empty. */ public Comparable deleteMin( ) { Comparable minItem = findMin( ); array[ 1 ] = array[ currentSize-- ]; percolateDown( 1 ); return minItem; } /* * Einrichten der Heap-Ordnung durch eine geeignete * Anordnung der Elemente. Ablauf in linearer Zeit. */ private void buildHeap( ) { for( int i = currentSize / 2; i > 0; i-- ) percolateDown( i ); } /* * Test, ob die Priority-Queue keine Elemente mehr enthaelt * return true, falls leer, false anderenfalls. */ public boolean isEmpty( ) { return currentSize == 0; } 135 Algorithmen und Datenstrukturen /* * Returns size. * return current size. */ public int size( ) { return currentSize; } /* * Leeren der Priority Queue */ public void makeEmpty( ) { currentSize = 0; } /* * Interne Methode zum Absteigen im Heap. * Parameter hole enthaelt den Index, bei dem der Abstieg beginnt. */ private void percolateDown( int hole ) { int child; Comparable tmp = array[ hole ]; for( ; hole * 2 <= currentSize; hole = child ) { child = hole * 2; if( child != currentSize && array[ child + 1 ].compareTo( array[ child ] ) < 0 ) child++; if( array[ child ].compareTo( tmp ) < 0 ) array[ hole ] = array[ child ]; else break; } array[ hole ] = tmp; } /* * Interne Methode den Array zu vergroessern. */ private void doubleArray( ) { Comparable [ ] newArray; newArray = new Comparable[ array.length * 2 ]; for( int i = 0; i < array.length; i++ ) newArray[ i ] = array[ i ]; array = newArray; } // Test program public static void main( String [ ] args ) { int numItems = 10000; BinaryHeap h1 = new BinaryHeap( ); Integer [ ] items = new Integer[ numItems - 1 ]; int i = 37; int j; for( i = 37, j = 0; i != 0; i = ( i + 37 ) % numItems, j++ ) { h1.insert( new Integer( i ) ); items[ j ] = new Integer( i ); } for( i = 1; i < numItems; i++ ) if( ((Integer)( h1.deleteMin( ) )).intValue( ) != i ) System.out.println( "Oops! " + i ); BinaryHeap h2 = new BinaryHeap( items ); for( i = 1; i < numItems; i++ ) if( ((Integer)( h2.deleteMin( ) )).intValue( ) != i ) System.out.println( "Oops! " + + i ); 136 Algorithmen und Datenstrukturen } } In der Priority-Queue wird für den Algorithmus von Dijkstra folgender Datensatz vermerkt: class Path implements Comparable { public Vertex dest; // w public double cost; // d(w) // Konstruktor public Path( Vertex d, double c ) { dest = d; cost = c; } // Methode des Comparable-Interface public int compareTo( Object rhs ) { double otherCost = ((Path)rhs).cost; return cost < otherCost ? -1 : cost > otherCost ? 1 : 0; } } Implementierung.72 /* * Kuerzster Pfad Algorihmus mit Gewichten. */ public void dijkstra( String startName ) { PriorityQueue pq = new BinaryHeap(); Vertex start = (Vertex) vertexMap.get( startName ); if( start == null ) throw new NoSuchElementException( "Start vertex not found" ); clearAll( ); pq.insert( new Path( start, 0 ) ); start.dist = 0; int nodesSeen = 0; while( !pq.isEmpty( ) && nodesSeen < vertexMap.size( ) ) { Path vrec = (Path) pq.deleteMin( ); Vertex v = vrec.dest; if( v.scratch != 0 ) // schon bearbeited v continue; v.scratch = 1; nodesSeen++; for (Edge e : v.adj) { Vertex w = e.dest; double cvw = e.cost; if( cvw < 0 ) throw new GraphException( "Graph hat negativ gewichtete Kanten" ); if( w.dist > v.dist + cvw ) { w.dist = v.dist +cvw; w.prev = v; pq.insert( new Path( w, w.dist ) ); } } } } 72 vgl. pr22859 137 Algorithmen und Datenstrukturen 138 Algorithmen und Datenstrukturen 2.2.11.4 Berechnung der kürzesten Pfadlängen in gewichteten Graphen mit negativen Kosten Falls ein Graph Kanten mit negative Kosten enthält, arbeitet der Dijkstra-Algorithmus 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. Eine mögliche, aber umständliche Lösung ist: Addition einer Konstanten ∆ zu jedem Kantengewicht. Die Konstante wird so groß gewählt, dass keine negative 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(); // /* 2*/ q.enqueue(s); // Einreihen des Startknoten s /* 3*/ while (!q.isEmpty()) { /* 4 */ v = 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); } } } 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. Implementierung73 public void negative( String startName ) { clearAll( ); Vertex start = (Vertex) vertexMap.get( startName ); if( start == null ) throw new NoSuchElementException( "Startknoten wurde nicht gefundem"); LinkedList<Vertex> q = new LinkedList<Vertex>( ); q.addLast( start ); start.dist = 0; start.scratch++; while( !q.isEmpty( ) ) { Vertex v = (Vertex) q.removeFirst( ); if( v.scratch++ > 2 * vertexMap.size( ) ) throw new GraphException( "Negative Zyklen entdeckt" ); for (Edge e : v.adj) { Vertex w = e.dest; double cvw = e.cost; if( w.dist > v.dist + cvw ) 73 vgl. pr22859 139 Algorithmen und Datenstrukturen { w.dist = v.dist + cvw; w.prev = v; // Einreihen nur, wenn noch nicht in der Schlange if( w.scratch++ % 2 == 0 ) q.addLast( w ); else w.scratch--; // undo the enqueue increment } } } } 2.2.11.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 Ordnung74) ausgewählt. Die Auswahl der Knoten in topologischer Folge garantiert: die Distanz dv kann nicht weiter erniedrigt werden. /* Kuezester Pfad fuer azyklische Graphen mit negativ Gewichten. */ public void acyclic( String startName ) { Vertex start = (Vertex) vertexMap.get( startName ); if( start == null ) throw new NoSuchElementException( "Startknoten wurde nicht gefunden" ); clearAll( ); LinkedList<Vertex> q = new LinkedList<Vertex>( ); start.dist = 0; // Berechne indegrees Collection vertexSet = vertexMap.values( ); for( Iterator vsitr = vertexSet.iterator( ); vsitr.hasNext( ); ) { Vertex v = (Vertex) vsitr.next( ); for( Iterator witr = v.adj.iterator( ); witr.hasNext( ); ) ( (Edge) witr.next( ) ).dest.scratch++; } // Einordnen der Knoten mit indegree zero in die Schlange for( Iterator vsitr = vertexSet.iterator( ); vsitr.hasNext( ); ) { Vertex v = (Vertex) vsitr.next( ); if( v.scratch == 0 ) q.addLast( v ); } int iterations; for( iterations = 0; !q.isEmpty( ); iterations++ ) { Vertex v = (Vertex) q.removeFirst( ); for (Edge e : v.adj) { Vertex w = e.dest; double cvw = e.cost; if( --w.scratch == 0 ) q.addLast( 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 new GraphException( "Graph has a cycle!" ); } } 74 vgl. 1.2.2.2 140 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 Ordnung75. 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. 75 1.2.2.2 141 Algorithmen und Datenstrukturen Abb.: Implementierung. void quicksort(char array[], int min, int max) { int left = min, right = max; // reads the value of the cell in the middle char middle = array[(left + right) / 2]; do { // find first element >= element in the middle while (array[left] < middle) left++; // find last element <= element in the middle while (array[right] > middle) right--; // swap these elements if (left < right) swap(array, left, right); if (left <= right) } left++; // go to the right--; // next elements } // continue as long as we meet in the middle } while (left <= right); if (min < right) // separate the array quicksort(array, min, right); // into two parts and continue sorting if (left < max) // as long there is a part which quicksort(array, left, max); // has more than one cell } 142 Algorithmen und Datenstrukturen 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 Leere76. 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. 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. Implementierung77. // import java.io.*; public class HeapSort { private static void durchdringeRunter(Comparable [] a, int i, int n) { int kind ; Comparable tmp; for (tmp = a[i]; (2* i + 1) < n; i = kind) { kind = 2 * i + 1; if (kind != n - 1 && a[kind].compareTo(a[kind+1]) < 0) kind++; System.out.println(i + ", " + kind + ", " + a[kind]); if (tmp.compareTo(a[kind]) < 0) a[i] = a[kind]; else break; } a[i] = tmp; } public static void heapsort(Comparable [] a) { 76 77 Eine der entstehenden Teilfoge ist leer, die andere enthält alle restlichen Elemente. pr13228 143 Algorithmen und Datenstrukturen for (int i = a.length / 2; i >= 0; i--) durchdringeRunter(a, i, a.length); for (int i = a.length - 1; i > 0; i--) { Comparable tmp = a[0]; a[0] = a[i]; a[i] = tmp; durchdringeRunter(a,0,i); } } public static void main(String[ ] args) { // InputStreamReader isr = new InputStreamReader(System.in); // BufferedReader ein = new BufferedReader(isr); int [] x = { 150, 80, 40, 30, 10, 70, 110, 100, 20, 90, 60, 50, 120, 140, 130 }; Comparable [] a = new Comparable[x.length]; for (int i = 0; i < x.length; i++) { a[i] = new Integer(x[i]); } HeapSort.heapsort(a); System.out.println("Sortierte Ausgabe: "); for (int i = 0; i < a.length; i++) System.out.print(a[i].toString() + ", " ); System.out.println(); } } 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 2l-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 2l-2 Elementen wieder um 1, usw. Damit gilt für die Anzahl der Vergleiche ≤2 l −1 ⋅ 2 ⋅ (l − 1) + 2 l −2 l −1 ⋅ 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 ) 144 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 Anwendung78. 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 78 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 145 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]; 146 Algorithmen und Datenstrukturen mische(a,nLinks,a + nLinks,nRechts,z); 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 147 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 79. 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 12 18 B: C: 06 18 12 42 44 55 94 67 A: 06 12 18 42 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. 79 vgl. 3.1.1.1, Sortieren durch Mischen 148 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 Mischsortieren80. 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 d3 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 80 vgl. PR33116.CPP 149 Algorithmen und Datenstrukturen d4: 6, 13, 15, 3, 9, 10 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 werden81. 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 81 vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 142 150 Algorithmen und Datenstrukturen BubbleSort(A,I); // Sortierte Teilfolgen werden abwechselnd nach Datei[2] // 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()) 151 Algorithmen und Datenstrukturen // Schleife eines Durchgangs { if(l1 <= l2) 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; } 152 Algorithmen und Datenstrukturen } // Alle Dateien schließen Datei[0].close(); Datei[1].close(); Datei[2].close(); Datei[3].close(); // Wechsle das Lesen und das Schreiben 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): d1, 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 d2 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. Verfahrensaufwand82: 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. Implementierung83in 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(); 82 83 vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 147 vgl. PR33118.CPP 153 Algorithmen und Datenstrukturen }; // Schnittstellenfunktionen // private Methoden 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++) 154 Algorithmen und Datenstrukturen } datei[(merkmal ? index + K : index)] >> A[index]; do { gefunden = 0; for (index = 0; index < K; index++) if (!datei[(merkmal ? index + K : index)].eof () && (!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 155 Algorithmen und Datenstrukturen 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. Beim Mehrphasen-Mischsortieren84 (polyphase mergesort)85 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 ist86. 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 d1 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 d1 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. 84 vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 148 vgl. Wirth, N.: Algorithmen und Datenstrukturen, Stuttgart 1979, S. 153 86 Das ist eine Phase. 85 156 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. X[J] heißt (maximaler) Lauf, wenn Vereinbarung: Eine Teilsequenz X[I] .. 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. 157 Algorithmen und Datenstrukturen G1 F G1 G1 F G2 F ....... F G2 F 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 158 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; 159 Algorithmen und Datenstrukturen 3.1.2 Suche in Texten Statischer und dynamischer Text. Man unterscheidet: Statischer Text wird nur wenig geändert im Verhältnis zum Umfang des Werks (Lexika, Nachschlagewerke). Hier lohnt sich durch Ergänzen mit geeigneter Information (z.B. Index) ein Aufbereiten des Textes zur Unterstützung der Suche nach verschiedenen Mustern. Dynamischer Text unterliegt häufig umfangreichen Änderungen (z.B. Bearbeitungen mit Texteditoren). Die folgenden Darstellungen von Algorithmen umfassen die Bearbeitung von dynamischen Text. Implementierung und Test der Suchalgorithmen. Zur Kontrolle der Wirkungsweise bzw. Implementierung der verschiedenen Verfahren für die Suche in Texten wird in C++ die Klasse „suche“ bereitgestellt. #include <string.h> class suche { private: const char *muster; int m; // Musterlaenge public: suche(const char *pat); // Konstruktor ~suche(){} // Destruktor char *finden(char *text); }; Die verschiedenen Suchalgorithmen der Textverarbeitung sind jeweils in der Methode finden() angegeben. Auch der Konstruktor wird an die spezifische Suchmethode angepaßt. Das folgende Programmschema87 zeigt, wie die Klasse „suche“ in die Textverarbeitung einbezogen wird: // Durchsuchen einer Textdatei nach einem Zeichenmuster #include #include #include #include #include <fstream.h> <iomanip.h> <stdlib.h> <string.h> "suche.h" void main(void) { char dName[50]; cout << "Eingabe-Datei: "; cin >> setw(50) >> dName; char musterWort[50]; cout << "Muster: "; cin >> musterWort; ifstream eingabe(dName, ios::in); if (!eingabe) { cout << "Kann " << dName << " nicht oeffnen" << endl; exit(1); } // Bestimme Laenge der Textdatei: int n = 0, i; char zch; 87 PR24140.CPP 160 Algorithmen und Datenstrukturen } while (eingabe.get(zch), !eingabe.fail()) n++; eingabe.clear(); eingabe.seekg(0); char *text = new char[n + 1]; char *p = text, *pattern = musterWort; // Einlesen der TextDatei: for (i = 0; i < n; i++) eingabe.get(text[i]); text[n] = '\0'; // Spezfikation Muster: suche x(pattern); // Ermittle alle Vorkommen von Muster im Text: cout << "Text vom gefundenen Muster an bis Zeilenende:\n"; while ((p = x.finden(p)) != 0) { for (i = 0; p[i] != '\0' && p[i] != '\n'; i++) cout << p[i]; cout << endl; p++; } // cin >> zch; Der zu durchsuchende Text wird aus einer Textdatei in die Zeichenkette „text“ eingelesen. Nachdem eine Instanz der Klasse „suche“ mit dem Suchmuster (pattern) gebildet wurde, erfolgt der Aufruf über die Methode finden() die Suche nach dem Muster. Bei erfolgreicher Suche wird der Text vom gefundenen Muster an bis zum Zeilenende ausgegeben. 161 Algorithmen und Datenstrukturen 3.1.2.1 Ein einfacher Algorithmus zum Suchen in Zeichenfolgen 1. Das naive Verfahren zur Textsuche (Brute-Force Algorithmus) Beschreibung. Am einfachsten läßt sich ein Vorkommen des Musters m1, ... , mM im Text mit den Zeichen z1, ... , zN folgendermaßen bestimmen: Man legt das Muster, das erste Zeichen im Text ist der Startpunkt, der Reihe nach an jeden Teilstring vom text mit der Länge M an und vergleicht zeichenweise von links nach rechts, ob eine Übereinstimmung zwischen Muster und Text vorliegt oder nicht. Das geschieht solange, bis man das Vorkommen des Musters im Text gefunden oder das Ende des Texts erreicht hat. Implementierung. char *suche::finden(char* text) { // Brute Force Algorithmus, Das naive Verfahren zur Textsuche int i = 0, j = 0; int letzterInd = strlen(text)- m; while ((j < m) && (i < letzterInd)) { if (text[i] == muster[j]) { i++; j++; } else { i -= j - 1; j = 0; } } if (j == m) return &text[i - m]; else return 0; } Analyse. Bei diesem Verfahren muß das Muster (N-M+1)-mal an den Text angelegt werden und dann jeweils ganz durchlaufen werden. Das bedeutet: Es müssen ( N − M + 1) ⋅ M Vergleiche ausgeführt werden. Da ( N − M + 1) ⋅ M < ( N − M + M ) ⋅ M = N ⋅ M ist, liegt im schlimmsten Fall der Aufwand in der Größenordnung von O( N ⋅ M ) . 2. Mustererkennung über den Vergleich des ersten und des letzten Zeichens Beschreibunng. Der Algorithmus vergleicht das erste und letzte Zeichen des Musters in einem Textbereich mit der Länge des Musters. Der Index, der zu einem übereinstimmenden Zeichen in der Zeichenkette verweist, wird in trefferAnf gespeichert. „trefferEnde“ ist der Index des letzten Zeichens im Textbereich, das mit dem letzten Zeichen im Muster übereinstimmen muß. Sind trefferAnf und trefferEnde bestimmt, dann werden die Zeichen des so definierten Textbereichs ohne das erste und letzte Zeichen mit dem entsprechenden Teilstring des Musters verglichen. Falls auch sie übereinstimmen ist das Muster gefunden, andernfalls muß der Text weiter (bis zum Ende) durchsucht werden. Implementierung. char* suche::finden(char* text) { int i = 0, j = 1, k; int letzterInd = strlen(text) - m; while (i < letzterInd) 162 Algorithmen und Datenstrukturen { while (text[i] != muster[0]) i++; int trefferAnf = i; if (trefferAnf > letzterInd) return 0; int trefferEnde = trefferAnf + m - 1; if (trefferEnde > letzterInd) return 0; if (text[trefferEnde] == muster[m - 1]) { if (m <= 2) return &text[trefferAnf]; for (k = trefferAnf + 1; k <= trefferAnf + m - 2; k++) { if (text[k] != muster[j++]) { i = k; break; } i = k; } if (i == trefferAnf + m - 2) return &text[trefferAnf]; } i++; j = 1; } return 0; } Analyse. Falls die ersten m Zeichen in der Zeichenkette text zum Muster passen, wird das über M Vergleiche ermittelt. Im besten Fall ist der vorliegende Algorithmus bzgl. der rechnerischen Komplexität von der Ordnung O(M). Im schlimmsten Fall müssen die Zeichen des String bis zum Ende der Zeichenkette durchsucht und verglichen werden. U.U. kann dabei auftreten, daß jeweils die ersten Zeichen beim Vergleich Muster mit denen in der Zeichenkette übereinstimmen, das Muster selbst jedoch mit keinem Teilbereich von Zeichen aus text zusammenpaßt. Bsp.: Das Muster „abc“ und der Text „aaaaaaaa“. Die 3 Zeichen des Muster „abc“ müssen sechsmal, d.h. N-M+1mal verglichen werden. Generell müssen M Zeichen (N-M+1)mal verglichen werden, das führt zu M ⋅ ( N − M + 1) Vergleichen. Da M ⋅ ( N − M + 1) < M ⋅ ( N − M + M ) = N ⋅ M ist, liegt im schlimmsten Fall der Aufwand in der Größenordnung von O( N ⋅ M ) . Der vorliegende Algorithmus zeigt keine wesentliche Verbesserung gegenüber dem unter 1. angegebenen naiven Algorithmus zur Textsuche. Die naiven Verfahren behalten nicht, welche Zeichen im Text mit einem Anfangsstück des Musters übereinstimmen, bis eine Fehlanzeige (mismatch) auftrat. In diesem Sinnen sind die naiven Verfahren gedächtnislos. 163 Algorithmen und Datenstrukturen 3.1.2.2 Das Verfahren von Knuth-Morris-Pratt Beschreibung des Algorithmus. Der Algorithmus beruht auf folgender Überlegung: Wenn an einer bestimmten Position eine Nichtübereinstimmung festgestellt wird, dann sind die bis zu dieser Fehlposition bearbeiteten Zeichen bekannt. Diese Information soll genutzt werden, und der Zeiger „i“, der die Zeichen durchläuft, sollte nicht so einfach über all diese bereits bekannten Zeichen hinweggesetzt werden. In der folgenden Darstellung kommt es erst zur Fehlanzeige an der 6. Zeigerposition „j“. Das Erkennen einer Fehlanzeige erfolgt nach „j-1“ Zeichen. i Index text[] [0] A muster[] A skip -1 [1] B [2] C [3] A [4] B [5] C B C A B D 1 j 2 0 0 0 [6] A A [7] B B [8] D C A [9] A B D Abb.: Darstellung des Verfahrensablaufs Würde kein einziges Zeichen sich in dem Muster wiederholen, könnte der Textzeiger i um eine Einheit erhöht werden und der Musteranfang auf diese Textstelle nach einer Fehlanzeige verschoben werden. Wiederholen sich Zeichen im Muster, d.h. stimmen Zeichenbereiche am Anfang des Musters mit Zeichenbereichen am Ende des Musters überein, dann braucht für den Vergleich der Zeiger im Muster lediglich um einige Positionen zurückgesetzt werden und zwar genau bis zu der Stelle, an der Übereinstimmung festgestellt wurde. Die Sprungtabelle. Die Sprungtabelle skip[0..M-1] wird zum Zurücksetzen bei Nichtübereinstimmung benutzt. Zur Konstuktion dieser Sprungtabelle schreibt man das Musterwort in zwei Zeilen untereinander, das erste Zeichen liegt anfangs unter dem zweiten, z.B.: A B A C B A A C B A B A C B D B A C D B A D B D Dann wird die untere Kopie soweit nach rechts verschoben, bis alle sich überlappenden Zeichen übereinstimmen (bzw. keine Zeichen sich mehr überlappen). Die übereinander liegenden Zeichen definieren die nächste Stelle, wo das Muster passen kann, wenn eine Nichtübereinstummung an der Stelle j des Musters festgestellt wurde. 164 Algorithmen und Datenstrukturen Das Feld skip bestimmt das Rücksetzen des Textzeigers i. Zeigen i und j auf nicht übereinstimmende Zeichen in Text und Muster, so beginnt die nächste mögliche Position für eine Übereinstimmung mit dem Muster bei Position i skip[j]. Da aber gemäß Definition von skip die ersten skip[j] Zeichen in dieser Position mit den ersten skip[j] Zeichen des Musters übereinstimmen, kann i unverändert bleiben und j auf skip[j] gesetzt werden: suche::suche(const char *must) { muster = must; m = strlen(muster); int i, j; skip[0] = -1; for (i = 0, j = -1; i < m; i++, j++, skip[i] = j) while (( j >= 0) && (muster[i] != muster[j])) j = skip[j]; } char *suche::finden(char* text) { int i, j; int letzterInd = strlen(text); for (i = 0, j = 0; j < m && i < letzterInd; i++, j++) while ((j >= 0) && (text[i] != muster[j])) j = skip[j]; if (j == m) return &text[i-m]; else return 0; } Falls j == 0 und text[i] mit dem Muster nicht übereinstimmt, gibt es keine übereinstimmende Zeichen. In diesem Fall wird i erhöht und j auf den Anfangswert des Musters zurückgesetzt. Dies wird erreicht, indem skip[0] durch Definition aud -1 gesetzt wird, danach wird i erhöht und j beim nächsten Schleifendurchlauf auf 0 gesetzt. Berechnung der Sprungtabelle. Das Muster wird auf Übereinstimmung mit sich selbst geprüft: skip[0] = -1; for (i = 0, j = -1; i < m; i++, j++, skip[i] = j) while (( j >= 0) && (muster[i] != muster[j])) j = skip[j]; Unmittelbar nachdem i und j erhöht wurden, steht fest: Die ersten j Zeichen des Musters stimmen mit den Zeichen an den Positionen muster[i - j + 1] bis muster[i - 1] überein, d.h. die letzten j Zeichen mit den ersten i Zeichen. Dies ist das größte j mit dieser Eigenschaft, da andernfalls eine mögliche Übereinstimmung des Musters mit sich selbst verpaßt worden wäre. Folglich ist j genau der Wert, der skip[j] zugewiesen werden muß. 165 Algorithmen und Datenstrukturen 3.1.2.3 Das Verfahren von Boyer-Moore Beschreibung. Die Zeichen im Muster werden nicht von links nach rechts, sondern von rechts nach links mit den Zeichen im Text verglichen. Man legt das Muster zwar der Reihe nach an von links nach rechts wachsende Textpositionen, beginnt aber einen Vergleich zwischen Zeichen im Text und Zeichen im Muster immer beim letzten Zeichen im Muster. Tritt dabei kein Fehler (d.h. keine Übereinstimmung) auf, hat man ein Vorkommen des Musters im Text gefunden. Tritt ein Fehler auf, so wird eine Verschiebung des Musters berechnet, d.h. die Anzahl von Positionen, um die man das Muster nach rechts verschieben kann, bevor ein erneuter Vergleich zwischen Muster und Text (Beginn wieder mit dem letzten Zeichen im Muster) durchgeführt wird. Soll bspw. ein 4 Zeichen umfassendes Wort in einem Textfeld (text[]) gesucht werden, dann liegt zu Beginn folgende Situation vor: Index text[] wort [0] A A [1] A B [2] B C [3] B D [4] A [5] B [6] C [7] D [8] E [9] F .... ... aktuelle Position Nach dem Boyer-Moore-Verfahren findet der erste Vergleich an der „aktuellen Position“ statt: text[3] und muster[3] werden miteinander verglichen. Falls die zugehörigen Zeichen gleich sind, setzen sich die Vergleiche (nach links) so lange fort, bis das Wort gefunden ist oder unterschiedliche Buchstaben zwischen Text und Wort (muster) erkannt wurden. Hier liegt gleich zu Beginn ein Unterschied vor. In dieser Situation kann man zwei Fälle unterscheiden: 1. Der Buchstabe an der aktuellen Position im „text“ (hier liegt ein Leerzeichen vor) kommt im Musterwort nicht vor. Dann kann ein Wort im Bereich von 0 bis „aktuelle Position“ an keiner Stelle im Text beginnen, da sonst mindestens ein Buchstabe aus dem Muster gleich dem Textzeichen in „aktuelle Position“ sein müßte. Der Eingabezeiger, der im Moment auf aktuelle Position steht, läßt sich dann ohne weiteren Vergleich um die Länge des Musterworts zur Ausrichtung des Suchworts für einen neuen Vergleich erhöhen. 2. Das Zeichen, das an der aktuellen Position von „text“ steht, kommt im Musterwort vor. Dann muß dieses Wort so ausgerichtet werden, daß das rechteste alle Vorkommen im „muster“ unter der Stelle „aktuelle Position“ im „text“ steht. Die folgende Darstellung zeigt eine solche Situation: Index text[] wort [0] A A [1] A B [2] B C A [3] B D B [4] A [5] B [6] C [7] D C A D B C D [8] E [9] F .... ... Der Wert, um den der Zeiger dann zu erhöhen ist, steht in einer Tabelle, die „Delta1 (bzw. skip1)“ genannt wird. Delta1 ist eine Funktion des jewiligen Zeichens in „text“ an der Stelle der aktuellen Position. Falls das an der aktuelle Position vorkommende Textzeichen im Suchwort nicht vorkommt, ist Delta1 gleich der Länge des Suchworts, sonst ist Delta1 die Differenz zwischen Musterwortlänge und der rechtesten Position von Zeichen an der (aktuellen (Text-) Position im „muster“. Vor der eigentlichen Suche muß man Delta1 für jedes Zeichen des 166 Algorithmen und Datenstrukturen zugrundeliegenden Alphabets (in der Regel ASCII-Code) Berechnung von Delta1 kann folgendermaßen realisiert sein: berechnen. Die // Berechnung von Delta1 for (zch = 0; zch < 256; zch++) { p = strrchr(muster, zch); h = int(p ? p - muster : -1); // Falls h != -1, muster[h] == zch skip1[zch] = m - 1 - h; } Natürlich kann es auch eine Übereinstimmung zwischen dem Textzeichen an „aktueller Position“ und dem Zeichen im Muster (muster[m-1]) geben. Dann werden die anderen Zeichen von „Muster“ solange sukzessive mit den entsprechenden Zeichen im „text“ verglichen bis entweder „muster“ im „text“ vorkommt oder nach „m“ Vergleichen ein Buchstabe im Text auftaucht, der nicht zu jenen in „muster“ paßt. In der folgenden Darstellung ist für das 8 Zeichen umfassende Musterwort eine Übereinstimmungh zwischen dem Musterwort an der Stelle m - 1 (muster[m-1]) und einem Textzeichen gegeben: Index text[] wort [0] ... D [1] ... A [2] ... B [3] ... C [4] D E [5] A A [6] B B [7] C C [8] E [9] A [10] B [11] C Nach insgesamt 2 Übereinstimmungen paßt das Textzeichen „D“ nicht zum Muster „E“. Das Suchwort soll nun möglichst weit nach rechts verschoben werden, so daß das Teilwort „ABC“ mit dem Textzeichen übereinstimmt und zum anderen ein Zeichen ungleich „E“ dem Teilwort im Muster vorausgeht. Die Position im Wort, an der das am weitesten rechts stehende Teilwort („ABC“) beginnt, das die Bedingungen erfüllt, ist die „right most plausible occurrence“. Wie weit man nach rechts verschieben darf, hängt von der Position im Muster ab, an der das dort vorliegende Zeichen ungleich dem zur Untersuchnug anstehenden Textzeichen ist. Zunächst kann das Muster auf das am weitesten rechts gelegene Teilwort ausgerichtet werden (im vorliegenden Beispiel umfaßt das Teilwort 3 mit „text“ übereinstimmende Zeichen), die Ausrichtung erfolgt durch Verschieben um (4 + 1 - 1) Positionen. Anschließend muß man noch auf das rechte Wortende erhöhen (im Bsp. um 7 - 3). Die Summe dieser beiden Werte für das Verschieben soll „Delta2 (bzw. skip2)“ genannt werden und muß für jede Stelle im Muster berechnet werden. Die Berechnung von Delta2 kann folgendermaßen realisiert werden: // Berechnung von Delta2 for (i = m - 2; i >= 0; i--) { p = muster + i + 1; // Guter Nachspann, p folgt Position i. suflen = m - 1 - i; // Teilwortlaenge for (h = i; h >= 0; h--) if (strncmp(muster + h, p, suflen) == 0) break; skip2[i] = i + 1 - h; // h = -1 falls nicht gefunden. } Die Klasse „suche“ für das Verfahren von Boyer-Moore #include <string.h> inline int max(int x, int y){return x > y ? x : y;} 167 Algorithmen und Datenstrukturen class suche { private: const char *muster; int skip1[256], // bezogen auf nicht passende Zeichen: Delta1 *skip2, // bezogen auf guten Nachspann: Delta2 m; // Musterlaenge public: suche(const char *pat); ~suche(){delete[] skip2;} char *finden(char *text); }; suche::suche(const char *must) { muster = must; m = strlen(muster); skip2 = new int[m-1]; const char *p; int zch, i, h, suflen; // Berechnung von Delta1 for (zch = 0; zch < 256; zch++) { p = strrchr(muster, zch); h = int(p ? p - muster : -1); // Falls h != -1, muster[h] == zch skip1[zch] = m - 1 - h; } // Berechnung von Delta2 for (i = m - 2; i >= 0; i--) { p = muster + i + 1; // Guter Nachspann, p folgt Position i. suflen = m - 1 - i; // Teilwortlaenge for (h = i; h >= 0; h--) if (strncmp(muster + h, p, suflen) == 0) break; skip2[i] = i + 1 - h; // h = -1 falls nicht gefunden. } // Abhaengig von muster[i] != text[j] = zch, wird das Muster // entweder um skip1[zch] = m-1-i oder skip2[i] Positionen // nach rechts verschoben, je nachdem welcher Sprungtabellen// wert groesser ist. } char *suche::finden(char* text) { int letztes = m - 1, k = letztes, j, i; // Das letzte Zeichen im Muster wird // mit text[k] verglichen: char zch; int n = strlen(text); while (k < n) { zch = text[k]; if (muster[letztes] != zch) k += skip1[zch]; else { i = letztes; j = k; do { if (--i < 0) return text + k - letztes; // Passendes wurde gefunden! } while (muster[i] == text[--j]); k += max(skip1[text[j]] - (letztes - i), skip2[i]); } } return 0; } 168 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ösung88. Bsp. für gierige Algorithmen: Algorithmus von Dijkstra89, Minimaler spannender Baum (Algorithmus von Prim)90. Eigenschaften von Greedy-Algorithmen: 1. Gegebene Menge Werte von Eingabewerten 2. Menge von Lösungen, die aus Eingabewerten aufgebaut sind 88 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 89 In jedem Schritt wird gierig aus den noch nicht besuchten Knoten jener augewählt, der die geringste Entfernung zum Startknoten besitzt. 90 In jedem Schritt wird gierig die kürzeste Kante vom Baum zu einem Knoten gewählt, der sich noch nicht im Baum befindet. 169 Algorithmen und Datenstrukturen 3. Lösungen lassen sich schrittweise aus partiellen Lösungen, beginnend bei der leeren Lösung, durch Hinzunahme von Eingabewerten aufbauen 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 170 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 B91 {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: - - 91 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 171 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) = n92 - 1 Ausgabe: T Abarbeitungsprotokoll des Kruskal-Algorithmus93: Greedy- Mengen94 Schritt 0 (Init.) {1},{2},{3},{4},{5},{6},{7 } 1 {1,2},{3},{4},{5},{6},{7} 2 {1,2,3},{4},{5},{6},{7} 3 {1,2,3},{4},{5},{6},{7} 4 {1,2,3},{4,5},{6,7} 5 {1,2,3,4,5},{6,7} 6 {1,2,3,4,5},{6,7} (u,v) ucomp vcomp T 0 (1.2) (2,3) (4,5) (6,7) (1,4) (2,5) {1} {1,2} {4} {6} {1,2,3} {1,2,3,4,5} {2} {3} {5} {7} {4,5} {1,2,3,4,5} {(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)} {6,7} {(1,2),(2,3),(4,5),(6,7),(1,4),(4,7) } 95 7 {1,2,3,4,5,6,7} (4,7) {1,2,3,4,5} Implementierung: vgl. Kruskal.java96 92 Anzahl der Knoten im Graphen vgl. 5.6.2 94 Knotenmenge der Teilbäume 95 nicht disjunkt 96 pr56200 93 172 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: 173 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 t T1 s a 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. 174 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 „T3“ 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 175 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: 176 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(C97logC). 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() 97 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 C ist die Anzahl der Zeichen. 177 Algorithmen und Datenstrukturen getTotalOut() setInput() setLevel() setStrategy() setDictionary gibt die Anzahl komprimierter Daten in Bytes an 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.98: Kompremierung einer Datei mit Anzeige des Komprimierungsergebnisses (Byteanzahl, Checksumme) 98 vgl.pr33120 178 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 Algorithmus99). public static int fibrek(int n) { if (n <= 1) return 1; else return fibrek(n - 1) + fibrek(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: 99 pr32301 179 Algorithmen und Datenstrukturen fibrek(6) fibrek(5) fibrek(4) fibrek(3) fibrek(4) 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 Algorithmus100 mit dem Aufwand O(N). public static int fibit(int n) { if (n <= 1) return 1; int letzterWert = 1; int vorletzterWert = 1; int resultat = 1; for (int i = 2;i <= n; i++) { resultat = letzterWert + vorletzterWert; vorletzterWert = letzterWert; letzterWert = resultat; } return resultat; } 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. 100 pr32301 180 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-Funktion101 ist nichtlinear rekursiv. 101 vgl. 1.2.1.5.1 bzw. 3.2.3 181 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-Funktion102 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 ann−1 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)); } } 102 Benannt nach dem Mathematiker F.W. Ackermann, 1896 - 1962 182 Algorithmen und Datenstrukturen 3.3.4 Rekursive Kurven Das Wesen der Rekursion – insbesondere der Selbstähnlichkeit – kann mit Hilfe von Monsterkurven demonstriert werden. Sie sind eindimensionale geometrische Gebilde unendlicher Länge, die eine Fläche abdecken. Monsterkurven werden nach einem regelmäßigen Muster schrittweise verfeinert. Sie errinnern an Formen, die mit Hilfe von Fraktalen erstellt werden können. Die Rekursion ist geeignet, die unendlich lange Monsterkurve mit endlich langen Kurven anzunähern. Solche Annäherungen einer Monsterkurve kann man als rekursive Kurve bezeichnen. Anweisungen für eine Monsterkurve können folgendermaßen formuliert werden: 1) Man nehme einen Initiator. Das kann eine einzelne Strecke sein, kann aber auch aus mehreren Liniensegmenten bestehen. 2) Man nehme einen Generator, der den Wachstumsprozeß des Monsters festlegt. Der Generator besteht aus mehreren Teilstrecken sowie einem Start- und einem Endpunkt. Im ersten Konstruktionsschritt (1. Rekursionsstufe) werden die Initiator-Segmente durch den Generator ersetzt. Der Generator wird dabei so gedreht und skaliert, daß seine Begrenzungspunkte mit der Initiator-Strecke zusammenfallen. Gibt es mehrere Initiator-Strecken, dann entstehen auch mehrere Generatoren. In der zweiten Rekursionsstufe wird jedes einzelne Segment wiederum durch den Generator ersetzt. Diesen Prozeß wiederhölt man so oft, bis das Fraktal (die Monsterkurve) die gewünschte Einheit zeigt. Zum Zeichnen von rekursiven Kurven wird die Technik der Turtle-Grafik103 herangezogen. Die Turtle (Grafik-Cursor) bewegt sich folgendermaßen über die Ebene: a) geradeaus in einem Schritt. Nach dem Arbeitsgang werden die neu berechneten Kordinaten der Turtle bereitgestellt. b) Wende über eine Drehung um einen bestimmten Winkel. Der Drehwinkel wird als Parameter im Aufruf übergeben. Beispiele. 1. Die Schneefockenkurve (Kochsche Kurve) Ausgangspunkt ist eine Gerade (Initiator). 103 Am MIT wurde die Turtle-Grafik erfunden 183 Algorithmen und Datenstrukturen Abb.: Schneeflockenkurve auf der Stufe 0 Diese Strecke wird in drei gleiche Teile unterteilt, und das Mittelstück durch 2 Seiten eines gleichseitigen Dreiecks ersetzt. Abb.: Schneeflockenkurve auf der Stufe 1 Im nächsten Schritt wird jede der vier Strecken des Generators, allerdings auf ein Drittel seiner Größe reduziert, ersetzt. 184 Algorithmen und Datenstrukturen Abb.: Schneeflockenkurve auf der Stufe 2 Die Prozedur für den Initiator umfaßt hie nur das Zeichnen der geraden Strecke. Entscheidend ist der Generator. Hier wandert der Grafik-Cursor (die Turtle) zunächst geradeaus (ein Drittel der vorhergehenden Stufe), wendet sich dann um 60 Grad nach links, dann 120 Grad nach rechts, usw. (vgl. Abb.). Das wird auf den weiteren Stufen fortgesetzt und führt zu einem allgemeinen Bildungsgesetz einer rekursiven Prozedur mit zwei Parametern im rekursiven Aufruf: „stufe – 1“, „laenge/3“. public void schneeflocke(int stufe,int laenge) { double xAlt, yAlt; if (stufe == 0) { xAlt = turtle.holeTurtleX(); // System.out.println(xAlt); yAlt = turtle.holeTurtleY(); // System.out.println(yAlt); turtle.setzeTurtleR(laenge); turtle.schritt(); grafKontext.drawLine((int) xAlt, (int) yAlt, (int) turtle.holeTurtleX(), (int) turtle.holeTurtleY()); } else { schneeflocke(stufe-1,laenge/3); turtle.wende(-60); schneeflocke(stufe-1,laenge/3); turtle.wende(120); schneeflocke(stufe-1,laenge/3); turtle.wende(-60); schneeflocke(stufe-1,laenge/3); } } 2. Die Drachenkurve Faltet man einen Papierstreifen doppelt in der Mitte und öffnet ihn dann so, daß sich rechte Winkel bilden, dann erhält man – von der Seite gesehen – die zweite Stufe der Drachenkurve. 185 Algorithmen und Datenstrukturen Abb.: Stufe 2 der Drachenkurve Der Initiator ist wieder die Einheitsstrecke, der Generator besteht aus zwei rechten Winkeln. Abb.: Stufe 3 der Drachenkurve Es ist zwischen Links- und Rechtsorientierung zu unterscheiden (Parameter Orientierung). Im weiteren Verlauf (Stufe 15) führt das zu folgendem Bild: 186 Algorithmen und Datenstrukturen Abb.: Stufe 15 der Drachenkurve Die folgende rekursive Prozedur104 ermöglicht das Zeichnen der vorliegenden Drachenkurven: public void drache(int stufe,double laenge,boolean richtung) { double wurzelZwei = Math.sqrt(2); double xAlt, yAlt; if (stufe == 0) { xAlt = turtle.holeTurtleX(); // System.out.println(xAlt); yAlt = turtle.holeTurtleY(); // System.out.println(yAlt); turtle.setzeTurtleR(laenge); turtle.schritt(); grafKontext.drawLine((int) xAlt, (int) yAlt, (int) turtle.holeTurtleX(), (int) turtle.holeTurtleY()); } else { final int gradLinks = 45 * (richtung ? 1 : -1); final int gradRechts = 90 * (richtung ? -1 : 1); turtle.wende(gradLinks); drache(stufe-1,laenge/wurzelZwei,true); turtle.wende(gradRechts); drache(stufe-1,laenge/wurzelZwei,false); turtle.wende(gradLinks); } } 104 pr33201 187 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 } } 188 Algorithmen und Datenstrukturen return false; } // falls true Rueckgabewert steht die Loesung in loesung 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()105“ 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. 105 vgl. pr32410, Labyrinth 189 Algorithmen und Datenstrukturen Abb. Die Feldwerte des Lösungsweges geben die Besuchsreihenfolge wieder. 190 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(kn) 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. 191 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(kn) und ist deswegen primär nur für kleine Probleme geeignet. - erlaubt Wissen über ein Problem in Form einer Heuristik106 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. 106 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. 192 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äme - 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 k1, k2, ... , kn (n >= 2), bei der ki 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. 193 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). 194 Algorithmen und Datenstrukturen Ebene 1 linker Teilbaum von k Ebene 2 k Ebene 3 Weg, Pfad Ebene 4 Randknoten oder Blätter 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: 195 Algorithmen und Datenstrukturen Z= h⋅ th th −1 − 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 = 1 Z ( N ⋅ ( t − 1) + 1) ⋅ log t ( N ⋅ ( t − 1) + 1) − N ( N ⋅ ( t − 1) + 1) ⋅ log t ( N ⋅ ( t − 1) + 1) = − = N N ⋅ ( t − 1) N ⋅ ( t − 1) t −1 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 gi (i = 1, 2, 3, ... , N) bzw. die Aufsuchwahrscheinlichkeiten zu: pi = N gi , G = ∑ gi G i =1 N Man kann eine gewichtete Pfadsumme Zg = ∑ g i ⋅ hi i =1 Suchaufwand ( Z g ) mit = Zg G N = ∑ p i ⋅ h i berechnen. i =1 196 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: 197 Algorithmen und Datenstrukturen 1710 3810 6310 1910 3210 2910 4410 2610 5310 = = = = = = = = = 0 1 1 0 1 0 1 0 1 1 0 1 1 0 1 0 1 1 0 0 1 0 0 1 1 1 0 0 1 1 0 0 1 1 0 1 0 1 1 1 0 0 0 1 1 12 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. 198 Algorithmen und Datenstrukturen Werden die Operationen "Einfügen", "Löschen" und "Suchen" ohne besondere Einschränkungen oder Zusätze angewendet, so spricht man von freien Bäumen. 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.: 199 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! 200 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 201 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) = 2 N 1 N ⋅ ( N + Z ⋅ ( k − ) + Z ⋅ ( N − k )) bzw. Z = 1 + ⋅ ∑ (Z k −1 ⋅ ( k − 1) 1 ∑ 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 2⋅N −1 N N −1 ⋅ ZN = ⋅ Z N +1 + N +1 N N ⋅ ( N + 1) YN = N ⋅ ZN N +1 folgt die Rekursionsformel: N N N 2⋅N −1 2⋅i −1 1 bzw. nach Auflösung107 YN = ∑ = 2 ⋅ ∑ − 3⋅ N ⋅ ( N + 1) 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 sich schließlich: Z mit = 14 . ⋅ ld ( N + 1) − 2 Darstellung Jeder geordnete binäre Baum ist eindeutig durch folgende Angaben bestimmt: 1. Angabe der Wurzel 107 vgl. Wettstein, H.: Systemprogrammierung, 2. Auflage, S.291 202 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. 1. Die Klassenschablone „Baumknoten“ in C++108 // 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 108 vgl. baumkno.h 203 Algorithmen und Datenstrukturen template <class T> baumKnoten<T>::~baumKnoten(void) {} 2. Baumknotendarstellung in Java109 // Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist // Der Zugriff auf diese Klasse ist nur innerhalb eines Verzeichnisses // bzw. Pakets moeglich class BinaerBaumknoten<T extends Comparable> { // Instanzvariable protected BinaerBaumknoten<T> links; protected BinaerBaumknoten<T> rechts; public T daten; // linker Teilbaum // rechter Teilbaum // Dateninhalt der Knoten // Konstruktor public BinaerBaumknoten(T datenElement) { this(datenElement, null, null ); } public BinaerBaumknoten(T datenElement, BinaerBaumknoten<T> l, BinaerBaumknoten<T> r) { daten = datenElement; links = l; rechts = r; } public BinaerBaumknoten<T> getLinks() { return links; } public BinaerBaumknoten<T> getRechts() { return rechts; } } 4.2.2 Operationen 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). 109 pr42000 204 Algorithmen und Datenstrukturen Schlüssel LINKS 12 RECHTS 7 5 2 15 8 13 6 14 Abb.: a) 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 } 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: 205 Algorithmen und Datenstrukturen ‘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); } 206 Algorithmen und Datenstrukturen b) Erzeugen eines Binärbaumknotens bzw. eines binären Baums in Java class StringBinaerBaumknoten { private String sK; protected StringBinaerBaumknoten links, rechts; public StringBinaerBaumknoten (String s) { links = rechts = null; sK = s; } public void insert (String s) // Fuege s korrekt ein. { if (s.compareTo(sK) > 0) // dann rechts { if (rechts==null) rechts = new StringBinaerBaumknoten(s); else rechts.insert(s); } else // sonst links { if (links==null) links=new StringBinaerBaumknoten(s); else links.insert(s); } } public String getString () { return sK; } public StringBinaerBaumknoten getLinks () { return links; } public StringBinaerBaumknoten getRechts () { return rechts; } } public class TestStringBinaerBaumKnoten { public static void main (String args[]) { StringBinaerBaumknoten baum=null; for (int i = 0; i < 20; i++) // 20 Zusfallsstrings speichern { String s = "Zufallszahl " + (int)(Math.random() * 100); if (baum == null) baum = new StringBinaerBaumknoten(s); else baum.insert(s); } print(baum); // Sortiert wieder ausdrucken } public static void print (StringBinaerBaumknoten baum) // Rekursive Druckfunktion { if (baum == null) return; print(baum.getLinks()); System.out.println(baum.getString()); print(baum.getRechts()); } } 207 Algorithmen und Datenstrukturen 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 a) Implementierung in C++ Das „Einfügen“110 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 ubd 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 110 vgl. bsbaum.h 208 Algorithmen und Datenstrukturen if (b->rechts != NULL) neurzgr = kopiereBaum(b->rechts); 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; }; 209 Algorithmen und Datenstrukturen Die Schnittstellenfunktion void einfuegen(const T& merkmal); besitzt folgende Definition111: // 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++; } b) Eine generische Klasse für den binären Suchbaum in Java112 Der binäre Suchbaum setzt voraus, dass alle Datenelemente in eine Ordnungsbeziehung113 gebracht werden können. Eine generische Klasse für einen binären Suchbaum erfordert daher ein Interface, das Ordnungsbeziehungen zwischen Daten eines Datenbestands festlegt. Diese Eigenschaft besitzt das Interface Comparable: public interface Comparable { int compareTo(Comparable rs) } Das Interface114 zeigt, dass zwei Datenelemente über die Methode „compareTo“ verglichen werden können. Über die Defintion eines Interface wird auch der zugehörige Referenztyp erzeugt, der wie andere Datentypen eingesetzt werden kann. // Freier binaerer Intervallbaum // Generische Klasse fuer einen unausgeglichenen binaeren Suchbaum 111 vgl. bsbaum.h vgl. pr42110 113 vgl. Kapitel 1, 1.2.2.2 114 Java-Interfaces werden in der Regel als eine Form abstrakter Klassen beschrieben, durch die einzelne Klassen der Hierarchie zusätzliche Funktionalität erhalten können. Sollen die Klassen diese Funktionalität durch Vererbung erhalten, müssten sie in einer gemeinsamen Superklasse angesiedelt werden. Weil ein Interface keine Implementierung enthält, kann auch keine Instanz davon erzeugt werden. Eine Klasse, die nicht alle Methoden eines Interface implementiert, wird zu einer abstrakten Klasse. 112 210 Algorithmen und Datenstrukturen // // Konstruktor: Initialisierung der Wurzel mit dem Wert null // // **********oeffentlich zugaengliche Methoden************************************* // void insert( x ) --> Fuege x ein // void remove( x ) --> Entferne x // Comparable find( x ) --> Gib das Element zurueck, das zu x passt // Comparable findMin( ) --> Rueckgabe des kleinsten Elements // Comparable findMax( ) --> Rueckgabe des groessten Elements // boolean isEmpty( ) --> Return true if empty; else false // void makeEmpty( ) --> Entferne alles // void printTree( ) --> Ausgabe der Binaerbaum-Elemente in sort. Folge // void ausgBinaerBaum() --> Ausgabe der Binaerbaum-Elemente um 90 Grad vers. /* * Implementiert einen unausgeglichenen binaeren Suchbaum. * Das Einordnen in den Suchbaum basiert auf der Methode compareTo */ public class BinaererSuchbaum<T extends Comparable> { // Private Datenelemente /* Die Wurzel des Baums */ private BinaerBaumknoten<T> root; // Oeffentlich zugaengliche Methoden /* * Konstruktor. */ public BinaererSuchbaum() { root = null; } /* * Einfuegen eines Elements in den binaeren Suchbaum * Duplikate werden ignoriert */ public void insert( T x ) { root = insert(x, root); } /* * Entfernen aus dem Baum. Falls x nicht da ist, geschieht nichts */ public void remove( T x ) { root = remove(x, root); } /* * finde das kleinste Element im Baum */ public T findMin() { return elementAt(findMin(root)); } /* * finde das groesste Element im Baum */ public T findMax() { return elementAt(findMax(root)); } /* * finde ein Datenelement im Baum */ public T find(T x) 211 Algorithmen und Datenstrukturen { return elementAt(find(x, root)); } /* * Mache den Baum leer */ public void makeEmpty() { root = null; } /* * Test, ob der Baum leer ist */ public boolean isEmpty() { return root == null; } /* * Ausgabe der Datenelemente in sortierter Reihenfolge */ public void printTree() { if( isEmpty( ) ) System.out.println( "Baum ist leer" ); else printTree( root ); } /* * Ausgabe der Elemente des binaeren Baums um 90 Grad versetzt */ public void ausgBinaerBaum() { if( isEmpty() ) System.out.println( "Leerer baum" ); else ausgBinaerBaum( root,0 ); } // Private Methoden /* * Methode fuer den Zugriff auf ein Datenelement */ private T elementAt( BinaerBaumknoten<T> b ) { return b == null ? null : b.daten; } /* * Interne Methode fuer das Einfuegen in einen Teilbaum */ private BinaerBaumknoten<T> insert(T x, BinaerBaumknoten<T> b) { /* 1*/ if( b == null ) /* 2*/ b = new BinaerBaumknoten<T>( x, null, null ); /* 3*/ else if( x.compareTo( b.daten ) < 0 ) /* 4*/ b.links = insert( x, b.links ); /* 5*/ else if( x.compareTo( b.daten ) > 0 ) /* 6*/ b.rechts = insert( x, b.rechts ); /* 7*/ else /* 8*/ ; // Duplikat; tue nichts /* 9*/ return b; } /* * Interne Methode fuer das Entfernen eines Knoten in einem Teilbaum */ private BinaerBaumknoten<T> remove(T x, BinaerBaumknoten<T> b) { if( b == null ) return b; // nichts gefunden; tue nichts 212 Algorithmen und Datenstrukturen if( x.compareTo(b.daten) < 0 ) b.links = remove(x, b.links ); else if( x.compareTo(b.daten) > 0 ) b.rechts = remove( x, b.rechts ); else if( b.links != null && b.rechts != null ) // Zwei Kinder { b.daten = findMin(b.rechts).daten; b.rechts = remove(b.daten, b.rechts); } else b = ( b.links != null ) ? b.links : b.rechts; return b; } /* * Interne Methode zum Bestimmen des kleinsten Datenelements im Teilbaum */ private BinaerBaumknoten<T> findMin(BinaerBaumknoten<T> b) { if (b == null) return null; else if( b.links == null) return b; return findMin(b.links ); } /* * Interne Methode zum Bestimmen des groessten Datenelements im Teilbaum */ private BinaerBaumknoten<T> findMax( BinaerBaumknoten<T> b) { if( b != null ) while( b.rechts != null ) b = b.rechts; return b; } /* * Interne Methode zum Bestimmen eines Datenelements im Teilbaum. */ private BinaerBaumknoten<T> find(T x, BinaerBaumknoten<T> b) { if(b == null) return null; if( x.compareTo(b.daten ) < 0) return find(x, b.links); else if( x.compareTo(b.daten) > 0) return find(x, b.rechts); else return b; // Gefunden! } /* * Internae Methode zur Ausgabe eines Teilbaums in sortierter Reihenfolge */ private void printTree(BinaerBaumknoten<T> b) { if(b != null) { printTree(b.links); System.out.print(b.daten); System.out.print(' '); printTree( b.rechts ); } } /* * Ausgabe des Binaerbaums um 90 Grad versetzt */ private void ausgBinaerBaum(BinaerBaumknoten<T> b, int stufe) { 213 Algorithmen und Datenstrukturen if (b != null) { ausgBinaerBaum(b.links, stufe + 1); for (int i = 0; i < stufe; i++) { System.out.print(' '); } System.out.println(b.daten); ausgBinaerBaum(b.rechts, stufe + 1); } } 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 B) Der zu löschende Knoten hat genau einen Sohn 214 Algorithmen und Datenstrukturen 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 Die Wurzel wird gelöscht. Welche Gestalt nimmt der Baum dann an: 215 Algorithmen und Datenstrukturen k1 vv k3 v 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 216 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.: 217 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 12 LINKS RECHTS 7 5 8 Abb.: Der Wurzelknoten wird gelöscht. Ergebnis: 218 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: 219 Algorithmen und Datenstrukturen Schlüssel 13 LINKS 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; 220 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--; } b) Implementierung der Verfahrenweise Schlüsseltransfer Baumknoten in einem binären Suchbaum in Java115 /* zum Löschen von * Interne Methode fuer das Entfernen eines Knoten in einem Teilbaum */ private BinaerBaumknoten<T> remove(T x, BinaerBaumknoten<T> b) { if( b == null ) return b; // nichts gefunden; tue nichts if( x.compareTo(b.daten) < 0 ) b.links = remove(x, b.links ); else if( x.compareTo(b.daten) > 0 ) b.rechts = remove( x, b.rechts ); else if( b.links != null && b.rechts != null ) // Zwei Kinder { b.daten = findMin(b.rechts).daten; b.rechts = remove(b.daten, b.rechts); } else b = ( b.links != null ) ? b.links : b.rechts; return b; } 115 pr42000 221 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) Durchlauden (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. 222 Algorithmen und Datenstrukturen a) 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); } } b) Rekursive Ausgabefunktion in Java116 public static void print (StringBinaerBaumknoten baum) // Rekursive Druckfunktion { if (baum == null) return; print(baum.getLinks()); System.out.println(baum.getString()); print(baum.getRechts()); } 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 116 vgl. pr42100 223 Algorithmen und Datenstrukturen "Postorder": I E J F C G K L H D B A 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 * 224 Algorithmen und Datenstrukturen Anwendungen der Durchlaufprinzipien Mit Hilfe der angegebenen Ordnungen bzw. Durchlaufprinzipien lassen sich weitere Operationen auf geordneten Wurzelbäumen bestimmen: a) C++-Anwendungen 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); 225 Algorithmen und Datenstrukturen // Rueckgabe des Zeigers auf den zuletzt erzeugten Baumknoten return neuerKnoten; } 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); } } b) Java-Anwendungen Grafische Darstellung eines binaeren Suchbaums117 private void zeichneBaum(BinaerBaumknoten b, int x, int y, int m, int s) { Graphics g = meinCanvas.getGraphics(); if (b != null) { if (b.links != null) { g.drawLine(x,y,x - m / 2,y + s); zeichneBaum(b.links,x - m / 2,y + s,m / 2,s); } if (b.rechts != null) { g.drawLine(x,y,x + m / 2,y+s); zeichneBaum(b.rechts,x + m / 2,y + s,m / 2,s); } } } } 117 vgl. pr42110 226 Algorithmen und Datenstrukturen 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.: 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 (AVL118-)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. 118 nach den Anfangsbuchstaben der Namen seiner Entdecker: Adelson, Velskii u. Landes 227 Algorithmen und Datenstrukturen Es gibt einen einfachen Algorithmus zum Erstellen eines pefekt ausgeglichenen Binärbaums119, falls (1) die einzulesenden Schlüsselwerte sortiert in aufsteigender Reihenfolge angegeben werden (2) bekannt ist, wieviel Objekte (Schlüssel) werden müssen. import java.io.*; class PBBknoten { // Instanzvariable protected PBBknoten links; // linker Teilbaum protected PBBknoten rechts; // rechter Teilbaum public int daten; // Dateninhalt der Knoten // Konstruktoren public PBBknoten() { this(0,null,null); } public PBBknoten(int datenElement) { this(datenElement, null, null ); } public PBBknoten(int datenElement, PBBknoten l, PBBknoten r) { daten = datenElement; links = l; rechts = r; } public PBBknoten getLinks() { return links; } public PBBknoten getRechts() { return rechts; } } public class PBB { static BufferedReader ein = new BufferedReader(new InputStreamReader( System.in)); // Instanzvariable PBBknoten wurzel; // Konstruktor public PBB(int n) throws IOException { if (n == 0) wurzel = null; else { int nLinks = (n - 1) / 2; int nRechts = n - nLinks - 1; wurzel = new PBBknoten(); wurzel.links = new PBB(nLinks).wurzel; wurzel.daten = Integer.parseInt(ein.readLine()); wurzel.rechts = new PBB(nRechts).wurzel; } } 119 pr43205 228 Algorithmen und Datenstrukturen public void ausgPBB() { ausg(wurzel,0); } private void ausg(PBBknoten b, int nSpace) { if (b != null) { ausg(b.rechts,nSpace += 6); for (int i = 0; i < nSpace; i++) System.out.print(" "); System.out.println(b.daten); ausg(b.links, nSpace); } } // Test public static void main(String args[]) throws IOException { int n; System.out.print("Gib eine ganze Zahl n an, "); System.out.print("gefolgt von n ganzen Zahlen in "); System.out.println("aufsteigender Folge"); n = Integer.parseInt(ein.readLine()); PBB b = new PBB(n); System.out.print( "Hier ist das Resultat: ein perfekt balancierter Baum, "); System.out.println("die Darstellung erfogt um 90 Grad versetzt"); b.ausgPBB(); } } 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 jedem 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. 229 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.120 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.: 120 vgl. pr43205, ZPBBApplet.java und ZPBBApplet.html 230 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. 231 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: 232 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 233 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 a b 1b 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. 234 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.: a) Implementierung in C++ Mit der unter 1. festgestellten Verfahrensweise soll eine Klasse AVLBaum bestimmt werden, die Knoten der zuvor angegebenen Struktur so in einen Binärbaum einfügt, daß die AVL-Eigenschaft gewährleistet bleibt. // avl: Demonstrationsprogramm fuer AVL-Baeume // Einfuegen und Loeschen von Knoten #include <iostream.h> #include <iomanip.h> #include <ctype.h> struct knoten 235 a Algorithmen und Datenstrukturen { int num, bal; struct knoten *zLinks, *zRechts; }; class AVLbaum { private: knoten *wurzel; void LinksRotation(knoten* &z); void RechtsRotation(knoten* &z); int einf(knoten* &z, int x); void aus(const knoten *z, int nLeerZeichen) const; public: AVLbaum():wurzel(NULL){} void einfuegen(int x){einf(wurzel, x);} void ausgabe()const{aus(wurzel, 0);} }; Methoden. void AVLbaum::LinksRotation(knoten* &z) { knoten *hz = z; z = z->zRechts; hz->zRechts = z->zLinks; z->zLinks = hz; hz->bal--; if (z->bal > 0) hz->bal -= z->bal; z->bal--; if (hz->bal < 0) z->bal += hz->bal; } void AVLbaum::RechtsRotation(knoten* &z) { knoten *hz = z; z = z->zLinks; hz->zLinks = z->zRechts; z->zRechts = hz; hz->bal++; if (z->bal < 0) hz->bal -= z->bal; z->bal++; if (hz->bal > 0) z->bal += hz->bal; } int AVLbaum::einf(knoten* &z, int x) { // Rueckgabewert: Anstieg in der Hoehe // (0 or 1) nach Einfuegen von x in den // Teilbaum mit Wurzel z int deltaH = 0; if (z == NULL) { z = new knoten; z->num = x; z->bal = 0; z->zLinks = z->zRechts = NULL; deltaH = 1; // Die Hoehe des Baums waechst um 1 } else if (x > z->num) { if (einf(z->zRechts, x)) { z->bal++; // Die Hoehe des rechten Teilbaums waechst if (z->bal == 1) deltaH = 1; else if (z->bal == 2) { if (z->zRechts->bal == -1) RechtsRotation(z->zRechts); LinksRotation(z); } } 236 Algorithmen und Datenstrukturen } } else if (x < z->num) { if (einf(z->zLinks, x)) { z->bal--; // Hoehe vom linken Teilbaum waechst if (z->bal == -1) deltaH = 1; else if (z->bal == -2) { if (z->zLinks->bal == 1) LinksRotation(z->zLinks); RechtsRotation(z); } } } return deltaH; b) Die generische Klasse „AvlBaum“ in Java121 Grundlagen: Die AVL-Eigenschaft ist verletzt, wenn diese 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. 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 X Abb.: 121 pr43210 237 Y Z Algorithmen und Datenstrukturen 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 C A B D C Abb.: Auch Fall 3 muß durch Doppelrotation behandelt werden k1 k2 k2 A k1 k3 k3 D B A B C D C Abb.: Implementierung: Zum Einfügen eines Knoten mit dem Datenwert „x“ in einen AVLBaum, 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. 238 Algorithmen und Datenstrukturen Avl-Baumknoten122 Er enthält für jeden Knoten eine Angabe zur Höhe(ndifferenz) seiner Teilbäume. // Baumknoten fuer AVL-Baeume class GenAvlKnoten<T extends Comparable> { // Instanzvariable protected GenAvlKnoten<T> links; // Linkes Kind protected GenAvlKnoten<T> rechts; // Rechtes Kind protected int hoehe; // Hoehe public T daten; // Das Datenelement // Konstruktoren public GenAvlKnoten(T datenElement) { this(datenElement, null, null ) } public GenAvlKnoten( T datenElement, GenAvlKnoten<T> lb, GenAvlKnoten<T> rb ) { daten = datenElement; links = lb; rechts = rb; hoehe = 0; } } Der Avl-Baum123 Bei jedem Schritt ist festzustellen, ob die Höhe des Teilbaums, in dem ein Element eingefügt wurde, zugenommen hat. private static <T extends Comparable> int hoehe(GenAvlKnoten<T> b) { return b == null ? -1 : b.hoehe; } 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. * b ist der jeweilige Wurzelknoten. * Rueckgabe der neuen Wurzel des jeweiligen Teilbaums. */ private GenAvlKnoten<T> insert(T x, GenAvlKnoten<T> b) { if( b == null ) b = new GenAvlKnoten<T>(x, null, null); else if (x.compareTo( b.daten) < 0 ) { b.links = insert(x, b.links ); if (hoehe( b.links ) - hoehe( b.rechts ) == 2 ) if (x.compareTo( b.links.daten ) < 0 ) b = rotationMitLinksNachf(b); else b = doppelrotationMitLinksNachf(b); } else if (x.compareTo( b.daten ) > 0 ) { b.rechts = insert(x, b.rechts); if( hoehe(b.rechts) - hoehe(b.links) == 2) if( x.compareTo(b.rechts.daten) > 0 ) b = rotationMitRechtsNachf(b); else b = doppelrotationMitRechtsNachf( b ); } else 122 123 vgl. pr43210 vgl. pr43210 239 Algorithmen und Datenstrukturen ; // Duplikat; tue nichts b.hoehe = max( hoehe( b.links ), hoehe( b.rechts ) ) + 1; return b; } 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. */ private static <T extends Comparable> GenAvlKnoten<T> rotationMitLinksNachf(GenAvlKnoten<T> k2) { GenAvlKnoten<T> k1 = k2.links; k2.links = k1.rechts; k1.rechts = k2; k2.hoehe = max( hoehe( k2.links ), hoehe( k2.rechts ) ) + 1; k1.hoehe = max( hoehe( k1.links ), k2.hoehe ) + 1; return 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. */ private static <T extends Comparable> GenAvlKnoten<T> rotationMitRechtsNachf(GenAvlKnoten<T> k1) { GenAvlKnoten<T> k2 = k1.rechts; k1.rechts = k2.links; k2.links = k1; k1.hoehe = max( hoehe( k1.links ), hoehe( k1.rechts ) ) + 1; k2.hoehe = max( hoehe( k2.rechts ), k1.hoehe ) + 1; return 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. */ private static <T extends Comparable> GenAvlKnoten<T> doppelrotationMitLinksNachf(GenAvlKnoten<T> k3) { k3.links = rotationMitRechtsNachf( k3.links ); return rotationMitLinksNachf( k3 ); } /* * Doppelrotation der Binaerbaumknoten: erster rechter Nachfolgeknoten * mit seinem linken Nachfolger;danach Knoten k1 mit neuem rechten * Nachfolgerknoten * Fuer AVL-Baeume ist dies eine doppelte Rotation (Fall 3) * Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel. */ private static <T extends Comparable> GenAvlKnoten<T> doppelrotationMitRechtsNachf(GenAvlKnoten<T> k1) { k1.rechts = rotationMitLinksNachf(k1.rechts); return rotationMitRechtsNachf(k1); } 240 Algorithmen und Datenstrukturen 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 H H+1 b c a b k c 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. 241 Algorithmen und Datenstrukturen 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 5 8 0 -1 4 6 0 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. Schlüssel 5 BALANCE 1 LINKS, RECHTS 7 4 0 -1 6 0 Abb.: 242 Algorithmen und Datenstrukturen 4.3.3 Splay-Bäume Zugrundeliegende Idee 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 B 2. e C e d F e d F d F c E c E b A a E a a D b A b c C D B B C C D A E a a F c c e d d A B F b A B b B E E C C D 243 D Algorithmen und Datenstrukturen 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 D x y A z B C D 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 A B C 244 C D Algorithmen und Datenstrukturen Implementierung124 BinaerBaumKnoten // Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist // Der Zugriff auf diese Klasse ist nur innerhalb eines Verzeichnisses // bzw. Pakets moeglich class BinaerBaumknoten { // Instanzvariable protected BinaerBaumknoten links; // linker Teilbaum protected BinaerBaumknoten rechts; // rechter Teilbaum public Comparable daten; // Dateninhalt der Knoten // Konstruktor public BinaerBaumknoten(Comparable datenElement) { this(datenElement, null, null ); } public BinaerBaumknoten(Comparable datenElement, BinaerBaumknoten l, BinaerBaumknoten r) { daten = datenElement; links = l; rechts = r; } public void insert (Comparable x) { if (x.compareTo(daten) > 0) // dann rechts { if (rechts == null) rechts = new BinaerBaumknoten(x); else rechts.insert(x); } else // sonst links { if (links == null) links = new BinaerBaumknoten(x); else links.insert(x); } } public BinaerBaumknoten getLinks() { return links; } public BinaerBaumknoten getRechts() { return rechts; } } SplayBaum // // // // // // // // // // 124 SplayBaum class ***************** PUBLIC OPERATIONen ******************** void insert( x ) --> Insert x void remove( x ) --> Remove x Comparable find( x ) --> Gib das Merkmal zurück, das x zugeordnet ist Comparable findMin( ) --> Rueckgabe des kleinsten elements Comparable findMax( ) --> Rueckgabe des groessten Elements boolean isEmpty( ) --> Rueckgabe true, falls leer; sonst false void makeEmpty( ) --> Entferne alle Elemente pr43215 245 Algorithmen und Datenstrukturen // void printTree( ) --> Gib den Baum sortiert aus /* * Implementiere einen top-down Splay Baum. * Vergleiche beziehen sich auf die Methode compareTo. */ public class SplayBaum { private BinaerBaumknoten root; private static BinaerBaumknoten nullNode; static // Static initializer for nullNode { nullNode = new BinaerBaumknoten( null ); nullNode.links = nullNode.rechts = nullNode; } private static BinaerBaumknoten newNode = null; // wird in diversen Einfuegevorgaengen benutzt private static BinaerBaumknoten header = new BinaerBaumknoten(null); /* * Konstruktor. */ public SplayBaum( ) { root = nullNode; } /* * Zugriff auf die Wurzel */ public BinaerBaumknoten holeWurzel() { return root; } /* * Insert. * Parameter x ist das einzufuegende Element. */ public void insert( Comparable x ) { if( newNode == null ) newNode = new BinaerBaumknoten( null ); newNode.daten = x; if( root == nullNode ) { newNode.links = newNode.rechts = nullNode; root = newNode; } else { root = splay( x, root ); if( x.compareTo( root.daten ) < 0 ) { newNode.links = root.links; newNode.rechts = root; root.links = nullNode; root = newNode; } else if( x.compareTo( root.daten ) > 0 ) { newNode.rechts = root.rechts; newNode.links = root; root.rechts = nullNode; root = newNode; } else return; } newNode = null; 246 Algorithmen und Datenstrukturen } /* * Remove. * Parameter x ist das zu entfernende Element. */ public void remove( Comparable x ) { BinaerBaumknoten neuerBaum; // Falls x gefunden wird, liegt x in der Wurzel root = splay( x, root ); if( root.daten.compareTo( x ) != 0 ) return; // Element nicht gefunden; tue nichts if( root.links == nullNode ) neuerBaum = root.rechts; else { // Finde das Maximum im linken Teilbaum // Splay es zur Wurzel; dann haenge das rechte Kind dran neuerBaum = root.links; neuerBaum = splay( x, neuerBaum ); neuerBaum.rechts = root.rechts; } root = neuerBaum; } /* * Bestimme das kleinste Daten-Element im Baum. * Rueckgabe: kleinstes Datenelement bzw. null, falls leer. */ public Comparable findMin( ) { if( isEmpty( ) ) return null; BinaerBaumknoten ptr = root; while( ptr.links != nullNode ) ptr = ptr.links; root = splay( ptr.daten, root ); return ptr.daten; } /* * Bestimme das groesste Datenelement im Baum. * Rueckgabe: das groesste Datenelement bzw. null, falls leer */ public Comparable findMax( ) { if (isEmpty( )) return null; BinaerBaumknoten ptr = root; while( ptr.rechts != nullNode ) ptr = ptr.rechts; root = splay( ptr.daten, root ); return ptr.daten; } /* * Bestimme ein Datenelement im Baum. * Parameter x entfält das zu suchende Element. * Rueckgabe: Das passende Datenelement oder null, falls leer */ public Comparable find( Comparable x ) { root = splay( x, root ); if (root.daten.compareTo( x ) != 0) return null; return root.daten; } /* * Mache den Baum logisch leer. */ public void makeEmpty( ) { root = nullNode; } 247 Algorithmen und Datenstrukturen /* * Ueberpruefe, ob der Baum logisch leer ist * Rueckgabe true, falls leer, anderenfalls false. */ public boolean isEmpty( ) { return root == nullNode; } /* * Gib den Inhalt des baums in sortierter Folge aus. */ public void printTree( ) { if (isEmpty( )) System.out.println( "Empty tree" ); else printTree( root ); } /* * Ausgabe des Binaerbaums um 90 Grad versetzt */ public void ausgBinaerbaum(BinaerBaumknoten b, int stufe) { if (b != b.links) { ausgBinaerbaum(b.links,stufe + 3); for (int i = 0; i < stufe; i++) { System.out.print(' '); } System.out.println(b.daten.toString()); ausgBinaerbaum(b.rechts,stufe + 3); } } /* * Interne Methode zur Ausfuehrung eines "top down" splay. * Der zuletzt im Zugriff befindliche Knoten * wird die neue Wurzel. * Parameter x Ist das Zielelement, die Umgebung fuer das Splaying. * Parameter b ist die Wurzel des Teilbaums, * um den das Splaying stattfindet. * Rueckgabe des Teilbaums. */ private BinaerBaumknoten splay( Comparable x, BinaerBaumknoten t ) { BinaerBaumknoten leftTreeMax, rightTreeMin; header.links = header.rechts = nullNode; leftTreeMax = rightTreeMin = header; nullNode.daten = x; // Guarantee a match for( ; ; ) if( x.compareTo( t.daten ) < 0 ) { if( x.compareTo( t.links.daten ) < 0 ) t = rotateWithLeftChild( t ); if( t.links == nullNode ) break; // Kette Rechts rightTreeMin.links = t; rightTreeMin = t; t = t.links; } else if( x.compareTo( t.daten ) > 0 ) { if( x.compareTo( t.rechts.daten ) > 0 ) t = rotateWithRightChild( t ); if( t.rechts == nullNode ) break; // Kette Links 248 Algorithmen und Datenstrukturen leftTreeMax.rechts = t; leftTreeMax = t; t = t.rechts; } else break; leftTreeMax.rechts = t.links; rightTreeMin.links = t.rechts; t.links = header.rechts; t.rechts = header.links; return t; } /* * Rotation BinaerBaumknoten mit linkem Nachfolger. */ static BinaerBaumknoten rotateWithLeftChild(BinaerBaumknoten k2) { BinaerBaumknoten k1 = k2.links; k2.links = k1.rechts; k1.rechts = k2; return k1; } /* * Rotation BinaerBaumknoten mit rechtem Nachfolger. */ static BinaerBaumknoten rotateWithRightChild(BinaerBaumknoten k1) { BinaerBaumknoten k2 = k1.rechts; k1.rechts = k2.links; k2.links = k1; return k2; } /* * Interne Methode zur Ausgabe eines Teilbaums in sortierter Folge. * Parameter b ist der jweilige Wurzelknoten. */ private void printTree( BinaerBaumknoten b ) { if( b != b.links ) { printTree(b.links); System.out.println(b.daten.toString( )); printTree(b.rechts); } } } SplaybaumTest import java.io.*; public class SplayBaumTest { public static void main( String [ ] args ) { SplayBaum b = new SplayBaum(); String eingabeZeile = null; System.out.println("Einfuegen"); BufferedReader eingabe = null; eingabe = new BufferedReader( new InputStreamReader(System.in)); try { int zahl; do { System.out.println("Zahl? "); 249 Algorithmen und Datenstrukturen eingabeZeile = eingabe.readLine(); try { zahl = Integer.parseInt(eingabeZeile); b.insert(new Integer(zahl)); b.ausgBinaerbaum(b.holeWurzel(),2); } catch (NumberFormatException ne) { break; } } while (eingabeZeile != ""); } catch (IOException ioe) { System.out.println("Eingefangen in main()"); } System.out.println("Loeschen"); try { int zahl; do { System.out.println("Zahl? "); eingabeZeile = eingabe.readLine(); try { zahl = Integer.parseInt(eingabeZeile); b.remove(new Integer(zahl)); b.ausgBinaerbaum(b.holeWurzel(),2); } catch (NumberFormatException ne) { break; } } while (eingabeZeile != ""); } catch (IOException ioe) { System.out.println("Eingefangen in main()"); } System.out.println("Zugriff auf das kleinste Element"); b.findMin(); b.ausgBinaerbaum(b.holeWurzel(),2); System.out.println("Zugriff auf das groesste Element"); b.findMax(); b.ausgBinaerbaum(b.holeWurzel(),2); } } 250 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. 251 Algorithmen und Datenstrukturen Eine Variante zu Rot-Schwarz-Bäumen 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: Ermittle, 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 252 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 253 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. 254 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 Rotation. /* * Interne Routine, die eine einfache oder doppelte Rotation veranlasst. * Da das Ergebnis an "parent" geheftet wird, gibt es vier Faelle. * Aufruf durch reOrientierung. * "item" ist das Datenelement reOrientierung. * "parent" ist "parent" von der wurzel des rotierenden Teilbaums. * Rueckgabe: Wurzel des rotierenden Teilbaums. */ private GenRotSchwarzKnoten<T> rotation(T item, GenRotSchwarzKnoten<T> parent) { if (item.compareTo(parent.daten) < 0) return parent.links = item.compareTo( parent.links.daten) < 0 ? rotationMitLinksNachf(parent.links) : // LL rotationMitRechtsNachf(parent.links) ; // LR else return parent.rechts = item.compareTo(parent.rechts.daten) < 0 ? rotationMitLinksNachf(parent.rechts) : // RL rotationMitRechtsNachf(parent.rechts); // RR } /* 255 Algorithmen und Datenstrukturen * Rotation Binaerbaumknoten mit linkem Nachfolger. */ static <S extends Comparable> GenRotSchwarzKnoten<S> rotationMitLinksNachf(GenRotSchwarzKnoten<S> k2) { GenRotSchwarzKnoten<S> k1 = k2.links; k2.links = k1.rechts; k1.rechts = k2; return k1; } /* * Rotation Binaerbaumknoten mit rechtem Nachfolger. */ static <S extends Comparable> GenRotSchwarzKnoten<S> rotationMitRechtsNachf(GenRotSchwarzKnoten<S> k1) { GenRotSchwarzKnoten<S> k2 = k1.rechts; k1.rechts = k2.links; k2.links = k1; return k2; } Implementierung in Java125 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. Der Inorder-Durchlauf nimmt aus diesem Grund folgende Gestalt an: /* * Interne Methode fuer die Ausgabe des Baum in sortierter Reihenfolge. * b ist der Wurzelknoten. */ private void printTree(GenRotSchwarzKnoten<T> b) { if (b != nullNode ) { printTree(b.links); System.out.println(b.daten ); printTree(b.rechts ); } } Die Klasse RotSchwarzknoten class GenRotSchwarzKnoten<T extends Comparable> { // Instanzvariable public T daten; // Dateninformation der Knoten protected GenRotSchwarzKnoten<T> links; // linkes Kind protected GenRotSchwarzKnoten<T> rechts; // rechtes Kind protected int farbe; // Farbe // Konstruktoren 125 vgl. pr43220 256 Algorithmen und Datenstrukturen public GenRotSchwarzKnoten(T datenElement) { this(datenElement, null, null ); } public GenRotSchwarzKnoten(T datenElement, GenRotSchwarzKnoten<T> l, GenRotSchwarzKnoten<T> r) { daten = datenElement; links = l; rechts = r; farbe = GenRotSchwarzBaum.BLACK; } } Das Gerüst der Klasse RotSchwarzBaum und Initialisierungsroutinen // // // // // // // // // // // // // // Die Klasse GenRotSchwarzBaum Konstruktion: mit einem "negative infinity sentinel" ******************PUBLIC OPERATIONEN********************* void insert(x) --> Insert x void remove(x) --> Entferne x (nicht implementiert) Comparable find(x) --> Ruecggabe des Datenelements, das x enthaelt Comparable findMin() --> Rueckgabe des kleinsten Datenelements Comparable findMax() --> Rueckgabe des groessten Datenelements boolean isEmpty() --> Rueckgabe true, falls leer; anderenfalls false void makeEmpty() --> Entferne alles void printTree() --> Ausgabe in aufsteigend sortierter Folge void ausgRotSchwarzBaum() --> Ausgabe des Baums um 90 Grad versetzt /* * Implementierung eines RotSchwarzBaum. * Vergleiche basieren auf der Methode compareTo. */ public class GenRotSchwarzBaum<T extends Comparable> { private GenRotSchwarzKnoten<T> header; private GenRotSchwarzKnoten<T> nullNode; // Fuer "insert routine" und zugehoerige unterstuetzende Routinen private GenRotSchwarzKnoten<T> current; private GenRotSchwarzKnoten<T> parent; private GenRotSchwarzKnoten<T> grand; private GenRotSchwarzKnoten<T> great; static // Static Initialisierer for nullNode { } static final int BLACK = 1; // Black must be 1 static final int RED = 0; /* * Baumkonstruktion. * negInf ist ein Wert, der kleiner oder gleich zu allen anderen Werten ist. */ public GenRotSchwarzBaum(T negInf) { nullNode = new GenRotSchwarzKnoten<T>(null); nullNode.links = nullNode.rechts = nullNode; header = new GenRotSchwarzKnoten<T>(negInf); header.links = header.rechts = nullNode; } // Die Klasse RotSchwarzBaum // // Konstruktion: mit einem "negative infinity sentinel" 257 Algorithmen und Datenstrukturen // // // // // // // // // // // ******************PUBLIC OPERATIONEN********************* void insert(x) --> Insert x void remove(x) --> Entferne x (nicht implementiert) Comparable find(x) --> Ruecggabe des Datenelements, das x enthaelt Comparable findMin() --> Rueckgabe des kleinsten Datenelements Comparable findMax() --> Rueckgabe des groessten Datenelements boolean isEmpty() --> Rueckgabe true, falls leer; anderenfalls false void makeEmpty() --> Entferne alles void printTree() --> Ausgabe in aufsteigend sortierter Folge void ausgRotSchwarzBaum() --> Ausgabe des Baums um 90 Grad versetzt /* * Implementierung eines RotSchwarzBaum. * Vergleiche basieren auf der Methode compareTo. */ public class RotSchwarzBaum { private RotSchwarzKnoten header; private static RotSchwarzKnoten nullNode; static // Static Initialisierer for nullNode { nullNode = new RotSchwarzKnoten(null); nullNode.links = nullNode.rechts = nullNode; } static final int BLACK = 1; // Black must be 1 static final int RED = 0; ….. /* * Baumkonstruktion. * negInf ist ein Wert, der kleiner oder gleich zu allen anderen Werten ist. */ public RotSchwarzBaum(Comparable negInf) { header = new RotSchwarzKnoten(negInf); header.links = header.rechts = nullNode; } ….. } Die Methode „insert“ // Fuer "insert routine" und zugehoerige unterstuetzende Routinen private GenRotSchwarzKnoten<T> current; private GenRotSchwarzKnoten<T> parent; private GenRotSchwarzKnoten<T> grand; private GenRotSchwarzKnoten<T> great; /* * Einfügen in den Baum. Duplikate werden ueberlesen. * "item" ist das einzufuegende Datenelement. */ public void insert(T item) { current = parent = grand = header; nullNode.daten = item; while( current.daten.compareTo( item ) != 0 ) { great = grand; grand = parent; parent = current; current = item.compareTo(current.daten ) < 0 ? current.links : current.rechts; // Pruefe, ob zwei rote Kinder; falls es so ist, fixiere es if( current.links.farbe == RED && current.rechts.farbe == RED ) reOrientierung( item ); } 258 Algorithmen und Datenstrukturen // Fehlanzeige fuer Einfuegen, falls schon da if( current != nullNode ) return; current = new GenRotSchwarzKnoten<T>( item, nullNode, nullNode ); // Anhaengen an die Eltern if( item.compareTo( parent.daten ) < 0 ) parent.links = current; else parent.rechts = current; reOrientierung( item ); } /* * 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. */ private void reOrientierung(T item) { // Tausch der Farben current.farbe = RED; current.links.farbe = BLACK; current.rechts.farbe = BLACK; if (parent.farbe == RED) // Rotation ist noetig { grand.farbe = RED; if ( (item.compareTo( grand.daten) < 0 ) != (item.compareTo( parent.daten) < 0 ) ) parent = rotation(item, grand); // Start Doppelrotation current = rotation(item, great); current.farbe = BLACK; } header.rechts.farbe = BLACK; // Mache die Wurzel schwarz } Bsp.: Java-Applet126 zur Darstellung eines Rot-Schwarz-Baums Abb.: 126 vgl. pr43222 259 Algorithmen und Datenstrukturen 260 Algorithmen und Datenstrukturen 4.3.5 AA-Bäume Die Implementierung eines Rot-Schwarz-Baums ist sehr trickreich (insbesondere das Löschen von Baumknoten). Bäume, die diese trickreiche Programmierung einschränken, sind der binäre B-Baum und der AA-Baum. Definitionen Ein BB-Baum ist ein Rot-Schwarz-Baum mit einer besonderen Bedingung: Ein Knoten hat, falls es sich ergibt, eine „roten“ Nachfolger“. Zur Vereinfachung der Implementierung kann man noch einige Regeln hinzufügen und darüber einen AA-Baum definieren: 1. Nur ein Nachfolgeknoten kann rot sein. Das bedeutet: Falls ein interner Knoten nur einen Nachfolger hat, dann muß er rot sein. Ein schwarzer Nachfolgeknoten verletzt die 4. Bedingung von Rot-Schwarz-Bäumen. Damit kann ein innerer Knoten immer durch den kleinsten Knoten seines rechten Teilbaums ersetzt werden. 2. Anstatt ein Bit zur Kennzeichnung der Färbung wird die Stufe zum jeweiligen Knoten bestimmt und gespeichert. Die Stufe eines Knoten ist - Eins, falls der Knoten ein Blatt ist. - Die Stufe eines Vorgängerknoten, falls der Knoten rot gefärbt ist. - Die Stufe ist um 1 niedriger als die seines Vorgängers, falls der Knoten schwarz ist. Eine horizontale Verkettung zeigt die Verbindung eines Knoten und eines Nachfolgeknotens mit gleicher Stufenzahl an. Der Aufbau verlangt, daß horizontale Verkettungen rechts liegen und daß es keine zwei aufeinander folgende horizontale Verkettungen gibt. Darstellung In einen zunächst leeren AA-Baum sollen Schlüssel in folgender Reihenfolge eingefügt werden: 10, 85 15, 70, 20, 60, 30, 50, 65, 80, 90, 40, 5, 55, 35. 10 10 85 10 85 15 10 15 85 „skew“ 15 „slip“ 10 85 261 Algorithmen und Datenstrukturen 70 15 10 70 20 85 15 10 70 20 85 60 15 10 70 20 60 85 30 15 10 70 20 60 85 30 15 10 30 20 15 20 262 70 60 85 30 70 60 85 Algorithmen und Datenstrukturen 30 15 10 70 20 60 85 50 30 15 10 70 20 50 60 85 65 30 15 10 60 20 70 50 65 85 80 30 15 10 60 20 70 50 65 80 90 30 70 15 10 60 20 263 50 85 65 80 90 85 Algorithmen und Datenstrukturen 40 30 70 15 60 10 20 40 85 50 65 80 90 5 30 70 15 5 60 10 20 40 85 50 65 80 90 55 30 70 15 5 10 50 20 40 60 55 85 65 80 90 35 30 15 5 10 70 20 50 35 Abb.: 264 40 60 55 85 65 - 80 90 Algorithmen und Datenstrukturen Implementierung127 Der Knoten des AA-Baums: // Baumknoten fuer AVL-Baeume class GenAAKnoten<T extends Comparable> { public T daten; // Datenelement im Knoten protected GenAAKnoten<T> links; // linkes Kind protected GenAAKnoten<T> rechts; // rechtes Kind protected int level; // Level // Konstruktoren public GenAAKnoten(T datenElement) { this(datenElement, null, null); } public GenAAKnoten(T datenElement, GenAAKnoten<T> l, GenAAKnoten<T> r) { daten = datenElement; links = l; rechts = r; level = 1; } } Operationen an AA-Bäumen: Ein „Sentinel“ repräsentiert „null“. Suchen. Es erfolgt nach die in binären Suchbäumen üblichen Weise. Einfügen von Knoten. Es erfolgt auf der untersten Stufe (bottom level) /* * Interne Methode zum Einfuegen in einen Teilbaum. * "x" enthaelt das einzufuegende Element. * "b" ist die wurzel des baums. * Rueckgabe: die neue Wurzel. */ private GenAAKnoten<T> insert(T x, GenAAKnoten<T> b) { if (b == nullNode) b = new GenAAKnoten<T>(x, nullNode, nullNode); else if (x.compareTo(b.daten) < 0 ) b.links = insert(x, b.links); else if (x.compareTo(b.daten) > 0 ) b.rechts = insert(x, b.rechts); else return b; b = skew(b); b = split(b); return b; } /* * Skew primitive for AA-trees. * "b" ist die Wurzel. * Rueckgabe: die neue Wurzel nach der Rotation. */ private GenAAKnoten<T> skew(GenAAKnoten<T> b) { if (b.links.level == b.level ) 127 pr43220 265 Algorithmen und Datenstrukturen b = rotationMitLinksNachf(b); return b; } /* * Split fuer AABaeume. * "b" ist die Wurzel. * Rueckgabe: die neue wurzel nach der Rotation. */ private GenAAKnoten<T> split(GenAAKnoten<T> b) { if (b.rechts.rechts.level == b.level ) { b = rotationMitRechtsNachf(b); b.level++; } return b; } Bsp.: 30 70 15 5 50 10 20 35 60 40 55 85 65 80 90 1. Einfügen des Schlüssels 2 Erzeugt wird ein linke horizontale Verknüpfung 2. Einfügen des Schlüssels 45 Erzeugt werden zwei rechte horizontale Verbindungen 30 70 15 2 5 50 10 20 35 40 60 45 55 85 65 80 90 In beiden Fällen können einfache Rotationen ausgleichen: - Linke horizontale Verknüpfungen werden durch ein einfache rechte Rotation behoben („skew“) X P X P C A B C A Abb.: Rechtsrotation 266 B Algorithmen und Datenstrukturen z.B.: 30 5 70 15 2 10 50 20 35 40 60 45 55 85 65 80 90 Abb.: - Aufeinanderfolgende rechte horizontale Verknüpfungen werden durch einfach linke Rotation behoben (split) X R G R C X A G B C A B Abb.: Linksrotation z.B. „Einfügen des Schlüssels 45“ 30 70 15 5 10 50 20 35 40 45 60 55 85 65 80 90 Abb.: „split“ am Knoten mit dem Schlüssel 35 30 15 5 10 70 40 20 35 50 45 60 55 85 65 80 90 „skew“ am Knoten mit dem Schlüssel 50 30 15 5 10 70 40 20 35 50 45 267 60 55 85 65 80 90 Algorithmen und Datenstrukturen „split“ am Knoten mit dem Schlüssel 40 30 70 15 5 50 10 20 85 40 35 60 45 55 80 90 65 Endgültige Baumgestalt nach „skew“ am Knoten 70 und „split“ am Knoten 30 50 30 70 15 5 10 40 20 35 60 45 55 85 65 80 90 Löschen von Knoten. /* * Interne Methode fuer das Entfernen aus einem Teilbaum. * Parameter x ist das zu entfernende Merkmal. * Parameter b ist die Wurzel des Baums. * Rueckgabe: die neue Wurzel. */ private GenAAKnoten<T> remove(T x, GenAAKnoten<T> b) { if( b != nullNode ) { // Schritt 1: Suche bis zum Grund des Baums, setze lastNode und // deletedNode lastNode = b; if( x.compareTo( b.daten ) < 0 ) b.links = remove( x, b.links ); else { deletedNode = b; b.rechts = remove( x, b.rechts ); } // Schritt 2: Falls am Grund des Baums und // x ist gegenwaertig, wird es entfernt if( b == lastNode ) { if( deletedNode == nullNode || x.compareTo( deletedNode.daten ) != 0 ) return b; // Merkmal nicht gefunden; es geschieht nichts deletedNode.daten = b.daten; b = b.rechts; } // Schritt 3: Anderenfalls, Grund wurde nicht erreicht; Rebalancieren else if( b.links.level < b.level - 1 || b.rechts.level < b.level - 1 ) { if( b.rechts.level > --b.level ) b.rechts.level = b.level; b = skew( b ); b.rechts = skew(b.rechts); b.rechts.rechts = skew(b.rechts.rechts); b = split( b ); b.rechts = split( b.rechts ); } } return b; } 268 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.: 269 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 Baums128. - 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. 128 In einigen Büchern wird als Ordnung des Baums der Verzweigungsgrad bezeichnet, hier also 2M+1 270 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 gespeichert129. - 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 2 - Falls B ein (a.b)-Baum mit n Blättern ist, dann gilt log b ( n) ≤ Höhe( B ) ≤ 1 + log a ( ) . Der rechte 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 71 9 4 7 9 17 15 17 52, 62 24 21 24 35 32 35 39 43,47 43 47 53,56 52 53 Abb.: (2,3) - Baum 2 1 1 3.4.5 2 3 4 5 Abb.: (2,4) - Baum 129 In dieser Sicht unterscheiden sich (a,b)-Bäume von den hier angesprochenen B-Bäumen. 271 56 67 62 67 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 Zl-1 verwiesen wird, sind kleiner als Sl. Alle Schlüssel in einem Unterbaum, auf den durch Zl 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 272 Algorithmen und Datenstrukturen 6 1 2 4 5 10 7 8 9 11 13 11 13 19 24 13 14 19 20 18 19 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.: a) C++-Darstellung 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) 273 Algorithmen und Datenstrukturen }; // Logische Ordnung: // z[0], s[0], z[1], s[1], ..., z[n-1], s[n-1], z[n] 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; }; b) Java-Darstellung Die Klasse CBNode zeigt die Implementierung eines Bayer-Baumknotens130. Der Konstruktor dieser Klasse leistet die Arbeit und bekommt dazu einen Übergabeparameter (M) geliefert, der die Ordnung des (M-ären) Bayer-Baums beschreibt. import java.util.*; /* * The CBNode class (Repraesentation eines Bayer-Baum-Knoten) */ public class CBNode { // Instanzvariable protected Vector key, nodeptr, initkey, initvec; int count; // Konstruktor /* * constructs a single Bayer Tree node with M */ CBNode(int M) { // System.out.println("CBNode(): constructor nodeptr = new Vector(); initvec = new Vector(); initvec = null; key = new Vector(); initkey = new Vector(); initkey = null; for(int i = 0; i <= M; i++) { nodeptr.addElement((Object)initvec); /* System.out.println(i + "ter Nodepointer erzeugt. Wert: " } for(int j = 0; j <= M - 1; j++) { key.addElement((Object)initkey); // System.out.println(j + "ter Key erzeugt. 130 Vgl. pr44200, CBNode.java 274 references to subnodes. invoked!"); + nodeptr.elementAt(i)); */ Wert: " + key.elementAt(j)); Algorithmen und Datenstrukturen } count = 0; // System.out.println("count Wert: " + count); } } 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 ZI 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 ZI 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). a) C++-Implementierung 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 << cout << endl; i = knotenSuche(x, w->s, n); if (i < n && x == w->s[i]) { cout << "Schluessel " << x << " << " vom zuletzt angegebenen return; } w = w->z[i]; } cout << "Schluessel " << x << " wurde } b) Java-Implementierung 275 " " << w->s[j]; wurde in Position " << i Knoten gefunden.\n"; nicht gefunden.\n"; Algorithmen und Datenstrukturen /* *Rueckgabe ist true, falls der Schlüssel gefunden wurde; * anderenfalls false. */ public boolean searchkey(int key) { boolean found = false; int i = 0, n; CBNode node = root; while(node != null) { i = 0; n = node.count; // search key in actual node while(i < n && key > ((Integer)node.key.elementAt(i)).intValue()) { i++; } // end while if(i < n && key == ((Integer)node.key.elementAt(i)).intValue()) { found = true; } if(node.nodeptr.elementAt(i) != null) { mpCVisualizeTree.MoveLevelDown(i); } node = (CBNode) node.nodeptr.elementAt(i); } // end while (node != null) return found; } // end searchkey Methode 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.: 276 Algorithmen und Datenstrukturen 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. +-----------------------------+ | ....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 (in C++) Zwei Funktionen (Methoden der Klasse Bbaum) „einfuegen“ und „einf“ teilen sich die Arbeit. Der Aufruf dieser Funktionen erfolgt bspw. über b.einfuegen(x)131; 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. 131 „b“ ist eine Instanz von Bbaum 277 Algorithmen und Datenstrukturen 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; 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; 278 Algorithmen und Datenstrukturen } 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; b) Methoden zum Einfügen in Java /* *Einfügen eines neuen Schlüssels. Rückgabe ist –1, falls * es misslingt, anderenfalls 0 * Parameter: value einzufuegender Wert */ public int Insert(Integer value) { if(root == null) { root = new CBNode(MAERER); root.key.setElementAt((Object)value, 0); root.count = 1; } // end if else { if(searchkey(((Integer)value).intValue()) == true) { //System.out.println("double key found and will be ignored!"); return -1; } // end if CBNode result; result = insrekurs(root, value); if(result != null) { CBNode node = new CBNode(MAERER); node.key.setElementAt(newValue, 0); node.nodeptr.setElementAt(root, 0); node.nodeptr.setElementAt(result, 1); node.count = 1; root = node; } // end if(result) } // end else mpCVisualizeTree.DeleteRootKnot(); mpCExtendedCanvas.repaint(); rootflag = 0; this.drawTree(root); mpCExtendedCanvas.repaint(); return 0; } // end Insert() Methode Die Methode „Insert“ ruft „insrekurs()“ auf. Diese rekursive Methode leistet die eigentliche Arbeit /* * der neue Wert wird rekursiv in den Baum eingebracht */ protected CBNode insrekurs(CBNode tempnode, Integer insValue) { 279 Algorithmen und Datenstrukturen CBNode result; result = null; newValue = insValue; if(tempnode.nodeptr.elementAt(0) != null) // kein Blatt -> Rekursion { int pos = 0; while(pos < tempnode.count && newValue.intValue() > ((Integer)tempnode.key.elementAt(pos)).intValue()) { pos++; } // end while result = insrekurs((CBNode)tempnode.nodeptr.elementAt(pos), newValue); if(result == null) { return null; // if result = null: nothing has to be inserted into // node-> finished! } // end if(resul == null) } // end if(tempnode.nodeptr.elementAt(0) != null) // insert a element CBNode node = null; int flag = 0; int s = tempnode.count; if(s >= MAERER - 1) // split the knot { tempnode.count = (MAERER - 1) / 2; node = new CBNode(MAERER); node.count = (MAERER - 1) / 2; for(int d = ((MAERER - 1) / 2); d > 0;) { if(flag != 0 || ((Integer)tempnode.key.elementAt(s - 1)).intValue() > newValue.intValue()) { node.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), d); node.key.setElementAt(tempnode.key.elementAt(--s), --d); } // end if(flag != 0 ... else { node.nodeptr.setElementAt(result, d); node.key.setElementAt(newValue, --d); flag = 1; } // end else } // end if(s >= MAERER - 1) if(flag != 0 || ((Integer)tempnode.key.elementAt(s - 1)).intValue() > newValue.intValue()) { node.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), 0); } // end if else { node.nodeptr.setElementAt(result, 0); } // end else } // end if(s >= MAERER - 1) else { tempnode.count++; } // end else // shift for(; s > 0 && ((Integer)tempnode.key.elementAt(s - 1)).intValue() > newValue.intValue(); s--) { tempnode.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), s + 1); tempnode.key.setElementAt(tempnode.key.elementAt(s - 1), s); } // end for tempnode.key.setElementAt(newValue, s); tempnode.nodeptr.setElementAt(result, s + 1); newValue = (Integer) tempnode.key.elementAt((MAERER - 1) / 2); 280 Algorithmen und Datenstrukturen return node; } // end insrekurs() methode 281 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 38 60 42 44 46 56 Abb.: 1) Löschen der Schlüssel 44, 80 282 58 65 70 80 90 95 96 Algorithmen und Datenstrukturen 50 30 10 20 35 40 38 42 56 46 58 65 Abb.: 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 90 60 42 46 50 60 56 58 Abb.: a) Implementierung in C++ Löschen eines Schlüssels im Blattknoten 283 95 70 95 96 Algorithmen und Datenstrukturen 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: 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: 284 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. 285 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. 286 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 in C++ 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); 287 Algorithmen und Datenstrukturen void leseStart(); long holeKnoten(); 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. 288 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 Wegweiser-Funktion 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 Bestimmen von k bzw. k* 289 Algorithmen und Datenstrukturen l = l M + l z + 2 ⋅ k ⋅ (l z + l S ) , k = l − lM − l z 2 ⋅ (l S +l z ) l − lM − 2 ⋅ l z 2 ⋅ (l S +l D ) l = l M + 2 ⋅ l z + 2 ⋅ k * ⋅(l S + l D ) ; k * = Höhe des B*-Baums: 1 + log 2 k +1 ( 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. 290 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) 291 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 ab132. Diese Baumstruktue 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. 132 wird aber wie "try" gesprochen 292 Algorithmen und Datenstrukturen 4.5.2 Tries Ein Trie ist eine auf Bäume 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 dargestellt133. 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 { for (int n = 0; n < s.length(); n++) { int index = s.charAt(n) – 'a'; if (t.next[index] == null) 133 von t) // Ueberpruefe fuer alle s[i] ... // .. ob der aktuelle Knoten einen // Zeiger auf Knoten besitzt, der entspricht in der Implementierung dem Setzen eines Flags isWord 293 Algorithmen und Datenstrukturen { t.next[index] = new Trie(); } t = t.next[index]; } t.isWord = true; } // // // // // // 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 extern134 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 134 d.h. alle Zeiger dieses Knotens sind auf Null gesetzt. 294 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 $135 A B C D E m=6 k=1 * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Abb. Trie für Schlüssel aus einem auf A … E beschränkten Alphabet. Grundoperationen. 135 Trennzeichen: kennzeichnet Schlüsselende 295 * * * * * * * * * * * * 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 Patricia Bäume (Compressed Tries) Merkmale. - Es handelt sich um einen binären Digitalbaum mit Binärdarstellung für Schlüsselwerte - 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 e a Database e u 5 m Datum s Datenbanken Datenbankmodell Datenbanksystem Bewertung - speichereffizient - 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' 296 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.: 4.5.4 Suffix Tries Ein Suffix Trie ist ein Compressed Trie, der aus allen Suffixes eines String s gebildet wird. 297 Algorithmen und Datenstrukturen 4.5.5 Dateikompression mit dem Huffman-Algorithmus Eine spezielle Form eines Trie ist der optimale Präfix-Baum, der mit Hilfe des Huffman-Algorithmus136 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. 136 vgl. 3.2.1.2 298 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. 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: 299 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.: Eine Kante (A,A) nennt man Schlinge 3. Darstellung durch Punkte (Kreise) und Pfeile (gerichteter Graph) A B D C Abb.: 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. 300 Algorithmen und Datenstrukturen 4. Darstellung durch paarweise geordnete Paare A A B B C C D D Abb 5. 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: 301 Algorithmen und Datenstrukturen Γ ( A) = ( B, D ) Γ ( B ) = ( B, C, D ) Γ( C ) = C Γ ( D ) = ( A, B, C, D ) 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 K137. 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. Ein Element von G selbst ist eine (gerichtete) Kante. Im vorstehenden Bsp138. 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). 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) im ungerichten Graphen. Ein gerichteter Graph Kanten zwischen 0 und 2 kann bis zu n ⋅ ( n − 1) Pfeile besitzen. Eingangsgrad: Zahl der ankommenden Kanten. 1 3 2 1 2 1 1 2 0 Abb.: Eingangsgrad 137 138 Anstatt K schreibt man häufig auch V (vom englischen Wort Vertex abgeleitet) vgl. 5.1.1 302 Algorithmen und Datenstrukturen Ausgangsgrad: Zahl der abgehenden Kanten 2 1 0 1 2 0 1 2 3 Abb.: Ausgangsgrad 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: 303 Algorithmen und Datenstrukturen X1 X2 X6 X3 X4 X5 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. 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.: 304 Algorithmen und Datenstrukturen 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. 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ängender 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. 305 Algorithmen und Datenstrukturen 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 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 Ki definiert der ADT alle benachbarten Knoten, die mit Ki durch eine Kante E(Ki,Kj) verbunden sind. ADT Graph Daten Sie umfassen eine Menge von Knoten {Ki} und Kanten {Ei}. Eine Kante ist ein Paar (Ki, Kj), das anzeigt: Es gibt eine Verbindung vom Knoten Ki zum Knoten Kj. Verbunden ist mit jeder Kante die Angabe eines Gewichts. Es bestimmt den Aufwand, um entlang der Kante vom Knoten Ki nach dem Knoten Kj zu kommen. Operationen Konstruktor Eingabe: keine Verarbeitung: Erzeugt den Graphen als Menge von Knoten und Kanten Einfuegen_Knoten Eingabe: Ein neuer Knoten Vorbedingung: keine Verarbeitung: Füge den Knoten in die Menge der Knoten ein Ausgabe: keine Nachbedingung: Die Knotenliste nimmt zu Einfügen_Kante Eingabe: Ein Knotenpaar Ki und Kj und ein Gewicht 306 Algorithmen und Datenstrukturen 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 Loesche_Knoten Eingabe: Eine Referenz für den Knoten Kl Vorbedingung: Der Eingabewert muß in der Knotenmenge vorliegen Verarbeitung: Lösche den Knoten aus der Knotenliste und lösche alle Kanten der Form (K,Kl) bzw. (Kl,K), die eine Verbindung mit Knoten Kl besitzen Loesche_Kante Eingabe: Ein Knotenpaar Ki und Kj Vorbedingung: Der Eingabewert muß in der Kantenliste vorliegen Verarbeitung: Falls (Ki,Kj) existiert, loesche diese Kante aus der Kantenliste Ausgabe: keine Nachbedingung: Die Kantenmenge wird modifiziert Hole_Nachbarn: Eingabe: Ein Knoten K Vorbedingung: keine Verarbeitung: Bestimme alle Knoten Kn, so daß (K,Kn) eine Kante ist Ausgabe: Liste mit solchen Kanten Nachbedingung: keine Hole_Gewichte Eingabe: Ein Knotenpaar Ki und Kj Vorbedingung: Der Eingabe wert muß zur Knotenmenge gehören Verarbeitung: Beschaffe das Gewicht der Kante (Ki, Kj), falls es existiert Ausgabe: Gib das Gewicht dieser Kante aus (bzw. Null, falls die Kante nicht existiert Nachbedingung: keine 2. Abbildung der Graphen Es gibt zahlreiche Möglichkeiten zur Abbildung von Knoten und Graphen in einem Rechnerprogramm. Eine einfache Abbildung speichert die Knoten in einer sequentiellen Liste. Die Kanten werden in einer Matrix beschrieben (Adjazenzmatrix), in der Zeile i bzw. Spalte j den Knoten Ki und Kj zugeordnet sind. Jeder Eintrag in der Matrix gibt das Gewicht der Kante Eij = (Ki,Kj) oder den Wert 0 an, falls die Kante nicht existiert. In ungewichteten, gerichteten Graphen hat der Eintrag der (booleschen) Wert 0 oder 1, je nachdem, ob die Kante zwischen den Knoten existiert oder nicht, z.B.: 2 A B 3 5 1 4 E C 7 D 307 Algorithmen und Datenstrukturen 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.: In der Darstellungsform „Adjazenzstruktur“ 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: D Liste der Nachbarn: A B 2 B C 5 C B 4 D C 7 E B 3 C 1 308 Algorithmen und Datenstrukturen B A C D E A B C B A C C A D E E C D Abb.: 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) 309 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. 5.2.1 Tiefensuche (depth-first search) Bei der Tiefensuche 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. 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 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 310 Algorithmen und Datenstrukturen 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 3. Schritt: b[ w] = 3 Stack u v w w x y v u z 311 Algorithmen und Datenstrukturen 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 8. Schritt: backtracking zu w, f [ z ] = 8 Stack u v w z w x y v u z 312 Algorithmen und Datenstrukturen 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 313 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 Q←s 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 | ). 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. 314 Algorithmen und Datenstrukturen 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 315 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 t u v w 1 0 2 3 r s t u v w x y 10. Schritt: x y 316 Algorithmen und Datenstrukturen 2 1 2 3 5.2.3 Implementierung In der Klasse Graph sind Tiefensuche (Methode traverseDFS) und Breitensuche (traverseBFS) implementiert139. 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 { 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!"); 139 vgl. pr52220 317 Algorithmen und Datenstrukturen 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; } /** 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(); 318 Algorithmen und Datenstrukturen 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(); while(eIter.hasNext()) { u = ((Edge)eIter.next()).dest; // System.out.println(u.key.toString()); if (color.get(u) == null) { 319 Algorithmen und Datenstrukturen 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 320 Algorithmen und Datenstrukturen 5.3 Topologischer Sort Sortieren bedeutet Herstellung einer totalen (vollständigen) Ordnung. Es gibt auch Prozesse zur Herstellung von teilweisen Ordnungen140, d.h.: Es gibt eine Ordnung für einige Paare dieser Elemente, aber nicht für alle. 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 Teste B 4 1 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) 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 140 vgl. 1.2.2.2 321 Algorithmen und Datenstrukturen 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. Damit kann der Algorithmus durch folgende Pseudocode-Darstellung beschrieben werden. void topsort() { Queue q; int zaehler = 0; Vertex v, w; q = new Queue(); for each vertex v if (v.indegree141 == 0) q = new Queue(); while (!q.isEmpty()) { v = q.dequeue(); zaehler++; for each w adjacent to v if (--w.indegree == 0) q.enqueue(w); } if (zaehler != anzahlKnoten) System.out.println(“Fehler: Zyklus gefunden”); } 141 indegree ist der Zähler für die jeweilige Anzahl von Vorgängerknoten 322 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. 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 Komplexität. 323 6 0 0 0 0 0 1 0 7 7 0 0 0 0 0 0 0 6 6 Algorithmen und Datenstrukturen 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 fogende 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: 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 324 Algorithmen und Datenstrukturen 5.5 Kürzeste Wege 5.5.1 Der Algorithmus von Dijkstra Der Algorithmus von Dijkstra142 (aus dem Jahre 1959) löst dieses Problem: Ausgehend von einem Startknoten (z.B. a) werden zunächst für alle Knoten die direkten Knoten eingetragen. Nun wird der billigste noch nicht besuchte Knoten gewählt und getestet, ob von diesem aus andere Knoten günstiger erreichbar sind. Diese Änderungen werden gespeichert und der Knoten als besucht markiert. Das Vorgehen wird solange wiederholt, bis alle Knoten besucht wurden. Die Komplexität des Verfahrens von Dijkstra beträgt O( | V 2 | ). Implementierung143: public static final int INFINITY = Integer.MAX_VALUE; public int [] dijkstra(int [][] a, int start) { if (a == null || a.length == 0) return null; // Matrix ist leer boolean visited[] = new boolean[a.length]; // Knoten besucht int [] costs = new int[a.length]; // Kosten start -> i int i, w = 0, j, billigsteKosten, n = a.length; for (i = 0; i < n; i++) visited[i] = false; visited[start] = true; // Kosten setzen im Rahmen der Initialisierung for (i = 0; i < n; i++) costs[i] = (a[start][i] > 0) ? a[start][i] : INFINITY; costs[start] = 0; // Start kostenlos for (i = 0; i < n; i++) { // Suche nicht besuchte Knoten w mit costs[w] minimal billigsteKosten = INFINITY; // maximale Kosten for (j = 0;j < n; j++) { if (!visited[j] && costs[j] < billigsteKosten) { w = j; billigsteKosten = costs[w]; } } visited[w] = true; // markiere w als besucht for (j = 0; j < n; j++) { if (a[w][j] == 0) continue; costs[j] = java.lang.Math.min(costs[j],costs[w] + a[w][j]); } } return costs; } 142 143 vgl. 2.2.11.3 pr52221 325 Algorithmen und Datenstrukturen Testbeispiel und Test: [0] [1] 3 a b 2 [2] 6 c [3] 4 1 5 d [4] 5 2 1 e [5] f 4 Abb.: Graph für den Test des Dijkstra-Algorithmus Der Test führt zu folgendem Programmablauf: Abb.: Lösungsschritte beim Test 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 326 Algorithmen und Datenstrukturen 5.5.2 Der Algorithmus von Floyd Der Algorithmus von Floyd berechnet die kürzesten Verbindungen von allen Knoten zu allen 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 | ). Implementierung144: 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: 144 pr52221 327 Algorithmen und Datenstrukturen 328 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 minimale 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 4 k2 1 3 10 2 7 k3 k4 5 8 k6 k5 4 6 k7 1 besitzt folgenden minimale Spannbaum: 2 k1 k2 1 2 k3 k4 k5 4 k6 1 6 k7 Abb.: Die Anzahl der Kanten in einem minimal spannenden Baum ist |V| - 1 (Anzahl der Knoten – 1). Der minimal spannende Baum ist 329 Algorithmen und Datenstrukturen - ein Baum, der keine Zyklen besitzt. - spannend, da er jeden Knoten abdeckt. - ein Minimum. Der Algorithmus von Prim arbeitet stufenweise. Auf jeder Stufe wird ein Knoten ausgewählt. Die Kanten auf seine nachfolgenden Knoten werden dann untersucht. Die Untersuchung folgt nach den Vorschriften des Dijkstra-Algorithmus. Es gibt nur eine Ausnahme hinsichtlich der Ermittlung der Distanz: d w = min(d v , c vw ) Die Ausgangssituation zeigt folgende Tabelle: k k1 k2 k3 k4 k5 k6 k7 bekannt false false false false false false false dv 0 ∞ ∞ ∞ ∞ ∞ ∞ pv null null null null null null null Abb.: Ausgangssituation „k1“ wird ausgewählt, „k2, k3, k4 sind zu k1 benachbart“. Das führt zur folgenden Tabelle: k k1 k2 k3 k4 k5 k6 k7 bekannt true false false false false false false dv 0 2 4 1 ∞ ∞ ∞ pv null k1 k1 k1 null null null Abb.: Die Tabelle im Zustand „k1 ist bekannt“ Der nächste Knoten, der ausgewählt wird ist k4. Jeder Knoten ist zu k4 benachbart. Ausgenommen ist k1, da dieser Knoten „bekannt“ ist. k2 bleibt unverändert, denn die „Kosten“ von k4 nach k2 sind 3, bei k2 ist 2 eingetragen. Der Rest wird, wie die folgende Tabelle zeigt, verändert: k k1 k2 k3 k4 k5 k6 k7 bekannt true false false true false false false dv 0 2 2 1 7 8 4 pv null k1 k4 k1 k4 k4 k4 Abb.: Die Tabelle im Zustand „k4 ist bekannt“ Der nächste Knoten, der ausgewählt wird, ist k2. Das zeigt keine Auswirkungen. Dann wird k3 gewählt. Das bewirkt eine Veränderung der Distanz zu k6. k k1 k2 bekannt true true dv 0 2 pv null k1 330 Algorithmen und Datenstrukturen k3 k4 k5 k6 k7 true true false false false 2 1 7 5 4 k4 k1 k4 k3 k4 Abb.: Tabelle mit Zustand „k2 ist bekannt“ und (anschließend) mit dem Zustand „k3 ist bekannt“ Es folgt die Wahl des Knoten k7, was die Ausrichtung von k6 und k5 bewirkt: k k1 k2 k3 k4 k5 k6 k7 Bekannt true true true true false false true dv 0 2 2 1 6 1 4 pv null k1 k4 k1 k7 k7 k4 Abb.: Tabelle mit Zustand „k7 ist bekannt“ Jetzt werden noch k6 und dann k5 bestimmt. Die Tabelle nimmt danach folgende Gestalt an: k k1 k2 k3 k4 k5 k6 k7 Bekannt true true true true true true true dv 0 2 2 1 6 1 4 pv null k1 k4 k1 k7 k7 k4 Abb.: Tabelle mit Zustand „k6 ist bekannt“ und (anschließend) „k5 ist bekannt“ Die Tabelle zeigt, daß folgende Kanten den minimal spannenden Baum bilden: (k2,k1),k3,k4)(k4,k1),(k5,k7),(k6,k7),(k7,k4) Der Algorithmus von Prim zeigt weitgehende Übereinstimmung mit dem Algorithmus von Dijkstra145. Komplexität: Die Laufzeit ist O(|V|2) Implementierung. MinimalSpanningTree.java146 145 146 vgl. 2.2.11.2 vgl. pr53330 331 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 4 k2 1 3 10 2 7 k3 k4 5 8 k6 k5 4 1 6 k7 Bestimme den minimale spannenden Baum nach dem Algorithmus von Kruskal: 332 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 k2 k1 1 2 k3 k4 k6 1 k5 k7 333 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 k6 6 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 147 bzw. Kruskal.java148 147 148 vgl. pr53331 vgl. pr56200 334