First version.

This commit is contained in:
krahets
2026-01-20 15:08:42 +08:00
parent 2213a59ff6
commit 8071daddaa
106 changed files with 11790 additions and 0 deletions
@@ -0,0 +1,247 @@
# Итерация и рекурсия
В алгоритмах часто требуется повторное выполнение определенной задачи, что тесно связано с анализом сложности. Поэтому, прежде чем перейти к обсуждению временной и пространственной сложности, рассмотрим, как реализовать повторное выполнение задач в программе, а именно две основные структуры управления программой: итерацию и рекурсию.
## Итерации
*Итерация* -- это структура управления, которая позволяет повторно выполнять определенную задачу. В итерации программа повторяет выполнение определенного участка кода, пока выполняется определенное условие.
### Цикл for
*Цикл* for -- одна из наиболее распространенных форм итерации, которая подходит для использования, когда количество итераций известно заранее.
Следующая функция реализует суммирование 1 + 2 + \... + *n* с использованием цикла for, результат суммирования сохраняется в переменной res. Следует отметить, что в Python диапазон range(a, b) соответствует левому закрытому, правому открытому интервалу, т. е. перебираются значения *a*, *a* + 1, \... , *b* 1:
```src
[file]{iteration}-[class]{}-[func]{for_loop}
```
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:下图是该求和函数的流程框图。 -->
![Блок-схема функции суммирования](../assets/iteration_and_recursion.assets/iteration.png)
Количество операций этой функции суммирования пропорционально размеру входных данных *n*, или, другими словами, линейно зависит от него. **На самом деле временная сложность описывает именно эту линейную зависимость**. Соответствующий материал будет подробно рассмотрен в следующем разделе.
### Цикл while
Подобно циклу for, цикл while также представляет собой метод реализации итерации. В цикле while программа перед каждой итерацией проверяет условие: если условие истинно, то выполнение продолжается, иначе цикл завершается.
Ниже приведен пример реализации суммирования 1 + 2 + \... + *n* с использованием цикла while:
```src
[file]{iteration}-[class]{}-[func]{while_loop}
```
**Цикл** while **обладает большей степенью свободы по сравнению с циклом** for. В цикле while можно свободно управлять инициализацией и обновлением условной переменной.
Например, в следующем коде условная переменная *i* обновляется дважды на каждой итерации, что затруднительно сделать с использованием цикла for:
```src
[file]{iteration}-[class]{}-[func]{while_loop_ii}
```
В целом **код с использованием цикла** for **более компактный**, **а цикл** while **более гибкий**. Но они оба могут реализовать итерационную структуру. Выбор между ними определяется требованиями конкретной задачи.
### Вложенные циклы
Внутрь одной циклической структуры можно вложить другую, например используя два цикла for:
```src
[file]{iteration}-[class]{}-[func]{nested_for_loop}
```
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:下图是该嵌套循环的流程框图。 -->
![Блок-схема вложенного цикла](../assets/iteration_and_recursion.assets/nested_iteration.png)
В этом случае количество выполненных действий пропорционально *n*², или, другими словами, время выполнения алгоритма и размер входных данных *n* находятся в квадратичной зависимости.
Можно и дальше добавлять вложенные циклы, тогда каждое вложение будет повышать размерность, увеличивая временную сложность до кубической зависимости, зависимости четвертой степени и т. д.
## Рекурсия
*Рекурсия* -- это стратегия алгоритма, при которой функция вызывает саму себя для решения задачи. Она включает два основных этапа.
1. **Вызов**: программа постоянно вызывает саму себя, обычно передавая меньшие или более упрощенные параметры, пока не будет достигнуто условие завершения.
2. **Возврат**: после срабатывания условия завершения программа начинает возвращаться из самой глубокой рекурсивной функции, объединяя результаты каждого уровня.
С точки зрения реализации рекурсивный код включает три основных элемента.
1. **Условие завершения**: используется для определения момента перехода от вызова к возврату.
2. **Рекурсивный вызов**: соответствует вызову, функция вызывает саму себя, обычно с меньшими или упрощенными параметрами.
3. **Возврат результата**: соответствует возврату, возвращает результат текущего уровня рекурсии на предыдущий уровень.
Рассмотрим следующий код: вызов функции recur(n) позволяет вычислить сумму 1 + 2 + \... + *n*.
```src
[file]{recursion}-[class]{}-[func]{recur}
```
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:下图展示了该函数的递归过程。 -->
![Рекурсивный вызов функции суммирования](../assets/iteration_and_recursion.assets/recursion_sum.png)
Хотя с точки зрения вычислений итерация и рекурсия могут давать одинаковый результат, они представляют собой совершенно разные парадигмы мышления и решения задач.
- **Итерация**: решение задачи снизу вверх. Начинаем с самых базовых шагов, которые затем повторяются или накапливаются до завершения задачи.
- **Рекурсия**: решение задачи сверху вниз. Исходная задача разбивается на более мелкие подзадачи, которые имеют ту же форму, что и исходная задача. Далее подзадачи продолжают делиться на еще более мелкие, пока не достигается базовый случай (решение базового случая известно).
Рассмотрим в качестве примера вышеупомянутую функцию суммирования, где решается задача *f*(*n*) = 1 + 2 + \... + *n*.
- **Итерация**: моделирование процесса суммирования в цикле проходит от 1 до *n*, выполняя операцию суммирования на каждом шаге, чтобы получить итоговое значение *f*(*n*).
- **Рекурсия**: последовательное разбиение задачи на подзадачи вида *f*(*n*) = *n* + *f*(*n* -- 1) до достижения базового случая *f*(1) = 1.
### Стек вызовов
Каждый раз, когда рекурсивная функция вызывает саму себя, система выделяет память для нового вызова функции, чтобы хранить локальные переменные, адрес вызова и другую информацию. Это поведение имеет два последствия.
- Контекстные данные функции хранятся в области памяти, называемой пространством стекового кадра, и освобождаются только после возврата функции. **Поэтому рекурсия обычно требует больше памяти, чем итерация**.
- Рекурсивный вызов функции создает дополнительные накладные расходы. **Поэтому рекурсия обычно менее эффективна по времени, чем циклы**.
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:如下图所示,在触发终止条件前,同时存在 $n$ 个未返回的递归函数,**递归深度为 $n$** 。 -->
![Глубина рекурсивного вызова](../assets/iteration_and_recursion.assets/recursion_sum_depth.png)
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。 -->
### Хвостовая рекурсия
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:有趣的是,**如果函数在返回前的最后一步才进行递归调用**,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为<u>尾递归(tail recursion</u>。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:- **普通递归**:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:- **尾递归**:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:以计算 $1 + 2 + \dots + n$ 为例,我们可以将结果变量 `res` 设为函数参数,从而实现尾递归: -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:```src
[file]{recursion}-[class]{}-[func]{tail_recur}
``` -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:尾递归的执行过程如下图所示。对比普通递归和尾递归,两者的求和操作的执行点是不同的。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:- **普通递归**:求和操作是在"归"的过程中执行的,每层返回后都要再执行一次求和操作。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:- **尾递归**:求和操作是在"递"的过程中执行的,"归"的过程只需层层返回。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:![尾递归过程](iteration_and_recursion.assets/tail_recursion_sum.png) -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:!!! tip
请注意,许多编译器或解释器并不支持尾递归优化。例如,Python 默认不支持尾递归优化,因此即使函数是尾递归形式,仍然可能会遇到栈溢出问题。 -->
### Дерево рекурсии
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:当处理与"分治"相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以"斐波那契数列"为例。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:!!! question
给定一个斐波那契数列 $0, 1, 1, 2, 3, 5, 8, 13, \dots$ ,求该数列的第 $n$ 个数字。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:设斐波那契数列的第 $n$ 个数字为 $f(n)$ ,易得两个结论。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:- 数列的前两个数字为 $f(1) = 0$ 和 $f(2) = 1$ 。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:- 数列中的每个数字是前两个数字的和,即 $f(n) = f(n - 1) + f(n - 2)$ 。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 `fib(n)` 即可得到斐波那契数列的第 $n$ 个数字: -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:```src
[file]{recursion}-[class]{}-[func]{fib}
``` -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:观察以上代码,我们在函数内递归调用了两个函数,**这意味着从一个调用产生了两个调用分支**。如下图所示,这样不断递归调用下去,最终将产生一棵层数为 $n$ 的<u>递归树(recursion tree</u>。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:![斐波那契数列的递归树](iteration_and_recursion.assets/recursion_tree.png) -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:从本质上看,递归体现了"将问题分解为更小子问题"的思维范式,这种分治策略至关重要。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:- 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:- 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。 -->
## Сравнение итерации и рекурсии
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:总结以上内容,如下表所示,迭代和递归在实现、性能和适用性上有所不同。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:<p align="center"> 表 <id> &nbsp; 迭代与递归特点对比 </p> -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:| | 迭代 | 递归 |
| -------- | -------------------------------------- | ------------------------------------------------------------ |
| 实现方式 | 循环结构 | 函数调用自身 |
| 时间效率 | 效率通常较高,无函数调用开销 | 每次函数调用都会产生开销 |
| 内存使用 | 通常使用固定大小的内存空间 | 累积函数调用可能使用大量的栈帧空间 |
| 适用问题 | 适用于简单循环任务,代码直观、可读性好 | 适用于子问题分解,如树、图、分治、回溯等,代码结构简洁、清晰 | -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:!!! tip
如果感觉以下内容理解困难,可以在读完"栈"章节后再来复习。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:那么,迭代和递归具有什么内在联系呢?以上述递归函数为例,求和操作在递归的"归"阶段进行。这意味着最初被调用的函数实际上是最后完成其求和操作的,**这种工作机制与栈的"先入后出"原则异曲同工**。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:事实上,"调用栈"和"栈帧空间"这类递归术语已经暗示了递归与栈之间的密切关系。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:1. **递**:当函数被调用时,系统会在"调用栈"上为该函数分配新的栈帧,用于存储函数的局部变量、参数、返回地址等数据。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:2. **归**:当函数完成执行并返回时,对应的栈帧会被从"调用栈"上移除,恢复之前函数的执行环境。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:因此,**我们可以使用一个显式的栈来模拟调用栈的行为**,从而将递归转化为迭代形式: -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:```src
[file]{recursion}-[class]{}-[func]{for_loop_recur}
``` -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:观察以上代码,当递归转化为迭代后,代码变得更加复杂了。尽管迭代和递归在很多情况下可以互相转化,但不一定值得这样做,有以下两点原因。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:- 转化后的代码可能更加难以理解,可读性更差。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:- 对于某些复杂问题,模拟系统调用栈的行为可能非常困难。 -->
<!-- 🔴 俄文版缺失此段落 -->
<!-- 中文原文:总之,**选择迭代还是递归取决于特定问题的性质**。在编程实践中,权衡两者的优劣并根据情境选择合适的方法至关重要。 -->