【数据结构与算法】第46篇:算法思想(一):递归与分治

一、递归的本质

1.1 什么是递归

递归就是函数调用自身。一个递归函数通常包含两部分:

  • 终止条件:什么时候停止递归

  • 递推公式:如何将大问题转化为小问题

c

复制代码
// 阶乘的递归实现
int factorial(int n) {
    if (n <= 1) return 1;        // 终止条件
    return n * factorial(n - 1); // 递推公式
}

1.2 递归的底层原理:系统栈

每次函数调用,系统都会在栈上分配空间,存储:

  • 函数的参数

  • 局部变量

  • 返回地址

当函数调用结束时,栈帧被弹出,程序回到调用点继续执行。

以 factorial(3) 为例

text

复制代码
调用 factorial(3):
  栈: [fact(3)] 
    调用 factorial(2):
      栈: [fact(3), fact(2)]
        调用 factorial(1):
          栈: [fact(3), fact(2), fact(1)]
          返回 1
        栈: [fact(3), fact(2)]
        计算 2 * 1 = 2,返回
      栈: [fact(3)]
      计算 3 * 2 = 6,返回
    栈: []

递归深度 :栈中最多同时存在的栈帧数量。深度过大(如递归10000次)会导致栈溢出

1.3 递归 vs 迭代

对比项 递归 迭代
代码可读性 简洁直观 相对复杂
空间复杂度 O(递归深度) O(1)
性能 函数调用开销大 循环开销小
适用场景 树、分治、回溯 简单重复计算

二、递归的经典问题:汉诺塔

2.1 问题描述

有三根柱子(A、B、C),A柱上有n个大小不同的圆盘,大的在下,小的在上。要求把所有圆盘从A移动到C,每次只能移动一个圆盘,且大圆盘不能放在小圆盘上面。求移动步骤。

2.2 递归思路

要把n个盘子从A移到C:

  1. 先把上面 n-1 个盘子从A移到B(借助C)

  2. 把最大的盘子从A移到C

  3. 再把 n-1 个盘子从B移到C(借助A)

text

复制代码
终止条件:n == 1 时,直接移动

2.3 代码实现

c

复制代码
#include <stdio.h>

// 汉诺塔递归实现
void hanoi(int n, char from, char to, char aux) {
    if (n == 1) {
        printf("移动 1 号盘: %c -> %c\n", from, to);
        return;
    }
    // 将 n-1 个盘子从 from 移到 aux
    hanoi(n - 1, from, aux, to);
    // 移动最大的盘子
    printf("移动 %d 号盘: %c -> %c\n", n, from, to);
    // 将 n-1 个盘子从 aux 移到 to
    hanoi(n - 1, aux, to, from);
}

int main() {
    int n = 3;
    printf("汉诺塔 %d 个盘子移动步骤:\n", n);
    hanoi(n, 'A', 'C', 'B');
    return 0;
}

运行结果:

text

复制代码
汉诺塔 3 个盘子移动步骤:
移动 1 号盘: A -> C
移动 2 号盘: A -> B
移动 1 号盘: C -> B
移动 3 号盘: A -> C
移动 1 号盘: B -> A
移动 2 号盘: B -> C
移动 1 号盘: A -> C

复杂度:移动次数 = 2ⁿ - 1,时间复杂度 O(2ⁿ)


三、分治法(Divide and Conquer)

3.1 分治法的三步骤

分治法将大问题分解成若干个相同的小问题,递归解决后合并结果。

步骤 说明
分解 将原问题分解成若干个子问题
解决 递归解决子问题(子问题足够小时直接解决)
合并 将子问题的解合并成原问题的解

3.2 典型应用

算法 分解 合并 时间复杂度
归并排序 分成两半 合并两个有序数组 O(n log n)
快速排序 按基准分区 不需要合并 O(n log n)
二分查找 取中间点 不需要合并 O(log n)
汉诺塔 分成n-1和1 简单组合 O(2ⁿ)
最大子数组 分成左右和跨中 取最大值 O(n log n)

3.3 归并排序(分治法经典)

c

复制代码
#include <stdio.h>
#include <stdlib.h>

// 合并两个有序子数组
void merge(int arr[], int left, int mid, int right, int temp[]) {
    int i = left, j = mid + 1, k = left;
    
    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) {
            temp[k++] = arr[i++];
        } else {
            temp[k++] = arr[j++];
        }
    }
    
    while (i <= mid) temp[k++] = arr[i++];
    while (j <= right) temp[k++] = arr[j++];
    
    for (i = left; i <= right; i++) {
        arr[i] = temp[i];
    }
}

// 归并排序(分治)
void mergeSort(int arr[], int left, int right, int temp[]) {
    if (left >= right) return;  // 终止条件:只有一个元素
    
    int mid = left + (right - left) / 2;
    
    // 分解
    mergeSort(arr, left, mid, temp);
    mergeSort(arr, mid + 1, right, temp);
    
    // 合并
    merge(arr, left, mid, right, temp);
}

int main() {
    int arr[] = {8, 3, 5, 1, 6, 2, 7, 4};
    int n = sizeof(arr) / sizeof(arr[0]);
    int *temp = (int*)malloc(n * sizeof(int));
    
    printf("原数组: ");
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");
    
    mergeSort(arr, 0, n - 1, temp);
    
    printf("排序后: ");
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");
    
    free(temp);
    return 0;
}

四、递归的优化:尾递归与记忆化

4.1 尾递归

尾递归是指递归调用是函数的最后一个操作,编译器可以优化为迭代,避免栈溢出。

c

复制代码
// 普通递归阶乘(不是尾递归,因为最后还有乘法)
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

// 尾递归阶乘
int factorialTail(int n, int result) {
    if (n <= 1) return result;
    return factorialTail(n - 1, n * result);
}

4.2 记忆化递归(避免重复计算)

斐波那契数列的普通递归有大量重复计算,用记忆化优化。

c

复制代码
#define MAX 100
int memo[MAX];

int fib(int n) {
    if (n <= 1) return n;
    if (memo[n] != 0) return memo[n];
    memo[n] = fib(n - 1) + fib(n - 2);
    return memo[n];
}
版本 时间复杂度 说明
普通递归 O(2ⁿ) 大量重复计算
记忆化递归 O(n) 每个数只算一次
迭代 O(n) 最优

五、递归的常见陷阱

5.1 栈溢出

递归深度过大时,系统栈空间耗尽。

c

复制代码
// 递归深度10000,可能导致栈溢出
void deepRecursion(int n) {
    if (n <= 0) return;
    deepRecursion(n - 1);
}

解决方案

  • 改用迭代

  • 增加栈大小(编译器选项)

  • 使用尾递归(部分编译器优化)

5.2 重复计算

斐波那契普通递归的重复计算问题。

5.3 终止条件错误

忘记终止条件或条件错误会导致无限递归。

c

复制代码
// 错误:n==0时没有正确返回
int badFactorial(int n) {
    return n * badFactorial(n - 1);  // n=0时永远不停止
}

六、递归与分治的应用场景

场景 推荐 原因
树/图的遍历 递归 结构天然适合递归
排序(归并/快排) 分治 经典应用
二分查找 递归/迭代均可 简单
动态规划 递归+记忆化 自顶向下思考
回溯(八皇后、迷宫) 递归 需要状态恢复
分治(最大子数组) 递归 分解合并清晰

七、小结

这一篇我们学习了递归与分治:

概念 核心要点
递归本质 系统栈的压入与弹出
递归要素 终止条件 + 递推公式
汉诺塔 经典递归问题,O(2ⁿ)
分治法 分解 → 解决 → 合并
归并排序 分治法的典型应用
尾递归 可被编译器优化为循环
记忆化 避免重复计算

递归思考模板

text

复制代码
function(问题):
    if (问题足够小):
        直接解决
    else:
        分解成子问题
        递归解决子问题
        合并子问题的解

下一篇我们讲动态规划。


八、思考题

  1. 递归函数的空间复杂度为什么等于递归深度?

  2. 汉诺塔问题中,移动 n 个盘子需要多少步?为什么?

  3. 如何判断一个问题适合用递归解决?递归的缺点是什么?

  4. 用递归实现二分查找,并分析其空间复杂度。

欢迎在评论区讨论你的答案。

相关推荐
Sirens.1 小时前
七大经典排序算法:原理、实现与复杂度分析
java·数据结构·算法·排序算法
wfbcg2 小时前
每日算法练习:LeetCode 54. 螺旋矩阵 ✅
算法·leetcode·矩阵
黎阳之光2 小时前
【从虚拟到实体:黎阳之光实时三维重构,开启AI空间智能新纪元
大数据·人工智能·算法·安全·数字孪生
Shadow(⊙o⊙)2 小时前
C中 memset enum malloc fputc fgetc fgets fread fwrite rewind指针回退
java·c语言·数据库
wengqidaifeng2 小时前
第十七届蓝桥杯C/C++软件赛C组算法题讲解
c语言·c++·蓝桥杯
玖釉-2 小时前
架构师视角:从 NVVK_CHECK 洞悉 Vulkan 渲染引擎的防御性编程哲学
c++·windows·图形渲染
jghhh012 小时前
基于主从博弈的主动配电网阻塞管理:MATLAB实现
算法·matlab
feng_you_ying_li2 小时前
C++11,lambda,包装器
开发语言·数据结构·c++
云栖梦泽2 小时前
Linux内核与驱动:11.设备树
linux·c++