深入浅出回溯算法
开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第23天,点击查看活动详情。
一,如何理解回溯算法
深度优先搜索算法利用的就是回溯算法思想,但它除了用来指导像深度优先搜索这种经典的算法设计之外,还可以用在很多实际的软件开发场景中,比如正则表达式匹配、编译原理中的语法分析等。
除此之外,很多经典的数学问题都可以用回溯算法解决,比如数独、八皇后、0-1
背包、图的着色、旅行商问题、全排列等等。
回溯的处理思想,有点类似枚举搜索。暴力枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。
回溯算法的模板代码总结如下:
cpp
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
二,回溯算法的经典应用
2.1,八皇后问题
有一个 8x8
的棋盘,希望往里放 8
个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子。这里的“对角线”指的是所有的对角线,不只是平分整个棋盘的那两条对角线。
解决思路:可以把这个问题划分成 8
个阶段,依次将 8
个棋子放到第一行、第二行、第三行……第八行,每一行都有 8 中放法(8 列)。在放置的过程中,我们不停地检查当前放法,是否满足要求。如果满足,则跳到下一行继续放置棋子;如果不满足,那就再换一种放法,继续尝试。这里用的是回溯思想,而回溯算法也非常适合用递归代码实现。
```cpp
// N 皇后问题 leetcode 51 http://leetcode-cn.com/problems/n-queens/
class Solution {
private:
vector
int leftup = column - 1; int rightup = column + 1; // 左上角和右上角
for(int i = row-1; i>=0; i--){ // 逐行网上考察每一行
// 判断第 i 行的 column 列是否有棋子
if(chessboard[i][column] == 'Q') {
return false;
}
// 考察左上对角线:判断第i行leftup列是否有棋子
if(leftup >=0 ){
if(chessboard[i][leftup] == 'Q') return false;
}
// 考察左上对角线:判断第i行rightup列是否有棋子
if(rightup < n){
if(chessboard[i][rightup] == 'Q') return false;
}
--leftup;
++rightup;
}
return true;
}
public:
vector
backtracking(n, 0, chessboard);
return result;
}
}; ```
2.2,0-1 背包问题
0-1
背包是非常经典的算法问题。0-1
背包问题有很多变体,这里介绍一种比较基础的。我们有一个背包,背包总的承载重量是 W
kg
。现在我们有 n
个物品,每个物品的重量不等,并且不可分割,即对于每个物品来说,都有两种选择,装进背包或者不装进背包,对于 n 个物品来说,总的装法就有 2^n 种。
我们现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量 W 的前提下,如何让背包中物品的总重量最大?
0-1
背包问题为什么不能用贪心算法求解?
因为不可分割,所以无法判断当前情况下,哪种物品对期望值贡献更大,即不存在当前最优的选择,所以就无法使用贪心算法了。
0-1
背包问题的高效解法是动态规划算法,但也可用没那么高效的回溯方法求解。我们可以把物品依次排列,整个问题就分解为了 n
个阶段,每个阶段对应一个物品怎么选择。先对第一个物品进行处理,选择装进去或者不装进去,然后再递归地处理剩下的物品。
```cpp int maxW = 0;
// cw 表示当前装进背包的物品的重量和,w 表示背包承载的重量
// items 表示物体的重量数组,n 表示总的物品个数, i 表示考察到第 i 个物品
int f(int i, int cw, vector
要理解 0-1
背包问题回溯解法的关键在于:对于一个物品而言,只有两种情况,不装入背包和装入背包两种情况。对应的就是 f(i+1, cw, items, n, w)
和 f(i+1, cw + items[i], items, n, w)
两个函数。
2.3,通配符匹配
假设正则表达式中只包含 “*”
和 “?”
这两种通配符,并且对这两个通配符的语义稍微做些改变,其中,“*”
匹配任意多个(大于等于 0
个)任意字符,“?”
匹配零个或者一个任意字符。基于以上背景假设,如何用回溯算法,判断一个给定的文本,是否和给定的正则表达式匹配?
如果遇到特殊字符的时候,我们就有多种处理方式了,也就是所谓的岔路口,比如 “*”
有多种匹配方案,可以匹配任意个文本串中的字符,我们就先随意的选择一种匹配方案,然后继续考察剩下的字符。如果中途发现无法继续匹配下去了,我们就回到这个岔路口,重新选择一种匹配方案,然后再继续匹配剩下的字符。
```cpp // 暴力递归 --> 记忆化 --> DP --> 状态压缩DP;
class Solution{ private: bool matched = false;
void backtracking(int ti, int pj, string text, string pattern){
if (matched) return;
if(pj == pattern.size()){ // 正则表达式到末尾了
if(ti == text.size())
matched = true;
return;
}
// *匹配任意个字符
if(pattern[pj] == '*'){
for(int k=0; k< text.size()-ti;k++)
backtracking(ti+k, pj+1, text, pattern);
}
// ?匹配0个或者1个字符
else if(pattern[pj] == '?'){
backtracking(ti, pj+1, text, pattern);
backtracking(ti+1, pj+1, text, pattern);
}
// 纯字符匹配才行
else if(ti < pattern.size() && pattern[pj] == text[ti]) {
backtracking(ti+1, pj+1, text, pattern);
}
}
public: bool isMatch(string text, string pattern){ matched = false; backtracking(0, 0, text, pattern); return matched; } }; ```
2.4,leetcode 正则表达式匹配
在 leetcode
也有变形题(leetcode10:正则表达式匹配)如下:
其他变形题:leetcode44-通配符匹配
给你一个字符串 s
和一个字符规律 p
,请你来实现一个支持 '.'
和 '*'
的正则表达式匹配。
- '.' 匹配任意单个字符
- '*' 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖整个字符串 s
的,而不是部分字符串。
方法一:回溯(分阶段分情况讨论,暴力搜索和剪枝)
首先,考虑特俗字符只有 '.'
的情况。这种情况会很简单:我们只需要从左到右依次判断 s[i] 和 p[i]
是否匹配。
```python def isMatch(self,s:str, p:str) -> bool: """字符串 s 和字符规律 p""" if not p: return not s # 边界条件
first_match = s and p[0] in {s[0],'.'} # 比较第一个字符是否匹配
return first_match and self.isMatch(s[1:], p[1:])
```
最后,考虑有 ’*'
的情况,它会出现在 p[1]
的位置,匹配过程中会出现两种情况:
- 星号代表匹配
0
个前面的元素。如'##'
和a*##
,这时我们直接忽略p
的a*
,比较##
和##
,也就是继续递归比较s
和p[i + 2:]
; - 星号代表匹配一个或多个前面的元素。如
aaab
和a*b
,这时我们将忽略s
的第一个元素,比较aab
和a*b
,也就是继续递归比较s[i + 1:]
和p
。(这里默认要检查s[0]
和p[0]
是否相等)。
Python3
代码如下:
```python class Solution: def isMatch(self, s: str, p: str) -> bool: if not p: return not s first_match = bool(s and p[0] in {s[0],'.'}) # 比较第一个字符是否匹配
if len(p) >=2 and p[1] == '*':
# * 匹配前面一个字符 0 次或者多次
return self.isMatch(s, p[2:]) or first_match and self.isMatch(s[1:], p)
else:
return first_match and self.isMatch(s[1:], p[1:])
```
C++
代码如下:
```cpp // letcode10 正则表达式匹配
include
include
using namespace std;
class Solution{ public: bool isMatch(string s, string p){ // 如果正则串 p 为空字符串,s 也为空,则匹配成功 if(p.empty()) return (s.empty()); // 判断 s 和 p 的首字符是否匹配,注意要先判断 s 不为空 bool match = (!s.empty()) && (s[0] == p[0] || p[0] == '.'); // 如果p的第一个元素的下一个元素是 ,则分别对两种情况进行判断 if(p.size() >= 2 && p[1] == ''){ // * 匹配前面一个字符 0 次或者多次 return isMatch(s, p.substr(2)) || (match && isMatch(s.substr(1), p)); } else{ // 单个匹配 return match && isMatch(s.substr(1), p.substr(1)); }
}
}; ```
直接递归时间复杂度太大(指数级),可以把之前的递归过程记录下来,用空间换时间。记忆化递归的 C++
代码如下:
```cpp
class Solution{
public:
bool isMatch(string s, string p){
unordered_map
bool backtracking(string s, int i, string p, int j, unordered_map<int, bool> & memo){
// # 检查 s[i] 是否能被匹配,注意要先判断 s 不为空
bool match = (i < s.size()) && (s[i] == p[j] || p[j] == '.');
if(j >= p.size()) return i >= s.size(); // p 和 s 同时遍历完
int key = i * (p.size() + 1) + j; // 哈希键
if (memo.find(key) != memo.end()) // 这个状态之前经历过,可以返回结果
return memo[key];
else if (i == s.size() && j == p.size()) // 如果s和p同时用完,匹配成功
return memo[key] = true;
else if((p.size()-j) >= 2 && p[j+1] == '*'){
// * 匹配前面一个字符 0 次或者多次
if(backtracking(s, i, p, j+2, memo) || match && backtracking(s, i+1, p, j, memo))
return memo[key] = true;
}
else { // 单个匹配
if(match && backtracking(s, i+1, p, j+1, memo))
return memo[key] = true;
}
return memo[key] = false; // 没辙了,匹配失败
}
}; ```
方法二:动态规划法
- [ ] 算法思路
- [ ] 代码
三,总结
回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率。
尽管回溯算法的原理非常简单,但是却可以解决很多问题,比如我们开头提到的深度优先搜索、八皇后、0-1
背包问题、图的着色、旅行商问题、数独、全排列、正则表达式匹配等等。
回溯算法能解决的问题,基本用动态规划也能解决,其时间复杂度更低,空间复杂度更高,用空间换时间。
参考资料
- ssh远程连接方式总结
- 深度学习基础-损失函数详解
- 模型压缩-剪枝算法详解
- 机器学习经典算法总结
- 深度学习基础-优化算法详解
- 深度学习基础-机器学习基本原理
- 机器学习基本概念总结
- 手把手教你注册和使用ChatGPT
- 深入浅出动态规划算法(中)
- 深度学习炼丹-数据预处理和增强
- 深入浅出回溯算法
- 深入浅出动态规划算法(上)
- GitHub 车牌检测识别项目调研
- 卷积神经网络压缩方法总结
- 神经网络模型复杂度分析
- MobileNetv1 论文详解
- ShuffleNetv2论文详解
- Pytorch基础-tensor数据结构
- Fast YOLO:用于实时嵌入式目标检测(附论文下载)
- 嵌入式新闻早班车-第24期