Урок курса

Рекурсия: базовый случай, стек вызовов и трассировка

Алгоритмы и структуры данных на Python

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

Базовый случай и рекурсивный шаг

Рекурсивная функция — это функция, которая в теле вызывает себя. Звучит как определение через само себя, и именно здесь кроется главный вопрос: когда она остановится?

Остановка гарантируется базовым случаем — условием, при котором функция возвращает результат, не делая нового вызова. Всё остальное тело — рекурсивный шаг: вызов с аргументом, который строго «меньше» исходного в каком-то измеримом смысле.

Посмотрим на простой пример без конкретной реализации. Допустим, задача — обработать натуральное число n. Тогда структура выглядит так:

def f(n):
    if n == 0:          # базовый случай
        return 0
    return 1 + f(n - 1) # рекурсивный шаг: n уменьшается на 1

Ключевое слово здесь — строго. Аргумент рекурсивного шага должен быть ближе к базовому случаю, чем текущий. Если n уменьшается на 1 при каждом вызове и база — n == 0, то цепочка вызовов конечна: за ровно n шагов мы её достигнем.

Что будет, если база есть, но аргумент не приближается к ней? Например:

def broken(n):
    if n == 0:
        return 0
    return broken(n)   # передаём тот же n — бесконечный цикл

Python в этом случае не будет выполнять вызовы бесконечно: при достижении настроенного предела глубины он выбросит RecursionError. Текущее значение предела можно узнать через sys.getrecursionlimit(). Но суть ошибки не техническая, а логическая: рекурсивный шаг не сужает задачу.

А что, если базового случая нет вовсе?

def also_broken(n):
    return also_broken(n - 1)   # n → -∞, остановки нет

Тот же RecursionError, другая причина: нет условия выхода.

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

Стек вызовов и ручная трассировка

Когда одна функция вызывает другую, Python сохраняет текущее состояние выполнения — локальные переменные, аргументы, адрес точки возврата — в структуре, которая называется фреймом. Фреймы складываются в стек: каждый новый вызов кладёт фрейм сверху, каждый возврат снимает его.

При рекурсии происходит то же самое. Каждый вызов функции самой себя — новый фрейм с собственной копией аргументов. Они живут одновременно: пока не завершён самый глубокий вызов, все предыдущие фреймы ждут в стеке.

Как трассировать вручную. Возьмём абстрактную функцию f(n), которая вызывает f(n-1). Трассировка состоит из двух фаз.

Спуск — фиксируем вызовы по порядку:

f(4) вызывает f(3)
f(3) вызывает f(2)
f(2) вызывает f(1)
f(1) вызывает f(0)
f(0) → база, возвращает результат

Подъём — фиксируем возвраты в обратном порядке:

f(0) вернул r0
f(1) получил r0, вернул r1
f(2) получил r1, вернул r2
f(3) получил r2, вернул r3
f(4) получил r3, вернул r4

В пике стека одновременно активны фреймы f(4), f(3), f(2), f(1), f(0) — пять фреймов для n = 4. В общем случае для f(n) одновременно живут n + 1 фрейм.

Для этой абстрактной f(n) каждый фрейм хранит константный объём локального состояния, а максимальная глубина равна n + 1. Поэтому память стека равна Θ(n). В общем случае одного подсчёта фреймов недостаточно: фрейм может удерживать объект переменного размера, и такую память нужно учитывать отдельно.

Важно не путать глубину стека с количеством всех вызовов. Вызовов тоже n + 1, но они идут последовательно: в каждый момент в стеке присутствуют только ещё не завершившиеся фреймы. Для линейной рекурсии это все вызовы сразу — они вложены один в другой. Именно поэтому глубина и суммарное число вызовов совпадают.

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

Рекурсивный факториал и сумма списка: реализация и анализ сложности

Факториал — классический первый пример, и это не случайно: его структура буквально совпадает с шаблоном из предыдущей секции.

def factorial(n: int) -> int:
    if n < 0:
        raise ValueError("factorial определён только для n >= 0")
    if n == 0:          # база: 0! = 1
        return 1
    return n * factorial(n - 1)  # шаг: n! = n · (n-1)!

Трассировка для factorial(4):

Спуск:
  factorial(4) → 4 * factorial(3)
  factorial(3) → 3 * factorial(2)
  factorial(2) → 2 * factorial(1)
  factorial(1) → 1 * factorial(0)
  factorial(0) → 1

Подъём:
  factorial(0) = 1
  factorial(1) = 1 * 1 = 1
  factorial(2) = 2 * 1 = 2
  factorial(3) = 3 * 2 = 6
  factorial(4) = 4 * 6 = 24

В пике стека живут 5 фреймов. В модели, где одно умножение считается элементарной операцией, выполняется Θ(n) умножений; глубина и память стека равны Θ(n). Реальное время умножения больших целых зависит от числа цифр, поэтому эта оценка относится к числу шагов рекурсии.


Теперь сумма списка — и здесь скрыта нетривиальная ловушка.

def sum_list_slice(lst: list) -> int:
    if not lst:           # база: пустой список
        return 0
    return lst[0] + sum_list_slice(lst[1:])  # срез создаёт новый список

Выглядит аккуратно, но lst[1:] — это не указатель на хвост, это копия хвоста. На уровне рекурсии k живёт срез длины n - k. Пока рекурсия идёт вниз, все эти срезы находятся в памяти одновременно:

Уровень 0: lst длиной n  (оригинал, хранится снаружи)
Уровень 1: срез длиной n-1
Уровень 2: срез длиной n-2
...
Уровень n-1: срез длиной 1
Уровень n: пустой список

Суммарная память срезов: (n-1) + (n-2) + … + 1 + 0 = n(n-1)/2 = Θ(n²). Время копирования — тоже Θ(n²), потому что каждый срез создаётся за время, пропорциональное его длине. Глубина стека при этом остаётся Θ(n), но общая пространственная сложность определяется квадратичными срезами.

Исправление простое: передавать индекс вместо среза.

def sum_list(lst: list, i: int = 0) -> int:
    if i == len(lst):       # база: индекс вышел за конец
        return 0
    return lst[i] + sum_list(lst, i + 1)  # шаг: i строго растёт

Здесь никаких копий нет. Все фреймы держат только целое число i и ссылку на один и тот же список. Время — Θ(n), пространство стека — Θ(n), дополнительная память на данные — O(1).

Проверим согласованность:

assert sum_list([1, 2, 3, 4, 5]) == 15
assert sum_list([]) == 0
assert sum_list_slice([1, 2, 3, 4, 5]) == 15

Оба варианта дают правильный результат, но первый делает это квадратичной ценой. Это типичная ситуация: рекурсивная запись может быть корректной, но скрывать неочевидные затраты на промежуточные структуры данных.

Анимация роста и сворачивания стека рекурсивных вызовов при вычислении факториала четырёх
Анимация роста и сворачивания стека рекурсивных вызовов при вычислении факториала четырёх

Рекурсивный бинарный поиск: логарифмическая глубина стека

У факториала и суммы списка глубина стека растёт линейно — с каждым шагом аргумент уменьшается на 1. Бинарный поиск устроен иначе: на каждом шаге область поиска уменьшается вдвое. Посмотрим, что это даёт стеку.

def binary_search(arr: list, target: int, lo: int, hi: int) -> int:
    """Ищет target в отсортированном arr[lo:hi]. Возвращает индекс или -1."""
    if lo >= hi:                        # база: область пуста
        return -1
    mid = lo + (hi - lo) // 2
    if arr[mid] == target:              # база: нашли
        return mid
    if arr[mid] < target:
        return binary_search(arr, target, mid + 1, hi)   # правая половина
    return binary_search(arr, target, lo, mid)           # левая половина

Вызов: binary_search(arr, target, 0, len(arr)).

Проверка:

arr = [1, 3, 5, 7, 9, 11, 13]
assert binary_search(arr, 7, 0, len(arr)) == 3
assert binary_search(arr, 6, 0, len(arr)) == -1
assert binary_search(arr, 1, 0, len(arr)) == 0
assert binary_search(arr, 13, 0, len(arr)) == 6

Почему глубина O(log n). Инвариант: каждый рекурсивный вызов получает область [lo, hi) с размером hi - lo. После вычисления mid один из двух вызовов получит область размером не более ⌊(hi - lo) / 2⌋. Значит, размер уменьшается как минимум вдвое на каждом уровне:

Уровень 0: область размером n
Уровень 1: область ≤ n/2
Уровень 2: область ≤ n/4
...
Уровень k: область ≤ n/2ᵏ

После k делений размер области не превосходит ⌊n / 2ᵏ⌋. Если считать и завершающий вызов с пустой областью, для n >= 1 максимальное число одновременных фреймов не превосходит ⌊log₂ n⌋ + 2; для пустого массива это один фрейм. Асимптотически глубина равна O(log n).

Для массива из 1 000 000 элементов линейная рекурсия (как у factorial) потребовала бы около миллиона одновременных фреймов. Неуспешный бинарный поиск использует не более 21 фрейма. Это и есть практическая ценность логарифмической глубины: пространственная сложность по стеку — O(log n) вместо O(n).

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

Сравнение итогов:

Алгоритм Глубина стека Память стека
factorial(n) n + 1 Θ(n)
sum_list с индексом n + 1 Θ(n)
binary_search ≤ ⌊log₂ n⌋ + 2 при n ≥ 1 O(log n)

Задача 1

Рекурсивная функция sum_list(lst, idx=0) вызвана как sum_list([2, 5, 3]). Сколько фреймов одновременно находится в стеке вызовов в момент достижения базового случая?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Горный эхолот

Геологическая экспедиция добралась до узкого горного ущелья, где из-за отвесных стен обычные методы съёмки рельефа не работают. Для таких мест разработан специальный прибор-эхолот: он зондирует заданный непрерывный участок ущелья, рекурсивно деля его на левую и правую половины, пока не доберётся до единственной неделимой точки. Показание такой точки — это просто высота скальной породы в ней.

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

Начальник экспедиции хочет заранее знать, в каком порядке записи будут появляться на ленте журнала, чтобы вовремя передавать данные на базу. Вам предстоит восстановить полную последовательность возвращаемых показаний в том порядке, в котором прибор их заносит в журнал.

Формат ввода

В первой строке записано n (1 ≤ n ≤ 3 000). Во второй строке записаны n целых высот. Для участка [l, r) с несколькими точками граница половин равна ⌊(l+r)/2⌋; сначала исследуется левая половина, затем правая.

Формат вывода

Сначала выведите число возвращаемых показаний. Во второй строке выведите все показания в порядке их занесения в журнал. Показание записывается и для единичного участка, и для каждого объединённого участка.

Пример

Ввод:

5
4 1 7 3 2

Вывод:

9
4 1 4 7 3 2 3 7 7

Пояснение

Сначала полностью завершается левая половина, затем правая, после чего в журнал попадает максимум всего исходного участка.

РешениеPython

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку