Vorlesung 10 | Fortgeschrittene Algorithmen
"Um Rekursion zu verstehen, muss man zuerst Rekursion verstehen."
Rekursion ist eine Programmiertechnik, bei der eine Funktion sich selbst aufruft.
Beispiel aus dem Alltag:
Russische Matroschka-Puppen - jede Puppe enthält eine kleinere Version von sich selbst.
Puppe in Puppe in Puppe...
Unendliche Reflexion
Ordner in Ordner in Ordner...
Jeder Teil sieht aus wie das Ganze
Gemeinsam: Ein großes Problem besteht aus kleineren Versionen von sich selbst!
void countdown(int n) { if (n == 0) { // Basisfall printf("Start!\n"); return; } printf("%d...\n", n); countdown(n - 1); // Rekursionsfall } // Aufruf: countdown(3);
Ausgabe: 3... 2... 1... Start!
Der Call Stack ist wie ein Stapel Teller:
return macht, wird ihr "Teller" entferntStack wachst = Mehr Funktionen warten aufeinander = Mehr Speicher wird benutzt!
Ruckgabe (Stack wird abgebaut): countdown(0) endet zuerst → dann 1 → dann 2 → dann 3
Wie beim Tellerstapel: Der oberste Teller wird zuerst weggenommen!
Wer legt zuerst auf?
Onkel legt auf → ✓ fertig
Oma kann jetzt auflegen → ✓ fertig
Mama kann jetzt auflegen → ✓ fertig
Du legst zuletzt auf → ✓ fertig
Du hast zuerst angerufen, legst aber zuletzt auf!
void countdown(int n) { if (n == 0) { printf("Start!\n"); return; } printf("%d...\n", n); countdown(n - 1); // ⏳ WARTET HIER! // Erst wenn countdown(n-1) // fertig ist, geht es weiter }
countdown(3): "Ich habe 3 gedruckt, jetzt rufe ich countdown(2)... Ich bin BLOCKIERT bis countdown(2) zuruckkommt!"
Jeder rekursive Aufruf wird im Speicher gestapelt. Die Funktionen warten alle!
countdown(3) ← wartet im Speicher
countdown(2) ← wartet im Speicher
countdown(1) ← wartet im Speicher
countdown(0) ← Basis erreicht!
Jetzt werden die wartenden Funktionen ruckwarts abgearbeitet:
countdown(0) → return
countdown(1) → jetzt fertig!
countdown(2) → jetzt fertig!
countdown(3) → jetzt fertig!
void demo(int n) { if (n == 0) { printf("=== BASIS ===\n"); return; } // Phase 1: VOR dem Abtauchen printf("Runter: %d\n", n); demo(n - 1); // Abtauchen! // Phase 2: NACH dem Auftauchen printf("Hoch: %d\n", n); }
Runter: 3
Runter: 2
Runter: 1
=== BASIS ===
Hoch: 1
Hoch: 2
Hoch: 3
← Speicher fullt sich
← Speicher fullt sich
← Speicher fullt sich
← Wendepunkt!
← Speicher leert sich
← Speicher leert sich
← Speicher leert sich
Stack Overflow: Der Speicher für den Stack ist begrenzt (meist 1-8 MB). Bei zu vielen rekursiven Aufrufen stürzt das Programm ab!
| Tiefe | Status |
| 1 - 100 | ✓ Kein Problem |
| 100 - 1.000 | ✓ Meistens OK |
| 1.000 - 10.000 | ⚠️ Riskant |
| > 10.000 | ❌ Stack Overflow |
Lösung: Für sehr tiefe Rekursion → Iteration verwenden!
n! = n × (n-1) × (n-2) × ... × 1
5! = 5 × 4 × 3 × 2 × 1 = 120
Rekursive Definition:
n! = n × (n-1)!
0! = 1 (Basisfall)
int fakultaet(int n) { // Basisfall if (n == 0) { return 1; } // Rekursionsfall return n * fakultaet(n - 1); }
fakultaet(4) = 4 × fakultaet(3)
fakultaet(3) = 3 × fakultaet(2)
fakultaet(2) = 2 × fakultaet(1)
fakultaet(1) = 1 × fakultaet(0)
fakultaet(0) = 1 ← Basisfall!
fakultaet(1) = 1 × 1 = 1
fakultaet(2) = 2 × 1 = 2
fakultaet(3) = 3 × 2 = 6
fakultaet(4) = 4 × 6 = 24
int fakultaet(int n) { if (n == 0) { return 1; } return n * fakultaet(n - 1); } printf("%d", fakultaet(5));
Frage: Berechnet gemeinsam - was ist 5! ?
Ergebnis: 120
Ein Stapel (Stack) im Speicher, der für jeden Funktionsaufruf speichert:
Achtung: Jeder Aufruf braucht Speicher! Zu tiefe Rekursion führt zu Stack Overflow.
↑ Stapel wächst nach oben
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, ...
Regel: Jede Zahl ist die Summe der zwei vorherigen!
F(n) = F(n-1) + F(n-2)
F(0) = 0, F(1) = 1
int fibonacci(int n) { // Basisfälle if (n == 0) return 0; if (n == 1) return 1; // Rekursionsfall return fibonacci(n-1) + fibonacci(n-2); }
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
/ \
fib(2) fib(1)
Das Problem:
● fib(3) wird 2x berechnet
● fib(2) wird 3x berechnet
Wir berechnen dieselben Werte immer wieder!
Fibonacci ruft sich zweimal pro Aufruf auf:
fib(n) = fib(n-1) + fib(n-2)
Das erzeugt einen Baum, keinen Stapel!
| fib(10) | 177 Aufrufe |
| fib(20) | 21.891 Aufrufe |
| fib(40) | 331 Millionen Aufrufe! |
fib(40) dauert Minuten - obwohl das Ergebnis nur 102.334.155 ist!
int fib_iter(int n) { if (n <= 1) return n; int prev = 0, curr = 1; for (int i = 2; i <= n; i++) { int next = prev + curr; prev = curr; curr = next; } return curr; }
✓ n Schritte statt Millionen
✓ Kein Stack-Overflow-Risiko
✓ fib(40) in Mikrosekunden!
int cache[100] = {0}; // Speicher int fib_memo(int n) { if (n <= 1) return n; if (cache[n] != 0) return cache[n]; // Schon berechnet! cache[n] = fib_memo(n-1) + fib_memo(n-2); return cache[n]; }
✓ Bleibt rekursiv, aber speichert Ergebnisse
✓ Jeder Wert wird nur einmal berechnet
✓ Auch fib(40) in Mikrosekunden!
Wann welche Lösung?
Iteration wählen:
Wenn du nur das Ergebnis brauchst → einfachste & schnellste Lösung
Memoization wählen:
Wenn du mehrere Fibonacci-Werte brauchst (Cache wiederverwendbar) oder rekursive Struktur beibehalten willst
Regel: fib(n) = fib(n-1) + fib(n-2)
fib(6) = 8
Die Folge: 0, 1, 1, 2, 3, 5, 8, 13, 21...
void mystery(int n) { if (n <= 0) { return; } printf("%d ", n); mystery(n - 2); } mystery(7);
Was wird ausgegeben?
void mystery(int n) { if (n <= 0) { return; } printf("%d ", n); mystery(n - 2); // ← n-2, nicht n-1! }
Trick: n wird um 2 verringert → nur ungerade Zahlen!
int geheimnis(int n) { if (n == 1) { return 1; } return n + geheimnis(n - 1); } printf("%d", geheimnis(4));
Was wird ausgegeben?
1 + 2 + 3 + 4 = 10
Formel: n + (n-1) + ... + 1 = n×(n+1)/2
4 × 5 / 2 = 20 / 2 = 10 ✓
Berechne 4! = 4 × 3 × 2 × 1 = 24
int fak_rek(int n) { if (n == 0) return 1; return n * fak_rek(n-1); }
Phase 1: Abtauchen (Stack füllt sich)
Phase 2: Auftauchen (Ergebnisse zurück)
int fak_iter(int n) { int erg = 1; for(int i=1; i<=n; i++) erg *= i; return erg; }
Direkte Berechnung (kein Stack nötig)
| Schritt | i | erg | Rechnung |
|---|---|---|---|
| Start | - | 1 | Initialisierung |
| 1 | 1 | 1 | 1 × 1 = 1 |
| 2 | 2 | 2 | 1 × 2 = 2 |
| 3 | 3 | 6 | 2 × 3 = 6 |
| 4 | 4 | 24 | 6 × 4 = 24 |
Beispiel: Alle Dateien in Ordnern zählen (inkl. Unterordner)
Denkweise:
"Zähle Dateien hier + frag jeden Unterordner rekursiv"
Einfach wie im echten Leben!
Du musst dir selbst merken, wo du warst:
Problem: Du brauchst eine "To-Do-Liste" für noch nicht besuchte Ordner!
Fazit: Bei Rekursion merkt sich der Computer automatisch, wo er war. Bei Iteration musst du das selbst organisieren!
Goldene Regel: Jede Rekursion kann in Iteration umgewandelt werden - aber manchmal ist Rekursion viel einfacher zu verstehen!
// FALSCH! int falsch(int n) { return n * falsch(n-1); }
Was passiert bei falsch(3)?
Stack Overflow! Kein Stopp!
// FALSCH! int falsch2(int n) { if (n == 0) return 1; return n * falsch2(n); // n-1 vergessen! }
Was passiert bei falsch2(3)?
Stack Overflow! n bleibt 3!
Checkliste vor dem Coden:
Schritt-für-Schritt-Anleitung zum "Rekursion tracen"
Schreibe den ersten Aufruf auf ein Blatt
Ist n == 0? Wenn ja, return 1
Schreibe den nächsten Aufruf eingerückt
Von unten nach oben die ? ersetzen
Tipp: Nimm Papier und Stift! Rekursion "im Kopf" zu tracen ist schwer - aufschreiben macht es einfach.
int fak(int n) { printf("fak(%d) aufgerufen\n", n); if (n == 0) return 1; return n * fak(n-1); }
int fak(int n) { if (n == 0) return 1; int erg = n * fak(n-1); printf("fak(%d) = %d\n", n, erg); return erg; }
Wenn dein Programm einfriert: Es gibt wahrscheinlich keinen Basisfall oder keinen Fortschritt!
void test(int n) { if (n > 0) { printf("%d ", n); // ← ZUERST drucken test(n - 1); // ← DANN abtauchen } } test(4);
Ausgabe: 4 3 2 1
Druckt beim Abtauchen (groß → klein)
void test(int n) { if (n > 0) { test(n - 1); // ← ZUERST abtauchen printf("%d ", n); // ← DANN drucken } } test(4);
Ausgabe: 1 2 3 4
Druckt beim Auftauchen (klein → groß)
Merksatz: Code vor dem rekursiven Aufruf → läuft beim "Abtauchen" | Code nach dem Aufruf → läuft beim "Auftauchen"!
int summe(int n) { return n + summe(n - 1); } printf("%d", summe(5));
Was passiert?
int summe(int n) { if (n == 0) return 0; // ← Basisfall! return n + summe(n - 1); }
Merke: IMMER zuerst den Basisfall schreiben!
int power(int basis, int exp) { if (exp == 0) { return 1; } return basis * power(basis, exp - 1); } printf("%d", power(2, 4));
Was wird ausgegeben?
24 = 2 × 2 × 2 × 2 = 16
Basisfall: exp == 0 → return 1
Rekursion: basis × power(basis, exp-1)
Das ist dein Basisfall. Bei Fakultät: n=0 → return 1
Bei Fakultät: Um n! zu berechnen, brauche ich nur (n-1)!
Bei Fakultät: n! = n × (n-1)! → also return n * fak(n-1)
int rekursiv(int n) { if (/* Basisfall erreicht? */) return /* einfache Lösung */; return /* kombiniere n mit rekursiv(kleiner) */; }
Die Funktion wird "pausiert" und wartet auf das Ergebnis des Unteraufrufs
LIFO = Last In, First Out → Der letzte Aufruf endet zuerst
Das Ergebnis geht zurück an den wartenden Aufruf darunter
Abtauchen
⬇️
Stack wächst
Basisfall
🛑
Wendepunkt!
Auftauchen
⬆️
Stack schrumpft
Bevor du den rekursiven Teil schreibst, definiere: "Wann hört die Rekursion auf?"
Der rekursive Aufruf muss das Problem verkleinern, sonst: Endlosschleife!
Probiere fak(0), fak(1), fak(2) aus - nicht gleich fak(100)!
Merksatz: "Löse das Problem, indem du es auf ein kleineres Problem zurückführst!"
Das einfachste Beispiel
n! = n × (n-1)!
Vorsicht: Doppelte Berechnung!
Tipp für die Klausur: Immer zuerst Basisfall und Rekursionsfall identifizieren!
Nächste Vorlesung: Sortieralgorithmen