基本概念:
backtracking(回溯算法)也叫试探法,它是一种系统地搜索问题的解的方法。回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。
回溯算法说白了就是穷举法。不过回溯算法使用剪枝函数,剪去一些不可能到达最终状态(即答案状态)的节点,从而减少状态空间树节点的生成。
回溯法是一个既带有系统性又带有跳跃性的的搜索算法。它在包含问题的所有解的解空间树中,按照深度优先的策略,从根结点出发搜索解空间树。算法搜索至解空间树的任一结点时,总是先判断该结点是否肯定不包含问题的解。如果肯定不包含,则跳过对以该结点为根的子树的系统搜索,逐层向其祖先结点回溯。否则,进入该子树,继续按深度优先的策略进行搜索。
- 回溯法在用来求问题的所有解时,要回溯到根,且根结点的所有子树都已被搜索遍才结束。
- 而回溯法在用来求问题的任一解时,只要搜索到问题的一个解就可以结束。
这种以深度优先的方式系统地搜索问题的解的算法称为回溯法,它适用于解一些组合数较大的问题。
解题思路:
在leetcode中比较经典的backtracking问题有以下几个:
51. N-Queens
52. N-Queens II
39. Combination Sum
40. Combination Sum II
216. Combination Sum III
46. Permutations
47. Permutations II
78. Subsets
90. Subsets II
131. Palindrome Partitioning
permutations
首先看 permutations 这个问题,是求一个数组的全排列,思路就是将数组当做一个池子,第一次取出一个数,然后在池子里剩下的数中再任意取一个数此时组成两个数,然后再在池子中剩下的数里取数,直到无数可取,即取完一次,形成一个排列。
public class Solution {
private List<List<Integer>> result = new ArrayList<List<Integer>>();
private int length;
public List<List<Integer>> permute(int[] nums) {
length = nums.length;
List<Integer> select = new ArrayList<Integer>();
helper(select,nums,0);
return result;
}
//s代表已取出的数,nums则是有所有数的池子,pos代表要取第几个位置的数
public void helper(List<Integer> s,int[] nums,int pos){
//跳出条件是已取了池子里所有的数,完成一次排列
if(pos == length){
result.add(new ArrayList<Integer>(s));
return;
}
for(int i=0;i<nums.length;i++){
int num = nums[i];
//取过的数不再取
if(s.contains(num)){
continue;
}
s.add(num);
helper(s,nums,pos+1);
//重要!!遍历过此节点后,要回溯到上一步,因此要把加入到结果中的此点去除掉!
s.remove(s.size()-1);
}
}
}
我总结其中最重要的有几点:
- 递归函数的开头写好跳出条件,满足条件才将当前结果加入总结果中
- 已经拿过的数不再拿 if(s.contains(num)){continue;}
- 遍历过当前节点后,为了回溯到上一步,要去掉已经加入到结果list中的当前节点。
因此我们可以总结出一个递归函数的模板:
//list s是已取出的数,nums是原始数组,pos是当前取第几个位置的数
public void helper(List<Integer> s,int[] nums,int pos){
//跳出条件
if(……){
……
return;
}
//遍历池子中的数
for(int i=0;i<nums.length;i++){
int num = nums[i];
//取过的数不再取
if(s.contains(num)){
continue;
}
//取出一个数
s.add(num);
//进行下一个位置的取数,pos+1
helper(s,nums,pos+1);
//重要!!遍历过此节点后,要回溯到上一步,因此要把加入到结果中的此点去除掉!
s.remove(s.size()-1);
}
}
}
这是最原始的模板,根据题要求的不同会增加参数或各种判断条件,但都离不开这个框架。
subsets
其次再来看Subsets问题,是取一个数组的组合而不是全排列,基本代码结构都很相似,不同的有:
- 不是在结果长度等于数组长度时才将结果加入总结果中,而是在每次递归中都将当前组合加入结果中,因为求的是子集而不是全排列。
- 每次递归不是在池子中随便取一个数加入当前结果,因为此题要求的是子集,[1,3]和[3,1]是相同的,要求的是[1,3],因此每次在取数时,都要从其位置开始取后面的数,防止取到[3,1]这样的结果。
public class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> result = new ArrayList<List<Integer>>();
List<Integer> s = new ArrayList<Integer>();
helper(result,s,nums,0,0);
return result;
}
public void helper(List<List<Integer>> result,List<Integer> s,int[] nums,int pos,int iter){
//不是在结果长度等于数组长度时才将结果加入总结果中,而是在每次helper中都将当前组合加入结果中,因为求的是子集而不是全排列。
result.add(new ArrayList<Integer>(s));
if(pos == nums.length){
return;
}
//增加iter参数,用来代表当前数的位置,只能取此数后面的数
for(int i=iter;i<nums.length;i++){
if(s.contains(nums[i])){
continue;
}
s.add(nums[i]);
helper(result,s,nums,pos+1,i);
s.remove(s.size()-1);
}
}
}
从这两道题可以有许多变种,但都可以归为permutations问题或subsets问题。求
follow-up
像上面这种permutations和subsets问题的II问题一般都是说数组中有重复的数。
如Permutations II,[1,1,2] have the following unique permutations:
[
[1,1,2],
[1,2,1],
[2,1,1]
]
这之中有两个重复的1,一次第一次选取了第一个1作为全排列的第一个数,得到112和121。但若选择第二个1作为全排列的第一个数,依旧能得到112和121,此时就会有重复的全排列出现,因此,在每一次选择同一位置的数时(如选择全排列第一个位置的数),只选择重复的数中的第一个。
为了保证重复的数在一起,一般这种问题都要对数组进行一个排序处理:
Arrays.sort(nums);
然后我再另外用一个数组来记录每个数在当前位置处有没有被取过:
int[] visited = new int[nums.length];
没有取过的话默认为0,取了则设为1;
因此比如当我们在全排列的第一个位置取了第二个1,而此时发现第一个1并没有被取过,这就说明第一个1还没有被作为全排列的第一个位置,因此此时就应该放弃取第二个1作为全排列的第一个位置。
因此递归函数应为:
public void helper(List<Integer> s,int[] nums,int[] visited,int pos){
if(pos == length){
List<Integer> list = new ArrayList<Integer>(s);
result.add(list);
return;
}
for(int i=0;i<nums.length;i++){
//如果此数取过了则不再取||此数和前面的数相同而前面的数没有取,则此数也不能取
if(visited[i]==1 || (i>0 && nums[i] == nums[i-1]&& visited[i-1] == 0)){
continue;
}
s.add(nums[i]);
visited[i] = 1;
helper(s,nums,visited,pos+1);
//注意要回溯到上一步的话也要把visited当前位置还原
s.remove(s.size()-1);
visited[i] = 0;
}
}
subsets问题在处理重复数时同理。
N-Queens
最为经典的n皇后问题,其实也是求满足一定条件下的permutations问题,再加上判断皇后位置是否有效和画棋盘。但基本回溯思路和permutations是一致的:
public void helper(int n,int row,int[][] pos){
//放置好最后一个皇后时完成一次排列
if(row == n){
draw(pos);
return;
}
for(int i=0;i<n;i++){
if(isValid(row,i,pos)){
pos[row][i] = 1;
helper(n,row+1,pos);
//为回溯到上一步做准备
pos[row][i] = 0;
}
}
}
Combination Sum
Combination Sum其实求的是一个subsets问题,在给定的数组中任意取几个数和为最后的target,这其中一个数可以被重复取。
因此大致思路和subsets问题一致:
public void helper(List<List<Integer>> result,List<Integer> list,int[] candidates,int target,int sum,int pos){
if(pos == candidates.length && sum!=target){
return;
}
//结果可行的条件,需维护一个当前所有数的sum
if(sum == target){
result.add(new ArrayList<Integer>(list));
return;
}
//和subsets一样,从当前位置向后取
for(int i=pos;i<candidates.length;i++){
//当前的数加上当前取出的数的和小于等于target时才继续向下
if(sum+candidates[i] <= target){
list.add(candidates[i]);
//每个数能取多次,因此传递的pos就是当前数的位置,这样下次可能再取
helper(result,list,candidates,target,sum+candidates[i],i);
list.remove(list.size()-1);
}
}
}
而Combination Sum II则是每个数只能取一次,这就和最原始的题一样,那么pos递归传递的就是pos+1;第二个不同是数组中可能有重复的数,那就参照上面的follow-up将原始数组排序并维护一个visited辅助数组。
Combination Sum III则是1-9中任取k个数和为n,是原始的subsets问题加上一些限制条件而已。
总结
backtracking类型题其实思路都是一致的,连解决follow-up都是一致的,只要掌握了思路,做同一类型的题都是手到擒来。