Рекурсия в Java
factorial(0) — вроде бы тривиальный случай: 0! = 1. Но если базовый случай написан как if (n == 1), метод не вернёт 1 — он упадёт с StackOverflowError. Рекурсия в Java прощает многое, кроме недостижимого базового случая.
Рекурсия в Java — это приём программирования, при котором метод вызывает сам себя для решения задачи. Рекурсивный метод разбивает исходную задачу на однотипные подзадачи меньшего размера и останавливается, когда достигает базового случая. Без базового случая вызовы не закончатся никогда, и программа завершится ошибкой StackOverflowError.
Что такое рекурсия в Java
Рекурсивный метод в Java — это метод, который вызывает сам себя напрямую или через цепочку других методов. Каждый такой вызов помещается в стек вызовов (call stack) и занимает память до тех пор, пока не вернёт результат.
Любая корректная рекурсия состоит из двух частей:
- базовый случай — условие, при котором метод возвращает результат без нового вызова себя;
- рекурсивный случай — метод вызывает сам себя с аргументом, который приближает вычисление к базовому случаю.
Задачи, где рекурсия выглядит естественнее цикла:
- обход деревьев (файловая система, DOM, JSON), графов и вложенных структур;
- алгоритмы «разделяй и властвуй»: быстрая сортировка, сортировка слиянием, бинарный поиск;
- перебор с возвратом (backtracking): расстановка ферзей, судоку, генерация перестановок;
- учебные математические задачи: факториал, числа Фибоначчи, сумма цифр, возведение в степень.
Как работает рекурсия: стек вызовов
При каждом вызове метода JVM создаёт в стеке вызовов новый кадр (stack frame). В кадре хранятся:
- аргументы метода;
- локальные переменные;
- адрес возврата — куда продолжить выполнение после завершения метода.
Кадр удаляется только тогда, когда метод завершился. Поэтому у рекурсии есть неочевидная особенность: пока вложенные вызовы не дойдут до базового случая, все промежуточные кадры одновременно висят в памяти.
Посмотрим, как разворачивается и сворачивается стек для factorial(5):
factorial(5) -> 5 * factorial(4) // кадр 1
factorial(4) -> 4 * factorial(3) // кадр 2
factorial(3) -> 3 * factorial(2)// кадр 3
factorial(2) -> 2 * factorial(1) // кадр 4
factorial(1) -> 1 // кадр 5: базовый случай, разворот назад
factorial(2) = 2 * 1 = 2
factorial(3) = 3 * 2 = 6
factorial(4) = 4 * 6 = 24
factorial(5) = 5 * 24 = 120 Умножение n * factorial(n - 1) выполняется после возврата вложенного вызова — именно поэтому кадр нельзя освободить раньше. Глубина стека здесь равна n.
Базовый случай и рекурсивный вызов
Базовый случай обязателен, и его недостаточно просто написать — он должен быть достижим для всех допустимых аргументов. Классический промах: базой объявлено n == 1, а метод вызывают с нулём или отрицательным числом. Условие никогда не сработает, аргумент уходит в минус, и стек переполняется.
Важно
Проверяйте базовый случай не равенством, а неравенством: if (n <= 1) вместо if (n == 1). Равенство «проскакивает», если аргумент вышел за ожидаемый диапазон, а неравенство перехватывает и граничные, и некорректные значения.
Факториал: рекурсивный метод
Факториал числа n равен произведению всех натуральных чисел от 1 до n:
n! = n × (n - 1) × (n - 2) × ... × 1, а по соглашению 0! = 1.
Рекурсивная реализация в Java:
public class RecursionExample {
static long factorial(int n) {
if (n < 0) {
throw new IllegalArgumentException("Факториал не определён для отрицательных чисел: " + n);
}
if (n <= 1) { // базовый случай: 0! = 1 и 1! = 1
return 1;
}
return n * factorial(n - 1); // рекурсивный случай
}
public static void main(String[] args) {
System.out.println("5! = " + factorial(5)); // 5! = 120
System.out.println("0! = " + factorial(0)); // 0! = 1
System.out.println("20! = " + factorial(20)); // 20! = 2432902008176640000
}
} Разбор:
n <= 1— базовый случай, он же обрабатывает0!;factorial(n - 1)— рекурсивный вызов с уменьшенным аргументом;- проверка
n < 0превращает бесконечную рекурсию в понятное исключение.
Следите за переполнением
Факториал растёт стремительно: 13! уже не помещается в int, а 21! — в long. Переполнение в Java происходит молча, без исключения: вы просто получите неверное (иногда отрицательное) число. Для больших значений используйте java.math.BigInteger.
Числа Фибоначчи и мемоизация
Последовательность Фибоначчи задаётся правилом F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2). Прямой перевод формулы в код выглядит так:
public class FibonacciExample {
static long fibonacci(int n) {
if (n <= 1) { // базовые случаи: F(0) = 0, F(1) = 1
return n;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
public static void main(String[] args) {
System.out.println(fibonacci(6)); // 8
}
} Код читается легко, но у него есть серьёзная проблема: два рекурсивных вызова на каждом шаге дают экспоненциальную сложность O(2n). Одни и те же значения пересчитываются снова и снова: fibonacci(40) выполняет более 300 миллионов вызовов и заметно «подвисает».
Лечится это мемоизацией — кешированием уже вычисленных значений. Сложность падает до O(n):
public class FibonacciMemo {
private static final long[] CACHE = new long[93]; // F(92) - максимум для long
static long fibonacci(int n) {
if (n <= 1) {
return n;
}
if (CACHE[n] != 0) { // значение уже посчитано
return CACHE[n];
}
CACHE[n] = fibonacci(n - 1) + fibonacci(n - 2);
return CACHE[n];
}
public static void main(String[] args) {
System.out.println(fibonacci(50)); // 12586269025 - мгновенно
}
} Тот же результат без рекурсии вообще — обычным циклом, за O(n) времени и O(1) памяти:
static long fibonacciIterative(int n) {
long prev = 0;
long curr = 1;
if (n <= 1) {
return n;
}
for (int i = 2; i <= n; i++) {
long next = prev + curr;
prev = curr;
curr = next;
}
return curr;
} Виды рекурсии: прямая, косвенная, хвостовая
Прямая рекурсия — метод вызывает сам себя (все примеры выше).
Косвенная (взаимная) рекурсия — метод A вызывает B, а B снова вызывает A:
static boolean isEven(int n) {
return n == 0 ? true : isOdd(n - 1);
}
static boolean isOdd(int n) {
return n == 0 ? false : isEven(n - 1);
} Хвостовая рекурсия — рекурсивный вызов является последней операцией метода, и после возврата делать уже нечего:
// хвостовая форма: результат накапливается в аргументе
static long factorialTail(int n, long acc) {
if (n <= 1) {
return acc;
}
return factorialTail(n - 1, n * acc); // ничего не выполняется после возврата
} В языках вроде Scala или Kotlin такой вызов компилятор превращает в цикл. В Java этого не происходит: спецификация JVM не гарантирует оптимизацию хвостовых вызовов (TCO), и HotSpot её не выполняет. Хвостовая форма в Java расходует стек ровно так же, как обычная рекурсия, и точно так же приводит к StackOverflowError. Это частый вопрос на собеседовании.
Рекурсия и итерация: что выбрать
Любую рекурсию можно переписать циклом, и наоборот. Разница — в читаемости и в расходе памяти.
| Критерий | Рекурсия | Итерация (цикл) |
|---|---|---|
| Память | O(глубины) в стеке вызовов: кадр на каждый вызов | O(1) дополнительной памяти |
| Скорость | Ниже: накладные расходы на вызов метода | Выше: нет вызовов методов |
| Риск отказа | StackOverflowError при большой глубине | Стек не растёт, переполнения нет |
| Читаемость | Выше для деревьев, графов, backtracking | Выше для линейных проходов и счётчиков |
| Когда применять | Вложенные и древовидные структуры, «разделяй и властвуй», заранее ограниченная глубина | Линейные вычисления, обработка больших объёмов данных, горячий код |
Итеративный факториал для сравнения — ни одного лишнего кадра в стеке:
static long factorialIterative(int n) {
long result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result;
} StackOverflowError и глубина рекурсии
StackOverflowError возникает, когда суммарный размер кадров превысил размер стека потока. Это Error, а не Exception: ловить его в catch и продолжать работу — плохая идея, состояние программы после переполнения не определено.
Что определяет предельную глубину:
- Размер стека потока. По умолчанию обычно 512 КБ – 1 МБ, задаётся флагом JVM
-Xss, например-Xss2m. Для отдельного потока размер можно передать в конструкторThread. - Размер кадра. Чем больше у метода параметров и локальных переменных, тем толще кадр и тем меньше вызовов помещается.
На практике простой метод выдерживает порядка нескольких тысяч — десятков тысяч вложенных вызовов. Точного числа не существует: оно зависит от JVM, платформы и самого метода, поэтому закладываться на конкретное значение нельзя.
java -Xss2m RecursionExample Увеличение -Xss — временная мера. Если глубина зависит от размера входных данных (обход дерева произвольной вложенности, разбор пользовательского JSON), надёжнее переписать алгоритм на цикл с явным стеком:
// обход дерева каталогов без рекурсии
static void printTree(File root) {
Deque<File> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
File current = stack.pop();
System.out.println(current.getName());
File[] children = current.listFiles();
if (children != null) {
for (File child : children) {
stack.push(child);
}
}
}
} Виртуальные потоки (Java 21+)
У виртуальных потоков стек хранится в куче и растёт по мере необходимости, поэтому флаг -Xss на них не влияет. Глубина рекурсии там ограничена доступной памятью, но и она конечна — StackOverflowError всё равно возможен. Виртуальные потоки не отменяют необходимости в корректном базовом случае.
На чём чаще всего ошибаются
- Нет базового случая или он недостижим (
n == 1вместоn <= 1) — мгновенныйStackOverflowError. - Аргумент не приближается к базе. Опечатка вроде
factorial(n)вместоfactorial(n - 1)даёт бесконечный цикл вызовов. - Экспоненциальное дублирование вызовов — наивный Фибоначчи. Решается мемоизацией или переходом на цикл.
- Глубина зависит от входных данных. Рекурсивный обход списка на миллион элементов упадёт, хотя на тестовых десяти работал.
- Общее изменяемое состояние. Статические поля, которые меняются между вызовами, ломают логику при возврате из рекурсии; передавайте состояние параметрами.
- Ловля
StackOverflowErrorвcatchвместо исправления алгоритма. - Циклические структуры данных. Обход графа со связями по кругу без множества посещённых узлов зациклит рекурсию.
Задача для практики
Напишите рекурсивные методы:
static int sumOfDigits(int n)— сумма цифр числа. Для1234результат10. Подсказка: базовый случайn < 10, рекурсивный шаг —n % 10 + sumOfDigits(n / 10).static String reverse(String s)— переворот строки. Базовый случай — пустая строка или строка из одного символа.static boolean isPalindrome(String s)— проверка палиндрома через сравнение первого и последнего символов.
Для каждого метода отдельно проверьте граничные значения: 0, пустую строку и null.
Заключение
Рекурсия в Java — способ описать задачу через саму себя, и в задачах на деревья, графы и перебор с возвратом она даёт самый короткий и понятный код. Плата за элегантность — кадр в стеке вызовов на каждый вызов, поэтому рекурсию выбирают там, где глубина ограничена и предсказуема.
Что важно запомнить:
- базовый случай обязателен и должен быть достижим для всех аргументов;
- каждый рекурсивный вызов обязан приближать вычисление к базовому случаю;
- JVM не оптимизирует хвостовую рекурсию — стек расходуется всегда;
- наивную рекурсию с повторяющимися вычислениями спасает мемоизация;
- если глубина зависит от входных данных — переходите на цикл с явным стеком.
Часто задаваемые вопросы
Почему рекурсия вызывает StackOverflowError и как это исправить?
Каждый вызов метода занимает кадр в стеке потока, и кадры освобождаются только при возврате. Когда суммарный размер кадров превышает размер стека, JVM бросает StackOverflowError. Сначала проверьте базовый случай и то, что аргумент действительно движется к нему. Если алгоритм корректен, но глубина велика, увеличьте стек флагом -Xss или перепишите обход на цикл с ArrayDeque.
Какая максимальная глубина рекурсии в Java?
Фиксированного лимита нет. Глубина зависит от размера стека потока (по умолчанию обычно 512 КБ – 1 МБ, настраивается флагом -Xss) и от размера кадра конкретного метода. Для простого метода это порядка нескольких тысяч или десятков тысяч вызовов, но значение меняется от JVM к JVM и от платформы к платформе, поэтому опираться на него в коде нельзя.
Оптимизирует ли Java хвостовую рекурсию?
Нет. Спецификация JVM не требует оптимизации хвостовых вызовов, и HotSpot её не выполняет. Даже если рекурсивный вызов стоит последней операцией метода, для него создаётся новый кадр стека. Поэтому в Java хвостовая форма не защищает от StackOverflowError, и при большой глубине её переписывают циклом вручную.
Что работает быстрее: рекурсия или цикл?
Цикл. Каждый рекурсивный вызов требует создания кадра стека, передачи аргументов и возврата, тогда как цикл работает с теми же переменными без вызовов методов. Разница обычно невелика и заметна лишь в горячем коде, но по памяти цикл выигрывает принципиально: O(1) против O(глубины) у рекурсии.
Как переписать рекурсию в итерацию?
Линейную рекурсию, как факториал или числа Фибоначчи, заменяют обычным циклом с накоплением результата. Для обхода деревьев и графов заводят явную структуру: ArrayDeque в роли стека для обхода в глубину или Queue для обхода в ширину. Алгоритм кладёт в неё узлы и обрабатывает их в цикле, пока она не опустеет, а стек потока при этом не растёт.
Видео объяснение
Предпочитаете видеоформат? Посмотрите этот урок с примерами и объяснениями.
Комментарии