JavaRush /Курсы /C++ SELF /Хвостовая рекурсия

Хвостовая рекурсия

C++ SELF
16 уровень , 4 лекция
Открыта

1. Введение

Если вы уже пробовали рекурсию, то могли заметить странную вещь: код получается красивым и «математичным», но как только входные данные становятся большими, где-то вдалеке начинает кричать стек, а программа иногда падает так уверенно, как будто она всю жизнь к этому готовилась. Хвостовая рекурсия — это попытка записать рекурсивный алгоритм так, чтобы компилятор мог (но не обязан) превратить его в более «циклическое» выполнение и не накапливать кадры стека.

Чтобы не путаться, сразу договоримся о терминах: «хвостовая рекурсия» — это форма записи, а «оптимизация хвостового вызова» — это возможное действие компилятора. Эти две вещи часто смешивают, а потом удивляются: «я же написал хвостовую рекурсию, почему она всё равно переполнила стек?».

Как распознать хвостовой вызов в коде

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

Посмотрите на два похожих фрагмента:

int sum_not_tail(int n) {
    if (n == 0) return 0;
    return n + sum_not_tail(n - 1); // НЕ хвостовая: после вызова есть '+'
}
int sum_tail_impl(int n, int acc) {
    if (n == 0) return acc;
    return sum_tail_impl(n - 1, acc + n); // хвостовая: return сразу результат вызова
}

В первом варианте компилятор (и вы тоже) видит: «я должен дождаться результата sum_not_tail(n - 1), а потом ещё прибавить n». Значит, работа откладывается «на возврате», и стек здесь реально используется как «память ожиданий».

Во втором варианте работа не откладывается: мы делаем acc + n до вызова, а потом просто передаём управление дальше. Теоретически это уже похоже на цикл: «пока n != 0 обновляй аккумулятор и уменьшай n».

Аккумулятор и нейтральный элемент

Когда люди впервые видят аккумулятор (acc), реакция часто такая: «О, это как будто мы обманули рекурсию и тайком сделали цикл». И да — в некотором смысле именно так, только это не обман, а нормальный метод перевести вычисление из режима «доделываем на возврате» в режим «делаем заранее».

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

Полезно запомнить «нейтральные элементы»:

Операция Нейтральный элемент Почему
сумма
0
x + 0 = x
произведение
1
x * 1 = x

Поэтому хвостовой факториал обычно выглядит так:

long long fact_tail_impl(int n, long long acc) {
    if (n == 0) return acc;
    return fact_tail_impl(n - 1, acc * n);
}

long long fact_tail(int n) {
    return fact_tail_impl(n, 1);
}

Обратите внимание на полезный стиль: мы делаем маленькую «внутреннюю» функцию *_impl, а наружу даём более приятную сигнатуру fact_tail(n). Это делает API дружелюбным: пользователь функции не обязан знать, что такое аккумулятор, чтобы посчитать факториал.

2. Хвостовая рекурсия, стек и TCO

Почему хвостовая форма сама по себе не спасает стек

Сейчас будет важная «прививка от магического мышления». Даже если рекурсия хвостовая, по модели выполнения каждый вызов всё равно создаёт новый кадр стека. То есть в строгой учебной картине мира хвостовая рекурсия так же растит стек, как и обычная.

Чтобы увидеть это «на пальцах», можно добавить отладочную печать, которая показывает глубину:

#include <iostream>

int sum_tail_dbg(int n, int acc, int depth) {
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "n=" << n << " acc=" << acc << '\n';

    if (n == 0) return acc;
    return sum_tail_dbg(n - 1, acc + n, depth + 1);
}

int main() {
    std::cout << sum_tail_dbg(3, 0, 0) << '\n';
}
// n=3 acc=0
//   n=2 acc=3
//     n=1 acc=5
//       n=0 acc=6
// 6

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

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

Что такое оптимизация хвостового вызова

Идея оптимизации хвостового вызова (часто говорят Tail Call Optimization, TCO) такая: если функция в конце делает return f(...);, то вместо того чтобы создавать новый кадр стека для f(...), можно переиспользовать текущий кадр, обновить параметры и «прыгнуть» в начало функции, как если бы это был цикл.

Это можно представить такой схемой:

flowchart TD
    A["sum_tail_impl(n, acc)"] -->|если n==0| B["return acc"]
    A -->|иначе| C["готовим next: (n-1, acc+n)"]
    C --> D["вместо нового кадра: перезаписали параметры в текущем кадре"]
    D --> A

Если оптимизация сработала, стек перестаёт расти по глубине цепочки хвостовых вызовов, потому что «новых этажей» не строится — всё происходит в одном «кадре».

Но ключевое слово здесь — если.

3. Почему оптимизация может не сработать

Давайте здесь быть честными: очень хочется верить, что компилятор — это ваш добрый волшебник, который увидит хвостовую рекурсию и скажет «О, да это же цикл в костюме рекурсии!» — и спасёт стек. Иногда он так и делает. Но «иногда» — не то слово, на котором стоит строить надёжность программы.

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

Во-вторых, компилятору может быть неудобно выкидывать кадры стека, потому что стек используется не только для выполнения, но и для диагностики. Даже без полноценного дебаггера люди любят видеть стек вызовов в сообщениях об авариях. А если компилятор агрессивно «схлопнул» рекурсию в цикл, цепочка вызовов исчезает — и отлаживать может стать сложнее.

В-третьих, не всякая рекурсия, которая кажется хвостовой, реально хвостовая с точки зрения компилятора. Иногда вы добавили маленькую «невинную» деталь — и хвостовость исчезла. Например:

int almost_tail(int n, int acc) {
    if (n == 0) return acc;
    int res = almost_tail(n - 1, acc + n);
    return res; // выглядит хвостово, но есть лишние шаги и переменная
}

Для человека это «почти то же самое», но для компилятора это уже другая история: появился дополнительный код после вызова (пусть и формально простой), появилась локальная переменная, и в зависимости от оптимизатора это может стать причиной не применять преобразование.

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

4. Как вручную заменить хвостовую рекурсию на цикл

Чтобы перестать надеяться на магию, полезно уметь делать «план Б»: если хвостовая рекурсия нужна только ради удобства записи, мы должны понимать, как выглядит эквивалентный цикл.

Возьмём нашу сумму от 1 до n.

Хвостовая рекурсия:

int sum_tail_impl(int n, int acc) {
    if (n == 0) return acc;
    return sum_tail_impl(n - 1, acc + n);
}

Эквивалентный цикл (по смыслу):

int sum_loop(int n) {
    int acc = 0;
    while (n != 0) {
        acc += n;
        --n;
    }
    return acc;
}

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

5. Практика: TextTools — сумма цифр в строке

Сейчас мы аккуратно привяжем тему к нашему учебному мини-приложению. Пусть это будет простая консольная утилита TextTools, которая читает команду и строку, а затем выполняет операцию. Раньше мы делали такие приложения, чтобы закреплять ввод строк, условия и функции; сегодня добавим хвостовую рекурсию как одну из реализаций.

Идея команды: посчитать сумму цифр в строке. Например, "a1b2c3"6. Это приятно тем, что мы тренируем и строки, и рекурсию, и аккуратную работу с индексами.

Хвостовая рекурсия для суммы цифр в строке

Сделаем хвостовую рекурсию по индексу i и аккумулятору acc. Мы не используем ничего «будущего»: только std::string, size(), индексацию и условия.

#include <string>

int sum_digits_tail_impl(const std::string& s, std::size_t i, int acc) {
    if (i == s.size()) return acc;

    char c = s[i];
    if (c >= '0' && c <= '9') acc += (c - '0');

    return sum_digits_tail_impl(s, i + 1, acc);
}

Обратите внимание: рекурсивный вызов — действительно последнее действие. Все вычисления (проверка символа и обновление acc) сделаны до return ....

Наружу дадим «чистую» функцию без аккумулятора:

int sum_digits_tail(const std::string& s) {
    return sum_digits_tail_impl(s, 0, 0);
}

Циклическая версия как «надёжный дублёр»

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

#include <string>

int sum_digits_loop(const std::string& s) {
    int acc = 0;
    for (std::size_t i = 0; i < s.size(); ++i) {
        char c = s[i];
        if (c >= '0' && c <= '9') acc += (c - '0');
    }
    return acc;
}

Мини-CLI: связываем команды в один main

Сейчас соберём маленький main, который поддерживает две команды: "sumdigits" (цикл) и "sumdigits_tail" (хвостовая рекурсия). Мы специально держим код маленьким, чтобы он был читаемым, а не «проектом на диплом».

#include <iostream>
#include <string>

int sum_digits_loop(const std::string& s);
int sum_digits_tail(const std::string& s);

int main() {
    std::string cmd;
    std::getline(std::cin, cmd);

    std::string line;
    std::getline(std::cin, line);

    if (cmd == "sumdigits") {
        std::cout << sum_digits_loop(line) << '\n';
    } else if (cmd == "sumdigits_tail") {
        std::cout << sum_digits_tail(line) << '\n';
    } else {
        std::cout << "Unknown command\n";
    }
}

Пример использования (ввод пользователя из двух строк):

sumdigits_tail
a1b2c3

Вывод:

6

Если вы сейчас спросите: «а зачем две версии?» — ответ очень практичный. Хвостовая рекурсия — классная техника, но цикл — более предсказуем по памяти, и вы всегда можете вернуться к нему как к «железобетонной» реализации.

6. Когда хвостовая рекурсия полезна как стиль

Хвостовая рекурсия часто ценится не только за гипотетическую оптимизацию, но и за то, что она дисциплинирует алгоритм: вы явно формулируете, что является текущим состоянием вычисления. Аккумулятор — это не «дополнительная переменная», а очень честный способ сказать: «вот всё, что мне нужно знать о прогрессе».

Например, если вы хотите не сумму цифр, а «сумму цифр до первого не-цифрового символа», вам нужно добавить правило остановки. В хвостовой версии это выглядит естественно: вы просто меняете условие базы.

int sum_prefix_digits_tail_impl(const std::string& s, std::size_t i, int acc) {
    if (i == s.size()) return acc;

    char c = s[i];
    if (c < '0' || c > '9') return acc;

    return sum_prefix_digits_tail_impl(s, i + 1, acc + (c - '0'));
}

Это по-прежнему хвостовая форма, и вы всё ещё не откладываете работу «на возврат».

7. Типичные ошибки при хвостовой рекурсии

Ошибка №1: считать хвостовую рекурсию «гарантией отсутствия переполнения стека».
Хвостовая рекурсия — это форма записи, которая может дать компилятору шанс убрать рост стека. Но если компилятор этого не сделал, глубина будет расти как обычно. Поэтому корректность программы нельзя строить на надежде «ну компилятор же умный».

Ошибка №2: случайно сломать хвостовость и не заметить.
Самая частая поломка — поставить вычисление после рекурсивного вызова, вроде return f(...) + 1;. Иногда поломка происходит менее очевидно: вы добавили локальную переменную и «постобработку» результата, и теперь это уже не чистый хвостовой вызов. В таких местах полезно буквально задавать себе вопрос: «после рекурсивного вызова у меня остаётся хоть какая-то работа?».

Ошибка №3: неверное начальное значение аккумулятора.
Если вы считаете сумму, старт acc должен быть 0, если произведение — 1. Ошибка кажется смешной, но встречается постоянно, потому что мозг у новичка ещё не автоматизировал идею нейтрального элемента. Итог — корректная рекурсия, которая возвращает неправильный ответ, и это обиднее, чем ошибка компиляции.

Ошибка №4: аккумулятор обновляют «не там».
Иногда пишут хвостовую рекурсию, но обновление состояния делают не до вызова, а после, пытаясь сохранить прежнюю структуру «на возврате». Тогда хвостовость теряется, и смысл аккумулятора тоже. Хороший признак правильного кода — все изменения состояния (acc, n, i) происходят до return recursion(...).

Ошибка №5: не обеспечили сходимость, но надеются на оптимизацию.
Если рекурсивный шаг не приближает к базе (например, забыли i + 1 или n - 1), программа уйдёт в бесконечную рекурсию. Никакая оптимизация не спасёт алгоритм, который не останавливается: в лучшем случае вы получите вечный цикл, в худшем — падение. Сначала логика остановки, потом всё остальное.

1
Задача
C++ SELF, 16 уровень, 4 лекция
Недоступна
Ускоренная сумма
Ускоренная сумма
1
Задача
C++ SELF, 16 уровень, 4 лекция
Недоступна
Хвостовой факториал
Хвостовой факториал
1
Задача
C++ SELF, 16 уровень, 4 лекция
Недоступна
Цифры в чате
Цифры в чате
1
Задача
C++ SELF, 16 уровень, 4 лекция
Недоступна
Два Fibonacci
Два Fibonacci
1
Опрос
Рекурсия и стек вызовов, 16 уровень, 4 лекция
Недоступен
Рекурсия и стек вызовов
Рекурсия и стек вызовов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ