В этой статье я расскажу как с помощью генераторов можно модифицировать рекурсию так, чтобы она использовала кучу вместо стека и при этом почти не отличалась от обычной рекурсии.
Постановка проблемы
Пусть дана некоторая рекурсивная функция, которую сложно выразить обычным циклом. Для примера я возьму функцию Аккермана
Наивная реализация
const ackermann1 = (m, n) => {
if (m === 0) {
return n + 1;
}
if (n === 0) {
return ackermann1(m - 1, 1);
}
return ackermann1(m - 1, ackermann1(m, n - 1));
};
Довольно быстро мы упрёмся в ограничения стека. При m = 3
и n = 12
, функция падает с ошибкой. Обычно, в таком случае рекурсию заменяют на цикл, и вместо стека вызовов используют обычный стек. В некоторых задачах (DFS, обходы деревьев) код не становится сильно сложнее. В общем же случае, приходится "рвать" функцию, сохранять состояние явно, после чего восстанавливать его, из-за чего программа на высокоуровневом языке становится похожа на программу на языке ассемблера (или на конечный автомат) и сильно страдает читаемость кода.
Цикл со стеком
const ackermann2 = (m, n) => {
const stack = []; // Стековые кадры
let state = "initial"; // Instruction Pointer
let value = undefined; // Возвращаемое значение рекурсивного вызова
const call = (params, returnState) => {
// Добавляем кадр и переходим в начало функции
stack.push({ params, returnState });
state = "initial";
};
const ret = (val) => {
// Удаляем кадр и переходим к месту возврата
const frame = stack.pop();
value = val;
state = frame.returnState;
};
call([m, n]);
while (stack.length !== 0) {
const frame = stack[stack.length - 1];
const {
params: [m, n],
} = frame;
if (state === "initial") {
if (m === 0) {
ret(n + 1);
continue;
}
if (n === 0) {
call([m - 1, 1], "recursive call n=0");
continue;
}
call([m, n - 1], "general recursive call 1");
} else if (state === "recursive call n=0") {
ret(value);
} else if (state === "general recursive call 1") {
call([m - 1, value], "general recursive call 2");
} else if (state === "general recursive call 2") {
ret(value);
}
}
return value;
};
Было бы здорово совместить плюсы обоих подходов, и получить читаемость как у наивной версии, а стек как у версии с циклом
Решение
Во многих современных языках есть способ прервать исполнение функции сохранив при этом её состояние. Я говорю, конечно, об асинхронных функциях и генераторах. В старых версиях javascript, когда уже появились генераторы, но ещё не появился async
-await
, можно было написать функцию превращающую генератор в асинхронную функцию. Код далее будет использовать схожие идеи, но по отношению к рекурсивным функциям.
Для этого будем использовать генераторы, и при необходимости сделать рекурсивный вызов, будем использовать yield
. "Рекурсивный планировщик" будет сохранять состояние, делать рекурсивный вызов, а после восстанавливать функцию, передавая ей результат вычисления.
Реализация на генераторах
const ackermann3 = recursive(function* (m, n) {
if (m === 0) {
return n + 1;
}
if (n === 0) {
return yield [m - 1, 1];
}
return yield [m - 1, yield [m, n - 1]];
});
Осталось написать функцию recursive
связывающую вычисления.
Функция recursive
const recursive = (generator) => (...params) => {
let generatorObj = generator(...params);
let request = undefined; // Рекурсивный запрос от генератора
let result = undefined; // Ответ на запрос
const stack = [];
while (true) {
// Получаем IteratorResult
const message = generatorObj.next(result);
if (message.done) {
// Если генератор завершил работу - передаём
// значение выше по стеку или завершаемся, если
// выше никого нет
if (stack.length === 0) {
return message.value;
}
result = message.value;
generatorObj = stack.pop();
} else {
// В противном случае - сохраняем состояние в стек,
// создаём новый генератор и дальше работаем с ним
result = undefined;
request = message.value;
stack.push(generatorObj);
generatorObj = generator(...request);
}
}
};
Теперь можно писать рекурсивный код не беспокоясь об ограничениях стека вызовов.
Сравнение
Весь код будет запускаться на node v20.16.0 LTS
. Первым делом узнаем сколько мы потеряли в скорости. Для этого сделаем сто замеров для каждой версии при параметрах m = 3
, n = 10
.
Код бенчмарка
const benchmark = (fn, params) => {
try {
const start = performance.now();
fn(...params);
const end = performance.now();
return end - start;
} catch (e) {
return undefined;
}
};
const params = [3, 10];
let fns = [
["ackermann1", ackermann1],
["ackermann2", ackermann2],
["ackermann3", ackermann3],
];
for (let [name, fn] of fns) {
const samples = 100;
let data = [];
for (let i = 0; i < samples; i++) {
data.push(benchmark(fn, params));
}
const total = data.reduce((acc, time) => acc + time, 0);
console.log(`${name} mean: ${total/samples}ms`);
}
Результаты:
ackermann1 mean: 178.13901175999723ms
ackermann2 mean: 916.1059493000008ms
ackermann3 mean: 2552.887184250003ms
Получаем, что наша версия в 2.7 раз медленнее версии с явным стеком и в 14.3 раза медленнее наивной реализации.
Далее узнаем насколько больше стал стек вызовов. Для этого будем использовать другие функции:
const count1 = (n) => {
if (n === 0) {
return 0;
}
return 1 + count1(n - 1);
};
const count2 = recursive(function* (n) {
if (n === 0) {
return 0;
}
return 1 + (yield [n - 1]);
});
На моей машине count1
запускается на значениях до 10468
и начинает ломаться на 10469
. count2
запускается на значениях до 29_229_797
и ломается на 29_229_798
. Таким образом на стандартных настройках стек стал больше в 2792
раза.
Ну вот и всё. Спасибо за внимание
Комментарии (8)
dio4
20.08.2024 19:25К чему вообще все это? Хвостовая рекурсия решает стековые проблемы, так зачем огород городить?
Alexandroppolus
20.08.2024 19:25+1Решает (но не в js, там нет этого). Однако в общем случае перековка рекурсии в хвостовую точно такая же, как в цикл. И не имеет смысла, если в языке есть цикл.
orenty7 Автор
20.08.2024 19:25Хвостовая рекурсия решает стековые проблемы
Хвостовая рекурсия это по сути просто цикл. Соответственно, если что-то сложно написать с помощью цикла, будет сложно и с помощью хвостовой рекурсии (можете сами попробовать).
К чему вообще все это?
Идея показалась интересной, но ничего подобного нагуглить не смог. Про генераторы, имхо, в целом очень мало пишут, поэтому считаю нужным делиться их нестандартными применениями
aeder
Конкретно для этой задаче - не проще сделать мэп с [m,n] -> value, и если в мэпе уже есть значение - возвращать его, а если нет - вызываться рекурсивно?
orenty7 Автор
Мемоизация решает другой набор проблем с рекурсией. В данном случае она может ускорить функцию и немного отложить пробитие стека, но глобально проблему не решит.
наивная версия с мемоизацией
В таком виде она будет проваливать стек при
m = 3
,n = 13
.aeder
Глубина стека - всего 16 элементов? Тут же в рекурсии в любой момент времени уменьшается или n, или m - значит, в любой момент времени глубина стека не более n+m.
Такая задача может по времени обламываться, т.к. без кэширования результатов "дерево" получается очень широкое.
Alexandroppolus
Не уменьшается.
ackermann(m - 1, ackermann(m, n - 1))
- здесь второй параметр может быть гораздо больше исходного nДля
ackermann(3, n)
глубина рекурсии будет 2^(n+3)aamonster
Функция Аккермана славна тем, что даёт огромные значения для довольно скромных аргументов и огромную глубину рекурсии, почему удобна для подобных экспериментов.
И, если мне не изменяет память, мемоизация не поможет: пара аргументов в разных вызовах будет разной...