編碼 · 演算法
回溯法教學:原理、程式碼與範例
簡答
回溯法是一種搜尋所有可能解的方法:一次做一個選擇來建構候選解,一旦發現無法得到合法解,就立刻撤銷最後一個選擇。它是對部分解所形成的樹進行的深度優先搜尋,遵循「選擇、探索、撤銷」的模式。回溯法可解決 N 皇后、數獨、子集與排列等謎題和組合問題。它的最差情況是指數時間,所以及早剪掉不合法的分支才能讓它實用。
開啟正規測試回溯法解決什麼問題
有些問題要求列出所有符合規則的排列方式,或只要找出其中任何一種:放置 8 個皇后使彼此不會攻擊、填滿數獨格子、列出總和等於目標值的所有子集、找出把字串切成字典單字的所有方法。這類問題沒有巧妙的公式,只能靠搜尋。盲目嘗試每一種完整組合實在太慢,但大多數部分組合在做了幾個選擇之後就已經不合法了。
回溯法在每一個選擇之後都檢查規則。一旦部分解違反規則,它就立刻放棄搜尋樹上的整個分支,退回去嘗試下一個選項。
用白話說明原理
- 如果目前的部分解已經完整,就記錄下來。
- 否則,對每一個可能的下一步選擇:如果這個選擇是允許的,就做出它(選擇)。
- 遞迴下去,繼續擴充這個解(探索)。
- 撤銷這個選擇,讓下一個選項從相同的狀態開始(撤銷)。
搜尋空間是一棵樹:根是空的解,每一層增加一個選擇,葉節點是完整的候選解。回溯法就是在這棵樹上做深度優先搜尋,而剪枝就是切掉不可能包含解的子樹。
範例:4 皇后
在 4 × 4 的棋盤上放 4 個皇后,每列一個,使任兩個皇后不在同一欄或同一條對角線上。欄編號為 0 到 3。
| 步驟 | 動作 | 目前的皇后(每列所在的欄) |
|---|---|---|
| 1 | 第 0 列:把皇后放在第 0 欄 | 0 |
| 2 | 第 1 列:第 0 欄與第 1 欄會被攻擊;嘗試第 2 欄 | 0, 2 |
| 3 | 第 2 列:每一欄都會被攻擊。回溯:把第 1 列移到第 3 欄 | 0, 3 |
| 4 | 第 2 列:第 1 欄是安全的 | 0, 3, 1 |
| 5 | 第 3 列:每一欄都會被攻擊。回溯到第 2 列:沒有其他欄可用。回溯到第 1 列:沒有欄可選了。回溯到第 0 列:移到第 1 欄 | 1 |
| 6 | 第 1 列:第 3 欄是安全的 | 1, 3 |
| 7 | 第 2 列:第 0 欄是安全的 | 1, 3, 0 |
| 8 | 第 3 列:第 2 欄是安全的。找到解 | 1, 3, 0, 2 |
繼續搜尋會找到唯一的另一個解 2, 0, 3, 1,它是第一個解的鏡像。
Python 實作
用集合記錄已使用的欄與對角線,讓每次安全檢查都是 O(1)。在其中一種對角線上 row - col 是常數,在另一種對角線上 row + col 是常數。
def solve_n_queens(n):
solutions, cols = [], []
used_cols, diag1, diag2 = set(), set(), set()
def place(row):
if row == n:
solutions.append(cols.copy()) # copy, not the live list
return
for col in range(n):
if col in used_cols or row - col in diag1 or row + col in diag2:
continue # prune: square is attacked
cols.append(col) # choose
used_cols.add(col); diag1.add(row - col); diag2.add(row + col)
place(row + 1) # explore
cols.pop() # unchoose
used_cols.remove(col); diag1.remove(row - col); diag2.remove(row + col)
place(0)
return solutions
print(solve_n_queens(4)) # [[1, 3, 0, 2], [2, 0, 3, 1]]
print(len(solve_n_queens(8))) # 92同一個模板可以產生所有子集與所有排列:
def subsets(items):
result, current = [], []
def go(i):
if i == len(items):
result.append(current.copy())
return
current.append(items[i]); go(i + 1); current.pop() # take items[i]
go(i + 1) # skip items[i]
go(0)
return result
def permutations(items):
result, current, used = [], [], [False] * len(items)
def go():
if len(current) == len(items):
result.append(current.copy())
return
for i, x in enumerate(items):
if not used[i]:
used[i] = True; current.append(x)
go()
used[i] = False; current.pop()
go()
return result
print(subsets([1, 2, 3]))
# [[1, 2, 3], [1, 2], [1, 3], [1], [2, 3], [2], [3], []]
print(permutations([1, 2, 3]))
# [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]在實際程式碼中,若只需要一般的子集和排列,itertools.combinations 與 itertools.permutations 更快。需要用特定問題的規則來剪枝時,再自己寫回溯法。
時間與空間複雜度
| 問題 | 結果數量 | 時間 | 額外空間 |
|---|---|---|---|
| n 個元素的所有子集 | 2ⁿ | O(n × 2ⁿ) | O(n) 的遞迴深度,加上輸出 |
| n 個元素的所有排列 | n! | O(n × n!) | O(n),加上輸出 |
| N 皇后 | 視 n 而定(n = 8 時為 92) | 最多 O(n!),剪枝後遠少於此 | O(n) |
| 數獨(9 × 9) | 合格的謎題只有 1 個 | 最差情況為空格數的指數時間 | O(格子數) |
回溯法不可能比輸出的大小更快:如果你必須列出 n! 個排列,至少需要 n! 步。剪枝只能藉由跳過不會產生任何結果的分支來幫忙。
變化版本與常見錯誤
- 忘了撤銷:如果遞迴呼叫之後沒有還原狀態,後面的分支就會看到過時的選擇。
- 加入的是仍在變動的串列:
result.append(current)儲存的是之後會被改動的參考;請改成 appendcurrent.copy()。 - 輸入中有重複元素:先排序,並在同一層跳過與前一個相等的元素,以避免產生重複的子集或排列。
- 找到第一個解就停止:當一個解就夠用時(如數獨求解器),讓遞迴回傳
True並停止迴圈。 - 更好的排序:先嘗試限制最多的選擇(數獨中就是候選數字最少的空格)。這通常能大幅縮小搜尋樹。
何時使用與實際應用
- 限制滿足問題:數獨、填字遊戲、地圖著色、課表編排與考試排程。
- SAT 求解器與 Prolog 風格的邏輯引擎,都建立在帶有強力剪枝的回溯搜尋上。
- 許多語言的正規表達式引擎(包括 Python 的
re)都使用回溯;寫得不好的模式可能造成指數級的回溯(「災難性回溯」)。 - 面試題目:子集、排列、組合總和、N 皇后、網格中的單字搜尋、回文分割、括號生成、電話號碼的字母組合。
常見問題
回溯法和暴力法有什麼差別?
暴力法會建構並檢查每一個完整的候選解。回溯法在每次選擇後都檢查限制條件,一旦部分候選解不合格就立刻放棄,因此能跳過整批候選解。
回溯法就是 DFS 嗎?
回溯法是對部分解的樹進行深度優先搜尋,並額外規定要撤銷每個選擇與剪掉不合法的分支。圖上的 DFS 則是把同樣的概念套用在既有的節點與邊上。
回溯法的時間複雜度是多少?
最差情況通常是指數或階乘時間,例如子集為 O(2ⁿ),排列為 O(n!)。良好的剪枝能減少實際的工作量,但無法降低最差情況的上界。
8 皇后問題有幾個解?
共有 92 個解;如果把互為旋轉或鏡射的解視為同一個,則有 12 個。