DFS 维护根到当前节点路径上的未匹配左括号栈,统计每个节点新增的以当前点结尾的合法括号子串数。
OJ: luogu
题目 ID: P5658
难度:提高+/省选-
标签:树形结构栈动态规划
日期: 2026-07-06 08:28
题意
有一棵以 1 为根的树,每个节点上有一个括号,可能是 (,也可能是 )。
对每个节点 i,把根到 i 的路径上的括号按顺序拼起来,得到字符串 s_i。
设 k_i 表示 s_i 中有多少个不同位置的子串是合法括号串。题目要求输出:
这里的“不同子串”按位置区分。也就是说,即使两个子串内容都等于 (),只要起止位置不同,也算两个不同子串。
思路
先看一个最直接的暴力做法:
// brute.cpp:小数据暴力解,逐个构造根到节点的括号串,再枚举所有子串检查是否合法。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 105;
int n;
string bracket_string;
int parent_node[MAXN];
bool is_valid(const string &s, int l, int r) {
int balance = 0;
for (int i = l; i <= r; i++) {
if (s[i] == '(') {
balance++;
} else {
balance--;
}
if (balance < 0) {
return false;
}
}
return balance == 0;
}
string build_path_string(int u) {
vector<int> nodes;
while (u != 0) {
nodes.push_back(u);
u = parent_node[u];
}
reverse(nodes.begin(), nodes.end());
string s;
for (int i = 0; i < (int)nodes.size(); i++) {
s.push_back(bracket_string[nodes[i] - 1]);
}
return s;
}
long long count_valid_substrings(const string &s) {
long long count_answer = 0;
int len = (int)s.size();
for (int l = 0; l < len; l++) {
for (int 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_string;
for (int i = 2; i <= n; i++) {
cin >> parent_node[i];
}
long long answer = 0;
for (int i = 1; i <= n; i++) {
string path_string = build_path_string(i);
long long k_i = count_valid_substrings(path_string);
answer ^= 1LL * i * k_i;
}
cout << answer << '\n';
return 0;
}brute.cpp 对每个节点都构造一次根到它的路径字符串,然后枚举所有子串,再判断这个子串是不是合法括号串。
这个做法很贴近题意,但复杂度太高。n 最大到 5 * 10^5,不能反复构造路径,更不能枚举每条路径上的所有子串。
把问题拆成“旧的 + 新增的”
从父亲 fa 走到当前节点 u 时,路径字符串只是在末尾多了一个字符。
因此 s_u 中的合法括号子串可以分成两类:
- 原来就在
s_fa中的合法括号子串; - 新增的、必须以
u结尾的合法括号子串。
于是设:
end_count[u]:s_u中以最后一个字符,也就是节点u结尾的合法括号子串数量;total_count[u]:s_u中所有合法括号子串数量,也就是题目里的k_u。
那么一定有:
现在问题变成:如何快速求 end_count[u]?
当前字符是左括号
如果节点 u 上是 (,那么没有合法括号串能以它结尾。
所以:
当前字符是右括号
如果节点 u 上是 ),它想形成合法括号串,必须先在根到 u 的路径上找到一个还没有被匹配掉的 (。
设这个匹配到的左括号节点是 p。
那么最短的一个新增合法串是:
p ... u这贡献 1。
更进一步,如果在 p 的父亲位置还能接上一个“以 parent[p] 结尾的合法括号串”,那么拼起来仍然合法:
[前面一个合法括号串] + [p ... u]所以新增数量是:
这一步是本题最关键的转移。
为什么只接 end_count[parent[p]]
因为一个以 u 结尾的合法括号串,最后一段一定是由 p 和 u 这对括号包住或闭合出来的。
如果它前面还要拼接别的合法串,那么这个合法串必须紧贴在 p 前面,也就是必须以 parent[p] 这个位置结尾。
不能随便接 total_count[parent[p]],因为 total_count 里包含许多更早结束的子串,它们和 [p ... u] 中间隔着字符,拼不成一个连续子串。
用栈维护能匹配的左括号
沿着 DFS 当前路径走时,我们维护一个栈:
open_stack = 当前根到 u 的路径上,尚未被匹配的左括号节点进入一个节点时:
- 如果是
(,就把当前节点压栈; - 如果是
)且栈非空,就弹出栈顶左括号p,用它和当前右括号匹配; - 如果是
)且栈为空,说明当前右括号不能作为任何新增合法串的结尾。
因为树有分叉,DFS 离开当前节点时必须恢复栈:
- 进入时压入的
(,离开时弹出; - 进入时为了匹配
)弹出的(,离开时要压回去。
这样每个节点进入一次、离开一次,整个过程是线性的。
样例过程
样例中括号串为:
1:'(' 2:'(' 3:')' 4:'(' 5:')'父亲关系为:
1 -> 2, 1 -> 3, 2 -> 4, 2 -> 5几个关键节点如下:
| 节点 | 路径串 | 匹配情况 | end_count |
total_count |
|---|---|---|---|---|
| 1 | ( |
不能以 ( 结尾 |
0 | 0 |
| 2 | (( |
不能以 ( 结尾 |
0 | 0 |
| 3 | () |
3 匹配 1 |
end_count[0] + 1 = 1 |
1 |
| 4 | ((( |
不能以 ( 结尾 |
0 | 0 |
| 5 | (() |
5 匹配 2 |
end_count[1] + 1 = 1 |
1 |
所以异或结果是:
代码
// main.cpp:用路径上的未匹配左括号栈,线性统计每个节点对应字符串中的合法括号子串数。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005;
struct Event {
int type; // 0 表示进入节点,1 表示离开节点并恢复栈
int u;
};
int n;
char bracket_char[MAXN];
int parent_node[MAXN];
int head[MAXN], to[MAXN], nxt[MAXN], edge_cnt;
long long end_count[MAXN]; // end_count[u]:根到 u 的字符串中,以 u 结尾的合法括号子串数量
long long total_count[MAXN]; // total_count[u]:根到 u 的字符串中所有合法括号子串数量
int action_type[MAXN]; // 1:进入时压入左括号;2:进入时弹出了一个左括号
int matched_open[MAXN]; // 当前右括号匹配到的左括号节点
vector<int> open_stack; // 当前根到节点路径上尚未匹配的左括号节点
void add_edge(int u, int v) {
edge_cnt++;
to[edge_cnt] = v;
nxt[edge_cnt] = head[u];
head[u] = edge_cnt;
}
void enter_node(int u, long long &answer) {
action_type[u] = 0;
matched_open[u] = 0;
end_count[u] = 0;
if (bracket_char[u] == '(') {
open_stack.push_back(u);
action_type[u] = 1;
} else if (!open_stack.empty()) {
int left_node = open_stack.back();
open_stack.pop_back();
matched_open[u] = left_node;
action_type[u] = 2;
// 形成一对 ( ... ) 后,可以接在 left_node 父亲处结尾的合法串后面。
end_count[u] = end_count[parent_node[left_node]] + 1;
}
total_count[u] = total_count[parent_node[u]] + end_count[u];
answer ^= 1LL * u * total_count[u];
}
void leave_node(int u) {
if (action_type[u] == 1) {
open_stack.pop_back();
} else if (action_type[u] == 2) {
open_stack.push_back(matched_open[u]);
}
}
long long solve() {
long long answer = 0;
vector<Event> events;
events.push_back({0, 1});
while (!events.empty()) {
Event cur = events.back();
events.pop_back();
int u = cur.u;
if (cur.type == 0) {
enter_node(u, answer);
events.push_back({1, u});
for (int e = head[u]; e != 0; e = nxt[e]) {
events.push_back({0, to[e]});
}
} else {
leave_node(u);
}
}
return answer;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
string s;
cin >> s;
for (int i = 1; i <= n; i++) {
bracket_char[i] = s[i - 1];
}
for (int i = 2; i <= n; i++) {
cin >> parent_node[i];
add_edge(parent_node[i], i);
}
cout << solve() << '\n';
return 0;
}复杂度
- 时间复杂度:
。每个节点只进入、离开各一次。 - 空间复杂度:
。需要存树、DP 数组、DFS 事件栈和当前路径上的左括号栈。
总结
这题难在不要真的去枚举路径上的所有子串。
核心拆法是:
当前节点的总答案 = 父亲的总答案 + 以当前节点结尾的新增合法串数量当当前节点是 ) 且匹配到左括号 p 时:
这个式子只看“紧贴在 p 前面、并且以 parent[p] 结尾”的合法串,因此用的是 end_count,不是 total_count。
实现上,DFS 维护未匹配左括号栈,并在回溯时恢复现场,就能把每条根到节点路径都正确处理一遍。
本文的 main.cpp 已用逐路径枚举子串的 brute.cpp 进行 300 组随机树对拍。
