算法题 全排列

46. 全排列

问题描述

给定一个不含重复数字的数组 nums,返回其所有可能的全排列。可以按任意顺序返回答案。

示例

示例 1:

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:

输入:nums = [0,1]
输出:[[0,1],[1,0]]

示例 3:

输入:nums = [1]
输出:[[1]]

算法思路

回溯法(DFS)

  1. 遍历数组中每个数字
  2. 若当前数字未被使用,则将其加入当前路径
  3. 递归处理剩余数字
  4. 回溯时移除最后添加的数字
  5. 当路径长度等于数组长度时,保存结果

核心

  • 使用布尔数组 used 标记数字使用状态
  • 递归树深度 = 数组长度
  • 每层递归尝试所有未使用的数字

代码实现

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        // 边界情况:空数组直接返回空结果
        if (nums == null || nums.length == 0) return result;
        
        // 创建路径列表和标记数组
        List<Integer> path = new ArrayList<>();
        boolean[] used = new boolean[nums.length];
        
        // 启动回溯
        backtrack(nums, used, path, result);
        return result;
    }
    
    /**
     * 回溯核心方法
     * 
     * @param nums  输入数组
     * @param used  标记数组,记录元素是否已使用
     * @param path  当前路径(当前排列)
     * @param result 结果集
     */
    private void backtrack(int[] nums, boolean[] used, 
                          List<Integer> path, List<List<Integer>> result) {
        // 终止条件:路径长度等于数组长度
        if (path.size() == nums.length) {
            result.add(new ArrayList<>(path)); // 创建新列表保存当前排列
            return;
        }
        
        // 遍历所有数字
        for (int i = 0; i < nums.length; i++) {
            // 跳过已使用的数字
            if (used[i]) continue;
            
            // 选择当前数字
            path.add(nums[i]);
            used[i] = true;
            
            // 递归进入下一层
            backtrack(nums, used, path, result);
            
            // 回溯:撤销选择
            path.remove(path.size() - 1);
            used[i] = false;
        }
    }
}

算法分析

  • 时间复杂度:O(n × n!)
    • 排列总数 n!(n 的阶乘)
    • 每个排列需要 O(n) 时间复制到结果中
  • 空间复杂度:O(n)
    • 递归栈深度 O(n)
    • 标记数组 O(n)
    • 路径列表 O(n)

算法过程

nums = [1,2,3]

开始:path=[], used=[F,F,F]
├─ 选1: path=[1], used=[T,F,F]
│  ├─ 选2: path=[1,2], used=[T,T,F]
│  │  └─ 选3: path=[1,2,3] → 加入结果
│  ├─ 选3: path=[1,3], used=[T,F,T]
│  │  └─ 选2: path=[1,3,2] → 加入结果
├─ 选2: path=[2], used=[F,T,F]
│  ├─ 选1: path=[2,1], used=[T,T,F]
│  │  └─ 选3: path=[2,1,3] → 加入结果
│  ├─ 选3: path=[2,3], used=[F,T,T]
│  │  └─ 选1: path=[2,3,1] → 加入结果
├─ 选3: path=[3], used=[F,F,T]
│  ├─ 选1: path=[3,1], used=[T,F,T]
│  │  └─ 选2: path=[3,1,2] → 加入结果
│  └─ 选2: path=[3,2], used=[F,T,T]
│     └─ 选1: path=[3,2,1] → 加入结果
结果:6种排列

关键点

  1. 回溯三要素

    • 选择:将未使用的数字加入路径
    • 递归:处理下一层决策
    • 撤销:回溯时移除最后添加的数字
  2. 结果保存

    • 必须创建新列表 new ArrayList<>(path)
    • 直接添加 path 会导致结果被后续修改影响
  3. 标记数组优化

    • path.contains() 更高效( O(1) vs O(n) )

测试用例

public static void main(String[] args) {
    Solution solution = new Solution();
    
    // 测试用例1:标准示例
    int[] nums1 = {1,2,3};
    System.out.println("Test 1: " + solution.permute(nums1));
    // 预期:6种排列 [[1,2,3],[1,3,2],...]
    
    // 测试用例2:单个元素
    int[] nums2 = {1};
    System.out.println("Test 2: " + solution.permute(nums2)); // [[1]]
    
    // 测试用例3:两个元素
    int[] nums3 = {0,1};
    System.out.println("Test 3: " + solution.permute(nums3)); // [[0,1],[1,0]]
    
    // 测试用例4:空数组
    int[] nums4 = {};
    System.out.println("Test 4: " + solution.permute(nums4)); // []
    
    // 测试用例5:含负数
    int[] nums5 = {-1,0,1};
    System.out.println("Test 5: " + solution.permute(nums5).size()); // 6
}

常见问题

  1. 为什么需要创建新列表 new ArrayList<>(path)
    直接添加 path 会导致结果集中所有元素指向同一个列表对象,回溯修改会影响已保存的结果。

  2. 如何处理含重复数字的全排列?
    本题前提是不含重复数字。若含重复数字,需先排序,然后添加跳过条件:

    if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
    
  3. 能否不使用标记数组?
    可以,但效率较低(每次用 path.contains() 判断,时间复杂度升至 O(n! × n²))。

  4. 递归树深度如何?
    递归深度 = 数组长度 n,当 n 较大时需注意栈溢出风险(通常 n≤10 可用此解法)。

47. 全排列 II

问题描述

给定一个可包含重复数字的序列 nums,返回所有不重复的全排列。可以按任意顺序返回答案。

示例

示例 1:

输入:nums = [1,1,2]
输出:
[[1,1,2],
 [1,2,1],
 [2,1,1]]
 
示例 2:

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

算法思路

回溯法 + 剪枝

  1. 排序预处理:将数组排序,使重复元素相邻
  2. 回溯框架
    • 遍历每个可用的数字
    • 添加当前数字到路径
    • 递归处理下一层
    • 回溯移除最后添加的数字
  3. 剪枝关键
    • 跳过已使用的数字
    • 跳过重复数字:当前数字与前一个相同,且前一个未被使用(树层剪枝)

树层剪枝原理

  • nums[i] == nums[i-1]!used[i-1]
  • 说明前一个相同数字在当前位置已尝试过所有组合
  • 当前选择会导致重复排列,直接跳过

误区

  1. 在排列问题中,去重的条件应该是:当当前元素与前一个元素相同,并且前一个元素没有被使用(即在同一层递归中,已经使用过相同的元素),则跳过。这是因为:
    • 如果前一个相同的元素已经被使用过(即used[i-1]为true),说明在同一个路径(递归深度)中,我们正在使用这个重复元素,这是允许的。
    • 如果前一个相同的元素没有被使用(即used[i-1]为false),说明在当前的递归层中,前一个相同的元素已经被撤销选择(回溯),那么再选择当前元素就会产生重复

代码实现

class Solution {
    public List<List<Integer>> permuteUnique(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        if (nums == null || nums.length == 0) return result;
        
        Arrays.sort(nums); // 关键:排序使重复元素相邻
        boolean[] used = new boolean[nums.length];
        backtrack(nums, used, new ArrayList<>(), result);
        return result;
    }
    
    private void backtrack(int[] nums, boolean[] used, 
                          List<Integer> path, List<List<Integer>> result) {
        // 完成条件:路径长度等于数组长度
        if (path.size() == nums.length) {
            result.add(new ArrayList<>(path));
            return;
        }
        
        for (int i = 0; i < nums.length; i++) {
            // 跳过已使用的元素
            if (used[i]) continue;
            
            /* 关键剪枝:避免重复排列
             * 条件1:当前元素与前一个相同 (i>0 && nums[i]==nums[i-1])
             * 条件2:前一个元素未被使用 (!used[i-1])
             * 
             * 解释:前一个相同元素未被使用,说明在当前位置已尝试过该值
             * 继续使用当前值会产生重复排列
             */
            if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) {
                continue;
            }
            
            // 选择当前元素
            used[i] = true;
            path.add(nums[i]);
            
            // 递归进入下一层
            backtrack(nums, used, path, result);
            
            // 回溯:撤销选择
            used[i] = false;
            path.remove(path.size() - 1);
        }
    }
}

算法分析

  • 时间复杂度:O(n × n!)
    • 最坏情况无重复:O(n! × n)(n! 种排列,每个排列 O(n) 复制)
    • 有重复时实际递归次数小于 n!
  • 空间复杂度:O(n)
    • 递归栈深度 O(n)
    • 标记数组 O(n)
    • 路径列表 O(n)

算法过程

nums = [1,1,2]

排序后:nums = [1,1,2]
开始:path=[], used=[F,F,F]
├─ i=0: 选第一个1 → used=[T,F,F], path=[1]
│  ├─ i=1: 剪枝条件不成立
│  │  used[0]=true → 不跳过 → 选第二个1 → used=[T,T,F], path=[1,1]
│  │  └─ 选2 → used=[T,T,T], path=[1,1,2] → 加入结果
│  └─ i=2: 选2 → used=[T,F,T], path=[1,2]
│     └─ 选第二个1 → used=[T,T,T], path=[1,2,1] → 加入结果
├─ i=1: 剪枝条件成立 (i=1>0, nums[1]==nums[0]==1, 且 !used[0] 为 true) → 跳过
└─ i=2: 选2 → used=[F,F,T], path=[2]
   ├─ i=0: 选第一个1 → used=[T,F,T], path=[2,1]
   │  └─ i=1: 选第二个1 → used=[T,T,T], path=[2,1,1] → 加入结果
   └─ i=1: 剪枝条件成立 → 跳过
最终得到3个不重复排列

关键点

  1. 排序预处理:使重复元素相邻,便于剪枝判断
  2. 树层剪枝条件
    i > 0 && nums[i] == nums[i-1] && !used[i-1]
    
  3. 去重本质:同一树层中,对重复数字只使用第一个未被使用的
  4. 结果保存:必须 new ArrayList<>(path) 创建新列表

剪枝条件

条件目的
i > 0确保有前一个元素
nums[i] == nums[i-1]当前元素与前一个相同
!used[i-1]前一个相同元素未被使用(说明在当前位置已尝试过该值)

测试用例

public static void main(String[] args) {
    Solution solution = new Solution();
    
    // 测试用例1:标准示例
    int[] nums1 = {1,1,2};
    System.out.println("Test 1: " + solution.permuteUnique(nums1));
    // 预期:[[1,1,2],[1,2,1],[2,1,1]]
    
    // 测试用例2:全相同元素
    int[] nums2 = {1,1,1};
    System.out.println("Test 2: " + solution.permuteUnique(nums2)); // [[1,1,1]]
    
    // 测试用例3:无重复元素
    int[] nums3 = {1,2,3};
    System.out.println("Test 3: " + solution.permuteUnique(nums3).size()); // 6
    
    // 测试用例4:含多个重复组
    int[] nums4 = {2,2,1,1};
    System.out.println("Test 4: " + solution.permuteUnique(nums4));
    /* 预期:
       [[1,1,2,2],[1,2,1,2],[1,2,2,1],
        [2,1,1,2],[2,1,2,1],[2,2,1,1]]
    */
    
    // 测试用例5:单个元素
    int[] nums5 = {0};
    System.out.println("Test 5: " + solution.permuteUnique(nums5)); // [[0]]
}

常见问题

  1. 为什么需要 !used[i-1] 条件?
    保证只对树层去重:当相同元素的前一个实例未被使用时,说明在当前位置已尝试过该值的排列,跳过当前重复值。

  2. 排序是否必须?
    是,排序使重复元素相邻,剪枝条件才能生效。

  3. 如何处理 [1,1,2] 中的两个1?
    通过剪枝条件,在第二层递归时跳过第二个1的直接选择,但允许在第一个1使用后选择第二个1。

  4. 与46题的区别?
    46题不含重复数字,无需排序和剪枝;本题含重复数字,需额外处理重复情况。

  5. 剪枝条件中 !used[i-1] 改为 used[i-1] 会怎样?
    会导致错误跳过有效排列(树枝剪枝),最终结果会遗漏部分排列。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值