化繁為簡 經典的漢諾塔遞迴問題 in Java

weixin_34006468發表於2017-10-03

問題描述
在世界中心貝拿勒斯(在印度北部)的聖廟裡,一塊黃銅板上插著三根寶石針。印度教的主神梵天在創造世界的時候,在其中一根針上從下到上地穿好了由大到小的64片金片,這就是所謂的漢諾塔。不論白天黑夜,總有一個僧侶在按照下面的法則移動這些金片:一次只移動一片,不管在哪根針上,小片必須在大片上面。僧侶們預言,當所有的金片都從梵天穿好的那根針上移到另外一根針上時,世界就將在一聲霹靂中消滅,而梵塔、廟宇和眾生也都將同歸於盡。
扯遠了,把這個問題簡單描述下有A,B,C三根柱子,將A柱上N個從小到大疊放的盤子移動到C柱,一次只能移動一個,不重複移動,小盤子必須在大盤子上面。
問一共需要移動多少次,步驟是什麼?


7875864-81a83e448ff9d066.png!web

解決思路
讓我們從簡單的情況下開始考慮
首先我說明幾個數學符號,我自己瞎編的,為了方便表示問題,不用打那麼多字
A -> B
箭頭意思代表是從 A柱 移動到 B柱 ,每次移動都是移動最上面的那一塊

n = 1
只有一個盤子的時候,很簡單,直接移就是了,用數學符號記錄一下代表 A -> C

n = 2
如果有兩個盤子,就分為三步
1.先將最上面的盤子也就是(從下往上數)第2個盤子,先移動到B 記為 A -> B
2.然後將第一個盤子移動移動到C , A -> C
3.最後將 B柱子上的盤子 移動到 C柱 , B -> C

n = 3
如果有三個盤子,就分為7步,這裡就不打字了,太累,用數學符號表示

A -> C

A -> B

C -> B

A -> C

B -> A

B -> C

A -> C


7875864-ec46a6d9458206b8.jpg!web

額,不要擺出一副黑人問號臉...,自己隨便拿三個道具擺一擺就知道了
當你擺一擺的時候就知道,我知道了,一個盤子移動1次,二個盤子移動3次,三個盤子移動7次,四個盤子移動15次,N個盤子移動 (2^n - 1) 次!
恭喜你答對了!數學歸納法找規律不需多久就可以找到規律,但還有問題是究竟要怎麼移呢?... 以及用程式怎麼解決呢?
n = n....
抽象出這個問題
將問題抽象出來並且轉化為數學模型或者公式,是解決現實生活中複雜問題的一個很好的解決辦法,例如谷歌的翻譯,大家都覺得很智慧,自然語言的翻譯從20世界60年代就開始研究了,具體細節這裡不是重點,最後的解決思路是基於統計模型來解決的,也就是說,困擾了很多年的問題最後是抽象成一個概率論公式得以解決,事實上,眾多複雜的問題最後進行抽象其實就是幾個流程和公式而已,下面就以這個哈諾塔問題為例。

當有N個盤子的時候,似乎很難去想具體怎麼實現,既然這麼難想,就不用去想,首先這個問題,中有三個柱子,A,B,C,這裡也太具體了,抽象一下,怎麼抽象呢?假如這個問題只有2根柱子,你能完成嗎?廢話,肯定不行啊,我還需要一個柱子來輔助移動,所以這裡的A,B,C三個柱子就抽象成,起始柱,中間柱,目標柱,這裡用from,mid,to來表示

ok,現在是盤子的數量抽象成了n,柱子也抽象成了,from,mid,to 三種柱子,下面對過程進行抽象,將n個盤子從 from 柱子 移動到 to 柱 ,其實總體來看是三步

將n-1個盤子從 起始 柱 移動到 中間 柱
將第n個盤子從 起始 柱 移動到 目標 柱
將第n-1個盤子從 中間 柱 移動到 目標 柱 這裡你可能會說了,我靠,不是隻讓移動一個盤子的嗎,你這第1步和第3步移動了n-1個盤子啊...,對,所以我這裡的過程說的是抽象的過程,也就是說不管具體的實現細節是怎樣的,要達成所有的盤子都從A->C的效果,中間一定是有一步是達到這個效果的,就好比你從北京去紐約,假設只有一條國際航班,要經過巴黎,那我就可以說你從北京去紐約,只有兩步,第一步是去巴黎,第二步是從巴黎去紐約,這裡的道理是同樣的。

那麼現在將上面的第1步怎麼實現呢?同樣抽象,只要將n 替換成 n-1 即可,第三步也是同理

將n-2個盤子從 起始 柱 移動到 中間 柱
將第n-1個盤子從 起始 柱 移動到 目標 柱
將第n-2個盤子從 中間 柱 移動到 目標 柱

程式碼實現
那這個過程,用程式來抽象就是 一個Plate方法,接受四個引數,n,from,mid,to,四個引數的意思分別是
n 代表要移動幾個盤子
from 代表起始柱的名字
mid 代表藉助的中間柱的名字
to 代表目標柱的名字

方法的 作用是 將 n 個盤子 從 from 移動到 to
程式碼如下

/**
     * 將n個盤子從 from 移動到 to
     */
    public static void movePlate(int n, String from, String mid, String to) {
        /* 如果只有一個盤子,就直接從 from 移動到 to */
        if (n <= 1) {
            System.out.println(from + " -> " + to);
            return;
        }

        /* 1.將 n-1 個盤子 從 from 移動到 mid */
        movePlate(n - 1, from, to, mid);

        /* 2.將第 n 個盤子 從 from 移動到 to */
        System.out.println(from + " -> " + to);

        /* 3.將 n-1 個盤子 從mid 移動到 to */
        movePlate(n - 1, mid, from, to);
    }

你可以發現除去註釋,真正的程式碼只有5行,就將這個問題給解決了,再次提醒這裡的from , mid ,to 是形參,代表的是起始住,中間柱,和目標駐,不是具體的哪一個柱子,所以在第12行,因為第一步是將N-1個移動到中間柱,所以引數時from,to,mid,第18行將n-1個從中間柱移動到目標駐,所以引數時mid,from,to,中間的引數就是需要藉助的柱子。

下面測試一下程式碼,這裡根據題目把from,mid,to起個名分別是A,B,C,那執行這個方法就是將3個盤子從A移動到C

public static void main(String[] args) {
        movePlate(3, "A", "B", "C");
    }

n = 3 的時候

A -> C
A -> B
C -> B
A -> C
B -> A
B -> C
A -> C

Process finished with exit code 0

發現和上面人為思考的結果是一樣的哦

當n = 4的時候

A -> B
A -> C
B -> C
A -> B
C -> A
C -> B
A -> B
A -> C
B -> C
B -> A
C -> A
B -> C
A -> B
A -> C
B -> C

Process finished with exit code 0

一共是15步,也沒有問題,再多的我就不測了,有興趣的自己試試按照上面的列印結果來進行操作

推算次數

利用遞迴的方法同樣可以很容易的寫出計算次數的方法

public int countMovePlate(int n) {
        if (n <= 1) return 1;
        return countMovePlate(n - 1) + 1 +countMovePlate(n-1);
    }

那問題來了,還能優化嗎?

上文說到人為觀察,利用數學歸納法可以得出需要的次數是 (2^n - 1) 次,那麼這個數究竟是怎麼得到呢?
先把上面的程式複製下來,進行觀察

/* 1.將 n-1 個盤子 從 from 移動到 mid */
        movePlate(n - 1, from, to, mid);

        /* 2.將第 n 個盤子 從 from 移動到 to */
        System.out.println(from + " -> " + to);

        /* 3.將 n-1 個盤子 從mid 移動到 to */
        movePlate(n - 1, mid, from, to);

核心程式碼就三行,假設moveplate這個方法需要移動的次數為 (a_n) 次,那麼上面的這三行需要移動的次數就應該是 [ a_n = a_{n-1} + 1 + a_{n-1} ]

第一步是 (a_n) ,第二步是固定的1次,第三步又是 (a_n) ,然後當 n = 1的時候 (a_1 = 1) ,再總結整理一下就成了

[ a_n=\left{ \begin{aligned} 1, n = 1\ 2a_{n-1} + 1,n > 1 \end{aligned} \right. ]

有沒有夢迴高中的趕腳,這是一個很簡單的變形等比數列,我們讓兩邊都加上1

[ a_n + 1 = 2a_{n-1} + 1 + 1 ]

也就是

[ a_n + 1 = 2a_{n-1} + 2 ]

再提取一下

[ a_n + 1 = 2(a_{n-1} + 1) ]

兩邊都除以 ((a_{n-1} + 1))

於是就成了

[ \frac {a_n + 1}{a_{n-1} + 1} = 2 ]

那接著將這個公式一直寫豎式將他們相乘

[ \frac {a_n + 1} {a_{n-1} + 1} = 2 ]

[\frac {a_{n-1} + 1}{a_{n-2} + 1} = 2 ]

[\frac {a_{n-2} + 1}{a_{n-3} + 1} = 2 ]

[\vdots]

[\frac {a_2 + 1}{a_1 + 1} = 2 ]

接著約分, Markdown LaTex公式的刪除線找了半天都沒找到,知道的麻煩告知一下.

約分結果是

[\frac {a_n + 1}{a_1 + 1} = 2^{n-1} ]

接著將前面的 (a_1 = 1) 代入,於是

[\frac {a_n + 1}{2} = 2^{n-1} ]

再整理一下

[a_n = 2^n - 1 , n > 1]

所以說

[ a_n=\left{ \begin{aligned} 1, n = 1\ 2^n - 1 , n > 1 \end{aligned} \right. ]

將n =1 代入 n > 1 的情況,也是成立的,因此

[a_n = 2^n - 1 ]

所以經過推導之後java程式碼如下,因為涉及到 (2^n) 這種運算,可以使用移位符,這樣底層移動速度很快

程式碼如下

public static int countMovePlate(int n) {
        return n >= 1 ? (1 << n) - 1 : 0;
    }

結論

最終我們解決漢諾塔的移動順序與統計次數的程式碼如下,可以看出並不需要幾行程式碼就解決了問題

/* 列印出移動順序 */
    public static void movePlate(int n, String from, String mid, String to) {
        if (n <= 1) {
            System.out.println(from + " -> " + to);
            return;
        }
        movePlate(n - 1, from, to, mid);
        System.out.println(from + " -> " + to);
        movePlate(n - 1, mid, from, to);
    }
    
    /* 返回需要移動的次數 */
    public static int countMovePlate(int n) { return n >= 1 ? (1 << n) - 1 : 0;}

java學習交流群:669823128 禁止閒聊,非喜勿加

相關文章