📌 题目链接:46. 全排列 - 力扣(LeetCode)
🔍 难度:中等 | 🏷️ 标签:数组、回溯、递归
⏱️ 目标时间复杂度:O(n × n!)
💾 空间复杂度:O(n)
🧠 题目分析
给定一个不含重复数字的整数数组 nums,要求返回其所有可能的全排列。全排列即对数组中所有元素进行重新排序,使得每种顺序都恰好出现一次。
- 输入特点:无重复元素、长度 ≤ 6(小规模)
- 输出要求:所有排列组合,顺序任意
- 关键限制:每个数字只能使用一次
这是一道经典的组合类搜索问题,非常适合用回溯法(Backtracking) 解决。
🔁 核心算法及代码讲解:回溯(Backtracking)
✅ 什么是回溯?
回溯是一种暴力搜索 + 剪枝优化的算法思想,常用于解决排列、组合、子集、N皇后等问题。其核心是:
- 尝试:在当前状态做出一个选择;
- 递归:进入下一层状态继续探索;
- 撤销:回退到上一状态,尝试其他选择(即“回溯”)。
🎯 回溯 = DFS(深度优先搜索) + 状态重置
✅ 本题回溯策略
我们把排列过程看作依次填满 n 个位置:
- 第 0 位:从所有数字中选一个;
- 第 1 位:从剩下的数字中选一个;
- ...
- 第 n-1 位:只剩最后一个数字。
为了高效实现“剩下的数字”,官方题解采用了一种巧妙的原地交换法,避免额外使用 visited 数组:
-
将数组划分为两部分:
[0, first - 1]:已确定的前缀(已选)[first, n - 1]:可选的候选集(未选)
-
每次从候选集中选一个数
nums[i],与nums[first]交换,使其进入前缀; -
递归处理
first + 1; -
回溯时再交换回来,恢复原状。
💡 这种方法空间更优(无需额外 visited),但不保证字典序输出。若题目要求字典序,需先排序或用 visited 数组。
✅ 代码逐行注释(C++)
void backtrack(vector<vector<int>>& res, vector<int>& output, int first, int len) {
// 🛑 终止条件:所有位置已填满(first == len)
if (first == len) {
res.emplace_back(output); // 将当前排列加入结果集
return;
}
// 🔁 枚举所有可选的数字(从 first 到 len-1)
for (int i = first; i < len; ++i) {
// 🔄 交换:将 nums[i] 放到第 first 位(选它!)
swap(output[i], output[first]);
// 📥 递归:处理下一个位置 first+1
backtrack(res, output, first + 1, len);
// 🔙 回溯:撤销选择,恢复原数组状态
swap(output[i], output[first]);
}
}
⚠️ 注意:
output是引用传递,因此必须在递归后显式撤销操作,否则会影响其他分支!
🧩 解题思路(分步拆解)
-
初始化:准备结果容器
res,直接复用输入nums作为工作数组。 -
递归入口:从第 0 位开始填,调用
backtrack(res, nums, 0, n)。 -
递归过程:
-
若
first == n,说明已生成一个完整排列,加入res。 -
否则,遍历
[first, n-1]中的每个位置i:- 交换
nums[first]与nums[i],使nums[i]被“选中”; - 递归填
first + 1; - 交换回来,恢复现场,尝试下一个
i。
- 交换
-
-
返回结果:
res包含所有全排列。
✅ 此方法利用了排列的对称性:通过交换,动态维护“已选”和“未选”区域。
📊 算法分析
⏱️ 时间复杂度:O(n × n!)
- 共有
n!种排列; - 每次生成一个排列需要
O(n)时间(复制到结果集); - 递归调用总次数约为
e × n!(数学上 ∑P(n,k) < 3n!),仍为 O(n!) 级别; - 总体:O(n × n!)
💾 空间复杂度:O(n)
- 递归栈深度:最多
n层(每个位置递归一次)→ O(n) - 结果存储:O(n × n!),但通常不计入空间复杂度(题目要求输出)
- 额外空间:仅用几个变量 → O(1) 辅助空间
✅ 该解法是空间最优的回溯实现之一。
💻 完整代码
C++ 版本
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
class Solution {
public:
void backtrack(vector<vector<int>>& res, vector<int>& output, int first, int len) {
// 所有数都填完了
if (first == len) {
res.emplace_back(output);
return;
}
for (int i = first; i < len; ++i) {
// 动态维护数组
swap(output[i], output[first]);
// 继续递归填下一个数
backtrack(res, output, first + 1, len);
// 撤销操作
swap(output[i], output[first]);
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int> > res;
backtrack(res, nums, 0, (int)nums.size());
return res;
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
Solution sol;
vector<int> nums1 = {1, 2, 3};
auto res1 = sol.permute(nums1);
for (auto& p : res1) {
for (int x : p) cout << x << " ";
cout << "\n";
}
cout << "---\n";
vector<int> nums2 = {0, 1};
auto res2 = sol.permute(nums2);
for (auto& p : res2) {
for (int x : p) cout << x << " ";
cout << "\n";
}
cout << "---\n";
vector<int> nums3 = {1};
auto res3 = sol.permute(nums3);
for (auto& p : res3) {
for (int x : p) cout << x << " ";
cout << "\n";
}
return 0;
}
JavaScript
/**
* @param {number[]} nums
* @return {number[][]}
*/
var permute = function(nums) {
const res = [];
function backtrack(first) {
if (first === nums.length) {
res.push([...nums]); // 深拷贝当前排列
return;
}
for (let i = first; i < nums.length; i++) {
// 交换
[nums[first], nums[i]] = [nums[i], nums[first]];
// 递归
backtrack(first + 1);
// 回溯
[nums[first], nums[i]] = [nums[i], nums[first]];
}
}
backtrack(0);
return res;
};
// 测试
console.log(permute([1,2,3]));
console.log(permute([0,1]));
console.log(permute([1]));
💡 JS 注意:
res.push([...nums])必须用展开运算符深拷贝,否则所有结果会指向同一个数组引用!
🎯 面试高频考点 & 延伸思考
❓ 面试常问问题
-
为什么需要两次 swap?
- 第一次 swap 是“做选择”,第二次是“撤销选择”,保证回溯后状态干净。
-
能否不用 swap,用 visited 数组?
- 可以!但空间复杂度变为 O(n),适合需要字典序输出或含重复元素的情况(如 LeetCode 47)。
-
时间复杂度为什么是 O(n × n!)?
- 因为要复制 n! 个长度为 n 的排列到结果中。
-
如果数组有重复元素怎么办?
- 需要去重:排序 + 跳过相同元素(见 LeetCode 47. 全排列 II)。
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!