1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6

# теория · шаг 3 из 6

Сколько стоит рекурсия и сколько она ест памяти

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

Как думаешь, во сколько раз это дороже

def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

При n = 30 эта функция делает больше двух с половиной миллионов вызовов. При n = 40 - около трёхсот тридцати миллионов. Каждое увеличение n на единицу умножает работу примерно на 1,6.

Причина не в рекурсии, а в том, что дерево вызовов ветвится, а подзадачи повторяются: fib(28) считается заново в каждой ветке, где встретился.

Дерево вызовов

Работу рекурсии удобно считать по её дереву: узел - один вызов, потомки - вызовы, которые он делает. Нужны две величины.

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

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

fib(5) fib(4) fib(3) fib(3) fib(2) fib(2) fib(1) fib(2) fib(1) fib(1) fib(0) fib(1) fib(0) fib(1) fib(0) глубина 5, узлов 15, и fib(3) посчитан дважды
Ветвление даёт время, высота даёт память: на стеке одновременно лежит только один путь от корня вниз.

Считаем вместе: три рекурсии и их цена

Один вызов на уровень. summa(a, i) из прошлого урока вызывает себя один раз, глубина n, узлов n. Время n, память n - и вторая цифра неприятная: на списке из миллиона элементов рекурсия упрётся в предел глубины стека.

Два вызова, но каждый вдвое меньше. Сортировка слиянием делит массив пополам: на уровне k идёт 2^k вызовов размера n/2^k, работа на уровне n, уровней log₂ n. Итого n·log₂ n времени и log₂ n глубины. При n = 1 000 000 глубина около двадцати.

Два вызова размера n-1. Ханойские башни: узлов 2ⁿ - 1, глубина n. Время экспоненциальное, память линейная - и здесь это не дефект, а свойство задачи: ходов действительно 2ⁿ - 1.

Два вызова размера n-1 и n-2 с повторами. fib - узлов около 1,618ⁿ. Здесь экспонента уже дефект: тех же чисел можно достичь за n шагов, если не считать одно и то же дважды. К этому вернёмся в разделе про динамическое программирование.

Считаем вместе: глубина против ветвления

Пусть каждый вызов делает константную работу и порождает b вызовов размера n-1. Тогда узлов примерно bⁿ, глубина n.

  • b = 1: время n, память n - обычный цикл, записанный рекурсией;
  • b = 2: время 2ⁿ, память n - перебор подмножеств;
  • b = 2, но размер делится пополам: время n, память log n - двоичный поиск, записанный рекурсией.

Отсюда видно, что вопрос «рекурсия или цикл» не про скорость. Скорость задаёт форма дерева. Рекурсия отличается только тем, что платит памятью за стек и избавляет от ручного хранения состояния.

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

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

В Python его нет и не будет: разработчики языка отказались от него сознательно, чтобы сохранить полную трассу вызовов в отчёте об ошибке. Практическое следствие - предел глубины около тысячи вызовов, после чего программа падает. Поэтому рекурсию глубиной n в Python пишут только там, где n заведомо мало, а глубину log n - свободно.

Глубина рекурсии - это память, и её никто не показывает

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

Теперь сам

Функция обходит двоичное дерево из n узлов и возвращает его высоту:

def vysota(uzel):
    if uzel is None:
        return -1
    return 1 + max(vysota(uzel.levyj), vysota(uzel.pravyj))

Сколько вызовов она делает и какова глубина стека для дерева-цепочки из n узлов и для сбалансированного дерева из n узлов?

Ответ. Вызовов в обоих случаях 2n + 1: по одному на каждый узел плюс по одному на каждую пустую ссылку. Время линейное всегда. А глубина зависит от формы: у цепочки она n, у сбалансированного дерева около log₂ n. То есть одна и та же функция на миллионе узлов либо занимает двадцать кадров стека, либо падает. Форма данных здесь важнее кода.

Главное

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

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.