今日算法(回溯全排列)

题目描述

给定一个不含重复数字的数组 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 <= numsi <= 10
  • nums 中的所有整数 互不相同

解题思路

全排列问题是回溯算法的经典入门题目,核心是穷举所有可能的元素排列顺序。下面我们将详细讲解两种最常用的解法:基于 used 数组标记的回溯法和基于原地交换的优化回溯法。

方法一:回溯法(used 数组标记)

思路分析

回溯法的核心思想是 "选择 - 递归 - 回溯"。对于全排列问题,我们需要从数组中依次选择未被使用过的元素,加入当前排列路径,直到路径长度等于数组长度,得到一个完整的排列。然后回溯撤销选择,尝试其他可能的元素。

具体来说:

  1. 维护一个当前排列path和一个布尔数组usedused[i]表示数组中第 i 个元素是否已被使用
  2. 遍历数组中的每个元素,如果该元素未被使用,则:
    • 将其标记为已使用
    • 加入当前排列path
    • 递归处理下一个位置
    • 递归返回后,从path中移除该元素(回溯)
    • 将其标记为未使用
  3. path的长度等于数组长度时,将path加入结果集
代码实现(C++)
复制代码
#include <vector>
using namespace std;

class Solution {
private:
    vector<vector<int>> result; // 存储所有全排列结果
    vector<int> path;           // 存储当前正在构建的排列

    void backtracking(vector<int>& nums, vector<bool>& used) {
        // 终止条件:当前排列长度等于数组长度,得到一个完整排列
        if (path.size() == nums.size()) {
            result.push_back(path);
            return;
        }

        // 遍历所有元素,选择未被使用的
        for (int i = 0; i < nums.size(); i++) {
            if (used[i]) continue; // 已使用的元素跳过

            used[i] = true;               // 标记为已使用
            path.push_back(nums[i]);      // 加入当前排列
            backtracking(nums, used);     // 递归处理下一个位置
            path.pop_back();              // 回溯,撤销选择
            used[i] = false;              // 取消标记
        }
    }

public:
    vector<vector<int>> permute(vector<int>& nums) {
        result.clear();
        path.clear();
        vector<bool> used(nums.size(), false);
        backtracking(nums, used);
        return result;
    }
};
复杂度分析
  • 时间复杂度:\(O(n \times n!)\)。共有 \(n!\) 种不同的全排列,每个排列需要 \(O(n)\) 的时间复制到结果集中。
  • 空间复杂度 :\(O(n)\)。递归调用栈的深度为 n,同时path数组和used数组的大小均为 n。

方法二:回溯法(原地交换优化)

思路分析

这种方法不需要额外的used数组来标记已使用的元素,而是通过交换数组元素的方式,将已选择的元素 "固定" 在当前位置,从而避免重复选择。

具体来说:

  1. 维护一个起始索引start,表示当前正在处理的位置
  2. start开始遍历数组,将每个元素与start位置的元素交换(相当于选择该元素作为当前位置的元素)
  3. 递归处理下一个位置(start + 1
  4. 递归返回后,将元素交换回来(回溯)
  5. start等于数组长度时,将当前数组加入结果集
代码实现(C++)
复制代码
#include <vector>
#include <algorithm> // 用于swap函数
using namespace std;

class Solution {
private:
    vector<vector<int>> result; // 存储所有全排列结果

    void backtracking(vector<int>& nums, int start) {
        // 终止条件:处理完所有位置,得到一个完整排列
        if (start == nums.size()) {
            result.push_back(nums);
            return;
        }

        // 从start开始遍历,选择当前位置的元素
        for (int i = start; i < nums.size(); i++) {
            swap(nums[start], nums[i]);    // 选择第i个元素作为当前位置的元素
            backtracking(nums, start + 1); // 递归处理下一个位置
            swap(nums[start], nums[i]);    // 回溯,交换回来
        }
    }

public:
    vector<vector<int>> permute(vector<int>& nums) {
        result.clear();
        backtracking(nums, 0);
        return result;
    }
};
复杂度分析
  • 时间复杂度:\(O(n \times n!)\)。与方法一相同,共有 \(n!\) 种排列,每个排列需要 \(O(n)\) 时间复制。
  • 空间复杂度 :\(O(n)\)。递归调用栈的深度为 n,不需要额外的used数组,空间效率略高于方法一。

两种方法对比

方法 优点 缺点 适用场景
used 数组标记法 逻辑清晰,易于理解,不易出错 需要额外的布尔数组空间 初学者入门,面试答题(不易写错)
原地交换法 节省空间,代码更简洁 生成的排列顺序与输入顺序不同,逻辑稍抽象 追求空间效率,熟悉回溯思想后使用

总结

全排列问题是回溯算法的标杆题目,掌握这两种解法对于理解回溯算法的核心思想至关重要。

  • used 数组标记法是最直观的写法,通过显式标记已使用的元素,逻辑清晰,面试时优先推荐使用
  • 原地交换法通过交换元素实现标记,节省了额外的空间,但需要注意交换和回溯的顺序
  • 与子集问题不同,全排列问题每次都需要遍历所有未使用的元素,而不是从某个起始索引开始,这是两者最核心的区别
相关推荐
Boom_Shu1 小时前
构造函数程序
数据结构·算法
MicroTech20251 小时前
微算法科技(NASDAQ: MLGO)量子安全与区块链:量子神经网络QNN赋能动态共识与量子密钥分发
科技·算法·安全
不会C语言的男孩1 小时前
C++ Primer 第6章:函数
开发语言·c++
码上有光1 小时前
c++:多态
java·jvm·c++·多态·多态原理
Lumbrologist1 小时前
【C++】零基础入门 · 第 18 节:互斥锁与线程同步
java·开发语言·c++
tangchao340勤奋的老年?1 小时前
C++ OpenGL显示地图
c++·opengl
I Promise341 小时前
C++ 多线程编程:从入门到实战
开发语言·c++
sali-tec1 小时前
C# 基于OpenCv的视觉工作流-章81-弯脚检测
图像处理·人工智能·opencv·算法·计算机视觉
kkeeper~1 小时前
0基础C语言积跬步之自定义类型联合和枚举
c语言·开发语言·算法