$ sudo teach IT
Модуль 8 · Функции

Урок 8.12 — Рекурсия

Метод вызывает сам себя: факториал, Фибоначчи, стек вызовов и мемоизация.

Рекурсия — это когда метод вызывает сам себя. Звучит странно, но это мощный инструмент для решения задач, которые естественно делятся на подзадачи меньшего размера. Древовидные структуры, обход папок, вычисление факториала — всё это отлично решается рекурсией. Главное — не забыть про базовый случай, иначе рекурсия станет бесконечной и приведёт к StackOverflowException.

🔄

Концепция рекурсии

Рекурсия — это подход, при котором функция вызывает саму себя. Каждый рекурсивный вызов решает уменьшенную версию той же задачи, пока не достигнет базового случая — простейшего сценария, который решается без рекурсии.

Структура рекурсивного метода:

if (базовый случай) return результат;

else return рекурсивный_вызов(уменьшенные_данные);

Пример из жизни: чтобы положить матрёшку в матрёшку, ты открываешь самую большую, находишь внутри поменьше, открываешь её — и так до самой маленькой. Это рекурсия. Базовый случай — самая маленькая матрёшка, которая уже не открывается.

C# · Простейшая рекурсия
// Счёт от 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)

Условие, при котором рекурсия останавливается. Без него рекурсия будет бесконечной. Обычно это проверка на минимальное/нулевое значение, пустой список, ноль и т.д.

C#
if (n == 0) return 1;     // факториал 0 = 1
if (n <= 1) return n;     // числа Фибоначчи
if (list.Count == 0) return;  // пустой список

2. Рекурсивный случай (recursive case)

Вызов метода с уменьшенными данными. Результат рекурсивного вызова комбинируется с текущими данными для получения итогового результата.

C#
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 (базовый случай).

C# · Рекурсивный факториал
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).

C# · Рекурсивный Фибоначчи
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
⚠️ Наивная рекурсия Фибоначчи очень медленная! Для n=40 будет ~330 миллионов вызовов. Это классический пример, где нужна мемоизация или итеративный подход.
📚

Стек вызовов и StackOverflowException

Каждый вызов метода (включая рекурсивный) создаёт фрейм в стеке вызовов — область памяти, где хранятся локальные переменные и адрес возврата. Когда рекурсия слишком глубокая, стек переполняется и возникает StackOverflowException.

C# · Демонстрация переполнения стека
// ❌ Бесконечная рекурсия — 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)
💡 Размер стека в C# по умолчанию — 1 МБ для 32-bit, 4 МБ для 64-bit. Примерно 10 000–30 000 фреймов — предел для типичной рекурсии. Если больше — StackOverflowException.
💾

Мемоизация — кэширование результатов

Мемоизация — это техника оптимизации, при которой результаты дорогих вызовов сохраняются в кэше (обычно Dictionary) и повторно используются при одинаковых входных данных. Это превращает экспоненциальную сложность Фибоначчи в линейную.

C# · Фибоначчи с мемоизацией
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
  • Медленнее (накладные расходы на вызов)
  • Сложнее отлаживать

Итерация

  • Код длиннее, но понятнее
  • Нет риска переполнения стека
  • Быстрее (нет накладных расходов)
  • Легче отлаживать
  • Требует явного управления состоянием
C# · Факториал: рекурсия vs итерация
// Рекурсия
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: Что такое базовый случай в рекурсии?

A) Первый вызов рекурсивной функции
B) Условие, при котором рекурсия завершается
C) Самый глубокий уровень рекурсии
D) Результат рекурсивного вызова

Правильный ответ: B

Вопрос 2: Чему равно Factorial(5)?

A) 60
B) 100
C) 120
D) 150

Правильный ответ: C

Вопрос 3: Какое исключение возникает при переполнении стека?

A) OutOfMemoryException
B) NullReferenceException
C) StackOverflowException
D) ArithmeticException

Правильный ответ: C

Вопрос 4: Что такое мемоизация?

A) Удаление дубликатов из массива
B) Кэширование результатов рекурсивных вызовов
C) Оптимизация хвостовой рекурсии
D) Увеличение размера стека

Правильный ответ: B

Вопрос 5: Что быстрее — рекурсия или итерация для вычисления факториала?

A) Рекурсия всегда быстрее
B) Итерация быстрее (меньше накладных расходов)
C) Одинаково
D) Рекурсия быстрее для чисел больше 20

Правильный ответ: B

🧪

Задание

Обход файловой системы

Создай консольное приложение для подсчёта статистики файлов с помощью рекурсии:

  1. Напиши рекурсивный метод CountFiles(string directoryPath), который подсчитывает количество всех файлов во всех подпапках.
  2. Напиши рекурсивный метод FindLargestFile(string directoryPath), который находит самый большой файл во всех подпапках.
  3. Напиши рекурсивный метод GetTotalSize(string directoryPath), возвращающий общий размер всех файлов.
  4. Добавь итеративные версии этих методов для сравнения.
  5. Реализуй рекурсивный вывод структуры папок (дерево) с отступами.
  6. Проверь на папке с проектом — попробуй найти StackOverflowException (если глубокая структура).

Цель

Отработать рекурсивные методы, базовые случаи, стек вызовов, сравнение с итерацией.

Сложность

Средняя

📌

Что важно запомнить

1️⃣
Рекурсия — метод вызывает сам себя. Обязательно должны быть базовый случай и рекурсивный случай.
2️⃣
Базовый случай — условие остановки. Без него рекурсия уходит в бесконечность (StackOverflowException).
3️⃣
Стек вызовов — каждый рекурсивный вызов занимает память. Глубина ограничена (обычно ~10-30 тысяч вызовов).
4️⃣
Мемоизация (кэширование) превращает экспоненциальную сложность в линейную. Используй Dictionary для хранения результатов.
5️⃣
Рекурсия vs итерация: рекурсия элегантна для деревьев и графов, итерация — быстрее и безопаснее по памяти для простых вычислений.

Тест: 8.12: Рекурсия

3 вопроса

Рекурсия

Premium