0. 前言
当涉及到复杂的计算或问题解决时,递归是一种强大的编程技巧,它允许函数在执行过程中调用自身。在C语言中,递归是一种常见的编程范式,它允许程序员解决各种问题,而无需使用复杂的循环结构。在本篇博客中,我们将深入探讨C语言中的函数递归,从基本概念到实际示例,了解如何使用递归解决问题。
1. 什么是递归
递归是一种在计算机编程中使用的重要概念,它涉及到函数在其执行过程中调用自身。这种自我调用的方式使得递归在解决问题时非常有用,尤其是那些可以被分解成较小相似问题的情况。下面,我们对递归这一概念进行初步介绍。
1.1 定义
递归是一种函数直接或间接地调用自身的过程。这种自我引用的方式允许函数反复执行,直到满足某种条件为止。这个条件通常称为基本情况,在基本情况下,递归不再继续,递归链路被打破,函数返回结果。
下面是Wikipedia对于递归的定义:
In computer science,recursion is a method of solving a computational problem where the solution depends on solutions to smaller instances of the same problem. Recursion solves such recursive problems by using functions that call themselves from within their own code. The approach can be applied to many types of problems, and recursion is one of the central ideas of computer science.(来源:Recursion (computer science) - Wikipedia)
1.2 工作原理
递归的工作原理可以用如下方式概括:
- 函数被调用,执行其代码。
- 函数检查是否满足基本情况,如果满足,则返回结果。
- 如果不满足基本情况,函数会调用自身,但通常使用不同的参数,然后等待递归调用的结果。
- 当递归调用返回结果后,原始函数可能会将这些结果组合成自己的结果。
- 这个过程重复进行,直到达到基本情况,然后结果被逐级返回。
1.3 递归的思想
递归思想在解决问题时具有强大的表达能力。它建立在以下观点上:解决一个复杂问题的过程可以通过解决一个相对较小的问题并反复应用来完成。这将复杂问题分解成一系列更简单的子问题,从而更容易理解和解决。
递归通常用于解决具有递归结构的问题。这些问题在概念上包含相似的子问题,每个子问题都可以用与原问题相同的算法来解决。递归的思想非常适合处理树形结构、列表、图形和其他复杂数据结构。
2. 递归的限制条件
递归是一种强大的编程技巧,但在使用它时必须小心,以避免无限递归和栈溢出。为了确保递归函数正常工作,必须满足递归的限制条件。在本节中,我们将深入探讨这些限制条件以及它们的重要性。
2.1 基本情况
递归的一个关键部分是定义基本情况(Base case),也称为递归终止条件。基本情况是指递归调用可以直接返回结果而不继续递归的条件。如果没有基本情况或者基本情况定义不当,递归可能会导致无限递归,最终耗尽内存并导致栈溢出。
2.2 递归调用条件
除了基本情况,递归函数还必须具有递归调用条件。递归调用条件是一个逻辑条件,它决定是否继续递归调用自身。如果递归调用条件不正确,递归可能无法终止或不会得到正确的结果。
2.3 每次递归的进展
另一个重要的概念是确保每次递归调用都向基本情况靠近。如果递归调用不朝着基本情况的方向进展,那么递归可能永远不会结束。
2.4 递归调用的深度
递归调用的深度是指递归链路中函数被调用的层级。如果递归调用的深度过大,可能会导致栈溢出。栈溢出是因为每次函数调用都需要在栈中分配一些内存来存储函数的状态,如果递归链路太长,栈可能会耗尽内存。
总而言之,我们在编写递归函数时应当注意:
• 递归存在限制条件,当满足这个限制条件的时候,递归便不再继续;
• 每次递归调用之后越来越接近这个限制条件。
3. 递归举例
在本节中,我们将探讨两个具体的递归示例,一个是计算一个整数的阶乘,另一个是顺序打印一个整数的每一位。下面我们开始介绍:
3.1 举例1:求n的阶乘
阶乘是一个常见的数学概念,表示一个正整数 n
的连乘积。例如,5 的阶乘表示为 5!
,等于 5 × 4 × 3 × 2 × 1 = 120
。
在这个示例中,我们将使用递归来计算整数 n
的阶乘。递归的思想在这里非常适用,因为阶乘可以被分解成逐渐减小的子问题。
#include <stdio.h>
unsigned long long factorial(int n)
{
if (n == 0)
{
return 1; // 阶乘的基本情况:0的阶乘为1
} else
{
return n * factorial(n - 1); // 递归调用
}
}
int main()
{
int num = 5; // 想要计算 5 的阶乘
unsigned long long result = factorial(num);
printf("%d 的阶乘是 %llu\n", num, result);
return 0;
}
在这段代码中,factorial
函数使用递归来计算整数 n
的阶乘。它首先检查是否满足基本情况,即 n
是否等于0,如果是,它返回1作为基本情况的结果。否则,它通过调用自身来计算 n
与 (n-1)
的阶乘的乘积,这一过程一直持续,直到 n
变为0。
当你运行这段代码时,它会输出:
5 的阶乘是 120
这个示例演示了如何使用递归来计算整数的阶乘,其中递归的思想是将问题分解成较小的子问题,直到达到基本情况。
3.2 举例2:顺序打印一个整数的每一位
在这个示例中,我们将使用递归来分解一个整数,然后顺序打印它的每一位数字。这个过程可以帮助我们更好地理解递归如何处理数字。
#include <stdio.h>
void printDigits(int n)
{
if (n < 10)
{
printf("%d", n);
}
else
{
printDigits(n / 10); // 递归调用,打印更高位的数字
printf(" %d", n % 10); // 打印最低位的数字
}
}
int main()
{
int num = 12345;
printf("整数 %d 的每一位数字是:", num);
printDigits(num);
printf("\n");
return 0;
}
在这段代码中,printDigits
函数使用递归来分解整数 n
,先打印更高位的数字,然后再打印最低位的数字。基本情况是 n
小于10,此时只有一个数字需要打印。否则,函数将递归地调用自身来处理更高位的数字,然后打印最低位的数字。
当你运行这段代码时,它会输出:
整数 12345 的每一位数字是:1 2 3 4 5
这个示例演示了如何使用递归来按顺序打印一个整数的每一位数字。递归的思想在这里非常适用,因为问题可以分解成按位处理,每次处理一位数字,然后逐渐减小问题的规模。
4. 递归和迭代
在这一部分,我们将探讨递归和迭代两种不同的方法,以及它们在计算第 n 个 Fibonacci (斐波那契数)数时的应用。我们将分别使用递归和迭代来实现,并对两种方法的效率进行分析和比较。
我们先介绍什么是斐波那契数:
(来自Wikipedia:斐波那契数 - 维基百科,自由的百科全书 (wikipedia.org))
斐波那契数(意大利语:Successione di Fibonacci),又译为菲波拿契数、菲波那西数、斐氏数、黄金分割数。所形成的数列称为斐波那契数列(意大利语:Successione di Fibonacci),又译为菲波拿契数列、菲波那西数列、斐氏数列、黄金分割数列。这个数列是由意大利数学家斐波那契在他的《算盘书》中提出。
用文字来说,就是斐波那契数列由0和1开始,之后的斐波那契数就是由之前的两数相加而得出。首几个斐波那契数是:
1、 1、 2、 3、 5、 8、 13、 21、 34、 55、 89、 144、 233、 377、 610、 987……(OEIS数列A000045)
好啦,下面我们开始看代码:
4.1 递归实现 Fibonacci 数列
首先,让我们使用递归来实现计算第 n 个 Fibonacci 数的函数。
#include <stdio.h>
int fibonacciRecursive(int n)
{
if (n <= 1)
{
return n; // 基本情况:第 0 和第 1 个 Fibonacci 数是 0 和 1
}
else
{
return fibonacciRecursive(n - 1) + fibonacciRecursive(n - 2); // 递归调用
}
}
int main()
{
int n = 10; // 想要计算第 10 个 Fibonacci 数
int result = fibonacciRecursive(n);
printf("第 %d 个 Fibonacci 数是 %d\n", n, result);
return 0;
}
备注:Recursive指“递归”。
4.2 迭代实现 Fibonacci 数列
接下来,我们使用迭代方法来计算第 n 个 Fibonacci 数。迭代方法通常比递归方法更高效,因为它避免了递归调用的开销。
#include <stdio.h>
int fibonacciIterative(int n)
{
if (n <= 1)
{
return n; // 基本情况:第 0 和第 1 个 Fibonacci 数是 0 和 1
}
int prev = 0, current = 1, next;
for (int i = 2; i <= n; i++)
{
next = prev + current;
prev = current;
current = next;
}
return current;
}
int main()
{
int n = 10; // 想要计算第 10 个 Fibonacci 数
int result = fibonacciIterative(n);
printf("第 %d 个 Fibonacci 数是 %d\n", n, result);
return 0;
}
备注:Iterative指“迭代”。
4.3 效率比较
递归和迭代方法的主要差异在于它们解决问题的方式。递归方法在解决问题时将其分解为更小的子问题,而迭代方法通过循环逐步计算结果。
效率比较通常取决于问题的规模。在 Fibonacci 数列中,递归方法会多次重复计算相同的子问题,而迭代方法则避免了这种重复计算。
在实际应用中,迭代方法通常更有效率,因为它避免了递归的调用开销。然而,对于较小的问题规模,递归方法可能更容易理解和实现。
综上所述,
- 递归方法通常更容易理解,能够直接反映问题的自然结构,但可能会因为递归调用的开销而导致性能问题。
- 迭代方法通常更高效,因为它避免了递归调用的开销,但在某些情况下可能不如递归方法直观。
- 实际应用中,选择适当的方法取决于问题的规模和实际需求。
5. 拓展学习
在这一部分,我们将探讨两个经典的问题,分别是青蛙跳台阶问题和汉诺塔问题,并使用递归方法进行代码实现。我们将详细讲解每个问题的背景、递归思路以及代码实现。
5.1 青蛙跳台阶问题
5.1.1 背景
青蛙跳台阶问题是一个常见的数学问题。假设一只青蛙可以跳上 1 级台阶,也可以跳上 2 级台阶。现在有一个 n 级的台阶,请问青蛙有多少种跳上这个台阶的方式?
5.1.2 递归思路
我们可以通过递归来解决这个问题。青蛙跳上 n 级台阶的方式等于青蛙跳上 (n-1) 级和 (n-2) 级台阶的方式之和。递归的基本情况是当 n 为 1 或 2 时,青蛙有 1 种和 2 种跳法,分别是直接跳上或者先跳上一级再跳上两级。
5.1.3 代码实现
#include <stdio.h>
int frogJump(int n)
{
if (n == 1 || n == 2)
{
return n; // 基本情况:n 为 1 或 2 时,有 1 种和 2 种跳法
}
else
{
return frogJump(n - 1) + frogJump(n - 2); // 递归调用
}
}
int main()
{
int n = 0;
scanf("%d",&n);//输入想要跳多少级台阶
int ways = frogJump(n);
printf("青蛙跳上 %d 级台阶有 %d 种方式\n", n, ways);
return 0;
}
5.2 汉诺塔问题
5.2.1 背景
汉诺塔问题是一个经典的递归问题。问题描述为有三根柱子,其中一根柱子上按照从大到小的顺序放置了若干个圆盘。要求将这些圆盘从一根柱子移动到另一根柱子,中间可以借助第三根柱子,但必须保证小圆盘始终在大圆盘上面。
5.2.2 递归思路
汉诺塔问题可以通过递归来解决。基本思路是将 n 个圆盘分为两部分,一部分是最底下的一个圆盘,另一部分是上面的 n-1 个圆盘。递归地将 n-1 个圆盘从起始柱子移动到辅助柱子,然后将最底下的圆盘从起始柱子移动到目标柱子,最后将 n-1 个圆盘从辅助柱子移动到目标柱子。
5.2.3 代码实现
#include <stdio.h>
// 全局变量用于统计移动次数
int moveCount = 0;
// 汉诺塔函数
void hanoi(int n, char source, char auxiliary, char target)
{
if (n == 1)
{
printf("移动圆盘 1 从 %c 到 %c\n", source, target);
moveCount++;
}
else
{
hanoi(n - 1, source, target, auxiliary);
printf("移动圆盘 %d 从 %c 到 %c\n", n, source, target);
moveCount++;
hanoi(n - 1, auxiliary, source, target);
}
}
int main() {
int n = 0;
scanf("%d", &n);
printf("汉诺塔步骤:\n");
hanoi(n, 'A', 'B', 'C');
printf("总移动次数: %d\n", moveCount);
return 0;
}
假设 n=4,输出的效果是:
5.2.4 小彩蛋
看懂汉诺塔问题的实现步骤了吗?如果不太懂,我为大家准备了一个小彩蛋:汉诺塔问题的逐步实现!
-
全局变量
moveCount
:- 用于记录移动的总次数,初始值为0。
- 在每次成功移动一个圆盘时,
moveCount
会增加。
-
汉诺塔函数
hanoi
:- 接受四个参数:
n
表示当前圆盘数量,source
表示起始柱,auxiliary
表示辅助柱,target
表示目标柱。 - 如果当前只有一个圆盘(基本情况),直接将其从起始柱移动到目标柱,并打印移动步骤,然后更新
moveCount
。 - 如果有多个圆盘,递归地执行以下步骤:
- 将 n-1 个圆盘从起始柱移动到辅助柱,通过将目标柱作为辅助柱。
- 移动当前的圆盘从起始柱到目标柱,并打印移动步骤,然后更新
moveCount
。 - 将 n-1 个圆盘从辅助柱移动到目标柱,通过将起始柱作为辅助柱。
- 接受四个参数:
-
主函数
main
:- 初始化圆盘数量
n
。 - 调用汉诺塔函数,传递起始柱 'A',辅助柱 'B',目标柱 'C',并传递圆盘数量
n
。 - 打印总移动次数。
- 初始化圆盘数量
怎么样?这下一定看懂了吧!
6. 总结与展望
在本篇博客的结尾,我们不妨思考一下递归这一主题的实际应用和深远影响。递归不仅仅是一种解决特定问题的技术,更是一种抽象思维的体现。通过将问题分解成可管理的部分,我们能够以更直观、清晰的方式思考和解决问题。递归的思想贯穿于许多计算机科学领域,包括算法设计、数据结构、人工智能等。学会理解和应用递归,将为我们在编程和问题解决的旅途中提供更为灵活而深入的视角。这一探讨递归的旅程只是计算机科学中无尽深奥之一小步,希望它能激发您对计算思维的好奇和探索。