Rekurencja — metoda, która wywołuje samą siebie

Niektóre zadania najłatwiej opisać przez nie same: silnia z liczby n to n razy silnia z n−1. Poznasz metody, które wywołują siebie, i nauczysz się pilnować, żeby kiedykolwiek się zatrzymały.

C# rekurencja stos wywołań 50 min
CEL LEKCJI

Czego się dziś nauczysz

  • Zapiszesz metodę rekurencyjną z warunkiem bazowym i krokiem
  • Prześledzisz na kartce kolejne wywołania i kolejność powrotów
  • Wyjaśnisz, czym jest stos wywołań i kiedy się przepełnia
  • Zamienisz prostą rekurencję na pętlę i odwrotnie
  • Ocenisz, kiedy rekurencja pomaga, a kiedy tylko kosztuje

Przygotowanie: lekcje 01–20. Przewidywany czas: 45–90 minut z zadaniami. Przykłady wymagają .NET 8 lub nowszego, z włączonymi ImplicitUsings i Nullable.

TEORIA

Metoda, która wywołuje samą siebie

Wyobraź sobie, że stoisz w kolejce i chcesz wiedzieć, które masz miejsce. Zamiast liczyć wszystkich przed sobą, pytasz osobę stojącą przed tobą: „które masz miejsce?”. Ona pyta następną, ta następną — aż ktoś odpowie „jestem pierwszy”. Wtedy odpowiedzi wracają: druga, trzecia, czwarta… aż do ciebie.

Tak działa rekurencja: metoda rozwiązuje mniejszą wersję tego samego zadania, wywołując samą siebie. Każda taka metoda musi mieć dwie rzeczy:

  • warunek bazowy — sytuacja, w której odpowiedź znamy od razu i nie wywołujemy siebie („jestem pierwszy”);
  • krok rekurencyjny — wywołanie siebie z mniejszym zadaniem, które zbliża nas do warunku bazowego.
definicja metody
static long Silnia(int n)
{
    if (n <= 1)
    {
        return 1;              // WARUNEK BAZOWY - tu rekurencja sie konczy
    }

    return n * Silnia(n - 1);  // KROK REKURENCYJNY - mniejsze zadanie
}

Silnia liczby 4 to 4 · 3 · 2 · 1. Metoda liczy to tak:

przebieg — na kartce
Silnia(4)  ->  4 * Silnia(3)
Silnia(3)  ->  3 * Silnia(2)
Silnia(2)  ->  2 * Silnia(1)
Silnia(1)  ->  1                 <- warunek bazowy, koniec schodzenia

potem wyniki wracaja w gore:
Silnia(2) = 2 * 1  = 2
Silnia(3) = 3 * 2  = 6
Silnia(4) = 4 * 6  = 24

Dwa kierunki

Rekurencja zawsze ma dwie fazy: schodzenie (kolejne wywołania, coraz mniejsze zadanie) i wracanie (obliczanie wyników w odwrotnej kolejności). Instrukcje zapisane przed wywołaniem rekurencyjnym wykonują się w drodze w dół, a te po nim — w drodze w górę. To najczęstsze źródło zaskoczenia przy pierwszych programach.

TEORIA

Skąd komputer wie, gdzie wrócić

Każde wywołanie metody dostaje własny komplet parametrów i zmiennych lokalnych. Środowisko odkłada je na stos wywołań i zdejmuje przy powrocie. Dlatego n w Silnia(4) i n w Silnia(3) to dwie różne zmienne, choć nazywają się tak samo.

Stos ma ograniczony rozmiar. Rekurencja bez warunku bazowego — albo taka, która się do niego nie zbliża — zapełni go i program przerwie się wyjątkiem StackOverflowException. Tego wyjątku nie da się przechwycić przez try: proces po prostu kończy pracę.

jak NIE pisać
static int Zla(int n)
{
    return n + Zla(n - 1);   // BRAK warunku bazowego - program padnie
}

static int TezZla(int n)
{
    if (n == 0) return 0;
    return n + TezZla(n + 1);  // warunek jest, ale krok ODDALA sie od zera
}

Sprawdź trzy rzeczy przed uruchomieniem

1. Czy jest warunek bazowy? 2. Czy każde wywołanie rekurencyjne dostaje argument bliższy warunkowi bazowemu? 3. Czy warunek bazowy na pewno zostanie trafiony — także dla danych ujemnych, zera i pustego napisu?

PRZYKŁAD

Przykład: cztery klasyczne rekurencje

Wklej cały przykład do nowego projektu konsolowego jako Program.cs.

Program.cs
class Program
{
    // 1. silnia
    static long Silnia(int n)
    {
        if (n <= 1) return 1;
        return n * Silnia(n - 1);
    }

    // 2. suma cyfr liczby
    static int SumaCyfr(int n)
    {
        if (n < 10) return n;
        return n % 10 + SumaCyfr(n / 10);
    }

    // 3. odwrocenie napisu
    static string Odwroc(string tekst)
    {
        if (tekst.Length <= 1) return tekst;
        return Odwroc(tekst.Substring(1)) + tekst[0];
    }

    // 4. najwiekszy wspolny dzielnik - algorytm Euklidesa
    static int Nwd(int a, int b)
    {
        if (b == 0) return a;
        return Nwd(b, a % b);
    }

    static void Main()
    {
        Console.WriteLine($"Silnia(6)      = {Silnia(6)}");
        Console.WriteLine($"SumaCyfr(48317)= {SumaCyfr(48317)}");
        Console.WriteLine($"Odwroc(\"ROBOT\") = {Odwroc("ROBOT")}");
        Console.WriteLine($"Nwd(84, 36)    = {Nwd(84, 36)}");
    }
}
wynik w konsoli
Silnia(6)      = 720
SumaCyfr(48317)= 23
Odwroc("ROBOT") = TOBOR
Nwd(84, 36)    = 12

Co dzieje się po kolei

  • SumaCyfr(48317) odcina ostatnią cyfrę operatorem %, a resztę liczby przekazuje dalej przez / 10. Warunek bazowy to liczba jednocyfrowa.
  • Odwroc najpierw odwraca ogon napisu, a dopiero potem dokłada pierwszą literę na koniec. Dlatego litera R ląduje na końcu wyniku.
  • Nwd nie zmniejsza argumentu o jeden, tylko podmienia parę liczb. Krok też jest zmniejszaniem — reszta z dzielenia zawsze jest mniejsza od dzielnika.
  • Typ long w silni nie jest ozdobą: Silnia(21) przekracza zakres long i wynik cicho się przekręci.
TEORIA

Rekurencja czy pętla

Każdą rekurencję da się zapisać pętlą, a każdą pętlę — rekurencją. Wybieramy to, co czytelniejsze dla konkretnego zadania.

ZadanieLepsza pętlaLepsza rekurencja
Suma liczb od 1 do ntak — jedna zmienna i licznikdziała, ale bez zysku
Przejście po katalogach na dyskutrudnetak — katalog zawiera katalogi
Wieże Hanoi, fraktale, drzewabardzo trudnetak — zadanie samo dzieli się na mniejsze
Sortowanie szybkie i przez scalanietrudnetak — dziel i zwyciężaj
Odliczanie do zeratakdziała, ale zużywa stos

Jest jeszcze pułapka kosztu. Naiwny ciąg Fibonacciego wywołuje się dwa razy w każdym kroku:

Program.cs
static int Fib(int n)
{
    if (n < 2) return n;
    return Fib(n - 1) + Fib(n - 2);   // dwa wywolania z jednego
}

Dla n = 40 to ponad trzysta milionów wywołań, bo te same wartości liczone są w kółko. Wersja z pętlą i dwiema zmiennymi daje wynik natychmiast. Rekurencja nie jest z natury wolniejsza — wolne jest powtarzanie tej samej pracy.

Prześledź na kartce, zanim uruchomisz

Wypisz kolejne wywołania z argumentami tak jak w tabelce przy silni. Jeśli nie potrafisz zapisać, kiedy rekurencja się zatrzyma, program też tego nie będzie wiedział.

ELEMENTY WBUDOWANE

Użyte w tej lekcji elementy

ElementZwracaDziałanie i ograniczenia
return w warunku bazowymwartośćKończy bieżące wywołanie i oddaje wynik do miejsca, z którego przyszło wywołanie. Bez niego rekurencja nie ma jak się zatrzymać.
tekst.Substring(i)stringNowy napis od indeksu i do końca. Wersja Substring(i, n) bierze n znaków. Napisu źródłowego nie zmienia; indeks poza zakresem daje ArgumentOutOfRangeException.
tekst[0]charPierwszy znak napisu. Dla pustego napisu wyjątek — dlatego warunek bazowy sprawdza Length <= 1.
a % bliczbaReszta z dzielenia. W algorytmie Euklidesa gwarantuje, że drugi argument maleje.
StackOverflowExceptionZgłaszany, gdy zabraknie miejsca na stosie wywołań. Nie da się go przechwycić instrukcją try — program kończy działanie.
longtypOsiem bajtów. W silni potrzebny już od n = 13, bo wynik nie mieści się w int.
CZĘSTE BŁĘDY

Na co uważać

ZapisProblem
Brak return w warunku bazowymMetoda schodzi do warunku bazowego i… idzie dalej do kroku rekurencyjnego. Efekt taki sam jak przy braku warunku.
Silnia(n) zamiast Silnia(n - 1)Argument się nie zmniejsza — nieskończone wywołania i przerwanie programu.
Warunek if (n == 1) przy ujemnym argumencieDla -3 warunek nigdy nie będzie prawdziwy. Bezpieczniejsze jest if (n <= 1) albo osobne odrzucenie danych ujemnych.
Wypisywanie zamiast zwracaniaMetoda rekurencyjna zwykle ma zwracać wynik. Metoda, która tylko wypisuje, nie da się użyć w większym obliczeniu.
Naiwny Fib dla dużych nWykładnicza liczba wywołań. Program nie zawiesza się — po prostu liczy godzinami.

Zadania

ZAD 1Potęga★☆☆

Napisz metodę Potega(int podstawa, int wykladnik) liczącą potęgę rekurencyjnie, bez Math.Pow. Warunek bazowy: wykładnik zero daje jeden. Sprawdź 2⁰, 2¹⁰ i 5³.

ZAD 2Odliczanie w dwie strony★☆☆

Napisz metodę void Odliczaj(int n), która wypisuje liczby od n do 1. Następnie przenieś instrukcję wypisującą za wywołanie rekurencyjne i wyjaśnij, dlaczego kolejność się odwróciła.

ZAD 3Palindrom★★☆

Napisz metodę bool Palindrom(string tekst) sprawdzającą rekurencyjnie, czy napis czyta się tak samo w obie strony. Porównuj pierwszy znak z ostatnim i przekazuj dalej środek. Sprawdź kajak, ala, abc i napis pusty.

ZAD 4Suma cyfr aż do jednej★★☆

Napisz metodę int SumaCyfrDoJednej(int n), która sumuje cyfry liczby, a potem powtarza sumowanie, dopóki wynik nie będzie jednocyfrowy: 48317 → 23 → 5. Zrób to bez pętli — obie czynności mają być rekurencyjne.

Sprawdź dla 48317, 99999, 7 i 0. Zastanów się, dla jakiej liczby metoda wykona najwięcej wywołań.

ZAD 5Fibonacci — dwa światy★★☆

Napisz obie wersje ciągu Fibonacciego: rekurencyjną i pętlą. Dodaj do wersji rekurencyjnej statyczny licznik wywołań. Uruchom dla n równego 10, 20 i 30 i zapisz, ile razy metoda została wywołana.

ZAD 6Wieże Hanoi★★★

Przenieś n krążków z pręta A na C, korzystając z B. Zasada: przenosimy po jednym krążku i nigdy nie kładziemy większego na mniejszy. Metoda ma wypisywać kolejne ruchy w postaci A -> C. Policz ruchy dla 3, 4 i 5 krążków i sprawdź wzór 2ⁿ − 1.

PODSUMOWANIE

Co trzeba zapamiętać

  • Metoda rekurencyjna potrzebuje warunku bazowego i kroku, który zbliża się do tego warunku.
  • Wywołania odkładane są na stosie; każde ma własne parametry i zmienne lokalne.
  • Brak albo nieosiągalny warunek bazowy kończy się StackOverflowException, którego nie przechwycisz.
  • Instrukcje przed wywołaniem rekurencyjnym działają w drodze w dół, po nim — w drodze w górę.
  • Rekurencja jest naturalna tam, gdzie zadanie samo dzieli się na mniejsze takie same zadania.
  • Powtarzanie tych samych obliczeń (naiwny Fibonacci) jest kosztowne — wtedy lepsza jest pętla albo zapamiętywanie wyników.

Dokumentacja: Microsoft Learn — temat tej lekcji.