河內塔(Tower of Hanoi)是一個很謎的數學遊戲,它是由三根桿子(Rod)和一個以上大小不同的碟片(Disk)所組成的。在遊戲的一開始,這些碟片按照底大頂小的順序疊在一起,由三根桿子中的其中一根串著。玩家每次可以移動一個碟片到其它的桿子上,但是不能將比較大的碟片疊在比較小的碟片上。當玩家把所有碟片都串到指定的桿子,遊戲就結束了。



tower-of-hanoi

如上圖所示,就是一個三碟的河內塔遊戲。習慣上會把最左邊的桿子稱作「A」,是起點。中間的桿子稱作「B」。右邊的桿子稱作「C」,是終點。

所以要完成上面的這個河內塔,就要把左邊的三個碟片移動到右邊。移動方式如下影片:

通常河內塔的遊戲至少會有三個碟片(如果碟片只有兩個以下那還玩個毛),碟片愈多難度愈高。如果您從來沒玩過的話,就算看了上面的影片,應該也還是不知道要怎麼移,或者不知道為什麼要那樣移動。

其實要完成一個河內塔遊戲,碟片的移動方式不只有一種而已。如果我們把影片中的碟片,以黃色、紅色、藍色的順序來表示其所在的桿子,一開始,我們可以用AAA來表示(黃色、紅色、藍色都在A桿子)。下一步,我們只能夠移動黃色碟片到B桿子或是C桿子,所以可以標示為BAA(黃色在B桿子,紅色、藍色在A桿子)或是CAA(黃色在C桿子,紅色、藍色在A桿子)。再下一步,可以移動黃色或紅色碟片到空的桿子上,所以如果這一步是BAA的話,下一步就是BCACAA;如果這一步是CAA的話,下一步就是CBABAA

讓我們依此類推,繼續用這樣的標示法來把所有的組合都找出來,會得到如下的結果:

              AAA
              / \
            BAA-CAA
            /     \
          BCA     CBA
          / \     / \
        CCA-ACA-ABA-BBA
        /             \
      CCB             BBC
      / \             / \
    ACB-BCB         CBC-ABC
    /     \         /     \
  ABB     BAB     CAC     ACC
  / \     / \     / \     / \
BBB-CBB CAB-AAB-AAC-BAC-BCC-CCC

如上,我們把三碟的河內塔所有的移動方式都畫成圖(Graph)了,每條邊所連接的兩個節點表示這兩個節點「只差了一步」。AAA作為我們的起點,CCC作為我們的終點,從上圖可以快速地看出起點到終點的最短路徑為7。換句話說,三碟的河內塔,最少只要移動7步就可以完成。

再仔細看一次上面的影片,可以發現影片中的碟片移動方式正是最短路線。而且我們還可以注意到一個點,那就是我們在把三個碟片移動到C桿子前,有先把兩個碟片移動到B桿子;而在兩個碟片移動到中間前,有先把一個碟片移動到C桿子。也就是說,若我們要移動#{{n}}#個碟片到某指定的桿子上,就要先把#{{n - 1}}#個碟片移動到旁邊的桿子上,此時最大的碟片就可以被移動到指定的桿子上,最後再把旁邊桿子上的#{{n - 1}}#個碟片移來最大的碟片上,#{{n}}#個碟片就都在指定的桿子上啦。

簡而言之,我們找出的「以最短步數解決河內塔問題的演算法」就是:

步驟一:移動A桿子上的#{{n - 1}}#個碟片到B桿子上。

tower-of-hanoi

步驟二:移動A桿子剩下的一個碟片到C桿子上。

tower-of-hanoi

步驟三:移動B桿子上的所有(#{{n - 1}}#個)碟片到C桿子上。

tower-of-hanoi

如何?有沒感覺像是在講屁話?可是電腦真的可以這樣做!

實際寫程式就知道了。首先建立一個hanoi函數,讓它可以透過參數輸入碟片的數量#{{n}}#、起始的桿子、結束的桿子和暫存的桿子。在程式一開始先去判斷碟片的數量是否為1,如果是的話就把碟片從起始的桿子移動到結束的桿子。我們不必真的建立出碟片的物件或是數值,因為河內塔的重點是在找碟片的移動方式。例如:A移到B→A移到C→B移到C。

可以先寫出以下的程式碼:

接著就要來套入我們剛才找出的演算法來計算#{{n > 1}}#時的情形了。首先是步驟一,要將起始的桿子上的#{{n - 1}}#個碟片移動到暫存的桿子。程式碼如下:

接著是步驟二,將起始的桿子上剩下來的一個碟片移動到結束的桿子上。程式碼如下:

最後是步驟三,移動暫存的桿子上所有的,也就是#{{n - 1}}#個碟片到結束的桿子上。程式碼如下:

至此我們就完成遞迴版本的河內塔函數了!很簡單吧!不過筆者當初接觸到河內塔的遞迴函數的時候,根本看不懂它在幹嘛,所以是直接把它背起來,應付考試而已。後來從頭開始釐清河內塔到底在幹什麼,自然就可以寫出遞迴函數了,完全不需要背。

另外我們根據一個碟片的河內塔最少要移動1次來完成、兩個碟片的河內塔最少要移動3次來完成、三個碟片的河內塔最少要移動7次來完成、四個碟片的河內塔最少要移動15次來完成,推測出#{{n}}#個碟片至少需要移動#{{2^n - 1}}#次來完成。利用數學歸納法的證明過程如下。

設#{{S_n}}#為#{{n}}#個碟片最少所需要的移動次數,而根據那三個步驟,我們知道#{{S_{n + 1} = S_n + 1 + S_n}}#。

#{{{
\begin{eqnarray}
\nonumber \\
\text{證明 } S_n &=& 2^n - 1, n \in \mathbb{N^+} \nonumber \\
\nonumber \\
n = 1, \nonumber \\
S_1 &=& 2^1 - 1 = 1 \text{ (成立)} \nonumber \\
\nonumber \\
假設 n = k, \nonumber \\
S_{k} &=& 2^k - 1 \nonumber \\
\nonumber \\
n = k + 1, \nonumber \\
S_{k + 1} &=& S_k + 1 + S_k \nonumber \\
&=& 2^k - 1 + 1 + 2^k - 1 \nonumber \\
&=& 2^k + 2^k - 1 + 1 - 1 \nonumber \\
&=& 2 \times 2^k - 1 \nonumber \\
&=& 2^{k + 1} - 1 \nonumber \\
\nonumber \\
\therefore S_n &=& 2^n - 1, n \in \mathbb{N^+} \text{ 是正確的}
\end{eqnarray}
}}}#

程式寫出來了,但還是不知道怎麼玩河內塔……

即便理解河內塔主要想展示的思想(大問題分解成小問題,也就是遞迴啦),在現實中使用教具或是桌遊玩具玩河內塔,甚至是玩河內塔的電子遊戲時,碟片數量超過三個的時候還是不知道該如何下手啊!畢竟人腦不像電腦那樣,可以快速以遞迴的方式來思考問題。

所以我們必須要再想個「迭代」的方式來解決河內塔的問題。

在開始想之前,可以先看看下面這篇文章,將遞迴版本的河內塔直接轉成迭代版本。

https://magiclen.org/recursive-to-iterative/

轉出來的程式如下:

以上的河內塔函數,雖然已經是迭代版本了沒錯,但骨子裡還是用遞迴(或者更準確來說是Top-down)的方式在算的,看看就好。

其實多玩幾次三個碟片的河內塔和四個碟片的河內塔(或是也可以玩兩個碟片,如果您真的不太會玩河內塔的話),就可以發現如果要用最少的步數來完成河內塔的話,三個碟片和四個碟片分別在一開始,最小的碟片必須要移動往不同的桿子。像是三個碟片的河內塔,第一步就必須要先將最小的碟片從A桿移到C桿;而四個碟片的河內塔,第一步就必須要先將最小的碟片從A桿移到B桿。這是為了要確保#{{n - 1}}#個碟片在移動之後,是在暫存的桿子上,而不是在結束的桿子上,好讓我們能把起始的桿子上的最大的碟片移動到結束的桿子。若#{{n - 1}}#個碟片在移動之後,是在結束的桿子上,那我們也就只能再把#{{n - 1}}#個碟片搬到暫存的桿子上後,才能把起始的桿子上的最大的碟片移動到結束的桿子。

也就是說,河內塔第一步最小的碟片移動的方式,要根據碟片的數量是奇數還是偶數來決定。如果是三個碟片的河內塔,那第一步就是把最小的碟片從A桿移到C桿;如果是四個碟片的河內塔,那第一步就是把最小的碟片從A桿移到B桿。

若您還不了解的話,試想一下,若我們玩四個碟片的河內塔,第一步是把最小的碟片從A桿移到C桿的話,那不就跟在玩三個碟片的河內塔時一樣,就會先把三個碟片移動到C桿了,然後又得把那三個碟片再移到B桿上,才能將A桿上最大的碟片移到C桿,所以這對四個碟片的河內塔遊戲來說也就不會是一個最佳的解決方式。

所以知道第一步要怎麼移之後呢?我們都知道河內塔的碟片只是大的在下、小的在上,因此最小的碟片在被移動之後,勢必得趕緊移回第二小的碟片之上,不然任何其它的碟片都無法使用最小的碟片所在的桿子。當然,我們不會在移動完最小的碟片後,下一步又把它移回去,或者又把它移動到另一個桿子上,因為這樣一點意義都沒有。也就是說,我們合理能做的第二步,就是將第二小的碟片移動到另一個桿子上,然後在第三步的時候,把最小的碟片移到第二小的碟片所在的桿子上。此時就完成了兩個碟片的移動。

再來要進行第四步的時候,我們就會發現,除了將最小的碟片又移動到其它桿子上外,其實也只剩下一種移動方式。在此時若將最小的碟片又移動到其它桿子上,就等同於又要開始進行最小和第二小的碟片的移動,沒有意義。所以我們也只能夠選擇剩下的那一種移動方式。

第五步時,我們不考慮去還原第四步,就只會剩下一種移動方式,那就是花費三步(第五步、第六步、第七步)去移動最小和第二小的碟片。

第八步時,同理,除了將最小的碟片又移動到其它桿子上外,其實也只剩下一種移動方式。

聰明的讀者們大概都看得出來這其中的規律了吧!也就是「四步一循環」:移動最小的碟片→移動第二小的碟片→移動最小的碟片到第二小的碟片上→移動其它的碟片。

現在的問題是在於,「移動最小的碟片」時,該碟片因為最小,所以另外兩個桿子都可以成為目標,但除了第一步我們知道最小的碟片究竟要移動到哪個桿子之外,第五步、第九步……到達這些後續的步數時,就不知道該怎麼移動了。

事實上,如果我們觀察河內塔最短步數的移動方式,會在「移動最小的碟片」的步驟時,發現它們總是往同一個「方向」移動一個距離。以三個碟片的河內塔來說,一開始將最小的碟片從A桿移到C桿,看作是「向左」(A桿子向左會到C桿子,形成循環);而四個碟片的河內塔,一開始將最小的碟片從A桿移到B桿,看作是「向右」(C桿子向右會到A桿子,形成循環)。找出這個「向左」、「向右」規律後,我們可以擴充一下我們剛剛發現的「四步一循環」規則:

  • 0. 判斷碟片的數量是奇數還是偶數,來決定最小的碟片和第二小的碟片移動時的方向。偶數往右;奇數往左。
  • 1. 移動最小的碟片一個桿子的距離。
  • 2. 移動第二小的碟片兩個桿子的距離。
  • 3. 移動最小的碟片一個桿子的距離。(將最小的碟片移動到第二小的碟片上。)
  • 4. 移動其它的碟片。

如此一來在遊玩河內塔的時候,我們可以在心中默念「一、二、三、四」,按照步驟來解決河內塔,完全不需要動腦。

總算會玩了吧!那用這樣的方式寫程式來實作一個迭代版本的河內塔函數如何?程式如下:

以上程式,將「向左」、「向右」的問題以交換除了起始的桿子之外的兩個桿子來簡化。試想一下,向右的順序如果是ABCABC,那麼向左就是ACBABC,這不就是把B、C兩個桿子交換了嗎?

再來,一個循環的第四步如果我們不知道當下碟片的分佈情形,就不知道要怎麼移動了(以上程式先以Move a disk from ? to ?.來輸出)。所以我們必須在每步都去改變並記錄碟片的分佈情形才行,可以用一個堆疊來模擬一個桿子,三個桿子就是三個堆疊。將以上程式加入堆疊後的樣子如下:

以上程式,建立了三個堆疊來儲存碟片(數值愈小的碟片尺寸愈大。0表示桿子的底部,以一個尺寸最大的碟片來模擬,所以移不走)。再來就是要處理每個循環的第四步,碟片要怎麼移動了。其實這個很簡單,我們只需判斷前兩小的碟片所在的另外兩個桿子,看它們頂部儲存的數值哪個比較大,就從頂部數值比較大的桿子移到頂部數值比較小的桿子。

所以最終的程式碼如下:

這邊再介紹另一個河內塔迭代版的演算法,現實中在玩河內塔的時候也可以使用,但是會比較燒腦一點。

如果仔細觀察河內塔最短路徑的解,可以發現一個「三步一循環」的規律,而且有區分偶數和奇數的碟片數量。偶數就是「AB→AC→BC」,奇數就是「AC→AB→BC」。

什麼意思?

如果碟片數量為偶數,在這三步組成的循環中,第一步我們只需要去看A、B這兩個桿子,看是要把碟片從A桿移到B桿,還是要反過來從B桿移到A桿;第二步只需要去看A、C這兩個桿子,看是要把碟片從A桿移到C桿,還是要反過來從C桿移到A桿;第三步只需要去看B、C這兩個桿子,看是要把碟片從B桿移到C桿,還是要反過來從C桿移到B桿。

與先前同樣地,若要以程式來實作,當碟片數量為奇數時,可以先去交換B、C兩個桿子,這樣一來「三步一循環」的規律就只剩下「AB→AC→BC」了。所以可以寫出以下迭代版本的河內塔函數:

不用堆疊空間的河內塔迭代演算法

回顧我們一開始介紹的遞迴版本的河內塔演算法:

步驟一:移動A桿子上的#{{n - 1}}#個碟片到B桿子上。

tower-of-hanoi

步驟二:移動A桿子剩下的一個碟片到C桿子上。

tower-of-hanoi

步驟三:移動B桿子上的所有(#{{n - 1}}#個)碟片到C桿子上。

tower-of-hanoi

其實要把已知步驟的#{{n}}#個碟片從一個桿子搬到另一個桿子上是很容易的。舉例來說,若要把三個碟片從A桿搬到C桿,搬移步驟如下:

Move a disk from A to C.
Move a disk from A to B.
Move a disk from C to B.
Move a disk from A to C.
Move a disk from B to A.
Move a disk from B to C.
Move a disk from A to C.

而若要把三個碟片從A桿搬到B桿,搬移步驟如下:

Move a disk from A to B.
Move a disk from A to C.
Move a disk from B to C.
Move a disk from A to B.
Move a disk from C to A.
Move a disk from C to B.
Move a disk from A to B.

有沒有發現?其實我們只要把從A桿搬到C桿的路線中的B、C桿子交換就可以了。

而若要把三個碟片從B桿搬到C桿,搬移步驟如下:

Move a disk from B to C.
Move a disk from B to A.
Move a disk from C to A.
Move a disk from B to C.
Move a disk from A to B.
Move a disk from A to C.
Move a disk from B to C.

有沒有發現?其實我們只要把從A桿搬到B桿的路線中的A換成B、B換成C、C換成A。

所以呢?

根據這個發現,要把#{{n}}#個碟片從A桿搬到C桿,我們可以先將#{{n - 1}}#個碟片搬到C桿(可沿用計算#{{n - 1}}#個碟片時找出的解),接著再把B、C桿子交換(利用A桿到C桿的路線,把B、C桿子交換),然後再把剩下的一個碟片從A桿搬到C桿,最後再把B桿的#{{n - 1}}#個碟片搬到C桿(利用A桿到B桿的路線,把A換成B、B換成C、C換成A)。

為了快速地將路線進行置換,我們可以把六種(#{{P^3_2 = 6}}#)移動方式編號如下,稱作「路線表」好了。

  • 0: A→B
  • 1: A→C
  • 2: B→A
  • 3: B→C
  • 4: C→A
  • 5: C→B

把「路線表」的B、C桿子交換,並依照原來A→B、A→C、B→A、……的方式來排序,結果如下:

  • 1: A→B
  • 0: A→C
  • 4: B→A
  • 5: B→C
  • 2: C→A
  • 3: C→B

這樣做可以讓我們利用原先路線的編號,快速地對應到把B、C桿子交換後的結果。

把「路線表」的A換成C、C換成B、B換成A,並依照原來A→B、A→C、B→A、……的方式來排序,結果如下:

  • 3: A→B
  • 2: A→C
  • 5: B→A
  • 4: B→C
  • 0: C→A
  • 1: C→B

這樣做可以讓我們利用原先路線的編號,快速地對應到把A換成B、B換成C、C換成A後的結果。

再來我們就可以實作出不需堆疊空間(但需要暫存結果)的河內塔迭代函數:

讓程式能輸出碟片編號

為了讓各位更容易了解,上面提供的程式碼都沒有輸出究竟是哪個碟片被移動了。所以最後就來提供一下,這些程式碼的「完整版」吧!

首先是遞迴版本的河內塔。(碟片數字愈小,尺寸愈小)

再來是「四步一循環」與「三步一循環」的堆疊空間迭代版所用的move_diskmoveDisk函數。(碟片數字愈小,尺寸愈大)

最後是無堆疊空間的迭代版河內塔。(碟片數字愈小,尺寸愈小)

利用碟片的輸出規律來實現不用堆疊空間的河內塔迭代版函數

在實作出有輸出碟片編號的河內塔程式後,可以試玩一下,會發現輸出的碟片編號其實也有規律。

如果我們只讓河內塔程式輸出碟片編號,四個碟片的結果如下:

1  2  1  3  1  2  1  4  1  2  1  3  1  2  1

五個碟片的結果如下:

1  2  1  3  1  2  1  4  1  2  1  3  1  2  1  5  1  2  1  3  1  2  1  4  1  2  1  3  1  2  1

六個碟片的結果如下:

1  2  1  3  1  2  1  4  1  2  1  3  1  2  1  5  1  2  1  3  1  2  1  4  1  2  1  3  1  2  1  6  1  2  1  3  1  2  1  4  1  2  1  3  1  2  1  5  1  2  1  3  1  2  1  4  1  2  1  3  1  2  1

看出來了嗎?這擺明就是一個數列嘛!而且可以找出以下公式來直接求得指定項數的值。

#{{{
a_n = \lfloor log_2{(n \text{ } XOR \text{ } (n + 1))} \rfloor + 1, n \in \mathbb{N^0}
}}}#

也就是說,我們先前利用「四步一循環」和「三步一循環」規律來實作的迭代版的河內塔函數,可以改成無堆疊空間的版本,只需要額外記錄當下的所有碟片在哪個桿子上即可,而且也可以順便將它們修改成以「碟片數字愈小,尺寸愈小」的方式來輸出。

將「四步一循環」修改後的程式碼如下:

將「三步一循環」修改後的程式碼如下: