Quicksort und der schlechte Pivot
Kurz gesagt: Quicksort teilt das Array an Ort und Stelle um einen Pivot und sortiert beide Teile: im Durchschnitt O(n log n), aber ein schlechter Pivot macht ihn bei sortierter Eingabe zu O(n²).
Problem
Die sortierte Preisliste friert ein
Der Shop sortiert seine 100.000 Produkte mit Quicksort: Bei einem gemischten Katalog sind das 2.136.015 Vergleiche, und die Seite öffnet sich sofort. Dann beginnt der Lieferant, seine Preisliste schon nach Preis sortiert zu schicken. Derselbe Knopf „Nach Preis sortieren“ denkt lange nach und hört mit einem Fehler auf:
static void QuickSort(int[] prices, int lo, int hi){ RuntimeHelpers.EnsureSufficientExecutionStack(); if (lo >= hi) { return; } int p = Partition(prices, lo, hi); QuickSort(prices, lo, p - 1); QuickSort(prices, p + 1, hi);}
int[] prices = Catalog(100000);Array.Sort(prices);var worker = new Thread(() =>{ try { QuickSort(prices, 0, prices.Length - 1); Console.WriteLine("Sortiert"); } catch (InsufficientExecutionStackException e) { Console.WriteLine("Stack erschöpft: " + e.GetType().Name); }}, 1024 * 1024);worker.Start();worker.Join();Stack erschöpft: InsufficientExecutionStackExceptionDie Eingabe ist nicht größer, nur sortiert. Eine sortierte Eingabe sollte die leichteste sein, und hier ist sie die schwerste. Um zu sehen, warum, schau zuerst, wie Quicksort arbeitet.
Die Idee
Pivot und Partition
Mergesort teilt die Liste in zwei Hälften, ohne die Preise anzusehen, und die ganze Arbeit steckt im Zusammenfügen. Quicksort macht das Gegenteil: Die Arbeit steckt im Teilen, und es gibt kein Zusammenfügen. Es ist trotzdem Teile und herrsche, wie bei Mergesort.
- Wähle einen Preis als Pivot. Hier ist es der erste Preis des Teils.
- Partitioniere: Die Preise, die kleiner sind als der Pivot, gehen nach links, der Rest nach rechts, und der Pivot landet dazwischen. Das ist auch sein endgültiger Platz.
- Sortiere den linken und den rechten Teil mit derselben Methode. Ein Teil mit einem Preis oder ein leerer ist schon sortiert: Das ist der Basisfall der Rekursion.
Das Partitionieren tauscht Preise im selben Array, ohne Kopien. Deshalb braucht Quicksort nicht O(n) zusätzlichen Speicher wie Mergesort.
Sieh es dir an
Das Partitionieren Schritt für Schritt
Die Partition ist hier die nach Lomuto: j geht die Preise einzeln durch, und i markiert das Ende der linken Zone, in der die Preise unter dem Pivot stehen. Jeder kleinere Preis tritt der Zone mit einem Tausch bei. Am Ende tauscht der Pivot mit prices[i] und landet an seinem Platz:
static int Partition(int[] prices, int lo, int hi){ int pivot = prices[lo]; int i = lo; for (int j = lo + 1; j <= hi; j++) { if (prices[j] < pivot) { i++; (prices[i], prices[j]) = (prices[j], prices[i]); } } (prices[lo], prices[i]) = (prices[i], prices[lo]); return i;}Schritt für Schritt, in Worten
- Der Pivot ist der erste Preis, 45. i = 0 markiert das Ende der linken Zone, der Preise unter 45. Sie ist noch leer.
- j = 1: 80 < 45? Nein. 80 bleibt rechts, i bewegt sich nicht.
- j = 2: 20 < 45? Ja. i wird 1 und 20 tauscht den Platz mit 80: Die linke Zone wächst.
- j = 3: 65 < 45? Nein. 65 bleibt rechts, i bewegt sich nicht.
- j = 4: 10 < 45? Ja. i wird 2 und 10 tauscht den Platz mit 80: Die linke Zone wächst.
- j = 5: 90 < 45? Nein. 90 bleibt rechts, i bewegt sich nicht.
- j = 6: 30 < 45? Ja. i wird 3 und 30 tauscht den Platz mit 65: Die linke Zone wächst.
- j = 7: 55 < 45? Nein. 55 bleibt rechts, i bewegt sich nicht.
- j hat das Ende erreicht: 7 Vergleiche bei 8 Preisen, n − 1. Der Pivot tauscht mit prices[i] = 30 und landet bei Index 3.
- Links 30, 20, 10, alle unter 45; rechts 80, 90, 65, 55. 45 steht an seinem endgültigen Platz, und die zwei Teile werden getrennt sortiert, im selben Array, ohne zusätzlichen Speicher.
int[] prices = { 45, 80, 20, 65, 10, 90, 30, 55 };int p = Partition(prices, 0, prices.Length - 1);Console.WriteLine("[" + string.Join(", ", prices) + "], p = " + p);[30, 20, 10, 45, 80, 90, 65, 55], p = 3Partition liefert, wo der Pivot gelandet ist, p = 3. Es gibt n − 1 Vergleiche: Jeder Preis außer dem Pivot wird genau einmal mit ihm verglichen.
(prices[i], prices[j]) = (prices[j], prices[i]); ist ein Tausch mit Tupeln: Die rechte Seite wird ganz ausgewertet, bevor die linke geschrieben wird.Sieh es dir an
Der ganze Quicksort
QuickSort partitioniert den Teil von lo bis hi und ruft sich für beide Seiten des Pivots selbst auf. Der Pivot selbst steht schon an seinem Platz und geht in keine von beiden:
static void QuickSort(int[] prices, int lo, int hi){ if (lo >= hi) { return; } int p = Partition(prices, lo, hi); QuickSort(prices, lo, p - 1); QuickSort(prices, p + 1, hi);}int[] prices = { 45, 80, 20, 65, 10, 90, 30, 55 };Console.WriteLine("Vorher: [" + string.Join(", ", prices) + "]");QuickSort(prices, 0, prices.Length - 1);Console.WriteLine("Nachher: [" + string.Join(", ", prices) + "]");Vorher: [45, 80, 20, 65, 10, 90, 30, 55]Nachher: [10, 20, 30, 45, 55, 65, 80, 90]Die Idee
Im Durchschnitt n · log n, manchmal n²
Was Quicksort kostet, hängt vom Pivot ab.
- Ein guter Pivot liegt irgendwo in der Nähe der Mitte. Die zwei Teile sind je etwa die Hälfte, und wie bei Mergesort gibt es etwa log₂ n Ebenen. Auf jeder Ebene gehen die Partitionen zusammen einmal durch alle n Preise: insgesamt etwa n · log₂ n. Bei zufällig gemischten Preisen ist der erste Preis zufällig, also ist das der durchschnittliche Fall.
- Ein schlechter Pivot ist der kleinste (oder der größte) Preis im Teil. Der linke Teil ist leer, und der rechte verliert nur den Pivot. Es gibt n Ebenen und (n − 1) + (n − 2) + … + 1 = n(n − 1)/2 Vergleiche. Das ist O(n²), so viel wie bei den einfachen Sortierverfahren.
Bei sortierter Eingabe ist der erste Preis jedes Teils der kleinste. Die leichteste Eingabe wird also zum schlechtesten Fall:
static void QuickSort(int[] prices, int lo, int hi){ if (lo >= hi) { return; } int p = Partition(prices, lo, hi); QuickSort(prices, lo, p - 1); QuickSort(prices, p + 1, hi);}static void QuickSort(int[] prices, int lo, int hi){ if (lo >= hi) { return; } int k = Random.Shared.Next(lo, hi + 1); (prices[lo], prices[k]) = (prices[k], prices[lo]); int p = Partition(prices, lo, hi); QuickSort(prices, lo, p - 1); QuickSort(prices, p + 1, hi);}Schritt für Schritt, in Worten
- Die Preisliste ist schon sortiert, von 7 bis 80. Der Pivot ist der erste Preis jedes Teils.
- Der Pivot 7 ist der kleinste Preis: 7 Vergleiche, und nach links geht nichts. Der linke Teil ist leer, der rechte hat 7 Preise.
- Auf jeder Ebene dasselbe: Der Pivot ist wieder der kleinste, und der Teil verliert nur einen Preis. Jede Ebene ist ein weiterer verschachtelter Aufruf.
- 8 Ebenen und 7 + 6 + 5 + 4 + 3 + 2 + 1 = 28 Vergleiche, n(n − 1)/2. Bei 100.000 Preisen: 4.999.950.000 Vergleiche und 100.000 verschachtelte Aufrufe, mehr, als der Stack fasst.
- Die Abhilfe: Vor dem Partitionieren wandert ein zufälliger Preis des Teils nach vorn. Der Rest des Codes ist derselbe.
- Ein möglicher Ablauf auf derselben sortierten Liste. Ein zufälliger Pivot liegt meist irgendwo in der Mitte, also bekommen beide Teile Preise.
- 5 Ebenen statt 8 und 17 Vergleiche statt 28. Ein anderer Ablauf wäre etwas anders, aber die Reihenfolge der Eingabe spielt keine Rolle mehr: im Durchschnitt etwa n · log₂ n.
Probier es aus
Sage die Ausgabe voraus
Fünf Preise, und der Pivot ist der erste, 50. Was gibt das Programm aus?
static int Partition(int[] prices, int lo, int hi){ int pivot = prices[lo]; int i = lo; for (int j = lo + 1; j <= hi; j++) { if (prices[j] < pivot) { i++; (prices[i], prices[j]) = (prices[j], prices[i]); } } (prices[lo], prices[i]) = (prices[i], prices[lo]); return i;}
int[] prices = { 50, 70, 20, 90, 40 };int p = Partition(prices, 0, prices.Length - 1);Console.WriteLine("[" + string.Join(", ", prices) + "], p = " + p);j geht über 70, 20, 90, 40. Kleiner als 50 sind 20 und 40: 20 tauscht mit 70 (i = 1), dann 40 mit 70 (i = 2). Am Ende tauscht 50 mit prices[2] = 40. Links stehen 40 und 20, kleiner als 50, aber nicht in Ordnung: Das Partitionieren sortiert die Teile nicht, es trennt sie nur.
Sieh es dir an
Miss es: der erste Pivot und ein zufälliger Pivot
Lass Quicksort auf einem gemischten und auf einem sortierten Katalog laufen. Gezählt werden die Vergleiche mit dem Pivot. Der erste Pivot bei 100.000 sortierten Preisen läuft nicht wirklich: Bench schätzt ihn aus der Kurve.
- Quicksort, erstes Pivot, gemischte Preise
O(n log n) - Quicksort, erstes Pivot, sortierte Preise
O(n²) - Quicksort, zufälliges Pivot, sortierte Preise
O(n log n)
| Algorithmus | n = 10 | n = 1.000 | n = 10.000 | n = 100.000 |
|---|---|---|---|---|
| Quicksort, erstes Pivot, gemischte Preise | 35 | 10.591 | 151.493 | 2.136.015 |
| Quicksort, erstes Pivot, sortierte Preise | 45 | 499.500 | 49.995.000 | ≈ 4.999.500.000 |
| Quicksort, zufälliges Pivot, sortierte Preise | 21 | 10.576 | 153.725 | 2.316.280 |
static void QuickSort(int[] prices, int lo, int hi){ if (lo >= hi) { return; } int k = Random.Shared.Next(lo, hi + 1); (prices[lo], prices[k]) = (prices[k], prices[lo]); int p = Partition(prices, lo, hi); QuickSort(prices, lo, p - 1); QuickSort(prices, p + 1, hi);}Bei einem gemischten Katalog mit 1000 Preisen macht der erste Pivot 10.591 Vergleiche. Beim selben Katalog, sortiert, sind es 499.500, genau 1000 · 999 / 2. Ein zufälliger Pivot beim sortierten Katalog: 10.576, fast so viele wie beim gemischten. Bei 100.000 Preisen macht der zufällige Pivot 2.316.280 Vergleiche, und der erste Pivot würde n(n − 1)/2 = 4.999.950.000 machen (Bench schätzt aus der Kurve etwa 5 Milliarden).
Genauer, für Neugierige
Dieselben Zahlen in deiner Sprache. Der Katalog und die zufälligen Positionen kommen aus einem kleinen Generator mit Seed, sind also bei jedem Lauf, in jeder Sprache und in Bench gleich. Das ist die endgültige Version aus der Lektion (mit Rekursion in den kleineren Teil, siehe unten), und sie gibt auch aus, wie tief der Stack wurde:
static long comparisons = 0;static long seed = 0;static int deepest = 0;
static int[] Catalog(int n){ int[] prices = new int[n]; long x = 2026; for (int k = 0; k < n; k++) { x = x * 48271 % 2147483647; prices[k] = 1 + (int)(x % 10000); } return prices;}
static void QuickSort(int[] prices, int lo, int hi, bool random, int depth){ deepest = Math.Max(deepest, depth); while (lo < hi) { if (random) { seed = seed * 48271 % 2147483647; int k = lo + (int)(seed % (hi - lo + 1)); (prices[lo], prices[k]) = (prices[k], prices[lo]); } int p = Partition(prices, lo, hi); if (p - lo < hi - p) { QuickSort(prices, lo, p - 1, random, depth + 1); lo = p + 1; } else { QuickSort(prices, p + 1, hi, random, depth + 1); hi = p - 1; } }}
static int Partition(int[] prices, int lo, int hi){ int pivot = prices[lo]; int i = lo; for (int j = lo + 1; j <= hi; j++) { comparisons++; if (prices[j] < pivot) { i++; (prices[i], prices[j]) = (prices[j], prices[i]); } } (prices[lo], prices[i]) = (prices[i], prices[lo]); return i;}
var runs = new (string Label, int N, bool Sorted, bool Random)[]{ ("erster Pivot, gemischt", 1000, false, false), ("erster Pivot, gemischt", 100000, false, false), ("erster Pivot, sortiert", 1000, true, false), ("zufälliger Pivot, sortiert", 1000, true, true), ("zufälliger Pivot, sortiert", 100000, true, true),};foreach (var run in runs){ int[] prices = Catalog(run.N); if (run.Sorted) { Array.Sort(prices); } comparisons = 0; seed = 12345; deepest = 0; QuickSort(prices, 0, prices.Length - 1, run.Random, 1); Console.WriteLine($"{run.Label}, n = {run.N}: {comparisons} Vergleiche, Tiefe {deepest}");}erster Pivot, gemischt, n = 1000: 10591 Vergleiche, Tiefe 7erster Pivot, gemischt, n = 100000: 2136015 Vergleiche, Tiefe 11erster Pivot, sortiert, n = 1000: 499500 Vergleiche, Tiefe 2zufälliger Pivot, sortiert, n = 1000: 10576 Vergleiche, Tiefe 7zufälliger Pivot, sortiert, n = 100000: 2316280 Vergleiche, Tiefe 11Mergesort macht bei denselben 100.000 Preisen 1.536.292 Vergleiche, weniger als Quicksort. Trotzdem ist Quicksort in der Praxis oft schneller: Er tauscht Preise im selben Array, ohne Kopien, und liest den Speicher der Reihe nach.
Die Idee
Warum der Stack zu Ende ist
Jeder Aufruf von QuickSort bleibt auf dem Aufrufstapel, bis er zurückkehrt. Bei einem schlechten Pivot ist der rechte Teil nur um einen Preis kleiner, also verschachteln sich die Aufrufe ineinander: n Ebenen bei n Preisen. Der Stack ist begrenzt, oft etwa 1 MB, und bei 100.000 verschachtelten Aufrufen ist er zu Ende.
StackOverflowException) nicht abfangen: Der Prozess hört einfach auf. Deshalb läuft die Sortierung hier in einem Thread mit 1-MB-Stack, und RuntimeHelpers.EnsureSufficientExecutionStack() am Anfang von QuickSort wirft InsufficientExecutionStackException, wenn der Stack fast voll ist. Diese Exception lässt sich abfangen.Getrennt vom Stack ist die Zeit: n(n − 1)/2 Vergleiche. Bei 100.000 Preisen sind das etwa 5 Milliarden, deshalb „denkt“ der Knopf nach, bevor er scheitert.
Die Idee
Drei Abhilfen
Ein zufälliger Pivot. Vor dem Partitionieren wandert ein zufälliger Preis des Teils nach vorn und wird der Pivot. Dann ist keine Eingabe absichtlich schlecht: sortiert, umgekehrt und gemischt ergeben im Durchschnitt alle etwa n · log₂ n. Der schlechteste Fall existiert weiter, aber seine Wahrscheinlichkeit ist verschwindend klein: Auf jeder Ebene müsste der zufällige Preis genau der kleinste oder der größte im Teil sein.
static void QuickSort(int[] prices, int lo, int hi){ if (lo >= hi) { return; } int k = Random.Shared.Next(lo, hi + 1); (prices[lo], prices[k]) = (prices[k], prices[lo]); int p = Partition(prices, lo, hi); QuickSort(prices, lo, p - 1); QuickSort(prices, p + 1, hi);}Median aus drei. Statt eines zufälligen Preises nimmst du den dem Wert nach mittleren aus dem ersten, dem in der Mitte und dem letzten Preis. Bei sortierter Eingabe ist das genau die Mitte. Diese Abhilfe ist die Aufgabe am Ende der Lektion.
Rekursion in den kleineren Teil. Der Aufruf gilt nur dem kleineren der zwei Teile, und der größere bleibt für die Schleife: lo oder hi rückt weiter, und die Schleife partitioniert ihn erneut. Der kleinere Teil ist höchstens die Hälfte, also ist der Stack bei jedem Pivot höchstens log₂ n Ebenen tief: bis zu 17 bei 100.000 Preisen (das zählende Programm kommt auf 11). Die Vergleiche sinken dadurch nicht, deshalb gehört diese Abhilfe zum zufälligen Pivot:
static void QuickSort(int[] prices, int lo, int hi){ while (lo < hi) { int k = Random.Shared.Next(lo, hi + 1); (prices[lo], prices[k]) = (prices[k], prices[lo]); int p = Partition(prices, lo, hi); if (p - lo < hi - p) { QuickSort(prices, lo, p - 1); lo = p + 1; } else { QuickSort(prices, p + 1, hi); hi = p - 1; } }}int[] prices = Catalog(100000);Array.Sort(prices);QuickSort(prices, 0, prices.Length - 1);Console.WriteLine("Sortiert: " + prices.SequenceEqual(prices.Order()));Sortiert: TrueProbier es aus
Finde den Fehler
Die acht Preise kommen fast sortiert heraus:
int[] prices = { 45, 80, 20, 65, 10, 90, 30, 55 };Console.WriteLine("Vorher: [" + string.Join(", ", prices) + "]");QuickSort(prices, 0, prices.Length - 1);Console.WriteLine("Nachher: [" + string.Join(", ", prices) + "]");Vorher: [45, 80, 20, 65, 10, 90, 30, 55]Nachher: [20, 30, 10, 45, 65, 80, 90, 55]Das ist das Partition des Programms. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
hi ist der letzte Platz des Teils, nicht der Platz danach. Die Schleife hört einen Preis zu früh auf, und der letzte Preis jedes Teils wird nie mit dem Pivot verglichen: Er bleibt rechts, auch wenn er kleiner ist. Deshalb stehen 55 und 10 am falschen Platz. Die Bedingung muss j bis einschließlich hi laufen lassen.
Probier es aus
Fülle die Lücke
Die Rekursion geht in den kleineren, linken Teil. Was ist der neue Wert von lo, damit die Schleife mit dem rechten Teil weitermacht?
static void QuickSort(int[] prices, int lo, int hi)
{
while (lo < hi)
{
int k = Random.Shared.Next(lo, hi + 1);
(prices[lo], prices[k]) = (prices[k], prices[lo]);
int p = Partition(prices, lo, hi);
if (p - lo < hi - p)
{
QuickSort(prices, lo, p - 1);
lo = ;
}
else
{
QuickSort(prices, p + 1, hi);
hi = p - 1;
}
}
}Der linke Teil geht von lo bis p - 1, und der Aufruf hat ihn schon sortiert. Der Pivot steht bei p. Übrig ist der rechte Teil, von p + 1 bis hi. Wird lo zu p, kommt der Pivot ohne Grund in die nächste Partition. Mit dem ersten Pivot ist er wieder der kleinste, bleibt wieder bei p, und die Schleife dreht sich auf der Stelle.
Übung
Median aus drei
Löse die Aufgabe in deinem Editor.
Median aus drei. Schreibe Median(prices, lo, hi), das drei Preise ansieht: bei lo, in der Mitte lo + (hi - lo) / 2 und bei hi, und die Position des dem Wert nach mittleren liefert. Tausche in QuickSort (die einfache Rekursion aus der Lektion) diesen Preis vor dem Partitionieren mit prices[lo]. Zähle die Vergleiche mit dem Pivot in Partition auf dem sortierten Katalog aus der Lektion (catalog aus dem zählenden Programm, dann sortiert) bei 1000 und bei 100.000 Preisen.
Median aus drei, sortiert, n = 1000: 8005 VergleicheMedian aus drei, sortiert, n = 100000: 1773717 VergleicheVergleiche mit dem ersten Pivot: 499.500 Vergleiche bei 1000 sortierten Preisen, und bei 100.000 ist der Stack zu Ende.
Weitere Aufgaben
- Eine umgekehrte Preisliste. Übergib den Katalog vom teuersten zum billigsten sortiert. Wie viele Vergleiche macht der erste Pivot? Und der Median aus drei?
- Gleiche Preise. Übergib 1000 gleiche Preise. Wie viele Vergleiche macht die Partition nach Lomuto, und warum? (Hinweis: Nichts ist echt kleiner als der Pivot.)
- Der teuerste zuerst. Ändere ein Zeichen in
Partition, damit die Liste vom teuersten zum billigsten herauskommt.
Genauer
Quicksort in der eingebauten Sortierung
Quicksort wurde 1959 von Tony Hoare erfunden, und er sitzt immer noch im Herzen der eingebauten Sortierung vieler Sprachen, aber nicht in reiner Form: Jede ergänzt Schutz gegen den schlechten Pivot.
Genauer, für Neugierige
Lomuto und Hoare. Die Partition hier ist die nach Lomuto: Sie ist leicht zu lesen, tauscht aber mehr. Die Partition nach Hoare, die ursprüngliche, lässt zwei Zeiger von beiden Enden aufeinander zulaufen und tauscht nur Paare von Preisen, die auf der falschen Seite stehen. Sie macht etwa dreimal weniger Tausche und kommt besser mit gleichen Preisen zurecht, aber der Pivot landet nicht unbedingt an seinem endgültigen Platz, und der Code ist leichter falsch zu schreiben.
Gleiche Preise. Bei vielen gleichen Preisen ergibt Lomuto trotzdem schlechte Teile: Alles, was dem Pivot gleicht, geht nach rechts. Eine Dreiwege-Partition bildet drei Zonen: kleiner, gleich und größer. Die gleichen sind erledigt und gehen nicht in die Rekursion.
Die eingebaute Sortierung. In C# sind Array.Sort und List<T>.Sort Introsort: ein Quicksort mit Median aus drei, der auf Heapsort umschaltet, wenn die Rekursion zu tief wird (etwa 2 · log₂ n Ebenen), und auf Insertionsort bei kleinen Teilen. Damit ist der schlechteste Fall O(n log n). std::sort in C++ macht dasselbe.
Quicksort ist nicht stabil: Ein Tausch kann über einen gleichen Preis springen, und gleiche Produkte ändern ihre Reihenfolge. Was das heißt und wann es zählt, steht in der Lektion Stabiles Sortieren und die eingebaute Sortierung.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Bester, durchschnittlicher und schlechtester Fall · Speicherkomplexität · Insertionsort und Selectionsort · Mergesort und „Teile und herrsche“
- Geht weiter in: Heap und Heapsort · Stabiles Sortieren und die eingebaute Sortierung · Teile und herrsche und der Rekursionsbaum · Binärer Suchbaum
- Aufgaben zum Thema: Sortieren