全排列问题深度解析:用 Python 玩转 DFS 回溯与迭代
全排列问题深度解析:用 Python 玩转 DFS 回溯与迭代
全排列是算法领域中 “组合搜索” 类问题的经典代表,其核心是找到一个集合中所有元素的不同排列方式(如集合[1,2,3]
的全排列包含[1,2,3]
、[1,3,2]
等 6 种情况)。无论是面试中的算法题,还是实际开发中的 “排列组合生成” 场景(如密码枚举、测试用例生成),全排列都有着广泛应用。本文将以 Python 为工具,从基础到进阶,带你吃透全排列问题的解法。
一、全排列问题的定义与核心难点
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 = [1,1,2]
-
输出:
[[1,1,2],[1,2,1],[2,1,1]]
(需去重)
2. 核心难点
-
不重复选择:每个元素在单个排列中只能使用一次(需标记已选元素);
-
不重复排列:若数组含重复元素,需避免生成相同的排列(需去重逻辑);
-
穷举所有可能:需遍历所有元素的组合方式,确保无遗漏。
二、基础解法:DFS 回溯(无重复元素)
全排列的本质是 “逐步选择元素,直到选完所有元素”,这一过程天然契合 DFS“深度优先、回溯探索” 的逻辑 —— 先选第一个元素,再从剩余元素中选第二个,以此类推,直到选完所有元素(得到一个排列),再回溯到上一步换另一个元素继续探索。
1. 解题思路
-
选择与标记:用
path
存储当前正在构建的排列,用visited
数组标记元素是否已被选中(True
表示已选,False
表示未选); -
终止条件:当
path
的长度等于nums
的长度时,说明已选完所有元素,将path
加入结果集; -
回溯探索:遍历
nums
中所有未被选中的元素,将其加入path
并标记为已选,递归探索下一个元素;递归结束后,撤销选择(从path
中移除该元素,标记为未选),继续遍历下一个元素。
2. Python 代码实现
def permute(nums):"""# 终止条件:当前路径长度等于数组长度,说明已生成一个全排列if len(path) == n:result.append(path.copy()) # 注意:需拷贝path,避免后续修改影响结果return# 遍历所有元素,选择未被选中的元素加入路径for i in range(n):if not visited[i]: # 只处理未被选中的元素# 1. 做出选择:将元素加入路径,标记为已选path.append(nums[i])visited[i] = True# 2. 递归探索下一个元素dfs(path)# 3. 撤销选择(回溯):从路径中移除元素,标记为未选path.pop()visited[i] = False# 从空路径开始探索dfs([])return result
# 测试示例1
nums1 = [1,2,3]
print("无重复元素全排列:", permute(nums1))
# 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
3. 代码解析
-
visited
数组:避免同一排列中重复选择同一个元素(如[1,1,2]
这种无效排列); -
path.copy()
:由于列表是引用类型,直接append(path)
会导致后续修改path
时,结果集中的元素也被修改,因此需拷贝当前路径; -
回溯步骤:
path.pop()
和visited[i] = False
是核心,确保递归返回后,能回到上一步的状态,继续探索其他可能的选择。
三、进阶解法:DFS 回溯(含重复元素,需去重)
当数组含重复元素时(如[1,1,2]
),直接使用上述方法会生成重复排列(如[1,1,2]
会出现多次)。此时需在回溯过程中加入 “去重逻辑”,核心思路是:排序后,跳过与前一个元素相同且前一个元素未被选中的情况。
1. 去重原理
-
先排序:将
nums
排序,使相同元素相邻(如[1,1,2]
排序后仍为[1,1,2]
); -
跳过重复:遍历元素时,若当前元素与前一个元素相同,且前一个元素未被选中(说明前一个元素已作为相同选项被探索过),则跳过当前元素,避免重复排列。
2. Python 代码实现
def permute_unique(nums):"""DFS回溯实现含重复元素的全排列(去重)nums: 可能含重复元素的整数数组返回:所有不重复的全排列(list of list)"""n = len(nums)result = []visited = [False] * n# 关键步骤1:先排序,使相同元素相邻nums.sort()def dfs(path):if len(path) == n:result.append(path.copy())returnfor i in range(n):if visited[i]:continue # 跳过已选中的元素# 关键步骤2:去重逻辑# 若当前元素与前一个元素相同,且前一个元素未被选中,说明已探索过该情况,跳过if i > 0 and nums[i] == nums[i-1] and not visited[i-1]:continue# 做出选择path.append(nums[i])visited[i] = True# 递归探索dfs(path)# 撤销选择(回溯)
print("含重复元素全排列(去重):", permute_unique(nums2))
# 输出:[[1,1,2],[1,2,1],[2,1,1]]
3. 去重逻辑解析
-
i > 0 and nums[i] == nums[i-1]
:确保当前元素与前一个元素相同; -
not visited[i-1]
:前一个元素未被选中,说明前一个元素已作为 “相同选项” 被探索过(例如,第一个1
未被选中时,第二个1
的选择会和第一个1
的选择重复),因此跳过当前元素。
四、补充解法:迭代法(非递归,模拟 DFS)
除了递归回溯,还可以用迭代法实现全排列,核心思路是用栈模拟 DFS 的递归过程,栈中存储 “当前路径” 和 “已选元素标记”,逐步构建所有排列。
1. 解题思路
-
初始化栈:栈中每个元素是
(当前路径, 已选元素标记)
,初始时将(空路径, 全False标记)
入栈; -
迭代处理:弹出栈顶元素,若当前路径长度等于数组长度,加入结果集;否则,遍历所有未被选中的元素,将新路径和新标记入栈;
-
生成排列:重复步骤 2,直到栈为空,此时已生成所有排列。
2. Python 代码实现
def permute_iterative(nums):"""迭代法实现无重复元素的全排列(模拟DFS)nums: 不含重复元素的整数数组返回:所有可能的全排列(list of list)"""n = len(nums)result = []# 初始化栈:每个元素是(当前路径, 已选元素标记),初始为(空路径, 全False标记)stack = [([], [False] * n)]while stack:path, visited = stack.pop() # 弹出栈顶元素(模拟DFS的“深度优先”)# 终止条件:路径长度等于数组长度,加入结果集if len(path) == n:result.append(path)continue
nums3 = [1,2]
print("迭代法全排列:", permute_iterative(nums3)) # 输出:[[2,1],[1,2]]
3. 迭代法特点
-
无递归栈溢出风险:递归法在数组长度较大时(如
n>1000
)会因递归深度过大导致栈溢出,迭代法可避免此问题; -
逻辑直观:直接模拟 DFS 的探索过程,每个栈元素对应一个 “探索状态”,适合理解回溯的本质;
-
顺序差异:由于栈是 “先进后出”,迭代法生成的排列顺序与递归法可能不同(如
[1,2]
的迭代结果是[[2,1],[1,2]]
,递归结果是[[1,2],[2,1]]
),但均为有效排列。
五、算法复杂度分析
无论是递归回溯还是迭代法,全排列问题的复杂度主要由 “排列总数” 决定:
算法 | 时间复杂度 | 空间复杂度 | 说明 |
---|---|---|---|
DFS 回溯(无重复) | O(n×n!) | O(n) | 时间:生成 n! 个排列,每个排列需 O (n) 时间拷贝;空间:递归栈深度 O (n) + visited 数组 O (n) |
DFS 回溯(含重复) | O(n×n!) | O(n) | 时间上限仍为 O (n×n!)(去重仅减少实际操作次数,不改变上限);空间与无重复情况一致 |
迭代法 | O(n×n!) | O(n×n!) | 时间与回溯法一致;空间:栈中最多存储 n! 个状态,每个状态需 O (n) 空间(路径 + 标记) |
六、总结与实际应用建议
- 方法选择:
-
若数组无重复元素,优先用递归 DFS(代码简洁,易于理解);
-
若数组含重复元素,需在递归 DFS 中加入排序 + 去重逻辑;
-
若数组长度较大(如
n>20
),建议用迭代法(避免递归栈溢出)。
-
核心思想提炼:
全排列的本质是 “穷举所有选择组合”,DFS 回溯是解决这类问题的通用思路 —— 通过 “选择 - 探索 - 撤销选择” 的循环,遍历所有可能的状态,而 “标记已选元素” 和 “去重” 是确保结果正确性的关键。
-
扩展应用:
全排列的解题思路可迁移到其他组合搜索问题,如 “子集生成”“组合总和”“字母大小写全排列” 等,只需根据问题需求调整 “终止条件” 和 “选择逻辑” 即可。
建议大家动手修改代码中的nums
数组(如[1,2,3,4]
、[2,2,3,3]
),观察不同场景下的运行结果,进一步加深对全排列逻辑的理解。