跳转至

组合数学 (Combinatorics)

在算法竞赛中,组合计数问题极为频繁。通常需要在给定素数模数 \(p\)(如 \(10^9+7\)\(998244353\))下快速计算组合数 \(\binom{n}{k} = C_n^k\)


阶乘与逆元预处理 (\(\mathcal{O}(n)\) 预处理,\(\mathcal{O}(1)\) 查询)

\(n \le 10^6\) 且模数 \(MOD\) 为质数时,预处理阶乘及其逆元是计算组合数的最优方式。

#include <vector>

struct Combinatorics {
    int n;
    long long mod;
    std::vector<long long> fact;
    std::vector<long long> inv_fact;

    long long power(long long a, long long b) const {
        long long res = 1;
        a %= mod;
        while (b > 0) {
            if (b & 1) res = res * a % mod;
            a = a * a % mod;
            b >>= 1;
        }
        return res;
    }

    Combinatorics(int n, long long mod = 1e9 + 7) : n(n), mod(mod), fact(n + 1), inv_fact(n + 1) {
        fact[0] = 1;
        for (int i = 1; i <= n; ++i) {
            fact[i] = fact[i - 1] * i % mod;
        }
        // 预处理末尾逆元
        inv_fact[n] = power(fact[n], mod - 2);
        for (int i = n - 1; i >= 0; --i) {
            inv_fact[i] = inv_fact[i + 1] * (i + 1) % mod;
        }
    }

    // 计算 C(n, k)
    long long C(int n, int k) const {
        if (k < 0 || k > n) return 0;
        return fact[n] * inv_fact[k] % mod * inv_fact[n - k] % mod;
    }

    // 计算排列数 P(n, k)
    long long P(int n, int k) const {
        if (k < 0 || k > n) return 0;
        return fact[n] * inv_fact[n - k] % mod;
    }
};

卢卡斯定理 (Lucas' Theorem)

\(n, k\) 极大(例如 \(10^{18}\)),而模数 \(p\) 是较小质数(例如 \(p \le 10^5\))时,适用卢卡斯定理:

\[ \binom{n}{k} \equiv \binom{\lfloor n/p \rfloor}{\lfloor k/p \rfloor} \binom{n \bmod p}{k \bmod p} \pmod p \]

时间复杂度为 \(\mathcal{O}(p + \log_p n)\)

long long C_small(long long n, long long k, long long p) {
    if (k < 0 || k > n) return 0;
    long long num = 1, den = 1;
    for (int i = 0; i < k; ++i) {
        num = num * (n - i) % p;
        den = den * (i + 1) % p;
    }
    // 求 den 的逆元
    auto power = [](long long a, long long b, long long mod) {
        long long res = 1;
        while (b > 0) {
            if (b & 1) res = res * a % mod;
            a = a * a % mod;
            b >>= 1;
        }
        return res;
    };
    return num * power(den, p - 2, p) % p;
}

long long lucas(long long n, long long k, long long p) {
    if (k == 0) return 1;
    return lucas(n / p, k / p, p) * C_small(n % p, k % p, p) % p;
}

LeetCode 经典真题精选与题解提示

  • LeetCode 62 - 不同路径 (Unique Paths) 中等

    提示

    网格移动与基础组合数

    • \(m \times n\) 网格左上角到达右下角,总共必须向右移动 \(n - 1\) 步、向下移动 \(m - 1\) 步,总步数为 \(m + n - 2\)
    • 答案即为在总步数中选取向下步数的组合数 \(\binom{m + n - 2}{m - 1}\),利用组合数乘除公式单次 \(\mathcal{O}(\min(m, n))\) 计算即可。
  • LeetCode 1359 - 有效的快递序列数目 (Count All Valid Pickup and Delivery Options) 困难

    提示

    空隙插入法与阶乘组合

    • 考虑逐步加入每个订单对 \((P_i, D_i)\):前 \(i - 1\) 份订单已排布为 \(2(i - 1)\) 个位置,在其中插入 \(P_i\)\(D_i\)(且 \(P_i\) 必须在 \(D_i\) 前面)。
    • \(2i - 1\) 个空隙中,若 \(P_i, D_i\) 放在不同空隙有 \(\binom{2i - 1}{2}\) 种选法;若放在同一空隙有 \(2i - 1\) 种选法。两者之和为 \(\frac{(2i - 1)(2i)}{2} = i(2i - 1)\)
    • 最终总序列数为 \(\prod_{i=1}^{n} i(2i - 1) \pmod{10^9 + 7}\)
  • LeetCode 920 - 音乐播放列表 (Number of Music Playlists) 困难

    提示

    带冷却限制的组合 DP / 斯特林数推广

    • \(dp[i][j]\) 表示当前已播放 \(i\) 首歌,且恰好包含 \(j\) 首不同的曲目。
    • 状态转移分为两类:
      1. 当前播放了一首从未听过的新歌:从剩余的 \(n - (j - 1)\) 首中挑选,\(dp[i][j] += dp[i - 1][j - 1] \times (n - j + 1)\)
      2. 当前播放了一首之前听过的老歌:为满足冷却限制 \(k\),最近播放的 \(k\) 首不可选,故可选的老歌数量为 \(\max(0, j - k)\),转移为 \(dp[i][j] += dp[i - 1][j] \times \max(0, j - k)\)
  • LeetCode 1866 - 恰有 K 根木棍可以看到的排列数 (Number of Ways to Rearrange Sticks With K Sticks Visible) 困难

    提示

    第一类斯特林数 (Stirling Numbers of the First Kind)

    • 考虑长度最短的木棍(长度 1):
      • 若将其放在最左侧,则该木棍必定可见,剩余 \(n - 1\) 根木棍需贡献 \(k - 1\) 根可见,方案数为 \(dp[n - 1][k - 1]\)
      • 若将其放在其余 \(n - 1\) 个位置中的任意一个,它必定被左侧更长的木棍遮挡而不可见,剩余木棍仍需贡献 \(k\) 根可见,方案数为 \((n - 1) \times dp[n - 1][k]\)
    • 转移方程 \(dp[n][k] = dp[n - 1][k - 1] + (n - 1) \cdot dp[n - 1][k]\),即为第一类无符号斯特林数 \(\begin{bmatrix} n \\ k \end{bmatrix}\)