Kapitel 2 Einfache Datenstrukturen Bis vor einigen Jahren hat es für das Telefonieren ausgereicht, neben dem Telefon ein Telefonbuch zur Hand zu haben. Das im wesentlichen auftretende Problem, daß man einen Namen und eine grobe Adresse, nicht aber die Telefonnummer des Anzurufenden hatte, konnte damit effizient gelöst werden. Seitdem aber auch Telefonnummern übermittelt und sichtbar gemacht werden, stellt sich bisweilen das Problem, daß man zu einer Telefonnummer den zugehörigen Namen finden möchte. Dazu taugt das Telefonbuch nur wenig. Mit anderen Worten: auf ein und derselben Datenmenge hängt die Effizienz von Operationen, die man auf den Daten ausüben will, sehr stark von der Art und Weise ab, in der die Daten aufbereitet sind bzw. zur Verfügung gestellt werden. Wir werden uns in diesem Kapitel mit einfachen Datenstrukturen befassen. 2.1 Speichern und Streichen von Daten an festgelegten Stellen In vielen Fällen reicht es aus lediglich zuzulassen, daß eine Datenmenge nur an festgelegten Stellen durch Einfügen neuer Daten bzw. Löschen von vorhandenen Daten verändert werden dürfen. Als Beispiele für diese Datenstrukturen dienen uns Warteschlangen und Tellerstapel. 2.1.1 Stacks (last-in-first-out: LIFO) Die Konzeption des Stacks beinhaltet sie folgenden Eigenschaften: • Daten können nur oben eingefügt werden (mit der Operation push). • Ein Datum kann nur oben gelesen werden (mit pop). Mit dem Lesen des Datums wird gleichzeitig der oberste Eintrag im Stack gelöscht. • Realisierung eines Stacks durch ein Array S : S 1 top auf das oberste Element 18 S2 ... Sn und Zeiger KAPITEL 2. EINFACHE DATENSTRUKTUREN push(S, top, x) top := top − 1 S(top) := x 19 pop(S, top, x) x := S(top) top := top + 1 • push(S, top, x) liest das Datum x in den Stack S ein. • pop(S, top, x) liest das Datum, das im Stack ganz oben liegt, in die Variable x ein und setzt den Zeiger eine Position nach unten. Auf die Stelle im Stack, in der der Inhalt von x stand, kann nun nicht mehr zugegriffen werden: sie ist “gelöscht”. Bemerkung 2.1. Bei der Realisierung ist es notwendig, Abfragen auf “leer” und auf “Überlauf” zu implementieren, damit man nicht aus einem leeren Stack liest oder in einen schon vollen Stack Daten einliest. • Lesen und Schreiben in Stacks benötigen konstante Zeit (also unabhängig von der jeweiligen Größe). Wir drücken diesen Sachverhalt durch die Notation O(1) aus. Beispiel 2.2. Wir wollen schnell erkennen, ob in einem Text (etwa einem Programmteil) die Klammerregeln eingehalten werden, d.h. die Anzahl der öffnenden Klammern muß gleich der Anzahl der schließenden Klammern sein und zu keinem Zeitpunkt werden mehr Klammern geschlossen als geöffnet worden sind. Dazu gehen wir den Text linear durch und legen die öffnenden Klammern auf einen Stack. Wenn wir auf eine schließende Klammer treffen, entfernen wir eine Klammer von dem Stack. Der Text hält die Klammerregeln genau dann ein, wenn wir zu jeder schließenden Klammer stets ein Element im Stack finden und der Stack am Ende leer ist. 2.1.2 Queues (first-in-first-out : FIFO) Queues stellen Datenstrukturen dar, die Daten in Form einer Warteschlange verwalten. • Daten werden am Ende eingefügt (mit schreiben) und am Anfang ausgelesen (mit lesen) • Typisches Beispiel: Kunden vor Kasse • Um eine speichereffiziente Verwaltung der Daten zu ermöglichen, ist einen Realisierung durch ein “zirkuläres” Feld Q1 . . . Qn und Zeigern “Anfang” (head) und “Ende” (tail) sinnvoll. Mit diesen legt man die Stelle fest, an der ausgelesen, bzw. eingelesen werden darf. • Folgende Situationen können eintreten: Anfang = Ende Anfang < Ende Anfang > Ende =⇒ Queue leer oder voll =⇒ Felder der Queue: QAnfang , . . . , QEnde - 1 =⇒ Felder der Queue: QAnfang . . . Qn , Q1 , . . . , QEnde - 1 KAPITEL 2. EINFACHE DATENSTRUKTUREN 20 Q2 Q n−1 Q Q1 n Abbildung 2.1: Eine Queue als zirkuläre Liste lesen(Q, Anf ang, Ende, x) x := Q(Anf ang) Anf ang := (Anf ang + 1)mod n schreiben(Q, Anf ang, Ende, x) Q(Ende) := x Ende := (Ende + 1)mod n • Abfrage auf “leer” bzw. “Überlauf” • Lesen und Schreiben in Queues und benötigen ebenfalls nur konstante Zeit. Natürlich sind auch Kombinationen von Stacks und Queues denkbar. In den sogenannten Doppelschlangen sind Einfüge- und Löschoperationen sowohl am Ende als auch am Anfng zugelassen. 2.2 Speichern und Streichen von Daten an beliebiger Stelle Die Ablage von Daten in einem Array bringt den Vorteil mit sich, daß wir auf das ite Datum direkt über ihre Adresse i zugreifen können. Sobald wir aber zulassen, daß Daten nicht nur am Anfang oder Ende eingefügt bzw. entnommen werden dürfen, wird der Aufwand für das Einfügen/Löschen deutlich größer, da die verbleibenden Daten verschoben werden müssen. 2.2.1 Einfach-verkettete Listen Statt die Daten so in einem zusammenhängenden Speicherbereich abzulegen, daß man den Speicherplatz des i-ten Datums durch eine Adreßberechnung leicht bestimmen kann, speichert man hier ein Datum mit einem Verweis auf das jeweils nächste Datum ab. Die Daten können also beliebig über den Speicher verstreut sein; insbesondere ist es nicht mehr erforderlich, vorab einen Bereich hinreichender Größe zur Aufnahme aller Listenelemente zu reservieren. Der belegte Speicherplatz paßt sich vielmehr dynamisch der jeweiligen aktuellen Größe der Liste an. 21 KAPITEL 2. EINFACHE DATENSTRUKTUREN • Realisierung: als Folge von Knoten (Listenelementen) Jeder Knoten enthält eine Datum-Komponente (zur Aufnahme des Datums) und einen Zeiger auf den jeweils nächsten Knoten. • Kennzeichnung des Listenendes durch Definition des letzten Zeigers als NIL • Kennzeichnung des Listenanfangs durch einen Zeiger auf den ersten Knoten (z.B. “Kopf” oder “Anfang”) • Eine einfach verkettete Liste ist gegeben durch den Zeiger auf den ersten Knoten. • Eine Liste, die durch einen Zeiger “Anfang” gegeben ist, nennt man leer, wenn Anfang = NIL. Kopf DatumNext 1 1 Datum Next 2 2 Datum Next n n Abbildung 2.2: Einfach-verkettete Liste 2.2.2 Doppelt-verkettete Listen • Im Vergleich zu einfach-verketteten Listen hat man hier in jedem Knoten noch einen weiteren Zeiger, der auf den jeweils vorhergehenden Knoten weist (zum “Rückwärtshangeln”). Dadurch wird das Einfügen und Entfernen von Listenelementen vereinfacht. • Listenanfang, bzw. Listenende werden wiederum durch Zeiger Anfang, bzw. Zeiger Ende markiert. • Eine doppelt-verkettete Liste ist gegeben durch den Anfangs- und/oder Endezeiger. 2.2.3 Operationen auf Listen • Test auf leere Liste • Aufbau einer Liste der Länge n : Füge n Listenelemente in die leere Liste ein. • Entfernen von Listenelementen: Gegeben ist ein Zeiger auf das k-te Listenelement. Lösche Element k + 1 , indem der Zeiger des k-ten Listenelements auf das k + 2-te Listenelement verbogen wird. • Einfügen von Elementen: Gegeben ist wieder ein Zeiger auf das k-te Listenelement und ein weiterer Zeiger auf das einzufügende Listenelement. Füge es zwischen dem k und k + 1-ten Listenelement ein, indem der Zeiger des einzufügenden Elements auf das k + 1-te Element und danach der Zeiger des k-ten Element auf das einzufügende Element verbogen wird. KAPITEL 2. EINFACHE DATENSTRUKTUREN 22 • Durchsuchen einer einfach-verketteten Liste: Gesucht wird ein Datum x in der durch den Kopf-Zeiger gegebenen Liste. Gehe die Liste durch, bis ein Knoten mit Datum-Komponente x gefunden wurde und liefere einen Zeiger auf diesen Knoten zurück. 2.2.4 Eigenschaften verketteter Listen Die Laufzeiten der oben beschriebenen Operationen mit Listen sind: • Test auf “leer”: O(1) • Aufbau einer Liste der Länge n : O(n) • Einfügen und Löschen eines Elements mit Zeiger: O(1) • Durchsuchen einer einfach-verketteten Liste: O(n) im worst-case Bemerkung 2.3. Was passiert, wenn wir ein Element direkt vor dem k-ten Element einfügen wollen? Verfahre wie oben und vertausche die Inhalt von Element k und dem neuen Element. Wie sieht es aus, wenn wir das k-te Element löschen wollen? 2.2.5 Anwendungsbeispiel für einfach-verkettete Listen Gegeben sei ein azyklischer, gerichteter Graph (Netzplan) mit n Knoten und m Kanten. 1 2 3 4 Abbildung 2.3: Beispiel eines gerichteten Graphen Wir stellen die Kanten durch Nachfolgerlisten, in denen zu jedem Knoten sie Liste seiner Nachfolger abgelegt ist. 2.3 Bäume (geordnete Wurzelbäume) Bäume lassen sich rekursiv wie folgt definieren. Ein Baum besteht aus einer endlichen Menge von Knoten, so daß gilt: • Es existiert ein ausgezeichneter Knoten r (Wurzel, root) • Die restlichen Knoten sind partitioniert in Mengen T 1 , . . . , Tk , die selbst wieder Bäume sind (Unterbäume) • r zeigt auf die Wurzeln r1 , . . . , rk der Unterbäume KAPITEL 2. EINFACHE DATENSTRUKTUREN 1 2 2 4 3 2 4 NIL 23 3 4 Abbildung 2.4: Darstellung über des gerichteten Graphen über Nachfolgerlisten Bemerkung 2.4. In der Informatik werden Bäume stets so dargestellt, daß die Wurzel oben und die Blätter unten liegen. Wurzel Blatt Abbildung 2.5: Veranschaulichung eines Baumes Definition 2.5. Sei T ein Baum mit Wurzel r und Unterbäumen T1 , . . . , Tk . Ferner sei ri = W urzel(Ti ), i = 1, k a) b) c) d) e) f) g) h) ri ist i-ter Sohn von r, r ist Vater der r 1 , . . . rk , rj ist Bruder von ri , Entsprechend: Nachfolger, Vorgänger, Ein Knoten ohne Nachfolger heißt Blatt, Knoten von T , die weder Wurzel noch Blatt sind, heißen innere Knoten, Tiefe(v, T ) = Länge des Weges (in # Kanten) von Knoten v zur Wurzel r, Höhe (v, T ) = Länge des längsten Weges von v zu einem Blatt, Höhe (T ) = Höhe (Wurzel, T ). 2.3.1 Mögliche Realisierung von Bäumen (a) Verkettete Listen mit variabler Zeigerzahl Nachteil: variable Zeigeranzahl (b) Verkettete Listen mit 3 Datentypen Wir benutzen eine verkettete Liste aus Elementen, die aus drei Datentypen besteht. Ist Indikator = 0, so enthält Datum die zu speichernde Größe, ist Indikator = 1, so enthält Datum einen Zeiger auf eine Liste mit Unterbaum. 24 KAPITEL 2. EINFACHE DATENSTRUKTUREN Daten Anzahl der Söhne Pointer zu Sohn Abbildung 2.6: Baum als verkette Liste mit variabler Zeigerzahl Datum Indikator Zeiger Abbildung 2.7: verkettete Liste mit 3 Datentypen 1 2 5 3 6 7 0 1 0 4 1 1 2 0 0 3 0 4 7 1 0 5 0 6 Abbildung 2.8: Darstellung als verkettete Liste mit 3 Einträgen 25 KAPITEL 2. EINFACHE DATENSTRUKTUREN Wir werden jedoch überwiegend Bäume mit spezieller Struktur betrachten (etwa indem wir die Anzahl der Söhne beschränken), für die die Speicherung kanonisch sein wird. Definition 2.6. Ein Baum T heißt binär, falls jeder Knoten höchstens 2 Söhne hat. Binäre Bäume können z.B. durch 2 Felder Lef tsoni und Rightsoni dargestellt werden. Dazu ggf. Informationen über: Inhalt der Knoten, Väter, # Nachfolger in zusätzlichen Feldern, z.B. 1 2 3 4 5 R 4 5 - L 2 3 - Lemma 2.7. Ein binärer Baum der Höhe h hat höchstens 2h+1 − 1 Knoten und 2h Blätter. Beweis: Induktion über h. Die Aussage ist richtig für h = 0. Sei nun T ein Baum der Höhe h. Dann hat die Wurzel höchstens zwei Söhne r1 und r2 , die Wurzeln von Unterbäumen T1 und T2 sind. T1 und T2 haben eine Höhe von höchstens h − 1. Per Induktion haben dann T1 und T2 jeweils höchstens 2h − 1 Knoten und 2h−1 Blätter. Damit hat T höchstens 2 ∗ (2h − 1) + 1 = 2h+1 − 1 Knoten und 2h Blätter. 2 Definition 2.8. Ein binärer Baum T der Höhe h heißt voll, wenn er 2h+1 − 1 Knoten hat. Wir werden gelegentlich die Knoten eines vollen binären Baums sequentiell darstellen, indem wir beginnend mit der Wurzel alle Knoten auf einem Tiefenniveau von links nach rechts durchnumerieren. 1 2 4 3 5 6 7 Abbildung 2.9: Ein voller Baum mit 7 Knoten Definition 2.9. Ein binärer Baum T mit n Knoten heißt vollst ändig, wenn seine Knoten den ersten n Knoten eines sequentiell dargestellten vollen binären Baums entsprechen. 26 KAPITEL 2. EINFACHE DATENSTRUKTUREN 1 2 3 4 5 6 Abbildung 2.10: Ein vollständiger Baum mit 6 Knoten Lemma 2.10. Sei T ein vollständiger und binärer Baum mit |T | = n. Dann gilt: Höhe (T ) = dlog(n + 1)e − 1. Beweis: Sei h die Höhe von T . Die Anzahl n der Knoten von T liegt zwischen der Knotenzahl der vollen Bäume mit den Höhen h − 1 und h. Somit: 2h − 1 < n ≤ 2h+1 − 1 ⇒ h < log(n + 1) ≤ h + 1. Da h ∈ N, folgt h < dlog(n + 1)e ≤ h + 1 und somit h ≤ dlog(n + 1)e − 1 ≤ h. 2 Vollständige binäre Bäume haben eine kompakte sequentielle Darstellung der Relationen Vater, linker Sohn und rechter Sohn (ohne Zeiger): 2.3.2 Vater (i) = bi/2c : für i > 1 − : für i = 1 (Wurzel) Leftson (i) = 2i : für 2i ≤ n − : für 2i > n Rightson (i) = 2i + 1 : für 2i + 1 ≤ n − : für 2i + 1 > n Durchmusterung von Bäumen Oft müssen die Knoten eines Baumes systematisch durchsucht werden, etwa um die in den Knoten enthaltenen Einträge auszugeben, sie aufzusummieren oder sie zu mitteln. Das bedeutet, daß wir die Knoten in einer bestimmten Reihenfolge durchlaufen wollen. Die drei gebräuchlichsten Reihenfolgen, in denen sämtliche Knoten eines Baumes durchlaufen werden, sind die Pr äordnung, die Postordnung und die symmetrische Durchmusterung, wobei die leetztere nur für Binärbäume definiert ist. Diese Reihenfolgen lassen sich über rekursive (d.h. sich selbst aufrufende) Algorithmen definieren: Sei T ein Baum mit Wurzel r und Söhnen r1 , . . . , rk . 2.3.2.1 Präordnung (1) Durchmustere die Wurzel (2) Durchmustere in Präordnung nacheinander T1 , . . . , Tk 27 KAPITEL 2. EINFACHE DATENSTRUKTUREN 2.3.2.2 Postordnung (1) Durchmustere in Postordnung nacheinander die T 1 , . . . , Tk (2) Durchmustere die Wurzel 2.3.2.3 Symmetrische Durchmusterung von bin ären Bäumen (1) Durchmustere den linken Teilbaum der Wurzel (falls ex.) (2) Durchmustere die Wurzel (3) Durchmustere den rechten Teilbaum (falls ex.) jeweils in symmetrischer Ordnung. Nach dem Durchsuchen identifizieren wir die Knoten mit ihrer Numerierung. 1 8 2 6 5 3 4 7 Präordnung 4 3 7 3 2 8 5 1 4 1 6 5 Postordnung 8 2 6 7 symmetr. Ordnung Abbildung 2.11: Ergebnisse der Durchmusterungen eines Baumes 2.3.3 Eigenschaften der Durchmusterungen Formal korrekt müssten wir zwischen einem Knoten v und seiner Nummer N um(v) unterscheiden. Da wir aber Knoten mit ihrer durch die Durchmusterung vergebenen Numerierung identifizieren, stehen im folgenden (etwas verwirrend, aber kurz und daher gebräuchlich) v oder r sowohl für Knoten als auch für natürliche Zahlen v, r ∈ N. Präordnung: r + |Tr | − 1. Sei r Wurzel des Teilbaums T r . Dann gilt v ∈ Tr ⇔ r ≤ v ≤ Folgerung 2.11. Ist |Tr | bekannt, so kann in O(1) Schritten entschieden werden, ob v Nachfolger von r ist. Postordnung: Sei r Wurzel des Teilbaums T r . Dann gilt v ∈ Tr ⇔ r− | Tr | +1 ≤ v ≤ r. Damit gilt die Folgerung 2.11 für die Postordnung analog. KAPITEL 2. EINFACHE DATENSTRUKTUREN 28 Symmetrische Ordnung: Die Knoten im linken Teilbaum von r tragen kleinere Nummern als r, die im rechten größere. Folgerung 2.12. Wir können u.a. in O(Höhe(T)) entscheiden, ob ein Knoten mit der Nummer p vorkommt. (Vgl. Suchbäume) Zur Veranschaulichung rekursiver Algorithmen und ihrer nichtrekursiven Varianten formulieren wir einen Algorithmus zur symmetrischen Durchmusterung in der Pseudo-Programmiersprache. 2.3.3.1 Algorithmus zur symmetrische Durchmusterung (1) begin { Hauptprogramm } (2) count := 1 (3) SymmOrd(Wurzel) (4) end (5) procedure SymmOrd(v) (6) if Leftson (v) 6= ∅ then SymmOrd(Leftson(v)) (7) Number (v) := count (8) count := count + 1 (9) if Rightson (v) 6= ∅ then SymmOrd(Rightson(v)) (10) end Rekursive Algorithmen werden üblicherweise mit Hilfe von Stacks realisiert. Jedem Aufruf wird ein Block des Kellers zugewiesen, der die Informationen enthält: 1) Werte der aktuellen Parameter 2) Rücksprungadresse 3) lokale Variablen 2.3.3.2 Nichtrekursive Version der symmetrischen Durchmusterung (1) begin (2) count := 1 (3) v := root (4) top := 0 (5) left: while leftson (v) 6= ∅ then (6) push (Stack, top, v) (7) v := leftson (v) (8) endwhile (9) Center: Number (v) := count (10) count := count + 1 (11) if rightson (v) = 6 ∅ then (12) v := rightson (v) KAPITEL 2. EINFACHE DATENSTRUKTUREN (13) (14) (17) (16) (17) (18) (19) end 2.3.4 29 goto left endif if top 6= 0 then Pop (Stack, top, v) goto center endif Diskurs: Prozeduren und Rekursionen Das obige Besipiel illustriert, daß rekursive Formulierungen von Algorithmen häufig einfacher zu verstehen sind. Hinzu kommt, daß auch Aussagen über Eigenschaften wie Korrektheit und Laufzeitverhalten bei einer rekursiven Formulierung leichter zu zeigen sind als bei nichtrekursiven Ansätzen. Am obigen Beispiel haben wir auch gesehen, wie wir Prozeduren und rekursive Aufrufe mit Hilfe von Stacks in ein sequentielles Programm überführen können. Da zur Verwaltung eines Prozeduraufrufs im wesentlichen nur die Parameter und die lokalen Variablen im Stack abgelegt werden müssen, können wir annehmen, daß der Verwaltungsaufwand nicht größer ist als der Aufwand für die Ausführung der Prozedur selbst (da die Parameter und lokalen Variablen mindestens einmal benutzt werden müssten). Formal werden wir den Aufwand für die Ausführung der Rekursion und ihrer Verwaltung dem Prozeduraufruf anschreiben. Im weiteren Verlauf der Vorlesung werden wir sehen, wie wir mit diesem Ansatz die Laufzeiten rekursiver Programme abschätzen. 2.3.5 Eine Anwendung von binären Bäumen: Stichwortsuche Gegeben ein Lexikon mit n Stichworten. Frage: Ist ein gegebener Begriff im Lexikon enthalten? Zur Beantwortung sind zwei verschiedene Ansätze denkbar: 1. Man könnte eine verkettete Liste benutzen, in der die Stichwörter alphabetisch sortiert sind. ⇒ jede Abfrage O(n) im worst-case. 2. Verwende binären Baum, der als Suchbaum organisiert ist: ein Stichwort, das in einem Knoten v abgespeichert ist, ist alphabetisch größer als alle Wörter im linken Unterbaum alphabetisch kleiner als alle Wörter im rechten Unterbaum Vorteil: Für den Fall, daß das gesuchte Stichwort nicht im Wurzelknoten steht, weiß man bei Suchbäumen immer, in welchem Teilbaum man weitersuchen muß und erspart sich damit jeweils das Durchsuchen eines gesamten Teilbaums. Im Extremfall erhält man hier wieder eine lineare Liste. Extremfall: lineare Liste mit Suchzeit O(n) Dies kann verhindert werden durch die Verwendung von sogenannten balancierten Bäumen. Ohne an dieser Stelle auf eine formale Definition einzugehen, werden wir versuchen zu erreichen, daß in jedem Knoten der linke und rechte Teilbaum etwa gleiche Größe hat. Dies führt zu einer Suchzeit von O(log n). 30 KAPITEL 2. EINFACHE DATENSTRUKTUREN a b c d j Abbildung 2.12: Suchbaum als lineare Liste g d b a i f c h j e Abbildung 2.13: balancierter Suchbaum KAPITEL 2. EINFACHE DATENSTRUKTUREN 2.4 31 Heaps Es seien Daten gegeben, auf denen eine lineare Ordnung gegeben ist (Wörter eines Alphabets, natürliche Zahlen usw.). Wir suchen eine Datenstruktur, die es erlaubt, effizient neue Daten einzufügen und das jeweilige Maximum zu bestimmen. Für das Sortierproblem, das im nächsten Kapitel behandelt werden wird, sollte auch das Löschen des jeweiligen Maximums effizient möglich sein. Implementierung als Stack oder Queue sortierte verkettete Liste Einfügen O(1) O(n) Maximum bestimmen O(n) O(1) Definition 2.13. Ein (Max-) Heap ist ein vollständiger binärer Baum, in dem für jeden Knoten gilt, daß der in ihm abgelegte Wert mindestens so groß ist, wie die in seinen Söhnen abgelegten Werte. Ein Min-Heap wird analog definiert. Wir benutzen eine sequentielle Darstellung als Feld: Daten(1), . . . , Daten(n). Dabei geht die Struktur des Baumes nicht verloren (kompakte sequentielle Darstellung der Relationen Vater, Söhne, s. Kapitel 2.3). 2.4.1 Einfügen eines neuen Datums in einen Heap Idee: • Füge einen neuen Knoten zum Heap hinzu, so daß wieder ein vollständiger binärer Baum entsteht (also Blatt einfügen) und speichere das neue Datum in diesem Knoten • Dieser Baum ist i.a. kein Heap mehr. Wende die Prozedur insert an, um wieder einen Heap zu erhalten: procedure insert (Daten,n) { n ist die Größe des binären Baums “Daten” nach Einfügen } j := n i := b n2 c item := Daten(n) while i > 0 und Daten(i) < item Daten(j) := Daten(i) { Sohn übernimmt Daten vom Vater } j := i { gehe zum Vater } i := b 2i c {betrachte dessen Vater } enddwhile { j ist die Stelle, an die das neue Datum paßt } Daten(j) := item end Lemma 2.14. (i) Das Einfügen eines Datums in einen Heap der Größe n benötigt O(log n) Schritte. KAPITEL 2. EINFACHE DATENSTRUKTUREN 32 (ii) Der Aufbau eines Heaps mit n Elementen benötigt O(n log n) Schritte (iii) Das Maximum in einem Heap kann in O(1) Schritten gefunden werden. Beweis: (i) Das neue Element wandert höchstens Höhe (T ) = dlog(n+1)e−1 = O(log n) Positionen nach oben mit O(1) Operationen pro Austausch. (ii) Wir verwenden die Prozedur Insert, um die Knoten nacheinander in einen anfänglich leeren Heap einzufügen. Da jede einzelne Einfüge-Operation höchstens O(log n) Schritte benötigt, folgt die Behauptung. (iii) klar, da das Maximum in der Wurzel abgelegt ist. 2 Bemerkung 2.15. Die Eingabe in der Reihenfolge 1, 2, 3, . . . , n zeigt, daß die Laufzeit dieses Verfahrens auch Θ(n log n) beträgt. Dieses Laufzeitverhalten läßt sich dadurch erklären, daß in einem vollständigen binären Baum fast alle Knoten “tief unten” im Baum liegen. So sind bereits die Hälfte aller Knoten Blätter: Lemma 2.16. Ein vollständiger binärer Baum mit n Knoten hat d n2 e Blätter. Beweis: Per Induktion: ein vollständiger binärer Baum besteht aus einer Wurzel, einigen Tiefenniveaus mit jeweils gerader Anzahl von Knoten und den restlichen Blättern. Ist somit n gerade, so hat er eine ungerade Anzahl von Blättern auf dem untersten Niveau. Damit gibt es genau einen inneren Knoten v, der nur einen linken und keinen rechten Knoten hat. Fügen wir einen neuen Knoten hinzu, so wird er rechter Sohn von v. Dann hat sich die Anzahl der Blätter erhöht und es ist e=d n2 e+1. Ist n ungerade, so wird ein neues linkes Blatt an einen Knoten auch d n+1 2 angefügt, der vorher selbst ein Blatt war und nun innerer Knoten wird. Damit bleibt e=d n e 2 die Anzahl der Bläter konstant und es gilt d n+1 2 2 2.4.2 Reparieren in einem Heap Gegeben sei ein vollständiger binärer Baum mit n Knoten {k1 , · · · , kn } in sequentieller Darstellung. Weiter sei ki ein Knoten mit der Eigenschaft, daß die Teilbäume unter seinen Söhnen bereits Heaps sind. Erzeuge daraus einen vollständigen binären Baum mit der Eigenschaft, daß der Teilbaum unter k i einen Heap bildet. Algorithmus: { versickere ki im Bereich ki bis kn } Solange ki einen linken Sohn kj hat, wiederhole: falls ki einen rechten Sohn hat, so sei kj der größere Sohn von ki ; falls ki < kj , so vertausche ki mit kj und setze i:=j, sonst halte an { Teilbaum ist schon Heap }. KAPITEL 2. EINFACHE DATENSTRUKTUREN 33 Lemma 2.17. Das Reparieren eines Heaps an der Stelle i benötigt O(Höhe(i)) Schritte. 2 2.4.3 Löschen des Maximums in einem Heap Idee: • Lösche den Wert des (Wurzel-) Knotens 1 (das Maximum) Knoten 1 ist jetzt überflüssig • Speichere den Inhalt des Knotens n im Knoten 1. • Lösche Knoten n. • Repariere den Baum an der Stelle 1. Lemma 2.18. Das Löschen des Maximums in einem Heap benötigt O(log n) Schritte. 2 2.4.4 Aufbau eines Heaps in linearer Zeit Das obige Verfahren des iterativen Einfügens in einen anfangs leeren Heap benötigt O(n log n) Schritte. Die Tatsache, daß die meisten Knoten tief unten im Baum liegen, machen wir uns bei der folgenden Vorgehensweise zu Nutze. Sie erlaubt den Aufbau eines Heaps in linearer Zeit. Idee: • Speichere in einem ersten Schritt die Daten in einem vollständigen binären Baum • Die Teilbäume unter den Blättern sind bereits Heaps. • Beginnend mit dem “rechtesten” inneren Knoten rückwärts bis zur Wurzel, repariere den Baum an der jeweiligen Stelle. Lemma 2.19. Der Aufbau eines Heaps mit n Elementen benötigt O(n) Schritte. Beweis: (i) Da wir den binären Baum rückwärts gehend reparieren, ist er am Schluß Heap-geordnet. (ii) Sei 2h − 1 < n ≤ 2h+1 − 1 mit h = H öhe(T ). Für k = 0, . . . , h ist die Anzahl der Knoten der Höhe k höchstens 2h−k . Repariert wird an allen inneren Knoten i mit Höhe(i) ≥ 1. Nach Lemma 2.17 beträgt die Laufzeit jeweils O(Höhe(i)). Somit beträgt die Gesamtlaufzeit höchstens (s. Lemma A.1): 34 KAPITEL 2. EINFACHE DATENSTRUKTUREN h X 2h−k · k = 2h · k=0 ≤ n· ≤ n h X k 2k k=0 h X k=0 h X k 2k ( 34 )k k=0 ≤ n ∞ X ( 34 )k k=0 1 (1 − 34 ) = O(n). = n· 2 Somit hat man mit Heaps eine effiziente Datenstruktur gefunden, mit der im Vergleich zum Stack oder zur sortierten Liste die Laufzeiten für die Prozeduren “Einfügen”, “Maximum bestimmen” kleiner sind und für die Prozedur “Aufbau” gleich bleiben.