【C语言】《C语言函数递归完全指南:思想、限制、经典例题(阶乘/斐波那契/青蛙跳台阶/汉诺塔)与迭代优化》

一.递归的定义

递归是学习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.问题描述:

跳台阶_牛客题霸_牛客网

LCR 127. 跳跃训练 - 力扣(LeetCode)


如图所示:

当 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 柱子上,移动过程中必须严格遵守以下三条规则:

  1. 每次只能移动一个盘子:在任意一次操作中,只能将一个盘子从一根柱子的顶端移动到另一根柱子的顶端,不能同时移动多个盘子。

  2. 盘子只能从柱子顶端滑出:每根柱子上的盘子都只能从最上面取走,也只有在柱子顶端才能放置新的盘子,这意味着盘子只能从顶端滑出移到下一根柱子的顶端。

  3. 大盘子不能压在小盘子上:在任何时候,任何一根柱子上的盘子都必须保持从下往上依次变小的顺序,即每个盘子只能叠在比它大的盘子上方,绝对不允许将大圆盘放在小圆盘上面。


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;
}

运行结果:

相关推荐
YSL0701242 小时前
动态内存管理
c语言
BreezeJuvenile3 小时前
基于C语言的SPSC环形缓冲区程序实现
c语言·环形缓冲区
SuperByteMaster3 小时前
recursive mutex的使用场景和场景代码
c语言
caimouse4 小时前
ReactOS 窗口系统架构分析
服务器·c语言
caimouse4 小时前
ReactOS 图形系统分析(46):DC 工具 — dcutil.c
c语言·开发语言
BreezeJuvenile5 小时前
嵌入式C语言常见考察点(2)
c语言·嵌入式
caimouse5 小时前
ReactOS 图形系统分析(50):画笔子系统 — pen.c
c语言·开发语言·stm32
caimouse6 小时前
ReactOS 图形系统分析(48):弧线绘制 — arc.c
c语言·开发语言
cypking7 小时前
Objective-C 语法完整学习手册(小白自学 + 开发备查)
c语言·开发语言·学习·objective-c