STL学习笔记:用非递归的方法实现汉诺塔问题
shaohui_1983#163.com http://blog.csdn.net/shaohui
早就想写篇关于用非递归的方法解决汉诺塔问题的文章,但是一直都没有时间去研究这个。最近学了点STL,但是一直都没有找到练手的地方,那就从这个地方开始吧。
关于这个问题的代码你可以在http://www.freewebs.com/zhengsh/download/hanoi.tar.gz下载。
关于怎么用递归的方法来解决汉诺塔问题数据结构和C语言的教材里面都有很清楚的描述,清华版的数据结构里面汉诺塔问题的解决方法非常简练,就只用了将近10行C代码。如果你是想找到一个全心的关于汉诺塔问题问题的解决方法,这篇文章可能会让你很失望,因为这里确实没有什么新的东西,只是把STL和C代码结合起来,试图用比较简单一点的方法来解决它。如果你要读懂里面的内容,需要对STL有一点基础的了解。
1.用堆栈来解决
递归和堆栈是相通的,能够用递归实现的程序就一定能用堆栈来实现,而用递归实际上是利用了系统的堆栈,把一切责任都推给了操作系统来完成,但是有个问题就是如果我们用堆栈的话我们就必须先定义一个堆栈的数据结构,还是比较麻烦,这样的话,代码量还是比较大的,不过STL可以帮我们解决这个问题。
需要说明的是,这里尽管没有用递归,但是还是没有脱离递归的思想。
在这里我们总是假定盘子的编号从顶端重小到大开始,如图
首先引入一个概念,移动操作,指把一个盘子从一个杆上移动到另外一个杆上的动作,或者把一叠连续放者的盘子整体从一个杆上移动到另外一个杆上的动作。为了便于在计算机中表示我定义了一个class如下:
1 #include <iostream>
2 #include <stack>
3 using namespace std;
4 //define the operation
5 class oprt
6 {
7 public:
8 int begin,end;
9 char src,dst,bri;
10 oprt(){};
11 oprt(int rbegin,int rend,char rsrc,char rdst,char rbri):
12 begin(rbegin),end(rend),src(rsrc),dst(rdst),bri(rbri){}
13 };
为了简化,我把其中的成员都定义成了public(当然了,这不是个好习惯)。Begin,end是指一叠盘子的开始和结束的盘子的标号,begin是标号比较小的盘子,而end是标号比较大的盘子。当begin == end时,表示移动的只是一个盘子。
Src,dst,bri,分别代表3个杆,src:source,dst:destination,
bri:bridge。看名字就知道,这3个成员代表什么意思了吧。
同时来定义了一个构造函数用来初始化对象。
14 #define print_oprt(op) cout<<"move disk "<<(op).begin<<" from \'"<<(op).src<<"\' to \'"<<(op).dst<<"\'"<<endl;
为了打印这个移动操作我定义了一个宏,至于为什么是个宏而不是个member function看看后面就知道了。
15 typedef stack<oprt> oprt_stack;
这行没有问题,用oprt_stack来表示堆栈。
16 int hanoi_s(int size,char src,char dst,char bri)
17 {
18 oprt_stack ostack;
19 oprt tmp;
20 ostack.push(oprt(1,size,src,dst,bri));
21 while (!ostack.empty())
22 {
23 tmp = ostack.top();
24 ostack.pop();
25 if (tmp.begin != tmp.end)
26 {
27 ostack.push(oprt(tmp.begin,tmp.end-1,tmp.bri,tmp.dst,tmp.src));
28 ostack.push(oprt(tmp.end,tmp.end,tmp.src,tmp.dst,tmp.bri));
29 ostack.push(oprt(tmp.begin,tmp.end-1,tmp.src,tmp.bri,tmp.dst));
30 }
31 else
32 print_oprt(tmp);
33 }
34 }
接下来,具体说一下工作原理。首先把整个开始的塔的全部盘子作为一个整体移动到目标杆上,也就是行20的代码。
然后我们要做的工作就是用递归的思想,依次弹出堆栈顶部的操作,把堆栈里面的操作依次给它拆开,分2种情况。
1.如果弹出的操作本来就是个原子操作(判断begin 和 end是否相等),也就是说这个操作只移动了一个盘子,那么就把它打印出来(行32)
2. 如果这个不是个原子操作,那么就把它分成3个小的移动操作,然后把这3个小的移动操作分别再压入堆栈(行27-29)。注意压堆栈的顺序和我们移动的操作要相反,因为堆栈是FILO,所以,先移动要后入栈这样才能够保证我们输出的顺序和自然的顺序相同
一直重复上面的步骤,直到堆栈中的每个操作都被分成原子操作然后被弹出为止。这样最后的输出序列就是我们移动盘子的序列。
2.其它非堆栈非递归的实现方法
有人总结出了盘子的移动规律。具体如下:
首先把src,bri,dst3个杆围成一个圆圈,规定方向为逆时针方向。
当汉诺塔问题的规模n为奇数的时候,
1. 奇数编号盘子总是移动移动到它后的第2个杆上。
2. 偶数编号的盘子总是移动移动到它的后第1个杆上
而当汉诺塔问题的规模n为偶数的时候,正好相反
1.奇数编号盘子总是移动移动到它后的第1个杆上。
2.偶数编号的盘子总是移动移动到它的后第2个杆上
看起来好象有点复杂,为了简化,我换了一个给盘子编号的方式,如图,上面的盘子标号大下面的盘子标号小。而最下面还假定有一个编号为0的不可以移动的盘子。代码行8,9就可以看出来,编号小的盘子是被压在最下面的。
然后我们的规律就变成了
当对于规模为n的汉诺塔问题的,
1. 奇数编号盘子总是移动移动到它后的第2个杆上。
2. 偶数编号的盘子总是移动移动到它的后第1个杆上
现在要简单多了。
不过这样还不够,我们还有给个规定:不能够连续两次移动同一个编号的盘子。这样才可以保证在任何情况下可以移动的盘子都只有一个。有了这个规律我们就可以很容易用程序来实现它,不过为了简便我还是用了堆栈来保存每个杆上的盘子,但是这个堆栈和上面的堆栈的作用就已经大不一样了,并不是问题的本质。
4 void hanoi_o(int scale,char src,char dst,char bri)
5 {
6 int cnt,srctop,dsttop,britop,last = 0;
7 stack<int> ssrc,sdst,sbri;
8 for (cnt=1 ;cnt<=scale; cnt++)
9 ssrc.push(cnt);
10
11 while (sdst.size() != scale)
12 {
13 srctop = ssrc.empty() ? 0 : ssrc.top();
14 dsttop = sdst.empty() ? 0 : sdst.top();
15 britop = sbri.empty() ? 0 : sbri.top();
16
17 if (last != srctop && ((srctop%2 && srctop>dsttop) || ((srctop%2==0) && srctop>britop)))
18 {
19 ssrc.pop();
20 last = srctop;
21 srctop % 2 ? sdst.push(srctop) : sbri.push(srctop);
22 if (srctop % 2)
23 cout<<"move disk "<<scale-srctop+1<<" from \'a\' to \'c\'\n";
24 else
25 cout<<"move disk "<<scale-srctop+1<<" from \'a\' to \'b\'\n";
26 }
27
28 if (last != britop && ((britop%2 && britop>srctop) || ((britop%2==0) && britop>dsttop)))
29 {
30 sbri.pop();
31 last = britop;
32 britop % 2 ? ssrc.push(britop) : sdst.push(britop);
33
34 if (britop % 2)
35 cout<<"move disk "<<scale-britop+1<<" from \'b\' to \'a\'\n";
36 else
37 cout<<"move disk "<<scale-britop+1<<" from \'b\' to \'c\'\n";
38 }
39
40 if (last != dsttop && ((dsttop%2 && dsttop>britop) || ((dsttop%2==0) && dsttop>srctop)))
41 {
42 sdst.pop();
43 last = dsttop;
44 dsttop % 2 ? sbri.push(dsttop) : ssrc.push(dsttop);
45 if (dsttop % 2)
46 cout<<"move disk "<<scale-dsttop+1<<" from \'c\' to \'b\'\n";
47 else
48 cout<<"move disk "<<scale-dsttop+1<<" from \'c\' to \'a\'\n";
49 }
50 }
51 }
看上面的代码好象复杂了点,不过主要还是因为判别的条件太复杂了,才会导致这段代码这么难懂。在这个while循环里面有3个if语句都在判定当前的盘子是否可以被移动,如果可以移动就移动它并且打印出来。
对于一个盘子要可以移动要满足以下2个条件
1. 前一次没有被移动过也就是不能够等于last
2. 盘子的编号必须要大于目标盘最上面的盘子的编号
这两个条件都要满足才能移动,可以证明在任何时候可以移动的盘子都是唯一的。
为了便于和开始的方法比较,在输出的时候对盘子的编号做了个转换,还是把它还原成原来的编号的方法。也就是编号小的在上,编号大的在下。上面我这个写法实在是太不简洁了,希望有写得更好的替换它。
3.递归的方法
尽管书上已经有递归的方法,不过我还是要给出我的递归的方法,作为参考。然后用于性能的比较。
3 #define move(disk,src,dst) cout<<"move disk "<<disk<<" from \'"<<src<<"\' to \'"<<dst<<"\'\n"
4 void hanoi_r(int size,char src,char dst,char bri)
5 {
6 if (size == 1)
7 move(size,src,dst);
8 else
9 {
10 hanoi_r(size-1,src,bri,dst);
11 move(size,src,dst);
12 hanoi_r(size-1,bri,dst,src);
13 }
14 }
4.各种方法的比较
有不同的实现方法,当然就有对比,我一直以为非递归的方法要比递归的方法要快一些,不过结果好象并不是这样。
由于打印输出需要消耗部少的时间,所以我就把它去掉了,只是改动我定义的宏就可以了。
堆栈
#define print_oprt(op)
递归
#define move(disk,src,dst)
表示这个打印操作什么都不做。
下面是我在我的计算机上虚拟的Linux环境下用3种不同的方法解决规模为1-30的汉诺塔问题所花的时间的比较结果(单位是秒),你也可以把这个程序下载到你的计算机上去测试一下。
Scale Stack Recursion Other (1-30)
1 0 0 0
2 0 0 0
3 0 0 0
4 0 0 0
5 0 0 0
6 0 0 0
7 0 0 0
8 0 0 0
9 0 0 0
10 0 0 0
11 0 0 0
12 0 0 0
13 0 0 0
14 0 0 0.01
15 0 0 0.01
16 0.02 0 0.01
17 0.03 0 0.03
18 0.05 0 0.05
19 0.12 0 0.12
20 0.23 0.01 0.23
21 0.49 0.01 0.47
22 0.95 0.04 0.89
23 1.92 0.07 1.87
24 3.8 0.15 3.55
25 7.65 0.3 7.52
26 15.2 0.6 14.24
27 30.49 1.2 29.7
28 61.11 2.41 57.24
29 111.15 4.29 97.56
30 201.09 7.72 184.32
用堆栈和其它的方法所花的时间比较接近,而用递归的速度却是最快的。相差非常的大。到底是什么原因呢,按理说应该不会有这么大的差别哦。
总结了一下,大概有以下原因:
1. STL本身的效率不是太高
STL功能强大了,效率上必然要受到一定的影响
2. 递归的方式函数调用比较少,就是调用它本身,而其它方式函数调用比较多
从代码中我们就可以看出,非递归方式调用函数的次数远远比递归方式多,每次移动操作都要调用好几次函数,pop,push等,而递归方式,每次移动操作只调用本身一次,故系统堆栈的负担要小的多。
3. 对堆栈实现,涉及到对象的多次构造和析构,并且还有就是堆栈的多次重新分配也需要消耗不少的时间(STL中stack是顺序存储结构,当空间不够用时,再向系统申请双倍的空间)
附录
上面提到的所以代码我都已经把它压缩好了,并且提供下载,
http://www.freewebs.com/zhengsh/download/hanoi.tar.gz
在readme文件中有比较详细的说明。除了hanoi_cmp.c其它的文件都可以在windows,linux下通过编译,我使用的是Linux下实现的,所以最好在Linux下重新编译它们。