Skip to content

随机棋盘(Hard Version) - 题解

标签与难度

标签: 组合数学, 生成函数, NTT, 容斥原理, 期望 难度: 2500

题目大意喵~

哈喵~ 各位算法大师们,今天我们来挑战一个有趣的棋盘问题!(ฅ'ω'ฅ)

在一个 n×nn \times n 的棋盘上,我们要放置 nn 个“车”,规则是它们不能互相攻击(也就是每行每列只有一个车)。但事情没那么简单哦!棋盘上有一些格子是禁止放车的。

这些禁止格子的生成方式有点特别:

  1. k=n/2k = \lfloor n/2 \rfloor
  2. 我们随机生成一个 1,2,,k1, 2, \dots, k 的排列 pp
  3. 对于每个 ii11kk,我们会在棋盘的三个特定位置设置禁止标记:
    • (2×pi1,2×i1)(2 \times p_i - 1, 2 \times i - 1)
    • (2×pi,2×i1)(2 \times p_i, 2 \times i - 1)
    • (2×pi,2×i)(2 \times p_i, 2 \times i)

因为排列 pp 是随机的,所以我们得到的棋盘也是随机的。我们的任务是,计算在这样随机生成的棋盘上,放置 nn 个互不攻击的车的方案数的期望

最后的结果需要对 998244353998244353 取模。

简单来说,就是对所有 k!k! 种可能的棋盘,算出每种棋盘的合法方案数,然后求个平均值。喵~

解题思路分析

这道题看起来好复杂呀,又是随机排列又是期望的,让人头晕晕呢~ (>ω<)。不过别怕,我我来带你一步步解开它的神秘面纱!

期望与容斥原理的结合

首先,我们要求的是方案数的期望。根据期望的线性性质,E[总方案数] = (所有可能棋盘的方案数之和) / (棋盘总数)。这里的棋盘总数就是排列 pp 的数量,即 k!k!

对于一个固定的棋盘(也就是固定了一组禁止格),计算放置 nn 个互不攻击的车的方案数,是一个经典的组合问题。我们可以使用容斥原理来解决。

FpF_p 是由排列 pp 生成的禁止格集合。放置 nn 个车的方案数等于:

Ways(Fp)=t=0n(1)trt(Fp)(nt)!\text{Ways}(F_p) = \sum_{t=0}^{n} (-1)^t \cdot r_t(F_p) \cdot (n-t)!

其中,rt(Fp)r_t(F_p) 表示在 FpF_p 这堆禁止格上,放置 tt 个互不攻击的车的方案数。

我们要求的是 Ways(Fp)\text{Ways}(F_p) 的期望值 E[Ways(Fp)]E[\text{Ways}(F_p)]。利用期望的线性性质,我们可以把期望符号放进求和里:

E[Ways(Fp)]=E[t=0n(1)trt(Fp)(nt)!]=t=0n(1)tE[rt(Fp)](nt)!E[\text{Ways}(F_p)] = E\left[\sum_{t=0}^{n} (-1)^t r_t(F_p) (n-t)!\right] = \sum_{t=0}^{n} (-1)^t E[r_t(F_p)] (n-t)!

现在,问题转化为了计算 E[rt(Fp)]E[r_t(F_p)],也就是在随机生成的禁止格上,放置 tt 个互不攻击的车的期望方案数

求解期望方案数 E[rt(Fp)]E[r_t(F_p)]

这部分的推导有点小绕,喵~ 但这是解题的关键! E[rt(Fp)]E[r_t(F_p)] 的定义是:

E[rt(Fp)]=1k!所有排列 prt(Fp)E[r_t(F_p)] = \frac{1}{k!} \sum_{\text{所有排列 } p} r_t(F_p)

rt(Fp)r_t(F_p) 是在 FpF_p 中选择 tt 个互不攻击的车的方案数。我们可以换个角度来统计 prt(Fp)\sum_p r_t(F_p):我们不先定下排列 pp,而是先选出 tt 个互不攻击的格子,然后看有多少个排列 pp 会使得这 tt 个格子全部被禁止。

prt(Fp)=pS:S=t,互不攻击I(SFp)=S:S=t,互不攻击pI(SFp)\sum_p r_t(F_p) = \sum_p \sum_{S: |S|=t, \text{互不攻击}} \mathbb{I}(S \subseteq F_p) = \sum_{S: |S|=t, \text{互不攻击}} \sum_p \mathbb{I}(S \subseteq F_p)

其中 I()\mathbb{I}(\cdot) 是指示函数。

一个格子 (r,c)(r, c) 会被禁止,需要满足两个条件:

  1. 它必须是“可能被禁止”的类型。观察禁止规则,我们发现形如 (2l1,2j)(2l-1, 2j) 的格子永远不会被禁止。
  2. 对于一个可能被禁止的格子 (r,c)(r,c),设 l=r/2l = \lceil r/2 \rceilj=c/2j = \lceil c/2 \rceil,当且仅当随机排列满足 pj=lp_j = l 时,它才真正被禁止。

现在,我们选一个包含 tt 个互不攻击格子的集合 SS。为了让 SS 中的所有格子都被禁止,它们必须首先都是“可能被禁止”的类型。其次,对于 SS 中的每个格子 (ri,ci)(r_i, c_i),都必须满足 pci/2=ri/2p_{\lceil c_i/2 \rceil} = \lceil r_i/2 \rceil

这里的数学推导非常复杂,涉及到对不同类型的 tt 格子集合进行分类计数。这只我的脑筋转了半天,发现直接推导非常困难!( >д<)

柳暗花明:生成函数大法!

当我们遇到这种复杂的组合计数和期望问题时,通常背后都藏着一个优美的生成函数模型!虽然直接推导很困难,但我们可以通过观察和归纳,发现一个惊人的规律!

问题的核心在于 E[rt(Fp)]E[r_t(F_p)] 的值。经过一番探索(或者说是天才的直觉!),我们可以发现 E[rt(Fp)]E[r_t(F_p)] 可以用一个非常简洁的多项式的系数来表示。

k=n/2k = \lfloor n/2 \rfloor。我们定义一个神奇的多项式:

P(x)=(1+3x+x2)kP(x) = (1 + 3x + x^2)^k

令人惊讶的是,我们要求的期望值 E[rt(Fp)]E[r_t(F_p)] 恰好就是 P(x)P(x)xtx^t 项的系数!

E[rt(Fp)]=[xt]P(x)=[xt](1+3x+x2)kE[r_t(F_p)] = [x^t]P(x) = [x^t](1 + 3x + x^2)^k

这里简单解释一下为什么是 1+3x+x21+3x+x^2: 这个多项式其实是某个 2×22 \times 2 小棋盘的车多项式(Rook Polynomial)。具体来说,对于一个有3个格子可以放棋子,1个格子禁止的 2×22 \times 2 棋盘(比如左上角、左下角、右下角可放),其车多项式就是 1+3x+x21 + 3x + x^2

  • 11: 放置 0 个车的方法数
  • 3x3x: 放置 1 个车的方法数
  • 1x21x^2: 放置 2 个互不攻击的车的方法数

整个问题可以被神奇地等效为:我们有 kk 个独立的 2×22 \times 2 小棋盘,每个小棋盘的车多项式都是 1+3x+x21+3x+x^2。整个等效大棋盘的车多项式就是这 kk 个小多项式的乘积,即 P(x)P(x)

虽然严格证明这个等价关系需要高深的组合理论(比如符号方法和对象建模),但我们可以相信这个漂亮的结论,它大大简化了问题!

最终的计算

现在我们有了 E[rt(Fp)]E[r_t(F_p)] 的生成函数 P(x)P(x),我们就可以计算最终答案了。 令 gt=[xt]P(x)g_t = [x^t]P(x),那么 E[rt(Fp)]=gtE[r_t(F_p)] = g_t。 代入我们之前的容斥公式:

答案=t=0n(1)tgt(nt)!\text{答案} = \sum_{t=0}^{n} (-1)^t g_t (n-t)!

这里的 gtg_t 是多项式 (1+3x+x2)k(1+3x+x^2)^k 的系数。

算法步骤

  1. 计算 k=n/2k = \lfloor n/2 \rfloor
  2. 构造基础多项式 A(x)=1+3x+x2A(x) = 1 + 3x + x^2
  3. 使用多项式快速幂计算 P(x)=A(x)kP(x) = A(x)^k。多项式乘法部分需要用**NTT (快速数论变换)**来加速,否则会超时。
  4. 得到 P(x)=gtxtP(x) = \sum g_t x^t 的所有系数 gtg_t
  5. 预计算阶乘。
  6. 根据公式 t=0n(1)tgt(nt)!\sum_{t=0}^{n} (-1)^t g_t (n-t)! 计算最终答案。注意 gtg_tt>2kt > 2k 时为 0。

这个方法对于 nn 是奇数还是偶数都适用哦!如果 n=2k+1n=2k+1 是奇数,第 nn 行和第 nn 列没有任何禁止格。我们的容斥公式和 E[rt]E[r_t] 的计算仍然有效,因为所有禁止格都在左上的 2k×2k2k \times 2k 区域内。

代码实现

下面是我为你精心准备的C++代码,带有详细的注释,希望能帮助你理解整个过程,喵~

cpp
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>

using namespace std;

// 使用 long long 防止溢出
using ll = long long;

// 模数
const int MOD = 998244353;
// NTT的原根
const int G = 3;

// 快速幂函数,用于计算 a^b % MOD
ll power(ll base, ll exp) {
    ll res = 1;
    base %= MOD;
    while (exp > 0) {
        if (exp % 2 == 1) res = (res * base) % MOD;
        base = (base * base) % MOD;
        exp /= 2;
    }
    return res;
}

// 模逆元
ll modInverse(ll n) {
    return power(n, MOD - 2);
}

// NTT核心实现
// flag = 1 表示正向NTT, flag = -1 表示逆向NTT
void ntt(vector<ll>& a, bool invert) {
    int n = a.size();

    // 位逆序置换 (Cooley-Tukey-FFT)
    for (int i = 1, j = 0; i < n; i++) {
        int bit = n >> 1;
        for (; j & bit; bit >>= 1) {
            j ^= bit;
        }
        j ^= bit;
        if (i < j) {
            swap(a[i], a[j]);
        }
    }

    // 蝶形运算
    for (int len = 2; len <= n; len <<= 1) {
        ll wlen = power(G, (MOD - 1) / len);
        if (invert) {
            wlen = modInverse(wlen);
        }
        for (int i = 0; i < n; i += len) {
            ll w = 1;
            for (int j = 0; j < len / 2; j++) {
                ll u = a[i + j];
                ll v = (a[i + j + len / 2] * w) % MOD;
                a[i + j] = (u + v) % MOD;
                a[i + j + len / 2] = (u - v + MOD) % MOD;
                w = (w * wlen) % MOD;
            }
        }
    }

    if (invert) {
        ll n_inv = modInverse(n);
        for (ll& x : a) {
            x = (x * n_inv) % MOD;
        }
    }
}

// 多项式乘法
vector<ll> multiply(vector<ll> a, vector<ll> b) {
    int sz = 1;
    while (sz < a.size() + b.size()) {
        sz <<= 1;
    }
    a.resize(sz);
    b.resize(sz);

    ntt(a, false);
    ntt(b, false);

    vector<ll> c(sz);
    for (int i = 0; i < sz; i++) {
        c[i] = (a[i] * b[i]) % MOD;
    }

    ntt(c, true);
    return c;
}

// 多项式快速幂
vector<ll> poly_power(vector<ll> base, int exp) {
    vector<ll> res = {1};
    while (exp > 0) {
        if (exp % 2 == 1) {
            res = multiply(res, base);
        }
        base = multiply(base, base);
        exp /= 2;
    }
    return res;
}

int main() {
    // 加速输入输出
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    int k = n / 2;

    // 基础多项式 P_small(x) = 1 + 3x + x^2
    vector<ll> base_poly = {1, 3, 1};

    // 计算 P(x) = (1 + 3x + x^2)^k
    vector<ll> g = poly_power(base_poly, k);

    // 预计算阶乘
    vector<ll> fact(n + 1);
    fact[0] = 1;
    for (int i = 1; i <= n; i++) {
        fact[i] = (fact[i - 1] * i) % MOD;
    }

    // 根据容斥公式计算最终答案
    // ans = sum_{t=0 to n} (-1)^t * g_t * (n-t)!
    ll ans = 0;
    for (int t = 0; t <= n; t++) {
        if (t >= g.size()) {
            break; // g_t is 0 for t >= g.size()
        }
        
        ll term = (g[t] * fact[n - t]) % MOD;
        
        if (t % 2 == 1) { // (-1)^t
            ans = (ans - term + MOD) % MOD;
        } else {
            ans = (ans + term) % MOD;
        }
    }

    cout << ans << endl;

    return 0;
}

复杂度分析

  • 时间复杂度: O(NlogN)O(N \log N)

    • 算法的主要开销在于多项式快速幂。
    • 我们需要计算一个次数为2的多项式的 kk 次幂,其中 k=n/2k = \lfloor n/2 \rfloor。最终多项式的次数为 2kn2k \approx n
    • 多项式快速幂需要进行 O(logk)O(\log k) 次多项式乘法。
    • 每次乘法使用NTT,两个次数为 DD 的多项式相乘的时间复杂度是 O(DlogD)O(D \log D)
    • 在快速幂过程中,多项式的度数会翻倍,最耗时的是最后几次乘法,其度数级别为 O(n)O(n)。所以总时间复杂度是 O(nlogn)O(n \log n)
    • 预计算阶乘和最后求和的复杂度都是 O(n)O(n),可以忽略不计。
  • 空间复杂度: O(N)O(N)

    • 我们需要存储多项式的系数,最大次数约为 nn,所以需要 O(n)O(n) 的空间。
    • NTT算法本身也需要 O(n)O(n) 的辅助空间。

知识点总结

这真是一道融合了多种思想的绝妙好题呀!我们来总结一下用到的知识点吧~

  1. 期望的线性性质: 这是解决期望问题的基石,它允许我们将复杂问题的期望分解为简单子问题期望的和。
  2. 容斥原理: 组合计数中的强大工具,特别适用于处理“至少”、“一个也不”等约束条件。这里我们用它来处理“不能放在禁止格”这一要求。
  3. 生成函数/车多项式: 将组合问题转化为代数问题的桥梁。虽然我们没有完整推导,但理解其思想有助于我们抓住问题的核心结构。1+3x+x^2 就是一个关键的“积木”。
  4. NTT (快速数论变换): 解决多项式乘法的利器。在处理生成函数时,我们经常需要计算多项式乘法和幂,NTT是实现这一目标的高效算法。

通过这道题,我们看到,即使问题表面看起来非常棘手,只要我们运用正确的数学工具(比如期望、容斥和生成函数),就能把它层层剥开,最终发现一个简洁而优美的核心结构。希望这篇题解能对你有所启发,喵~ (ฅ^•ﻌ•^ฅ)