|
1 | 1 | # Рекурсия |
2 | 2 |
|
3 | | -https://blog.skillfactory.ru/glossary/rekursiya/ |
| 3 | +Функция называется **рекурсивной**, если в процессе своего выполнения она вызывает сама себя. Суть рекурсии состоит в |
| 4 | +разбиении одной большой задачи на более простые однотипные подзадачи, которые поступают на вход рекурсивному вызову. |
| 5 | + |
| 6 | +Ветвь алгоритма, на которой задача сводится к более простой, и происходит рекурсивный вызов, называется **шагом |
| 7 | +рекурсии** или **рекуррентным соотношением**. |
| 8 | + |
| 9 | +Процесс повторяется до тех пор, пока очередную подзадачу нельзя разбить на более мелкие. Эта часть алгоритма |
| 10 | +называется **базой рекурсии**. Другими словами, база рекурсии — это условие при котором функция прекращает вызывать саму |
| 11 | +себя. База рекурсии позволяет выйти из цикла вызовов функции. |
| 12 | + |
| 13 | +Python и прочие языки программирования имеют ограничение на количество вложенных вызовов функций или **глубину |
| 14 | +рекурсии**, по умолчанию для python это значение равно 1000. Ниже приведена схема рекурсивного алгоритма. |
| 15 | + |
| 16 | + |
| 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 | + |
| 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 | + |
| 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