算法

150 阅读16分钟

理解递归——通过一个简单的例子

递归是一种函数自己调用自己的编程技术。递归的核心思想是将一个复杂问题逐步简化,直到问题足够简单时,可以直接解决。为了帮助你更好地理解递归,我们来举几个具体的例子。


例子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:递归求斐波那契数列

斐波那契数列是一个数字序列,其中每个数字是前两个数字的和,序列从 01 开始:

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:递归处理文件夹结构(类比日常生活)

想象你在电脑里有一个文件夹,里面可能有很多子文件夹,每个子文件夹里还可以有更多的文件和文件夹。你想查找某个特定的文件,怎么办?

递归查找文件的思路是:

  • 如果当前文件夹包含你要找的文件,就返回文件
  • 否则,遍历所有的子文件夹,对每个子文件夹递归调用同样的查找方法。
  • 当所有文件夹都查找完时,如果还没找到,表示文件不存在。

递归的关键概念总结

  1. 递归函数:一个函数调用自身
  2. 递归结束条件:递归必须有一个明确的结束条件,否则会进入无限循环
  3. 分而治之:通过将问题拆分为更小的子问题,每次解决一个更简单的问题。

递归的日常生活例子

  1. 镜子中的镜子

    • 你站在两个相对的镜子之间,会看到无穷无尽的自己。这就类似于递归——每个镜子中的自己又看到另一个镜子里的自己,直到看不清为止。
  2. 俄罗斯套娃

    • 每个娃娃里都有一个更小的娃娃,递归地打开套娃,直到最后只剩下一个最小的娃娃为止。

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);
}


  1. 函数声明和基本结构:
void quick_sort(int q[], int l, int r)
  • 参数说明:

    • q[] 是需要排序的整数数组
    • l 是当前需要排序的区间左端点
    • r 是当前需要排序的区间右端点
  1. 递归终止条件:
if (l >= r) return;
  • 当区间长度小于等于1时(l >= r),说明该区间已经有序,直接返回
  1. 初始化变量:
int i = l - 1, j = r + 1;  // 双指针,分别从区间两端开始
int x = q[l + r >> 1];     // 选取分界点(中间位置的数)
  • l + r >> 1 等价于 (l + r) / 2,是位运算写法
  • x 是选取的分界值,用于将数组分成两部分
  1. 划分过程:

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
  1. 递归处理:

quick_sort(q, l, j), quick_sort(q, j + 1, r);
  • 将划分好的两个子区间继续递归排序

具体工作流程示例: 假设数组:[4, 2, 7, 1, 3]

  1. 第一次划分:

    • 选择分界值x=2
    • 划分后:[1, 2, 7, 4, 3]
    • j指向位置1(值为2)
  2. 递归处理左区间[1, 2]和右区间[7, 4, 3]

特点分析:

  1. 这种实现方式是经典快排的一种优化版本:

    • 选择中间位置的数作为分界值,避免了有序数组的最差情况
    • 使用do-while循环处理相等元素,能更好地处理有重复元素的情况
  2. 时间复杂度:

    • 平均情况:O(nlogn)
    • 最坏情况:O(n²),但由于选取中间值作为分界点,最坏情况较难出现
  3. 空间复杂度:

    • O(logn),主要是递归调用栈的空间

quick_sort(q, l, j) 中各个参数的含义:

  1. q - 数组参数:

    • 这是需要排序的整数数组
    • 它包含了所有需要排序的元素
    • 在递归调用中,q 始终指向同一个数组,只是处理的区间范围不同
  2. l - 左边界:

    • 表示当前要处理的区间的左端点下标
    • 在这个递归调用中,l 保持原值不变
    • 它表示本次要处理的左侧起始位置
  3. 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] 这部分

这种划分方式确保了:

  1. 两个子区间互不重叠
  2. 左区间的所有元素 ≤ 分界值
  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)的代码:

这是一个典型的归并排序实现,让我们一步步分析:

  1. 函数声明和递归终止条件:
void merge_sort(int q[], int l, int r) {
    if (l >= r) return;  // 当区间长度为1或无效时终止递归
  1. 递归分治:
int mid = l + r >> 1;    // 计算中点,等价于 (l + r) / 2
merge_sort(q, l, mid);   // 递归排序左半部分
merge_sort(q, mid + 1, r); // 递归排序右半部分
  1. 合并过程:
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++];
  1. 处理剩余元素:
while (i <= mid) tmp[k++] = q[i++];  // 将左区间剩余元素放入临时数组
while (j <= r) tmp[k++] = q[j++];    // 将右区间剩余元素放入临时数组
  1. 将排好序的部分复制回原数组:

for (i = l, j = 0; i <= r; i++, j++) q[i] = tmp[j];

算法的主要思想:

  1. 采用分治策略,将数组不断二分,直到子数组长度为1
  2. 然后将相邻的有序子数组合并为一个更大的有序数组
  3. 合并过程使用双指针技术,保证稳定性
  4. 使用临时数组tmp来存储合并过程中的有序序列

时间复杂度为O(nlogn),空间复杂度为O(n),是一个稳定的排序算法。

举个例子说明: 假设输入数组为:[6, 2, 4, 1]

  1. 首先分成 [6, 2] 和 [4, 1]
  2. [6, 2] 继续分成 [6] 和 [2],合并得到 [2, 6]
  3. [4, 1] 继续分成 [4] 和 [1],合并得到 [1, 4]
  4. 最后合并 [2, 6] 和 [1, 4],得到最终结果 [1, 2, 4, 6]

需要注意的是,代码中使用了全局数组tmp作为临时存储空间,这在实际使用时需要确保tmp数组大小足够。

(2).为什么 j = mid + 1 是右边有序区间的起始位置:

  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]
  1. 画图说明:
原数组:  [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
  1. 为什么 mid+1 是右边区间的起始位置:

    • mid 是左半区间的结束位置
    • 自然地,mid+1 就是右半区间的开始位置
    • 这确保了两个区间没有重叠,并且完整覆盖了整个需要排序的区间
  2. 在合并时:

int k = 0, i = l, j = mid + 1;
  • i = l:指向左半区间的起始位置
  • j = mid + 1:指向右半区间的起始位置
  • 这样两个指针就分别指向了两个已经排好序的子区间的开始位置
  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

合并时比较 ij 位置的元素,选择较小的放入临时数组

所以 j = mid + 1 是为了:

  1. 确保与左区间不重叠
  2. 正确定位右区间的起始位置
  3. 保证两个子区间的连续性和完整性

这就是为什么我们使用 mid + 1 作为右边有序区间的起始位置。

(3).如何保证有序性

  1. 先看基本情况:

void merge_sort(int q[], int l, int r) {
    if (l >= r) return;  // 当区间长度为1时,认为是有序的

当区间长度为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]
  1. 回溯合并过程:
[3]    [1]      [4]    [2]   // 单个元素,已经有序
   \    /          \    /
   [1, 3]          [2, 4]    // 合并后的子数组有序
      \               /
      [1, 2, 3, 4]          // 最终合并,整体有序
  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++];

关键点在于:

  1. 基本情况:单个元素必定有序

  2. 合并过程保证了有序性:

    • 比较两个有序区间的元素
    • 始终选择较小的元素放入临时数组
    • 这样合并后的数组必定有序

实际例子详细过程:

原数组:[3, 1, 4, 2]

1. 分解到最小:
[3] [1] [4] [2]  // 单元素有序

2. 第一次合并:
合并[3][1]时:
- 比较31,取1
- 剩下3
得到[1, 3]

合并[4][2]时:
- 比较42,取2
- 剩下4
得到[2, 4]

3. 最后合并[1,3][2,4]- 比较12,取1
- 比较32,取2
- 比较34,取3
- 剩下4
最终得到[1,2,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)代码解释:

  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时结束
  1. 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指向右边的数
  • 同样,最终区间会收缩到一个点

主要区别:

  1. bsearch_1用于找左边界时,mid向下取整

  2. bsearch_2用于找右边界时,mid向上取整(加1再除2)

  3. 区间更新方式不同:

    • bsearch_1: [l, mid][mid+1, r]
    • bsearch_2: [l, mid-1][mid, r]

使用场景示例:


// 例如在递增数组中找第一个大于等于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:

  1. 对于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]
}

分析过程类似:

  1. 每次循环区间长度都会减小

  2. 当区间长度为2时,比如区间是[3,4]:

    mid = (3 + 4 + 1) >> 1 = 4
    if check(4): 区间变成[4,4]      // l = mid = 4
    else: 区间变成[3,3]             // r = mid - 1 = 3
    
  3. 同样,区间长度会从2变成1,导致 l == r

总结

  1. 两个模板都保证每次循环都会使区间长度严格减小
  2. 当区间长度为2时,无论check结果如何,新区间长度必定变为1
  3. 区间长度为1意味着 l == r,此时while循环条件不满足,循环结束
  4. 这就保证了算法一定会终止,且终止时 l == r

这种设计是刻意的,目的是:

  1. 避免死循环
  2. 保证能找到边界(不会跳过正确答案)
  3. 确保算法一定会终止

这也解释了为什么bsearch_1用向下取整(找左边界),而bsearch_2用向上取整(找右边界)的原因:就是为了保证在区间长度为2时能正确收缩到单个元素。