CSP-S 2021 第二轮认证 解题报告

CSP-S 2021 第二轮认证 解题报告

比赛:CSP-S 2021 第二轮认证(提高级)
语言:C++14 | 全部代码均已在本地官方测试数据上验证通过(见各题末尾的得分档说明)


一、比赛总体概述

1.1 题目总览

题号 题目名称 洛谷编号 难度 题型 核心考点
T1 廊桥分配 P7913 普及+/提高 贪心 + 数据结构 贪心正确性、堆维护、前缀和
T2 括号序列 P7914 提高+/省选− 动态规划 区间 DP、计数去重、分治转移
T3 回文 P7915 普及+/提高 构造 + 贪心 双向取数、字典序、双指针
T4 交通规划 P7916 省选/NOI− 图论 平面图最小割、对偶图最短路、环形区间 DP

1.2 整体难度感受

  • T1(廊桥分配) 是本场最友好的一题,思维上只需要一个关键的贪心观察,配合堆与前缀和即可满分,是"想通了很简单、没想到会卡住"的典型。
  • T2(括号序列) 是经典的区间 DP,但计数去重是本场最易写错的环节:如何保证并列拼接不重复、如何用"第一块"分解是核心难点,很多选手因为转移式有重复或遗漏而丢掉大量分数。
  • T3(回文) 题目描述简洁,正解是一个巧妙的贪心:每次取的值其"配对位置"必须被预留到最后对称步再取,由此用双指针模拟即可,O(n) 可过。难点在于把"回文对称"转化为可模拟的约束。
  • T4(交通规划) 是全场压轴,考察平面图最小割与对偶图最短路的转化,再套一层环形括号匹配 DP。能做出 T4 的选手需要扎实的图论功底,本题也是本场区分度最大的一题。

1.3 考点分布

  • 贪心:T1、T3
  • 区间 DP:T2
  • 最短路 / 平面图对偶:T4
  • 计数取模:T2
  • 字典序构造:T3

1.4 策略建议

本场考试的建议策略是:稳拿 T1 满分 → 用 T2 的 15/40 分档保底、逐档递进冲击满分 → 冲击 T3 满分 → 最后用网络流/对偶图拿 T4 部分分。T4 即使不会满分,网络流的建模思路也能稳定拿下前 60 分左右,因此每道题先写部分分、再冲满分是本场的核心节奏。


二、T1:廊桥分配(P7913)

1. 题目大意

有 \(n\) 个廊桥需要分配给国内区(\(m_1\) 架飞机)和国际区(\(m_2\) 架飞机),飞机按"先到先得"原则停靠空闲廊桥,否则停远机位。求一种分配方案,使停在廊桥的飞机总数最大。

2. 部分分:20 分做法(测试点 1~4,\(n \le 100\))

  • 思路:\(n\) 很小,直接枚举国内区分得 \(a\) 个廊桥(\(0 \le a \le n\)),国际区得 \(n-a\) 个。对每种分配,按到达时刻顺序模拟每架飞机的停靠过程:先释放已离开的廊桥,再找第一个空闲廊桥停靠。取所有分配中的最大值。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 105;

// 20 分做法:n <= 100, m1 + m2 <= 100
// 枚举国内区分配的廊桥数 a,其余 n - a 个给国际区,
// 对每种分配方案直接模拟每架飞机的停靠过程(O(m * n))。

int n, m1, m2;
pair<int, int> p1[MAXN], p2[MAXN];  // (到达时刻, 离开时刻)

// 模拟:cnt 个廊桥,m 架飞机(已按到达时刻排序),返回能停靠的数量
int simulate(pair<int, int> *p, int m, int cnt)
{
    int busy[MAXN];        // busy[i] 表示 1 号廊桥当前占用飞机的离开时刻(0 表示空闲)
    int ans = 0;
    memset(busy, 0, sizeof(busy));
    for (int i = 1; i <= m; i++)
    {
        int a = p[i].first, b = p[i].second;
        // 先释放所有离开时刻 <= a 的廊桥(此时它们已经空闲)
        for (int j = 1; j <= cnt; j++)
            if (busy[j] && busy[j] <= a) busy[j] = 0;
        // 找一个空闲廊桥停靠
        for (int j = 1; j <= cnt; j++)
            if (busy[j] == 0)
            {
                busy[j] = b;
                ans++;
                break;
            }
    }
    return ans;
}

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

    cin >> n >> m1 >> m2;
    for (int i = 1; i <= m1; i++) cin >> p1[i].first >> p1[i].second;
    for (int i = 1; i <= m2; i++) cin >> p2[i].first >> p2[i].second;
    sort(p1 + 1, p1 + m1 + 1);
    sort(p2 + 1, p2 + m2 + 1);

    int ans = 0;
    // a 个廊桥分给国内区,n - a 个分给国际区
    for (int a = 0; a <= n; a++)
    {
        int b = n - a;
        int cur = simulate(p1, m1, a) + simulate(p2, m2, b);
        ans = max(ans, cur);
    }
    cout << ans << "\n";
    return 0;
}
  • 复杂度:枚举分配共 \(O(n)\) 种;每种分配要对 \(m_1+m_2\) 架飞机各扫描至多 \(n\) 个廊桥(找空闲廊桥是 \(O(cnt)\)),即每组模拟 \(O((m_1+m_2) \cdot n)\);总 \(O((m_1+m_2) \cdot n^2)\)。空间 \(O(n + m_1 + m_2)\)(busy 数组 \(O(n)\),飞机数组 \(O(m_1+m_2)\))。

3. 部分分:40 分做法(测试点 1~9,\(n \le 5000\))

  • 思路:注意到一个关键性质——给某个区分配 \(k\) 个廊桥时,能停靠的飞机数与"具体哪 \(k\) 个廊桥"无关,只与数量有关:让每架飞机贪心停靠编号最小的空闲廊桥,则第 \(i\) 号廊桥服务的飞机数 \(cnt_i\) 是固定的,分配 \(k\) 个廊桥的答案是 \(\sum_{i=1}^k cnt_i\)。于是只需对每个区做一次"无限廊桥编号"的模拟,得到前缀和 \(f[k]\),最后 \(O(n)\) 枚举分配取 \(\max(f_1[a] + f_2[n-a])\)。本版用线性扫描找空闲廊桥(\(O(n)\)/架),对 \(n \le 5000\) 足够。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 100005;  // 数组按 n 的极限开,保证 n 大飞机少的中间数据也能正确运行

// 40 分做法:n <= 5000, m1 + m2 <= 5000
// 关键观察:给某个区分配 k 个廊桥时,能停靠的飞机数可以独立计算。
// 让每架飞机贪心地停靠在"编号最小"的空闲廊桥,则第 i 号廊桥服务的
// 飞机数 cnt[i] 与其它廊桥无关,分配 k 个廊桥的答案就是 cnt[1..k] 的前缀和。
// 这里用线性扫描找空闲廊桥,复杂度 O((m1+m2) * n)。

int n, m1, m2;
pair<int, int> p1[MAXN], p2[MAXN];  // (到达时刻, 离开时刻)

// 计算 f[k]:给 m 架飞机分配 k 个廊桥时能停靠的飞机数(k = 1..n)
int f[MAXN];
void calc(pair<int, int> *p, int m)
{
    sort(p + 1, p + m + 1);  // 按到达时刻排序
    static int cnt[MAXN];
    memset(cnt, 0, sizeof(cnt));
    static bool used[MAXN];  // 该廊桥当前是否被占用
    memset(used, 0, sizeof(used));
    // occ:正在占用廊桥的飞机的离开时刻(小根堆),存 (离开时刻, 廊桥编号)
    priority_queue<pair<int, int>, vector<pair<int, int> >, greater<pair<int, int> > > occ;

    for (int i = 1; i <= m; i++)
    {
        int a = p[i].first, b = p[i].second;
        // 释放所有离开时刻 <= a 的廊桥
        while (!occ.empty() && occ.top().first <= a)
        {
            used[occ.top().second] = false;
            occ.pop();
        }
        // 线性扫描找到编号最小的空闲廊桥
        for (int j = 1; j <= n; j++)
            if (!used[j])
            {
                used[j] = true;
                cnt[j]++;
                occ.push(make_pair(b, j));
                break;
            }
    }
    // f[k] = 编号 1..k 的廊桥服务总数
    f[0] = 0;
    for (int i = 1; i <= n; i++) f[i] = f[i - 1] + cnt[i];
}

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

    cin >> n >> m1 >> m2;
    for (int i = 1; i <= m1; i++) cin >> p1[i].first >> p1[i].second;
    for (int i = 1; i <= m2; i++) cin >> p2[i].first >> p2[i].second;

    static int f1[MAXN], f2[MAXN];
    calc(p1, m1);
    for (int i = 0; i <= n; i++) f1[i] = f[i];
    calc(p2, m2);
    for (int i = 0; i <= n; i++) f2[i] = f[i];

    int ans = 0;
    for (int a = 0; a <= n; a++)
        ans = max(ans, f1[a] + f2[n - a]);
    cout << ans << "\n";
    return 0;
}
  • 复杂度:\(O((m_1+m_2) \cdot n)\),空间 \(O(n)\)。

4. 满分做法(测试点 1~20,\(n, m_1+m_2 \le 10^5\))

  • 关键观察/性质:与 40 分做法相同,核心性质是"廊桥编号的贪心分配与服务数量分解":每个廊桥的服务链互不依赖,前缀和 \(f[k]\) 可以独立求得。
  • 算法推导:40 分做法找空闲廊桥是 \(O(n)\)/架,瓶颈在 \(n\) 很大。用一个小根堆维护空闲廊桥编号,配合"离开时刻小根堆"即可把每架飞机的操作降为 \(O(\log n)\)。
  • 数据结构:两个小根堆——occ 存 (离开时刻, 廊桥编号),用于按时释放;freeQ 存空闲廊桥编号,保证每次拿到编号最小的空闲廊桥。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 100005;

// 满分做法:n, m1, m2 <= 1e5
// 与 40 分做法同样的关键观察(每条廊桥的服务链互相独立),
// 区别在于用一个小根堆维护"空闲廊桥编号",找廊桥从 O(n) 降为 O(log n),
// 总复杂度 O((m1+m2) * log n)。

int n, m1, m2;
pair<int, int> p1[MAXN], p2[MAXN];  // (到达时刻, 离开时刻)
int f1[MAXN], f2[MAXN];             // f[k] = 分配 k 个廊桥能停靠的飞机数

void calc(pair<int, int> *p, int m, int *f)
{
    sort(p + 1, p + m + 1);  // 按到达时刻排序
    static int cnt[MAXN];
    memset(cnt, 0, sizeof(cnt));
    // occ:正在占用廊桥的飞机的离开时刻,存 (离开时刻, 廊桥编号)
    priority_queue<pair<int, int>, vector<pair<int, int> >, greater<pair<int, int> > > occ;
    // freeQ:空闲廊桥编号的小根堆
    priority_queue<int, vector<int>, greater<int> > freeQ;
    for (int i = 1; i <= n; i++) freeQ.push(i);

    for (int i = 1; i <= m; i++)
    {
        int a = p[i].first, b = p[i].second;
        // 释放所有离开时刻 <= a 的廊桥
        while (!occ.empty() && occ.top().first <= a)
        {
            freeQ.push(occ.top().second);
            occ.pop();
        }
        // 没有空闲廊桥,这架飞机停远机位
        if (freeQ.empty()) continue;
        int id = freeQ.top();
        freeQ.pop();
        cnt[id]++;
        occ.push(make_pair(b, id));
    }
    f[0] = 0;
    for (int i = 1; i <= n; i++) f[i] = f[i - 1] + cnt[i];
}

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

    cin >> n >> m1 >> m2;
    for (int i = 1; i <= m1; i++) cin >> p1[i].first >> p1[i].second;
    for (int i = 1; i <= m2; i++) cin >> p2[i].first >> p2[i].second;

    calc(p1, m1, f1);
    calc(p2, m2, f2);

    int ans = 0;
    for (int a = 0; a <= n; a++)
        ans = max(ans, f1[a] + f2[n - a]);
    cout << ans << "\n";
    return 0;
}
  • 复杂度:\(O((m_1+m_2) \log n)\),空间 \(O(n)\)。

5. 总结

  • 核心考点:贪心观察"廊桥服务链独立" + 堆优化 + 前缀和枚举分配。
  • 实现注意:飞机要按到达时刻排序后再模拟;数组按 \(n\) 的极限开(如 \(n=10^5\) 但飞机很少的测试点);答案可能到 \(10^5\),用 int 即可。
  • 得分档验证(本地官方数据):20 分版通过测试点 1~4,40 分版通过测试点 1~9,满分版通过全部 20 个测试点。

三、T2:括号序列(P7914)

1. 题目大意

给定由 (、)、*、? 组成的长度为 \(n\) 的串,? 可以任意替换为三种字符之一。求有多少种替换方案,使结果成为"符合规范的超级括号序列",答案对 \(10^9+7\) 取模。

规范定义回顾:()、(S) 规范;规范序列 A、B 拼接 AB、ASB 规范(S 为 1~k 个 *);(A)、(SA)、(AS) 规范。

2. 档位一:15 分做法(测试点 1~3,\(n \le 15\))

  • 思路:\(n \le 15\) 时 ? 个数 \(\le 15\),直接枚举每个 ? 的三种填法(\(3^q\) 种候选串),用"规则直译"的递归判定函数 valid 判断每个候选串是否规范。判定时对子串做记忆化避免重复计算。该做法与正解思路完全独立,是本档的朴素实现。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll MOD = 1000000007LL;

// 15 分做法:n <= 15(测试点 1~3)
// 枚举每个 '?' 的三种填法('('、')'、'*'),共 3^q 种候选串,
// 用独立实现"规则直译"的判定函数 valid 逐一检查是否规范,累加计数。

int n, k;
string s;

// 判定字符串 t 是否为"符合规范的超级括号序列"(按题面规则 1~4 直译)
map<string, bool> memo;  // 记忆化,避免重复判定相同子串
bool valid(const string &t)
{
    int len = (int)t.size();
    if (len == 0) return false;
    auto it = memo.find(t);
    if (it != memo.end()) return it->second;

    bool ok = false;
    // 规则 1、3:(X) 形式,X 为内层内容
    if (t[0] == '(' && t[len - 1] == ')')
    {
        string x = t.substr(1, len - 2);
        if (x.empty()) ok = true;  // ()
        // (S):S 为 1..k 个 '*' 组成的非空串
        if (!ok && (int)x.size() >= 1 && (int)x.size() <= k)
        {
            bool all = true;
            for (char c : x) if (c != '*') { all = false; break; }
            if (all) ok = true;
        }
        // (A)
        if (!ok && valid(x)) ok = true;
        // (SA):S(1..k 个 '*') + 规范序列 A
        if (!ok)
            for (int i = 1; i < (int)x.size(); i++)
            {
                string s1 = x.substr(0, i), a1 = x.substr(i);
                if ((int)s1.size() <= k)
                {
                    bool all = true;
                    for (char c : s1) if (c != '*') { all = false; break; }
                    if (all && !a1.empty() && valid(a1)) { ok = true; break; }
                }
            }
        // (AS):规范序列 A + S(1..k 个 '*')
        if (!ok)
            for (int i = 1; i < (int)x.size(); i++)
            {
                string a1 = x.substr(0, i), s1 = x.substr(i);
                if ((int)s1.size() <= k)
                {
                    bool all = true;
                    for (char c : s1) if (c != '*') { all = false; break; }
                    if (all && !a1.empty() && valid(a1)) { ok = true; break; }
                }
            }
    }
    // 规则 2:AB 或 ASB(A、B 为规范序列,S 为 1..k 个 '*')
    if (!ok)
    {
        for (int i = 1; i < len; i++)
        {
            string a1 = t.substr(0, i), b1 = t.substr(i);
            if (valid(a1) && valid(b1)) { ok = true; break; }
        }
        if (!ok)
            for (int i = 1; i < len; i++)
                for (int j = i + 1; j < len; j++)
                {
                    string a1 = t.substr(0, i), mid = t.substr(i, j - i), b1 = t.substr(j);
                    if ((int)mid.size() >= 1 && (int)mid.size() <= k)
                    {
                        bool all = true;
                        for (char c : mid) if (c != '*') { all = false; break; }
                        if (all && valid(a1) && valid(b1)) { ok = true; break; }
                    }
                    if (ok) break;
                }
    }
    memo[t] = ok;
    return ok;
}

// 枚举 s 中第 pos 位起的 '?',统计合法方案数
ll dfs(int pos)
{
    if (pos == n) return valid(s) ? 1 : 0;
    if (s[pos] != '?') return dfs(pos + 1);
    ll res = 0;
    s[pos] = '('; res += dfs(pos + 1);
    s[pos] = ')'; res += dfs(pos + 1);
    s[pos] = '*'; res += dfs(pos + 1);
    s[pos] = '?';
    return res % MOD;
}

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

    cin >> n >> k >> s;
    cout << dfs(0) % MOD << "\n";
    return 0;
}
  • 复杂度:\(O(3^q \cdot n)\)(\(q\) 为 ? 个数),空间 \(O(n)\)。

3. 档位二:40 分做法(测试点 4~8,\(n \le 40\))

  • 思路:\(n \le 40\) 时 \(3^q\) 枚举不可行,改成朴素区间 DP(O(n^4))。定义:
    • \(f[i][j]\):\(s[i..j]\) 形如 (X) 的方案数(X 为空 / 纯星号串 / 规范序列 / 规范序列夹非空星号串);
    • \(dp[i][j]\):\(s[i..j]\) 为规范序列的方案数,按"第一块"唯一分解:\(dp[i][j] = \sum_k f[i][k] \cdot H(k+1, j)\);
    • \(H(l, r)\):星号串(可为空) + 规范块序列 的方案数,每次实时计算(不预存辅助数组)。
  • 与满分的关键差异:本档的 \(H\) 不做任何预计算与优化,转移完全按定义展开;\(H\) 的左端 \(kk+1\) 严格大于当前区间左端 \(i\),因此它内部用到的 \(dp\) 区间必然比 \([i,j]\) 短,按区间长度从小到大迭代即可,天然不存在同层依赖问题(满分版的 \(h\) 数组需要额外处理这个细节)。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 45;
const ll MOD = 1000000007LL;

// 25 分做法:n <= 40(测试点 4~8),O(n^4)
// 朴素区间 DP,不预计算任何辅助数组,转移完全按定义展开:
//   f[i][j]  :s[i..j] 形如 "(X)" 的方案数(X 为空/纯星号/规范序列/夹星号)
//   dp[i][j] :s[i..j] 为规范序列(含并列拼接)的方案数
//   H(l, r)  :"星号串(可为空) + 规范块序列" 的方案数,每次实时计算
// 并列拼接按"第一块"分解去重:dp[i][j] = Σ f[i][kk] * H(kk+1, j),
// 其中 H 的左端 kk+1 严格大于 i,H 内部用到的 dp 区间都比 [i,j] 短,
// 因此按区间长度从小到大迭代即可,无需处理同层依赖。
// 每区间计算 O(n^2)(枚举第一块 kk,每个 kk 的 H 是 O(n) 的累加),
// 总 O(n^4)。n = 40 时约 2.5e6 次操作,运行飞快。

int n, k;
char s[MAXN];
bool canOpen[MAXN], canClose[MAXN], canStar[MAXN];
bool star[MAXN][MAXN];      // star[i][j]:s[i..j] 全可星号且长度 <= k
ll f[MAXN][MAXN];
ll dp[MAXN][MAXN];

// H(l, r):星号串(可为空) + 规范块序列 的方案数(空区间返回 1)
ll H(int l, int r)
{
    if (l > r) return 1;               // 空区间:只可能是"空星号串"
    ll res = dp[l][r];                 // 空星号分支:整段就是块序列
    for (int t = l; t <= r - 1; t++)   // 非空星号 s[l..t],后面必须接块序列
        if (star[l][t])
            res = (res + dp[t + 1][r]) % MOD;
    return res;
}

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

    cin >> n >> k;
    cin >> (s + 1);

    for (int i = 1; i <= n; i++)
    {
        canOpen[i]  = (s[i] == '(' || s[i] == '?');
        canClose[i] = (s[i] == ')' || s[i] == '?');
        canStar[i]  = (s[i] == '*' || s[i] == '?');
    }
    // 预处理 star[i][j]
    for (int i = 1; i <= n; i++)
        for (int j = i; j <= n; j++)
        {
            if (j - i + 1 > k) { star[i][j] = false; continue; }
            if (j == i) star[i][j] = canStar[i];
            else star[i][j] = star[i][j - 1] && canStar[j];
        }

    for (int len = 2; len <= n; len++)
    {
        for (int i = 1; i + len - 1 <= n; i++)
        {
            int j = i + len - 1;
            // ---- f[i][j]:一层括号 (X) ----
            if (canOpen[i] && canClose[j])
            {
                ll &cur = f[i][j];
                if (i + 1 > j - 1) cur = (cur + 1) % MOD;          // ()
                else if (star[i + 1][j - 1]) cur = (cur + 1) % MOD; // (S)
                // (SA):星号串(非空) + 规范序列
                for (int t = i + 1; t <= j - 1; t++)
                    if (star[i + 1][t - 1])
                        cur = (cur + dp[t][j - 1]) % MOD;
                // (AS):规范序列 + 星号串(非空)
                for (int t = i + 1; t <= j - 1; t++)
                    if (star[t + 1][j - 1])
                        cur = (cur + dp[i + 1][t]) % MOD;
                // (A):中间是非空规范序列
                if (i + 1 <= j - 1)
                    cur = (cur + dp[i + 1][j - 1]) % MOD;
            }
            // ---- dp[i][j]:第一块 f[i][kk] + 剩余部分 H(kk+1, j) ----
            for (int kk = i; kk <= j; kk++)
            {
                ll ff = f[i][kk];
                if (!ff) continue;
                ll hh = H(kk + 1, j);      // 实时展开,O(n)
                if (hh) dp[i][j] = (dp[i][j] + ff * hh) % MOD;
            }
        }
    }
    cout << dp[1][n] % MOD << "\n";
    return 0;
}
  • 复杂度:每区间计算 \(O(n^2)\)(枚举第一块 \(kk\),每个 \(kk\) 的 \(H\) 是 \(O(n)\) 累加),总 \(O(n^4)\);\(n=40\) 时约 \(2.5\times10^6\) 次操作,空间 \(O(n^2)\)。

4. 档位三:65 分做法(测试点 9~13,\(n \le 100\))

  • 思路:\(n \le 100\) 时 \(O(n^4)\) 会退化(约 \(10^8\)),在档位二基础上用"连续可星号区间 + 前缀和"把星号串枚举 O(1) 化:
    • \(contR[p]\) / \(contL[p]\):位置 \(p\) 向右 / 向左连续可星号的长度,则 \(star(i,j)\)(\(s[i..j]\) 全可星号且长 \(\le k\))等价于 \(j-i+1 \le k\) 且 \(contR[i] \ge j-i+1\);
    • 固定左端 \(l\) 时,\(star(l,t)\) 为真的 \(t\) 构成连续区间,于是形如 \(\sum star(l,t) \cdot dp\) 的求和可以用列前缀和 / 行前缀和 \(O(1)\) 求出;
    • 这样每区间只枚举第一块 \(kk\)(\(O(n)\)),剩余部分 \(O(1)\) 求值,总复杂度降为 \(O(n^3)\)。
  • 实现陷阱:区间 DP 按长度从小到大推进,colPre[a][b] 中"行更小"的 \(dp\) 反而更晚才算完(\([a][b]\) 的区间长度是 \(b-a+1\)),因此不能在算完单个区间后增量累加前缀和,而要在每个长度层开始前把已算完的 \(dp\) 全量重建一遍前缀和(\(O(n^2)\)/层,不影响总复杂度)。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 105;
const ll MOD = 1000000007LL;

// 25 分做法:n <= 100(测试点 9~13),O(n^3)
// 在 O(n^4) 朴素版的基础上,用"连续可星号区间 + 前缀和"把星号串的枚举 O(1) 化:
//   contR[p]:从 p 向右连续 canStar 的长度  -> star(i,j) ⟺ j-i+1<=k 且 contR[i]>=j-i+1
//   contL[p]:从 p 向左连续 canStar 的长度
//   固定左端 l 时,star(l, t) 为真的 t 构成连续区间,因此形如
//   Σ star(l,t)*dp 的求和可以用按列/按行的前缀和直接求出:
//     colPre[a][b] = Σ_{u<=a} dp[u][b]   (固定列 b,按行累加)
//     rowPre[a][b] = Σ_{v<=b} dp[a][v]   (固定行 a,按列累加)
// 注意:区间 DP 按长度从小到大推进,colPre[a][b] 中"行更小"的 dp 反而更晚
// 才算完([a][b] 的区间长度 = b-a+1),因此不能在算完单个区间后增量累加前缀和。
// 正确做法是:每个长度层开始前,把"所有已算完(长度更短)的 dp"完整重建一遍
// 前缀和(重建 O(n^2)),保证层内查询一致。总复杂度 O(n^3)。
// 每区间计算 O(n)(只枚举第一块 kk,剩余部分 O(1) 求值)。

int n, k;
char s[MAXN];
bool canOpen[MAXN], canClose[MAXN], canStar[MAXN];
int contR[MAXN], contL[MAXN];       // 向右 / 向左连续可星号长度
ll f[MAXN][MAXN];
ll dp[MAXN][MAXN];
ll colPre[MAXN][MAXN], rowPre[MAXN][MAXN];  // 列前缀和 / 行前缀和

// star(i, j):s[i..j] 全可星号且长度 <= k(i <= j)
inline bool isStar(int i, int j)
{
    return (j - i + 1 <= k) && (contR[i] >= j - i + 1);
}

// 求 Σ_{t=l..r-1} star(l,t) * dp[t+1][r]("非空星号串 + 规范序列"的剩余部分)
// star(l,t) 为真的 t 是连续区间 [l, min(r-1, l+k-1, l+contR[l]-1)],
// 令 u = t+1,即对 u ∈ [l+1, min(r, l+k, l+contR[l])] 求 Σ dp[u][r],用列前缀和。
ll starSum(int l, int r)
{
    if (l > r) return 0;
    int hi = min(r, l + k);
    hi = min(hi, l + contR[l]);
    if (hi < l + 1) return 0;
    ll res = colPre[hi][r] - colPre[l][r];   // Σ_{u=l+1..hi} dp[u][r]
    res %= MOD;
    if (res < 0) res += MOD;
    return res;
}

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

    cin >> n >> k;
    cin >> (s + 1);

    for (int i = 1; i <= n; i++)
    {
        canOpen[i]  = (s[i] == '(' || s[i] == '?');
        canClose[i] = (s[i] == ')' || s[i] == '?');
        canStar[i]  = (s[i] == '*' || s[i] == '?');
    }
    // 预处理连续可星号长度
    for (int i = n; i >= 1; i--) contR[i] = canStar[i] ? contR[i + 1] + 1 : 0;
    for (int i = 1; i <= n; i++) contL[i] = canStar[i] ? contL[i - 1] + 1 : 0;

    for (int len = 2; len <= n; len++)
    {
        // 重建前缀和:包含所有已算完(区间长度 < len)的 dp。
        // 未算的 dp 仍为 0,加 0 无影响;全量重写保证前缀和一致。
        for (int a = 1; a <= n; a++)
            for (int b = a; b <= n; b++)
            {
                colPre[a][b] = (colPre[a - 1][b] + dp[a][b]) % MOD;
                rowPre[a][b] = (rowPre[a][b - 1] + dp[a][b]) % MOD;
            }
        for (int i = 1; i + len - 1 <= n; i++)
        {
            int j = i + len - 1;
            // ---- f[i][j]:一层括号 (X) ----
            if (canOpen[i] && canClose[j])
            {
                ll &cur = f[i][j];
                if (i + 1 > j - 1) cur = (cur + 1) % MOD;             // ()
                else if (isStar(i + 1, j - 1)) cur = (cur + 1) % MOD; // (S)
                // (SA):Σ star(i+1, t-1)*dp[t][j-1]
                //   t-1 需满足长度 <= k 且连续可星号 -> t 落在连续区间
                {
                    int lo = i + 2;                       // S 非空
                    int hi = min(j - 1, i + 1 + k);
                    hi = min(hi, i + 1 + contR[i + 1]);
                    if (lo <= hi)
                    {
                        ll v = colPre[hi][j - 1] - colPre[lo - 1][j - 1];
                        v %= MOD;
                        if (v < 0) v += MOD;
                        cur = (cur + v) % MOD;
                    }
                }
                // (AS):Σ star(t+1, j-1)*dp[i+1][t]
                //   从右向左看:t 需满足 j-1-t <= k 且 j-1-t <= contL[j-1]
                {
                    int lo2 = max(i + 1, j - 1 - k);
                    lo2 = max(lo2, j - 1 - contL[j - 1]);
                    int hi2 = j - 2;                      // S 非空
                    if (lo2 <= hi2)
                    {
                        ll v = rowPre[i + 1][hi2] - rowPre[i + 1][lo2 - 1];
                        v %= MOD;
                        if (v < 0) v += MOD;
                        cur = (cur + v) % MOD;
                    }
                }
                // (A):中间是非空规范序列
                if (i + 1 <= j - 1)
                    cur = (cur + dp[i + 1][j - 1]) % MOD;
            }
            // ---- dp[i][j]:第一块 f[i][kk] + 剩余部分(O(1) 求值) ----
            for (int kk = i; kk <= j; kk++)
            {
                ll ff = f[i][kk];
                if (!ff) continue;
                ll hh;
                if (kk + 1 > j) hh = 1;                    // 剩余为空
                else
                {
                    hh = dp[kk + 1][j];                    // 空星号:剩余整段是块序列
                    hh = (hh + starSum(kk + 1, j)) % MOD;  // 非空星号串 + 块序列
                }
                if (hh) dp[i][j] = (dp[i][j] + ff * hh) % MOD;
            }
        }
    }
    cout << dp[1][n] % MOD << "\n";
    return 0;
}
  • 复杂度:\(O(n^3)\),空间 \(O(n^2)\)。

5. 档位四:75 分做法(测试点 14~15,\(S\) 全为 ? 的特殊性质,\(n \le 500\))

  • 思路:特殊性质下 \(S\) 全是 ?,每个位置都可以是任意字符,于是规范序列的计数只与长度有关、与具体字符无关,把区间 DP 的所有状态压缩成"长度"一维:
    • \(F[l]\):长度为 \(l\) 的一层括号 (X) 的方案数;
    • \(D[l]\):长度为 \(l\) 的规范序列方案数;
    • \(H[l]\):长度为 \(l\) 的 星号串(可为空) + 块序列 方案数(\(H[0]=1\))。
  • 转移(利用全 ? 的对称性):
    • \(F[l] = [l=2] + [1 \le l-2 \le k] + [l-2 \ge 2]\, D[l-2] + 2 \sum_b D[b]\)(\(b \in [\max(2,l-2-k),\,l-3]\),(SA)/(AS) 两方向各一次);
    • \(H[l] = D[l] + \sum_{a=1}^{\min(k,l-2)} D[l-a]\);
    • \(D[l] = \sum_{f=2}^{l} F[f] \cdot H[l-f]\)。
  • 与满分的关键差异:本档完全不走"区间"路线,只维护长度索引;两处 \(\sum\) 用 \(D\) 的前缀和优化到 \(O(1)\),只剩 \(D[l]\) 的朴素卷积 \(O(l)\),总 \(O(n^2)\)。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 505;
const ll MOD = 1000000007LL;

// 10 分做法(特殊性质):n <= 500 且 S 全为 '?'(测试点 14~15),O(n^2)
// 全 '?' 意味着每个位置都可以是任意字符,于是"规范超级括号序列"的计数
// 只与长度有关(与具体字符无关),可以把区间 DP 的所有状态压缩成"长度"一维:
//   F[l]:长度为 l 的一层括号 (X) 的方案数
//   D[l]:长度为 l 的规范序列的方案数
//   H[l]:长度为 l 的"星号串(可为空) + 块序列"的方案数(H[0] = 1)
//
// 转移(X = s[1..l-2]):
//   F[l] = [l == 2]                      (空括号 ())
//        + [1 <= l-2 <= k]               ((S):中间全星号)
//        + [l-2 >= 2] * D[l-2]           ((A):中间是规范序列)
//        + 2 * Σ_{b} D[b]                ((SA) 与 (AS):星号串长度 a∈[1,k],
//                                         规范部分 b = l-2-a >= 2,两个方向各一次)
//   H[l] = D[l]                          (空星号 + 块序列)
//        + Σ_{a=1..min(k,l-2)} D[l-a]    (非空星号在前 + 块序列)
//   D[l] = Σ_{f=2..l} F[f] * H[l-f]      (第一块长度 f,剩余长度 l-f)
// 其中两处 Σ 用 D 的前缀和 S_D 优化到 O(1),D[l] 的卷积朴素 O(l),
// 总复杂度 O(n^2),n = 500 时毫秒级。

int n, k;
ll F[MAXN], D[MAXN], H[MAXN];
ll S_D[MAXN];            // S_D[x] = Σ_{b<=x} D[b]

// 区间和 Σ_{b=lo..hi} D[b](lo > hi 时返回 0)
inline ll sumD(int lo, int hi)
{
    if (lo > hi) return 0;
    ll res = S_D[hi] - S_D[lo - 1];
    res %= MOD;
    if (res < 0) res += MOD;
    return res;
}

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

    string str;
    cin >> n >> k >> str;

    H[0] = 1;             // 空串:空星号串
    for (int l = 2; l <= n; l++)
    {
        // ---- F[l] ----
        ll &f = F[l];
        if (l == 2) f = (f + 1) % MOD;                    // ()
        if (l - 2 >= 1 && l - 2 <= k) f = (f + 1) % MOD;  // (S)
        if (l - 2 >= 2) f = (f + D[l - 2]) % MOD;         // (A)
        // (SA)/(AS):a 为星号长度 a∈[1,k],规范部分 b = l-2-a >= 2
        //            -> b ∈ [max(2, l-2-k), l-3],两个方向各一次
        {
            int lo = max(2, l - 2 - k);
            int hi = l - 3;
            f = (f + 2 * sumD(lo, hi)) % MOD;
        }
        // ---- D[l]:第一块 F[f] + 剩余 H[l-f] ----
        for (int fl = 2; fl <= l; fl++)
        {
            if (F[fl] && H[l - fl])
                D[l] = (D[l] + F[fl] * H[l - fl]) % MOD;
        }
        // ---- H[l] ----
        H[l] = D[l];
        // 非空星号 a∈[1,k],块序列长度 l-a >= 2 -> a <= l-2
        {
            int aMax = min(k, l - 2);
            if (aMax >= 1)
                H[l] = (H[l] + sumD(l - aMax, l - 1)) % MOD;  // Σ_{a=1..aMax} D[l-a]
        }
        // 维护 D 的前缀和
        S_D[l] = (S_D[l - 1] + D[l]) % MOD;
    }
    cout << D[n] % MOD << "\n";
    return 0;
}
  • 复杂度:\(O(n^2)\),空间 \(O(n)\)。

6. 档位五(满分):100 分做法(测试点 16~20,\(n \le 500\))

  • 关键观察/性质:
    1. 规范序列的最外层结构是"括号块":每个规范序列按并列顺序看,第一个元素必为 (,与其配对的 ) 位置唯一,这一"第一块"使得并列拼接可以唯一分解,避免重复计数。
    2. 中间内容四分类:一层括号 (X) 中,X 只能是空、纯星号串、规范序列,或规范序列前后夹非空星号串——恰好对应题面规则 1、3。
  • 算法推导:定义三个数组:
    • \(star[i][j]\):\(s[i..j]\) 能否全为 * 且长度 \(\le k\);
    • \(f[i][j]\):(X) 形式方案数(O(1) 求纯星号,其余用短区间 \(dp\) 求和);
    • \(h[l][r]\):星号串(可为空) + 块序列 方案数,\(h\) 的空星号分支恰为 \(dp\) 本身,因此把它内联进 \(dp\) 转移式,先算 \(dp\) 再算 \(h\) 即可避免同层依赖;
    • \(dp[i][j] = \sum_{kk} f[i][kk] \cdot (\text{剩余部分})\)。
    • 所有转移按区间长度从小到大进行,\(O(n^3)\)。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 505;
const ll MOD = 1000000007LL;

// 满分做法:n, k <= 500
// 区间 DP,O(n^3)。
//
// 记 S[i] 为第 i 个字符。三种"可替换"关系:
//   canOpen[i]  : S[i] 可为 '('   (S[i]=='(' 或 '?')
//   canClose[i] : S[i] 可为 ')'   (S[i]==')' 或 '?')
//   canStar[i]  : S[i] 可为 '*'   (S[i]=='*' 或 '?')
//
// star[i][j](i<=j):S[i..j] 能否全部替换为 '*',且长度不超过 k(0/1)。
//
// f[i][j]  :S[i..j] 形如 "(X)" 的方案数,即最外层一对括号包住的序列。
//            中间 X 只能是下列四种之一(依据题面规则 1、3):
//              S   :全星号串(长度 1..k)
//              S A :星号串(1..k) + 规范序列 A
//              A S :规范序列 A + 星号串(1..k)
//              A   :规范序列 A
// h[l][r]  :S[l..r] 形如"星号串(可为空) + 块序列"的方案数。
//            文法上星号必须夹在两个规范块之间,因此整段纯星号不合法(空区间取 1)。
// dp[i][j] :S[i..j] 是规范序列(含并列拼接 AB、ASB)的方案数。
//            去重分解:第一块 f[i][k](唯一,因为最外层括号的配对位置唯一)
//            + 剩余部分 h[k+1][j],即 dp[i][j] = Σ f[i][k] * h[k+1][j]。

int n, k;
char s[MAXN];
bool canOpen[MAXN], canClose[MAXN], canStar[MAXN];
bool star[MAXN][MAXN];      // star[i][j]:S[i..j] 全可星号且长度 <= k
ll f[MAXN][MAXN];           // 一层括号 (X)
ll h[MAXN][MAXN];           // 星号串(可为空) + 块序列
ll dp[MAXN][MAXN];          // 规范序列

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

    cin >> n >> k;
    cin >> (s + 1);

    for (int i = 1; i <= n; i++)
    {
        canOpen[i]  = (s[i] == '(' || s[i] == '?');
        canClose[i] = (s[i] == ')' || s[i] == '?');
        canStar[i]  = (s[i] == '*' || s[i] == '?');
    }
    // 预处理 star[i][j]
    for (int i = 1; i <= n; i++)
        for (int j = i; j <= n; j++)
        {
            if (j - i + 1 > k) { star[i][j] = false; continue; }
            if (j == i) star[i][j] = canStar[i];
            else star[i][j] = star[i][j - 1] && canStar[j];
        }

    for (int len = 2; len <= n; len++)
    {
        for (int i = 1; i + len - 1 <= n; i++)
        {
            int j = i + len - 1;
            // ---- 计算 f[i][j] ----
            if (canOpen[i] && canClose[j])
            {
                ll &cur = f[i][j];
                // (1) 中间是空串(对应规则 1 的 ())或纯星号串 S(非空,1..k 个)
                if (i + 1 > j - 1) cur = (cur + 1) % MOD;          // 空括号 ()
                else if (star[i + 1][j - 1]) cur = (cur + 1) % MOD; // 纯星号 (S)
                // (2) S A:S 为星号串(非空),A 为规范序列
                for (int t = i + 1; t <= j - 1; t++)
                    if (star[i + 1][t - 1])
                        cur = (cur + dp[t][j - 1]) % MOD;
                // (3) A S:A 为规范序列,S 为星号串(非空)
                for (int t = i + 1; t <= j - 1; t++)
                    if (star[t + 1][j - 1])
                        cur = (cur + dp[i + 1][t]) % MOD;
                // (4) A:中间直接是规范序列
                cur = (cur + dp[i + 1][j - 1]) % MOD;
            }
            // ---- 计算 dp[i][j]:第一块 f[i][k] + 剩余部分 h[k+1][j] ----
            // 必须先算 dp 再算 h(h 的空星号分支会引用 dp[i][j] 本身)
            for (int kk = i; kk <= j; kk++)
            {
                ll hh = (kk + 1 > j) ? 1 : h[kk + 1][j];
                if (f[i][kk] && hh)
                    dp[i][j] = (dp[i][j] + f[i][kk] * hh) % MOD;
            }
            // ---- 计算 h[i][j]:星号串(可为空) + 规范块序列 ----
            // 空星号分支 = dp[i][j] 本身(整段就是块序列);
            // 非空星号 s[i..t] 后必须接非空块序列,纯星号串非法。
            ll &hv = h[i][j];
            hv = (hv + dp[i][j]) % MOD;
            for (int t = i; t <= j - 1; t++)
                if (star[i][t])
                    hv = (hv + dp[t + 1][j]) % MOD;
        }
    }
    cout << dp[1][n] % MOD << "\n";
    return 0;
}
  • 复杂度:\(O(n^3)\),空间 \(O(n^2)\)。

7. 总结

  • 核心考点:区间 DP 的去重分解(第一块唯一)+ 星号串边界处理。
  • 五档递进关系:枚举(15 分)→ 朴素区间 DP(40 分)→ 前缀和优化(65 分)→ 特殊性质长度 DP(75 分)→ 完整区间 DP 满分(100 分)。每档都是独立可运行的实现,代码风格统一。
  • 实现注意:
    • 别忘了空括号 () 也是一类合法形态;
    • 规范序列不能以星号开头或结尾(并列分解时纯星号串非法),非空星号串必须夹在两个规范块之间;
    • 档位三的 \(h\) 类辅助值若预存数组,空星号分支会引用同层 \(dp\) 导致顺序错误(档位二用实时计算规避、满分版先算 \(dp\) 再算 \(h\)、档位三前缀和需按层重建);
    • 所有乘法取模用 long long。
  • 得分档验证(本地官方数据 + 随机对拍):档一通过测试点 1~3,档二通过 4~8,档三通过 9~13,档四通过 14~15,满分版通过 16~20;档二与暴力枚举在 3000 组随机串上完全一致,档三、档四各与档二对拍 3000 / 2000 组全部一致。

四、T3:回文(P7915)

1. 题目大意

给定长度 \(2n\) 的序列 \(a\)(\(1..n\) 每个数恰好出现 2 次),每次可移除 \(a\) 的开头或末尾并放入 \(b\) 末尾,共 \(2n\) 次。求使 \(b\) 成为回文序列的操作序列(L/R 串),若可行输出字典序最小的,否则输出 -1。

2. 部分分:25 分做法(测试点 1~7,\(n \le 10\))

  • 思路:回文要求 \(b[i] = b[2n+1-i]\),因此前半 \(n\) 步取出的值决定了整个 \(b\):后半第 \(k\) 步必须取出与前半对称位置相同的值。DFS 枚举前半每一步取左还是取右(\(2^n\) 种),深搜优先尝试 L 保证第一个找到的解字典序最小;前半确定后,后半按"需要值"强制验证。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 45;

// 25 分做法:n <= 10(测试点 1~7)
// 回文序列 b 满足 b[i] = b[2n+1-i],因此:
//   - 前半 n 步(取出的值构成 b 的前半)决定了整个 b;
//   - 后半 n 步每步取出的值必须等于"前半对称位置"的值。
// 用 DFS 枚举前半每一步取左(L)还是取右(R),
// 深搜顺序保证第一个找到的解字典序最小(优先尝试 L)。
// 复杂度 O(2^n * n)。

int n, m;
int a[MAXN];
char ans[MAXN];      // 最终操作串(长度 2n)
char op[MAXN];       // 前半操作记录
int frontv[MAXN];    // 前半取出的值(b 的前半)
bool found;

// 前半已确定后,验证并构造后半
bool checkTail(int l, int r)
{
    // 后半第 k 步取的值必须等于 frontv[n+1-k]
    for (int k = 1; k <= n; k++)
    {
        int need = frontv[n + 1 - k];
        if (a[l] == need)
        {
            ans[n + k] = 'L';
            l++;
        }
        else if (a[r] == need)
        {
            ans[n + k] = 'R';
            r--;
        }
        else return false;
    }
    return true;
}

// 枚举前半:pos 表示当前决定第 pos 步(1..n),l、r 为剩余区间端点
void dfs(int pos, int l, int r)
{
    if (found) return;
    if (pos > n)
    {
        if (checkTail(l, r))
        {
            for (int i = 1; i <= n; i++) ans[i] = op[i];
            found = true;
        }
        return;
    }
    // 优先尝试 'L'(保证字典序最小)
    op[pos] = 'L';
    frontv[pos] = a[l];
    dfs(pos + 1, l + 1, r);
    if (found) return;
    // 尝试 'R'
    op[pos] = 'R';
    frontv[pos] = a[r];
    dfs(pos + 1, l, r - 1);
}

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

    int T;
    cin >> T;
    while (T--)
    {
        cin >> n;
        m = 2 * n;
        for (int i = 1; i <= m; i++) cin >> a[i];
        found = false;
        dfs(1, 1, m);
        if (found)
        {
            for (int i = 1; i <= m; i++) cout << ans[i];
            cout << "\n";
        }
        else cout << "-1\n";
    }
    return 0;
}
  • 复杂度:\(O(2^n \cdot n)\),空间 \(O(n)\)。

3. 满分做法(测试点 1~25,\(\sum n \le 5 \times 10^5\))

  • 关键观察/性质:
    1. 对称配对约束:回文要求第 \(i\) 步取的值 \(=\) 第 \(2n+1-i\) 步取的值。第一步取某端 \(x\) 后,\(x\) 的另一个位置 \(p\) 必须留到最后一步(第 \(2n\) 步)取——这是整道题的突破口。
    2. 预留区间:随着前半步骤推进,所有被"预留到最后对称步取"的位置构成一个连续区间 \([ql, qr]\)(从 \(p\) 出发向两侧扩展)。前半第 \(i\) 步从外层取左/右端时,其配对位置必须恰好在 \([ql, qr]\) 端点外侧(\(ql-1\) 或 \(qr+1\)),否则该值无法在对称步取出。
    3. 后半强制:前半确定后,后半每步按预留顺序 \(need[i]\) 取配对区间 \([ql, qr]\) 的对应端点。
  • 算法推导:分别尝试第一步取左和第一步取右两条分支(\(O(n)\) 各跑一次双指针模拟),可行分支中取字典序较小者。由于第一步就决定字符串首字符,两条分支的候选答案各是确定的串,直接逐位比较即可。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 1000005;   // 2n <= 1e6

// 满分做法:O(n)
// 回文序列 b 满足 b[i] = b[2n+1-i],即第 i 步取的值 == 第 2n+1-i 步取的值。
// 第一步取某端 x,x 的另一个位置 p 必须在最后一步被取。由此:
//   - 前半步骤(第 1..n 步)从外层两端取;
//   - 每次取的值 v 的配对位置 p' 会被"预留"到对称步骤(第 2n+1-i 步)取;
//   - 预留位置构成连续区间 [ql, qr](从 p 出发向两侧扩展),前半取的位置
//     必须恰好落在该区间端点外侧(p' == ql-1 或 qr+1);
//   - 后半步骤(第 n+1..2n 步)按预留顺序 need[i] 强制取配对区端点。
// 分别尝试第一步取左 / 取右,取可行且字典序较小的答案。

int n, m;
int a[MAXN];
int pos1[MAXN], pos2[MAXN];   // 每个值出现的两个位置
char ansL[MAXN], ansR[MAXN];  // 两套候选答案
int need[MAXN];               // need[i]:第 i 步(后半)必须取的位置

// 返回配对位置(v 的另一个位置,cur 是其中一个)
inline int mate(int v, int cur)
{
    return (pos1[v] == cur) ? pos2[v] : pos1[v];
}

// first == 0:第一步取左端;first == 1:第一步取右端
bool simulate(int first, char *ans)
{
    int l = 1, r = 2 * n;
    int ql, qr;   // 预留(配对)区 [ql, qr]
    // 第一步
    int e = first ? r : l;
    int v = a[e];
    int p = mate(v, e);
    if (first) { ans[1] = 'R'; r--; }
    else       { ans[1] = 'L'; l++; }
    ql = qr = p;
    need[2 * n] = p;
    // 前半步骤 i = 2..n:从外层端取,配对位置扩展预留区
    for (int i = 2; i <= n; i++)
    {
        bool ok = false;
        // 尝试取左端(要求左端还在外层,即 l < ql)
        if (l < ql)
        {
            int pv = mate(a[l], l);
            if (l <= pv && pv <= r)   // 配对位置未取
            {
                if (pv == ql - 1)
                {
                    ql--;
                    ans[i] = 'L';
                    need[2 * n + 1 - i] = pv;
                    l++;
                    ok = true;
                }
                else if (pv == qr + 1)
                {
                    qr++;
                    ans[i] = 'L';
                    need[2 * n + 1 - i] = pv;
                    l++;
                    ok = true;
                }
            }
        }
        // 尝试取右端
        if (!ok && r > qr)
        {
            int pv = mate(a[r], r);
            if (l <= pv && pv <= r)
            {
                if (pv == ql - 1)
                {
                    ql--;
                    ans[i] = 'R';
                    need[2 * n + 1 - i] = pv;
                    r--;
                    ok = true;
                }
                else if (pv == qr + 1)
                {
                    qr++;
                    ans[i] = 'R';
                    need[2 * n + 1 - i] = pv;
                    r--;
                    ok = true;
                }
            }
        }
        if (!ok) return false;
    }
    // 后半步骤 i = n+1..2n:按预留顺序取配对区端点
    for (int i = n + 1; i <= 2 * n; i++)
    {
        int w = need[i];
        if (ql == w)      { ans[i] = 'L'; ql++; }
        else if (qr == w) { ans[i] = 'R'; qr--; }
        else return false;
    }
    return true;
}

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

    int T;
    cin >> T;
    while (T--)
    {
        cin >> n;
        m = 2 * n;
        memset(pos1, 0, sizeof(pos1));
        memset(pos2, 0, sizeof(pos2));
        for (int i = 1; i <= m; i++)
        {
            cin >> a[i];
            if (pos1[a[i]] == 0) pos1[a[i]] = i;
            else pos2[a[i]] = i;
        }
        // 两种第一步方向:优先取字典序小的(以 'L' 开头的)
        bool okL = simulate(0, ansL);
        bool okR = simulate(1, ansR);
        if (okL && okR)
        {
            // 逐位比较,取字典序小者
            bool chooseL = true;
            for (int i = 1; i <= m; i++)
                if (ansL[i] != ansR[i]) { chooseL = (ansL[i] < ansR[i]); break; }
            for (int i = 1; i <= m; i++) cout << (chooseL ? ansL[i] : ansR[i]);
            cout << "\n";
        }
        else if (okL)
        {
            for (int i = 1; i <= m; i++) cout << ansL[i];
            cout << "\n";
        }
        else if (okR)
        {
            for (int i = 1; i <= m; i++) cout << ansR[i];
            cout << "\n";
        }
        else cout << "-1\n";
    }
    return 0;
}
  • 复杂度:\(O(n)\) 每次,总 \(O(\sum n)\),空间 \(O(n)\)。

4. 总结

  • 核心考点:回文对称性的约束转化 + 贪心双指针 + 字典序。
  • 实现注意:
    • 两条分支(先取左/先取右)都必须完整跑一遍再比较字典序,不能只取"第一个可行";
    • 前半取端时必须判断"配对位置是否还在未取区间内"(l <= pv && pv <= r),防止取到已预留的位置;
    • 注意边界:预留区间初始为配对位置 \(p\) 本身,\(ql\)、\(qr\) 的扩展方向相反。
  • 得分档验证(本地官方数据):25 分版通过测试点 1~7,满分版通过全部 25 个测试点。

五、T4:交通规划(P7916)

1. 题目大意

\(n\) 行 \(m\) 列网格,每条网格边有非负边权;边界外有若干附加点(带边权、黑/白色)。每次询问:给网格内每个格点染黑或白,使"两端颜色不同的边"的边权和最小,输出最小值。

2. 部分分:35~60 分做法(测试点 1~12,\(n,m \le 100\),最小割建模)

  • 思路:把染色问题建模为最小割:
    • 每个格点和附加点作为一个网络节点;
    • 网格边、附加边作为无向边(两个方向容量均为边权)——割断一条无向边就等价于该边两端异色,代价为边权;
    • 黑色附加点连超级源 \(S\)(容量 \(\infty\)),白色附加点连超级汇 \(T\)(容量 \(\infty\))。
    • 则 \(S\)-\(T\) 最小割 = 使黑/白附加点必须分居两侧的最小"异色边权和",用 Dinic 最大流求解。
  • 说明:该做法能稳定通过 \(n,m \le 100\) 的所有测试点(1~12,共 60 分);对 \(n,m \le 500\) 的大数据因建图节点数达 \(2.5\times10^5\) 而超时/超内存,需要满分做法。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 10005;              // 格点数上限(n,m <= 100 的部分分)
const ll INF = (1LL << 60);

// 35~60 分做法:n, m <= 100(测试点 1~12)
// 把"给格点染黑白、最小化异色边权和"建模为最小割:
//   - 每个格点、每个附加点是网络中的一个节点;
//   - 网格边与附加边作为无向边(容量 = 边权);
//   - 黑色附加点连超级源(INF 容量),白色附加点连超级汇(INF 容量);
//   - 则 s-t 最小割 = 使黑/白附加点分居两侧的最小异色边权和。
// 用 Dinic 求最大流(= 最小割)。复杂度 O(V^2 E) 的 Dinic 上界,
// 对小规模网格(V <= 1e4)可运行;极限数据 n,m <= 500 需对偶图最短路。

struct Edge
{
    int to, rev;
    ll cap;
};

vector<Edge> G[MAXN];
int level[MAXN], iter[MAXN];

void addEdge(int u, int v, ll cap)
{
    G[u].push_back((Edge){v, (int)G[v].size(), cap});
    G[v].push_back((Edge){u, (int)G[u].size() - 1, 0});
}

// 无向边:两个方向容量均为 w
void addUndirected(int u, int v, ll w)
{
    addEdge(u, v, w);
    addEdge(v, u, w);
}

void bfs(int s)
{
    memset(level, -1, sizeof(level));
    queue<int> q;
    level[s] = 0;
    q.push(s);
    while (!q.empty())
    {
        int u = q.front();
        q.pop();
        for (size_t i = 0; i < G[u].size(); i++)
        {
            Edge &e = G[u][i];
            if (e.cap > 0 && level[e.to] < 0)
            {
                level[e.to] = level[u] + 1;
                q.push(e.to);
            }
        }
    }
}

ll dfs(int u, int t, ll f)
{
    if (u == t) return f;
    for (int &i = iter[u]; i < (int)G[u].size(); i++)
    {
        Edge &e = G[u][i];
        if (e.cap > 0 && level[u] < level[e.to])
        {
            ll d = dfs(e.to, t, min(f, e.cap));
            if (d > 0)
            {
                e.cap -= d;
                G[e.to][e.rev].cap += d;
                return d;
            }
        }
    }
    return 0;
}

ll maxflow(int s, int t)
{
    ll flow = 0;
    for (;;)
    {
        bfs(s);
        if (level[t] < 0) break;
        memset(iter, 0, sizeof(iter));
        ll f;
        while ((f = dfs(s, t, INF)) > 0) flow += f;
    }
    return flow;
}

int n, m, T;
int x1_[105][105], x2_[105][105];   // 水平边 / 垂直边权

inline int pid(int r, int c) { return (r - 1) * m + c; }

// 射线编号 p -> 边界格点坐标
void rayToPoint(int p, int &r, int &c)
{
    if (p <= m)          { r = 1; c = p; }
    else if (p <= m + n) { r = p - m; c = m; }
    else if (p <= 2 * m + n) { r = n; c = 2 * m + n - p + 1; }
    else                 { r = 2 * m + 2 * n - p + 1; c = 1; }
}

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

    cin >> n >> m >> T;
    for (int i = 1; i <= n - 1; i++)
        for (int j = 1; j <= m; j++)
            cin >> x1_[i][j];
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= m - 1; j++)
            cin >> x2_[i][j];

    while (T--)
    {
        int k;
        cin >> k;
        int V = n * m + k + 2;
        int s = n * m + k + 1, t = n * m + k + 2;
        for (int i = 0; i <= V; i++) G[i].clear();
        // 网格内部边(无向,容量 = 边权)
        for (int i = 1; i <= n - 1; i++)
            for (int j = 1; j <= m; j++)
                addUndirected(pid(i, j), pid(i + 1, j), x1_[i][j]);
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= m - 1; j++)
                addUndirected(pid(i, j), pid(i, j + 1), x2_[i][j]);
        // 附加点
        for (int j = 1; j <= k; j++)
        {
            ll x3;
            int p, col;
            cin >> x3 >> p >> col;
            int r, c;
            rayToPoint(p, r, c);
            int kp = n * m + j;
            if (col == 1) addEdge(s, kp, INF);      // 黑色附加点强制源侧
            else          addEdge(kp, t, INF);      // 白色附加点强制汇侧
            addUndirected(kp, pid(r, c), x3);       // 附加边
        }
        cout << maxflow(s, t) << "\n";
    }
    return 0;
}
  • 复杂度:Dinic \(O(V^2 E)\)(本题 \(V=nm+k\),\(E=O(V)\)),对 \(n,m \le 100\) 可运行。

3. 满分做法(测试点 1~20,\(n,m \le 500\),\(\sum k \le 50\))

  • 关键观察/性质:
    1. 最小割 = 对偶图最短路:平面图上的 \(s\)-\(t\) 最小割,等于其对偶图上从 \(s'\) 到 \(t'\) 的最短路。
    2. 对偶图构造:每个小方格作为对偶图内部节点;外部面被 \(2n+2m\) 条射线切成 \(2n+2m\) 段,每段一个节点并连成环(相邻段之间默认 0 权边)。内部相邻 cell 连边,权 = 原网格边权;边界 cell 连到外部段,权 = 边界原边权;射线 \(p\) 处若有附加点,则该处环边权 = 附加边权。
    3. 割路径端点(分界点):把附加点按射线编号排序后,相邻异色附加点之间的那段外部面才是黑白区域的分界,割路径必须从"白→黑"分界出发、在"黑→白"分界穿出。因此:白→黑分界记为 S(源),黑→白分界记为 T(汇);若所有附加点同色,答案直接为 0。
    4. 多路径配对:\(k>2\) 时最小割由多条互不相交的路径组成,它们在环形顺序上两两配对且不交叉,等价于环形括号匹配(区间 DP)。
  • 算法推导:
    1. 预处理对偶图(与询问无关);
    2. 每次询问:确定所有分界点 → 对每个 S 分界点跑一次 Dijkstra 到所有 T 分界点,得距离矩阵 \(g\);
    3. 把分界点按环序复制成 \(2 \times cnt\) 长,做环形括号匹配 DP:\(dp[i][j] = \min(dp[i+1][j-1] + g(i,j),\ \min_l dp[i][l] + dp[l+1][j])\);
    4. 答案 \(= \min_i dp[i][i+cnt-1]\)。
  • 代码:
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int N = 505;
const ll INF = 1e16;

// 满分做法:平面图最小割 -> 对偶图最短路
// 原问题"给 n*m 个格点染黑白、最小化异色边权和(边界附加点颜色固定)"
// 等价于求一个最小割:黑色附加点必须与白色附加点分离。
// 平面图中 s-t 最小割 = 对偶图最短路:
//   - 对偶图节点 = 每个小方格(cell) + 外部被 2n+2m 条射线切成的段;
//   - 相邻 cell 之间连边,权 = 共享原网格边权;
//   - 边界 cell 连到外部段,权 = 边界原边权;
//   - 外部段连成环(默认 0 权),射线 p 处若有附加点,则该环边权 = 附加边权。
// 每次询问:附加点按射线编号排序,相邻异色对(白->黑 / 黑->白)是
// 割路径的端点(分界点)。对每个"白->黑"分界点跑 Dijkstra 到所有
// "黑->白"分界点,得到两两距离,再做一个环形括号匹配 DP 配对即可。
// 复杂度 O(sum_k * nm log(nm) + k^3)。

int head[N * N], tot = 1;      // 链式前向星,tot 从 1 开始(利用 i^1 找反向边)
int n, m, Q;
int sPt[N], tPt[N];            // 分界点集合:S = 白->黑,T = 黑->白
int cSeq[N], cId[N * N];       // 环形分界点序列及其编号
int cnts, cntt, cnt;
int idx;                       // 外部段节点数 = 2(n+m)
bool vis[N * N];
ll dis[N * N];
ll dp[N][N], g[N][N];          // g[i][j]:分界点 i 到 j 的最短路;dp:环形匹配

struct Attach
{
    ll x;      // 附加边权
    int p;     // 射线编号
    int t;     // 颜色(0 白,1 黑)
    bool operator < (const Attach &rhs) const
    {
        return p < rhs.p;
    }
} a[N];

struct Edge
{
    int v, nxt;
    ll w;
} e[(N * N) * 8];

inline int cellId(int i, int j) { return (i - 1) * m + j; }

void add(int u, int v, ll w)
{
    e[++tot] = (Edge){v, head[u], w};
    head[u] = tot;
    e[++tot] = (Edge){u, head[v], w};
    head[v] = tot;
}

void dij(int st)
{
    for (int i = 0; i < N * N; i++) dis[i] = INF;
    memset(vis, false, sizeof(vis));
    priority_queue<pair<ll, int> > q;
    dis[st] = 0;
    q.push(make_pair(0, st));
    while (!q.empty())
    {
        int u = q.top().second;
        q.pop();
        if (vis[u]) continue;
        vis[u] = true;
        for (int i = head[u]; i; i = e[i].nxt)
        {
            int v = e[i].v;
            if (dis[v] > dis[u] + e[i].w)
            {
                dis[v] = dis[u] + e[i].w;
                q.push(make_pair(-dis[v], v));
            }
        }
    }
}

// 环形下标
inline int cir(int x) { return (x - 1) % cnt + 1; }

void solveOne()
{
    int k;
    cin >> k;
    cnt = cnts = cntt = 0;
    // 重置外部段环边的权值(内部 cell 之间的边权不变)
    for (int i = 1; i <= idx; i++)
        e[i << 1].w = e[i << 1 | 1].w = 0;
    for (int i = 1; i <= k; i++)
    {
        cin >> a[i].x >> a[i].p >> a[i].t;
        // 射线 p 处的环边权 = 附加点权(无附加点时该边为 0)
        e[a[i].p << 1].w = e[a[i].p << 1 | 1].w = a[i].x;
    }
    sort(a + 1, a + k + 1);
    // 相邻异色附加点 -> 分界点(含环绕 a[1] 与 a[k])
    // 白->黑 记为 S,黑->白 记为 T,分界点取"后一个附加点"所在射线
    if (a[1].t == 1 && a[k].t == 0) sPt[++cnts] = a[1].p, cSeq[++cnt] = a[1].p;
    if (a[1].t == 0 && a[k].t == 1) tPt[++cntt] = a[1].p, cSeq[++cnt] = a[1].p;
    for (int i = 2; i <= k; i++)
    {
        if (a[i].t == 1 && a[i - 1].t == 0) sPt[++cnts] = a[i].p, cSeq[++cnt] = a[i].p;
        if (a[i].t == 0 && a[i - 1].t == 1) tPt[++cntt] = a[i].p, cSeq[++cnt] = a[i].p;
    }
    if (cnts == 0) { cout << "0\n"; return; }   // 所有附加点同色
    sort(cSeq + 1, cSeq + cnt + 1);
    for (int i = 1; i <= cnt; i++) cId[cSeq[i]] = i;
    memset(g, 0x7f, sizeof(g));
    memset(dp, 0x7f, sizeof(dp));
    // 每个 S 分界点跑一次最短路,得到到所有 T 分界点的距离
    for (int i = 1; i <= cnts; i++)
    {
        dij(sPt[i]);
        for (int j = 1; j <= cntt; j++)
            g[cId[sPt[i]]][cId[tPt[j]]] = g[cId[tPt[j]]][cId[sPt[i]]] = dis[tPt[j]];
    }
    // 环形括号匹配 DP(S 与 T 在环上交替出现)
    for (int i = 1; i < (cnt << 1); i++)
        dp[i][i + 1] = g[cir(i)][cir(i + 1)];
    for (int len = 4; len <= cnt; len += 2)
        for (int i = 1; i <= (cnt << 1) - len + 1; i++)
        {
            int j = i + len - 1;
            dp[i][j] = dp[i + 1][j - 1] + g[cir(i)][cir(j)];
            for (int l = i + 1; l < j; l += 2)
                dp[i][j] = min(dp[i][j], dp[i][l] + dp[l + 1][j]);
        }
    ll ans = INF;
    for (int i = 1; i <= cnt; i++)
        ans = min(ans, dp[i][i + cnt - 1]);
    cout << ans << "\n";
}

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

    cin >> n >> m >> Q;
    idx = (n + m) * 2;
    // 外部段节点连成环(默认 0 权)
    for (int i = 1; i < idx; i++) add(i, i + 1, 0);
    add(idx, 1, 0);
    // 水平边 x1[i][j]:(i,j)-(i+1,j),i=1..n-1, j=1..m
    for (int i = 1; i < n; i++)
        for (int j = 1; j <= m; j++)
        {
            ll x;
            cin >> x;
            if (j == 1)             // 最左列:cell(i,1) 连外部段
                add(idx + cellId(i, j), idx - i + 1, x);
            else if (j == m)        // 最右列:cell(i,m-1) 连外部段
                add(idx + cellId(i, j - 1), m + i + 1, x);
            else                    // 内部:cell(i,j-1) 与 cell(i,j)
                add(idx + cellId(i, j - 1), idx + cellId(i, j), x);
        }
    // 垂直边 x2[i][j]:(i,j)-(i,j+1),i=1..n, j=1..m-1
    for (int i = 1; i <= n; i++)
        for (int j = 1; j < m; j++)
        {
            ll x;
            cin >> x;
            if (i == 1)             // 最上行:cell(1,j) 连外部段
                add(idx + cellId(i, j), j + 1, x);
            else if (i == n)        // 最下行:cell(n-1,j) 连外部段(注意编号 2m+n)
                add(idx + cellId(i - 1, j), 2 * m + n - j + 1, x);
            else                    // 内部:cell(i-1,j) 与 cell(i,j)
                add(idx + cellId(i - 1, j), idx + cellId(i, j), x);
        }
    while (Q--) solveOne();
    return 0;
}
  • 复杂度:预处理 \(O(nm)\);每次询问 \(O(k \cdot nm \log(nm) + k^3)\);总 \(O(\sum k \cdot nm \log(nm) + \sum k^3)\)。

4. 总结

  • 核心考点:平面图最小割与对偶图最短路的转化、外部面分段处理、环形区间 DP 配对。
  • 实现注意:
    • 边界 cell 连外部段的编号要仔细推导(左下角附近容易把 2m+n 写成 2m+2n 之类);
    • 链式前向星 tot 从 1 开始,边权按 i<<1 / i<<1|1 成对重置;
    • 同色附加点直接输出 0;
    • \(g\) 与 \(dp\) 数组初始化为极大值(\(0x7f\) 按字节填充后为 \(1.4\times10^{18}\) 量级,够用)。
  • 得分档验证(本地官方数据):网络流版通过测试点 1~12(60 分),满分版通过全部 20 个测试点。

六、整体总结与反思

6.1 策略回顾

题号 预期目标 达成手段 实测得分档
T1 廊桥分配 满分 贪心 + 堆 + 前缀和 100(20/20 测试点)
T2 括号序列 五档递进冲满分 区间 DP(15 枚举 / 40 朴素 O(n⁴) / 65 前缀和优化 / 75 特殊性质长度 DP / 100 满分) 100(20/20 测试点)
T3 回文 满分 回文约束贪心 + 双指针 100(25/25 测试点)
T4 交通规划 部分分起步 网络流 60 分 → 对偶图最短路满分 100(20/20 测试点)

6.2 知识盲区与经验教训

  1. T1:贪心正确性要"敢想"。本题最关键的 40 分跳板是"廊桥服务链独立"这一性质——一旦接受"给某区 k 个廊桥的答案只依赖 k",解法立刻变成前缀和。比赛时遇到这种"分配类"问题,先固定一侧、独立计算往往是突破点。
  2. T2:区间 DP 的计数去重是重灾区。本题反复踩坑的两个点:一是遗漏空括号 () 形态;二是 \(h\)(星号+块)的"空星号分支"引用同层 \(dp\) 导致的循环依赖/顺序错误(本报告的档位三前缀和优化也因此必须按层重建)。凡是计数型区间 DP,务必用暴力枚举 + 独立文法判定做小数据对拍,本报告中的 15 分版正是为此准备的。
  3. T3:把"回文"翻译成可模拟的约束。"配对位置必须预留到对称步取"一句即可引出双指针全解;字典序需要两条分支都跑完再比较。
  4. T4:平面图最小割的"对偶化"是一整套套路。外部面分段(2n+2m 个段节点)、分界点(相邻异色)、环形括号匹配,三步缺一不可。即使不能完整想出,网络流建模(黑连源、白连汇、边作容量)也能稳定拿到 60 分,这是本场最重要的"保分手段"。
  5. 通用经验:
    • 每题先写暴力/部分分验证思路,再上数据结构优化;
    • 计数题必对拍,图论题必测边界(同色、k=2、n=m=1 等);
    • 数组开全局、按数据范围上限取模,避免 int 溢出;
    • 考试时间分配上,T1/T3 优先满分,T2 用部分分保底,T4 留足时间给网络流建模。

一句话总结:CSP-S 2021 是一套"贪心友好、DP 细腻、图论压轴"的试卷——T1/T3 考观察力,T2 考转移的正确性与去重,T4 考知识面的深度。把部分分写满、把对拍养成习惯,是稳定拿高分的关键。

posted @ 2026-08-18 17:24  surprise_ying  阅读(114)  评论(0)    收藏  举报