按 f_u<u 的编号顺序递推,用父结点继承的未匹配左括号栈,求以每个结点结尾的合法括号子串数。
OJ: luogu
题目 ID: P5658
难度:提高+/省选-
标签:树形结构栈动态规划
创建: 2026-07-06 08:28
更新: 2026-10-07 10:45
形式化题目
给定一棵以结点
记
其中
样例
flowchart TD
N1["1: ("] --> N2["2: ("]
N1 --> N3["3: )"]
N2 --> N4["4: ("]
N2 --> N5["5: )"]
从图中可以看到根到每个结点的路径:
暴力解法
思路
最直接的做法是照着题意把每个
判断合法性用括号匹配的经典判据:从左到右扫描,任意前缀的 ( 数都不少于 ) 数,且最后两者数量相等。
代码
/**
* 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 20:19
* update_at: 2026-10-01 20:39
*/
// brute.cpp:小数据暴力解,逐个构造根到结点的括号串,再枚举所有子串检查是否合法。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 105; // 暴力只用于小数据对拍,n 取到 100 左右
int n;
char bracket_char[MAXN]; // bracket_char[u]:结点 u 上的括号
int parent_node[MAXN]; // parent_node[u]:u 的父亲,根结点 1 的父亲记为 0
// 判断 s[l..r] 是否为合法括号串:任意前缀和非负,且最终总和为 0。
bool is_valid(const string &s, ll l, ll r) {
ll balance = 0;
for (ll i = l; i <= r; i++) {
if (s[i] == '(') {
balance++;
} else {
balance--;
}
if (balance < 0) {
return false;
}
}
return balance == 0;
}
// 拼出根到结点 u 的括号串:先按 u 到根的顺序取字符,再整体反转。
string build_path_string(int u) {
string path_string;
while (u != 0) {
path_string.push_back(bracket_char[u]);
u = parent_node[u];
}
reverse(path_string.begin(), path_string.end());
return path_string;
}
// 枚举 s 的所有子串,统计其中合法括号串的个数。
ll count_valid_substrings(const string &s) {
ll len = s.size();
ll count_answer = 0;
for (ll l = 0; l < len; l++) {
for (ll r = l; r < len; r++) {
if (is_valid(s, l, r)) {
count_answer++;
}
}
}
return count_answer;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
cin >> (bracket_char + 1); // 直接读到下标 1 开始,和结点编号对齐
for (int i = 2; i <= n; i++) {
cin >> parent_node[i];
}
ll answer = 0;
for (int u = 1; u <= n; u++) {
string path_string = build_path_string(u);
ll k_u = count_valid_substrings(path_string);
answer ^= u * k_u;
}
cout << answer << '\n';
return 0;
}复杂度
每个结点要拼一次路径,再枚举
瓶颈
这个做法把“不同子串按位置计数”完整做了一遍,思路最贴近题意,但连
- 每个结点都重新拼一遍路径;
- 每个结点都重新枚举一遍子串。
题目要求
正解
思路
把问题拆成“旧的 + 新增的”
从父亲
: 中以结点 结尾的合法括号子串数量; : 中全部合法括号子串数量,即题面的 。
于是
第一处瓶颈就此消除:父亲的答案可以直接复用,不必重新枚举。
关键观察:与右括号配对的左括号是唯一确定的
如果
如果
任意一个以 ) 配对的左括号必然就是
于是
这里必须用
让每个结点都有一份栈
现在只剩第二处瓶颈:如何快速找到
沿一条路径走时,未匹配左括号天然构成一个栈:遇到 ( 压入,遇到 ) 弹出栈顶。链上的情况可以维护一个全局栈,但树会分叉,回溯时恢复现场很麻烦。更干净的做法是让每个结点只记录栈顶,栈本身通过父亲方向共享:
stack_top[u]:处理完结点之后,根到 路径上的未匹配左括号栈顶结点编号( 表示栈空); stack_next[x]:结点在栈中的下一个结点,沿着它就能还原整条栈。
转移就很直接:
: 自己成为新栈顶, stack_top[u] = u,并令stack_next[u] = stack_top[f_u];且 stack_top[f_u] != 0:栈顶被匹配弹出, stack_top[u] = stack_next[p],同时取; 且栈空: stack_top[u] = 0,。
每个结点只会写入一次 stack_next[u](只有在它自己是 ( 时才写),而不同分支的结点各自持有自己的指针,因此共享前缀不会互相干扰,栈的总空间是
题目保证
样例推演
下表用样例
| 结点 |
匹配到的 |
stack_top[u] |
|||
|---|---|---|---|---|---|
( |
无 | ||||
( |
无 | ||||
) |
|||||
( |
无 | ||||
) |
最后
代码
/**
* 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 20:19
* update_at: 2026-10-01 20:39
*/
// main.cpp:题目保证 f_u < u,按结点编号从小到大递推,用两个数组模拟“根到当前结点的未匹配左括号栈”。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 500005; // n <= 5e5,结点编号用 int 足够,还能直接当数组下标
int n;
char bracket_char[MAXN]; // bracket_char[u]:结点 u 上的括号
int parent_node[MAXN]; // parent_node[u]:u 的父亲,根结点 1 的父亲记为 0
// 用两个数组表示根到 u 路径上“尚未匹配的左括号”栈:
// stack_top[u] 是 u 处栈顶结点的编号(0 表示栈空),
// stack_next[x] 是结点 x 在栈中的下一个结点,沿着它就能还原整条栈。
// 这样每个结点只需从父亲处继承栈顶,不必在回溯时恢复现场。
int stack_top[MAXN];
int stack_next[MAXN];
ll end_count[MAXN]; // end_count[u]:根到 u 的串中,以结点 u 结尾的合法括号子串数
ll total_count[MAXN]; // total_count[u]:根到 u 的串中全部合法括号子串数,即题面的 k_u
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
cin >> (bracket_char + 1); // 直接读到下标 1 开始,和结点编号对齐
for (int i = 2; i <= n; i++) {
cin >> parent_node[i];
}
ll answer = 0;
// f_u < u 保证按编号递增递推时,父亲一定已经算完,因此不需要 DFS。
for (int u = 1; u <= n; u++) {
int fa = parent_node[u];
if (bracket_char[u] == '(') {
// 左括号自己入栈,成为新的栈顶,没有合法括号串以它结尾。
end_count[u] = 0;
stack_next[u] = stack_top[fa];
stack_top[u] = u;
} else if (stack_top[fa] != 0) {
// 右括号与父亲路径上最近的未匹配左括号 left_node 配对,
// 再接到以 left_node 父亲结尾的合法串后面。
int left_node = stack_top[fa];
end_count[u] = end_count[parent_node[left_node]] + 1;
stack_top[u] = stack_next[left_node];
} else {
// 栈空,说明当前右括号配不到左括号,不能作为任何合法子串的结尾。
end_count[u] = 0;
stack_top[u] = 0;
}
total_count[u] = total_count[fa] + end_count[u];
answer ^= u * total_count[u];
}
cout << answer << '\n';
return 0;
}复杂度
- 时间复杂度:
。每个结点只处理一次,压栈、弹栈、递推都是 。 - 空间复杂度:
。需要存父亲、括号、两个栈数组和两个计数数组,均为 级别。
总结
这题的核心是不要把路径上的子串真的枚举出来,而是拆成
当
这个式子只看“紧贴在
实现上,用 stack_top 记录每个结点的栈顶、用 stack_next 共享栈结构,就不必在回溯时恢复现场,一次线性递推即可处理任意分叉的树。
本文的 main.cpp 已用逐路径枚举子串的 brute.cpp 对拍
