2. Suchen und Sortieren
2.5 Quicksort
Quicksort ist vielleicht der bekannteste Sortieralgorithmus überhaupt. oberflächlich erscheint er dem Mergesort recht ähnlich zu sein; in Wirklichkeit aber gibt es zwei große Unterschiede. Erstens ist Quicksort typischerweise randomisiert, d.h., es verwendet Zufall. Zweitens ist die Laufzeitanalyse deutlich trickreicher als bei den vorherigen Algorithmen. Quicksort ist in der Praxis häufig der schnellste Sortieralgorithmus (obwohl dies auch von den genauen Umständen und der Anwendung abhängt).
Die Idee von Quicksort ist einfach. Wir nehmen ein zufälliges Element unseres
Arrays
array
und nennen
es pivot
. Dann teilen wir das Array in zwei Teile auf, nämlich
smaller
,
bestehend aus allen Elementen, die kleiner sind als Pivot, und
larger
(alle
größeren); und ein drittes Array equal
aus denjenigen Elementen,
die gleich groß
sind wie
pivot
. Wir sortieren rekursiv smaller
und
larger
und
fügen in einem
letzten Schritt alles wieder zusammen: smaller + equal + larger
.
Die
Implementierung in Python
ist sehr einfach:
def quicksort(array):
n = len(array)
if n < 2 :
return array
pivot = array[0]
smaller = [x for x in array if x < pivot]
equal = [x for x in array if x == pivot] # in der Praxis kann dies mehr als ein Element sein!
larger = [x for x in array if x > pivot]
return quicksort(smaller) + equal + quicksort(larger)
Um die Laufzeit von Quicksort zu verstehen, ist es essentiell, dass wir uns die Rekursionsbäume vor Augen halten. Ich zeige Ihnen nun eine Animation, die die rekursiven Aufrufe von Quicksort visualisiert:
Laufzeitanalyse
Betrachten wir noch einmal in Ruhe das Endergebnis, also den Rekursionsbaum von Quicksrt:
Beobachtung 2.5.1 Jede Vergleichsoperation in Quicksort findet statt zwischen dem rot markierten Pivot-Element und den anderen Elementen in diesem Knoten.
Übungsaufgabe 2.5.1
Zeichnen Sie den Rekursionsbaum für Quicksort auf dem Array
[4,2,6,1,5,7,3]
und zählen die Anzahl der ausgeführten Vergleichsoperationen.
Wiederholen Sie die Übung für [1,2,3,4,5,6,7]
Übungsaufgabe 2.5.2
Wie viele Vergleichsoperationen führt Quicksort auf dem Array
Wie sie in der letzten Übung gesehen haben, kann Quicksort im schlimmsten Fall tatsächlich sehr viele Vergleichsoperationen durchführen. Paradoxerweise tritt dies dann auf, wenn das Array bereits sortiert ist!
Beobachtung 2.5.2 Im Worst-Case macht Quicksort
Laufzeitanalyse
Um Quicksort zu einem guten Algorithmus zu machen, müssen wir es schaffen, den Worst-Case des vollständig oder auch nur teilweise sortieren Arrays zu vermeiden. Am einfachsten geht das, indem man am Anfang das Array shuffelt, also mischt. Doch dazu später mehr. Die Laufzeitanalyse von Quicksort ist nicht offensichtlich und erfordert einige Tricks und Techniken, unter Anderem aus der Wahrscheinlichkeitsrechnung.
Der Schlüssel liegt darin, die richtige Frage zu stellen. In den unteren Rekursionsbaum wäre das zum Beispiel: Warum werden be und of nicht verglichen?
In der Tat: warum werden be und of nicht verglichen? Weil sie
durch in getrennt werden! Was heißt hier "getrennt" werden? Sei
In Worten: in kommt im Array vor be und of, im Alphabet aber dazwischen. Und spätestens nachdem in Quicksort das Element in als Pivot drankommt, sind be und of getrennt und werden nicht mehr verglichen. Formalisieren wir das ganze nun.
Definition 2.5.3 Für Elemente
Beispielsweise gilt für obiges Array
Im folgenden wird es sich lohnen, den obigen Baum etwas zu verschlanken und für jeden Knoten nur das erste Element zu behalten.
Lemma 2.5.4 Seien
- Falls
, also als allererstes kommt, dann vergleicht Quicksort mit , und zwar zu dem Zeitpunkt, wo als Pivot ausgewählt wird; im Quicksortbaum ist ein Vorfahre von . Beispiel: . - Falls
, also als allererstes kommt, dann vergleicht Quicksort mit , und zwar zu dem Zeitpunkt, wo als Pivot ausgewählt wird; im Quicksortbaum ist ein Vorfahre von . Beispiel: . -
Falls
weder noch ist, also ein ganz anderes Element die kleinste Position aller Elemente in hat, dann werden und nicht verglichen; im Quicksort-Baum sind und Nachfahren von , sie werden also zu dem Zeitpunkt in verschiedene Teilarrays gesteckt, wo als Pivot ausgewählt wird.
Wir führen nun ein Symbol ein, das im Prinzip zählt, ob Fall 1 eingetreten ist.
Für je zwei Elemente
Damit keine Zweideutigkeiten aufkommen: es gilt
Um nun die Gesamtzahl der Vergleichsoperationen auszurechnen, müssen wir zählen, wie oft Fall 1 oder Fall 2 aufgetreten ist. Aber halt: wir müssen tatsächlich nur Fall 1 zählen; denn tritt Fall 2 ein (wie zum Beispiel für das Paar be, in, dann wird Fall 1 für das Paar in, be auftreten. Wenn wir also alle Paare zählen (in jeglicher Reihenfolge), dann müssen wir tatsächlich nur Fall 1 zählen. Daher:
Lemma 2.5.5 Die Gesamtzahl an Vergleichsoperationen, die Quicksort auf einem Array ausführt, ist
Randomisierung
Jetzt kommt die Randomisierung ins Spiel. Wir stellen uns vor, dass wir zuerst einmal ein zufälliges Shuffling erzeugen, also die Einträge des Arrays in zufällige Reihenfolge bringen. Dann lassen wir Quicksort auf ddiesem Shuffling laufen, also
def randomizedQuicksort(array):
random.shuffle(array)
quicksort(array)
Da nun aber die Anzahl der Vergleichsoperationen vom konkreten Shuffling abhängt, dass
randomizedQuicksort
in Zeile 2 gewählt haben, sollten wir
(
um eben die Abhängigkeit vom konkreten Shuffling hervorzuheben. Wenn wir die Laufzeit eines
randomisierten Algorithmus untersuchen, dann interessiert uns normalerweise die
erwartete Laufzeit, die also "im Schnitt" auftritt. Was heißt das in unserem Fall?
Wir wollen (
Wir müssen also alle möglichen Shufflings untersuchen, um den Mittelwert zu ermitteln.
Das klingt enorm komplex. Hier kommt ein Trick ins Spiel, den man in der Mathematik als
Linearität des Erwartungswertes bezeichnet. Wir können die Summierung in
(
Was ist der Vorteil? Nun ja, wir können uns nun auf zwei verschiedene Elemente
zu bestimmen. Was stellt dieser Wert denn dar? Er besagt, welchen Wert die Variable
Und was ist die Wahrscheinlichkeit? Wenn
Lemma 2.5.6 Es gilt
und somit ist die erwartete Anzahl an Vergleichsoperationen gleich
Von nun an wird es von Vorteil sein, unsere
Aber das muss uns gar nicht interessieren. Denn: wenn wir die Summe (
Da diese Summe immer noch etwas furchteinflößend aussieht, stellen wir sie wieder
als
Unser gewünschter Wert, die erwartete Anzahl von Vergleichsoperationen, ist also die Summe der Zahlen in dieser Matrix. Der einfachste Weg, diese nach oben abzuschätzen ist, noch ein paar fiktive Zahlen dazuzuaddieren:
Wir machen das Produkt also "teurer als nötig"; das ist aber in Ordnung, denn unser Ziel ist es ja, eine Aussage der Form "Quicksort macht im Schnitt höchstens ... Vergleiche" zu zeigen. Jede Zeile in der obigen Matrix ergibt die Summe
Diese Summe hat keine "geschlossene" Form, kommt aber recht häufig vor. Daher hat man für sie
das
Symbol
Theorem 2.5.7 Auf einem Array der Länge quicksortRandomized
im Erwartungswert
höchstens
Vergleiche. Der Ausdruck
Genaure Analyse
Wir haben in Theorem 1.7 eine obere Schranke hergeleitet. Bis
(
Die erwartete Anzahl an Vergleichen ist 2 mal die Summe der schwarzen Zahlen.
Die Gesamtheit der schwarzen, blauen und roten Zahlen
addiert sich zu
Wir sehen, dass sich jede fallende Diagonale zu 1 aufaddiert. Die Summe der roten Zahlen ist
also ebenfalls
Schlussendlich dürfen wir nicht vergessen, die Summe der schwarzen Zahlen mit 2 zu multiplizieren:
Theorem 2.5.8 Auf einem Array der Länge quicksortRandomized
im Erwartungswert exakt
Meine Implementierung ist sehr schlampig. Sehen Sie, dass jedes Element dreimal mit dem Pivot verglichen wird, und zwar in Zeile 6, 7, und 8. Eine sauberere Implementierung könnte das mit einem Vergleich hinbekommen. Dennoch ist Quicksort selbst in dieser rudimentären Implementierung schneller als Mergesort und Heapsort. Hier sehen Sie die Laufzeiten in Sekunden für Arrays verschiedener Größe:
length of input | Mergesort | Heapsort | Quicksort |
---|---|---|---|
8000 | 0.0389379980006197 | 0.06752109599983669 | 0.02765761100090458 |
16000 | 0.07571700700100337 | 0.14723161900110426 | 0.06383249400096247 |
32000 | 0.1558326260001195 | 0.3184903329984081 | 0.12433080700066057 |
64000 | 0.3328166669998609 | 0.7593994590006332 | 0.2662465240009624 |
128000 | 0.722483514000487 | 1.5454070180003328 | 0.5753753440003493 |
Zeigen Sie, wie man Quicksort in place implementiert. Schreiben Sie
hierfür Code
(Pseudo-Code, Python,
was auch immer) für eine Funktion
splitByPivot (array, pivotIndex, leftIndex, rightIndex)
, der
folgendes leistet:
Schreiben Sie dann eine Funktion quicksortInPlace
, die eben
keine neuen Arrays
anlegt, sondern sich mit verschiedenen Indices
(leftIndex, rightIndex
) auf dem
Eingabe-Array aufruft und splitByPivot
als Subroutine
verwendet.
Lesen Sie meine Notizen zu Quicksort und führen Sie die Laufzeitanalyse zu Ende!
Wägen Sie für sich selbst ab, wie genau Sie rechnen wollen und wie
bequem
Sie rechnen wollen.
Erinnern Sie sich an Mergesort: die Anzahl der Operationen, um die
sortierten Hälften von
array
per merge
zusammenzufügen, war im Worstcase
Quickselect
Wie finde ich in einem Array das array[k]
zurückgeben. Das klingt aber nach zuviel arbeit.
Der
Algorithmus Quickselect ist schneller, indem er Quicksort nur dort wo nötig
ausführt und andere
unnötige rekursive Aufrufe sein lässt.
Machen Sie sich mit der Idee und dem Code von QuickSelect vertraut. Lesen Sie hierzu die letzten paar Seiten meiner Notizen zu Quicksort. Der Bequemlichkeit halber hier nochmal mein Python-Code für Quickselect:
def quickselect(array,k):
n = len(array)
if (n <= 1):
return array[0]
pivot = array[0]
left = [x for x in array if x < pivot]
same = [x for x in array if x == pivot]
right= [x for x in array if x > pivot]
if (k < len(left)):
return quickselect(left,k)
elif (k >= len(left) + len(same)):
return quickselect(right, k - len(left) - len(same))
else:
return pivot
Den Code finden Sie zusammen mit allen anderen Sortieralgorithm auch unter sorting.py.
Führen Sie per Hand quickselect(array,2)
aus für
array = ['b','i','f','g','a','j','e','d','h','c']
. Erinnern Sie
sich an den
Quicksort-Baum für array
:
Malen Sie diesen Baum ab und demonstrieren, dass man
quickselect(array,2)
als ein nur teilweise ausgeführtes quicksort(array)
betrachten
kann.
Zeigen Sie, welche Teile des Baumes von quickselect(array,2)
besucht/erzeugt/erschlossen werden
und welche ignoriert werden.
Erinnern Sie sich an die Indikatorvariable
Ich will eine entsprechende Variable
Zeigen Sie, wie man ein solches array
sieht, nicht aber array
selbst (sehen Sie:
Sie können
array
nicht vollständig rekonstruieren, wenn Sie nur seinen Quicksort/Baum sehen).
quicksort-quickselect.pdf
haben wir gesehen,
dass array
vorkommt. Finden Sie eine
ähnliche
Charakterisierung
für
Schreiben Sie die erwartete Anzahl von Vergleichsoperationen, die
quickselect(array,k)
macht, als Summe und versuchen, deren Wert
zu bestimmen. Wiederum müssen Sie zwischen Genauigkeit und vertretbarem
Arbeitsaufwand
Ihrerseits abwägen.