Rekurencja i backtracking w Javie z przykładami
Zaktualizowano 22 października 2026
Rekurencja w Javie oznacza, że metoda wywołuje samą siebie. Podstawową ideę możesz już znać z artykułu o metodach i rekurencji. W tym artykule zobaczysz, co dzieje się przy tym w pamięci, kiedy rekurencja pasuje lepiej niż pętla i jak za pomocą backtrackingu (metody z powrotami) systematycznie sprawdzić wszystkie możliwości: ustawienia, podzbiory, drogi przez labirynt.
Warunek stopu i przypadek rekurencyjny
Każda metoda rekurencyjna ma dwie części:
- Warunek stopu (przypadek bazowy) jest tak mały, że wynik jest od razu znany: liczba z jedną cyfrą, tekst z co najwyżej jednym znakiem, pusta tablica.
- Przypadek rekurencyjny sprowadza problem do mniejszej wersji tego samego problemu i w tym celu wywołuje samą metodę.
Warunek stopu zawsze sprawdzasz najpierw. W tablicach problem maleje, bo indeks przesuwa się dalej (index + 1), w tekstach przez substring(1), w liczbach przez n - 1 albo n / 10.
public class Main { public static int countDigits(int n) { if (n < 10) { return 1; } return 1 + countDigits(n / 10); }
public static String reverse(String s) { if (s.length() <= 1) { return s; } return reverse(s.substring(1)) + s.charAt(0); }
public static void main(String[] args) { System.out.println(countDigits(7)); System.out.println(countDigits(2026)); System.out.println(reverse("stack")); }}Stos wywołań
Każde wywołanie metody dostaje własny obszar na stosie wywołań (call stack), czyli stosie z własnymi parametrami i zmiennymi lokalnymi dla każdego wywołania. Gdy factorial(4) wywołuje samą siebie, na końcu leżą jedno na drugim cztery wywołania, każde z własnym n. Dopiero gdy najwyższe się skończy, to pod nim działa dalej.
Wynika z tego: co stoi przed wywołaniem rekurencyjnym, wykonuje się w kolejności wywołań, a co stoi po nim, w odwrotnej kolejności. Wcięcia w przykładzie pokazują, jak głęboki jest w danej chwili stos.
public class Main { public static int factorial(int n, String indent) { System.out.println(indent + "factorial(" + n + ") called"); int result; if (n <= 1) { result = 1; } else { result = n * factorial(n - 1, indent + " "); } System.out.println(indent + "factorial(" + n + ") returns " + result); return result; }
public static void main(String[] args) { factorial(4, ""); }}
Stos ma ograniczony rozmiar. Kilka tysięcy wywołań jedno na drugim to nie problem, miliony już tak: wtedy Java przerywa działanie z StackOverflowError.
Rekurencja czy pętla?
Wszystko, co potrafi pętla, potrafi też rekurencja, i odwrotnie. Obie sumy w przykładzie potrzebują O(n) kroków. Wersja rekurencyjna potrzebuje jednak dodatkowo O(n) miejsca na stosie, a pętla tylko jednej zmiennej.
Praktyczna zasada:
- Pętla, gdy po prostu przechodzisz tablicę albo listę od początku do końca.
- Rekurencja, gdy problem w naturalny sposób rozkłada się na podproblemy tego samego rodzaju: wieże Hanoi, drzewa, quicksort i sortowanie przez scalanie oraz wszystkie zadania, w których sprawdza się kolejne możliwości.
public class Main { public static int sumRecursive(int[] numbers, int index) { if (index == numbers.length) { return 0; } return numbers[index] + sumRecursive(numbers, index + 1); }
public static int sumLoop(int[] numbers) { int total = 0; for (int number : numbers) { total += number; } return total; }
public static void main(String[] args) { int[] numbers = {4, 8, 15, 16, 23, 42}; System.out.println(sumRecursive(numbers, 0)); System.out.println(sumLoop(numbers)); }}Backtracking: wybierz, zbadaj, cofnij
Backtracking buduje rozwiązanie krok po kroku. W każdym miejscu jest kilka możliwości. Dla każdej z nich obowiązuje ten sam schemat:
- Wybierz: ustal jedną możliwość, np. dopisz literę i oznacz ją jako użytą.
- Zbadaj: rekurencyjnie rozwiąż następny krok.
- Cofnij: odwróć wybór, żeby następna możliwość startowała z czystego stanu.
Gdy rozwiązanie jest kompletne (wszystkie litery ułożone, wyjście osiągnięte), zostaje wypisane, policzone albo zapisane. Jeśli częściowe rozwiązanie przestaje pasować (hetman zagrożony, ściana na drodze), od razu porzuca się tę gałąź. Właśnie dlatego backtracking jest szybszy niż ślepe sprawdzanie wszystkich kombinacji.
public class Main { static int found = 0;
public static void permute(char[] letters, boolean[] used, StringBuilder current) { if (current.length() == letters.length) { System.out.println(current); found++; return; } for (int i = 0; i < letters.length; i++) { if (used[i]) { continue; } used[i] = true; // choose current.append(letters[i]); permute(letters, used, current); // explore current.deleteCharAt(current.length() - 1); used[i] = false; // undo } }
public static void main(String[] args) { char[] letters = {'a', 'b', 'c'}; permute(letters, new boolean[letters.length], new StringBuilder()); System.out.println(found + " permutations"); }}Złożoność: szybko wykładnicza
Rozwiązania rekurencyjne, które w każdym wywołaniu wywołują same siebie dwa razy, szybko rosną. Wieże Hanoi potrzebują dla n krążków 2ⁿ − 1 ruchów, czyli O(2ⁿ). Każdy dodatkowy krążek podwaja pracę.
Tak samo jest z backtrackingiem: istnieje 2ⁿ podzbiorów i n! ustawień n elementów. Dla n = 20 to ponad milion podzbiorów i ponad 2 tryliony ustawień. Takie algorytmy nadają się tylko dla małych n. Porzucanie gałęzi, gdy tylko przestaje pasować, bardzo pomaga, ale często nie zmienia rzędu wielkości w najgorszym przypadku.
public class Main { static long moves = 0;
public static void move(int n, char from, char to, char via) { if (n == 0) { return; } move(n - 1, from, via, to); moves++; move(n - 1, via, to, from); }
public static void main(String[] args) { for (int n = 1; n <= 20; n += 4) { moves = 0; move(n, 'A', 'C', 'B'); System.out.println(n + " disks: " + moves + " moves"); } }}Typowe błędy
Brak warunku stopu albo zły warunek stopu
Bez warunku stopu metoda wywołuje samą siebie bez końca, aż pojawi się
StackOverflowError. Równie podstępny jest zły warunek stopu:if (n <= 1) return n;daje dlafactorial(0)wartość0zamiast1. Sprawdź warunek stopu na najmniejszych danych wejściowych, także na0i pustej tablicy.Problem się nie zmniejsza
sumFrom(numbers, index)zamiastsumFrom(numbers, index + 1)wywołuje się zawsze z tą samą wartością i nigdy nie dochodzi do warunku stopu. Każde wywołanie rekurencyjne musi zbliżać się do warunku stopu. Przyn - 2zamiastn - 1można go też przeskoczyć:n == 0nigdy nie trafi liczby nieparzystej,n <= 0już tak.Zapomniane cofnięcie
W backtrackingu każdy wybór trzeba po zbadaniu cofnąć: usunąć literę, ustawić
used[i]z powrotem nafalse, przywrócić komórkę labiryntu na'.'. Inaczej następna możliwość zacznie z pozostałościami poprzedniej. A jeśli zapisujesz częściowe rozwiązanie, zapisz kopię (new ArrayList<>(current)), bo sama lista za chwilę znowu się zmieni.
Zadania z tego tematu
Przećwicz to, czego się nauczyłeś, od razu w przeglądarce. Testy natychmiast sprawdzają twoje rozwiązanie.
Powiązane artykuły
- Metody i rekurencjaMetody w Javie w prosty sposób: parametry, return, przeciążanie, varargs i rekurencja z przykładami. Do tego typowe błędy, np. brakujący return.Czytaj artykuł
- Drzewa i kopceDrzewo binarne, drzewo BST, kopiec i sortowanie przez kopcowanie w Javie: przechodzenie drzewa, przykłady i typowe błędy na kolokwium.Czytaj artykuł
- Szybkie sortowanieSortowanie przez scalanie, quicksort i sortowanie przez zliczanie w Javie: przykłady kodu, złożoność, stabilność i typowe błędy na kolokwium.Czytaj artykuł