☰
全排列算法与回溯法:从递归实现到剪枝优化
2026/9/30 4:15:01 网站建设 项目流程

1. 全排列到底在考什么:从数学定义到算法题本质

很多人第一次遇到“全排列算法”这个词,是在LeetCode的第46题“Permutations”上,也可能是在面试手撕算法环节被面试官冷不丁地问一句:“给你一个不含重复数字的数组,输出它的所有排列。”这道题标配的解法就是回溯法,而回溯法又是整个算法体系里最常被考察的思想之一。所以全排列算法表面上是一道题,实际上是理解递归、状态回溯、剪枝这三件事的最佳训练场。

先回到数学定义。给定一个含n个不同元素的集合,全排列就是从第一个位置到第n个位置,把每个元素按任意顺序各放一次,得到的所有长度为n的序列。数量是n!,也就是n的阶乘。比如[1,2,3]的全排列一共有6个:123、132、213、231、312、321。如果元素里有重复,比如[1,1,2],那全排列数量不再是3!等于6,而是3!/(2!1!)等于3个,即112、121、211。这个去重的过程在算法题里叫“剪枝”,也是LeetCode 47题的考点。

从算法分类上看,全排列涉及的知识点很集中:递归、回溯、深度优先搜索(DFS)、状态重置、剪枝。这些关键词和热搜列表里的KMP算法、Prim算法、堆排序不一样,它们一个属于字符串匹配,一个属于图论,一个属于排序,而全排列属于“暴力枚举”与“搜索”这一类。明白这一点很重要,因为你面对的不是一个孤立题目,而是一整类“用搜索穷举所有可能性”的问题模型。

那为什么全排列这么适合作为回溯算法的入门题?因为它结构足够简单,问题空间清晰,不需要额外设计复杂的数据结构,却又完整包含了回溯算法最核心的“做选择——递归——撤销选择”三步曲。学会了全排列,后面的组合总和、N皇后、数独、子集、括号生成,本质上都是同一套框架换了换约束条件。

这篇文章我会从递归树的视角把全排列的求解过程拆开,给出Python和Java两种语言的对比实现,重点讲清楚三个大多数人容易卡壳的地方:一是为什么撤销操作不能省,二是重复元素剪枝条件里的visited[i-1]到底应该判断true还是false,三是复杂度的递推推导。最后再用一个专门的章节讲我在实际刷题和面试辅导里见到的常见错误,以及怎么从全排列延伸出去解决组合、子集、N皇后这些问题。

如果你只是急着背代码,网上到处都能找到题解。但如果想把这道题吃透,让它成为你解决一大类“排列组合型”题目的弹药库,这篇文章值得你耐心看完,每个章节还有配套的调试经验。

2. 回溯法:从决策树视角推导全排列的标准解法

2.1 把全排列拆成“逐位填空”的递归模型

回溯法解决全排列,最直观的模型是“逐位填空”。假设数组是[1,2,3],第一位可以选1、2、3中任意一个;选完第一位后,第二位只能在剩余元素里选一个;第三位只能选剩下的最后一个。整个过程是一棵三层的决策树,每层代表一个位置,每个节点分支数递减。

以第一位选1为例,第二位只能选2或3;如果第二位选了2,第三位只能选3。于是得到排列[1,2,3]。如果第二位选了3,第三位只能选2,得到[1,3,2]。“回溯”就发生在这里:当[1,2,3]这个分支走到底以后,程序要退回上层,把第二位的2换成3重新尝试。这个退回并尝试其他分支的动作,就是回溯。

用递归代码表达就非常清晰:递归函数的核心逻辑是“在当前层尝试每一个还没用过的数字,然后进入下一层”。递归的出口是“当前位置已经填到了第n位”,这时候path里装的n个数就是一个完整排列,保存结果后直接返回。

这里的关键点在于:每层递归尝试一个数字之后,必须把这个数字从“已使用”状态改回来,否则下一层分支会看不到这个数字。这个“改回来”的动作,就是整个回溯算法的灵魂所在。

2.2 路径维护法与交换法的Python/Java对比实现

先看最经典的“路径维护法”实现,我用Python写一版,用Java再写一版。两版代码逻辑完全等价,区别只是数据结构表达方式不同。

def permute(nums): n = len(nums) used = [False] * n path = [] result = [] def dfs(): if len(path) == n: result.append(path[:]) return for i in range(n): if not used[i]: used[i] = True path.append(nums[i]) dfs() path.pop() used[i] = False dfs() return result

Java版本如下:

public List<List<Integer>> permute(int[] nums) { List<List<Integer>> result = new ArrayList<>(); boolean[] used = new boolean[nums.length]; Deque<Integer> path = new ArrayDeque<>(); dfs(nums, used, path, result); return result; } private void dfs(int[] nums, boolean[] used, Deque<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]) { used[i] = true; path.addLast(nums[i]); dfs(nums, used, path, result); path.removeLast(); used[i] = false; } } }

这两段代码的框架完全一致:一个used数组标记元素是否已经填进当前路径,一个path容器记录当前排列前缀,一个result收集最终答案。递归函数里先判断“够不够长”,够就收下结果;不够就遍历所有元素,挑没被用过的填进去。

再说另一种实现——交换法。交换法的思路不是额外用used数组,而是直接在原数组上做交换。递归的每一层负责确定当前位置的数字,做法是把从当前位置到末尾的每个元素依次交换到当前位置,然后递归处理下一层。换完之后一定要再换回来,否则数组状态会乱掉。

def permute_swap(nums): result = [] n = len(nums) def dfs(start): if start == n: result.append(nums[:]) return for i in range(start, n): nums[start], nums[i] = nums[i], nums[start] dfs(start + 1) nums[start], nums[i] = nums[i], nums[start] dfs(0) return result

交换法代码更短,不需要额外空间,但有一个隐藏的坑:原始数组被不断修改,如果后续还要使用原数组,必须先拷贝一份。而且交换法的顺序和路径维护法的顺序不一样,面试时如果你用交换法,建议先想清楚自己能不能讲清楚递归过程中数组到底处于什么状态。我自己更喜欢路径维护法,因为它更贴近“逐位填空”的自然思维,调试时也更容易理解当前路径的含义。

2.3 回溯的核心动作:为什么必须有“撤销”这一步

初学者最容易问的问题就是:为什么递归调用完了还要path.pop()?还要used[i] = False?不撤销会发生什么?

答案很简单:DFS的本质是沿着一条路走到底,再退回分岔口走另一条路。如果不撤销,退回分岔口的时候,之前那条路的使用痕迹还留着,新分支就没法正常选择可用元素。用一个生活化的类比:你在一个迷宫的分岔口选择走左边,走到底发现是死胡同,回到分岔口后,你肯定要“放弃”刚才左边那条路的标记,才能往右边走。不撤销,就等于你人回到了分岔口,但记忆里还停留在左边的路上。

具体到代码上,path是一个共享的容器,used是共享的标记数组。所有递归分支共享同一份状态,所以每个分支在退出之前必须把状态还原成进入前的样子。如果你在dfs()之前把used[i]置为True,那dfs()返回后必须重新置为False。这一步的“对称性”是最容易出bug的点,我见过不少人在提交代码时漏掉path.pop(),结果输出里每个排列都长得一模一样,全是n个相同的路径叠加。

撤销操作并不是回溯算法的“附加项”,它就是回溯本身。理解了这个细节,你就理解了整个回溯法的核心:状态在递归进入时改变,在递归退出时恢复,保证每个分支都从同一种起始状态出发。

3. 重复元素与剪枝:应对LeetCode 47这类变体

3.1 先排序,再用剪枝条件剔除重复分支

如果原数组不包含重复元素,第2章的两版代码已经能够正确输出所有排列。但LeetCode第47题“全排列II”把条件改成了“数组可能包含重复元素”,如果不做任何处理,输出的排列里会出现大量重复项。比如[1,1,2],按原逻辑会生成6个排列,但其中112会出现两次,121出现两次,211出现两次。

为什么重复呢?因为两个1虽然是两个不同的数组下标,但它们在排列中的“角色”完全一样。第一位填下标0的1和填下标1的1,得到的都是112,无法区分。要剔除重复,最直接的方法是用HashSet记录已经尝试过的数字,但这会增加额外空间。更优雅的方式是先对数组排序,让相同的数字相邻,再在递归里加一个剪枝条件。

排序的目的很简单:把相同元素聚到一起。这样当我们遍历到某个元素时,可以立即知道前一个位置的元素是不是和自己相同。如果相同且前一个元素在本次递归中处于“未使用”状态,那说明当前分支产生的排列一定和之前某个分支重复,没必要继续执行下去。

3.2 剪枝条件的两种写法,区别在哪

网上流传最广的剪枝写法有两种,很多人在两种写法之间来回摇摆。一种是用used[i-1] == false,另一种是用used[i-1] == true。到底哪种对?这要看你想要的剪枝语义。

先看used[i-1] == false的版本,这是比较推荐的写法。它的逻辑是:只有当重复元素的前一个相同元素“处于未使用”时,才跳过当前元素。这个条件隐含的意思是:相同元素之间,我们只允许“从左到右依次使用”这一种顺序。如果前一个相同元素还没被用过,说明当前元素想越过它先被使用,这会产生与“前一个先使用”时完全相同的结果,所以应当剪枝。

举个例子。数组排序后是[1a, 1b, 2]。如果第一位选了1b,此时1a还没被用过,按照used[0]==false的条件,1b在第一位的分支会被剪掉。所以排列里只保留了第一位是1a的情况,而1a和1b的问题在数学上是同一个1,结果自然就不重复了。

再看used[i-1] == true的写法。它的逻辑是:当前一个相同元素已经被使用过,才跳过当前元素。这保留的是另一种顺序语义——相同元素之间必须“从右到左”使用。这种写法也能去重,但因为遍历顺序不同,输出结果的排序会和第一种写法不同。在某些更复杂的题目里,两种写法可能产生不同的剪枝效果,新手容易搞混。

我的建议是记住一种就够:判断前一个相同元素是否未被使用(used[i-1] == false)。原因是它更直观,剪枝后得到的结果顺序更符合“第一个相同元素先被填入”的自然顺序,调试时思路更清晰。

3.3 完整的去重全排列实现

基于前面的分析,给出可运行的Python和Java版本。Python版本用used布尔数组配合排序,Java版本几乎一模一样,只是数据结构换成ArrayList。

def permuteUnique(nums): nums.sort() n = len(nums) used = [False] * n path = [] result = [] def dfs(): if len(path) == n: result.append(path[:]) return for i in range(n): if used[i]: continue if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]: continue used[i] = True path.append(nums[i]) dfs() path.pop() used[i] = False dfs() return result
public List<List<Integer>> permuteUnique(int[] nums) { List<List<Integer>> result = new ArrayList<>(); Arrays.sort(nums); boolean[] used = new boolean[nums.length]; Deque<Integer> path = new ArrayDeque<>(); dfs(nums, used, path, result); return result; } private void dfs(int[] nums, boolean[] used, Deque<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; if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue; used[i] = true; path.addLast(nums[i]); dfs(nums, used, path, result); path.removeLast(); used[i] = false; } }

这段代码里,剪枝条件是i > 0 && nums[i] == nums[i-1] && !used[i-1]。很多第一次接触这段代码的人会对!used[i-1]感到困惑:为什么前一个相同元素“没被使用”反而是重复?因为这意味着前一个相同元素在当前递归层级里还没被填,而当前这个相同元素想抢在前面填。一旦允许这种情况,就会出现同一个“值”在同一个位置被多个不同下标“轮换着填”的场景,最后产生的排列完全一样。所以必须砍掉这类分支。

补充一个等价的做法:不用used数组,直接在每一层递归用HashSet记录本层已经尝试过的值。顺序遍历到每个位置时,如果当前值在本层已经尝试过,就直接跳过。这个方案额外引入了一个集合,逻辑上和排序剪枝完全等价,但因为HashSet无法利用有序性,结果顺序不稳定,代码也稍微多几行。我更推荐排序剪枝,因为代价只有一次排序的O(n log n),对整体复杂度影响微乎其微。

4. 从复杂度到实际应用:全排列的数学模型为什么能支撑整个回溯体系

4.1 时间复杂度n!是怎么算出来的

全排列的复杂度分析,是很多面试官喜欢追问的点。回答这个问题,关键不在于背下“O(n*n!)”这个结论,而是理解它是怎么推出来的。

每一层递归的调用次数由分支数决定。第一层有n个选择,第二层有n-1个选择,第三层有n-2个选择,直到最后一层只有1个选择。所以叶子节点总数是n×(n-1)×(n-2)×...×1,也就是n!个叶子。每一个叶子对应一个完整排列,每个排列长度为n,仅仅生成并记录这些排列就需要O(n×n!)的时间。

除了叶子,每个内部节点也需要花时间去遍历候选元素。内部节点总数大约也是n!量级,每个内部节点遍历长度不超过n,所以总时间依然是O(n×n!)。这个复杂度在算法题里属于“爆炸级别”,n=10时是3628800个排列,勉强还能跑;n=12是479001600个排列,已经非常吃力;n=20更是天文数字,根本不可能跑完。

明白了这个复杂度,你就知道为什么全排列类题目通常限制n在8到10左右。面试时如果面试官让你优化全排列,不要试图把复杂度降到多项式级别,因为这是不可能的——输出量本身就是n!级别,你能优化的只是常系数,比如用交换法省去used数组的遍历开销,或者用剪枝提前排除一些明显不可能的分支。

4.2 空间复杂度不只看递归深度

空间复杂度方面,递归深度是n,所以调用栈占用O(n)。如果采用路径维护法,还有path容器和used数组,各占O(n)。结果集合result存储了n!个排列,每个排列长度n,这部分空间是O(n×n!),但通常不把它计入算法本身的辅助空间,因为这是题目要求的输出。如果额外使用HashSet记录已尝试值,空间复杂度还要再加一层。

这里有一个值得注意的事:如果你在写成结果的时候直接result.append(path)而不是result.append(path[:]),那么你存进去的其实是对同一个可变对象的引用。后续path.pop()会把已经存进结果里的数据也改掉。这是Python语言特有的坑,Java也因为path是引用类型,必须new ArrayList<>(path)复制一份才能加入结果。我在带新人的时候,这个问题几乎每次都会出现,值得多强调一遍。

4.3 字典序法和堆算法的原理与适用场景

回溯法是全排列最通用的思路,但并不是唯一思路。工程上还有两种算法值得了解:字典序法和堆算法,它们都常用于某些需要按序生成排列的场景。

字典序法的核心是“找下一个排列”。给定一个排列,通过一次扫描找到从右往左第一个“递增对”,然后调整后续元素,得到字典序意义上的下一个排列。从[1,2,3]出发依次生成:[1,2,3] → [1,3,2] → [2,1,3] → [2,3,1] → [3,1,2] → [3,2,1],正好覆盖全部排列。这个算法不需要额外的递归栈和used数组,而且生成的排列天然按字典序排列,非常适合需要有序输出的场景。C++标准库里的next_permutation就是基于这个思想实现的。

堆算法则是一种巧妙的交换算法,它保证每一轮只用一次交换就能从一个排列得到下一个排列,避免了重复的候选遍历。堆算法的代码非常短,但理解起来相对绕,需要对数组下标之间的关系有清晰认知。它的应用场景主要集中在某些特定工程环境或需要极致性能的程序中,算法题里很少用到。

表格对比一下三种方法:

算法核心思想是否按字典序额外空间适用场景
回溯法(路径维护)递归填位,状态回溯取决于遍历顺序O(n)算法题主力,易于理解与剪枝扩展
字典序法每次生成下一个排列是O(1)需要有序输出,工程实测常用
堆算法每次交换得到下一排列否O(1)对常数优化要求极高的场景

理解这三种方法,你就不至于在面试中被问“有没有比回溯更快的方法”时哑口无言。没有绝对的最优方法,只是每种方法的目标场景不一样。

5. 实战经验:面试和刷题中最常踩的四个坑

5.1 退出条件写错:位置与长度的关系

全排列递归的退出条件有两个等价写法:一是if len(path) == n,二是if start == n(交换法)。看起来简单,但容易在两种写法的混用中出错。比如在路径维护法里写if start == n,而start根本没有定义,程序直接报错;或者在交换法里写if len(path) == n,而交换法里根本没有维护path,逻辑就乱了。

还有一种更隐蔽的错误:有人把退出条件写在循环内部。比如在for i in range(n)的循环体里判断len(path) == n才返回,这样会导致递归的栈帧在返回前可能多执行了后半段代码,引发奇怪的重复或漏项。退出条件必须放在递归函数的最前面,进入递归体后第一时间判断,这是所有DFS题目的通用约定。

5.2 状态恢复遗漏:少了一行代码,结果全错

这个坑我提过好几次,但每次复盘都值得再讲一遍,因为它真的是新手最高频的错误。以路径维护法为例,递归进入时对used[i]和path做的所有修改,必须在递归返回后对称地恢复。具体来说就是:递归前used[i] = True、path.append(nums[i]),递归后used[i] = False、path.pop()。

少写path.pop()的典型后果是:最终输出结果里的每个排列长度各不相同,有的长有的短,而且里面会残留之前分支的元素。少写used[i] = False的后果更隐蔽:某一层的某些元素永远无法被选择,导致结果缺失严重,输出数量远小于n!。

我自己排查这类bug的经验是:在递归进入和返回的地方各打一行日志,打印i、used数组和path,看状态是否“进出一致”。如果发现path在递归返回后比进入前多了一个元素,八成就是撤销漏了。

5.3 剪枝条件写反:到底该判断used[i-1]还是!used[i-1]

前面第3章已经详细分析过两种剪枝写法,这里从排错角度再强调一遍。当你写出if i > 0 and nums[i] == nums[i-1] and used[i-1]:时,得到的结果虽然能去重,但去重的“顺序语义”变成了“相同元素按从右到左的顺序使用”。这本身不一定是错的,但在与其他逻辑混用时容易产生诡异的结果。

我在实际调试中见到的最常见的“写反”错误,是有人先尝试used[i-1] == true发现结果顺序不对,然后直接改成!used[i-1]但不理解含义,最后代码能通过测试,却完全讲不清楚为什么。面试时一旦被追问“为什么这里取反”,就会露馅。

所以要真正掌握,必须回到定义:!used[i-1]的意思是“前一个相同元素还没有被使用”。在这个前提下,当前元素如果尝试使用,就会打破相同元素必须从左到右使用的不成文规则,产生重复,所以剪枝。理解了这个语义,哪怕面试官让你改成used[i-1] == true并解释区别,你也能从“去重顺序不同”这个角度回答清楚。

5.4 排列和组合分不清:全排列的一个常见误用场景

全排列和组合是两个容易混淆的概念。组合只关心“选哪几个”,不关心“先后顺序”;排列则关心顺序。比如从[1,2,3]中选2个数,组合只有3种:[1,2]、[1,3]、[2,3];排列却有6种:[1,2]、[2,1]、[1,3]、[3,1]、[2,3]、[3,2]。

很多想要枚举组合的初学者会误用全排列代码,结果就是输出炸裂、数量失控、内部还有大量顺序不同但集合相同的项。区分它们的关键在于递归参数:全排列每层都从0开始遍历所有未使用元素,因为任何数字都可以出现在任意位置;组合则通常从start开始遍历,要求后续元素下标只能递增,从而避免顺序重复。

如果你刷完全排列题,下一步练习组合总和、子集问题,就会发现它们的代码框架非常像全排列,只是“遍历起点”变了。这也是我反复强调全排列重要的原因:它是理解整个回溯家族的基础,而排列与组合的差异,就是从这个“起点参数”开始分叉的。

6. 扩展思考:从全排列出发还能解决什么

全排列不只是LeetCode里的一道题,它是一个延伸到很多领域的泛化武器。理解它的本质之后,同一套思路可以迁移到至少三类问题。

第一类是“所有可能顺序类”问题。比如字符串的全排列、数字数组的全排列、日程安排的排列、任务调度顺序枚举。只要结果空间是所有元素的一种线性排列,就可以用同一套回溯框架。多数情况下,只需要增加一些约束条件,比如相邻元素不能相同、奇数位必须为偶数等,这些都是在递归填位时增加一个判断而已。

第二类是“所有选择组合类”问题。组合总和、子集、电话号码字母组合、括号生成,本质都是在“选择”与“不选择”的二叉树上做递归。它们的模板和全排列高度一致,区别在于每次递归时start参数如何变化。如果你已经熟练掌握了全排列,再去写组合类题目会非常轻松,因为你的思维框架已经建立起来了。

第三类是“棋盘放置类”问题。N皇后、数独、解谜题,这些看起来和排列没什么关系的题目,底层其实也是在全排列的思想上叠加约束条件。N皇后可以看作在n×n棋盘上为每一行选一个列号,这个列号的排列必须满足不同行不在同一列、同一对角线。你甚至可以先把所有列号的排列枚举一遍,再筛选满足对角线条件的那些——暴力,但确实可行,而且在小规模场景下足够验证你的理解是否正确。

从工程应用来说,全排列的思想常用于穷举所有可能性来寻找最优方案,例如旅行商问题在小规模城市数下可以直接枚举所有路径,挑选总距离最短的。当然这只适合n很小的场景,n一大就必须用动态规划或启发式算法了。但理解暴力枚举的过程,是理解那些优化算法的基础。

我个人在实际操作中的建议是:学全排列的时候,一定不要只抄一遍代码就跑,而是拿着[1,2,3]或[1,2,3,4]这种小用例,把递归调用的全过程手动画一遍,感受一下每一个分支的进入和退出顺序。等你彻底看懂了那棵递归树,后面再学剪枝、学状态压缩、学记忆化搜索,都会顺畅得多。最后再分享一个小技巧:每次写完全排列代码,用n=1到n=5的数分别测试,检查输出数量是不是1、2、6、24、120。这个验证成本极低,却能在几秒钟内抓到绝大多数状态恢复和剪枝条件的低级错误。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询