窮舉遞歸和回溯算法
在一般的遞歸函數中,如二分查找、反轉文件等,在每個決策點只需要調用一個遞歸(比如在二分查找,在每個節點我們只需要選擇遞歸左子樹或者右子樹),在這樣的遞歸調用中,遞歸調用形成了一個線性結構,而算法的性能取決於調用函數的棧深度。比如對於反轉文件,調用棧的深度等於文件的大小;再比如二分查找,遞歸深度為O(nlogn),這兩類遞歸調用都非常高效。
現在考慮子集問題或者全排列問題,在每一個決策點我們不在只是選擇一個分支進行遞歸調用,而是要嘗試所有的分支進行遞歸調用。在每一個決策點有多種選擇,而這多種選擇的每一個選擇又導致了更多的選擇,直到我們碰到base case。這樣的話,隨着遞歸調用的深入,窮舉遞歸(exhaustive recursion)算法時間復雜度就會很高。比如:在每個決策點我們需要確定選擇哪個一個字母或者在當前位置選擇下一步去哪個城市(TSP)。那么我們有辦法避免代價高昂的窮舉遞歸嗎?答案是視情況而定。在有些情況下,我們沒有辦法,必須窮舉遞歸,比如我們需要找到全局的最優解。然而在更多的情況下我們只希望找到滿意解,在每個決策點,我們選擇只選擇一條遞歸調用路徑,希望它能夠成功,如果我們最終發現,可以得到一個滿意解,OK我們不再遍歷其他的情況了。否則如果這次嘗試沒有成功,我們退回決策點,換一個選擇嘗試,這就是回溯算法。值得說明的是,關於回溯的深度,我們只需要向上回溯到最近的決策點,該決策點滿足還有其他的選擇沒有嘗試。隨着回溯的向上攀升,最終我們可能回到初始狀態,這時候其實我們已經窮舉遞歸了所有的情況,那么該問題是不可解的。
典型問題回顧
上面說的是不是很抽象?我也覺得,但是沒辦法,嚴謹還是要有的,說的再多不如來看幾個例子來得實在,畢竟我們學習它是為了解決實際問題的。
【經典窮舉問題】窮舉所有的排列
問題描述:給定一個字符串,重排列后輸出所有可能的排列。
在每個決策點,我們需要在剩余待處理的字符串中,選擇一個字母,假設剩余字符串長度為k,那么在每個決策點我們有k種選擇,我們對每個選擇都嘗試一次,每次選擇一個后,更新當前已經字符串和剩余字符串。當剩余字符串為空時,我們到達base case,輸出當前選擇的字符串即可。偽代碼及C++代碼如下:
1 // Permutation Problem 2 // If you have no more characters left to rearrage, print the current permutation 3 // for (every possible choice among the characters left to rearrage) 4 // { 5 // Make a choice and add that character to the permutation so far 6 // Use recursion to rearrage the remaing letters 7 // } 8 // 9 void RecursivePermutation(string sofar, string remain) 10 { 11 if (remain == "") {cout << sofar << endl; reutrn;} 12 13 for (size_t i = 0; i < remain.size(); ++i) 14 { 15 string sofar2 = sofar + remain[i]; 16 string remain2 = remain.substr(0, i) + remain.substr(i+1); 17 RecursivePermutation(sofar2, remain2); 18 } 19 }
在這個問題中,我們嘗試了所有可能的選擇,屬於窮舉遞歸,總共有n!中排列方法。這是一個非常經典的模式,是許多遞歸算法的核心,比如猜字謎問題,數獨問題,最優化匹配問題,調度問題等都可以通過這種模式解決。
【經典窮舉問題】子集問題
問題描述:給定一個集合,列出該集合的所有子集
對於每一個決策點,我們從剩余的集合中選擇一個元素后,有兩種選擇,子集包括該元素或者不包括該元素,這樣每次遞歸一步的話,剩余集合中的元素就會減少一個,直到剩余集合為空,我們到達base case。偽代碼及C++代碼如下:
1 // Subset Problem 2 // 3 // If there are no more elements remaining, print current subset 4 // Consider the next element of those remaining 5 // Try adding it to current subset and use recursion to build subsets from here 6 // Try not adding it to current subset and use recursion to build subsets from here 7 void RecursiveSubset(string sofar, string remain) 8 { 9 // base case 10 if (remain == "") { cout << sofar << endl; return; } 11 12 char ch = remain[0]; 13 string remain2 = remain.substr(1); 14 RecursiveSubset(sofar, remain2); // choose first element 15 RecursiveSubset(sofar + ch, remain2); // not choose first element 16 }
這是另外一個窮舉遞歸的典型例子。每次遞歸調用問題規模減少一個,然而會產生兩個新的遞歸調用,因而時間復雜度為O(2^n)。這也是個經典問題,需要牢記解決該類問題的pattern,其他與之類似的問題還有最優填充問題、集合划分問題、最長公共子列問題(longest shared subsequence)等。
這兩個問題看起來很像,實際上差別很大,屬於不同的兩類問題。在permutation問題中,我們在每次決策點是要選擇一個字母包含到當前子串中,我們有n中選擇(假設剩余子串長度為n),每一次選擇后遞歸調用一次,因而有n個規模為n-1的子問題,即T(n) = n T(n-1)。而對於subset問題,我們在每個決策點對於字母的選擇只能是剩余子串的首字母,而我們決策的過程為選擇or not選擇(這是一個問題,哈哈),我們拿走一個字母后,做了兩次遞歸調用(對比permutation問題,我們拿下一個字母后只進行了一次遞歸調用),因此T(n) = 2 * T(n-1)。
總結說來:permutation問題拿走一個字母后,遞歸調用一次,我們的決策點是有n個字母可以拿;而subset問題是拿走一個字母后,進行了兩次遞歸調用,我們的決策點是包括還是不包括該拿下的字母,請仔細體味兩者的區別。
遞歸回溯
在permutation問題和subset問題中,我們探索了每一種可能性。在每一個決策點,我們對每一個可能的選擇進行嘗試,知道我們窮舉了我們所有可能的選擇。這樣以來時間復雜度就會很高,尤其是如果我們有許多決策點,並且在每一個決策點我們又有許多選擇的時候。而在回溯算法中,我們嘗試一種選擇,如果滿足了條件,我們不再進行其他的選擇。這種算法的一般的偽代碼模式如下:
1 bool Solve(configuration conf) 2 { 3 if (no more choice) 4 return (conf is goal state); 5 6 for (all available choices) 7 { 8 try choice c; 9 10 ok = solve(conf with choice c made); 11 if (ok) 12 return true; 13 else 14 unmake c; 15 } 16 17 retun false; 18 }
寫回溯函數的忠告是:將有關格局configuration的細節從函數中拿出去(這些細節包括,在每一個決策點有哪些選擇,做出選擇,判斷是否成功等等),放到helper函數中,從而使得主體函數盡可能的簡潔清晰,這有助我們確保回溯算法的正確性,同時有助於開發和調試。
我們先看第一個例子,從permutation問題中變異而來。問題是給定一個字符串,問是否能夠通過重新排列組合一個合法的單詞?這個問題不需要窮舉所有情況,只需要找到一個合法單詞即可,因而可用回溯算法加快效率。如果能夠構成合法單詞,我們return該單詞;否則返回空串。問題的base case是檢查字典中是否包含該單詞。每次我們做出選擇之后遞歸調用,判斷做出當前選擇之后能否成功,如果能,不再嘗試其他可能;如果不能,我們換一個別的選擇。代碼如下:
1 string FindWord(string sofar, string rest, Dict& dict) 2 { 3 // Base Case 4 if (sofar.empty()) 5 { 6 return (dict.containWords(sofar)? sofar : ""); 7 } 8 9 for (int i = 0; i < rest.size(); ++i) 10 { 11 // make a choice 12 string sofar2 = sofar + rest[i]; 13 string rest2 = rest.substr(0, i) + rest.substr(i+1); 14 String found = FindWord(sofar2, rest2, dict); 15 16 // if find answer 17 if (!found.empty()) return found; 18 // else continue next loop, make an alternative choice 19 } 20 21 return "";
我們可以對這個算法進行進一步剪枝來早些避免進入“死胡同”。例如,如果輸入字符串是"zicquzcal",一旦你發現了前綴"zc"你就沒有必要再進行進一步的選擇,因為字典中沒有以“zc”開頭的單詞。具體說來,在base case中需要加入另一種終止條件,如果sofar不是有效前綴,直接返回“”。
【經典回溯問題1】八皇后問題
問題是要求在8x8的國際象棋盤上放8個queue,要求不沖突。(即任何兩個queue不同行,不同列,不同對角線)。按照前面的基本范式,我們可以給出如下的偽代碼及C++代碼::
#include <iostream> #include <vector> using namespace std; // Start in the leftmose column // // If all queens are placed, return true // else for (every possible choice among the rows in this column) // if the queue can be placed safely there, // make that choice and then recursively check if this choice lead a solution // if successful, return true // else, remove queue and try another choice in this colunm // if all rows have been tried and nothing worked, return false to trigger backtracking const int NUM_QUEUE = 4; const int BOARD_SIZE = 4; typedef vector<vector<int> > Grid; void PlaceQueue(Grid& grid, int row, int col); void RemoveQueue(Grid& grid, int row, int col); bool IsSafe(Grid& grid, int row, int col); bool NQueue(Grid& grid, int curcol); void PrintSolution(const Grid& grid); int main() { vector<vector<int> > grid(BOARD_SIZE, vector<int>(BOARD_SIZE, 0)); if (NQueue(grid, 0)) { cout << "Find Solution" << endl; PrintSolution(grid); } else { cout << "Cannot Find Solution" << endl; } return 0; } void PlaceQueue(Grid& grid, int row, int col) { grid[row][col] = 1; } void RemoveQueue(Grid& grid, int row, int col) { grid[row][col] = 0; } bool IsSafe(Grid& grid, int row, int col) { int i = 0; int j = 0; // check row for (j = 0; j < BOARD_SIZE; ++j) { if (j != col && grid[row][j] == 1) return false; } // check col for (i = 0; i < BOARD_SIZE; ++i) { if (i != row && grid[i][col] == 1) return false; } // check left upper diag for (i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) { if (grid[i][j] == 1) return false; } // check left lower diag for (i = row + 1, j = col - 1; i < BOARD_SIZE && j >= 0; i++, j--) { if (grid[i][j] == 1) return false; } return true; } bool NQueue(Grid& grid, int curcol) { // Base case if (curcol == BOARD_SIZE) { return true; } for (int i = 0; i < BOARD_SIZE;++i) { if (IsSafe(grid, i, curcol)) { // try a choice PlaceQueue(grid, i, curcol); // if this choice lead a solution, return bool success = NQueue(grid, curcol + 1); if (success) return true; // else unmake this choice, try an alternative choice else RemoveQueue(grid, i, curcol); } } return false; } void PrintSolution(const Grid& grid) { for (int i = 0; i < BOARD_SIZE; ++i) { for (int j = 0; j < BOARD_SIZE; ++j) { cout << grid[i][j] << " "; } cout << endl; } cout << endl; }
【經典回溯問題2】數獨問題
數獨問題可以描述為在空格內填寫1-9的數字,要求每一行每一列每一個3*3的子數獨內的數字1-9出現一次且僅出現一次。一般數獨問題會實現填寫一些數字以保證解的唯一性,從而使得不需要暴力破解,只是使用邏輯推理就可以完成。這一次讓我們嘗試用計算機暴力回溯來得到一個解。解決數獨問題的偽代碼及C++代碼如下:
#include <iostream> #include <string> #include <vector> #include <algorithm> #include <iterator> #include <cstdio> using namespace std; // Base Case: if cannot find any empty cell, return true // Find an unsigned cell (x, y) // for digit from 1 to 9 // if there is not conflict for digit at (x, y) // assign (x, y) as digit and Recursively check if this lead to a solution // if success, return true // else remove the digit at (x, y) and try another digit // if all digits have been tried and still have not worked out, return false to trigger backtracking const int GRID_SIZE = 9; const int SUB_GRID_SIZE = 3; typedef vector<vector<int> > Grid; bool IsSafe(const Grid& grid, int x, int y, int num); bool FindEmptyCell(const Grid& grid, int& x, int& y); bool Sudoku(Grid& grid); void PrintSolution(const Grid& grid); int main() { freopen("sudoku.in", "r", stdin); vector<vector<int> > grid(GRID_SIZE, vector<int>(GRID_SIZE, 0)); for (int i = 0; i < GRID_SIZE; ++i) { for (int j = 0; j < GRID_SIZE; ++j) { cin >> grid[i][j]; } } if (Sudoku(grid)) { cout << "Find Solution " << endl; PrintSolution(grid); cout << endl; } else { cout << "Solution does not exist" << endl; } return 0; } bool Sudoku(Grid& grid) { // base case int x = 0; int y = 0; if (!FindEmptyCell(grid, x, y)) return true; // for all the number for (int num = 1; num <= 9; ++num) { if (IsSafe(grid, x, y, num)) { // try one choice grid[x][y] = num; // if this choice lead to a solution if (Sudoku(grid)) return true; // otherwise, try an alternative choice else grid[x][y] = 0; } } return false; } bool IsSafe(const Grid& grid, int x, int y, int num) { // check the current row for (int j = 0; j < grid[x].size(); ++j) { if (j != y && grid[x][j] == num) return false; } // check current col for (int i = 0; i < grid.size(); ++i) { if (i != x && grid[i][y] == num) return false; } // check the subgrid int ii = x / 3; int jj = y / 3; for (int i = ii * SUB_GRID_SIZE; i < (ii+1) * SUB_GRID_SIZE; ++i) { for (int j = jj * SUB_GRID_SIZE; j < (jj+1) * SUB_GRID_SIZE; ++j) { if (i != x || j != y) { if (grid[i][j] == num) return false; } } } return true; } // Find next Empty Cell bool FindEmptyCell(const Grid& grid, int& x, int& y) { for (int i = 0; i < GRID_SIZE; ++i) { for (int j = 0; j < GRID_SIZE; ++j) { if (grid[i][j] == 0) { x = i; y = j; return true; } } } return false; } void PrintSolution(const Grid& grid) { for (int i = 0; i < GRID_SIZE; ++i) { for (int j = 0; j < GRID_SIZE; ++j) { cout << grid[i][j] << " "; } cout << "\n"; } cout << endl; }
【經典回溯問題3】迷宮搜索問題
該問題在實現給定一些黑白方塊構成的迷宮,其中黑塊表示該方塊不能通過,白塊表示該方塊可以通過,並且給定迷宮的入口和期待的出口,要求找到一條連接入口和出口的路徑。有了前面的題目的鋪墊,套路其實都是一樣的。在當前位置,對於周圍的所有方塊,判斷可行性,對於每一個可行的方塊,就是我們當前所有可能的choices;嘗試一個choice,遞歸的判斷是否能夠導致一個solution,如果可以,return true;否則,嘗試另一個choice。如果所有的choice都不能導致一個成功解,return false。剩下的就是遞歸終止的條件,當前所在位置如果等於目標位置,遞歸結束,return true。C++代碼如下:
#include <iostream> #include <string> #include <vector> using namespace std; const int BOARD_SIZE = 4; enum GridState {Gray, White, Green}; const int DIRECTION_NUM = 2; const int dx[DIRECTION_NUM] = {0, 1}; const int dy[DIRECTION_NUM] = {1, 0}; typedef vector<vector<GridState> > Grid; bool IsSafe(Grid& grid, int x, int y); bool SolveRatMaze(Grid& grid, int curx, int cury); void PrintSolution(const Grid& grid); int main() { vector<vector<GridState> > grid(BOARD_SIZE, vector<GridState>(BOARD_SIZE, White)); for (int j = 1; j < BOARD_SIZE; ++j) grid[0][j] = Gray; grid[1][2] = Gray; grid[2][0] = Gray; grid[2][2] = Gray; grid[2][3] = Gray; // Place the init position grid[0][0] = Green; bool ok = SolveRatMaze(grid, 0, 0); if (ok) { cout << "Found Solution" << endl; PrintSolution(grid); } else { cout << "Solution does not exist" << endl; } return 0; } bool SolveRatMaze(Grid& grid, int curx, int cury) { // base case if (curx == BOARD_SIZE - 1 && cury == BOARD_SIZE - 1) return true; // for every choice for (int i = 0; i < DIRECTION_NUM; ++i) { int nextx = curx + dx[i]; int nexty = cury + dy[i]; if (IsSafe(grid, nextx, nexty)) { // try a choice grid[nextx][nexty] = Green; // check whether lead to a solution bool success = SolveRatMaze(grid, nextx, nexty); // if yes, return true if (success) return true; // no, try an alternative choice, backtracking else grid[nextx][nexty] = White; } } // try every choice, still cannot find a solution return false; } bool IsSafe(Grid& grid, int x, int y) { return grid[x][y] == White; } void PrintSolution(const Grid& grid) { for (int i = 0; i < BOARD_SIZE; ++i) { for (int j = 0; j < BOARD_SIZE; ++j) { cout << grid[i][j] << " "; } cout << "\n"; } cout << endl; }
本文小結
遞歸回溯算法想明白了其實很簡單,因為大部分工作遞歸過程已經幫我們做了。再重復一下,遞歸回溯算法的基本模式:識別出當前格局,識別出當前格局所有可能的choice,嘗試一個choice,遞歸的檢查是否導致了一個solution,如果是,直接return true;否則嘗試另一個choice。如果嘗試了所有的choice,都不能導致一個解,return false從而觸發回溯過程。剩下的就是在函數的一開始定義遞歸終止條件,這個需要具體問題具體分析,一般情況下是,當前格局等於目標格局,遞歸終止,return false。
在理解了遞歸回溯算法的思想后,記住經典的permutation問題和子集問題,剩下就是多加練習和思考,基本沒有太難的問題。在geekforgeeks網站有一個回溯算法集合Backtracking,題目很經典過一遍基本就沒什么問題了。
參考文獻
[1] Exhaustive recursion and backtracking
[2] www.geeksforgeeks.org-Backtracking
[3] Backtracking algorithms "CIS 680: DATA STRUCTURES: Chapter 19: Backtracking Algorithms"
