矩阵 II

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

把红筹和黑筹分别看成左括号与右括号,用前缀差值 dp[i][bal] 统计合法前缀数量,再用高精度加法保存第 n 个 Catalan 数。

OJ: luogu

题目 ID: P1722

难度:普及+/提高

标签:动态规划高精度组合计数递推数学

日期: 2026-06-20 08:39

题意

要构造一个长度为 2n 的序列,其中:

  • 红色算筹恰好 n
  • 黑色算筹恰好 n
  • 任意前缀里红色个数都不少于黑色个数

求这样的方案数。

思路

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

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

int n;
long long ans;

// 小数据直接搜索:
// red 表示已经放了多少个红筹,black 表示已经放了多少个黑筹。
// 任意前缀都要满足 red >= black。
void dfs(int red, int black) {
    if (red == n && black == n) {
        ans++;
        return;
    }

    if (red < n) {
        dfs(red + 1, black);
    }

    if (black < n && red > black) {
        dfs(red, black + 1);
    }
}

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

    cin >> n;
    dfs(0, 0);
    cout << ans << '\n';

    return 0;
}

这个搜索会维护:

  • 已经放了多少个红筹
  • 已经放了多少个黑筹

如果还没放满红筹,就可以继续放红筹;
如果当前红筹比黑筹多,才允许放黑筹。

这正是合法括号序列的构造方式:

  • 红筹看成左括号
  • 黑筹看成右括号

所以本题本质上就是在数第 n 个 Catalan 数。

不过由于 n <= 100,答案会远远超过 long long,因此正式做法需要高精度。

一个小表格

这张表展示 dp[i][bal] 的含义:

状态 含义
i 已经放了多少个位置
bal 当前前缀中“红筹数 - 黑筹数”
dp[i][bal] 满足前缀始终合法的方案数

转移非常自然:

  • 放红筹:dp[i+1][bal+1] += dp[i][bal]
  • 放黑筹:若 bal > 0dp[i+1][bal-1] += dp[i][bal]

初始只有 dp[0][0] = 1,最终答案就是 dp[2n][0]

代码

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

const int MAXN = 105;
const int BASE = 10000;
const int WIDTH = 4;

struct BigInt {
    vector<int> d;

    BigInt(int x = 0) {
        if (x == 0) {
            d.push_back(0);
            return;
        }
        while (x > 0) {
            d.push_back(x % BASE);
            x /= BASE;
        }
    }

    void trim() {
        while (d.size() > 1 && d.back() == 0) {
            d.pop_back();
        }
    }
};

BigInt operator + (const BigInt &a, const BigInt &b) {
    BigInt c;
    c.d.clear();

    int len = max((int) a.d.size(), (int) b.d.size());
    int carry = 0;

    for (int i = 0; i < len; i++) {
        int x = carry;
        if (i < (int) a.d.size()) {
            x += a.d[i];
        }
        if (i < (int) b.d.size()) {
            x += b.d[i];
        }
        c.d.push_back(x % BASE);
        carry = x / BASE;
    }

    if (carry) {
        c.d.push_back(carry);
    }

    c.trim();
    return c;
}

ostream &operator << (ostream &out, const BigInt &a) {
    out << a.d.back();
    for (int i = (int) a.d.size() - 2; i >= 0; i--) {
        out << setw(WIDTH) << setfill('0') << a.d[i];
    }
    return out;
}

int n;
BigInt f[MAXN];

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

    cin >> n;

    f[0] = BigInt(1);

    // Catalan 递推:
    // f[i] = sum_{j=0}^{i-1} f[j] * f[i-1-j]
    //
    // 但这里为了复用更直观的“前缀不为负”模型,
    // 改用经典一维 DP:
    // dp[len][bal] 表示当前长度和栈高。
    // 由于 n <= 100,这里直接用更常见的组合递推:
    // f[i] = f[i-1] * (4i-2) / (i+1)
    //
    // 为了保持高精度实现简单,这里仍使用区间划分递推。
    for (int i = 1; i <= n; i++) {
        f[i] = BigInt(0);
        for (int j = 0; j <= i - 1; j++) {
            // 这里 n 很小,如果写乘法高精度会增加代码复杂度。
            // 所以改成用括号序列 DP 的 BigInt 相加版更自然。
        }
    }

    vector<vector<BigInt>> dp(2 * n + 1, vector<BigInt>(n + 2, BigInt(0)));
    dp[0][0] = BigInt(1);

    for (int i = 0; i < 2 * n; i++) {
        for (int bal = 0; bal <= n; bal++) {
            if (dp[i][bal].d.size() == 1 && dp[i][bal].d[0] == 0) {
                continue;
            }

            // 放一个红筹,对应左括号。
            if (bal + 1 <= n) {
                dp[i + 1][bal + 1] = dp[i + 1][bal + 1] + dp[i][bal];
            }

            // 放一个黑筹,对应右括号,但前缀不能失衡。
            if (bal > 0) {
                dp[i + 1][bal - 1] = dp[i + 1][bal - 1] + dp[i][bal];
            }
        }
    }

    cout << dp[2 * n][0] << '\n';

    return 0;
}

复杂度

  • DP 状态数约为 O(n2)O(n^2)
  • 每次转移做一次高精度加法
  • 空间复杂度约为 O(n2)O(n^2)

总结

这题的关键识别是:

  • “任意前缀不失衡”
  • “红黑数量最终相等”

这就是合法括号序列,也就是 Catalan 数模型。

实现时不需要硬背公式,直接按前缀差值做 DP,思路更顺,和题面也更贴近。

一图流解析

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

一图流解析