Рекурсивные определения: базовые случаи, меньшие себя и контракт за прыжком веры
У рекурсивного определения две части: базовые случаи и правило, строящее n из меньшего себя — 0! = 1, n! = n·(n-1)!. Списки, деревья и файловые системы определены так же. Два закона: каждый вызов шагает к базе, и база существует на каждом пути.
Джуниору прилетает тикет: посчитать суммарные лайки по ветке комментариев. Легко — пройтись циклом по комментариям и сложить. Вот только у ответов есть ответы, а у тех — свои ответы, вложенные настолько глубоко, насколько хватило азарта спорщиков. Версия с циклом обрастает стопкой индексных переменных, потом самодельной очередью, потом багом. Мимо проходит сеньор и заменяет сорок строк четырьмя: функцией, которая вызывает саму себя по разу на каждый ответ. Джуниор смотрит на это и задаёт вопрос, который каждый программист задаёт ровно один раз в жизни: как функция может пользоваться собой до того, как её закончили определять? Это похоже на словарь, объясняющий слово этим же словом. Но это не так — и разница между запретной цикличностью и законным рекурсивным определением сводится ровно к двум требованиям, оба проверяемы, оба взяты прямиком из математики. Этот урок — про этот контракт.
После этого урока вы сможете назвать две части рекурсивного определения, вручную проследить рекурсивный вызов через стек вызовов, объяснить, почему самоссылка — не то же самое, что цикличность, и проверить, подчиняется ли определение двум законам, удерживающим рекурсию здравой.
Две части: пол и правило, спускающееся к нему.
Рекурсивное определение задаёт вещь через её же меньшую версию. В нём всегда ровно два вида частей:
- Базовые случаи — входы, на которые ответ даётся сразу, без ссылки на определяемую вещь. Это пол.
- Рекурсивное правило — рецепт, собирающий ответ для большего входа из ответа для строго меньшего.
Классический пример — факториал числа, записывается n! — произведение всех целых чисел от 1 до n:
- Базовый случай:
0! = 1 - Правило:
n! = n · (n-1)!для любого n ≥ 1
Посмотрите, как определение само вычисляет 4!. Каждый шаг заменяет факториал тем, что велит правило, пока базовый случай не оборвёт цепочку:
4! = 4 · 3!
= 4 · (3 · 2!)
= 4 · (3 · (2 · 1!))
= 4 · (3 · (2 · (1 · 0!)))
= 4 · (3 · (2 · (1 · 1))) ← база: 0! = 1, дальше ссылок нет
= 24Ничего циклического не произошло. Правило для 4! ни разу не упомянуло 4! — оно упомянуло 3!, строго меньшую задачу. Цепочка 4 → 3 → 2 → 1 → 0 обязана была закончиться: каждый шаг вёл вниз, а на нуле был пол.
▸Почему это работает
Почему определению вообще разрешено упоминать само себя? Потому что оно никогда не упоминает себя на том же размере. Словарная статья «предок: родитель или предок родителя» выглядит циклической, но проследите её: ваш предок — родитель, или родитель родителя, или родитель того — каждый шаг разворачивания поднимается на одно поколение по конечному семейному дереву и упирается в настоящих родителей. Математики называют это фундированностью: самоссылка всегда указывает строго вниз, к полу. Цикличность — это когда цепочка может вернуться в исходную точку; рекурсия — когда доказуемо не может.
Трассировка factorial(4) через стек.
Переведём двухчастное определение прямо в код, базовый случай первым:
function factorial(n) {
if (n === 0) return 1; // база ПЕРВОЙ: пол отвечает сразу
return n * factorial(n - 1); // правило: один слой работы · меньший себя
}
factorial(4); // 24Теперь проследим factorial(4) так, как это делает машина. Каждый вызов, который пока не может ответить, ждёт, и рантайм держит каждый ждущий вызов живым — кадром (frame) на стеке вызовов:
factorial(4) → нужно 4 * factorial(3) …ждёт
factorial(3) → нужно 3 * factorial(2) …ждёт
factorial(2) → нужно 2 * factorial(1) …ждёт
factorial(1) → нужно 1 * factorial(0) …ждёт
factorial(0) → база → возвращает 1 (не ждёт!)
factorial(1) продолжает: 1 * 1 = 1
factorial(2) продолжает: 2 * 1 = 2
factorial(3) продолжает: 3 * 2 = 6
factorial(4) продолжает: 4 * 6 = 24Пять кадров вниз, пять ответов каскадом вверх. В самый глубокий момент живы все пять кадров сразу.
Рекурсивные структуры данных.
Паттерн нужен не только числам. Самые полезные структуры данных в программировании — рекурсивные определения:
- Список — это либо пустота, либо первый элемент (голова), за которым идёт меньший список (хвост).
- Дерево каталогов — это несколько файлов плюс несколько каталогов, каждый из которых — меньшее дерево каталогов.
Вы уже запускали рекурсивное определение в бою: каждый du -sh node_modules, каждый «посчитать размер папки» в файловом менеджере — это исполненное определение каталога. Структура кода повторяет структуру определения строка в строку:
function dirSize(dir) {
let total = 0;
for (const entry of entriesOf(dir)) {
if (entry.isFile) total += entry.size; // базовый ингредиент: у файла есть размер
else total += dirSize(entry); // каталог — это МЕНЬШЕЕ дерево
}
return total;
}Где базовый случай? Спрятан на самом виду: у пустого каталога нет записей, тело цикла не выполняется ни разу, и функция возвращает 0, не вызвав себя. Базовые случаи не всегда носят if — иногда это ветка, в которой рекурсивный вызов просто не случается. Каждый лист файловой системы — пол.
Вот второе определение, построенное так же, без единой формулы. Лестничные числа: S(n) считает, сколькими способами можно подняться по лестнице из n ступеней, если шаг покрывает 1 или 2 ступени.
- Базовые случаи:
S(1) = 1(один одиночный шаг),S(2) = 2(1+1 или один двойной) - Правило:
S(n) = S(n-1) + S(n-2)— первый шаг либо одиночный (остаётся лестница из n−1), либо двойной (остаётся n−2)
Правило позволяет вычислять значения, которых вы никогда не видели: S(3) = S(2) + S(1) = 3, затем S(4) = S(3) + S(2) = 5. Определение и есть алгоритм.
Два закона — и что ломается при их нарушении.
Каждое законное рекурсивное определение (и каждая рекурсивная функция) подчиняется двум законам:
- Каждый рекурсивный шаг обязан двигаться к базовому случаю. Вход внутреннего вызова должен быть строго меньше по какой-то честной мере: меньшее число, более короткий список, поддерево.
- Базовый случай обязан существовать на каждом пути. С какого входа ни начни, сжимающаяся цепочка должна реально приземлиться на пол.
Удалим базовый случай и запустим:
def factorial(n):
return n * factorial(n - 1) # пола нет
factorial(4)
# RecursionError: maximum recursion depth exceededНичего мистического — просто следите за кадрами. factorial(4) ждёт factorial(3), тот ждёт factorial(2), потом 1, потом 0, потом −1, потом −2… Ни один вызов никогда не возвращается: чтобы вернуться, нужен базовый случай, а его нет. Каждый незавершённый вызов сидит и ждёт на стеке, кадр за кадром, а стек — конечная полоска памяти. Python сдаётся примерно на тысяче кадров с RecursionError; JavaScript бросает RangeError: Maximum call stack size exceeded. «Переполнение стека» — не машина, запутавшаяся в самоссылке, а машина, честно складирующая тысячи долговых расписок, которые никто никогда не оплатит.
Закон 2 коварнее, чем кажется: база должна быть достижима с любого стартового входа. factorial(4.5) шагает 4.5 → 3.5 → 2.5 → … и перепрыгивает n === 0, так и не попав в него — цепочка движется к полу и промахивается мимо. Настоящий код ставит охрану (if (!Number.isInteger(n) || n < 0) throw …); настоящие определения объявляют область («для целых n ≥ 0»).
Коллега определяет факториал в документации одной строкой: «n! = n · (n-1)!». Что не так с определением в таком виде?
Заблуждение: рекурсия — не циклическая магия.
Джуниору из пролога мы должны ответ. Вызов factorial(n - 1) внутри factorial ощущается как использование вещи до её существования. Это не так. Это использование определения: (n-1)! — определённое, зафиксированное значение; определение пригвождает его так же намертво, как 0! пригвождён к единице. Телу функции не нужно, чтобы factorial был «дописан»; ему нужно, чтобы уравнение было истинным, а уравнение стоит само по себе.
Вот рабочий контракт — то, что сеньоры на самом деле держат в голове:
- Обработайте один слой сами. Ваш код отвечает за соединение головы с результатом хвоста, текущего каталога — с его поддеревьями. Один слой, не больше.
- Полностью доверьтесь меньшему вызову. Считайте, что
factorial(n - 1)возвращает ровно(n-1)!. Не перепроверяйте его мысленно. - Проверьте два закона. Меньше на каждом шаге; пол на каждом пути.
Второй шаг называют прыжком веры, но название преувеличивает риск: вы доверяете не удаче, а определению, которое заякорено в базовом случае и сжимается к нему. В следующих уроках это доверие получит ценник (сколько работы набегает по всем этим вызовам?) и гарантию (технику доказательства, превращающую прыжок в теорему).
Проследите factorial(4) от вызова до возврата, отследив каждый кадр.
Определение: 0! = 1, и n! = n · (n-1)!.
Шаг 1 — разматываем. Каждый вызов, попадающий под правило, не может ответить сразу — он паркуется на стеке и запускает меньший вызов. Начиная с 4:
factorial(4) паркуется, запускает factorial(3)
factorial(3) паркуется, запускает factorial(2)
factorial(2) паркуется, запускает factorial(1)
factorial(1) паркуется, запускает factorial(0)
factorial(0) попадает в базовый случай → возвращает 1В этот самый глубокий момент на стеке живут пять кадров одновременно: один базовый, возвращающий немедленно, и четыре запаркованных, каждый с незавершённым умножением.
Шаг 2 — сматываемся обратно. Ответ базового случая путешествует обратно через запаркованные кадры в порядке, обратном их созданию:
factorial(1) продолжает: 1 · 1 = 1, возвращает 1
factorial(2) продолжает: 2 · 1 = 2, возвращает 2
factorial(3) продолжает: 3 · 2 = 6, возвращает 6
factorial(4) продолжает: 4 · 6 = 24, возвращает 24Результат: 24. Вызов factorial(n) всегда создаёт ровно n+1 кадров (n ждущих плюс 1 базовый), достигает наибольшей глубины когда срабатывает база, затем разматывается за то же число шагов. Ни один кадр не используется повторно; каждое умножение терпеливо ждёт, пока вернётся внутренний вызов.
factorial(3) в самый глубокий момент держит сколько кадров стека? Напиши число.
По формуле S(n) = S(n-1) + S(n-2), S(1)=1, S(2)=2 — чему равно S(5)? Напиши число.
По той же формуле S(n) — чему равно S(4)? Напиши число.
Если у factorial нет базового случая и вызывается factorial(3) — сколько вызовов успешно завершится до переполнения стека? Напиши 0, если ни одного.
Из каких двух обязательных частей состоит рекурсивное определение?
Рекурсивное определение строит вещь из её меньших версий, и в нём всегда две части: базовые случаи, отвечающие сразу (0! = 1; пустой каталог имеет размер 0), и правило, собирающее ответ для n из ответа для строго меньшего входа (n! = n·(n-1)!; размер каталога — его файлы плюс поддеревья). Тот же паттерн определяет данные: список — это пустота или голова плюс меньший список, файловая система — файлы плюс меньшие деревья; именно поэтому четырёхстрочный рекурсивный обходчик побеждает сорокастрочный цикл на вложенных структурах. Исполнение определения механично: каждый вызов, не способный ответить сейчас, ждёт кадром на стеке вызовов, базовый случай отвечает не спрашивая, и результаты каскадом возвращаются вверх — factorial(4) в самый глубокий момент держит пять кадров и возвращает 24. Здравость всего этого держат два закона: каждый рекурсивный шаг обязан двигаться к базовому случаю, и база должна быть достижима на каждом пути. Нарушьте их — и ничто не возвращается; кадры копятся, пока рантайм не оборвёт программу с RecursionError или RangeError: переполнение стека — это просто тысячи неоплаченных расписок. И рекурсия — не циклическая магия: правило для n ссылается лишь на строго меньшие экземпляры, поэтому честный рабочий процесс — контракт: обработай один слой сам, доверься меньшему вызову, потому что определение пригвождает его значение, и проверь два закона перед тем, как отгружать.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.