Урок 8.12 — Рекурсия
Метод вызывает сам себя: факториал, Фибоначчи, стек вызовов и мемоизация.
Рекурсия — это когда метод вызывает сам себя. Звучит странно, но это мощный инструмент для решения задач, которые естественно делятся на подзадачи меньшего размера. Древовидные структуры, обход папок, вычисление факториала — всё это отлично решается рекурсией. Главное — не забыть про базовый случай, иначе рекурсия станет бесконечной и приведёт к StackOverflowException.
Концепция рекурсии
Рекурсия — это подход, при котором функция вызывает саму себя. Каждый рекурсивный вызов решает уменьшенную версию той же задачи, пока не достигнет базового случая — простейшего сценария, который решается без рекурсии.
Структура рекурсивного метода:
if (базовый случай) return результат;
else return рекурсивный_вызов(уменьшенные_данные);
Пример из жизни: чтобы положить матрёшку в матрёшку, ты открываешь самую большую, находишь внутри поменьше, открываешь её — и так до самой маленькой. Это рекурсия. Базовый случай — самая маленькая матрёшка, которая уже не открывается.
// Счёт от N до 1
static void CountDown(int n)
{
if (n <= 0) // базовый случай
return;
Console.WriteLine(n);
CountDown(n - 1); // рекурсивный вызов с уменьшенным n
}
CountDown(5);
// Вывод: 5 4 3 2 1
Базовый случай и рекурсивный случай
Два ключевых элемента любой рекурсии:
1. Базовый случай (base case)
Условие, при котором рекурсия останавливается. Без него рекурсия будет бесконечной. Обычно это проверка на минимальное/нулевое значение, пустой список, ноль и т.д.
if (n == 0) return 1; // факториал 0 = 1
if (n <= 1) return n; // числа Фибоначчи
if (list.Count == 0) return; // пустой список
2. Рекурсивный случай (recursive case)
Вызов метода с уменьшенными данными. Результат рекурсивного вызова комбинируется с текущими данными для получения итогового результата.
return n * Factorial(n - 1); // факториал
return Fibonacci(n - 1) + Fibonacci(n - 2); // Фибоначчи
Классический пример: факториал
Факториал числа n (обозначается n!) — это произведение всех натуральных чисел от 1 до n. Например, 5! = 5 x 4 x 3 x 2 x 1 = 120.
Рекурсивное определение: n! = n * (n-1)!, где 0! = 1 (базовый случай).
static int Factorial(int n)
{
// Базовый случай
if (n <= 1)
return 1;
// Рекурсивный случай
return n * Factorial(n - 1);
}
Console.WriteLine(Factorial(5)); // → 120
Console.WriteLine(Factorial(0)); // → 1
Console.WriteLine(Factorial(10)); // → 3628800
// Как это работает для Factorial(3):
// Factorial(3) = 3 * Factorial(2)
// Factorial(2) = 2 * Factorial(1)
// Factorial(1) = 1 ← базовый случай
// Factorial(2) = 2 * 1 = 2
// Factorial(3) = 3 * 2 = 6
Числа Фибоначчи
Последовательность Фибоначчи: 0, 1, 1, 2, 3, 5, 8, 13, 21... Каждое число равно сумме двух предыдущих. Рекурсивное определение: F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2).
static int Fibonacci(int n)
{
// Базовые случаи
if (n == 0) return 0;
if (n == 1) return 1;
// Рекурсивный случай
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
for (int i = 0; i <= 10; i++)
{
Console.Write($"{Fibonacci(i)} ");
}
// → 0 1 1 2 3 5 8 13 21 34 55
Стек вызовов и StackOverflowException
Каждый вызов метода (включая рекурсивный) создаёт фрейм в стеке вызовов — область памяти, где хранятся локальные переменные и адрес возврата. Когда рекурсия слишком глубокая, стек переполняется и возникает StackOverflowException.
// ❌ Бесконечная рекурсия — StackOverflowException!
static void InfiniteRecursion()
{
Console.WriteLine("Глубина...");
InfiniteRecursion(); // нет базового случая!
}
// ❌ Слишком глубокая рекурсия
static void DeepRecursion(int depth)
{
if (depth == 0) return;
DeepRecursion(depth - 1);
}
DeepRecursion(100000); // Скорее всего StackOverflowException
// Стек вызовов для Factorial(5):
// Call stack (сверху вниз):
// Factorial(1) → возвращает 1
// Factorial(2) → ждёт Factorial(1)
// Factorial(3) → ждёт Factorial(2)
// Factorial(4) → ждёт Factorial(3)
// Factorial(5) → ждёт Factorial(4)
// Main() → вызвал Factorial(5)
Мемоизация — кэширование результатов
Мемоизация — это техника оптимизации, при которой результаты дорогих вызовов сохраняются в кэше (обычно Dictionary) и повторно используются при одинаковых входных данных. Это превращает экспоненциальную сложность Фибоначчи в линейную.
static Dictionary<int, long> cache = new();
static long FibonacciMemo(int n)
{
// Базовые случаи
if (n == 0) return 0;
if (n == 1) return 1;
// Если уже вычисляли — возвращаем из кэша
if (cache.ContainsKey(n))
return cache[n];
// Вычисляем и сохраняем
cache[n] = FibonacciMemo(n - 1) + FibonacciMemo(n - 2);
return cache[n];
}
// Теперь миллион раз быстрее!
for (int i = 0; i <= 50; i++)
{
Console.WriteLine($"F({i}) = {FibonacciMemo(i)}");
}
// F(50) = 12586269025 — мгновенно!
Сравни: обычный рекурсивный Fibonacci(40) делает ~330 миллионов вызовов. С мемоизацией — всего 41 вызов (по одному на каждое n).
Рекурсия vs Итерация
Любую задачу, решаемую рекурсией, можно решить итеративно (циклом), и наоборот. Выбор зависит от ситуации.
Рекурсия
- Код короче и элегантнее
- Естественна для деревьев, графов
- Риск StackOverflow
- Медленнее (накладные расходы на вызов)
- Сложнее отлаживать
Итерация
- Код длиннее, но понятнее
- Нет риска переполнения стека
- Быстрее (нет накладных расходов)
- Легче отлаживать
- Требует явного управления состоянием
// Рекурсия
static int FactorialRec(int n)
{
if (n <= 1) return 1;
return n * FactorialRec(n - 1);
}
// Итерация
static int FactorialIter(int n)
{
int result = 1;
for (int i = 2; i <= n; i++)
result *= i;
return result;
}
// Когда использовать рекурсию:
// - Обход деревьев и графов
// - Задачи типа "разделяй и властвуй" (quicksort, mergesort)
// - Задачи с естественной рекурсивной структурой
// Когда использовать итерацию:
// - Простые вычисления (факториал, Фибоначчи)
// - Когда глубина рекурсии может быть большой
// - Когда производительность критична
Мини-тест
Вопрос 1: Что такое базовый случай в рекурсии?
Правильный ответ: B
Вопрос 2: Чему равно Factorial(5)?
Правильный ответ: C
Вопрос 3: Какое исключение возникает при переполнении стека?
Правильный ответ: C
Вопрос 4: Что такое мемоизация?
Правильный ответ: B
Вопрос 5: Что быстрее — рекурсия или итерация для вычисления факториала?
Правильный ответ: B
Задание
Обход файловой системы
Создай консольное приложение для подсчёта статистики файлов с помощью рекурсии:
- Напиши рекурсивный метод
CountFiles(string directoryPath), который подсчитывает количество всех файлов во всех подпапках. - Напиши рекурсивный метод
FindLargestFile(string directoryPath), который находит самый большой файл во всех подпапках. - Напиши рекурсивный метод
GetTotalSize(string directoryPath), возвращающий общий размер всех файлов. - Добавь итеративные версии этих методов для сравнения.
- Реализуй рекурсивный вывод структуры папок (дерево) с отступами.
- Проверь на папке с проектом — попробуй найти StackOverflowException (если глубокая структура).
Цель
Отработать рекурсивные методы, базовые случаи, стек вызовов, сравнение с итерацией.
Сложность
Средняя
Что важно запомнить
Тест: 8.12: Рекурсия
3 вопроса