【数据结构与算法】第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. 用递归实现二分查找,并分析其空间复杂度。

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

相关推荐
炘爚4 分钟前
Phase 4:业务线程池 — IO/计算解耦
linux·c++
张小姐的猫6 分钟前
【Linux】多线程 —— 线程池 | 单例模式 | 常见锁
linux·运维·服务器·c++·单例模式·设计模式·策略模式
_日拱一卒6 分钟前
LeetCode:39组合总和
java·算法·leetcode·职场和发展
无限进步_7 分钟前
【Linux】进程状态、僵尸与孤儿、进程调度
linux·运维·服务器·开发语言·数据结构·算法
郝学胜-神的一滴8 分钟前
力扣 662 :二叉树最大宽度
java·数据结构·c++·python·算法·leetcode·职场和发展
2301_7644413310 分钟前
基于Stackelberg博弈的分散式库存模型
python·算法·数学建模
大阳12312 分钟前
ARM.9(RGBLCD,PWM)
c语言·开发语言·汇编·单片机·嵌入式硬件·pwm·rgblcd
加油码16 分钟前
Linux IO 多路转接详解:从 select、poll 到 epoll
linux·c++
qq 137401861119 分钟前
医用无菌屏障系统加速老化标准解读:ASTM F1980-2016 全解析
人工智能·算法·加速老化·包装测试·astm·医疗器械包装·无菌屏障系统
wayz1119 分钟前
Overlap:SLOPE(线性回归斜率)技术指标详解
算法·金融·数据分析·回归·线性回归·量化交易·特征工程