写在前面
回溯法属于图的暴力深度优先搜索的一种应用,其特点是,维护一条深度遍历的路径,在路径的尽头(遍历完毕)判断条件是否满足,若是则找到一个组合,若否,则沿着递归深度的路径逐步回溯,回溯时调转遍历方向,递归至其他路径check是否满足条件,直至遍历完所有的路径则终止,递归深入+回溯退出。
LeetCode中对回溯法的总结非常到位,见链接,引用如下:
回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就 “回溯” 返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为 “回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。
回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。
重点批注:
- 题127虽然解法不是回溯解法,因为在此题中回溯解法不是最优,选BFS解最佳,另外题127和题126属于LT中经典且有一定难度的题,可重点关注;
经典解题场景
- 子集问题
37. 解数独
解题思路: 采用行填充的方式,先填充第1行,再填充第2行…,直至填充完第9行,在填充第i行时,若填充至第9列,则下一次将填充第i+1行第0列,OK,保持这个整个思路,接下来来计算按照规定动作写回溯算法的代码了,递归进入的时候修改board[i][j]
值,递归退出时复原board[i][j]
值。另外回溯函数还有一个特点,即函数返回值翻译递归的路径是否成功,若不成功,则需要回溯,调整搜索的方向。
class Solution {
public:
void solveSudoku(vector<vector<char>>& board) {
int rows = board.size(), cols = board[0].size();
helper(board, 0, 0);
}
bool isSudoku(vector<vector<char>>& board, int i, int j, char ch) {
int r = i - i % 3, c = j - j % 3;
for (int k = 0; k < 9; ++k) {
if (board[i][k] == ch) return false;
}
for (int k = 0; k < 9; ++k) {
if (board[k][j] == ch) return false;
}
for (int k = 0; k < 3; ++k) {
for (int l = 0; l < 3; ++l) {
if (board[r + k][c + l] == ch) return false;
}
}
return true;
}
bool helper(vector<vector<char>>& board, int i, int j) {
if (i >= 9) return true;
if (j == 9) return helper(board, i + 1, 0);
if (board[i][j] != '.') return helper(board, i, j + 1);
for (char c = '1'; c <= '9'; ++c) {
if (!isSudoku(board, i, j, c)) continue;
board[i][j] = c;
if (helper(board, i, j + 1)) return true;
board[i][j] = '.';
}
return false;
}
};
51. N皇后
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。
每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
解题思路: 本题的解题思路依然是遵照回溯算法的模板做题,放置的方法依然是逐行放置,理解回溯算法后解题比较简单,请看代码。
class Solution {
public:
vector<vector<string>> solveNQueens(int n) {
vector<vector<string>> res;
vector<int> out(n, -1);
helper(n, 0, out, res);
return res;
}
void helper(int n, int r, vector<int> out, vector<vector<string>> &res) {
if (r >= n) {
vector<string> tmp(n, string(n, '.'));
for (int i = 0; i < n; ++i) {
tmp[i][out[i]] = 'Q';
}
res.push_back(tmp);
}
for (int k = 0; k < n; ++k) {
if (!isValid(n, out, r, k)) continue;
out[r] = k;
helper(n, r + 1, out, res);
out[r] = -1;
}
}
bool isValid(int n, vector<int> out, int r, int c) {
for (int k = r - 1; k >= 0; --k) {
if (out[k] == c) return false;
}
for (int k = 1; r - k >=0 && c - k >= 0; ++k) {
if (out[r - k] == c - k) return false;
}
for (int k = 1; r - k >= 0 && c + k < n; ++k) {
if (out[r - k] == c + k) return false;
}
return true;
}
};
127. 单词接龙
给定两个单词(beginWord 和 endWord)和一个字典,找到从 beginWord 到 endWord 的最短转换序列的长度。转换需遵循如下规则:
- 每次转换只能改变一个字母。
- 转换过程中的中间单词必须是字典中的单词。
解题思路: 我们可以手动解一下此题,我们会尝试依次对word的第0-n-1位上用’a’-'z’来修改它,看它是否在字典中,以决定是否继续搜索,显然我们能够感知是一个图的遍历问题,那么接下来是选DFS还是选BFS,这里我们会联想到迷宫找出口的题,那么此题自然选BFS,OK至此此题基本就解出来了。但是BFS是近乎Brute Force解法,因此我们还需要剪枝,做两步剪枝:1)若修改后的word与原word相同,则此条路径break,这个不用解释,2)若搜索时修改后的word在字典中,可将字典中这个word删掉,这是为什么呢?因为下次搜索到此word时,又会把后来的路径重新走一次,显然此次找到的word路径比后面再次找到word的路径短,那如果我们删掉它,有没有可能导致更短的路径搜不到word呢?不会,因为第一次碰到word,这条路径是有word中最短的(这是根据BFS层层打开的特征推断的),这里我们可以用反证法证,假设有一条更短的路径(简称A)在word删除之后搜索到的,那请问如果存在这条更短路径,那这更短路径应该在更小的层(更早)被搜索到(推论1),而在word被删掉之后搜索掉路径A,这明显与推论1矛盾,因此2)解答完毕。
class Solution {
public:
int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
unordered_set<string> st(wordList.begin(), wordList.end());
if (!st.count(endWord)) return 0;
queue<string> q{{beginWord}};
int res = 0;
while (!q.empty()) {
++res;
for (int i = q.size(); i > 0; --i) {
string word = q.front(); q.pop();
if (word == endWord) return res;
for (int j = 0; j < word.size(); ++j) {
string newWord = word;
for (char ch = 'a'; ch <= 'z'; ++ch) {
newWord[j] = ch;
if (st.count(newWord) && newWord != word) {
q.push(newWord);
st.erase(newWord);
}
}
}
}
}
return 0;
}
};
97. 交错字符串
解题思路: 打脸了打脸了,╮(╯▽╰)╭,这题是在动态规划笔记中补充的解法,在笔记中,我最初想的是回溯解此题,但是没找到合适的剪枝方法,在解完动态规划解法后,沿着博主的博客看到两层剪枝,即1)当其中一个字符串耗尽时,剩下的另一个字符串后半段和s3直接子串匹配,2)记录匹配错误的位置,虽然博主把他的解法归为BFS/DFS,但是回溯法不正是DFS的一种应用嘛,没等看完,立马捡起最初写的回溯代码,将剪枝加上,AC~,代码如下。
OK,我们回过头分析一下,为什么会想到这样的剪枝方式以及为什么这种剪枝能成立,剪枝1好理解不解释,这里解释剪枝2,当s1s2s3字符串比较长时,s1s2位置对(i,j)会在回溯中重复出现的占比非常大,这个我们在程序中加打印检验很容易观察出这个现象,此处我们从理论上解释为什么出出现这种情况,假设我们某次访问的位置对是(k1,r1),那下次回溯的路径中假设有(i,j),若此时我们在这条路径上判断出来(i,j)是假,那一定至少存在一条路径也穿过(i,j),严格意义上这条路径我们是不再需要判断的(剪枝),e.g.,若在回溯路径上某次访问的是位置对(k2,r2),这完全是有可能的,那这条路径也有可能走(i,j)。以实际数字举例,假设某个访问的是位置对(40,39),沿着这条路径走,会访问到(51,67),而在另一个路径中,假设某个访问位置对(45,65),沿着路径走,也会访问到(51,67),假设此时在前一条路径上我们已经判断出来(51,76)是假,那么实际上第二条路径就不必再穿过(51,76)了。
实际上,把这题画成网络交叉图,会更好路径,而每次访问的结果用交点表征。
class Solution {
public:
bool isInterleave(string s1, string s2, string s3) {
if (s1.size() + s2.size() != s3.size()) return false;
return helper(s1, 0, s2, 0, s3, 0);
}
bool helper(string s1, int i, string s2, int j, string s3, int k) {
int key = i * s3.size() + j;
if (st.count(key)) return false;
if (i == s1.size()) return s2.substr(j) == s3.substr(k);
if (j == s2.size()) return s1.substr(i) == s3.substr(k);
if (i < s1.size() && s1[i] == s3[k]) {
if (helper(s1, i + 1, s2, j, s3, k + 1)) return true;
}
if (j < s2.size() && s2[j] == s3[k]) {
if (helper(s1, i, s2, j + 1, s3, k + 1)) return true;
}
st.insert(key);
return false;
}
private:
unordered_set<int> st;
};
78. 子集
解题思路: 本题考查回溯算法,一般考察类似试探型解题的场景都应该想到是不是能用回溯算法解。本题按照回溯算法的模板写即可,由于求的是子集,那么对每一个出现的out都需要保存。
// 考察回溯法,按照模板写即可
// T:O(n), space:O(n)
class Solution {
public:
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> res;
vector<int> out;
helper(nums, 0, out, res);
return res;
}
void helper(vector<int>& nums, int i, vector<int> out, vector<vector<int>>& res) {
res.push_back(out);
if (i >= nums.size()) return;
for (int j = i; j < nums.size(); ++j) {
out.push_back(nums[j]);
helper(nums, j + 1, out, res);
out.pop_back();
}
}
};
90. 子集 II
解题思路: 本题在题78的基础上增加了,序列元素可重复,并且依然要求子集不重新,那么这里需要考虑如何对子集进行去重,解好此点即可解此题,一个直接且偷懒的方法,去重最常规的方法是用集合去重,因为集合一个重要的特点是,集合内元素不重复,在向集合中添加新元素时,集合会对添加的元素自动去重,但是如果使用集合去重,会引入额外的空间复杂度,因此解法不优。那有木有在遍历过程中直接去重的方法?如果我们没有思路或者一下子思路打不开,可以尝试的手动解此题,首先此题的解题肯定是依据题78回溯代码改,那么在哪儿加去重代码,如何加,这应该是我们需要思考的,OK,我们可以手动模拟回溯过程,并画图,回溯算法的本质是图的遍历,因此我们需要画图的遍历。根据回溯算法定义,每个多叉树(图)的路径即为我们一个子集元素,我们观察标红的2,当我们获取了第一个2的全部路径后,第二个2的路径我们就可以舍弃掉了,OK,至此我们应该知道如何去重如何continue了。请看下面代码。
class Solution {
public:
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
sort(nums.begin(), nums.end());
vector<vector<int>> res;
vector<int> out;
subsetsWithDup(nums, 0, out, res);
return res;
}
void subsetsWithDup(vector<int>& nums, int i, vector<int> out, vector<vector<int>>& res) {
res.push_back(out);
if (i >= nums.size()) return;
for (int j = i; j < nums.size(); ++j) {
if (j > i && nums[j] == nums[j - 1]) continue;
out.push_back(nums[j]);
subsetsWithDup(nums, j + 1, out, res);
out.pop_back();
}
}
};