Рекурсия в Java - Вопросы
Всего: 5 вопросов
1. Что такое рекурсия в Java и из каких двух обязательных частей состоит рекурсивный метод?
Что такое рекурсия в Java и из каких двух обязательных частей состоит рекурсивный метод?
Рекурсия в Java — это приём, при котором метод вызывает сам себя (напрямую или через цепочку других методов), разбивая задачу на однотипные подзадачи меньшего размера.
Любой корректный рекурсивный метод состоит из двух частей:
1. Базовый случай — условие, при котором метод возвращает результат без нового вызова себя. Он обязателен и должен быть достижим для всех допустимых аргументов: базу пишут неравенством if (n <= 1), а не равенством if (n == 1), иначе вызов с нулём или отрицательным числом «проскочит» базу и приведёт к StackOverflowError.
2. Рекурсивный случай — метод вызывает сам себя с аргументом, который приближает вычисление к базовому случаю.
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); // рекурсивный случай
}
Рекурсия естественнее цикла для обхода деревьев и графов, алгоритмов «разделяй и властвуй» (быстрая сортировка, сортировка слиянием, бинарный поиск) и перебора с возвратом (backtracking).
2. Как работает стек вызовов при рекурсии, почему возникает StackOverflowError и какая максимальная глубина рекурсии в Java?
Как работает стек вызовов при рекурсии, почему возникает StackOverflowError и какая максимальная глубина рекурсии в Java?
При каждом вызове метода JVM создаёт в стеке вызовов новый кадр (stack frame) с аргументами, локальными переменными и адресом возврата. Кадр удаляется только тогда, когда метод завершился, поэтому при рекурсии все промежуточные кадры одновременно висят в памяти, пока вызовы не дойдут до базового случая.
StackOverflowError возникает, когда суммарный размер кадров превысил размер стека потока. Это Error, а не Exception: ловить его в catch и продолжать работу нельзя — состояние программы после переполнения не определено.
Фиксированного лимита глубины не существует. Она зависит от:
- размера стека потока — по умолчанию обычно 512 КБ – 1 МБ, задаётся флагом JVM
-Xss(например,java -Xss2m RecursionExample); - размера кадра — чем больше у метода параметров и локальных переменных, тем меньше вызовов помещается в стек.
На практике простой метод выдерживает порядка нескольких тысяч — десятков тысяч вложенных вызовов, но закладываться на конкретное число нельзя. Увеличение -Xss — временная мера: если глубина зависит от входных данных, алгоритм переписывают на цикл.
У виртуальных потоков (Java 21+) стек хранится в куче и растёт по мере необходимости, поэтому -Xss на них не влияет, но и там глубина конечна.
3. Какие бывают виды рекурсии и оптимизирует ли Java хвостовую рекурсию?
Какие бывают виды рекурсии и оптимизирует ли Java хвостовую рекурсию?
Прямая рекурсия — метод вызывает сам себя.
Косвенная (взаимная) рекурсия — метод 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);
}
Java хвостовую рекурсию не оптимизирует. Спецификация JVM не требует оптимизации хвостовых вызовов (TCO), и HotSpot её не выполняет: для каждого вызова всё равно создаётся новый кадр стека. В Scala и Kotlin компилятор превращает такой вызов в цикл, в Java — нет. Поэтому хвостовая форма в Java не защищает от StackOverflowError, и при большой глубине её переписывают циклом вручную. Это частый вопрос на собеседовании.
4. Почему наивный рекурсивный расчёт чисел Фибоначчи такой медленный и что такое мемоизация?
Почему наивный рекурсивный расчёт чисел Фибоначчи такой медленный и что такое мемоизация?
Наивная реализация делает два рекурсивных вызова на каждом шаге, из-за чего одни и те же значения пересчитываются снова и снова. Сложность получается экспоненциальной — O(2n): fibonacci(40) выполняет более 300 миллионов вызовов и заметно подвисает.
static long fibonacci(int n) {
if (n <= 1) { // базовые случаи: F(0) = 0, F(1) = 1
return n;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
Мемоизация — кеширование уже вычисленных значений, чтобы каждое считалось ровно один раз. Сложность падает до O(n):
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];
}
Тот же результат можно получить вообще без рекурсии — обычным циклом за O(n) времени и O(1) памяти.
5. Чем рекурсия отличается от итерации, что выбрать и как переписать рекурсию в цикл?
Чем рекурсия отличается от итерации, что выбрать и как переписать рекурсию в цикл?
Любую рекурсию можно переписать циклом, и наоборот. Разница — в памяти, скорости и читаемости:
- Память: рекурсия — O(глубины) в стеке вызовов (кадр на каждый вызов), цикл — O(1).
- Скорость: цикл быстрее, у рекурсии есть накладные расходы на вызов метода и возврат.
- Риск отказа: рекурсия при большой глубине падает с
StackOverflowError, у цикла стек не растёт. - Читаемость: рекурсия выигрывает на деревьях, графах и backtracking, цикл — на линейных проходах.
Линейную рекурсию (факториал, Фибоначчи) заменяют обычным циклом с накоплением результата:
static long factorialIterative(int n) {
long result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result;
}
Для обхода деревьев и графов заводят явный стек: ArrayDeque для обхода в глубину или Queue для обхода в ширину. Вложенность переезжает из стека потока в кучу, и глубина ограничена уже доступной памятью, а не флагом -Xss:
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);
}
}
}
}