Algorithmus Korrekt, einfach zu verstehen, zu implementieren, geringstmöglicher Platz- und Laufzeitbedarf. Laufzeitkomplexität Anzahl der Elementaroperationen in Abhängigkeit der Eingabekomplexität. Abstraktion: Keine Unterscheidung der Elementaroperationen, Menge aller Eingaben in Komplexitätsklassen zerlegen, und in diesen nur noch den best-, average-, worst-case betrachten, Faktoren und Summanden ignorieren. Es wird also nur noch das Wachstum betrachtet. O-Notation f, g : N → R+ . f = O(g) :⇔ ∃n0 ∈ N, c ∈ R+ : ∀n ≥ n0 : f (n) ≤ cg(n) ⇐ monotonie ⇒ ∃ lim fg(n) (n) (d.h. f wächst höchstens so schnell wie g). n→∞ f = O(g) : f ≤ g, f = o(g) : f < g, f = Θ(g) : f = g, f = ω(g) : f > g, f = Ω(g) : f ≥ g. Sei Ω(g) die (mindest)Komplexität eines Problems P und sei f = O(g) die Laufzeitkomplexität des Algorithmuses A der P löst, dann ist A asymptotisch optimal. externer Algorithmus Wir sprechen von einem externen Algorithmus, wenn zur Verarbeitung einer Objektmenge der Größe n nur O(1) interner Speicher benötigt wird und Ω(n) externer Hintergrundspeicherplatz (z.B. Festplatte). Eine externe Datenstruktur (=Speicherstruktur) ist vollständig auf Hintergrundspeicher dargestellt. Das Lesen/Schreiben von k Bytes im Hauptspeicher ist in etwa k-mal so teuer wie das Lesen/Schreiben von einem Byte, deshalb ist eine Kostenrechnung pro Elementaroperation hier sinnvoll. Ein Byte von einem Plattenspeicher lesen/schreiben ist unwesentlich billiger als 1 Kilobyte zu lesen/schreiben, da die Hauptzeit für das Positionieren des Lesekopfs verwendet wird und für das Warten darauf, dass das richtige Segment unter selbigen ist. Deshalb werden Daten immer in größeren Blöcken von der Platte gelesen die man auch als (Speicher)Seiten bezeichnet. Daher betrachtet man i.d.R. als Kostenmaß für die Laufzeit eines externen Algorithmus die Anzahl der Seitenzugriffe und für den Platzbedarf die Anzahl der belegten Seiten. Ein weiteres interessantes Maß für die Speicherplatzausnutzung ist die Anzahl der benutzten Bytes pro verfügbarer Bytes in den benutzten Seiten(in Prozent). Listen Es gibt eine Anordnung also auch Vorgänger und Nachfolger, Elementduplikate sind möglich. Algebren list1 sorts l, e, b ops f irst : l → e, rest : l → l, append : l × e → l, concate : l×l →l Implem. list1 Implem. list2 list2 sorts l, e, b, p ops f ront|last : l → p, next|previous : l → p∪ ⊥, bol|eol : l × p → b, insert : l × p × e → l, delete : l × p → l, concat : l × l → l, f ind : l × (e → b) → p ∪ null, retrive : l × p → e. Stack (LIFO) top=first, push = append, pop=rest, stack=list. Wird i.d.R. im Array implementiert. Queues (FIFO) front = first, enqueue = rappend1 , dequeue = rest, queue=list. Wird häufig in zyklischen Arrays implementiert. Doppelt verkettete Liste alle Operationen werden effizient unterstützt. Einfach verkettete Liste pos zeigt immer auf den Vorgänger damit kann delete effizient implementiert werden und durch einen Zeiger auf das erste und 1 append wird hier durch rappend = rare append ersetzt 1 letzte Listenelement kann Einfügen am Anfang und concat effizient implementiert werden. Delete für das letzte Element und previous können nicht effizient implementiert werden, Positionen sind instabil bei Änderungsoperationen. Im Array Nachteile sind der höhere Implementierungsaufwand, die Größe des Arrays ist festgelegt. Vorteile sind, dass die Struktur gespeichert werden kann und der Hauptspeicher für jedes Element nicht vom Betriebssystem angefordert werden muss. Abbildungen Binäre Bäume (mapping) sorts mapping =: m, domain =: d, range =: r ops assign: m × d × r → m, apply: m × d → r ∪ ⊥. Entspricht einem Array auf Implementierungseben. (i) Der leere Baum (3, ) ist ein binärer Baum. (ii) Sei x ein Knoten, T1 und T2 binäre Bäume, dann ist (T1 , x, T2 ) auch ein binärer Baum. Bezeichnungen x heißt Wurzel, T1 linker T2 rechter Teilbaum. Seien t1 , t2 die Wurzeln von T1 und T2 , dann heißen sie Söhne von x oder auch Geschwister/Brüder und x heißt Vater von t1 und t2 . Ein Knoten ohne Söhne heißt Blatt. Eine Folge von Knoten p0 , p1 , . . . , pn mit pi ist Vater von pi+1 heißt Pfad. Sei p0 , . . . , pn ein Pfad, dann heißen die Knoten p0 , . . . , pn−1 Vorfahren von pn . Alle Knoten die von einem Knoten p aus erreichbar sind heißen Nachfahren von p. Die Anzahl der Knoten weniger eins in einem Pfad ist die Länge des Pfades. Die Höhe eines Baumes ist die Länge des längsten Pfades im Baum. Die Pfadlänge von der Wurzel bis zu einem Knoten p ist die Tiefe von p. max./min. Höhe Die maximale Höhe eines Baumes mit n Knoten ist n-12 , die minimale Höhe ist O(log2 n) (exakt blogn c. Haben alle inneren Knoten zwei Söhne, dann hat ein binärer Baum mit n + 1 Blättern n Knoten. Algebra Implementierung allg. Bäume sorts tree =: t, e, l ops maketree : t×e×t → t, key : t → e, lef t/right : t → t, in−, pre−, postorder : t → l. Mit Zeiger Jeder Knoten enthält z.B. einen Record mit einem Zeiger auf den linken Sohn und eine auf den Rechten (natürlich auch noch Datenfelder für Informationen). Im Array Die Knoten eines vollständigen Baumes werden von links nach rechts aufsteigend durchnummeriert, dann wird der i-te Knoten in das i-te Arrayelement geschrieben. Damit ergibt sich, dass der linke Sohn des iten Knoten im Array A an Position A[2i] steht, der rechte an Position A[2i + 1]. (i) Ein einzelner Knoten ist ein Baum. (ii) Sei x ein Knoten und seien T1 , . . . , Tk Bäume, dann ist (x, T1 , . . . , Tk ) ein Baum. Bezeichnungen Der Grad eines Knoten ist die Anzahl seiner Söhne. Der Grad eines Baumes ist der höchste Grad den einer seiner Knoten hat. Ein Wald ist eine Menge von Bäumen. min./max. Höhe Die maximale Höhe eines Baumes vom Grad d mit n Knoten ist n-1, die mini2 Entartung zur Liste 2 Implementierung male Höhe ist O(logd n). Mit Arrays Der Grad d des Baumes wird festgelegt; ein Knoten wird dann durch ein Array mit d Elementen repräsentiert. Vorteil: Zugriff auf einen Sohn in konstanter Zeit (O(1)). Nachteil: Wird der Grad nur selten erreicht wird Platz verschwendet; die max. Anzahl Söhne pro Knoten ist durch den Grad beschränkt. Im Binärbaum Die Wurzel enthält nur den linken Sohn von diesem zweigt links der erste eigene Sohn ab während rechts ein Bruder abzweigt. Man kann also allgemein sagen, dass von einem Knoten3 links der erste eigen Sohn abzweigt und rechts ein weiterer Sohn des Vaterknoten (also ein Bruder). Vorteil: der Platzbedarf ist O(n) die Anzahl der Söhne ist nur durch die vorhandenen Ressourcen beschränkt. Nachteil: Zugriff auf einen Sohn ist nicht mehr in konstanter Zeit möglich. Mengen ∩∪\ Implementierung set1 sorts set =: s, e, l ops insert : s × e → s, union : s × s → s, diff erence : s × s → s, enumerate : s → l. Bitvektor insert O(1); empty, enumerate O(N ); ∩ ∪ \ O(N ). N ist die Größe des Universums. ungeordnete Liste ∩ ∪ \ O(n · m) denn bei ∩ und \ muss jedes Element mit jedem verglichen werden; bei ∪ muss auf Dubletten geprüft werden. geordnete Liste Ist die beste Implementierung. insert und enumerate können in O(n) realisiert werden. Durch die Technik des parallelen Durchlaufs benötigen ∩ ∪ \ nur O(n + m) Zeit. paralleler Durchlauf Hierbei wird das erste Element der ersten Liste gemerkt und die zweite Liste durchsucht bis ein Element größer oder gleich dem gemerkten Element gefunden wird. Anschließend kann die, der Operation entsprechende, Verarbeitung durchgeführt werden. Sind die Elemente gleich gehören sie in den Durchschnitt bzw. entfernt wenn es sich um die Differenz handelt. Bei der Vereinigung kann an dieser Stelle eine die Ordnung erhaltende Verkettungsoperation durchgeführt werden. Ist die Verarbeitung beendet merkt man sich das aktuelle Element der zweite Listen und durchwandert die Erste nach dem selben Prinzip. Dictionaries Sind Mengen mit INSERT, DELETE und MEMBER. set2 sorts elemset =: s, e, b ops insert : s × e → e, delete : s × e → s, member : s × e → b. sequentiell geordnete Liste im Array insert und delete benötigen O(n), member O(log n) und Platz O(N ) mit N ist Größe des Arrays. ungeordnete Liste insert benötigt O(1) mit Duplikatsprüfung O(n) Zeit, delete und member O(n), Platzbedarf ist O(n). geordnete Liste insert, delete, member und Platz benötigen O(n). Bitvektor insert, delete, member benötigen nur konstante Zeit der Platzbedarf ist O(U ) mit U ist Größe des Universums. Dies ist nur für kleine Mengen (Universen) praktikabel. einfache Implementierungen Hash Wert/Schlüssel eines Mengenelements wird durch die Hashfunktion in die Speicheradresse umgerechnet unter der sie in einem sog. Bucket (Behälter) abgelegt wird. 3 mit Ausnahme der Wurzel 3 Hashfunktion Die Hashfunktion h : D → {0, 1, . . . , m − 1} sollte surjektiv und effizient zu | {z } m−Behälter berechnen sein und die Elemente gleichmäßig verteilen. Kollision Wird ein Schlüssel auf einen schon besetzten Behälter abgebildet erfolgt eine Kollision mit der Wahrscheinlichkeit Pk = 1 − P¬k = 1 − P¬k (1) · P¬k (2) · . . . · m m−1 · m · . . . · m−(n−1) . P¬k (n) = 1 − m m Es kommt also bei m=365 Behältern mit dem Einfügen des n=50sten Wertes mit einer Wahrscheinlichkeit von 97% zu einer Kollision4 . offenes Hashing Jeder Behälter wird als Liste implementiert. Im Mittel gilt n Elemente pro m n ); bei einer gleichmäßigen Verteilung ergibt sich für die Operationen Buckets ( m n Suchen, Einfügen, Entfernen eine Laufzeitkomplexität von O(1 + m ) = O(1). Im Worst-Case (Entartung zur Liste) O(n) und ein Platzbedarf von O(n + m). Eine Kollision stellt kein Problem dar, da das kollidierende Element an der entsprechenden Liste angefügt wird. geschlossenes Hashing Hier ist die Zahl der Einträge auf eine Tabelle mit m · b Einträge beschränkt. (Sei b = 1). Kollisionsstrategien Hashfunktionen Zusammenfassung Kommt es beim geschlossenen Hashing zu Kollision muss eine rehashing Operation durchgeführt werden. Hierzu werden rehashing Funktionen h1 , h2 , . . . , hm−1 definiert, welche für den kollidierten Schlüssel x die Buckets h1 (x), . . . , hm−1 (x) inspizieren. In die erste freie/gelöschte Stelle wird der Schlüssel abgelegt. Beim Suchen nach x wird die gleiche Folge betrachtet. lineares Sondieren hi = (h(s)+c·i) mod m, c /m, | damit alle Behälter getroffen 5 werden. Nachteil Kettenbildung im Abstand c. quadratisches Sondieren h2i−1 (x) = (h(x) + i2 ) mod m, h2i (x) = (h(x) − i2 + und m = 4 · j + 3, m ist Prim, damit m2 ) mod m mit 1 ≤ i ≤ m−1 2 alle Buckets getroffen werden. Der Vorteil ist keine Clusterbildung beim rehashing; für die Primärkollision ändert sich nichts. Doppel-Hashing h und h0 sind voneinander unabhängige Hashfunktionen. Damit folgt für die Kollisionswahrscheinlichkeit Pk (h(x) = h(y))∩Pk (h0 (x) = h0 (y)) = m12 . Die Hashfunktion ist dann hi (x) = (h(x)+h0 (x)·i2 ) mod m. Experimente zeigen, dass diese Methode von idealem Hashing praktisch nicht unterscheidbar ist. Der Pferdefuß ist das finden (und beweisen) zweier unabhängiger Hashfunktionen. Divisionsmethode Sei der Schlüsselbereich ⊂ N: h(k) = k mod m. Mittel-Quadrat-Methode Sei der Schlüsselbereich ⊂ N. Man bildet von dem Schlüssel x das Quadrat und wählt von der resultierenden Ziffernfolge xr , xr−1 , . . . , x1 = x2 einen Block von mittleren Ziffern aus. Da diese von allen Ziffern in x abhängen erreicht man eine bessere Streuung aufeinanderfolgender Werte. Schlechtes Worst-Case verhalten, keine sortierte Ausgabe der Schlüssel möglich6 . Das Duchschnittsverhalten ist mit O(1) perfekt, alle Hashverfahren sind relativ einfach zu implementieren. 4 Geburtstagsparadoxon 5 man spricht auch von Clusterbildung für binäre Suchbäume 6 Motivation 4 Bei dynamischen Anwendungen7 ist offenes Hashing die erste Wahl. Wird die Tabellengröße um ein vielfaches überschritten sollte die Tabelle reorganisiert werden. Bei einer beschränkten Anzahl der Elemente und wenig Löschoperationen wählt man i.d.R geschlossenes Hashing wobei man darauf achten muss, dass die Auslastung unter 80% bleibt. binäre Suchbäume Sei T ein Baum, Tk seine Knotenmenge und D eine Menge auf der sich eine Ordnung definieren lässt. Dann heißt µ : Tk → D eine Knotenmakierung von T. Ein knotenmarkierter binärer Baum T heißt binärer Suchbaum, gdw für jeden Teilbaum T 0 von T mit T 0 = (Tl0 , y, Tr0 ) gilt ∀x ∈ Tl0 : µ(x) < µ(y), ∀z ∈ Tr0 : µ(z) > µ(y)8 . Damit liefert ein inorder-Durchlauf die Knoten des Baumes als aufsteigen sortierte Liste entsprechend der auf D definierten Ordnung. Alg. insert insert(t,x) suche den Knoten x im Baum t. Wird x gefunden verlasse insert ansonsten sei p der Zeiger an dem die Suche endete mit p=nil. Setze p auf x. Alg. delete delete(t,x) ist der Knoten x in dem Baum t enthalten dann unterscheiden die folgenden Fälle: x ist ein Blatt dann setze den entsprechenden Zeiger im Vaterknoten auf nil. x hat genau einen Sohn dann ersetze den Zeiger des Vaterknotens, der auf x zeigt, durch den Zeiger auf den Sohn von x. x hat zwei Söhne dann bestimme den kleinsten Knoten, der größer x ist in dem Teilbaum, der von x abzweigt und ersetze x durch diesen Knoten. Damit der Baum nicht linkslastig wird durch häufiges löschen, sollte zufällig bestimmt werden ob x durch den kleinsten Knoten der größer als x ist ersetzt wird oder ob x durch den größten Knoten der kleiner als x ist ersetzt wird. Analyse Der Aufwand von insert, delete und member ist proportional zum durchwanderten Pfad im worst-case also zur Höhe des Baumes, die schlechtesten falls n − 1 beträgt und zu einer linearen Laufzeitkomplexität9 führt (Aufbau O(n2 )). Ist der binäre Suchbaum ausgeglichen beträgt die Laufzeitkomplexität O(log n). AVL-Bäume Ein binärer Suchbaum mit der Strukturinvarianten: “die Höhe der zwei Teilbäume eines jeden Knoten unterscheidet sich höchstens um 1” heißt AVL-Baum 10 . Nach einer Aktualisierung des Baumes muss geprüft werden ob die Strukturinvariante noch erfüllt ist indem man vom aktuellen Knoten nach der Lösch- oder Einfügeoperation zurück zur Wurzel wandert und für jedem Knoten auf dem Pfad auf Verletzung der Strukturinvariante prüft. Ist die Strukturinvariante durch verletzt muss rebalanciert werden. Es gilt: 7 häufiges Löschen und Einfügen spricht hier von einer Strukturinvarianten 9 Motivation für balancierte Bäume 10 nach den Erfindern Adelson-Velskii und Landis 8 man 5 - Nach dem Einfügen genügt eine Einfach- oder Doppelrotation um den Baum wieder zu balancieren. - Nach dem wird auch durch Einfachoder Doppelrotation balanciert, allerdings kann es vorkommen, dass durch das Balancieren der Vaterknoten des balancierten Teilbaums die Strukturinvariante verletzt und auch balanciert werden muss. Dies kann sich schlechtestenfalls bis zur Wurzel fortsetzen. Zur Einfach- und Doppelrotation kann man sich die Faustregel merken: Bringt ein “äußerer” Teilbaum (wie z.B. C in der Graphik) einen Baum aus der Balance muss einfach rotiert werden, verletzt ein “innerer” Teilbaum die Strukturinvariante muss eine Doppelrotation durchgeführt werden. Analyse Man kann zeigen, dass die Höhe eines AVL-Baumes 1, 440(log n) beträgt. Da auch hier wieder das Laufzeitverhalten für die drei Operationen proportional zur Höhe des Baumes ist ergibt sich eine Laufzeitkomplexität von O(log n) für alle drei Operationen. Der Platzbedarf ist O(n). Um eine externe Implementierung des Dictionary-Datentyps zu realisieren fast man Speicherseiten als Knoten eines Suchbaums auf. Um die Kosten für eine Suche, die der Pfadlänge entsprechen, gering zu halten, wählt man Bäume mit hohem Verzweigungsgrad. Vielweg-Suchbäume -Der leere Baum ist ein Vielweg-Suchbaum mit der Schlüsselmenge ∅. -Seien T0 , . . . , Ts Vielweg Suchbäume mit Schlüsselmengen T 0 , . . . , T s und sei k1 , . . . , ks eine Folge von Schlüsseln, sodass gilt: k1 < k2 < . . . < ks . Dann ist die Folge T0 k1 T1 k2 T2 k3 . . . ks Ts ein Vielweg-Suchbaum genau dann, wenn gilt: ∀x ∈ T 0 : x < k1 , 1, . . . , s − 1 ∀x ∈ T s : ks < x, Seine Schlüsselmenge ist {k1 , . . . , ks } ∪ ∀x ∈ T i : ki < x < ki+1 Ss i=0 mit i= T i. B-Bäume Ein B-Baum der Ordnung m ist ein Vielweg-Suchbaum mit folgenden Eigenschaften: - Die Anzahl der Schlüssel in jedem Knoten mit Ausnahme der Wurzel liegt zwischen m und 2m. Die Wurzel enthält mindestens einen und maximal 2m Schlüssel. - Alle Pfadlängen von der Wurzel zu einem Blatt sind gleich. - Jeder innere Knoten mit s Schlüsseln hat genau s+1 Söhne (d.h., es gibt keine leeren Teilbäume). Höhe Betrachtet man einen minimal gefüllten11 B-Baum der Ordnung m mit n Schlüsseln, kann man mit Hilfe der Strukturdefinition zeigen, dass für dessen Höhe h gilt: h = O(log(m+1) n). Einfügealgorithmus insert (root, x) suche nach x im Baum mit Wurzel root; wenn x nicht gefunden wird, dann sei p das Blatt, in dem die Suche endete; 11 jeder Knoten außer der Wurzel hat m Schlüssel und somit m+1 Söhne 6 füge x an der richtigen Position in p ein; hat p nun 2m + 1 Schlüssel, dann behandle den Overflow von p. Overflow Ein Overflow wird behandelt indem der Knoten p mit 2m+1 Schlüssel am mittleren Schlüssel km+1 geteilt wird und dieser dann in den Vaterknoten wandert12 . Damit wird p zu zwei Knoten mit je m Schlüssel, die entweder alle größer als m+1 oder kleiner sind und somit problemlos links bzw. rechts, von dem in den Vaterknoten gewanderten Schlüssel m+1, angefügt werden können. Läuft nun der Vaterknoten über, dann wird dieses Prozedere wiederholt bis der Vaterknoten entweder weniger als 2m+1 Schlüssel enthält oder die Wurzel ist (siehe Fußnote). Löschalgorithmus delete (root, x) suche nach x im Baum mit der Wurzel root; //Abbruch bei erfolgloser Suche Liegt x in einem inneren Knoten dann suche x’, den Nachfolger von x13 im Baum14 ; vertausche x mit x’; Lösche x nun aus dem Blatt p, das x enthält; Ist p nicht die Wurzel und p hat nun m-1 Schlüssel dann behandle den Underflow von p. Underflow Der Underflow von p wird behandelt indem der direkte Nachbar15 p’ betrachtet wird. Enthält dieser mehr als m Schlüssel, dann ermittle den mittleren Schlüssel kr der gesamten Schlüsselfolge über p und p’, und ersetze den Vaterschlüssel y von dem p und p’ abzweigen durch kr und schreibe alle Schlüssel < kr mit y nach p (Baum ist balanciert). Enthält p’ weniger als m Schlüssel dann verschmelze p und p’ indem der Schlüssel y des Vaterknotens von dem p und p’ abzweigen in die Schlüsselmenge aller Schlüssel aus p und p’ einsortiert wird und verschmelze p und p’ zu einem Knoten p”. Durch das Entfernen von y aus dem Vaterknoten kann dieser nun einen Underflow haben der ebenso behandelt wird bis hoch zur Wurzel falls nötig. Tritt an der Wurzel der Fall ein, dass diese keinen Knoten mehr hat, dann entferne die Wurzel und mache den letzten Verschmolzenen Knoten zur Wurzel. Analyse Priority Queus Implementierung Die die Kosten für eine Einfüge-, Such- oder Lösch-Operation sind proportional zur Höhe des Baumes. Der B-Baum unterstützt also alle DictionaryOperationen in O(log(m+1) n) Zeit mit einem Platzbedarf von O(n). Die Speicherplatzausnutzung ist mit Ausnahme der Wurzel immer besser als 50sorts pqueue16 =: p, e ops insert : p × e → p, deletemin : p → p × e. Ein Heap kann mit einem leicht modifizierten AVL-Baum implementiert werden was zu einer Laufzeitkomplexität von O(log n) und einem Platzbedarf von O(n) führt. Eine, an das Problem angepasste Alternative, ist ein partiell geordneter Baum, wobei ein knotenmarkierter Binärbaum T , in dem für jeden Teilbaum T 0 mit 12 Ist p die Wurzel, dann wird m+1 zur neuen Wurzel nächstgrößeren gespeicherten Schlüssel 14 x’ liegt in einem Blatt 15 Dieser existiert, da p nicht die Wurzel ist und der Vater von p wenigstens eine Schlüssel hat (Wurzel) und somit wenigstens zwei Söhne 16 auch Heap genannt 13 den 7 Wurzel x gilt ∀y ∈ T 0 : µ(x) ≤ µ(y) partiell geordneter Baum heißt. alg. insert insert(h,e Füge das Element e auf der ersten freien Position der untersten Ebene des Heaps h ein. Sei v der Vater von e. Solange es einen Vaterknoten gibt und dieser größer ist als e tausche mit diesem den Platz. alg. deletemin deletemin(h) Entferne den letzte besetzten Knoten k aus dem Heap h und ersetze die Wurzel w durch k. Solange k einen rechten oder linken kleineren Sohn hat tausche mit dem kleineren der Beiden den Platz. Liefere schließlich w (das kleinste Element) zurück. Sn Sei M eine Menge und seien M1 , . . . , Mn Teilmengen von M für die gilt i=1 Mi = M, Mi ∩ Mj = ∅ mit i 6= j, i, j ∈ {1, . . . , n} dann heißt P = {M1 , . . . , Mn } Partition von M . Eine Teilmenge Mi wird auch mit Komponente bezeichnet. Partition Implementierung sorts partition =: p, compname =: c, e ops addcomp : p × c × e → p, merge : p × c × c → p, f ind : p × e → c. In Arrays Bei dieser Implementierung definiert man ein Komponentenarray das durch die Komponentennamen indiziert ist und zweidimensionale Elemente hat. Die erste Dimension enthält die Mächtigkeit der Komponente die zweite den Namen des “ersten” Elements der Komponente (oder 0). Außerdem benötigt man noch ein Elementarray, dass mit den Elementnamen indiziert ist und dessen Elemente ebenfalls zweidimensional sind. Die erste Dimension enthält den Komponentennamen, die Zweite den Namen des “nächsten” Elements der Komponente (oder 0). Beide Arrays müssen in ihrer Größe der Mächtigkeit von M entsprechen(, damit auch der Fall, dass P aus |M | Komponenten besteht abgedeckt ist). f ind kann offensichtlich in konstanter Zeit realisiert werden, das Verschmelzen ist proportional zur Anzahl der Elemente der durchlaufenen Liste, also O(n). Durch den Trick, dass beim Verschmelzen immer die kleinere Komponente der größeren hinzugefügt wird kann man n − 1 merge-Operationen in O(n log n) Zeit17 realisieren. Mit Bäumen Hierbei wird jede Komponente durch einen Baumrepräsentiert dessen Knoten einen Verweis auf den Vater enthalten. Als Komponentenname wird ein Verweis auf den Wurzelknoten des entsprechenden Baumes benutzt. Des weiteren benötigt man noch eine Array, dass mit den Elementnamen indiziert ist und je einen Zeiger auf das entsprechende Element enthält. merge wird realisiert indem die eine Komponente zum Sohn der Anderen gemacht wird. Hierzu sind stets zwei Operationen möglich woraus sich eine konstante Laufzeitkomplexität ergibt. f ind wird realisiert indem man über das Elementarray auf den Knoten des Elements zugreift und sich bis zur Wurzel hocharbeitet, somit ist die Laufzeitkomplexität proportional zur Höhe des Baumes, also O(h). Während des Hochlaufens zur Wurzel können alle auf dem Pfad liegenden Komponenten “mitgenommen” werden und dem Wurzelknoten hinzugefügt werden. Diese Technik nennt sich Pfadkompression. Ohne Pfadkompression können n f ind-Operationen in O(n log n) Zeit ausgeführt werden. Mit 17 man spricht bei einer solchen Verbesserung von einer amortisierten worst-case Laufzeit 8 Pfadkompression kann man zeigen, dass nur noch O(n · G(n)) Operationen benötigt werden, wobei G eine extrem langsam wachsende Funktion ist für die gilt G(n) ≤ 5 für alle n ≤ 265536 . Sortieren Gegeben sei eine Folge S = s1 . . . sn von Records, die eine key-Komponente eines linear geordneten Datentyps besitzen. Man berechne die Folge S 0 = si1 . . . sin als Permutation der Folge S, sodaß si1 .key ≤ si2 .key ≤ . . . ≤ sin .key. Klassifizierung -nach Speichernutzung: intern alles im Hauptspeicher extern datensatzweise -nach Methode: Sortieren durch Einfügen, durch Auswählen, Divide-and-Conquer, Fachverteilen; -nach Effizienz: Einfache Verfahren O(n2 ), gute Verfahren mit O(n log n) -in nur einem Array (in situ oder nicht (halber Hauptspeicher). -Allgemeines Verfahren oder auf Folgen mit bestimmten Eigenschaften spezialisiert. Auswählen und Einfügen Zur Beschreibung der beiden Strategien werden die Folgen SORTED und UNSORTED in zwei einfachen Verfahren verwendet mit O(n2 ) Laufzeitkomplexität. algorithm SelectionSort (S) UNSORTED := S; SORTED := ∅; while UNSORTED 6= ∅ do entnimm UNSORTED das Minimum und hänge es an SORTED an. end while. algorithm InsertionSort (S) UNSORTED := S; SORTED := ∅; while UNSORTED 6= ∅ do entnimm UNSORTED das erste Element und füge es an der richtigen Position in SORTED ein. end while. Analyse Heapsort und Baumsortiern Wir sprechen von direktem Auswählen und Einfügen, wenn sie ohne weiteres im Array realisiert werden. SelectionSort 1. Schleifendurchlauf n Operationen, 2. Durchlauf n-1 OperatioPn nen,. . . , n. Durchlauf 1 Operation. =⇒ n+(n−1)+. . .+1 = i=1 i = n(n+1) = 2 2 2 18 O(n ); C(n) = O(n ), X(n) = O(n) . Pn InsertionSort 1 + 2 + . . . + n = i=1 i = O(n2 ) = Cw (n) = Mw (n); 21 · (1 + 2 + Pn . . . + n) = 12 i=1 i = O(n2 ) = Ca (n) = M 19 a (n). Ist S fallend geordnet muß die innere Schleife bis zu S[0] durchlaufen. Die kritischen Operationen von SelectionSort und InsertionSort, Auswahl des Minimums bzw. das Einfügen in eine geordnete Folge, wurde in O(n) Zeit realisiert. Durch geeignete Datenstrukturen werden diese Operationen von Heapsort bzw. Baumsortieren in O(log n) Zeit durchgeführt was zu einer verbesserten Laufzeitkomplexität von O(n log n) für den Sortiervorgang führt. Da die Datenstruktur von balancierten Suchbäumen recht komplex ist, ist Baumsortieren in der Praxis nicht so interessant. Wegen der effizienten Implementierbarkeit eines partiell geordneten Baumes (Heap) im Array ist Heapsort auch in der Praxis interessant. Grundprinzip: -Die n zu sortierenden Elemente werden in einen Heap eingefügt: Komplexität O(n log n). -Dann wird n-mal das Minimum aus dem Heap entnommen: Komplexität O(n log n). 18 Comparisons, Exchanges 19 Move-Operationen 9 Um den Aufbau zu beschleunigen und in situ sortieren zu können definieren wir den Begriff des Teilheaps für eine im Array implementierte Folge von Records S[i..k], die eine key-Komponente eines linear geordneten Datentyps besitzen. Teilheap Ein Teilarray S[i..k], 1 ≤ i ≤ k ≤ n, heißt Teilheap :⇐⇒ ∀j ∈ [i, . . . , k] : S[j].key ≤ S[2j].key falls 2j ≤ k und S[j].key ≤ S[2j + 1].key falls 2j + 1 ≤ k Algorithmus Aufbau des Heap: S[bn/2c + 1..n] ist bereits ein Teilheap (⇐ Blätter) for i := b n/2 cdownto 1 do erweitere den Teilheap S[(i+1)..n] zu einem Teilheap S[i..n] durch Einsinken lassen von S[i] end for Sortieren der Folge: Da S nun ein Heap ist, ist S[1] das kleinste Element der Folge. Wir tauschen S[1] mit S[n] und lassen das neue S[1] in den Teilheap S[1..n-1] einsinken, womit S[1] wieder das kleinste Element des Teilheaps S[1..n1]. Nun tauschen wir wieder S[1] mit S[n-1] usw. und erhalten schließlich eine absteigend20 sortierte Folge. Das Einsinken von Element E im Teilheap wird realisiert indem der kleiner der beiden Söhne von E ermittelt wird, falls diese existieren, ist dieser kleiner als E dann wird Platz getauscht und das Prozedere so lange wiederholt bis der kleinere Sohn größer als E ist oder E keine Söhne mehr hat, also ein Blatt ist. Analyse Man kann zeigen, dass der Aufbau des Heap O(n) Zeit benötigt damit kann man Heapsort benutzen, um in O(n + k log n) die k kleinsten Elemente einer Menge in Sortierreihenfolge zu erhalten. Wenn k ≤ n/ log n ist, kann dieses Problem also in O(n) Zeit gelöst werden. Vergleich mit Quicksort Da das Einsinken auf jeder Ebene des Heap-Baumes zwei Vergleiche benötigt, ist die Gesamtzahl der Vergleiche Cw (n) = 2 · n · log n + O(n). Da auf der untersten Ebene schon 50% der Knoten liegen sieht es für das Durchschnittsverhalten nicht viel besser aus im Gegensatz zu Quicksort mit ≈ 1, 386 · n · log n + O(n). Durch Trennung der beiden Entscheidungen welcher Sohn ist zu wählen und muss das bearbeitete Element noch tiefer sinken kann man den Faktor des Durchschnittsverhaltens in Richtung 1 drücken. Man verfolgt hier zuerst den Pfad (Entscheidung 1) und wandert dann aufwärz zur Einfügeposition (bottom up Heapsort). Praktische Untersuchungen haben gezeigt, dass Heapsort für n ≥ 400 besser als Standard-Quicksort und für n ≥ 16000 besser als Clever Quicksort ist. Hinzu kommt noch das gute O(n log n) worst-case Verhalten im Gegensatz zu O(n2 ) vom Quicksort. DAC-Verfahren Das Divide-and-Conquer -Paradigma lässt sich allgemein so formulieren: Ist die Objektmenge klein genug löse das Problem direkt ansonsten Divide: Zerlege die Menge in mehrere möglichst gleichgroße Teilmengen. Conquer: Löse das Problem rekursiv für jede Teilmenge. Merge: Berechne aus den Teillösungen die Gesamtlösung des Problems. Mergesort benutzt einen trivialen Divide-Schritt und leistet die eigentliche Arbeit im Merge-Schritt wo zwei sortierte Folgen zu einer verschmolzen werden was bei 20 für ein aufsteigende Ordnung einen Maximumheap verwenden 10 Algorithmus Implementierung parallelem Durchlauf in O(k + m) Zeit realisierbar ist. MergeSort(S) Wenn |S| = 1 ist die Rekursionsabbruchbedingung erreicht ansonsten halbiere S (Divide), dann rufe MergeSort je mit den beiden Hälften auf (Conquer ) und schließlich verschmelze die Rückgabewerte dieser Aufrufe (Merge). Die Implementierung mit verketteten Listen braucht O(n) Zeit für das Zerlegen einer List der Länge n, da stets halbiert wird, wird der Divide-Schritt log n mal durchgeführt damit wird auch der Merge-Schritt log n mal ausgeführt, der für das Verschmelzen zweier Listen mit insgesamt n Elementen O(n) Zeit benötigt. Insgesamt ergibt sich also O(n log n). Eine Implementierung im Array mit zwei21 Arrays ist möglich. Der DivideSchritt benötigt hier nur konstante Zeit, der Conquer-Schritt wird wieder log n mal durchgeführt und somit auch der Mergeschritt mit linearer Laufzeitkomplexität was wieder zu einem Gesamtaufwand von O(n log n) führt. Also kann man festhalten, dass Mergesort O(n log n) Zeit benötigt. Dies lässt sich auch formal zeigen indem man die entsprechende Rekursionsgleichung aufstellt, die ein Spezialfall einer allgemeinen Rekursionsgleichung für DAC-Algorithmen ist, von der man zeigen kann, dass Sie bei linearen Divide- und Merge-Schritten eine Laufzeitkomplexität von O(n log n) hat. Quicksort Algorithmus arbeitet im Divide-Schritt während der Mergeschritt in konstanter Zeit durchgeführt werden kann (concatenieren von Listen) oder ganz überflüssig ist (Sortieren im Array). QuickSort(S) wenn |S| = 1 oder alle Schlüssel in S gleich sind, dann ist die Rekursionsabbruchbedingung erfüllt ansonsten Divide: Wähle den Schlüssel (=Splittelement) so das dieser nicht minimal22 ist in S. Berechne eine Teilfolge S1 aus S mit den Elementen, deren Schlüsselwert kleiner als der Schüssel ist und eine Teilfolge S2 mit Elementen die größer sind. Conquer : Rufe Quicksort mit den beiden Teilfolgen auf. Merge: Concatiniere die Rückgabewerte des Conquer-Schrittes. Der Divide Schritt hat ein lineares Laufzeitverhalten, die Häufigkeit des ConquerSchrittes ist von der Wahl des Schlüssels abhängig. Wird als Schlüssel immer der zweitgrößte Wert gewählt, dann entartet der Rekursionsstack23 und der Divide-Schritt wird n mal vom Conquer-Schritt aufgerufen, was zu einer Laufzeitkomplexität von O(n2 ) im Worst-Case führt. Dies passiert z.B. wenn man das erste nicht minimale Element der Folge als Schlüssel ermittelt wird und die Folge schon sortiert ist. Weitestgehend Vermeiden lässt sich dies indem man den Schlüsselwert per Zufall ermitteln lässt oder noch besser: man ermittelt drei Werte per Zufall und wählt den Median aus (Clever Quicksort). Bei dieser Variante liegt die Anzahl der Vergleiche im Average-Case nur um 18,8% über dem optimalen Wert während die Standardvariante 38,6% darüber liegt. 21 Es soll auch trickreiche in situ Implementierungen geben er minimal terminiert Quicksort nicht 23 dies kann verhindert werden indem die rekursive in eine iterative Implementierung umgewandelt wird. Die kleinere Partition wird gleich weiterverarbeitet die größere auf einem Stack gemerkt. Dieser Stack kann höchstens auf O(log n) anwachsen. 22 ist 11 Allgemein kann man zeigen, dass das Durchschnittsverhalten bei O(n log n) liegt. Trotz des besseren Worst-Case Verhalten und besseren Average-Case Verhalten ab einer bestimmten Eingabekomplexität von Heapsort wird Quicksort häufiger verwendet da die Entartung in der Praxis kaum vorkommt und weil die Verarbeitungschritte weniger komplex sind. Das Splittelement kann in einem schnellen Register gehalten werden. Fachverteilen Bucketsort Radixsort Unterliegen die Schlüsselwerte bestimmten Einschränkungen und können auch andere Operationen als Vergleiche angewandt werden, so ist es möglich in O(n) Zeit zu sortieren. Sei der keytype = 0..m-1 und seien Duplikate erlaubt. Seien B0 , . . . , Bm−1 Behälter die durch Listen implementiert und in einem Array organisiert sind und S eine n-Elementige Folge von Records mit oben genannten keytype. -Dann werden die Behälter initialisiert mit leeren Listen (O(m)); -Die n Elemente werden auf die entsprechenden Buckets verteilt Bsi .key = si (O(n)); -Schließlich werden die Buckets ausgegeben (O(n)). Also wird in O(m + n + n) Zeit sortiert mit m = O(n) folgt eine Laufzeitkomplexität von O(n). Dieses verfahren lässt sich noch verallgemeinern indem man keytype = 0..nk − 1 setzt und Bucketsort mit m=n Buckets in mehreren Phasen anwendet. 1. Phase: Bucketsort mit Bj = si und j = div n0 mod n. 2. Phase: Bucketsort mit Bj = si und j = div n1 mod n. .. . k. Phase: Bucketsort mit Bj = si und j = div nk−1 mod n. Wobei die i-te Phase immer mit der Ergebnisliste der (i-1)-ten Phase arbeitet und im k-ten Schritt kann man auf “mod n” verzichten. Bei diesem Verfahren werden die Schlüsselwerte als Ziffernfolgen zur Basis m=n aufgefasst und in jeder Phase wird nach einer Ziffer der Ziffernfolge sortiert, in der ersten nach der ersten Ziffer in der zweiten nach der zweiten und so weiter. Jede Phase hat eine Laufzeitkomplexität von O(n) betrachtet man k nicht als Konstante erhältman O(kn). Externes Sortieren Mittels externem Sortieren können groß externe Datenbestände sortiert werden. Gegeben sei also eine gespeicherte Menge von n Datensätzen mit Schlüsseln aus einem geordneten Wertebereich. Die Sätze stehen in einem vom Betriebssystem verwalteten File, dessen Darstellung wir uns als Seitenfolge vorstellen können. Wir nehmen der Einfachheit halber an , dass alle Datensätze feste Länge haben und dass b Sätze auf eine Seite passen. Seite 1: d1 . . . db , Seite 2: db+1 . . . d2b , Seite k: d(k−1)·b+1 . . . dn . Sequentielles Lesen aller n Datensätze erfordert k = dn/be Seitenzugriffe. Ein Lesen aller Datensätze in irgendeiner anderen Reihenfolge wird im allgemeinen etwa n Seitenzugriffe erfordern. Da b gewöhnlich erheblich größer ist als 1, ist man auf Sortierverfahren angewiesen, die eine Menge von Sätzen sequentiell24 verarbeiten. Hier ist Mergesort die erste Wahl wobei im externen Fall die Divide- und Conquer-Schritte wegfallen, 24 d.h. in Speicherungsreihenfolge 12 man beginnt direkt mit dem bottom-up Merge-Vorgang. Lauf Eine aufsteigende geordnete Teilfolge von Sätzen innerhalb eines Files heißt Lauf. Als Lauftrennsymbol wird in der nebenstehenden Graphik ein senkrechter Strich benutzt. Algorithmus Seien seien g1 , g2 , f1 , f2 Dateien wobei g1 die zu sortierenden n Datensätze enthält und die übrigen leer sind. Im initialen Lauf (ir) wird die Datensätze aus g1 eingelesen und in Läufen der Länge eins25 abwechselnd26 in die Dateien f1 , f2 geschrieben. Dann werden in Phase 1 (p1) die Läufe von f1 nach g1 geschrieben wobei immer zwei aufeinanderfolgende Läufe verschmolzen werden. Ebenso wird mit f2 nach g2 verfahren. Damit hat sich die Anzahl der Läufe halbiert. Nun werden die Läufe aus g1 bzw. g2 wieder unter Verschmelzung zweier aufeinanderfolgender Läufe nach f1 bzw. f2 geschrieben. Damit wird nun so lange fortgefahren bis die Anzahl der Läufe 1 ist (p3 in der Graphik). Analyse Im initialen Lauf und in jeder Phase werden sämtliche Datensätze jeweils einmal sequentiell gelesen und geschrieben, mit Kosten O(n/b). Beginnt man mit n Läufen der Länge 1 gibt es dlog2 ne Phasen. Der Gesamtaufwand beträgt also O(n/b log n) Seitenzugriffe, der interne Zeitbedarf ist auch O(n log n). direktes Mischen Verwendet man im initialen Lauf Läufe fester Länge, wie oben, spricht man auch von direktem Mischen. Das obige Verfahren lässt sich noch verbessern indem man interne und externe Sortierverfahren kombiniert. Anstatt nur 4/8 Seiten im Puffer zu halten sortiert man beim direkten Mischen im initialen Lauf jeweils so viele Datensätze wie in den Hauptspeicher passen mit einem internen Sortierverfahren. So werden Anfangsläufe der Länge m (also fester Länge) erzeugt. Es werden im initialen Lauf also k = dn/me Läufe erzeugt, damit kann die Anzahl der Phasen (dlog2 ke) stark reduziert werden. natürliches Mischen Um vorsortierte Teile von Dateien auszunutzen und um initiale Läufe erzeugen zu können, die die Größe des Hauptspeichers übersteigen verwendet man Läufe variabler Länge. Die Idee ist einen Puffer im Hauptspeicher zu halten der n Datensätze aufnehmen kann. Man entnimmt den Datensatz mit minimalem Schlüssel und schreibt ihn in den Lauf. Dann füllt man den Puffer wieder auf. Ist der hinzugekommene Datensatz größer als der zuletzt geschrieben kann dieser auch noch dem aktuellen Lauf hinzugefügt werden, anderenfalls muss er auf den nächsten Lauf warten. Implementieren lässt sich dieses Verfahren mit einem Heap der in einem Array der Größe m liegt. Der Heap wird gefüllt und das minimale Element emin in die Ausgabedatei geschrieben. Dann wird wieder ein Satz der Eingabedatei gelesen, 25 die somit trivialerweise sortiert sind ist sichergestellt, dass die Anzahl der Läufe pro Datei sich höchstens um eins unterscheiden 26 somit 13 ist dieser größer als emin dann lasse den Satz einsinken ansonsten verkleinere den Heapbereich auf m-1 und schreibe den neuen Satz ans Ende des Arrays. Fahre so lange fort bis der Heapbereich die Länge Null hat. Rekonstruiere den Heapbereich und beginne einen neuen Lauf. Vielweg Mischen Hierbei werden statt je zwei Eingabe- und Ausgabedateien k Ausgabe- und Eingabedateien verwendet. In einer Phase werden wiederholt k Läufe zu einem Lauf verschmolzen; die entstehenden Läufe werden zyklisch auf die Ausgabedateien verteilt. Intern muss man dabei in jedem Schritt aus den k ersten Schlüsseln der Eingabedateien den minimalen Schlüssel bestimmen; bei großem k kann das wiederum effizient mithilfe eines Heaps geschehen. Analyse: In jeder Phase wird nun die Anzahl vorhandener Läufe durch k dividiert; wenn zu Anfang r Läufe vorhanden sind, ergeben sich dlogk re Phasen, gegenüber dlog2 re beim binären Mischen. Die Anzahl externer Seitenzugriffe wird also noch einmal erheblich reduziert. Der interne Zeitbedarf ist O(n·log2 k) pro Phase, also insgesamt O(n · log2 k · logk r) = O(n · log2 r) | {z } log2 r gerichtete Graphen Ein gerichteter Graph 27 ist ein Paar G = (V, E) mit V 6= ∅ als endliche Menge von Knoten und E ⊆ V × V einer Menge von Kanten. Bezeichnungen Sei e := (v1 , v2 ) dann sagt man e ist inzident mit v1 und v2 bzw. v1 und v2 sind benachbart bzw. adjazent. Sei v einbestimmter Knoten und v 0 beliebig. Grad(v) := |{v, v 0 ), (v 0 , v) ∈ E}|, Eingangsgrad(v) := |{(v 0 , v) ∈ E}|, Ausgangsgrad(v) := |{(v, v 0 ) ∈ E}. Eine Folge von Knoten v1 , . . . , vn mit (vi , vi+1 ) ∈ E mit i ∈ {1, . . . , n − 1} heißt Pfad mit der Länge := n − 1 = |{(xi , xi+1 ) ∈ E}| mit i ∈ {1, . . . , n − 1}. Ein Pfad heißt einfach, wenn alle Knoten, mit Ausnahmen von v1 = vn paarweise verschieden sind. Ein Einfacher Pfad mit der Länge ≥ 1 und v1 = vn heißt einfacher Zyklus. Ein Teilgraph eines Graphen G = (V, E), ist ein Graph G0 = (V 0 , E 0 ) mit V 0 ⊆ V und E 0 ⊆ E. Die Knoten v1 , v2 eines gerichteten Graphen heißen stark verbunden, wenn es einen Pfad von v1 nach v2 und von v2 nach v1 gibt. Eine starke Komponente ist ein Teilgraph mit maximaler Knotenanzahl in dem alle Knoten paarweise stark verbunden sind. Speicherdarstellung Sind alle v ∈ V von einem Wurzelknoten aus erreichbar heißt G Wurzelgraph. Adjazenzmatrix Der Graph mit |V | = n wird durch eine boolsche n × n-Matrix Aij := (true falls (i, j) ∈ E, f alse sonst) dargestellt. Bei Kantenmarkierten Graphen kann statt true/false die Kantenmarkierung ∪∞ eingetragen werden. Der Vorteil dieser Darstellung ist, dass die Existenz einer Verbindung zwischen zwei Knoten in konstanter Zeit abgefragt werden kann. 27 auch Digraph = directed graph 14 Nachteile sind ein Platzbedarf von O(n2 ), das Auffinden aller Nachfolger benötigt O(n) Zeit und die Initialisierung Θ(n2 ). Adjazenzlisten Man verwaltet für jeden Knoten eine Liste seiner Nachfolger womit der Platzbedarf auf O(|V | + |E|) sinkt, alle k Nachfolger in O(k) Zeit ermittelbar sind, allerdings ist ein Test auf Nachbarschaft nicht mehr in konstanter Zeit realisierbar. Um die Vorgänger eines Knoten verfügbar zu machen kann eine inverse Adjazenzliste verwaltet werden. Expansion Die Expansion X(v) eines Graphen G in einem Knoten v ist wie folgt definierter Baum: (i) Falls v keinen Nachfolger hat, ist X(v) ein Baum der nur aus dem Knoten v besteht. (ii) Falls v1 , . . . , vk Nachfolger von v sind, ist X(v) = (v, X(v1 ), . . . , X(vk )) Dieser Baum wird unendlich falls er Zyklen enthält. Ein Tiefendurchlauf entspricht einem Preorder-Durchlauf von X(v), der jeweils bei einem schon besuchten Knoten abgebrochen wird. Ein Breitendurchlauf wird realisiert indem die Knoten des Expansionsbaums aufsteigend ebenenweiße von links nach rechts durchlaufen werden. Auch wird bei schon besuchten Knoten abgebrochen. Die resultierenden Bäume des Tiefen- bzw. Breitendurchlaufs heißen depthbzw. breadth-first Spannbaum. Verbleiben nach einem Durchlauf unbesuchte Knoten vu , beginnt mit vu ein neuer Durchlauf. Man erhält dann sog. spannende Wälder. Einen Tiefendurchauf erhält man mit folgendem Algorithmus: algorithm dfs(v) if besucht[v]=false then verarbeite v; besucht[v] = true; for each Nachfolger v i von v do dfs(v i) end for end if Ein Breitendurchlauf kann wie folgt realisiert werden: algorithm bfs(v) Queue:q; enqueue(q,v); besucht[v] := true; while not isempty(q) do verarbeite v’ := front(q); dequeue(q); for each Nachfolger v 0 i von v’ do if not besucht [v 0 i] then enqueue(q,v 0 i); besucht[v 0 i] := true; end if 15 end for end while Die Laufzeitkomplexität einer Implementierung mit Adjazenzliste beträgt O(|V |+ |E|) Sei G ein kantenmarkierter, gerichteter Graph wobei die Knotenmarkierung Kosten sind, dann ist die Länge eines Pfades die Summe der besuchten Kantenkosten. Dijkstra Algorithmus der kürzesten Wege von einem bestimmten Knoten zu allen anderen erreichbaren Knoten. Hierbei man von v ausgehend einen Teilgraphen wachsen, der aus Knoten besteht deren ausgehende Kanten schon betrachtet wurden (innere Knoten) und bei denen dies noch nicht der Fall ist Peripherieknoten. Die Menge der Kanten des Teilgraphen setzt sich zusammen aus den Kanten des Baumes der kürzesten Wege und den übrigen. Der Teilgraph wächst indem in jedem Schritt aus der Menge der Peripherieknoten, der Knoten mit dem kürzesten Abstand vk zu v in die Menge der inneren Knoten übergeht. Dabei werden alle Nachfolger von vk betrachtet und zwei Fälle unterschieden. 1. Fall Der betrachtete Nachfolger gehört schon zur Knotenmenge des Teilgraphen, dann wird überprüft ob der neue Pfad über vk kürzer ist als der bisherige, falls ja wird die Pfadlänge zu diesem Knoten aktualisiert und die bisherige Kante aus der Menge der Kanten, die zum Baum der kürzesten Wege gehören durch die neue Kante mit vk ersetzt. 2. Fall Der betrachteten Nachfolger gehört noch nicht zur Knotenmenge des Teilgraphen, dann wird die Information der Pfadlänge gespeichert und die entsprechende Kante in die Menge der Kanten, die zum Baum der kürzesten Wege gehören hinzugefügt. Implementierung Der Algorithmus startet indem der Peripherieknotenmenge v hinzugefügt wird und endet wenn diese leer ist. mit Adjazenzmatrix Es wird ein Array benötigt in dem die Pfadlänge zu den jeweiligen Knoten hinterlegt wird (dist). dist entspricht der Menge der Peripherieknoten. Es wird ein Array benötigt, dass den unmittelbaren Vorgänger zu einem Knoten enthält (f ather). f ather entspricht dem Baum der kürzesten Wege. Außerdem wird noch ein Array für die inneren Knoten (inner) benötigt. Schließlich wird noch eine Kosten-Adjazenzmatrix (cost(i, j) benötigt. - ermittle aus dist den Knoten w mit minimalen Abstand zu v. (O(n)) - durchlaufe die Zeile cost(w,-) um für alle Nachfolger von w ggf. den Abstand und den Vater zu korrigieren (O(n)) Die Laufzeitkomplexität dieser Implementierung ist O(n2 ). mit Adjazenzliste und Heap dist enthält wieder alle Abstände der Knoten des Teilgraphen zu v, und f ahter wird in der selben Bedeutung wie oben verwendet. Dann benötigen wir noch einen Heap (mittels Array implementiert) der alle Peripherieknoten enthält und mit einem weiteren Array (heapadress), 16 das zu jedem Knoten seine Heapposition enthält, doppelt verkettet ist. - Entnimm den Knoten mit dem kürzesten Abstand zu v aus dem Heap (O(log n)). - Finde in dessen Adjazenzliste die mi Nachfolger von wi (O(mi )). a) füge jeden Nachfolger der noch nicht in dem Heap ist in diesen ein (O(log n)) b) Schon vorhandene Nachfolger der müssen evtl. aktualisiert werden und im Heap nach oben wandern. Aufwand für a) und b) ist O(mi log n). Über alle Schritte des AlgoP rithmus summiert gilt mi = e = |E| Es ergibt sich ein Gesamtaufwand von O(e · log n) mit einem Platzbedarf von O(|V | + |E|). Floyd Bestimmung kürzerster Wege zwischen allen Knoten im Graphen auch als “all pairs shortest path”-Problem bekannt. Sei G = (V, E) mit |V | = n. Der Algorithmus berechnet eine Folge von Graphen G0 , . . . , Gn . Graph Gi entsteht jeweils durch Modifikation des Graphen Gi−1 . Jeder Graph Gi ist wie folgt definiert: (i) Gi hat die gleiche Knotenmenge wie G. α (ii) Es existiert in Gi eine Kante v −→ w ⇔ es existiert in G ein Pfad von v nach w, in dem als Zwischenknoten nur Knoten aus der Menge {1, . . . , i} verwendet werden. Der kürzeste derartige Pfad hat Koste α. α Dabei bezeichne die Notation v −→ w eine Kante (v, w) mit υ(v, w) = α. i-te Schritt des Algorithmus von Floyd berechnet sich wie folgt: Betrachte alle Paare (vj , wk ) der Vorgänger vj und Nachfolger wk mit j = 1, . . . , r und k = 1, . . . , s von vi des Graphen Gi−1 . (a) Falls noch keine Kante von vj nach wk existiert, so erzeuge die Kante α+β vj −→ wk . γ (b) Falls schon eine Kante vj → wk existiert, ersetze die Markierung γ dieser Kante durch α + β, falls α + β < γ. Implementierung Sei Cij die Kostenmatrix von G, für i = j setze Cij = 0 und außerdem Cij = ∞ gdw (vi , vj ) 6∈ E. Initialisiere eine weitere Matrix A mit C und eine Matrix P mit -1. for (int i=0; i<n; i++) for (int j=0; j<n; j++) for (int k=0; k<n; k++) if (A[j][i] + A[i][k] < A[j][k]) { A[j][k] = A[j][i] + A[i][k]; //k ist über i von j günstiger zu erreiche P[j][k] = i;} // O(nˆ3) //j erreicht k über i Warshalls Alg. Berechne einen Graphen G = (V, E) vom dem Graphen G = (V, E) mit E := {(v, w) | es gibt in G einen Pfad von v nach w} Sei A ein boolsches Array mit A[i][j] = true, gdw es gibt eine Pfad von i nach j. //A initialisieren anhand von C 17 for ... for ... for //siehe Floyd Algorithmus if (A[j][i] && A[i][k] && !A[j][k] {// j erreicht k über i A[j][k] = true;} G heißt transitive Hülle von G. Starke Komponenten Alle alle starken Komponenten eines Graphen berechnen. - Alle Depth-First-Spannbäume von G ermitteln und die Knoten in der Reihenfolge der Beendigung ihrer rekursiven Aufrufe indizieren. - Inversen Graphen Gr konstruieren durch Richtungsumkehrung aller Kanten von G. - Depth-First-Spannbäume von Gr erzeugen, angefangen mit den Knoten der höchsten Nummer aus dem ersten Schritt. Ist ein Spannbaum komplett fahre mit dem höchsten der verblieben Knoten fort. - Die Knotenmenge jedes im vorhergehenden Schritt entstanden Spannbaums bildet die Knotenmenge einer starken Komponente. ungerichtete Graphen Ein ungerichteter Graph ist ein Graph, in dem die Relation E symmetrisch ist. Verbundene Komponenten lassen sich ermitteln durch einen Tiefen- bzw. Breitendurchlauf. Die Spannbäume sind Knotenmengen der Zyklen. Ein verbundener, azyklischer Graph heißt freier Baum. Es gilt n ≥ 1 ⇒ |E| = n − 1 und durch hinzufügen einer Kante entsteht ein Zyklus. Ein freier Baum von einem verbundenen Graphen mit Kantenbewertung, der alle Knoten von G enthält und dessen Kanten eine Teilmenge der Kanten von G ist heißt Spannbaum. Kruskal Alg. Berechne berechne einen Spannbaum mit minimalen Kosten zu G = (V, E). Es wird ein Graph T entwickelt, der zunächst nur einen Knoten enthält. sukzessive werden die Kanten in aufsteigender Kostenfolge betrachtet, verbindet eine Kante zwei getrennte Komponenten von T so wird sie in den Graphen eingefügt. Sobald T nur noch eine einzige Komponente besitzt, ist T ein minimaler Spannbaum für G. 18 Geom. Probleme (i)Alle Punkte p ∈ P innerhalb eines Rechtecks bestimmen; (ii)alle sich schneidenden Paare von Rechtecken einer Ebene bestimmen; (iii)entscheiden welche Plyederkanten im Raum sichtbar sind; (iv)den naheliegensten Punkt zu einem anderen Punkt im k-dimensionalekn Raum ermitteln. Anwendung (i) Datenbank, (ii) VLIS-Entwurf, (iii) 3D-Szenen, (iv) Spracherkennung. Klassifikation Es werden im Weiteren Mengen- und Suchprobleme mit orthogonalen Objekten betrachtet. Mengenprb. Interessanten Eigenschaften einer Menge berechnen. Suchproblem Ermittle alle Objekte s ∈ S die mit dem Queryobjekt q in interessanter Beziehung stehen. orthogonal heißt ein geometrisches Objekt28 wenn es als kartesisches Produkt von k Intervallen beschrieben werden kann. (Intervall darf zum Punkt entarten.) beliebig orientiert heißt ein geometrisches Objekt wenn seine Kanten bzw. Flächen beliebige Richtungen haben dürfen. Plane-Sweep Ist eine Methode um Mengenprobleme zu lösen. Die Idee ist, eine horizontale/vertikale Gerade/Hyperebene genannt Sweepline durch die Ebene/den Raum zu schieben und dabei den Schnitt mit der Objektmenge zu betrachten. Zu ein Plane-Sweep-Algorithmus gehören immer zwei Datenstrukturen, die Sweepline-Status-Struktur und die SweepEvent-Struktur. Die Sweepline-Status-Struktur muss dynamisch sein, die Event-Struktur ist von Fall zu Fall statisch oder dynamisch. Notation h = (x1 , x2 , y) ist ein horizontales Segment, v = (x, y1 , y2 ) ein Vertikales und r = (x1 , x2 , y1 , y2 ) ist ein Rechteck. Die i-te Komponente eines der Tupel wird mit dem mit i indizierten Namen des Tupels bezeichnet (Bsp. v2 = y1 von v). Für eine Tupelmenge S bezeichnet πi (S) die Menge aller i-ter Komponenten der Tupel der Menge S. Wird von einer Menge von x-Koordinaten gesprochen, dann ist damit gemeint eine Menge von Paaren, die aus der x-Koordinate (Schlüssel) und z.B. zugehörigem vertikalem Segment bestehen. Segment-Schnitt-Problem Finde in einer Menge horizontaler und Vertikaler Liniensegmente die sich schneidenden Paare. Es gilt, dass horizontale Segmente die Sweepline während eines bestimmten Zeitintervalls schneiden, die vertikalen zu einem bestimmten Zeitpunkt. Das horizontal schneidende Segment gehört zur Statusstruktur und liegt im y-Intervall des einen oder der mehreren vertikalen Elemente der Statusstruktur. Die Statusstruktur sollte also die y-Koordinaten der die Sweepline schneidenden Segmente enthalten und wann immer eines od. mehrere vertikalen Elemente getroffen werden, werden alle vorhandenen y-Koordinaten ihrer y-Intervalle ermittelt. Die Sweep-Eventstruktur sind alle linken und rechten Enden der horizontalen Segmente und die x-Koordinaten der vertikalen Segmente: Algorithmus SegmentIngersectionPS(H,V) S = {(h1 , h)|h ∈ H} ∪ {(h2 , h) | h ∈ H} ∪ {(v1 , v)|v ∈ V }; //Eventstruktur Sortiert nach x Y = {(h3 , h) | h ∈ H, h ist unter der Sweepline }; //Statusstruktur Y := ∅; //Initialisierung (Sweeplien am Start) durchlaufe S: das gerade erreichte Objekt ist 28 eine zusammenhängende Teilmenge des Rn 19 -h1 ∈ π1 (H) =⇒ Y := Y ∪ {(h3 , h)}; //Sweepline über neuem horizontalem Segment -h2 ∈ π2 (H) =⇒ Y := Y \{(h3 , h)}; //Sweepline hat horizontales Segment hinter sich gelassen -v1 ∈ π1 (V ) =⇒ A := π2 ({w ∈ Y | w1 ∈ [y1 , y2 ]}; //Ermittle v schneidende hrz. Segmente. gib alle Paare in A × {v} aus. end Datenstruktur die das Einfügen und Entfernen einer Koordinate und das Auffinden aller gespeicherten Koordinaten, die in einem Intervall liegen realisiert. Dies kann mit einem AVL-Baum gelöst werden, der nur in den Blättern Koordinaten enthält welche zu einer Liste verkettet sind. Laufzeitkomplexität Sei n = |H| + |V |. Das Sortieren benötigt O(n log n); beim Durchlaufen von S werden O(n) Einfüge-, Entferne- und Query-Operationen durchgeführt, jede kostet O(log n) Zeit. Ist nun k die Gesamtzahl der sich schneidenden Paare, so folgt eine Laufzeitkomplexität von O(n log n + k), der Speicherbedarf ist offensichtlich O(n). Die Problemkomplexität ist Ω(n log n + k), Ω(n) für Zeitund Platzbedarf also ist der Algorithmus asymptotisch optimal. DAC-Lösung Bei dieser Lösungsvariante des Problems wird auch zunächst die Menge S entwickelt wie bei der Plane-Sweep Variante. Dann wird der rekursive Algorithmus linesect(S,L,R,V) aufgerufen mit L = {(h1 , h) ∈ S | (h2 , h) 6∈ S}, R = {(h2 , h) ∈ S | (h1 , h) 6∈ S}, V = {([v2 , v3 ], v) | v ∈ S} algorithm linesect(S,L,R,V) Enthält S nur ein Element s: L := (h3 , h); R := ∅; V := ∅; //s ist linker Endpunkt (h1 , h) R := (h3 , h); L := ∅; V := ∅; //s ist rechter Endpunkt (h2 , h) V := ([v2 , v3 ], v); L := ∅; R := ∅; //s ist vertikales Segment(v1 , v) Enthält S mehr als ein Element: Divide: Wähle eine x-Koordinate xm , die S in zwei etwa gleich große Teilmengen S1 und S2 teilt, wobei S1 = {s ∈ S | s1 < xm } und S2 = S\S1 Conquer: linsect(S1 , L1 , R1 , V1 ); linsec(S2 , L2 , R2 , V2 ); Merge: LR := L1 ∩ R2 ;//horiz. Segmente, die sich im Mergeschritt “treffen” L := (L1 \LR) ∪ L2 ; R := R1 ∪ (R2 \LR); V := V1 ∪ V2 ; output((L1 \LR) ⊗ V2 ); output((R2 \LR) ⊗ V1 ) end linsect. Rechteckschnitt-Problem Finde alle achsenparallele Rechtecke einer Eben, die sich schneiden29 . Rechtecke deren Kanten sich schneiden, lassen sich mit dem Segmentschnitt-Algorithmus finden. Das Problem von Rechtecke die sich einander enthalten, lässt sich auf ein Punkteischluß-Problem zurückführen. Liegt ein Rechteck in einem anderen so liegen auch alle Punkte dieses Rechtecks in dem anderen also auch insbesondere ein beliebig ausgewählter. Algorithmus Punkteinschluss(R) 29 Zwei geometrische Objekte schneiden sich, wenn sie gemeinsame Punkte haben 20 S = {(r1 , lef t, (r3 , r4 ), r) | r ∈ R}∪{(r2 , right, (r3 , r4 ), r) | r ∈ R}∪{(p1 , point, p2 ) | p ∈ P unkte} Y = {([r3 , r4 ], r) | r ∈ R, r ist unter der Sweepline } Y := ∅ durchlaufe S: Das gerade erreichte Objekt s ist -s2 == lef t =⇒ Y = Y ∪ {([r3 , r4 ], r)}; -s2 == right =⇒ Y = Y \{([r3 , r4 ], r) | r = s4 }; -s2 == point =⇒ A := π2 ({[r3 , r4 ], r0 ) ∈ I|s3 ∈ [r3 , r4 ]}); gib alle Paare in A × {r} aus. end Segmentbaum covered nodes Es wird also eine Datenstruktur benötigt, die das Einfügen und Entfernen eines Intervalls und das Auffinden aller Intervalle, die eine Query-Koordinate enthalten, erlaubt. Eine geordnete Menge von Koordinaten {x0 , . . . , xN } teilt den eindimensionalen Raum in eine Sequenz “atomarer” Intervalle [x0 , x1 [, [x1 , x2 [, . . . , [xN −1 , xN ] auf. Jeder Knoten des Segment-Baums hat eine zugeordnete Liste (die Knotenliste) von “Inervall-Namen”. Intervallnamen werden immer so hoch wie möglich im Baum eingetragen: wann immer zwei Söhne eines Knotens von einem Intervall “markiert” würden, markiere stattdessen den Vater. Alle Intervalle die einen Punkt x enthalten findet man indem der Segmentbaum durchlaufen wird bis zu dem Intervall [xi , xi+1 [ das x enthält. Alle auf dem Pfad liegende Intervall-Namen der Knotenlisten sind x enthaltende Intervalle. CN (i) := {p ∈ S | interval(p) ⊆ i ∧ ¬(interval(f ather(p)) ⊆ i)} Menge der von einem, Intervall i in einem Segment-Baum S kanonisch bedeckten Knoten. Analyse Segement-Baum Der leere Baum hat N Blätter und N − 1 innere Knoten benötigt also O(N ) Speicherplatz und hat die Höhe O(log N ). Eine Menge von n Intervallen lässt sich mit O(N +n log N ) Platz abspeichern. Das Einfügen und Entfernen eines Intervalls kostet O(log N ), das Auffinden der t Intervalle, die x0 enthalten, erfolgt in O(log N + t) Zeit. Algorithmus Analyse Sei r = |Rechtecke|, p = |P unkte|, k = |P unkteinschlüsse|, n = r + p. Aufbauen und Sortieren der Sweep-Struktur benötigt O(n log n) Zeit, Konstruktion des Segmentbaums O(n) also erfordert die Initialisierung O(n log n) Zeit. Während des Sweeps werden r linke und rechte Rechteckkanten und p Punkte angetroffen. Jede der zugeordneten Operationen kann in O(log r) = O(log n) Zeit durchgeführt werden, ausgenommen die Ausgabe der Punkteinschlüsse, die O(k0 ) Zeit benötigt also insgesamt O(n log n + k0 ) Zeit- und O(n + r log r) Platzbedarf. Hinzu kommen noch (O(n log n + k00 ) Zeit- und O(n) Platzbedarf für den Segmentschitt-Algorithmus. Da O(k0 ) = O(k00 ) = O(k) gilt zusammengefasst für das Rechteckschnitt-Problem mit Plane-Sweep die Laufzeitkomplexität von O(n log n + k) mit einem Platzbedarf von O(n log n) dieser kann verbessert werden auf O(n) durch andere Datenstrukturen oder DAC-Algorithmen. Maßproblem Bestimme die, von einer Menge Rechtecken bedeckte, Fläche. Zu jedem Zeitpunkt schneidet die Sweepline die Rechteckmenge in einer Menge von y-Intervallen. Zwischen zwei aufeinanderfolgenden Sweep-Positionen xi und xi+1 gibt es eine wohldefinierte Menge Y aktuell vorhandener y-Intervalle. Die von Rechtecken bedeckte Fläche innerhalb des Streifens zwischen xi und xi+1 ist gegeben durch (xi+1 − xi ) · measure(Y ). measure(Y) ist die Länge der Vereinigung disjunkter Y- 21 Intervalle der linken Rechteckkanten der Rechtecke die gerade unter der Sweepline sind. Also ist eine Datenstruktur gesucht, die Intervalle zu speichern, zu löschen und das Maß einer gespeicherten Intervall-Menge abzufragen erlaubt. Der Segmentbaum von oben erlaubt schon das löschen und hinzufügen von Intervallen um das Abfragen des Maßes zu realisieren wird der Knotentyp modifiziert. Statt der Liste erhält jeder Knoten eine Zähler der inkrementiert wird beim hinzufügen des entsprechenden Intervalls, dekrementiert beim Entfernen außerdem wird das Maß des Teilbaumes hinterlegt dessen Wurzel der knoten ist. Sei p nun ein Knoten dann errechnet sich das Maß p.measure = if p.count > 0 then p.top - p.bottom else if p ist ein Blatt then 0 else p.left.measure + p.right.measure Der modifizierte Segment-Baum stellt eine Intervallmenge über einem Raster der Größe N in O(N ) Speicherplatz dar. Das Einfügen oder Entfernen eines Intervalls unter gleichzeitiger Korrektur aller Knotenmaße ist in O(log N ) Zeit möglich. Die Abfrage des Maßes der dargestellten Intervallmenge erfordert nur O(1) Zeit. Analyse Mit der Laufzeitkomplexität von O(n log n) für den Aufbau der Event- und Sweep-Struktur, den n Einfüge- und Löschoperationen von Intervallen in O(log n) Zeit während des Sweeps und dem n-maligen Ablesen ind Aufaddieren des Maße folgt für das Maß-Problem gelöst mittels Plane-Sweep ein Zeit- und Platzbedarf von O(n log n) und O(n). 22