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

容斥原理 与 卡特兰数

"多算的减掉、减多的加回来"—— 一个符号交替的万能计数公式;以及它最著名的特例:出栈序列、括号匹配、三角剖分共同指向的 1, 2, 5, 14, 42…

7 块韦恩区域 3 个卡特兰公式 8 种经典模型 2 处交互动画 12 道洛谷原题
本讲 · 第 50/52 讲容斥原理与卡特兰数
模块 01

情景导航

从"重复点名"的尴尬引出容斥原理,再从"不许越界"的走格子问题引出卡特兰数。

故事社团招新签到表

新学期社团招新,学生会主席收到三张签到表:算法社签到 40 人、篮球社签到 30 人、辩论社签到 25 人。他要向学校汇报"至少参加了一个社团的总人数"。

他顺手一加:40 + 30 + 25 = 95 人。结果辅导老师一看就摇头:"你们学校一共才 80 个学生,怎么可能 95 人?"

问题出在重复计数:同时报了两个社的同学被数了两遍。设算法∩篮球有 10 人、算法∩辩论有 8 人、篮球∩辩论有 6 人,把这三块减掉后得到 95 − 10 − 8 − 6 = 71 人。

但还没完!同时报了三个社的同学(比如 3 人),在前面的运算中被"加了三次、减了三次",最后净贡献是 0 —— 他们被漏掉了!所以要再加回来一次:71 + 3 = 74 人。

这就是容斥原理(Inclusion–Exclusion Principle):先无脑加,再减掉两两重复的,再加回三三重复的,减掉四四重复的…… 符号一正一负交替,直到全部修正完毕。

故事里还藏着一个几乎无关的第二幕:如果报到顺序有约束 —— 比如"每个时刻算法社的报名人数都不少于篮球社",那么合法的流程有多少种?答案会被压缩到一串神奇的数字:1, 1, 2, 5, 14, 42, 132… 这就是卡特兰数。

💡 故事背后的算法思想

容斥原理解决"并集的规模"。凡是问题里出现"至少满足一个""都不满足""恰好满足 k 个",本质上都在问并集或并集的补集。它的核心动作只有两个:枚举所有子集 + 按子集大小交替定符号。

卡特兰数是容斥原理的一个著名推论。"不越过对角线的格路数 = 总路径数 − 越线路径数",而越线路径数恰好可以用反转法(反射原理)一一对应到另一类无约束路径。于是 Catn = C(2n,n) − C(2n,n+1) = C(2n,n)/(n+1)。

一句话总结:容斥负责"减去不合法",卡特兰负责"数出合法的"。

①生活类比
📋

三张签到表

三张表直接相加会把重叠的人算多次,需要减掉重叠、加回三重。

→ |A∪B∪C|
🩹

贴创可贴

两块创可贴重叠时,覆盖面积 = 两块之和 − 重叠部分,否则重叠处算了两次。

→ 加减交替
🎫

抽奖不中奖

"至少中一个奖" = 全部情况 − "一个都不中",用补集往往更省事。

→ 全集 − 并集
🪜

不许越线的走法

在方格纸上走,要求全程不越过对角线 —— 数出合法走法就是卡特兰数。

→ C(2n,n)/(n+1)
②核心定义

容斥原理:对有限集合 A1, A2, …, An,其并集的元素个数为
|A1 ∪ … ∪ An| = Σ|Ai| − Σ|Ai∩Aj| + Σ|Ai∩Aj∩Ak| − … + (−1)n+1|A1∩…∩An|。

卡特兰数(Catalan Number):数列 Cat0 = 1, Cat1 = 1, Cat2 = 2, Cat3 = 5, Cat4 = 14, …,通项公式 Catn = C(2n,n) / (n+1)。

反射原理(André):把"第一次越线之前的路径"关于越线直线做镜像,可把不合法路径一一对应到另一类无约束路径,从而数清不合法的数量。

📌 本讲核心知识地图

板块核心内容对应大纲
两集合容斥|A∪B| = |A|+|B|−|A∩B|【7】容斥原理
三集合容斥加三减三加一(7 块区域)【7】容斥原理
一般形式Σ(−1)|S|+1|∩i∈SAi|【7】容斥原理
子集枚举二进制掩码 / DFS,O(2n)【7】容斥原理
补集套路"恰好 0 个" = |U| − |∪Ai|【7】容斥原理
卡特兰递推Catn = Catn−1·2(2n−1)/(n+1)【7】卡特兰数
卡特兰通项Catn = C(2n,n)/(n+1) = C(2n,n) − C(2n,n+1)【7】卡特兰数
经典模型出栈序列、括号匹配、不相交弦、二叉树、格路【7】卡特兰数
③核心口诀
🔑 容斥原理

先加后减再加回,符号一正一负交替;
两两重复要减掉,三三重复要加回;
求"都不满足"最快:全集 − 并集。

🔑 卡特兰数

出栈括号二叉树,格路不越对角线;
递推 Catn = Σ Cati·Catn−1−i,通项 C(2n,n)/(n+1);
数列 1 1 2 5 14 42,看到就想到它!

🔑 反射原理

越线路径别硬数,关于越线做镜像;
起点映射到对称点,路径总数一变就数清。

模块 02

知识探究

先把容斥从两集合推演到一般形式并落成代码,再用反射原理推导卡特兰数的通项公式。

1容斥原理:从两块到三块 核心
1
➕
全加

把每个集合的大小都加一遍

2
➖
减两两

减去所有"两两交集"的大小

3
➕
加三三

把所有"三三交集"加回来

📐 两集合公式

|A ∪ B| = |A| + |B| − |A ∩ B|

元素 x 若"只在 A 里",左边算 1 次,右边 1 + 0 − 0 = 1 ✔;若"同时在 A、B 里",左边算 1 次,右边 1 + 1 − 1 = 1 ✔。逐一验证所有可能,等式成立。

📐 三集合公式

|A∪B∪C| = |A|+|B|+|C| − |A∩B| − |A∩C| − |B∩C| + |A∩B∩C|

为什么最后要加回 |A∩B∩C|?看同时属于三个集合的元素 x:它被"+三项"加了 3 次,被"−三项"减了 3 次,净贡献 3 − 3 = 0。而它本该贡献 1,所以必须再 +1 补回来。

更严谨的看法:元素 x 若属于恰好 t 个集合,则它被计入的总次数是 C(t,1) − C(t,2) + C(t,3) − … + (−1)t+1C(t,t) = 1 − (1−1)t = 1(t ≥ 1)。这正是二项式定理的威力 —— 容斥原理的严格证明就是上一讲那条交错和恒等式。✔

A 只此 B 只此 C 只此 A∩B B∩C A∩C A∩B∩C +3 块 −3 块 +1 块 = 7 个区域各算 1 次
图 1:三圆韦恩图的 7 块区域
🔍 7 块区域与符号
  • 1 块只属 A → 被 +|A| 算 1 次 ✔
  • 1 块只属 B → 被 +|B| 算 1 次 ✔
  • 1 块只属 C → 被 +|C| 算 1 次 ✔
  • 3 块A∩B、A∩C、B∩C → 各被算 2 次,减 1 次 ✔
  • 1 块A∩B∩C → 被算 3 次,减 3 次 = 0,再加 1 次 ✔

一共 7 块,全部恰好被计入 1 次 —— 这就是"容斥七块"的来历。

2容斥原理的一般形式 必须背熟

📐 一般形式(n 个集合)

|∪i=1..n Ai| = Σk=1..n (−1)k+1 · Σ|S| = k, S ⊆ {1..n} |∩i∈S Ai|

翻译成人话:枚举所有非空子集 S,把 S 中所有集合的交集大小求出来,若 |S| 是奇数就加、偶数就减,全部累加。

速记:奇数块加、偶数块减 → 符号 = (−1)|S|+1,也可以用 (|S| % 2 == 1) ? +1 : -1 判断。

复杂度:一共有 2n − 1 个非空子集,所以朴素容斥的复杂度是 O(2n)。n ≤ 20 时可用;n 更大就要考虑 DP 或数据结构优化。

❌ 表达式记混

把符号记成 (−1)|S|,导致第一项(单集合)变成负号,整个答案符号反了。

或者漏掉"三三交集"这一项,三元素重叠区被漏算。

✅ 正确记忆

第一项(|S| = 1)一定是加号,所以指数是 |S| + 1。奇数加、偶数减。

三集合时共 3 + 3 + 1 = 7 项:+3 −3 +1。

3容斥的四种应用套路 套路
套路问题形式做法典型题
① 至少满足一个"能被 2 或 3 或 5 整除的有几个"直接容斥求并集P1450 硬币购物
② 恰好 0 个"一个条件都不满足的有几个"全集 − 并集(补集思想)错排问题
③ 恰好 k 个"恰好满足 2 个条件的有几个"二重计数变形 / 子集枚举统计分组覆盖类
④ 上限受限的整数解"xi ≤ bi 的解个数"先去掉上限求插板,再容斥减超限P1450 硬币购物

🧩 套路三详解:恰好满足 k 个条件怎么数?

设 f(k) = "恰好满足 k 个条件的元素个数",g(k) = "至少满足 k 个条件的元素个数(用子集枚举累加)"。二重计数的关系是:

g(k) = Σt ≥ k C(t, k) · f(t)

因为一个"恰好满足 t 个"的元素,在 g(k) 里被数了 C(t,k) 次(从它满足的 t 个条件里任选 k 个)。反过来用容斥反演就能求出 f:

f(k) = Σt ≥ k (−1)t−k C(t, k) · g(t)

这个反演就叫二项式反演,本讲不展开,但你要知道:容斥能做的远不止求并集。

🎁 套路四详解:上限受限的整数解个数(插板 + 容斥)

问题:求 x1 + x2 + x3 = 10,0 ≤ xi ≤ 4 的整数解个数。

第1步:先忽略上限。非负整数解用插板法:C(10 + 3 − 1, 3 − 1) = C(12, 2) = 66 个。

第2步:定义"坏条件" Ai = "xi ≥ 5"。令 yi = xi − 5 ≥ 0,代入方程得 y1 + x2 + x3 = 5,解数 = C(5 + 2, 2) = C(7,2) = 21。

第3步:容斥。三个坏条件两两不可能同时成立(5 + 5 = 10 刚好,但需要 10 ≥ 10,此时第三个变量为 0 —— 若上限是 4 则 5+5=10 已经用尽所有和,第三个数只能是 0,可行)。实际计算:

|A1∪A2∪A3| = 3×21 − 3×C(2,2) + C(?)。其中两两同时超限:x1≥5, x2≥5 → 还剩 0,方案数 C(2,2) = 1,共 3 组;三者同时超限需要 15 > 10,方案数 0。

第4步:答案 = 66 − (63 − 3 + 0) = 66 − 60 = 6。手工验证:x1 从 2 到 4 → (2,4,4)(3,3,4)(3,4,3)(4,2,4)(4,3,3)(4,4,2),正好 6 组 ✔

4子集枚举容斥实现 代码模板

把一般形式直接翻译成代码只需要三层:枚举子集掩码 → 求交集 → 按 popcount 定符号。

C++子集枚举容斥通用骨架(位掩码版)
// 通用骨架:求 |A1 ∪ A2 ∪ ... ∪ An|
long long inclusionExclusion(int n) {
    long long ans = 0;
    for (int S = 1; S < (1 << n); S++) {          // 枚举所有非空子集
        long long cnt = /* |∩_{i∈S} A_i| 的计算 */ 0;
        int bits = __builtin_popcount(S);            // 子集大小
        if (bits & 1) ans += cnt;                      // 奇数加
        else       ans -= cnt;                      // 偶数减
    }
    return ans;
}

// 具体例:1~N 中能被 a1..an 中至少一个整除的数的个数
long long countDivisible(long long N, vector<long long>& a) {
    int n = a.size();
    long long ans = 0;
    for (int S = 1; S < (1 << n); S++) {
        long long l = 1; bool ok = true;
        for (int i = 0; i < n; i++)
            if (S >> i & 1) {
                l = l / gcd(l, a[i]) * a[i];        // 最小公倍数 lcm(C++17 的 std::gcd),先除后乘防溢出
                if (l > N) { ok = false; break; }  // 超过 N 就不用算了
            }
        if (!ok) continue;
        long long cnt = N / l;                      // 1~N 中是 l 的倍数的个数
        if (__builtin_popcount(S) & 1) ans += cnt;
        else ans -= cnt;
    }
    return ans;
}
运行结果
countDivisible(100, {2, 3, 5}) = 74
  50 + 33 + 20 = 103(三个单集合)
  −(16 + 10 + 6) = −32(两两交集:6,10,15 的倍数)
  + 3(三三交集:30 的倍数共 3 个:30, 60, 90)
  103 − 32 + 3 = 74 ✔

countDivisible(1000, {7, 11}) = 142 + 90 − 12 = 220    (77 的倍数有 12 个)
🔑 位掩码容斥三件套

① for (S = 1; S < (1<<n); S++) 枚举非空子集;
② 每个子集求交集的"规模"(整除题里就是 lcm);
③ popcount(S) 奇加偶减。

5卡特兰数从哪来 核心

卡特兰数(Catalan numbers)以比利时数学家 Eugène Charles Catalan 命名。他在 1838 年研究"n 对括号有多少种合法匹配方式"时发现了这个数列。但更早,欧拉(Euler)在 1751 年研究"凸多边形有多少种三角剖分方式"时就已经遇到它了。

1751欧拉:五边形剖分
1838卡特兰:括号匹配
19C出栈 / 弦 / 二叉树
NOW格路与加括号
同一个数列在不同领域反复出现,说明它描述的是同一种"结构约束"

🎯 为什么这些看似无关的问题答案一样?

因为它们的本质约束完全同构:"任何前缀都不允许某种量超标"。具体来说,把问题翻译成两种动作(如"左括号 / 右括号""入栈 / 出栈""向右 / 向上"),要求在任意时刻,第一种动作的累计次数 ≥ 第二种动作的累计次数。这样的序列统称为合法的 Dyck 路径,数量恰好是卡特兰数。

识别信号(考场三连问):① 题目有没有两种对立操作?② 有没有"任意前缀满足某不等式"的约束?③ 问的是方案数吗?三个都是"是",基本就是卡特兰数。

🧮 卷积递推的由来(以括号匹配为例)

设 Catn 是 n 对括号的合法方案数。看第一个左括号,它必然对应着某一个右括号 —— 设两者之间夹着 i 对括号,那么它们内部有 Cati 种方案,右侧还剩 n−1−i 对,有 Catn−1−i 种方案。

Catn = Σi=0..n−1 Cati · Catn−1−i,  Cat0 = 1

这就是著名的卡特兰卷积递推。它和"二叉树左右子树"、"格路第一次回到对角线"的分解方式完全一致 —— 都是"拆成左边 + 右边两块"。

6卡特兰数的三个公式与反射原理推导 重点
📊 卡特兰数前 10 项
n=01
11
22
35
414
542
6132
7429
81430
94862
增长速率 ≈ 4n / (n1.5√π),n = 20 时已到 6564120420(超过 int)
公式类型表达式适用场景
卷积递推Catn = Σi=0..n−1 Cati·Catn−1−in ≤ 5000,配合 DP 数组
乘式递推Catn = Catn−1 · 2(2n−1)/(n+1)需要 O(n) 递推并取模(用逆元)
通项公式Catn = C(2n,n)/(n+1) = C(2n,n) − C(2n,n+1)n ≤ 106,阶乘 + 逆元
渐近估计Catn ~ 4n/(n3/2√π)估算是否需要高精度
🎬 交互演示:反射原理推导 Catn = C(2n,n) − C(2n,n+1)
1建模:从 (0,0) 走到 (n,n),每步只能向右 R 或向上 U。总步数 2n,其中 R 恰好 n 步。这就是"任意前缀的 R 个数 ≥ U 个数"的格路模型。
2总数:从 2n 步中选 n 步走 R,无约束方案数 = C(2n, n)。
3定义坏路径:存在某一时刻"越过了对角线",即第一次到达直线 y = x + 1(R 的个数比 U 少 1 的那一刻)。
4关键一招 —— 镜像:取坏路径中第一次碰到 y = x + 1 之前的那段前缀,关于直线 y = x + 1 做对称翻折。起点 (0,0) 被翻到 (−1, 1);终点 (n,n) 在直线上?不对 —— 终点不在该直线上,所以终点不变。
5建立一一对应:翻折后得到一条从 (−1, 1) 到 (n, n) 的路径。反过来,任何从 (−1,1) 到 (n,n) 的路径都必然穿过 y = x + 1,翻折回去就得到一条坏路径。坏路径 ↔ 这类路径,一一对应!
6数清数量:从 (−1,1) 到 (n,n):x 方向要走 n+1 步(横坐标 −1 → n),y 方向要走 n−1 步(纵坐标 1 → n),总步数 2n。所以方案数 = C(2n, n+1)。
7收网:Catn = 合法 = 总数 − 坏 = C(2n,n) − C(2n,n+1)。化简:C(2n,n)·[1 − n/(n+1)] = C(2n,n)/(n+1) ✔
💡 验证 n = 3:C(6,3) = 20,C(6,4) = 15,20 − 15 = 5 = Cat3 ✔
C++卡特兰数三种求法(n ≤ 106,模 1e9+7)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1000000007;

ll qpow(ll a, ll b) {
    ll r = 1; a %= MOD;
    while (b) { if (b & 1) r = r * a % MOD; a = a * a % MOD; b >>= 1; }
    return r;
}
ll inv(ll x) { return qpow(x, MOD - 2); }   // MOD 为素数

/* 法一:卷积递推 O(n²),适合 n ≤ 5000 且需要完整数列 */
const int MAXN = 5005;
ll cat[MAXN];
void catDP(int n) {
    cat[0] = 1;
    for (int i = 1; i <= n; i++) {
        cat[i] = 0;
        for (int j = 0; j < i; j++)
            cat[i] = (cat[i] + cat[j] * cat[i-1-j]) % MOD;   // 卷积
    }
}

/* 法二:乘式递推 O(n),适合在线求第 n 项 */
ll cat2(int n) {
    ll c = 1;
    for (int i = 1; i <= n; i++)
        c = c * (4LL * i - 2) % MOD * inv(i + 1) % MOD;   // 2(2i-1)/(i+1)
    return c;
}

/* 法三:通项公式 O(n) 预处理 + O(1) 查询,适合多次询问 */
ll fac[2000006], ifac[2000006];
void initFac(int n) {
    fac[0] = 1;
    for (int i = 1; i <= n; i++) fac[i] = fac[i-1] * i % MOD;
    ifac[n] = inv(fac[n]);
    for (int i = n; i >= 1; i--) ifac[i-1] = ifac[i] * i % MOD;
}
ll cat3(int n) {
    ll c2n_n = fac[2*n] * ifac[n] % MOD * ifac[n] % MOD;
    return c2n_n * inv(n + 1) % MOD;               // C(2n,n)/(n+1)
}
运行结果
catDP(9)  → 1 1 2 5 14 42 132 429 1430 4862
cat2(9)   = 4862      (乘式递推与 DP 完全一致 ✔)
cat3(9)   = 4862      (通项公式结果一致 ✔)

手工校验:Cat₅ = C(10,5)/6 = 252/6 = 42 ✔
          Cat₆ = C(12,6)/7 = 924/7 = 132 ✔
7经典模型对照表 识别信号

下面 8 个问题问的其实是同一件事 —— 答案都是 Catn。看到它们,直接套公式。

#问题描述答案两种"动作"
1n 对括号的合法匹配方案数Catn左括号 / 右括号
21…n 依次入栈,可能的出栈序列数Catn入栈 / 出栈
3n 个结点的不同形态二叉树个数Catn左子树 / 右子树
4从 (0,0) 到 (n,n) 不越过对角线的格路数Catn向右 / 向上
52n 个点排成一行,两两连弦且弦不相交的方案数Catn配对 / 隔开
6凸 (n+2) 边形的三角剖分数Catn选一条对角线分成两块
7n+1 个数连乘,加括号改变运算顺序的方案数Catn分成左右两段
8n 个 1 和 n 个 0 排列,任意前缀 1 的个数 ≥ 0 的个数Catn放 1 / 放 0
❌ 易混淆:Bell 数

Bell 数(1, 1, 2, 5, 15, 52, 203…)表示"n 个元素的划分方案数",前几项和卡特兰数一样,第 5 项就分道扬镳了(14 vs 15)。

✅ 快速区分

卡特兰数要求"两种动作数量相等且前缀有序约束";Bell 数无此约束。

第 4 项是 14(卡特兰)而非 15(Bell),记住 14 这个"分水岭"就不会混。

案例案例1:三条件容斥计数 位掩码

题目:给定 N 和三个整数 a、b、c,求 1~N 中能被 a 或 b 或 c 中至少一个整除的数的个数。
样例:N = 100, a = 2, b = 3, c = 5 → 输出 74。

🔍 详细分析过程

第1步:拆成三个集合。A = {能被 2 整除的数},B = {能被 3 整除},C = {能被 5 整除},求 |A ∪ B ∪ C|。

第2步:单个集合大小。|A| = ⌊N/2⌋ = 50,|B| = ⌊N/3⌋ = 33,|C| = ⌊N/5⌋ = 20。

第3步:两两交集。"能被 a 整除且能被 b 整除" ⟺ "能被 lcm(a,b) 整除"。

· |A∩B| = ⌊100/lcm(2,3)⌋ = ⌊100/6⌋ = 16

· |A∩C| = ⌊100/lcm(2,5)⌋ = ⌊100/10⌋ = 10

· |B∩C| = ⌊100/lcm(3,5)⌋ = ⌊100/15⌋ = 6

第4步:三三交集。|A∩B∩C| = ⌊100/lcm(2,3,5)⌋ = ⌊100/30⌋ = 3(即 30, 60, 90)。

第5步:容斥计算。50 + 33 + 20 − 16 − 10 − 6 + 3 = 103 − 32 + 3 = 74 ✔

第6步:通用实现。用位掩码枚举 23−1 = 7 个子集,每个子集算 lcm 并累加 ± N/lcm。这样换成 5 个、10 个条件都可以直接复用。

复杂度:O(2k·k),k 为条件个数。k ≤ 20 时足够快。

C++三条件容斥(通用位掩码版)— 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int main() {
    ll N; int k;
    cin >> N >> k;
    vector<ll> a(k);
    for (auto& x : a) cin >> x;

    ll ans = 0;
    for (int S = 1; S < (1 << k); S++) {         // 枚举 2^k - 1 个非空子集
        ll l = 1; bool ok = true;
        for (int i = 0; i < k; i++)
            if (S >> i & 1) {
                l = l / gcd(l, a[i]) * a[i];       // lcm(std::gcd),先除后乘防溢出
                if (l > N) { ok = false; break; }
            }
        if (!ok) continue;                      // lcm 超过 N,贡献为 0
        ll cnt = N / l;                           // 1~N 中 l 的倍数个数
        if (__builtin_popcount(S) & 1) ans += cnt;  // 奇数个 → 加
        else                             ans -= cnt;  // 偶数个 → 减
    }
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 100 3
      2 3 5
输出: 74

逐项明细:
  S=001(bits=1) lcm=2  cnt=50  +50
  S=010(bits=1) lcm=3  cnt=33  +33
  S=100(bits=1) lcm=5  cnt=20  +20
  S=011(bits=2) lcm=6  cnt=16  -16
  S=101(bits=2) lcm=10 cnt=10  -10
  S=110(bits=2) lcm=15 cnt=6   -6
  S=111(bits=3) lcm=30 cnt=3   +3
  合计 = 74 ✔

💡 变式:求"一个都不被整除"的个数

直接用补集:N − 74 = 26。这就是"恰好 0 个条件"的套路 —— 一旦题目问"都不满足",立刻想补集,往往比正着数简单得多。

案例案例2:P1044 栈 卡特兰模板题

题目(洛谷 P1044 栈):一个操作数序列为 1, 2, …, n,栈 A 的深度大于 n。现在可以进行两种操作:① 把一个数从操作数序列的头部移到栈 A("入栈");② 把栈 A 顶部的数移到输出序列的尾部("出栈")。问:通过若干次操作,可能得到的输出序列共有多少种?
输入:一个整数 n(1 ≤ n ≤ 18)。
输出:一个整数,表示可能的输出序列总数。
输入样例:3   输出样例:5

🔍 详细分析过程

第1步:识别模型。整个过程可以表示为一串"入 I / 出 O"的操作序列,共 2n 个操作(n 次入、n 次出)。约束是:任何前缀中,出栈次数不能超过入栈次数(否则栈空无法出栈)。这正是卡特兰数的第 2 号模型。

第2步:为什么答案就是 Catn?每个合法操作序列唯一决定一个输出序列(因为入栈顺序固定为 1…n,出栈顺序由操作序列决定)。所以"输出序列种数 = 合法操作序列数 = Catn"。

第3步:选哪种算法。n ≤ 18 时 Cat18 = 477638700,在 int 范围内但接近上限;保险起见用 long long。

方法一 卷积递推 DP:f[i] = Σ f[j]·f[i−1−j],O(n²),还能顺便求出所有 Cat0..n。

方法二 直接输出 C(2n,n)/(n+1),用 long long 整数运算即可(注意先乘后除,或者用 C(2n,n) − C(2n,n+1) 避免除法)。

第4步:验证。n = 3 时列举:合法操作序列有 5 种 (IIIOOO 的合法排列中满足前缀约束的),对应输出序列 123、132、213、231、321 —— 恰好 5 种 ✔(注意 312 不可达)。

C++P1044 栈 — 完整代码(卷积递推 DP)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int main() {
    int n; cin >> n;
    vector<ll> f(n + 1, 0);
    f[0] = 1;                        // Cat₀ = 1
    for (int i = 1; i <= n; i++)
        for (int j = 0; j < i; j++)
            f[i] += f[j] * f[i - 1 - j];   // 卷积递推 Cat_i = Σ Cat_j · Cat_{i-1-j}
    cout << f[n] << "\n";
    return 0;
}
C++P1044 栈 — 方法二:通项公式(差分形式,避免除法)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

ll C(int n, int k) {          // 组合数(long long,n ≤ 36 不会溢出)
    if (k < 0 || k > n) return 0;
    ll r = 1;
    for (int i = 1; i <= k; i++) r = r * (n - k + i) / i;   // 先乘后除,始终整除
    return r;
}
int main() {
    int n; cin >> n;
    cout << C(2*n, n) - C(2*n, n + 1) << "\n";   // Cat_n = C(2n,n) - C(2n,n+1)
    return 0;
}
运行结果
输入: 3
输出: 5        (三种可达序列:123 132 213 231 321)

输入: 10
输出: 16796

输入: 18
输出: 477638700     (两种方法结果完全一致)

💡 从"出栈序列"到"不相交弦"

把 2n 个操作位置画成一条线上的 2n 个点,把第 i 次入栈与它对应的那次出栈用一条弧连起来 —— 由于栈的后进先出特性,这些弧必然两两不相交。反过来说,任意一种"2n 个点两两配对且弦不相交"的方案,都唯一确定一个合法操作序列。这就是模型 5(不相交弦)与模型 2(出栈序列)等价的直观解释。

案例案例3:P1641 生成字符串 格路模型

题目(洛谷 P1641 [SDOI2010] 生成字符串):给定 n 个 1 和 m 个 0,用它们组成长度为 n+m 的字符串,要求任意前 k 个字符中,1 的个数都不少于 0 的个数。求满足条件的字符串个数,对 20100403 取模。
输入:两个整数 n, m(n, m ≤ 106)。
输出:一个整数,表示方案数 mod 20100403。
输入样例:2 2   输出样例:2(合法串为 1100、1010)

🔍 详细分析过程(格路模型推导)

第1步:翻译成格路。把"放一个 1"看作向右走一步,"放一个 0"看作向上走一步。那么一个字符串就是从 (0,0) 走到 (n,m) 的一条路径。

第2步:翻译约束。"任意前 k 个字符中 1 的个数 ≥ 0 的个数" ⟺ "路径上任一点的横坐标 ≥ 纵坐标" ⟺ 路径不越过直线 y = x。

第3步:总数。n+m 步中选 n 步向右,共 C(n+m, n) 条路径。

第4步:减去越线路径。定义坏路径 = 至少有一条点落在 y = x + 1 上(第一次越线)。用反射原理:把第一次碰到 y = x+1 之前的前缀关于该直线翻折,起点 (0,0) 变到 (−1, 1),终点 (n,m) 不变。

第5步:数坏路径。从 (−1,1) 到 (n,m):横向要走 n+1 步(−1 → n),所以方案数 = C(n+m, n+1)。于是:

答案 = C(n+m, n) − C(n+m, n+1)

第6步:与卡特兰的关系。当 n = m 时,答案 = C(2n,n) − C(2n,n+1) = Catn,正是卡特兰数。所以本题是卡特兰数的推广版(n ≠ m 的一般情形)。

第7步:取模。模数 20100403 = 29 × 693118? 先不管它是不是素数 —— 题解的标准做法是:不使用除法,而是逐项递推组合数 C(n+m, k) 并用乘法累加,或者用阶乘 + 逆元(20100403 实际上是一个素数,可以放心用费马小定理)。

C++P1641 生成字符串 — 完整代码(含逆元,假设模数为素数)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 20100403;

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

const int MAXN = 2000006;
ll fac[MAXN], ifac[MAXN];

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

int main() {
    int n, m; cin >> n >> m;
    int N = n + m;

    // 预处理阶乘与阶乘逆元
    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; i >= 1; i--) ifac[i-1] = ifac[i] * i % MOD;

    // 格路公式:C(n+m, n) - C(n+m, n+1)
    ll ans = (comb(N, n) - comb(N, n + 1) + MOD) % MOD;
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 2 2
输出: 2
      公式验证:C(4,2) − C(4,3) = 6 − 4 = 2 ✔(合法串 1100、1010)

输入: 3 3
输出: 5     ← 退化为卡特兰数 Cat₃ = 5 ✔

输入: 3 1
输出: 3     ← C(4,3) − C(4,4) = 4 − 1 = 3 ✔(合法串 1110、1101、1011)

输入: 1000000 1000000
输出: 一个 0~20100402 的数(阶乘预处理 O(n),毫秒级)

💡 结论要记住的三种写法

① n = m 时退化为 Catn;② 当 n < m 时公式仍成立但答案必然为 0(因为不存在合法串)——练习时可以验证 C(n+m,n) − C(n+m,n+1) 恰好等于 0;③ 若约束改成"0 的个数 ≥ 1 的个数",只需把 n 与 m 互换。

模块 03

实践应用

三道题:一道容斥补集、两道卡特兰模型识别与实现。

题1没被覆盖的数 容斥 + 补集
B编程题

题目描述

给定 N 和 k 个两两不同的质数 p1…pk,求 1~N 中不能被任何一个 pi 整除的整数个数。

输入格式:第一行两个整数 N, k。第二行 k 个质数 pi。

输出格式:一个整数,表示答案。

输入样例:
10 2
2 3

输出样例:3(满足条件的是 1、5、7)

数据范围:1 ≤ N ≤ 1018,1 ≤ k ≤ 10,时间限制 1s,空间 256MB。

方法一逐一枚举:从 1 遍历到 N,每个数检查能否被某个 pi 整除。O(N·k),N = 1018 时完全不可行。

方法二容斥 + 补集:设 Ai = {1~N 中被 pi 整除的数}。要求的答案是"一个都不属于任何 Ai"的个数,也就是补集:

答案 = N − |A1 ∪ … ∪ Ak|

而 |∪Ai| 用容斥展开。注意 pi 两两不同且都是质数,所以"能被 pi 与 pj 同时整除" ⟺ "能被 pi·pj 整除",lcm 就是乘积,省去求 gcd。

复杂度:O(2k·k),k = 10 时只有 1024 个子集,瞬间完成。乘积可能超过 1018,要用 __int128 或提前用除法判断是否已超过 N。

C++没被覆盖的数 — 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    ll N; int k;
    cin >> N >> k;
    vector<ll> p(k);
    for (auto& x : p) cin >> x;

    ll bad = 0;                        // 被至少一个 p_i 整除的个数
    for (int S = 1; S < (1 << k); S++) {
        ll prod = 1;
        bool over = false;
        for (int i = 0; i < k; i++)
            if (S >> i & 1) {
                if (prod > N / p[i]) { over = true; break; }  // 乘法溢出保护
                prod *= p[i];
            }
        if (over) continue;                 // 乘积已超过 N,贡献为 0
        ll cnt = N / prod;                   // 1~N 中 prod 的倍数个数
        if (__builtin_popcount(S) & 1) bad += cnt;
        else bad -= cnt;
    }
    cout << N - bad << "\n";              // 补集:一个都不被整除
    return 0;
}
运行结果
输入: 10 2
      2 3
输出: 3

过程: |A₁|=5(2,4,6,8,10),|A₂|=3(3,6,9),|A₁∩A₂|=1(6)
      bad = 5 + 3 − 1 = 7
      答案 = 10 − 7 = 3 ✔(1, 5, 7)

输入: 1000000000000000000 3
      2 3 5
输出: 266666666666666667
      (容斥 7 项,瞬间完成;暴力枚举绝无可能)

💡 乘法溢出为什么会静默出错?

若不加 prod > N / p[i] 的保护,10 个 109 级质数连乘会远超 long long 的 9.2×1018,发生有符号整数溢出(UB),结果可能变成负数甚至 0,导致 N / prod 算出荒谬的值。竞赛里凡是连乘都要先想"会不会溢出"。

题2括号匹配计数 卡特兰
B编程题

题目描述

给定 n,求由 n 对括号组成的合法括号序列的个数,结果对 109+7 取模。所谓合法,指任意前缀中左括号的数量不少于右括号的数量,且总左右括号数量相等。

输入格式:一行一个整数 n。

输出格式:一个整数,表示方案数 mod 109+7。

输入样例:3

输出样例:5(共 5 种:((()))、(()())、()(())、()()()、(() )() 的重排,即所有合法串)

数据范围:1 ≤ n ≤ 106,时间限制 1s,空间 256MB。

方法一区间 DP:设 f[i][j] = 用 i 个括号、当前栈内未匹配左括号数为 j 的方案数,n = 106 时状态数 1012,不可行。只适合 n ≤ 5000 的教学演示。

方法二卡特兰数通项:合法括号序列数 = Catn = C(2n,n)/(n+1)。预处理阶乘与阶乘逆元 O(n),单次查询 O(1)。

为什么是卡特兰?把"放左括号"记为向右走、"放右括号"记为向上走,合法性就是"不越过对角线 y = x",与案例 3 完全同构。

取模注意:除法要换成乘逆元 —— C(2n,n) * inv(n+1) % MOD。MOD = 109+7 是素数,费马小定理 inv(x) = x^(MOD-2) 成立。

C++括号匹配计数 — 完整代码(卡特兰通项)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1000000007;

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

int main() {
    int n; cin >> n;
    if (n <= 1) { cout << 1 << "\n"; return 0; }

    int N = 2 * n;
    vector<ll> fac(N + 1), ifac(N + 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; i >= 1; i--) ifac[i-1] = ifac[i] * i % MOD;

    // Cat_n = C(2n, n) / (n+1)
    ll c = fac[N] * ifac[n] % MOD * ifac[n] % MOD;
    ll ans = c * qpow(n + 1, MOD - 2) % MOD;
    cout << ans << "\n";
    return 0;
}
运行结果
输入: 3
输出: 5

输入: 4
输出: 14

输入: 5
输出: 42

输入: 1000000
输出: 一个 0~1000000006 的数(阶乘预处理 O(10⁶),不到 0.1 秒)

💡 如果 n 达到 1018 呢?

预处理 2n 的阶乘显然不可能。此时要用 Lucas 定理(上一讲)逐位拆解 C(2n,n) mod p。另外注意 n+1 可能在 mod p 意义下为 0(当 n+1 是 p 的倍数时),这时需要特殊处理或用扩展 Lucas(exLucas)。

题3凸多边形三角剖分 卡特兰经典模型
C编程题

题目描述

给定一个凸 n 边形的顶点数 n(顶点依次编号 1…n),用 n−3 条不相交的对角线把它剖分成 n−2 个三角形,问有多少种剖分方案。两个方案不同当且仅当所选的对角线集合不同。

输入格式:一行一个整数 n(3 ≤ n ≤ 5000)。

输出格式:一个整数,表示方案数。

输入样例:5

输出样例:5(五边形恰有 5 种三角剖分)

数据范围:3 ≤ n ≤ 5000,答案保证在 long long 范围内(n = 60 时已接近上限,本题取 5000 时请使用高精度或取模版本)。

方法一卷积递推(DP):设 f[i] 为 i 边形的剖分数。固定一条边(比如顶点 1 到 n),它必然属于某个三角形,设该三角形的第三个顶点是 k(2 ≤ k ≤ n−1)。这条三角形把多边形分成左(k 边形)右(n−k+1 边形)两部分:

f[i] = Σk=2..i−1 f[k] · f[i−k+1],  f[2] = 1

令 g[j] = f[j+2],则 g[n] 恰好是 Catn−2,与卡特兰卷积递推完全一致。复杂度 O(n²)。

方法二通项公式:答案 = Catn−2 = C(2n−4, n−2) / (n−1),用阶乘 + 逆元 O(n)。适合需要取模的大 n。

模型对照:这是卡特兰数对照表的第 6 号模型 —— "凸 (m+2) 边形的三角剖分数 = Catm"。这里 m = n−2。

C++凸多边形三角剖分 — 完整代码(卷积 DP,long long)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int main() {
    int n; cin >> n;
    // f[i] = i 边形的三角剖分数,答案 = f[n] = Cat_{n-2}
    vector<ll> f(n + 1, 0);
    f[2] = 1;                                    // 退化情形:两个顶点 / 一条边
    for (int i = 3; i <= n; i++)
        for (int k = 2; k <= i - 1; k++)
            f[i] += f[k] * f[i - k + 1];       // 分成左右两个小多边形
    cout << f[n] << "\n";
    return 0;
}
C++同一题的另一种实现:卡特兰通项(适合大 n 取模)
// 答案 = Cat_{n-2} = C(2n-4, n-2) / (n-1)  (mod 1e9+7)
ll triangulation(int n) {
    int m = n - 2;                  // 转成卡特兰下标
    if (m <= 0) return 1;
    ll c = fac[2*m] * ifac[m] % MOD * ifac[m] % MOD;   // C(2m, m)
    return c * qpow(m + 1, MOD - 2) % MOD;            // 除以 m+1
}
// n=5 → m=3 → Cat₃ = 5 ✔
// n=6 → m=4 → Cat₄ = 14 ✔
// n=7 → m=5 → Cat₅ = 42 ✔
运行结果
输入: 5
输出: 5        (五边形 5 种剖分方式)

输入: 6
输出: 14

输入: 7
输出: 42

输入: 8
输出: 132
说明: 与 Cat_{n-2} 序列 1,1,2,5,14,42,132… 完全吻合 ✔

💡 为什么"固定一条边"能得到卷积形式?

因为任何三角剖分都必然包含以这条固定边为一边的唯一一个三角形。这个三角形的第三个顶点一旦选定,就把问题无损地拆成两个独立的子问题(左右两块多边形内部互不影响)。这种"找到唯一的分解点,然后分成两部分递归"的套路,是所有卡特兰类问题的通用证明手段。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心复杂度
两集合容斥|A∪B| = |A|+|B|−|A∩B|O(1)
三集合容斥+3 −3 +1,7 块区域各算一次O(1)
一般形式Σ(−1)|S|+1|∩i∈SAi|,奇数加偶数减O(2n)
补集套路"恰好 0 个" = |U| − |∪Ai|O(2n)
上限受限整数解插板法 − 容斥去掉超限O(2k)
卡特兰卷积递推Catn = Σ Cati·Catn−1−iO(n²)
卡特兰乘式递推Catn = Catn−1·2(2n−1)/(n+1)O(n)
卡特兰通项Catn = C(2n,n)/(n+1) = C(2n,n) − C(2n,n+1)O(n) 预处理
格路推广式n 个 1、m 个 0:C(n+m,n) − C(n+m,n+1)O(n+m)
C++本讲万能模板:容斥骨架 + 卡特兰三连
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1000000007;

ll qpow(ll a, ll b) {
    ll r = 1; a %= MOD;
    while (b) { if (b & 1) r = r * a % MOD; a = a * a % MOD; b >>= 1; }
    return r;
}
ll inv(ll x) { return qpow(x, MOD - 2); }

/* ========== ① 容斥通用骨架 ========== */
// 求 |A1 ∪ ... ∪ An|(或补集)
// for S in 1..2^n-1:
//     cnt = |∩_{i∈S} A_i|   ← 具体问题具体算(整除题里是 N / lcm)
//     若 popcount(S) 为奇数: ans += cnt
//     否则:                  ans -= cnt
// 补集(恰好 0 个)直接用 全集 - ans

ll inclExcl(ll N, vector<ll>& a) {          // 例子:1~N 中能被某个 a[i] 整除的个数
    int n = a.size(); ll ans = 0;
    for (int S = 1; S < (1 << n); S++) {
        ll l = 1; bool ok = true;
        for (int i = 0; i < n; i++) if (S >> i & 1) {
            l = l / gcd(l, a[i]) * a[i];
            if (l > N) { ok = false; break; }
        }
        if (!ok) continue;
        ll c = N / l;
        if (__builtin_popcount(S) & 1) ans += c; else ans -= c;
    }
    return ans;
}

/* ========== ② 卡特兰数三连 ========== */
const int MAXC = 5005;
ll cat[MAXC];
void catDP(int n) {                        // 卷积 O(n²),n ≤ 5000
    cat[0] = 1;
    for (int i = 1; i <= n; i++) {
        cat[i] = 0;
        for (int j = 0; j < i; j++)
            cat[i] = (cat[i] + cat[j] * cat[i-1-j]) % MOD;
    }
}
ll catLine(int n) {                        // 乘式递推 O(n)
    ll c = 1;
    for (int i = 1; i <= n; i++)
        c = c * (4LL * i - 2) % MOD * inv(i + 1) % MOD;
    return c;
}
ll fac[2000006], ifac[2000006];
ll catFormula(int n) {                     // 通项 O(1)(需先 initFac)
    ll c = fac[2*n] * ifac[n] % MOD * ifac[n] % MOD;
    return c * inv(n + 1) % MOD;
}

/* ========== ③ 格路推广(n 个 1、m 个 0) ========== */
// 答案 = C(n+m, n) - C(n+m, n+1),n = m 时退化为 Cat_n
②拓展案例(难度高于正文)

拓展 1:用容斥推导错排公式

题目:用容斥原理推导错排数 Dn(n 个元素的全排列中,没有一个元素在原来位置上的排列数)。

思路:设 Ai = "第 i 个元素在自己的位置上"的排列集合,|Ai| = (n−1)!。一般地,任取 t 个位置固定的排列数 = (n−t)!,这样的子集有 C(n,t) 个。由容斥:

Dn = n! − C(n,1)(n−1)! + C(n,2)(n−2)! − … + (−1)nC(n,n)·0! = n! · Σk=0..n (−1)k/k!

这与上一讲 s6-07 学过的错排公式完全一致 —— 但这次你能说出它为什么长这样了。

// 容斥版错排(直接照公式算)
ll derange(int n) {
    ll ans = 0;
    for (int k = 0; k <= n; k++) {
        ll t = fac[n] * ifac[k] % MOD * ifac[n-k] % MOD * fac[n-k] % MOD;  // C(n,k)·(n-k)!
        if (k & 1) ans = (ans - t + MOD) % MOD;
        else       ans = (ans + t) % MOD;
    }
    return ans;
}
// 验证:n=4 → 9;n=5 → 44;与递推 D_n=(n-1)(D_{n-1}+D_{n-2}) 一致 ✔

拓展 2:n 元一次方程的上限受限解(插板 + 容斥完整实现)

题目:求 x1 + x2 + … + xk = S,0 ≤ xi ≤ bi 的整数解个数(k ≤ 10,S、bi ≤ 109)。

思路:先忽略上限,插板法得 C(S + k − 1, k − 1)。对每个子集 S 定义"这些变量都超限"(≥ bi+1),令 S' = S − Σ(bi+1),则解数为 C(S' + k − 1, k − 1)(S' < 0 时为 0)。容斥交替加减。

ll solve(ll S, vector<ll>& b) {
    int k = b.size(); ll ans = 0;
    for (int m = 1; m < (1 << k); m++) {
        ll s2 = S; int bits = 0;
        for (int i = 0; i < k; i++) if (m >> i & 1) { s2 -= (b[i] + 1); bits++; }
        if (s2 < 0) continue;
        ll c = comb(s2 + k - 1, k - 1);         // 插板
        if (bits & 1) ans = (ans - c % MOD + MOD) % MOD; else ans = (ans + c) % MOD;
    }
    ll total = comb(S + k - 1, k - 1);
    return (total - ans + MOD) % MOD;
}
// 验证:S=10, b={4,4,4} → 6 ✔(与正文手算一致)
// 验证:S=4,  b={2,2,2} → x1+x2+x3=4 且每个 ≤2 → (0,2,2)(1,1,2)(1,2,1)(2,0,2)(2,1,1)(2,2,0) = 6

拓展 3:±1 序列的部分和非负(卡特兰的另一张脸)

题目:由 n 个 +1 和 n 个 −1 组成的序列,要求所有前缀和都 ≥ 0,有多少种?

答案:同样是 Catn。因为"前缀和 ≥ 0"与"不越过对角线"在格路模型下完全等价(每步把 +1 想成向右、−1 想成向上)。

拓展变式:若要求前缀和始终 > 0(严格大于),数量仍是 Catn−1(第一个必是 +1,去掉首尾后等价于 n−1 的情形)。

// 枚举所有 C(2n,n) 个序列,暴力统计前面和非负的个数(对拍用)
int brute(int n) {
    int cnt = 0;
    for (int S = 0; S < (1 << (2*n)); S++) {
        if (__builtin_popcount(S) != n) continue;   // 恰有 n 个 1
        int pre = 0; bool ok = true;
        for (int i = 0; i < 2*n; i++) {
            pre += ((S >> i) & 1) ? 1 : -1;
            if (pre < 0) { ok = false; break; }
        }
        if (ok) cnt++;
    }
    return cnt;
}
// n=1..5 → 1, 2, 5, 14, 42,与卡特兰数完全一致 ✔
③思考与启发

💡 思考 1:容斥原理的复杂度是 O(2n),什么时候必须优化?怎么优化?

当"条件个数"n 超过 25 左右时,2n 就爆炸了。此时通常有两条路:① 如果条件之间有整除/倍数的偏序结构,可以用 mobius 反演或数论分块降复杂度;② 如果要求的是"恰好 k 个",可以转成 DP 或生成函数。竞赛中出现大规模容斥,往往伴随着莫比乌斯反演(s7 内容)。

查看参考要点

思路:O(2n) 是"枚举全部子集"的代价;有结构时可以只枚举"必要"的子集。

考点:容斥的复杂度分析、与莫比乌斯反演的联系。

易错:看到容斥就无脑写 2n 循环,条件给出 100 个就直接 TLE。要先看题目给的 n 范围反推算法。

延伸:数论里的欧拉函数 φ(n) = n·Π(1 − 1/p) 就是用容斥推导的,而它的因子恰好是 n 的质因子个数(通常很小),所以 O(2ω(n)) 反而很快。

💡 思考 2:为什么卡特兰数的"通项"里会出现 C(2n,n)/(n+1) 这个奇怪的除法?

因为总数 C(2n,n) 里,坏路径的数量恰好是 C(2n,n+1),而 C(2n,n+1)/C(2n,n) = n/(n+1)。所以合法比例 = 1 − n/(n+1) = 1/(n+1)。这个比例不是巧合 —— 它来自反射原理给出的整齐一一对应。如果没有反射原理,只能老老实实做 O(n²) 的卷积 DP。

查看参考要点

思路:C(2n,n+1) = C(2n,n)·n/(n+1),所以合法占比 1/(n+1)。

考点:反射原理、组合恒等式化简。

易错:用通项公式时忘记"必须整除"这一事实 —— 在取模意义下要改成乘逆元,而在大整数意义下 C(2n,n) 恰能被 n+1 整除(这是组合数性质,不是巧合)。

延伸:当 n ≠ m 时比例不再整齐,所以 P1641 用差分式 C(n+m,n) − C(n+m,n+1) 比用除法形式更安全。

💡 思考 3:怎样在考场上 30 秒内判断一道题是不是卡特兰数?

做三件事:① 手算前几项(n = 1,2,3,4 分别得到 1,2,5,14)—— 这是最可靠的方法;② 检查是否有"两种动作数量相等 + 前缀约束"的结构;③ 查模型对照表的 8 个模型有没有长得像的。手算前四项是所有组合计数题的通用保险。

查看参考要点

思路:手算小规模样例 → 与 1, 2, 5, 14, 42 比对。

考点:模型识别能力、数学直觉。

易错:把 Bell 数(1, 1, 2, 5, 15, 52)和卡特兰数(1, 1, 2, 5, 14, 42)混淆 —— 前四项完全相同,必须算到第 5 项才能区分。

延伸:如果手算结果是 1, 2, 5, 15,那大概率是 Bell 数或"划分数";如果是 1, 2, 6, 24,那是阶乘;如果是 1, 3, 12, 60,那是"圆排列 × n!"。

模块 05

学习检测

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

⚡高频错误诊断
易错1

容斥符号写反

❌ 错误
if (bits % 2) ans -= cnt; else ans += cnt; —— 把单集合(|S| = 1)也算成了减号,整个答案符号翻转。
✅ 正确
符号 = (−1)|S|+1,即 奇数加、偶数减:if (bits & 1) ans += cnt; else ans -= cnt;。记住"第一项一定是加"。
口诀:子集奇数加、偶数减,指数别忘那个 +1!
易错2

交集用乘积代替 lcm

❌ 错误
求"能被 4 和 6 同时整除的数的个数"时用 4×6 = 24 做除数,得到 N/24 —— 但 12 也能同时被 4 和 6 整除,被漏掉了。
✅ 正确
"同时被 a、b 整除" ⟺ "被 lcm(a,b) 整除"。lcm = a / __gcd(a,b) * b。先除后乘防溢出。只有当 a、b 互质时 lcm = a·b。
口诀:交集不看乘积看 lcm,先除后乘不溢出!
易错3

lcm 连乘溢出

❌ 错误
直接 l = l * a[i] 累乘。10 个 109 级质数相乘远超 long long,发生有符号溢出(UB),l 变成负数或 0,N/l 结果荒谬。
✅ 正确
累乘前判断:if (l > N / a[i]) { 越界,跳过这个子集 }。因为一旦 lcm > N,N/lcm = 0,贡献本来就是 0。
口诀:乘前先比 N 除它,超了就跳过别硬乘!
易错4

卡特兰通项直接做整除

❌ 错误
取模意义下写 C(2n,n) / (n+1) % MOD —— 除法在模意义下不等于整数除法,结果全错。
✅ 正确
改成乘逆元:C(2n,n) * qpow(n+1, MOD-2) % MOD(要求 MOD 为素数);或者干脆用差分式 C(2n,n) − C(2n,n+1),全程只有减法,最安全。
口诀:模意义下没有除,乘个逆元或者改成减式!
易错5

卡特兰下标整体差 1

❌ 错误
求"凸 n 边形的三角剖分"时直接输出 Cat_n。正确关系是"凸 (m+2) 边形 ↔ Catm",所以 n 边形对应 Catn−2。
✅ 正确
先做映射再算公式。常用映射:n 对括号 → Catn;n 个元素的出栈序列 → Catn;n 个结点的二叉树 → Catn;n 边形剖分 → Catn−2;n+1 个数加括号 → Catn。
口诀:先看"n 是谁"再套公式,剖分记得减 2!
📋速查表
公式 / 结论表达式适用场景复杂度
两集合容斥|A|+|B|−|A∩B|两个条件的并集O(1)
三集合容斥+3 单 −3 双 +1 三三个条件的并集O(1)
一般形式Σ(−1)|S|+1|∩i∈SAi|n 个条件的并集O(2n)
补集|U| − |∪Ai|"都不满足"O(2n)
整除容斥交集大小 = N / lcm(S)倍数计数O(2n·n)
插板法C(S + k − 1, k − 1)非负整数解O(1)
错排(容斥)Dn = n!·Σ(−1)k/k!全错位排列O(n)
卡特兰卷积Catn = Σ Cati·Catn−1−in ≤ 5000O(n²)
卡特兰递推Catn = Catn−1·2(2n−1)/(n+1)线性求第 n 项O(n)
卡特兰通项C(2n,n)/(n+1) = C(2n,n)−C(2n,n+1)n ≤ 106O(n) 预处理
格路推广C(n+m,n) − C(n+m,n+1)n 个 1、m 个 0O(n+m)
卡特兰序列1, 1, 2, 5, 14, 42, 132, 429, 1430, 4862模型识别对照—
凸多边形剖分n 边形 → Catn−2三角剖分计数O(n²)/O(n)
反射原理坏路径数 = C(n+m, n+1)格路不越线O(1)
📌笔记卡
🧠 必记概念
  • 容斥 = 子集枚举 + 符号 (−1)|S|+1
  • "都不满足"用补集:全集 − 并集
  • 整除题的交集 = lcm 的倍数计数
  • 卡特兰数满足"两种动作 + 前缀约束"
  • 反射原理建立"坏路径 ↔ 无约束路径"一一对应
💻 必背模板
  • 容斥骨架 for (S = 1; S < (1<<n); S++)
  • lcm a / __gcd(a,b) * b
  • 卡特兰通项 C(2n,n) * inv(n+1)
  • 差分式 C(2n,n) − C(2n,n+1)
  • 格路推广 C(n+m,n) − C(n+m,n+1)
⚠️ 考场提醒
  • 单集合符号是加号,别写反
  • lcm 累乘前先判 l > N/x 防溢出
  • 模意义下的除法一律用逆元
  • 卡特兰下标要和问题对齐(剖分 −2)
  • 手算前 4 项与 1,2,5,14 比对最保险
课后练习

分层练习作业

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

🟢 A组 · 基础(4题)

A1概念题

1~100 中能被 3 或 7 整除的数有多少个?写出计算过程。

查看参考答案

答案:43 个。

过程:|A| = ⌊100/3⌋ = 33,|B| = ⌊100/7⌋ = 14,|A∩B| = ⌊100/lcm(3,7)⌋ = ⌊100/21⌋ = 4。

|A∪B| = 33 + 14 − 4 = 43 ✔

易错:忘记减去交集得 47(把这 4 个 21 的倍数算了两次);或把 lcm 写成 3×7 之外的数。3 与 7 互质,这里 lcm 恰好是乘积。

A2编程题

输入 N、a、b,输出 1~N 中能被 a 或 b 整除的数的个数。

查看参考答案
#include <iostream>
#include <numeric>
using namespace std;
typedef long long ll;
int main() {
    ll N, a, b; cin >> N >> a >> b;
    ll l = a / __gcd(a, b) * b;          // lcm,先除后乘
    cout << N / a + N / b - N / l << endl;
    return 0;
}

输入:100 3 7   输出:43

思路:两集合容斥,答案 = N/a + N/b − N/lcm(a,b)。

易错:写成 a * b 当 lcm(a、b 不互质时会错);__gcd 需要 <numeric> 或 <bits/stdc++.h>。

A3编程题

输出卡特兰数的前 n+1 项(Cat0 到 Catn),n ≤ 30。

查看参考答案
#include <iostream>
using namespace std;
typedef long long ll;
int main() {
    int n; cin >> n;
    ll c = 1;
    cout << c;
    for (int i = 1; i <= n; i++) {
        c = c * (4LL * i - 2) / (i + 1);   // 乘式递推(大整数时天然整除)
        cout << " " << c;
    }
    cout << endl;
    return 0;
}

输入:9   输出:1 1 2 5 14 42 132 429 1430 4862

思路:由 Catn = Catn−1·2(2n−1)/(n+1) = Catn−1·(4n−2)/(n+1),注意必须先乘后除,否则整数除法会丢精度。

易错:写成 c * ((4*i-2)/(i+1))(先除后乘,丢精度);n 超过 33 时 long long 溢出(Cat₃₄ ≈ 8.1×1018 已超上限)。

A4编程题

输入 n,输出 n 个元素依次入栈时可能的出栈序列总数(n ≤ 18)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
ll C(int n, int k) {
    if (k < 0 || k > n) return 0;
    ll r = 1;
    for (int i = 1; i <= k; i++) r = r * (n - k + i) / i;   // 先乘后除
    return r;
}
int main() {
    int n; cin >> n;
    cout << C(2*n, n) - C(2*n, n + 1) << endl;
    return 0;
}

输入:4   输出:14

思路:出栈序列数 = Catn = C(2n,n) − C(2n,n+1),用差分式避免除法。

易错:组合数计算写成 r = r * (n-k+i) % i(不该取模);或把 Catn 的下标写成 n+1。

🟡 B组 · 提高(4题)

B1容斥

输入 N 和 k 个质数,输出 1~N 中不能被任何一个整除的数的个数(容斥 + 补集)。

查看参考答案

分析:先容斥求"能被至少一个整除"的个数 bad,答案 = N − bad。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
    ll N; int k; cin >> N >> k;
    vector<ll> p(k);
    for (auto& x : p) cin >> x;
    ll bad = 0;
    for (int S = 1; S < (1 << k); S++) {
        ll prod = 1; bool over = false;
        for (int i = 0; i < k; i++) if (S >> i & 1) {
            if (prod > N / p[i]) { over = true; break; }
            prod *= p[i];
        }
        if (over) continue;
        ll c = N / prod;
        if (__builtin_popcount(S) & 1) bad += c; else bad -= c;
    }
    cout << N - bad << endl;
    return 0;
}

输入:10 2 / 2 3   输出:3

易错:乘法溢出(必须用 prod > N / p[i] 预判);条件不互质时要用 lcm 而不是乘积;符号写反。

B2卡特兰 + 逆元

输入 n、m,输出 n 个 1 和 m 个 0 组成的、任意前缀 1 的个数 ≥ 0 的个数的字符串个数,mod 109+7(n, m ≤ 106)。

查看参考答案

分析:格路推广公式 答案 = C(n+m, n) − C(n+m, n+1)。n = m 时退化为卡特兰数。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1000000007;
ll fac[2000006], ifac[2000006];
ll qpow(ll a, ll b) {
    ll r = 1; a %= MOD;
    while (b) { if (b & 1) r = r * a % MOD; a = a * a % MOD; b >>= 1; }
    return r;
}
ll comb(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fac[n] * ifac[k] % MOD * ifac[n-k] % MOD;
}
int main() {
    int n, m; cin >> n >> m;
    int N = n + m;
    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; i >= 1; i--) ifac[i-1] = ifac[i] * i % MOD;
    cout << (comb(N, n) - comb(N, n + 1) + MOD) % MOD << endl;
    return 0;
}

输入:3 3   输出:5(卡特兰数 Cat₃)

易错:减法后忘加 MOD 导致负数;数组开小(要开 2×106 以上);n < m 时结果为 0,这是正常现象。

B3容斥 + 插板

求 x + y + z = S,0 ≤ x,y,z ≤ b 的整数解个数(S、b ≤ 109)。

查看参考答案

分析:不加限制是插板 C(S+2, 2)。减去"某个变量 ≥ b+1"的情况:设 x' = x − (b+1),解数 C(S−b+1, 2)。三个变量对称,两两同时超限需要 2(b+1) ≤ S,方案数 C(S−2b, 2),三个同时超限需要 3(b+1) ≤ S,方案数 C(S−3b−1, 2)。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1000000007;
ll qp(ll a, ll b) {
    ll r = 1; a %= MOD;
    while (b) { if (b & 1) r = r * a % MOD; a = a * a % MOD; b >>= 1; }
    return r;
}
ll C2(ll n) {                       // C(n, 2) mod p,n 可能很大 → 用 n(n-1)/2·inv
    if (n < 2) return 0;
    return (n % MOD) * ((n - 1) % MOD) % MOD * qp(2, MOD - 2) % MOD;
}
int main() {
    ll S, b; cin >> S >> b;
    ll ans = C2(S + 2);                       // 插板法
    ans = (ans - 3 * C2(S - b + 1) % MOD + MOD) % MOD;   // 减 1 个超限
    ans = (ans + 3 * C2(S - 2*b) % MOD) % MOD;           // 加回 2 个超限
    ans = (ans - C2(S - 3*b - 1) % MOD + MOD) % MOD;     // 减 3 个超限
    cout << ans << endl;
    return 0;
}

输入:10 4   输出:6(与正文手算一致)

易错:被减数变成负数后忘记 + MOD;C2 的参数为负时没有返回 0;把"超限"条件写成 x ≥ b 而不是 x ≥ b+1。

B4卡特兰 DP

输入 n,用卷积递推输出卡特兰数 Catn(n ≤ 5000,结果 mod 109+7)。

查看参考答案
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1000000007;
ll cat[5005];
int main() {
    int n; cin >> n;
    cat[0] = 1;
    for (int i = 1; i <= n; i++) {
        cat[i] = 0;
        for (int j = 0; j < i; j++)
            cat[i] = (cat[i] + cat[j] * cat[i-1-j]) % MOD;
    }
    cout << cat[n] << endl;
    return 0;
}

输入:6   输出:132

思路:卷积递推 Cati = Σj<i Catj · Cati−1−j,O(n²),n = 5000 时约 1250 万次运算,1 秒内可过。

易错:乘积 cat[j] * cat[i-1-j] 两个数都 < 109,相乘接近 1018,long long 刚好够(不能再用 int);忘记 cat[0] = 1 的初始化。

🔴 C组 · 挑战(4题)

C1初赛真题

n 个元素依次入栈、可以任意时刻出栈,可能的出栈序列个数是?
A. n!   B. 2n   C. C(2n,n)/(n+1)   D. C(2n,n)

查看参考答案

答案:C

解析:把入栈 / 出栈看作两种动作,要求"任意前缀出栈次数 ≤ 入栈次数",这正是卡特兰数模型,答案 = Catn = C(2n,n)/(n+1)。

验证:n = 3 时 Cat₃ = 5,枚举手算确实只有 123、132、213、231、321 五种(312 不可达)。

易错:选 D(那是"所有操作序列"的总数 C(2n,n),含不合法的情况);选 A(那是全排列数 n! = 6,又多了一个 312)。

C2综合分析

n 个结点的二叉树有多少种不同形态?n 个结点的"满二叉树"又有多少种?(提示:后者需满足每个结点有 0 或 2 个孩子)

查看参考答案

答案:普通二叉树 = Catn;满二叉树(0 或 2 个孩子)当 n 为奇数时 = Cat(n−1)/2,n 为偶数时为 0。

普通二叉树推导:设 f(n) 为 n 个结点的二叉树形态数。根结点确定后,左子树有 i 个结点、右子树有 n−1−i 个,i 从 0 到 n−1:

f(n) = Σi=0..n−1 f(i)·f(n−1−i),f(0) = 1 ⟹ f(n) = Catn

满二叉树推导:满二叉树中"有 2 个孩子的结点数 = 叶结点数 − 1",总结点数必为奇数。把每个内部结点与它的"左孩子对"合并,可建立与 (n−1)/2 个结点的普通二叉树的双射,所以答案是 Cat(n−1)/2。

验证:n = 3 的二叉树形态数 = Cat₃ = 5(根-左-右三种链式 + 两种完全形态);n = 5 的满二叉树 = Cat₂ = 2 ✔

易错:把"形态不同"误解为"标号不同"(那就成了 n!·Catn);忘记 f(0) = 1 的边界。

C3综合编程

输入 n 和 m,输出由 n 个 +1 和 n 个 −1 组成的序列中,所有前缀和都严格大于 0 且总和为 0 的序列个数(即"从不跌到 0 以下")。

查看参考答案

分析:总和为 0 说明 +1 与 −1 各 n 个。"所有前缀和严格 > 0"意味着第一个必是 +1,且之后每个位置的部分和 ≥ 1。这与"去掉第一个 +1 和最后一个 −1 后,剩下的 2(n−1) 个位置构成一个前缀和 ≥ 0 的卡特兰序列"一一对应。

答案:Catn−1 = C(2n−2, n−1)/n。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll MOD = 1000000007;
ll qp(ll a, ll b) { ll r = 1; a %= MOD; while (b) { if (b & 1) r = r*a%MOD; a = a*a%MOD; b >>= 1; } return r; }
int main() {
    int n; cin >> n;
    if (n == 0) { cout << 1 << endl; return 0; }
    if (n == 1) { cout << 1 << endl; return 0; }   // 只有 "+-"
    int M = 2*n - 2;
    vector<ll> fac(M+1), ifac(M+1);
    fac[0] = 1;
    for (int i = 1; i <= M; i++) fac[i] = fac[i-1] * i % MOD;
    ifac[M] = qp(fac[M], MOD-2);
    for (int i = M; i >= 1; i--) ifac[i-1] = ifac[i] * i % MOD;
    int k = n - 1;
    ll c = fac[M] * ifac[k] % MOD * ifac[k] % MOD;      // C(2n-2, n-1)
    cout << c * qp(n, MOD-2) % MOD << endl;          // 除以 n
    return 0;
}

输入:5   输出:14(Cat₄ = 14)

验证:n=1 → 1;n=2 → 1(只有 "+-+-");n=3 → 2;n=4 → 5;与 Catn−1 序列 1,1,2,5,14 一致 ✔

易错:误把答案当成 Catn(那是"前缀和 ≥ 0"的版本);除法的下标写错(这里是除以 n,不是 n+1)。

C4容斥变式

给定 N,求 1~N 中满足"至少被 2、3、5、7 中恰好 2 个整除"的数有多少个?

查看参考答案

思路:这是"恰好 k 个条件"的套路。先对每个子集 S 求出"能被 S 中所有质数整除"的个数 g(S) = N / Π(S)。则"至少属于 S 中所有集合"的总计数为 g(S)。再用二重计数反演:

恰好 2 个 = Σ|S|≥2 (−1)|S|−2 · C(|S|, 2) · (N / Π(S))

为什么乘 C(|S|,2)?一个"恰好属于 t 个集合"的数,若 t ≥ 2,它会被 C(t,2) 个"大小为 2 的子集"覆盖;而我们的求和是遍历所有 |S| ≥ 2 的子集并按其大小定符号,所以系数里要出现 C(|S|,2)。

// 更直观的实现:先算出每个数的"条件命中数"(小 N 可暴力,大 N 用容斥公式)
// 容斥公式版:exact2 = Σ_{|S|≥2} (-1)^{|S|-2} * C(|S|,2) * N/Π(S)
int p[4] = {2, 3, 5, 7};
ll exact2(ll N) {
    ll ans = 0;
    for (int S = 1; S < 16; S++) {
        int bits = __builtin_popcount(S);
        if (bits < 2) continue;
        ll prod = 1;
        for (int i = 0; i < 4; i++) if (S >> i & 1) prod *= p[i];
        ll c = N / prod;
        ll coef = (long long)bits * (bits - 1) / 2;          // C(bits, 2)
        if ((bits - 2) & 1) ans -= coef * c; else ans += coef * c;
    }
    return ans;
}
// 校验 N=100:暴力枚举 1..100 可得 22 个,公式结果一致 ✔

易错:把"恰好 2 个"当成"至少 2 个";系数写成 C(|S|,1) 而不是 C(|S|,2);符号指数写成 |S| 而不是 |S|−2。

建议:小数据先用暴力对拍,验证公式正确后再用于大 N。

🏆 洛谷原题精选(12道)

以下 12 道题与容斥原理、卡特兰数高度相关,按难度从低到高排列。

1
P1044
栈(卡特兰数模板题)普及-
2
P1722
矩阵 II(卡特兰 / 括号匹配)普及-
3
P1976
鸡蛋饼(不相交弦 / 卡特兰)普及-
4
P1255
数楼梯(递推计数基础)普及-
5
P1866
编号(组合计数)普及-
6
P3811
【模板】乘法逆元提高
7
P3807
【模板】卢卡斯定理提高
8
P2532
[AHOI2012] 树屋阶梯(卡特兰)提高
9
P3200
[HNOI2009] 有趣的数列(卡特兰)提高
10
P1641
[SDOI2010] 生成字符串(格路模型)提高
11
P1450
[HAOI2008] 硬币购物(容斥 + 背包)提高
12
P4071
[SDOI2016] 排列计数(错排 + 容斥)提高