版權申明:本文為博主窗戶(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)\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.偶數步的移動發生在最小的盤所在柱子之外的兩根柱子之間
對於上述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步,這個做法不好。