版權申明:本文爲博主窗戶(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步,這個作法很差。