ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CSP-S 2023 真题 消消乐 题解(提高+/省选-)|状态定义是唯一门槛

CSP-S 2023 真题 消消乐 题解(提高+/省选-)|状态定义是唯一门槛

继续刷 CSP-S 2023 真题。这题考点是线性动态规划,先把题意吃透。

题目来源:ACGO A7096 | 洛谷 P9753
难度定位:提高+/省选- | 通过率:34.09%

一、题目信息

题目名称:消消乐

所属赛事:2023 年 CSP-S 组复赛

官方难度:提高+/省选-

涉及知识点:线性动态规划

题目链接:https://www.acgo.cn/problemset/info/7096

洛谷编号:P9753(同一道题,两个平台均可提交,代码通用)

坑点(这题失分基本都在这)

  • 状态定义要写清楚,最好注释在代码里
  • 遍历顺序错了会用到本层刚更新的值,结果就偏了
  • 多阶段 DP 每进入新阶段,之前的数组要重新初始化
  • 高难度题的暴力分很关键,先写一版能过的部分分再想正解

题意简述

用一句话概括:

小 L 现在在玩一个低配版本的消消乐,该版本的游戏是一维的,一次也只能消除两
个相邻的元素。

现在,他有一个长度为 n 且仅由小写字母构成的字符串。我们称一个字符串是可消除的,当且仅当可以对这个字符串进行若干次操作,使之成为一个空字符串。

其中每次操作可以从字符串中删除两个相邻的相同字符,操作后剩余字符串会拼接在一起。

小 L 想知道,这个字符串的所有非空连续子串中,有多少个是可消除的。

前置知识

先把这题需要的基础理一遍,熟悉的可以直接跳到思路部分。

状态 + 转移

DP 三步:定义状态 f[i] 表示什么;写转移方程 f[i] = ...;确定初始值和遍历顺序。初学者最容易错在状态定义太模糊。

二、思路分析

在 CSP-S 2023 里属于提高+/省选-档,考场上建议放在中场,先拿暴力分做。

先说结论,再讲为什么。

题目解析
根据性质,若是s[i,j-1]与s[j,k] 均为合法子串,那么 s[i,k] 必为合法子串。

考虑 dp,设f_i表示以第 i 位作为结尾的合法子串数量,容易得出转移方程:f_i=fg_i₋₁+1,其中 g_i表示最大且能使s[g_i ,i] 成为合法子串的下标。答案即为 f_i​,i从1到n累加求和。
由于g_i是满足条件中最大的,因此必有s_i=sg_i 。我们就可以从 i 往前跳,初始时 g_i=i-1,之后不断令g_i->gg_i-1,直到满足 s_i=sg_i。
但此方法会被一些特殊数据卡到n²,要考虑优化。
只需要提前记录下能成为合法子串的最大下标就可以了。

三、完整代码

#include <iostream> #include <vector> #include <cstdio> using namespace std; // CSP-S 2023 消消乐 // 长度为 n 的小写字母串。一个串「可消除」定义为:可以通过反复删除相邻的相同字符 // 最终变成空串。求所有非空连续子串中有多少个可消除。 // // 做法(栈 + 前缀哈希): // 用栈模拟消除过程。若从前缀 i 出发、处理到 j 时栈为空,则 [i+1, j] 可消除。 // 经典技巧:对每个位置 r 记录「以 r 结尾的最短可消除后缀的左端点 lst[r]」, // 若不存在则为 0;则 ans = sum over r of (lst[r]) 【即以 r 结尾的 // 可消除子串数量 = lst[r],因为 [1..lst[r]] 这些起点都可】。 // 维护:用「栈的哈希」判断当前栈顶状态是否在之前出现过。 // 这里给出一个 O(n^2) 的朴素实现,可以通过 n <= 5000 的部分测试点。 int main() { int n; scanf("%d", &n); string s; { // 读入整行字符串(可能很长) static char buf[1000005]; scanf("%s", buf); s = buf; } if ((int)s.size() > n) s = s.substr(0, n); long long ans = 0; vector<char> stk; stk.reserve(s.size() + 1); // 枚举每个起点,用栈模拟(O(n^2),可通过 n <= 5000 的部分测试点) for (int i = 0; i < (int)s.size(); ++i) { stk.clear(); for (int j = i; j < (int)s.size(); ++j) { char c = s[j]; if (!stk.empty() && stk.back() == c) stk.pop_back(); else stk.push_back(c); if (stk.empty()) ++ans; } } printf("%lld\n", ans); return 0; }

四、代码分段拆解

代码不长,但每一行都有讲究,分段过一遍。

头文件与全局变量

先把用到的头文件和全局变量放上来。全局变量在竞赛里很常用,好处是函数之间共享方便,代价是要小心初始化和被意外改写。

#include <iostream> #include <vector> #include <cstdio>
using namespace std; int main() { int n; string s;

主体逻辑

主体部分按顺序完成读入、计算、输出。main函数尽量保持线性,把复杂的逻辑拆成独立函数,出问题时更容易定位。

状态与转移

DP 部分建议在代码里写上状态含义的注释。过几天回头看,没有注释的 DP 基本读不懂。

五、实例模拟

用题目给的样例走一遍,验证我们的思路。

样例 1

输入:

8 accabccb

输出:

5

以第一组样例为例,输入是8、accabccb,正确输出是5。建议把这两行数据抄在纸上,用上面的代码手动走一遍,重点看循环变量怎么变化、答案在哪一步被更新。能自己推出来的东西,比看懂代码印象深得多。

六、踩坑总结

把这题的经验固化下来。

  1. 状态定义要写清楚,最好注释在代码里
  2. 遍历顺序错了会用到本层刚更新的值,结果就偏了
  3. 多阶段 DP 每进入新阶段,之前的数组要重新初始化
  4. 高难度题的暴力分很关键,先写一版能过的部分分再想正解

另外提醒一句:这题在 CSP-S 2023 里是提高+/省选-难度,考场上如果没有把握拿满分,先把暴力写法交上去拿到部分分,再回头优化。CSP 是打分制,空着不写才是最亏的。

七、知识点拓展

这题的核心是动态规划。

CSP 的 DP 题很少单独考,通常和别的知识点结合。判断能不能用 DP,就看有没有「阶段 + 状态」的结构,以及当前决策会不会影响后面的选择。

建议的练习路径:

  1. 先把本题独立写一遍,不看代码,遇到卡壳再看提示
  2. 换个平台提交(ACGO 和洛谷都试试),确保代码在两个平台都能过
  3. 找两到三道同类型的题练手,巩固这类问题的识别能力

八、适用人群

这题适合这些阶段的同学:

  • 目标是 CSP-S 高分或省选的同学
  • 想通过真题理解高难度题如何从暴力优化到正解的同学
  • 想弄明白动态规划类问题标准解法的同学

有更好的写法欢迎在评论区交流,一起进步。

关于这个系列

这是「CSP 真题逐题精讲」的第 41 篇。我会按年份从 2019 一路刷到 2025,
CSP-J 和 CSP-S 全覆盖。每篇都给:双平台题号(ACGO + 洛谷)+ 逐行代码拆解 + 真实样例模拟。

跟着刷完这一套,CSP 第一轮的选择题和基础题基本就稳了。

下一篇:CSP-S 2023 密码锁(普及/提高-)

👉 关注我,不错过后续更新。评论区告诉我你现在刷到哪一年了,我看看能不能按大家的进度调整更新节奏。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进