1.递归的基本概念
1.1 什么是递归
递归是一种通过函数自我调用来解决问题的技术。递归函数在内部会多次调用自己,在每一次调用中,它将处理一个与原问题相似的子问题,但规模会逐渐减小。递归可以看作是将问题逐步简化至最小规模,从而实现对整个问题的解决。
递归调用通常包含两个部分:
- 递归基(Base Case):终止递归的条件,当满足此条件时,递归将停止。
- 递归关系(Recursive Case):即递归调用函数自身,通过递归关系逐步缩小问题的规模,最终达到递归基。
1.2 递归的结构
递归函数的结构非常规律,通常可以用以下形式表示:
返回类型 函数名(参数列表) {
if (终止条件) {
// 当满足终止条件时返回结果
return 结果;
} else {
// 递归调用
return 函数名(缩小后的参数);
}
}
递归在书写的时候,有2个必要条件:
- 递归存在限制条件,当满足这个限制条件的时候,递归便不再继续。
- 每次递归调用之后越来越接近这个限制条件。
2.递归的实际案例
2.1 阶乘的递归实现
一个正整数的阶乘(factorial)是所有小于及等于该数的正整数的积,并且0的阶乘为1。 自然数n的阶乘写作 n!。
题目:计算n的阶乘(不考虑溢出),n的阶乘就是1~n的数字累积相乘。
阶乘是递归的一个经典示例。阶乘定义为:
- 当 n = 0时,0!=1。
- 当 n>0 时,n!=n×(n−1)!n!。
从这个公式不难看出:n的阶乘和n-1的阶乘是相似的问题,但是规模要少了n。有一种有特殊情况是:当n == 0
的时候,n的阶乘是1,而其余n的阶乘都是可以通过上面的公式计算。
2.1.1 调用过程分析
以factorial(4)
为例,函数的递归过程如下:
factorial(4)
调用4 * factorial(3)
factorial(3)
调用3 * factorial(2)
factorial(2)
调用2 * factorial(1)
factorial(1)
调用1 * factorial(0)
factorial(0)
返回1
,停止递归
在递归基 factorial(0)
返回后,结果会逐步返回到上一层,直到最终计算完成:
factorial(1)
计算结果1 * 1 = 1
,返回1
factorial(2)
计算结果2 * 1 = 2
,返回2
factorial(3)
计算结果3 * 2 = 6
,返回6
factorial(4)
计算结果4 * 6 = 24
,返回24
最终结果为 4! = 24
。
2.1.2 实现代码
#include <stdio.h>
int factorial(int n) {
if (n == 0) {
return 1; // 递归基:当n为0时返回1
} else {
return n * factorial(n - 1); // 递归关系:n! = n * (n-1)!
}
}
int main()
{
printf("%d", factorial(4));
return 0;
}
2.1.3 递归调用过程图示
factorial(4)
└── 4 * factorial(3)
└── 3 * factorial(2)
└── 2 * factorial(1)
└── 1 * factorial(0)
└── return 1 (递归基)
<- 1 * 1 = 1 返回 1
<- 2 * 1 = 2 返回 2
<- 3 * 2 = 6 返回 6
<- 4 * 6 = 24 返回 24
2.2 递归实现顺序打印一个整数的每一位
输入一个整数m,按照顺序打印整数的每一位。
比如:
输入:114514 输出:1 1 4 5 1 4
输入:5201314 输出:5 2 0 1 3 1 4
2.2.1 思路分析
递归实现的思路是:
- 通过递归调用来逐步缩小整数的规模,每次递归去掉最后一位,直到只剩下最高位。
- 当整数只剩下一位时,打印该位并返回。
- 在递归返回的过程中,逐步打印每一位剩余的数字。
2.2.2 步骤详细说明
- 递归基:当整数
n
小于 10 时,说明n
已经是个位数,可以直接打印。 - 递归关系:在当前整数
n
大于等于 10 的情况下,先递归调用n / 10
,然后再打印n % 10
,即最后一位数字。
通过 n / 10
可以去掉当前的个位数字,并让问题缩小规模进入下一次递归调用;通过 n % 10
可以得到当前数字的个位数,打印出来。
2.2.3 代码实现
#include <stdio.h>
void printDigits(int n) {
if (n < 10) {
// 递归基:如果 n 是个位数,直接打印
printf("%d ", n);
} else {
// 递归关系:先打印前面几位
printDigits(n / 10);
// 打印当前的个位数
printf("%d ", n % 10);
}
}
int main() {
int number = 12345;
printf("顺序打印数字:");
printDigits(number);
return 0;
}
2.2.4 递归调用过程的图示
可以将递归调用过程理解为一个从上到下的逐步分解过程,最终到达递归基,然后再逐层返回,打印每一位数字:
printDigits(12345)
└── printDigits(1234)
└── printDigits(123)
└── printDigits(12)
└── printDigits(1) -> 打印 "1 "
<- 打印 "2 "
<- 打印 "3 "
<- 打印 "4 "
<- 打印 "5 "
2.3 斐波那契数列的递归实现
斐波那契数列也是递归的经典应用,其定义为:
- F(0)=0
- F(1)=1
- F(n)=F(n−1)+F(n−2),当 n≥2 时。
2.3.1 调用过程分析
当调用 fibonacci(4)
时,递归调用会按照以下步骤进行:
fibonacci(4)
调用fibonacci(3) + fibonacci(2)
fibonacci(3)
调用fibonacci(2) + fibonacci(1)
fibonacci(2)
调用fibonacci(1) + fibonacci(0)
fibonacci(1)
返回1
(递归基)fibonacci(0)
返回0
(递归基)fibonacci(2)
计算结果为1 + 0 = 1
,返回1
fibonacci(1)
返回1
(递归基)fibonacci(3)
计算结果为1 + 1 = 2
,返回2
fibonacci(2)
调用fibonacci(1) + fibonacci(0)
fibonacci(1)
返回1
(递归基)fibonacci(0)
返回0
(递归基)fibonacci(2)
计算结果为1 + 0 = 1
,返回1
fibonacci(4)
计算结果为2 + 1 = 3
,返回3
最终结果为 fibonacci(4) = 3
。
2.3.2 代码实现
int fibonacci(int n) {
if (n == 0) return 0; // 递归基1
if (n == 1) return 1; // 递归基2
return fibonacci(n - 1) + fibonacci(n - 2); // 递归关系
}
2.3.3递归调用过程图示
下面是 fibonacci(4)
的递归调用过程图示:
fibonacci(4)
└── fibonacci(3) + fibonacci(2)
├── fibonacci(3)
│ └── fibonacci(2) + fibonacci(1)
│ ├── fibonacci(2)
│ │ └── fibonacci(1) + fibonacci(0)
│ │ ├── fibonacci(1) -> 1
│ │ └── fibonacci(0) -> 0
│ │ <- 返回 1 + 0 = 1
│ └── fibonacci(1) -> 1
│ <- 返回 1 + 1 = 2
└── fibonacci(2)
└── fibonacci(1) + fibonacci(0)
├── fibonacci(1) -> 1
└── fibonacci(0) -> 0
<- 返回 1 + 0 = 1
<- 返回 2 + 1 = 3
3. 递归的应用场景
递归非常适合处理那些可以分解为多个相似子问题的问题,常见的递归应用场景包括:
- 数学运算:如阶乘、斐波那契数列、幂运算等。
- 分治算法:递归特别适用于分治法,如快速排序和归并排序。
- 树和图的遍历:递归适合用来遍历树结构,因为树本质上具有层次关系,每一层都可以看作是相似的子树。
- 回溯算法:在解决需要回溯的问题时,如迷宫问题、八皇后问题等,递归能有效地实现回溯过程。
示例:快速排序
快速排序是一种高效的排序算法,它通过递归的方式将数组分为两个子数组,分别对每个子数组进行排序。快速排序的递归实现如下:
void quickSort(int arr[], int low, int high) {
if (low < high) {
int pivot = partition(arr, low, high);
quickSort(arr, low, pivot - 1); // 对左子数组排序
quickSort(arr, pivot + 1, high); // 对右子数组排序
}
}
4. 递归的优缺点
4.1 优点
- 代码简洁:递归函数通常比迭代更简单直观,尤其是对于分治类问题,递归可以自然表达问题的分解过程。
- 符合逻辑:递归算法更贴近问题的数学定义和逻辑,因此更容易理解和实现。
4.2 缺点
-
效率低 开销大
- 在C语言中每一次函数调用,都需要为本次函数调用在内存的栈区,申请一块内存空间来保存函数调用期间的各种局部变量的值,这块空间被称为运行时堆栈,或者函数栈帧。
- 函数不返回,函数对应的栈帧空间就一直占用,所以如果函数调用中存在递归调用的话,每一次递归函数调用都会开辟属于自己的栈帧空间,直到函数递归不再继续,开始回归,才逐层释放栈帧空间。
- 所以如果采用函数递归的方式完成代码,递归层次太深,就会浪费太多的栈帧空间,也可能引起**栈溢出(stack overflow)**的问题。
-
重复计算:在某些递归中会出现重复计算问题,如斐波那契数列的递归计算会重复求解某些子问题。
为了解决这些问题,可以使用“记忆化”(将计算结果缓存以避免重复计算)或通过迭代进行优化。
5. 递归与迭代的比较
递归和迭代都是实现重复操作的手段,但在实现上各有特点:
- 递归:递归通过函数的自我调用实现,代码简洁,逻辑清晰,适合树、图等层次结构的遍历。
- 迭代:迭代通过循环实现,通常效率更高,适合计算密集型问题。
5.1 示例:斐波拉契数列
斐波那契数列可以通过递归和迭代两种方式来实现,但这两种实现方式在性能、内存消耗以及复杂度等方面有明显差异。下面我们来比较迭代与递归实现斐波那契数列的优缺点,并展示具体的代码实现和效率分析。
5.1.1 递归实现(略)
5.1.1.1 递归的特点
- 简洁性:代码非常简洁,直接按照数学定义进行实现,符合斐波那契数列的数学递推公式。
- 逻辑清晰:递归实现方式容易理解,代码结构紧凑,逻辑直观。
5.1.1.2 递归的缺点
- 效率低:递归实现的时间复杂度为 O(2^n),因为每次调用
fibonacci(n)
都会再次调用fibonacci(n-1)
和fibonacci(n-2)
,导致大量的重复计算。例如,在计算fibonacci(5)
时,会多次计算fibonacci(3)
和fibonacci(2)
。 - 内存消耗大:每次递归调用都需要保存当前的函数状态在栈中,如果递归层数过深,可能导致栈溢出。
5.1.2 迭代实现
迭代实现则采用循环的方式,按顺序计算每一个斐波那契数。
int fibonacci_iterative(int n) {
if (n == 0) return 0;
int a = 0, b = 1, c;
for (int i = 2; i <= n; i++) {
c = a + b;
a = b;
b = c;
}
return b;
}
5.1.2.1 迭代的特点
- 高效:迭代的时间复杂度为 O(n)。每个斐波那契数只需计算一次,因此避免了重复计算。
- 低内存消耗:迭代实现只需要常量级的空间来保存当前和前一个斐波那契数,因此空间复杂度为 O(1),不会出现栈溢出的问题。
5.1.2.2 迭代的缺点
- 代码略复杂:相比递归实现,迭代实现需要引入额外的变量来存储中间结果,代码相对较长,逻辑上不如递归简洁。
5.1.3 递归与迭代的比较总结
特性 | 递归实现 | 迭代实现 |
---|---|---|
代码简洁性 | 简洁、符合数学定义 | 略复杂,需要引入额外变量 |
效率 | 时间复杂度为 O(2^n),低效 | 时间复杂度为 O(n),高效 |
内存消耗 | 占用栈空间,递归层次深时容易栈溢出 | 常量空间 O(1),不占用栈空间 |
适用情况 | 适用于小规模或递归结构较浅的情况 | 适用于大规模计算,性能更高 |
六、编写递归函数的注意事项
编写递归函数时,注意以下几个方面:
- 明确递归基:递归基是递归停止的条件,确保递归能在有限次数内结束。
- 减少重复计算:对于斐波那契等容易出现重复计算的递归,可以使用记忆化技术将已计算的值缓存起来。
- 控制递归深度:递归深度太深会导致栈溢出,通常可以设定最大递归深度,或者选择迭代替代递归。
- 调试技巧:递归调用时的参数和返回值变化较多,可以通过调试打印输出每次调用的参数和返回值,有助于分析问题所在。
七、总结
递归是一种功能强大的编程技巧,它能够将复杂的问题转化为较小的子问题,从而逐步求解。虽然递归实现的代码通常简洁,但在一些场景下会带来性能问题。掌握递归的基本原理和实现方法,理解递归和迭代的差异,对于程序的优化与调试至关重要。在实际应用中,选择递归或迭代需要结合具体问题的特点,合理运用递归技术,可以使代码简洁且高效。