Fortgeschrittene Algorithmen und Programmierung
Prof. Dr. Alexandra Mikityuk
HTW Berlin
Drei einfache Sortier-Algorithmen:
Alle drei sind O(n²) im schlechtesten Fall.
Bei 1 Mio. Elementen: ca. 10¹² Operationen → Stunden Laufzeit.
Quicksort und der Vergleich aller Sortier-Verfahren folgen im nächsten Lab — heute geht es um Rekursion + Merge Sort gründlich.
Konkrete Laufzeit bei 1 Milliarde Operationen pro Sekunde (eine moderne CPU):
| Eingabegröße n | O(n²) — Bubble/Selection | O(n log n) — Merge/Quick | Faktor |
|---|---|---|---|
| 100 | 0,00001 s | 0,0000007 s | 15× |
| 10 000 | 0,1 s | 0,0001 s | 1 000× |
| 1 000 000 | ~ 17 Minuten | 0,02 s | 50 000× |
| 100 000 000 | ~ 115 Tage | 2,7 s | ~ 3 Mio.× |
Ein uralter Trick — schon die Römer haben so regiert: divide et impera. In der Informatik heißt das:
Zerschneide das große Problem in zwei (oder mehr) kleinere Probleme — am besten gleich groß.
Löse jedes kleine Problem (oft rekursiv — also wieder mit derselben Methode).
Setze die Teil-Lösungen zur Gesamt-Lösung zusammen.
Wenn wir n=16 immer wieder halbieren, sind wir in 4 Schritten bei 1. Das ist log₂(16) = 4.
Du schlägst nicht Seite 1, 2, 3… auf. Du öffnest in der Mitte und schaust: zu früh oder zu spät? Dann halbierst du wieder.
Das ist Divide & Conquer. Aus Tausenden Seiten → nur ca. 10 Schritte.
WM mit 32 Mannschaften → erst 16 Spiele, dann 8, dann 4, dann 2, dann 1.
Nicht 32×31/2 = 496 Spiele (jeder gegen jeden), sondern nur 31. log-Verhalten in der Praxis.
Bevor wir Merge Sort und Quicksort schreiben — ein Konzept, das wir brauchen.
Eine Funktion ruft sich selbst auf — mit einem etwas kleineren Problem.
Das Bild dazu: russische Matrjoschka-Puppen. Jede enthält eine kleinere Version von sich selbst. Irgendwann ist die kleinste erreicht — und es geht zurück nach außen.
Mathematisch: 5! = 5 · 4 · 3 · 2 · 1 = 120. Aber auch: 5! = 5 · 4! — und dasselbe Muster wiederholt sich nach innen.
fakt(5) = 5 · fakt(4)
fakt(4) = 4 · fakt(3)
fakt(3) = 3 · fakt(2)
fakt(2) = 2 · fakt(1)
fakt(1) = 1 ← Basis-Fall!
Jeder Aufruf ist einfacher als der vorherige (n wird um 1 kleiner). Bei n=1 hören wir auf.
int fakt(int n) {
if (n <= 1) {
return 1; // Basis
}
return n * fakt(n - 1); // ruft sich selbst
}
fakt(5)? Die Funktion ruft sich selbst mit 4 auf → die ruft sich mit 3 auf → mit 2 → mit 1. Bei 1 ist Basis: gib 1 zurück. Dann „klappen" die Aufrufe von innen nach außen wieder zu: 2·1=2, 3·2=6, 4·6=24, 5·24=120.
Jeder Aufruf von fakt bekommt ein eigenes Stockwerk (Stack-Frame). Solange ein Aufruf noch wartet, bleibt sein Frame liegen — gestapelt wie Teller, der neueste oben drauf (push).
Sobald die Basis erreicht ist, „klappen" die Frames von oben nach unten wieder zu. Jeder Frame nutzt seine gepufferten Werte und das Ergebnis von oben, um seine eigene Rechnung fertigzustellen.
Drei Schritte — Schritt 2 ist rekursiv (genau das Konzept von eben):
n <= 1 → return 1-Fall bei der Fakultät.
Beispiel mit 8 Karten: [ 5, 2, 8, 1, 9, 3, 7, 4 ]
Bei 8 Karten: 3 Halbierungen (log₂ 8 = 3).
A = [5] · B = [2]
Vergleich 5 vs 2 → 2 ist kleiner → nimm 2 (aus B). B ist jetzt leer → Rest aus A übernehmen (die 5).
Ergebnis: [2, 5]
Genauso für die anderen Paare: [8]+[1] → [1,8], [9]+[3] → [3,9], [7]+[4] → [4,7]. Wir haben jetzt 4 sortierte 2-er-Listen.
A und B sind die Ergebnisse aus ① — sie wurden gerade selbst durch Merge erzeugt:
Jetzt verschmelzen wir A und B mit demselben Trick:
| A vorne | B vorne | nimm | Ergebnis |
|---|---|---|---|
| 2 | 1 | 1 (aus B) | [1] |
| 2 | 8 | 2 (aus A) | [1, 2] |
| 5 | 8 | 5 (aus A) | [1, 2, 5] |
| leer | 8 | Rest aus B | [1, 2, 5, 8] |
Jetzt wenden wir das Werkzeug von Slide 9 nicht nur einmal an, sondern immer wieder — Ebene für Ebene, bis das ganze Array eine sortierte Liste ist. Aufteilen + Trick allein reichen nicht; erst die Wiederholung des Tricks macht das Sortieren komplett.
✓ 3 Stufen · jede Stufe verschmilzt alle 8 Karten · zusammen ca. n × log n Vergleiche
Schauen wir das Aufteilen einmal von oben: aus einer großen Liste werden in jeder Stufe doppelt so viele, halb so große Häufchen.
3 Stufen bei n=8 — weil 8 = 2³.
Allgemein: log₂(n) Stufen. Bei n = 1 Mio. nur 20 Stufen.
Beim Verschmelzen werden auf jeder Stufe insgesamt alle n Karten angefasst — egal in wie viele Häufchen sie aufgeteilt sind.
Drei Zeilen sind das Herz: halbieren · sich selbst auf jede Hälfte aufrufen · verschmelzen.
// Sortiert den Abschnitt a[links..rechts]
function mergeSort(a, links, rechts):
if links >= rechts:
return // Basis-Fall: 0 oder 1 Element ist sortiert
mitte = (links + rechts) / 2
mergeSort(a, links, mitte) // linke Hälfte weiter teilen (sich selbst rufen)
mergeSort(a, mitte + 1, rechts) // rechte Hälfte weiter teilen
merge(a, links, mitte, rechts) // HIER wird sortiert: vergleichen + verschmelzen
// Hilfs-Funktion: verschmilzt a[links..mitte] und a[mitte+1..rechts]
// (genau der Trick aus Slide 9 — vorderste Karten vergleichen)
function merge(a, links, mitte, rechts):
// → siehe nächste Slide (C-Code)
mergeSort ruft sich zweimal selbst auf — mit der linken und der rechten Hälfte. Jede dieser Hälften wird wieder halbiert, und so weiter. Beim Basis-Fall (1 Element) macht die Funktion nichts — sie kehrt einfach zurück. Erst der Eltern-Aufruf verschmilzt: sobald seine zwei Kind-Aufrufe zurück sind, ruft er merge auf. So wird auf jeder Ebene nach den Rückkehrern verschmolzen.
Erst die Hilfs-Funktion: verschmilzt a[l..m] und a[m+1..r] — beide sind bereits sortiert.
void merge(int a[], int l, int m, int r) {
int hilfs[1024]; // Hilfs-Array für das Ergebnis
int i = l; // Zeiger auf vorderstes Element links
int j = m + 1; // Zeiger auf vorderstes Element rechts
int k = 0; // Schreib-Position in hilfs[]
// Solange beide Hälften noch Elemente haben: nimm das kleinere
while (i <= m && j <= r) {
if (a[i] <= a[j]) {
hilfs[k] = a[i];
i = i + 1;
} else {
hilfs[k] = a[j];
j = j + 1;
}
k = k + 1;
}
// Rest übernehmen (immer nur eine der beiden Schleifen läuft)
while (i <= m) { hilfs[k] = a[i]; i = i + 1; k = k + 1; }
while (j <= r) { hilfs[k] = a[j]; j = j + 1; k = k + 1; }
// Hilfs-Array zurück in a[l..r] kopieren
for (int x = 0; x < k; x = x + 1) {
a[l + x] = hilfs[x];
}
}
i und j sind die „Zeiger" auf die vordersten Karten der zwei Hälften — genau wie in der Tabelle auf Slide 9. Bei jedem Schritt rückt einer um 1 weiter.
Drei Zeilen Logik — exakt das Pseudocode-Muster von Slide 11. Die Funktion ruft sich für jede Hälfte selbst auf.
// Sortiert a[l..r] rekursiv
void merge_sort(int a[], int l, int r) {
if (l >= r) {
return; // Basis-Fall
}
int m = (l + r) / 2; // Mitte
merge_sort(a, l, m); // links weiter teilen
merge_sort(a, m + 1, r); // rechts weiter teilen
merge(a, l, m, r); // HIER wird sortiert!
}
// Aufruf für ein Array a der Länge n:
// merge_sort(a, 0, n - 1);
a — das Array (z.B. {5,2,8,1,9,3,7,4})l — linker Index (inklusiv)r — rechter Index (inklusiv)Bei den rekursiven Aufrufen bleibt a immer dasselbe Array — nur die Indizes verschieben sich.
merge_sort(a, 0, 7); // ✓ richtig
merge_sort(a[], 0, 7); // ✗ Fehler
merge_sort(a[0], 0, 7); // ✗ a[0] ist 5!
a alleine = ganzes Array · a[i] = ein Element.
merge_sort(a, 0, 7) (n=8): wird zu merge_sort(a, 0, 3) + merge_sort(a, 4, 7) + merge(a, 0, 3, 7). Wir schneiden das Array nicht zerteilt, sondern verschieben nur die Indizes. → wo das eigentliche Sortieren passiert, klären wir auf der nächsten Slide.
Beim Lesen der drei Zeilen merge_sort(...) · merge_sort(...) · merge(...) denkt jeder: „die ersten zwei machen doch nichts — die rufen sich nur selbst auf!" Genau richtig:
Vergleicht keine einzige Karte. Es zerlegt nur das Array immer weiter:
Hälfte → Hälfte → … → Einzel-Karte
Auch hier passiert nichts. Die Funktion sieht ein einzelnes Element, sagt „ist schon sortiert" und kehrt sofort per return zurück.
Das ist der einzige Ort, wo Elemente verglichen und in die richtige Reihenfolge gebracht werden — vergleichend ins Hilfs-Array geschrieben.
merge aufgerufen — von ganz unten (1+1 Karten) bis ganz oben (n/2 + n/2 Karten). Da entsteht die Ordnung. merge_sort liefert nur die Struktur (Aufteilung + Reihenfolge der merge-Aufrufe), merge erledigt die echte Arbeit.
Alles zusammen in einer Datei merge_sort.c — kopier-und-kompilier-fertig. Erinnerung: merge_sort teilt auf · merge sortiert beim Zusammenfügen.
#include <stdio.h>
// WERKZEUG: nimmt 2 sortierte Teile, macht daraus 1 sortierten Teil
void merge(int a[], int l, int m, int r) {
int hilfs[1024];
int i = l, j = m + 1, k = 0; // Zeiger links/rechts/hilfs
while (i <= m && j <= r) { // kleinere von beiden nehmen
if (a[i] <= a[j]) { hilfs[k] = a[i]; i++; }
else { hilfs[k] = a[j]; j++; }
k++;
}
while (i <= m) { hilfs[k] = a[i]; i++; k++; } // Rest links
while (j <= r) { hilfs[k] = a[j]; j++; k++; } // Rest rechts
for (int x = 0; x < k; x++) a[l + x] = hilfs[x];
}
// SORTIERER: ruft sich rekursiv für jede Hälfte auf, dann merge
void merge_sort(int a[], int l, int r) {
if (l >= r) return; // Basis: 0/1 Element ist sortiert
int m = (l + r) / 2;
merge_sort(a, l, m); // linke Hälfte weiter teilen
merge_sort(a, m + 1, r); // rechte Hälfte weiter teilen
merge(a, l, m, r); // HIER wird sortiert!
}
int main(void) {
int a[] = {5, 2, 8, 1, 9, 3, 7, 4};
int n = 8;
merge_sort(a, 0, n - 1); // ganzes Array sortieren
for (int i = 0; i < n; i++) printf("%d ", a[i]);
printf("\n"); // → 1 2 3 4 5 7 8 9
return 0;
}
$ gcc merge_sort.c -o ms
$ ./ms
1 2 3 4 5 7 8 9
merge muss vor merge_sort im Code stehen — sonst kennt der Compiler die Funktion noch nicht.hilfs[1024] ist hart auf 1024 Elemente begrenzt — größere Arrays brauchen malloc (kommt später).#includemerge (Werkzeug)merge_sort (Sortierer)main (Eingabe + Aufruf + Ausgabe)Jeder Aufruf halbiert die Liste.
Aus n → n/2 → n/4 → … → 1
Das geht genau log₂(n)-mal.
n=8 → 3 Ebenen
n=1024 → 10 Ebenen
n=1 000 000 → 20 Ebenen
Auf jeder Ebene werden insgesamt n Elemente verschmolzen — egal wie viele Teilstücke es sind.
Beispiel n=8:
n · log₂(n) Operationen. Für n = 1 000 000 sind das ~ 20 Mio. Operationen — eine moderne CPU schafft das in 0,02 Sekunden.
Operationen für verschiedene Eingabegrößen (Balkenlänge proportional zur Operations-Anzahl, log-skaliert):
| Algorithmus | Best | Average | Worst | Speicher | Stabil? | Wann? |
|---|---|---|---|---|---|---|
| Bubble Sort | O(n) | O(n²) | O(n²) | O(1) | ja | nur Lehre |
| Selection Sort | O(n²) | O(n²) | O(n²) | O(1) | nein | kleine n, wenig Swaps wichtig |
| Insertion Sort | O(n) | O(n²) | O(n²) | O(1) | ja | fast-sortierte Daten |
| Merge Sort | O(n log n) | O(n log n) | O(n log n) | O(n) | ja | wenn O(n log n) garantiert |
Standard-Library hat qsort — eine optimierte O(n log n)-Sortierung. Sehr schnell, aber instabil.
Eine Mischung aus Merge Sort + Insertion Sort, optimiert für realistische Daten. Stabil, O(n log n).
std::sort — eine hybride O(n log n)-Sortierung mit Garantie auch im Worst Case.
merge — merge_sort teilt nur die Arbeit auf.[6, 3, 8, 1, 5, 2, 7, 4] — Aufteilen und Verschmelzen, Stufe für Stufe.qsort-Standardfunktion in C an.
Fragen?
Prof. Dr. Alexandra Mikityuk
HTW Berlin · Büro Raum 308
Tel +49 30 5019-2664
Nächste Woche: Suchen & Hashing