quick

Werbung
Quicksort
1
Vorbemerkung
Der um 1960 von C.A.R. Hoare vorgeschlagene Sortieralgorithmus quicksort gehört zweifellos zu den populärsten, am meisten verwendeten und am
besten untersuchten Algorithmen überhaupt. Sortieren ist ohnehin eine recht
häufige Aufgabe, und mit einigem Recht kann man quicksort als einen der besten “general-purpose” Sortieralgorithmen ansprechen. Solche Qualifizierungen
bedürfen natürlich einer genaueren Begründung, die ihrerseits nur auf der Basis umfangreicher Erfahrung und Vergleiche möglich ist. Darüber ist soviel und
kompetent geschrieben worden, daß es keinen Sinn macht, das hier in wenigen
Sätzen darstellen zu wollen. Ein Verweis auf Autoritäten wie Knuth, Sedgewick und Bentley muß genügen. Insbesondere die Dissertation von Sedgewick ist ein studierenswertes Beispiel skrupulöser Auseinandersetzung mit Implementierung und Analyse der algorithmischen Idee von quicksort. So bestechend einfach die ursprüngliche Idee von Hoare ist, ihre Realsierung ist durchaus
nicht ohne Tücken! Die Zeigerbewegungen in der splitting-Phase muß man sehr
sorgfältig behandeln, spezielle Vorkehrungen sind zu treffen, um das worst-case
Verhalten1 zu verbessern, und durch iterative Implementierung kann man zwar
die Effizienz steigen, die Programmierung wird aber delikater.
Die Analyse des average-case Verhaltens von quicksort ist ein “Klassiker”
der Algorithmenanalyse, und darum geht es hier vor allem. Wie schon in den
früher behandelten divide-and-conquer Situationen führt die rekursive Struktur
des Algorithmus auf eine Rekursionsgleichung für die Kosten. Hier allerdings
kann die Problemgrösse der jeweils induzierten Teilprobleme stark schwanken
— was sich in einem anderen Typ von Rekursionsgleichung äussert.
Es wird sich zeigen, daß quicksort im Mittel einen Aufwand (gemessen in
der Anzahl der Vergleichsoperationen) benötigt, der sich bei Listenlänge n wie
2n log n verhält, also asymptotisch von optimaler Wachstumsordnung ist. Dies
korrespondiert mit den guten praktischen Erfahrungen, die ja auch noch den
overhead des Verfahrens berücksichtigen, der bei der Zählung der Vergleichsoperationen unterschlagen wird. quicksort kann aus dieser Sicht sehr gut mit
Algorithmen wie heapsort oder mergesort konkurrieren, die ja auch im worst
case ein Θ(n log n) verhalten zeigen.
2
Die Idee — rekursives splitting
quicksort basiert auf einer bestechend einfachen Idee (man muß nur darauf
kommen):
In einer zu sortierenden Liste A[1..n] (von ganzen Zahlen etwa) ist
das Element an der k-ten Position, A[k] also, ein splitter, wenn es an
1
das sich bei naiver Implementierung paradoxerweise gerade dann zeigt, wenn man quicksort auf schon weitgehend sortierte Listen anwendet
1
der “richtigen” Position in Bezug auf die Liste steht, die aus A[1..n]
durch Sortieren entsteht. D.h. es gilt
∀i < k : A[i] ≤ A[k] ∧ ∀j > k : A[k] ≤ A[j]
Hat man eine solche Information, kann man A[1..n] dadurch sortieren, daß man die Teillisten A[1..k − 1] und A[k + 1..n] separat
sortiert. Hat man eine solche Information nicht, wird man durch
eine spezielle Prozedur split (mit möglichst wenig Aufwand) A[1..n]
so umordnen, daß dabei ein splitter in einer bekannten Position (!)
entsteht — dann kann man zu den Teillisten wie beschrieben übergehen und dieses Verfahren rekursiv anwenden.
Eine quicksort-Implementierung hat also die Struktur
qksort := proc(A:array(integer),left:integer,right:integer)
if left < right then
split(A,left,right,’k’);
qksort(A,left,k-1);
qksort(A,k+1,right)
fi;
end;
wobei der Aufruf zum Sortieren von A[left..right] so behandelt wird, daß
durch den Aufruf split(A,left,right,’k’) eine Umordnung A’[left..right]
von A[left..right] (in place) erzeugt wird, bei der A’[k] ein splitter ist. Auf
A’[left..k-1] und A’[k+1..right] wird das Verfahren rekursiv angewandt.
Der ganze Witz von quicksort liegt also im splitting !
3
Splitting
Die ursprüngliche Idee von Hoare besteht darin, zum Splitten einer Liste
A[1..n] so vorzugehen, daß man zwei Zeiger, i und j, und zwar i von 1 her
aufsteigend, j von n her absteigend, aufeinander zubewegt. Jedesmal, wenn
man A[i] > A[j] feststellt, werden diese beiden Elemente vertauscht — danach
setzen die Zeiger ihren Weg analog fort. Treffen sich die beiden Zeiger, hat
man die Position eines splitters gefunden. Die Details der Zeigerbewegungen
(Randfälle!) sind etwas subtil. Man kann sie in der Literatur nachlesen.
Konzeptuell und für die Implementierung etwas einfacher ist eine elegante
Idee von N. Lomuto, die von Bentley propagiert wird. Hier bewegen sich
auch zwei Zeiger i und j, aber in der gleichen Richtung! Das Listenelement A[1]
soll splitter werden. Man startet mit j von j = 1 aus und läuft so lange, bis
man einen Index j mit A[j] < A[1] findet - dann erhöht man i (das auch bei
i = 1 startet) um 1 und vertauscht A[i] und A[j]. Dieses Spiel (j erhöhen bis
wieder A[j] < A[1] gefunden wird, dann mit i um eine Stelle nachziehen und
A[i] mit A[j] vertauschen) setzt sich fort, bis Zeiger j das Listenende erreicht.
Dann werden A[i] und A[1] vertauscht — mit dem Erfolg, daß das nunmehr an
der Stelle i stehende Element (das anfängliche A[1]) ein splitter der Liste ist.
2
Es geht kein Weg dran vorbei: wenn man verstehen will, wie und warum
das funktioniert, muß man sich — etwa anhand des weiter unten reproduzierten Programmtextes — Schritt für Schritt durch Beispiele arbeiten. Das bleibt
der Eigeninitiative überlassen. Vorher aber noch ein Hinweis auf eine sinnvolle
Modifikation der Splittings.
4
Eine Verbesserung: Splitting mit “Randomisieren”
Die eben beschriebene Idee des Splittings hat noch einen Nachteil: es gibt (sehr
einfache!) Listen, bei denen die Aufteilung in Teillisten vor und nach dem splitter
sehr extrem ausfällt, wenn man das erste Listenelement als Vergleichselement
nimmt: die linke Teilliste ist leer und die Länge der rechten Teilliste ist gerade mal um 1 kleiner als die Länge des ursprünglichen inputs. Wenn sich diese
Erscheinung durch alle Phasen des Algorithmus durchzieht, wird er ein quadratisches Laufzeitverhalten im worst case haben! Ärgerlich daran ist, daß dieses
“schlechte” Verhalten immer bei denselben inputs auftritt. Was man zumindest
erreichen möchte, ist dies: es gibt keine inputs bei denen sich der Algorithmus
immer schlecht verhält. Das kann man bei einem seiner Natur nach deterministischen Algorithmus nur dadurch erreichen, indem man ein zufälliges Element
mit einbaut. Wenn man das geschickt macht, kann man dafür sorgen, daß es
keine schlechten Instanzen für den Algorithmus gibt, sondern nur noch schlechte
Exekutionen.
Beim Splitting kann man eine solche Verbesserung dadurch erreichen, daß
man zu Beginn ein zufälliges Element A[r] mit 1 ≤ r ≤ n auswählt und mit
A[1] vertauscht. Damit haben alle Listenelemente die gleiche Chance, splitter
zu sein, unabhängig von der ursprünglichen Form der Liste. Klar ist dann auch,
daß die Position k des splitters jede der Positionen 1 ≤ k ≤ n mit gleicher
Wahrscheinlichkeit 1/n sein wird.
Die hier angesprochene Technik des “Randomisierens” ist in den letzten
Jahren sehr populär geworden. “Randomisierte Algorithmen”, auch für Problemstellungen, wo man ein determiniertes Resultat erwartet, können u.a. verhindern, daß lange Laufzeiten immer bei den gleichen inputs auftreten (wenn sie
denn unvermeidbar sind). Für viele Fragestellungen gibt es randomisierte Algorithmen, die weitaus besser sind als die besten deterministischen Algorithmen,
wenn man noch geringe “Versagerquoten” in Kauf zu nehmen bereit ist.
5
Das Programm
In diesem Abschnitt wird ein Maple Programm quicksort präsentiert. Dabei
kommt es nur auf die korrekte und klare Realisierung der dargestellten Ideen an, nicht auf Effizienzsteigerung durch Detailverbesserung. Interessenten
(insbesondere auch solche, die sich mit den Interna von Maple etwas auskennen), sind eingeladen, dieses Programm zu “tunen” oder gleich eine noch
Maschinen-näheres Programm zu schreiben. Es wurde ebenfalls darauf verzichtet, Konsistenz-checks der Eingabedaten mit in die einzelnen Prozeduren aufzunehmen.
3
Die Prozedur swap dient lediglich dazu, zwei Elemente eines arrays zu vertauschen, nämlich die Elemente in Position u und Position v.
swap := proc(A:array(integer),u:integer,v:integer)
local tmp;
if not (u=v) then
tmp := A[u];
A[u] := A[v];
A[v] := tmp;
fi;
RETURN(op(A));
end;
Die Prozedur split ist das Herzstück von quicksort. Hierbei wird der Abschnitt A[left..right] eines arrays A[lower..upper] dem splitting unterworfen — es wird unterstellt, daß left und right mit den array-Grenzen verträglich sind. Aus dem Intervall [left..right] wird per Zufallsgenerator ein
r ausgewählt, und A[r] wird als splitting-Element bestimmt (und mit A[left]
vertauscht). Der Index I als Resultat der Prozedur gibt an, in welcher Position
dieses Element nach dem splitting steht. Als Seiteneffekt wird A entsprechend
dem splitting verändert.
4
split := proc(A:array(integer),left:integer,right:integer,I:string)
local i,j,r;
r := rand(left..right)();
swap(A,left,r);
i := left;
for j from left+1 to right do
if A[j] < A[left] then
i := i+1;
swap(A,i,j)
fi;
od;
swap(A,left,i);
I:=i;
end;
Die rekursive Struktur von quicksort wurde schon oben angesprochen. qksort
sortiert den Bereich A[left..right] der arrays A, indem durch split(A,left,right,’k’)
ein splitter an der Stelle k erzeugt wird. Für Teilarrays links und rechts davon
wird qksort rekursiv aufgerufen. Bei arrays der Länge ≤ 1 ist nichts zu tun.2
qksort := proc(A:array(integer),left:integer,right:integer)
local k;
if left < right then
split(A,left,right,’k’);
qksort(A,left,k-1);
qksort(A,k+1,right)
fi;
end;
quicksort(L) schließlich sortiert Listen L durch Aufruf von qksort mit der
Listenlänge als Parameter.
quicksort := proc(L:list(integer))
local length,A;
length := nops(L);
A := convert(L,array);
qksort(A,1,length);
convert(op(A),list);
end
6
Die quicksort-Rekursion
Es bezeichne nun F (n) die mittlere Anzahl von Vergleichsoperationen, bei Ausführung
von quicksort auf Listen der Länge n. Wie schon früher wollen wir annehmen,
2
Bei “richtigen” Implementierungen von quicksort wird man die Rekursion nicht so weit
treiben: man schaltet auf ein schnelles, nichtrekursives Verfahren (wie z.B. insertionsort um,
sobald die Länge der arrays 10-15 (etwa) unterschreitet.
5
daß alle n! relativen Anordnungen (Permutationen) von n Elementen mit der
gleichen Wahrscheinlichkeit auftreten. Aus der Struktur des Algorithmus ergibt
sich somit:
n
1X
F (n) = (n − 1) +
{F (k − 1) + F (n − k)}
n k=1
wobei n − 1 die Anzahl der Vergleichsoperationen in split bei Listenlänge n ist,
und k die Position des splitters angibt: jede dieser Positionen hat die gleiche
Wahrscheinlichkeit 1/n. Zu Sortieren sind dann Teillisten der Längen k − 1 und
n − k, und man überlegt sich leicht, daß hierbei wiederum alle (k − 1)! bzw
(n − k)! relativen Anordnungen mit gleicher Wahrscheinlichkeit auftreten.
Eine kleine Vereinfachung ergibt sich aus der Beobachtung, daß auf der
rechten Seite der Rekursion zweimal die gleiche Summe (mit unterschiedlicher
Summationsrichtung) steht, also:
F (0) = 0 , F (n) = n − 1 +
X
2 n−1
F (i)
n i=0
Die Frage ist nun, wie sich die Lösung F (n) dieser Rekursion für n → ∞ verhält.
Experimentell wird rasch klar, daß F (n) stärker als linear wächst, aber auch
nicht sehr viel stärker:
F (1) = 0
F (2) = 1
F (3) = 8/3 = 2.66...
F (4) = 29/6 = 4.83...
F (5) = 37/5 = 7.4
F (6) = 10.3
F (7) = 13.48...
F (8) = 16.92...
F (9) = 20.57...
F (10) = 30791/1260 = 24.43...
F (100) = 647.85...
F (1000) = 10985.9...
Interessant ist, daß sich die Lösung der quicksort-Rekursion explizit angeben
läßt:
F (n) = 2 (n + 1) Hn − 4 n
und damit kann man leicht auch den Funktionsverlauf für größere n verfolgen:
F (104 ) = 155771.6...
F (105 ) = 0.2018... 107
F (106 ) = 0.2478... 108
F (107 ) = 0.2939... 109
F (108 ) = 0.3399... 1010
6
F (109 ) = 0.3860... 1011
F (1010 ) = 0.4320... 1012
Insgesamt zeigt sich also:
F (n) ∼ 2 n log n
7
Zur Analyse der Quicksort-Rekursion
In einem ersten Abschnitt wird die Quicksort-Rekursion behandelt, indem sie in
eine Differenzengleichung umgeformt wird, die einer expliziten Lösung zugänglich ist. Allgemeiner gilt, daß sich Rekursionen dieses Typs mit Hilfe von Differentialgleichungen für die “erzeugenden Funktionen” der Lösung beschreiben
lassen. Diese Technik wird in einem zweiten Abschnitt vorgestellt.
7.1
Behandlung mittels Differenzengleichung
Wir schreiben die Quicksort-Rekursion
F (0) = 0 , F (n) = n − 1 +
in der Form
n F (n) = n(n − 1) + 2
n
X
X
2 n−1
F (i)
n i=0
F (i − 1)
(n > 0)
i=1
und ziehen diese Gleichung, aber mit n durch n − 1 ersetzt, also
(n − 1) F (n − 1) = (n − 1)(n − 2) + 2
n−1
X
F (i − 1)
(n > 0)
i=1
von der vorigen Gleichung ab:
n F (n) − (n − 1) F (n − 1) = 2 (n − 1) + 2 F (n − 1)
Das ergibt
n F (n) − (n + 1) F (n − 1) = 2 (n − 1)
Dies ist eine lineare Differenzengleichung 1. Ordnung mit polynomialen Koeffizienten. Ersetzt man F (n) durch (n+1) G(n), so genügt diese neue Funktion G(n)
einer linearen Differenzengleichung 1. Ordnung mit konstanten Koeffizienten:
G(n) − G(n − 1) =
2 (n − 1)
(n > 0) , G(0) = 0
n (n + 1)
Hier kann man zweimal den “Teleskop-Trick” spielen:
G(n) =
n
X
{G(i) − G(i − 1)}
i=1
7
n
X
2 (i − 1)
=
i (i + 1)
i=1
n X
= 2
i=1
= 2
2
1
−
i+1
i
n X
2
i=1
i+1
2
i
−
+2
n
X
1
i=1
i
2
− 2 + 2 Hn
n+1
4n
= −
+ 2 Hn
n+1
= 2
und somit
F (n) = (n + 1) G(n) = 2 (n + 1) Hn − 4 n ∼ 2 n log n
7.2
Behandlung mittels Differentialgleichung
Wir betrachten wieder
X
2 n−1
F (0) = 0 , F (n) = n − 1 +
F (i)
n i=0
und definieren uns die “erzeugende Funktion”
φ(z) :=
X
F (n) z n
n≥0
Multiplikation der Rekursionsgleichung mit n liefert
n F (n) = n(n − 1) + 2
n−1
X
F (i)
(n ≥ 0)
i=0
Multiplikation mit z n und Summation über n ≥ 0 ergibt
X
n F (n) z n =
n≥1
X
n(n − 1) z n + 2
X n−1
X
F (i) z n
n≥1 i=0
n≥1
und das ist äquivalent zu
φ0 (z) =
2
2z
+
φ(z)
(1 − z)3 1 − z
Diese lineare Differentialgleichung kann man mittels der Methode der Variation der Konstanten lösen. Dazu betrachtet man erst einmal die zugehörige
homogene Gleichung
2
φ0h (z) =
φh (z)
1−z
die man leicht lösen kann.
d
φ0 (z)
2
log φh (z) = h
=
dz
φh (z)
1−z
8
liefert
log φh (z) = −2 log(1 − z) + γ
mit einer Integrationskonstanten γ, also
φh (z) =
λ
(1 − z)2
mit einer Konstanten λ. Nun der Ansatz
φ(z) =
λ(z)
(1 − z)2
d.h. für die Behandlung der inhomogenen Gleichung wird φ(z) so angesetzt,
daß an Stelle der Konstanten λ eine Funktion λ(z) erscheint. Dies in die urspüngliche Differentialgleichung eingesetzt liefert eine Differentialgleichung für
die zu bestimmende Funktion λ(z):
λ0 (z)
2z
=
(1 − z)2
(1 − z)3
oder
λ0 (z) =
2z
2
=
−2
1−z
1−z
was offenbar durch
λ(z) = 2 (− log(1 − z) − z) + c
gelöst wird. Hierbei ist c eine passende Konstante. Damit hat man die Lösung:
−1
z
φ(z) = 2
log(1 − z) −
2
(1 − z)
(1 − z)2
+c
Davon interessiert uns die Potenzreihenentwicklung. Es gilt
X
X zj
−1
i
log(1
−
z)
=
(i
+
1)
z
·
(1 − z)2
j
j>0
i≥0


n
X
1
 (n − j + 1)  z n
=
j
n>0 j=1
X
=
X
((n + 1)Hn − n) z n
n>0
und daher kann man schreiben:
!
φ(z) =
X
n≥0
F (n) z n = 2
X
X
((n + 1) Hn − n) z n −
n>0
n>0
was per Koeffizientenvergleich zu
⇒ F (n) = 2 (n + 1)Hn − 4 n
führt.
9
(n > 0)
n zn + c
8
Empfohlene Literatur
1. C.A.R. Hoare, Quicksort,
Computer Journal 5 (1962), 10-15.
2. R. Sedgewick, Quicksort,
ACM Outstanding Dissertations in Computer Science (Stanford, 1975),
Garland, 1980.
14GI/mat 8.7-50[492
3. R. Sedgewick, Implementing quicksort programs,
Communications of the ACM 21 (1978), 847-857.
4. R. Sedgewick, Quicksort,
Kapitel 9 in Algorithms.
5. Cormen/Leiserson/Rivest, Quicksort,
Kapitel 8. in Introduction to Algorithms.
6. J. Bentley, Kolumne 10 in Communications of the ACM, 27 (1984).
ist abgedruckt in:
7. J. Bentley, Programming Pearls,
Addison-Wesley, 1986/89.
14GI/mat 12.2-83
10
Herunterladen