文章目录
1 回溯
什么问题适合用回溯
回溯适合解决排列组合的问题。例如从集合A中选出哪些元素组成的集合B(找出子集B),使得B可行/求可行的B个数/最佳方案/所有方案。
回溯的目的是遍历所有组合的可能性。比如有3个元素,如何遍历出所有组合方式(所有子集)?
回溯与深度优先遍历
回溯和深度优先遍历很类似。回溯的关键是画出回溯路径,即回溯树
回溯与递归
简单的递归不能剪枝。只能一递到底。
回溯=递归+剪枝。
回溯与枚举
枚举的难点是需要枚举出所有情况。因此有的问题不能枚举。
例如下面的例题-最多可达的换楼请求,用枚举的话刚好可以用一个二进制数对应一种情况 (哪一位为1就表示选中),则可以用枚举。
例如从n个不同的数里选m个,如果m已知,可以用m层for循环遍历。否则就不能枚举。
2 回溯问题解决思路
步骤:
- 画出回溯路径,确定哪些地方需要检验。
- 确定可行性检验条件,方法(如何判断可行)
- 确定状态变量,前进和回溯的时候如何更新状态变量。
如常见的状态变量是有一个 当前已选元素集合,前进的时候添加元素,回溯的时候移除元素。
解决方式
方式1:
先选一个元素,检验。接着选下一个,循环。
特点:
- 适合有序选取。因为会同时遍历到ABC和BCA这几种情况,不过可以通过剪枝排除重复情况。
- 对每一个点进行检验。而不仅仅是叶子节点。
- 需要维护数组,表示到达这个点的时候剩余可供选择的点。比较麻烦
方式2:
对于每一个元素,要么选择,要么不选。每一层分出两条枝。
特点:
- 适合无序选取,因为是从第一个元素往后判断,元素A必定在B前面。不会出现BA
- 仅仅对叶子节点判断
- 不需要维护数组来表示当前可用选项。始终是两个选项(选、不选)。
方法1适合可以重复选的情况,此时没有第3条限制,即不用维护数组。方法2适合不能重复选的情况,每个元素要么选要么不选,也可以用于可以重复选的情况。
优化(剪枝)
有时候某一点往后的所有节点都不能满足要求。就要从这点剪掉(return)
例如生成括号的例题,当左括号大于一半或者小于右括号的时候就不满足。
例如组合题中,如果剩余选项+已选元素数量<要求数量,则没必要再往下了,因为即使把所有剩余选项假如已选集合,也不满足要求。
代码范式
方式2的代码大概长这样
Class Solution{
定义一些参数在外面,省的dfs参数太多。
int n;
int ans
public int Solution(String[] args){
初始化一些参数,包括用于记录每个一个节点的状态的参数,以及统计参数(方案对应的因变量)
this.n=n;
调用dfs,主要参数是层数,其他参数可以放在外面,也可以放在这里。
dfs(0);//0表示在第0层。
返回
return ans;
}
public void dfs(int pos){
当到达最后一层的时候,判断可行性,更新统计参数,return。
if(pos ==n){
if(Fessible()){
update(ans);
}
return;
}
如果接受,更新哪些状态变量
dfs(pos+1);
恢复状态变量
如果不接受,更新哪些状态变量
dfs(pos+1);
恢复状态变量
}
}
3 回溯例题–最多可达的换楼请求
问题解析:
给定集合requests,从中选出若干元素,满足每个数字在元素第一列出现的次数等于第二列出现的次数,求元素最多的方案。
回溯路径
这里是找无序子集,选择方式2。
可行性检验
使用数组delta表示当前时刻/当前状态/当前节点,每个建筑的人数变化。delta[k]=m表示第k栋建筑增加了m个人。
使用ans表示所求的最大子集B的大小。
可行性检验:
若delta所有元素都为0,则可行,更新ans。
若delta所有元素不都为0,不可行。若可行,
前进和回溯的过程
前进的时候更新delta,即出去人的建筑delta–,进来人的建筑delta++。
回溯的时候回复delta。即出去人的建筑delta++,进来人的建筑delta++。
代码
注意用到的状态变量:delta,cnt。delta表示当前各个建筑物的人数增加情况,cnt表示当前选择的方案数量。
class Solution {
int ans=0;//最佳选择方案数
int n;//建筑数
int m;//方案总数
int[] delta;
int cnt=0; //被选择方案数量
public int maximumRequests(int n, int[][] requests) {
this.n=n;
this.m = requests.length;
this.delta = new int[n];
dfs(requests, 0);
return ans;
}
public void dfs(int[][] requests, int pos){
if (pos== m){
if(fessible(delta)){
ans = Math.max(ans,cnt);
}
return;
}
//接受
int[] curReq = requests[pos];
delta[curReq[0]]++;
delta[curReq[1]]--;
cnt++;
dfs(requests, pos+1);
delta[curReq[1]]++;
delta[curReq[0]]--;
cnt--;
//不接受
dfs(requests, pos+1);
}
public boolean fessible(int[] delta){
for(int item: delta){
if(item!=0){
return false;
}
}
return true;
}
}
优化:
这里使用了fessible函数判断方案可行性,fessible函数内部有循环,增加了时间复杂度。O(2^m*n)
实际上可以维护一个状态zero变量,表示delta数组中0的数量。实时更新。O(2^m)
剪枝
对于每一个分支,都要等到最后才知道是否可行。因此不能剪枝。
另一种解法:枚举
用二进制数mask表示选取状态。如mask=00001表示仅仅选择方案1.遍历mask,从0000到11111(m个1。对每一个mask,判断此时的可行性,更新ans。
判断可行性的方式是把mask转为delta(如mask=00101表示:delta[requests[0][0]]++,delta[requests[0][1]]–,delta[requests[2][0]]++,delta[requests[2][1]]–,),并用上面的fessible函数判断delta。
4 其他题目
括号生成
解法:同样用方法2.每一次可以选择左括号或者右括号。当到达最后一层即第2n层时,判断有效性。
技巧:使用brace 表示 (左括号数-右括号数) 的值。
优化:可以剪枝,如果当前状态是:左括号少于右括号即brace<0/左括号数量大于n即brace>剩余层数。不再继续。
组合总和
说明:从集合中选出若干元素(可以重复选),总和等于target。
解法:像是方法1和方法2的结合。
每次选择一个元素,判断此时和是否大于等于target(停止条件),判断和是否等于target(目标),接着选下一个元素。
需要时刻判断
不用维护当前选项,因为可以重复选。
构造的回溯树(以3个元素为例):
注意A的下面可以是ABC,B的下面只有BC,这是为了避免重复(AB和BA)。
也可以构造的回溯树(以3个元素为例):
p是0到n的索引,如果选p对应元素就不移动p,否则p++。表示集合索引。例如点方案是{B}
组合总和2
题目:和上一题的区别是:每个元素只能使用一次,元素可能相同。具有相同的元素集合不能返回多次。
如输入 {2,2} target=2. 需要输出{{2}},而不是 {{2}, {2}}
分析:
如果直接用方法2,即依次判断每个元素是否需要,元素相同的组合会算多次。还要进一步去重。
把输入转换成哈希表。相当于每个元素只能用有限次。
其实上一题相当于这一题的特殊情况:每一个元素可用无限次。
因此一个可行的方法 是维护这个哈希表。在进入之前增加判断条件(value>=1),进入前更新表,回溯时恢复表。
组合
用方法2即可,重点是可以剪枝:
一、如果当前元素数量list.size=要求数量k,则已找到,返回(显然)
二、如果当前元素数量+剩余可选数量<要求数量。 则不可能再找到,返回。curList.size()+(n-pos+1)<k
第二个剪枝尤其重要。