【C++】競程筆記(DFS:深度優先搜尋) 程式碼範例參考:NTUCPC Guide,此筆記僅為個人學習用途。
DFS 的前菜:八皇后問題 Problem : https://zerojudge.tw/ShowProblem?problemid=i644
這題可以透過窮舉法+遞迴(回溯法:Backtracking)的方式去解。
回溯法就是窮舉+遞迴,遇到不可能或失敗的情況後會回退一步,嘗試其他可能的解,直到找到解或全部演算完畢。
八皇后問題的目標是:
在 $8 \times 8$ 的棋盤上放置 $8$ 個皇后,使得任意兩個皇后不會互相攻擊,也就是說:
任意兩個皇后不在同一列(row) 任意兩個皇后不在同一行(column) 任意兩個皇后不在同一條對角線(diagonal) 至於為什麼是這樣?可以參考這篇西洋棋規則:https://blog.udn.com/puzzlez/4342425
解法 由於每列只能放一個皇后,因此可以將問題簡化為每一列放在哪一行。
在這邊開一個陣列 queens[8] 表示整個棋盤,queens[i] = j 表示第 i 列的皇后放在第 j 行。
邏輯判定 每次遞迴放一個皇后,然後對目前位置去做衝突判定(isValid):
對於目前第 row 列、欲擺在第 col 行,檢查:是否有其他皇后也在 col 行 → 同行衝突。 是否有其他皇后在相同的對角線上:左上到右下:(r1 - r2) == (c1 - c2)。 左下到右上:(r1 - r2) == -(c1 - c2)。 上述兩項可縮短成 abs(r1 - r2) == abs(c1 - c2) 方便計算。 只要有一項衝突,就不能擺這個位置,回傳 false,必須試其他位置。
回溯邏輯 1 void solve (int row, vector<int > &queens, vector<string> &solutions)
如果 row == 8,表示八個皇后都放好了 → 收集這個解。
否則就從第 $0$ 行到第 $7$ 行一個個試,直到成功為止。
每次放下一層(下一列)時,就遞迴往下走。
若沒成功,回到上一層試下一個可能點(回溯法)。
範例程式碼 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 #include <bits/stdc++.h> using namespace std;bool isValid (const vector<int > &queens, int row, int col) { for (int prevRow = 0 ; prevRow < row; ++prevRow) { int prevCol = queens[prevRow]; if (prevCol == col || abs (prevCol - col) == abs (prevRow - row)) { return false ; } } return true ; } void solve (int row, vector<int > &queens, vector<string> &solutions) { if (row == 8 ) { string solution; for (int col : queens) { solution += to_string (col + 1 ); } solutions.push_back (solution); return ; } for (int col = 0 ; col < 8 ; ++col) { if (isValid (queens, row, col)) { queens[row] = col; solve (row + 1 , queens, solutions); } } } int main () { vector<string> solutions; vector<int > queens (8 ) ; solve (0 , queens, solutions); sort (solutions.begin (), solutions.end ()); for (int i = 0 ; i < solutions.size (); ++i) { cout << (i + 1 ) << ": " << solutions[i] << endl; } return 0 ; }
剪枝(Pruning) 剪枝(Pruning)是一種在搜尋或遞迴演算法中的優化技巧,目的在於提前排除不可能得到有效解的分支,以減少不必要的計算,提升效率。
回溯法就算是一種暴力法,透過剪枝可以優化他,少走冤枉路。
白話一點:剪枝就是當我們發現可能走在一條完全錯誤的路上時,那就應該要放棄這條,走別條。
來細講一下要怎麼對八皇后問題做剪枝吧:
這邊首先用三個 bool 陣列來記錄哪些位置已被皇后佔用:
cols[8]:記錄某一欄是否已有皇后。
diag1[15]:主對角線(↘),對應 row + col。
diag2[15]:副對角線(↙),對應 row - col + 7(可能有負值,需 +7 平移)。
首先剪掉同一行被佔用的情形:
比對沒有剪枝的程式碼,光是這行就省略了從 row = 0 到 row = row - 1 的遍歷檢查,從 $O(n)$ 降至 $O(1)$ 。
其實對角線的做法也跟上面的一樣,因為我們都用了陣列去紀錄哪些皇后佔了什麼位置,所以可直接用一行判斷去解決:
1 if (!diag2[row - col + 7 ])
剪枝後的程式碼 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 #include <iostream> #include <vector> #include <string> #include <algorithm> using namespace std;vector<string> solutions; int queens[8 ];bool cols[8 ] = {false };bool diag1[15 ] = {false }; bool diag2[15 ] = {false }; void solve (int row) { if (row == 8 ) { string s; for (int i = 0 ; i < 8 ; ++i) { s += to_string (queens[i] + 1 ); } solutions.push_back (s); return ; } for (int col = 0 ; col < 8 ; ++col) { if (!cols[col] && !diag1[row + col] && !diag2[row - col + 7 ]) { queens[row] = col; cols[col] = diag1[row + col] = diag2[row - col + 7 ] = true ; solve (row + 1 ); cols[col] = diag1[row + col] = diag2[row - col + 7 ] = false ; } } } int main () { solve (0 ); sort (solutions.begin (), solutions.end ()); for (int i = 0 ; i < solutions.size (); ++i) { cout << (i + 1 ) << ": " << solutions[i] << endl; } return 0 ; }
深度優先搜尋(Depth Fisrt Search, DFS) DFS 是啥?能吃嗎?請看以下這張圖:
Image Source:https://zh-yue.wikipedia.org/wiki/File:Depth-First-Search.gif
DFS 是遍歷或搜尋樹或圖的演算法。
其實跟回溯法有點像,但有點不一樣是 DFS 會探到底,如果走到不能走了,再回到起點,繼續走別條路試試看。
有兩種實作 DFS 的方式:
遞迴(Recursive) 堆疊(stack) 普遍使用遞迴,因為你用堆疊你也是一樣再模擬遞迴,還不如用遞迴。
範例:走迷宮 Problem : https://zerojudge.tw/ShowProblem?problemid=a982
題目敘述:
給定一個 $n \times n$ 格的迷宮,迷宮中 # 代表障礙物,. 代表一般的路,玩家固定於 $(2, 2)$ 出發,目的地為 $(n-1, n-1)$ ,求包括起點和終點,最少路徑的長度。
輸入說明:
$N \leq 100$ 。
$N$ 行 $N$ 列由 # 和 . 組成的迷宮。
輸出說明:
一個正整數,代表最短路徑的長度,如果不可能到達終點,則印出 No solution!
範例輸入:
1 2 3 4 5 6 7 8 9 10 9 ######### #.......# #.#####.# #.......# ##.#.#### #..#.#..# #.##.##.# #.......# #########
範例輸出:
解題思路 準備幾個資料結構:
迷宮矩陣 標記陣列 1 2 bool visited[105 ][105 ] = {false };
方向向量 1 2 int dx[4 ] = {-1 , 1 , 0 , 0 }; int dy[4 ] = { 0 , 0 ,-1 , 1 };
最短步數變數 演算流程:
一開始先判斷「若起點或終點任一為 #,則立刻輸出 No solution!」,結束程式。 呼叫 DFS 函數 + 回溯(Backtracking) 1 2 visited[1 ][1 ] = true ; dfs (1 , 1 , 1 );
咦?不是從 $(2, 2)$ 開始嗎?這是因為程式中的陣列都是從 $0$ 開始,所以 $(1, 1)$ 就等同 $(2, 2)$ 。
DFS 內部函數細節: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 void dfs (int x, int y, int steps) { if (steps >= ans) return ; if (x == n-2 && y == n-2 ) { ans = steps; return ; } for (int dir = 0 ; dir < 4 ; ++dir) { int nx = x + dx[dir]; int ny = y + dy[dir]; if (nx >= 0 && nx < n && ny >= 0 && ny < n && maze[nx][ny] == '.' && !visited[nx][ny]) { visited[nx][ny] = true ; dfs (nx, ny, steps + 1 ); visited[nx][ny] = false ; } } }
完整程式碼 使用 DFS 方法 submit 過後,會發現有個測資點 TLE,這是因為 DFS 不適合拿來求最短路徑,效率太差了。
所以這時候就要改用 BFS(廣度優先搜尋)來做這題(請見下一篇筆記講解)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 #include <bits/stdc++.h> using namespace std;int n;vector<string> maze; bool visited[105 ][105 ];int ans = INT_MAX;int dx[4 ] = {-1 , 1 , 0 , 0 };int dy[4 ] = {0 , 0 , -1 , 1 };void dfs (int x, int y, int steps) { if (steps >= ans) return ; if (x == n-2 && y == n-2 ) { ans = steps; return ; } for (int dir = 0 ; dir < 4 ; ++dir) { int nx = x + dx[dir]; int ny = y + dy[dir]; if (nx >= 0 && nx < n && ny >= 0 && ny < n && !visited[nx][ny] && maze[nx][ny] == '.' ) { visited[nx][ny] = true ; dfs (nx, ny, steps + 1 ); visited[nx][ny] = false ; } } } int main () { ios::sync_with_stdio (false ); cin.tie (nullptr ); cin >> n; maze.resize (n); for (int i = 0 ; i < n; ++i) { cin >> maze[i]; } if (maze[1 ][1 ] == '#' || maze[n-2 ][n-2 ] == '#' ) { cout << "No solution!\n" ; return 0 ; } visited[1 ][1 ] = true ; dfs (1 , 1 , 1 ); if (ans == INT_MAX) { cout << "No solution!\n" ; } else { cout << ans << "\n" ; } return 0 ; }
習題練習 1. Lotto Problem : https://tioj.sprout.tw/problems/61
這題就是組合(Combination)問題,給你一個 $k$ 且 $k > 6$ ,並且求出 $C(k, 6)$ 的所有可能組合。
解題思路:
設計 dfs 遞迴函數,共四個參數,numbers 表示輸入的已排序集合,comb 為所有可能的組合之集合,index 為在 numbers 中下個可選擇的起始位置,depth 為目前已選多少數字(depth 0 ~ 5)。
終止條件:
depth == 6 時終止條件,表示 comb 裡面的數字滿了,並依序輸出這些可能的組合,再進行回溯。
遞迴選擇:
1 2 3 4 // pseudo code for i = index … k-1: comb[depth] = numbers[i]; dfs(numbers, comb, i+1, depth+1);
每次從 i = index 開始選一個新數字,放入 comb[depth],再遞迴挑下一個。
i+1 作為下一層的起點,確保不重複,也維持順序。
範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 #include <bits/stdc++.h> using namespace std;void dfs (const vector<int >& numbers, vector<int >& comb, int index, int depth) { if (depth == 6 ){ for (int i = 0 ; i < 6 ; ++i){ if (i > 0 ) cout << " " ; cout << comb[i]; } cout << endl; return ; } int n = numbers.size (); for (int i = index; i < n; ++i){ comb[depth] = numbers[i]; dfs (numbers, comb, i + 1 , depth + 1 ); } } int main () { ios::sync_with_stdio (false ), cin.tie (nullptr ); int T; cin >> T; while (T--){ int k; cin >> k; vector <int > numbers (k); for (int i = 0 ; i < k; ++i){ cin >> numbers[i]; } vector <int > comb (6 ); dfs (numbers, comb, 0 , 0 ); } return 0 ; }
2. 數獨 Problem : https://tioj.sprout.tw/problems/60
數獨的規則是在每一行、每一列、每個子矩陣中都不能出現相同的數字,一般的數獨都是給 $9 \times 9$ 的方格,以九宮格的形式進行數獨遊戲。
具體玩法可參考:https://sudoku.com/tw/shu-du-gui-ze/
解題思路:
首先每筆測資會收到 81 個字元,代表這個 $9 \times 9$ 方格的初始佈局。
先檢查「已填數字」有無違規。若同一行(col)、同一列(row)或同一 $3 \times 3$ 九宮格內,同樣的數字重複出現,就直接判定「No solution!」。
再來是一些資料結構上的初始化設定:
格子狀態:用 grid[r][c] 存放第 r 行、第 c 列的字元('.' 或 '1'~'9')。 rowUsed[r][d]:第 r 行是否已經用過數字 d?colUsed[c][d]:第 c 列是否已經用過數字 d?blockUsed[b][d]:第 b 號 $3 \times 3$ 宮格是否已經用過數字 d?然後把所有 '.' 的格子位置依序存進一個陣列 empties。
演算法流程(DFS+回溯法):
填上第一個空格(empties[0])對應 empties[0],在第 r 行、第 c 列、第 b 宮格。 嘗試數字 d = 1,2,3…9:若 rowUsed[r][d] || colUsed[c][d] || blockUsed[b][d] 運算式結果為 true,代表這已經違規了,就直接跳過 d。 否則,填入 d,並將三個用來記錄的陣列標記為已用:1 2 grid[r][c] = '0' +d; rowUsed[r][d] = colUsed[c][d] = blockUsed[b][d] = true ;
填完後,就直接遞迴呼叫 dfs(pos+1),往下一個空格移動。
回溯機制:如果遞迴回傳 true,表示「下一層已經成功找到一組完整解」,一路往上直接 return true,就不要再試更大的 d 了,因為題目這邊要求輸出字典序最小的一組解。 若 1~9 全部填過都不行的話,就要還原:1 2 rowUsed[r][d] = colUsed[c][d] = blockUsed[b][d] = false ; grid[r][c] = '.' ;
然後回到上一層,嘗試上一格的下一個數字。
終止條件:
當 pos == empties.size(),代表所有空格都已成功填入,就馬上 return true,觸發整個搜尋樹的「捷徑返回」,然後再印出這 81 個字元的解。 無解判定:
若一開始判斷就發現違規的話,或整個 DFS 搜尋完都沒找到可以填的地方,就 return false,輸出 No solution.。 範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 #include <bits/stdc++.h> using namespace std;int T; string s; char grid[9 ][9 ]; bool rowUsed[9 ][10 ], colUsed[9 ][10 ], blockUsed[9 ][10 ];vector<int > empties; bool dfs (int pos) { if (pos == (int )empties.size ()) { return true ; } int idx = empties[pos]; int r = idx / 9 ; int c = idx % 9 ; int b = (r/3 )*3 + (c/3 ); for (int d = 1 ; d <= 9 ; ++d) { if (rowUsed[r][d] || colUsed[c][d] || blockUsed[b][d]) { continue ; } grid[r][c] = char ('0' + d); rowUsed[r][d] = true ; colUsed[c][d] = true ; blockUsed[b][d] = true ; if (dfs (pos + 1 )) { return true ; } rowUsed[r][d] = false ; colUsed[c][d] = false ; blockUsed[b][d] = false ; grid[r][c] = '.' ; } return false ; } int main () { ios::sync_with_stdio (false ); cin.tie (nullptr ); cin >> T; while (T--) { cin >> s; empties.clear (); memset (rowUsed, 0 , sizeof (rowUsed)); memset (colUsed, 0 , sizeof (colUsed)); memset (blockUsed, 0 , sizeof (blockUsed)); bool invalid = false ; for (int i = 0 ; i < 81 ; ++i) { int r = i / 9 , c = i % 9 ; int b = (r/3 )*3 + (c/3 ); char ch = s[i]; grid[r][c] = ch; if (ch == '.' ) { empties.push_back (i); } else { int d = ch - '0' ; if (rowUsed[r][d] || colUsed[c][d] || blockUsed[b][d]) { invalid = true ; } else { rowUsed[r][d] = true ; colUsed[c][d] = true ; blockUsed[b][d] = true ; } } } if (invalid || !dfs (0 )) { cout << "No solution.\n" ; } else { for (int r = 0 ; r < 9 ; ++r) { for (int c = 0 ; c < 9 ; ++c) { cout << grid[r][c]; } } cout << "\n" ; } } return 0 ; }
3. UVa 572 - Oil Deposits Problem : https://zerojudge.tw/ShowProblem?problemid=c129
若兩個 @ 在 8 個方向上彼此相鄰,就視為一整塊油礦(Oil Deposit)。
而相鄰定義與踩地雷遊戲相同的意思是:
上下左右(4 個方向):
對角線(4 個方向):
座標偏移表:
方向 dx dy 說明 ↖ -1 -1 左上 ↑ -1 0 上 ↗ -1 +1 右上 ← 0 -1 左 → 0 +1 右 ↙ +1 -1 左下 ↓ +1 0 下 ↘ +1 +1 右下
只要有任兩個 @ 與上述八個方向相連起來,就是一個 oil deposit。
而目標是要找出有多少個不一樣的 oil deposit。
解題思路:
遍歷整張地圖 若遇到一個還沒處理過的 @,就從該格出發,做一次 DFS,然後把所有與它相連的 @ 都標記為「已訪問」。 每一次 DFS,就代表發現一塊新的油礦。因為這些 @ 沒跟之前的任何油礦連在一起。 統計做 DFS 的次數,即為油礦總數。 範例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 #include <bits/stdc++.h> using namespace std;const int dx[8 ] = {-1 , -1 , -1 , 0 , 0 , 1 , 1 , 1 }; const int dy[8 ] = {-1 , 0 , 1 , -1 , 1 , -1 , 0 , 1 }; int m, n; vector<string> grid; vector<vector<bool >> visited; void dfs (int x, int y) { visited[x][y] = true ; for (int dir = 0 ; dir < 8 ; ++dir) { int nx = x + dx[dir]; int ny = y + dy[dir]; if (nx >= 0 && nx < m && ny >= 0 && ny < n) { if (!visited[nx][ny] && grid[nx][ny] == '@' ) { dfs (nx, ny); } } } } int main () { ios::sync_with_stdio (false ), cin.tie (nullptr ); while (cin >> m >> n && (m != 0 || n != 0 )) { grid.assign (m, "" ); visited.assign (m, vector <bool >(n, false )); for (int i = 0 ; i < m; ++i) { cin >> grid[i]; } int oilDeposits = 0 ; for (int i = 0 ; i < m; ++i) { for (int j = 0 ; j < n; ++j) { if (!visited[i][j] && grid[i][j] == '@' ) { dfs (i, j); ++oilDeposits; } } } cout << oilDeposits << '\n' ; } return 0 ; }
4. 祖靈被榨乾了!!!!!!!! Problem : https://zerojudge.tw/ShowProblem?problemid=a597
基本上就是跟前面一樣的套路,只是稍微改一下而已。
解題思路:
1.
首先輸入 m, n(行列數),
建立 grid[m][n] 陣列儲存圖形資訊;
建立 visited[m][n] 陣列來標記是否走過。
用 DFS 搜尋一整塊的 JIZZ 流程如下:
遍歷整張地圖 每當遇到 ‘J’ 而且還沒被走過:認定這是一塊新 JIZZ(連通塊)。 進行 DFS 探索整塊 JIZZ,計算其面積。 更新連通塊數量、最大面積。 輸出總連通塊數、最大的 JIZZ 面積 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 #include <bits/stdc++.h> using namespace std;const int MAX = 505 ; int m, n; char grid[MAX][MAX]; bool visited[MAX][MAX]; int dx[4 ] = {-1 , 1 , 0 , 0 };int dy[4 ] = {0 , 0 , -1 , 1 };int dfs (int x, int y) { visited[x][y] = true ; int area = 1 ; for (int dir = 0 ; dir < 4 ; ++dir) { int nx = x + dx[dir]; int ny = y + dy[dir]; if (nx >= 0 && nx < m && ny >= 0 && ny < n && grid[nx][ny] == 'J' && !visited[nx][ny]) { area += dfs (nx, ny); } } return area; } int main () { ios::sync_with_stdio (false ); cin.tie (nullptr ); while (cin >> m >> n) { for (int i = 0 ; i < m; ++i) { cin >> grid[i]; } for (int i = 0 ; i < m; ++i) fill (visited[i], visited[i] + n, false ); int count = 0 ; int max_area = 0 ; for (int i = 0 ; i < m; ++i) { for (int j = 0 ; j < n; ++j) { if (grid[i][j] == 'J' && !visited[i][j]) { int area = dfs (i, j); count++; max_area = max (max_area, area); } } } cout << count << " " << max_area << "\n" ; } return 0 ; }