汉诺塔——各种编程范式的解决

  版权申明:本文为博主窗户(Colin Cai)原创,欢迎转帖。如要转贴,必须注明原文网址

  http://www.cnblogs.com/Colin-Cai/p/7823264.html 

  作者:窗户

  QQ/微信:6679072

  E-mail:6679072@qq.com

  理解递归,汉诺塔(Tower of Hanoi)是个很适合的工具,不大不小,作为最开始递归的理解正合适。从而学习各种计算机语言乃至各种编程范式的时候,汉诺塔一般都作为前几个递归实现的例子之一,是入门的好材料。

  本文从汉诺塔规则出发,讲讲汉诺塔的递归解法以及各种编程范式下汉诺塔的解实现。

  

  汉诺塔介绍

  

  汉诺塔传说是源于印度的古老传说。

  汉诺塔游戏一共有三根柱子,第一根柱子上有若干个盘,另外两根柱子上没有盘。

  

  柱子上的盘是按照大小从小到大的排列的,最上面的盘是最小的,越到下面越大。

  每一次将任意一根柱子上最上面的一个盘放到另外一根柱子上,但要遵守以下两条:

  1.每一次必须移动一个盘

  2.大盘不可以压在小盘上面

  

  我们的目标就是反复移动盘,直到把所有的盘从第一根柱子上全部移动到其他的柱子,比如,这里我们就定死,全部移动到第三根柱子,则达到目的。

  

  

  以上6个盘的移动方法我做了个动画,如下所示:

  

  

  

  递归

  如果是第一次看到汉诺塔,估计会一下子变的手足无措。

  但我们细细的去想想,从简单的开始入手,先看一个盘的情况,这个太简单了,只需要一步即可。

  

  既然是递归,我们就要考虑问题的分解,也就是降阶。

  我们考虑n个盘的汉诺塔问题(n>1)。

  我们来看,最大的那个盘什么时候才可以移动走呢?因为这是最大的盘,大盘不可以压小盘,所以它移动的前提一定是在其他的盘都在另外一根柱子上,这样可以空出来一根柱子让它移动过去。而同时,它的存在并不影响任何小盘的移动。

  

  于是我们可以把问题分解一下:

  当n>1时,我们把n个盘从第一根柱子移动到第三根柱子,可以分为三个步骤:

  1.把上面的n-1个盘从第一根柱子移动到第二根柱子

  2.把最大的盘从第一根柱子移动到第三根柱子

  3.把那n-1个盘从第二根柱子移动到第三根柱子

  

  

  于是,一个问题就这样被分解为三个小问题,达到了递归的降阶。

  用数学归纳法很容易证明上述的移动方法,对于n个盘的移动步数是2n-1

  当然,问题本身的形式化,我们用可以用hanoi(n, from, to, buffer)来表示问题,n是盘子的个数,from是盘子初始时所在的柱子,to是盘子最终所在的柱子,buffer是除了from和to的另外一个柱子。

  

  于是用伪码来表示这个递归:

  hanoi(n, from, to, buffer):

  begin

  if n=1

    begin

    move_disk(from,to)

    end

  else

    begin

    hanoi(n-1,from,buffer,to)

    hanoi(1,from,to,buffer)

    hanoi(n-1,buffer,to,from)

    end

  end

  

  递归过程动画:

   

  C++实现

  C++作为当今世界上最复杂的计算机语言,没有之一,是值得说说的。C++支持过程式编程,同时也支持过程式基础上的面向对象,乃至泛型(其实比起很多语言比如lisp的泛型抽象来说,C++的泛型还是带有底层语言的特征)等。

  C++还有实现很好的STL,支持各种常用数据结构,用来做算法描述真的比C语言舒服多了,而且编译后运行效率比C语言差不了多少。这也是为什么很多信息竞赛是用C++答题。

  我们每一次移动盘,都会从某一个柱子(源柱)移动到另外一个柱子(目的柱),用源柱号和目的柱号的pair来代表一步,STL里有pair,正好使用,这也是集合论中比较基础的概念。

  然后我们用pair串成的list来表示一个汉诺塔问题的解。 

#include <list>
#include <utility>
using namespace std;
list<pair<int, int> > hanoi(int n, int from, int to, int buffer)
{
        if(n == 1) {
                list<pair<int, int> > s;
                s.push_back(pair<int, int>(from, to));
                return s;
        }

        list<pair<int, int> > s = hanoi(n-1,from,buffer,to);
        list<pair<int, int> > s2 = hanoi(1,from,to,buffer);
        list<pair<int, int> > s3 = hanoi(n-1,buffer,to,from);
        s.splice(s.end(), s2);
        s.splice(s.end(), s3);
        return s;
}

  这基本就是上面的递归伪码的C++实现,需要注意的是,list的splice方法,是把s2、s3的链表直接搬过来,而不是复制。

  Scheme实现

  Scheme作为一种Lisp,支持多种范式,最主要当然是函数式编程,采用lambda演算作为其计算手段。Lisp一直是我认为必学的语言。而我心里越来越削弱Common Lisp的地位,觉得Scheme更为纯正,纯就纯在它至简的设计,Common Lisp还要分函数和变量两个名字空间,这时常让我觉得没有真正体现数据和函数一家的意思。

  我们还是使用Scheme的实现当然比C++更为简洁一些

(define (hanoi n from to buffer)
 (if (= n 1)
  (list (cons from to))
  (append
   (hanoi (- n 1) from buffer to)
   (hanoi 1 from to buffer)
   (hanoi (- n 1) buffer to from))))

  Prolog实现

  Prolog是与C语言同时代的语言,曾经AI的三大学派之一符号学派的产物,当然,Lisp也属于这一学派的产物。

  Prolog是明显不同于之前的几种编程语言,它使用的是逻辑范式,使用谓词演算来计算。

hanoi(1,FROM,TO,_,[[FROM,TO]]).
hanoi(N,FROM,TO,BUFFER,S) :-
        N>1,
        M is N-1,
        hanoi(M,FROM,BUFFER,TO,S2),
        hanoi(1,FROM,TO,BUFFER,S3),
        hanoi(M,BUFFER,TO,FROM,S4),
        append(S2,S3,S5),
        append(S5,S4,S).

  

  有点诡异啊,长的和平常习惯的语言很不一样了。

  比如这里如果我想查4个盘的汉诺塔,从柱1移到柱3,

  ?- hanoi(4,1,3,2,S),write(S),!.
  [[1,2],[1,3],[2,3],[1,2],[3,1],[3,2],[1,2],[1,3],[2,3],[2,1],[3,1],[2,3],[1,2],[1,3],[2,3]]

  改进的递归

  我们重新去看看这个递归的伪码  

  hanoi(n, from, to, buffer):

  begin

  if n=1

    begin

    move_disk(from,to)

    end

  else

    begin

    hanoi(n-1,from,buffer,to)

    hanoi(1,from,to,buffer)

    hanoi(n-1,buffer,to,from)

    end

  end

  

  绿色的hanoi(1,from,to,buffer)自然就是move_disk(from,to)

  而两个红色的hanoi(n-1,from,buffer,to)hanoi(n-1,buffer,to,from),其实不过是柱号有所偏差,其实只需要解得hanoi(n-1,from,buffer,to),然后通过from->buffer,buffer->to,to->from这样改变柱号,就得到hanoi(n-1,from,buffer,to)的解。

  

  C++的代码并不难改,只要遍历一把list,每个转换一遍,然后再来合并list就行了。

#include <list>
#include <utility>
using namespace std;
list<pair<int, int> > hanoi(int disks, int from, int to, int buffer)
{
        if(disks == 1) {
                list<pair<int, int> > s;
                s.push_back(pair<int, int>(from, to));
                return s;
        }

        list<pair<int, int> > s = hanoi(disks-1,from,buffer,to);
        list<pair<int, int> > s2;
        pair<int, int> x;
        for(list<pair<int, int> >::iterator i=s.begin();i!=s.end();i++) {
                if(i->first == from) {
                        x.first = buffer;
                } else if(i->first == buffer) {
                        x.first = to;
                } else {
                        x.first = from;
                }
                if(i->second == from) {
                        x.second = buffer;
                } else if(i->second == buffer) {
                        x.second = to;
                } else {
                        x.second = from;
                }
                s2.push_back(x);
        }
        s.push_back(pair<int, int>(from, to));
        s.splice(s.end(),s2);
        return s;
}

  

  lambda满天飞的Scheme,上述的list转换完全可以用几个lambda来表示,  

(define (hanoi disks from to buffer)
 (if (= disks 1)
  (list (cons from to))
  (let ((s (hanoi (- disks 1) from buffer to)))
   (append
    s
    (list (cons from to))
    (map
     (lambda (x)
      (let ((f (lambda (y) (cond ((= y from) buffer) ((= y buffer) to) (else from)))))
       (cons (f (car x)) (f (cdr x)))
      )
     )
     s
    )
   )
  )
 )
)

  

  C++一直是一个大试验田,里面可谓古灵精怪什么都有。其实,C++11也同样引入了lambda,于是C++局部也可以引入函数式编程,我在这里不给出代码,这个就交给有兴趣的读者去完成吧。

  Prolog的转化则值得讲一讲,先把hanoi谓词修改了

hanoi(1,FROM,TO,_,[[FROM,TO]]).
hanoi(N,FROM,TO,BUFFER,S) :-
        N>1,
        M is N-1,
        hanoi(M,FROM,BUFFER,TO,S2),
        turn(S2,[[FROM,BUFFER],[BUFFER,TO],[TO,FROM]],S3),
        append(S2,[[FROM,TO]],S4),
        append(S4,S3,S).

  我在这里加了一个谓词turn,而[[FROM,BUFFER],[BUFFER,TO],[TO,FROM]]代表着转化规则FROM=>BUFFER,BUFFER=>TO,TO=>FROM,通过规则把S2转换成S3。

  再举个例子,turn([[1,2],[3,4],[5,9]], [[1,10],[2,20],[3,30],[4,40],[5,50]], [[10,20],[30,40],[50,90]])

  因为[[1,10],[2,20],[3,30],[4,40],[5,50]]代表着转换规则1=>10,2=>20,3=>30,4=>40,5=>50

  [[1,2],[3,4],[5,9]]里面只有9在转换表里找不到,其他都可以转换,所以最终最右边的这个是 [[10,20],[30,40],[50,90]]

  接下来就是如何实现turn,这个需要逐步递归过去。

  对于空列,当然转换为空列,

  turn([],_,[]).

  而对于其他情况,

  我们可以先定义一个turn_list谓词,它跟turn谓词很相似,只是,它处理的对象是单个list

  比如turn_list([1,2,3], [[1,10],[2,20],[3,30]], [10,20,30]).

  于是我们对于普通情况的turn就可以如下定义:  

  turn([A|B],C,S) :-
    turn_list(A,C,D),
    turn(B,C,S2),
    S = [D|S2].

  

  于是解决turn就转化为turn_list问题,处理的问题规模得到了降阶,这的确是解决递归真谛啊。

  我们在用递归的过程中,就是用尽任何手段来降阶,也就是说解决一个复杂问题转化为解决若干个复杂程度降低的问题。能够理解这一点,这篇文章的目的也就达到了。

  turn_list谓词还是太复杂,继续降阶,我们再定义一个谓词turn_one,它只是用来转换单个元素的。

  比如turn_one(1, [[1,10]], 10).

  于是turn_list的描述则可以如下:  

  turn_list([],_,[]).
  turn_list([A|B],C,S) :-
    turn_one(A,C,D),
    turn_list(B,C,S2),
    S = [D|S2].

  

  而最终,turn_one的实现如下: 

  turn_one(A,[],A).
  turn_one(A,[[A,B]|_],B).
  turn_one(A,[[B,_]|D],E) :-
    not(A=B),
    turn_one(A,D,E).

  现实中的玩法

  

  以上讨论递归,虽然可以解决问题,但是似乎并不适合于现实中的汉诺塔游戏,人脑不是计算机,不太适合干递归的事情。

  我们稍微修改一下Scheme程序,来观察移动过程中到底移动的是哪个盘,以期待更多的信息,从而发现规律。

  我们对所有的盘从小到大从1号开始依次标号。 

(define (hanoi disks from to buffer)
 (if (= (length disks) 1)
  (list (list from to (car disks)))
  (append
   (hanoi (cdr disks) from buffer to)
   (hanoi (list (car disks)) from to buffer)
   (hanoi (cdr disks) buffer to from)
  )
 )
)

(for-each
 (lambda (x) (display (format "柱~a -> 柱~a (盘~a)
" (car x) (cadr x) (caddr x))))
 (hanoi (range (read) 0 -1) 1 3 2)
)

  对于3个盘的情况,

  柱1 -> 柱3 (盘1)
  柱1 -> 柱2 (盘2)
  柱3 -> 柱2 (盘1)
  柱1 -> 柱3 (盘3)
  柱2 -> 柱1 (盘1)
  柱2 -> 柱3 (盘2)
  柱1 -> 柱3 (盘1)

  对于4个盘的情况, 

  柱1 -> 柱2 (盘1)
  柱1 -> 柱3 (盘2)
  柱2 -> 柱3 (盘1)
  柱1 -> 柱2 (盘3)
  柱3 -> 柱1 (盘1)
  柱3 -> 柱2 (盘2)
  柱1 -> 柱2 (盘1)
  柱1 -> 柱3 (盘4)
  柱2 -> 柱3 (盘1)
  柱2 -> 柱1 (盘2)
  柱3 -> 柱1 (盘1)
  柱2 -> 柱3 (盘3)
  柱1 -> 柱2 (盘1) 
  柱1 -> 柱3 (盘2)
  柱2 -> 柱3 (盘1)

  我们再继续观察别的数量的盘,

  总结一下,我们发现:

  1.从第一步开始,奇数步都是移动最小的盘

  2.对于奇数个盘的情况, 最小的盘的移动顺序是柱1->柱3->柱2->柱1->柱3->柱2->...

  3.对于偶数个盘的情况, 最小的盘的移动顺序是柱1->柱2->柱3->柱1->柱2->柱3->...

  4.偶数步的移动发生在最小的盘所在柱子之外的两根柱子之间

  对于上述2、3,在于我们的移动目的是想把盘从第一根柱子移动到第三根柱子,如果我们是想把盘从第一根柱子移动到第二根柱子,那么2、3的移动顺序交换。

  对于上述4,因为大盘不能压小盘的规则,所以实际的移动方向是固定的,需要临时比较一下从而看出移动方向。

  以下的动画可以说明移动过程:

  

  思考

  我还是留下几个思考给读者:

  1.可不可以证明对于n个盘,上述的2n-1步是最少的移动步数?

  2.可以证明“现实中的玩法”的正确性吗?对于“现实中的玩法”,可以用计算机语言实现吗?

  3.这个问题有点意思,对于n个从小到大的盘,全部放在3个柱子中任何一个柱子上,每个盘任意放,但要满足大盘不可以压小盘上。这有很多种不同的放法。

  

  比如上图中下面的情况就是6个盘随便给定的一个放法,满足大盘不压小盘。

  初始的时候n个盘都在第一根柱子上,可不可以使用汉诺塔的规则一步步移动到某个给定的放法?再进一步,可以编程解决吗?

  4.这个问题比较难一点,需要一定的数学推导了。可不可以直接解决step(n,from,to,buffer,m),表示n个盘的汉诺塔的解的第m步。当然,我要的可不是一步步先算出来,再找出第m步,这个做法不好。

   

原文地址:https://www.cnblogs.com/Colin-Cai/p/7823264.html