组合数学深度实战:从生成函数、容斥原理与卡特兰数到斯特林数、Burnside 引理与组合计数的完整工程链路
组合数学是离散世界的"计数工程学"——它给"有多少种可能"一个精确答案,而不是一个模糊的直觉。从算法复杂度分析中的递推、概率论中的样本空间、密码学中的密钥组合,到现代深度学习里的排列不变性(permutation invariance)与组合优化推理,组合数学是第一性原理。本文沿用"深度实战"路线:每个核心概念都配一段可运行、且已实跑验证的 Python 代码,并贯穿一条主线——组合结构 ↔ 现代 AI/ML 的可计算映射。
一、第一性原理:计数的公理与双射
组合计数的两块基石是加法原理与乘法原理:
- 加法原理:若任务 A 有
m种完成方式、任务 B 有n种,且二者互斥,则"做 A 或做 B"共有m+n种方式。 - 乘法原理:若任务需先后完成 A(m 种)与 B(n 种),则共有
m×n种方式。
更高阶的思维是双射(bijection):要数集合 X 的元素个数,不一定要直接数,而是构造一个从 X 到已知大小的集合 Y 的一一对应,从而 |X| = |Y|。例如:含 n 个元素的集合有 2ⁿ 个子集——因为每个元素"在/不在"子集里是独立的 2 种选择,由乘法原理即得 2ⁿ。双射证明的力量在于它不依赖枚举,只依赖结构的等价性,这是所有组合恒等式背后的统一逻辑。
二、生成函数:把整个序列塞进一个形式幂级数
生成函数把离散序列 {a₀, a₁, a₂, …} 编码成一个形式幂级数 A(x) = Σ aₙ xⁿ。
- 普通生成函数(OGF)
A(x) = Σ aₙ xⁿ,适合"无标号、组合"结构; - 指数生成函数(EGF)
Â(x) = Σ aₙ xⁿ/n!,适合"标号、排列"结构。
生成函数最迷人的性质是:两个 OGF 的乘积,其系数正是两个序列的卷积 cₙ = Σₖ aₖ bₙ₋ₖ。这意味着"先选一个大小为 k 的对象、再选一个大小为 n−k 的对象"的组合数,恰好是乘积级数的第 n 项系数。下面用凑零钱(硬币 {1,2,5})演示:每种硬币对应一个几何级数 1/(1−xᶜ),它们的乘积 OGF 的 xⁿ 系数就是凑出 n 分的方案数。
import numpy as np
def ways_dp(coins, n):
"""朴素 DP:组合数(顺序无关)。"""
dp = [0] * (n + 1); dp[0] = 1
for c in coins:
for s in range(c, n + 1):
dp[s] += dp[s - c]
return dp[n]
def ogf_ways(coins, n, cap=500):
"""用 OGF 卷积做多项式乘法:每种硬币 1/(1-x^c) 截断成全 1 序列再卷积。"""
poly = np.zeros(cap + 1); poly[0] = 1.0
for c in coins:
g = np.zeros(cap + 1)
for k in range(0, cap + 1, c):
g[k] = 1.0 # 1 + x^c + x^{2c} + ... 截断
poly = np.convolve(poly, g)
return int(round(poly[n]))
for n in [10, 20, 50]:
print(f"n={n}: dp={ways_dp([1,2,5], n)} ogf={ogf_ways([1,2,5], n)}")
# n=10: dp=10 ogf=10
# n=20: dp=29 ogf=29
# n=50: dp=146 ogf=146
两种算法在 n=10/20/50 上完全一致(10 / 29 / 146),印证了"递推"与"生成函数卷积"的同构关系。注意 numpy.convolve 是 OGF 乘积的工程化实现——卷积即组合在此被一行代码坐实。
三、容斥原理:从"减去重叠"到错排与满射
容斥原理解决"多个集合并集大小"的问题:
|A₁ ∪ … ∪ Aₙ| = Σ|Aᵢ| − Σ|Aᵢ∩Aⱼ| + Σ|Aᵢ∩Aⱼ∩Aₖ| − … + (−1)ⁿ⁺¹|A₁∩…∩Aₙ|
其补集形式在计数"都不满足"的对象时极有用。两个经典应用:
错排(derangement) D(n):n 个元素全都不在原位的排列数。
D(n) = n! · Σₖ₌₀ⁿ (−1)ᵏ/k! = round(n!/e)
满射(onto function) 数:从 n 元集到 k 元集的满射个数 = k! · S(n,k),也等于容斥展开 Σᵢ₌₀ᵏ (−1)ⁱ C(k,i)(k−i)ⁿ。
import math
def derange(n):
return int(round(sum((-1)**k * math.factorial(n) / math.factorial(k) for k in range(n + 1))))
def onto(n, k):
return int(round(sum((-1)**i * math.comb(k, i) * (k - i)**n for i in range(k + 1))))
for n in [3, 4, 5, 6]:
print(f"D({n})={derange(n)}", end=" ")
print()
print("onto(4,3)=", onto(4, 3), " (= 3!·S(4,3) = 6·6 = 36)")
print("onto(5,3)=", onto(5, 3))
print("onto(6,4)=", onto(6, 4))
# D(3)=2 D(4)=9 D(5)=44 D(6)=265
# onto(4,3)= 36 onto(5,3)= 150 onto(6,4)= 1560
D(4)=9(4 封信装错信封有 9 种)、D(5)=44,与教科书一致;onto(4,3)=36 = 3!·S(4,3) 同时验证了容斥与斯特林数的勾连(下一节)。容斥的本质是用符号交替抵消重复计数——这是几乎所有"排除约束"型组合问题的统一武器。
四、卡特兰数:最优雅的递归结构
卡特兰数 Cₙ 出现在大量看似无关的结构中:合法括号序列、Dyck 路径、二叉树、凸多边形三角剖分。其递推
Cₙ = Σᵢ₌₀ⁿ⁻¹ Cᵢ · Cₙ₋₁₋ᵢ,闭式 Cₙ = (1/(n+1))·C(2n, n)。
直觉:Cₙ 计数"长度为 2n 的合法括号串"——任意前缀中左括号数 ≥ 右括号数,这恰是栈/递归调用的不变量。
def catalan(n):
C = [0] * (n + 1); C[0] = 1
for i in range(1, n + 1):
for j in range(i):
C[i] += C[j] * C[i - 1 - j]
return C
def enumerate_parens(n):
res = []
def rec(s, o, cl):
if len(s) == 2 * n:
res.append(s); return
if o < n: rec(s + "(", o + 1, cl)
if cl < o: rec(s + ")", o, cl + 1)
rec("", 0, 0)
return res
C = catalan(6)
print("C[0..6] =", C) # [1, 1, 2, 5, 14, 42, 132]
print("C(5) =", C[5], " enum(5) =", len(enumerate_parens(5)),
" closed =", math.comb(10, 5) // 6) # 42 42 42
print("C(6) =", C[6], " closed =", math.comb(12, 6) // 7) # 132 132
C(5)=42 同时被三种方式核对:DP、穷举枚举(恰好 42 个合法括号串)、闭式 C(10,5)/6。这种多算法交叉验证是实战中避免 off-by-one 的标准做法。卡特兰数与"栈可表示的序列"等价,直接对应编译器/解释器的语法分析。
五、斯特林数与贝尔数:集合的划分宇宙
第二类斯特林数 S(n,k) 把 n 个标号元素划分成 k 个非空无标号集合的方式数,递推:
S(n,k) = k·S(n−1,k) + S(n−1,k−1),边界 S(0,0)=1。
含义:新元素要么并入已有 k 个集合之一(k 种选择),要么独自开一个新集合。
贝尔数 B(n) = Σₖ S(n,k) 是 n 个元素的所有划分总数,可用贝尔三角形递推:
B(n) = B(n−1, n−1),B(i,j) = B(i−1,j−1) + B(i,j−1)。
def stirling2(n, k):
S = [[0] * (k + 1) for _ in range(n + 1)]
S[0][0] = 1
for i in range(1, n + 1):
for j in range(1, min(i, k) + 1):
S[i][j] = j * S[i - 1][j] + S[i - 1][j - 1]
return S[n][k]
def bell(n):
b = [[0] * (n + 1) for _ in range(n + 1)]
b[0][0] = 1
for i in range(1, n + 1):
b[i][0] = b[i - 1][i - 1]
for j in range(1, i + 1):
b[i][j] = b[i - 1][j - 1] + b[i][j - 1]
return b[n][0]
for n in range(1, 7):
s = stirling2(n, 3) if n >= 3 else "-"
print(f"S({n},3)={s} B({n})={bell(n)}")
# S(3,3)=1 B(3)=5
# S(4,3)=6 B(4)=15
# S(5,3)=25 B(5)=52
# S(6,3)=90 B(6)=203
S(5,3)=25、B(5)=52 正是标准值。斯特林/贝尔数在 ML 里对应聚类簇数与层次结构的划分数——给定 n 个样本,可能的硬聚类方案总数就是 B(n),这是理解"无监督结构搜索空间"的钥匙。
六、Burnside 引理与 Pólya 计数:在对称下数轨道
当对象带有对称性(旋转、翻转),朴素计数会严重重复。设有限群 G 作用在集合 X 上,轨道(orbit) 是对称等价类的集合,不动点 Fix(g) = {x∈X : g·x = x}。
Burnside 引理:轨道数 |X/G| = (1/|G|)·Σ_{g∈G} |Fix(g)|。
直觉:把所有群元素下的不动点加起来再平均,恰好等于等价类的个数。例如用 c 种颜色给 n 颗珠子串成项链(只考虑旋转对称,群 Cₙ),旋转 r 步的循环分解为 gcd(n,r) 个等长循环,每个循环必须同色,故 |Fix(r)| = c^{gcd(n,r)}。
import math
def burnside_necklace(n, c):
total = 0
for r in range(n): # 旋转群 C_n,旋转 r 步
total += c ** math.gcd(n, r) # 不动点 = c^(循环数)
return total // n
print("n=6,c=2 ->", burnside_necklace(6, 2)) # 14 (hand: (64+2+4+8+4+2)/6 = 14)
print("n=4,c=2 ->", burnside_necklace(4, 2)) # 6 (hand: (16+2+4+2)/4 = 6)
print("n=6,c=3 ->", burnside_necklace(6, 3)) # 130 (hand: (729+3+9+27+9+3)/6 = 130)
print("n=5,c=3 ->", burnside_necklace(5, 3)) # 51
(6,2)→14、(4,2)→6、(6,3)→130 全部与手算吻合。Pólya 计数定理是 Burnside 的强化版:把"颜色加权"引入,直接给出图案的生成函数,是化学同分异构体计数、图论非同构图计数的基础。对称下的计数思维,正是 Transformer 里"排列等变/不变层"的理论前身。
七、组合数学 ↔ 现代 AI/ML 映射表
组合不是古典玩具,它是现代机器学习可计算结构的底层语法:
| 组合概念 | AI/ML 对应 | 工程含义 |
|---|---|---|
| 排列/对称群 | 排列不变性、DeepSets、注意力 | 集合输入须对顺序免疫(见下代码) |
| 组合优化(TSP/子集选择) | 推理、搜索、离散决策 | LLM 的 beam search、工具调用规划本质是组合搜索 |
| 容斥 / 并集界 | 概率推断、集合覆盖、模型集成误差 | 集成模型失误率上界 = 并集界 |
| 生成函数 | 矩母函数 / 特征函数 / EGF | 分布的结构由生成函数唯一决定 |
| 斯特林 / 贝尔数 | 聚类簇数、层次结构搜索空间 | 无监督结构的可能方案总数 |
| 卡特兰 / 栈不变量 | 语法分析、自回归解码约束 | 解码必须满足括号/栈不变量才合法 |
| 组合计数 | 假设空间大小、过拟合风险 | 参数组合数决定所需样本量 |
下面用代码坐实第一条——排列不变性:sum-pooling 的 DeepSets 层对输入顺序完全不变,而朴素 MLP 因把向量展平而泄漏了顺序。
import numpy as np
def deepsets(X):
"""集合编码器:先对所有元素 sum-pool,再做非线性。对置换不变。"""
return np.tanh(X.sum(axis=0))
def naive_mlp(X):
"""朴素 MLP:把集合展平成定长向量,顺序直接决定输出。"""
v = X.reshape(-1)
return np.tanh(v[:4] @ np.array([0.3, 0.5, -0.2, 0.4]))
np.random.seed(0)
X = np.random.randn(3, 2)
perm = np.random.permutation(3)
print("deepsets 置换不变:", np.allclose(deepsets(X), deepsets(X[perm]))) # True
print("naive_mlp 顺序敏感:", not np.allclose(naive_mlp(X), naive_mlp(X[perm]))) # True
print("deepsets(X) =", np.round(deepsets(X), 4))
print("deepsets(X[perm]) =", np.round(deepsets(X[perm]), 4))
输出显示 deepsets 在置换前后完全一致,而 naive_mlp 不一致——这正是 Transformer 注意力机制"对 token 顺序本不敏感、必须靠位置编码注入顺序信息"的组合学根源。
八、工程边界与陷阱
- 阶乘爆炸:
n!在 n>170 即溢出双精度。实战改用对数:log n! = Σ log k,或math.lgamma。斯特林近似n! ≈ √(2πn)(n/e)ⁿ适合只取量级的场合。 - 计数 ≠ 采样:精确计数在 n 大时不可行(状态空间指数爆炸)。此时转向 MCMC / 重要性采样——组合计数提供的是"理论上界",采样提供"实践中近似"。
- 对称性必须显式建模:漏掉一个对称群(如只去旋转没去翻转)会系统性重复计数。Burnside/Pólya 的价值正在于把对称性"算进分母"。
- 数值溢出的对数技巧:容斥里
kⁿ、生成函数乘积都易溢出,统一在对数域做加减(logsumexp)。 - 组合计数的过拟合信号:模型假设空间大小由参数组合数决定,组合数学给出的上界可直接换算为所需最小训练样本量(VC 维思想的离散版)。
结语
组合数学给了我们一套"精确数数"的语言:生成函数把递推变成代数、容斥把约束变成符号交替、卡特兰把递归结构变成闭式、斯特林/贝尔把划分变成三角形、Burnside 把对称变成群平均。当这些古典工具遇上深度学习,它们并没有过时——排列不变性、组合优化推理、集合编码、假设空间估计,全都是组合数学在现代 AI 里的投影。理解组合结构,就是理解"可能性的空间"本身。

发表评论 取消回复