详解回溯法
2019/12/06 add背包问题
2019/12/09 add排列树问题
2019/12/10 addN皇后问题
2020/01/21 add leetcode-282
references:
【算法】用回溯法(backtracking algorithm)求解N皇后问题(N-Queens puzzle)
概念
回溯(backtracking)法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。
回溯法是一个既带有系统性又带有跳跃性的的搜索算法。它在包含问题的所有解的解空间树中,按照深度优先的策略,从根结点出发搜索解空间树。算法搜索至解空间树的任一结点时,总是先判断该结点是否肯定不包含问题的解。如果肯定不包含,则跳过对以该结点为根的子树的系统搜索,逐层向其祖先结点回溯。否则,进入该子树,继续按深度优先的策略进行搜索。回溯法在用来求问题的所有解时,要回溯到根,且根结点的所有子树都已被搜索遍才结束。而回溯法在用来求问题的任一解时,只要搜索到问题的一个解就可以结束。这种以深度优先的方式系统地搜索问题的解的算法称为回溯法,它适用于解一些组合数较大的问题.
适用条件
- 问题的解用向量表示
X = (x1, x2, ..., xn)
-
需要搜索一个或一组解
-
满足约束条件的最优解
回溯法中,首先需要明确下面三个概念:
- 约束函数:约束函数是根据题意定出的。通过描述合法解的一般特征用于去除不合法的解,从而避免继续搜索出这个不合法解的剩余部分。因此,约束函数是对于任何状态空间树上的节点都有效、等价的。
- 状态空间树:一个问题的解可以表示成解向量
X = (x1, x2, ..., xn)
,X中每个分量xi所有取值的组合构成问题的解向量空间,简称解空间或者解空间树,又称为状态空间树,是一个对所有解的图形描述。树上的每个子节点的解都只有一个部分与父节点不同。 - 扩展节点、活结点、死结点:所谓扩展节点,就是当前正在求出它的子节点的节点,在DFS中,只允许有一个扩展节点。活结点就是通过与约束函数的对照,节点本身和其父节点均满足约束函数要求的节点;死结点反之。由此很容易知道死结点是不必求出其子节点的(没有意义)。
- 由于采用回溯法求解时存在退回到祖先结点的过程,所以需要保存搜索过的结点。通常采用:
- 定义栈来保存
- 采用递归方法
- 用回溯法通常采用两种策略(均称为剪枝函数)避免无效搜索。
- 用约束函数在扩展结点处剪除不满足约束条件的路径
- 用限界函数剪去得不到问题的解或最优解的路径
解题步骤
(1)描述解的形式,定义一个解空间,它包含问题的所有解,这一步主要明确问题的解空间树。
(2)确定结点的扩展搜索规则。
(3)构造约束函数(用于杀死节点)。然后就要通过DFS思想完成回溯,DFS可以采用递归回溯或者非递归(迭代)回溯。
确定问题的解空间 --> 确定结点的扩展搜索规则–> 以DFS方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。
通用代码
// 递归版
const backtrack= (t)=>{
if (t>n) console.info(x); //叶子节点,输出结果,x是可行解
else{
// //当前节点的所有子节点
for (let i=0;i<k;i++){
x[t]=value(i); //每个子节点的值赋值给x
//满足约束条件和限界条件
if (constraint(t)&&bound(t))
backtrack(t+1); //递归下一层
}
}
}
//针对N叉树的迭代回溯方法
const iterativeBacktrack=()=>{
let t=1;
while (t>0) {
if(ExistSubNode(t)) //当前节点的存在子节点
{
for(let i=0;i<k;i++){
x[t]=value(i);//每个子节点的值赋值给x
if (constraint(t)&&bound(t))//满足约束条件和限界条件
{
//solution表示在节点t处得到了一个解
if (solution(t)){
console.info(x);//得到问题的一个可行解,输出
}else{
t++;//没有得到解,继续向下搜索
}
}
}
}
else //不存在子节点,返回上一层
{
t--;
}
}
}
子集树和排列树
子集树
所给的问题是从n个元素的集合S中找出满足某种性质的子集时,相应的解空间成为子集树。如素数环问题,背包问题
// 算法范式
const backtrack=t=>{
if (t>n) console.info(x);
else{
for (let i=0;i<=k;i++) {
// k的大小取决于子集树的分支多少,比如素数环可以取1-n, k=n 而背包问题是0-1 k=1
x[t]=i;
if (constraint(t)){
// 满足约束条件
backtrack(t+1);
// 下面代码进行回溯
// 一般是恢复到原值即可
}
}
}
}
排列树
所给的问题是确定n个元素满足某种性质的排列时,相应的解空间就是排列树。
如旅行售货员问题,一个售货员把几个城市旅行一遍,要求走的路程最小。它的解就是几个城市的排列,解空间就是排列树。
// 算法范式
const backtrack=t=>{
if (t>n) console.info(x);
else{
for (let i=t;i<=n;i++) {
swap(x[t], x[i]);
if (constraint(t)&&bound(t)) backtrack(t+1);
swap(x[t], x[i]);
}
}
}
回溯法和DFS的区别
- 访问次序不同:深度优先遍历的目的是“遍历”,本质是无序的,重要的是是否被访问过,因此在实现上只需要对于每个位置是否被访问就足够了。回溯法的目的是“求解过程”,本质是有序的,也就是说必须每一步都是要求的次序。
- 访问次数不同:深度优先遍历对已经访问过的顶点不再访问。回溯法中已经访问过的顶点可能再次访问。
- 剪枝不同:深度优先遍历不含剪枝。
实际上,除了剪枝是回溯法的一个明显特征外(并非任何回溯法都包含剪枝部分),很难严格区分回溯法 与深度优先遍历。因为这些算法很多是递归算法,在递归调用中隐含着状态的自动回退和恢复。
常见题型
素数环
思考三个问题:
- Q1:解空间是什么?
- 应该是一个长度为n的数组,数组可能为多个。
- Q2:扩展搜索原则是什么?
- 每一个位置的值都可以是
1-n
中间的数
- 每一个位置的值都可以是
- Q3:约束函数是什么?
check0
检查当前加进来的值是否和索引前有重复?是否和前一位的和为素数?若为最后一位是否和第一位的和为素数?
迭代
/**
* 问题描述:输入正整数n,把整数1,2,3,…n组成一个环,使得相邻两个整数之和均为素数。输出时从整数1开始逆时针排列。同一个环应恰好输出一次。N<=16。
输入样例:6
输出样例:
1 4 3 2 5 6
1 6 5 2 3 4
素数就是质数,因数只有1和它自己。
*/
/**
* 判断一个数是不是素数,只需要折半判断即可
* @param num
* @returns {boolean}
*/
const testPrime=num=>{
let mid=Math.floor(Math.sqrt(num));
for(let i=2;i<=mid;i++){
if(num%i===0){
return false;
}
}
return true;
};
/**
* 判断当前加进去的数字是否满足条件
* 1. 是否与之前重复
* 2. 相邻之和是否为素数
* 3. 第一个和最后一个相加是否为素数
* @param a
* @param k
* @param n
* @returns {boolean}
*/
const check=(a,k,n)=>{
let flag = false ;
for (let i =0;i<k;i++) //检查是否重复
if (a[i] === a[k])
return false;
flag = testPrime(a[k]+a[k-1]); //检验与相邻之和是否是素数
if (flag ===1&&k ===n-1) //如果是最后一个,需要检查与开始值得和
flag = testPrime(a[k]+a[0]);
return flag;
};
/**
* 1:如果填入一个数是成立的,我们就继续填写下一个位置,如果这个数不成立,我们就换下一个数填写,相当于我们剪掉了这个数的树枝,去寻找下一个;
* 2:填写最后一个数时,应该考虑其应该和第一个数之和是素数;
* 使用迭代法实现:虽然代码比较多,但是思路还是比较清晰的,回溯的位置也很清晰
* @param n
*/
const primeRing=n=>{
if(n===1) return [1];
let res=[];
// initialize arr
let arr=new Array(n).fill(0);
let k=1;
arr[0]=1;
while(k>=1)
{
arr[k]=arr[k]+1;
while(arr[k]<n){
if(check(arr,k,n)){
break;
}else{
// 否则进行叠加
arr[k]=arr[k]+1;
}
}
if(arr[k]<=n&&k===n-1){
console.info(arr);
let temp=JSON.parse(JSON.stringify(arr));
res.push(temp);
}
if(arr[k]<=n&&k<n-1&&check(arr,k,n)){
k+=1;
}else{
arr[k--]=0;//剪枝回溯
}
}
return res;
};
递归
/**
* 回溯法通常使用递归来实现,
* vis用来保存每一个1-n的元素是否在一个解向量中用过
*/
const primeRing1=n=>{
let arr=[],vis=[],res0=[];
arr[0] = 1;
vis[0]=true;
for(let i = 1; i <n;i++){
vis[i] = false;
}
/**
* check0的难点在于检测的永远是idx指针前面的数是否和k重复以及k和idx-1所在位置的数的和是否为素数
* 这也是能够回溯的关键
*/
const check0=(arr,k,idx)=>{
let flag;
for(let i=0;i<idx;i++){
if(arr[i]===k) return false;
}
flag=testPrime(arr[idx-1]+k);
return flag;
};
/**
* vis[i-1] = false;是不满足条件回溯的过程
* @param cur
*/
const dfs=cur=>{
if(cur >= n&&testPrime(arr[n]+arr[0])){
let temp=JSON.parse(JSON.stringify(arr));
res0.push(temp);
// console.info(arr);
// console.info(res0);
}else{
for(let i = 1; i <= n; i++){
// //尝试放置每个数i 使用这个代码或者下面的判断语句
// if(!vis[i-1] && testPrime(i+arr[cur-1])){//如果这个数没有被使用,并且与前一个数的和是质数
// vis[i-1] = true;//标明这个数被使用
// arr[cur] = i;//将这个数加入队列
// // console.info(arr);
// dfs(cur+1);//求下一个数
// vis[i-1] = false;//取消这个数
// }
//
if(check0(arr,i,cur)){//如果这个数没有被使用,并且与前一个数的和是质数
arr[cur] = i;//将这个数加入队列
dfs(cur+1);//求下一个数
}
}
}
};
dfs(1);
return res0;
};
// 用子集树的思路
const primeRing=n=>{
let res=new Array(n).fill(0);
res[0]=1;
const testPrime=num=>{
let temp=Math.sqrt(num);
for(let i=2;i<=temp;i++){
if(num%i===0){
return false;
}
}
return true;
};
const check=(arr,idx,val)=>{
let flag;
// 不用forEach 避免无法跳出循环出现错误
for(let i=0;i<idx;i++){
if(arr[i]===val){
return false;
}
}
flag=testPrime(arr[idx-1]+val);
if(idx===n-1){
flag=flag&&testPrime(arr[0],val);
}
return flag;
};
const dfs=k=>{
if(k>n-1){
console.info(res);
}else{
for(let i=1;i<=n;i++){
if(check(res,k,i)){
res[k]=i;
dfs(k+1);
// 此处不必另外加回溯语句是因为每次遍历都给res[k]重新赋值了而不必再更改赋初始值 默认进入回溯状态
//res[k]=0;
}
}
}
};
dfs(1);
};
背包问题
问题:给定n种物品和一背包。物品i的重量是wi,其价值为pi,背包的容量为C。问应如何选择装入背包的物品,使得装入背包中物品的总价值最大?
const knapsack=(tWeight,n,obj)=>{
let curWeight=0,curVal=0,maxVal=0,compose=new Array(n).fill(0)
,max=[];
let w=Object.keys(obj),v=[];
w.forEach(item=>{
v.push(obj[item]);
});
console.info(w,v);
const dfs=t=>{
if(t>n-1&&curWeight<=tWeight){
console.info('==>',compose,curVal);
// 控制输出条件
if(curVal>maxVal){
maxVal=curVal;
max=JSON.parse(JSON.stringify(compose));
}
}else{
for(let i=0;i<=1;i++){
if(i===0){
// 不放入背包
dfs(t+1);
}else{
// 放入背包
if(curWeight+Number(w[t])<=tWeight){
curWeight+=Number(w[t]);
curVal+=v[t];
compose[t]=w[t];
dfs(t+1);
// 回溯
compose[t]=0;
curWeight-=Number(w[t]);
curVal-=v[t];
}
}
}
}
};
dfs(0);
console.info('result==>',max,maxVal);
return maxVal;
};
N皇后问题
/**
* n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
* 给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。
* 每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。
* 显然从测试用例上看符合问题的解用向量表示,需要搜索一组解的情况,因此满足用回溯法解题的要求
* 解空间自然就是子集树
* 扩展规则是n个皇后可以放在任意位置
* 约束条件是:该位置无皇后,行方向没有,列方向没有,斜方向也没有皇后
* 但是时间复杂度过高,无法AC
* 修改回溯法之后:用k参数代表所在行,
* 时间复杂度:\mathcal{O}(N!)O(N!). 放置第 1 个皇后有 N 种可能的方法,放置两个皇后的方法不超过 N (N - 2) ,放置 3 个皇后的方法不超过 N(N - 2)(N - 4) ,以此类推。总体上,时间复杂度为 \mathcal{O}(N!)O(N!) .
* 空间复杂度:\mathcal{O}(N)O(N) . 需要保存对角线和行的信息。
作者:LeetCode
链接:https://leetcode-cn.com/problems/n-queens/solution/nhuang-hou-by-leetcode/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
* @param n
*/
const solveNQueens =n=>{
let res=new Array(n),res0=[];
for(let i=0;i<n;i++){
res[i]=new Array(n).fill('.');
}
const feasible=(res0,i,j)=>{
// todo 位置不合理,已经限制过,不再处理
// todo 已经有皇后了
if(res0[i][j]==='Q'){
return false;
}
// todo 横向纵向有皇后
for(let k=0;k<n;k++){
if(res0[i][k]==='Q'||res0[k][j]==='Q'){
return false;
}
}
// todo 斜方向有皇后
// 左上角
let s=i,t=j;
while(s>=0&&t>=0){
if(res0[s][t]==='Q'){
return false;
}else{
s--;
t--;
}
}
// 右上角
s=i;t=j;
while(s<n&&t>=0){
if(res0[s][t]==='Q'){
return false;
}else{
s++;
t--;
}
}
// // 左下角
s=i;t=j;
while(s>=0&&t<n){
if(res0[s][t]==='Q'){
return false;
}else{
s--;
t++;
}
}
// // 右下角
s=i;t=j;
while(s<n&&t<n){
if(res0[s][t]==='Q'){
return false;
}else{
s++;
t++;
}
}
return true;
};
const dfs=k=>{
if(k>n-1){
res0.push(JSON.stringify(res));
}else{
// 怎么去考虑第k个皇后的摆放位置呢,显然需要双层循环,时间复杂度过高,无法AC
// 但其实我们的k可以拿来充当k,试探每一个k行的每一列
// for(let i=0;i<n;i++){
for(let j=0;j<n;j++){
if(feasible(res,k,j)){
res[k][j]='Q';
dfs(k+1);
res[k][j]='.';
}
}
// }
}
};
dfs(0);
for(let i=0;i<res0.length;i++){
res0[i]=JSON.parse(res0[i]);
for(let j=0;j<res0[i].length;j++){
res0[i][j]=res0[i][j].join('');
}
}
return res0;
};
console.info(solveNQueens(4));
/**
* 采用arr保存第i行第arr[i]列有皇后,很巧妙的方法
* @param n
* @returns {[]}
*/
const solveNQueens1=n=>{
let arr=new Array(n),res0=[],res=new Array(n);
const check=(row,col)=>{
for(let i=0;i<row;i++){
// 同一行肯定不会有的 主要检查同一列,以及斜对角,检查斜对角的方式真的很妙啊
if(col===arr[i]||Math.abs(row-i)===Math.abs(col-arr[i])){
return false;
}
}
return true;
};
const dfs=k=>{
if(k>n-1){
arr.forEach((item,idx)=>{
res[idx]=new Array(n).fill('.');
res[idx][item]='Q';
res[idx]=res[idx].join('');
});
res0.push(JSON.parse(JSON.stringify(res)));
}else{
// 试探每一个k行的每一列
// for(let i=0;i<n;i++){
for(let j=0;j<n;j++){
if(check(k,j)){
arr[k]=j;
dfs(k+1);
// 此处不必另外加回溯语句是因为每次遍历都给arr[k]重新赋值了而不必再更改赋初始值 参考primeRing同理
}
}
// }
}
};
dfs(0);
return res0;
};
leetcode-282-给表达式添加运算符
以下是两种方法,优化前的暴力遍历法和优化后的方法
- 问题的解是一组,即解用向量表示,因此用回溯法来解决,并且观察其特征应该是排列树(确定n个元素是满足某种性质的排列
- 以下是第一版代码。无法通过所有测试用例,原因实则是暴力遍历了所有可能结果,复杂度过高.
- 主要思路是通过最初用eval判断结果是否为target,但是每次eval运算耗费时间较长,优势是代码极其简单清晰,且不用单独处理乘法。
/*
* @param {string} num
* @param {number} target
* @return {string[]}
*/
const addOperators = (num, target)=>{
let res=[],ans=[];
const findRes=(num0,k)=>{
if(num0.length<1) return;
let flag=(num0.length>1&&num0[0]!=='0')||(num0.length===1);
if(flag&&eval(`${res.slice(0,k).join('')}${num0}`)===target){
res[k]=num0;
ans.push(res.slice(0,k+1).join(''));
}else{
for(let i=0;i<num0.length;i++){
res[k]=num0.slice(0, i + 1);
res[k+1]='+';
findRes(num0.slice(i + 1), k+2);
res[k+1]='-';
findRes(num0.slice(i + 1), k+2);
res[k+1]='*';
findRes(num0.slice(i + 1),k+2);
if(num0[0]==='0') break;
}
}
};
findRes(num,0);
return ans;
};
======> 优化
- 合理降低暴力遍历的复杂度,暂存每次计算的val
- 优化方案中关键一步是如何处理像
1-2*3*4*5
这种情况,其若从1-2*3*4
过渡需要:1-2*3*4-(-2*3*4)+(-2*3*4*5)
- 但在暴力求解的方法中通过校验最后一位加上后是否满足target的方式不容易取得pre即234的值
- 因此转而改变思路:通过判断最终结果是否符合条件,而像上面例子中暂存的结果如何判断呢,此时会非常容易(私以为本思路相对于官方思路更加清晰
/**
* @param num
* @param target
* @returns {number}
*/
const addOperators2 = (num, target)=>{
let res=[],ans=[];
const backTrack=(num0,val,pre,k)=>{
if(num0.length<1){
if(val===target){
ans.push(res.slice(0,k).join(''));
}
return;
}
for(let i=0;i<num0.length;i++){
if(k===0){
res[k]=(num0.slice(0,i+1));
backTrack(num0.slice(i+1),Number(res[k]),Number(res[k]),k+1);
}else{
res[k]=('+');
res[k+1]=num0.slice(0,i+1);
backTrack(num0.slice(i+1),val+Number(res[k+1]),Number(res[k+1]),k+2);
res[k]=('-');
res[k+1]=num0.slice(0,i+1);
backTrack(num0.slice(i+1),val-Number(res[k+1]),-Number(res[k+1]),k+2);
res[k]=('*');
res[k+1]=num0.slice(0,i+1);
backTrack(num0.slice(i+1),val-pre+pre*Number(res[k+1]),pre*Number(res[k+1]),k+2);
}
if(num0[0]==='0') break;
}
};
backTrack(num,0,0,0);
return ans;
};