第一次看到这场训练的G题时,我心里其实有点犯嘀咕。倒不是说题目本身长得吓人,而是它的出题风格和通常的“压轴题套路”不太一样:没有一上来就给你一棵满二叉树的树剖,也没有花里胡哨的卷积形式,就是一棵很朴素的树,配上一个很直接的询问。但恰恰是这种“朴素”,最容易让人在推正解的时候产生“好像会了”的错觉,然后在代码里翻车。这次训练我花了大概一个半小时在这道题上,把暴力和正解都写了一遍,过程中踩了几个值得记录的坑,也把思路重新梳理了一遍,趁热分享出来。
1. 这道题到底在问什么:先把题意压缩到最简
G题的题干虽然写了一大段故事背景,但剥掉包装之后,核心诉求其实很干净。我这里用自己的话重新描述一遍,方便后面聊思路时不跑偏。
题目给了一棵n个节点的树,节点从1到n编号,每个节点上有一个整数权值a_i。接下来有q次询问,每次询问给出一个起点u和一个非负整数k,要求你找出从u出发,沿着树上的简单路径向上走(也就是朝根的方向走)恰好k步后到达的节点v,然后输出以v为根的子树中所有节点权值的某种聚合值。聚合方式题目里已经定义好了,这里不展开细节,你只需要知道它满足“可合并”的性质,也就是说我们需要一种方式把一棵子树的信息快速拼出来。
需要特别注意的是,这棵树的根是固定的,所以“向上走”的方向没有任何歧义,u的k级祖先一定是唯一的。如果u的深度小于k,那这次询问就属于非法情况,题目会要求你输出一个约定好的空值。
我第一次读完题后的第一反应是:这不就是“倍增求祖先 + 子树查询”的缝合怪吗?预处理出每个节点的2^j级祖先,然后对于每个询问先倍增跳到目标节点,再用 DFS 序把子树转成区间,最后套一个线段树或者树状数组就能做。这个思路本身没有任何问题,也是这题最直观的暴力解法。但如果只是这么写,这道题放在第三题都嫌简单,它能在 G 题的位置上,肯定有它的讲究。
真正的难点藏在数据范围里。题目没有给出特别宽松的约束,n和q的上限都在2 × 10^5级别,而且每个节点的权值范围很大,聚合操作还涉及顺序问题,不能简单地把两个子树的结果直接合并了事。你如果天真地认为“树状数组维护一下前缀和就行”,那只能说明还没有看清楚聚合操作对顺序的依赖。这一点后面细说。
2. 为什么不能直接倍增加树状数组:聚合操作的顺序依赖
很多人(包括我一开始)看到“向上走 k 步到达 v,然后查子树”就会下意识地开始写倍增和树状数组。但在动手之前,我多花了两分钟去想聚合操作本身,结果发现事情没那么简单。
假设题目要求的聚合操作是求子树内所有点权的最大值,那确实无脑,DFS 序加树状数组就是标准答案。但如果操作是“把子树内所有点权按某种顺序排成一个序列,然后做前缀异或”或者“维护一个有限状态自动机的转移”这类带顺序的东西,那么子树查询的关键就不只是“有哪些节点”,还包括“这些节点的访问顺序”。
G 题这次就给我挖了这么一个坑:它要求的聚合结果,和子树在 DFS 序中的区间位置是对应的,但因为操作不满足交换律,你不能简单地用两个前缀相减得到区间答案。这直接让“倍增求祖先 + 区间查询”的常规组合失去了意义。我看了不少人的赛后代码,发现他们大多都用了某种支持“动态合并子树信息”的数据结构,而不是常规的静态区间查询,原因就在这。
换句话说,这道题真正考察的点是:你能否按照题目要求的聚合方式,把一棵子树的完整信息维护出来,并在多次询问中快速响应。由于每个查询的v是变化的,你不能只做一次全局预处理就万事大吉,必须在查询时动态地拼出答案。
我当时重新读了三遍题目,才确认了聚合操作的不可交换性。确认完之后,原本想好的做法全部推翻,重新从暴力开始推。
3. 暴力的正确姿势:先保证小数据不出错
在推正解之前,我把暴力做法写得很保守,确保小数据能对。暴力的逻辑非常简单:对于每个询问(u, k),先循环k次、每次取u = fa[u],如果中途发现u已经变成0(表示不存在的父节点),就直接返回空值。找到v之后,再对以v为根的子树做一次 DFS,按题目要求的顺序收集信息并计算聚合结果。
代码量大约五十行,思路零技术含量,但它有非常大的价值:它是验证后续所有优化做法正确性的唯一标准。这里我给自己定了一个规矩——任何优化做法,都必须先通过和暴力对拍的方式验证,而不是靠“我推了一下应该没问题”这种盲目自信。
在写暴力的过程中,有几个细节值得注意:
k的取值可能很大,但暴力循环不会超时吗?不用担心,因为我只在小数据上跑,n和q都限制在10^3以内,暴力是能轻松跑完的。- 向上跳的过程中,父节点数组的边界处理一定要是“深度不够就返回空值”,不能想当然地用
fa[u][j] = 0来判断,因为0这个节点在邻接表里可能被当成合法节点存过。 - 聚合操作如果是不可交换的,那么收集子树信息时,必须严格按照子树内节点的某种顺序来遍历,比如 dfs 序的先后顺序,否则结果会出错。
暴力跑通之后,我拿着几组手造数据反复确认了输出,才放心开始推正解。这一步浪费不了多少时间,却能让你在后面对拍时节省大量时间。
4. 正解心路:从重链剖分想到重构树
4.1 第一步:借助树上倍增定位祖先节点
定位u的k级祖先是一个经典问题了,用倍增数组up[u][j]可以在O(log n)的时间内完成。做法是:预处理时,对每个节点u,令up[u][0]等于它的父节点,然后up[u][j] = up[up[u][j-1]][j-1]。查询的时候,把k拆成二进制位,逐位往上跳。
这个部分属于基础,但需要留意的是,如果你的题目中根节点的父节点设置成0,那么up[root][j]也全部是0,在查询时要注意判断v != 0,否则后续访问子树会出错。
4.2 第二步:子树信息动态合并的两种路线
定位到v之后,剩下来的问题就变成:如何快速获取v的子树聚合值。这里有两种主流路线,分别适合不同的情况。
路线一:平衡树/线段树维护 DFS 序区间。如果你能保证聚合操作满足交换律,那么直接对 DFS 序建一棵线段树,每个节点维护区间内所有节点的聚合值,查询时覆盖对应的区间即可。但 G 题不满足交换律,所以我们需要一个能严格按 DFS 序从左到右合并的区间查询结构。其实线段树是能做到这一点的,只要在build和query时,严格按照左儿子到右儿子的顺序合并即可。因为线段树天然按区间划分,query 时按左到右的区间顺序合并。也就是说,即便聚合操作不可交换,只要它满足结合律,线段树还是能做区间查询。
那问题来了:为什么不能直接上线段树?G 题的坑在于,v会随着查询变化,而每次查询的v是某棵子树,在 DFS 序上对应的是一段连续的区间。理论上,线段树查询区间[dfn[v], dfn[v] + size[v] - 1]是完全可行的。所以这条路其实没断,我之前说不行,是我一开始想用树状数组“前缀相减”导致的错误想法。线段树本身并没有受限。
但如果题目操作是“按 DFS 序顺序合并”的,线段树依然可以在O(log n)时间内完成查询。于是我在这一步重新审视:G 题是不是真的需要更复杂的数据结构?答案是不一定。
路线二:如果每个询问的 k 固定,可以利用树上启发式合并预处理。假设所有询问的k都相同,那我们可以一次 DFS 求出所有节点的k级祖先,然后对每个节点保存一个子树 DFS 序区间,最后一次性把所有询问离线处理,用莫队或者常规区间数据结构跑完。但 G 题的k是每个询问不同的,所以不能直接用这个思路。
也就是说,线段树的做法其实已经足够应对这题的大部分情况了。真正让我多花时间的,是题目中可能存在的另一个隐含条件——询问的k是否保证合法?以及聚合操作的复杂度是否足够小?如果聚合两个结果的时间是O(m),其中m是信息大小,那么线段树合并两个节点信息时,总复杂度就会从O(log n)变成O(m log n),这时就需要考虑信息量的大小了。
4.3 第三步:关于信息合并复杂度的思考
我在写到这步时,特意回去看了眼题目的输出要求:每个节点权值可能很大,而聚合结果的数据量远远小于子树大小。也就是说,合并两个聚合结果时,并不是要你把两个集合完整地拼起来,而是把两个已经压缩过的信息做一次“叠加”。如果这个叠加操作的代价是常数级,那么线段树查询就是O(log n)的。
但如果叠加操作的代价是O(size_of_info),而且这个size_of_info可能达到O(n),那问题就会变得非常棘手。因为每条链上的信息都要一路向上汇总,总复杂度可能退化到O(n^2)。
所以,判断一道题能不能用“子树区间查询 + 线段树”来解决,核心就看两步:
- 聚合操作是否具有结合律?
- 合并两个聚合信息的复杂度是否足够小?
把这个想清楚之后,我就不再纠结要不要写更复杂的数据结构了。G 题最终需要的是线段树,最多加上一点线段树动态开点或者离散化的细节。至于有些人可能提到的 “Link-Cut Tree” 或者 “树上启发式合并”,在2×10^5的数据范围下都不是必要的。
5. 线段树处理的几个分量:顺序、边界的调试实录
5.1 数据组织:DFS 序是唯一的分组依据
线段树方案的第一步,是把树的 DFS 序求出来。这里的 DFS 序我指的是“访问节点的顺序”,也就是dfn[u] = ++timer那种。然后对每个节点u,维护sz[u]表示子树大小。这样,以v为根的子树就映射到区间[dfn[v], dfn[v] + sz[v] - 1]。
在写 DFS 序的时候,有一个细节必须注意:如果你用的是递归 DFS,那么对于n = 2×10^5的树,递归深度可能会爆栈。我一开始没想太多,直接写了递归,结果在测试时拍出了栈溢出。解决办法有两个:一是把树改成用栈的迭代 DFS,二是把递归的栈空间通过编译器选项调大。个人推荐用迭代写法,更保险,也方便在 DFS 过程中同时处理up数组和sz。
5.2 线段树维护不可交换聚合的关键写法
既然聚合不可交换,pushUp 时就要严格按照“左儿子结果 + 右儿子结果”的顺序做聚合,不能因为懒就写成merge(val[ls], val[rs])或merge(val[rs], val[ls])然后不管了。看起来是小事,但对某些数据会直接导致结果错误。
在我写的模板里,我把聚合操作封装成一个函数:
Node merge(Node L, Node R) { // 按题目要求的顺序合并 L 和 R Node ret; ret.val = cal(L.val, R.val); return ret; }然后线段树的pushUp固定写成:
tr[p] = merge(tr[p << 1], tr[p << 1 | 1]);这样保证每个内部节点存储的都是“该区间从左到右依次计算后的聚合值”。
查询的时候,我采用了一个经典的小技巧:用一个bool标记表示当前是否已经收集过左半部分的信息,然后用一个变量res作为累积结果。
Node query(int p, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tr[p]; int mid = (l + r) >> 1; if (qr <= mid) return query(p << 1, l, mid, ql, qr); if (ql > mid) return query(p << 1 | 1, mid + 1, r, ql, qr); Node left = query(p << 1, l, mid, ql, qr); Node right = query(p << 1 | 1, mid + 1, r, ql, qr); return merge(left, right); }这个写法能保证查询区间被拆分成若干段时,严格按照从左到右的顺序合并,不会因为递归顺序导致区间顺序错乱。
5.3 倍增与线段树联调时遇到的隐蔽错误
我在调试时遇到一个很隐蔽的 bug,值得拿出来单独说说。
一开始我预处理倍增数组时,把up[u][0]写成fa[u],而fa[u]是在建树时通过父节点传入的。这个逻辑本身没有错,但我忽略了根节点的父节点是0,而0在邻接表数组中会被当做一个合法节点。于是当我查询一个深度不足k的询问时,倍增会把u跳到0,然后我又拿着dfn[0]和sz[0]去线段树里查,得到的是一个完全错误的结果。
调试的时候我发现,对于“深度不够”的询问,答案应该是约定的空值,但程序总是输出一个奇怪的数字。加上一行判断if (u == 0) return empty;之后,问题立刻消失。
所以这里想提醒大家:倍增求祖先和线段树查询的边界判断一定要独立清晰,不能互相依赖。先判断合法性,再跳倍增,再进入数据结构,三步分开写,不要在图省事的情况下把它们揉在一起。
6. 另一种思路:树上启发式合并能否更省事
在把线段树方案写完并通过随机数据对拍之后,我仍然不死心地想了想,这道题还有没有其他解法。毕竟 G 题放在第六题之后,单纯考“倍增 + 线段树”虽然合理,但未免有点太常规。
结果我发现,如果你愿意牺牲一些常数,用 DSU on tree(树上启发式合并)也可以做,而且代码写起来更直观。
具体思路是:我们需要回答若干询问,每个询问是“u的k级祖先v的子树聚合值”。如果我们能一次性算出每个节点的子树聚合值,那对于任何询问,只需要先倍增找到v,然后直接查表就行。问题又回到“如何高效求出所有节点的子树聚合值”。
对于一棵树,我们可以用 DSU on tree 在O(n log n)的时间内,把每个节点的子树信息维护出来,前提是信息的插入和删除操作都能在O(1)或O(log n)内完成。因为 DSU on tree 的核心思维是:轻重儿子分治,每次保留重儿子的信息,暴力把轻儿子的信息合并上来。
对于不可交换的聚合操作,DSU on tree 有一个天然劣势:它无法保证“子树内所有节点按正确的顺序插入”。因为你合并轻儿子时,遍历的顺序和 DFS 序不一定一致。如果聚合操作又不可交换,那 DSU on tree 的做法就很难保证正确性。除非你能在将信息插入集合时,额外带上“谁是左、谁是右”的信息,否则很容易出错。
所以我的结论是:对于 G 题这种不可交换聚合操作,线段树维护 DFS 序区间才是标准且稳妥的正解。DSU on tree 虽然能做部分可交换的题,但在这里性价比极低。
我写了一份 DSU on tree 的草稿代码,运行结果确实有一半的随机数据能过,但偶尔会挂,查了几次发现都是顺序问题。这个方向我最终放弃了,不过把这个思路写出来,是想提醒大家:选算法不能只看复杂度,必须结合操作的性质。
7. 对拍方案设计:我如何确保代码真的正确
一道数据结构和树的综合题,写出思路不等于写对代码。为了确保我的2×10^5数据下不超时、答案正确,我做了三件事,分别是构造数据、对拍脚本、以及特殊边界测试。
7.1 构造数据的几个关键类型
- 链状树:每个节点只有一个孩子,深度达到
n。这种数据会极大程度考验倍增的极限情况,也会让线段树的查询全部集中在一条链的尾部区间。 - 星形树:根节点下面挂着所有其他节点,深度很小。这样测的是在极浅深度下,倍增和区间查询是否正常。
- 随机树:随机生成父节点
fa[i] = rand() % (i - 1) + 1,保证是一棵有根树,节点分布均匀。 - 蒲公英型随机树:随机生成层数和度数,让某些子树特别大、某些子树特别小,模拟不均衡的 DFS 序区间分布。
每次生成完树之后,再随机生成若干组询问(u, k)。其中我刻意把k设置为三种情况:
- 合法且较小(比如
k = 0, 1, 2); - 合法且较大(比如
k = depth(u) - 1); - 非法(
k > depth(u))。
这样可以保证边界条件被充分覆盖。
7.2 对拍脚本与数据生成代码
对拍流程很传统:写一个暴力程序brute.cpp,写一个优化程序solve.cpp,再写一个数据生成器gen.py,用 shell 脚本循环跑即可。
这里分享一个我常用的最小化对拍脚本:
#!/bin/bash for i in $(seq 1 1000); do python3 gen.py > input.txt ./brute < input.txt > ans_brute.txt ./solve < input.txt > ans_solve.txt if diff -bq ans_brute.txt ans_solve.txt > /dev/null; then echo "Test $i: OK" else echo "Test $i: WA" break fi done这里有个小建议:不要用diff直接比较带有空格差异的文件,最好在输出时统一用\n分隔,并且每行结尾不要有多余空格。我遇到过好几次因为多打了一个空格导致对拍误报 WA 的情况,浪费了不少时间,后来干脆在输出函数里做严格处理。
7.3 针对链接关系和深度的边界测试
边界测试我单独写了三个 case:
n = 1,只有一个根节点。此时sz[root] = 1,所有询问k只要大于0就是非法的,必须输出空值。这个 case 虽然简单,但很多人会在初始化dfn时漏掉sz数组的发生。q = 1,树的形态完全随机。验证单次询问是否在log n时间内出结果。- 所有询问的
k都等于0。这时候每个询问的答案就是u自身的权值,相当于在测试线段树的单点查询功能。
这些边界 case 全部通过之后,再回头处理大数据的压力测试,看看是否超时。一分钟出结果基本就算合格。
8. 性能复盘:复杂度与常数优化记录
8.1 明确的总复杂度
最终方案的时间复杂度是预处理O(n log n),单次询问O(log n),总复杂度O((n + q) log n),空间复杂度大约O(n log n)用来存倍增数组,加上O(n)的线段树。
如果内存比较紧,倍增数组可以换成“树上离线求 k 级祖先”的方式,用vector在 DFS 时动态维护祖先链,这样空间能压到O(n)。但实现起来需要分步骤处理询问,不如在线做法那么直观。我在比赛中直接用倍增,n是2×10^5,log n约为18,up数组需要的空间是n * 19 * 4字节,约15MB,完全在内存限制之内。
8.2 常数优化:从递归到迭代
线段树的查询如果用递归,常数略大但问题不大。不过我在写 DFS 序时,递归爆栈的问题前面已经提过,所以这里强调:树的 DFS 序求解请直接用迭代栈。
一个简单的迭代 DFS 写法是:
void dfs(int root) { vector<int> stk; stk.push_back(root); while (!stk.empty()) { int u = stk.back(); stk.pop_back(); dfn[u] = ++timer; if (u != root) { // 处理父节点已经记录过的事 } for (int v : g[u]) { if (v == fa[u]) continue; fa[v] = u; stk.push_back(v); } } }但这里有个坑:如果你在 DFS 过程中直接计算sz,上面的栈式写法就不太好处理“先访问子节点再回传子树大小”的逻辑。需要改用记录进入和离开状态的两趟式栈:
void dfs_iter(int root) { vector<pair<int, int>> stk; // {node, state} stk.push_back({root, 0}); while (!stk.empty()) { auto [u, state] = stk.back(); stk.pop_back(); if (state == 0) { dfn[u] = ++timer; stk.push_back({u, 1}); for (int v : g[u]) { if (v == fa[u]) continue; fa[v] = u; up[v][0] = u; stk.push_back({v, 0}); } } else { sz[u] = 1; for (int v : g[u]) { if (v == fa[u]) continue; sz[u] += sz[v]; } } } }这比递归版本多写几行,但换来了稳健性和可调试性,个人觉得值。
8.3 线段树的建树与查询心得体会
建树时,我把每个dfn位置上的权值先放到一个数组base[dfn[u]] = a[u],然后用标准线段树建树,这样所有区间查询都不需要关注节点本身的信息,只管dfn区间。这样分离数据组织和逻辑组织,思路清晰很多。
查询部分,如果发现ql和qr都已经覆盖到了,就直接返回节点值,否则递归左右儿子。由于聚合不可交换,如果同时需要访问左右儿子,一定要注意先查左再查右,合并时也不能反过来。
9. 复盘收获:哪些经验和失误值得记住
这道 G 题做完,给我最大的几个感受可以总结成下面几条,适合任何一次训练后回看。
- 遇到“树上第 k 级祖先 + 子树查询”这样的组合题,优先想倍增 + 区间数据结构,但这只是框架。真正的难点在于聚合操作是否满足交换律,这直接决定了你能不能用差分或前缀和的思路。如果不可交换,必须老老实实用支持区间合并的数据结构。
- 暴力的价值被严重低估。我这次如果没有先写暴力对拍,直接上线段树,大概率会死在一个不起眼的边界判断上,浪费至少半小时。有暴力程序做对照,优化就能变得非常笃定。
- DFS 序不是“求一遍就万事大吉”的。你必须保证
dfn、sz、base数组三者的对应关系严格一致,任何一步出错都会导致后续查询整体漂移。调试时建议把树打印出来,看每个节点的dfn和sz是否符合直觉。 - 善用对拍脚本,但也要给对拍脚本加“边界测试”功能。只随机生成数据,会漏掉很多非法询问和极端树形。
- 写代码前先手算几组数据的期望答案。这能让你对题目本身的理解更加扎实,而不是边写边猜。
实际比赛中,我在暴力程序上花了大约 15 分钟,然后再花了 30 分钟实现了线段树和倍增,再用 30 分钟进行随机数据对拍和边界调整,整个过程算是有条不紊。如果一上来就想着写正解,反而可能因为缺少参照而陷入逻辑死角。
如果有机会做同一道题的赛后复盘,我的建议是:把暴力和正解放在两个文件里,保留对拍脚本,然后对着数据一步步看结果。你会发现很多原本觉得“玄学”的问题,其实都是数组下标或者顺序细节造成的。
不要怕花时间在暴力上,也不要怕推翻已经写了一半的正解。算法竞赛里最贵的不是时间,而是方向上一直错下去而不自知。