Einfache Datenstrukturen

Werbung
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.
Herunterladen