小白易懂的回溯算法!!!
1.什么是回溯算法?
- 回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。
回溯算法解题套路模板:
- 1.回溯出口,当找到了一个问题的解时,存储该解。
- 2.回溯主体,就是遍历当前的状态的所有子节点,并判断下一个状态是否是满足问题条件的,如果满足问题条件,那么进入下一个状态。
- 3.状态返回,如果当前状态不满足条件,那么返回到前一个状态。
res = [] # 定义全局变量保存最终结果
state = [] # 定义状态变量保存当前状态
p,q,r # 定义条件变量(一般条件变量就是题目直接给的参数)
def back(状态,条件1,条件2,……):
if # 不满足合法条件(可以说是剪枝)
return
elif # 状态满足最终要求
res.append(state) # 加入结果
return
# 主要递归过程,一般是带有 循环体 或者 条件体
for # 满足执行条件
if # 满足执行条件
back(状态,条件1,条件2,……)
back(状态,条件1,条件2,……)
return res
- 简化版
public void backtrack(...){
if(是一个可行解) 将结果存入集合中;
if(无备选项或无须进一步搜索) return;
for(所有的备选项){
if(该备选项不满足约束) continue;
生成当前节点;
更新集合;
子问题backtrack();
//状态回退
删除节点;
回退集合;
}
}
实战出真知,下面请看例题: 例1: 46. 全排列
- 1.出口:当temp列表的大小和给定数组的长度一致时,说明形成了一个可行结果,需要存入ret中。同时两者数值大小一致,也说明再无备选项,搜索应该回溯到上一步。
- 2.主体: 筛选满足约束的备选项。这里用到了一个布尔数组uesd,用来记录哪些数是已经被使用了的。显然我们应该选取那些未被使用过的数。布尔数组的技巧非常实用,应该记住。
- 3.返回: 产生子问题并求解,求解完后恢复求解之前的状态。子问题求解前需要更新布尔数组used和暂存列表temp,子问题求解完以后,需要恢复used和temp之前的状态。
#include <iostream>
#include <vector>
using namespace std;
class Solution {
public:
vector<vector<int>> permute(vector<int>& nums)
{
vector<vector<int>> ret;
vector<bool> used(nums.size(), false);
backTrack(nums, ret,new vector<int>,used);
return ret;
}
void backTrack(vector<int>& nums, vector<vector<int>>& ret, vector<int>* temp, vector<bool>& used)
{
//当temp数组存储元素个数等于size时,说明找到了一个解
if (temp->size() == nums.size())
{
ret.push_back(*temp);
}
else
{
//遍历所有可能子节点
for (int i = 0; i < nums.size(); i++)
{
//如果当前子节点已经被访问过,那么就跳过该子节点,访问下一个子节点
if (used[i]== true)
continue;
//设置当前没有访问过的子节点为访问过
used[i] = true;
//并加入temp数组
temp->push_back(nums[i]);
//temp的数组个数还不等于size,无法构成一个完整的解,因此需要通过递归找到剩余解
backTrack(nums,ret,temp, used);
//进行回退操作,将当前子节点的访问性改成未被访问过
used[i] = false;
//删除temp数组尾部元素,弹出当前子节点,因为当前子节点不符合规则
temp->pop_back();
}
}
}
};
void test()
{
Solution s;
vector<int> nums = { 1,2,3 };
vector<vector<int>> ret=s.permute(nums);
for (int i = 0; i < ret.size(); i++)
{
for (int j = 0; j < ret[0].size(); j++)
{
cout << ret[i][j] << " ";
}
cout << endl;
}
}
int main()
{
test();
system("pause");
return 0;
}
官方解法:
- 要排数组分两半,左排过,右没排
- 通过交换,来得到不同的排列组合
class Solution {
public:
void backtrack(vector<vector<int>>& res, vector<int>& output, int first, int len){
// 所有数都填完了
if (first == len) {
res.emplace_back(output);
return;
}
for (int i = first; i < len; ++i) {
// 动态维护数组
swap(output[i], output[first]);
// 继续递归填下一个数
backtrack(res, output, first + 1, len);
// 撤销操作
swap(output[i], output[first]);
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int> > res;
backtrack(res, nums, 0, (int)nums.size());
return res;
}
};
例2: 47. 全排列 II
相较于上一题全排列,这题最大的难点在于如何解决重复数字的排列问题。 如果继续采用上一题的解法,在输入为[1,1,2]的情况下,[2,1,1]这种结果会出现2次,显然不满足题目要求。 这里我们不妨回顾一下排列公式和组合公式。A(3,4) = 432,而C(3,4) = 432/(321),两者的唯一区别在于,组合去除了排列的顺序,即组合不在乎所选3个数字的具体选择顺序是怎么样的,只在乎我们具体选择了哪3个数字。 进一步,若给排列问题加上数字选择的顺序约束,即只能按照编号从小到大选择数字,其实质也将变为组合问题。 回到原问题,我们能否也去除数组中这两个1的顺序性,从而避免产生重复情况。类似的,我们将固定2个1的选择顺序,只能先选择第1个1,再选择第2个1,而不存在先选择第2个1,再选择第1个1的情况。 当然在调用回溯算法以前,我们需要先对数组进行排序,从而确保相同的数是相邻的。除此之外,和上一题的唯一差别仅在于选取备选项时要求固定顺序。
class Solution {
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
vector<vector<int>> ret;
vector<bool> used(nums.size(), false);
vector<int> path;
sort(nums.begin(), nums.end());
backTrack(nums, ret,path,used);
return ret;
}
void backTrack(vector<int>& nums, vector<vector<int>>& ret, vector<int>& path, vector<bool>& used)
{
if (path.size() == nums.size())
{
ret.push_back(path);
return;
}
else
{
for (int i = 0; i < nums.size(); i++)
{
if (used[i] || (i>0&&nums[i] == nums[i-1] && !used[i-1]))
continue;
used[i] = true;
path.push_back(nums[i]);
backTrack(nums, ret, path, used);
used[i] = false;
path.pop_back();
}
}
}
};
例3: 39. 组合总和
- 1.出口:当所选取数字和为目标值时
- 2.主体:每一次都从数组首元素开始选择一个元素,如果最后发现累加发现无法构成目标值,就回退从第二个元素试水,依次类推
- 3.返回:构不成目标值或者构成了目标值还需要继续寻找其他解,都需要回退剪 枝来寻找其他可能解
注意:
这题同样存在如何避免重复结果的问题。类似的,为了变排列问题为组合问题,我们对数组的全部数字固定了选择顺序——下标小的数字必须在下标大的数字之前被选择。这里不再用到布尔数组used,原因是当我们固定了选择顺序后,在每一步,我们都容易知道,下标大于当前数字的是未被选择过的,而下标小于当前数字的是已经被选择过的。 不能再次选择之前选择过的数字,当选过第二个数字后,就不能选择第一个数字了
class Solution {
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target)
{
vector<vector<int>> ret;
vector<int> path;
backTrack(ret, target, path, candidates,0);
return ret;
}
//这里left作用:不能再次选择之前选择过的数字
void backTrack(vector<vector<int>>& ret, int target, vector<int>& path, vector<int>& cand, int left)
{
if (accumulate(path.begin(), path.end(), 0) == target)
{
ret.push_back(path);
return;
}
else if (accumulate(path.begin(), path.end(), 0) > target)
{
return;
}
else
{
for (int i = left; i < cand.size(); i++)
{
path.push_back(cand[i]);
backTrack(ret, target, path, cand, i);
path.pop_back();
}
}
}
};
例4:走迷宫
//迷宫 1为墙 0为通路
int Graph[][6] =
{
{1, 1, 1, 1, 1, 1},
{1, 0, 0, 0, 1, 1},
{1, 0, 1, 0, 0, 1},
{1, 0, 0, 0, 1 ,1},
{1, 1, 0, 0, 0, 1},
{1, 1, 1, 1, 1, 1}
};
- 打印输出所有通路
出口:找到了迷宫出口 主体:从起点开始,不断往四周深入探索,直到遇到最终出口 返回:如果找到了一条通路或者进入思路,那么需要往后退一步,寻找其他可能解
#include<iostream>
using namespace std;
#include<vector>
#include<algorithm>
//迷宫 1为墙 0为通路
int Graph[][6] =
{
{1, 1, 1, 1, 1, 1},
{1, 0, 0, 0, 1, 1},
{1, 0, 1, 0, 0, 1},
{1, 0, 0, 0, 1 ,1},
{1, 1, 0, 0, 0, 1},
{1, 1, 1, 1, 1, 1}
};
//迷宫 起点 终点
void findPath(int(*Graph)[6], int x1, int y1, int x2, int y2)
{
//存放迷宫路径
static vector<pair<int,int>> path;
//找到出口
if (x1 == x2 && y1 == y2)
{
cout << "迷宫路径如下:" << endl;
path.push_back({ x1,y1 });
Graph[x1][y1] = -1;
for_each(path.begin(), path.end(), [](pair<int, int> path) {cout << "{" << path.first << "," << path.second << "}" << endl; });
return;
}
else
{
if (Graph[x1][y1]==0)//当前要走的方向为通路
{
int di = 0;//方向--右 下 左 上
int x, y;//记录下一次要走的方向
for (; di < 4; di++)
{
switch (di)
{
case 0://右---列加一
{
x = x1;
y = y1 + 1;
break;
}
case 1://下---行加一
{
x = x1 + 1;
y = y1;
break;
}
case 2://左---列减一
{
x = x1;
y = y1 - 1;
break;
}
case 3://上---行减一
{
x = x1 - 1;
y = y1;
break;
}
}
path.push_back({ x1,y1 });
Graph[x1][y1] = -1;
findPath(Graph, x, y, x2, y2);//更新寻找的起点
//回溯加剪枝
Graph[x1][y1] = 0;
path.pop_back();
}
}
}
}
int main()
{
findPath(Graph, 1, 1, 4, 4);
system("pause");
return 0;
}
总结:
- 现在我们对回溯算法进行总结。回溯算法的本质是一种深搜递归遍历。通过上面的例题,我们可以知道回溯算法可以对各类组合、排列问题进行较好的求解,所以当遇到可以抽象建模为排列或组合的问题,回溯算法都可以作为一种求解手段。
相关文章
- 在 Go 里用 CGO?这 7 个问题你要关注!
- 9款优秀的去中心化通讯软件 Matrix 的客户端
- 求职数据分析,项目经验该怎么写
- 在OKR中,我看到了数据驱动业务的未来
- 火山引擎云原生大数据在金融行业的实践
- OpenHarmony富设备移植指南(二)—从postmarketOS获取移植资源
- 《数据成熟度指数》报告:64%的企业领袖认为大多数员工“不懂数据”
- OpenHarmony 小型系统兼容性测试指南
- 肯睿中国(Cloudera):2023年企业数字战略三大趋势预测
- 适用于 Linux 的十大命令行游戏
- GNOME 截图工具的新旧截图方式
- System76 即将推出的 COSMIC 桌面正在酝酿大变化
- 2GB 内存 8GB 存储即可流畅运行,Windows 11 极致精简版系统 Tiny11 发布
- 迎接 ecode:一个即将推出的具有全新图形用户界面框架的现代、轻量级代码编辑器
- loongarch架构介绍(三)—地址翻译
- Go 语言怎么解决编译器错误“err is shadowed during return”?
- 敏捷:可能被开发人员遗忘的部分
- Denodo预测2023年数据管理和分析的未来
- 利用数据推动可持续发展
- 在 Vue3 中实现 React 原生 Hooks(useState、useEffect),深入理解 React Hooks 的