[CSP-S 2023] 消消乐

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

把每个前缀消除后的栈状态做成 trie,可消除子串数等于相同状态的前缀对数。

OJ: luogu

题目 ID: P9753

难度:提高+/省选-

标签:字符串数据结构模拟数学

创建: 2026-07-06 08:46

更新: 2026-10-07 10:45

形式化题目

给定一个长度为 nn、只含小写字母的字符串 ss。

一次操作可以删去一对相邻且相同的字符,剩下的字符拼接在一起。如果一个字符串能经过若干次操作变成空串,就称它是可消除的。

求 ss 的非空连续子串中,可消除的子串个数。

样例 11 的 s=accabccbs=\texttt{accabccb},可消除的子串是 cc、acca、cc、bccb、accabccb,共 55 个。

暴力解法

思路

直接枚举所有非空连续子串,对每个子串模拟一遍消除:从左到右扫描,栈顶与当前字符相同就弹栈,否则压栈。扫描完后栈为空即为可消除。

模拟时不用真的反复找“相邻相同对”,因为消除顺序不影响最终结果,一次线性扫描的栈模拟就是最终结果。

代码

cpp
/**
 * 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;
}

复杂度

枚举子串 O(n2)O(n^2) 个,每个模拟一次 O(n)O(n),总时间复杂度 O(n3)O(n^3),空间 O(n)O(n)。

瓶颈

nn 最大 2×1062\times10^6,O(n3)O(n^3) 绝对不可行。真正的困难是:子串数量本身就是 O(n2)O(n^2),所以必须一次性统计,不能逐个子串判断。

正解

思路

前缀消除状态

对每个前缀 s[0..i]s[0..i] 做一次栈模拟,得到的栈内容记为 R(i)R(i)。R(i)R(i) 一定是一个没有相邻相同字符的串(否则还能继续消)。

比如 s=accabccbs=\texttt{accabccb}:

ii s[i]s[i] R(i)R(i)
−1-1 无 ε\varepsilon
00 a a
11 c ac
22 c a
33 a ε\varepsilon
44 b b
55 c bc
66 c b
77 b ε\varepsilon

关键观察:可消除子串就是两端状态相同的区间

断言:子串 s[j+1..i]s[j+1..i] 可完全消除,当且仅当 R(j)=R(i)R(j)=R(i)。

证明。记 ρ(x)\rho(x) 为串 xx 消除后的结果。栈模拟的可结合性给出

ρ(xy)=ρ(ρ(x) y). \rho(xy)=\rho(\rho(x)\,y).

(⇒\Rightarrow)若 ρ(s[j+1..i])=ε\rho(s[j+1..i])=\varepsilon,则

R(i)=ρ(s[0..i])=ρ(s[0..j]⋅s[j+1..i])=ρ(R(j))=R(j). R(i)=\rho(s[0..i])=\rho\bigl(s[0..j]\cdot s[j+1..i]\bigr)=\rho\bigl(R(j)\bigr)=R(j).

(⇐\Leftarrow)反过来,把每个字母看成自己的逆元(x⋅x=εx\cdot x=\varepsilon),约化后的串恰好构成一个群,乘法就是“拼接后约化”。于是

ρ(R(j)⋅s[j+1..i])=R(j) \rho\bigl(R(j)\cdot s[j+1..i]\bigr)=R(j)

两边左乘 R(j)R(j) 的逆元,得到 ρ(s[j+1..i])=ε\rho(s[j+1..i])=\varepsilon。 ■\blacksquare

换句话说:从栈 R(j)R(j) 出发处理 s[j+1..i]s[j+1..i],最后又回到 R(j)R(j),就说明这一段自己在空栈上也一定消得干干净净。

于是问题变成“数相同状态的对”

设状态 uu 在所有前缀中出现了 cuc_u 次,答案就是

∑u(cu2). \sum_u \binom{c_u}{2}.

所以只要能给每个 R(i)R(i) 一个“身份编号”,并统计每个编号出现次数,就能 O(n)O(n) 求出答案。

用 trie 表示消除状态

难点在于:从 R(i−1)R(i-1) 到 R(i)R(i) 有两条路——

  • 栈顶与 s[i]s[i] 相同:弹栈,R(i)R(i) 就是 R(i−1)R(i-1) 去掉栈顶;
  • 否则压栈,R(i)R(i) 是 R(i−1)R(i-1) 后面接上 s[i]s[i]。

所以状态之间是父子关系,天然构成一棵树:把每个状态看成结点,parent_node[u] 是它去掉栈顶字符后的状态,node_char[u] 是它的栈顶字符。

“在状态 uu 后追加字符 cc”就是找 uu 的孩子中栈顶字符为 cc 的那个结点;没有就新建。根结点 00 表示空栈。这样一个状态最多 2626 个孩子,查找很快。

最后扫一遍:维护当前状态 cur,每处理一个字符就转到新状态,答案累加 seen_count[cur],再把它加一。样例中的过程:

ii s[i]s[i] 新状态 本次新增
00 a a 00
11 c ac 00
22 c a 11(cc)
33 a ε\varepsilon 11(acca)
44 b b 00
55 c bc 00
66 c b 11(cc)
77 b ε\varepsilon 22(bccb、accabccb)

合计 55,与样例一致。

代码

cpp
/**
 * 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;
}

复杂度

  • 时间复杂度:O(26n)O(26n)。每个字符要么压栈要么弹栈,压栈时在至多 2626 个孩子里查找。
  • 空间复杂度:O(n)O(n)。每个前缀最多新建一个结点,结点总数不超过 n+1n+1。

总结

这题的关键是把“这个子串能不能消除”翻译成“两个前缀的消除状态是否相同”:

s[j+1..i] 可消除  ⟺  R(j)=R(i). s[j+1..i]\text{ 可消除}\iff R(j)=R(i).

于是问题从“逐个子串判定”变成“统计相同状态的对数”,复杂度从 O(n3)O(n^3) 降到 O(n)O(n)。

状态本身是一棵由“压栈 / 弹栈”生成的树,用 trie 表示后,转移就是找孩子或退到父亲,不需要真的维护字符串。

值得注意的是这个消除过程背后是一个群结构(每个字母是自己的逆元),这正是断言里“左乘可消去”的来源。

本文的 main.cpp 已用栈模拟的 brute.cpp 对拍 600600 组随机数据(含 22 字母表与 2626 字母表),并对二元字母表上长度不超过 88 的全部 510510 个串做了穷举核对,题面样例通过。