回溯
一步一步做選擇,發現走不通就退回上一步換一個選擇。以 N 皇后為例,看剪枝如何讓搜尋量從天文數字降到幾千步。
N
在 6×6 的棋盤放 6 個皇后,任兩個都不在同一列、同一行或同一條斜線上。一列放一個,從左往右試。
1/368
正在試衝突/退回已放的皇后
試過幾格
1
放下幾次
0
退回幾次
0
找到第一個解要試
171
試著把皇后放在第 1 列第 1 行。
亮起來的是這一步執行的程式碼
function solveNQueens(n: number): number[] | null { const cols: number[] = []; // cols[r] = column of the queen in row r function safe(row: number, col: number): boolean { for (let r = 0; r < row; r++) { const c = cols[r]; if (c === col || Math.abs(c - col) === row - r) { return false; } } return true; } function place(row: number): boolean { if (row === n) return true; for (let col = 0; col < n; col++) { if (!safe(row, col)) continue; cols.push(col); if (place(row + 1)) return true; cols.pop(); } return false; } return place(0) ? cols : null;}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 要在一大堆組合裡找出符合條件的(全部或任一個):排列、子集合、數獨、填字、排班。
- 條件可以在做到一半時就檢查:越早發現走不通,剪掉的分支越大。
- 沒有已知的多項式演算法、但問題規模不大時:回溯通常是最直接、也最好寫對的做法。
和其他主題的關係
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| N 皇后:找出所有解 剪枝讓實際搜尋量遠小於 N!,但仍是指數成長 | O(N!) | O(N!) |
| 每次檢查安不安全 這裡的程式逐列比對;改用三個布林陣列記錄欄和兩種斜線,可降到 O(1) | O(N) | O(N) |
| 完全不剪枝的暴力法 | O(N^N) | O(N^N) |
空間:O(N),遞迴深度 N,加上記錄每列皇后位置的陣列
和其他做法比
| 解的個數 | 放下幾次(找全部) | 試過幾格(找全部) | 每列每行各一個:N! | 完全不剪枝:N^N | |
|---|---|---|---|---|---|
| N = 4 | 2 | 16 | 60 | 24 | 256 |
| N = 5 | 10 | 53 | 220 | 120 | 3,125 |
| N = 6 | 4 | 152 | 894 | 720 | 46,656 |
| N = 7 | 40 | 551 | 3,584 | 5,040 | 823,543 |
| N = 8 | 92 | 2,056 | 15,720 | 40,320 | 16,777,216 |
把全部的解找出來。N = 8 時,每列隨便放一個的盤面有 16,777,216 種;回溯只放下 2,056 次、試了 15,720 格,因為一發現衝突就整棵子樹不看了。成本仍然隨 N 爆炸性成長,只是長得慢得多。
真實世界裡的它
- 數獨與各種解謎程式。
- 正規表達式引擎(例如 JavaScript、Python、Java 內建的)遇到分支就是用回溯在試。
- SAT(Boolean Satisfiability)求解器、Prolog 的查詢、排課與排班系統的核心都是有剪枝的回溯搜尋。
取捨與陷阱
- 最差情況仍是指數時間:輸入稍微變大就可能跑不完。正規表達式的「災難性回溯」曾經讓整個網站停擺。
- 退回時一定要把狀態還原(把剛放的拿掉):忘了還原,後面的分支就會在錯的狀態上繼續試。
- 嘗試的順序很重要:先試最受限的位置(例如數獨先填候選最少的格子),能剪掉的分支多得多。