2. Suchen und Sortieren
2.4 Heapsort
Heapsort
Der zweite effiziente Algorithmus, den wir kennenlernen, ist Heapsort. Der für diese Vorlesung vielleicht wichtigste Grund, warum wir ihn drannehmen ist, dass er eine schlaue Datenstruktur verwendet, die wir später im Kontekt von Graphenalgorithmen wieder brauchen werden: den Heap.
Ein Binärbaum ist ein Baum mit Wurzel, in dem jeder Knoten bis zu zwei Kinder hat.
Die Tiefe eines Knotens \(u\) ist der Abstand von der Wurzel zu \(u\). Die Wurzel selbst hat also Tiefe 0. Die Höhe des Baums ist die Tiefe des tiefsten Knoten. Sei \(h\) von nun an die Höhe.
Ein Binärbaum ist fast vollständig, wenn (1) alle Knoten der Tiefe \(i \leq h-2\) genau zwei Kinder haben und (2) die Knoten der Tiefe \(h\) so weit links wie möglich liegen.
Übungsaufgabe Zeichnen Sie einen fast vollständigen Binärbaum mit \(n\) Knoten für \(n = 3, 6, 8, 11\).
Ein Heap ist ein fast vollständiger Binärbaum, in dem jeder Knoten \(v\) ein Label \(l_v\) aus einer total geordneten Menge \(L\) hat (beispielsweise Zahlen oder lexikographisch geordnete Strings), der folgende Eigenschaft besitzt:
Insbesondere bedeutet dies, dass \(l_{\rm root}) = \min(L)\), d.h., die Wurzel hat das global kleinste Label.Ein Heap \(H\) ist ein Heap für die Menge \(L\), wenn \(L\) genau die Menge der in \(H\) vorkommenden Labels ist.
Heaps sind gut, weil sie zwei wichtige Operationen effizient unterstützen:
Ein neues Element in einen Heap einfügen
Wir hängen das neue Element als Blatt an die erste verfügbare Position (soweit oben wie möglich, soweit links wie möglich). Dann lassen wir es nach oben "blubbern", bis es die richtige Position gefunden hat, also größer ist als das Element in seinem Eltern-Knoten.
Hier ist der Pseudo-code dafür:
# Pseudo-code für das Einfügen eines neuen Elementes in einen Heapinsert(newLabel, heap):
create new leaf v at lowest and left-most position
label(v) := newLabel
while v != root and label(u) < label(parent(v)):
swap label(v) and label(parent(v))
v := parent(v)
Die Wurzel extrahieren
Dazu nehmen wir das letzte Element des Heaps (ganz unten, ganz rechts), löschen den Knoten und schieben das Element in den Wurzelknoten. Um die Heap-Eigenschaft wieder herzustellen, lassen wir jetzt das Element von der Wurzel nach unten fallen, bis es seinen richtigen Platz gefunden hat. Das heißt, solange ein Kind ein kleineres Element hat, vertauschen wir es mit dem kleineren der beiden Kinder.
# Pseudo-code für das Extrahieren der Wurzel in einen HeapremoveRoot(heap):
result := label(root(heap))
swap label(root) and label(last element of heap)
delete last element of heap)
v := root
while v has a child w with label(v) > label(w):
swap label(v) with label(w) (or with label(w') in case v has another child w' with label(w') < label(w))
v := w
return result
Hier nun eine vollständige Darstellung von Heapsort an einem Beispiel:
Hier finden Sie eine Animation von Heapsort.
Zeigen Sie, dass so ein Heap weitere Operationen unterstützt,
nämlich
decreaseKey (node, newLabel)
.
In dieser Operation haben Sie einen Knoten gegeben (also einen
Pointer zu
einem Knoten im
Heap, oder
auch einfach einen Index, falls Sie Ihren Heap als Array
implementiert
haben), und wollen
das alte Label durch ein neues ersetzen (newLabel
).
Als
Anwendung stellen Sie
sich vor,
dass Ihre Objekte Produkte in einem Supermarkt sind, die nach
Preis
sortiert werden
sollen;
der Preis kann sich aber ändern.
Beschreiben Sie mit Pseudo-Code, wie Sie
decreaseKey (node, newLabel)
implementieren.
Die Hauptarbeit liegt natürlich darin, nach der Änderung die
Heap-Eigenschaft
wiederherzustellen.
Laufzeitanalyse
Sei \(n\) die Länge des zu sortierenden Arrays. Der Heap hat zu jedem Zeitpunkt höchstens \(n\) Knoten. Was ist also die maximale Höhe? Ein Binärbaum mit 7 Knoten hat drei Ebenen, also Höhe 2; mit 8 Knoten sind es schon vier Ebenen und somit Höhe 3. Wenn wir das verallgemeinern, so schließen wir: ein fast vollständiger Binärbaum mit \(n\) Knoten hat Höhe von
\begin{align*} d := \ceil{\log (n+1)} - 1 \end{align*}Bei jeder Einfügeoperation lassen wir das Element nach oben treiben; auf jeder Ebene kann es maximal eine Vergleichsoperation geben, und auf Ebene 0 (sollte das Element bis zur Wurzel hochgeschwommen sein) gibt es keine Vergleichsoperation mehr. Daher gibt es maximal \(d\) Vergleichsoperationen. Insgesamt braucht das Aufbauen des Heaps also maximal
\begin{align*} n d \end{align*}Vergleichsoperationen. Wie schaut es mit dem Abbau des Heaps aus? Wenn wir ein Element \(x\) in die Wurzel stecken und es dann nach unten sinken lassen, dann sinkt \(x\) von oben (Tiefe 0) bis maximal Tiefe \(d\). In jedem Schritt werden maximal zwei Vergleichsoperationen durchgeführt: zwischen den Kindern von \(x\) und zwischen dem "Sieger" und \(x\) selbst. Wenn \(x\) Tiefe \(d\) erreicht hat, werden keine weiteren Vergleiche durchgeführt, da es seinen richtigen Platz gefunden hat. Es werden also für Ebenen \(0, 1, \dots, d-1\) jeweils zwei Vergleichsoperationen gemacht, also insgesamt \(2d\). Die Gesamtzahl, addiert über alle Entfernungsoperationen, ist also maximal
\begin{align*} 2\, nd \end{align*}Wir folgern:
Theorem Sei \(d := \ceil{\log(n+1)}-1\). Heapsort auf einem Array der Länge \(n\) verursacht maximal \(3nd\) Vergleichsoperationen.
Implementierung von Heapsort
Vielleicht fürchten Sie, Heapsort sei schwierig zu implementieren, weil wir ja erst einmal eine Datenstruktur für Binärbäume brauchen, also wie in Elm
type BinaryTree a
= Empty
| Branch a BinaryTree BinaryTree
Das ist glücklicherweise nicht der Fall. Da unsere Heaps immer fast vollständige Binärbäume sind, gibt es eine besonders einfache Repräsentierung. Tun wir so, als hätten wir von Anfang an einen fast vollständigen Binärbaum mit \(n\) Knoten, von denen aber noch nicht alle Knoten gefüllt sind (manche stecken noch im Array):
Als nächstes numerieren wir die Zellen des Arrays und die Knoten des Binärbaumes durch, von 0 bis \(n-1\):
Jetzt können wir einfach den Heap (die blauen Zahlen) im Array in den jeweiligen Zellen abspeichern:
Um jetzt aber den Algorithmus laufen lassen zu können, brauchen wir eine einfache Formel, um von einem Knoten \(x\) (nun einfach eine Zelle im Array) zum Elternknoten \(y\) zu kommen. Hier ist sie:
\begin{align*} {\rm parent}(k) = \floor{\frac{k-1}{2}} \end{align*}Und gerade beim Abbau des Heaps müssen wir für einen Knoten \(k\) auch die Indizes der Kinder berechnen:
Ein Knoten mit Index \(k\) hat also einen Elternknoten mit Index \(\floor{k-1}{2}\) (außer \(k=0\), dann ist es die Wurzel); darüberhinaus hat \(k\) Kinder mit Index \(2k+1\) und \(2k+2\), sofern diese Indizes noch im Rahmen sind. So hat beispielsweise Knoten 3 im obigen Beispiel nur Kind 7 und eben nicht Kind 8, weil das ganze Array nur acht Elemente hat und daher nur Indizes \(0, \dots, 7\) überhaupt verwendet werden.
Übungsaufgabe
Schreiben Sie in Python eine Funktion
insert(value, heap, index)
.
Hier ist die Idee, dass heap
ein Array
ist und die ersten index
Elemente bereits ein
Heap sind; nach Einfügeoperation sollen also die
ersten index+1
Elemente aus array
den
neuen Heap darstellen.
Übungsaufgabe
Schreiben Sie, aufbauend auf insert
, eine Funktion
buildheap
, die aus dem Array einen Heap macht. Sie
soll
keinen zusätzlichen Speicher belegen, also kein neues Array
erschaffen!
Übungsaufgabe
Jetzt wird es schwieriger: schreiben Sie eine Funktion
extractRoot(heap, size)
; die Annahme ist hier
wieder,
dass heap
ein Array ist und
die ersten size
Elemente von heap
einen Heap
darstellen. Nach der Wurzelentfernung sollen die ersten
size-1
wiederum den korrekten Heap (nach Löschen) darstellen. Die
Funktion
soll den ursprünglichen Wert an der Wurzel zurückgeben.
Übungsaufgabe
Schreiben Sie jetzt eine Funktion heapSort
,
aufbauend auf dem,
was Sie bis jetzt implementiert haben. An keiner Stelle sollen
Sie ein
neues Array bauen!
Heapsort braucht also im schlimmsten Fall \(3nd\) Vergleichsoperationen, mit \(d = \ceil{\log(n+1)}-1\). Das sind ungefähr dreimal so viele wie Mergesort. Warum wird dennoch in gewissen Kontexten Heapsort verwendet? Vorteile von Heapsort sind:
- Es gibt keine Rekursion braucht; Sie brauchen also keine Umgebung mit Stack-Management.
- Es ist in place, braucht keinen zusätzlichen Speicherplatz (bzw. nur die wenigen lokalen Variablen, die im Code definiert sind).