{* YBB Header v4.0 — Alpine.js + 自定义 CSS *}

组合数学深度实战:从生成函数、容斥原理与卡特兰数到斯特林数、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 顺序本不敏感、必须靠位置编码注入顺序信息"的组合学根源。

八、工程边界与陷阱

  1. 阶乘爆炸:n! 在 n>170 即溢出双精度。实战改用对数:log n! = Σ log k,或 math.lgamma。斯特林近似 n! ≈ √(2πn)(n/e)ⁿ 适合只取量级的场合。
  2. 计数 ≠ 采样:精确计数在 n 大时不可行(状态空间指数爆炸)。此时转向 MCMC / 重要性采样——组合计数提供的是"理论上界",采样提供"实践中近似"。
  3. 对称性必须显式建模:漏掉一个对称群(如只去旋转没去翻转)会系统性重复计数。Burnside/Pólya 的价值正在于把对称性"算进分母"。
  4. 数值溢出的对数技巧:容斥里 kⁿ、生成函数乘积都易溢出,统一在对数域做加减(logsumexp)。
  5. 组合计数的过拟合信号:模型假设空间大小由参数组合数决定,组合数学给出的上界可直接换算为所需最小训练样本量(VC 维思想的离散版)。

结语

组合数学给了我们一套"精确数数"的语言:生成函数把递推变成代数、容斥把约束变成符号交替、卡特兰把递归结构变成闭式、斯特林/贝尔把划分变成三角形、Burnside 把对称变成群平均。当这些古典工具遇上深度学习,它们并没有过时——排列不变性、组合优化推理、集合编码、假设空间估计,全都是组合数学在现代 AI 里的投影。理解组合结构,就是理解"可能性的空间"本身。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部