前言
汉诺塔问题是在编程学习中遇到的很典型的一个递归问题!本篇博客,不仅仅会讲解递归版本的汉诺塔解决方案,还会详细解释非递归版本的处理办法,代码主要使用C/C++ 编写完成,使用VS2019进行编译运行,递归版本使用纯C语言,适合编程小白阅读,非递归版本使用C++ 编写,涉及到数据结构中 栈 的基础知识和C++ 中容器的使用,建议有一定数据结构和C++ 基础后进行阅读理解。
总之:适合在学习C语言进阶想要了解递归的使用原理,适合C++ 学习入门者
运行结果
这里我简单起见,使用三个盘子做演示,在完整了解程序之后,就可以自由修改盘子个数了但是,一定不要设置太多个盘子!!
三个盘子的运行结果:
汉诺塔介绍
这里先对汉诺塔问题做一个简单的介绍,对问题由一个初步的认识
汉诺塔(Tower of Hanoi),又称河内塔,是一个源于印度古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。 ——百度百科
这就是对于汉诺塔的本来问题,并且在传说中,当所有的金片都从梵天穿好的那根柱子上移到另外一根柱子上时,世界就将在一声霹雳中消灭,而梵塔、庙宇和众生也都将同归于尽。
如果真的有兴趣的话,可以简单计算下,这究竟需要多少时间,这里篇幅有限,我就直接使用结论了,如果要移动完64片,需要移动18446744073709551615 次,哪怕假设一秒钟移动一次,一天不间断的移动,也需要5845.42亿年以上 (╹ڡ╹ ) ,这个时间真的太长,甚至太阳的预期寿命也只有百亿年,真的有这么多时间,那一切灰飞烟灭的语言便也成真了。
好了,回到编程中来,我们将这个古老传说简化成一道可以理解的题目:
将一个柱子中的所有圆盘移动到另一个柱子,移动过程需遵守以下规则:
1.每次只能移动一个圆盘,而且只能移动某个柱子上最顶部的圆盘;
2.移动过程中,必须保证每个柱子上的大圆盘都位于小圆盘的下面。
就是如图这样的一个问题
这里为了表示规范,我将最开始的放圆盘的柱子称为 Source (源柱),要存放最终结果的柱子称为 Target (目标柱),剩下的一根柱子称为 Station (中转柱)。
我们要做的就是将所有圆盘从 源柱,借助 中转柱 转移到 目标柱上,即 Source -> Target 就是我们要进行的操作。
思路整理
我们要实现汉诺塔问题,就要先考虑出一个解决办法,而不能随意的移动,毕竟是要使用编程实现的嘛,编程思路最重要的就是—逻辑。
三个圆盘?
我们首先来考虑三个圆盘的情形,那我们要把三个圆盘从Source 移动到 Target,其实可以分为三个步骤:
- 先将 2 个圆盘从Source 移动到 Station个上(这时Source上只有一个最大的盘子,Station上有两个盘子,Target 上是空的)
- 然后将 Source上的盘子 转移到 target 上(这时Source上是空的,Station上有2个盘子,Target 上有一个最大的盘子)
- 最后将 2 个盘子再从Station 上 转移到Target上(完成)
看完上面的三个步骤,是不是有点云里雾里的?不是一次就只能转移一个盘子吗???
你要仔细看看,第一步和第三步我可没有说是一次就完成呀!!我的意思是,不论有多少次,第一步都是要把 2 个盘子转移到Station上,直到把这两个盘子转移完了,第一步才算完成!!
这里就是理解递归的关键了:我要实现 3 个盘子的移动,首先要实现 2 个盘子的移动!那我要怎么实现两个盘子的移动呢?当然是先实现一个盘子的移动呀!一个盘子的移动,这不就简单了嘛!
随着一步一步的推进,我发现我要解决的问题,变的越来越简单了,简单到最后,可以一步就解决了,而且我要实现的每一步的功能都是类似的,不仅仅是类似,每一步的功能也越发的简单,这就是递归。
每一步就可以看做是一次函数调用,不过函数调用的是我函数本身,而且每调用一次,问题就变简单一点,知道最后问题变成一步就可以解决的问题,也就是问题规模随着递归而变小,这样的递归就是有出口的,就不是死递归了,递归的过程就类似这样的:
既然了解了 3 个 盘子的实现方法,那我们如果扩展到 n 个盘子呢?
n个盘子的递归
其实 n 盘子的递归过程和 3 个盘子是基本一样的,就是把数字换成 n 的区别,也是三步:
- 将 n-1 个盘子从 Source 借助 Target 移动到 Station 柱上
- 将 1 个盘子 从 Source 直接放到 Target 上
- 将在 Station上的 n-1 个盘子,借助Source 移动到 Target 上
完成这三个步骤,就可以实现所有的移动操作了。虽然每个步骤会包含很多很多次移动,但是每个步骤都可以分解成这三个步骤了,就变的逻辑清晰,可以进行编程解决了。
递归实现
下面是试下汉诺塔的函数
void Hanoi(int num, char Source, char Station, char Target)
{ //Source:源座 Station:中转座 Target:目标座
//如果只有一个盘子
if (1==num) //将常量放在前面,可以很好的防止逻辑判断错误
{
printf("# %d : from %c to %c \n", num,Source,Target);
//将num 号盘子,从Source 移动到 Target上
}
else //递归调用
{
Hanoi(num - 1, Source, Target, Station); //第一步
printf("# %d : from %c to %c \n", num, Source, Target); //第二部
Hanoi(num - 1, Station, Source, Target); //第三步
}
}
要完整测试这段代码,当然是还得来个 入口点函数了,这里简单起见,我默认是三个圆盘,下面是完整代码:
#include <stdio.h>
void Hanoi(int num, char Source, char Station, char Target)
{ //Source:源座 Station:中转座 Target:目标座
//如果只有一个盘子
if (1==num) //将常量放在前面,可以很好的防止逻辑判断错误
{
printf("# %d : from %c to %c \n", num,Source,Target);
//将num 号盘子,从Source 移动到 Target上
}
else //递归调用
{
Hanoi(num - 1, Source, Target, Station); //第一步
printf("# %d : from %c to %c \n", num, Source, Target); //第二部
Hanoi(num - 1, Station, Source, Target); //第三步
}
}
int main()
{
char A = 'A';
char B = 'B';
char C = 'C';
int n=3; //移动盘子的个数,这里设置默认为3个盘子
Hanoi(n, 'A', 'B', 'C');
return 0;
}
递归中的问题
虽然递归可以比较好的理解问题规模,而且在实现方面也比较简单,可读性也不错,至少别人可以一眼就看出来这个地方使用了递归调用,可以很快的调整代码阅读思路,但是使用递归也会带来很多问题,下面的问题就是递归调用不可避免的问题:
- 递归调用,占用空间大
- 递归太深,容易发生栈溢出
- 可能会存在大量重复计算
其实也很好理解,一个函数被自己重复调用了很多次,每次都是这个函数,每次又都得保存这个函数的所有信息,就是为了找到最简单的那个调用,如果问题的规模非常大,那么就会有超级多个函数要保存和调用,那种内存的占用量就是很恐怖的,所以我们就可以考虑,是不是可以不使用递归来实现递归实现的功能呢?
非递归的实现
递归的本质
我们知道递归其实就是在调用函数罢了,只不过递归函数一直在调用自己,而且每一次调用,都在把问题变简单,调用本身当然也是函数调用,既然是函数调用,那么就要使用到内存中的 栈区,同样,如果递归规模过大,栈 溢出的问题也是这里的问题,**那么我们是不是可以自己设计一个栈,来实现这个功能呢?**这样的话,就可以自己控制栈的大小,而且也不会占用那么多的资源。
实现思路
那我们首先就要先实现一个栈,在C++ 中,有现成的容器stack 就可以使用。剩下就是要向栈中放内容了,我们在递归调用中,相当于是把一个个函数进行压栈处理,现在我们是自己实现了一个栈,要说最简单的实现方法—当然是每个向栈中放一个类,在类内有我需要的成员函数,通过不同的成员函数,就可以实现我们所需要的全部功能了。那我们就按照顺序来依次实现这些功能:
实现一个汉诺塔的类
要把汉诺塔作为一个类,那我们就需要先描述出它的特征,一个汉诺塔,需要 若干个 圆盘和三个柱子,既然有这四个变量,就还需要可以获取这四个变量,同时我们需要的是对一个汉诺塔的类的转换,为了方便起见,就重载了 = 操作符,下面是完整的头文件实现:
HanoiItem.h 详细的每一步的实现办法,我都写在注释中了
#pragma once
class CHanoiItem
{
public:
/*使用初始化列表 构造类,下面是语法:
构造函数():属性1(值1),属性2(值2)...{}
相当于构造函数的使用
*/
CHanoiItem(int nNumber,char nSource,char nStation,char nTarget)
:m_nNumber(nNumber),m_nSource(nSource),m_nStation(nStation),m_nTarget(nTarget){}
//重载版本的构造函数
CHanoiItem(const CHanoiItem& hSource)
:m_nNumber(hSource.m_nNumber),m_nSource(hSource.m_nSource),
m_nStation(hSource.m_nStation),m_nTarget(hSource.m_nTarget){}
//重载 = 操作符
CHanoiItem& operator=(const CHanoiItem& hSource) {
m_nNumber = hSource.m_nNumber;
m_nSource = hSource.m_nSource;
m_nStation = hSource.m_nStation;
m_nTarget = hSource.m_nTarget;
return *this;
}
//纯虚析构函数
virtual ~CHanoiItem(){}
//获取私有成员变量
int GetNumber() { return m_nNumber; } //获取圆盘数
char GetSource() { return m_nSource; } //获取 底座
char GetStation() { return m_nStation; } //获取 中转座
char GetTarget() { return m_nTarget; } //获取目标座
//全局变量
static const char A = 'A';
static const char B = 'B';
static const char C = 'C';
static const char E = 'E';
private:
int m_nNumber;
char m_nSource;
char m_nStation;
char m_nTarget;
};
模拟递归
接下来,就是最重要的,要用一个栈来实现模拟递归过程。
首先,我们在数据结构的学习中知道,栈 是一种 [ 先进后出 ] 的结构,我是一直把 栈 理解为一个 弹夹,在最下面的子弹最先被压进弹夹,却在最后被打出。
在先进后出的这个基础上,我们就可以设计栈内的元素了
- 首先我们需要让汉诺塔这个类入栈,我们要解决的就是这个栈内的问题
- 然后就是将这个问题分解为三个步骤,这三个步骤再上面已经说过了,这里不再赘述,但是我们要注意入栈的顺序,先入栈的是最后要解决的步骤,最后入栈的是我们要解决的第一步,就是先将n-1个圆盘,从 Source 通过 Station 转移到 Target 上。
- 最后就是将其封装作为一个函数进行实现了。
下面首先,我使用一个单独的输出函数,来解决输出的问题,这样,再函数中就只需要直接调用这个输出函数就可以了,输出函数如下:
//输出形式
void Print(CHanoiItem* pItem)
{
std::cout << "#" << pItem->GetNumber()
<< ": from" << pItem->GetSource() << " to " << pItem->GetTarget() << "\n";
}
下面就是最重要的Hanoi函数了,关于代码的解释,我在注释中也有详细的说明,可以参考:
void Hanoi(std::stack<CHanoiItem*>& hStack) {
//遍历该容器
while (!hStack.empty())
{
CHanoiItem* pTop = hStack.top(); //pTop 获取栈顶的圆盘
hStack.pop(); //栈顶 出栈
//如果只有一个圆盘
if (pTop->GetStation()==CHanoiItem::E||pTop->GetNumber()==1)
{
Print(pTop); //打印栈顶元素
delete pTop; //销毁栈顶元素
continue; //进入下一次while循环
}
//第三步先入栈 将n-1 个圆盘从source通过source移动到target上
// station : source : target
CHanoiItem* pItem = new CHanoiItem(pTop->GetNumber() - 1,
pTop->GetStation(), pTop->GetSource(), pTop->GetTarget());
hStack.push(pItem); //入栈n-1
//一个圆盘 将 1 个圆盘从source 移动到 target上
// Just one item 这里有E,就是if要判断的一个条件
pItem = new CHanoiItem(pTop->GetNumber(), pTop->GetSource(), CHanoiItem::E, pTop->GetTarget());
hStack.push(pItem); //入栈1
//第一步最后入栈,先执行 将 1 个圆盘从source 移动到 target上
// source : target : station
pItem = new CHanoiItem(pTop->GetNumber() - 1, pTop->GetSource(), pTop->GetTarget(), pTop->GetStation());
hStack.push(pItem); //入栈n-1
//通过这三步,就可以实现 n 个圆盘的汉诺塔问题,因为是栈先进后出的特性,只能让 最先执行的 最后入栈
}
}
第二步中,借助的是E 就是我单独进行定义的一个量,主要用来区分这一步的,如果自己喜欢的话,也可以把它替换成自己想要的变量。
入口点处的问题入栈
最后,再添上入口点函数,就可以很好的实现所有功能了,入口点函数如下:
int main() {
std::stack<CHanoiItem*> hStack;
CHanoiItem* pItem = new CHanoiItem(3, CHanoiItem::A, CHanoiItem::B, CHanoiItem::C);
hStack.push(pItem); //将汉诺塔类 入栈
Hanoi(hStack); //相当于递归调用的函数
return 0;
}
好了,这就是非递归的实现的完整思路了。
关于非递归的完整实现代码,我会放在我的Github仓库中,可以前往获取完整代码( ̄︶ ̄*))
总结
这里我使用递归和非递归实现了同一个问题的解决,如果仔细阅读,其实会有一种感觉,递归的解决确实是比较容易理解,不论是阅读还是构建,都比较好完成,但是如果要使用非递归来实现递归的功能,就显的比较麻烦了,就需要好好整理自己的思路,构建整体,但是非递归的就是由这种被程序员完美掌控的感觉,而且效率也很高,相比递归实现,尤其是再问题规模很大时,优势还有很明显的。平时再编程学习时,多多考虑使用其他方式来解决同样的问题,也是很好的锻炼自己思维能力的方法了。(。・∀・)ノ゙
最后
感谢观赏,一起提高,慢慢变强