Learning Platform
Глоссарий Troubleshooting
Урок 18.01 · 25 мин
Начальный
recursionbase-casememoizationfibonaccifactorial

Что такое рекурсия

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

  1. Base case — условие остановки. Случай, который мы можем решить без обращения к рекурсии.
  2. Recursive step — шаг рекурсии. Случай, где мы сводим задачу к меньшей версии той же задачи.

Без base case рекурсия будет бесконечной и взорвёт стек. Без recursive step это уже не рекурсия. Обе части обязательны.

Классический пример — факториал. n! = 1 * 2 * 3 * ... * n. Рекурсивно это:

  • factorial(0) = 1 — base case.
  • factorial(n) = n * factorial(n - 1) — recursive step.
def factorial(n):
    if n == 0:
        return 1                    # base case
    return n * factorial(n - 1)     # recursive step

print(factorial(5))   # 120

Каждый вызов сводит задачу к меньшей. factorial(5) зовёт factorial(4), который зовёт factorial(3), и так до factorial(0). Потом стек разворачивается обратно, перемножая значения.

Дерево вызовов factorial(4)

Каждый вызов делает один рекурсивный вызов до достижения base case. Глубина равна аргументу.

вызовfactorial(4)ждёт результат factorial(3) чтобы умножить на 4
вызываетfactorial(3)depth 1
вызываетfactorial(2)depth 2
вызываетfactorial(1)depth 3
вызываетfactorial(0)depth 4, base case, возвращает 1
разворот1 * 1 = 1factorial(1) возвращает 1 * factorial(0) = 1 * 1 = 1
2 * 1 = 2factorial(2) возвращает 2 * factorial(1) = 2 * 1 = 2
3 * 2 = 6factorial(3) возвращает 3 * factorial(2) = 3 * 2 = 6
итог4 * 6 = 24factorial(4) возвращает 4 * factorial(3) = 4 * 6 = 24

Когда рекурсия удобна

Рекурсия естественна для задач, в которых сама структура — рекурсивная:

  • Деревья. Дерево состоит из узлов, каждый узел содержит поддеревья. Обход — это рекурсия.
  • Графы (DFS). Поиск в глубину естественно записывается через рекурсию.
  • Divide-and-conquer. Merge sort, quicksort, binary search — все они «разбей пополам и реши на половинах».
  • Парсинг. Парсер JSON / выражений — естественно рекурсивный.
  • Backtracking. Перебор с откатами — N-queens, sudoku — рекурсия с состоянием.

Если задача имеет структуру «решить большую через меньшие той же формы» — рекурсия подходит идеально. Если же задача — линейный проход с накапливаемым состоянием — итерация обычно лучше.

Фибоначчи: рекурсия без мемоизации

Числа Фибоначчи: F(0) = 0, F(1) = 1, F(n) = F(n-1) + F(n-2). Прямая рекурсивная реализация:

def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(10))   # 55
print(fib(30))   # 832040 — заметная пауза
print(fib(40))   # очень долго, секунды

Проблема: вычисление fib(40) занимает несколько секунд. fib(50) уже минуты. Почему? Потому что рекурсия здесь создаёт дерево вызовов, а не линейную цепочку:

fib(5)
├── fib(4)
│   ├── fib(3)
│   │   ├── fib(2)
│   │   │   ├── fib(1) = 1
│   │   │   └── fib(0) = 0
│   │   └── fib(1) = 1
│   └── fib(2)
│       ├── fib(1) = 1
│       └── fib(0) = 0
└── fib(3)
    ├── fib(2)
    │   ├── fib(1) = 1
    │   └── fib(0) = 0
    └── fib(1) = 1

Заметьте: fib(2) вычисляется три раза, fib(1) — пять раз. Полное число вызовов растёт как сами числа Фибоначчи: T(n) = F(n), экспоненциально. Это O(phi^n), где phi ~ 1.618. Для n = 40 это около 100 миллионов вызовов.

Мемоизация: O(2^n) превращается в O(n)

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

def fib_memo(n, cache=None):
    if cache is None:
        cache = {}
    if n in cache:
        return cache[n]
    if n < 2:
        return n
    cache[n] = fib_memo(n - 1, cache) + fib_memo(n - 2, cache)
    return cache[n]

print(fib_memo(100))   # 354224848179261915075 — мгновенно

Теперь каждое значение fib(k) вычисляется ровно один раз — итого O(n) вызовов. На 1000 раз быстрее на n = 40.

В Python есть встроенный декоратор:

from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(500))   # вычисляется за миллисекунды

lru_cache хранит результаты в dict (где ключ — аргументы), и при повторном вызове возвращает кеш. Это decorator pattern — никак не меняет код функции, добавляет автоматический кеш.

Без мемоизации vs с мемоизацией

Мемоизация превращает экспоненциальную сложность в линейную.

без мемоO(phi^n)экспоненциальный рост — каждый узел дерева вычисляется заново
n=40~10^8 вызововнесколько секунд CPU
с мемоO(n)каждое значение fib(k) вычисляется ровно один раз
n=40~40 вызововмиллисекунды
с мемоO(n)линейный рост
n=10001000 вызововмиллисекунды, но упирается в setrecursionlimit

Анатомия рекурсивной функции

Шаблон корректной рекурсивной функции:

def recursive_function(arg):
    # 1. Проверка base case
    if base_condition(arg):
        return base_value

    # 2. Уменьшение задачи
    smaller_arg = reduce(arg)

    # 3. Рекурсивный вызов
    sub_result = recursive_function(smaller_arg)

    # 4. Комбинирование
    return combine(arg, sub_result)

Где могут быть ошибки:

  • Забыли base case — бесконечная рекурсия, RecursionError или stack overflow.
  • Base case не достижим — например, ждём n == 0, но передаём n - 2 для нечётного n. Никогда не достигнем 0, упадём на отрицательных.
  • Не уменьшаем задачуrecursive_function(arg) зовёт recursive_function(arg). Бесконечный цикл.
  • Не комбинируем результат — забыли использовать sub_result, функция возвращает None или мусор.

Каждый рекурсивный вызов должен приближаться к base case. Это инвариант, который надо держать в голове.

TIP

Перед тем как писать рекурсию, проговорите вслух: “base case — это X. Recursive step — задача размера N сводится к задаче размера N-1 (или N/2) операцией Y.” Если не получается проговорить — рекурсия не подходит, нужна итерация.

Бинарный поиск рекурсивно

В прошлом модуле мы делали бинарный поиск итеративно. Рекурсивная форма:

def binary_search(xs, target, lo=0, hi=None):
    if hi is None:
        hi = len(xs)

    # base case: пустой диапазон
    if lo >= hi:
        return -1

    mid = lo + (hi - lo) // 2

    # base case: нашли
    if xs[mid] == target:
        return mid

    # recursive step: половина диапазона
    if xs[mid] < target:
        return binary_search(xs, target, mid + 1, hi)
    return binary_search(xs, target, lo, mid)

Логика та же, но граничные условия выражены как base case. Какая форма лучше — итеративная или рекурсивная? Для бинарного поиска большинство профессионалов выбирают итеративную:

  1. Глубина рекурсии log(n), что хорошо, но добавляет overhead на каждый вызов (создание stack frame).
  2. Итеративный код проще оптимизировать.
  3. В Python tail recursion не оптимизируется — об этом будет урок дальше.

Но для деревьев и графов рекурсия часто читается лучше — это вопрос натуральности структуры.

Замеры

Сравним рекурсивный fib без мемо, с мемо и итеративный:

import timeit
from functools import lru_cache

def fib_naive(n):
    if n < 2:
        return n
    return fib_naive(n - 1) + fib_naive(n - 2)

@lru_cache(maxsize=None)
def fib_memo(n):
    if n < 2:
        return n
    return fib_memo(n - 1) + fib_memo(n - 2)

def fib_iter(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

n = 30
print(f"naive: {timeit.timeit(lambda: fib_naive(n), number=10):.3f} s")
print(f"memo:  {timeit.timeit(lambda: fib_memo(n), number=10):.6f} s")
print(f"iter:  {timeit.timeit(lambda: fib_iter(n), number=10):.6f} s")

Типичные результаты: naive около 3 секунд, memo около 0.0001 секунды, iter около 0.00005 секунды. Итеративная версия немного быстрее мемоизованной из-за отсутствия overhead на dict-lookup, но обе на много порядков быстрее наивной.

Попробуй сам

Реализуйте рекурсивный обход списка и сумму его элементов:

def sum_recursive(xs, i=0):
    """Сумма xs, начиная с индекса i."""
    # base case: дошли до конца
    if i == len(xs):
        return 0
    # recursive step: текущий + сумма остальных
    return xs[i] + sum_recursive(xs, i + 1)

print(sum_recursive([1, 2, 3, 4, 5]))   # 15

Сравните глубину рекурсии с длиной списка. Попробуйте sum_recursive(list(range(1000))) — это работает. А list(range(10000)) — RecursionError, потому что лимит стека по умолчанию 1000. Об этом — в следующих уроках.

Декораторы и functools в CPython: lru_cache под капотом
Проверка знанийKnowledge check
Почему наивная рекурсивная реализация Фибоначчи fib(40) занимает несколько секунд, хотя fib(40) = 102334155 — небольшое число? Объясни, как мемоизация превращает экспоненциальную сложность в линейную.
ОтветAnswer
Наивный fib(n) строит ДЕРЕВО вызовов, а не цепочку. fib(40) вычисляет fib(39) и fib(38). fib(39) вычисляет fib(38) и fib(37). Заметьте — fib(38) вычисляется дважды. fib(37) вычисляется три раза. Общее число вызовов растёт как сами числа Фибоначчи: T(n) ~ phi^n, где phi ~ 1.618. Для n=40 это ~10^8 вызовов, каждый создаёт stack frame. Память и CPU тратятся на пересчёт одних и тех же значений. Мемоизация хранит результат каждого fib(k) в dict. При повторном вызове с тем же k возвращается кеш — O(1) вместо нового рекурсивного дерева. Итого каждое значение fib(0)..fib(n) вычисляется ровно один раз, общее число вызовов O(n). На fib(40) это 40 операций вместо 100 миллионов — ускорение в 2.5 миллиона раз.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Что обязательно должна содержать любая корректная рекурсивная функция?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5