理解递归——通过一个简单的例子
递归是一种函数自己调用自己的编程技术。递归的核心思想是将一个复杂问题逐步简化,直到问题足够简单时,可以直接解决。为了帮助你更好地理解递归,我们来举几个具体的例子。
例子1:递归求阶乘
阶乘是一个数字乘以比它小的所有正整数的结果,通常表示为 n!,公式为:
n!=n×(n−1)×(n−2)×…×1n! = n \times (n - 1) \times (n - 2) \times \ldots \times 1n!=n×(n−1)×(n−2)×…×1
比如:
5!=5×4×3×2×1=1205! = 5 \times 4 \times 3 \times 2 \times 1 = 1205!=5×4×3×2×1=120
使用递归计算阶乘
递归的思路:
- 如果
n == 1,那么1!就等于1,这就是递归的结束条件。 - 如果
n > 1,则n! = n × (n - 1)!,这就将问题分解成更小的问题。
递归函数实现
#include <iostream>
using namespace std;
int factorial(int n) {
if (n == 1) return 1; // 递归结束条件
return n * factorial(n - 1); // 递归调用
}
int main() {
int n = 5;
cout << "5! = " << factorial(n) << endl;
return 0;
}
如何理解递归的执行过程?
当我们调用 factorial(5) 时,发生了以下步骤:
ruby
复制代码
factorial(5)
=> 5 * factorial(4)
=> 5 * (4 * factorial(3))
=> 5 * (4 * (3 * factorial(2)))
=> 5 * (4 * (3 * (2 * factorial(1))))
=> 5 * (4 * (3 * (2 * 1)))
=> 120
递归的每一层都会将问题拆分,直到达到最小的问题 factorial(1),然后再将结果逐层返回。
例子2:递归求斐波那契数列
斐波那契数列是一个数字序列,其中每个数字是前两个数字的和,序列从 0 和 1 开始:
0,1,1,2,3,5,8,13,…0, 1, 1, 2, 3, 5, 8, 13, \ldots0,1,1,2,3,5,8,13,…
数学公式为:
F(n)=F(n−1)+F(n−2)F(n) = F(n-1) + F(n-2)F(n)=F(n−1)+F(n−2)
- 当
n = 0时,F(0) = 0 - 当
n = 1时,F(1) = 1
使用递归计算斐波那契数
#include <iostream>
using namespace std;
int fibonacci(int n) {
if (n == 0) return 0; // 递归结束条件
if (n == 1) return 1; // 递归结束条件
return fibonacci(n - 1) + fibonacci(n - 2); // 递归调用
}
int main() {
int n = 6;
cout << "Fibonacci(6) = " << fibonacci(n) << endl;
return 0;
}
如何理解递归的执行过程?
当我们调用 fibonacci(6) 时,递归过程如下:
fibonacci(6)
=> fibonacci(5) + fibonacci(4)
=> (fibonacci(4) + fibonacci(3)) + (fibonacci(3) + fibonacci(2))
=> ((fibonacci(3) + fibonacci(2)) + (fibonacci(2) + fibonacci(1))) + ((fibonacci(2) + fibonacci(1)) + fibonacci(1))
递归的调用会继续展开,直到达到基本情况 fibonacci(0) 和 fibonacci(1)。
例子3:递归处理文件夹结构(类比日常生活)
想象你在电脑里有一个文件夹,里面可能有很多子文件夹,每个子文件夹里还可以有更多的文件和文件夹。你想查找某个特定的文件,怎么办?
递归查找文件的思路是:
- 如果当前文件夹包含你要找的文件,就返回文件。
- 否则,遍历所有的子文件夹,对每个子文件夹递归调用同样的查找方法。
- 当所有文件夹都查找完时,如果还没找到,表示文件不存在。
递归的关键概念总结
- 递归函数:一个函数调用自身。
- 递归结束条件:递归必须有一个明确的结束条件,否则会进入无限循环。
- 分而治之:通过将问题拆分为更小的子问题,每次解决一个更简单的问题。
递归的日常生活例子
-
镜子中的镜子:
- 你站在两个相对的镜子之间,会看到无穷无尽的自己。这就类似于递归——每个镜子中的自己又看到另一个镜子里的自己,直到看不清为止。
-
俄罗斯套娃:
- 每个娃娃里都有一个更小的娃娃,递归地打开套娃,直到最后只剩下一个最小的娃娃为止。
2. 快速排序算法模板
void quick_sort(int q[], int l, int r)
{
if (l >= r) return;
int i = l - 1, j = r + 1, x = q[l + r >> 1];
while (i < j)
{
do i ++ ; while (q[i] < x);
do j -- ; while (q[j] > x);
if (i < j) swap(q[i], q[j]);
}
quick_sort(q, l, j), quick_sort(q, j + 1, r);
}
- 函数声明和基本结构:
void quick_sort(int q[], int l, int r)
-
参数说明:
- q[] 是需要排序的整数数组
- l 是当前需要排序的区间左端点
- r 是当前需要排序的区间右端点
- 递归终止条件:
if (l >= r) return;
- 当区间长度小于等于1时(l >= r),说明该区间已经有序,直接返回
- 初始化变量:
int i = l - 1, j = r + 1; // 双指针,分别从区间两端开始
int x = q[l + r >> 1]; // 选取分界点(中间位置的数)
l + r >> 1等价于(l + r) / 2,是位运算写法- x 是选取的分界值,用于将数组分成两部分
- 划分过程:
while (i < j)
{
do i ++ ; while (q[i] < x); // 从左向右找到大于等于x的数
do j -- ; while (q[j] > x); // 从右向左找到小于等于x的数
if (i < j) swap(q[i], q[j]); // 如果指针未相遇,则交换这两个数
}
这个过程会将数组分成两部分:
- 左半部分的数都小于等于x
- 右半部分的数都大于等于x
- 递归处理:
quick_sort(q, l, j), quick_sort(q, j + 1, r);
- 将划分好的两个子区间继续递归排序
具体工作流程示例: 假设数组:[4, 2, 7, 1, 3]
-
第一次划分:
- 选择分界值x=2
- 划分后:[1, 2, 7, 4, 3]
- j指向位置1(值为2)
-
递归处理左区间[1, 2]和右区间[7, 4, 3]
特点分析:
-
这种实现方式是经典快排的一种优化版本:
- 选择中间位置的数作为分界值,避免了有序数组的最差情况
- 使用do-while循环处理相等元素,能更好地处理有重复元素的情况
-
时间复杂度:
- 平均情况:O(nlogn)
- 最坏情况:O(n²),但由于选取中间值作为分界点,最坏情况较难出现
-
空间复杂度:
- O(logn),主要是递归调用栈的空间
quick_sort(q, l, j) 中各个参数的含义:
-
q- 数组参数:- 这是需要排序的整数数组
- 它包含了所有需要排序的元素
- 在递归调用中,q 始终指向同一个数组,只是处理的区间范围不同
-
l- 左边界:- 表示当前要处理的区间的左端点下标
- 在这个递归调用中,l 保持原值不变
- 它表示本次要处理的左侧起始位置
-
j- 右边界:- 这里的 j 是划分过程结束后的分界点位置
- 它代表本次划分后,左半部分的终点位置
- 所有小于等于分界值x的元素都在j的左侧(包括j位置)
让我们通过一个具体例子来说明:
假设有数组:[4, 2, 7, 1, 3],初始调用是 quick_sort(q, 0, 4)
void quick_sort(int q[], int l, int r)
{
if (l >= r) return;
int i = l - 1, j = r + 1, x = q[l + r >> 1];
while (i < j)
{
do i ++ ; while (q[i] < x);
do j -- ; while (q[j] > x);
if (i < j) swap(q[i], q[j]);
}
quick_sort(q, l, j), quick_sort(q, j + 1, r);
}
第一次划分后可能的情况:
初始:[4, 2, 7, 1, 3] x=2
划分后:[1, 2, 7, 4, 3]
j指向位置1(值为2)
此时的递归调用 quick_sort(q, l, j) 意味着:
- q:仍然是原数组 [1, 2, 7, 4, 3]
- l:是0(原始左边界)
- j:是1(分界点位置)
所以 quick_sort(q, l, j) 会处理数组的 [0,1] 区间,也就是 [1, 2] 这部分
而后面的 quick_sort(q, j + 1, r) 会处理:
- q:同样是原数组
- j + 1:是2(分界点位置+1)
- r:是4(原始右边界)
也就是处理数组的 [2,4] 区间,即 [7, 4, 3] 这部分
这种划分方式确保了:
- 两个子区间互不重叠
- 左区间的所有元素 ≤ 分界值
- 右区间的所有元素 ≥ 分界值
3.归并排序的模板
void merge_sort(int q[], int l, int r)
{
if (l >= r) return;
int mid = l + r >> 1;
merge_sort(q, l, mid);
merge_sort(q, mid + 1, r);
int k = 0, i = l, j = mid + 1;
while (i <= mid && j <= r)
if (q[i] <= q[j]) tmp[k ++ ] = q[i ++ ];
else tmp[k ++ ] = q[j ++ ];
while (i <= mid) tmp[k ++ ] = q[i ++ ];
while (j <= r) tmp[k ++ ] = q[j ++ ];
for (i = l, j = 0; i <= r; i ++, j ++ ) q[i] = tmp[j];
}
作者:yxc
链接:https://www.acwing.com/blog/content/277/
来源:AcWing
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
(1).解释
这段归并排序(Merge Sort)的代码:
这是一个典型的归并排序实现,让我们一步步分析:
- 函数声明和递归终止条件:
void merge_sort(int q[], int l, int r) {
if (l >= r) return; // 当区间长度为1或无效时终止递归
- 递归分治:
int mid = l + r >> 1; // 计算中点,等价于 (l + r) / 2
merge_sort(q, l, mid); // 递归排序左半部分
merge_sort(q, mid + 1, r); // 递归排序右半部分
- 合并过程:
int k = 0, i = l, j = mid + 1; // k是tmp数组的索引,i和j分别指向左右两个有序区间的起始位置
// 比较两个区间的元素,将较小的放入临时数组
while (i <= mid && j <= r)
if (q[i] <= q[j]) tmp[k++] = q[i++];
else tmp[k++] = q[j++];
- 处理剩余元素:
while (i <= mid) tmp[k++] = q[i++]; // 将左区间剩余元素放入临时数组
while (j <= r) tmp[k++] = q[j++]; // 将右区间剩余元素放入临时数组
- 将排好序的部分复制回原数组:
for (i = l, j = 0; i <= r; i++, j++) q[i] = tmp[j];
算法的主要思想:
- 采用分治策略,将数组不断二分,直到子数组长度为1
- 然后将相邻的有序子数组合并为一个更大的有序数组
- 合并过程使用双指针技术,保证稳定性
- 使用临时数组tmp来存储合并过程中的有序序列
时间复杂度为O(nlogn),空间复杂度为O(n),是一个稳定的排序算法。
举个例子说明: 假设输入数组为:[6, 2, 4, 1]
- 首先分成 [6, 2] 和 [4, 1]
- [6, 2] 继续分成 [6] 和 [2],合并得到 [2, 6]
- [4, 1] 继续分成 [4] 和 [1],合并得到 [1, 4]
- 最后合并 [2, 6] 和 [1, 4],得到最终结果 [1, 2, 4, 6]
需要注意的是,代码中使用了全局数组tmp作为临时存储空间,这在实际使用时需要确保tmp数组大小足够。
(2).为什么 j = mid + 1 是右边有序区间的起始位置:
- 当我们执行 merge_sort(q, l, r) 时,数组被分成两半:
int mid = l + r >> 1; // 找中点
merge_sort(q, l, mid); // 左半部分: [l, mid]
merge_sort(q, mid + 1, r); // 右半部分: [mid+1, r]
- 画图说明:
原数组: [3 1 4 2 5 6]
l mid r
↓ ↓ ↓
位置: 0 1 2 3 4 5
左半区间: [3 1 4] // 从l到mid
l mid
右半区间: [2 5 6] // 从mid+1到r
mid+1 r
-
为什么 mid+1 是右边区间的起始位置:
- mid 是左半区间的结束位置
- 自然地,mid+1 就是右半区间的开始位置
- 这确保了两个区间没有重叠,并且完整覆盖了整个需要排序的区间
-
在合并时:
int k = 0, i = l, j = mid + 1;
- i = l:指向左半区间的起始位置
- j = mid + 1:指向右半区间的起始位置
- 这样两个指针就分别指向了两个已经排好序的子区间的开始位置
- 具体例子:
Copy
原始数组: [3, 1, 4, 2, 5, 6]
分解后: [3, 1, 4] [2, 5, 6]
↑ ↑ ↑ ↑
l mid mid+1 r
排序后: [1, 3, 4] [2, 5, 6]
↑ ↑
i j
合并时比较 i 和 j 位置的元素,选择较小的放入临时数组
所以 j = mid + 1 是为了:
- 确保与左区间不重叠
- 正确定位右区间的起始位置
- 保证两个子区间的连续性和完整性
这就是为什么我们使用 mid + 1 作为右边有序区间的起始位置。
(3).如何保证有序性
- 先看基本情况:
void merge_sort(int q[], int l, int r) {
if (l >= r) return; // 当区间长度为1时,认为是有序的
当区间长度为1时,这个子数组天然就是有序的。
- 递归过程举例说明: 比如数组 [3, 1, 4, 2]
第一层递归:
[3, 1, 4, 2]
/ \
[3, 1] [4, 2]
第二层递归:
[3, 1, 4, 2]
/ \
[3, 1] [4, 2]
/ \ / \
[3] [1] [4] [2]
- 回溯合并过程:
[3] [1] [4] [2] // 单个元素,已经有序
\ / \ /
[1, 3] [2, 4] // 合并后的子数组有序
\ /
[1, 2, 3, 4] // 最终合并,整体有序
- 为什么合并后会有序?看合并的代码:
// 合并过程保证了有序性
while (i <= mid && j <= r)
if (q[i] <= q[j]) tmp[k++] = q[i++]; // 取较小的元素
else tmp[k++] = q[j++];
// 处理剩余元素
while (i <= mid) tmp[k++] = q[i++];
while (j <= r) tmp[k++] = q[j++];
关键点在于:
-
基本情况:单个元素必定有序
-
合并过程保证了有序性:
- 比较两个有序区间的元素
- 始终选择较小的元素放入临时数组
- 这样合并后的数组必定有序
实际例子详细过程:
原数组:[3, 1, 4, 2]
1. 分解到最小:
[3] [1] [4] [2] // 单元素有序
2. 第一次合并:
合并[3][1]时:
- 比较3和1,取1
- 剩下3
得到[1, 3]
合并[4][2]时:
- 比较4和2,取2
- 剩下4
得到[2, 4]
3. 最后合并[1,3]和[2,4]:
- 比较1和2,取1
- 比较3和2,取2
- 比较3和4,取3
- 剩下4
最终得到[1,2,3,4]
所以递归完后数组一定是有序的,因为:
- 递归到底时是有序的(单个元素)
- 每次合并都保证了结果的有序性
- 层层回溯,保持有序性不变
- 最终得到一个完整的有序数组
这就是归并排序能够保证得到有序结果的基本原理。
4.整数二分
// 区间[l, r]被划分成[l, mid]和[mid + 1, r]时使用:
int bsearch_1(int l, int r)
{
while (l < r)
{
int mid = l + r >> 1;
if (check(mid)) r = mid; // check()判断mid是否满足性质
else l = mid + 1;
}
return l;
}
// 区间[l, r]被划分成[l, mid - 1]和[mid, r]时使用:
int bsearch_2(int l, int r)
{
while (l < r)
{
int mid = l + r + 1 >> 1;
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
(1)代码解释:
- bsearch_1函数 - 用于查找左边界:
int bsearch_1(int l, int r)
{
while (l < r)
{
int mid = l + r >> 1; // 等价于 (l + r) / 2,向下取整
if (check(mid)) r = mid; // 如果mid满足性质,说明答案在左半区间[l, mid]
else l = mid + 1; // 如果mid不满足性质,说明答案在右半区间[mid+1, r]
}
return l;
}
- 这个模板用于找到满足某个性质的第一个位置(左边界)
mid的计算采用向下取整,保证当区间长度为2时,mid指向左边的数- 最终区间会收缩到一个点,即
l == r时结束
- bsearch_2函数 - 用于查找右边界:
int bsearch_2(int l, int r)
{
while (l < r)
{
int mid = l + r + 1 >> 1; // 等价于 (l + r + 1) / 2,向上取整
if (check(mid)) l = mid; // 如果mid满足性质,说明答案在右半区间[mid, r]
else r = mid - 1; // 如果mid不满足性质,说明答案在左半区间[l, mid-1]
}
return l;
}
- 这个模板用于找到满足某个性质的最后一个位置(右边界)
mid的计算采用向上取整,保证当区间长度为2时,mid指向右边的数- 同样,最终区间会收缩到一个点
主要区别:
-
bsearch_1用于找左边界时,mid向下取整
-
bsearch_2用于找右边界时,mid向上取整(加1再除2)
-
区间更新方式不同:
- bsearch_1:
[l, mid]和[mid+1, r] - bsearch_2:
[l, mid-1]和[mid, r]
- bsearch_1:
使用场景示例:
// 例如在递增数组中找第一个大于等于x的位置
bool check(int mid) {
return array[mid] >= x;
}
// 使用bsearch_1
// 例如在递增数组中找最后一个小于等于x的位置
bool check(int mid) {
return array[mid] <= x;
}
// 使用bsearch_2
(2)循环结束时一定有 l == r:
- 对于bsearch_1:
``·cpp while (l < r) { int mid = l + r >> 1; if (check(mid)) r = mid; // 区间变成[l, mid] else l = mid + 1; // 区间变成[mid+1, r] }
让我们分析区间长度的变化:
1. 初始区间长度为 r - l + 1
1. 每次循环:
- 如果check(mid)为真: r 变成 mid,区间变成 [l, mid]
- 如果check(mid)为假: l 变成 mid + 1,区间变成 [mid+1, r]
1. **关键点**:
- 每次循环区间长度都会减小
- 当区间长度为2时,比如区间是[3,4]:
```
mid = (3 + 4) >> 1 = 3
if check(3): 区间变成[3,3] // r = mid = 3
else: 区间变成[4,4] // l = mid + 1 = 4
```
- 不管check结果如何,区间长度都会从2变成1
- 当长度变为1时,意味着 l == r,循环结束
<!---->
2. **对于bsearch_2**:
```cpp
while (l < r) {
int mid = l + r + 1 >> 1;
if (check(mid)) l = mid; // 区间变成[mid, r]
else r = mid - 1; // 区间变成[l, mid-1]
}
分析过程类似:
-
每次循环区间长度都会减小
-
当区间长度为2时,比如区间是[3,4]:
mid = (3 + 4 + 1) >> 1 = 4 if check(4): 区间变成[4,4] // l = mid = 4 else: 区间变成[3,3] // r = mid - 1 = 3 -
同样,区间长度会从2变成1,导致 l == r
总结:
- 两个模板都保证每次循环都会使区间长度严格减小
- 当区间长度为2时,无论check结果如何,新区间长度必定变为1
- 区间长度为1意味着 l == r,此时while循环条件不满足,循环结束
- 这就保证了算法一定会终止,且终止时 l == r
这种设计是刻意的,目的是:
- 避免死循环
- 保证能找到边界(不会跳过正确答案)
- 确保算法一定会终止
这也解释了为什么bsearch_1用向下取整(找左边界),而bsearch_2用向上取整(找右边界)的原因:就是为了保证在区间长度为2时能正确收缩到单个元素。