我的CSDN: ListerCi
我的簡(jiǎn)書: 東方未曦
一、回溯算法與DFS
回溯算法是暴力求解的一種,它能系統(tǒng)地搜索一個(gè)問題的所有解或者任意解。它通過深度優(yōu)先搜索遞歸地遍歷所有可能的解。遍歷到解空間的某個(gè)分支時(shí),如果確定當(dāng)前分支不滿足條件,那么就退回到上一步嘗試別的分支。這種回退到上一步的方式就是“回溯”,判斷分支是否有解就是“剪枝”。
如果你是一名新手,看完回溯的定義,你是心懷期待呢還是心亂如麻呢?其實(shí)回溯法是算法學(xué)習(xí)的入門,評(píng)價(jià)一個(gè)程序員是不是學(xué)過算法,看他會(huì)不會(huì)寫回溯就可以了。因此回溯算法頻繁地出現(xiàn)在各種筆試和面試中,當(dāng)你面對(duì)一個(gè)搜索問題時(shí),如果寫出了回溯法而不是一個(gè)又一個(gè)的for
循環(huán),面試官也會(huì)覺得你的代碼眉清目秀的。
回溯算法的基礎(chǔ)是深度優(yōu)先搜索(DFS),這種搜索會(huì)在一個(gè)分支上一直深入,直到遍歷完該分支,之后再嘗試另外的分支。DFS的偽代碼如下,visited[i]
數(shù)組代表i點(diǎn)是否訪問過。
void dfs(int v) {
visited[v] = true;
for (v的所有鄰接點(diǎn)w) {
if (!visited[w])
dfs(w);
}
}
回溯算法就是在DFS的過程里加入了返回條件以及剪枝,下面讓我們來通過Leetcode真題一步一步地去理解它。
二、Leetcode回溯真題
1. 求解子集
Leetcode地址:78. 子集
題目的意思很簡(jiǎn)單,給你一個(gè)長(zhǎng)度為n的不包含重復(fù)數(shù)字的序列,返回該序列的所有子集。既然該序列不包含重復(fù)數(shù)字,那么子集的個(gè)數(shù)肯定是2^n(因?yàn)榍笞蛹瘯r(shí)對(duì)每個(gè)元素都有選或者不選兩個(gè)操作),算法的時(shí)間復(fù)雜度就是O(2^n)。
那么選和不選這兩種操作怎么體現(xiàn)在程序中呢?舉個(gè)例子,假設(shè)當(dāng)前的序列為[1, 2, 3],我們可以選擇將元素1添加到子集中,再求剩下的序列[2, 3]的所有子集;我們也可以選擇不將元素1添加到子集中,再求[2, 3]的所有子集。
這明顯是一個(gè)遞歸的過程:對(duì)第i個(gè)元素選擇之后,將結(jié)果保存,再對(duì)剩下的元素進(jìn)行選擇。對(duì)所有的元素選擇完畢之后,得到的結(jié)果就是一個(gè)子集。
// nums為需要求子集的序列
vector<vector<int> > subsets(vector<int>& nums) {
vector<vector<int> > result; // 保存所有的子集
vector<int> path; // 在搜索時(shí)保存當(dāng)前的子集
search(0, nums, path, result); // 從第0個(gè)元素開始搜索
return result;
}
// idx表示當(dāng)前搜索nums[idx]
void search(int idx, vector<int>& nums, vector<int>& path, vector<vector<int> >& result) {
// 當(dāng)對(duì)所有元素選擇后,就得到了一個(gè)子集
if (idx >= nums.size()) {
result.push_back(path);
return; // 此處一定要返回
}
// 選擇nums[idx]
path.push_back(nums[idx]);
search(idx + 1, nums, path, result);
// 不選擇nums[idx]
path.pop_back();
search(idx + 1, nums, path, result);
}
2. 全排列
Leetcode地址:46. 全排列
題目給定一個(gè)沒有重復(fù)數(shù)字的序列,返回其所有可能的全排列。
上題求解子集時(shí)是對(duì)每個(gè)元素選擇是否添加到子集中,而求解全排列就是選擇添加哪個(gè)沒有被添加過的元素到當(dāng)前的排列中。因?yàn)橐袛嗄硞€(gè)元素是否被選取過,所以需要一個(gè)bool visited[i]
數(shù)組判斷i元素是否已經(jīng)被添加到排列中。
例如求解[1, 2, 3]的全排列,流程如下:
① 將1添加到第一位,此時(shí)1已經(jīng)被訪問,則visited[1]=true
,之后的元素只能從[2, 3]中選取,最終會(huì)形成1在第一位的所有全排列[1, 2, 3]和[1, 3, 2]。
② 此時(shí)1在第一位的所有情況都遍歷完成,需要將1的訪問狀態(tài)返還為未訪問,也就是visited[1]=false
。這樣之后將其他數(shù)放在第一位時(shí),依舊可以選擇1跟在后面。
③ 將2添加到第一位,再跟上[1, 3]的全排列。
vector<vector<int> > permute(vector<int>& nums) {
vector<vector<int> > result;
vector<int> cur;
int size = nums.size();
// 如果nums為空,返回空結(jié)果
if (size == 0)
return result;
// visited數(shù)組判斷當(dāng)前元素是否已經(jīng)添加到全排列中
bool* visited = new bool[size];
for (int i = 0; i < size; ++i)
visited[i] = false;
dfs(0, result, cur, nums, visited);
return result;
}
// index表示當(dāng)前添加第index個(gè)數(shù)字到全排列中
void dfs(int index, vector<vector<int> >& result, vector<int> cur, vector<int>& nums, bool* visited) {
if (index >= nums.size()) {
result.push_back(cur);
return;
}
for (int i = 0; i < nums.size(); ++i) {
// 如果當(dāng)前元素未被添加到全排列中
if (!visited[i]) {
// 將當(dāng)前元素添加到全排列
cur.push_back(nums[i]);
visited[i] = true;
dfs(index + 1, result, cur, nums, visited);
// 返還狀態(tài)
cur.pop_back();
visited[i] = false;
}
}
}
3. 組合總和
Leetcode地址:39. 組合總和
給定一個(gè)無重復(fù)元素的數(shù)組 candidates
和一個(gè)目標(biāo)數(shù) target
,找出 candidates
中所有可以使數(shù)字和為target
的組合。candidates
中的數(shù)字可以無限制重復(fù)被選取。
在這道題中,數(shù)組中的元素可以重復(fù)選取,因此不需要visited[]
來判斷某個(gè)數(shù)字是否被訪問過。在遍歷時(shí),如果確定將candidates[i]
添加到組合中,那么可以將target-candidates[i]
作為下一輪遞歸的新target
,如果新target
為0,那么之前的組合就是一個(gè)解;如果小于0,那么當(dāng)前分支不成立,退回到上一步;如果大于0,則可以再選取一個(gè)數(shù)字添加到組合中。
vector<vector<int> > combinationSum(vector<int>& candidates, int target) {
vector<vector<int> > result;
vector<int> path; // 用于保存臨時(shí)的組合
sort(candidates.begin(), candidates.end()); // 排序
search(0, result, candidates, path, target);
return result;
}
void search(int idx, vector<vector<int> >& result, vector<int>& candidates,
vector<int>& path, int target) {
if (target == 0) {
result.push_back(path);
return;
}
if (target < 0) {
return;
}
// 依此遍歷可選元素
for (int i = idx; i < candidates.size(); ++i) {
path.push_back(candidates[i]);
// 由于可以重復(fù)選取,因此下次遞歸仍舊可以從當(dāng)前元素開始
search(i, result, candidates, path, target - candidates[i]);
// 不選擇當(dāng)前元素,for循環(huán)向下遍歷
path.pop_back();
}
}
上述程序通過遞歸時(shí)判斷target
是否小于0來確認(rèn)當(dāng)前分支是否已經(jīng)無解,這樣的“剪枝”方式未免太過粗糙。如果將該程序放入OJ中運(yùn)行,雖然可以通過,但是運(yùn)行時(shí)間只擊敗了大約28%的程序,這種情況下一定存在優(yōu)化的辦法。
仔細(xì)查看后發(fā)現(xiàn),candidates
是經(jīng)過由小到大排序的,如果target-candidates[i] < 0
,那么target-candidates[i + 1], target-candidates[i + 2]......
都是小于0的。因此search()
函數(shù)可以這么優(yōu)化。
void search(int idx, vector<vector<int> >& result, vector<int>& candidates,
vector<int>& path, int target) {
if (target == 0) {
result.push_back(path);
return;
}
for (int i = idx; i < candidates.size(); ++i) {
if (target - candidates[i] < 0) {
break;
} else {
path.push_back(candidates[i]);
search(i, result, candidates, path, target - candidates[i]);
path.pop_back();
}
}
}
優(yōu)化之后,代碼的運(yùn)行時(shí)間擊敗了98%以上的程序,當(dāng)真是細(xì)節(jié)決定成敗。
4. 組合總和變體
Leetcode地址:40. 組合總和 II
這道題與上一道有不少區(qū)別,一是數(shù)字可能重復(fù),二是每個(gè)數(shù)字只能取一次。如果你認(rèn)為將上一題解答中的search(i, result...)
改為search(i + 1, result...)
就可以的話,那你就錯(cuò)了。
如果只是這么修改,在candidates
為[1, 1, 3]和target
為4的情況下,結(jié)果中會(huì)出現(xiàn)兩個(gè)[1, 3],因此我們需要?jiǎng)h掉重復(fù)結(jié)果。使用set是一個(gè)辦法,但是效率低下,我們可以在外層遍歷時(shí)跳過重復(fù)的數(shù)字從而達(dá)到跳過重復(fù)解的效果。
vector<vector<int> > combinationSum2(vector<int>& candidates, int target) {
vector<vector<int> > result;
vector<int> path;
sort(candidates.begin(), candidates.end());
search(0, result, path, candidates, target);
return result;
}
void search(int idx, vector<vector<int> >& result, vector<int>& path,
vector<int>& candidates, int target) {
if (target == 0) {
result.push_back(path);
return;
}
for (int i = idx; i < candidates.size(); ++i) {
if (i > idx && candidates[i] == candidates[i - 1])
continue;
if (target - candidates[i] < 0) {
break;
} else {
path.push_back(candidates[i]);
search(i + 1, result, path, candidates, target - candidates[i]);
path.pop_back();
}
}
}
如果你一時(shí)無法理解,可以在紙上模擬一下代碼的流程。
三、八皇后
講到回溯就繞不開八皇后問題,因?yàn)樗鼘?shí)在是太經(jīng)典了,Leetcode上有一道變種的N皇后問題,讓我們來一探究竟。
Leetcode地址:51. N皇后
國(guó)際象棋中,皇后可以攻擊同一直線和同一斜線的棋子。如果想要在N*N的棋盤上放置N個(gè)皇后,使它們之間互不攻擊,就意味著每行、每列、每條斜線上都只有一個(gè)皇后。下圖就是八皇后的一個(gè)解。
一個(gè)N*N的棋盤,顯然只有N行N列,那么每一行每一列上都會(huì)有一個(gè)皇后。那么這個(gè)棋盤有多少條斜線呢?初中數(shù)學(xué)告訴我們,正斜線和反斜線都有2N-1條。其中正斜線的斜率都為1,反斜線的斜率都為-1。
正斜線的公式為y = x + k1
,反斜線的公式為y = -x + k2
。其中,k1和k2的取值都有2N-1種,每個(gè)k1都能確定一條正斜線,每個(gè)k2都能確定一條反斜線。所以使用兩個(gè)數(shù)組就能確定每條斜線是否已經(jīng)被一個(gè)皇后占據(jù)了。(上述的斜率是在標(biāo)準(zhǔn)坐標(biāo)系中的,二維數(shù)組的坐標(biāo)系并不是如此。)
假設(shè)當(dāng)前需要在(i, j)
放置一個(gè)皇后,那么i
所代表的行和j
所代表的列必須沒有被占據(jù),而i - j
和i + j
所代表的兩條斜線也必須沒有被占據(jù)。為了節(jié)省空間,我們使用一維數(shù)組path[]
來存儲(chǔ)皇后的擺放情況,path[i] = val
代表i
行val
列有一個(gè)皇后。因?yàn)槊啃卸急仨氂幸粋€(gè)皇后,可以直接遍歷行來存放。
vector<vector<string> > solveNQueens(int n) {
vector<vector<string> > result;
// path[i] = val 代表i行val列有一個(gè)皇后
int* path = new int[n];
// 標(biāo)記某一列是否被占據(jù)
bool* visited = new bool[n];
for (int i = 0; i < n; ++i)
visited[i] = false;
// 標(biāo)記兩條斜線是否被占據(jù)
bool* slash1 = new bool[2 * n];
bool* slash2 = new bool[2 * n];
for (int i = 0; i < 2 * n; ++i) {
slash1[i] = false;
slash2[i] = false;
}
search(0, result, path, n, visited, slash1, slash2);
return result;
}
void search(int idx, vector<vector<string> >& result, int* path, int n,
bool* visited, bool* slash1, bool* slash2) {
// 這里是生成返回結(jié)果的,不重要
if (idx >= n) {
vector<string> tmp_result;
for (int i = 0; i < n; ++i) {
// 每一行
char* tmp = new char[n + 1];
for (int j = 0; j < n; ++j) {
if (path[i] == j)
tmp[j] = 'Q';
else
tmp[j] = '.';
}
tmp[n] = '\0';
string tmp_string(tmp);
tmp_result.push_back(tmp_string);
}
result.push_back(tmp_result);
return;
}
// 在每一行添加一個(gè)皇后
for (int i = 0; i < n; ++i) {
if (!visited[i] && !slash1[idx + i] && !slash2[idx - i + n]) {
// 該位置合法,嘗試在這里擺放一個(gè)皇后
path[idx] = i;
visited[i] = true;
slash1[idx + i] = true;
slash2[idx - i + n] = true;
search(idx + 1, result, path, n, visited, slash1, slash2);
// 不在此處擺放,返還狀態(tài)
visited[i] = false;
slash1[idx + i] = false;
slash2[idx - i + n] = false;
}
}
}