47. 全排列 II
目录
题目链接:
题目:
解题思路:
代码:
总结:
题目链接:
47. 全排列 II - 力扣(LeetCode)
题目:
解题思路:
排列问题,依旧树枝树层去重问题
树枝去重,设置形参 boolean[] used=new boolean[nums.length]表示每一位是否用过
(为什么树枝去重,因为排列问题的for循环每次都是从0开始)
树层去重, 判断条件 if(i!=0&&nums[i]==nums[i-1]&&used[i-1]==true)
首位不能判断(因为不能有-1的下标,第一个是每一层的首节点,无需判断),看看是否前一位相同值是否用过,若用过就直接跳就行(因为这个题有重复的值)
代码:
/*
*Stack Integer ArrayList String StringBuffer peek
*Collections imports LinkedList offer return
*empty polls offerLast pollFirst isEmpty
*List Deque append length HashMap
*return remove boolean continue charAt
*toString
*/
class Solution {List<List<Integer>> res;List<Integer> path;public List<List<Integer>> permuteUnique(int[] nums) {res=new ArrayList<>();path=new ArrayList<>();boolean[] used=new boolean[nums.length];Arrays.sort(nums);find(nums,used);return res;}public void find(int[] nums,boolean[] used){if(path.size()==nums.length){res.add(new ArrayList<>(path));return ;}for(int i=0;i<nums.length;i++){if(i!=0&&nums[i]==nums[i-1]&&used[i-1]==false) continue ;if(used[i]==true) continue;used[i]=true;path.add(nums[i]);find(nums,used);path.remove(path.size()-1);used[i]=false;}}
}
深度解析带重复元素的全排列算法:回溯与去重的完美结合全排列问题是算法领域的经典课题,而当输入数组包含重复元素时,问题的复杂度会显著提升。本文将详细解析一段高效解决带重复元素全排列问题的代码,从去重逻辑、排序作用到回溯过程,全方位展示如何在生成所有排列的同时避免重复结果。一、问题背景与需求分析1. 问题描述带重复元素的全排列问题可描述为:给定一个可能包含重复元素的整数数组 nums生成该数组所有可能的全排列结果中不得包含重复的排列例如,对于输入数组 [1,1,2],其不重复的全排列为:[[1,1,2], [1,2,1], [2,1,1]]2. 核心挑战与无重复元素的全排列相比,主要挑战在于去重:当数组存在重复元素时,直接使用普通回溯法会生成大量重复排列需要设计高效的去重策略,在生成排列的过程中主动避免重复结果去重逻辑不能破坏全排列的完整性,必须确保所有可能的不重复排列都被生成二、代码整体结构解析java运行import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class Solution {
// 存储最终所有不重复的全排列
List<List<Integer>> res;
// 存储当前正在构建的排列
List<Integer> path;
// 对外接口:输入数组,返回所有不重复的全排列
public List<List<Integer>> permuteUnique(int[] nums) {
res = new ArrayList<>();
path = new ArrayList<>();
boolean[] used = new boolean[nums.length]; // 记录元素使用状态
Arrays.sort(nums); // 排序:为去重做准备
find(nums, used); // 启动回溯过程
return res;
}
// 回溯核心函数
/**
* @param nums 输入数组(已排序)
* @param used 元素使用状态数组
*/
public void find(int[] nums, boolean[] used) {
// 终止条件:当前路径长度等于数组长度,生成一个完整排列
if (path.size() == nums.length) {
res.add(new ArrayList<>(path)); // 添加路径的拷贝到结果集
return;
}
// 遍历所有元素,尝试选择未使用的元素
for (int i = 0; i < nums.length; i++) {
// 去重核心逻辑:跳过同一层的重复元素
if (i != 0 && nums[i] == nums[i-1] && used[i-1] == false) {
continue;
}
// 跳过已使用的元素
if (used[i] == true) {
continue;
}
// 选择当前元素
used[i] = true;
path.add(nums[i]);
// 递归探索下一个位置
find(nums, used);
// 回溯:撤销选择
path.remove(path.size() - 1);
used[i] = false;
}
}
}
代码采用回溯法框架,在普通全排列算法的基础上增加了关键的去重逻辑,主要包含三个核心部分:成员变量:res 存储最终结果,path 存储当前构建的排列对外接口 permuteUnique:负责初始化、排序和启动回溯过程回溯核心函数 find:实现排列生成的核心逻辑,包含关键的去重判断代码的精妙之处在于排序预处理与同一层去重的结合,这是解决带重复元素全排列问题的关键。三、去重核心逻辑深度解析1. 排序预处理的作用java运行Arrays.sort(nums);
在回溯开始前对数组进行排序是去重的基础,其作用是:使重复元素相邻:排序后,相同的元素会连续排列(如 [1,2,1] 排序后为 [1,1,2])为去重判断提供条件:只有当重复元素相邻时,才能通过比较 nums[i] 和 nums[i-1] 来判断是否为重复元素2. 去重判断的核心代码java运行if (i != 0 && nums[i] == nums[i-1] && used[i-1] == false) {
continue;
}
这行代码是去重的关键,我们需要深入理解其三个条件的含义:条件 1:i != 0确保当前元素不是数组的第一个元素,避免数组越界(i-1 >= 0),同时也说明存在前一个元素可以比较。条件 2:nums[i] == nums[i-1]判断当前元素与前一个元素是否相同,这是重复元素的直接标志。由于数组已排序,只有相邻元素才可能重复。条件 3:used[i-1] == false这是最关键的条件,用于判断前一个重复元素是否未被使用(即处于同一层递归中)。3. 为什么这样可以去重?去重的核心思想是在同一层递归中,不允许选择与前一个重复且未被使用的元素,而允许不同层递归中选择重复元素(因为这会生成不同的排列)。我们通过 used[i-1] 的状态来区分元素是处于同一层还是不同层:used[i-1] == false:表示前一个重复元素在当前层未被使用,此时如果选择当前元素,会导致与前一个元素在其他位置被选择时产生重复排列used[i-1] == true:表示前一个重复元素在之前的层(上层)已被使用,此时选择当前元素属于不同的排列分支,不会产生重复举例说明对于排序后的数组 [1,1,2]:第一层递归中,i=0(第一个 1):used[0] 标记为 true,加入路径 [1]进入第二层递归第二层递归中,i=1(第二个 1):used[0] == true(前一个 1 已在上层使用)可以选择,加入路径 [1,1]进入第三层递归,生成 [1,1,2]回溯到第一层递归,i=1(第二个 1):nums[1] == nums[0] 且 used[0] == false(前一个 1 在当前层未被使用)触发去重条件,跳过,避免生成与 i=0 分支重复的排列这个逻辑确保了重复元素只会在不同层被选择,同一层不会选择重复元素,从而彻底避免重复排列。四、完整执行流程模拟以输入数组 [1,1,2] 为例,我们模拟算法的核心执行流程:1. 初始化与排序输入数组:[1,1,2]排序后:[1,1,2]res = [],path = [],used = [false, false, false]调用 find(nums, used)2. 第一次调用 find(nums, used)path.size() = 0 != 3,进入循环 i=0 到 2i=0(元素 1)不触发去重条件(i=0)used[0] = false,未被使用used[0] = true → used = [true, false, false]path.add(1) → path = [1]调用 find(nums, used)第二次调用 find(nums, used)path.size() = 1 != 3,进入循环 i=0 到 2i=0(元素 1)used[0] = true,跳过i=1(元素 1)i != 0,nums[1] == nums[0],used[0] == true(前一个 1 已在上层使用)不触发去重条件used[1] = false,未被使用used[1] = true → used = [true, true, false]path.add(1) → path = [1,1]调用 find(nums, used)第三次调用 find(nums, used)path.size() = 2 != 3,进入循环 i=0 到 2i=0(元素 1)used[0] = true,跳过i=1(元素 1)used[1] = true,跳过i=2(元素 2)used[2] = false,未被使用used[2] = true → used = [true, true, true]path.add(2) → path = [1,1,2]调用 find(nums, used)第四次调用 find(nums, used)path.size() = 3 == 3,满足终止条件res.add(new ArrayList<>(path)) → res = [[1,1,2]]返回回溯操作:path.remove(2) → path = [1,1]used[2] = false → used = [true, true, false]循环结束,返回第三次调用回溯操作:path.remove(1) → path = [1]used[1] = false → used = [true, false, false]继续循环i=2(元素 2)不触发去重条件used[2] = false,未被使用used[2] = true → used = [true, false, true]path.add(2) → path = [1,2]调用 find(nums, used)(后续过程省略,算法将继续探索 [1,2,1] 和 [2,1,1] 等排列)最终,res 将包含 [1,1,2] 的所有 3 种不重复排列。五、算法特性分析1. 时间复杂度对于长度为 n 的数组,带重复元素的全排列时间复杂度为 \(O(n \times n!)\):最坏情况下(无重复元素),有 \(n!\) 种不同的排列每个排列的生成需要 \(O(n)\) 的时间(复制 path 到结果集)排序操作的时间复杂度为 \(O(n \log n)\),相对于 \(O(n \times n!)\) 可忽略不计2. 空间复杂度空间复杂度主要来自四个部分:递归调用栈:深度为 n(与数组长度相同)path 列表:最多存储 n 个元素used 数组:大小为 n结果集 res:存储 k 种排列(\(k \leq n!\)),每种排列包含 n 个元素,空间复杂度为 \(O(k \times n)\)因此,算法的空间复杂度为 \(O(n \times n!)\)(最坏情况)。3. 与普通全排列算法的对比特性普通全排列(无重复元素)带重复元素的全排列输入特点元素无重复元素可能重复去重逻辑无需去重需要排序 + 同一层去重时间复杂度\(O(n \times n!)\)\(O(n \times n!)\)(最坏)关键区别直接遍历所有元素增加 i != 0 && nums[i] == nums[i-1] && used[i-1] == false 去重判断通过对比可以看出,带重复元素的全排列算法在普通全排列的基础上,增加了排序预处理和去重判断,确保结果中没有重复排列。六、常见问题与优化建议1. 为什么 used[i-1] == false 而不是 used[i-1] == true?这是最容易混淆的点,我们可以通过一个形象的比喻理解:把递归过程想象成一棵决策树,每一层递归是树的一个层级used[i-1] == false 表示前一个重复元素在当前层未被使用,此时选择当前元素会导致同一层出现重复选择used[i-1] == true 表示前一个重复元素在父节点(上层)已被使用,此时选择当前元素属于不同的分支,不会重复如果误将条件写为 used[i-1] == true,会错误地过滤掉合法的排列,导致结果不完整。2. 不排序能否实现去重?理论上可以,但实现会复杂得多:不排序时,重复元素可能不相邻,需要使用哈希集合记录当前层已使用的元素例如,在每层递归中创建一个 HashSet,记录已选择的元素值,若当前元素已在集合中则跳过这种方法的时间复杂度与排序法相近,但空间复杂度更高(每层都需要一个哈希集合)排序法是更优的选择,因为它将重复元素集中在一起,便于高效去重。3. 如何验证算法的正确性?可以通过以下测试用例验证算法是否正确:空数组:返回 [[]]单元素数组:返回 [[x]]含重复元素的数组:如 [1,1,2] 应返回 3 种排列全重复元素的数组:如 [2,2,2] 应返回 1 种排列4. 算法的优化空间提前终止:对于全重复元素的数组(如 [3,3,3]),可以在排序后判断所有元素是否相同,直接返回一个结果,避免不必要的递归空间优化:可以通过交换元素的方式避免使用 path 列表,直接在原数组上操作,但会修改原数组七、总结带重复元素的全排列算法是回溯法与去重策略结合的典范,其核心亮点包括:排序预处理:使重复元素相邻,为高效去重奠定基础精妙的去重逻辑:通过 i != 0 && nums[i] == nums[i-1] && used[i-1] == false 确保同一层不选择重复元素完整的回溯流程:实现了 "选择 - 递归 - 撤销" 的经典回溯模式,确保探索所有可能的排列理解这个算法不仅能解决带重复元素的全排列问题,更能掌握处理 "重复元素 + 排列组合" 类问题的通用思路:排序使重复元素相邻使用状态数组跟踪元素使用情况在适当的位置(通常是循环选择元素时)添加去重判断回溯法的魅力在于其系统性地探索解空间的能力,而本文的算法则展示了如何在这种探索中加入约束条件,既保证解的完整性,又避免重复,这是解决复杂组合优化问题的关键所在。
总结:
本文解析了带重复元素的全排列问题,通过回溯法结合高效去重策略实现。核心在于排序预处理和同一层去重判断(i!=0 && nums[i]==nums[i-1] && used[i-1]==false),确保生成所有不重复的排列。算法时间复杂度为O(n×n!),空间复杂度为O(n×n!)。相比普通全排列,增加了排序和去重逻辑,是处理重复元素组合问题的典型范例。