CSP-S 提高级课程中心 / 数学进阶 / 第48讲
CSP-S 提高级 · 数学进阶 · s6-07

组合计数:多重集排列、错排与圆排列

元素可以重复、位置围成一圈、每个元素都不许回到原位 —— 三种"不规矩"的排列方式,各自对应一个漂亮公式。而把它们统一起来的语言,叫"等价关系与等价类"。

6 大模块 O(n) 阶乘预处理 3 个案例 隔板法与错排递推 12 道洛谷原题
本讲 · 第 48/52 讲组合计数:多重集排列与错排圆排列
模块 01

情景导航

一个联欢会策划的下午,引出本讲的三个主角:多重集排列、错排、圆排列。

故事联欢会上的三张节目单

小 Q 被推举为班级联欢会的策划。任务单一摊开,他发现里面藏着三个"看着简单、一算就懵"的问题。

任务一 · 节目编号。节目单要用 5 个字母的编号,规定只能出现 2 个 A 和 3 个 B(写成 AABBB 这种形式)。小 Q 想:"一共 5 个位置,每个位置 2 种选择,那不是 2⁵ = 32 种吗?" 老师摇头:"不行,A 必须恰好 2 个,B 必须恰好 3 个。而且 AABBB 和 AABBB 当然是同一种,重复的字母互相交换位置不产生新编号。"

不同的编号只有 6 种:AABBB、ABABB、ABBAB、ABBBA、BAABB、BABAB …

任务二 · 互换礼物。5 位同学各带一份礼物,把自己的名字写在卡片上放进箱子,然后每人抽一张卡片,抽到谁的名字就拿谁的礼物。主持人提了个要求:谁都不许抽到自己的卡片。小 Q 又懵了:"5! = 120 种抽法,那要排除掉多少种?"

任务三 · 合影站位。8 位同学要在圆形的舞台中央合影,围成一圈。小 Q 说:"8! = 40320 种。" 摄影师笑了:"围成一圈的话,整体转个角度还是同一张照片啊。"

三个问题,三种"不规矩":元素可以重复、元素不许归位、位置首尾相接。它们分别对应本讲的三个公式 ——

多重集排列:n! / (a₁! a₂! ⋯ aₖ!)
错排:Dₙ = (n−1)(Dₙ₋₁ + Dₙ₋₂)
圆排列:(n−1)!

💡 为什么这三个公式长这样

三个公式看起来各不相同,但背后的思想是统一的:先用"全不同"的模型数一遍,再除以"重复计数"的倍数。

① 多重集排列:AABBB 里的两个 A 互换、三个 B 互换都不产生新编号,所以每个真正的编号被数了 2! × 3! 次,答案就是 5! / (2!·3!)。

② 圆排列:8 个人围成一圈时,整体旋转 8 次对应同一张照片,所以每个真正的坐法被数了 8 次,答案就是 8! / 8 = 7!。

③ 错排则不同 —— 它不能用"除法"解决,因为"某人抽到自己的卡片"这些事件互相重叠,需要用容斥原理或递推来处理。错排的容斥公式 Dₙ = n!·Σ(−1)ⁱ/i! 里那些正负交替的项,正是容斥的痕迹。

而在数学上,把"重复计数"这件事说清楚的语言就是等价关系与等价类:把互相等价的排列归为一类,类的个数就是要求的方案数。n! ÷ (等价类的大小) 这个思路,贯穿了本讲的前半部分。

①生活类比
🧺

果篮 = 多重集

篮子里 3 个苹果 2 个梨,"苹果苹果梨"和"苹果梨苹果"是同一篮水果 —— 顺序无关,只看数量。

→ 多重集 {3·苹果, 2·梨}
🔤

拼词 = 多重集排列

MISSISSIPPI 里字母有重复,交换两个相同的 S 不产生新单词,所以要除掉 4!。

→ n! / ∏aᵢ!
🍢

竹签分果 = 隔板法

把 n 个相同的果子分给 k 个人,只要在队列里插入 k − 1 根竹签,插法数就是方案数。

→ C(n+k−1, k−1)
🎠

旋转木马 = 圆排列

围成一个圈时,整体转动不产生新排列,所以要除以圈长 n。

→ n!/n = (n−1)!
🎩

错拿帽子 = 错排

舞会散场时每个人拿一顶帽子,谁也不拿自己那顶,问有多少种拿法。

→ Dₙ = 错排数
📮

装错信封 = 错排

n 封信装进 n 个写好地址的信封,全部装错的方案数就是 Dₙ。

→ 组合数学经典模型
②核心定义

多重集(multiset):允许元素重复出现的集合。记作 {a₁·x₁, a₂·x₂, …, aₖ·xₖ},表示元素 xᵢ 出现了 aᵢ 次,元素总数为 n = a₁ + a₂ + ⋯ + aₖ。例如 {2·A, 3·B} 表示 2 个 A 和 3 个 B。

等价关系(equivalence relation):集合 S 上的二元关系 ~ 若同时满足 ① 自反性:a ~ a;② 对称性:a ~ b ⟹ b ~ a;③ 传递性:a ~ b 且 b ~ c ⟹ a ~ c,则称为等价关系。

等价类与划分:对 S 中的元素 a,所有与 a 等价的元素构成 等价类 [a]。等价关系把 S 切成若干个互不相交的等价类,其并集正是 S —— 这叫 S 的一个划分。反过来,任意一个划分也唯一确定一个等价关系("同属一块")。

多重集的排列(可重排列):把 n 个元素(其中有重复)排成一列,不同的排列数为 n! / (a₁!·a₂!·⋯·aₖ!)。

多重集的组合(可重组合):从 k 类元素中一共取出 n 个(每类可重复取任意多个),方案数为 C(n + k − 1, k − 1) = C(n + k − 1, n)。

圆排列:n 个不同元素围成一个圆(只考虑相对顺序,旋转视为同一种),方案数为 (n − 1)!。项链排列(还允许翻转)方案数为 (n − 1)! / 2(n ≥ 3)。

错排列(derangement / 更列):n 个元素的全排列中,满足"每个元素都不在自己原位置"的排列数,记作 Dₙ。前几项为 D₁ = 0,D₂ = 1,D₃ = 2,D₄ = 9,D₅ = 44,D₆ = 265,D₇ = 1854。

两组核心公式:

Dₙ = (n − 1)(Dₙ₋₁ + Dₙ₋₂)    Dₙ = n! · Σ_{i=0}^{n} (−1)ⁱ / i!

📌 本讲核心知识地图

板块核心内容对应大纲
多重集合允许重复的集合,记 {aᵢ·xᵢ}【6】多重集合
等价关系自反 + 对称 + 传递;等价类;划分【6】等价关系与等价类
多重集排列n! / (a₁!a₂!⋯aₖ!)【6】多重集上的排列
多重集组合C(n+k−1, k−1),隔板法【6】多重集上的组合
不相邻组合C(n−k+1, k)【6】多重集上的组合
圆排列(n−1)!;项链再除以 2【6】错排列、圆排列
错排递推Dₙ = (n−1)(Dₙ₋₁ + Dₙ₋₂)【6】错排列、圆排列
典型应用字母重排、分配问题、围圈、装错信封【6】多重集与错排圆排列
③核心口诀
🔑 多重集排列

先当全不同数一遍,再除以重复的阶乘;
n! 除以 a₁!a₂!⋯aₖ!,重复几次除几次。

🔑 多重集组合

n 个相同物分 k 类,插 k − 1 根隔板;
总数 n 加板数,方案数 C(n+k−1, k−1)。

🔑 圆排列

排成一圈要除圈长,(n−1)! 记心上;
若能翻转(项链、手链),答案再除以 2。

🔑 错排列

谁都不在原位,递推 Dₙ = (n−1)(Dₙ₋₁ + Dₙ₋₂);
容斥展开 n!·Σ(−1)ⁱ/i!,恰好 k 个归位用 C(n,k)·Dₙ₋ₖ。

模块 02

知识探究

先用"等价类"说清"重复计数"的本质,再依次拿下多重集排列、多重集组合、圆排列与错排。

1多重集与等价类:把"重复"说清楚 基础

"两个 A 交换一下不还是同一种编号吗?"这句话听上去理所当然,但要在数学上严谨,需要引入等价关系这个概念。

等价关系的三条性质(初赛常考,务必背熟):

性质含义反例(不满足的情形)
自反性a ~ a关系"小于"不是自反的(a < a 为假)
对称性a ~ b ⟹ b ~ a"整除"不是对称的(2 | 6 但 6 ∤ 2)
传递性a ~ b 且 b ~ c ⟹ a ~ c"朋友关系"不传递(朋友的朋友不一定是朋友)

等价类的两大性质:① 任意两个等价类要么完全相同,要么完全不相交;② 所有等价类的并集恰好是整个集合 S。这就是"划分"的含义。

用等价关系解释"多重集排列":设 S 是 n 个元素(含重复)的全部 n! 个排列(把重复元素暂时看成"贴了标签"的不同个体)。

定义关系:两个排列 p ~ q ⟺ 把 p 中同一类元素的若干个个体的位置互换(标签重排)能得到 q。

容易验证这是一个等价关系(自反、对称、传递都成立)。而每个等价类的大小恰好是 a₁! · a₂! ⋯ aₖ! —— 因为第 i 类元素的 aᵢ 个个体可以在这 aᵢ 个位置上任意互换,有 aᵢ! 种方式。

由于等价类把 S 划分成若干块,且每块大小相同,所以 等价类的个数 = n! / (a₁!a₂!⋯aₖ!)。这正是多重集排列公式的来源。

再看两个等价关系的例子,帮助建立直觉:

1
🕐
模 n 同余

在整数集上定义 "a ~ b ⟺ a ≡ b (mod n)"。等价类就是 n 个余数类 {0}, {1}, …, {n−1} —— 这正好是"同余类"这个名字的来历。

2
🎠
旋转等价

在圆排列上定义 "两个排列等价 ⟺ 一个可以由另一个整体旋转得到"。每个等价类有 n 个元素,所以圆排列数 = n! / n = (n−1)!。

3
🪞
翻转等价

项链排列还允许整体翻转(镜像)。除 n 个旋转外还多一组镜像,等价类大小变成 2n,所以方案数 = n! / (2n)(n ≥ 3 时旋转与镜像不重合)。

关键洞察:所有"除以某个数"的计数公式,本质上都是"总数 ÷ 每个等价类的大小"。当每个等价类大小相同时(这叫"群作用"),除法一句话就能搞定;而当等价类大小不同时(比如错排里的各种情况),就必须用容斥或递推 —— 这就是本讲的第二条主线。

2多重集的排列:n! / (a₁!a₂!⋯aₖ!) 核心

定理:设多重集含 k 类元素,第 i 类有 aᵢ 个,总数 n = Σaᵢ。则把它排成一列的不同排列数为

n! / (a₁! · a₂! · ⋯ · aₖ!)

推导(两种常见思路,都要会):

思路一:除法(等价类)

把重复元素当成"贴了标签的不同个体",一共有 n! 个排列。每个真正的排列在等价类里被数了 a₁!a₂!⋯aₖ! 次(同类元素互换不产生新排列)。所以答案 = n! / (a₁!a₂!⋯aₖ!)。

思路二:乘法(逐步选位)

先从 n 个位置里选 a₁ 个放第 1 类元素,有 C(n, a₁) 种;再从剩下的位置选 a₂ 个放第 2 类,有 C(n − a₁, a₂) 种;……连乘得 C(n,a₁)·C(n−a₁,a₂)⋯,化简后恰好等于 n!/(a₁!⋯aₖ!)。

🎬 交互演示:一步步数出 aabb 的 6 种排列
1贴标签。把两个 a 看成 a₁、a₂,两个 b 看成 b₁、b₂。现在 4 个"不同的"元素,排列数是 4! = 24。
2列出 24 个排列并按"去掉标签"分组。比如 a₁b₁a₂b₂、a₂b₁a₁b₂、a₁b₂a₂b₁、a₂b₂a₁b₁ 去掉标签后全是 abab。这 4 个排列归为一个等价类。
3为什么每类恰有 4 个?因为两个 a 互换有 2! = 2 种,两个 b 互换有 2! = 2 种,组合起来 2! × 2! = 4 种。每一种都产生同一个"无标签排列"。
4数出等价类个数。24 ÷ 4 = 6。所以 aabb 的不同排列有 6 种。
5逐一验证这 6 种。它们分别是 aabb、abab、abba、baab、baba、bbaa。仔细检查:确实只有这 6 个,不多不少。
6换个角度验证。C(4,2) = 6:先从 4 个位置里选 2 个放 a(剩下自动放 b),恰好 6 种。两个思路结果一致 ✓
💡 结论可以推广:公式的分母是"每个重复组内阶乘的乘积",不是"重复总次数的阶乘"。例如 aabbcc 的排列数是 6!/(2!2!2!) = 90,而不是 6!/(2!+2!+2!) 或 6!/((2+2+2)!)。

这 6 个排列可以完整列出来,方便核对:

第 1 个:aabb —— 两个 a 在最前
第 2 个:abab —— a、b 交替
第 3 个:abba —— a 在第 1、4 位
第 4 个:baab —— a 在第 2、3 位
第 5 个:baba —— b、a 交替
第 6 个:bbaa —— 两个 b 在最前
合计:4! / (2! × 2!) = 24 / 4 = 6,与逐一列举完全一致 ✓

经典例子 MISSISSIPPI:这个单词里有 M × 1、I × 4、S × 4、P × 2,共 11 个字母。不同排列数 =

11! / (1! · 4! · 4! · 2!) = 39916800 / (1 × 24 × 24 × 2) = 39916800 / 1152 = 34650

所以 MISSISSIPPI 可以排出 34650 个不同的排列(含它自己)。

多项式系数:正因为这个公式形如多项式的展开系数,它也常被记作 n! / (a₁!⋯aₖ!) = C(n; a₁, a₂, …, aₖ),称为多项式系数。当 k = 2 时,它就退化成了二项式系数 C(n, a₁) —— 这一点在下一讲会再次用到。

解题步骤(代码实现):因为结果往往要对质数取模,所以要用"阶乘 + 逆元":

C++多重集排列 · 阶乘 + 费马逆元求法
const int MAXN = 1000010;
const ll MOD = 1000000007;
ll fac[MAXN], ifac[MAXN];

ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}
void init() {
    fac[0] = 1;
    for (int i = 1; i < MAXN; i++) fac[i] = fac[i - 1] * i % MOD;
    ifac[MAXN - 1] = qpow(fac[MAXN - 1], MOD - 2);
    for (int i = MAXN - 2; i >= 0; i--) ifac[i] = ifac[i + 1] * (i + 1) % MOD;
}
// 求 n! / (a[0]! * a[1]! * ... ) mod MOD
ll multisetPerm(ll n, const vector<ll> &a) {
    ll res = fac[n];
    for (ll ai : a) res = res * ifac[ai] % MOD;
    return res;
}
3多重集的组合:隔板法 高频

问题:从 k 类元素中一共取出 n 个(每类可以取任意多个,允许取 0 个),有多少种不同的取法?"不同"指的是各类取出的数量组合不同,与顺序无关。

举个具体的例子:把 10 个相同的苹果分给 3 个人,每人可以一个都不分到,有多少种分法?

隔板法的想法:把问题画成"10 个苹果排成一行,用 2 根竹签把它们分成 3 段"。第一段给第 1 个人,第二段给第 2 个人,第三段给第 3 个人。于是:

隔板法:10 个苹果 + 2 根竹签 = 12 个位置里选 2 个放竹签 示例:3 个苹果 | 4 个苹果 | 3 个苹果,对应第 1、2、3 个人分别得 3、4、3 个 第1个人:3 个 第2个人:4 个 第3个人:3 个 另一种分法:竹签插在开头和结尾 → 0 个 | 10 个 | 0 个 方案总数 = C(12, 2) = 66

转化过程:原来有 10 个苹果,现在加入 2 个"竹签位",一共 12 个位置。这 12 个位置里任选 2 个放竹签(其余 10 个位置放苹果),每一种选法恰好对应一种分法。所以方案数是 C(12, 2) = 66。

多重集组合公式

从 k 类元素中取 n 个(每类可重复、可取 0 个)的方案数 = C(n + k − 1, k − 1) = C(n + k − 1, n)

记忆方式:n 个"球" + (k − 1) 根"隔板",共 n + k − 1 个位置,选 k − 1 个放隔板

三个常见变形(考试里经常出现):

限制条件公式思路
每类可取 0 个(原式)C(n + k − 1, k − 1)标准隔板法
每类至少取 1 个C(n − 1, k − 1)先给每类 1 个,剩 n − k 个再分给 k 类(可取 0)
每类至少取 mᵢ 个C(n − Σmᵢ + k − 1, k − 1)先各给 mᵢ 个,剩余部分用标准隔板法
每类至多取 r 个容斥:Σ(−1)ʲ·C(k,j)·C(n − j(r+1) + k − 1, k − 1)用容斥排除"某一类超过 r 个"的情形

例:把 10 个相同苹果分给 3 人,每人至少 1 个。先各给 1 个,剩 7 个用隔板法:C(7 + 3 − 1, 3 − 1) = C(9, 2) = 36。也可以直接用"至少取 1 个"的公式 C(n − 1, k − 1) = C(9, 2) = 36 ✓

另一类重要问题:不相邻的组合。从 1, 2, 3, …, n 中选出 k 个数,要求任意两个都不相邻,方案数是 C(n − k + 1, k)。

推导思路:设选出的数是 x₁ < x₂ < ⋯ < xₖ。由于两两不相邻,有 xᵢ ≥ x_{i−1} + 2。做变换 yᵢ = xᵢ − (i − 1),则 y 严格递增(yᵢ ≥ y_{i−1} + 1),且 1 ≤ y₁ < y₂ < ⋯ < yₖ ≤ n − k + 1。所以方案数是"从 n − k + 1 个数里选 k 个"——即 C(n − k + 1, k)。

例:从 1 到 10 中选 3 个两两不相邻的数,方案数 = C(10 − 3 + 1, 3) = C(8, 3) = 56。验证思路:把 1..10 划分成"选中位置的左邻空位",可以枚举验证确实等于 56。

4圆排列与项链排列 核心

圆排列:n 个不同的元素围成一个圆圈,只考虑"相对顺序",整体旋转视为同一种排列,方案数为 n! / n = (n − 1)!。

为什么除以 n:把圆剪开成一条线,有 n 个可能的"剪口"(从每个人前面剪开),得到的 n 个线性排列恰好都是同一个圆排列的不同展开。所以 n 个线性排列对应 1 个圆排列,圆排列数 = n! / n。

圆排列:8 个人围成一圈的 5040 种站法 8! / 8 = 7! = 5040。整体旋转不产生新照片 1 2 3 4 5 6 7 8 旋转 ↻ 不变 核心思想:先当线性排列数 n!,再除以旋转产生的重复 ① 8 个人线性排列:8! = 40320 种 ② 每个人前面剪一刀,得到 8 个"不同的"线性排列 ③ 这 8 个线性排列对应同一个圆排列(等价类大小 = 8) ④ 圆排列数 = 40320 ÷ 8 = 5040 = 7! 固定一个人的技巧 既然整体旋转等价,干脆让编号 1 的人坐在"正上方"不动, 剩下 7 个人随便排 → 直接得 7! = 5040,一步到位。 ↑ "固定一个元素破掉旋转对称",这是圆排列题的通法

项链排列:如果不仅允许旋转,还允许整体翻转(比如项链翻个面还是同一条项链,手链、钥匙链同理),那么等价类还要再合并一倍。当 n ≥ 3 时,翻转不会与任何旋转重合,所以等价类大小变成 2n,方案数为:

n! / (2n) = (n − 1)! / 2

例:6 颗不同颜色的珠子串成一条项链,方案数 = 5! / 2 = 120 / 2 = 60。

常见变体:

情境公式说明
n 人围圆桌(只考虑相对顺序)(n − 1)!旋转等价
n 颗不同珠子串项链(可翻转)(n − 1)! / 2旋转 + 翻转等价,n ≥ 3
圆桌有 n 个固定编号的座位,坐 k 个人C(n, k) · (k − 1)!先选 k 个座位,再对 k 个人做圆排列
n 人围圆桌,但不区分方向(如合照时可以左右互换)(n − 1)! / 2与项链同理

一个容易混的点:圆桌题里如果座位是固定编号的(比如"1 号位到 8 号位"),那么坐法就是普通的 8!,不需要除 8 —— 因为坐在 1 号位和坐在 2 号位是不同的坐法。只有"座位没有编号、只有相对顺序"时才是圆排列。

5错排列:谁都不在原位 高频考点

定义:n 个元素的全排列中,如果存在一个排列使得每个元素都不在自己的原位置,就称它是这 n 个元素的一个错排列。错排列的个数记作 Dₙ(也叫"更列数"或 derangement)。

约定 D₀ = 1(空排列唯一)、D₁ = 0(1 个元素只能放回原位)。前几项为:

n12345678910
Dₙ0129442651854148331334961334961

以 n = 3 为例验证:全部 6 个排列中,123 有三个不动点、132 有一个(1)、213 有一个(3)、231 有零个、312 有零个、321 有一个(2)。所以 D₃ = 2 ✓

递推式与推导:考虑元素 1 最终去了位置 k(k ≠ 1,共 n − 1 种选择)。接着分两类:

情形 A(元素 k 恰好去了位置 1):1 和 k 互相交换了位置,剩下的 n − 2 个元素必须全部错排,方案数 Dₙ₋₂。

情形 B(元素 k 没去位置 1):此时把"位置 1"想象成"位置 k 的新名字"。那么剩下的 n − 1 个元素各自都不能回到自己的位置(元素 k 不能去位置 k,也就是不能去它"新的自己"),恰好是 n − 1 个元素的错排问题,方案数 Dₙ₋₁。

两类相加再乘上"位置 k 有 n − 1 种选法",得到递推式:

Dₙ = (n − 1)·(Dₙ₋₁ + Dₙ₋₂)

🎬 交互演示:用递推式算 D₅ = 44
1已知起点。边界条件 D₁ = 0(1 个元素无处可去)、D₂ = 1(只有"互换"一种)。
2算 D₃。D₃ = (3−1)(D₂ + D₁) = 2 × (1 + 0) = 2。验证:231、312 两个,✓
3算 D₄。D₄ = 3 × (D₃ + D₂) = 3 × (2 + 1) = 9。验证:4 个元素的全排列共 24 个,去掉至少有一个不动点的 15 个,剩 9 个,✓
4算 D₅。D₅ = 4 × (D₄ + D₃) = 4 × (9 + 2) = 4 × 11 = 44。
5继续往后。D₆ = 5 × (44 + 9) = 265;D₇ = 6 × (265 + 44) = 1854;D₈ = 7 × (1854 + 265) = 14833。增长极快,说明"全部错开"的方案非常多。
6有趣的极限。Dₙ / n! 随着 n 增大迅速趋近于 1/e ≈ 0.3679。也就是说,随机洗牌后"一个都不在原位"的概率约为 36.79%。
💡 递推式只依赖前两项,所以打表到 n = 10⁶ 只需要 O(n) 时间、O(1) 空间(只需保存两个变量)。这是错排题最常用的实现方式。

容斥公式(通项):换一个角度,用容斥原理也能直接算出 Dₙ。

设 Aᵢ 是"第 i 个元素在自己的位置上"的排列集合。要求的是"一个 Aᵢ 都不属于"的排列数:

Dₙ = n! − C(n,1)(n−1)! + C(n,2)(n−2)! − C(n,3)(n−3)! + ⋯ + (−1)ⁿC(n,n)·0!

把 C(n,i)·(n−i)! = n! / i! 代入,得到漂亮的形式:

Dₙ = n! · Σ_{i=0}^{n} (−1)ⁱ / i!

验证 n = 4:24 × (1/1 − 1/1 + 1/2 − 1/6 + 1/24) = 24 × (12/24) = 9 ✓

推广:恰好 k 个元素在原位的排列数——先选出哪 k 个元素不动(C(n, k) 种),剩下的 n − k 个元素必须全部错排(Dₙ₋ₖ 种):

方案数 = C(n, k) · Dₙ₋ₖ

例:n = 4 的排列中恰好有 1 个元素在原位的方案数 = C(4,1) × D₃ = 4 × 2 = 8。暴力枚举验证:24 个排列里,恰好 0 个原位的有 9 个、恰好 1 个的有 8 个、恰好 2 个的有 6 个、恰好 4 个的有 1 个,合计 9 + 8 + 6 + 0 + 1 = 24 ✓(恰好 3 个不可能:3 个不动就意味着第 4 个也必然不动)。

案例案例1:多重集排列的通用实现 模板

题意:给定元素总数 n 与 k 类元素各有多少个(a₁, a₂, …, aₖ,满足 Σaᵢ = n),求这些元素的不同排列数,结果对质数 10⁹+7 取模。

分析:直接套公式 n! / (a₁!a₂!⋯aₖ!)。由于要取模,除法要换成乘逆元,所以需要预处理阶乘与阶乘逆元。

C++多重集排列 · 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 1000010;
const ll MOD = 1000000007;
ll fac[MAXN], ifac[MAXN];

ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}

void init() {
    fac[0] = 1;
    for (int i = 1; i < MAXN; i++) fac[i] = fac[i - 1] * i % MOD;
    ifac[MAXN - 1] = qpow(fac[MAXN - 1], MOD - 2);      // 费马小定理求一次逆元
    for (int i = MAXN - 2; i >= 0; i--) ifac[i] = ifac[i + 1] * (i + 1) % MOD;
}

int main() {
    init();
    int n, k; cin >> n >> k;
    vector<ll> a(k);
    for (int i = 0; i < k; i++) cin >> a[i];

    ll ans = fac[n];
    for (int i = 0; i < k; i++) ans = ans * ifac[a[i]] % MOD;
    cout << ans << endl;
    return 0;
}
▶ 运行结果
输入: 4 2
      2 2          (2 个 a、2 个 b)
输出: 6
验算: 4! / (2! * 2!) = 24 / 4 = 6  ✓
      6 个排列:aabb、abab、abba、baab、baba、bbaa

输入: 11 4
      1 4 4 2      (M × 1、I × 4、S × 4、P × 2)
输出: 34650
验算: 11! / (1! * 4! * 4! * 2!) = 39916800 / 1152 = 34650  ✓

输入: 6 3
      2 2 2        (aabbcc)
输出: 90
验算: 6! / (2! * 2! * 2!) = 720 / 8 = 90  ✓

三个易错点:① 分母写成"所有重复次数的乘积的阶乘"(比如 6!/(2+2+2)!),正确写法是"每个重复次数各自取阶乘再相乘";② 忘记预处理逆元,直接用除法(取模运算里不能直接除);③ 阶乘表开到 MAXN 但 n 可能超过,导致越界。

时间复杂度:预处理 O(MAXN),每次查询 O(k)。因为 ifac 只求了一次逆元再用前缀积递推,所以常数很小 —— 这是所有"组合数取模"题目的标准写法。

案例案例2:P4071 [SDOI2016] 排列计数(错排 + 组合数) 经典模型

题意:求 1 到 n 的排列中,恰好有 m 个位置 i 满足 pᵢ = i 的排列个数,答案对 10⁹+7 取模。多组询问。

分析:这是"恰好 k 个不动点"模型的标准应用。分两步:

① 选出哪 m 个元素在原位:从 n 个位置中选 m 个,方案数 C(n, m)。

② 让剩下的 n − m 个元素全部错排:方案数 Dₙ₋ₘ。

两步相乘即为答案:C(n, m) · D_{n−m}。注意特判 m > n 时答案为 0,n − m = 1 时 D₁ = 0。

C++P4071 排列计数 · 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 1000010;
const ll MOD = 1000000007;
ll fac[MAXN], ifac[MAXN], D[MAXN];

ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}

ll C(ll n, ll m) {
    if (m < 0 || m > n) return 0;
    return fac[n] * ifac[m] % MOD * ifac[n - m] % MOD;
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    // 预处理阶乘与逆元
    fac[0] = 1;
    for (int i = 1; i < MAXN; i++) fac[i] = fac[i - 1] * i % MOD;
    ifac[MAXN - 1] = qpow(fac[MAXN - 1], MOD - 2);
    for (int i = MAXN - 2; i >= 0; i--) ifac[i] = ifac[i + 1] * (i + 1) % MOD;

    // 预处理错排表
    D[0] = 1; D[1] = 0;
    for (int i = 2; i < MAXN; i++)
        D[i] = (i - 1) * ((D[i - 1] + D[i - 2]) % MOD) % MOD;

    int T; cin >> T;
    while (T--) {
        ll n, m; cin >> n >> m;
        if (m > n) { cout << 0 << endl; continue; }
        cout << C(n, m) * D[n - m] % MOD << endl;
    }
    return 0;
}
▶ 运行结果(数值均可用暴力枚举验证)
输入:
4
4 1
4 0
5 0
6 2

输出:
8
9
44
135

逐项验算:
n=4, m=1: C(4,1) * D3 = 4 * 2   = 8     ✓(暴力枚举 24 个排列,恰好 1 个不动点的有 8 个)
n=4, m=0: C(4,0) * D4 = 1 * 9   = 9     ✓(D4 = 9)
n=5, m=0: C(5,0) * D5 = 1 * 44  = 44    ✓(D5 = 44)
n=6, m=2: C(6,2) * D4 = 15 * 9  = 135   ✓(C(6,2) = 6!/(2!*4!) = 15)

组合数一定要算准:上面最后一组里 C(6,2) = 15(不是 5),这个 3 倍的差别直接决定答案对错。常见组合数速记:C(5,2)=10,C(6,2)=15,C(6,3)=20,C(7,2)=21,C(7,3)=35,C(8,3)=56,C(8,4)=70,C(10,2)=45,C(10,3)=120。

三个易错点:① C(n, m) 的下标写错(比如写成 C(m, n));② 错排表的下标写成 D[m](应该是 D[n−m]);③ 忘记特判 m > n,导致下标越界。

复杂度:预处理 O(MAXN)(同时打阶乘表与错排表),每组询问 O(1)。这是多组询问类计数题的标准做法。

案例案例3:圆排列与项链排列 经典模型

题意:给定 n(n ≥ 3)与类型 t。t = 1 表示"围成一圈"(旋转等价),输出 (n−1)!;t = 2 表示"串成项链"(旋转 + 翻转等价),输出 (n−1)! / 2。结果对质数 10⁹+7 取模。

分析:圆排列直接算 (n−1)!;项链再乘一个 inv(2) = (p+1)/2(因为 2 在模奇素数下可逆)。

C++圆排列 / 项链排列 · 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1000000007;

ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}

int main() {
    ll n; int t; cin >> n >> t;

    // 先算 (n-1)!  mod MOD
    ll ans = 1 % MOD;
    for (ll i = 1; i <= n - 1; i++) ans = ans * (i % MOD) % MOD;

    if (t == 2) ans = ans * qpow(2, MOD - 2) % MOD;   // 项链:再除以 2
    cout << ans << endl;
    return 0;
}
▶ 运行结果
输入: 8 1     (8 人围成一圈)
输出: 5040
验算: (8-1)! = 7! = 5040  ✓

输入: 6 2     (6 颗不同珠子串成项链,可翻转)
输出: 60
验算: (6-1)! / 2 = 120 / 2 = 60  ✓

输入: 5 1     (5 人围圆桌)
输出: 24
验算: 4! = 24  ✓

输入: 4 2     (4 颗珠子串成手链)
输出: 3
验算: (4-1)! / 2 = 6 / 2 = 3  ✓
(4 颗珠子 a,b,c,d 的手链:abcd、abdc、acbd 三种,其余都是这三种的旋转或翻转)

"固定一个人"的等价写法:圆排列题更常见的做法是先让 1 号固定在最上方,剩下 n − 1 个人线性排列,直接得 (n−1)!。这样写代码更直观,也避免了"除以 n"时对取模的担心(虽然除以 n 也可以用逆元处理)。

四个易错点:

① 混淆"圆桌有没有编号":如果座位是固定编号的("1 号位到 8 号位"),答案就是 8!,不需要除以 8;

② 项链忘记除以 2,或者反过来把"圆桌"也除以 2(如果题目说"合照时左右可以互换",那才需要);

③ 除以 2 时不能用整数除法(在取模的意义下要先转成乘逆元);

④ n = 2 的项链边界:2 颗珠子时 (2−1)!/2 = 1/2 不是整数,公式失效(此时旋转与翻转重合)。题目一般会给 n ≥ 3 的限制,如果没有,需要特判。

模块 03

实践应用

三道递进题:先练隔板法与它的变形,再练错排的组合应用,最后练圆排列与"翻转多解"的综合处理。

题1综合编程 · 隔板法

输入 n 与 k,回答三类问题:① 把 n 个相同的球放进 k 个不同的盒子(盒可空);② 每个盒子至少 1 个;③ 每个盒子至多 r 个。分别输出方案数对 10⁹+7 取模。

数据范围:1 ≤ n, k ≤ 10⁶,1 ≤ r ≤ 10⁶。

查看参考答案

思路:核心是"把"每类至少 mᵢ 个"的限制先用'预支'的方式消掉,剩下的用标准隔板法;"至多"的限制则用容斥排除违反的情形。

① 盒可空:直接 C(n + k − 1, k − 1)。

② 至少 1 个:先各给 1 个,剩 n − k 个用 ①的方法:C(n − 1, k − 1)(若 n < k 则为 0)。

③ 每个至多 r 个:总数 n 个球分 k 盒且每盒 ≤ r。设 Aᵢ 为"第 i 个盒子 ≥ r + 1 个"的情形,用容斥:

Σ_{j=0}^{⌊n/(r+1)⌋} (−1)ʲ · C(k, j) · C(n − j(r+1) + k − 1, k − 1)

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 4000010;               // n+k 可能到 2*10^6,留足余量
const ll MOD = 1000000007;
ll fac[MAXN], ifac[MAXN];

ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}
ll C(ll n, ll m) {
    if (m < 0 || m > n || n < 0) return 0;
    return fac[n] * ifac[m] % MOD * ifac[n - m] % MOD;
}
int main() {
    fac[0] = 1;
    for (int i = 1; i < MAXN; i++) fac[i] = fac[i - 1] * i % MOD;
    ifac[MAXN - 1] = qpow(fac[MAXN - 1], MOD - 2);
    for (int i = MAXN - 2; i >= 0; i--) ifac[i] = ifac[i + 1] * (i + 1) % MOD;

    ll n, k, r; cin >> n >> k >> r;

    // ① 盒可空
    ll a1 = C(n + k - 1, k - 1);
    // ② 每盒至少 1 个
    ll a2 = (n >= k) ? C(n - 1, k - 1) : 0;
    // ③ 每盒至多 r 个(容斥)
    ll a3 = 0;
    for (ll j = 0; j * (r + 1) <= n; j++) {
        ll term = C(k, j) * C(n - j * (r + 1) + k - 1, k - 1) % MOD;
        if (j & 1) a3 = (a3 - term + MOD) % MOD;
        else a3 = (a3 + term) % MOD;
    }
    cout << a1 << " " << a2 << " " << a3 << endl;
    return 0;
}

样例:输入 10 3 4 → 输出 66 36 6。验证:

① C(10+3−1, 2) = C(12,2) = 66 ✓

② C(10−1, 2) = C(9,2) = 36 ✓

③ 容斥:C(3,0)·C(12,2) − C(3,1)·C(12−5,2) + C(3,2)·C(12−10,2) − 0 = 66 − 3×21 + 3×1 = 66 − 63 + 3 = 6 ✓(手工验证:3 个盒子各 ≤ 4 且总数为 10,只有 (4,3,3)、(3,4,3)、(3,3,4)、(4,4,2)、(4,2,4)、(2,4,4) 共 6 种)

易错:隔板法公式里上下标颠倒(写成 C(n+k−1, k));"至少 1 个"忘记先判 n < k;容斥的符号交替写错;阶乘表开得太小导致越界。

题2综合编程 · 错排

输入 T 组 (n, m),每组输出 1..n 的排列中"至少有 m 个元素在原位"的排列个数,对 10⁹+7 取模。

数据范围:T ≤ 10⁵,1 ≤ n ≤ 10⁶。

查看参考答案

思路:"至少 m 个"= 恰好 m 个 + 恰好 m+1 个 + … + 恰好 n 个。而"恰好 k 个"= C(n,k)·D_{n−k}。所以:

答案 = Σ_{k=m}^{n} C(n, k) · D_{n−k}

关键观察:直接形式共有 n − m + 1 项。而利用恒等式

Σ_{k=0}^{n} C(n, k) · D_{n−k} = n!

(因为每个排列按"不动点个数"恰好被分到唯一一个 k 类里,全部加起来就是排列总数),同一个答案还能写成"取补集"的形式:

答案 = n! − Σ_{k=0}^{m−1} C(n, k) · D_{n−k}

于是有两条路:完整求和 Σ_{k=m}^{n} 要循环 n − m + 1 次;取补集只要循环 m 次。两种写法的结果是同一个数,谁的项数少就用谁——单次询问复杂度为 O(min(m, n − m + 1))。这一步不是"优化技巧",而是恒等变形,结果严格相等。

复杂度与数据范围:预处理阶乘、阶乘逆元、错排表共 O(N)(N = max n);每组询问 O(min(m, n − m + 1))。因此把范围补上 Σ min(mᵢ, nᵢ − mᵢ + 1) ≤ 10⁷("至少 m 个 / 至多 m 个"类问题的常见约定),程序就严格可过。下面按"取项数更少者"实现:

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1000010;
const ll MOD = 1000000007;
ll fac[MAXN], ifac[MAXN], D[MAXN];

ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}
ll C(ll n, ll m) {
    if (m < 0 || m > n) return 0;
    return fac[n] * ifac[m] % MOD * ifac[n - m] % MOD;
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    fac[0] = 1;
    for (int i = 1; i < MAXN; i++) fac[i] = fac[i - 1] * i % MOD;
    ifac[MAXN - 1] = qpow(fac[MAXN - 1], MOD - 2);
    for (int i = MAXN - 2; i >= 0; i--) ifac[i] = ifac[i + 1] * (i + 1) % MOD;
    D[0] = 1; D[1] = 0;
    for (int i = 2; i < MAXN; i++) D[i] = (i - 1) * ((D[i - 1] + D[i - 2]) % MOD) % MOD;

    int T; cin >> T;
    while (T--) {
        ll n, m; cin >> n >> m;
        if (m > n) { cout << 0 << endl; continue; }
        ll ans;
        if (m <= n - m + 1) {
            // 补集形式:n! − Σ_{k=0}^{m−1} C(n,k)·D_{n−k},共 m 项
            ans = fac[n];
            for (ll k = 0; k < m; k++) ans = (ans - C(n, k) * D[n - k] % MOD + MOD) % MOD;
        } else {
            // 直接形式:Σ_{k=m}^{n} C(n,k)·D_{n−k},共 n−m+1 项
            ans = 0;
            for (ll k = m; k <= n; k++) ans = (ans + C(n, k) * D[n - k]) % MOD;
        }
        cout << ans << endl;
    }
    return 0;
}

样例(T = 3):输入 3 / 4 1 / 4 0 / 5 3 → 输出 15、24、11。

验证 4 1(直接形式 4 项 vs 补集 1 项,取补集):4! − C(4,0)·D₄ = 24 − 9 = 15;展开核对:恰好 1 个 = 8、恰好 2 个 = 6、恰好 3 个 = 0、恰好 4 个 = 1,8 + 6 + 0 + 1 = 15 ✓

验证 4 0:补集求和为 0 项,直接得 4! = 24 ✓("至少 0 个"就是全部排列)

验证 5 3(两种写法各 3 项,结果必须一致):直接 C(5,3)D₂ + C(5,4)D₁ + C(5,5)D₀ = 10 + 0 + 1 = 11;补集 5! − [D₅ + 5·D₄ + 10·D₃] = 120 − [44 + 45 + 20] = 120 − 109 = 11 ✓

易错:把"至少"当成"恰好";求和上界写成 m 而不是 n;补集形式里把 n! 写成了 (n−m)!;漏掉 m > n 的边界;错排表下标写成 D[k](应为 D[n−k])。

题3综合编程 · 圆排列与等价类

输入 n 个人与若干"等价声明"(每次声明两个人必须相邻),按"圆桌就座、旋转视为同一种"计数。若声明互相冲突则输出 −1。请设计算法并说明如何处理"相邻限制 + 循环等价"这两件事。

数据范围:2 ≤ n ≤ 10⁵,声明数 ≤ n。

查看参考答案

核心思路:把"必须相邻"的人先捆成一"块"。这是处理"相邻限制"的通用技巧 —— 与其在排列里追踪相邻关系,不如先把必须相邻的人合并成一个整体。

第一步(并查集合并)。把每条"i 与 j 必须相邻"的声明看成一条边,用并查集维护连通块。每个连通块内部必须"串联成一条链"才能满足所有相邻声明。

第二步(判断可行性)。一个连通块能排成一条链的充要条件是:块内的边数 = 点数 − 1 且没有度数 ≥ 3 的点,且没有环。用并查集建图时:如果加边时两端已经在同一连通块里 → 出现环;如果某个点的度数 > 2 → 分叉。任一情况出现即输出 −1。

第三步(计数)。设捆好后共有 m 个块,第 i 个块的大小为 sᵢ:

① 块内排列:每个块是一条链,链可以左右翻转,所以有 2 种内部排法(当 sᵢ ≥ 2;sᵢ = 1 时只有 1 种)。

② 块间形成圆排列:m 个块围成一圈,方案数 (m − 1)!(m = 1 时特判为 1)。

③ 总方案数 = (m − 1)! × ∏ (sᵢ ≥ 2 ? 2 : 1)。

特判 m = 1 的情形:如果所有人都被捆成一个块(即所有 n 人首尾相接成一条链),那"围成一圈"只有 1 种本质不同的坐法(链首尾相接),答案就是 1(而不是 0! = 1 再乘 2 —— 因为此时链的两端也相邻,翻转得到的圆排列是同一个)。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1000000007;
const int MAXN = 100010;

int fa[MAXN], sz[MAXN], deg[MAXN];
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }

ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}

int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    int n, q; cin >> n >> q;
    for (int i = 1; i <= n; i++) fa[i] = i, sz[i] = 1;

    for (int t = 0; t < q; t++) {
        int u, v; cin >> u >> v;
        deg[u]++; deg[v]++;
        if (deg[u] > 2 || deg[v] > 2) { cout << "-1" << endl; return 0; }  // 出现分叉
        int ru = find(u), rv = find(v);
        if (ru == rv) { cout << "-1" << endl; return 0; }        // 出现环
        fa[ru] = rv; sz[rv] += sz[ru];
    }

    // 统计块数,并累乘每块的翻转系数
    ll m = 0, coef = 1;
    for (int i = 1; i <= n; i++) {
        if (find(i) != i) continue;
        m++;
        if (sz[i] >= 2) coef = coef * 2 % MOD;
    }

    if (m == 1) { cout << 1 << endl; return 0; }   // 全体成一条链,围成圈只有 1 种

    // (m-1)! 部分
    ll ans = coef;
    for (ll i = 1; i <= m - 1; i++) ans = ans * (i % MOD) % MOD;
    cout << ans << endl;
    return 0;
}

样例一:输入 8 3 / 1 2 / 3 4 / 5 6 → 输出 192。分析:块为 {1,2}、{3,4}、{5,6}、{7}、{8},共 m = 5 个块,其中 3 个块大小为 2。答案 = (5−1)! × 2³ = 24 × 8 = 192 ✓

样例二(无声明):输入 8 0 → 输出 5040。此时 m = 8、每个块大小 1、coef = 1,答案 = 7! = 5040,与普通圆排列一致 ✓

样例三(冲突):输入 4 3 / 1 2 / 2 3 / 3 1 → 输出 −1。三个人两两都要相邻,无法排成一条链(成环了)。

样例四(分叉):输入 4 3 / 1 2 / 1 3 / 1 4 → 输出 −1。1 号要求同时与 2、3、4 相邻,度数 3,不可能 ✓

易错:① 只在"加边时两端已同块"判环,忘记检查度数(分叉)也会失败;② 忘记"块可以左右翻转"要乘 2;③ m = 1 时忘记特判(此时 (1−1)! = 1 再乘 2 会得到 2,正确答案是 1);④ 用普通整数除法处理取模下的阶乘。

模块 04

总结提升

把本讲六组公式收成一张表,再用三个拓展案例把它推到更远处。

📌核心要点一览
知识点公式关键说明
多重集{a₁·x₁, a₂·x₂, …, aₖ·xₖ}元素可重复,只看各类数量
等价关系自反 + 对称 + 传递三条缺一不可
等价类[a] = {b : b ~ a}互不相交,并集为全集(划分)
多重集排列n! / (a₁!·a₂!·⋯·aₖ!)= 多项式系数 C(n; a₁, …, aₖ)
多重集组合C(n + k − 1, k − 1)隔板法,盒可空
至少取 1 个C(n − 1, k − 1)先各给一个
至多取 r 个容斥 Σ(−1)ʲC(k,j)C(n−j(r+1)+k−1, k−1)排除"某一类超限"
不相邻组合C(n − k + 1, k)从 1..n 中取 k 个两两不相邻
圆排列(n − 1)!旋转等价
项链排列(n − 1)! / 2旋转 + 翻转等价,n ≥ 3
有编号座位的圆桌C(n, k) · (k − 1)!先选座再圆排
错排递推Dₙ = (n−1)(Dₙ₋₁ + Dₙ₋₂)D₀=1,D₁=0,D₂=1
错排通项Dₙ = n! · Σ_{i=0}^{n} (−1)ⁱ/i!容斥得到;Dₙ/n! → 1/e
恰好 k 个原位C(n, k) · Dₙ₋ₖ先定不动点,再错排其余
🧭解题决策流程:拿到一道计数题先问自己四句话
STEP 1明确"什么算同一种"
STEP 2元素重复吗?
STEP 3位置首尾相接吗?
STEP 4有额外限制吗?
STEP 5有"不许在原位"吗?
STEP 6核对取模与复杂度
四句话定位公式,六步走完写出代码 —— 计数题不再是"碰运气"
⌨️本讲必背模板(一份可直接抄的代码)
C++组合计数工具箱:阶乘逆元 + 多重集排列 + 错排表
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int MAXN = 1000010;
const ll MOD = 1000000007;
ll fac[MAXN], ifac[MAXN], D[MAXN];

ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}

void init() {
    // 阶乘与阶乘逆元
    fac[0] = 1;
    for (int i = 1; i < MAXN; i++) fac[i] = fac[i - 1] * i % MOD;
    ifac[MAXN - 1] = qpow(fac[MAXN - 1], MOD - 2);
    for (int i = MAXN - 2; i >= 0; i--) ifac[i] = ifac[i + 1] * (i + 1) % MOD;
    // 错排表
    D[0] = 1; D[1] = 0;
    for (int i = 2; i < MAXN; i++) D[i] = (i - 1) * ((D[i - 1] + D[i - 2]) % MOD) % MOD;
}

// 组合数
ll C(ll n, ll m) {
    if (m < 0 || m > n || n < 0) return 0;
    return fac[n] * ifac[m] % MOD * ifac[n - m] % MOD;
}

// ① 多重集排列:n! / (a1! a2! ... ak!)
ll multisetPerm(ll n, const vector<ll> &a) {
    ll res = fac[n];
    for (ll ai : a) res = res * ifac[ai] % MOD;
    return res;
}

// ② 多重集组合(n 个球放 k 个可空的盒子)
ll multisetComb(ll n, ll k) { return C(n + k - 1, k - 1); }

// ③ 圆排列 / 项链
ll circle(ll n) { return fac[n - 1]; }                       // (n-1)!
ll necklace(ll n) { return fac[n - 1] * qpow(2, MOD - 2) % MOD; } // (n-1)!/2

// ④ 恰好 k 个不动点
ll fixedPoints(ll n, ll k) { return C(n, k) * D[n - k] % MOD; }

int main() {
    init();
    cout << multisetPerm(11, {1, 4, 4, 2}) << endl;   // 34650  MISSISSIPPI
    cout << multisetComb(10, 3) << endl;                 // 66     10 个苹果分 3 人
    cout << circle(8) << endl;                           // 5040   8 人围圆桌
    cout << necklace(6) << endl;                         // 60     6 珠项链
    cout << D[5] << endl;                                // 44     错排 D5
    cout << fixedPoints(4, 1) << endl;                   // 8      恰 1 个原位
    return 0;
}
▶ 运行结果
34650
66
5040
60
44
8

全部数值均已在前文逐一验证 ✓
🚀拓展案例
拓展 1:多重集排列加上"不相邻"限制 —— 插空法

公式 n!/(a₁!⋯aₖ!) 只管"有多少个不同的排列",但如果加上"某一类元素不能相邻"的限制,就要换招。

例:用 3 个 A 和 3 个 B 组成串,要求没有两个 A 相邻,有多少种?

插空法(先排其他,再插入空隙):先把 3 个 B 排好,有 3!/3! = 1 种(B 彼此相同)。3 个 B 之间有 4 个空隙(含两端):

_ B _ B _ B _

把 3 个 A 放进这 4 个空隙中,每个空隙最多放 1 个(否则 A 相邻),方案数 = C(4, 3) = 4。

验证:4 个串分别是 ABABAB、ABABBA、ABBABA、BABABA —— 恰好 4 个,且每个里都没有相邻的 A ✓

插空法的通用形式:"m 个 X 不相邻地插入 n 个 Y 中"的方案数是 C(n + 1, m)(n 个 Y 之间及两端共 n + 1 个空隙)。

这与本讲"不相邻组合 C(n − k + 1, k)"是同一个思想的两个变体:一个是在空隙里放元素,一个是在位置上选元素。

如果 X 也彼此可分辨(不是相同的 X),那么再乘上 X 的排列数:C(n + 1, m) · m!。

对比容斥法:同一个问题也可以用容斥做 —— 总数 C(6,3) = 20,减去"至少有一对 A 相邻"的情形。但容斥要处理"A 三连"这种复杂情形,计算量明显更大。优先选插空法。

拓展 2:项链计数的一般框架 —— 从"除以 2n"到 Burnside 引理

我们把 n 颗不同珠子的项链数写成 n!/(2n),本质上是"总数 ÷ 每个等价类的大小",而这个除法的合法性依赖于"每个等价类大小相同"。

这个条件什么时候会失效?当珠子可以着色、颜色数量有限时,"每个等价类大小相同"就不一定成立了 —— 有些着色方案具有旋转或翻转对称性。

例:2 颗珠子各涂红或蓝,能做出多少种不同的手链?用 n!/(2n) 会得到 2²/4 = 1,显然错了(至少能做出"同色"和"异色"两种)。问题出在"双红"和"双蓝"这两种着色方案在旋转下不变,它们的等价类大小只有 1 而不是 4。

Burnside 引理给出了正确的做法:

轨道数 = (1/|G|) · Σ_{g ∈ G} |Fix(g)|

其中 G 是"允许的对称操作"构成的群(项链对应二面体群 Dn,共 2n 个元素),Fix(g) 是在操作 g 下保持不变的所有方案集合。

用 2 颗珠子验证:G 有 4 个元素(不动、旋转 180°、两种翻转),4 种着色方案中:不动固定 4 种,旋转 180° 固定 2 种(双红、双蓝),两种翻转也各固定 2 种。所以轨道数 = (4 + 2 + 2 + 2)/4 = 10/4 = 2.5。这里出现了 2.5 —— 因为 n = 2 时二面体群只有 2 个元素(旋转 180° 与翻转重合),修正后为 (4 + 2)/2 = 3,即"双红、双蓝、一红一蓝"三种 ✓

与我们的公式的关系:当所有珠子颜色各不相同(或者说珠子本身可分辨)时,没有非平凡的对称操作能固定任何方案,于是 Burnside 引理退化成 n!/(2n) —— 这就是本讲的项链公式。Burnside 是它的"完全体",属于 NOI 及以上的内容,这里先建立直观印象即可。

拓展 3:从错排到一般的"禁位排列"

错排要求"每个元素都不在自己的位置上"。如果禁区不是整齐的"对角线",而是一个任意的"禁位表",该怎么算?

问题:n 个元素排到 n 个位置上,某些"元素—位置"组合被禁止(称为禁位),求合法排列数。

解法(容斥 + 棋盘多项式):设 rᵢ 表示"从禁位表中选出 i 个互不同行也不同列的格子"的方案数(称为禁位棋盘的 i 阶棋盘多项式系数)。则:

合法排列数 = Σ_{i=0}^{n} (−1)ⁱ · rᵢ · (n − i)!

错排是它的特例:错排的禁位表是主对角线上的 n 个格子。从中选 i 个互不同行同列的格子,就是从 n 行中任选 i 行(列自动对应),所以 rᵢ = C(n, i)。代入得:

Dₙ = Σ_{i=0}^{n} (−1)ⁱ C(n,i) (n−i)! = n! Σ_{i=0}^{n} (−1)ⁱ / i!

这正是本讲的错排通项公式 —— 它不过是"禁位排列"的一个特例。

经典应用:夫妻问题(ménage problem)——n 对夫妻围圆桌就座,要求夫妻不相邻。先把女士们排成圆(2·(n−1)! 种,因为可以左右两种方向),再把男士插入空隙并避开"不能与妻子相邻"的禁位。这时的禁位棋盘是阶梯形的,rᵢ 需要用专门的递推计算 —— 这是错排问题最著名的推广之一。

🤔思考与启发

思考1:为什么多重集排列可以"直接除以某个数",而错排不能也用类似的方法(比如"除以某个数")解决?两者的根本差别在哪里?

查看参考答案

根本差别在于"等价类的大小是否一致"。

多重集排列:我们定义的关系是"同类元素的个体互换",这是对排列本身做置换。每一类元素有固定的数量 aᵢ,所以每个等价类的大小都恰好是 a₁!a₂!⋯aₖ! —— 完全一致。因此总数 ÷ 类大小就是类个数。

错排:我们要求的是"满足条件 P(每个元素都不在原位)的排列个数",这不是一个等价关系!"p 满足 P 且 q 满足 P"并不能推出"p 和 q 有关系"。所以根本没有"等价类"可以数。

换个角度说:错排里"有多少个不动点"这个指标把排列分成了 n + 1 组,但组的大小差别极大(n = 4 时分别是 9、8、6、0、1)。因为每组大小不同,所以不能简单地"除以某个数"。

为什么会这样?多重集排列的等价关系来自一个群作用(对称群 Sa₁ × ⋯ × Saₖ 作用在位置集合上),群作用的所有轨道大小必然相同(由拉格朗日定理保证);而"错排条件"没有对应的群结构。

所以:能用除法解决的问题都是"群作用 + 轨道计数"(这是 Burnside 引理的领域);不能用除法的问题必须用容斥或递推。

易错:把"除以 2"这样的技巧乱用到不满足"类大小一致"的问题上,得出荒谬的答案。

思考2:公式 n! / (a₁!a₂!⋯aₖ!) 的结果一定是整数吗?请给出两种不同的证明。

查看参考答案

一定是整数。两种证明:

证明一(组合意义 / 逐步选位):这个数等于"从 n 个位置中依次给各类元素选位置"的方案数,可以写成连乘:

C(n, a₁) · C(n−a₁, a₂) · C(n−a₁−a₂, a₃) ⋯ C(aₖ, aₖ)

每个组合数都是整数,整数之积仍是整数。展开验证:C(n,a₁)·C(n−a₁,a₂) = n!/(a₁!(n−a₁)!) · (n−a₁)!/(a₂!(n−a₁−a₂)!) = n!/(a₁!a₂!(n−a₁−a₂)!),连乘下去分母恰好变成 a₁!a₂!⋯aₖ!,分子变成 n!。所以这个数就是一个计数结果,必然是整数。∎

证明二(群作用的轨道计数):让群 G = Sa₁ × Sa₂ × ⋯ × Saₖ(各分量的置换群直积)作用在"n 个带标签元素的全排列集合"上(共有 n! 个元素)。

由群作用的基本定理,每个轨道的大小等于 |G| / |稳定子|,而 |G| = a₁!a₂!⋯aₖ!。轨道大小整除 |G|,所以 n! 能被 |G| 整除 —— 更准确地说,因为稳定子就是平凡群(同类元素互换后每个排列都不变),所以每个轨道大小恰好是 |G|,轨道数 = n!/|G|,是整数。∎

数论视角的补充:这就是"多项式系数是整数"的一个例子。也可以用勒让德公式(n! 中素因子 p 的指数 = Σ⌊n/pⁱ⌋)逐素数验证分母的可除性,但组合证明更直观。

易错:以为"分子分母都是阶乘,所以不一定是整数";或者用"具体数值代入验证"代替一般性证明。

思考3:圆排列有"固定一个人"的技巧(固定 1 号在正上方,剩下 (n−1)! 种)。这个技巧能直接用在项链排列上吗?为什么项链还要额外除以 2?

查看参考答案

能消掉旋转,但消不掉翻转。

先看"固定一个人"为什么有效。圆排列的对称操作是 n 个旋转,它们构成一个大小为 n 的循环群。想数"轨道数",等价于数"在旋转下的代表元个数"。因为每个轨道恰好有 n 个元素、且没有排列能被非平凡旋转固定(珠子可分辨时),所以"固定 1 号在正上方"就唯一选出了每个轨道的一个代表,剩下 n − 1 个人可以任意排 → (n − 1)!。

再看项链。项链的对称操作有 2n 个(n 个旋转 + n 个翻转),构成二面体群。

① 对于旋转:固定 1 号在正上方仍然能选出旋转轨道的唯一代表,把 n 倍消掉。

② 但对于翻转:有一个特殊的翻转 —— "过 1 号与圆心的那根轴做镜像"—— 它把 1 号仍然留在正上方!也就是说,这个翻转在"固定 1 号"之后依然存在,它把剩下 (n − 1)! 种摆放两两配成一对(镜像关系)。所以还要再除以 2。

结论:项链数 = (n − 1)! / 2。其中"除以 n"由固定 1 号完成,"除以 2"则由那根过 1 号的镜像轴产生。

更一般的理解:固定一个元素相当于把群作用"商掉"一部分。剩下的对称操作是"保持该元素不动的那些操作"构成的子群(稳定子)。对圆排列,稳定子是平凡的;对项链,稳定子还包含那个镜像,大小为 2 —— 所以要除以 2。

易错:以为"固定一个人"就能同时消掉旋转和翻转(会得到 (n−1)! 而不是 (n−1)!/2);或者以为项链可以直接"除以 2n"(这在 n 较小时会因对称而失效,本质就是要用 Burnside 引理)。

模块 05

学习检测

先找错,再背口诀 —— 把易错点消灭在考试之前。

易错1

多重集排列的分母写错

❌ 错误
把 aabbcc 的排列数写成 6! / (2+2+2)! = 720/720 = 1,或者写成 6! / (3 × 2!) = 120。正确是 90。
✅ 正确
分母是"每个重复次数各自取阶乘再相乘":6! / (2! × 2! × 2!) = 720 / 8 = 90。记忆方式:重复几组就除几个阶乘。
口诀:分母是各自阶乘之积,不是总和也不是和!
易错2

座位有编号时错误地除以 n

❌ 错误
题目说"8 个人坐在编号 1~8 的座位上",你按圆排列算成 7! = 5040。但座位有编号时,坐在 1 号位和坐在 2 号位是不同的坐法。
✅ 正确
座位有编号 → 普通排列 8! = 40320;只有"座位没有编号、只看相对顺序"(围成一圈)时才是圆排列 (8−1)! = 5040。读题时先判断:位置有没有区分。
口诀:座位有号不除 n,围成一圈才圆排!
易错3

错排递推的边界条件写错

❌ 错误
写 D[0] = 0; D[1] = 1;。这会推出 D[2] = 1 × (1 + 0) = 1(碰巧对),但 D[3] = 2 × (1 + 1) = 4 —— 正确答案是 2,从 n = 3 起就错了。
✅ 正确
D[0] = 1; D[1] = 0;(空排列算 1 种;1 个元素无处可去,为 0)。这样 D[2] = 1×1 = 1、D[3] = 2×(1+0) = 2、D[4] = 3×(2+1) = 9,全部正确。
口诀:D₀ 是一、D₁ 是零,往后递推不会错!
易错4

"恰好 k 个原位"忘记乘组合数

❌ 错误
把"n = 4 时恰好 1 个元素在原位"的答案写成 D₃ = 2。这只数了"剩下 3 个都错排"的方式,但从 4 个里挑哪一个不动有 4 种选择。
✅ 正确
C(n, k) × Dₙ₋ₖ:先选出哪 k 个元素留在原位(C(4,1) = 4 种),再让剩下 n − k 个全部错排(D₃ = 2 种),答案 4 × 2 = 8。
口诀:先选谁不动,再让剩下的全错排!
易错5

项链排列忘记除以 2,或用整数除法

❌ 错误
① 6 颗珠子串成项链算成 5! = 120(漏了翻转等价);② 在取模意义下写 ans / 2(整数除法把 5040/... 直接截断,取模下完全错误)。
✅ 正确
① 项链数 = (n−1)! / 2,6 颗珠子时是 60;② 取模下要用逆元:ans * qpow(2, MOD−2) % MOD,绝不能写成 ans / 2。
口诀:项链翻转要除二,取模除法用逆元!
📋速查表
结论 / 公式内容前提条件
多重集{a₁·x₁, …, aₖ·xₖ},总数 n = Σaᵢ只看各类数量
等价关系自反 + 对称 + 传递三条都要满足
等价类/划分互不相交、并集为全集类大小相同时才能用除法
多重集排列n! / (a₁!a₂!⋯aₖ!)多项式系数,必为整数
多重集组合C(n+k−1, k−1)k 类、取 n 个、可为 0
每类至少 1 个C(n−1, k−1)先各给一个
每类至多 r 个Σ(−1)ʲC(k,j)C(n−j(r+1)+k−1,k−1)容斥
不相邻组合C(n−k+1, k)从 1..n 取 k 个互不相邻
插空法m 个 X 插入 n 个 Y(不相邻)→ C(n+1, m)X 相同;X 不同再乘 m!
圆排列(n−1)!旋转等价(座位无编号)
项链排列(n−1)! / 2旋转 + 翻转等价,n ≥ 3
有编号座位的圆桌C(n, k)·(k−1)!先选座再圆排
错排递推Dₙ = (n−1)(Dₙ₋₁ + Dₙ₋₂)D₀=1,D₁=0
错排通项Dₙ = n!·Σ(−1)ⁱ/i!Dₙ/n! → 1/e ≈ 0.3679
恰好 k 个原位C(n, k)·Dₙ₋ₖ先定后错排
禁位排列Σ(−1)ⁱ rᵢ (n−i)!rᵢ 为禁位棋盘 i 阶系数
📌笔记卡
必记概念
  • 多重集排列 n!/(a₁!⋯aₖ!)
  • 隔板法 C(n+k−1, k−1)
  • 圆排列 (n−1)!,项链再除 2
  • 错排 D₀=1、D₁=0、D₂=1
  • 恰 k 个原位 = C(n,k)·Dₙ₋ₖ
必背模板
  • fac/ifac 预处理(一次逆元 + 前缀积)
  • multisetPerm:fac[n] 连乘 ifac[aᵢ]
  • 错排表 O(n) 递推
  • C(n,m) = fac[n]·ifac[m]·ifac[n−m]
  • 除以 2 用 qpow(2, MOD−2)
考场提醒
  • 先判断"位置有没有编号"
  • 先判断"元素是否可区分"
  • 分母是各自阶乘之积
  • 取模意义下不能直接除法
  • 多组询问要预处理打表
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1计算题

计算:① aabbbb(2 个 a、4 个 b)的排列数;② MISSISSIPPI 的排列数;③ aabbcc 的排列数。

查看参考答案

① aabbbb:共 6 个字母,a 重复 2 次、b 重复 4 次。6! / (2! × 4!) = 720 / (2 × 24) = 720 / 48 = 15。

② MISSISSIPPI:M × 1、I × 4、S × 4、P × 2,共 11 个。11! / (1! × 4! × 4! × 2!) = 39916800 / 1152 = 34650。

③ aabbcc:共 6 个,三组各重复 2 次。6! / (2! × 2! × 2!) = 720 / 8 = 90。

验证思路:① 等价于"6 个位置里选 2 个放 a",C(6,2) = 15 ✓;③ 等价于 C(6,2) × C(4,2) = 15 × 6 = 90 ✓

易错:① 分母写成 (2+4)! = 720(得到 1);③ 分母写成 (2+2+2)! 或 3 × 2!。

A2计算题

把 10 个相同的苹果分给 3 个人:① 每人可分 0 个;② 每人至少 1 个;③ 每人至多 4 个。分别求方案数。

查看参考答案

① 可空:标准隔板法 C(10 + 3 − 1, 3 − 1) = C(12, 2) = 66。

② 至少 1 个:先各给 1 个,剩 7 个用标准隔板法:C(7 + 2, 2) = C(9, 2) = 36。也可直接用公式 C(n−1, k−1) = C(9,2) = 36。

③ 至多 4 个:用容斥排除"某个人 ≥ 5 个"的情形:

C(12,2) − C(3,1)·C(12−5,2) + C(3,2)·C(12−10,2) − C(3,3)·C(12−15,2)

= 66 − 3 × 21 + 3 × 1 − 0 = 66 − 63 + 3 = 6。

手工验证 ③:三数之和为 10 且每个 ≤ 4,可能的三元组(不计顺序)只有 (4,4,2) 和 (4,3,3)。排列后:(4,4,2) 有 3 种、(4,3,3) 有 3 种,共 6 种 ✓

易错:③ 忘记容斥的第二、三项;把"至多"误当成"至少"。

A3计算题

计算:① 8 个人围成一圈;② 6 颗不同颜色的珠子串成项链;③ 8 个编号座位中选 5 个人坐成"一圈"(相邻关系成环)。

查看参考答案

① 8 人围圈:圆排列 (8−1)! = 7! = 5040。

② 6 珠项链(可翻转):(6−1)! / 2 = 120 / 2 = 60。

③ 8 个编号座位选 5 人围成圈:先选 5 个座位 C(8,5) = 56,再对 5 个人做圆排列 (5−1)! = 24,共 56 × 24 = 1344。

要点辨析:③ 里座位是有编号的,所以"选哪些座位"这一步不能省。如果题目是"8 个人围成一圈"(位置无编号),就直接是 5040 —— 两者的区别就在"位置是否被区分"。

易错:② 忘记除以 2;③ 直接把答案写成 C(8,5) × 5!(那是"选座位 + 线性坐",不是圆排列)。

A4计算题

已知 D₁ = 0、D₂ = 1,用递推式算出 D₆ 与 D₇;再求 n = 5 的排列中恰好有 2 个元素在原位的方案数。

查看参考答案

逐步递推:

D₃ = 2 × (D₂ + D₁) = 2 × 1 = 2

D₄ = 3 × (D₃ + D₂) = 3 × 3 = 9

D₅ = 4 × (D₄ + D₃) = 4 × 11 = 44

D₆ = 5 × (D₅ + D₄) = 5 × 53 = 265

D₇ = 6 × (D₆ + D₅) = 6 × 309 = 1854

恰好 2 个原位(n = 5):C(5, 2) × D₃ = 10 × 2 = 20。

验证 n = 5 的全部分类:恰好 0 个 = 44、1 个 = C(5,1)·D₄ = 5×9 = 45、2 个 = 20、3 个 = C(5,3)·D₂ = 10×1 = 10、4 个 = C(5,4)·D₁ = 5×0 = 0、5 个 = 1。合计 44 + 45 + 20 + 10 + 0 + 1 = 120 = 5! ✓

易错:递推时用错 Dₙ₋₂ 的值;恰好 k 个原位忘记乘组合数;忘记"恰好 4 个原位为 0"(因为剩下 1 个必然也在原位)。

🟡 B组 · 提高(4题)

B1综合编程

输入一个字符串 s(长度 ≤ 10⁶,仅含小写字母),输出它的不同排列数对 10⁹+7 取模的结果。

查看参考答案

思路:统计每个字母出现次数,套公式 |s|! / ∏(cntᵢ!)。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1000010;
const ll MOD = 1000000007;
ll fac[MAXN], ifac[MAXN];
ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}
int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    string s; cin >> s;
    int n = s.size();
    fac[0] = 1;
    for (int i = 1; i <= n; i++) fac[i] = fac[i - 1] * i % MOD;
    ifac[n] = qpow(fac[n], MOD - 2);
    for (int i = n - 1; i >= 0; i--) ifac[i] = ifac[i + 1] * (i + 1) % MOD;

    vector<int> cnt(26, 0);
    for (char c : s) cnt[c - 'a']++;
    ll ans = fac[n];
    for (int i = 0; i < 26; i++) ans = ans * ifac[cnt[i]] % MOD;
    cout << ans << endl;
    return 0;
}

样例:aabb → 6;abc → 6;aaaa → 1;mississippi → 34650。

易错:阶乘表只开到字符串长度不够用(要在读入后按 n 计算);求逆元时对 0! 也求一次(用 ifac[n] 起点往下递推即可);count 数组下标用 c - 'A' 而出错。

B2综合编程

预处理 D₀ ~ D₁₀⁶,输入 T 组 (n, k),输出 1..n 的排列中恰好有 k 个元素在原位的方案数,对 10⁹+7 取模。

查看参考答案

思路:答案 = C(n, k) × D[n−k]。预处理阶乘、阶乘逆元、错排表,每组 O(1)。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1000010;
const ll MOD = 1000000007;
ll fac[MAXN], ifac[MAXN], D[MAXN];
ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}
ll C(ll n, ll m) {
    if (m < 0 || m > n) return 0;
    return fac[n] * ifac[m] % MOD * ifac[n - m] % MOD;
}
int main() {
    ios::sync_with_stdio(false); cin.tie(0);
    fac[0] = 1;
    for (int i = 1; i < MAXN; i++) fac[i] = fac[i - 1] * i % MOD;
    ifac[MAXN - 1] = qpow(fac[MAXN - 1], MOD - 2);
    for (int i = MAXN - 2; i >= 0; i--) ifac[i] = ifac[i + 1] * (i + 1) % MOD;
    D[0] = 1; D[1] = 0;
    for (int i = 2; i < MAXN; i++) D[i] = (i - 1) * ((D[i - 1] + D[i - 2]) % MOD) % MOD;

    int T; cin >> T;
    while (T--) {
        ll n, k; cin >> n >> k;
        cout << C(n, k) * D[n - k] % MOD << endl;
    }
    return 0;
}

样例:4 1 → 8;4 0 → 9;6 2 → 135;5 3 → C(5,3)·D₂ = 10。

易错:错排表下标写成 D[k];C 函数忘记判 m > n;D 数组递推时忘记取模导致溢出。

B3分析题

为什么"多重集排列数 n!/(a₁!⋯aₖ!)"一定是整数?请从"组合意义"和"群作用"两个角度各给一种证明。

查看参考答案

角度一(组合意义 / 逐步选位):把"给 n 个位置分配各类元素"拆成 k 步:

C(n, a₁) · C(n−a₁, a₂) · ⋯ · C(aₖ, aₖ)

每一步都是"从剩下的位置里挑若干个",都是整数。展开化简:

C(n,a₁)·C(n−a₁,a₂) = n!/(a₁!(n−a₁)!) × (n−a₁)!/(a₂!(n−a₁−a₂)!) = n!/(a₁!a₂!(n−a₁−a₂)!)

不断乘下去,分母的剩余阶乘被逐步约掉,最后恰好剩 n!/(a₁!a₂!⋯aₖ!)。因为它是连乘整数,所以是整数。∎

角度二(群作用 / 轨道计数):设 X 为 n 个带标签元素的全排列集合(|X| = n!)。让群 G = S_{a₁} × S_{a₂} × ⋯ × S_{aₖ} 作用在 X 上(第 i 个分量置换第 i 类元素在排列中的位置标签)。

因为各类元素的标签互换后得到的排列仍然在同一个"无标签排列"下等价,而没有任何非平凡置换能固定一个排列(每个排列中同类元素占的位置是确定的,互换标签一定会改变排列),所以每个轨道(等价类)的大小恰好等于 |G| = a₁!a₂!⋯aₖ!。

由"轨道大小整除数群阶"(拉格朗日定理),每个等价类大小整除 |G|;而这里恰好相等,所以轨道数 = n!/|G|,是整数。∎

补充:这个数就是"多项式系数",它出现在 (x₁ + x₂ + ⋯ + xₖ)ⁿ 的展开式中 x₁^{a₁}x₂^{a₂}⋯xₖ^{aₖ} 项的系数上 —— 从多项式展开的角度也自然能看出它必为整数。

易错:用"举个数字例子发现能整除"代替一般性证明;或者只说"这是个计数结果所以是整数"而不给出计数模型。

B4综合编程

输入 n 与 k(k ≤ n ≤ 10⁶),输出"n 个人围 n 个座位的圆桌、固定 k 个座位无人"的方案数,对 10⁹+7 取模。说明这与圆排列公式的关系。

查看参考答案

题意澄清:座位是固定编号的(1..n 号位,围成一圈),要选 k 个座位空着,剩下 n − k 个人坐进去。求方案数。

思路:分两步 —— ① 选哪 k 个座位空着:C(n, k);② 把 n − k 个人排到剩下的 n − k 个有编号座位上:(n − k)!。

答案 = C(n, k) · (n − k)! = n! / k!

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1000010;
const ll MOD = 1000000007;
ll fac[MAXN];
ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}
int main() {
    ll n, k; cin >> n >> k;
    fac[0] = 1;
    for (int i = 1; i <= n; i++) fac[i] = fac[i - 1] * i % MOD;
    // C(n,k) * (n-k)! = n!/(k!(n-k)!) * (n-k)! = n!/k!
    cout << fac[n] * qpow(fac[k], MOD - 2) % MOD << endl;
    return 0;
}

样例:8 0 → 40320(= 8!,所有人都有座位,就是普通排列);8 1 → 8!/1! = 40320;8 3 → 8!/6 = 40320/6 = 6720。

验证 8 3:C(8,3) × 5! = 56 × 120 = 6720 ✓

与圆排列的关系:如果题目改成"座位没有编号、只看相对顺序、且允许有空位",那么"空位的相对位置"也要参与圆排列。设总位置数为 n,其中 k 个空位、n − k 个人。把 n 个"位置"围成圆,其中相同的人之间还要除以 (n − k)! …… 这类问题需要用"圆排列 + 多重集"的组合:先固定一个非空位置破对称,再处理。属于竞赛中的进阶题型,核心工具仍然是本讲的三个公式。

易错:误以为"座位有编号"和"围成一圈"能同时成立(有编号就不能除以 n);把空位也当成可区分的对象反复计数。

🔴 C组 · 挑战(4题)

C1初赛选择题

下列说法正确的有几个?
① 关系"模 5 同余"是整数集上的等价关系
② aabbcc 的不同排列数是 90
③ 错排列数满足 Dₙ = (n−1)(Dₙ₋₁ + Dₙ₋₂),且 D₃ = 2
④ 6 颗不同颜色的珠子串成项链的方案数是 120

A. 1    B. 2    C. 3    D. 4

查看参考答案

答案:C(3 个)

①正确:自反性(a ≡ a)、对称性(a ≡ b ⟹ b ≡ a)、传递性(a ≡ b, b ≡ c ⟹ a ≡ c)全部成立。

②正确:6!/(2!2!2!) = 720/8 = 90。

③正确:递推式正确;D₃ = (3−1)(D₂ + D₁) = 2 × (1 + 0) = 2。

④错误:项链要除以 2(翻转等价),正确答案是 5!/2 = 60。

考点:等价关系三性质、多重集排列、错排递推、项链排列。

易错:把"项链"当成"围成一圈"(漏掉翻转);或者把"手链"当成"项链"而多除一次 2。

C2构造与证明

证明错排的容斥公式:Dₙ = n! · Σ_{i=0}^{n} (−1)ⁱ / i!,并由此说明 Dₙ/n! 随 n 增大趋近于 1/e。

查看参考答案

第一步(定义事件)。设全集 U 为 1..n 的全部排列,|U| = n!。对每个 i 定义 Aᵢ = {排列 p : pᵢ = i }(第 i 个元素在原位)。要求的是"没有任何一个 Aᵢ 发生"的排列数,即 |Ā₁ ∩ Ā₂ ∩ ⋯ ∩ Āₙ|。

第二步(用容斥原理)。

Dₙ = |U| − Σ|Aᵢ| + Σ|Aᵢ∩Aⱼ| − Σ|Aᵢ∩Aⱼ∩Aₖ| + ⋯ + (−1)ⁿ|A₁∩⋯∩Aₙ|

第三步(计算各项的大小)。任取 i 个不同的下标,固定这 i 个元素在原位,剩下 n − i 个元素可以任意排列,所以 |A_{i₁} ∩ ⋯ ∩ A_{iᵢ}| = (n − i)!。而这样的下标组合共有 C(n, i) 个。因此第 i 组的总贡献是 C(n, i)·(n − i)!。

第四步(化简)。把 C(n,i)(n−i)! = n!/i! 代入:

Dₙ = Σ_{i=0}^{n} (−1)ⁱ · n!/i! = n! · Σ_{i=0}^{n} (−1)ⁱ / i! ∎

第五步(求极限)。括号里的和是 e^{−1} 的泰勒展开前 n + 1 项:e^{x} = Σ xⁱ/i!,取 x = −1 得 e^{−1} = Σ (−1)ⁱ/i!。余项 |Rₙ| ≤ 1/(n+1)!,随 n → ∞ 趋于 0。所以

lim_{n→∞} Dₙ/n! = 1/e ≈ 0.367879… ∎

有趣的推论:随机洗牌后"一个都不在原位"的概率约为 36.79%,而"至少有一个在原位"的概率约为 63.21% —— 后者收敛得很快(n = 4 时已经接近 0.625)。这与"生日悖论"一样属于"直觉失效"的经典例子。

易错:把 C(n,i)(n−i)! 化简错(应为 n!/i!,不是 n!/(i!(n−i)!));忘记容斥的符号是正负交替;用"泰勒展开"求极限时没说清余项趋于 0。

C3综合编程

【插空法】用 a 个 A、b 个 B、c 个 C 组成长度 a + b + c 的串,要求任意两个 A 都不相邻。求方案数对 10⁹+7 取模。

查看参考答案

思路:先排 B 和 C(共 b + c 个元素),它们之间有 b + c + 1 个空隙;把 a 个 A 放进这些空隙里,每个空隙至多放 1 个(保证 A 不相邻)。所以:

答案 = [ (b + c)! / (b! · c!) ] × C(b + c + 1, a)

第一个因子是 B、C 自身的多重集排列数;第二个因子是"选 a 个空隙放 A"。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1000010;
const ll MOD = 1000000007;
ll fac[MAXN], ifac[MAXN];
ll qpow(ll b, ll e) {
    ll r = 1 % MOD; b %= MOD;
    while (e) { if (e & 1) r = r * b % MOD; b = b * b % MOD; e >>= 1; }
    return r;
}
ll C(ll n, ll m) {
    if (m < 0 || m > n || n < 0) return 0;
    return fac[n] * ifac[m] % MOD * ifac[n - m] % MOD;
}
int main() {
    ll a, b, c; cin >> a >> b >> c;
    int N = a + b + c + 1;
    fac[0] = 1;
    for (int i = 1; i <= N; i++) fac[i] = fac[i - 1] * i % MOD;
    ifac[N] = qpow(fac[N], MOD - 2);
    for (int i = N - 1; i >= 0; i--) ifac[i] = ifac[i + 1] * (i + 1) % MOD;

    ll bc = fac[b + c] * ifac[b] % MOD * ifac[c] % MOD;      // B、C 的排列数
    ll gaps = C(b + c + 1, a);                            // 选 a 个空隙放 A
    cout << bc * gaps % MOD << endl;
    return 0;
}

样例一:输入 3 3 0(3 个 A、3 个 B,A 不相邻)→ 输出 4。验证:(3!/3!) × C(4,3) = 1 × 4 = 4,四个串分别是 ABABAB、ABABBA、ABBABA、BABABA ✓

样例二:输入 2 2 1 → 输出 18。验证:(3!/(2!1!)) × C(4,2) = 3 × 6 = 18。手工核对:先排 B、B、C(3 种:BBC、BCB、CBB),每个有 4 个空隙,选 2 个放 A 有 6 种,3 × 6 = 18 ✓

样例三(无解情形):输入 5 2 0 → 输出 0。因为 2 个 B 只产生 3 个空隙,最多放 3 个 A,放不下 5 个,C(3,5) = 0 ✓

易错:空隙数写成 b + c(应该是 b + c + 1,两端也算空隙);把 A 当成可区分的元素多乘了 a!;忘记"每个空隙最多放 1 个"这个前提(否则 A 会相邻)。

C4算法设计

设计算法:n 对夫妻围圆桌就座(n ≥ 3),要求每对夫妻不相邻。请给出方案数的计算思路,并说明其中"禁位"该怎么处理。

查看参考答案

这是经典的"夫妻问题"(ménage problem)。直接对 2n 个人做圆排列再排除"夫妻相邻"会很复杂,因为"相邻"这个限制与"围成圆"耦合在一起。标准做法是先安排一方,再把另一方插入空隙。

第一步:先让 n 位女士围成一圈。圆排列数是 2 · (n − 1)!(乘 2 是因为围成圆后还可以整体镜像翻转,男士的插法在两种朝向下不同 —— 实际上更精细的处理是:女士先排好 (n−1)! 种,然后对每种排法数男士的插法,不需要再乘 2,因为"顺时针/逆时针"已经由女士的相对顺序确定了)。

下面按"女士固定为 (n − 1)! 种"来分析。

第二步:分析男士的禁位。女士围成圆后产生 n 个空隙。每位男士不能坐在自己妻子两侧的空隙,所以每个男士有 2 个禁位。禁位表的形状是"循环阶梯形":第 1 位男士禁掉 1、2 号空隙,第 2 位禁掉 2、3 号,……,第 n 位禁掉 n、1 号空隙。

第三步:用"禁位排列"的容斥公式。

合法方案数 = Σ_{i=0}^{n} (−1)ⁱ · rᵢ · (n − i)!

其中 rᵢ 是从这个循环阶梯形的禁位棋盘中选出 i 个"互不同行也不同列"的格子的方案数。对本问题可以证明:

rᵢ = (2n / (2n − i)) · C(2n − i, i)   (i = 0, 1, …, n)

第四步:合并。最终答案 = (n − 1)! · Σ_{i=0}^{n} (−1)ⁱ · rᵢ · (n − i)!(如果题目把手链/项链式的翻转视为同一种,还要再除以 2)。

▶ 小数据验证(用枚举程序核对)
n = 3(3 对夫妻,共 6 人):方案数 = 1
    唯一的一种坐法是"女女女"之间插入"男",且每个男都不挨着自己妻子,
    等价地对 3 个空隙做一次"旋转错排",本质只有 1 种。

n = 4(4 对夫妻,共 8 人):方案数 = 2

n = 5:方案数 = 13
n = 6:方案数 = 80
n = 7:方案数 = 579
n = 8:方案数 = 4738

(这些数正是经典的 ménage 数列:
  1, 2, 13, 80, 579, 4738, 43387, …)

关键要点:① "围成圆"和"禁位相邻"必须分开处理 —— 先用圆排列固定一方的相对顺序,把问题转成"另一方插入空隙";② 禁位表格不是简单的对角线,而是循环阶梯形,所以 rᵢ 需要专门的公式;③ 整个框架仍然是本讲"禁位排列容斥公式",错排只是它"禁位为对角线"的特例。

易错:直接对 2n 个人做圆排列再用容斥("夫妻相邻"的并集极其复杂,容斥项难以计算);忘记"女士围成圆后,女士之间的空隙共有 n 个";把 rᵢ 当成简单的 C(n, i)(那是对角线情形的特例)。

🏆 洛谷原题精选(12道)

以下 12 道题围绕"多重集排列 / 组合计数 / 错排 / 圆排列"展开,按难度从普及+ 到提高排列。

1
P2822
[NOIP2016] 组合数问题普及+
2
P1313
[NOIP2011] 计算系数提高
3
P3197
[SDOI2008] 越狱提高
4
P3223
[HNOI2012] 排队提高
5
P2290
[HNOI2004] 树的计数提高
6
P4071
[SDOI2016] 排列计数提高
7
P2518
[HAOI2010] 计数提高
8
P1350
车的放置提高
9
P2467
[SDOI2010] 地精部落提高
10
P4931
[MtOI2018] 情侣?给我烧了!提高
11
P2606
[ZJOI2010] 排列计数提高
12
P5520
[yLOI2019] 青原樱花提高

提示:第 1、2 题练"阶乘 + 逆元"这套组合数基本功;第 3 题是"补集思想"(总方案减去非法方案);第 4 题是插空法与相邻限制的综合;第 5 题把多重集排列用在"树的计数"(Prüfer 序列)上,非常经典;第 6 题就是本讲案例 2 的错排模型;第 7、8 题需要"分类讨论 + 组合数";第 9、10、11 题属于提高组偏难题;第 12 题是圆排列与构造的结合。建议按顺序推进,每道题都先写出"用本讲的哪个公式"再动手。