[CSP-S 2019] 括号树

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

形式化题目

给定一棵以结点 11 为根的树,共 nn 个结点,父亲满足 fu<uf_u<u。每个结点 uu 上有一个括号 cu∈{(,)}c_u\in\{\texttt{(},\texttt{)}\}。

记 sus_u 为根到 uu 的简单路径上的括号按经过顺序拼接得到的字符串。设 kuk_u 为 sus_u 中位置不同的合法括号子串个数,求

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

其中 ⊕\oplus 是位异或。所谓“位置不同”,是指子串的起止位置 (l,r)(l,r) 不同,即使内容相同也分别计数。

样例 11 的树如下,结点上标注了它对应的括号。

flowchart TD
  N1["1: ("] --> N2["2: ("]
  N1 --> N3["3: )"]
  N2 --> N4["4: ("]
  N2 --> N5["5: )"]

从图中可以看到根到每个结点的路径:s1=(s_1=\texttt{(},s2=((s_2=\texttt{((},s3=()s_3=\texttt{()},s4=(((s_4=\texttt{(((},s5=(()s_5=\texttt{(()}。只有 s3s_3 与 s5s_5 各含一个合法子串,所以答案是 3⊕5=63\oplus 5=6。

暴力解法

思路

最直接的做法是照着题意把每个 sus_u 都构造出来,再枚举它的所有子串,逐个判断是不是合法括号串。

判断合法性用括号匹配的经典判据:从左到右扫描,任意前缀的 ( 数都不少于 ) 数,且最后两者数量相等。

代码

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

复杂度

每个结点要拼一次路径,再枚举 O(n2)O(n^2) 个子串、每个子串 O(n)O(n) 判定,单结点是 O(n3)O(n^3),全部结点 O(n4)O(n^4),空间 O(n)O(n)。

瓶颈

这个做法把“不同子串按位置计数”完整做了一遍,思路最贴近题意,但连 n=2000n=2000 都跑不动。瓶颈有两处:

  1. 每个结点都重新拼一遍路径;
  2. 每个结点都重新枚举一遍子串。

题目要求 n⩽5×105n\leqslant 5\times10^5,这两处都必须消掉。

正解

思路

把问题拆成“旧的 + 新增的”

从父亲 fuf_u 走到 uu 时,路径字符串只在末尾多了一个字符。因此 sus_u 的合法括号子串要么原来就在 sfus_{f_u} 里,要么必须以 uu 结尾。设

  • end_count[u]end\_count[u]:sus_u 中以结点 uu 结尾的合法括号子串数量;
  • total_count[u]total\_count[u]:sus_u 中全部合法括号子串数量,即题面的 kuk_u。

于是

total_count[u]=total_count[fu]+end_count[u]. total\_count[u]=total\_count[f_u]+end\_count[u].

第一处瓶颈就此消除:父亲的答案可以直接复用,不必重新枚举。

关键观察:与右括号配对的左括号是唯一确定的

如果 cu=(c_u=\texttt{(},任何合法括号串都不可能以它结尾,直接有 end_count[u]=0end\_count[u]=0。

如果 cu=)c_u=\texttt{)},设 pp 是根到 uu 的路径上、uu 左边最近的未被匹配左括号,也就是对前缀 sus_u 做括号匹配时与 uu 配对的左括号。

任意一个以 uu 结尾的合法括号子串 TT,都对应路径上一段连续区间 [l,u][l,u]。TT 本身是合法括号串,而合法括号串内部的配对关系与在整个前缀 sus_u 中的配对关系一致,所以 TT 里与末尾那个 ) 配对的左括号必然就是 pp。

于是 T=[l,p]∪[p+1,u]T=[l,p]\cup[p+1,u]。其中 [p+1,u][p+1,u] 已经是一对合法括号,剩下的 [l,p−1][l,p-1] 也必须是一个以 fpf_p 结尾的合法括号串(或者为空)。以 fpf_p 结尾的合法括号串有 end_count[fp]end\_count[f_p] 个,再加上“[l,p−1][l,p-1] 为空、TT 就是 [p,u][p,u]”这一种,得到

end_count[u]=end_count[fp]+1. end\_count[u]=end\_count[f_p]+1.

这里必须用 end_count[fp]end\_count[f_p] 而不是 total_count[fp]total\_count[f_p]:能和 [p,u][p,u] 拼在一起的合法串必须紧贴在 pp 前面,因此必须以 fpf_p 结尾;total_count[fp]total\_count[f_p] 里那些更早结束的串中间隔着字符,拼不成连续子串。

让每个结点都有一份栈

现在只剩第二处瓶颈:如何快速找到 pp,而且不重复扫描路径前缀。

沿一条路径走时,未匹配左括号天然构成一个栈:遇到 ( 压入,遇到 ) 弹出栈顶。链上的情况可以维护一个全局栈,但树会分叉,回溯时恢复现场很麻烦。更干净的做法是让每个结点只记录栈顶,栈本身通过父亲方向共享:

  • stack_top[u]:处理完结点 uu 之后,根到 uu 路径上的未匹配左括号栈顶结点编号(00 表示栈空);
  • stack_next[x]:结点 xx 在栈中的下一个结点,沿着它就能还原整条栈。

转移就很直接:

  • cu=(c_u=\texttt{(}:uu 自己成为新栈顶,stack_top[u] = u,并令 stack_next[u] = stack_top[f_u];
  • cu=)c_u=\texttt{)} 且 stack_top[f_u] != 0:栈顶 pp 被匹配弹出,stack_top[u] = stack_next[p],同时取 end_count[u]=end_count[fp]+1end\_count[u]=end\_count[f_p]+1;
  • cu=)c_u=\texttt{)} 且栈空:stack_top[u] = 0,end_count[u]=0end\_count[u]=0。

每个结点只会写入一次 stack_next[u](只有在它自己是 ( 时才写),而不同分支的结点各自持有自己的指针,因此共享前缀不会互相干扰,栈的总空间是 O(n)O(n)。

题目保证 fu<uf_u<u,所以按 u=1,2,…,nu=1,2,\ldots,n 的顺序递推时父亲一定已经算完,连 DFS 都不需要。

样例推演

下表用样例 11 走一遍。约定 f1=0f_1=0,且 end_count[0]=total_count[0]=0end\_count[0]=total\_count[0]=0。

结点 uu cuc_u 匹配到的 pp stack_top[u] end_count[u]end\_count[u] total_count[u]total\_count[u]
11 ( 无 11 00 00
22 ( 无 22 00 00
33 ) 11 00 end_count[f1]+1=1end\_count[f_1]+1=1 total_count[1]+1=1total\_count[1]+1=1
44 ( 无 44 00 00
55 ) 22 11 end_count[f2]+1=1end\_count[f_2]+1=1 total_count[2]+1=1total\_count[2]+1=1

最后 (1⋅0)⊕(2⋅0)⊕(3⋅1)⊕(4⋅0)⊕(5⋅1)=3⊕5=6(1\cdot0)\oplus(2\cdot0)\oplus(3\cdot1)\oplus(4\cdot0)\oplus(5\cdot1)=3\oplus5=6,与样例一致。

代码

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

复杂度

  • 时间复杂度:O(n)O(n)。每个结点只处理一次,压栈、弹栈、递推都是 O(1)O(1)。
  • 空间复杂度:O(n)O(n)。需要存父亲、括号、两个栈数组和两个计数数组,均为 nn 级别。

总结

这题的核心是不要把路径上的子串真的枚举出来,而是拆成

total_count[u]=total_count[fu]+end_count[u]. total\_count[u]=total\_count[f_u]+end\_count[u].

当 cu=)c_u=\texttt{)} 且匹配到左括号 pp 时,

end_count[u]=end_count[fp]+1. end\_count[u]=end\_count[f_p]+1.

这个式子只看“紧贴在 pp 前面、并且以 fpf_p 结尾”的合法串,因此用的是 end_countend\_count 而不是 total_counttotal\_count。

实现上,用 stack_top 记录每个结点的栈顶、用 stack_next 共享栈结构,就不必在回溯时恢复现场,一次线性递推即可处理任意分叉的树。

本文的 main.cpp 已用逐路径枚举子串的 brute.cpp 对拍 400400 组随机数据(链与随机树各半)。