[Cnoi2020] 四角链

GitHub跳转原题关系图返回列表

先写出原题的 O(nk) 计数 DP,再把状态改写成第二类 Stirling 数,最后用满射计数的容斥公式在线性预处理后求出 S(n,n-k)。

OJ: luogu

题目 ID: P6162

难度:提高+/省选-

标签:组合计数容斥数学推导动态规划

日期: 2026-06-20 08:03

题意

n-1 个格子,第 i 个格子可以不填,或者填一个 1..i 之间的正整数。

要求:

  • 恰好有 k 个格子被填
  • 所有填入的数字两两不同

求合法方案数,答案对 998244353 取模。

思路

先看一个可以直接验证想法的朴素解:

cpp
#include <bits/stdc++.h>
using namespace std;

using i64 = long long;

const i64 MOD = 998244353LL;

int n, k;
vector<i64> dp;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> k;

    dp.assign(k + 1, 0);
    dp[0] = 1;

    // 直接按题意做 DP:
    // dp[j] = 处理完前若干个格子,恰好填了 j 个格子的方案数
    //
    // 处理第 i 个格子时:
    // 1. 不填,方案数不变
    // 2. 填一个数。若之前已经填了 j-1 个互不相同的数,
    //    那么 1..i 中还剩 i-(j-1) = i-j+1 个数可选。
    for (int i = 1; i <= n - 1; i++) {
        for (int j = min(i, k); j >= 1; j--) {
            dp[j] = (dp[j] + 1LL * (i - j + 1) * dp[j - 1]) % MOD;
        }
    }

    cout << dp[k] << '\n';

    return 0;
}

这个 brute.cpp 其实已经不是 DFS 暴力了,而是按题意直接写出的 O(nk)O(nk) DP。

f[i][j] 表示处理完前 i 个格子,恰好填了 j 个格子的方案数。

处理第 i 个格子时:

  • 不填:贡献 f[i-1][j]
  • 填:若前面已经填了 j-1 个不同数字,那么 1..i 中还剩 i-j+1 个可选新数字

所以递推是:

f[i][j] = f[i-1][j] + (i-j+1) * f[i-1][j-1]

一个小表格

这张表展示 n=5 时,前几层 f[i][j] 的变化:

i \\ j 0 1 2 3 4
0 1 0 0 0 0
1 1 1 0 0 0
2 1 3 1 0 0
3 1 6 7 1 0
4 1 10 25 15 1

如果你熟悉组合数学,会发现这张表非常像第二类 Stirling 数。

令:

g[i][m] = f[i][i+1-m]

代入后得到:

g[i][m] = g[i-1][m-1] + m * g[i-1][m]

这正是第二类 Stirling 数 S(i+1,m) 的标准递推,因此:

f[n-1][k] = S(n,n-k)

接下来问题就变成如何快速求一个第二类 Stirling 数单点值。

用“满射计数”可以得到容斥公式:

S(n,m) = 1 / m! * sum_{i=0}^{m} (-1)^i C(m,i)(m-i)^n

其中 m = n-k

于是我们只要:

  1. 预处理 0..m 的阶乘和逆阶乘
  2. 枚举容斥项 i
  3. 计算 (m-i)^n / (i!(m-i)!)
  4. 按奇偶加减

就能在线性复杂度内求出答案。

代码

cpp
#include <bits/stdc++.h>
using namespace std;

using i64 = long long;

const int MAXN = 1000000 + 5;
const i64 MOD = 998244353LL;

int n, k;
int group_cnt;
i64 fact[MAXN], inv_fact[MAXN];

i64 quick_pow(i64 base, int exp) {
    i64 ans = 1;
    base %= MOD;

    while (exp > 0) {
        if (exp & 1) {
            ans = ans * base % MOD;
        }
        base = base * base % MOD;
        exp >>= 1;
    }

    return ans;
}

void init_comb(int up) {
    fact[0] = 1;
    for (int i = 1; i <= up; i++) {
        fact[i] = fact[i - 1] * i % MOD;
    }

    inv_fact[up] = quick_pow(fact[up], (int)MOD - 2);
    for (int i = up; i >= 1; i--) {
        inv_fact[i - 1] = inv_fact[i] * i % MOD;
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> k;
    group_cnt = n - k;

    init_comb(group_cnt);

    // 原题答案 = 第二类 Stirling 数 S(n, n-k)
    // 用满射计数的容斥公式:
    // S(n,m) = 1 / m! * sum_{i=0}^{m} (-1)^i C(m,i) (m-i)^n
    //        = sum_{i=0}^{m} (-1)^i (m-i)^n / (i! (m-i)!)
    i64 ans = 0;
    for (int i = 0; i <= group_cnt; i++) {
        int remain = group_cnt - i;
        i64 term = quick_pow(remain, n);
        term = term * inv_fact[i] % MOD * inv_fact[remain] % MOD;

        if (i & 1) {
            ans -= term;
            if (ans < 0) {
                ans += MOD;
            }
        }
        else {
            ans += term;
            if (ans >= MOD) {
                ans -= MOD;
            }
        }
    }

    cout << ans << '\n';

    return 0;
}

复杂度

m = n-k

  • 预处理阶乘、逆阶乘:O(m)O(m)
  • 容斥枚举 m+1 项,每项一次快速幂:O(mlogn)O(m log n)
  • 空间复杂度:O(m)O(m)

总结

这题最重要的不是直接记住第二类 Stirling 数,而是先从原题按题意写出:

f[i][j] = f[i-1][j] + (i-j+1)f[i-1][j-1]

然后再通过状态代换识别出它其实就是 S(n,n-k)

一旦识别成功,后面的正式做法就是标准的“第二类 Stirling 数 + 满射计数容斥”。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析