Возврат нескольких значений и рекурсия на пальцах
Чему научитесь
- Возвращать из функции несколько величин сразу
- Писать рекурсию с базой и шагом, ведущим к базе
- Прикидывать глубину и понимать, когда она упрётся в предел
- Отличать рекурсию, которая работает, от той, что взрывается
Как устроено занятие
Сначала разбор с примерами и короткими проверками понимания — они нужны, чтобы поймать непонятое сразу, а не через три темы.
Дальше 15 задач лестницей: разминка, основа, со звёздочкой. Занятие засчитывается, когда решено 10 — остальные не пропадают и учитываются отдельно.
После занятия — вторая часть, ещё 15 задач на те же приёмы в новых сюжетах.
Сколько это займёт
Примерно час-полтора вместе с задачами. Сроков нет: можно закрыть вкладку и вернуться когда удобно — прогресс сохранится.
Вернуть несколько значений
Функция «найди минимум и максимум» естественно считает оба сразу — а вернуть по правилам прошлого занятия может только одно. Приходится либо писать две функции, каждая из которых проходит список заново, либо…
def extremes(values):
low = values[0]
high = values[0]
for x in values:
if x < low:
low = x
if x > high:
high = x
return low, high # два значения через запятую
low, high = extremes(a) # и принимаем тоже два
print(low, high)
Через запятую можно вернуть сколько угодно значений — главное, чтобы при приёме имён было столько же.
На самом деле return low, high возвращает одно значение — пару, которая в Python называется кортежем. Но пользоваться этим знанием пока не придётся: достаточно помнить, что справа и слева от знака равенства должно быть одинаковое количество имён.
Кстати, приём знакомый. Обмен значениями a, b = b, a, который встречался раньше, устроен ровно так же.
Функция, которая вызывает себя
Факториал определяется через самого себя: факториал — это , умноженное на факториал . Так его и можно записать.
def factorial(value):
if value <= 1:
return 1 # база: дальше не спускаемся
return value * factorial(value - 1) # шаг: задача поменьше
Функция, вызывающая саму себя, называется рекурсивной. Устроена она всегда из двух частей, и обе обязательны:
- база — случай, который решается сразу, без вызова себя;
- шаг — сведение задачи к такой же, но меньшей.
Без базы функция будет вызывать себя бесконечно. Без уменьшения — тоже: важно, чтобы каждый вызов приближал к базе.
Читать такое поначалу непривычно. Помогает не пытаться проследить все вызовы в голове, а поверить в два утверждения: «для единицы работает» и «если работает для , то работает и для ». Этого достаточно.
def digit_sum(value):
if value < 10:
return value
return value % 10 + digit_sum(value // 10)
Последняя цифра плюс сумма цифр всего остального — определение, переписанное кодом почти дословно.
Проверка: чего не хватает
def total(value):
return value + total(value - 1)
Что произойдёт при вызове total(5)?
Глубина: сколько вызовов помещается
Каждый незавершённый вызов занимает место в памяти: пока factorial(5) ждёт результат от factorial(4), оба существуют одновременно.
Поэтому глубина ограничена. В Python — около тысячи вложенных вызовов, дальше программа останавливается с ошибкой RecursionError.
def total(value):
if value == 1:
return 1
return value + total(value - 1)
print(total(900)) # работает
print(total(100000)) # RecursionError
Это не редкость и не экзотика: «сумма чисел до миллиона» рекурсией просто не считается. Отсюда практическое правило:
Если рекурсия уменьшает аргумент на единицу, она годится примерно до тысячи. Если делит пополам — глубина получается около шестидесяти даже для гигантских чисел, и всё в порядке.
Второй случай — самый ценный. Возведение в степень: чтобы вычислить , достаточно вычислить и возвести в квадрат.
def power(base, exp, mod):
if exp == 0:
return 1 % mod
half = power(base, exp // 2, mod)
result = half * half % mod
if exp % 2 == 1:
result = result * base % mod
return result
Показатель в миллиард миллиардов — шестьдесят вызовов. Цикл сделал бы столько же шагов, но здесь рекурсия честно короче и понятнее.
Когда рекурсия — плохая идея
Числа Фибоначчи определяются через себя, и напрашивается записать это дословно:
def fib(n):
if n <= 2:
return 1
return fib(n - 1) + fib(n - 2) # выглядит красиво
Работает. Но fib(50) вы не дождётесь.
Причина не в глубине — она всего пятьдесят. Причина в количестве вызовов: каждый вызов порождает два, те ещё по два, и всего их получается больше, чем самих чисел Фибоначчи. Для сорокового числа — около двухсот миллионов вызовов, для девяностого — больше, чем секунд в возрасте Вселенной.
И дело даже не в скорости: одно и то же вычисляется заново десятки миллионов раз. fib(10) в этом дереве встречается тысячи раз, и каждый раз считается с нуля.
Для чисел Фибоначчи есть простой выход — считать по порядку, храня два последних:
a, b = 1, 1
for _ in range(n - 2):
a, b = b, a + b
Общий признак опасной рекурсии: в шаге больше одного вызова себя. Один вызов — цепочка, всё хорошо. Два — дерево, которое растёт вдвое на каждом уровне. Способ это лечить есть — запоминать уже посчитанное, — и до него мы дойдём в шестом модуле.
Проверка: какая глубина
Функция вычисляет , каждый раз уменьшая показатель вдвое.
Примерно какой глубины будет рекурсия при около миллиона? Введите целое число — количество вложенных вызовов, округлив до целого.
Три ошибки этого занятия
Нет базы. Функция вызывает себя всегда, и программа падает с RecursionError. Признак очевидный, лечение тоже: добавить случай, который решается сразу.
База есть, но до неё не доходит. Аргумент не уменьшается или уменьшается не в ту сторону. Проверяйте: каждый вызов должен быть ближе к базе, чем предыдущий.
Разное количество значений при возврате и приёме. low, high = f(), где функция возвращает одно значение или три. Признак: ValueError про распаковку.
Как проверять себя
- самый маленький аргумент — 0 или 1: именно на нём проверяется база;
- посчитайте глубину: если аргумент уменьшается на единицу, она равна самому аргументу;
- посчитайте количество вызовов: если в шаге два вызова себя, их будет катастрофически много;
- проверьте вручную на трёх: рекурсию проще проследить на маленьком примере, чем на большом.
Практика: пятнадцать задач
Лестница прежняя: пять разминочных, семь основных, три со звёздочкой. Зачёт при десяти решённых.
Часть задач — про возврат пары: там функция считает две величины за один проход. Часть — рекурсивные, и ограничения в них подобраны под глубину: где аргумент уменьшается на единицу, он не превышает девятисот.
В двух задачах рекурсия по определению не подойдёт — это числа Фибоначчи и пути по сетке при больших размерах. Ограничения подсказывают, когда так: если задача решается за секунду только при маленьких числах, посмотрите, не считается ли одно и то же по многу раз.
Красивая рекурсия, которой не дождаться
Задача: «вычислите -е число Фибоначчи, до 90».
Ученик написал:
def fib(n):
if n <= 2:
return 1
return fib(n - 1) + fib(n - 2)
n = int(input())
print(fib(n))
На маленьких тестах программа отвечает верно, на больших получает превышение времени. Ученик считает, что дело в глубине рекурсии, и хочет её увеличить.
Объясните, в чём он не прав, откуда берётся такое количество работы, и как решить задачу.