CSP-S 2025 第二轮比赛题解

作者: | 更新日期:

图的剪枝、AC自动机、王之钦定trick

本文首发于公众号:天空的代码世界,微信号:tiankonguse

零、背景

目前我已经做了历年 CSP-J/S 的所有常规算法题目。
CSP-J 题解:2025202420232022202120202019
CSP-S 题解:20242023202220212020

后来对历年题型做了总结,《CSP-J 题型分析》和《CSP-S 题型分析》。

最后也分享了备考策略与常见算法,如 备考策略:得分技巧环境准备
常见算法:二分线段树状态最短路动态规划数学常见算法和数据结构

今天是 2025 年 11 月 2 日,CSP-S 第二轮算法比赛已于昨日结束;下面分享我的题解。

第一题,难度为普及+/提高,后悔贪心,难度还好,即使不会可以得到 70 分。
第二题,难度为提高+/省选−,剪枝或者多路合并,就会卡主不少人,不过可以得 80 分。
第三题,难度为省选/NOI−,哈希与 AC 自动机会简单一些,暴力可以得 80 分。
第四题,难度为省选/NOI−,三维动态规划,难度非常大,属于 noi 级别的题目了,暴力只能得 28 分。

这样看来,暴力可以得 70+80+80+28=258 分。

PS1:我觉得第四条应该有 NOI 级别了,毕竟即使是三维动态规划,还涉及到排列组合,太复杂了。
PS2:比赛pdf题目已经上传网盘,有需要的可以关注公众号号,回复 CSP-S-2025 获取。
PS3:后面我会基于这次比赛的题型,来展开介绍最小生成树、字符串、动态规划等相关算法专题。

一、社团招新(club)

题意:我们学校的算法协会招募了 n 名新成员,其中 n 是一个偶数。
现在需要将这些新成员分配到协会的三个部门中。
每个新成员对这三个部门都有一个满意度得分。

我们的目标是找到一个分配方案,使得所有成员的满意度之和最大。
但是,分配有一个限制条件:任何一个部门分配到的新成员数量都不能超过 n/2
简而言之,就是要在限制每个部门人数不超过总人数一半的前提下,最大化总满意度。

最优解:后悔贪心

思路1:动态规划

状态定义:dp[i][j][k] 表示前 i 名新成员中,分配到部门 1 的人数为 j、分配到部门 2 的人数为 k 时的最大满意度(注意部门 3 的人数为 i - j - k)。

状态转移方程:第 i 个人枚举分配到哪个部门(下面用 j,k 表示部门 1/2 的人数,c 表示部门 3):

// 假设 c = i - j - k
dp[i][j][k] = -INF; // 或者初始化为 -1 表示不可达
// 选择 A(部门1)
if (j > 0 && dp[i - 1][j - 1][k] != -1) {
  dp[i][j][k] = max(dp[i - 1][j - 1][k] + a1, dp[i][j][k]);
}
// 选择 B(部门2)
if (k > 0 && dp[i - 1][j][k - 1] != -1) {
  dp[i][j][k] = max(dp[i - 1][j][k - 1] + a2, dp[i][j][k]);
}
// 选择 C(部门3)
int c = i - j - k;
if (c > 0 && dp[i - 1][j][k] != -1) {
  dp[i][j][k] = max(dp[i - 1][j][k] + a3, dp[i][j][k]);
}

答案:枚举所有情况,求最大值。

for (int a = 0; a <= n; a++) {
  for (int b = 0; b <= n; b++) {
    ans = max(ans, dp[n][a][b]);
  }
}

复杂度:O(n^3)
得分:55 分

特殊性质 A:a2 和 a3 全为 0 思路:在这种情况下只需从 a1 中选择最大的 n/2 个,其余分配为 0。 得分:60 分

// 特殊性质 A: a2 和 a3 全为 0
if (sum2 == 0 && sum3 == 0) {  
  // a1 中选择最大的 n/2 个,其他选 0
  sort(a.begin(), a.end(), greater<tuple<ll, ll, ll>>());
  ll ans = 0;
  for (int i = 0; i < n / 2; i++) {
    ans += get<0>(a[i]);
  }
  printf("%lld\n", ans);
}

特殊性质 C:独立均匀随机生成 思路:当样本是独立均匀随机生成时,直接对每个人选择三项中的最大值(不考虑 n/2 限制)通常能取得较好结果。 得分:70 分

// 特殊性质 C:贪心选择
ll ans = 0;
for (auto& [a1, a2, a3] : a) {
  ans += max(a1, max(a2, a3));
}
printf("%lld\n", ans);

最优解:后悔贪心

根据容忍与鸽巢原理,可以得到几个性质。

性质1:最多有一个部门的人数超过 n/2(若两部门都超过 n/2,则两者之和 > n,矛盾)。 性质2:如果将某个超过 n/2 的部门通过调整(按最小代价把多余成员转出)降到 n/2,则调整结束后不会让其他部门超过 n/2(因为总人数固定为 n)。

根据上面的两个性质,可以先贪心,在反悔贪心。

贪心:不考虑 n/2 限制,贪心选择最大值。
反悔:对于超过 n/2 的人,选择一些返回选择这个部门,改为选择其他部门(选择哪个不重要,根据性质2其他部门不会超过限制)。

所以这里就需要决策哪些人反悔。

定义反悔代价:假设当前部门的满意度是 a1,另外两个部门的满意度是 a2 和 a3,那么反悔代价就是 a1 - max(a2, a3)

显然,需要选择反悔代价最小的那些人,直到该部门人数不超过 n/2
复杂度:O(n log(n))
得分:100 分

vector<ll> g[3];
// g[0].push_back(a1 - max(a2, a3));
// 输入与贪心选择,代码省略

// 后悔贪心, 找到超过 n/2 的部门 i
int sz = g[i].size();
sort(g[i].begin(), g[i].end());
for (int j = 0; j < sz - n / 2; j++) {
  ans -= g[i][j];
}

二、道路修复(road)

题意:我们有一个由 N 个城市和 M 条双向道路构成的交通系统。
最初,所有 M 条道路都已损坏,修复第 i 条道路的费用是 w_i
此外,还有 K 个乡镇可以进行城市化改造。对第 j 个乡镇进行城市化改造的费用是 c_j
改造后,可以在该乡镇和原有的 N 个城市之间修建道路,乡镇 j 到城市 i 的修建费用是 a_{j,i}

我们的目标是:选择任意多个乡镇进行城市化改造(可以选择 0 个)。
修复一些原有道路,并修建一些新的乡镇-城市道路。
最终使得原有的 N 个城市两两连通。
求出达成这一目标的最小总费用(乡镇改造费 + 道路修复/新建费)。

最优解:最小生成树+剪枝

思路1:暴力枚举

暴力枚举所有乡镇的选择方案,合并所有边,排序,最后求最小生成树。

关于如何枚举所有子集,我之前在《动态规划》文章的状态压缩小节介绍过。

const int MASK = (1 << k) - 1;
for (int sub = MASK; sub; sub = (sub - 1) & MASK) {
  townSelectEdges = baseEdges;
  ll subCost = 0;
  int selectNum = 0;
  for (int i = 0; i < k; i++) {
    if ((sub >> i) & 1) {
      selectNum++;
      // 选择第 i 个乡镇
      subCost += townCosts[i];
      townSelectEdges.insert(townSelectEdges.end(), 
                             townEdgeCosts[i].begin(),
                             townEdgeCosts[i].end());
    }
  }
  sort(townSelectEdges.begin(), townSelectEdges.end());
  ll totalCost = MinimumSpanningTree(selectNum, subCost, townSelectEdges);
  ans = min(ans, totalCost);
}

复杂度:O(2^K * (M + K*N) * log(E))
得分:56 分(逆天,暴力的分数竟然这么高)

优化1:原图中的边集合优化

显然,加入乡村后,原图的最小生成树未被选择的边,以后也永远不会被选择。
即只有最小生成树的边,未来才会被选择。

所以,第一次求最小生成树时,可以记录下被选中的边。
枚举乡村时,直接使用被选中的边,边的数量就会从 M 降低到 N-1

复杂度:O(2^K * (N + K*N) * log(E))
得分:80 分

const int MASK = (1 << k) - 1;
for (int sub = MASK; sub; sub = (sub - 1) & MASK) {
  townSelectEdges = selectEdges;
}

优化2:乡镇的边集合优化
假设原图最小生成树的最大边权为 maxW
乡镇新增边中权重大于 maxW 的边显然不会被选入最终最优解,因此可以剪枝过滤这些边。

证明:假设答案生成树中存在这样一个边权 maxW + ε(ε>0)
删除这个乡镇的边权后,答案生成树就变成两个联通分支。
之后,可以在原图最小生成树的边集合中,选择某个边,使得两个联通分支连通,从而得到更小的生成树。
故,假设不成立,不存在这样的边权。

有了这个性质后,可以在枚举乡镇时,直接过滤掉大于 maxW 的边。
复杂度:O(2^K * (N + K*E') * log(E')),其中 E' 是过滤后的边数。
得分:100 分

PS: 可以发现,数据比较弱,这个边权优化并没有降低最坏复杂度,但是可以拿到满分了。

const int MASK = (1 << k) - 1;
for (int sub = MASK; sub; sub = (sub - 1) & MASK) {
  baseEdges.clear();
  ll subCost = 0;
  for (int i = 0; i < k; i++) {
    if ((sub >> i) & 1) {
      // 选择第 i 个乡村
      subCost += townCosts[i];
      for (int j = 0; j < n; j++) {
        auto [w, v] = townEdgeCosts[i][j];
        int u = n + i;
        if (w > maxEdge) break;      // 剪枝优化
        baseEdges.push_back({w, u, v});
      }
    }
  }
  sort(baseEdges.begin(), baseEdges.end());
  ll totalCost = MinimumSpanningTree(subCost, baseEdges);
  ans = min(ans, totalCost);
}

优化3:多路合并最小堆

上面复杂度比较高的原因是我们需要合并 原树与 k 个乡村的边集,然后排序。
最后才能从小到大遍历这些边,通过并查集来计算出最小生成树。

如果我们能不合并也能从小到大遍历这些边呢?

多个集合,如何快速选出一个最小值?
这不就是文件的多路排序吗?

具体来说,每个集合预处理保证有序。
假设当前选择了 k 个乡镇,维护一个大小为 k+1 的最小堆,堆的值为 {w, listIndex, listOffset},表示第 listIndex 个集合的第 listOffset 个元素,值为 w

好吧,这个优化最坏复杂度依旧没有降低。
不过少了合并与排序,常数会降低的比较低。

min_queue<tuple<ll, int, int>> que; // {w, listIndex, listOffset}
//多路合并,每次弹出堆顶元素,并将对应集合的下一个元素加入堆
tuple<ll, int, int> PopQue() {
  const auto [w, k, index] = que.top();
  que.pop();
  int edgesSize = townEdgeCosts[k].size();
  if (index + 1 < edgesSize) {
    que.push({get<0>(townEdgeCosts[k][index + 1]), k, index + 1});
  }
  return townEdgeCosts[k][index];
}

// 基于多路合并的最小生成树
ll MinimumSpanningTree(ll cost) {
  dsu.Init(n + K);
  int groupNum = n + K;
  while (!que.empty()) {
    auto [w, u, v] = PopQue();
    if (dsu.Find(u) != dsu.Find(v)) {
      groupNum--;
      dsu.Union(u, v);
      cost += w;
    }
    if (cost >= ans || groupNum == 1) {
      return cost; // 剪枝
    }
  }
  return cost;
}

三、谐音替换(replace)

题目:小 W 作为一名语言学爱好者,将“谐音替换”现象抽象成一个字符串问题。
核心操作是:给定 n 种替换规则 (s_{i,1}, s_{i,2}),其中 s_{i,1}s_{i,2} 长度相等。 对于一个字符串 s,如果它的一个子串 y 等于某个 s_{i,1},就可以将这个子串 y 替换成对应的 s_{i,2},从而得到一个新的字符串 s'

目标是:回答 q 个询问,每个询问给定两个不同的字符串 t_{j,1}t_{j,2},求 t_{j,1} 经过恰好一次替换得到 t_{j,2} 的不同方案数。

方案数定义:
两种替换方案不同,当且仅当:被替换的子串 y 的起始位置不同。
用于替换的二元组 (s_{i,1}, s_{i,2}) 的下标 i 不同。

最优解:AC自动机+哈希
PS: 据说也可以 Trie+哈希,后面我研究下在分享。

首先是理解题意并进行预处理,分析之后,可以发现,每个二元组可以拆分为三部分:前缀A + 替换部分B + 后缀C
其中前缀A和后缀C是不变的,只有替换部分B会变化。

此时可以发现,不可能存在相同的方案数。
因为如果一个二元组可以发生替换,替换的位置是固定的,这已经满足二元组下标不同的要求了。

二元组如何分组呢?
可以发现,替换部分的二元组 {B-S1, B-S2} 是用来分组的唯一标识。
字符串操作成本比较大,所以可以合并后使用哈希值作为标识。

unordered_map<ll, vector<pair<A, B>>> mp;
ll h = hash("B-S1", "$", "B-S2");
mp[h].push_back({A, C});  

之后,对于每个询问二元组 T1, T2,先拆分为 TA, TB, TC
然后合并替换比分计算出哈希值 h,在 mp 中找到对应的分组。

这时候,需要找到所有的二元组 {SA,SC},使得 SATA 的后缀,SCTC 的前缀。

两个子串不方便匹配,所以一般使用特殊字符连接后匹配。

这个时候,带匹配的二元组就是 SA#SC,带匹配的查询串就是 TC#TA,需要询问 SA#SC 是否是 TC#TA 的子串。

子串问题,可以使用 kmp 来做。
一次匹配复杂度为 O(|TC| + |TA| + |SA| + |SC|)
总复杂度为 O(Q * (|TC| + |TA|) + Σ(|SA| + |SC|))
得分:75 分

// 设 text 为构造的查询串(例如 text = TA + "#" + TC)
ll res = 0;
for (const string& pat : mp[h]) {
  if (KMP::kmp(text.c_str(), pat.c_str(), pat.length()) != -1) {
    res++;
  }
}
printf("%lld\n", res);

可以发现,匹配时对 SA#SC 重复计算了很多次 next 数组。
所以可以预处理所有 SA#SC 的 next 数组。
得分:80 分。

// 使用预处理过的 next 数组进行匹配(text 为查询串)
ll res = 0;
for (const auto& [pat, nextVec] : mp[h]) {
  if (KMP::kmp(text.c_str(), pat.c_str(), pat.length(), nextVec) != -1) {
    res++;
  }
}
printf("%lld\n", res);

PS:直接使用 strstr 竟然也是 80 分。

vector<string>& patterns = mp[h];
ll res = 0;
for (const string& pat : patterns) {
  if (strstr(text.c_str(), pat.c_str()) != nullptr) {
    res++;
  }
}
printf("%lld\n", res);

多模式匹配,其实就是 AC 自动机的模版题,直接套 AC 自动机模版即可。
得分:100 分。

scanf("%d%d", &n, &q);
for (int i = 0; i < n; i++) {
  scanf("%s%s", s1, s2);
  int len = strlen(s1);
  ll h = MergeS1S2(len);
  if (ACIndex.count(h) == 0) {
    ACIndex[h] = AC::NextNode(); // 创建一个 AC 自动机
  }
  // 插入 AC 自动机(插入构造出来的 pattern,例如 pattern = SA + "#" + SC)
  // string pattern = SA + "#" + SC;
  AC::insert(ACIndex[h], pattern.c_str());
}

// 构建 AC 自动机
for (auto& [_, root] : ACIndex) {
  AC::build(root);
}

// q 个询问
while (q--) {
  scanf("%s%s", s1, s2);
  int len1 = strlen(s1);
  int len2 = strlen(s2);
  if (len1 != len2) {
    printf("0\n");  // 长度不同,直接输出 0
    continue;
  }
  ll h = MergeS1S2(len1);
  if (ACIndex.count(h) == 0) {
    printf("0\n");  // 不存在该模式串,直接输出 0
    continue;
  }
  int root = ACIndex[h];
  // text 为查询串,例如 text = TA + "#" + TC
  ll res = AC::query(root, text.c_str());
  printf("%lld\n", res);
}

四、员工招聘(employ)

题意:想象一下小 Z 和小 H 正在招聘员工。一共有 n 个人来应聘,他们希望至少能录用 m 个人。

面试会持续 n 天,每天面试一个人,而小 Z 需要决定这 n 个人的面试顺序。
小 H 准备了 n 套面试题,题目的难度是固定的(由一个 n 长度的字符串 s 决定),其中 ‘1’ 代表简单(所有应聘者都能做出并被录用),’0’ 代表困难(所有应聘者都做不出并被拒绝)。

关键在于应聘者的耐心: 每个人 i 有一个耐心上限 c[i]​。
如果在他来面试之前,已经被拒绝或放弃面试的人数达到了 c[i]​,那么这个人就会直接放弃面试。

目标: 找出有多少种不同的面试顺序(即 1∼n 的排列 p)能够最终录用至少 m 个人。
答案需要对 998244353 取模。

最优解:动态规划。

这道题是一个复杂的计数问题,涉及排列、组合和动态约束条件(耐心上限)。

思路1:暴力排列组合
得分: 8分

vector<int> P;  // 排列,标记是否选择
ll Dfs(int p, int cnt) {  // cnt 代表之前已经选择了 cnt 个员工
  if (p == n + 1) {
    return cnt >= m ? 1 : 0;
  }

  ll ret = 0;
  const int c = (p - 1) - cnt;    // 前 p-1 天中未被录用的人数(即已被拒绝或放弃的人数)
  for (int i = 1; i <= n; i++) {  // 第 p 天选择一个员工
    if (P[i] == 0) {              // 员工 i 未被选择,尝试选择
      P[i] = 1;                   // 选择员工 i
      if (S[p] == '1' && c < C[i]) {
        ret += Dfs(p + 1, cnt + 1);
      } else {
        ret += Dfs(p + 1, cnt);
      }
      P[i] = 0;  // 回溯,取消选择员工 i
    }
  }
  return ret;
}

if (n <= 10) {
  P.resize(n + 1, 0);
  ans = 0;
  Dfs(1, 0);
  printf("%lld\n", ans);
  return;
}

特殊性质 A: 对于所有 i,均有 s[i]=1
思路:全排列,都是答案
得分: 12 分

PS: 有三组 case,其他组存在 C[i] 为 0,所以只得到一组的分。

if (sum == n) {
  // 特殊性质 A: 对于所有 i,均有 s[i]=1。
  ans = 1;
  for (int i = 1; i <= n; i++) {
    ans = (ans * i) % Comb::mod;
  }
  printf("%lld\n", ans);
  return;
}

贪心:如果必然未录取的员工不足 m 个,直接返回 0。
得分:16 分

// 这里 C0 与 sum 在实现中需要提前计算:
// C0: 已确定不会被录取的人数(例如耐心为 0 的人);
// sum: S 中 '1' 的数量(即可能被录取的人数)。
if (n - C0 < m || sum < m) {
  printf("0\n");
  return;
}

性质:n=18,显然可以使用状态压缩。

状态定义:dp[day][mask][nopass] 表示在第 day 天,选择的员工集合为 mask,且已有 nopass 人未通过/已参与面试的方案数。
得分:28 分。

状态转移方程:枚举第 i 天的员工的结果。

int dp[19][1 << 18][19];

int Dfs(int day, int mask, int nopass) {  //
  // 状态记忆与出口...
   
  ret = 0;
  // 枚举第 day 天选择的员工
  for (int i = 0; i < n; i++) {
    if (mask & (1 << i)) {                   // 员工 i 被选择
      const int prevMask = mask ^ (1 << i);  // 去掉员工 i
      int prevNoPass = nopass;
      if (S[day] == '0') {  // 这天不可能招聘通过
        ret = (ret + Dfs(day - 1, prevMask, prevNoPass - 1)) % Comb::mod;
      } else {
        if (prevNoPass < C[i + 1]) {  // 假设这名员工通过
          ret = (ret + Dfs(day - 1, prevMask, prevNoPass)) % Comb::mod;
        }
        if (prevNoPass > 0 && prevNoPass - 1 >= C[i + 1]) { // 假设这名员工未通过
          ret = (ret + Dfs(day - 1, prevMask, prevNoPass - 1)) % Comb::mod;
        }
      }
    }
  }

  return ret;
}

if (n <= 18) {
  ll ans = 0;
  memset(DP::dp, -1, sizeof(DP::dp));
  for (int pass = m; pass <= n; pass++) {
    ans = (ans + DP::Dfs(n, (1 << n) - 1, n - pass)) % Comb::mod;
  }
  printf("%lld\n", ans);
  return;
}

总结

针对 n=10的 case ,可以使用暴力排列组合。
针对 n=18的 case ,可以使用状态压缩动态规划。
针对必然没答案的情况,可以直接返回 0。
针对 s[i]=1 的特殊情况,可以直接计算全排列。
针对其他情况,n=500,显然需要三维动态规划。

三维动态规划比较复杂,我还在想怎么介绍,后面会单独写一篇文章介绍这道题的三维动态规划。

五、最后

这次比赛还是比较难的。

第一题,后悔贪心,难度还好,即使不会可以得到 70 分。
第二题,剪枝或者多路合并,就会卡主不少人,不过可以得 80 分。
第三题 ,哈希与 AC 自动机会简单一些,暴力可以得 80 分。
第四题,三维动态规划,难度非常大,属于 noi 级别的题目了,暴力只能得 28 分。
这样看来,暴力可以得 70+80+80+28=258 分。

《完》

-EOF-

本文公众号:天空的代码世界
个人微信号:tiankonguse
公众号 ID:tiankonguse-code

本文首发于公众号:天空的代码世界,微信号:tiankonguse
如果你想留言,可以在微信里面关注公众号进行留言。

关注公众号,接收最新消息

tiankonguse +
穿越