1. 错位排列到底在解决什么问题
第一次接触错位排列,是在做一个抽奖系统的“防重复中奖”逻辑。当时的需求很简单:年会抽奖,已经中过奖的人不能再中,但每个人初始都有一次机会。我一开始想用简单的随机打乱,结果发现总有人连续中奖,概率明显不对。后来才意识到,这本质上是一个错位排列问题——每个元素都不能回到它原来的位置。
错位排列,英文叫derangement,是组合数学里一个非常经典的概念。用最直白的话说:有 n 个元素,每个元素都有一个“原本的位置”,现在要把它们重新排列,要求每个元素都不在自己原来的位置上。这样的排列有多少种?这个数量记作 D(n) 或者 !n。
举个最常见的例子:n 封信装进 n 个信封,要求每封信都不装进对应的信封,有多少种装法?再比如:n 个人交换礼物,要求每个人都不能拿到自己带来的礼物,有多少种交换方案?这些场景的核心都是错位排列。
它解决的问题很具体:在“完全禁止原位”的约束下,统计合法排列的总数。这个问题看起来简单,但直接枚举会爆炸,必须找到递推关系或者容斥公式。适合谁来学?我觉得三类人最需要:一是准备算法面试的,错位排列是容斥原理和递推思想的经典考题;二是做概率统计相关开发的,比如抽奖、匹配、洗牌算法;三是纯粹对组合数学感兴趣的,这个问题的推导过程非常漂亮。
我后面会从容斥原理和递推公式两条路分别推一遍,再把代码实现、常见坑、以及实际项目里的变体都讲清楚。你不需要有很强的数学背景,只要能看懂基本的排列组合符号,剩下的我尽量用生活化的例子说明白。
2. 两种核心推导思路:容斥与递推
2.1 用容斥原理一步步推出通项公式
容斥原理的核心思想是:先放开限制,再把违反限制的情况减掉,再加回多减的,如此反复。错位排列的容斥推导是教科书级别的案例,我把它拆成四步。
第一步,不考虑任何限制,n 个元素的全排列有 n! 种。
第二步,减去“至少有一个元素在原位”的情况。设 A_i 表示第 i 个元素在原位的排列集合。如果第 i 个元素固定不动,剩下 n-1 个元素随便排,有 (n-1)! 种。一共有 n 个这样的集合,所以减去 C(n,1) × (n-1)!。
第三步,加回“至少有两个元素同时在原位”的情况。因为第二步把这些情况减了两次。固定两个元素,剩下 n-2 个随便排,有 (n-2)! 种,共有 C(n,2) 个组合,所以加回 C(n,2) × (n-2)!。
第四步,依此类推,交替加减。最终公式是:
D(n) = n! × [1 - 1/1! + 1/2! - 1/3! + ... + (-1)^n / n!]
这个公式可以写成求和形式:
D(n) = n! × Σ(k=0 到 n) [(-1)^k / k!]
我实测下来,这个公式在 n 比较小的时候(比如 n ≤ 10)直接算完全没问题。但当 n 很大时,n! 会迅速溢出,即使你用 64 位整数也扛不住。所以工程上更常用的是递推公式,下面细说。
注意:容斥公式里的交替符号非常容易写错。我见过不少人在代码里写成全部相加,结果算出来的数字比 n! 还大,那就明显不对了。记住第一项是减号(k=1 时),第二项是加号。
2.2 递推公式的两种形式与推导逻辑
递推公式是我在实际编码里最常用的,因为它不需要处理阶乘溢出,而且可以边算边取模(做算法题时经常要求对 1e9+7 取模)。
第一种递推形式:D(n) = (n-1) × [D(n-1) + D(n-2)]
这个公式的推导很有意思。考虑第 n 个元素,它不能放在第 n 个位置,所以它有 (n-1) 个可选位置。假设它放到了第 k 个位置(k ≠ n)。这时候分两种情况:
- 情况一:第 k 个元素放到了第 n 个位置。那这两个元素互相交换了位置,剩下的 n-2 个元素构成一个独立的错位排列,有 D(n-2) 种。
- 情况二:第 k 个元素没有放到第 n 个位置。那我们可以把第 n 个位置“看成”是第 k 个元素原本的位置,这样就变成了 n-1 个元素的错位排列,有 D(n-1) 种。
所以对于每一个 k,都有 D(n-1) + D(n-2) 种,而 k 有 (n-1) 个选择,乘起来就是 D(n) = (n-1) × [D(n-1) + D(n-2)]。
第二种递推形式:D(n) = n × D(n-1) + (-1)^n
这个形式更简洁,但推导稍微绕一点。它可以从容斥公式变形得到,也可以用生成函数推。实际用的时候,第一种更直观,第二种在只需要单项计算时更快。
基础值:D(1) = 0,D(2) = 1。这两个必须记牢,否则递推会崩。D(1)=0 是因为一个元素不可能不在原位;D(2)=1 是因为两个元素只有一种错位方式,就是互换。
2.3 两种方法的对比与选型建议
| 对比维度 | 容斥公式 | 递推公式 |
|---|---|---|
| 时间复杂度 | O(n) 每次从头算 | O(n) 可打表 |
| 空间复杂度 | O(1) | O(n) 或 O(1) 滚动 |
| 大数溢出风险 | 高(涉及 n!) | 低(可边算边取模) |
| 代码实现难度 | 中等(符号易错) | 低 |
| 适合场景 | 数学推导、小 n 验证 | 工程实现、算法题 |
我的建议是:做算法题一律用递推,做数学证明用容斥。如果你要写一个工具函数反复调用,先把 D(1) 到 D(MAXN) 打表存起来,查询就是 O(1)。如果 MAXN 超过 20,记得用大整数或者取模。
3. 代码实现:从暴力到高效
3.1 暴力枚举验证小规模结果
在写高效算法之前,我习惯先用暴力法验证前几项,确保自己的递推没写错。暴力法就是生成全排列,然后检查每个元素是否都不在原位。
from itertools import permutations def derange_brute(n): count = 0 for perm in permutations(range(n)): if all(perm[i] != i for i in range(n)): count += 1 return count for n in range(1, 8): print(n, derange_brute(n))跑出来的结果是:0, 1, 2, 9, 44, 265, 1854。这串数字你要记住,后面调试的时候拿来对照非常方便。n=3 时是 2,n=4 时是 9,n=5 时是 44。我见过有人把 n=4 算成 8,那就是漏了一种情况。
提示:暴力法只适合 n ≤ 8,再大就跑不动了。n=10 的全排列是 362 万,还能忍;n=12 就是 4.79 亿,直接卡死。
3.2 递推打表的标准写法
这是我最推荐的工程写法,一次打表,多次查询:
def build_derangement_table(max_n, mod=None): D = [0] * (max_n + 1) if max_n >= 1: D[1] = 0 if max_n >= 2: D[2] = 1 for n in range(3, max_n + 1): val = (n - 1) * (D[n-1] + D[n-2]) if mod: val %= mod D[n] = val return D # 不取模,看真实值 table = build_derangement_table(10) print(table[1:11]) # 输出: [0, 1, 2, 9, 44, 265, 1854, 14833, 133496, 1334961]如果你只需要第 n 项,不需要整张表,可以用滚动变量把空间压到 O(1):
def derangement_n(n, mod=None): if n == 0: return 1 # 空集的错位排列定义为1 if n == 1: return 0 prev2, prev1 = 0, 1 # D(1), D(2) for i in range(3, n + 1): cur = (i - 1) * (prev1 + prev2) if mod: cur %= mod prev2, prev1 = prev1, cur return prev1这里有个细节:D(0) 定义为 1。虽然直觉上“0 个元素的错位排列”有点怪,但数学上为了递推自洽,约定 D(0)=1。你在写代码时如果遇到 n=0 的边界,直接返回 1 就行。
3.3 大数场景下的取模处理
算法题里经常要求结果对 1e9+7 取模。这时候递推公式的优势就体现出来了:每一步都是加法和乘法,可以随时取模,不会溢出。但容斥公式就不行,因为里面有除法(除以 k!),取模需要求逆元,麻烦得多。
MOD = 10**9 + 7 def derangement_mod(n): if n == 0: return 1 if n == 1: return 0 prev2, prev1 = 0, 1 for i in range(3, n + 1): cur = (i - 1) * (prev1 + prev2) % MOD prev2, prev1 = prev1, cur return prev1 print(derangement_mod(100000))这个写法在 n=100000 时瞬间出结果,完全不会溢出。我实测过 n=10^6 也就几十毫秒。
注意:取模的时候,(i-1) 本身可能很大,但 Python 会自动处理大整数,所以不用担心中间溢出。如果你用 C++ 或 Java,记得用 long long,并且先取模再乘,避免 (i-1) * (prev1 + prev2) 溢出。
4. 常见问题与排查技巧实录
4.1 递推基础值写错导致全盘崩溃
这是我最常踩的坑。D(1) 和 D(2) 一旦写错,后面所有项都是错的。我见过有人把 D(1) 写成 1,结果整个表偏移。记住:D(1)=0,D(2)=1。如果你不确定,用暴力法跑 n=1 到 5 对照一下。
还有一个隐蔽的坑:循环从 n=3 开始,但如果你把 D(0) 也初始化了,要确保 D(0)=1 而不是 0。有些模板代码里 D 数组默认全是 0,然后只设了 D[1] 和 D[2],结果 n=0 查询时返回 0,但正确答案应该是 1。
4.2 容斥公式符号错误的排查方法
容斥公式的符号是交替的,写代码时很容易漏掉 (-1)^k。一个简单的自检方法:算出来的 D(n) 必须小于 n!。如果你算出的值大于等于 n!,那一定是符号错了或者某项漏了。
另一个自检:D(n) 和 n! 的比值趋近于 1/e ≈ 0.3679。当 n 比较大时(比如 n ≥ 8),D(n)/n! 应该非常接近 0.3679。如果偏离很远,说明公式实现有问题。
| 常见错误 | 现象 | 修正方法 |
|---|---|---|
| D(1) 写成 1 | 所有项偏移 | 改为 0 |
| 容斥符号全加 | 结果 > n! | 改为交替加减 |
| 循环从 n=2 开始 | D(2) 被覆盖 | 从 n=3 开始 |
| 取模时先加后乘溢出 | 结果异常 | 先取模再乘 |
| 忘记 D(0)=1 | n=0 查询错误 | 显式初始化 |
4.3 实际项目中的变体与扩展
错位排列在实际项目里很少以“纯数学题”的形式出现,更多是变体。我遇到过的有:
变体一:部分错位。要求恰好有 k 个元素在原位,其余错位。这个用组合数乘一下就行:C(n,k) × D(n-k)。比如 n=5,恰好 2 个在原位,方案数就是 C(5,2) × D(3) = 10 × 2 = 20。
变体二:带权错位。每个元素不能去某些特定位置,但不一定是自己的原位。这就变成了二分图匹配计数问题,一般用状态压缩 DP 做,n 小的时候可行。
变体三:循环错位。要求每个元素不能在自己原位,且整体构成一个循环。这个数量是 (n-1)!,因为固定第一个元素的位置后,剩下的就是圆排列。
提示:如果你在面试里遇到“错位排列”的题,先问清楚是求数量还是求具体方案。求数量用递推,求方案用回溯或 DP。两者难度差很多。
5. 从数学到工程:错位排列的实际应用场景
5.1 抽奖与随机匹配中的防重复逻辑
回到我开头提到的抽奖系统。当时的需求是“已中奖者不再参与后续抽奖”,但更严格的要求是“每个人都不能抽到自己”。这其实就是错位排列的应用。
具体做法:把参与者编号 0 到 n-1,生成一个错位排列作为“抽奖映射”。第 i 个人抽到第 perm[i] 个人的奖品。因为 perm 是错位排列,所以每个人都不会抽到自己。生成一个随机错位排列的算法我用的是“拒绝采样”:先随机打乱,检查是否是错位排列,不是就重来。当 n 比较大时,命中概率约 1/e ≈ 36.8%,平均试 2.7 次就能成功,效率可以接受。
如果你要生成大量错位排列,拒绝采样就有点慢了。可以用“基于递推的构造法”:从 n 开始,每次决定第 n 个元素放到哪个位置,然后递归处理剩下的。这个构造法的时间复杂度是 O(n),而且保证一次成功。
5.2 算法面试中的高频考法与应对
错位排列在面试里通常不会直接问“什么是错位排列”,而是包装成场景题。我整理了几种常见问法:
- 问法一:n 个人交换礼物,每个人都不能拿到自己的,有多少种方案?——直接套 D(n)。
- 问法二:n 对夫妻跳舞,每对夫妻不能互为舞伴,有多少种配对?——这是错位排列的变体,答案是 D(n)。
- 问法三:一个数组,要求每个元素都不在原来的下标上,有多少种重排方式?——还是 D(n)。
- 问法四:求 D(n) 对 1e9+7 取模。——用递推,注意取模。
面试时如果你能主动说出“这是错位排列问题,可以用递推 D(n)=(n-1)(D(n-1)+D(n-2)) 求解”,基本就稳了。如果再能补一句“容斥公式是 n! × Σ(-1)^k/k!”,面试官会觉得你基础很扎实。
5.3 与其他排列问题的边界区分
错位排列容易和几个概念混淆,我列一下区别:
| 概念 | 核心约束 | 公式 |
|---|---|---|
| 全排列 | 无约束 | n! |
| 错位排列 | 每个元素不在原位 | D(n) |
| 圆排列 | 首尾相接,旋转视为相同 | (n-1)! |
| 部分错位 | 恰好 k 个在原位 | C(n,k) × D(n-k) |
| 禁位排列 | 每个元素有禁位集合 | 容斥或 DP |
禁位排列是错位排列的推广:错位排列是禁位排列的特例,每个元素的禁位集合就是它自己的原位。如果你能理解错位排列的容斥推导,禁位排列的容斥推导就是把“固定一个元素”换成“固定一个禁位”,思路完全一样。
6. 我踩过的坑与实操心得
6.1 递推打表的边界处理经验
打表的时候,数组大小一定要开到 max_n + 1,否则查询 D(max_n) 时会越界。我见过有人开 max_n 大小,然后循环到 max_n,结果最后一个元素写不进去。这个 bug 很隐蔽,因为 Python 会抛 IndexError,但 C++ 不会,它会默默写越界内存,导致后面数据莫名其妙出错。
另一个经验:如果你要多次查询不同的 n,打表是最优解。但如果你只查一次,滚动变量更省内存。我一般会写两个函数,一个 build_table 用于批量查询,一个 derangement_n 用于单次查询。
6.2 取模运算中的常见陷阱
取模的时候,减法要特别小心。虽然错位排列的递推公式里只有加法和乘法,但如果你用容斥公式,就会有减法。在取模意义下,a - b 要写成 (a - b + MOD) % MOD,否则可能出现负数。
还有一个坑:如果你用第二种递推形式 D(n) = n × D(n-1) + (-1)^n,取模时 (-1)^n 要处理成 MOD-1 或 1。我一般直接判断奇偶:n 为奇数时加 MOD-1,偶数时加 1。
提示:做算法题时,如果题目没说要取模,但 n 很大,你也要主动取模,否则结果会溢出。很多在线判题系统对溢出是直接判错的。
6.3 性能优化的几个实用技巧
如果你需要计算 D(n) 对多个不同的模数取模,打表就不太方便了,因为表是针对特定模数的。这时候可以用滚动变量每次重算,时间复杂度 O(n),对于 n ≤ 10^6 完全够用。
如果 n 特别大(比如 10^9),那就不能用 O(n) 递推了,需要用矩阵快速幂。错位排列的递推可以写成矩阵形式:
[D(n), D(n-1)]^T = [[n-1, n-1], [1, 0]] × [D(n-1), D(n-2)]^T
但注意矩阵里的 n-1 是变化的,所以不能直接用固定矩阵快速幂。实际上错位排列没有简单的固定矩阵快速幂形式,因为系数随 n 变化。所以 n 特别大时,一般还是用容斥公式配合快速阶乘算法,但那个实现复杂度很高,实际项目中很少遇到。
我个人的经验是:n ≤ 10^6 用递推,n > 10^6 考虑近似值 D(n) ≈ n!/e。当 n 很大时,D(n)/n! 趋近于 1/e,如果你只需要比值,直接用 1/e 就行,误差小于 1/(n+1)!。
6.4 一个容易被忽略的细节:D(0) 的定义
最后说一个很多人忽略的点:D(0) = 1。这个定义在数学上是合理的,因为“0 个元素的排列”只有一种(空排列),而空排列满足“每个元素都不在原位”的条件(因为没有元素违反)。在代码里,如果你不处理 n=0,遇到边界查询就会出错。
我在一个项目里就因为这个 bug 排查了半天:用户输入 0 时,系统返回 0,但业务逻辑期望返回 1,导致后续计算全部偏移。后来我在函数入口加了if n == 0: return 1才解决。这个坑不常遇到,但遇到一次就够你记一辈子。
错位排列这个主题,从数学推导到代码实现,再到实际应用,每一层都有值得深挖的细节。我上面写的这些,基本都是我在实际项目和面试准备中积累下来的经验,希望能帮你少走一些弯路。如果你在实现过程中遇到其他奇怪的问题,欢迎一起交流。