Алгоритмы с нуля
Стек вызовов
Когда функция вызывает саму себя (рекурсия), что-то должно помнить, что происходило до этого вызова — параметры, локальные переменные, строку кода для возврата. Твой компьютер держит стек вызовов для этого: стек stack frames, один на каждый вызов функции. Когда рекурсивная функция вызывает саму себя, новый frame заталкивается. Когда она возвращается, frame выталкивается. Если рекурсия слишком глубокая, стек переполняется и программа падает.
После этого урока ты поймёшь, как работает стек вызовов: что такое stack frame и что он содержит, как стек растёт при вызове функций друг другом (и сжимается при их возврате), почему рекурсивные вызовы строят стек frames, как глубина рекурсии переводится в пространственную сложность O(n), и что такое переполнение стека и как его избежать.
Что такое стек вызовов?
Стек вызовов — это структура данных (стек — LIFO), которая отслеживает активные вызовы функций. Каждый раз, когда функция вызывается, создаётся новый stack frame и заталкивается в стек. Frame содержит:
- Параметры функции.
- Её локальные переменные.
- Адрес возврата (где в вызывающем коде продолжить после возврата этой функции).
Когда функция возвращается, её frame выталкивается из стека, и управление переходит вызывающему коду.
Как стек растёт и сжимается
Рассмотри простой пример:
function foo() { bar(); }
function bar() { baz(); }
function baz() { return 42; }
foo();Когда ты вызываешь foo():
- Frame для
foo()заталкивается. foo()вызываетbar(), заталкивается frame дляbar().bar()вызываетbaz(), заталкивается frame дляbaz().baz()возвращает 42 → её frame выталкивается.bar()возвращается → её frame выталкивается.foo()возвращается → её frame выталкивается.
На шаге 3 стек содержит [foo, bar, baz] (baz на вершине, следующий к запуску). Глубина стека = 3.
Рекурсия и стек вызовов
Рекурсия — просто частный случай: функция вызывает саму себя. Каждый рекурсивный вызов заталкивает новый frame.
Давай проследим factorial(4):
- Вызови
factorial(4)→ заталкни frame[n=4]. - Вызови
factorial(3)→ заталкни frame[n=3]. Стек:[factorial(4), factorial(3)]. - Вызови
factorial(2)→ заталкни frame[n=2]. Стек:[factorial(4), factorial(3), factorial(2)]. - Вызови
factorial(1)→ заталкни frame[n=1]. Стек:[factorial(4), factorial(3), factorial(2), factorial(1)]. factorial(1)попадает в базовый случай, возвращает 1 → выталкни[n=1]. Стек:[factorial(4), factorial(3), factorial(2)].factorial(2)получает ответ 1, вычисляет 2 × 1 = 2, возвращается → выталкни[n=2]. Стек:[factorial(4), factorial(3)].factorial(3)получает ответ 2, вычисляет 3 × 2 = 6, возвращается → выталкни[n=3]. Стек:[factorial(4)].factorial(4)получает ответ 6, вычисляет 4 × 6 = 24, возвращается → выталкни[n=4]. Стек пуст.
На шаге 4 (самая глубокая точка) глубина стека = 4. Это n — размер входа.
Стек имеет конечный размер
Память твоего компьютера ограничена. Стек (область памяти, где живут frames) имеет фиксированный размер — обычно несколько мегабайт. Если рекурсия заходит слишком глубоко и продолжает заталкивать frames без выталкивания, она может исчерпать это место.
Когда стек исчерпывает свою память, происходит переполнение стека: программа падает с ошибкой вроде “Maximum call stack size exceeded.” Это жёсткий предел, который нельзя игнорировать.
function badLoop(n) {
return badLoop(n + 1); // Нет базового случая! Бесконечная рекурсия.
}
badLoop(0); // В итоге: переполнение стека.Даже с базовым случаем, если рекурсия достаточно глубока, ты упрёшься в лимит:
function deepRecursion(n) {
if (n === 0) return 1;
return deepRecursion(n - 1); // Каждый вызов заталкивает frame.
}
deepRecursion(1000000); // На большинстве систем: переполнение стека.Память стека — это пространственная сложность
На прошлом уроке ты видел, что factorial(n) имеет O(n) пространственную сложность. Теперь ты понимаешь почему: каждый рекурсивный вызов заталкивает frame. Самая глубокая рекурсия достигает глубины n, поэтому стек содержит одновременно до n frames. То есть O(n) памяти стека.
Если ты вызовешь factorial(1000), 1000 frames накапливаются до того, как самый глубокий вызов начнёт возвращаться. Если вызовешь factorial(1000000), стек переполнится задолго до завершения.
Рекурсия можно переписать итеративно
Любую рекурсию можно, в принципе, переписать с явным циклом и явным стеком (или другой структурой). Рекурсия — удобство: стек вызовов ведёт учёт за тебя. Но если память стека — проблема, ты можешь обменять рекурсию на итерацию + ручное управление стеком.
Пример: вычисли факториал итеративно (без рекурсии).
function factorialIterative(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}Это использует O(1) памяти — нет рекурсивных вызовов, только один цикл. Но это менее интуитивно, когда задача естественно рекурсивна (вроде обхода дерева).
Давай кодировать рекурсию и видеть стек вызовов в действии.
Пример 1: Факториал (вызовы растут и сжимаются)
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
console.log(factorial(5)); // 120Пример 2: Глубокая рекурсия (приближается к лимитам стека)
function countDown(n) {
if (n === 0) {
console.log("Done!");
return;
}
console.log(n);
countDown(n - 1);
}
countDown(10); // Выводит 10, 9, 8, ..., 1, потом "Done!"
countDown(100000); // Вероятно переполнение стека на большинстве системПример 3: Итеративная версия (использует O(1) памяти)
function countDownIterative(n) {
for (let i = n; i >= 1; i--) {
console.log(i);
}
console.log("Done!");
}
countDownIterative(10);
countDownIterative(100000); // Нет проблемы: использует только цикл, нет frames в стекеПопробуй все три:
Давай проследим factorial(4) frame за frame, показывая, как стек растёт к самой глубокой точке, потом сжимается.
1
function factorial(n) {
2
if (n <= 1) return 1;
3
return n * factorial(n - 1);
4
}
Время: Для factorial(n) есть n рекурсивных вызовов, каждый выполняет O(1) работу (одно умножение, одно сравнение). Итого: O(n) времени.
Пространство (стек вызовов): Каждый вызов заталкивает frame. Самая глубокая рекурсия достигает глубины n, поэтому стек содержит максимум n frames. Итого: O(n) памяти стека. Это главное понимание: пространственная сложность рекурсивной функции определяется максимальной глубиной рекурсии.
Практические лимиты: На большинстве систем стек 1–8 МБ. Каждый frame стоит примерно 100–500 байт (в зависимости от количества локальных переменных и параметров функции). Это значит, что глубина рекурсии в 10,000–80,000 обычно безопасна, но 1,000,000 нет.
Сравнение с итерацией: Итеративный цикл, выполняющий то же вычисление, использует O(1) памяти стека — нет рекурсивных вызовов. Но рекурсивный код часто чище и интуитивнее, когда задача имеет рекурсивную структуру (деревья, разделяй-и-властвуй).
Компромисс: рекурсия элегантна, но дорога по памяти; итерация эффективна, но иногда менее интуитивна.
Какова максимальная глубина стека при вызове factorial(7)?
В стеке вызовов, какой frame находится на вершине (следующий к исполнению)?
Какова пространственная сложность factorial(n)?
Почему factorial(1000000) вероятно падает с переполнением стека?
Когда ты вызываешь sumTo(4), упорядочи frames, что появляются на стеке (от дна к вершине в самой глубокой точке):
- Frame для sumTo(4)
- Frame для sumTo(3)
- Frame для sumTo(2)
- Frame для sumTo(1)
Частая ошибка
Ошибка: Недооценка глубины стека. Ты пишешь рекурсивную функцию и думаешь “она зайдёт только на несколько уровней глубже”, но потом передаёшь большой вход:
function process(arr, i = 0) {
if (i >= arr.length) return;
process(arr, i + 1);
}
process(new Array(100000)); // Переполнение стека!Функция рекурсивно вызывает саму себя раз на элемент массива. С 100,000 элементов, то 100,000 frames. Стек не может держать столько. Всегда думай о максимальной глубине рекурсии и размере входа вместе.
Частая ошибка
Ошибка: Забывание, что рекурсия стоит памяти стека. Ты помнишь, что функция O(n) по времени, но забываешь, что она ещё O(n) по памяти стека:
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
fibonacci(100); // Переполнение стека, даже хотя это выглядит "простым"Эта функция медленна (экспоненциальное время), но точка здесь в том: каждый рекурсивный вызов ест память стека. Если ты не имеешь явного контроля над глубиной рекурсии, ты можешь упреться в лимит стека. Для fibonacci итеративный подход или мемоизация (хранение ответов) намного лучше.
Ты пишешь рекурсивную функцию, что считает от 1 до n. На твоём компьютере она падает с 'переполнение стека' при n = 50,000. Что это говорит?
Стек вызовов — это место, где компьютер отслеживает вызовы функций.
Stack frame: Когда функция вызывается, создаётся frame и заталкивается в стек. Frame содержит параметры, локальные переменные и адрес возврата. Когда функция возвращается, frame выталкивается.
Рекурсия и стек: Каждый рекурсивный вызов заталкивает новый frame. Самая глубокая рекурсия имеет глубину стека, равную количеству вложенных вызовов. Для factorial(n), глубина n.
Пространственная сложность: Глубина рекурсии переводится в память стека. Рекурсия глубины n использует O(n) памяти стека, даже если алгоритм ничего больше не делает.
Переполнение стека: Стек имеет конечный размер (обычно 1–8 МБ). Если рекурсия заходит глубже доступного места, происходит переполнение и программа падает. Это жёсткий лимит.
Итеративная альтернатива: Любую рекурсию можно переписать с циклом и явной структурой данных (ручной стек). Итерация использует O(1) памяти стека, но иногда менее интуитивна.
Когда рекурсия подходит: Используй рекурсию, когда задача естественно разбивается на рекурсивные подзадачи (деревья, разделяй-и-властвуй, backtracking). Если вход так велик, что глубина рекурсии переполнит стек, переделай итеративно.