Skip to content

Commit 843e62c

Browse files
agorinenkoAnton Gorinenko
andauthored
Recursion (#15)
* Рекурсия --------- Co-authored-by: Anton Gorinenko <[email protected]>
1 parent 0de13a5 commit 843e62c

File tree

5 files changed

+224
-2
lines changed

5 files changed

+224
-2
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545

4646
[Бинарный поиск](tutorial/binary_search.md)
4747

48-
[Рекурсия](tutorial/recursion.md) (в работе)
48+
[Рекурсия](tutorial/recursion.md)
4949

5050
[Динамическое программирование](tutorial/dynamic_programming.md) (в работе)
5151

img/req_1.png

28 KB
Loading

img/req_2.png

27.6 KB
Loading

img/req_3.png

10.1 KB
Loading

tutorial/recursion.md

Lines changed: 223 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,225 @@
11
# Рекурсия
22

3-
https://blog.skillfactory.ru/glossary/rekursiya/
3+
Функция называется **рекурсивной**, если в процессе своего выполнения она вызывает сама себя. Суть рекурсии состоит в
4+
разбиении одной большой задачи на более простые однотипные подзадачи, которые поступают на вход рекурсивному вызову.
5+
6+
Ветвь алгоритма, на которой задача сводится к более простой, и происходит рекурсивный вызов, называется **шагом
7+
рекурсии** или **рекуррентным соотношением**.
8+
9+
Процесс повторяется до тех пор, пока очередную подзадачу нельзя разбить на более мелкие. Эта часть алгоритма
10+
называется **базой рекурсии**. Другими словами, база рекурсии — это условие при котором функция прекращает вызывать саму
11+
себя. База рекурсии позволяет выйти из цикла вызовов функции.
12+
13+
Python и прочие языки программирования имеют ограничение на количество вложенных вызовов функций или **глубину
14+
рекурсии**, по умолчанию для python это значение равно 1000. Ниже приведена схема рекурсивного алгоритма.
15+
16+
![Рекурсия](../img/req_1.png)
17+
18+
Реализация рекурсивных вызовов функций неотрывно связана с такой структурой данных, как **стек вызовов** — адрес
19+
возврата и локальные переменные функции записываются в стек, благодаря чему каждый следующий рекурсивный вызов этой
20+
функции пользуется своим набором локальных переменных. Следует понимать, что при таком подходе на каждый рекурсивный
21+
вызов требуется некоторое количество оперативной памяти, и при чрезмерно большой глубине рекурсии может наступить
22+
переполнение стека вызовов. Поэтому иногда бывает целесообразно заменить рекурсию на итеративный подход.
23+
24+
Рассмотрим следующую задачу. Дан массив букв s. Напишите функцию, которая переворачивает строку задом наперед. Не
25+
разрешается использовать дополнительную память, все преобразования должны производиться с исходным массивом.
26+
27+
Оригинал: [344. Reverse String](https://leetcode.com/problems/reverse-string/description/)
28+
29+
Реализация с использованием рекурсии
30+
31+
```python
32+
from typing import List, Optional
33+
34+
35+
def reverse_string(s: List[str], left: Optional[int] = 0) -> None:
36+
right = -(left + 1)
37+
if left > (len(s) // 2) - 1:
38+
return None
39+
40+
reverse_string(s, left + 1)
41+
s[left], s[right] = s[right], s[left]
42+
```
43+
44+
Итеративный подход с использованием двух указателей
45+
46+
```python
47+
from typing import List
48+
49+
50+
def reverse_string(s: List[str]) -> None:
51+
left, right = 0, len(s) - 1
52+
while left < right:
53+
s[left], s[right] = s[right], s[left]
54+
left += 1
55+
right -= 1
56+
```
57+
58+
Сравнение использования ЦПУ и памяти
59+
60+
![Сравнение использования ЦПУ и памяти](../img/req_2.png)
61+
62+
Как видим использование цикла сокращает потребление памяти, однако рекурсия иногда позволяет очень кратко и наглядно
63+
решить поставленную задачу, например, рекурсию очень часто используют при обходе графов в глубину взамен явного
64+
использования стека.
65+
66+
Итак, прежде чем реализовывать рекурсивную функцию, необходимо выяснить две важные вещи:
67+
68+
1. Определить базу рекурсии — т.е. случай, когда можно вычислить ответ напрямую без каких-либо дополнительных вызовов
69+
рекурсии.
70+
2. Определить связь между результатом задачи и результатом ее подзадач, т.е. найти рекуррентное соотношение.
71+
72+
Чтобы показать это на примере, решим задачу про треугольник Паскаля, используя рекурсию.
73+
74+
Оригинал: [118. Pascal's Triangle](https://leetcode.com/problems/pascals-triangle/)
75+
76+
Требуется построить первые num_rows строк треугольника Паскаля. Треугольник Паскаля — бесконечная таблица, имеющая
77+
треугольную форму. В этом треугольнике на вершине и по бокам стоят единицы. Каждое число равно сумме двух расположенных
78+
над ним чисел. Строки треугольника симметричны относительно вертикальной оси.
79+
80+
![Треугольник Паскаля](../img/req_3.png)
81+
82+
Для начала определим функцию ``f(i, j)``, которая по номеру строки ``i`` и номеру столбца ``j`` возвращает число ячейки
83+
треугольника.
84+
85+
Базой рекурсии является то утверждение, что на вершине и по бокам стоят единицы. Формально это можно записать так:
86+
87+
``f(i, j) = 1, если j = 0 или i = j``
88+
89+
Рекуррентное соотношение основывается на формуле:
90+
91+
``f(i, j) = f(i - 1, j - 1) + f(i - 1, j)``
92+
93+
Реализацию данной задачи мы можем видеть ниже:
94+
95+
```python
96+
from typing import List
97+
98+
99+
def generate(num_rows: int) -> List[List[int]]:
100+
triangle = []
101+
for i in range(num_rows):
102+
row = []
103+
for j in range(i + 1):
104+
row.append(calculate_cell(i, j))
105+
106+
triangle.append(row)
107+
108+
return triangle
109+
110+
111+
def calculate_cell(i: int, j: int) -> int:
112+
# Базовый случай
113+
if j == 0 or i == j:
114+
return 1
115+
# рекуррентное соотношение
116+
return calculate_cell(i - 1, j - 1) + calculate_cell(i - 1, j)
117+
```
118+
119+
Можем заметить, что вычисление промежуточных ячеек мы производили по несколько раз. Спускаясь от самой первой строке к
120+
строке с номером num_rows, мы повторно подсчитывали значения ячеек предыдущих строк. Или например, для ячеек (5, 3) и (
121+
5, 4), мы два раза посчитали (4, 3). Чтобы этого избежать, следует применить **мемоизацию**.
122+
123+
**Мемоизация** — пример использования кеша при разработке программного обеспечения, в программировании сохранение
124+
результатов выполнения функций для предотвращения повторных вычислений. Это один из способов оптимизации, применяемый
125+
для увеличения скорости выполнения компьютерных программ.
126+
127+
Перед вызовом функции проверяется, вызывалась ли функция ранее:
128+
129+
1. если не вызывалась, то функция вызывается, и результат её выполнения сохраняется;
130+
1. если вызывалась, то используется сохранённый результат.
131+
132+
Доработаем реализацию генератора треугольника Паскаля с применением мемоизации. В качестве кеша используем хеш-таблицу,
133+
ключами которой будут являться координаты ячейки `(i, j)`.
134+
135+
```python
136+
from typing import List
137+
138+
139+
def generate(num_rows: int) -> List[List[int]]:
140+
triangle = []
141+
memory = {}
142+
for i in range(num_rows):
143+
row = []
144+
for j in range(i + 1):
145+
row.append(calculate_cell(i, j, memory))
146+
147+
triangle.append(row)
148+
149+
return triangle
150+
151+
152+
def calculate_cell(i: int, j: int, memory: dict) -> int:
153+
coordinate = (i, j)
154+
if coordinate in memory:
155+
return memory[coordinate]
156+
# Базовый случай
157+
if j == 0 or i == j:
158+
memory[coordinate] = 1
159+
return 1
160+
# рекуррентное соотношение
161+
result = calculate_cell(i - 1, j - 1, memory) + calculate_cell(i - 1, j, memory)
162+
memory[coordinate] = result
163+
return result
164+
```
165+
166+
Общая **временная сложность** рекурсии `O(T)` определяется количеством рекурсивных вызовов `R` умноженном на временную
167+
сложность одной операции `O(s)` или `O(T) = R * O(s)`.
168+
169+
Оценим временную сложность генерации треугольника Паскаля без мемоизации. Пусть в нем n ячеек, для каждой ячейки в
170+
худшем случае мы должны сделать n рекурсивных вызовов для расчета предыдущих значений. Таким образом всего будет n<sup>
171+
2</sup> вызовов. Временная сложность одной операции сложения константа. Т.о. общая временная сложность составит O(T) =
172+
n<sup>2</sup> * O(1) = n<sup>2</sup>. С учетом мемоизации потребуется О(n) памяти для хранения уже вычисленных значений.
173+
Однако для каждой ячейки не следует снова вычислять предыдущие значения, следовательно, каждая ячейка будет вычислена
174+
один раз. Временная сложность в этом случае составит О(n).
175+
176+
При оценке **занимаемой памяти** в предыдущем примере мы не учли ресурсы, которые необходимы для неявно используемого
177+
рекурсией стека вызовов. В процессе вызова функций система выделяет некоторое количество оперативной памяти для хранения
178+
в стеке вызовов следующих вещей:
179+
180+
1. Адрес команды, следующей за командой вызова ("адрес возврата"). При вызове подпрограммы или возникновении прерывания,
181+
в стек заносится адрес возврата — адрес в памяти приостановленной программы и управление передается подпрограмме. При
182+
последующем рекурсивном вызове в стек заносится очередной адрес возврата и т.д.
183+
1. Аргументы, переданные в функцию
184+
1. Локальные переменные внутри вызова функции
185+
186+
Это минимальные затраты оперативной памяти для вызова любой функции. После того как функция завершиться, данные будут
187+
освобождены. Однако в рекурсивных алгоритмах образуется цепочка вызовов, в которой самая первая функция не будет
188+
завершена, пока связанные с ней подпрограммы не достигнут базового случая. Таким образом объем занимаемой памяти с
189+
каждым разом увеличивается вместе со стеком вызовов. Это наглядно продемонстрировано в решении задачи о разворачивании
190+
строки с использованием рекурсии и итеративного подхода. Второй занял меньше памяти.
191+
192+
Подводя итоги, хочется отметить, что при оценке пространственной сложности задач, связанных с рекурсией стоит не
193+
забывать учитывать память, связанную с неявным использованием стека вызовов, а также память, потребляемую самой
194+
программой. Например, при использовании мемоизации.
195+
196+
**Хвостовая рекурсия** — частный случай рекурсии, который освобожден от неявных накладных расходов, связанных со стеком
197+
вызовов и памятью. Хвостовая рекурсия — это рекурсия, в которой рекурсивный вызов является последней и единственной
198+
инструкцией перед возвратом из функции. При таком подходе нет локальных переменных, а значит нет необходимости хранить
199+
их в стеке, а адрес возврата уже находится там. Поэтому в такой ситуации вместо полноценного рекурсивного вызова функции
200+
можно просто заменить значения параметров в стеке и передать управление на точку входа. Некоторые компиляторы
201+
оптимизируют хвостовую рекурсию, преобразуя ее с использованием итеративного подхода, однако python и java этого не
202+
делают.
203+
204+
Пример вычисления факториала с использованием хвостовой рекурсии:
205+
206+
```python
207+
def factorial(n: int, acc: int) -> int:
208+
if n == 0:
209+
return acc
210+
211+
return factorial(n - 1, acc * n)
212+
```
213+
214+
Пример вычисления факториала без использования хвостовой рекурсии:
215+
216+
```python
217+
def factorial(n: int) -> int:
218+
if n == 0:
219+
return 1
220+
221+
return n * factorial(n - 1)
222+
```
223+
224+
Видим, что в последнем случае рекурсивная функция умножается на n, т.е. ее вызов не является единственной инструкцией
225+
перед возвратом из функции.

0 commit comments

Comments
 (0)