《简记回溯算法》
回溯算法是递归的一种形式;是一种暴力枚举算法,但却是解决特定问题所必须的算法,相比于深度优先搜索,回溯算法需要记录选择的Sequence,而且具有显式的回溯操作,但是本质上是一样的,本文主要记录一下对于不同的场景如何对回溯的结果进行有效的剪枝。
Key Words:BackTracking、剪枝方法
Beijing, 2020
作者:RaySue
Code:
文章目录
回溯算法 BackTracking
回溯介绍
回溯算法通常的场景是你面对一定数量的选择,并且你必须从这些选择中选择一。当你选择其中之一的时候你会得到一个新的选择集合;也就是你会得到什么样的选择集合取决于你做过的选择。这个过程会一遍一遍的重复,直到你达到了最终的状态。如果你做的一系列的选择是好的,那么你的最终的状态就是好的状态;否则就不是。
形象的说,你在一棵树的树根开始;这个树可能有一些好的叶子和一些坏的叶子,不过也可能所有的叶子都是好的或坏的。你想要得到一片好的叶子。从根节点开始,你从所有的孩子节点中选择一个执行下去,然后你一直这样做直到你得到了一片叶子。
假设你得到了一片坏的叶子。你可以“回溯”来继续寻找一片好叶子,具体做法是通过撤销你最近一次的选择,然后试验选择集合中的下一个选择。如果你已经做完了所有的选择,那就撤销让你到达这个选择集合的选择,然后在那个节点来尝试其他的选择。如果你最终返回到了根节点,而且没有做任何的选择,说明没有发现好叶子。
这有一个例子:
- 从根节点开始,你的选择是 A 和 B。你选择了 A。
- 在节点 A,你的选择是 C 和 D 。你选择了 C。
- 节点 C 是坏叶子,返回到 A。
- 在节点 A,你已经选择过 C 了,并且失败了,尝试 D。
- 节点 D 是坏叶子,返回到 A。
- 在 A 节点你已经没有选择了,回溯到根节点。
- 在根节点,你已经尝试过 A 了,所以选择 B。
- 在 B 节点,你可以选择 E 或 F,尝试 E。
- E 是好叶子,恭喜!
在这个例子中,我们画了一棵树的图,这棵树是我们做出选择的序列的可能结果的抽象模型。但是通常我们没有一个数据结构来告诉我们我们已经做过了哪些选择。(如果我们有一个真实的树的数据结构,回溯算法就相当于深度优先搜索)
算法程序框架
Python
result = []
def backtrack(选择列表,深度,路径,...):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(选择列表,深度,路径,...)
撤销选择
Cpp
vector<int> ans;
void backtrack(选择列表,深度,路径,...)
{
if (满足条件)
{
ans.push_back(路径);
return;
}
for (auto 选择 : 选择列表)
{
做选择
backtrack(选择列表,深度,路径,...)
}
}
程序框架解读
这个程序框架是比较清晰明了的,首先要清楚,我们的终止条件是什么,可能是深度达到某个值后可能到达最终状态,或者是选择列表内的元素满足某种条件(比如相加等于某个数)我们达到最终的状态;然后要清楚我们在每一步所对应的选择集合有哪些;做选择的过程是有先后顺序的,因为选择列表是有序的,最后我们应该清楚什么样的选择是无效的,进行剪枝,减少运算量,比如N皇后的问题,我们不需要对每个位置都进行遍历,那将是 n n + 1 n^{n+1} nn+1的灾难计算。
思路:
-
终止条件(决定backtrack的入参需要加哪些变量)
-
每一步的选择列表,做选择和撤销选择是关于backtrack对称的
-
剪枝,在某种非终止条件下停止选择开始回溯(决定backtrack的入参需要加哪些变量)
复杂度
复杂度可以参考全排列问题,不管怎么优化,都符合回溯框架,而且时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的。这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。
常用剪枝方法
-
选择中continue,剪掉了这次选择对应的子树
-
选择中return,剪掉了当前余下的所有选择,回溯,重新选上一次的选择
-
vector isVis; // 用于记录已经做过的选项,防止重复选择
-
if (i > 0 && nums[i-1] == nums[i] && !isVis[i - 1]) continue; // 用以保证相同元素的顺序,前面相同的元素还没出现,后面不可以出现 (需要sort)
-
参数start:for (int i = start; i < candidates.size(); ++i) // 用来保证前后的顺序,当前的选择仅限于选择当前或其后面 (需要sort)
-
N皇后问题我们就可以根据题意来写对应的剪枝方法
39.组合总和(硬币找零)
每个硬币可以无限次使用,但是结果不能出现重复的数组。
- 加入参数start,然后在backtrac的时候,传入当前的 i ,backtrack(candidates, i(int start), depth + 1, target, path);
46.全排列
给定一个没有重复数字的序列,返回其所有可能的全排列
- 利用 isVis 对已经出现的选择进行记录(保证已选择列表无重复元素) if (isVis[i]) continue;
46.全排列II
给定一个可包含重复数字的序列,返回所有不重复的全排列(虽然存在重复元素,但是需要保证三点即可保证不重复,1.元素要有序,这样重复元素才挨着;2.出现过的元素不再出现;3.重复的元素要按照顺序出场)
- 先对数组进行排序
- 利用 isVis 对已经出现的选择进行记录 if (isVis[i]) continue;
- 相邻的相等且前一个还没出现,那么就剪枝(说明顺序乱了)if (i > 0 && nums[i-1] == nums[i] && !isVis[i - 1]) continue;
根据回溯框架,原创的两道LC的题解
131. 分割回文串
- 思路:
-
由于是回文串分割,那么就少不了回文串的判断,先写个回文串的判断, isPalindrome(string &s, int l, int r)
-
然后开始设计回溯函数backtrack,终止条件可以为当前位置变量 cur 恰好等于 s.size(),说明已经分割完成,如果 cur 大于 s.size()(剪枝1)
-
选择集合是[1,2,3,…,s.size()],每次从中选择一个长度,然后cur位置到新选择的长度得到的子串需要判断是否是回文串 (剪枝2),更新 cur = cur + i
-
由于cur是累加的,所以能够保证顺序,相当于组合数中的 start 变量
-
刚看到这个问题的时候我头脑中想到的模型是硬币找零,给定[1,2,3],找到所有能够等于 3 的组合数, [1,2,3] 1 1 1; 1 2; 2 1; 3 但需要start变量来控制顺序,原理是一样的,只不过这里变为了对字符串截取子串而已。
class Solution
{
public:
vector <vector<string>> ans;
bool isPalindrome(string &s, int l, int r)
{
r += l - 1;
while (l < r)
{
if (s[l++] != s[r--]) return false;
}
return true;
}
void backtrack(string &s, int cur, vector<string> path)
{
if (cur == s.size())
{
ans.push_back(path);
return;
}
for (int i = 1; i <= s.size(); ++i)
{
if (cur + i > s.size()) continue;
if (!isPalindrome(s, cur, i)) continue;
// symmetry
path.push_back(s.substr(cur, i));
cur += i;
backtrack(s, cur, path);
cur -= i;
path.pop_back();
}
}
vector <vector<string>> partition(string s)
{
vector<string> path;
backtrack(s, 0, path);
return ans;
}
};
79. 单词搜索
- 思路
-
明确 终止条件:depth 等于 target.size()的时候
-
明确 每步的选择集合,上下左右四个方向
-
明确剪枝情况:
3.1 depth > target.size() 的时候直接 return;
3.2 搜索边界溢出的时候 continue
3.3 如果下一个选择和我们预期的不符 continue
3.4 不走回头路,如果下一个选择走过 continue -
遍历整个board,如果发现起始字母和target的首字母相同,开始回溯
v1.0版本
这个程序提交后较慢,速度和空间都是5% ^_^!
class Solution
{
public:
bool ans = false;
int direction[4][2] = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
void backtrack(vector <vector<char>> &board, vector<vector<int>> isVis, int depth, int i, int j, string path, string &target)
{
if (ans) return;
if (depth == target.size())
{
ans = true;
return;
}
for (int p = 0; p < 4; ++p)
{
if (depth > target.size()) return;
if (i + direction[p][0] < 0 || i + direction[p][0] >= board.size() || j + direction[p][1] < 0 || j + direction[p][1] >= board[0].size()) continue;
if (board[i + direction[p][0]][j + direction[p][1]] != target[depth]) continue; // 剪枝
if (isVis[i + direction[p][0]][j + direction[p][1]] == 1) continue;
isVis[i + direction[p][0]][j + direction[p][1]] = 1;
path += board[i + direction[p][0]][j + direction[p][1]];
backtrack(board, isVis, depth + 1, i + direction[p][0] , j + direction[p][1], path, target);
path.pop_back();
isVis[i + direction[p][0]][j + direction[p][1]] = 0;
}
}
bool exist(vector <vector<char>> &board, string word)
{
for (int i = 0; i < board.size(); ++i)
{
for (int j = 0; j < board[0].size(); ++j)
{
if (board[i][j] != word[0]) continue;
vector<vector<int> > isVis(board.size(), vector<int>(board[0].size(), 0));
isVis[i][j] = 1;
string path (1, board[i][j]);
backtrack(board, isVis, 1, i, j, path, word);
}
}
return ans;
}
};
v2.0版本
- 优化后的版本,利用board自身来规避往回走的情况
Runtime:72 ms, faster than 52.83% of C++ online submissions.
Memory Usage:16.5 MB, less than 46.60% of C++ online submissions.
class Solution
{
public:
bool ans = false;
int direction[4][2] = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
void backtrack(vector <vector<char>> &board, int depth, int i, int j, string path, string &target)
{
if (ans) return;
if (depth == target.size())
{
cout << path << endl;
if (path == target)
{
ans = true;
return;
}
}
for (int p = 0; p < 4; ++p)
{
if (depth > target.size()) return;
if (i + direction[p][0] < 0 || i + direction[p][0] >= board.size() || j + direction[p][1] < 0 || j + direction[p][1] >= board[0].size()) continue;
if (board[i + direction[p][0]][j + direction[p][1]] != target[depth]) continue; // 剪枝
if (board[i + direction[p][0]][j + direction[p][1]] == '*') continue;
path += board[i + direction[p][0]][j + direction[p][1]];
board[i][j] = '*';
backtrack(board, depth + 1, i + direction[p][0] , j + direction[p][1], path, target);
board[i][j] = path[depth - 1];
path.pop_back();
}
}
bool exist(vector <vector<char>> &board, string word)
{
for (int i = 0; i < board.size(); ++i)
{
for (int j = 0; j < board[0].size(); ++j)
{
if (board[i][j] != word[0]) continue;
string path;
path.push_back(board[i][j]);
backtrack(board, 1, i, j, path, word);
}
}
return ans;
}
};
结论
回溯算法适合暴力枚举才能解决的问题,配合剪枝的话能够有效的提高程序的效率。
参考
https://www.cis.upenn.edu/~matuszek/cit594-2012/Pages/backtracking.html