漢諾塔——各類編程範式的解決

  版權申明:本文爲博主窗戶(Colin Cai)原創,歡迎轉帖。如要轉貼,必須註明原文網址

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

  做者:窗戶

  QQ/微信:6679072

  E-mail:6679072@qq.com

  理解遞歸,漢諾塔(Tower of Hanoi)是個很適合的工具,不大不小,做爲最開始遞歸的理解正合適。從而學習各類計算機語言乃至各類編程範式的時候,漢諾塔通常都做爲前幾個遞歸實現的例子之一,是入門的好材料。html

  本文從漢諾塔規則出發,講講漢諾塔的遞歸解法以及各類編程範式下漢諾塔的解實現。算法

  

  漢諾塔介紹sql

  

  漢諾塔傳說是源於印度的古老傳說。編程

  漢諾塔遊戲一共有三根柱子,第一根柱子上有若干個盤,另外兩根柱子上沒有盤。微信

  

 

  柱子上的盤是按照大小從小到大的排列的,最上面的盤是最小的,越到下面越大。數據結構

  每一次將任意一根柱子上最上面的一個盤放到另一根柱子上,但要遵照如下兩條:app

  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方法,是把s二、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)\n" (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.偶數步的移動發生在最小的盤所在柱子以外的兩根柱子之間

 

  對於上述二、3,在於咱們的移動目的是想把盤從第一根柱子移動到第三根柱子,若是咱們是想把盤從第一根柱子移動到第二根柱子,那麼二、3的移動順序交換。

  對於上述4,由於大盤不能壓小盤的規則,因此實際的移動方向是固定的,須要臨時比較一下從而看出移動方向。

  如下的動畫能夠說明移動過程:

  

 

  思考

 

  我仍是留下幾個思考給讀者:

  1.可不能夠證實對於n個盤,上述的2n-1步是最少的移動步數?

  2.能夠證實「現實中的玩法」的正確性嗎?對於「現實中的玩法」,能夠用計算機語言實現嗎?

  3.這個問題有點意思,對於n個從小到大的盤,所有放在3個柱子中任何一個柱子上,每一個盤任意放,但要知足大盤不能夠壓小盤上。這有不少種不一樣的放法。

  

  好比上圖中下面的狀況就是6個盤隨便給定的一個放法,知足大盤不壓小盤。

  初始的時候n個盤都在第一根柱子上,可不可使用漢諾塔的規則一步步移動到某個給定的放法?再進一步,能夠編程解決嗎?

  4.這個問題比較難一點,須要必定的數學推導了。可不能夠直接解決step(n,from,to,buffer,m),表示n個盤的漢諾塔的解的第m步。固然,我要的可不是一步步先算出來,再找出第m步,這個作法很差。

相關文章
相關標籤/搜索