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