把每个前缀消除后的栈状态做成 trie,可消除子串数等于相同状态的前缀对数。
OJ: luogu
题目 ID: P9753
难度:提高+/省选-
标签:字符串数据结构模拟数学
创建: 2026-07-06 08:46
更新: 2026-10-07 10:45
形式化题目
给定一个长度为
一次操作可以删去一对相邻且相同的字符,剩下的字符拼接在一起。如果一个字符串能经过若干次操作变成空串,就称它是可消除的。
求
样例 cc、acca、cc、bccb、accabccb,共
暴力解法
思路
直接枚举所有非空连续子串,对每个子串模拟一遍消除:从左到右扫描,栈顶与当前字符相同就弹栈,否则压栈。扫描完后栈为空即为可消除。
模拟时不用真的反复找“相邻相同对”,因为消除顺序不影响最终结果,一次线性扫描的栈模拟就是最终结果。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-01 22:36
* update_at: 2026-10-01 22:36
*/
// brute.cpp:小数据暴力解,枚举所有子串并用栈模拟相邻相同字符的消除。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 105; // 暴力只服务小数据,n 取到 100 左右
int n;
string s;
// 判断 s[left..right] 能否被完全消除:扫描一遍,栈顶与当前字符相同就弹栈。
bool can_delete(int left, int right) {
string st;
for (int i = left; i <= right; i++) {
if (!st.empty() && st.back() == s[i]) {
st.pop_back();
} else {
st.push_back(s[i]);
}
}
return st.empty();
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> s;
// 依次枚举所有非空连续子串,能消除就计数。
ll answer = 0;
for (int l = 0; l < n; l++) {
for (int r = l; r < n; r++) {
if (can_delete(l, r)) {
answer++;
}
}
}
cout << answer << '\n';
return 0;
}复杂度
枚举子串
瓶颈
正解
思路
前缀消除状态
对每个前缀
比如
| 无 | ||
a |
a |
|
c |
ac |
|
c |
a |
|
a |
||
b |
b |
|
c |
bc |
|
c |
b |
|
b |
关键观察:可消除子串就是两端状态相同的区间
断言:子串
证明。记
(
(
两边左乘
换句话说:从栈
于是问题变成“数相同状态的对”
设状态
所以只要能给每个
用 trie 表示消除状态
难点在于:从
- 栈顶与
相同:弹栈, 就是 去掉栈顶; - 否则压栈,
是 后面接上 。
所以状态之间是父子关系,天然构成一棵树:把每个状态看成结点,parent_node[u] 是它去掉栈顶字符后的状态,node_char[u] 是它的栈顶字符。
“在状态
最后扫一遍:维护当前状态 cur,每处理一个字符就转到新状态,答案累加 seen_count[cur],再把它加一。样例中的过程:
| 新状态 | 本次新增 | ||
|---|---|---|---|
a |
a |
||
c |
ac |
||
c |
a |
cc) |
|
a |
acca) |
||
b |
b |
||
c |
bc |
||
c |
b |
cc) |
|
b |
bccb、accabccb) |
合计
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-01 22:36
* update_at: 2026-10-01 22:36
*/
// main.cpp:把每个前缀消除后的栈状态存成 trie 结点,用“同一状态出现次数”统计可消除子串。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 2000005; // n <= 2e6
int n;
string s;
// 每个结点代表一个“消除栈状态”,结点 0 表示空栈。
// parent_node[u]:状态 u 去掉栈顶字符之后的状态;
// node_char[u]:状态 u 的栈顶字符(只有非根结点才有意义)。
int parent_node[MAXN];
char node_char[MAXN];
// 孩子用链式前向星存:一个状态最多有 26 个孩子(追加不同字符各一个)。
int first_edge[MAXN], to_node[MAXN], next_edge[MAXN];
int node_cnt, edge_cnt;
ll seen_count[MAXN]; // seen_count[u]:状态 u 作为前缀消除结果出现过的次数
// 在状态 u 后追加字符 c 得到的状态;已经存在就复用,避免重复建结点。
int get_child(int u, char c) {
for (int e = first_edge[u]; e != 0; e = next_edge[e]) {
int v = to_node[e];
if (node_char[v] == c) {
return v;
}
}
node_cnt++;
parent_node[node_cnt] = u;
node_char[node_cnt] = c;
edge_cnt++;
to_node[edge_cnt] = node_cnt;
next_edge[edge_cnt] = first_edge[u];
first_edge[u] = edge_cnt;
return node_cnt;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> s;
ll answer = 0;
int cur = 0; // 当前前缀消除后的状态,0 表示空栈
seen_count[0] = 1; // 空前缀是一个合法起点
for (int i = 0; i < n; i++) {
char c = s[i];
if (cur != 0 && node_char[cur] == c) {
// 栈顶字符和新字符相同,两者一起消去,状态退到父亲。
cur = parent_node[cur];
} else {
// 否则把新字符压栈,得到一个新状态。
cur = get_child(cur, c);
}
// 子串 (j, i] 可完全消除,当且仅当前缀 j 与前缀 i 的消除状态相同。
answer += seen_count[cur];
seen_count[cur]++;
}
cout << answer << '\n';
return 0;
}复杂度
- 时间复杂度:
。每个字符要么压栈要么弹栈,压栈时在至多 个孩子里查找。 - 空间复杂度:
。每个前缀最多新建一个结点,结点总数不超过 。
总结
这题的关键是把“这个子串能不能消除”翻译成“两个前缀的消除状态是否相同”:
于是问题从“逐个子串判定”变成“统计相同状态的对数”,复杂度从
状态本身是一棵由“压栈 / 弹栈”生成的树,用 trie 表示后,转移就是找孩子或退到父亲,不需要真的维护字符串。
值得注意的是这个消除过程背后是一个群结构(每个字母是自己的逆元),这正是断言里“左乘可消去”的来源。
本文的 main.cpp 已用栈模拟的 brute.cpp 对拍