1. 杨氏矩阵与杨表:不只是数学的艺术品
我第一次听说杨氏矩阵和杨表,是在准备一场算法竞赛的时候。当时被一道关于最长上升子序列期望值的题目卡住了,常规的动态规划方法在数据规模面前完全失效。直到一位前辈指点说:“试试用杨表的知识,用Hook公式去枚举所有形状。” 我当时的反应和很多刚接触的朋友一样:这是什么听起来高大上的东西?跟算法有什么关系?但当我真正静下心来研究,才发现这简直是解决一类组合计数问题的“核武器”。它不像普通的动态规划那样直接,却能在看似无解的问题上,开辟出一条优雅的路径。
简单来说,你可以把杨氏矩阵(也叫杨图)想象成一种用方格堆叠起来的“图形化整数拆分”。比如,数字5可以拆成3+1+1,或者2+2+1。每一种拆分方式,都可以用一个左对齐的方格图来表示,其中每一行的方格数对应拆分中的一个加数。这种图就是杨图。而杨表,则是给这个杨图的每个格子填上数字,并且要满足一定的单调性规则。最常见的是标准杨表,要求填入1到n的所有数字,并且每行从左到右严格递增,每列从上到下严格递增。这就像是在一个不规则的棋盘上,按照严格递增的规则摆放一套编号的棋子。
那么,这个东西为什么对算法选手,尤其是处理排列、子序列问题的我们如此重要呢?核心秘密在于一个惊人的对应关系:一个1到n的排列,唯一地对应着一对形状相同的标准杨表。更妙的是,这对杨表中的第一个表(插入表P)的第一行长度,恰好就等于原排列的最长上升子序列(LIS)的长度!这个性质直接把一个动态的序列问题,转化为了一个静态的、可以枚举的图形计数问题。对于求解LIS长度的分布、期望,或者一些相关的组合数量,这个视角的转换往往是突破的关键。在接下来的内容里,我会带你从最直观的理解开始,一步步拆解杨表的构造、核心算法和那些威力巨大的公式,并用实战题目让你感受它如何化繁为简。
2. 深入核心:杨表的构造与RSK算法
理解了杨表是什么之后,最关键的问题来了:怎么从一个具体的排列,得到它对应的那个杨表呢?这个过程依赖于一个精巧的算法——Robinson-Schensted-Knuth算法,通常简称为RSK算法。这个算法是连接排列与杨表的桥梁,也是整个理论得以应用的基础。
2.1 插入操作:像整理扑克牌一样构建杨表
RSK算法的核心是“插入”操作。我更喜欢把它比喻成整理一手杂乱无章的扑克牌。假设我们有一个已经部分构建好的杨表(可能一开始是空的),现在要把一个新数字x“插入”到这个杨表中,规则如下:
- 从第一行开始:在当前行(最初是第一行)中,寻找第一个(即最小的)大于x的数字。你可以把每一行想象成一条已经排好序的队列。
- 找到则“踢走”并下沉:如果找到了这个数字y,我们就把y“踢”出当前位置,用x来占据y原来的位置。而被踢走的y,则需要带着“委屈”去下一行,尝试在那里找到自己的新位置(即重复步骤1,但以y作为新的待插入数字)。
- 找不到则安家:如果在当前行找不到比x大的数字(说明x比这一行所有的数都大),那么恭喜x,它可以直接站到这一行的末尾,插入过程结束。
这个插入过程一定会终止,并且终止的位置一定位于杨表的“边角”。边角指的是一个格子,它的右边和下边都没有格子了,就像一块不规则拼图最外缘的凸起。这个性质很重要。
让我用一个具体的例子来演示。假设我们已经有一个杨表,现在要插入数字6。
已有杨表(部分):
行1: 1 3 5
行2: 2 4
行3: 7
插入6的过程:
- 第一行:寻找第一个大于6的数。1,3,5都小于6,找不到。所以6应该放在第一行末尾吗?不,我们继续看,第一行末尾之后?不对,规则是找不到大于它的数时才放末尾。这里5<6,后面没数了,所以第一行找不到大于6的数。因此,6放在第一行末尾?等等,这里有个关键点,第一行有5,小于6,但我们要找的是“第一个大于x的数”。5小于6,所以不是。检查7?7在第三行。所以第一行里所有数(1,3,5)都小于6,因此在第一行找不到大于6的数。所以,根据规则3,6应该放在第一行末尾。
但让我们更严谨地走一遍算法:从第一行开始,找第一个大于6的数。遍历1,不大于;3,不大于;5,不大于。结束,没找到。因此,将6放在第一行末尾。插入结束。新的第一行变成
1 3 5 6。注意,此时(1,4)(第1行第4列)是一个边角吗?它的右边没有格子,下边(第二行只有2列)也没有格子,所以是边角。符合算法描述。
这个例子展示了最直接的情况。再看一个“踢走”的例子:向同一个表(插入6之前的原始表)插入数字4。
- 第一行:找第一个大于4的数。是5。于是,4把5踢走,4坐在5的位置上(第一行变成
1 3 4),被踢走的5要去第二行找位置。 - 第二行(带着数字5):找第一个大于5的数。第二行是
2 4,2和4都小于5,找不到大于5的数。所以5放在第二行末尾(第二行变成2 4 5)。插入结束。新位置(2,3)是边角。
通过这样逐个插入排列中的数字,我们就能构建出与排列对应的那个杨表P。这个过程是确定性的,一个排列只对应一个杨表P。
2.2 记录表Q与双射:完整的对应关系
只有插入表P还不够。RSK算法的精妙之处在于,它在构建P的同时,还会同步构建另一个形状完全相同的杨表,称为记录表Q。记录表Q记录的是“时间戳”或“插入顺序”。
规则很简单:每当一个数字在插入过程中,最终被放置在一个新的边角位置时(即插入操作终止时),我们就在记录表Q的相同位置,填入当前数字在原排列中的下标(序号)。
继续上面的例子,假设我们按顺序插入排列 [3, 1, 4, 2] 来构建标准杨表。
- 插入3:插入空表,3放在(1,1)。这是新边角,所以在Q的(1,1)位置记录序号1。
P:
[[3]], Q:[[1]] - 插入1:从第一行找大于1的数,找到3。1踢走3,1坐(1,1)。被踢走的3去第二行(新建),放在(2,1)。插入终止于(2,1),是新边角。在P中,1在(1,1),3在(2,1)。在Q中,当插入终止于(2,1)时,记录当前序号2。注意,Q的(1,1)已经被1占据了吗?不,Q记录的是终止位置。这里有两个终止位置?让我们仔细想:这次插入,数字1最终占据了(1,1),但(1,1)在插入前是空位吗?不,插入前(1,1)是3。根据算法,1把3从(1,1)踢走,自己坐进去。那么(1,1)是这次插入的终止位置吗?不是,因为1踢走3后,3还要继续插入。这次插入真正的终止,是3被放到(2,1)的时候。所以,只在最终放置新数字(导致插入过程结束)的那个边角位置,记录序号。因此,只在Q的(2,1)记录序号2。
此时 P:
[[1], [3]], Q:[[?], [2]]。Q的(1,1)是什么?它应该记录第一次插入3时终止的位置(1,1)的序号1。所以 Q:[[1], [2]]。 - 插入4:从第一行找大于4的数,第一行只有1,小于4,找不到。所以4放在第一行末尾(1,2)。是新边角,在Q的(1,2)记录序号3。
P:
[[1, 4], [3]], Q:[[1, 3], [2]] - 插入2:从第一行找大于2的数,找到4。2踢走4,坐(1,2)。被踢走的4去第二行找位置。第二行是3,找大于4的数,找不到,所以4放在第二行末尾(2,2)。是新边角,在Q的(2,2)记录序号4。
最终 P:
[[1, 2], [3, 4]], Q:[[1, 3], [2, 4]]
这样,我们就得到了一对形状相同的标准杨表(P, Q)。Robinson-Schensted对应指出,1到n的每一个排列,与所有形状相同的标准杨表对(P, Q)之间存在一一对应(双射)。这个定理是组合数学中的一个经典结果,它意味着我们可以通过枚举杨表对来研究排列的性质,反之亦然。
3. 威力强大的工具:Hook公式与计数
当我们通过RSK算法把排列问题转化为杨表形状的问题后,下一个挑战就是:对于一个给定的杨图形状(即一个整数拆分λ),有多少种不同的方式能填出一个标准杨表?换句话说,这个形状对应了多少个排列?Hook公式(勾长公式)就是回答这个问题的神兵利器。
3.1 什么是勾长?
勾长公式的名字很形象。杨图中每一个方格,都有一个“钩子”。这个钩子包括:
- 该方格本身。
- 该方格右边的所有方格(同一行)。
- 该方格下边的所有方格(同一列)。
钩子所覆盖的方格总数,就是这个方格的勾长。例如,在一个形状为(3,2,1)的杨图中(即第一行3格,第二行2格,第三行1格),左上角(1,1)的方格,它的右边有2格,下边有2格,加上自己,勾长就是1+2+2=5。
计算勾长是机械的。你需要做的是,对于给定形状的杨图,计算出每一个格子的勾长。
3.2 Hook公式的内容与应用
Hook公式告诉我们:对于一个有n个方格的杨图(对应整数n的一个拆分λ),其标准杨表的填法数目为:
f^λ = n! / (所有方格勾长的乘积)
是的,公式就这么简洁!n! 是n个数字的所有排列总数。分母是所有格子勾长的乘积。这个公式将复杂的组合计数,简化成了简单的乘除法。
为什么这个公式如此有用?在算法竞赛中,我们经常需要计算满足某种条件的排列个数。如果这种条件能对应到杨表的形状上(比如LIS长度等于杨表第一行长度),那么问题就变成了:
- 枚举所有可能的杨图形状(即n的所有整数拆分)。
- 对每个形状λ,用Hook公式计算
f^λ,即能填出该形状标准杨表的排列数(也就是产生该形状P表的排列数)。 - 由于双射,每一对(P,Q)对应一个排列,而形状λ的标准杨表P的个数是
f^λ,标准杨表Q的个数也是f^λ,所以对应这个形状的排列数就是(f^λ)^2吗?这里需要小心。RSK双射是一个排列对应一对(P,Q)。对于一个固定的形状λ,有多少对(P,Q)呢?有f^λ个可能的P,和f^λ个可能的Q,所以总共有f^λ * f^λ = (f^λ)^2对(P,Q)。根据双射,这正好就对应了(f^λ)^2个排列。但是,所有形状的排列数总和应该等于n!。实际上,有一个著名的等式:Σ_λ (f^λ)^2 = n!,其中求和遍历n的所有整数拆分λ。这从侧面验证了双射的正确性。
在实际解题时,我们通常关心的是具有特定性质的排列数,例如LIS长度 = k的排列有多少个。根据RSK对应,这等价于第一行长度恰好为k的标准杨表P有多少个。而第一行长度为k的杨图形状,就是那些拆分λ满足 λ₁ = k 的形状。对于每个这样的形状λ,能填出的标准杨表P的个数就是 f^λ。那么,对应的排列数是不是 f^λ * f^λ 呢?不完全是。因为一个排列对应一对(P,Q)。当我们说“LIS长度=k的排列数”时,我们是在数排列。对于某个固定形状λ(第一行长度=k),能产生这种P的排列数,正是所有Q与这个P配对的数目,也就是 f^λ(因为Q可以是任何同形状的标准杨表)。所以,对于这个特定的λ,对应的排列数就是 f^λ(个P表)乘以 f^λ(个Q表)再除以……等等,这里逻辑有点绕。让我们重新梳理:
- 目标:计算LIS长度 = k的排列数。
- 根据RSK:排列π -> (P, Q),且
LIS(π) = P的第一行长度。 - 所以,
LIS(π)=k当且仅当P的第一行长度 = k。 - 现在,固定一个形状λ,使得λ₁ = k。有多少个排列π,其对应的P表形状是λ?根据双射,这样的排列π与所有形状为λ的(P, Q)对一一对应。
- 形状为λ的P表有多少个?Hook公式给出
f^λ。 - 形状为λ的Q表有多少个?同样是
f^λ。 - 因此,形状为λ的(P, Q)对一共有
f^λ * f^λ个。 - 这
f^λ * f^λ个对,就对应了f^λ * f^λ个排列,这些排列的P表形状都是λ,因而LIS长度都是k。
所以,对于所有满足λ₁=k的形状λ,把它们对应的 (f^λ)^2 加起来,就得到了LIS长度 = k的总排列数。
有了这个框架,求LIS长度的期望值就变成了一个可计算的问题:期望 = Σ (k * LIS长度为k的排列数) / n!。而分子部分,就是 Σ [k * Σ_{λ: λ₁=k} (f^λ)^2]。Hook公式让计算 f^λ 成为可能,从而让整个问题从动态规划的死胡同里跳了出来,变成了一个优雅的组合枚举问题。
4. 实战解析:BJWC2018最长上升子序列期望
理论讲得再多,不如真刀真枪解决一个问题。我们来看一道经典题目:[BJWC2018]最长上升子序列。题意很简单:求长度为n(n<=28)的随机排列,其最长上升子序列(LIS)长度的期望值。答案对998244353取模。
如果直接用动态规划求每个排列的LIS然后平均,复杂度是O(n! * n log n),完全不可行。即使使用状压DP记录状态,在n=28时状态数也过于庞大。这时候,杨表和Hook公式的组合拳就派上用场了。
4.1 解题思路拆解
我们的核心思路,就是利用前面阐述的理论:
- 排列与杨表双射:每个排列对应一对形状相同的标准杨表(P, Q),且LIS长度等于P表第一行长度。
- 枚举形状而非排列:我们不枚举排列,而是枚举n的所有整数拆分(即杨图形状)。n=28时,拆分数p(28)大概在2000量级,这是完全可以暴力枚举的。
- Hook公式计数:对于每个枚举出的形状λ(一个整数拆分列表,如[λ₁, λ₂, ...]),我们用Hook公式计算出
f^λ,即这个形状的标准杨表填法数。 - 计算贡献:对于形状λ,其第一行长度
len = λ₁。所有P表形状为λ的排列有多少个?根据双射,是(f^λ)^2个。每一个这样的排列,其LIS长度就是len。所以,形状λ对所有排列LIS长度总和的贡献是len * (f^λ)^2。 - 期望计算:遍历所有形状λ,将贡献累加,得到所有排列的LIS长度总和
total_sum。排列总数为n!。期望值E = total_sum / n!。
因此,问题转化为:如何高效枚举所有整数拆分,并对每个拆分快速计算Hook公式的值 f^λ。
4.2 代码实现与关键细节
下面结合代码片段,讲解实现中的关键点。代码使用DFS来枚举整数拆分。
#include<bits/stdc++.h>
using namespace std;
const int N=30, mod=998244353;
int n, inv[N], a[N], ans;
// a数组存储当前的整数拆分,a[1]...a[y-1]是已确定的部分,a[i]表示第i行的格子数
// x 表示剩余待分配的数字总和
// y 表示当前准备决定第几行的长度
void dfs(int x, int y) {
if(!x) { // 剩余为0,一个完整的拆分产生了
// 计算当前拆分形状λ对应的 f^λ,根据Hook公式: f^λ = n! / Π(每个格子的勾长)
// 我们先计算分母,即所有格子勾长的乘积
int res = 1;
// 先乘上 n!
for(int i=1; i<=n; ++i) res = 1LL * res * i % mod;
// 遍历每一行 i (从1到y-1)
for(int i=1; i<y; ++i) {
// 遍历该行每一列 j (从1到a[i])
for(int j=1; j<=a[i]; ++j) {
int hook = a[i] - j; // 同一行右边的格子数
// 加上同一列下面的格子数
for(int k=i+1; k<y; ++k) { // 遍历下面的行
if(a[k] >= j) hook++; // 如果下面第k行的长度>=j,说明第j列有格子
}
hook++; // 加上自己
// Hook公式分母是连乘,这里转化为乘逆元
res = 1LL * res * inv[hook] % mod;
}
}
// 此时res = f^λ
// 该形状对总和的贡献是 λ₁ * (f^λ)^2
int contribution = 1LL * res * res % mod * a[1] % mod; // a[1]就是λ₁
ans = (ans + contribution) % mod;
return;
}
// 枚举下一行的长度i,长度不能超过剩余数x,也不能超过上一行的长度(保证行长度非递增)
for(int i=1; i<=x; ++i) {
if(y != 1 && i > a[y-1]) continue; // 保证拆分是递减的,避免重复枚举
a[y] = i;
dfs(x-i, y+1);
}
}
int main() {
scanf("%d", &n);
// 预处理1到n的模逆元,用于Hook公式中的除法
inv[1] = 1;
for(int i=2; i<=n; ++i) {
inv[i] = 1LL * (mod - mod/i) * inv[mod%i] % mod;
}
ans = 0;
dfs(n, 1); // 初始:总和为n,从第1行开始
// 此时ans是所有形状的贡献总和 Σ [λ₁ * (f^λ)^2]
// 期望 E = ans / n!
for(int i=1; i<=n; ++i) {
ans = 1LL * ans * inv[i] % mod; // 连续乘上 1/i,即除以 n!
}
printf("%d\n", ans);
return 0;
}
几个需要特别注意的细节:
- 枚举去重:整数拆分
5=3+2和5=2+3在杨图中是同一个形状(第一行3格,第二行2格)。我们的DFS通过限制i <= a[y-1](即下一行长度不超过上一行)来保证生成的是非递增序列,从而自动去重。 - 勾长计算:代码中双重循环计算每个格子
(i, j)的勾长。a[i] - j是同行右边的格子数。内层循环for(int k=i+1; k<y; ++k)是计算同列下面的格子数,条件是下面行的长度a[k]要大于等于当前列号j。 - 模运算处理:Hook公式涉及除法,在模质数
mod=998244353下,我们通过预处理所有数字的逆元inv[i],将除法转换为乘法。 - 贡献计算:最终答案
ans在DFS结束后存储的是Σ [λ₁ * (f^λ)^2]。最后需要除以n!得到期望。因为n!是1*2*...*n的乘积,所以我们连续乘上inv[1], inv[2], ..., inv[n]来实现除法。
4.3 复杂度分析与优化点
这个算法的核心复杂度取决于整数拆分的数量 p(n)。对于 n=28,p(28) 大约是 2000 量级。对于每个拆分,我们需要计算其所有格子的勾长,最坏情况下格子数是 n,计算每个格子勾长需要遍历其下方的行,最坏是 O(n)。所以总复杂度大约是 O(p(n) * n^2),在 n=28 时完全可行。
如果 n 更大(比如达到50或100),拆分数会急剧增长(p(50)~20万,p(100)~1.9亿),暴力枚举所有拆分就不现实了。这时候往往需要结合动态规划或其他计数技巧,或者利用杨表性质的数学推导来直接求解,这通常就属于更深入的组合数学领域了。但对于算法竞赛中的此类问题,n 的范围通常设计得正好让这种优雅的暴力枚举方法能够通过,考察的正是选手对杨表这一工具的理解和运用能力。
通过这道题,我们可以深刻体会到,将序列的LIS问题转化为杨图的形状枚举问题,是如何将一个 O(n!) 级别的恐怖问题,降维到一个 p(n) 级别的可解问题的。这种思维上的跃迁,正是学习高级算法和组合数学的乐趣所在。
6万+



被折叠的 条评论
为什么被折叠?



