Перейти к содержимому
Topos Uranos

Краткое содержание

Функция может вызывать саму себя: рекурсия решает задачу, сводя её к меньшему экземпляру той же задачи. Этот урок переводит на C++ рекурсивные определения из математики (факториал, степень, сумму цифр), каждое со своим базовым и своим рекурсивным случаем; показывает, что индукция гарантирует их корректность, а убывающая мера гарантирует их завершение; прослеживает по стеку вызовов кадры, которые накапливаются и снимаются, и объясняет, когда стек исчерпывается. Урок разбирает предел типа результата, который переполняется намного раньше стека; сравнивает рекурсивный и итеративный варианты вычисления чисел Фибоначчи, подсчитывая вызовы, и завершается критерием выбора между рекурсией и циклом.

Цели обучения

  1. Писать рекурсивную функцию, с её базовым и рекурсивным случаем, исходя из рекурсивного определения.
  2. Обосновывать корректность и завершение рекурсивной функции индукцией по размеру задачи.
  3. Прослеживать по стеку вызовов выполнение рекурсивной функции и объяснять переполнение стека.
  4. Сравнивать сложность рекурсивного и итеративного вариантов вычисления чисел Фибоначчи, подсчитывая их вызовы и операции.

Рекурсивные определения

Факториал

Факториал натурального числа nn есть произведение натуральных чисел от 1 до nn: n!=1⋅2⋯nn! = 1 \cdot 2 \cdots n, с соглашением 0!=10! = 1. Многоточие в этой формуле скрывает цикл, и урок Циклы научил его писать. Однако то же определение допускает и другую форму, в которой нет никакого цикла: факториал числа nn равен nn, умноженному на факториал числа n−1n - 1, а факториал нуля равен 1.

0!=1,n!=n⋅(n−1)!(n≥1).0! = 1, \qquad n! = n \cdot (n - 1)! \quad (n \geq 1).

Это рекурсивное определение: оно определяет факториал числа nn через факториал меньшего числа. Оно не порочный круг, потому что каждое применение второго правила спускается на одну ступень, а лестница кончается первым правилом: 3!=3⋅2!=3⋅2⋅1!=3⋅2⋅1⋅0!=63! = 3 \cdot 2! = 3 \cdot 2 \cdot 1! = 3 \cdot 2 \cdot 1 \cdot 0! = 6. У обоих правил есть имена. Первое называется базовым случаем: оно даёт результат, ни на что больше не ссылаясь; второе называется рекурсивным случаем: оно сводит задачу к меньшему экземпляру той же задачи.

C++ позволяет функции вызывать саму себя, и благодаря этому определение переводится почти слово в слово.

ПримерРекурсивный факториал
Программа на C++
#include <print>​// Факториал n.// Предусловие: 0 <= n <= 12 (граница обосновывается ниже).// Постусловие: возвращает n! = 1 · 2 · ... · n, причём 0! = 1.int factorial(int n){    if (n == 0) {        return 1;                   // базовый случай    }    return n * factorial(n - 1);    // рекурсивный случай}​int main(){    for (int n{0}; n <= 6; ++n) {        std::println("{}! = {}", n, factorial(n));    }}
Вывод
0! = 11! = 12! = 23! = 64! = 245! = 1206! = 720

Функция в точности повторяет форму определения: if обрабатывает базовый случай, а return применяет рекурсивный. Вызов factorial(n - 1) ничем не отличается от любого другого вызова из урока Функции: вычисляется его аргумент, создаётся новый кадр со своим собственным n, а по завершении возвращённое значение умножается на n вызывающего.

Контракт не декоративен: при n=−1n = -1 рекурсивный случай вызвал бы функцию с −2-2, затем с −3-3 и никогда не дошёл бы до 0; а 13!13! не помещается в int. Многие вводные тексты пишут этот факториал с базовым случаем if (n == 0 || n == 1), избыточным, но безвредным, и ничего не говорят ни об отрицательных числах, ни о размере результата: их код не завершается при n<0n < 0 и даёт бессмысленный результат начиная с 13!13!. Оба дефекта будут рассмотрены в своём параграфе.

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

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

Степень xnx^n, где nn натуральное, определяется равенствами x0=1x^0 = 1 и xn=x⋅xn−1x^n = x \cdot x^{n - 1}: размер здесь есть показатель степени. Сумма цифр натурального числа nn (для 2026 она равна 2+0+2+6=102 + 0 + 2 + 6 = 10) определяется так: если n<10n < 10, сумма равна nn, потому что у числа одна цифра; иначе она есть сумма цифр числа ⌊n/10⌋\lfloor n / 10 \rfloor, то есть числа nn без последней цифры, плюс эта последняя цифра, n mod 10n \bmod 10. Здесь размер есть количество цифр.

ПримерСтепень и сумма цифр
Программа на C++
#include <print>​// x в степени n.// Предусловие: n >= 0.// Постусловие: возвращает x^n, причём x^0 = 1.double power(double x, int n){    if (n == 0) {        return 1.0;    }    return x * power(x, n - 1);}​// Сумма десятичных цифр n.// Предусловие: n >= 0.// Постусловие: возвращает сумму цифр n.int digitSum(int n){    if (n < 10) {        return n;    }    return digitSum(n / 10) + n % 10;}​int main(){    std::println("2^10 = {}", power(2.0, 10));    std::println("1.5^3 = {}", power(1.5, 3));    std::println("сумма цифр числа 2026: {}", digitSum(2026));}
Вывод
2^10 = 10241.5^3 = 3.375сумма цифр числа 2026: 10

В digitSum целочисленное деление n / 10 отбрасывает последнюю цифру, а n % 10 извлекает её, как при переводе в другую систему счисления в уроке Численные алгоритмы. При n=2026n = 2026 функция запрашивает сумму цифр числа 202, та запрашивает сумму для 20, а та для 2; это базовый случай, он возвращает 2, и результаты складываются на обратном пути: 2+0=22 + 0 = 2, 2+2=42 + 2 = 4, 4+6=104 + 6 = 10.

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