P6778 [Ynoi2009] rpdq 题解

Link

很毒瘤的题目。

使用了另外的树分块方法:\(\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)

basic

那么发现这个东西能把树分割成若干的块,似乎能实现树分块的功能。

于是有了接下来的部分。

树簇的构建与简单正确性证明

考虑一些好玩的性质要求,也就是对于一个簇的两个界点,一定是祖宗关系。因为这样这个分块能更好的维护信息。

现在我们想让每一个块的大小都是 \(\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

从推式子到二次离线莫队

发现题目里给的式子其实很不好直接维护,我们考虑推几步式子化简一下:

\[\sum_{i=l}^r\sum_{j=i+1}^r \operatorname{dis}(i,j) \]

根据树上距离的经典公式 \(\operatorname{dis}(i,j) = dep_i + dep_j - 2 \times dep_{\operatorname{LCA}(i,j)}\),带入原式:

\[\Rightarrow \sum_{i=l}^r\sum_{j=i+1}^r (dep_i + dep_j - 2 \times dep_{\operatorname{LCA}(i,j)}) \]

\[\Rightarrow (r-l) \times \sum_{i=l}^r dep_i - 2 \times \sum_{i=l}^r\sum_{j=i+1}^r dep_{\operatorname{LCA}(i,j)} \]

观察这个化简后的式子,前面那个 \(\sum dep_i\) 显然是个前缀和就能 \(\mathcal{O}(1)\) 算出来的摆设,真正的硬骨头在后面这个 \(\sum \sum dep_{\operatorname{LCA}(i,j)}\) 上。

发现这个东西依然很不好做,但是可以离线,考虑掏出莫队。
当我们把莫队的右端点从 \(r\) 移动到 \(r+1\) 时,新增的贡献本质上就是求:

\[\sum_{i=l}^r dep_{\operatorname{LCA}(i, 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]\) 两个前缀贡献。前面的可以直接预处理求出,后面的离线下来跑扫描线。
跑二次莫队的话,我们相当于要在树上执行这样两个核心操作:

  1. 修改:将点 \(u\) 到根的路径上所有的点加上对应权值。
  2. 查询:求点 \(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\),它到根的路径可以被清晰地拆为三段:

  1. \(x\) 到它在簇路径上的投影点 \(near\_CLP[x]\) 的距离(在簇的侧面分支上)。
  2. \(near\_CLP[x]\) 到当前簇的上界点 \(up\_BN\) 的距离(在簇路径上)。
  3. \(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)\) 拼凑出来:

\[Ans(x) = pre\_in[x] + pre\_clp[near\_CLP[x]] + add\_whole[C] \times dist(near\_CLP[x], up\_BN) + pre\_rt[up\_BN[x]] \]

那么对于 \(\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。

posted @ 2026-06-12 09:52  To_string  阅读(9)  评论(0)    收藏  举报