全排列(Permutations)

全排列题的本质不是“生成所有顺序”,而是维护一个路径,再保证每个元素在当前路径里只出现一次。

题目定义

给定一个不含重复数字的数组 nums,返回其所有可能的全排列。

示例:
输入: nums = [1,2,3]
输出:
[
  [1,2,3],
  [1,3,2],
  [2,1,3],
  [2,3,1],
  [3,1,2],
  [3,2,1]
]

输入: nums = [0,1]
输出: [[0,1],[1,0]]

输入: nums = [1]
输出: [[1]]

核心思路

使用回溯算法穷举所有排列:

  • 选择:从剩余元素中选择一个
  • 探索:递归生成剩余元素的排列
  • 撤销:回溯,尝试其他选择
决策树示例 [1,2,3]:

                    []
         /          |          \
       [1]         [2]         [3]
      /  \        /  \        /  \
   [1,2][1,3]  [2,1][2,3]  [3,1][3,2]
     |    |      |    |      |    |
  [1,2,3][1,3,2][2,1,3][2,3,1][3,1,2][3,2,1]

复杂度分析

方法时间复杂度空间复杂度说明
回溯法O(n × n!)O(n)n!个排列,每个O(n)复制
迭代法O(n × n!)O(n × n!)需要存储所有中间结果
交换法回溯O(n × n!)O(n)原地交换生成排列

Go 代码

used 数组写法

package main
 
import "fmt"
 
func permute(nums []int) [][]int {
    result := [][]int{}
    visited := make([]bool, len(nums))
    path := []int{}
 
    var backtrack func()
    backtrack = func() {
        // 终止条件
        if len(path) == len(nums) {
            temp := make([]int, len(path))
            copy(temp, path)
            result = append(result, temp)
            return
        }
 
        // 遍历所有元素
        for i := 0; i < len(nums); i++ {
            if visited[i] {
                continue
            }
 
            // 做选择
            path = append(path, nums[i])
            visited[i] = true
 
            // 递归
            backtrack()
 
            // 回溯
            path = path[:len(path)-1]
            visited[i] = false
        }
    }
 
    backtrack()
    return result
}
 
// 方法2: 交换元素
func permuteSwap(nums []int) [][]int {
    result := [][]int{}
 
    var backtrack func(start int)
    backtrack = func(start int) {
        // 终止条件
        if start == len(nums) {
            temp := make([]int, len(nums))
            copy(temp, nums)
            result = append(result, temp)
            return
        }
 
        // 遍历从start开始的所有位置
        for i := start; i < len(nums); i++ {
            // 交换
            nums[start], nums[i] = nums[i], nums[start]
 
            // 递归
            backtrack(start + 1)
 
            // 回溯
            nums[start], nums[i] = nums[i], nums[start]
        }
    }
 
    backtrack(0)
    return result
}
 
func main() {
    nums := []int{1, 2, 3}
    result := permute(nums)
 
    fmt.Println("所有全排列:")
    for _, perm := range result {
        fmt.Println(perm)
    }
}

这里最关键的不变量是:

  • path 里存的是当前已经选过的数字。
  • visited[i] == true 表示 nums[i] 已经在当前路径里。
  • 当 len(path) == len(nums) 时,说明一条完整排列已经构造完成。

思路展开

回溯过程详解

nums = [1, 2, 3]

=== 第1层:选择第1个元素 ===
选择1: path=[1], choices=[2,3]
选择2: path=[2], choices=[1,3]
选择3: path=[3], choices=[1,2]

=== 第2层:选择第2个元素(以path=[1]为例)===
path=[1], 可选[2,3]
  选择2: path=[1,2], choices=[3]
  选择3: path=[1,3], choices=[2]

=== 第3层:选择第3个元素 ===
path=[1,2], 可选[3]
  选择3: path=[1,2,3], choices=[] ✓ 收集结果

path=[1,3], 可选[2]
  选择2: path=[1,3,2], choices=[] ✓ 收集结果

回溯到第1层,尝试选择2...

去重策略详解

有重复元素: [1, 1, 2]

排序后: [1, 1, 2]

树形结构:
                    []
         /          |          \
       [1]        [1]×         [2]
      /  \        /  \        /  \
   [1,2][1,1]  [1,2]×[1,1]×[2,1][2,1]×
     |    |      |    |      |    |
  [1,2,1][1,1,2] ...  ...  [2,1,1] ...

× 标记的是被剪枝的分支

去重条件:
if i > 0 and nums[i] == nums[i-1] and not visited[i-1]:
    continue

解释:
- i > 0: 不是第一个元素
- nums[i] == nums[i-1]: 当前元素与前一个相同
- not visited[i-1]: 前一个未被使用

→ 说明在同一层(树的横向)重复了,需要剪枝

两种常见写法对比

方法1: visited数组
优点: 清晰易懂,容易处理重复元素
缺点: 需要O(n)额外空间

方法2: 交换元素
优点: 原地操作,空间O(1)
缺点: 不易理解,处理重复元素复杂

经典题目

LeetCode 问题

  • 全排列 - LeetCode 46
  • 全排列 II - LeetCode 47(有重复元素)
  • 下一个排列 - LeetCode 31
  • 第k个排列 - LeetCode 60

扩展问题

  • 字符串的排列
  • 字典序的第k个排列

易错点

全排列的 bug 基本都出在“状态没有完全恢复”。

  • 收集答案时一定要拷贝 path。
  • visited[i] = true 之后,递归回来必须改回 false。
  • 有重复元素时要先排序,再做树层去重。
  • 交换法写起来更短,但更容易把交换还原漏掉。

优缺点

优点

  • ✅ 保证完整:生成所有排列
  • ✅ 易于理解:递归思路清晰
  • ✅ 可扩展:容易处理各种约束

缺点

  • ❌ 时间复杂度高:O(n × n!)
  • ❌ 空间消耗:递归栈深度O(n)
  • ❌ 大规模问题:n > 10时性能差

🎨 应用场景

💡 优化技巧

相关主题


返回:回溯算法 | 算法学习导航