Quicksort

Aus FLBK-Wiki
Zur Navigation springen Zur Suche springen

Einführung

Rekursive Aufteilung beim Quicksort

Ein in der Praxis oft eingesetzter und sehr effizienter Sortieralgorithmus ist der Quicksort (englisch für „schnelles Sortieren“). Er funktioniert nach dem Prinzip „Teile und herrsche“ (Divide and Conquer), welches in der Programmierung meist mithilfe von Rekursion realisiert wird.

Die Grundidee ist einfach: Die zu sortierende Datenmenge wird in zwei kleinere Teillisten zerlegt ("Teile") und diese werden anschließend separat sortiert ("Herrsche"). Diese Teillisten werden wiederum in noch kleinere Teillisten zerlegt, bis eine Liste nur noch aus einem einzigen Element besteht. Eine Liste mit nur einem Element ist naturgemäß immer sortiert – an diesem Punkt endet die Rekursion (vgl. Galileo Computing).

Wie funktioniert die Zerlegung (Partitionierung)?

Die Zerlegung der Liste erfolgt anhand eines sogenannten Pivot-Elements (Dreh- und Angelpunkt).

  1. Ein beliebiges Element der Liste wird als Pivot ausgewählt.
  2. Alle anderen Elemente der Liste werden mit diesem Pivot verglichen.
  3. Die Liste wird in zwei Bereiche unterteilt:
    • Linke Teilliste: Alle Elemente, die kleiner (oder gleich) dem Pivot sind.
    • Rechte Teilliste: Alle Elemente, die größer als das Pivot sind.

Nach diesem Schritt steht das Pivot-Element bereits an seiner endgültigen, korrekten Position in der Liste. Die Elemente in der linken und rechten Teilliste sind zwar untereinander noch unsortiert, aber es ist garantiert, dass alle Elemente links vom Pivot kleiner sind als alle Elemente rechts davon.

Anschließend ruft sich der Quicksort-Algorithmus selbst auf (Rekursion), um die linke und die rechte Teilliste nach exakt demselben Muster weiter zu sortieren. Ein großer Vorteil dieses Verfahrens: Die Teillisten sind immer Abschnitte der ursprünglichen Liste. Es wird also (abgesehen vom Speicher für die Rekursionsaufrufe) kein zusätzlicher Speicherplatz für neue Listen benötigt (In-Place-Sortierung).

Beispiel

Als Beispiel betrachten wir eine unsortierte Liste mit Zahlen. Der Einfachheit halber wählen wir in diesem Beispiel immer das erste Element als Pivot-Element. Zu Beginn ist die Zahl 7 also unser Pivot.

7 10 8 6 11 4 9 3 1 5 2 12

Die Zahlen, die kleiner als das Pivot-Element 7 sind, wurden blau markiert. Die Zahlen, die größer als 7 sind, wurden rot markiert. Nach dem ersten Partitionierungsschritt hat QuickSort die Liste folgendermaßen umgeordnet:

3 2 5 6 1 4 7 9 11 8 10 12

Nun stehen alle Zahlen, die kleiner sind als 7, links von ihr. Alle Zahlen, die größer sind, stehen rechts. Die Reihenfolge der Zahlen innerhalb der blauen und roten Teillisten ist hier noch willkürlich und hängt von der genauen Programmierung ab. Entscheidend ist: Die 7 hat nun ihre endgültige, korrekte Position gefunden.

Als Nächstes wird die linke (blaue) Teilliste per QuickSort sortiert. Als neues Pivot wählen wir wieder die erste Zahl des Bereichs, die 3. Die Zahlen, die kleiner sind als 3, werden wieder blau markiert, die größeren rot. Die bereits einsortierte 7 und die rechte Teilliste ignorieren wir für den Moment (grau hinterlegt):

3 2 5 6 1 4 7 9 11 8 10 12

Der Algorithmus sortiert die Elemente relativ zur 3 um:

1 2 3 6 5 4 7 9 11 8 10 12

Dieser Prozess wird fortgesetzt (erst die 1 und 2, dann die 6, 5 und 4), bis der gesamte linke Teil des Arrays vollständig sortiert ist. Das Ergebnis sieht dann so aus:

1 2 3 4 5 6 7 9 11 8 10 12

Anschließend wird die Teilliste rechts von der 7 nach demselben Prinzip sortiert. Wieder wird ein Pivot gewählt (hier die 9), und die kleineren und größeren Zahlen werden auf die jeweils richtige Seite gebracht, bis das gesamte Array sortiert ist.

Pseudocode

Der folgende Pseudocode illustriert die Arbeitsweise des Algorithmus. Bei jedem Aufruf von quicksort(...) gibt links den Index des ersten Elements in der Teilliste an und rechts den des letzten. Beim ersten Aufruf (oberste Rekursionsebene) ist links = 0 und rechts = n-1.

funktion quicksort(links, rechts) 
    falls links < rechts dann 
        // teile() ordnet das Array um und liefert die finale Position des Pivots
        teiler := teile(links, rechts) 
        
        // Rekursiver Aufruf für die linke Teilliste
        quicksort(links, teiler - 1) 
        
        // Rekursiver Aufruf für die rechte Teilliste
        quicksort(teiler + 1, rechts) 
    ende
ende

Wahl des Pivot-Elements

Die Effizienz von Quicksort steht und fällt mit der Wahl des Pivot-Elements.

  • Schlechteste Wahl: Das Pivot-Element ist zufällig immer das kleinste oder größte Element der Teilliste. Dann wird die Liste extrem ungleichmäßig geteilt (eine Liste mit 0 Elementen, eine mit [math]\displaystyle{ n-1 }[/math] Elementen).
  • Beste Wahl: Das Pivot-Element ist genau der Median (der Wert, der exakt in der Mitte der sortierten Liste stehen würde). Die Liste wird in zwei exakt gleich große Hälften geteilt. Da der Median in unsortierten Daten aber aufwendig zu suchen ist, behilft man sich mit Tricks.

Warum das erste Element oft eine schlechte Wahl ist: Wenn man immer stur das erste Element als Pivot wählt (wie in unserem Beispiel) und die Eingabeliste ist bereits (oder fast) fertig sortiert, trifft automatisch immer der oben beschriebene schlechteste Fall ein. Der Algorithmus wird dadurch extrem langsam.

Lösungsansätze für die Praxis:

  1. Randomisierung: Man wählt ein zufälliges Element aus dem Intervall als Pivot.
  2. Median-of-Three: Man betrachtet das erste, das mittlere und das letzte Element der Teilliste und wählt von diesen dreien den mittleren Wert als Pivot. Dies schützt sehr gut vor bereits vorsortierten Listen.

Java Implementierung

Bisher wurde die allgemeine Funktionsweise beschrieben. Am besten lassen sich komplexe Algorithmen nachvollziehen, wenn man sie anhand einer Implementierung mit Hilfe eines Debuggers analysiert. In der folgenden Java-Implementierung wird ein Array aus Ganzzahlen (int) sortiert.

Zur Abwechslung wird in dieser Implementierung immer das letzte Element des aktuellen Bereichs (array[rechts]) als Pivot gewählt.

public void quickSort(int array[], int links, int rechts) {
      if (links < rechts) {
         // Teile das Array und finde die Position des Pivot-Elements
         int i = teile(array, links, rechts);
         
         // Sortiere den linken Teil
         quickSort(array, links, i - 1);
         // Sortiere den rechten Teil
         quickSort(array, i + 1, rechts);
      }
}

public int teile(int array[], int links, int rechts) {
      int pivot, i, j, help;
      pivot = array[rechts]; // Wir wählen das rechte Randelement als Pivot               
      i = links;
      j = rechts - 1;
      
      // Von links und rechts zur Mitte laufen, bis sich i und j kreuzen
      while (i <= j) {
         // Suche von links ein Element, das größer als das Pivot ist
         if (array[i] > pivot) {     
            // Tausche array[i] mit array[j], um es in den rechten Bereich zu bringen
            help = array[i]; 
            array[i] = array[j]; 
            array[j] = help;                             
            j--; // j einen Schritt weiter nach links schieben
         } else {
            i++; // i war kleiner/gleich Pivot, ist also korrekt positioniert -> weiter nach rechts
         }          
      }

      // Das Pivot-Element an seine endgültige Position (i) tauschen
      help = array[i];
      array[i] = array[rechts];
      array[rechts] = help;
      
      // Rückgabe der finalen Position des Pivot-Elements
      return i;
}

Laufzeitanalyse

Worst-Case (Schlechtester Fall)

Worst-Case: Entarteter Baum

Betrachten wir zunächst die Worst-Case-Laufzeitanalyse. Angenommen, wir haben extremes Pech und die Partitionierung ist maximal unausgeglichen. Das gewählte Pivot ist immer das absolut kleinste oder größte Element. Dann enthält eine der Partitionen 0 Elemente und die andere Partition [math]\displaystyle{ n-1 }[/math] Elemente.

Der Aufwand für das Vergleichen (Partitionieren) einer Ebene mit [math]\displaystyle{ n }[/math] Elementen ist proportional zu [math]\displaystyle{ n }[/math]. Nennen wir diesen Aufwand [math]\displaystyle{ c \cdot n }[/math] (wobei [math]\displaystyle{ c }[/math] eine Konstante für die Dauer eines Vergleichs ist). Auf der nächsten Ebene müssen wir [math]\displaystyle{ n-1 }[/math] Elemente vergleichen, der Aufwand ist [math]\displaystyle{ c \cdot (n-1) }[/math]. Danach [math]\displaystyle{ c \cdot (n-2) }[/math] und so weiter.

Wenn wir die Partitionierungszeiten für alle Ebenen aufsummieren, erhalten wir folgende Reihe: [math]\displaystyle{ c \cdot n + c \cdot (n - 1) + c \cdot (n - 2) + \dots + c \cdot 2 }[/math]

Wir klammern [math]\displaystyle{ c }[/math] aus und erkennen die bekannte Gaußsche Summenformel: [math]\displaystyle{ c \cdot (n + (n-1) + (n-2) + \dots + 2) \approx c \cdot \frac{n(n+1)}{2} = \frac{c}{2}n^2 + \frac{c}{2}n }[/math]

Da in der asymtotischen Laufzeitanalyse konstante Faktoren und kleinere Terme (wie [math]\displaystyle{ n }[/math]) ignoriert werden, wächst der Aufwand quadratisch. Fazit Worst-Case: Quicksort hat im schlechtesten Fall eine Zeitkomplexität von [math]\displaystyle{ \mathcal{O}(n^2) }[/math].

Best-Case (Bester Fall)

Best-Case: Symmetrischer Baum

Der beste Fall liegt vor, wenn das Pivot-Element die Liste immer exakt in der Mitte teilt. Die Teillisten sind dann immer gleich groß.

Halbierung der Arraygrößen

Wird die Anzahl der zu sortierenden Elemente [math]\displaystyle{ n }[/math] verdoppelt, kommt durch die fortlaufende Halbierung lediglich eine einzige neue Rekursionsebene (ein Baum-Level) hinzu. Dieses Wachstumsverhalten (wie oft kann ich [math]\displaystyle{ n }[/math] durch 2 teilen, bis 1 übrig bleibt?) wird mathematisch durch den Logarithmus zur Basis 2 ausgedrückt: [math]\displaystyle{ \log_2(n) }[/math].

Zu sortierende Elemente [math]\displaystyle{ n }[/math] 4 8 16 32
Als Zweierpotenz [math]\displaystyle{ \color{green}{2^2} }[/math] [math]\displaystyle{ \color{green}{2^3} }[/math] [math]\displaystyle{ \color{green}{2^4} }[/math] [math]\displaystyle{ \color{green}{2^5} }[/math]
Anzahl Rekursionsebenen [math]\displaystyle{ \log_2(n) }[/math] 2 3 4 5

Wir haben also insgesamt [math]\displaystyle{ \log_2(n) }[/math] Ebenen. Auf jeder einzelnen Ebene müssen in der Summe [math]\displaystyle{ n }[/math] Elemente betrachtet und mit dem jeweiligen Pivot verglichen werden. Wir multiplizieren also die Arbeit pro Ebene ([math]\displaystyle{ n }[/math]) mit der Anzahl der Ebenen ([math]\displaystyle{ \log_2(n) }[/math]). Fazit Best-Case: Die Laufzeit beträgt [math]\displaystyle{ \mathcal{O}(n \log n) }[/math].

Average-Case (Durchschnittlicher Fall)

In der Praxis haben wir selten den perfekten Best-Case, aber fast nie den absoluten Worst-Case. Meistens teilt das Pivot die Liste beispielsweise im Verhältnis 30:70 oder 60:40.

Um den Average-Case mathematisch exakt zu beweisen, greifen wir auf das Prinzip der Indikatorvariablen und der Harmonischen Reihe zurück. Wir fragen uns: Wie hoch ist die Wahrscheinlichkeit, dass zwei beliebige Elemente (nennen wir sie [math]\displaystyle{ z_i }[/math] und [math]\displaystyle{ z_j }[/math]) im Laufe des Algorithmus miteinander verglichen werden?

  • Zwei Elemente werden genau dann miteinander verglichen, wenn eines der beiden als Pivot-Element ausgewählt wird, bevor irgendein Element, dessen Wert zwischen den beiden liegt, als Pivot gewählt wird.
  • Sind die Daten völlig zufällig verteilt, ist die Wahrscheinlichkeit, dass von [math]\displaystyle{ k }[/math] Elementen genau das größte oder kleinste zuerst als Pivot gewählt wird, exakt [math]\displaystyle{ \frac{2}{k} }[/math].

Summieren wir diese Wahrscheinlichkeiten (Erwartungswert) für alle Elementpaare im Array auf, erhalten wir folgende Doppel-Summe: [math]\displaystyle{ E(X) = \sum_{i=1}^{n-1} \sum_{j=i+1}^{n} \frac{2}{j - i + 1} }[/math]

Setzt man für den Abstand [math]\displaystyle{ (j - i + 1) }[/math] eine neue Variable [math]\displaystyle{ k }[/math] ein, löst sich die innere Summe auf zu: [math]\displaystyle{ \sum_{k=2}^{n} \frac{2}{k} = 2 \cdot \left( \frac{1}{2} + \frac{1}{3} + \dots + \frac{1}{n} \right) }[/math]

Hier erkennen wir exakt die Harmonische Reihe ([math]\displaystyle{ H_n }[/math]) wieder, die wir bereits bei der Maximumsuche kennengelernt haben! Da die Harmonische Reihe für große [math]\displaystyle{ n }[/math] gegen den natürlichen Logarithmus [math]\displaystyle{ \ln(n) }[/math] konvergiert, ergibt die innere Summe ungefähr [math]\displaystyle{ 2 \cdot \ln(n) }[/math]. Da die äußere Summe [math]\displaystyle{ n }[/math]-mal läuft, erhalten wir insgesamt rund [math]\displaystyle{ 2n \cdot \ln(n) }[/math] Vergleiche.

Da Konstanten wie der Faktor 2 beim Groß-O ignoriert werden, ist nun stochastisch bewiesen: Fazit Average-Case: Auch bei zufälliger Verteilung konvergiert Quicksort dank des logarithmischen Wachstums der harmonischen Reihe sicher gegen die Komplexitätsklasse [math]\displaystyle{ \mathcal{O}(n \log n) }[/math].