Harris
发布于 2026-03-22 / 33 阅读
0
0

最长上升子序列期望的两种巅峰解法:状压 DP 与杨表 RSK 对应

算法之美:最长上升子序列 (LIS) 期望的两种巅峰解法

timu

—— 洛谷 P4484 深度剖析 (状压 DP vs 杨表与 RSK 对应)

求一个随机排列的最长上升子序列 (Longest Increasing Subsequence, LIS) 长度的期望,是一道极具数学和算法双重美感的题目。

对于数据范围 N \leq 28(甚至拓展到更大),如果仅仅枚举全排列,时间复杂度 O(N!)N=15 都过不去。本文将带你从动态规划的视角和群表示论(杨表)的视角,两次打破认知上限。

视角一:书本思路——差分数组与状态压缩 DP

面对排列问题,我们通常习惯从左到右一个个填数字。但如果转换视角:我们按数字从小到大(1, 2, ..., N)的顺序,依次把数字插入到当前序列中。

1. 差分数组的玄机

当我们准备插入第 i 个数时,因为它比前面已经插入的 i-1 个数都要大,所以它插入后,自身必然构成一个新的前缀最大值,从而能有效延长它前面的某段 LIS

f_j 表示当前序列中,以从左数第 j 个位置结尾的前缀中,最长上升子序列的长度。显然 f 数组是单调不降的,且相邻两个位置的差值 d_j = f_j - f_{j-1} 只能是 01因此,我们可以用一个 0/1 串来完美表示整个 f 数组的状态! 1 代表长度加了 1,0 代表长度没变。

2. 状态转移(模拟 O(N log N) 贪心算法)

在第 i 个数(它是当前最大数)插入到序列的第 k 个缝隙中时:

  1. 它自己一定能让 LIS 长度 ​+1,所以插入位置的状态位变成 1
  2. 对于插入位置右侧的序列,原有的某个 LIS 的增长潜力被提前消耗了。具体表现为:插入位置右侧,第一个原本是 1 的差分位,会被抹平变成 0

这完美对应了我们求 LIS 时的 std::lower_bound 替换操作!

  1. 解密位运算“天书”代码

书中那段嵌套在 DP 里的位运算极度晦涩,我们来逐行破译它:

// 假设当前状态为 j,我们在第 k 个空位插入新数字
// 注意:最低位(bit 0)代表序列最左端,最高位代表序列最右端
pos = -1;
// 倒序遍历,其实是在从左到右扫描序列(寻找插入点右侧的第一个 1)
for (k = i - 1; ~k; k--) {
    // 构造新状态 t:
    // 1. (j >> k) << (k + 1) : 把 k 位及以上的位整体左移 1 位,腾出空隙
    // 2. (1 << k)            : 在第 k 位强行塞入一个 1 (新插入的最大值)
    // 3. (j & ((1 << k) - 1)): k 位以下的位保持原样
    t = ((j >> k) << (k + 1)) | (1 << k) | (j & ((1 << k) - 1));
  
    // 寻找右侧的 1: 因为低位在左,高位在右
    // pos 记录的是“当前遍历过的位置中,遇到的最近的 1”
    if (j & (1 << k)) pos = k; 
  
    // 如果右侧存在 1,将其翻转为 0
    if (~pos) t ^= (1 << (pos + 1)); 
  
    // 状态转移累加
    dp[now][t] = (dp[now][t] + dp[now ^ 1][j]) % mod;
}

复杂度分析:时间复杂度 O(N \cdot 2^N)。在 N=20 时约有 2 \times 10^7 次运算,常数极小,卡常后勉强能过。但如果 N=28(原题数据范围),则直接超时。

位运算

/**
 * 洛谷 P4484 最长上升子序列的期望 (状态压缩 DP 解法)
 * * 【原书代码缺陷修复说明】:
 * 1. 内存溢出修复:原书 `ll dp[2][134217730]` 高达 2.14GB,在大部分 OJ 都会直接 MLE。
 * 实际上 N=24 时状态最多 2^24,且方案数取模后可用 int 存储。
 * 这里修改为 `int dp[2][1 << 24]`,内存骤降至 128MB。
 * 2. 移除重复计算:原书自己手写了 getlen 数组求二进制 1 的个数,
 * 这里替换为 GCC 内置的 `__builtin_popcount`,更优雅且省空间。
 * 3. 规范作用域与可读性:将原来堆在全局的 i, j, k 移入局部,
 * 并将反人类的 `~k` 还原为 `k >= 0`。
 */

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int MOD = 998244353;

// 快速幂求逆元 (原书的 my_pow)
long long power(long long base, long long exp) {
    long long res = 1;
    base %= MOD;
    while (exp > 0) {
        if (exp & 1) res = (res * base) % MOD;
        base = (base * base) % MOD;
        exp >>= 1;
    }
    return res;
}

// DP 数组:滚动数组优化空间
// 1 << 24 刚好能过 N=24 的部分分数据 (128MB 内存)
// 如果强行跑 N=28,必须使用杨表解法,否则任何机器都会内存超限
int dp[2][1 << 24]; 

int main() {
    // 优化标准输入输出
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int N;
    if (!(cin >> N)) return 0;
  
    // N-- 的原因是:第 1 个数插进去状态必定是 1,我们直接初始化掉,少一轮循环
    N--; 
  
    // 初始化:插入第 1 个数,差分数组状态为 0 (此时长度1,差分序列无元素,表现为0)
    dp[0][0] = 1; 

    int now = 1; // 滚动数组的当前维度

    // i 表示当前正在处理第 i+1 个插入的数字 (从小到大插入)
    for (int i = 1; i <= N; i++, now ^= 1) {
  
        // 每次进入新的一层,先清空当前层将要用到的状态空间
        // 状态上限为 1 << i,因为插入 i 个数最多产生 i 个差分位
        fill(dp[now], dp[now] + (1 << i), 0);
  
        // j 遍历上一层的所有合法状态
        for (int j = 0; j < (1 << (i - 1)); j++) {
            if (!dp[now ^ 1][j]) continue; // 剪枝:无方案数的状态直接跳过

            // ---------------------------------------------------------
            // 情况 1:新数字插在最前面 (对低位没有影响,直接整体左移)
            // ---------------------------------------------------------
            // 因为是从小到大插入,插在最前面意味着它比之前所有的数都大,
            // 且排在最前面。它自己构成一个长度为 1 的上升序列,
            // 并把后续的 LIS 整体往后推,所以末尾直接补 0 (j << 1)
            dp[now][j << 1] = (dp[now][j << 1] + dp[now ^ 1][j]) % MOD;

            // ---------------------------------------------------------
            // 情况 2:新数字插在序列中间或末尾
            // ---------------------------------------------------------
            int pos = -1; 
  
            // k 表示插入的空隙位置。从后往前倒着遍历 (k 从 i-1 到 0)
            // 为什么要倒着遍历?
            // 因为新插入一个上升点,会“消耗”掉它右侧(高位)的第一个上升点(即将 1 变成 0)。
            // 倒序遍历刚好能顺带记录遇到的上一个 1 的位置 (pos)。
            for (int k = i - 1; k >= 0; k--) {
  
                // 【核心位运算 1】:拆分并强行塞入 1
                // (j >> k) << (k + 1) : 把 k 位及以上的高位整体左移一位,腾出位置
                // (1 << k)            : 在第 k 位强行塞入一个 1 (新数字带来的 LIS 增长)
                // (j & ((1<<k)-1))    : 第 k 位以下的低位原封不动地拼接上去
                int t = ((j >> k) << (k + 1)) | (1 << k) | (j & ((1 << k) - 1));
  
                // 【核心位运算 2】:记录右侧(高位)最近的 1
                // 如果当前位 k 是 1,把它记下来。
                // 因为 k 是递减的,所以 pos 记录的永远是当前空隙右侧的、离得最近的 1 的位置。
                if (j & (1 << k)) pos = k;
  
                // 【核心位运算 3】:抹平潜能 (模拟 lower_bound 替换)
                // 如果右侧存在 1 (~pos 等价于 pos != -1)
                // 那么新插入的这个 1 会把原有的那个 1 替换掉(变为 0)。
                // 注意:在新的状态 t 中,原来的 pos 位已经被左移到了 pos+1,所以对 pos+1 取反
                if (pos != -1) t ^= (1 << (pos + 1)); 
  
                // 累加状态
                dp[now][t] = (dp[now][t] + dp[now ^ 1][j]) % MOD;
            }
        }
    }

    // N 轮循环结束后,答案存在 dp[now ^ 1] 中
    int final_state = now ^ 1;
    long long ans = 0;
    int max_S = 1 << N;

    // 统计所有最终状态对期望的贡献
    for (int i = 0; i < max_S; i++) {
        if (!dp[final_state][i]) continue;
  
        // __builtin_popcount 计算二进制中 1 的个数。
        // LIS 的实际长度 = 差分数组中 1 的个数 + 1 (即原本初始化的第 1 个数)
        int lis_len = __builtin_popcount(i) + 1;
  
        // 累加:当前状态的方案数 * 该状态的 LIS 长度
        ans = (ans + 1LL * dp[final_state][i] * lis_len) % MOD;
    }

    // 计算总期望 = 总和 / N! (等价于 乘上 N! 的逆元)
    long long fac = 1;
    for (int i = 1; i <= N + 1; i++) {
        fac = (fac * i) % MOD;
    }
  
    ans = (ans * power(fac, MOD - 2)) % MOD;

    cout << ans << "\n";

    return 0;
}



视角二:降维打击——杨表 (Young Tableau) 与 RSK 对应

书上留了一个彩蛋:“另有一种使用杨表解决、效率更高的拓展解法”。这就是现代代数组合学中的核武器 —— Robinson-Schensted-Knuth (RSK) 对应

1. RSK 对应定理

RSK 定理指出:任意一个长度为 N 的排列,与一对形状相同、大小为 N 的标准杨表 (Standard Young Tableau, SYT) 存在一一对应关系。

最神奇的数学性质: 如果一个排列对应的杨表形状为 \lambda\lambdaN 的一个整数划分,比如 [4,2,1]),那么这个排列的 最长上升子序列 (LIS) 的长度,恰好等于杨表第一行的长度 \lambda_1

2. 钩子长度公式 (Hook Length Formula)

根据 RSK 定理,LIS 长度为 k 的排列个数,等于所有满足 \lambda_1 = k 的杨表形状对应的排列个数。而给定一个形状 \lambda,其能构成的标准杨表数量 f^\lambda 可以由钩子公式瞬间求出:

f^\lambda = \frac{N!}{\prod_{u \in \lambda} h(u)}

其中 h(u) 指的是单元格 u 的“钩长”(其正下方和正右方的格子数,加上自己本身)。因为一个排列对应一对形状相同的杨表,所以形状为 \lambda 的排列总数就是 (f^\lambda)^2

3. 期望的终极推导

我们要求期望 E = \frac{1}{N!} \sum_{\pi} \text{LIS}(\pi),即所有排列的 LIS 长度之和除以 N!

E = \frac{1}{N!} \sum_{\lambda \vdash N} \lambda_1 \cdot (f^\lambda)^2 = \sum_{\lambda \vdash N} \frac{\lambda_1 \cdot N!}{\left( \prod_{u \in \lambda} h(u) \right)^2}

代入钩子公式,消掉一个 N!

这简直就是降维打击! 我们只需要用 DFS 枚举 N整数划分(即 \lambda)。对于 N=28,整数划分的数量仅仅只有 3718 种!相比于 DP 的 2^{28} \approx 2.6 \times 10^8,我们把计算量压缩了上万倍!代码运行时间从几秒钟骤降到 1 毫秒内

核心代码实现:基于杨表的最优解

摒弃复杂的位运算与巨大的空间开销,这里给出基于“杨表 + 钩子公式”的极致优美代码:

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

const int MOD = 998244353;

// 快速幂求逆元
long long power(long long base, long long exp) {
    long long res = 1;
    base %= MOD;
    while (exp > 0) {
        if (exp % 2 == 1) res = (res * base) % MOD;
        base = (base * base) % MOD;
        exp /= 2;
    }
    return res;
}

long long modInverse(long long n) {
    return power(n, MOD - 2);
}

int n;
long long fact_n;
long long total_expected = 0;

// 计算当前整数划分的钩子并累加期望
void process_partition(const vector<int>& part) {
    int rows = part.size();
    if (rows == 0) return;
  
    int cols = part[0]; // 第一行长度,也就是该划分对应的 LIS 长度 lambda_1
    vector<int> col_len(cols, 0); // 记录每一列的长度
  
    // 预处理每列长度
    for (int i = 0; i < rows; ++i) {
        for (int j = 0; j < part[i]; ++j) {
            col_len[j]++;
        }
    }

    // 计算分母:所有钩长平方的乘积
    long long prod_hook_sq = 1;
    for (int i = 0; i < rows; ++i) {
        for (int j = 0; j < part[i]; ++j) {
            // 钩长 = (该行右侧格子数) + (该列下方格子数) + 1 (自己)
            long long hook = (part[i] - 1 - j) + (col_len[j] - 1 - i) + 1;
            long long hook_sq = (hook * hook) % MOD;
            prod_hook_sq = (prod_hook_sq * hook_sq) % MOD;
        }
    }

    // 分子:n! * lambda_1
    long long term = (fact_n * cols) % MOD;
  
    // 当前划分对总期望的贡献:(n! * lambda_1) / 钩长平方积
    term = (term * modInverse(prod_hook_sq)) % MOD;
  
    total_expected = (total_expected + term) % MOD;
}

// DFS 枚举所有的整数划分
// rem: 剩余要划分的总和; max_val: 限制下一个选择的最大值以保证递减(形状合法)
void dfs(int rem, int max_val, vector<int>& part) {
    if (rem == 0) {
        process_partition(part);
        return;
    }
    for (int i = min(rem, max_val); i >= 1; --i) {
        part.push_back(i);
        dfs(rem - i, i, part);
        part.pop_back();
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    if (cin >> n) {
        // 预处理 N!
        fact_n = 1;
        for (int i = 1; i <= n; ++i) {
            fact_n = (fact_n * i) % MOD;
        }

        vector<int> part;
        dfs(n, n, part);

        cout << total_expected << "\n";
    }
    return 0;
}

总结

书本中的 DP 解法是典型的 “将算法逻辑状态化” 的神级思维训练;而使用杨表与钩子公式,则是用 高等代数学对问题维度的直接碾压

建议将此题作为从算法向数学组合拓展的里程碑进行收藏,在遇到 N \leq 28 都过不去的全排列题目时,想一想杨表。


评论