题目
给定一个可包含重复数字的序列,返回所有不重复的全排列。
示例:
输入: [1,1,2]
输出:
[
[1,1,2],
[1,2,1],
[2,1,1]
]
解题
回溯+决策树
该题是Leetcode【回溯框架 递归】| 46. 全排列的进阶版,加了一个条件,即给定序列中有重复的元素。全排列的解决和回溯框架的介绍可以看这篇博文,我们需要额外解决的就是,在决策/选择的时候,如果避免重复?
我初始的想法是根据全排列中的交换+回溯法,先排序(为剪枝的前提),将重复的元素放一块,然后选择第i个元素剪枝的时候,判断是否和上一个元素相等,即第i个元素是否已经选择了这一元素,如果相等,说明重复了这次选择就可跳过。但是会出现一个问题:在选择时会出现交换,交换后剩余的元素的排序或许已经没有排好序了,比如{0,0,0,1,9}这个序列,当我们第二个元素选择9时,将其交换过来{0,9,0,1,0},剩余的元素{0,1,0}已经没有不排序了,所以在进行第三个元素选择的过程中无法通过相邻的元素相等来剪枝,会选择两次0,导致重复。
第一个解决方法是,这样进行部分剪枝后,在最后添加元素的时候再进行一次判断,是否已有该序列,即下列代码中的res.contains(sub)。但这种办法是最原始的办法,就算我们不在中间剪枝,所有全排列后判断重复再添加也是一样的,只不过效率很低。
class Solution {
public List<List<Integer>> res;
public List<Integer> sub;
public List<List<Integer>> permuteUnique(int[] nums) {
//回溯 决策树
Arrays.sort(nums); //剪枝的先决条件,排序,这样才能把重复的数字放在一块好判断
res = new ArrayList<>();
backtrack(nums,0);
return res;
}
public void backtrack(int[] nums, int begin){
if(begin == nums.length){
sub = new ArrayList<>();
for(int num:nums){
sub.add(num);
}
//再次判断是否有重复的排列
if(!res.contains(sub)) res.add(new ArrayList(sub));
return;
}
for(int i = begin; i < nums.length; i++){
//选择i
//看是否与上一个元素相等,相等就跳过,进行一定的剪枝,剪枝条件是剩余的元素是排好序的,这样相同的元素才相邻
if(i>begin && nums[i] == nums[i-1]){
continue;
}
//sub.add(nums[i]);//选择i 改为最后一起添加 速度快
swap(nums,begin,i);//把当前选择的元素i放在当前未排序序列的最前面begin,然后将剩余元素放一块继续递归全排列
//下一步决策
backtrack(nums,begin+1);
swap(nums,begin,i);
// sub.remove(sub.size()-1);
}
}
public void swap(int[] nums, int i, int j){
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
}
标记判断重复
不交换原数组位置,设置一个标记数组isVisited[i]标记nums中的第i个元素是否已被选择过。
注意:我们需要剪枝的是在选择同一层的元素时出现重复元素剪掉,而选择不同层元素的时候重复元素不剪。
class Solution {
public List<List<Integer>> res;
public List<Integer> sub;
public boolean[] isVisited;
public List<List<Integer>> permuteUnique(int[] nums) {
//回溯 决策树
res = new ArrayList<>();
sub = new ArrayList<>();
if(nums == null || nums.length == 0) return res;
Arrays.sort(nums);
isVisited = new boolean[nums.length];
backtrack(nums,0,sub);
return res;
}
public void backtrack(int[] nums, int begin,List<Integer> sub){
if(begin == nums.length){
res.add(new ArrayList<>(sub));
return;
}
for(int i = 0; i < nums.length; i++){
//选择i
//判断当前位置是否已被选择过
if(isVisited[i]){
continue;
}
//因为从前到后选择,所以在相同层的遍历时,和前一个数字一样的时候,前一个数字一定优先被选择过了,!isVisited[i]是因为前一个数字在当前第begin个数字已选择过,但是撤销选择了 所以现在isVisited[i]是false了
//如果isVisited[i]当前是true,说明这个元素目前是被选择状态,肯定是在当前层之前的层中被选择过,不可能是相同层所以不算重复可以继续选择这个数字,就不continue
//要么写!isVisited[i-1]要么写isVisited[i-1],不能什么都不写,因为不写的话,不管选择第几个元素即那一层元素每次遍历出现重复的就跳过了,当有几个重复元素时根本进行不到下一层。
//可以自己回溯理解下 带上!isVisited[i-1]或者isVisited[i-1]或者什么都不写的回溯过程 我太难了
if(i>0 && nums[i] == nums[i-1] && !isVisited[i-1]){
continue;
}
sub.add(nums[i]);//选择i
isVisited[i] = true;
//下一步决策
backtrack(nums,begin+1,sub);
isVisited[i] = false;
sub.remove(sub.size()-1);
}
}
}
拓展:
!isVisited[i-1] 和 isVisited[i-1] 区别在于:前者是易于理解的树层上去重(即同层不能选择重复元素),后者是树枝上去重,从例子中可以看出会做更多无用搜索。(想理解可看47. 全排列 II:【彻底理解排列中的去重问题】详解)和回溯搜索 + 剪枝(Java、Python)。从第二篇里面的例子可以看出if(i>0 && nums[i]== nums[i-1] && isVisited[i-1])的写法表示了,当遇到挨着的重复相同元素是,必须前一个相同元素没有被选择当前元素才能被选择 =>所以保证了相同元素必须从后往前选择即倒序选择这唯一一个选择顺序,其他选择顺序最终都会被那个if条件在某一步剪枝剪掉,所以通过决定唯一的选择顺序保证了相同元素选择的非重复性。