递归算法与汉诺塔

目录

递归核心

思想关键

示例

阶乘

斐波那契数列

汉诺塔

题目/规则:

思考

找基线条件

疑问:为什么第一步1号一定要去C

思考怎么"递"

疑问:怎么就实现了?

过一遍

手动演绎递归过程

[n = 3时](#n = 3时)


递归核心

递归:函数自己调用自己 两个必备条件(缺一不可,否则无限栈溢出)

基线条件(终止条件):满足条件直接返回,不再递归调用,防止死递归。

递归条件 :把原问题拆解成规模更小、结构相同的子问题。

大事化小,直到最小可直接求解的情况,再逐层返回合并结果。

思想关键

① 不要试图一层一层跟踪全部调用!

我认为最关键的一条,就是不要去质疑自己写的递归(或许莫名其妙地就写对了)

误区:手动去模拟每一层递归的执行,栈深一大就混乱。

正确思维:相信递归函数是正确的(数学归纳法思想)。

假设 f(n-1) 能正确完成任务,我只要写好 f(n) 和 f(n-1) 的关系即可。

汉诺塔:相信 hanoi (n-1, ...) 可以把 n-1 个盘子完整挪好,不用关心它内部怎么挪。

② 递归分为两个阶段:递、归

  • 递(向下):不断拆分问题,函数不断调用自身,压入栈,直到碰到基线条件。此时还没有返回结果。
  • 归(向上回溯):到达基线,开始返回;子问题算出结果,逐层向上合并得到大问题答案。

阶乘:递:fact (4)→fact (3)→fact (2)→fact (1);归:1→2→6→24

③ 递归的本质:利用函数调用栈保存中间状态

每一次递归调用,都会产生独立的局部变量,保存在栈帧里。 回溯的时候,自动恢复上一层函数的上下文。

汉诺塔打印移动步骤,就是回溯 / 递过程中输出状态。

示例

阶乘

公式:

cpp 复制代码
#include <stdio.h>
using namespace std;
int fact(int n)
{
	if (n == 1 || n == 0) // 基线条件
		return 1;
	return n * fact(n - 1); // 递归:拆成更小子问题
}
int main()
{
	cout << fact(4);
	return 0;
}

调用过程

fact(4):

fact(4)=4*fact(3) →

fact(3)=3*fact(2) →

fact(2)=2*fact(1) →

fact(1)=1

然后回溯:2*1=2 → 3*2=6 → 4*6=24


斐波那契数列

公式:

cpp 复制代码
#include <stdio.h>
#include <iostream>
using namespace std;
int fib(int n)
{
	if (n == 1 || n == 2)
		return 1;
	return fib(n - 1) + fib(n - 2);
}
int main()
{
	cout << fib(4);
	return 0;
}

缺点:大量重复计算,效率低。


汉诺塔

今天尝试了在知道要用递归的前提下,试着写了汉诺塔,结果莫名其妙地写对了。

很开心。之后又花了一小下午弄懂其中的原理。

题目/规则:

起始有3 根柱子,一般命名:A(源柱)、B(辅助柱)、C(目标柱)

有n 个大小互不相同的圆盘 ,一开始全部叠在 A 柱,大盘在下,小盘在上,依次堆叠。

移动规则

一次只能移动 1 个圆盘

圆盘只能从一根柱子顶部拿,放到另一根柱子顶部

任何时刻,不能把大盘放在小盘上面

目标:把全部 n 个圆盘,从 A 柱完整移动到 C 柱,移动过程遵守上面规则。

思考

普通的线性运算很难实现,用递归最好写;

以三个(n = 3)为例:

找基线条件

首先想基线条件:

当n = 1时,将1号盘从当前柱移动到目标柱,并且返回,完成"归"。

注意:这里我没有说从A柱移动到C柱。

因为在递归中,你无法确定1号盘当前在哪个柱子,也不能确定1号盘去哪个柱子。

疑问:为什么第一步1号一定要去C

或许你会问:只有一个盘的时候肯定要A -> C,那多个盘的时候,1号盘不能现A -> B?

结论是确实可以,但不是最优解了。

可以这么理解:如果一个柱子的顶端是1号盘。那这个柱子就"死了"------

此时这个柱子不能再移入任何的盘子

若一开始就将1号盘移动到B柱,2号会去C,想要再操作只能进行1号盘B -> C

但这样已经gameover了------两个最小的在C柱上,已经不可能实现最短路径了。

思考怎么"递"

现在不以三个为例了,个数太少容易使人想走"捷径"

假如现在有八个盘:

先写个函数模板:

cpp 复制代码
void hanruo(int n, char ori, char buf, char dest)

n为个数,ori为柱当前所在的 ,buf为当前的缓存柱子,dest是当前目标移动的柱。

注意:以上的参数只是针对当前状态,这对理解特别重要------

因为递归中谁做缓冲柱,谁做目标柱都不一定

当然第一层的ori buf dest就分别是A B C


看最外层:当n = 8输入进函数后,在写入基线条件后,要思考面对这个八层的塔,要干什么?

我们想要把1~7层全部挪到B柱,来解放8号盘------将其移动到C;

这是将8号盘移动到C的唯一方式,所以也其他没异议

于是就开始递归:

cpp 复制代码
void hanruo(int n, char ori, char buf, char dest)
{//ori -> A   buf -> B dest -> dest
	if (n == 1)
	{
		printf("%d号盘从%c移动到%c\n", n, ori, dest);
		return;
	}
	hanruo(n - 1, ori, dest, buf);

hanruo(n - 1, ori, dest, buf);

这一句,就表示:将n - 1,也就是前七层,从ori(A),以dest(C)为缓冲,移动到buf(B)

也就实现了我们想要的效果

疑问:怎么就实现了?

这也没写啥具体的代码,怎么就实现这个功能了?

确实,我们代码还没写全,逻辑还没闭环,不了解也没事,接着看就好了。


之后,就要将8号盘移动到C

cpp 复制代码
void hanruo(int n, char ori, char buf, char dest)
{
	if (n == 1)
	{
		printf("%d号盘从%c移动到%c\n", n, ori, dest);
		return;
	}
	hanruo(n - 1, ori, dest, buf);
	printf("%d号盘从%c移动到%c\n", n, ori, dest);

对,就加了一句话

printf("%d号盘从%c移动到%c\n", n, ori, dest);

足以表示这个过程

那之后呢,这C柱也被"占领了",咋办?

其实C柱没有"被占"------C柱有最大号的盘子,意味着这个柱可以执行任何操作------不会受到阻碍

那我们就直接把C柱子看成空的就好了。

那是不是和这个情况很像?只不过此时满的应该是B 而不是A

那不就是初始n = 7的样子吗?只是缓存柱子不一样了,那就可以写

cpp 复制代码
void hanruo(int n, char ori, char buf, char dest)
{
	if (n == 1)
	{
		printf("%d号盘从%c移动到%c\n", n, ori, dest);
		return;
	}
	hanruo(n - 1, ori, dest, buf);
	printf("%d号盘从%c移动到%c\n", n, ori, dest);
	hanruo(n - 1, buf, ori, dest);
}

以ori(A)为缓冲柱,将盘子从buf(B)移动到dest(C)

好了,已经写完了,下面就交给递归吧。

或许你很惊讶------这么短?好吧,其实我一开始也很惊讶------我压根没打算这点代码可以跑起来

但结果确实是对的,我很开心

cpp 复制代码
#include <stdio.h>
#include <iostream>
using namespace std;
void hanruo(int n, char ori, char buf, char dest)
{
	if (n == 1)
	{
		printf("%d号盘从%c移动到%c\n", n, ori, dest);
		return;
	}
	hanruo(n - 1, ori, dest, buf);
	printf("%d号盘从%c移动到%c\n", n, ori, dest);
	hanruo(n - 1, buf, ori, dest);
}
int main()
{
	int n;
	scanf("%d",&n);
	hanruo(n, 'A', 'B', 'C');
}
过一遍

再想一遍一套流程中都干了什么

第 1 步:hanruo(n - 1, ori, dest, buf);

把 ori 上面的 n-1 个小盘,借助 dest,搬到 buf。

重点: 现在要移动的是 n-1 个盘子,所以三个柱子角色发生切换:

  • 起点依旧是 ori
  • 辅助柱变成了 dest
  • 目标柱变成了 buf

效果:执行完这一步之后,最大的第 n 号盘子,孤零零留在 ori 上;上面 n-1 个小盘全部挪到 buf。 此时 ori 只剩最大盘,下面是空的,可以移动大盘。


第 2 步:printf("%d号盘从%c移动到%c\n", n, ori, dest);

移动第 n 号最大盘子,从 ori → dest。

这一步是当前层真正做的操作,递归调用只是安排小盘。 大盘没有任何盘子压着,目标柱 dest 现在是空(或者上面盘子都比 n 号大,满足规则),直接移动。

执行完:最大盘子已经就位,固定在 dest 底部,之后再也不动它。


第 3 步:hanruo(n - 1, buf, ori, dest);

把 buf 上存放的 n-1 个小盘,借助 ori,搬到 dest。

角色再次切换:

  • 起点现在是 buf(n-1 个小盘现在在这里)
  • 辅助柱变成 ori(现在只有那个已经移走大盘的空柱子)
  • 目标柱是 dest(最大盘已经放在这里,小盘最后叠上去)

效果:n-1 个小盘全部移动到 dest,叠在第 n 号大盘上面。 至此,n 个盘子全部从 ori 移到 dest,任务完成。


对的,这已经是一套完美的逻辑,所以可以运行起来。


手动演绎递归过程

虽然我说得头头是道,但我实际上了对这个递归的实际操作不很熟悉。

所以我决定拿几个比较小的数来演绎一下过程

不过,一开始写的时候千万不要自己演绎,容易把自己绕进去。要靠逻辑与"感觉"。

n = 3时

接着开始递归:

↓

再"归"↓

↓

↓

此时1 2的进程都关闭了,再继续3的进程:

↓

这就演示完了,虽然我的图可能不是很美观,没办法,太难画了。

通过这个流程,希望可以加深你的理解。

相关推荐
Fruit_Caller1 小时前
static
开发语言·c++
用户979984807186 小时前
200 行代码写一个能跑的 AI Agent:不依赖任何框架,只靠 tool-calling 原理
算法
threerocks6 小时前
【Muse实战】X 流量作战室搭建保姆级教程 - 拥有你自己的运营团队
算法
CSharp精选营6 小时前
我用 ASP.NET Core 做了个水稻病虫害检查系统
c#·毕业设计·.net core·码农刚子
GreenTea6 小时前
深度拆解 ScienceBuddy:如何用“双层递归自进化”构建高可靠科研 Agent Harness
前端·后端·算法
唐青枫6 小时前
我用 C# 开发了一款电子发票自动整理工具
c#·.net
下页、再停留6 小时前
【C#桌面客户端系列学习-2】在原来窗口基础上打开新窗口
c#·visual studio
花椒技术6 小时前
2.46 秒生成 5 秒视频:拆解 H3 Max 的模型、推理栈与硬件协同
算法·音视频开发·视频编码
Behavior6 小时前
Unity 手游 iOS Deep Link 唤醒全流程:从 URL Scheme / Universal Links 到 C# 层参数投递
c#·unity3d·游戏开发