Ein anderer Weg, die Laufzeit der quadratischen Binärsuche zu zeigen, ist das Auflösen der Rekursionsgleichung. Zur Vereinfachung nehmen wir ohne Bek schränkung der Allgemeinheit an, dass n = 22 ist, da man sonst nicht auf Gaußklammern verzichten könnte, die die Rechnung komplizierter machen würden. √ Nach jedem Schritt verkürzt sich das zu durchsuchende Intervall von n auf n Einträge. Wir müssen also folgende Gleichung lösen: √ T (n) = T ( n) + c für eine Konstante c q √ n +c+c =T 1 = T (n 4 ) + 2c 1 = T (n 8 ) + 3c 1 = T (n 2k ) + kc Als Rekursionsanker haben wir T (2) = b (wobei b konstant). Wir suchen also 1 das k, für das n 2k = 2 gilt. 1 n 2k = 2 1 log n = log2 = 1 2k log n = 2k log log n = k Setzen wir das in die Rekursionsgleichung ein erhalten wir: T (n) = T (2) + c ∗ log log n = Θ(log log n) Abschließend lassen sich die drei Suchalgorithmen in folgendem Satz zusammenfassen: Satz 2.5.1. Eine Menge S von n Elementen aus einem linear geordneten Universum (U, ≤) sei sortiert in einem Feld gespeichert. Dann gilt: 1. Binärsuche sucht nach einem gegebenen a ∈ U . Benötigt O(log n) Zeit. 2. Falls U = [0, 1] und die Elemente von S zufällig und gleichverteilt aus U gezogen sind, dann braucht die Interpolationssuche eine erwartete Zeit von O(log log n). 3. Unter der Voraussetzung von 2. braucht die quadratische Binärsuche erwartet O(log log n) Zeit. Alle drei Algorithmen arbeiten auf sortierten Feldern, diese haben jedoch einen Nachteil: das Einfügen oder Entfernen einzelner Elemente ist nicht effizient möglich und benötigt O(n) Zeit. Hat man eine, sich dynamisch verändernde, Menge von Elementen sollte man auf eine effizientere Datenstruktur, wie sie im nächsten Kapitel vorgestellt werden, zurückgreifen. 35 Kapitel 3 Datenstrukturen Eine Datenstruktur bezeichnet eine Art Daten abzuspeichern, so dass gewisse Operationen effizient durchführbar sind. Eine Datenstruktur ist die algorithmische Realisierung eines abstrakten Datentyps. Zum Beispiel ist im sortieren Feld das Suchen sehr effizient. Einfügen und Entfernen jedoch nicht, da man beim Einfügen ein neues Array erstellen muss und alle Elemente mitsamt dem Eingefügten hineinkopieren muss. Gleiches gilt beim Entfernen und kostet somit Θ(n). Alternativ hierzu könnte man gelöschte Felder markieren ohne ein neues Array zu erstellen. Dies ist aber auch nicht zu empfehlen, da auf Dauer größere unbesetzte Bereiche entstehen. 3.1 Wörterbücher (Dictionaries) Abstrakter Datentyp: eine Menge S ∈ U , U : Universum (meistens linear geordnet) Operationen: • SUCH(a,S) mit a ∈ U liefert 0 falls a ∈ / S und 1 falls a ∈ S. Bemerkung. Die Rückgabe ist zwar für den ADT sinnvoll, in der Praxis (zum Beispiel in einem Telefonbuch) werden üblicherweise anstelle der 1 ein Verweis auf a sowie zusätzliche Informationen dazu ausgegeben. In der Regel ist a ein Schlüssel in einem größeren Datensatz. • EINF(a,S) S := S ∪ {a} • STREICHE(a,S) S := S \ {a} Bemerkung. Bei EINF und STREICHE bleibt S nach Definition von ∪ und \ unverändert, wenn a schon in S enthalten ist, beziehungsweise gar nicht enthalten war. Nun benötigen wir eine effiziente Datenstruktur für den abstrakten Datentyp. Als erste Idee könnte man das sortierte Feld haben. 36 3.1.1 sortiertes Feld Die Suchen-Funktion benötigt O(log n) Zeit, Einfügen und Streichen aber Θ(n). Die beiden letzteren Operationen sind nicht effizient (siehe vorheriges Kapitel), daher kommt das sortierte Feld nicht in Frage. 3.1.2 Hashing Wir haben eine Hashfunktion h : U → N. Für jedes a ∈ U berechnet h(x) die Stelle, an die a gespeichert wird. Da U im Allgemeinen nicht begrenzt ist, der Speicher jedoch schon, ist h in der Regel nicht injektiv, bildet also verschiedene a auf die selbe Stelle h(a) ab. Daher sollte man h besser folgendermaßen beschreiben: h : U → [1, m], wobei m die Größe des verfügbaren Feldes angibt. Bei einem nicht injektiven h kann es aber zu Konflikten kommen, wenn a 6= b aber h(a) = h(b). Daher speichert man für jeden Hashwert eine Liste von Elementen, die den gleichen Hashwert haben, siehe Abbildung 3.1. 1 i m a b Abbildung 3.1: Hashing Beispiel für h(a) = h(b) = i Wenn die Hashfunktion die Eingaben gut verteilt, also jedes Ergebnis zwischen 1 und m die gleiche Wahrscheinlichkeit hat, und das m groß genug gewählt wurde, üblicherweise Θ(|S|), dann sind die Operationen des Wörterbuchproblems erwartet in O(1) möglich, also in Konstanter Zeit“. ” Bemerkung. Hier ist nicht mal eine lineare Ordnung erforderlich. In der Praxis wird Hashing oft erfolgreich verwendet, motiviert durch die binäre Suche gibt es aber noch weitere interessante Datenstrukturen, diese werden im Folgenden vorgestellt. 3.1.3 Binärer Baum Ein binärer Baum speichert die Elemente von S in seinen inneren Knoten. Für jeden inneren Knoten v gilt: 1. Elemente im linken Teilbaum sind kleiner, als das Element von v 2. Elemente im rechten Teilbaum sind größer, als das Element von v Zur Veranschaulichung siehe Abbildung 3.2. Die Blätter stehen für erfolgloses Suchen. Man landet also in einem Blatt, wenn ein Element nicht in dem Baum gespeichert ist. 37 a v <a >a Abbildung 3.2: Ein binärer Baum mit zwei angedeuteten Teilbäumen Beispiel. Wir wollen die Zahlen 5, 4, 6 ,3 in einen leeren Baum speichern, dieser Entwickelt sich dann wie in der Abbildung 3.3 zu sehen 5 4 6 3 5 5 4 5 4 5 6 4 6 3 Abbildung 3.3: Einfügen der Zahlenfolge 5, 4, 6, 3 in einen leeren Baum Das Streichen ist etwas komplizierter. Dabei sucht man den zu streichenden Knoten und ersetzt ihn durch das Maximum aus dem linken Teilbaum. Da das Maximum aus dem linken Teilbaum ebenfalls Kinder haben kann, die allerdings nur kleiner sein können, zieht man dessen Teilbaum an die Stelle des Maximums. In einem binären Baum geht das Suchen, Einfügen und Streichen in O(h) Zeit, wobei h die Höhe des Baumes bezeichnet, da man den Baum jeweils maximal bis zu einem Blatt durchlaufen muss. Im günstigsten Fall ist der Baum b balanciert“, dann gilt: h = Θ(log n) (Ab” bildung 3.5). Im schlechtesten Fall hat jeder Knoten nur ein Kind, dann gilt: h = Θ(n) (Abbildung 3.4). Im schlechtesten Fall wird der Baum somit zu einer verketteten Liste und garantiert keine logarithmische Laufzeit mehr. Wie kann man nun einen Baum so definieren, dass der schlechteste Fall immer noch eine effiziente Laufzeit für die Operationen auf dem Baum garantiert? Zuerst wollen wir dazu untersuchen, wie groß die mittlere Höhe eines zufälligen Binärbaums ist. Hierfür gibt es 2 Ansätze. Behauptung 3.1.1. In einen ursprünglich leeren Baum werden n Elemente aus U eingefügt, wobei jeder Permutation der aufsteigenden Ordnung gleich 38 Abbildung 3.4: Ein Binärbaum im ungünstigsten Fall Abbildung 3.5: Ein Binärbaum im optimalen Fall wahrscheinlich ist. Dann gilt: Die erwartete Höhe des entstehenden Baums ist O(log n). Beweis. Beweis zu komplex, siehe Cormen - Introduction to Algorithms p.254 Stattdessen untersuchen wir, wie viel n Einfügeoperationen im Mittel kosten. Wir gehen davon aus, das jedes Element mit der Wahrscheinlichkeit n1 auftritt. Damit ist die Wahrscheinlichkeit, dass das i-t kleinste Element ai an erster Stelle eingefügt wird n1 . Damit werden in den linken Teilbaum i−1 Elemente eingefügt und in den rechten Teilbaum n − i Elemente, s. Abbilding 3.6. Diese Teilbäume werden wiederum zufällig aufgebaut. Um die Rekursionsgleichung aufzustellen müssen wir nun den Erwartungswert T (n) der Einfügezeit für n Elemente über alle Auswahlmöglichen des ersten Elements ai ausrechnen. Für jedes i setzen sich die Gesamtkosten wie folgt zusammen: T (i − 1) + T (n − i) + O(n), wobei T (i − 1) und T (n − i) für die erwarteten Kosten für den Aufbau beider Teilbäume stehen, und der lineare Term dadurch zustande kommt, dass man die restlichen n − 1 Elemente mit ai vergleichen muss um den Binärbaum gemäß der Invariante aufzubauen. Nun 39 ai zufällige Bäume n-i i-1 Elemente Elemente Abbildung 3.6: Die Wahrscheinlichkeit des ai Elements beträgt n1 , dass es an erster Stelle eingefügt wird. Die beiden Teilbäume sind beides zufällige Bäume mitteln wir über alle i und erhalten folgende Rekursionsgleichung: n 1X [T (i − 1) + T (n − i) + O(n − 1)] n i=1 ( n ) 1 X = [T (i − 1) + T (n − i)] + nO(n − 1) n i=1 T (n) = n = 1X [T (i − 1) + T (n − i)] + O(n − 1) n i=1 Der Rekursionsanker liegt bei T (1) = c wobei c eine Konstante ist. Die Rekursionsgleichung ist identisch mit der Rekursionsgleichung der mittleren Laufzeit des Quicksort-Algorithmus. Es gilt also folgender Satz: Satz 3.1.2. Fügt man in einen ursprünglich leeren binären Suchbaum n Elemente ein, wobei die Reihenfolge jeder Permutation der aufsteigenden Ordnung gleich wahrscheinlich ist, so erfordert dies im Mittel Θ(n log n) Zeit. Folgerung: Die mittlere Tiefe eines Elements des Suchbaums ( Abstand zu der Wurzel ) ist Θ(log n). Entsprechend benötigt man im Mittel für das Suchen bzw. Streichen eines Elements Θ(log n) Zeit. Dies geht in der Praxis meistens gut, aber wie kann man nun den schlechtesten Fall ebenfalls auf Θ(log n) drücken? 3.1.4 AVL-Baum (Adelson-Velski/Landis - 1962) AVL-Bäume wurden von Georgi Adelson-Velski und Jewgeni Landis 1962 entwickelt. Ziel war es einen binären Suchbaum zu erstellen, der möglichst ausgeglichen ist um eine Laufzeit von Θ(log n) für die Operationen SUCH, EINF und STREICHE zu garantieren. 40 Definition 3.1.3 (AVL-Baum). AVL-Bäume sind binäre Suchbäume, wobei für jeden inneren Knoten v gilt: Die Höhe der beiden Unterbäume von v unterscheidet sich um höchstens 1. Man schreibt zur Vereinfachung in jeden Knoten die Differenz der Höhen des linken Teilbaums zum rechten Teilbaum auf. Ist sie Betragsmäßig kleiner oder gleich 1, so ist der Baum ein AVL-Baum. Siehe dazu Abbildungen 3.7 und 3.8. +1 0 0 0 0 Abbildung 3.7: Beides sind AVL-Bäume, da jeder Knoten die Invariante eines AVL-Baums erfüllt. +2 0 0 0 Abbildung 3.8: Das ist kein AVL-Baum, da bei der Wurzel die Höhe des linken Teilbaums um 2 geringer ist, als die Höhe des rechten Teilbaums. Satz 3.1.4. Die Höhe eines AVL-Baums mit n inneren Knoten ist Θ(log n) Beweis. Sei nh die minimale Anzahl der inneren Knoten eines AVL-Baums der Höhe h. Damit ein AVL-Baum möglichst unausgeglichen ist, muss der Baum in jedem inneren Knoten soweit unausgeglichen sein, wie es die Invariante zulässt. Das heißt der linke Teilbaum eines inneren Knotens ist immer um 1 größer als der rechte Teilbaum eines Knotens. Siehe dazu Abbildung 3.9. Daraus ergibt sich folgende Rekursionsgleichung: nh = nh−1 + nh−2 + 1 Durch Induktion lässt sich nh von unten durch die Fibinacci-Zahlen abschätzen. Induktionsbehauptung : nh ≥ f ibo(h − 1) 41 +1 h-2 h-1 Abbildung 3.9: Der linke Teilbaum hat die Höhe h-1, der rechte Teilbaum die Höhe h-2. Wird dies in jedem Knoten rekursiv fortgesetzt hat man einen AVLBaum der größtmöglichsten Höhe für n Knoten. Induktionsanfang : √ n1 = 1 = f ibo(0) = 1 √ n2 = 2 > f ibo(1) = 1 Induktionsschritt : 3 ≤ h → h + 1 nh = nh−1 + nh−2 + 1 ≥ f ibo(h − 2) + f ibo(h − 3) + 1 ≥ f ibo(h − 2) + f ibo(h − 3) √ = f ibo(h − 1) √ Da sich die Fibonacci-Zahlen von unten durch den Goldenen Schnitt (Φ = abschätzen lassen gilt weiterhin: nh ≥ Φh−2 log nh ≥ (h − 2) log Φ ⇒ h = O(log nh ) = O(log n) Also folgt, ein AVL-Baum mit n Knoten hat die Höhe O(log n). 42 5+1 2 )