\( \newcommand{\data}{\textnormal{data}} \newcommand{\lower}{\textnormal{lower}} \newcommand{\upper}{\textnormal{upper}} \newcommand{\array}{\textnormal{array}} \newcommand{\depth}{\textnormal{depth}} \newcommand{\height}{\textnormal{height}} \newcommand{\BST}{\textnormal{BST}} \newcommand{\floor}[1]{\left\lfloor #1 \right\rfloor} \newcommand{\ceil}[1]{\left\lceil #1 \right\rceil} \newcommand{\lognpone}{\ceil{\log_2(n+1)}} \newcommand{\bitsize}{\textnormal{bitSize}} \newcommand{\mult}{\textnormal{mult}} \newcommand{\inneighbors}{\textnormal{inNeighbors}} \newcommand{\outneighbors}{\textnormal{outNeighbors}} \newcommand{\neighbors}{\textnormal{neighbors}} \newcommand{\indeg}{\textnormal{indeg}} \newcommand{\outdeg}{\textnormal{outdeg}} \newcommand{\scc}{\textnormal{scc}} \newcommand{\R}{\mathbb{R}} \newcommand{\val}{\textnormal{val}} \newcommand{\dist}{\textnormal{dist}} \newcommand{\flows}{\textnormal{flows}} \)

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.

Definition

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.

Fast vollständiger Binärbaum der Tiefe 3.
Nicht fast vollständig. Das rote Blatt müsste zwei Kinder haben.
Nicht fast vollständig. Zwischen dem blauen und dem roten Blatt fehlt eins. Die Knoten der Tiefe 3 liegen nicht so weit links wie möglich.

Übungsaufgabe Zeichnen Sie einen fast vollständigen Binärbaum mit \(n\) Knoten für \(n = 3, 6, 8, 11\).

Definition

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:

Sei \(v\) ein Knoten (nicht die Wurzel) und \(u\) der Elternknoten von \(v\). Dann gilt \(l_u < l_u\).
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.

Übungsaufgabe Zeichnen Sie drei verschiedene Heaps für die gleiche Menge: {"of", "to", "in", "it", "is", "be"}.

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 Heap
insert(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 Heap
removeRoot(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.

Übungsaufgabe

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:

  1. Es gibt keine Rekursion braucht; Sie brauchen also keine Umgebung mit Stack-Management.
  2. Es ist in place, braucht keinen zusätzlichen Speicherplatz (bzw. nur die wenigen lokalen Variablen, die im Code definiert sind).