Wszystkie wpisy

Rekurencja i matrioszki

Każda laleczka zawiera mniejszą wersję samej siebie - aż do tej, której nie da się otworzyć.


Matrioszka #

Dostajesz od babci matrioszkę - taką rosyjską drewnianą laleczkę. Otwierasz ją, a tam… jest mniejsza matrioszka. Otwierasz tę mniejszą - a tam jeszcze mniejsza. I tak dalej.

Ale w którymś momencie… dochodzisz do najmniejszej laleczki, której już nie da się otworzyć. To jest koniec. Pora wracać.

To właśnie jest rekurencja. Funkcja, która w środku wywołuje samą siebie - ale na mniejszym problemie.

Dwie zasady, których NIE WOLNO łamać #

Rekurencja bez tych dwóch rzeczy to katastrofa:

  1. Musi istnieć najmniejsza matrioszka (warunek bazowy) - inaczej otwierasz laleczki w nieskończoność i… crash, stack overflow. Komputer ma półkę na matrioszki, a ta półka jest ograniczona.
  2. Każda kolejna matrioszka musi być mniejsza od poprzedniej (krok redukujący) - inaczej otwierasz w kółko tę samą wielkość i znów nie dochodzisz do końca.

W kodzie: liczenie matrioszek #

function policzMatrioszki(matrioszka) {
  // Warunek bazowy: najmniejsza laleczka
  if (matrioszka.wSrodku === null) {
    return 1;
  }
  // Krok rekurencyjny: 1 (ta) + to, co jest w środku
  return 1 + policzMatrioszki(matrioszka.wSrodku);
}

Dlaczego to ma sens #

Pętla idzie po płaskim zlewie talerzy. Rekurencja wchodzi w głąb - drzew, struktur, zagnieżdżeń. Katalog z folderami? Komentarze do komentarzy? Drzewo DOM? Rekurencja.

Einstein powiedziałby: rekurencja to po prostu mówienie dziecku „zajrzyj, co jest w środku” - i powtarzanie tego, aż nie ma już czego zaglądać.

Dorosła wersja #

W pracy zobaczysz rekurencję m.in. przy:

Rekurencja jest eleganckim narzędziem, ale mała kuzynka pętli: jeśli nie rozumiesz dobrze warunku bazowego, wywali Ci produkcję o 3 w nocy.

Definicja #

Funkcja rekurencyjna to funkcja wywołująca samą siebie (bezpośrednio lub przez pośrednika - rekurencja wzajemna). Każde wywołanie tworzy nową ramkę (stack frame) z własnymi parametrami i zmiennymi lokalnymi.

Kontrakt - dwa elementy obowiązkowe #

int Factorial(int n)
{
    if (n <= 1) return 1;          // (1) base case
    return n * Factorial(n - 1);   // (2) recursive step z mniejszym inputem
}
  • Base case - warunek zakończenia. Bez niego stack overflow.
  • Recursive step - redukcja problemu w kierunku base case. Jeśli argument nie maleje, utkniesz.

Call stack - co siedzi pod spodem #

.NET alokuje wątkom domyślny stos (~1 MB na 64-bit). Każde wywołanie metody to ramka:

  • Adres powrotu.
  • Zapisane rejestry.
  • Parametry i zmienne lokalne.

Dla Factorial(1000) potrzebujesz 1000 ramek. Dla Factorial(1_000_000) - StackOverflowException, nie do złapania (proces pada).

Sprawdzenie na szybko:

try { Factorial(100_000); }
catch (StackOverflowException) { /* nie tu, proces umrze */ }

TCO - którego C# (prawie) nie ma #

Tail call optimization pozwala skompilować wywołanie rekurencyjne w ostatniej pozycji jako skok (zamiast push ramki). Wynik: O(1) stack dla rekurencji ogonowej.

JIT w .NET potrafi to robić (szczególnie w Release/x64), ale nie jest gwarantowane - spec języka nie wymaga. Dla stabilnego O(1) stosu przepisz na iterację:

// Rekurencyjnie - zależne od kaprysów JIT-a
int Sum(int n, int acc = 0) =>
    n == 0 ? acc : Sum(n - 1, acc + n);

// Iteracyjnie - zawsze O(1) stack
int SumIter(int n)
{
    int acc = 0;
    while (n > 0) { acc += n; n--; }
    return acc;
}

F# i inne języki funkcyjne gwarantują TCO - używaj rekurencji swobodnie. W C# - ostrożnie dla głębokich zejść.

Kiedy rekurencja jest naturalna #

Struktury rekurencyjne - drzewa, grafy, JSON, XML, AST, system plików. Iteracyjny kod dla drzewa wymaga ręcznego Stack<T>:

// title: TreeWalk.cs
record TreeNode(int Value, List<TreeNode> Children);

// Rekurencyjnie - czytelne
int Sum(TreeNode node)
{
    int total = node.Value;
    foreach (var child in node.Children)
        total += Sum(child);
    return total;
}

// Iteracyjnie z explicit stack
int SumIter(TreeNode root)
{
    var stack = new Stack<TreeNode>();
    stack.Push(root);
    int total = 0;
    while (stack.Count > 0)
    {
        var node = stack.Pop();
        total += node.Value;
        foreach (var child in node.Children)
            stack.Push(child);
    }
    return total;
}

Drugi wariant wygrywa tylko gdy drzewo jest wyjątkowo głębokie (ryzyko SOE).

Memoizacja - rekurencja z tablicą wyników #

Naiwny Fibonacci jest O(2^n):

long Fib(int n) => n < 2 ? n : Fib(n - 1) + Fib(n - 2);
// Fib(40) już wolne, Fib(50) - minuty

Problem: te same wartości liczone wielokrotnie. Memoizacja cache’uje wynik dla danego argumentu:

private readonly Dictionary<int, long> _cache = new() { [0] = 0, [1] = 1 };

long FibMemo(int n)
{
    if (_cache.TryGetValue(n, out var v)) return v;
    return _cache[n] = FibMemo(n - 1) + FibMemo(n - 2);
}

Złożoność spada do O(n) - każdy n liczony raz.

Divide and conquer - rekurencja z redukcją 2:1 #

Merge sort, quicksort, binary search - wszystkie dzielą problem na dwie połowy i rozwiązują rekurencyjnie. Złożoność opisana Master Theorem:

T(n) = 2 * T(n/2) + O(n)O(n log n) (merge sort).

Szczegóły w osobnym wpisie o merge sort.

Pułapki #

  • Shared mutable state - rekurencyjne metody modyfikujące pole klasy trudne do debugowania, szczególnie wielowątkowo.
  • Allocations - rekurencja na listach przez .Skip(1).ToList() tworzy O(n) kopii. Używaj indeksu / ReadOnlySpan<T>.
  • Captured closures - lambdy rekurencyjne (przez Func<> zmienną) alokują delegate + closure.
  • Deserializacja JSON / XML z referencjami cyklicznymi - bez MaxDepth lub detekcji cykli wali SOE na sprytnie sfabrykowanym inpucie. Bezpieczeństwo.

Kiedy wybrać co #

SytuacjaWybierz
Płaska iteracja po kolekcjipętla / LINQ
Drzewo / graf o nieznanej głębokościrekurencja (jeśli głębokość < kilku tysięcy)
Gwarantowana głęboka struktura (niskopoziomowy parser, deserializacja)iteracja z explicit stack
Problem “dzielony na dwa”rekurencja
Hot path, zero alokacjiiteracja

Skrótowo #

Rekurencja to kompaktowy zapis dla problemów samo-podobnych. Mechanicznie: stos wywołań + warunek bazowy + krok redukujący. W C# uważaj na brak gwarantowanego TCO, memoizuj gdy widzisz wykładniczy blowup, dla naprawdę głębokich struktur używaj explicit stack.

Podobne wpisy

🔗 Linkują tu