Лекции / 3. Рекурсия. Мемоизация.ipynb - Colab (1)
.pdf
Рекурсия
Рекурсия — описание какого то объекта или процесса внутри этого же объекта или процесса. Т.е. рекурсия это описание объекта которые содержит подобный себе объект внутри, или процесса который в свою очередь состоит из таких же процессов. И хотя определение рекурсии встречается в различных областях человеческой деятельности, в дальнейшем мы будет рассматривать рекурсию в программировании.
Рекурсия в программировании
Рекурсия — возможность функции или процедуры вызывать саму себя.
В зависимости от того, как реализуется рекурсивный вызов рассматривают следующие виды рекурсии:
Простая — функция или процедура вызывают сами себя.
Сложная или косвенная — вызов функции происходит посредством вспомогательных функций. Например основная функция вызывает функцию A, а уже в свою очередь функция A вызывает основную функцию.
В зависимости от того сколько раз (за один вызов базовой) производиться рекурсивных вызовов рекурсия подразделяется на:
Одиночная — в рекурсивной части производиться ровно один рекурсивный вызов.
Множественная (параллельная рекурсия) — в рекурсивной части производиться более одного вызова рекурсивной функции или процедуры.
Анонимная рекурсия — рекурсия с использованием неявных реализаций функций или процедур (лямбда функции и подобные им механизмы).
Глубина рекурсии — количество вложенных вызовов функции или процедуры.
Простая рекурсия (одиночная)
def factorial(n): |
|
if n<= 1: #базовыйслучай |
|
return 1 |
|
return n*factorial(n- |
1) #рекурсивныйвызов |
print(factorial(5)) #120 |
|
120 |
|
Множественная рекурсия (числа Фибоначчи)
def fibonacci(n): |
|
|
if n<= 1: |
|
|
return n |
|
|
return fibonacci(n- |
1)+fibonacci(n- |
2) #дварекурсивныхвызова |
print(fibonacci(7)) #13
13
Важно: В Python глубина рекурсии по умолчанию ограничена (обычно 1000). Это можно изменить с помощью sys.setrecursionlimit() , но злоупотреблять не стоит из-за риска переполнения стека.
Описание базовых составляющих рекурсии
Рекурсивная функция или процедура всегда должна содержать условный оператор описывающий условие прекращение рекурсии
— терминальная часть.
Терминальная часть выполняется если условие прекращения рекурсии вернет true . После терминальной части идет описание
рекурсивной части. Рекурсивная часть должна выполняться в случае когда условие прекращение рекурсии вернет false .
Правильно реализованная рекурсивная функция или процедура должна давать гарантию завершения за конечное число вызовов.
Структура рекурсивной функции
def recursive_function(parameters):
if termination_condition: #терминальнаячасть
#возврбазовоготзначения return base_value
else: #рекурсивнаячасть
#изменениепараметровирекурсивныйвызов
return recursive_function(modified_parameters)
Важные моменты
Компонент |
Описание |
|
Пример |
Терминальная часть |
Условие выхода из рекурсии |
if n |
<= 1: return 1 |
Рекурсивная часть |
Шаг к терминальному условию |
return n |
* factorial(n - 1) |
Изменение параметров Каждый вызов приближает к терминальному условию |
n - 1 , n // 2 и т.д. |
||
Гарантия завершения: Каждый рекурсивный вызов должен приближать функцию к терминальному условию. Иначе возникнет бесконечная рекурсия и переполнение стека.
Как обычно реализуется рекурсия
В большинстве языков программирования реализация рекурсии опирается на механизм стека вызовов. Переменные функции и адрес возврата сохраняются в стек (область оперативной памяти используемой ПО), благодаря чему каждый следующий рекурсивный вызов этой функции пользуется своим набором локальных переменных и за счёт этого работает корректно.
С другой стороны каждый рекурсивный вызов требует некоторое количество оперативной памяти компьютера, и при чрезмерно большой глубине рекурсии может наступить переполнение стека вызовов (классический Stack Overflow).
Поэтому обычно не рекомендуют использовать рекурсивные методы с большой глубиной рекурсии. В таком случае если есть возможность, то следует попробовать использовать циклический подход.
Сравнение рекурсивного и циклического подходов
Подход |
Преимущества |
Недостатки |
Рекурсия |
Простота кода, читаемость |
Расход памяти, риск Stack Overflow |
Цикл |
Экономия памяти, скорость |
Сложнее для некоторых алгоритмов |
Хвостовая рекурсия — частный случай рекурсии, при котором любой рекурсивный вызов является последней операцией перед возвратом из функции. Такой вызов может быть оптимизирован компилятором языка в простую итерацию, как следствие переполнение стека не произойдет.
Пример обычной рекурсии (не хвостовая)
def factorial(n): |
|
|
|
if n<= |
1: |
|
|
return 1 |
|
|
|
return n*factorial(n- |
1) #Умножениепослерекурсивноговызова |
||
Пример хвостовой рекурсии |
|
|
|
def factorial_tail(n, acc=1): |
|
|
|
if n<= |
1: |
|
|
return acc |
|
|
|
return factorial-_tail(n |
1,acc*n) |
#Рекурсивныйвызовпоследний |
|
Важно: Python не поддерживает оптимизацию хвостовой рекурсии! Даже хвостовая рекурсия в Python будет потреблять память стека.
Демонстрация ограничения глубины рекурсии в Python
import sys
print(f"Максимаглубинаьнаярекурсии: {sys.getrecursionlimit()}")
#Можноувеличитьноосторожно, :
#sys.setrecursionlimit(3000)
Максимальная глубина рекурсии: 3000
Рекомендация: В Python для задач с большой глубиной лучше использовать циклы или итеративные подходы вместо рекурсии.
Пример сохранения данных на стеке
Когда стоит использовать рекурсию
Рекурсивный подход облегчает реализацию в нескольких случаях:
1.Задача разбивается на подзадачи. Но каждая подзадача эквивалентна базовой задаче.
2.Задача изначально сформулирована с помощью рекурсивного описания.
Примеры задач, где рекурсия особенно эффективна
|
|
Задача |
|
|
Почему подходит рекурсия |
|
Обход деревьев (файловая система, DOM-дерево) |
|
Каждая ветка — поддерево, аналогичное исходному |
||
|
Алгоритмы сортировки (быстрая сортировка, сортировка слиянием) |
Разделение массива на части и их сортировка |
|||
|
Вычисление чисел Фибоначчи |
|
Математическое определение рекурсивно |
||
|
Ханойские башни |
|
Классическая рекурсивная задача |
||
|
|
Фракталы |
|
|
Самоподобие на разных масштабах |
Пример: обход дерева каталогов |
|
|
|||
|
import os |
|
|
|
|
|
def list_files(path): |
|
|
|
|
|
"""Рекурсивныйобходфайловойсистемы""" |
|
|
||
|
for item in os.listdir (path): |
|
|
||
|
full_=pathos.path.join |
(path,item |
) |
|
|
|
if os.path.isdir (full_path): |
|
|
||
|
print(f"Каталог: {full_path}") |
|
|
||
|
list_files |
(full_path) #Рекурсивныйобходподкаталога |
|||
|
else: |
|
|
|
|
|
print(f" Файл: |
{full_path}") |
|
|
|
Признак хорошей задачи для рекурсии: Каждый шаг уменьшает область задачи и приближает к базовому случаю, при этом структура подзадачи идентична исходной.
Поддержка рекурсии
Поддерживается:
Прямой рекурсивный вызов методов Косвенный рекурсивный вызов методов Параллельная рекурсия
Не поддерживается:
Оптимизация хвостовой рекурсии
Реализация рекурсии в Python
Прямой рекурсивный вызов функции в Python реализован довольно просто, достаточно в теле функции применить оператор вызова функции к идентификатору этой функции. Если существует еще одна ссылка которая также указывает на текущую функцию, то можно использовать и ее.
В качестве примера приведем функцию которая считает количество пробелов в строке текста. Сначала реализуем эту функцию циклически, а впоследствии рекурсивно.
Реализация с помощью цикла
def counting_white_space_cyclically(text):
n= |
0 |
|
|
for symbol in text: |
|
|
if symbol== |
"" : |
|
n=n+ |
1 |
|
return n |
|
В этом примере продемонстрирована реализация с помощью цикла. Переменная цикла проходит по всем символам строки и если текущий символ равен пробелу то увеличиваем переменную для хранения количества пробелов. После цикла возвращаем
значение этой переменной.
Реализация с помощью рекурсии
В этом примере продемонстрирована реализация с помощью рекурсии. Терминальная стадия выполняет проверку длинны строки и если длинна строки равна 0, то возвращаем 0. Потом проверяем какой символ стоит на первом месте в этой строке. Если это пробел то вернуть число 1 плюс рекурсивный вызов этой же функции. Но теперь в качестве параметра используется строка без
первого символа (вы уменьшили ее длину на единицу).
Принцип работы рекурсивной функции
Выше показан принцип работы рекурсивной функции для подсчета количества пробелов в строке. Терминальная часть содержит проверку длинны строки и если длинна строки равна 0, то нужно вернуть ноль и все (строка в которой ничего нет, не содержит пробелов). В рекурсивной части проверяем первый символ этой строки и если это пробел, то стоит вернуть 1 плюс вызов этой же функции где в качестве параметра используется строка но без первого символа. Так, как строка параметр на каждом вызове уменьшается на один символ то как только ее длинна станет равно 0, то сработает терминальная часть и рекурсивные вызовы будут окончены. Начнется процесс возвратов результатов, и начиная от терминальной части происходит обратный подъем по стеку вызовов до базового вызова.
Хвостовая рекурсия
Хвостовая рекурсия — особый вид рекурсии при котором вызов рекурсивной функции является единственным выражением в операторе возврата.
Предыдущий пример не является хвостовой рекурсией потому что оператор возврата выглядит как:
return n+counting_white_space_recursively(text[ 1:])
В этом примере после оператора возврата идет еще сложение с результатом рекурсивного вызова.
Для демонстрации принципа хвостовой рекурсии, функцию придется изменить так что бы после оператора возврата был только рекурсивный вызов.
Внимание! Python (версия 3.9 и выше) не производит оптимизацию хвостовой рекурсии, так что особого смысла в ее применении в данном языке нет.
Пример хвостовой рекурсии
Это пример хвостовой рекурсии. После оператора возврата описан только рекурсивный вызов. Для ее реализации этого приема бы добавлен дополнительный параметр функции в котором будет храниться количество найденных пробелов.
Превышение допустимой глубины рекурсии
Для демонстрации превышения допустимой глубины рекурсии напишем рекурсивную функцию вычисления факториала.
Данная функция прекрасно работает для небольших значений. Однако если в качестве параметра передать значение 1000. То программа завершиться с исключением:
RecursionError: maximum recursion depth exceeded in comparison
Это связанно с тем, что по умолчанию в Python установлено ограничение на глубину рекурсивных вызовов равное 1000 вызовов
Ограничение максимальной глубины рекурсивных вызовов в Python
В Python установлено ограничение на глубину рекурсивных вызовов. По умолчанию это значение равно 1000. Узнать значение этого ограничения можно используя функцию getrecursionlimit() модуля sys . Ниже приведен пример кода который выведет на экран это ограничение:
import sys print(sys.getrecursionlimit())
При желании вы можете установить новое ограничение, используя функцию setrecursionlimit(limit) из этого же модуля. Например вот таким способом вы можете поднять ограничение до 2000 вызовов:
import sys
sys.setrecursionlimit(2000)
Внимание! Не стоит увлекаться установкой слишком больших значений так как это может привести к повышенному потреблению оперативной памяти.
Пример когда рекурсия действительно упрощает решение
Предположим у нас есть список разных элементов. Это могут быть как обычные элементы (например числа) так и другие списки, и даже списки списков и так далее. Задача создать список в котором будут элементы хранящиеся во всех списках в базовом списке. Например:
base_list=[ |
1,[ 2, 3],[ 4, 5,[ 6, 7]],[ 8,[ 9]], 10] |
#Результат[1,2,3,: 4,5,6,7,8,9,10]
И хотя эту задачу можно решить циклически, но гораздо проще она решается рекурсивно.
Пример рекурсивного решения
def get_element(base_list, result_list=None): if result_list is None:
result=[]list
for element in base_list:
if type(element)== list: get_element(element,result list)
else: result_list.append(element)
return result_list
Решение этого задания в рекурсивном стиле довольно простое. Перебираем элементы базового списка и если текущий элемент
список, то вызываем этот же метод еще раз передавая текущий элемент в качестве параметра. Если же текущий элемент не список, то просто добавляем его в список результат.
Вывод: Для задач с вложенными структурами данных (деревья, вложенные списки, JSON) рекурсия часто даёт более элегантное и понятное решение.
Мемоизация
Мемоизация — сохранение результатов выполнения функций для предотвращения повторных вычислений. Применяется для
увеличения скорости выполнения программ.
Основной механизм реализации - перед вызовом функции проверяется, вызывалась ли функция ранее:
если не вызывалась, то функция вызывается, и результат её выполнения сохраняется; если вызывалась, то используется сохранённый результат.
Мемоизацию можно отнести к разновидности кеширования данных. При работе с рекурсивными функциями может повышать производительность.
Пример: числа Фибоначчи без мемоизации
def fibonacci(n): |
|
|
|
|
if n<= |
1: |
|
|
|
return n |
|
|
|
|
return fibonacci(n- |
1)+fibonacci(n- |
2) |
|
|
#Приn=40будетогромноеколичествоповторных |
вычислений! |
|
||
Пример: числа Фибоначчи с мемоизацией |
|
|
||
def fibonacci_memo(n, memo={}): |
|
|
||
if n in memo: |
|
|
|
|
return memo[n] |
|
|
|
|
if n<= |
1: |
|
|
|
return n |
|
|
|
|
memo[n]=fibonacci-_memo(n |
1,memo)+fibonacci-_memo(n |
2,memo) |
||
return memo[n] |
|
|
|
|
Сравнение производительности
nБез мемоизации С мемоизацией
10 |
~0.001 сек |
~0.0001 сек |
30 |
~0.3 сек |
~0.0001 сек |
40 |
~40 сек |
~0.0002 сек |
Важно: Мемоизация особенно эффективна для функций с повторяющимися вызовами с одинаковыми аргументами (например, рекурсивные вычисления чисел Фибоначчи, обход деревьев, динамическое программирование).
Общие идеи при реализации мемоизации
Нужно создать элемент (в дальнейшем хранилище) который способен хранить пары значений с последующим быстрым извлечением. Для этого идеально подойдут ассоциативные массивы (словарь, карта) — в качестве ключа использовать значение параметра функции, в качестве значения результат ее работы.
Алгоритм работы с мемоизацией
1. При вызове функции сначала проверяем, нет ли таких параметров в хранилище (проверяем наличие такого ключа):
Если ключ существует — возвращаем сохранённое значение, не выполняя тело функции Если ключа нет — выполняем тело функции, после чего записываем в хранилище пару: ключ = параметры, значение =
результат
2. Возвращаем вычисленное значение
Пример реализации на Python
def memoize(func): |
|
|
cache={} |
#хранилищерезультатов |
|
def wrapper(*args): |
|
|
if args |
in cache: |
|
print(f"Берёмизкэша: |
{args}") |
|
return cache[args] print(f"Вычисляем: {args}")
result=func(*args) cache[a=regs]ult
return result return wrapper
Применение декоратора к рекурсивной функции
@memoize |
|
|
def fibonacci(n): |
|
|
if n<= 1: |
|
|
return n |
|
|
return fibonacci(n- |
1)+fibonacci(n- |
2) |
#Проверка |
|
|
print(fibonacci(10)) |
|
|
print(fibonacci(10)) |
#повторныйвызовбудетизкэша |
|
Вычисляем: (10,) Вычисляем: (9,) Вычисляем: (8,) Вычисляем: (7,) Вычисляем: (6,) Вычисляем: (5,) Вычисляем: (4,) Вычисляем: (3,) Вычисляем: (2,) Вычисляем: (1,) Вычисляем: (0,)
Берём из кэша: (1,) Берём из кэша: (2,) Берём из кэша: (3,) Берём из кэша: (4,) Берём из кэша: (5,) Берём из кэша: (6,) Берём из кэша: (7,) Берём из кэша: (8,) 55
Берём из кэша: (10,) 55
Альтернативный подход: декоратор lru_cache
from functools import lru_cache |
|
|
@lru_cache(maxsize=128) #встроеннаямемоизациявPython |
|
|
def fibonacci(n): |
|
|
if n<= 1: |
|
|
return n |
|
|
return fibonacci(n- |
1)+fibonacci(n- |
2) |
print(fibonacci(10))
55
Совет: В Python для мемоизации удобно использовать встроенный декоратор @lru_cache из модуля functools . Он автоматически реализует кэширование результатов с ограничением размера кэша.
Замечания по использованию мемоизации
Общие рекомендации при применении мемоизации:
Для того, чтобы функцию можно было подвергнуть мемоизации, она должна быть чистой:
детерминированной (т.е. при одном и том же наборе параметров функция должна возвращать одинаковое значение) без побочных эффектов (т.е. не должна влиять на состояние системы)
Мемоизация — это компромисс между производительностью и потреблением памяти.
Мемоизация хороша для функций, имеющих сравнительно небольшой диапазон входных значений, что позволяет достаточно часто при повторных вызовах функций задействовать значения, найденные ранее, не тратя на хранение данных слишком много памяти.
Функции с мемоизацией хорошо показывают себя там, где выполняются сложные, ресурсоёмкие вычисления. Здесь данная техника может значительно повысить производительность решения.
Иллюстрация компромисса "память vs скорость"
Фактор |
Без мемоизации |
С мемоизацией |
Скорость |
Медленно при повторных вызовах |
Быстро (особенно при повторах) |
Память |
Минимальное потребление |
Дополнительная память под кэш |
Когда эффективно |
Редкие вызовы, простые вычисления |
Частые вызовы, сложные вычисления |
Пример функции, не подходящей для мемоизации
#Функциянедетерминиро(зависитаннаяотвремени) import random
def random_multiplier(x): return x*random.random()
Вывод: Мемоизация наиболее эффективна для чистых функций с ограниченным набором входных параметров и
тяжёлыми вычислениями (числа Фибоначчи, динамическое программирование, рекурсивные обходы деревьев).
Реализация на Python
Простая реализация мемоизации
В примере показан пример простой реализации мемоизации. В качестве хранилища используется внешний словарь. В функции выполняется проверка (есть ли такой ключ в словаре) и если да, то возвращается значение с ним связанное. В противном случае вычисляется новое значение и после вычисления заносится в хранилище.
Реализация с помощью замыкания
В примере показан пример реализации мемоизации с помощью замыканий. Данный прием можно применить к более широкому спектру функций. Теперь если вам нужно на основе функции получить функцию с мемоизацией достаточно передать эту функцию в качестве параметра.
Пример использования
В этом примере продемонстрирована получение функции с мемоизацией на основе обычной функции. А так, как функция get_memoization_function принимает один параметр, то ее также можно использовать как декоратор, что делает ее применение еще более удобным.
Пример применения как декоратор
