一.递归的定义
递归是学习C语⾔函数绕不开的⼀个话题,那什么是递归呢?
递归其实是⼀种解决问题的⽅法,在C语⾔中,递归就是函数⾃⼰调⽤⾃⼰。
写⼀个史上最简单的C语⾔递归代码:
cpp
#include <stdio.h>
int main()
{
printf("hehe\n");
main();//main函数中⼜调⽤了main函数
return 0;
}

上述就是⼀个简单的递归程序,只不过上⾯的递归只是为了演⽰递归的基本形式,不是为了解决问题,代码最终也会陷⼊死递归,导致栈溢出(Stack overflow)。

1.递归的思想
把⼀个⼤型复杂问题层层转化为⼀个与原问题相似,但规模较⼩的⼦问题来求解;直到⼦问题不能再被拆分,递归就结束了。所以递归的思考⽅式就是把**⼤事化⼩** 的过程。
递归中的递就是递推 的意思,归就是回归的意思,接下来慢慢来体会。
2.递归的限制条件
递归在书写的时候,有2个必要条件:
- 递归存在限制条件,当满⾜这个限制条件的时候,递归便不再继续。
- 每次递归调⽤之后越来越接近这个限制条件。
在下⾯的例⼦中,我们逐步体会这2个限制条件。
二. 递归举例
举例1:求n的阶乘
⼀个正整数的阶乘(factorial)是所有⼩于及等于该数的正整数的积,并且0的阶乘为1。⾃然数n的阶乘写作n!。
题⽬:计算n的阶乘(不考虑溢出),n的阶乘就是1~n的数字累积相乘。
分析和代码实现:我们知道n的阶乘的公式:n! = n ∗(n −1)!
cpp
举例:
5! = 5*4*3*2*1
4! = 4*3*2*1
所以:5! = 5*4!
这样的思路就是把⼀个较⼤的问题,转换为⼀个与原问题相似,但规模较⼩的问题来求解的。当 n==0 的时候,n的阶乘是1,其余n的阶乘都是可以通过公式计算.
n的阶乘的递归公式如下:

那我们就可以写出函数Fact求n的阶乘,假设Fact(n)就是求n的阶乘,那么Fact(n-1)就是求n-1的阶
乘,函数如下:
cpp
int Fact(int n)
{
if(n==0)
return 1;
else
return n*Fact(n-1);
}
测试代码:
cpp
#include <stdio.h>
int Fact(int n)
{
if(n==0)
return 1;
else
return n*Fact(n-1);
}
int main()
{
int n = 0;
scanf("%d", &n);
int ret = Fact(n);
printf("%d\n", ret);
return 0;
}
运⾏结果(这⾥不考虑n太⼤的情况,n太⼤存在溢出):

太大的话:

画图推演:

cpp
递推阶段(从顶到底不断拆分):
Fact(5) = 5 * Fact(4)
Fact(4) = 4 * Fact(3)
Fact(3) = 3 * Fact(2)
Fact(2) = 2 * Fact(1)
Fact(1) = 1 * Fact(0)
Fact(0) = 1 <-- 遇到终止条件,停止递推
回归阶段(从底到顶逐层返回):
Fact(0) = 1
Fact(1) = 1 * Fact(0) = 1 * 1 = 1
Fact(2) = 2 * Fact(1) = 2 * 1 = 2
Fact(3) = 3 * Fact(2) = 3 * 2 = 6
Fact(4) = 4 * Fact(3) = 4 * 6 = 24
Fact(5) = 5 * Fact(4) = 5 * 24 = 120
递推: 从 Fact(5) 不断调用自己,直到 Fact(0),不断拆解问题
回归: 从 Fact(0) 逐层返回计算结果,最终得到 1
举例2:顺序打印⼀个整数的每⼀位
输⼊⼀个整数m,按照顺序打印整数的每⼀位。⽐如:
输⼊:1234 输出:1 2 3 4
输⼊:520 输出:5 2 0
分析和代码实现:
这个题⽬,放在我们⾯前,⾸先想到的是,怎么得到这个数的每⼀位呢?
如果n是⼀位数,n的每⼀位就是n⾃⼰;n是超过1位数的话,就得拆分每⼀位
1234%10就能得到4,然后1234/10得到123,这就相当于去掉了4,然后继续对123%10,就得到了3,再除10去掉3,以此类推,不断的**%10 和 /10** 操作,直到1234的每⼀位都得到;但是这⾥有个问题就是得到的数字顺序是倒着的
但是我们有了灵感,我们发现其实⼀个数字的最低位是最容易得到的,通过%10就能得到那我们假设想写⼀个函数Print来打印n的每⼀位,如下表⽰:
cpp
Print(n)
如果n是1234,那表⽰为
Print(1234) //打印1234的每⼀位
其中1234中的4可以通过%10得到,那么
Print(1234)就可以拆分为两步:
1. Print(1234/10) //打印123的每⼀位
2. printf(1234%10) //打印4
完成上述2步,那就完成了1234每⼀位的打印
那么Print(123)⼜可以拆分为Print(123/10) + printf(123%10)
以此类推下去,就有
cpp
Print(1234)
==>Print(123) + printf(4)
==>Print(12) + printf(3)
==>Print(1) + printf(2)
==>printf(1)
直到被打印的数字变成⼀位数的时候,就不需要再拆分,递归结束。那么代码完成也就⽐较清楚:
cpp
#include <stdio.h>
void Print(int n)
{
if (n > 9)
{
Print(n / 10);
}
printf("%d ", n % 10);
}
int main()
{
int m = 0;
scanf("%d", &m);
Print(m);
return 0;
}
输⼊和输出结果:

在这个解题的过程中,我们就是使⽤了⼤事化⼩的思路
把Print(1234) 打印1234每⼀位,拆解为⾸先Print(123)打印123的每⼀位,再打印得到的4
把Print(123) 打印123每⼀位,拆解为⾸先Print(12)打印12的每⼀位,再打印得到的3
直到Print打印的是⼀位数,直接打印就⾏。
画图推演
以1234每⼀位的打印来推演⼀下

cpp
递推阶段:
Print(1234)
n > 9 成立--> 调用 Print(1234 / 10) = Print(123)
Print(123)
n > 9 成立--> 调用 Print(123 / 10) = Print(12)
Print(12)
n > 9 成立--> 调用 Print(12 / 10) = Print(1)
Print(1)
n > 9 不成立 --> 不再调用,准备打印
回归阶段(逐层打印):
Print(1) --> 打印 1 % 10 = 1 -->输出 1
Print(12) --> 打印 12 % 10 = 2 --> 输出 2
Print(123) --> 打印 123 % 10 = 3--> 输出 3
Print(1234) --> 打印 1234 % 10 = 4 -->输出 4
三. 递归与迭代
递归是⼀种很好的编程技巧,但是和很多技巧⼀样,也是可能被误⽤的,就像举例1⼀样,看到推导的公式,很容易就被写成递归的形式:

cpp
int Fact(int n)
{
if(n==0)
return 1;
else
return n*Fact(n-1);
}
Fact函数是可以产⽣正确的结,但是在递归函数调⽤的过程中涉及⼀些运⾏时的开销。
在C语⾔中每⼀次函数调⽤,都需要为本次函数调⽤在内存的栈区,申请⼀块内存空间来保存函数调⽤期间的各种局部变量的值,这块空间被称为运⾏时堆栈,或者函数栈帧。函数不返回,函数对应的栈帧空间就⼀直占⽤,所以如果函数调⽤中存在递归调⽤的话,每⼀次递归函数调⽤都会开辟属于⾃⼰的栈帧空间,直到函数递归不再继续,开始回归,才逐层释放栈帧空间。所以如果采⽤函数递归的⽅式完成代码,递归层次太深,就会浪费太多的栈帧空间,也可能引起栈溢出(stack overflow)的问题。
关于函数栈帧的详细内容,有专门的讲解文章
所以如果不想使⽤递归,就得想其他的办法,通常就是迭代的⽅式(通常就是循环的⽅式)。
⽐如:计算 n 的阶乘,也是可以产⽣1~n的数字累计乘在⼀起的。
cpp
int Fact(int n)
{
int i = 0;
int ret = 1;
for(i=1; i<=n; i++)
{
ret *= i;
}
return ret;
}
上述代码是能够完成任务,并且效率是⽐递归的⽅式更好的。
事实上,我们看到的许多问题是以递归的形式进⾏解释的,这只是因为它⽐⾮递归的形式更加清晰,但是这些问题的迭代实现往往⽐递归实现效率更⾼。当⼀个问题⾮常复杂,难以使⽤迭代的⽅式实现时,此时递归实现的简洁性便可以补偿它所带来的运⾏时开销。
举例3:求第n个斐波那契数
我们也能举出更加极端的例⼦,就像计算第n个斐波那契数,是不适合使⽤递归求解的,但是斐波那契数的问题通过是使⽤递归的形式描述的,如下:

看到这公式,很容易诱导我们将代码写成递归的形式,如下所⽰:
cpp
int Fib(int n)
{
if(n<=2)
return 1;
else
return Fib(n-1)+Fib(n-2);
}
测试代码:
cpp
int Fib(int n)
{
if (n <= 2)
return 1;
else
return Fib(n - 1) + Fib(n - 2);
}
#include <stdio.h>
int main()
{
int n = 0;
scanf("%d", &n);
int ret = Fib(n);
printf("%d\n", ret);
return 0;
}
当我们n输⼊为50的时候,需要很⻓时间才能算出结果,这个计算所花费的时间,是我们很难接受的,这也说明递归的写法是⾮常低效的,那是为什么呢?

其实递归程序会不断的展开,在展开的过程中,我们很容易就能发现,在递归的过程中会有重复计
算,⽽且递归层次越深,冗余计算就会越多。我们可以作业测试:
cpp
#include <stdio.h>
int count = 0;
int Fib(int n)
{
if (n == 3)
count++;//统计第3个斐波那契数被计算的次数
if (n <= 2)
return 1;
else
return Fib(n - 1) + Fib(n - 2);
}
int main()
{
int n = 0;
scanf("%d", &n);
int ret = Fib(n);
printf("%d\n", ret);
printf("\ncount = %d\n", count);
return 0;
}
输出结果:

这⾥我们看到了,在计算第20个斐波那契数的时候,使⽤递归⽅式,第3个斐波那契数就被重复计算了6765次,这些计算是⾮常冗余的。所以斐波那契数的计算,使⽤递归是⾮常不明智的,我们就得想迭代的⽅式解决。我们知道斐波那契数的前2个数都1,然后前2个数相加就是第3个数,那么我们从前往后,从⼩到⼤计算就⾏了。这样就有下⾯的代码:
cpp
int Fib(int n)
{
int a = 1;
int b = 1;
int c = 1;
while (n > 2)
{
c = a + b;
a = b;
b = c;
n--;
}
return c;
}
运行结果:

迭代的⽅式去实现这个代码,效率就要⾼出很多了。有时候,递归虽好,但是也会引⼊⼀些问题,所以我们⼀定不要迷恋递归,适可⽽⽌就好。
四.拓展问题
以下2个问题都可以使⽤递归很好的解决
1.⻘蛙跳台阶问题
a.问题描述:


如图所示:
当 n = 1 时,一共只有一级台阶,那么青蛙这时就只有一种跳法如下:

当 n = 2 时,一共有两级台阶,这时青蛙的跳法有两种

b.问题分析:
青蛙跳台阶问题的求解思路与斐波那契数列高度相似,其核心在于通过递归思想将复杂问题逐步拆解为更简单的子问题。
假设跳上 n 级台阶一共有 f(n) 种跳法,通过分析青蛙的最后一步可以发现,青蛙的最后一跳只有两种情况:跳 1 级或跳 2 级台阶。这两种情况对应了完全不同的跳法路径,因此可以使用分类讨论来推导递推关系。
当青蛙最后一跳跳上 1 级台阶时,说明它已经跳到了第 n-1 级台阶,此时前 n-1 级台阶共有 f(n-1) 种跳法。当青蛙最后一跳跳上 2 级台阶时,说明它已经跳到了第 n-2 级台阶,此时前 n-2 级台阶共有 f(n-2) 种跳法。将这两种情况相加,就得到了总的跳法数,即递推公式:f(n) = f(n-1) + f(n-2) 。
接下来明确初始条件。当 n = 1 时,青蛙只能跳 1 级,只有 1 种跳法,所以 f(1) = 1。当 n = 2 时,青蛙可以一次跳 2 级,也可以分两次各跳 1 级,共有 2 种跳法,所以 f(2) = 2。为了方便递推计算,补充定义 f(0) = 1,表示跳上 0 级台阶有一种方式(即原地不动),这样计算 f(2) 时就可以通过 f(2) = f(1) + f(0) = 1 + 1 = 2 来得到正确结果。因此青蛙跳台阶问题的初始条件为:f(0) = 1,f(1) = 1,f(2) = 2 。
值得一提的是,斐波那契数列的标准定义为 f(0) = 0,f(1) = 1,f(2) = 1,递推公式同样为 f(n) = f(n-1) + f(n-2)。

c.代码描述:
cpp
#include <stdio.h>
// 求 n 级台阶青蛙的跳法
int frog_jump_step(int n)
{
// 对特殊情况作处理
if (n == 1)
{
return 1; // 1级台阶只有1种跳法
}
if (n == 2)
{
return 2; // 2级台阶有2种跳法:1+1 或 2
}
// 递归调用:最后一跳跳1级或跳2级
return frog_jump_step(n - 1) + frog_jump_step(n - 2);
}
int main()
{
int n = 0;
scanf("%d", &n); // 输入台阶数
int ways = frog_jump_step(n); // 计算跳法总数
printf("%d\n", ways); // 输出结果
return 0;
}
递归展开过程( n=4 )
cpp
frog_jump_step(4)
= frog_jump_step(3) + frog_jump_step(2)
frog_jump_step(3)
= frog_jump_step(2) + frog_jump_step(1)
= 2 + 1 = 3
frog_jump_step(2)
= 2
最终:frog_jump_step(4) = 3 + 2 = 5
递归调用链
cpp
frog_jump_step(4)
/ \
frog_jump_step(3) frog_jump_step(2)
/ \ |
frog_jump_step(2) frog_jump_step(1) 2
| |
2 1
最终:frog_jump_step(4) = (2 + 1) + 2 = 5
2.汉诺塔问题

汉诺塔,又称河内塔,是一个源于印度古老传说的经典益智玩具,也是递归算法的入门经典案例。相传在世界创世之初,大梵天在创造世界时建造了三根金刚石柱子,其中一根柱子上从下往上按照从大到小的顺序摞着64片黄金圆盘,形状如同一个金字塔。大梵天命令婆罗门将这64片圆盘全部移动到另一根柱子上,并且在整个移动过程中必须严格遵循两条规则:
每次只能移动一个圆盘
任何时刻都不能将大圆盘放在小圆盘上面,即小圆盘必须始终保持在大圆盘之上
此外,三根柱子都可以作为临时中转使用,但所有移动都必须满足上述规则。这个看似简单的游戏实际上蕴含着深刻的数学与计算机科学原理,当圆盘数量为 n 时,最少需要移动 2ⁿ - 1 次。如果按照传说中 n = 64 来计算,完成整个移动需要 2⁶⁴ - 1 次移动,即使每秒移动一次,也需要大约 5849 亿年,远超过宇宙的年龄,因此这个任务在现实中几乎无法完成。
面试题 08.06. 汉诺塔问题 - 力扣(LeetCode)
a.问题描述
有三根相邻的柱子,标记为 A、B、C。A 柱子上从下往上按照从大到小的顺序摞着 n 个盘子,形状如同金字塔。目标是将 A 柱子上的所有盘子全部移动到 C 柱子上,移动过程中必须严格遵守以下三条规则:
-
每次只能移动一个盘子:在任意一次操作中,只能将一个盘子从一根柱子的顶端移动到另一根柱子的顶端,不能同时移动多个盘子。
-
盘子只能从柱子顶端滑出:每根柱子上的盘子都只能从最上面取走,也只有在柱子顶端才能放置新的盘子,这意味着盘子只能从顶端滑出移到下一根柱子的顶端。
-
大盘子不能压在小盘子上:在任何时候,任何一根柱子上的盘子都必须保持从下往上依次变小的顺序,即每个盘子只能叠在比它大的盘子上方,绝对不允许将大圆盘放在小圆盘上面。

b.问题分析:
当 n = 1 时,只有一个盘子,移动非常简单,不需要借助任何中转柱子,直接将这个盘子从 A 柱移动到 C 柱即可,总共只需要 1 步,记为 A-->C。
当 n = 2 时,A 柱上有两个盘子,下面的是大盘子,上面的是小盘子,目标是将两个盘子都移动到 C 柱,且始终保持大盘子在下、小盘子在上。此时需要借助 B 柱作为中转,具体移动过程分为三步:第一步 ,将上面的小盘子从 A 柱移动到 B 柱(A-->B),这样 A 柱上只剩下最大的盘子;第二步 ,将下面的大盘子从 A 柱直接移动到 C 柱(A-->C);第三步,将 B 柱上的小盘子移动到 C 柱(B-->C)。至此,两个盘子都成功移动到 C 柱,共需 2² - 1 = 3 步。
当 n = 3 时,A 柱上有三个盘子,分别是盘子1 (最小,在最上面)、盘子2 (中等大小)、盘子3 (最大,在最下面),目标是将这三个盘子全部移动到 C 柱。我们可以先将最上面的两个盘子(盘子1和盘子2 )看成一个整体,先把它们从 A 柱借助 C 柱移动到 B 柱 ,然后将盘子3从 A 柱移动到 C 柱,最后再将 B 柱上的两个盘子借助 A 柱移动到 C 柱。其中,将两个盘子从 A 移到 B 的过程与 n = 2 时从 A 移到 C 的过程完全一致 ,只是目标柱子 从C 变成了 B ,同样需要 3 步;将两个盘子从 B 移到 C 的过程也是如此。因此 n = 3 时总共需要 3 + 1 + 3 = 7 步,即 2³ - 1 步。
综合分析:
当 n = 1 时,直接将盘子从 A 柱移动到 C 柱,无需借助中转柱子,记作 A-->C。
当 n > 1 时,采用递归方法完成三步移动:
第一步,将 A 柱上的 n-1 个盘子从 A 柱借助 C 柱移动到 B 柱。这一步的实现方式是:将最上面的 n-2 个盘子从 A 借助 B 移动到 C,再将第 n-1 个盘子从 A 移动到 B,再将 n-2 个盘子从 C 借助 A 移动到 B,以此类推,直到问题规模减小到 1。
第二步,将 A 柱上剩下的最后一个盘子(即最大的盘子)从 A 柱直接移动到 C 柱,记作 A-->C。
第三步,将 B 柱上的 n-1 个盘子从 B 柱借助 A 柱移动到 C 柱。这一步与第一步对称,只是方向和角色发生了互换,同样通过递归调用完成。
当第一步和第三步中的递归调用执行完成后,第二步移动的大盘子就被夹在中间,上面和下面分别被 n-1 个较小的盘子包裹,整个汉诺塔的移动就全部完成了。
| 步骤 | 移动 | 移动前状态 (A, B, C) | 移动后状态 (A, B, C) | 说明 |
|---|---|---|---|---|
| 初始 | --- | (3,2,1), (,), (,) | --- | 三个盘子在A柱 |
| 第1步 | 1: A→C | (3,2,1), (,), (,) | (3,2), (,), (1,) | 最小盘移到C |
| 第2步 | 2: A→B | (3,2), (,), (1,) | (3), (2,), (1,) | 中盘移到B |
| 第3步 | 1: C→B | (3), (2,), (1,) | (3), (2,1), (,) | 最小盘移到B(压在中盘上) |
| 第4步 | 3: A→C | (3), (2,1), (,) | (), (2,1), (3,) | 最大盘移到C |
| 第5步 | 1: B→A | (), (2,1), (3,) | (1,), (2), (3,) | 最小盘移到A |
| 第6步 | 2: B→C | (1,), (2), (3,) | (1,), (), (3,2) | 中盘移到C(压在最大盘上) |
| 第7步 | 1: A→C | (1,), (), (3,2) | (), (), (3,2,1) | 最小盘移到C,完成 |
格式说明: (3,2,1) 表示柱子上从下往上依次是盘子3、盘子2、盘子1,即数字越大盘子越大。
c.代码描述:
cpp
#include <stdio.h>
// 打印移动步骤
void move(char from, char to, int n)
{
printf("第%d个圆盘:%c ------> %c\n", n, from, to);
}
// 汉诺塔递归函数
// 功能:将 n 个圆盘从 A 柱借助 B 柱移动到 C 柱
void HanoiTower(char A, char B, char C, int n)
{
if (n == 1)
{
move(A, C, n); // 只有一个盘子,直接从A移到C
}
else
{
// 第一步:将 A 柱上 n-1 个盘子从 A 借助 C 移动到 B
HanoiTower(A, C, B, n - 1);
// 第二步:将 A 柱上剩下的最大盘子从 A 直接移动到 C
move(A, C, n);
// 第三步:将 B 柱上 n-1 个盘子从 B 借助 A 移动到 C
HanoiTower(B, A, C, n - 1);
}
}
int main()
{
int n = 0;
printf("请输入A柱上的圆盘个数:");
scanf("%d", &n); // 去掉 \n
// 将 n 个盘子从 A 柱借助 B 柱移动到 C 柱
HanoiTower('A', 'B', 'C', n);
return 0;
}
运行结果:
