P6778 [Ynoi2009] rpdq 题解
很毒瘤的题目。
使用了另外的树分块方法:\(\text{Top Cluster}\)。
所以本文将会从一个蒟蒻的角度从 \(0\) 开始,着重讲解 \(\text{Top Cluster}\) 分块。
阅读者应具有基本莫队,二次离线莫队,树上分块的基础。
从 \(\operatorname{Compress}\) 和 \(\operatorname{Rake}\) 开始
我们定义一个树簇(Cluster)是树上的一个连通点集,且仅有至多两个点与外界连接,这两个点称为 界点,簇中其他点被称为 内点,两个界点之间的路径被称为簇路径。为了叙述方便,下面都假设每个簇都有两个界点,且深度较浅的被称为上界点,深度较深的称为下界点。
那么我们可以通过下面的两种操作合并两个簇(考虑的都是一个簇缩点过后的点)。
- \(\operatorname{Compress}(v)\):设 \(v\) 是一个二度的簇,那么将 \(v\) 和它的两个邻簇 \(u\),\(w\) 合并也是一个簇。
- \(\operatorname{Rake}(v)\):设 \(v\) 是一个一度的簇,它的邻点为 \(x\), 且 \(x\) 的度大于 \(1\) ,设 \(x\) 的另外一个邻点为 \(w\),那么显然将 \(v\) 和 \(x\)、\(w\) 合并是一个合法的簇。
(这里为了方便描述说的是合并到点,实际上在 这篇 关于 \(\text{Top Tree}\) 的博客中描述的是将新建的这条边定义为原始的 \(v\) 代表的簇,我这样只是为了方便理解/kel)

那么发现这个东西能把树分割成若干的块,似乎能实现树分块的功能。
于是有了接下来的部分。
树簇的构建与简单正确性证明
考虑一些好玩的性质要求,也就是对于一个簇的两个界点,一定是祖宗关系。因为这样这个分块能更好的维护信息。
现在我们想让每一个块的大小都是 \(\mathcal{O}(B)\) 的,块的个数是 \(\mathcal{O}(\frac{n}{B})\)。
考虑像 \(\operatorname{Tarjan}\) 算法一样,维护一个栈用于保存还未归类的边(或者说点 \(u\) 和它的父亲的边)。当 \(u\) 结束 \(\operatorname{dfs}\) 的时候,如果发生以下三种情况之一,则需要将 \(u\) 作为栈中若干个簇的上界点:
- \(u\) 为根节点。
- \(u\) 的至少两个两个子树内有界点。
- 栈中剩余数量大于 \(B\)。
解释一下第二种情况:至少两个子树内有界点的时候,而 \(u\) 的某一个祖先是 \(u\) 这个簇的上界点,那么 \(u\) 这个簇就至少有 \(3\) 个界点了,显然不合法,所以就只能将 \(u\) 设为上界点。
下面还需要解决的就是怎么合适的把 \(u\) 的子树划分成若干个簇,来满足大小限制和个数限制。考虑在栈中寻找级长前缀作为同一个簇,直到一下情况之一发生:
- \(u\) 的子树都用完了。
- 新加入一个子树会使簇有两个下界点。
- 新加入一个子树会使当前簇的大小大于 \(B\)。
我们来想一下为什么这样的划分是正确的?
首先肯定每个簇的大小是 \(\mathcal{O}(B)\) 的。
对于第一个部分,显然 \(1\)、\(3\) 情况的出现次数是 \(\mathcal{O}(\frac{n}{B})\) 的,情况二只会发生在已经划分的界点形成的虚树上,次数也是 \(\mathcal{O}(\frac{n}{B})\)。
对于第二个部分:显然情况 \(1\)、\(2\) 和第一部分的发生次数有关,考虑情况 \(3\)。考虑将这种情况发生的时候正在划分的簇和当前做到的字数配对,则当前未归类的边数和一定大于 \(B\),从而对数不超过 \(\mathcal{O}(\frac{n}{B})\),所以也是正确的。
总上所述,我们的算法可以做到在树上合法划分 \(\mathcal{O}(B)\) 个块,且块数为 \(\mathcal{O}(\frac{n}{B})\)。
下面给出基础的划分代码:
namespace TOP_CLUSTER {
int cur_CL[N], cur_CL_cnt; // 当前正在打包的簇内的节点,以及节点数量
// 【函数1:新建并确定一个簇】
// u: 簇的上边界点 (Top Boundary Node)
// v: 簇的下边界点 (Bottom Boundary Node)。如果簇没有下边界,v 传入时为 0。
void add_CL(int u, int v) {
// 如果没有下边界点(即这是一个叶子簇),强行把簇内最后一个点当作 v,方便后续统一处理
if (!v) v = cur_CL[cur_CL_cnt];
CT_fa[v] = u; // 在簇树(Cluster Tree)中,将下边界点连接到上边界点
near_CLP[u] = u;
// 标记上边界到下边界的这条主路径。路径上的点,其 nearest_CLP(最近的主路径点)就是自己
for (int r = v; r != u; r = fa[r]) near_CLP[r] = r;
// 遍历当前簇内的所有点
for (int i = 1; i <= cur_CL_cnt; ++i) {
int r = cur_CL[i];
int j;
up_BN[r] = u; // 记录节点 r 所在簇的上边界点是 u
down_BN[r] = v; // 记录节点 r 所在簇的下边界点是 v
down_CL[v].push_back(r); // 将 r 加入以 v 为下边界标识的簇集合中
// 找当前点 r 在主路径(u->v)上的“投影”点(最近点)
// 不断往父亲跳,直到碰到主路径上的点为止
for (j = r; !near_CLP[j]; j = fa[j]);
near_CLP[r] = near_CLP[j]; // 路径外节点的 near_CLP 继承自主路径上的连接点
}
cur_CL_cnt = 0; // 清空暂存区,准备迎接下一个簇
}
int ST[N], top, rec_top[N]; // ST: 节点栈;top: 栈顶指针;rec_top: 记录进入某节点时的栈顶位置
int waiting[N], rec_BN[N]; // waiting: 子树中等待分块的节点数;rec_BN: 子树中存在的下边界点
// 【函数2:DFS 遍历整棵树进行簇划分】
// u: 当前节点; FA: u 的父节点
inline void CL_partition(int u, int FA) {
fa[u] = FA; // 记录普通树上的父节点
rec_top[u] = top; // 记录刚进入 u 时的栈大小,方便后续刚好把 u 的子树节点全部弹出
// O(1) 技巧:从 u 的出边中删掉父节点 FA,把树变成严格向下的有向树
for (int i = 0; i < (int)edge[u].size(); ++i) {
if (edge[u][i].to == FA) {
swap(edge[u][i], edge[u].back()); // 把父节点边换到末尾
edge[u].pop_back(); // 弹出
break;
}
}
waiting[u] = 1; // 初始时,u 自身是一个等待分配的节点
int BN_cnt = 0; // 统计 u 的子树中,包含了几个下边界点
// 1. 递归处理所有子树
for (auto& e : edge[u]) {
int v = e.to;
ST[++top] = v; // 把子节点压入栈中
CL_partition(v, u); // 递归遍历子节点
waiting[u] += waiting[v]; // 累加等待分配的节点数
// 如果子树 v 中产生了一个边界点
if (rec_BN[v]) {
rec_BN[u] = rec_BN[v]; // 暂存这个边界点
BN_cnt++; // 边界点计数 + 1
}
}
// 2. 判断当前节点 u 是否必须成为一个新的“边界点” (Boundary Node)
// 触发条件有三个:
// ① 积累的未分配节点数超过了阈值 B(需要打包了)
// ② 子树中传上来的下边界点数量 > 1(因为一个簇最多只能有1个下边界,如果有2个或以上,它们必须在 u 这里分道扬镳,u 必须成为边界点)
// ③ !FA:这是整棵树的根节点,根节点必定是边界点
if (waiting[u] > B || BN_cnt > 1 || !FA) {
waiting[u] = 0; // u 变成了边界点,不再是"等待分配"的普通节点
rec_BN[u] = u; // 告诉上层,u 现在是一个边界点了
BN.push_back(u); // 全局记录边界点 u
// 3. 将 u 的子树中的等待节点打包成一个或多个簇
// 循环遍历 u 的子节点。注意 i 取到了 edge[u].size(),这是为了在最后一次循环强制结算(触发 !v)
for (int i = 0, j = rec_top[u] + 1, cnt = 0, cur_down = 0, v; i <= (int)edge[u].size(); ++i) {
// v 取子节点;如果是最后一次越界循环,v 强制为 0
v = (i == (int)edge[u].size() ? 0 : edge[u][i].to);
// 触发将当前累积的节点打包成一个簇的条件:
// ① 当前累积的节点 cnt + 新子树 v 的等待节点数 > B
// ② 已经有一个下边界点 (cur_down),且新子树 v 也有下边界点 (rec_BN[v]) (一个簇不能容纳两个下边界点!)
// ③ !v:所有子树都遍历完了,需要把剩下的打包带走
if (cnt + waiting[v] > B || (cur_down && rec_BN[v]) || !v) {
// 把栈中属于这批子树的节点全部倒入 cur_CL 缓冲区
// j 遍历栈,直到达到当前子树的栈顶 rec_top[v]
for (; (j < rec_top[v] || !v) && j <= top; j++)
cur_CL[++cur_CL_cnt] = ST[j];
// 如果有攒到的节点,就正式建簇。上边界是 u,下边界是 cur_down
if (cur_CL_cnt) add_CL(u, cur_down);
cnt = cur_down = 0; // 重置累加器,开始新一轮的打包
}
cnt += waiting[v]; // 累加等待节点
if (rec_BN[v]) cur_down = rec_BN[v]; // 如果子树 v 有下边界点,记录下来作为当前这一波的下边界
}
top = rec_top[u]; // u 的所有子树已经处理完毕并打包,将栈顶恢复到进入 u 时的状态
}
}
}
Sol
从推式子到二次离线莫队
发现题目里给的式子其实很不好直接维护,我们考虑推几步式子化简一下:
根据树上距离的经典公式 \(\operatorname{dis}(i,j) = dep_i + dep_j - 2 \times dep_{\operatorname{LCA}(i,j)}\),带入原式:
观察这个化简后的式子,前面那个 \(\sum dep_i\) 显然是个前缀和就能 \(\mathcal{O}(1)\) 算出来的摆设,真正的硬骨头在后面这个 \(\sum \sum dep_{\operatorname{LCA}(i,j)}\) 上。
发现这个东西依然很不好做,但是可以离线,考虑掏出莫队。
当我们把莫队的右端点从 \(r\) 移动到 \(r+1\) 时,新增的贡献本质上就是求:
大家知道 \(dep_{\operatorname{LCA}(x, y)}\) 有一个非常经典的转化:它等于 \(x\) 到根的路径,与 \(y\) 到根的路径的交集长度。
换句话说,如果我们把 \(l \dots r\) 中所有的点 \(i\),都将其到根节点路径上的点权(或者说边权)加上一个对应长度。那么对于 \(r+1\),它到根节点路径上的权值和,就是我们要的 \(\sum dep_{\operatorname{LCA}(i, r+1)}\)!
好,信息可差分,贡献可拆分。这就到了二次离线莫队(SOMA)的表演时间。
我们将莫队端点移动带来的 \([l, r]\) 区间贡献拆成 \([1, r]\) 和 \([1, l-1]\) 两个前缀贡献。前面的可以直接预处理求出,后面的离线下来跑扫描线。
跑二次莫队的话,我们相当于要在树上执行这样两个核心操作:
- 修改:将点 \(u\) 到根的路径上所有的点加上对应权值。
- 查询:求点 \(x\) 到根的路径上的点权和。
众所周知,二次离线莫队会产生 \(\mathcal{O}(n)\) 次修改和 \(\mathcal{O}(n \sqrt{m})\) 次查询。如果你掏出树剖等 \(\mathcal{O}(\log n)\) 的数据结构,总复杂度会变成 \(\mathcal{O}(n \sqrt{m} \log n)\),在这道毒瘤题里显然是过不去的。
我们需要一个 \(\mathcal{O}(\sqrt{n})\) 修改,\(\mathcal{O}(1)\) 查询 的神仙科技进行根号平衡。
这,就是 \(\text{Top Cluster}\) 闪亮登场的时刻。
Top Cluster 平衡时间复杂度
有了前面讲的 \(\text{Top Cluster}\) 划分,我们已经把原树分成了大小为 \(\mathcal{O}(B)\) 的块,且界点个数为 \(\mathcal{O}(\frac{n}{B})\)。取 \(B = \sqrt{n}\),两边的数量级都是 \(\mathcal{O}(\sqrt{n})\)。
想要 \(\mathcal{O}(1)\) 查询 \(x\) 到根的路径和,就必须把答案拆解成几段能在 \(\mathcal{O}(1)\) 时间内获取的预处理信息。
对于任意一个点 \(x\),它到根的路径可以被清晰地拆为三段:
- \(x\) 到它在簇路径上的投影点 \(near\_CLP[x]\) 的距离(在簇的侧面分支上)。
- \(near\_CLP[x]\) 到当前簇的上界点 \(up\_BN\) 的距离(在簇路径上)。
- \(up\_BN\) 到根节点的距离(在界点构成的收缩树上)。
因此,我们需要维护这三个部分的前缀和:
pre_in[x]:点 \(x\) 到 \(near\_CLP[x]\) 路径上的权值和。pre_clp[y]:簇路径上的点 \(y\) 到上界点 \(up\_BN\) 的权值和。pre_rt[u]:界点 \(u\) 到根的权值和。
外加一个维护整条簇路径被覆盖次数的懒标记add_whole[C]。
这样,最头疼的查询操作就可以非常优雅地 \(\mathcal{O}(1)\) 拼凑出来:
那么对于 \(\mathcal{O}(\sqrt{n})\) 的修改操作(把 \(u\) 到根的路径加权值),我们分三步来看如何暴力维护:
第一步:簇内暴力修改
路径 \(u \to root\) 会先在当前簇内走一段:\(u \to near\_CLP[u] \to up\_BN\)。
因为一个簇的大小撑死也就 \(B\),我们干脆直接暴力操作!
沿着 \(u \to near\_CLP[u]\) 往上跳修改点权,然后花 \(\mathcal{O}(B)\) 把这根侧面分支上的 pre_in 全部重构一遍;
接着沿着 \(near\_CLP[u] \to up\_BN\) 往上跳修改,再花 \(\mathcal{O}(B)\) 把当前簇的 pre_clp 重新做一遍前缀和。
同时别忘了,这段簇路径被修改了,意味着下面的点到根的距离增加了,所以如果有下界点 \(down\_BN\),我们要把这段簇路径加上的总权值扔给它,记作 val_BN[down_BN] += sum。
第二步:界点树打标记
出了当前簇,接着就是从 \(up\_BN\) 一直往上跨越界点跳到根。
这部分走的全部都是一整条一整条的完整簇路径。由于全树界点个数是 \(\mathcal{O}(\frac{n}{B})\),我们继续暴力往上跳:
对于路过的每一个完整的簇 \(C\),把它的懒标记 add_whole[C] ++,并且把这个簇的完整长权值加到它对应的 val_BN[down_BN[C]] 上。
这一步最多跳 \(\mathcal{O}(\frac{n}{B})\) 次,也是根号级别的。
第三步:重构界点前缀和
修改完了界点收缩树上的单点权值 val_BN,我们需要更新所有的 pre_rt 以保证下一次的 \(\mathcal{O}(1)\) 查询。
你会发现界点一共就 \(\mathcal{O}(\frac{n}{B})\) 个,如果我们每次修改都去跑什么树状数组或者子树打标记,常数和代码长度都会起飞。
最暴力的往往也是最快的:我们直接按照所有界点的拓扑序(预处理好的 DFS 序),花 \(\mathcal{O}(\frac{n}{B})\) 的时间把全树所有界点的 pre_rt 给从头到尾扫一遍重构!
转移式极为简单:pre_rt[u] = pre_rt[fa_BN[u]] + val_BN[u]。
至此,所有的修改结束。因为 \(\mathcal{O}(B) + \mathcal{O}(\frac{n}{B}) + \mathcal{O}(\frac{n}{B}) = \mathcal{O}(\sqrt{n})\),我们完美地完成了根号平衡/kel。
这样我们就把 \(\mathcal{O}(n \sqrt{m})\) 查询、\(\mathcal{O}(n)\) 修改的二次离线莫队,稳稳当当地卡进了线性根号的时间复杂度里。剩下的就是极致的卡常部分了/kk。
下面给出代码,变量名的定义略有不同:
#include <bits/stdc++.h>
#define FASTIO ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
using namespace std;
const int N = 200005;
// 原树与簇树的基础数组
int fa[N], CT_fa[N], near_CLP[N], up_BN[N], down_BN[N];
vector<int> BN, down_CL[N];
struct Edge { int id, to; };
vector<Edge> edge[N];
int B;
// diff_dep[x]: x 到 fa[x] 的边权
// diff_CT_dep[x]: 界点 x 到界点树父亲 CT_fa[x] 的路径长度
// dep[x]: x 到根节点的深度
unsigned diff_dep[N], diff_CT_dep[N], dep[N];
namespace TOP_CLUSTER {
// ... (此处省略簇划分的具体实现,同上) ...
}
using namespace TOP_CLUSTER;
// ======================= Top Cluster 数据结构核心部分 =======================
// val[x]: 点 x 到根的路径上,经过点 x 的次数(或者说点 x 被覆盖的权值)
// val_CLP[x]: 以 x 为下界点的簇路径,作为一个整体被经过的总权值
// tag[x]: 以 x 为下界点的簇路径,作为一个整体被经过的次数(懒标记)
// sum1[x]: 簇内前缀和(对应 Sol 中的 pre_in[x] + pre_clp[x])
// sum2[x]: 界点收缩树上的前缀和(对应 Sol 中的 pre_rt[x])
unsigned val[N], val_CLP[N], tag[N], sum1[N], sum2[N];
// 【操作1:O(sqrt N) 暴力修改】将 x 到根节点的路径权值 + 1
inline void update(int x) {
const int up = up_BN[x], down = down_BN[x];
// 第一步:簇内侧面分支暴力跳,更新点权
for (; x != near_CLP[x]; x = fa[x]) val[x] += diff_dep[x];
// 第二步:簇内主路径(簇路径)暴力跳,更新点权
// 注意:这段主路径权值的增加,等价于从下界点 down_BN 往下的所有点,到根的距离都增加了
// 所以要把这段增加的权值打包,加到 val_CLP[down] 上!
for (; x != up; x = fa[x]) {
val[x] += diff_dep[x];
val_CLP[down] += diff_dep[x];
}
// 第三步:跨出当前簇,在界点收缩树上往上跳
// 每次跳跃都是一整段簇路径,直接给经过的簇打上 tag 标记,并更新整条路径带来的 val_CLP
for (; CT_fa[x]; x = CT_fa[x]) {
val_CLP[x] += diff_CT_dep[x];
tag[x]++;
}
// 第四步:重构修改过的那个簇内部的前缀和 (O(B))
// 因为簇内节点拓扑序已经排好,直接一遍递推即可
for (int it : down_CL[down])
sum1[it] = (down_BN[fa[it]] == down ? sum1[fa[it]] : 0) + val[it];
// 第五步:重构全局界点树的前缀和 (O(n/B))
// BN 数组存了所有的界点,且在 main 函数中已经被 reverse 成了从上到下的拓扑序
for (int it : BN)
sum2[it] = sum2[CT_fa[it]] + val_CLP[it];
}
// 【操作2:O(1) 极速查询】查询 x 到根节点的路径上的权值和
inline unsigned query(int x) {
const int up = up_BN[x], down = down_BN[x], near = near_CLP[x];
// 答案三部分拼接:
// 1. sum1[x]:簇内部侧面分支 + 簇路径上半段 的权值和
// 2. 懒标记贡献:整个簇被跨越的次数 * (near 投影点 到 up 上界点 的实际距离)
// 3. sum2[up]:上界点到全树根节点的权值和
return sum1[x] + (dep[near] - dep[up]) * tag[down] + sum2[up];
}
// =========================================================================
// 基础树上初始化
void dfs_init(int u, int p, unsigned d) {
dep[u] = d;
for (auto& e : edge[u]) {
int v = e.to;
if (v == p) continue;
diff_dep[v] = e.id;
dfs_init(v, u, d + e.id);
}
}
unsigned pre[N], P1[N], P2[N], D[N], delta_ans[N], final_ans[N];
// 莫队常规操作:查询区间结构体与奇偶排序优化
struct Query { int l, r, id; } Q[N];
int B_mo;
bool cmp(const Query& a, const Query& b) {
if (a.l / B_mo != b.l / B_mo) return a.l < b.l;
return ((a.l / B_mo) & 1) ? a.r < b.r : a.r > b.r;
}
// 二次离线莫队:离线下来的询问
struct OfflineQuery {
int l, r, sign, id;
};
vector<OfflineQuery> off_queries[N];
inline void add_offline_query(int P, int l, int r, int sign, int id) {
if (P <= 0 || l > r) return;
off_queries[P].push_back({ l, r, sign, id });
}
int main() {
FASTIO;
int n, m;
cin >> n >> m;
for (int i = 1; i < n; ++i) {
int u, v, w; cin >> u >> v >> w;
edge[u].push_back({ w, v });
edge[v].push_back({ w, u });
}
// Top Cluster 块长设为 0.8 * sqrt(n) 常数较优
B = max(1, (int)(sqrt(n) * 0.8));
dfs_init(1, 0, 0);
TOP_CLUSTER::top = 0;
TOP_CLUSTER::ST[++TOP_CLUSTER::top] = 1;
TOP_CLUSTER::CL_partition(1, 0);
// 核心:DFS划分出来的界点是从下到上的,翻转一下变成从上到下的拓扑序
// 这也是为什么我们在 update 里面可以直接 O(n/B) 递推 sum2 的原因!
reverse(BN.begin(), BN.end());
for (int i = 1; i <= n; ++i) {
if (CT_fa[i]) diff_CT_dep[i] = dep[i] - dep[CT_fa[i]]; // 预处理簇路径长度
D[i] = D[i - 1] + dep[i]; // 预处理深度的前缀和,用于最后算 (r-l) * sum_dep
}
for (int i = 1; i <= m; ++i) {
cin >> Q[i].l >> Q[i].r;
Q[i].id = i;
}
// 莫队块长设定
B_mo = max(1, (int)(n / sqrt(max(1, m))));
sort(Q + 1, Q + m + 1, cmp);
int L = 1, R = 0;
// 二次离线莫队拆分贡献:将莫队指针移动造成的 O(n sqrt m) 次查询离线到对应的前缀上
for (int i = 1; i <= m; ++i) {
int qL = Q[i].l, qR = Q[i].r;
if (L > qL) { add_offline_query(R, qL, L - 1, 1, i); L = qL; }
if (R < qR) { add_offline_query(L - 1, R + 1, qR, -1, i); R = qR; }
if (L < qL) { add_offline_query(R, L, qL - 1, -1, i); L = qL; }
if (R > qR) { add_offline_query(L - 1, qR + 1, R, 1, i); R = qR; }
}
// 扫描线执行二次离线莫队:跑 n 次修改,跑 O(n sqrt m) 次查询
for (int i = 1; i <= n; ++i) {
pre[i] = query(i); // O(1) 查询 LCA(1~i-1, i) 的深度之和
update(i); // O(sqrt N) 把 i 到根的路径加上权值
// P1 是单纯的前缀和;P2 用于处理 [L, R] 内部指针移动时 i 本身的自我产生的前缀和修正
P1[i] = P1[i - 1] + pre[i];
P2[i] = P2[i - 1] + pre[i] + dep[i];
// 处理挂在点 i 上面的离线查询
for (auto& oq : off_queries[i]) {
unsigned sum = 0;
// 这里的查询因为有了 Top Cluster 变得极快,单次 O(1),所以总共 O(n sqrt m) 是可以承受的
for (int x = oq.l; x <= oq.r; ++x) sum += query(x);
if (oq.sign == 1) delta_ans[oq.id] += sum;
else delta_ans[oq.id] -= sum;
}
}
// 二次莫队合并离线结果
L = 1; R = 0;
for (int i = 1; i <= m; ++i) {
int qL = Q[i].l, qR = Q[i].r;
if (L > qL) { delta_ans[i] -= (P2[L - 1] - P2[qL - 1]); L = qL; }
if (R < qR) { delta_ans[i] += (P1[qR] - P1[R]); R = qR; }
if (L < qL) { delta_ans[i] += (P2[qL - 1] - P2[L - 1]); L = qL; }
if (R > qR) { delta_ans[i] -= (P1[R] - P1[qR]); R = qR; }
}
// 拼凑最后的数学公式!
unsigned cur_ans = 0;
for (int i = 1; i <= m; ++i) {
cur_ans += delta_ans[i]; // cur_ans 现在就是 ΣΣ dep_LCA(i, j)
// 公式:(r - l) * Σ dep - 2 * ΣΣ dep_LCA
unsigned total_dist = (unsigned)(Q[i].r - Q[i].l) * (D[Q[i].r] - D[Q[i].l - 1]) - 2u * cur_ans;
final_ans[Q[i].id] = total_dist;
}
// 按原顺序输出
for (int i = 1; i <= m; ++i)
cout << final_ans[i] << '\n';
return 0;
}
目前是洛谷次优解,代码长度约为 6K,最慢的一个点跑了 1.76s,空间占用为约 60MB。
Upd:在 AI 的卡常下,拿下最优解,最慢点为 1.33s,总用时领先次优解 2s。

浙公网安备 33010602011771号