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.
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.
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.
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:
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.
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ę.
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: cztery klasyczne rekurencje
Wklej cały przykład do nowego projektu konsolowego jako 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)}");
}
}
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.Odwrocnajpierw odwraca ogon napisu, a dopiero potem dokłada pierwszą literę na koniec. Dlatego literaRląduje na końcu wyniku.Nwdnie zmniejsza argumentu o jeden, tylko podmienia parę liczb. Krok też jest zmniejszaniem — reszta z dzielenia zawsze jest mniejsza od dzielnika.- Typ
longw silni nie jest ozdobą:Silnia(21)przekracza zakreslongi wynik cicho się przekręci.
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.
| Zadanie | Lepsza pętla | Lepsza rekurencja |
|---|---|---|
| Suma liczb od 1 do n | tak — jedna zmienna i licznik | działa, ale bez zysku |
| Przejście po katalogach na dysku | trudne | tak — katalog zawiera katalogi |
| Wieże Hanoi, fraktale, drzewa | bardzo trudne | tak — zadanie samo dzieli się na mniejsze |
| Sortowanie szybkie i przez scalanie | trudne | tak — dziel i zwyciężaj |
| Odliczanie do zera | tak | działa, ale zużywa stos |
Jest jeszcze pułapka kosztu. Naiwny ciąg Fibonacciego wywołuje się dwa razy w każdym kroku:
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ł.
Użyte w tej lekcji elementy
| Element | Zwraca | Działanie i ograniczenia |
|---|---|---|
return w warunku bazowym | wartość | 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) | string | Nowy 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] | char | Pierwszy znak napisu. Dla pustego napisu wyjątek — dlatego warunek bazowy sprawdza Length <= 1. |
a % b | liczba | Reszta z dzielenia. W algorytmie Euklidesa gwarantuje, że drugi argument maleje. |
StackOverflowException | — | Zgłaszany, gdy zabraknie miejsca na stosie wywołań. Nie da się go przechwycić instrukcją try — program kończy działanie. |
long | typ | Osiem bajtów. W silni potrzebny już od n = 13, bo wynik nie mieści się w int. |
Na co uważać
| Zapis | Problem |
|---|---|
Brak return w warunku bazowym | Metoda 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 argumencie | Dla -3 warunek nigdy nie będzie prawdziwy. Bezpieczniejsze jest if (n <= 1) albo osobne odrzucenie danych ujemnych. |
| Wypisywanie zamiast zwracania | Metoda rekurencyjna zwykle ma zwracać wynik. Metoda, która tylko wypisuje, nie da się użyć w większym obliczeniu. |
Naiwny Fib dla dużych n | Wykładnicza liczba wywołań. Program nie zawiesza się — po prostu liczy godzinami. |
Zadania
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³.
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.
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.
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ń.
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.
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.
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.