2. Suchen und Sortieren
2.3 Mergesort
Jetzt lernen wir den ersten "effizienten" Algorithmus kennen: Mergesort. Dieser Algorithmus teilt array in zwei gleich große Hälften array1 und array2, sortiert diese (rekursiv, durch einen Aufruf von Mergesort selbst), und verschmilzt die beiden sortieren Teil-Arrays mit Hilfe einer Subroutine merge zu einer großen sortieren Gesamtliste.Hier wieder die pdf-Datei mergeSort.pdf mit der ganzen Animation und hier meine Python-Implementierung:
def merge (array1, array2):
n1 = len(array1)
n2 = len(array2)
n = n1 + n2
result = [0] * n
p = 0 # pointer to where to insert the next element into result
p1 = 0 # pointer to where to read the next element of array1
p2 = 0 # pointer to where to read the next element of array2
while (p < n):
if (p2 == n2 or (p1 < n1 and array1[p1] < array2[p2])):
result[p] = array1[p1]
p1 = p1+1
else:
result[p] = array2[p2]
p2 = p2+1
p = p+1
return result
def mergesort(array):
if (len(array) < 2):
return array
middle = len(array) // 2
array1 = array[0:middle]
array2 = array[middle:]
array1 = mergesort(array1)
array2 = mergesort(array2)
return merge(array1, array2)
Bevor wir die Laufzeit analysiere, sollten wir den Algorithmus experimentell testen. Hierzu brauchen wir wieder folgende Dateien:
- Kopieren Sie den obigen Code in eine Datei
mergeSort.py
. In den gleichen Ordner wie diese kopieren Sie bitte - timeMyPrograms.py und
- englishVocabulary.py, falls diese noch nicht in dem Ordner sein sollten.
python3 -i mergeSort.py
measure_alg (selectionSort, wordlist_unsorted)
210.4567011850013 # Laufzeit in Sekundenmeasure_alg (insertionSort, wordlist_unsorted)
343.31445141899894 # überraschend, dass es länger dauertmeasure_alg (mergesort, wordlist_unsorted)
0.3041463459994702
Der Unterschied zwischen den langsamen Algorithmen SelectionSort und InsertionSort und dem schnellen Mergesort ist gigantisch: Mergesort ist über tausendmal schneller als Insertion-Sort und knapp 700 mal schneller als SelectionSort. Dabei gibt es mehrere Dinge, die oberflächlich gegen Mergesort sprechen:
- Mergesort ist rekursiv. Es wird also Overhead für das Stack-Management geben.
- Mergesort ist nicht in-place: wir vergeben in Zeile 5 von Mergesort neuen Speicherplatz, während Insertion-Sort und Selection-Sort rein auf dem gegebenen Platz von array arbeiten.
Was Sie hier sehen, ist nicht clevere Implementierung. Was Sie sehen, ist ein überlegener Algorithmus.
merge
)
führt
merge(array1, array2)
im besten und im schlechtesten
Falle durch, in Abhängigkeit von
den Längen von array1
und array2
?
Können Sie zu jeden gegebenen Größen \(n_1\) und \(n_2\) Arrays dieser Größe konstruieren, für die Ihre obere Schranke erreicht wird?
mergesort
;
schreiben Sie zu jedem Knoten in diesem Baum, wieviele
Vergleiche der dortige Aufruf von merge
durchführt.
mergesort
auf einem
Array der Größe \(n\) maximal durchführt?
Seien Sie so präzise, wie Sie können! Geben Sie möglichst
genau an, wieviele Vergleiche mergesort
im Worst-Case durchführt.
wordlist_unsorted
.
Verwenden Sie timeMyPrograms.py.
Korrektheit und Laufzeit. Wenn wir einen neuen Algorithmus einführen, dann müssen wir in der Regel zwei Dinge untersuchen:
- Ist der Algorithmus korrekt?
- Welche Laufzeitkomplexität hat er?
In der Tat wird die erste Frage, die nach der Korrektheit, in den ersten Wochen dieses Kurses leicht zu beantworten sein. Für alle Algorithmen bisher war die Korrektheit ziemlich offensichtlich. Dies wird sich in späteren Kapiteln ändern.
Suboptimale Laufzeitanalyse
Lemma (suboptimal) Seien \(A\) ein Array aus \(n\) Elementen und \(A_1, A_2\) die beiden Teilarrays der Größen \(\floor{n/2}\) bzw. \(\ceil{n/2}\). \(\texttt{merge}(A_1, A_2)\) tätigt höchstens \(n\) Vergleichsoperationen, wobei \(n = |A_1| + |A_2|\) die Größe des Ergebnisarrays ist.
Wie können wir von hier aus die maximale Anzahl von Vergleichsoperationen in einem
Aufruf von mergeSort(array)
bestimmen?
Da MergeSort ein rekursiver Algorithmus ist, bietet es sich immer an, seinen Rekursionsbaum
zu skizzieren. Für ein Array der Größe 14 sähe der zum Beispiel so aus:
Jedes graue Rechteck stellt einen Aufruf von mergesort
dar, und die
Zahl darin ist die Größe des Arrays bei diesem Aufruf.
Wie wir in Lemma 1.2 gesehen haben, ist die Zahl in dem grauen Rechteck auch eine obere
Schranke für die Anzahl der Vergleiche, die merge
macht, um die zwei Teilarrays
wieder zu einem großen zusammenzufügen. Daher:
Beobachtung Die Anzahl der Vergleiche, die Mergesort tätigt, ist höchstens die Summe der Zahlen in den grauen Rechteckten im Rekursionsbaum.
Gibt es eine einfache Formel für diese Summe? In der Tat:
Sehen Sie: jede "Ebene" des Baumes bis auf die letzte ergibt in Summe 14, also im Allgemeinen \(n\), die Größe des Eingabearrays. Um uns das Leben einfacher zu machen, runden wir großzügig auf und sagen: jede Ebene summiert sich zu höchsetns \(n\) auf. Daher gilt: die Anzahl der Vergleiche ist \(n\) mal die Anzahl der Ebenen im Baum. Der Rekursionsbaum hat genau
\begin{align*} \ceil {1 + \log n } \end{align*}Ebenen. Daher schließen wir nun:
Theorem Die Anzahl der Vergleiche, die ein Aufruf
von mergeSort
auf einem Array der Größe \(n\) verursacht, ist höchstens
\(n \ceil{1 + \log n}\).
Optimale Analyse
Wir beginnen mit einer verbesserten Version von Lemma 1.2:Lemma Seien \(A\) ein Array aus \(n\) Elementen und \(A_1, A_2\) die beiden Teilarrays der Größen \(\floor{n/2}\) bzw. \(\ceil{n/2}\). \(\texttt{merge}(A_1, A_2)\) tätigt höchstens \(n-1\) Vergleichsoperationen, wobei \(n = |A_1| + |A_2|\) die Größe des Ergebnisarrays ist.
Beweis. Seien \(n_1, n_2\) die Größen der beiden Teilarrays. Wenn wir im Python-Code die beiden Pointer \(p_1, p_2\) anschauen, die auf die beiden Teilarrays deuten, dann ist \(p_1 + p_2\) anfangs 0 und wächst mit jeder Vergleichsoperation um 1. Eine Vergleichsoperation wird nur ausgeführt, wenn \(p_2 \leq n_2-1\) und \(p_1 \leq n_1-1\) gilt (siehe Zeile 11). Also nur, falls \(p_1 + p_2 \leq n-2\) gilt. Der letzte Wert von \(p_1 + p_2\) ist also höchstens \(n-1\), und somit werden auch höchstens \(n-1\) Vergleiche getätigt. \(\square\)
Mit diesem frischen Wissen betrachten wir noch einmal den Rekursionsbaum:
Wie zuvor ist die schwarze Zahl im Rechteck die Größe des Teilarrays; die rote daneben ist eins
weniger und
stellt eine obere Schranke für die Vergleichsoperationen dar, die merge
an diesem
Knoten ausführt. Im weiteren sei \(d\) die Höhe des Baumes, also der größte Abstand von einem
Blatt zur Wurzel; in anderen Worten: die Anzahl der Ebenen minus 1. Wir numerieren die
Ebenen mit 0 beginnend, also \(0, 1, 2, \dots, d\). Sammeln wir ein paar Fakten:
- \(d = \ceil{\log n }\).
- Ebene \(d\), also die unterste, hat nur rote Nullen, erzeugt also keine
Vergleichsoperationen. Das
sehen wir auch im Pseudocode, weil dann nämlich die Bedingung
len(array) < 2
erfüllt ist und Zeile 22 ausgeführt wird. - Die Ebenen \(0, 1, 2, \dots, d-1\) haben \(1, 2, 4, \dots, 2^{d-1}\) Knoten. Ganz allgemein: Ebene \(i\) hat \(2^i\) Knoten, solange \(0 \leq i \leq d-1\) ist.
Wir können von nun an Ebene \(d\) ignorieren. Die Sume der schwarzen Zahlen in den Ebenen 0 bis \(d-1\) ist \(nd\). Die der roten Zahlen ist \(nd\) minus die Anzahl der Knoten in den Ebenen \(0\) bis \(d-1\).
\begin{align*} \textnormal{Anzahl der Knoten in den Ebenen } 0, \dots, d-1 = 1 + 2 + 4 + \cdots + 2^{d-1} = 2^d - 1\ . \end{align*} Und somit ist die Gesamtzahl der Vergleichsoperationen maximal \(nd - 2^d + 1\).A
ein Array aus \(n\) Elementen und sei
\(d = \ceil{\log n}\). Dann tätigt mergesort(A)
maximal
$$
nd - 2^d + 1
$$
Vergleichsoperationen.
Übungsaufgabe
Zeigen Sie, dass Lemma 1.5 optimal ist. Das heißt, zeigen Sie, dass Sie für alle Zahln
\(n_1, n_2\) Arrays
Array der Längen \(n_1\) bzw \(n_2\) gibt, auf denen merge
genau \(n_1 + n_2 -
1\)
Vergleichsoperationen ausführt.
Die Schranke von \(n-1\) Vergleichsoperationen für merge
ist eine obere Schranke.
Für manche Arrays kann es schneller gehen. Um nun zu zeigen, dass auch unsere Analyse von
mergeSort
optimal ist, müssen wir ein Array der Länge \(n\) konstruieren,
so dass jeder Aufruf von merge
im Rekursionsbaum die maximal mögliche Anzahl
von Vergleichsoperationen nach sich zieht. Jedes Teilarray muss also auch wieder
"so schlimm wie möglich" aussehen.
Übungsaufgabe
Konstruieren Sie Arrays der Länge \(n\), auf denen mergeSort
die obere
Schranke von Theorem 1.6 erreicht. Beschränken Sie sich der Einfachheit halber
auf \(n = 2^d\), also Zweierpotenzen.