LeetCode 经典回溯题------46. 全排列,这道题是回溯算法的入门必刷题,核心考察"穷举所有可能"的思路,虽然代码不长,但每一步都藏着回溯的精髓,新手很容易在"回溯回退"这一步踩坑,今天就带着大家逐行拆解,把思路讲透。
一、题目解读:什么是全排列?
题目很简单:给定一个不含重复数字的数组 nums,返回它所有可能的全排列,顺序不限制。
举个例子:如果 nums = [1,2,3],它的全排列就是所有不同顺序的组合,一共 3! = 6 种,分别是:
\[1,2,3\], \[1,3,2\], \[2,1,3\], \[2,3,1\], \[3,1,2\], \[3,2,1\]
关键注意点:数组不含重复元素,所以不需要考虑去重(这是和"全排列II"的核心区别),只需专注于"如何穷举所有顺序"。
二、核心思路:回溯法------"试错+回退"的穷举艺术
全排列的本质是"从剩余元素中不断选择一个,直到选完所有元素",这个过程就像走迷宫:
-
选一个元素放进"临时结果"(temp);
-
从剩下的元素中,再选一个放进临时结果;
-
重复步骤,直到没有剩余元素(此时临时结果就是一个完整的排列);
-
"回退"一步,把最后选的元素拿出来,换另一个元素尝试(这就是"回溯"的核心)。
这种思路可以用「深度优先搜索(DFS)」来实现,DFS负责"深入选元素",回溯负责"回退换元素",两者结合就能穷举所有可能。
三、代码逐行拆解(附完整代码)
先贴出完整AC代码,再逐行拆解每一部分的作用,新手跟着走,一定能看懂:
typescript
function permute(nums: number[]): number[][] {
const res: number[][] = []; // 存储最终所有全排列结果
// 递归函数:arr是剩余待选元素,temp是当前临时排列
const dfs = (arr: number[], temp: number[]) => {
// 终止条件:剩余元素为空,说明temp是一个完整排列
if (arr.length === 0) {
res.push([...temp]); // 深拷贝,避免后续修改影响结果
return;
}
// 遍历剩余所有元素,逐个尝试选择
for (let i = 0; i < arr.length; i++) {
// 1. 从剩余元素中选出当前元素(排除第i个元素,得到新的剩余数组)
const newArr = arr.filter((_, index) => index !== i);
// 2. 将当前元素加入临时排列
temp.push(arr[i]);
// 3. 递归:继续从新的剩余元素中选择
dfs(newArr, temp);
// 4. 回溯:把刚才加入的元素拿出来,换下一个元素尝试
temp.pop();
}
}
// 初始调用:剩余元素是nums,临时排列为空
dfs(nums, []);
return res;
};
1. 变量初始化:res 存储最终结果
const res: number[][] = []; ------ 用来保存所有完整的全排列,比如上面例子中的6种组合,最终都会存在这里。
2. 核心递归函数 dfs:负责"选元素+回溯"
dfs 有两个参数:
-
arr:当前剩余待选择的元素(比如第一次调用时是nums,选了1之后,arr就变成[2,3]);
-
temp:当前正在构建的临时排列(比如选了1之后,temp就是[1],再选2就是[1,2])。
3. 终止条件:arr.length === 0
当剩余元素为空时,说明temp已经包含了所有nums的元素,是一个完整的排列,此时需要把temp加入res。
注意:这里必须用 [...temp] 深拷贝,而不是直接 res.push(temp)!因为temp是引用类型,后续回溯时会修改temp的值,如果直接push,res里的元素会跟着变,最后全是空数组。
4. 循环遍历:逐个尝试剩余元素
for (let i = 0; i < arr.length; i++) ------ 遍历当前所有剩余元素,每个元素都要尝试作为"下一个选中的元素"。
这部分是核心,拆解为4步:
-
const newArr = arr.filter((_, index) => index !== i); ------ 生成新的剩余元素数组,排除当前选中的第i个元素(比如arr是[1,2,3],i=0时,newArr就是[2,3]);
-
temp.push(arr[i]); ------ 把当前选中的元素加入临时排列(比如选中1,temp就变成[1]);
-
dfs(newArr, temp); ------ 递归调用,继续从newArr中选元素,构建临时排列;
-
temp.pop(); ------ 回溯的关键!把刚才加入的元素"拿出来",恢复temp的状态,方便下一次循环尝试其他元素(比如选了1之后,递归结束,pop掉1,temp变回空,再尝试选2)。
5. 初始调用:启动DFS
dfs(nums, []); ------ 第一次调用时,剩余元素是原始数组nums,临时排列为空,正式开始穷举。
四、关键易错点(新手必看)
-
深拷贝问题:res.push([...temp]) 不能写成 res.push(temp),否则会因为引用类型导致结果错误;
-
回溯回退:temp.pop() 必须放在递归调用之后,确保递归结束后,temp能恢复到上一步的状态,否则会漏选或多选元素;
-
剩余元素处理:newArr是通过filter生成的新数组,不是修改原arr,这样能保证每次递归的剩余元素都是独立的,不会相互影响。
五、思路拓展:时间复杂度与空间复杂度
了解复杂度,能帮我们更好地理解算法的效率:
-
时间复杂度:O(n!) ------ n是数组长度,全排列的总数是n!,每个排列的构建需要O(n)时间,整体就是O(n×n!),简化为O(n!);
-
空间复杂度:O(n) ------ 递归栈的深度是n(最多递归n层),temp数组的长度最多也是n,res数组存储所有排列,属于输出空间,一般不计入复杂度。
六、总结
LeetCode 46. 全排列的核心是「回溯法+DFS」,记住一句话:"选一个元素,递归穷举剩余,回溯回退换另一个"。
这道题的代码很简洁,但每一步都体现了回溯的思想,尤其是temp.pop()的回退操作,是新手理解回溯的关键。建议大家自己动手调试一遍,看着temp和arr的变化,就能彻底明白回溯的逻辑。