[CSP-S 2019] 括号树

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

DFS 维护根到当前节点路径上的未匹配左括号栈,统计每个节点新增的以当前点结尾的合法括号子串数。

OJ: luogu

题目 ID: P5658

难度:提高+/省选-

标签:树形结构动态规划

日期: 2026-07-06 08:28

题意

有一棵以 1 为根的树,每个节点上有一个括号,可能是 (,也可能是 )

对每个节点 i,把根到 i 的路径上的括号按顺序拼起来,得到字符串 s_i

k_i 表示 s_i 中有多少个不同位置的子串是合法括号串。题目要求输出:

(1k1)(2k2)(nkn) (1k_1)\oplus(2k_2)\oplus\cdots\oplus(nk_n)

这里的“不同子串”按位置区分。也就是说,即使两个子串内容都等于 (),只要起止位置不同,也算两个不同子串。

思路

先看一个最直接的暴力做法:

cpp
// 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 中的合法括号子串可以分成两类:

  1. 原来就在 s_fa 中的合法括号子串;
  2. 新增的、必须以 u 结尾的合法括号子串。

于是设:

  • end_count[u]s_u 中以最后一个字符,也就是节点 u 结尾的合法括号子串数量;
  • total_count[u]s_u 中所有合法括号子串数量,也就是题目里的 k_u

那么一定有:

total_count[u]=total_count[fa]+end_count[u] total\_count[u] = total\_count[fa] + end\_count[u]

现在问题变成:如何快速求 end_count[u]

当前字符是左括号

如果节点 u 上是 (,那么没有合法括号串能以它结尾。

所以:

end_count[u]=0 end\_count[u] = 0

当前字符是右括号

如果节点 u 上是 ),它想形成合法括号串,必须先在根到 u 的路径上找到一个还没有被匹配掉的 (

设这个匹配到的左括号节点是 p

那么最短的一个新增合法串是:

text
p ... u

这贡献 1

更进一步,如果在 p 的父亲位置还能接上一个“以 parent[p] 结尾的合法括号串”,那么拼起来仍然合法:

text
[前面一个合法括号串] + [p ... u]

所以新增数量是:

end_count[u]=end_count[parent[p]]+1 end\_count[u] = end\_count[parent[p]] + 1

这一步是本题最关键的转移。

为什么只接 end_count[parent[p]]

因为一个以 u 结尾的合法括号串,最后一段一定是由 pu 这对括号包住或闭合出来的。

如果它前面还要拼接别的合法串,那么这个合法串必须紧贴在 p 前面,也就是必须以 parent[p] 这个位置结尾。

不能随便接 total_count[parent[p]],因为 total_count 里包含许多更早结束的子串,它们和 [p ... u] 中间隔着字符,拼不成一个连续子串。

用栈维护能匹配的左括号

沿着 DFS 当前路径走时,我们维护一个栈:

text
open_stack = 当前根到 u 的路径上,尚未被匹配的左括号节点

进入一个节点时:

  • 如果是 (,就把当前节点压栈;
  • 如果是 ) 且栈非空,就弹出栈顶左括号 p,用它和当前右括号匹配;
  • 如果是 ) 且栈为空,说明当前右括号不能作为任何新增合法串的结尾。

因为树有分叉,DFS 离开当前节点时必须恢复栈:

  • 进入时压入的 (,离开时弹出;
  • 进入时为了匹配 ) 弹出的 (,离开时要压回去。

这样每个节点进入一次、离开一次,整个过程是线性的。

样例过程

样例中括号串为:

text
1:'('  2:'('  3:')'  4:'('  5:')'

父亲关系为:

text
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

所以异或结果是:

3×15×1=6 3 \times 1 \oplus 5 \times 1 = 6

代码

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

复杂度

  • 时间复杂度:O(n)O(n)。每个节点只进入、离开各一次。
  • 空间复杂度:O(n)O(n)。需要存树、DP 数组、DFS 事件栈和当前路径上的左括号栈。

总结

这题难在不要真的去枚举路径上的所有子串。

核心拆法是:

text
当前节点的总答案 = 父亲的总答案 + 以当前节点结尾的新增合法串数量

当当前节点是 ) 且匹配到左括号 p 时:

end_count[u]=end_count[parent[p]]+1 end\_count[u] = end\_count[parent[p]] + 1

这个式子只看“紧贴在 p 前面、并且以 parent[p] 结尾”的合法串,因此用的是 end_count,不是 total_count

实现上,DFS 维护未匹配左括号栈,并在回溯时恢复现场,就能把每条根到节点路径都正确处理一遍。

本文的 main.cpp 已用逐路径枚举子串的 brute.cpp 进行 300 组随机树对拍。