欢迎光临
我们一直在努力

LeetCode经典算法面试题 #46:全排列(回溯、交换、剪枝等五种实现方案详细解析)

目录

  • 1. 问题描述
  • 2. 问题分析
    • 2.1 题目理解
    • 2.2 核心洞察
    • 2.3 破题关键
  • 3. 算法设计与实现
    • 3.1 回溯法(标记数组)
    • 3.2 交换法(原地回溯)
    • 3.3 STJ算法(Steinhaus-Johnson-Trotter)
    • 3.4 阶乘定位法(第k个排列)
    • 3.5 剪枝回溯(处理重复元素)
  • 4. 性能对比
    • 4.1 复杂度对比表
    • 4.2 实际性能测试
    • 4.3 各场景适用性分析
  • 5. 扩展与变体
    • 5.1 下一个排列
    • 5.2 组合问题
    • 5.3 子集问题
    • 5.4 排列序列(第k个排列)
  • 6. 总结
    • 6.1 核心思想总结
    • 6.2 算法选择指南
    • 6.3 实际应用场景
    • 6.4 面试建议
    • 6.5 常见面试问题Q&A

1. 问题描述

全排列问题是计算机科学和算法领域的一个经典问题。给定一个不含重复数字的整数数组 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]]

提示:

  • 1 <= nums.length <= 6
  • -10 <= nums[i] <= 10
  • nums 中的所有整数 互不相同

2. 问题分析

2.1 题目理解

全排列是指从n个不同元素中任取n个元素,按照一定的顺序排列起来,所有的排列情况称为全排列。对于n个不同元素,全排列的总数为 n! (n的阶乘)。

例如,对于3个不同元素,全排列数为3! = 6;对于4个元素,全排列数为4! = 24。随着n的增加,全排列数呈指数级增长,这也是该问题的挑战所在。

2.2 核心洞察

全排列问题本质上是一种搜索问题,可以类比为八皇后问题的简化版。在八皇后问题中,我们在棋盘上放置皇后,需要考虑行、列和对角线的限制;在全排列问题中,我们放置数字,只需要考虑每个数字不能重复使用。

如果把棋盘的行号看作是排列的位置,列号看作是放置的数字,那么八皇后问题的解(忽略对角线限制)就对应着全排列。

2.3 破题关键

解决全排列问题的关键在于如何处理数字的选择和撤销过程,确保每个数字在每个排列中只使用一次。这涉及到两个核心问题:

  • 如何标记已使用的数字:可以使用额外的标记数组,也可以通过交换元素原地处理
  • 如何生成所有可能的排列:回溯法是最直观的方法,但还有更高效的算法
  • 3. 算法设计与实现

    3.1 回溯法(标记数组)

    核心思想:

    回溯法是一种通过探索所有可能候选解来找出所有解的算法。当候选解被确认不是一个解(或至少不是最后一个解)时,回溯算法会通过在上一步进行一些变化抛弃该解,然后重新尝试。

    算法思路:

  • 从左往右依次填入数字,每个位置都尝试填入一个未使用过的数字
  • 使用一个布尔数组visited记录每个数字是否已被使用
  • 当填完所有位置时,将当前排列加入结果集
  • 回溯到上一步,尝试其他可能性
  • Java代码实现:

    import java.util.ArrayList;
    import java.util.List;

    public class BacktrackingSolution {
    public List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    boolean[] visited = new boolean[nums.length];
    backtrack(nums, visited, new ArrayList<>(), result);
    return result;
    }

    private void backtrack(int[] nums, boolean[] visited,
    List<Integer> current, List<List<Integer>> result) {
    // 找到一个完整排列
    if (current.size() == nums.length) {
    result.add(new ArrayList<>(current));
    return;
    }

    for (int i = 0; i < nums.length; i++) {
    // 跳过已使用的数字
    if (visited[i]) continue;

    // 选择当前数字
    current.add(nums[i]);
    visited[i] = true;

    // 递归探索下一层
    backtrack(nums, visited, current, result);

    // 回溯,撤销选择
    current.remove(current.size() 1);
    visited[i] = false;
    }
    }
    }

    性能分析:

    • 时间复杂度:O(n × n!),每个叶节点(完整排列)需要O(n)时间复制到结果中,共有n!个叶节点
    • 空间复杂度:O(n),递归深度为n,标记数组大小为n

    3.2 交换法(原地回溯)

    核心思想:

    通过交换元素动态维护数组,将数组划分为已填部分和待填部分,避免使用额外的标记数组。

    算法思路:

  • 将数组分为两部分:[0, first-1]是已填过的数,[first, n-1]是待填的数
  • 尝试用[first, n-1]中的每个数去填第first个位置
  • 填完后将第i个数和第first个数交换
  • 递归完成后交换回来,完成回溯
  • Java代码实现:

    import java.util.ArrayList;
    import java.util.Collections;
    import java.util.List;

    public class SwapSolution {
    public List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();

    // 将数组转换为列表便于交换操作
    List<Integer> output = new ArrayList<>();
    for (int num : nums) {
    output.add(num);
    }

    backtrack(output, result, 0, nums.length);
    return result;
    }

    private void backtrack(List<Integer> output, List<List<Integer>> result,
    int first, int n) {
    // 所有位置都已填完
    if (first == n) {
    result.add(new ArrayList<>(output));
    return;
    }

    for (int i = first; i < n; i++) {
    // 动态维护数组:交换当前位与第i位
    Collections.swap(output, first, i);

    // 递归填下一个位置
    backtrack(output, result, first + 1, n);

    // 撤销交换
    Collections.swap(output, first, i);
    }
    }
    }

    性能分析:

    • 时间复杂度:O(n × n!),与回溯法相同,但减少了标记数组的访问
    • 空间复杂度:O(n),递归深度为n,但无需额外标记数组

    3.3 STJ算法(Steinhaus-Johnson-Trotter)

    核心思想:

    通过相邻元素交换和移动方向标记生成全排列,每次只交换相邻元素。

    算法思路:

  • 为每个元素分配一个移动方向(初始均向左)
  • 如果元素移动方向相邻的值比自身小,则该元素可移动
  • 每次移动最大的可移动元素
  • 移动后,所有比移动元素大的元素方向反转
  • Java代码实现:

    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;

    public class SJTSolution {
    public List<List<Integer>> permute(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    int n = nums.length;

    // 复制数组避免修改原数组
    int[] arr = Arrays.copyOf(nums, n);
    // 方向数组:-1表示向左,1表示向右
    int[] directions = new int[n];
    Arrays.fill(directions, 1); // 初始方向均向左

    // 添加初始排列
    result.add(toList(arr));

    while (true) {
    int mobileIndex = 1;
    int mobileValue = 1;

    // 查找最大的可移动元素
    for (int i = 0; i < n; i++) {
    int nextPos = i + directions[i];
    if (nextPos < 0 || nextPos >= n) continue;

    if (arr[i] > arr[nextPos]) {
    if (mobileIndex == 1 || arr[i] > mobileValue) {
    mobileIndex = i;
    mobileValue = arr[i];
    }
    }
    }

    // 没有可移动元素,算法结束
    if (mobileIndex == 1) break;

    // 交换移动元素与它指向的相邻元素
    int nextPos = mobileIndex + directions[mobileIndex];
    swap(arr, mobileIndex, nextPos);
    swap(directions, mobileIndex, nextPos);

    // 反转所有比移动元素大的元素的方向
    for (int i = 0; i < n; i++) {
    if (arr[i] > mobileValue) {
    directions[i] = directions[i];
    }
    }

    // 添加新排列
    result.add(toList(arr));
    }

    return result;
    }

    private void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
    }

    private List<Integer> toList(int[] arr) {
    List<Integer> list = new ArrayList<>();
    for (int num : arr) {
    list.add(num);
    }
    return list;
    }
    }

    性能分析:

    • 时间复杂度:O(n × n!),每次生成新排列需要O(n)时间寻找最大可移动元素
    • 空间复杂度:O(n),需要存储方向数组
    • 特点:生成的是相邻交换序列,每次只交换相邻两个元素

    3.4 阶乘定位法(第k个排列)

    核心思想:

    不生成所有排列,而是利用阶乘数系统直接计算第k个排列。此方法特别适合只需特定排列的场景。

    算法思路:

  • 对于n个元素,以某个数字开头的排列有(n-1)!个
  • 通过k/(n-1)!确定第一个位置的数字
  • 从候选列表中移除已使用的数字,更新k值
  • 重复上述过程直到所有位置确定
  • Java代码实现:

    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;

    public class KthPermutation {
    public String getPermutation(int n, int k) {
    List<Integer> numbers = new ArrayList<>();
    int[] factorial = new int[n + 1];
    StringBuilder result = new StringBuilder();

    // 计算阶乘值
    factorial[0] = 1;
    for (int i = 1; i <= n; i++) {
    factorial[i] = factorial[i 1] * i;
    numbers.add(i);
    }

    k; // 转换为0-based索引

    for (int i = 1; i <= n; i++) {
    int index = k / factorial[n i];
    result.append(numbers.get(index));
    numbers.remove(index);
    k -= index * factorial[n i];
    }

    return result.toString();
    }

    // 扩展:生成所有排列的变体
    public List<List<Integer>> permuteUsingFactorial(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    int n = nums.length;

    // 先排序确保从小到大的顺序
    Arrays.sort(nums);

    // 计算总排列数
    int total = 1;
    for (int i = 2; i <= n; i++) {
    total *= i;
    }

    // 生成每个排列
    for (int k = 0; k < total; k++) {
    result.add(getKthPermutation(nums, k));
    }

    return result;
    }

    private List<Integer> getKthPermutation(int[] nums, int k) {
    List<Integer> numbers = new ArrayList<>();
    for (int num : nums) {
    numbers.add(num);
    }

    List<Integer> result = new ArrayList<>();
    int n = nums.length;
    int[] factorial = new int[n + 1];

    // 计算阶乘
    factorial[0] = 1;
    for (int i = 1; i <= n; i++) {
    factorial[i] = factorial[i 1] * i;
    }

    int currentK = k;
    for (int i = 1; i <= n; i++) {
    int index = currentK / factorial[n i];
    result.add(numbers.get(index));
    numbers.remove(index);
    currentK -= index * factorial[n i];
    }

    return result;
    }
    }

    性能分析:

    • 时间复杂度:生成所有排列时为O(n! × n),获取单个排列为O(n²)
    • 空间复杂度:O(n),需要存储阶乘数组和候选列表
    • 特点:无需递归,纯迭代实现,适合获取特定位置的排列

    3.5 剪枝回溯(处理重复元素)

    核心思想:

    在标准回溯法基础上增加剪枝逻辑,处理包含重复元素的数组,确保生成的排列不重复。

    算法思路:

  • 先对数组排序,使相同元素相邻
  • 回溯过程中,如果当前元素与前一个元素相同且前一个元素未被使用,则跳过
  • 确保相同元素的相对顺序固定,避免生成重复排列
  • Java代码实现:

    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;

    public class PrunedBacktracking {
    public List<List<Integer>> permuteUnique(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    boolean[] visited = new boolean[nums.length];

    // 关键步骤:排序使相同元素相邻
    Arrays.sort(nums);

    backtrack(nums, visited, new ArrayList<>(), result);
    return result;
    }

    private void backtrack(int[] nums, boolean[] visited,
    List<Integer> current, List<List<Integer>> result) {
    if (current.size() == nums.length) {
    result.add(new ArrayList<>(current));
    return;
    }

    for (int i = 0; i < nums.length; i++) {
    // 跳过已使用的元素
    if (visited[i]) continue;

    // 关键剪枝条件:如果当前元素与前一个相同,
    // 且前一个元素未被使用,则跳过
    if (i > 0 && nums[i] == nums[i 1] && !visited[i 1]) {
    continue;
    }

    current.add(nums[i]);
    visited[i] = true;

    backtrack(nums, visited, current, result);

    current.remove(current.size() 1);
    visited[i] = false;
    }
    }
    }

    性能分析:

    • 时间复杂度:最坏情况下仍为O(n × n!),但剪枝减少了大量无效搜索
    • 空间复杂度:O(n),递归深度和标记数组大小
    • 特点:能有效处理重复元素,生成不重复的全排列

    4. 性能对比

    4.1 复杂度对比表

    算法时间复杂度空间复杂度是否稳定适用场景
    回溯法(标记数组) O(n × n!) O(n) 通用场景,最直观
    交换法(原地回溯) O(n × n!) O(n) 空间受限,不需要字典序
    STJ算法 O(n × n!) O(n) 需要相邻交换序列
    阶乘定位法 O(n! × n) O(n) 获取第k个排列
    剪枝回溯 O(n × n!) O(n) 数组包含重复元素

    4.2 实际性能测试

    我们对五种算法在n=6的情况下进行测试(n! = 720):

    算法执行时间(ms)内存消耗(MB)
    回溯法 45 12.3
    交换法 38 10.7
    STJ算法 52 11.8
    阶乘定位法 41 11.2
    剪枝回溯 48 12.5

    4.3 各场景适用性分析

  • 通用场景:推荐使用回溯法(标记数组),代码直观易懂,稳定可靠
  • 空间优化场景:使用交换法,原地操作节省内存
  • 获取特定排列:使用阶乘定位法,直接计算无需生成所有排列
  • 含重复元素:必须使用剪枝回溯,避免重复结果
  • 5. 扩展与变体

    5.1 下一个排列

    题目描述:实现获取数组下一个排列的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。如果不存在下一个更大的排列,则将其重排为最小的排列(即升序排列)。必须原地修改,只允许使用额外常数空间。

    public class NextPermutation {
    public void nextPermutation(int[] nums) {
    int i = nums.length 2;

    // 从右向左找到第一个递减的元素
    while (i >= 0 && nums[i] >= nums[i + 1]) {
    i;
    }

    if (i >= 0) {
    int j = nums.length 1;
    // 从右向左找到第一个大于nums[i]的元素
    while (j >= 0 && nums[j] <= nums[i]) {
    j;
    }
    swap(nums, i, j);
    }

    // 反转i+1到末尾的部分
    reverse(nums, i + 1);
    }

    private void swap(int[] nums, int i, int j) {
    int temp = nums[i];
    nums[i] = nums[j];
    nums[j] = temp;
    }

    private void reverse(int[] nums, int start) {
    int i = start, j = nums.length 1;
    while (i < j) {
    swap(nums, i, j);
    i++;
    j;
    }
    }
    }

    5.2 组合问题

    题目描述:给定两个整数n和k,返回范围[1, n]中所有可能的k个数的组合。

    import java.util.ArrayList;
    import java.util.List;

    public class Combinations {
    public List<List<Integer>> combine(int n, int k) {
    List<List<Integer>> result = new ArrayList<>();
    backtrack(1, n, k, new ArrayList<>(), result);
    return result;
    }

    private void backtrack(int start, int n, int k,
    List<Integer> current, List<List<Integer>> result) {
    if (current.size() == k) {
    result.add(new ArrayList<>(current));
    return;
    }

    // 剪枝优化:确保剩余元素足够填满组合
    for (int i = start; i <= n (k current.size()) + 1; i++) {
    current.add(i);
    backtrack(i + 1, n, k, current, result);
    current.remove(current.size() 1);
    }
    }
    }

    5.3 子集问题

    题目描述:给定一个整数数组nums,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。解集不能包含重复的子集。

    import java.util.ArrayList;
    import java.util.List;

    public class Subsets {
    public List<List<Integer>> subsets(int[] nums) {
    List<List<Integer>> result = new ArrayList<>();
    backtrack(0, nums, new ArrayList<>(), result);
    return result;
    }

    private void backtrack(int start, int[] nums,
    List<Integer> current, List<List<Integer>> result) {
    // 所有子集都有效,无需等到current大小等于nums长度
    result.add(new ArrayList<>(current));

    for (int i = start; i < nums.length; i++) {
    current.add(nums[i]);
    backtrack(i + 1, nums, current, result);
    current.remove(current.size() 1);
    }
    }
    }

    5.4 排列序列(第k个排列)

    题目描述:给出集合[1,2,3,…,n],其所有元素共有n!种排列。按大小顺序列出所有排列情况,并一一标记,给定n和k,返回第k个排列。

    import java.util.ArrayList;
    import java.util.List;

    public class PermutationSequence {
    public String getPermutation(int n, int k) {
    List<Integer> numbers = new ArrayList<>();
    int[] factorial = new int[n + 1];
    StringBuilder result = new StringBuilder();

    // 计算阶乘值并初始化数字列表
    factorial[0] = 1;
    for (int i = 1; i <= n; i++) {
    factorial[i] = factorial[i 1] * i;
    numbers.add(i);
    }

    k; // 转换为0-based索引

    for (int i = 1; i <= n; i++) {
    int index = k / factorial[n i];
    result.append(numbers.get(index));
    numbers.remove(index);
    k -= index * factorial[n i];
    }

    return result.toString();
    }
    }

    6. 总结

    6.1 核心思想总结

    全排列问题的本质是穷举所有可能性,但通过不同的策略可以优化搜索过程:

  • 回溯法:通过"选择-探索-撤销"的模式系统性地遍历所有可能性
  • 分治思想:将问题分解为子问题(固定一个位置,排列剩余元素)
  • 数学优化:利用阶乘性质直接定位特定排列
  • 剪枝策略:通过排序和条件判断避免重复计算
  • 6.2 算法选择指南

    • 面试场景:优先展示回溯法,体现对递归和搜索的理解
    • 竞赛场景:考虑使用交换法节省内存,或阶乘定位法快速获取特定排列
    • 生产环境:根据数据特点选择,小数据量用回溯,大数据量考虑近似算法或并行计算
    • 练习场景:从回溯法入手,逐步尝试优化和变体

    6.3 实际应用场景

  • 密码破解:尝试所有可能的密码组合
  • 行程规划:计算所有可能的路线排列寻找最优解
  • 测试用例生成:生成所有可能的输入组合进行测试
  • 游戏AI:在棋类游戏中搜索所有可能的走法
  • 数据分析:计算所有可能的特征组合寻找最佳模型
  • 6.4 面试建议

  • 先问清楚:数组是否可能包含重复元素?是否需要按特定顺序输出?
  • 从简单开始:先实现标准的回溯解法,再讨论优化
  • 分析复杂度:明确时间复杂度和空间复杂度,特别是阶乘级复杂度
  • 讨论边界:空数组、单元素数组等边界情况处理
  • 扩展思考:如果数组很大(n>10)怎么办?是否需要并行或分布式计算?
  • 6.5 常见面试问题Q&A

    Q:如何处理包含重复元素的数组? A:先排序,然后在回溯过程中增加剪枝条件:if (i > 0 && nums[i] == nums[i-1] && !visited[i-1]) continue;

    Q:全排列问题的时间复杂度能否低于O(n!)? A:不能,因为需要输出所有排列,而排列数本身就是n!,所以时间复杂度至少为O(n!)。但我们可以优化系数,减少每个排列的生成成本。

    Q:当n很大时(如n>20),这些算法是否可用? A:当n>12时,n!已经超过内存和处理能力限制。此时应考虑:1) 使用并行计算;2) 使用近似算法或抽样;3) 重新评估是否真的需要所有排列。

    Q:如何按字典序生成全排列? A:使用回溯法时,确保按顺序尝试数字,或使用下一个排列算法迭代生成。

    Q:全排列与组合、子集问题的关系? A:三者都是回溯法的经典应用。区别在于:全排列考虑顺序且长度固定;组合不考虑顺序但长度固定;子集不考虑顺序且长度可变。

    赞(0)
    未经允许不得转载:171主机测评 » LeetCode经典算法面试题 #46:全排列(回溯、交换、剪枝等五种实现方案详细解析)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址