這題的主文使用英文,因此這邊附上個人大致的翻譯,不會完全按照原文翻:
內容:
河內塔問題在電腦科學是有名的問題之一,簡單說就是有3根柱子,在A柱上有大小若干圓盤,要將全部圓盤從A柱藉由B柱這暫存柱移到C柱,移動過程大圓盤都不能在小圓盤上面,目標是求移動的最小次數。
初學者在圓盤只有3個或4個時可以很輕鬆就了解其移動過程,使用簡單的程式也能找出更多圓盤數的移動過程,這裡我們讓題目增加一點靈活性來提升難度,題目一開始的圓盤可以在任一柱上。
題目一開始時若某柱有2個以上的圓盤,那一定是「合法」的順序(即較大的圓盤一定在較小的圓盤下面),
題目將給你2種圓盤狀態,你的任務是找出從初始狀態到結束狀態時移動圓盤的最少次數,想當然移動過程必須符合大圓盤都不會壓在小圓盤上面。
輸入說明:
輸入測資有多筆測資點,每筆測資點有3行
第1行為一個正整數 n,1 <= n <= 30,為圓盤的數量
第2行及第3行分別為圓盤的初始狀態與結束狀態,每個狀態有 n 個數字,其值為 1、2、3 任一個,分別代表該圓盤放在A柱、B柱、C柱,舉例來說若第 i 個圓盤 ( 1 <= i <= n ) 的值為 1,代表第 i 個圓盤在A柱,例如以下測資:
4
1 1 1 1
1 2 2 1
代表這題有4個圓盤,初始狀態第1、2、3、4號圓盤都在A柱,結束狀態第1、4號圓盤在A柱;第2、3號圓盤在B柱。(圖略)
輸入測資為 0 代表結束,請不要讓程式做任何操作。
輸出說明:
每筆測資點請輸出一個數字(一行一個),代表河內塔從初始狀態到結束狀態時所需要的最少移動次數。
-----------------------------------------------------------------------------------------------------------------------------------
在初次學習各種程式語言的時候,只要到遞迴的部分一定會學到河內塔
所以基礎的部分這邊完全不會說,如果還沒學到基礎版,請先去學習了解後再看這篇
由於範例太簡單,難以說明我的思路歷程,以下是我自己想的範例
9
1 2 3 1 2 3 1 2 3
1 1 1 2 2 2 3 3 3
範例有9個圓盤,初始狀態如下:
而目標狀態如下:
這題我在網上搜尋時有看見使用DP(動態規劃)的技巧來解題,但我看好幾遍都看不懂,所以決定自製複雜範例,並一步一步去構築整個程式架構,最終使用遞迴來解。
一:思路歷程
原本的河內塔玩法是將A柱上的所有圓盤都移到C柱,而這題則是指定初始狀態與目標狀態,不論狀態如何,大圓盤一定不能放在小圓盤上,那如果大圓盤要移動到它的目標,這個大圓盤一定只能是這根柱子的頂端盤,並且目標柱上不能有比這個大圓盤還小的盤子,換句話說,將大圓盤從當前所在柱移動到目標柱,比它小的所有圓盤都只能放在沒使用到的第3根柱子上。
例如這題範例,最大號盤為9號,它剛好不用移動,因此我們可以完全忽略它。那我們就從8號開始,它要從B柱移動到C柱,那麼1~7號盤都必須離開B柱與C柱,才能移動8號盤並且不會讓8號盤放在這些小盤上,那唯一的方法就是把1~7號盤集中到A柱,這樣才能搬8號,如同一般河內塔玩法。當1~7號都在A柱,那它為了符合規則,一定會形成一座完整、編號連續的塔,當8號移動過去之後,接下來第3大的盤子是7號,它要搬到C柱,此時只要1~6號搬到B柱,7號就能從A柱移動到C柱,依此類推。
由上可知,把大的圓盤移動到目標,在整理過程較小的圓盤一定會形成完整子塔,在移動完大圓盤後,就把自然形成的完整塔由大到小逐一安排進圓盤各自目標,因此主程式我們使用一個迴圈,從最大號圓盤開始,比對圓盤的當前位置與目標位置,如果位置相同代表不用移動,直接檢查小一號圓盤,但如果位置不同,就呼叫另一個方法遞迴處理。
for(i=n ; i>0 ; i--) { if(當前柱 == 目標柱){ continue; } // 移動第i盤 } |
只要移動了最大盤,剩餘小盤一定會形成完整子塔,那我們就需要一個監控的 boolean 變數,這樣進入下一個迴圈後,完整塔的最大盤想要移動,只要使用原本的河內塔解法 "搬走" 上方的子盤,最大盤再移動一步就搞定了。
firstArbitrarilyToSubTower = false;//一個監控"初次調整"的變數 for(i=n ; i>0 ; i--) { if(當前柱 == 目標柱){ continue; } // 移動第i盤 if(!firstArbitrarilyToSubTower){ //如果還沒有"初次調整",需要遞迴處理 } else { //只要調整過第一次,一定形成完整的塔 //那只要使用一般河內塔解法,就能求得最少步數 } } |
二:程式實作細節
(一) 首先建立2個 int 陣列,第一個陣列power2[]儲存2的次方的數字,第二個陣列plates[]儲存盤子,這裡的盤子我使用一個class Disc來生成,每個Disc物件代表一個盤子,記錄自己的當前柱(now)與目標柱(goal)。
(二) 接著讀取測資,在儲存盤子的位置時為了讓盤子編號與陣列索引一一對應,儲存時索引從1開始。
(三) 接下來建立變數,int ans 代表這題答案,int temp 代表暫存柱,int subTower 代表 1~subTower 的完整子塔,接著初始化變數值,其中 subTower 進行如下處理:
ans = 0; subTower = 1; while(subTower < 測資最大值 && plates[subTower].now == plates[subTower+1].now) { subTower++; } |
這裡意思是如果編號 subTower的盤子與編號 subTower+1 的盤子在相同柱子上,把 subTower 加一,即把大一號的盤子納入子塔,例如 1號與 2號在A柱,subTower++後變成2,再比較2號與3號,直到條件不符才離開迴圈。
(四) 接著建立迴圈如上,從最大盤開始檢查,我們就用剛剛的範例,此時 n 等於 9,9號因為不用移動
直接跳過,從8號繼續。
(五) 8號因為當前柱(B)與目標柱(C)不同,必須移動,因此我們要求出1~7號的暫存柱 temp,補充一下,這裡將柱子編號使用數字儲存,令 A柱 = 1、B柱 = 2、C柱 = 3,求出
temp = 6 - plates[i].now - plates[i].goal,因為3柱編號加起來等於6,用6扣掉盤子當前柱與目標柱就是暫存柱編號,1~7號盤的暫存柱 temp = 6-2-3 = 1,即A柱,接著遞迴求解:
if(!firstArbitrarilyToSubTower) { if(subTower > i-1) subTower = i-1 ;
ans += hanoiArbitrarily(i-1, temp, (subTower == i-1) ); firstArbitrarilyToSubTower = true; }else { if(i!=1) { for(j=i-1 ; j>0 ; j--) plates[j].moveTo(plates[i].now); //這裡等同於 plates[j].now = plates[i].now; ans += hanoiArbitrarily(i-1, temp, true); } } |
還沒初次調整前,可能就已經有完整子塔了,例如以下範例:
41 1 1 1 <- 此時1~4號都在A柱,完整子塔 subTower 等於 4,
1 2 2 1 <- 迴圈處理3號盤時,變更 subTower 值為 2
判斷是否存在完整子塔,可以直接使用一般河內塔解 2^n-1 來加速程式求解,因此"初次調整"前,
(subTower == i-1) 判斷完整子塔的存在。回到剛剛範例,8號盤要移動,將1~7號盤移動到暫存柱,但是目前還不知道要移動幾步,因此呼叫 hanoiArbitrarily(),這方法輸入要調整的子盤數、暫存柱編號、以及是否完整子塔作為參數,回傳最少步數作為答案,由 ans 獲得答案,設定"初次調整"為 true。下一個迴圈開始要處理的當前盤子與其子盤一定是完整子塔,為了避免求解時因為子盤位置錯誤而求出錯誤答案,在else 跑一個for迴圈設定子盤位置與當前盤位置相同,設定完就可以呼叫 hanoiArbitrarily()方法求解了。
(六) 調整完子盤位置,最後別忘了,當前迴圈的盤子要移動,我們只要一行程式:ans++ ;
for(i=n ; i>0 ; i--) { if(plates[i].now == plates[i].goal) { ...... } temp = 6 - plates[i].now - plates[i].goal; if(!firstArbitrarilyToSubTower) { ...... }else { ...... } ans++; } |
剛剛範例,將 8號盤移動到C柱,答案+1,下一個迴圈之後就不會再移動8號了,我們就不用管他的當前位置。接著調整7號盤,剛剛8號從B柱移到C柱,7號現在在A柱,它目標是C柱,因此1~6號要到B柱。搬完7號之後河內塔變成如下:
迴圈進行到6、5、4號時,因為這些盤子都不用移動,所以跳過,而跳過之前要設定子盤位置與當前盤位置相同:
if(plates[i].now == plates[i].goal) { if(firstArbitrarilyToSubTower && i!=1) { for(j=i-1 ; j>0 ; j--) plates[j].moveTo(plates[i].now); } continue; }
|
一樣是避免後面求解發生錯誤,最後剩下的3、2、1號盤都移動到A柱,答案就出來了。
(七) 主要部分處理完了,接著是遞迴的部分,剛剛在移動8號盤之前我們要求1~7號盤移到暫存柱的答案,
呼叫 hanoiArbitrarily(7, 1, (subTower == 7) ) 開始遞迴(subTower是1),首先是遞迴式的停止條件:
public static int hanoiArbitrarily(int subDiscs, int target, boolean isSubTower) { if(subDiscs == 0) return 0; if(subDiscs == 1) { if(plates[1].now != target) { plates[1].moveTo(target); return 1; } else return 0; } ...... |
如果調整子盤 subDiscs 等於 0,代表呼叫方法時只剩1個盤子,不用調整,
如果調整子盤 subDiscs 等於 1,代表呼叫方法時只剩2個盤子,1號盤可能在或不在要過去的暫存柱。
停止條件說完了,接下來是核心流程:
(1) 1~7號盤需要移到暫存柱 target,對這些盤子而言就是要從當前位置 now 移動到 target,但是不同測資下這些盤子可能在同一柱上也可能在不同柱上,因此需要 isSubTower 判斷,如果是 true,就可以使用一般解 2^ subDiscs -1,如果是 false,往下一個 if 判斷。這裡 isSubTower 由上可知是 false。
(2) 1~7號盤要移動,初始位置各不相同,但最大盤 7號要從"當前"柱(now)移動到"目標"柱(target),其餘1~6號一定要移到剩下的"暫存"柱(temp),那對於7號而言,1~6號要先"搬走",7號才能"移動一步",然後1~6號才能"搬回",因此總移動步數 = "搬走" + "1" + "搬回",對於"搬走"我們既然仍不知道答案,就繼續遞迴,然後7號"移動一步",最後"搬回"時1~6號一定是完整子塔,套用一般解即可。
(3) 但是題目要求"最少"步數,能不搬就不搬,在搬7號盤之前先判斷7號要不要移動,如果
plates[7].now 不等於 target,我們才需要"搬走" 跟 "搬回",反之如果相等,7號盤此時只要遞迴呼叫方法,接收1~6號盤從各自位置搬到 target 的答案。這裡的範例從開始到現在都還沒搬任何盤子,因此此時的 7號盤的 now = 1 (A柱),而 target 也是 1,所以我們直接呼叫 hanoiArbitrarily(6, 1, false); 等答案。
int steps = 0, substep = 0; int temp = 0; if(isSubTower) { ...... } else if(plates[subDiscs].now != target) { temp = 6 - plates[subDiscs].now - target ; substep = hanoiArbitrarily(subDiscs-1, temp, isSubTower); // "搬走" plates[subDiscs].moveTo(target); // "一步" steps = substep + 1 + (power2[subDiscs-1]-1); // 最後是"搬回" } else { steps = hanoiArbitrarily(subDiscs-1, target, isSubTower); } return steps;
|
(八) 遞迴部分大致上處理好了,if( isSubTower ) 這裡還需要單獨拉出來說明:
(1) 因為測資有任意可能的狀態,在迴圈裡單純判斷 (subTower == i-1) 會有盲點,例如以下測資:
4
1 1 1 2
1 1 1 3
這題在迴圈判斷,(subTower == 3) 結果是 true,而另一個測資:
4
1 1 1 1
1 1 1 2
(subTower == 3) 結果也是 true,但很明顯第一個測資答案 = 1,第二個測資答案一定不是 1,因此在遞迴方法的 if( isSubTower ) 部分,使用以下的判斷:
if(isSubTower) { boolean isfinish = true; for(int i=subDiscs ; i>0 ; i--) { if(plates[i].now != target) { isfinish = false; break; } } if(isfinish) { steps = 0; } else { plates[subDiscs].moveTo(target); steps = power2[subDiscs]-1; } } else if(plates[subDiscs].now != target) { ...... |
主迴圈第一次呼叫遞迴方法時,是
尚未"初次調整"狀態,但
不代表完整子塔不存在,完整子塔可能
在或
不在 target 柱上,上面第一個範例,4號從B柱移動到C柱前先遞迴呼叫1~3號,程式到 if( isSubTower ) 部分時,先確認1~3號是否已經在 target 柱(A),因為1~3號已經在A柱上了,所以不用移動;第二個範例,4號從A柱移動到B柱前先遞迴呼叫1~3號,程式到 if( isSubTower ) 部分時,先確認1~3號是否已經在 target 柱(C),此時1~3號都在A柱,那只要將3號盤從A柱搬到C柱,直接套公式解 2^3-1,子塔就調整完畢。
(1、2號不用設定新位置,因為下一個迴圈處理3號時會修正他們的位置)
三:範例流程
還是使用一開始我舉的範例跑整個流程:
9
1 2 3 1 2 3 1 2 3
1 1 1 2 2 2 3 3 3
(一) 主迴圈 i = 9:因為 plates[9] 的 now(C) == goal(C),跳過處理 (continue ),答案 ans = 0
(二) 主迴圈 i = 8:因為 plates[8] 的 now(B) != goal(C),要移動,求出 temp = 6 - 2 - 3 = 1,遞迴呼叫(a)
(1) hanoiArbitrarily(7, 1, false) ---(a)
首先1~7號盤不是完整子塔,接著 plates[7] 的 now(A) == target (A),遞迴呼叫(b)等答案
(2) hanoiArbitrarily(6, 1, false) ---(b)
1~6號盤不是完整子塔,plates[6] 的 now(C) != target (A),要移動,
求出 temp = 6 - 3 - 1 = 2,先"搬走",遞迴呼叫(c)等答案
(3) hanoiArbitrarily(5, 2, false) ---(c)
1~5號盤不是完整子塔,plates[5] 的 now(B) == target (B),遞迴呼叫(d)等答案
(4) hanoiArbitrarily(4, 2, false) ---(d)
1~4號盤不是完整子塔,plates[4] 的 now(A) != target (B),要移動,
求出 temp = 6 - 1 - 2 = 3,先"搬走",遞迴呼叫(e)等答案
(5) hanoiArbitrarily(3, 3, false) ---(e)
1~3號盤不是完整子塔,plates[3] 的 now(C) == target (C),遞迴呼叫(f)等答案
(6) hanoiArbitrarily(2, 3, false) ---(f)
1~2號盤不是完整子塔,plates[2] 的 now(B) != target (C),要移動,
求出 temp = 6 - 2 - 3 = 1,先"搬走",遞迴呼叫(g)等答案
(7) hanoiArbitrarily(1, 1, false) ---(g)
此時 subDiscs 等於 1,因為 plates[1] 的 now(A) == target (A),不用移動,回傳答案 0 給 (f)
(8) 回到(f),"搬走" = 0,移動 2號到 target (C),總步數為 0 + 1 + ( 2^(2-1) -1 ) = 2
回傳答案 2 給 (e)
(9) 回到(e),此時 1~3號已到 C柱,回傳答案 2 給 (d)
(10) 回到(d),"搬走" = 2,移動 4號到 target (B),總步數為 2 + 1 + ( 2^(4-1) -1 ) = 10
回傳答案 10 給 (c)
(11) 回到(c),此時 1~5號已到 B柱,回傳答案 10給 (b)
(12) 回到(b),"搬走" = 10,移動 6號到 target (A),總步數為 10 + 1 + ( 2^(6-1) -1 ) = 42
回傳答案 42 給 (a)
(13) 回到(a),此時 1~7號已到 A柱,回傳答案 42
( i = 8 )
答案 ans += 42,"初次調整" 設為 true,移動 8號,ans ++,迴圈結束
(三) 主迴圈 i = 7:(現在1~7號都在 A柱),
因為 plates[7] 的 now(A) != goal(C),要移動,求出 temp = 6 - 1 - 3 = 2,已"初次調整",設定1~7號盤當前位置為 A柱,遞迴呼叫(a1)
(1) hanoiArbitrarily(6, 2, true) ---(a1)
1~6號盤是完整子塔,1~6號都在 A要移動,移動 6號到 B,答案為 2^6 -1 = 63,回傳答案 63
( i = 7 )
答案 ans += 63,移動 7號,ans ++,迴圈結束
(四) 主迴圈 i = 6:(現在1~6號都在 B柱),
因為 plates[6] 的 now(B) == goal(B),不用移動,已"初次調整",設定1~5號盤當前位置為 B柱,迴圈結束
(五) 主迴圈 i = 5:(現在1~5號都在 B柱),
因為 plates[5] 的 now(B) == goal(B),不用移動,已"初次調整",設定1~4號盤當前位置為 B柱,迴圈結束
(六) 主迴圈 i = 4:(現在1~4號都在 B柱),
因為 plates[4] 的 now(B) == goal(B),不用移動,已"初次調整",設定1~3號盤當前位置為 B柱,迴圈結束
(七) 主迴圈 i = 3:(現在1~3號都在 B柱),
因為 plates[3] 的 now(B) != goal(A),要移動,求出 temp = 6 - 2 - 1 = 3,已"初次調整",設定1~2號盤當前位置為 B柱,遞迴呼叫(a2)
(1) hanoiArbitrarily(2, 3, true) ---(a2)
1~2號盤是完整子塔,1~2號都在 B要移動,移動 2號到 C,答案為 2^2 -1 = 3,回傳答案 3
( i = 3 )
答案 ans += 3,移動 3號,ans ++,迴圈結束
(八) 主迴圈 i = 2:(現在1~2號都在 C柱),
因為 plates[2] 的 now(C) != goal(A),要移動,求出 temp = 6 - 3 - 1 = 2,已"初次調整",設定1號盤當前位置為 C柱,遞迴呼叫(a3)
(1) hanoiArbitrarily(1, 2, true) ---(a3)
此時 subDiscs 等於 1,因為 plates[1] 的 now(C) != target (B),要移動,移動 1號到 target (B)
回傳答案 1
( i = 2 )
答案 ans += 1,移動 2號,ans ++,迴圈結束
(九) 主迴圈 i = 1:(現在1號在 B柱),
因為 plates[1] 的 now(B) != goal(A),要移動,求出 temp = 6 - 2 - 1 = 3,已"初次調整",因為 i == 1,結束 if,答案 ans ++,迴圈結束
主迴圈結束,輸出答案 ans = 0 + (42+1) + (63+1) + 0 + 0 + 0 + (3+1) + (1+1) + 1 = 114
四:完整程式
上面的範例流程一定很難懂,所以在看的過程請配合下面程式
import java.io.*; public class b592{ static BufferedReader buf = new BufferedReader(new InputStreamReader(System.in)); static final int DISC_MAX = 31;// DISC_MAX : 測資最大盤子數+1 static Disc_b592[] plates = new Disc_b592[DISC_MAX]; static int[] power2 = new int[DISC_MAX]; public static void main(String[] args) throws IOException { PrintWriter prt = new PrintWriter(new BufferedOutputStream(System.out)); int i, j; for(i=0 ; i < DISC_MAX ; i++) { plates[i] = new Disc_b592(-1, -1); power2[i] = 1 << i; } int n = nextInt(); int ans, temp, subTower; // subTower: 由 1~subTower的完整子塔,盤子連續無缺 boolean firstArbitrarilyToSubTower; // 經過第一次調整後存在最大子塔 => true while(n > 0) { for(i=1 ; i<=n ; i++) plates[i].now = nextInt(); for(i=1 ; i<=n ; i++) plates[i].goal = nextInt(); ans = 0; subTower = 1; // subTower要小於最大值減一,因最大值是測資最大盤子數加一 while(subTower < DISC_MAX-1 && plates[subTower].now == plates[subTower+1].now) { subTower++; } firstArbitrarilyToSubTower = false; for(i=n ; i>0 ; i--) { if(plates[i].now == plates[i].goal) { if(firstArbitrarilyToSubTower && i!=1) { for(j=i-1 ; j>0 ; j--) plates[j].moveTo(plates[i].now); } continue; } temp = 6 - plates[i].now - plates[i].goal; if(!firstArbitrarilyToSubTower) { if(subTower > i-1) subTower = i-1 ; ans += hanoiArbitrarily(i-1, temp, (subTower == i-1) ); firstArbitrarilyToSubTower = true; }else { if(i!=1) { for(j=i-1 ; j>0 ; j--) plates[j].moveTo(plates[i].now); ans += hanoiArbitrarily(i-1, temp, true); } } ans++; } prt.println(ans); n = nextInt(); } prt.flush(); } public static int hanoiArbitrarily(int subDiscs, int target, boolean isSubTower) { if(subDiscs == 0) return 0; if(subDiscs == 1) { if(plates[1].now != target) { plates[1].moveTo(target); return 1; } else return 0; } int steps = 0, substep = 0; int temp = 0; if(isSubTower) { boolean isfinish = true; for(int i=subDiscs ; i>0 ; i--) { if(plates[i].now != target) { isfinish = false; break; } } if(isfinish) { steps = 0; } else { plates[subDiscs].moveTo(target); steps = power2[subDiscs]-1; } } else if(plates[subDiscs].now != target) { temp = 6 - plates[subDiscs].now - target ; substep = hanoiArbitrarily(subDiscs-1, temp, isSubTower); plates[subDiscs].moveTo(target); steps = substep + 1 + (power2[subDiscs-1]-1); } else { steps = hanoiArbitrarily(subDiscs-1, target, isSubTower); } return steps; } public static int nextInt() throws IOException { int c = buf.read(); if(c == -1) return -1; while(c <= 32) c = buf.read(); int ans = 0; while(c > 32) { ans = ans*10 + c-'0'; c = buf.read(); } return ans; } }
class Disc_b592 { int now; int goal; Disc_b592(int now, int goal){ this.now = now; this.goal = goal; } public void moveTo(int target) { now = target; } } |
----------------------------------------------------------------------------------------------------------------------------------------我在建構程式流程時,腦袋很容易被混亂河內塔狀態搞暈,要親手把範例流程畫在紙上,一步一步去思考,從迷霧中探索道路,我的方法可能不是最好的,或許還有能進步的空間,但就目前為止我應該想不到遞迴以外的方法了。下面是類似題,核心思路不變。
這題要列印過程,所以每次遇到需要移動盤子的時機都要輸出,即便是搬完整塔也需要
這題只會輸入偶數盤,起點都在A柱,奇數編號盤都去B柱,偶數編號盤都去C柱,因此goal 自己設定
這題有一萬盤,遞迴有機會讓記憶體堆疊爆掉,因此不建議使用遞迴(反正我是通過了~)