【C语言程序设计】第27篇:递归函数原理与实例分析

1 引言

考虑计算阶乘的问题:n! = n × (n-1) × ... × 2 × 1。我们可以用循环实现:

c

复制代码
int factorial(int n) {
    int result = 1;
    for (int i = 1; i <= n; i++) {
        result *= i;
    }
    return result;
}

但也可以换一种思路:n! = n × (n-1)!,即阶乘可以用自身来定义。这种"自己定义自己"的方式就是递归。

c

复制代码
int factorial(int n) {
    if (n <= 1) return 1;        /* 基本情况 */
    return n * factorial(n - 1);  /* 递归调用 */
}

这段代码简洁优雅,但它背后发生了什么?为什么函数可以调用自己?这就是本章要探讨的问题。


2 递归的数学基础:数学归纳法

2.1 数学归纳法简介

数学归纳法(Mathematical Induction)是证明与自然数有关的命题的一种方法,包含两个步骤:

  1. 基础步骤:证明命题对最小的自然数(通常是1或0)成立

  2. 归纳步骤:假设命题对某个自然数k成立,证明它对k+1也成立

2.2 递归与归纳法的对应关系

递归函数的设计思想与数学归纳法惊人地一致:

数学归纳法 递归函数
证明基础情况成立 定义基本情况(递归终止条件)
假设命题对k成立 假设递归调用能解决规模更小的子问题
证明命题对k+1成立 利用子问题的解构造原问题的解

c

复制代码
/* 阶乘的递归实现与数学归纳法的对应 */
int factorial(int n) {
    /* 基础步骤:证明 n=1 时成立 */
    if (n <= 1) return 1;
    
    /* 归纳步骤:假设 factorial(n-1) 正确,
       那么 factorial(n) = n * factorial(n-1) 也正确 */
    return n * factorial(n - 1);
}

这种对应关系不是巧合------递归的本质就是用数学归纳法的思想来设计算法。当我们写递归函数时,实际上是在做两件事:

  • 写出临界条件(基础情况)

  • 找这一次和上一次的关系(归纳步骤)

2.3 用数学归纳法理解递归的正确性

以阶乘为例,我们可以用归纳法证明递归函数正确:

  • n=1时:函数返回1,正确

  • 假设函数对n-1正确 :即factorial(n-1)返回(n-1)!

  • 那么对n :函数返回n * factorial(n-1) = n × (n-1)! = n!,正确

重要 :写递归函数时,不要试图跟踪每一层调用的细节,而应该相信递归调用能正确解决规模更小的子问题。这正是数学归纳法的核心思想。


3 递归的栈实现

3.1 函数调用的底层机制

在计算机底层,函数调用是通过来实现的。当一个函数调用另一个函数时:

  1. 将实参、返回地址等信息压栈

  2. 为被调函数的局部变量分配空间

  3. 将控制转移到被调函数

当被调函数返回时:

  1. 保存返回值

  2. 释放局部变量空间

  3. 根据返回地址跳回调用函数

3.2 递归调用时的栈帧变化

递归调用本质上就是同一个函数的多次嵌套调用 ,每次调用都会在栈上创建一个新的栈帧(Stack Frame)。

以计算 factorial(3) 为例,栈的变化过程:

text

复制代码
/* 调用序列 */
factorial(3)
    → factorial(2)
        → factorial(1)
            → 返回1
        ← 返回 2*1 = 2
    ← 返回 3*2 = 6

栈帧变化示意图

text

复制代码
调用 factorial(3) 时:
栈顶 → [factorial(3)的栈帧]  ← n=3, 等待递归返回

调用 factorial(2) 时:
栈顶 → [factorial(2)的栈帧]  ← n=2
      [factorial(3)的栈帧]

调用 factorial(1) 时:
栈顶 → [factorial(1)的栈帧]  ← n=1,满足终止条件,直接返回
      [factorial(2)的栈帧]
      [factorial(3)的栈帧]

factorial(1) 返回后:
栈顶 → [factorial(2)的栈帧]  ← 收到返回值1,计算2*1=2后返回
      [factorial(3)的栈帧]

factorial(2) 返回后:
栈顶 → [factorial(3)的栈帧]  ← 收到返回值2,计算3*2=6后返回

factorial(3) 返回后:栈空

3.3 递归深度与栈溢出

每次递归调用都会消耗栈空间来存储局部变量和返回地址。如果递归层次太深,可能导致栈溢出(Stack Overflow)。

c

复制代码
/* 危险!递归深度过大 */
void infinite_recursion(int n) {
    int large_array[1000];  /* 每个栈帧4KB */
    infinite_recursion(n + 1);  /* 无限递归 */
}

在常见的系统中,栈大小通常只有几MB。因此,当递归深度达到数千层时,程序就可能崩溃。


4 递归 vs 迭代:以阶乘为例

4.1 阶乘的递归实现

c

复制代码
#include <stdio.h>

/* 递归版阶乘 */
long factorial_recursive(int n) {
    if (n <= 1) return 1;           /* 基本情况 */
    return n * factorial_recursive(n - 1);  /* 递归调用 */
}

4.2 阶乘的迭代实现

c

复制代码
/* 迭代版阶乘 */
long factorial_iterative(int n) {
    long result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}

4.3 对比分析

对比维度 递归版 迭代版
代码简洁性 简洁,直接对应数学定义 略复杂
可读性 高,易于理解算法思想 需要理解循环逻辑
执行效率 较低(函数调用开销) 高(无额外开销)
内存占用 O(n) 栈空间 O(1) 额外空间
适用场景 n较小,追求代码清晰 n较大,追求性能

测试对比

c

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

int main(void) {
    int n = 20;
    clock_t start, end;
    
    start = clock();
    long r1 = factorial_recursive(n);
    end = clock();
    printf("递归:结果=%ld,时间=%ldms\n", r1, end - start);
    
    start = clock();
    long r2 = factorial_iterative(n);
    end = clock();
    printf("迭代:结果=%ld,时间=%ldms\n", r2, end - start);
    
    return 0;
}

当 n 较小时,两者差异不大;但当 n 增大时,递归的额外开销会变得明显。


5 斐波那契数列:递归的陷阱

5.1 斐波那契数列的定义

斐波那契数列(Fibonacci sequence)定义为:

  • F(1) = 1

  • F(2) = 1

  • F(n) = F(n-1) + F(n-2) (n ≥ 3)

5.2 递归实现

c

复制代码
#include <stdio.h>

/* 递归版斐波那契 */
long fibonacci_recursive(int n) {
    if (n <= 2) return 1;  /* 基本情况 */
    return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2);  /* 递归调用 */
}

这段代码直接对应数学定义,简洁优美。但是......它存在严重的问题。

5.3 递归调用的爆炸性增长

让我们分析一下 fibonacci_recursive(5) 的调用过程:

text

复制代码
fibonacci(5)
├── fibonacci(4)
│   ├── fibonacci(3)
│   │   ├── fibonacci(2)
│   │   └── fibonacci(1)
│   └── fibonacci(2)
└── fibonacci(3)
    ├── fibonacci(2)
    └── fibonacci(1)

调用次数统计

  • fibonacci(5) 调用 fibonacci(3) 2次

  • fibonacci(5) 调用 fibonacci(2) 3次

  • 总调用次数:15次(而 n=5 只需要计算5个数!)

更惊人的是,这个数字增长极快:

  • n=10 时,fibonacci(3) 被调用 21 次

  • n=20 时,fibonacci(3) 被调用 2584 次

  • n=30 时,fibonacci(3) 被调用 317811 次!

5.4 为什么会有如此多的重复计算?

原因是递归树中包含了大量重复的子问题。每个 fibonacci(k) 被多次重复计算,导致时间复杂度高达 O(2ⁿ)------指数级爆炸。

5.5 迭代实现

c

复制代码
/* 迭代版斐波那契 */
long fibonacci_iterative(int n) {
    if (n <= 2) return 1;
    
    long a = 1, b = 1, c;
    for (int i = 3; i <= n; i++) {
        c = a + b;
        a = b;
        b = c;
    }
    return b;
}

时间复杂度 O(n) ,空间复杂度 O(1)

5.6 性能对比

n 递归版(调用次数) 迭代版(循环次数)
10 约 177 次 8 次
20 约 21891 次 18 次
30 约 2692537 次 28 次
40 约 3.3 亿次 38 次
50 约 400 亿次 48 次

结论:对于斐波那契数列,递归实现是灾难性的,绝对不应用在实际代码中。


6 递归与迭代的综合对比

对比维度 递归 迭代
代码简洁性 通常更简洁,直接对应数学定义 需要额外的循环控制变量
可读性 问题分解清晰 需要理解循环逻辑
执行效率 有函数调用开销 高效
内存占用 可能占用大量栈空间 通常 O(1)
适用范围 树形结构、分治算法 线性处理、简单循环
重复计算 可能重复计算子问题 通常不会
尾递归优化 某些编译器支持优化 不适用

6.1 什么时候用递归?

  • 问题天然具有递归结构(如树的遍历、汉诺塔)

  • 代码可读性优先于性能

  • 问题规模较小,递归深度可控

  • 分治算法(如快速排序、归并排序)

6.2 什么时候用迭代?

  • 性能要求高

  • 递归深度可能很大(如超过 1000 层)

  • 存在大量重复计算(如斐波那契)

  • 内存受限的环境(如嵌入式系统)


7 如何写好递归函数

7.1 递归函数的三要素

  1. 明确函数功能:清楚定义函数要做什么

  2. 确定基本情况:找到递归的终止条件

  3. 找到递推关系:将原问题分解为子问题

7.2 设计步骤

以计算数组元素之和为例:

c

复制代码
/* 问题:计算数组 arr[0..n-1] 的和 */

/* 1. 明确功能:sum(arr, n) 返回数组前n个元素的和 */
int sum(int arr[], int n) {
    /* 2. 基本情况:空数组和为0 */
    if (n <= 0) return 0;
    
    /* 3. 递推关系:sum(arr, n) = sum(arr, n-1) + arr[n-1] */
    return sum(arr, n - 1) + arr[n - 1];
}

7.3 尾递归优化

尾递归(Tail Recursion)是指递归调用是函数的最后一步操作。某些编译器可以对尾递归进行优化,将其转换为迭代,从而避免栈溢出。

c

复制代码
/* 普通递归(不是尾递归) */
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);  /* 最后一步是乘法,不是递归调用 */
}

/* 尾递归版本 */
int factorial_tail(int n, int accumulator) {
    if (n <= 1) return accumulator;
    return factorial_tail(n - 1, n * accumulator);  /* 最后一步是递归调用 */
}

/* 包装函数 */
int factorial(int n) {
    return factorial_tail(n, 1);
}

尾递归的好处:编译器可以复用当前栈帧,不需要为每次调用创建新栈帧。


8 常见错误与注意事项

8.1 忘记基本情况

c

复制代码
/* 错误:没有终止条件 */
int bad_recursion(int n) {
    return n * bad_recursion(n - 1);  /* 无限递归,栈溢出 */
}

8.2 基本情况永远不会到达

c

复制代码
/* 错误:n 永远不等于 1 */
int wrong_recursion(int n) {
    if (n == 1) return 1;  /* 如果初始 n < 1,这个条件永远不会满足 */
    return n * wrong_recursion(n - 2);  /* n=5 → 3 → 1 ✓,但 n=4 → 2 → 0 → -2 ... ✗ */
}

8.3 递归深度过大

c

复制代码
/* 危险:递归深度 = n,当 n=100000 时必然栈溢出 */
int sum(int n) {
    if (n <= 0) return 0;
    return n + sum(n - 1);
}

8.4 重复计算子问题

如前面斐波那契的例子,应该避免这种指数级爆炸。


9 综合示例:汉诺塔

汉诺塔是递归的经典案例:

c

复制代码
#include <stdio.h>

/* 将 n 个盘子从 A 移到 C,借助 B */
void hanoi(int n, char A, char B, char C) {
    if (n == 1) {
        printf("将盘子从 %c 移到 %c\n", A, C);
        return;
    }
    
    /* 1. 将上面 n-1 个盘子从 A 移到 B,借助 C */
    hanoi(n - 1, A, C, B);
    
    /* 2. 将最大的盘子从 A 移到 C */
    printf("将盘子从 %c 移到 %c\n", A, C);
    
    /* 3. 将 B 上的 n-1 个盘子移到 C,借助 A */
    hanoi(n - 1, B, A, C);
}

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

这个问题的递归解法直接对应问题的自然分解,如果用迭代实现会复杂得多。


10 本章小结

本章系统介绍了递归函数的原理与应用:

1. 递归的数学基础

  • 递归与数学归纳法一脉相承

  • 基础情况对应归纳基础,递归调用对应归纳假设

2. 递归的栈实现

  • 每次递归调用创建新的栈帧

  • 递归深度过大导致栈溢出

3. 递归 vs 迭代

  • 阶乘:递归简洁,迭代高效,两者均可

  • 斐波那契:递归有大量重复计算,绝对应该用迭代

4. 递归的适用场景

  • ✅ 树形结构遍历

  • ✅ 分治算法

  • ✅ 问题天然有递归定义

  • ❌ 线性问题、深度很大、有重复子问题

5. 写好递归的要点

  • 明确功能

  • 确定基本情况

  • 找到递推关系

  • 相信递归调用(不要跟踪细节)

相关推荐
Jia-Hui Su2 小时前
Python类型标准(Type Hints)详解
开发语言·python·numpy·pyqt·ipython·python3.11
Barkamin2 小时前
直接选择排序
数据结构
無限進步D2 小时前
C++ 万能头
开发语言·c++·算法·蓝桥杯·竞赛·万能头
十年编程老舅2 小时前
吃透 Linux 内核 IO 体系:块缓存与页缓存的核心设计与实现逻辑
linux·数据库·c++·spring·后端技术·页缓存
小白学大数据2 小时前
小说爬虫实战:《斗罗大陆》章节自动抓取与合并
开发语言·爬虫·python·数据分析
qq_418101772 小时前
C++中的状态模式
开发语言·c++·算法
weixin_307779132 小时前
构建健壮的XML文档抓取与摘要流水线:Requests + urllib3.Retry + lxml 实践
xml·开发语言·python·算法·性能优化
晨非辰2 小时前
Makefile构建哲学:从依赖推导到自动化编译,掌握大型项目的构建逻辑,告别手动编译焦虑
linux·运维·服务器·c++·人工智能·后端·自动化
如何原谅奋力过但无声2 小时前
【力扣-Python-74】搜索二维矩阵(middle)
数据结构·python·算法·leetcode·矩阵