1.树链剖分

  • 洛谷题目:P3379

  • 参考视频:https://www.bilibili.com/video/BV1tY4y1G7em/

  • 题目描述

    如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先。

    输入格式

    第一行包含三个正整数 N,M,S,分别表示树的结点个数、询问的个数和树根结点的序号。

    接下来 N−1 行每行包含两个正整数 x,y,表示 x 结点和 y 结点之间有一条直接连接的边(数据保证可以构成树)。

    接下来 M 行每行包含两个正整数 a,b,表示询问 a 结点和 b 结点的最近公共祖先。

    输出格式

    输出包含 M 行,每行包含一个正整数,依次为每一个询问的结果。

    输入输出样例

    输入 #1复制

    5 5 4 3 1 2 4 5 1 1 4 2 4 3 2 3 5 1 2 4 5

    输出 #1复制

    4 4 1 4 4

    说明/提示

    对于 30% 的数据,N≤10,M≤10。

    对于 70% 的数据,N≤10000,M≤10000。

    对于 100% 的数据,1≤N,M≤5×105,1≤x,y,a,b≤N,不保证 a=b。

  • 重链 / 重儿子定义:

    • 重儿子:节点的所有子节点中,子树大小最大的那个子节点(son[u])。

    • 重链:由父节点和重儿子依次连接形成的路径(轻儿子作为新重链的起点)。

  • LCA 求解逻辑:

    • 若两个节点不在同一条重链上,将链头深度更大的节点跳转到其链头的父节点;

    • 重复上述步骤直到两个节点在同一条重链上,此时深度更小的节点即为 LCA。

  • 时间复杂度:

    • 两次 DFS 的时间复杂度为 O (n);

    • 单次 LCA 查询的跳转次数为 O (log n)(树的高度),m 次查询总复杂度为 O (m log n)。

应用树链剖分可以求 LCA,也可在此基础上扩展为树链剖分的区间修改 / 查询(如维护树上路径和、路径最大值等)。

参考代码

// 树链剖分模板(用于求解LCA),时间复杂度 O(m log n)
// 核心思想:将树划分为若干条"重链",通过链头快速跳转求解最近公共祖先(LCA)
#include<bits/stdc++.h>
using namespace std;

// 常量定义:树的最大节点数(根据题目需求调整,此处为5e5级别)
const int N = 500010;

// 全局变量说明
int n, m, s;   // n:节点数 m:查询次数 s:树的根节点
int a, b;      // 临时变量,用于读入边/查询的两个节点
vector<int> e[N];  // 邻接表,存储树的无向边

// 树链剖分核心数组
int fa[N];     // fa[u]:节点u的父节点
int son[N];    // son[u]:节点u的"重儿子"(子树大小最大的子节点)
int dep[N];    // dep[u]:节点u的深度(根节点深度为1)
int siz[N];    // siz[u]:以u为根的子树的节点总数
int top[N];    // top[u]:节点u所在"重链"的链头(链中深度最小的节点)

// 第一次DFS:初始化fa、dep、siz、son(重儿子)
// u:当前遍历的节点  f:当前节点的父节点
void dfs1(int u, int f)
{
    fa[u] = f;                // 记录u的父节点为f
    dep[u] = dep[f] + 1;      // u的深度 = 父节点深度 + 1(根的父是0,dep[0]=0)
    siz[u] = 1;               // 初始子树大小为1(仅包含自身)

    // 遍历u的所有邻接节点
    for (int v : e[u])
    {
        if (v == f) continue; // 跳过父节点,避免回环
        dfs1(v, u);           // 递归遍历子节点v,父节点为u

        // 回溯时更新子树大小:u的子树大小 += 子节点v的子树大小
        siz[u] += siz[v];

        // 选择重儿子:如果v的子树大小大于当前重儿子的子树大小,则更新son[u]
        if (siz[son[u]] < siz[v])
            son[u] = v;
    }
}

// 第二次DFS:初始化top(链头),划分重链
// u:当前遍历的节点  t:当前重链的链头
void dfs2(int u, int t)
{
    top[u] = t;  // 记录u所在重链的链头为t

    // 优先遍历重儿子:重儿子与父节点属于同一条重链,链头不变
    if (son[u])
        dfs2(son[u], t);

    // 遍历轻儿子:轻儿子作为新重链的链头(每条轻儿子对应一条新重链)
    for (int v : e[u])
    {
        // 跳过父节点和重儿子(已处理)
        if (v == fa[u] || v == son[u])
            continue;
        dfs2(v, v);  // 轻儿子v的链头是自身
    }
}

// 树链剖分求LCA(最近公共祖先)
// u、v:需要查询LCA的两个节点
int lca(int u, int v)
{
    // 核心逻辑:将u和v不断跳转到所在重链的链头,直到二者在同一条重链上
    while (top[u] != top[v])
    {
        // 始终让深度更大的链头对应的节点上跳(减少跳转次数)
        if (dep[top[u]] < dep[top[v]])
            swap(u, v);
        // 将u跳转到其链头的父节点(相当于"跨过"当前重链,向上移动)
        u = fa[top[u]];
    }
    // 当u和v在同一条重链上时,深度更小的节点就是LCA
    return dep[u] < dep[v] ? u : v;
}

int main()
{
    // 读入节点数、查询次数、根节点
    scanf("%d%d%d", &n, &m, &s);

    // 读入n-1条树边(树的边数=节点数-1)
    for (int i = 1; i < n; i++)
    {
        scanf("%d%d", &a, &b);
        e[a].push_back(b);  // 无向边,双向存入邻接表
        e[b].push_back(a);
    }

    // 执行两次DFS,完成树链剖分的初始化
    dfs1(s, 0);  // 第一次DFS:根节点s,父节点为0(虚拟节点)
    dfs2(s, s);  // 第二次DFS:根节点s的链头是自身

    // 处理m次LCA查询
    while (m--)
    {
        scanf("%d%d", &a, &b);       // 读入查询的两个节点
        printf("%d\n", lca(a, b));  // 输出LCA结果
    }

    return 0;
}

2.点分治

  • 洛谷题目:P3806

  • 参考视频:https://www.bilibili.com/video/BV1ZN4y1F7eo/

  • 题目描述

    给定一棵有 n 个点的树,询问树上距离为 k 的点对是否存在。

    输入格式

    第一行两个数 n,m。

    第 2 到第 n 行,每行三个整数 u,v,w,代表树上存在一条连接 u 和 v 边权为 w 的路径。

    接下来 m 行,每行一个整数 k,代表一次询问。

    输出格式

    对于每次询问输出一行一个字符串代表答案,存在输出 AYE,否则输出 NAY。

    输入输出样例

    输入 #1复制

    2 1
    1 2 2
    2
    

    输出 #1复制

    AYE
    

    说明/提示

    数据规模与约定

    • 对于 30% 的数据,保证 n≤100。

    • 对于 60% 的数据,保证 n≤1000,m≤50 。

    • 对于 100% 的数据,保证 1≤n≤104,1≤m≤100,1≤k≤107,1≤u,v≤n,1≤w≤104。

  • 解决的问题:

    • 给定一棵带权无向树,多次询问某个数值 k,判断树上是否存在一条路径的长度恰好等于 k。最终输出每个询问的结果(AYE 表示存在,NAY 表示不存在)。
  • 重心的作用:

    点分治选择「重心」作为分治中心,是因为重心的最大子树大小最小,能保证递归深度为 O(log n),避免退化为链时的 O(n) 深度,保证总时间复杂度为 O(n log n)。

  • calc 函数的核心思想:

    • 对于当前重心 u,所有经过 u 的路径可拆分为:u 到子树 A 节点的路径 + u 到子树 B 节点的路径(A≠B)。

    • 先统计一个子树的所有节点到 u 的距离,再用这些距离去匹配「已统计的距离」,判断是否能组成询问值(即 dis1 + dis2 = ask[k])。

    • 用 judge 数组标记已统计的距离,避免重复计算同一子树内的路径(同一子树的路径拼接会绕回,不是简单路径)。

  • 关键细节:

    • del 数组标记已处理的重心,避免重复处理;

    • 每次处理完一个重心的所有子树后,清空 judge 数组,防止影响下一轮分治;

    • 两次调用 getroot:第一次找重心,第二次重构子树大小(确保后续分治的 siz 数组准确)。

  • 时间复杂度:

    每次找重心和处理路径的时间为 O(n),递归深度为 O(log n),总时间复杂度为 O(n log n + m n)(若 m 较大,可优化为用哈希 / 桶统计距离,将 m 的复杂度降为 O(m))。

参考代码

#include<bits/stdc++.h>
using namespace std;

// 常量定义
const int N = 10005;          // 树的最大节点数
const int INF = 10000005;     // 距离上限(大于所有可能的路径长度)

// 邻接表结构体:存储树的边(链式前向星)
struct node
{
    int v, w, ne;  // v: 边的终点  w: 边的权值  ne: 下一条边的索引
} e[N << 1];       // 无向树,边数是2*(n-1),所以开2倍空间

// 全局变量说明
int h[N], idx;     // h[]: 链式前向星头节点  idx: 边的索引计数器(加边用)
// 点分治核心变量(找重心用)
int del[N];        // del[u]=1 表示节点u已作为重心处理过(被"删除"),不再参与后续分治
int siz[N];        // siz[u] 表示以u为根的子树大小(仅考虑未被删除的节点)
int mxs;           // 记录当前节点的最大子树大小(用于判断重心)
int sum;           // 当前待处理子树的总节点数
int root;          // 当前待处理子树的重心
// 距离相关变量
int dis[N];        // 临时存储某棵子树中所有节点到重心的距离
int d[N];          // d[u] 表示节点u到当前重心的距离(递归求距离时用)
int cnt;           // dis数组的有效长度(子树节点数)
// 查询相关变量
int ans[N];        // ans[i] 标记第i个询问是否有解(1=存在,0=不存在)
int q[INF];        // 临时存储需要清空的距离值(用于重置judge数组)
int judge[INF];    // judge[x]=1 表示当前已统计的路径中存在长度为x的路径
int n, m;          // n: 节点数  m: 询问次数
int ask[N];        // 存储m个询问的目标长度

// 链式前向星加边函数
// u: 边的起点  v: 边的终点  w: 边的权值
void add(int u, int v, int w)
{
    e[++idx].v = v;   // 记录终点
    e[idx].w = w;     // 记录权值
    e[idx].ne = h[u]; // 指向当前u的上一条边
    h[u] = idx;       // 更新u的头节点为当前边的索引
}

// 第一次DFS:寻找当前子树的重心
// u: 当前遍历节点  fa: 父节点(避免回环)
// 重心定义:删除该节点后,所有子树的大小的最大值最小
void getroot(int u, int fa)
{
    siz[u] = 1;       // 初始子树大小为1(仅自身,未被删除)
    int s = 0;        // 记录u的最大子树大小(子节点的子树)
    // 遍历u的所有邻边
    for (int i = h[u]; i; i = e[i].ne)
    {
        int v = e[i].v;
        // 跳过父节点 或 已被删除的节点(已处理过的重心)
        if (v == fa || del[v]) continue;
        getroot(v, u); // 递归遍历子节点v
        siz[u] += siz[v]; // 回溯更新子树大小
        s = max(s, siz[v]); // 更新最大子树大小
    }
    // 补充:父节点方向的子树大小 = 总大小sum - 当前子树大小siz[u]
    s = max(s, sum - siz[u]);
    // 如果当前节点的最大子树大小小于全局最小值mxs,更新重心
    if (s < mxs) mxs = s, root = u;
}

// 第二次DFS:计算子树中所有节点到当前重心的距离
// u: 当前遍历节点  fa: 父节点(当前重心)
void getdis(int u, int fa)
{
    dis[++cnt] = d[u]; // 记录u到重心的距离到dis数组
    // 遍历u的所有邻边
    for (int i = h[u]; i; i = e[i].ne)
    {
        int v = e[i].v;
        // 跳过父节点 或 已被删除的节点
        if (v == fa || del[v]) continue;
        d[v] = d[u] + e[i].w; // 计算v到重心的距离(父节点距离+边权)
        getdis(v, u);         // 递归遍历子节点v
    }
}

// 核心函数:处理经过当前重心root的所有路径
// u: 当前分治的重心
void calc(int u)
{
    judge[0] = 1; // 初始化:距离0存在(重心自身到自身的路径)
    int p = 0;    // 记录本次需要清空的距离数量(q数组的指针)

    // 遍历重心u的所有邻边,处理每个子树
    for (int i = h[u]; i; i = e[i].ne)
    {
        int v = e[i].v;
        if (del[v]) continue; // 跳过已删除的节点

        // 第一步:计算子树v中所有节点到重心u的距离
        cnt = 0;              // 重置dis数组长度
        d[v] = e[i].w;        // 子树v的根节点到u的距离 = 边权
        getdis(v, u);         // 递归获取子树v所有节点到u的距离,存入dis

        // 第二步:检查当前子树的距离能否和已统计的距离组成询问值
        // 枚举当前子树的所有距离dis[j]
        for (int j = 1; j <= cnt; ++j)
        {
            // 枚举所有询问k
            for (int k = 1; k <= m; ++k)
            {
                // 若询问值ask[k] >= 当前距离dis[j],且存在距离为ask[k]-dis[j]
                // 则说明存在两条路径(dis[j] 和 ask[k]-dis[j])拼接成ask[k]
                if (ask[k] >= dis[j])
                    ans[k] |= judge[ask[k] - dis[j]]; // 按位或,标记有解
            }
        }

        // 第三步:将当前子树的距离加入judge数组(供后续子树使用)
        for (int j = 1; j <= cnt; ++j)
        {
            if (dis[j] < INF) // 仅记录有效距离
            {
                q[++p] = dis[j];       // 记录需要清空的距离
                judge[q[p]] = 1;       // 标记该距离存在
            }
        }
    }

    // 第四步:清空本次统计的距离(避免影响后续分治)
    for (int i = 1; i <= p; ++i)
        judge[q[i]] = 0;
}

// 点分治主函数:递归处理每个子树
// u: 当前子树的重心
void divide(int u)
{
    // 第一步:处理经过重心u的所有路径
    calc(u);

    // 第二步:标记u为已处理(删除),避免重复计算
    del[u] = 1;

    // 第三步:递归处理u的所有子树
    for (int i = h[u]; i; i = e[i].ne)
    {
        int v = e[i].v;
        if (del[v]) continue; // 跳过已删除的节点

        // 重新计算子树v的重心:
        mxs = sum = siz[v];   // 子树总大小为siz[v],重置最大子树大小
        getroot(v, 0);        // 找子树v的重心
        getroot(root, 0);     // 重构子树大小(确保siz数组准确)
        divide(root);         // 递归处理子树的重心
    }
}

int main()
{
    // 第一步:读入树的节点数n和询问次数m
    scanf("%d%d", &n, &m);

    // 第二步:读入n-1条无向边,构建链式前向星
    for (int i = 1; i < n; ++i)
    {
        int u, v, w;
        scanf("%d%d%d", &u, &v, &w);
        add(u, v, w); // 加u->v的边
        add(v, u, w); // 加v->u的边(无向树)
    }

    // 第三步:读入m个询问的目标长度
    for (int i = 1; i <= m; ++i)
        scanf("%d", &ask[i]);

    // 第四步:初始化找整棵树的重心
    mxs = sum = n;    // 总节点数为n,重置最大子树大小
    getroot(1, 0);    // 第一次找重心(初始根为1)
    getroot(root, 0); // 重构子树大小(确保siz数组准确)

    // 第五步:启动点分治
    divide(root);

    // 第六步:输出每个询问的结果
    for (int i = 1; i <= m; ++i)
        ans[i] ? puts("AYE") : puts("NAY"); // 1=AYE(存在),0=NAY(不存在)

    return 0;
}

3.虚树

  • 洛谷题目:P2495 消耗战

  • 参考视频:https://www.bilibili.com/video/BV1mX4y1j7AZ/

  • 题目描述

    在一场战争中,战场由 n 个岛屿和 n−1 个桥梁组成,保证每两个岛屿间有且仅有一条路径可达。现在,我军已经侦查到敌军的总部在编号为 1 的岛屿,而且他们已经没有足够多的能源维系战斗,我军胜利在望。已知在其他 k 个岛屿上有丰富能源,为了防止敌军获取能源,我军的任务是炸毁一些桥梁,使得敌军不能到达任何能源丰富的岛屿。由于不同桥梁的材质和结构不同,所以炸毁不同的桥梁有不同的代价,我军希望在满足目标的同时使得总代价最小。

    侦查部门还发现,敌军有一台神秘机器。即使我军切断所有能源之后,他们也可以用那台机器。机器产生的效果不仅仅会修复所有我军炸毁的桥梁,而且会重新随机资源分布(但可以保证的是,资源不会分布到 1 号岛屿上)。不过侦查部门还发现了这台机器只能够使用 m 次,所以我们只需要把每次任务完成即可。

    输入格式

    第一行一个整数 n,表示岛屿数量。

    接下来 n−1 行,每行三个整数 u,v,w ,表示 u 号岛屿和 v 号岛屿由一条代价为 w 的桥梁直接相连。

    第 n+1 行,一个整数 m ,代表敌方机器能使用的次数。

    接下来 m 行,第 i 行一个整数 ki​ ,代表第 i 次后,有 ki​ 个岛屿资源丰富。接下来 ki​ 个整数 h1​,h2​,...,hki​​ ,表示资源丰富岛屿的编号。

    输出格式

    输出共 m 行,表示每次任务的最小代价。

    输入输出样例

    输入 #1复制

    10
    1 5 13
    1 9 6
    2 1 19
    2 4 8
    2 3 91
    5 6 8
    7 5 4
    7 8 31
    10 7 9
    3
    2 10 6
    4 5 7 8 3
    3 9 4 6
    

    输出 #1复制

    12
    32
    22
    

    说明/提示

    数据规模与约定

    • 对于 10% 的数据,n≤10,m≤5 。

    • 对于 20% 的数据,n≤100,m≤100,1≤ki​≤10 。

    • 对于 40% 的数据,n≤1000,1≤ki​≤15 。

    • 对于 100% 的数据,2≤n≤2.5×105,1≤m≤5×105,∑ki​≤5×105,1≤ki​<n,hi​=1,1≤u,v≤n,1≤w≤105 。

  • 问题归纳:

    • 此问题需要结合树上倍增(求 LCA)、虚树(压缩树结构)、树形 DP(求解最优值) 三大核心算法,解决的问题是:给定一棵带边权的无向树,多次查询 k 个关键点,求将这些关键点与根节点(1 号节点)连通的最小代价(代价规则:路径上的最小边权,最终取所有必要路径的最小总代价)。核心优化思路:通过虚树压缩原树结构(仅保留关键点和 LCA),再在虚树上做树形 DP,大幅降低多次查询的时间复杂度。

    1. 树上倍增预处理
    • 预处理 fa[x][i] 数组,实现 O(log n) 时间求 LCA,是虚树构建的基础;

    • 预处理 mn[x] 数组,记录根到 x 的最小边权,是 DP 的核心状态。

    2. 虚树构建核心

    • 按 DFS 序排序关键点,保证栈维护的是 “从根到当前节点的链”;

    • 用栈维护虚树的节点,通过 LCA 合并不同分支,仅保留关键点和必要的 LCA,将原树的 O(n) 节点压缩为 O(k) 节点(k 为关键点数量),大幅降低 DP 的时间开销。

    3. 树形 DP 状态转移

    • 对于虚树的叶子节点(关键点):必须连通到根,代价为 mn[x](根到 x 的最小边权);

    • 对于虚树的非叶子节点:有两种选择:

      • 汇总所有子节点的 DP 值(子树内关键点各自连通的总代价);

      • 直接取 mn[x](当前节点到根的最小边权,覆盖所有子树关键点的连通需求);

    • 最终取两种选择的较小值,即为当前子树的最小连通代价。

    4. 时间复杂度

    • 原树预处理:O(n log n);

    • 单次查询:虚树构建 O(k log k)(排序) + DP O(k);

    • 总复杂度:O(n log n + m k log k),远优于直接在原树上做 DP 的 O(m n)。

参考代码

// 核心算法:树上倍增(求LCA) + 虚树(压缩树形) + 树形DP(计算最小代价)
// 功能:处理多组查询,每组查询给定k个关键点,求覆盖这些关键点的最小代价
// 代价规则:关键点需连到根,非关键点可合并子树代价或选自身到根的最小边权(取更小)
#include <bits/stdc++.h>
#define LL long long
using namespace std;

const int N = 250005; // 节点数上限(2.5e5)
int h[N], tot; // h[]:邻接表表头(复用:先存原树,后存虚树);tot:邻接表边计数

// 邻接表边结构体
struct edge
{
    int x, y, w, ne; // x/y:边的两个端点;w:边权;ne:下一条边的索引(邻接表链表)
} e[N << 1]; // 原树是无向树,边数2*(n-1),开2倍空间

// 邻接表加边函数(无向边需调用两次)
// x/y:边的两个端点;w:边权
void add(int x, int y, int w)
{
    e[++tot] = {x, y, w, h[x]}; // 新建边,ne指向当前x的表头
    h[x] = tot; // 更新x的表头为新边的索引
}

// 树上倍增相关变量
int dep[N]; // dep[x]:节点x的深度(根1深度为1)
int fa[N][20]; // fa[x][i]:x的2^i级祖先(i∈[0,19],覆盖2^19>2.5e5)
int siz[N]; // siz[x]:x的子树大小(本代码未实际使用)
int dfn[N], cnt; // dfn[x]:x的DFS序;cnt:DFS序计数器(虚树排序用)
int s[N], top; // s[]:构建虚树的栈;top:栈顶指针
int n, m, k, a[N]; // n:原树节点数;m:查询数;k:单次查询的关键点数量;a[]:存储单次查询的关键点
int vis[N]; // vis[x]=1表示x是当前查询的关键点,DP后重置为0
LL mn[N]; // mn[x]:根(1)到x路径上的最小边权

// 预处理DFS:初始化深度、倍增数组、DFS序、mn数组、子树大小
// x:当前节点;f:x的父节点
void dfs(int x, int f)
{
    dfn[x] = ++cnt; // 记录x的DFS序
    dep[x] = dep[f] + 1; // x的深度=父节点深度+1
    fa[x][0] = f; // x的2^0=1级祖先就是父节点f
    siz[x] = 1; // 子树大小初始化为1(仅自身)

    // 构建倍增数组:fa[x][i] = fa[fa[x][i-1]][i-1]
    for (int i = 1; i <= 19; i++)
    {
        fa[x][i] = fa[fa[x][i - 1]][i - 1];
    }

    // 遍历x的所有邻边(原树邻接表)
    for (int i = h[x]; i; i = e[i].ne)
    {
        int y = e[i].y; // 邻接节点y
        if (y == f) continue; // 跳过父节点,避免回边

        // 计算y的mn值:根到y的最小边权 = min(根到x的最小边权, x-y的边权)
        mn[y] = min(mn[x], (LL) e[i].w);
        dfs(y, x); // 递归处理子节点y
        siz[x] += siz[y]; // 更新x的子树大小(累加子节点y的子树大小)
    }
}

// 树上倍增求x和y的最近公共祖先(LCA)
int lca(int x, int y)
{
    // 保证x的深度≥y的深度,方便后续上跳
    if (dep[x] < dep[y]) swap(x, y);

    // 第一步:将x上跳至与y同一深度
    for (int i = 19; ~i; i--)
    {
        // 从2^19到2^0枚举
        if (dep[fa[x][i]] >= dep[y])
        {
            x = fa[x][i];
        }
    }
    if (x == y) return y; // 深度相同后若x=y,直接返回LCA

    // 第二步:x和y同步上跳,直到父节点相同
    for (int i = 19; ~i; i--)
    {
        if (fa[x][i] != fa[y][i])
        {
            // 祖先不同时才上跳
            x = fa[x][i];
            y = fa[y][i];
        }
    }
    return fa[x][0]; // 最终父节点即为LCA
}

// 排序比较函数:按节点的DFS序升序排列(虚树构建的前提)
int cmp(int a, int b)
{
    return dfn[a] < dfn[b];
}

// 构建虚树:仅保留关键点和必要的LCA,压缩树形规模
void build()
{
    // 第一步:将关键点按DFS序排序,保证栈的处理顺序符合树形结构
    sort(a + 1, a + k + 1, cmp);

    tot = 0; // 重置边计数器(虚树复用tot和h数组)
    s[top = 1] = 1; // 栈初始化:根节点1入栈
    s[++top] = a[1]; // 第一个关键点入栈

    // 遍历剩余关键点,构建虚树
    for (int i = 2; i <= k; i++)
    {
        int l = lca(s[top], a[i]); // 栈顶节点与当前关键点的LCA

        // 情况1:栈顶的上一个节点深度≥LCA深度 → 弹出栈顶并连边
        while (top > 1 && dep[s[top - 1]] >= dep[l])
        {
            add(s[top - 1], s[top], 0); // 虚树连边(边权无意义,设为0)
            top--;
        }

        // 情况2:LCA≠栈顶节点 → 连LCA与栈顶,弹出栈顶,LCA入栈
        if (l != s[top])
        {
            add(l, s[top], 0);
            s[top] = l;
        }

        // 当前关键点入栈
        s[++top] = a[i];
    }

    // 处理栈中剩余节点,完成虚树连边
    while (top > 1)
    {
        add(s[top - 1], s[top], 0);
        top--;
    }
}

// 树形DP:计算虚树上以x为根的子树的最小代价
// 返回值:x子树的最小代价
LL DP(int x)
{
    if (vis[x])
    {
        // 情况1:x是当前查询的关键点
        // 递归处理x的所有虚树子节点
        for (int i = h[x]; i; i = e[i].ne)
        {
            DP(e[i].y);
            vis[e[i].y] = 0; // 处理完子节点后重置vis标记
        }
        h[x] = 0; // 清空x的虚树邻接表(避免干扰下次查询)
        return mn[x]; // 关键点代价=根到x的最小边权
    }
    else
    {
        // 情况2:x不是关键点
        LL sum = 0; // 累加所有子树的代价和
        for (int i = h[x]; i; i = e[i].ne)
        {
            sum += DP(e[i].y);
            vis[e[i].y] = 0; // 重置子节点vis标记
        }
        h[x] = 0; // 清空x的虚树邻接表
        // 非关键点代价:min(子树代价和, 根到x的最小边权)
        return min(sum, mn[x]);
    }
}

int main()
{
    // 第一步:读入原树结构
    scanf("%d", &n);
    for (int i = 1; i <= n - 1; i++)
    {
        int x, y, z;
        scanf("%d%d%d", &x, &y, &z);
        add(x, y, z); // 无向边:x→y
        add(y, x, z); // 无向边:y→x
    }

    // 第二步:预处理(DFS初始化倍增、mn等)
    mn[1] = 2e18; // 根节点无父边,mn设为极大值
    dfs(1, 0); // 根节点1,父节点0(无)
    memset(h + 1, 0, n << 2); // 清空原树邻接表(h数组复用为虚树)

    // 第三步:处理多组查询
    scanf("%d", &m);
    while (m--)
    {
        scanf("%d", &k);
        // 读入k个关键点,标记vis=1
        for (int i = 1; i <= k; i++)
        {
            scanf("%d", &a[i]);
            vis[a[i]] = 1;
        }
        build(); // 构建当前查询的虚树
        printf("%lld\n", DP(1)); // DP计算根1的最小代价并输出
    }
    return 0;
}

4.动态树LCT

  • 洛谷题目:P3690 动态树

  • 问题描述

  • 给定 n 个点以及每个点的权值,要你处理接下来的 m 个操作。 操作有四种,操作从 0 到 3 编号。点从 1 到 n 编号。

    • 0 x y 代表询问从 x 到 y 的路径上的点的权值的 xor 和。保证 x 到 y 是联通的。

    • 1 x y 代表连接 x 到 y,若 x 到 y 已经联通则无需连接。

    • 2 x y 代表删除边 (x,y),不保证边 (x,y) 存在。

    • 3 x y 代表将点 x 上的权值变成 y。

    输入格式

    第一行两个整数,分别为 n 和 m,代表点数和操作数。

    接下来 n 行,每行一个整数,第 (i+1) 行的整数 ai​ 表示节点 i 的权值。

    接下来 m 行,每行三个整数,分别代表操作类型和操作所需的量。

    输出格式

    对于每一个 0 号操作,你须输出一行一个整数,表示 x 到 y 的路径上点权的 xor 和。

    输入输出样例

    输入 #1复制

    3 3 
    1
    2
    3
    1 1 2
    0 1 2 
    0 1 1
    

    输出 #1复制

    3
    1
    

    输入 #2复制

    5 14
    114
    514
    19
    19
    810
    1 1 2
    0 1 2
    2 1 2
    1 1 2
    1 2 3
    2 1 3
    1 1 3
    1 4 5
    1 2 5
    0 3 5
    0 3 4
    3 5 233333
    0 1 5
    0 2 5
    

    输出 #2复制

    624
    315
    296
    232709
    232823
    

    说明/提示

    数据规模与约定

    对于全部的测试点,保证:

    • 1≤n≤105,1≤m≤3×105,1≤ai​≤109。

    • 对于操作 0,1,2,保证 1≤x,y≤n。

    • 对于操作 3,保证 1≤x≤n,1≤y≤109。

    参考视频:https://www.bilibili.com/video/BV14S4y1x7rr/

  • 实链 / 虚链:实链是显式用 Splay 维护的路径(父节点的指针在 Splay 中),虚链是原树的边但不在 Splay 中(父节点指针仅记录原树关系);

  • Access 操作:核心基础,将任意节点到根的路径转为实链,是所有路径操作的前提;

  • Makeroot 操作:通过翻转路径实现换根,让任意节点成为原树的根,方便处理 “任意两点间路径”;

  • 懒标记:翻转标记延迟处理,避免每次翻转都遍历路径,保证效率。

  • LCT 的核心思想是通过Splay 树维护树的 “实链”(显式路径),将原树的路径操作转化为 Splay 的区间操作,实现均摊 O (log n) 的时间复杂度。

2. 关键操作的逻辑链路

  • 路径查询(output):makeroot(x) → access(y) → splay(y) → y 的 sum 即为 x 到 y 的路径异或和;

  • 连边(link):makeroot(x) 后,若 x 和 y 不连通,将 x 的父设为 y;

  • 断边(cut):makeroot(x) 后,验证 x 和 y 直接相连,清空 y 的父节点;

  • 单点修改(change):将 x 伸展到 Splay 根,直接修改权值并上传 sum。

3. 时间复杂度

所有操作的均摊时间复杂度为 O (log n),核心是 Splay 的伸展操作保证了均摊对数级别的访问次数,懒标记则避免了重复处理路径信息。

4. 注意事项

  • pushall 必须在 Splay 操作前执行,否则懒标记未下传会导致 sum 计算错误;

  • notroot 宏是判断节点是否为所在 Splay 的根,而非原树的根;

  • 翻转标记仅影响 Splay 的左右孩子,不改变原树的父节点关系。

参考代码

#include <bits/stdc++.h>
using namespace std;

// 常量与宏定义:简化代码书写,提升可读性
#define N 100010                  // 树的最大节点数
#define fa(x) tr[x].fa            // 节点x的父节点(Splay树/原树共用)
#define lc(x) tr[x].ch[0]         // 节点x的左孩子(Splay树中,对应实链的“下一级”)
#define rc(x) tr[x].ch[1]         // 节点x的右孩子(Splay树中,对应实链的“上一级”)
#define notroot(x) lc(fa(x))==x||rc(fa(x))==x  // 判断x是否是其所在Splay树的根(非根返回true)

int n, m;  // n:节点总数;m:操作次数

// LCT节点结构体(每个节点对应Splay树节点+原树节点的信息)
struct node
{
    int ch[2];   // ch[0]=左孩子,ch[1]=右孩子(Splay树结构)
    int fa;      // 父节点(Splay树和原树共用该指针)
    int v;       // 节点自身的权值
    int sum;     // 以x为根的Splay子树对应的路径异或和(核心维护的信息)
    int tag;     // 翻转懒标记(用于makeroot操作,标记路径需要翻转方向)
} tr[N];

// 【上传操作】更新当前节点的sum(异或和)
// 原理:当前节点的异或和 = 左子树异或和 ^ 自身权值 ^ 右子树异或和
void pushup(int x)
{
    tr[x].sum = tr[lc(x)].sum ^ tr[x].v ^ tr[rc(x)].sum;
}

// 【下传操作】处理当前节点的翻转懒标记
// 原理:翻转标记生效时,交换左右孩子(对应路径方向翻转),并将标记下传给子节点
void pushdown(int x)
{
    if (tr[x].tag)  // 若当前节点有翻转标记
    {
        swap(lc(x), rc(x));          // 交换左右孩子(翻转路径方向)
        tr[lc(x)].tag ^= 1;          // 左孩子标记取反(后续需处理)
        tr[rc(x)].tag ^= 1;          // 右孩子标记取反
        tr[x].tag = 0;              // 清空当前节点标记
    }
}

// 【递归下传】从x的所有祖先开始,逐层下传懒标记
// 作用:保证Splay操作前,路径上所有节点的标记都已生效(避免信息错误)
void pushall(int x)
{
    if (notroot(x))  // 若x不是所在Splay的根,先处理父节点的标记
        pushall(fa(x));
    pushdown(x);     // 处理当前节点的标记
}

// 【Splay旋转操作】将x绕其父节点y旋转,保持Splay的中序遍历对应原路径
// 旋转类型:根据x是y的左/右孩子,分为左旋/右旋
void rotate(int x)
{
    int y = fa(x), z = fa(y);        // y是x的父,z是y的父
    int k = rc(y) == x;              // k=1表示x是y的右孩子(右旋),k=0表示左孩子(左旋)

    // 步骤1:处理z与x的连接(若y不是Splay根,更新z的孩子为x)
    if (notroot(y))
        tr[z].ch[rc(z) == y] = x;
    fa(x) = z;                       // x的父节点更新为z

    // 步骤2:处理x的异侧孩子与y的连接(k^1表示异侧:右→左,左→右)
    tr[y].ch[k] = tr[x].ch[k ^ 1];
    if (tr[x].ch[k ^ 1])             // 若x的异侧孩子存在,更新其父节点为y
        fa(tr[x].ch[k ^ 1]) = y;

    // 步骤3:将y设为x的异侧孩子,更新父节点
    tr[x].ch[k ^ 1] = y;
    fa(y) = x;

    // 步骤4:自底向上更新sum(先更新y,再更新x)
    pushup(y);
    pushup(x);
}

// 【Splay伸展操作】将x伸展到其所在Splay树的根
// 核心规则:折线型先旋x,直线型先旋y,最终旋x(保证均摊复杂度)
void splay(int x)
{
    pushall(x);  // 先下传所有祖先的标记,保证操作前信息正确
    while (notroot(x))  // 直到x成为Splay根
    {
        int y = fa(x), z = fa(y);
        // 若y也不是Splay根:判断是折线(旋x)还是直线(旋y)
        if (notroot(y))
            (rc(y) == x) ^ (rc(z) == y) ? rotate(x) : rotate(y);
        rotate(x);  // 最终旋x到父节点位置
    }
}

// 【Access操作】打通x到原树根的实链(将路径转为Splay的实链)
// 原理:从x向上遍历,将每个节点的右孩子替换为前一个节点,形成连续实链
void access(int x)
{
    for (int y = 0; x; )  // y记录上一个处理的节点,初始为0(空)
    {
        splay(x);         // 将x转到当前Splay的根(方便修改右孩子)
        rc(x) = y;        //** x的右孩子设为y(连接前后实链),一箭双雕,x-y建实链,x与另一个自然成虚链 **
        pushup(x);        // 更新x的sum(右孩子变化)
        y = x;           //** 记录当前节点为下一轮的y **
        x = fa(x);        //** x向上跳到父节点,继续处理  **
    }
}

// 【Makeroot操作】将x设为原树的根
// 原理:先access(x)打通x到原根的实链,再splay(x)到根,最后翻转路径(交换左右孩子)
void makeroot(int x)
{
    access(x);      // 打通x到原根的实链
    splay(x);       // 将x伸展到Splay根(此时x是实链的末端)
    tr[x].tag ^= 1;   // 翻转标记(路径方向反转,x变为根)
}

// 【Split操作】拆分出x到y的路径,使其成为独立的Splay树(根为y)
// 原理:makeroot(x)将x设为根,access(y)打通x到y的实链,splay(y)到根
void split(int x, int y)
{
    makeroot(x);  // x设为原树根
    access(y);    // 打通x到y的实链
    splay(y);     // y伸展到Splay根(此时y的sum即为x到y的路径异或和)
}

// 【查询操作】输出x到y路径的异或和
void output(int x, int y)
{
    split(x, y);  // 拆分出x到y的路径
    printf("%d\n", tr[y].sum);  // y的sum即为路径异或和
}

// 【找根操作】查找x所在原树的根节点(用于判断连通性)
int findroot(int x)
{
    access(x);    // 打通x到原根的实链
    splay(x);     // x伸展到Splay根
    // 一直向左走(实链的最底端),找到原树的根
    while (lc(x))
    {
        pushdown(x);  // 下传标记(避免左孩子错误)
        x = lc(x);
    }
    splay(x);  // 伸展根节点(防止后续操作卡链,优化复杂度)
    return x;
}

// 【连边操作】连接x和y(保证x和y不连通时才连边)
void link(int x, int y)
{
    makeroot(x);  // x设为原树根
    // 若x和y不在同一棵树(根不同),则将x的父节点设为y
    if (findroot(y) != x)
        fa(x) = y;
}

// 【断边操作】切断x和y之间的边(保证边存在时才断边)
void cut(int x, int y)
{
    makeroot(x);  // x设为原树根
    // 断边条件:y的根是x && y的父是x && y没有左孩子(x和y直接相连)
    if (findroot(y) == x && fa(y) == x && !lc(y))
    {
        fa(y) = 0;       // 清空y的父节点(断边)
        pushup(x);       // 更新x的sum(右孩子y被移除)
    }
}

// 【修改操作】修改节点x的权值为y
void change(int x, int y)
{
    splay(x);         // x伸展到Splay根(保证能直接修改)
    tr[x].v = y;       // 更新节点权值
    pushup(x);        // 上传sum(权值变化)
}

int main()
{
    scanf("%d%d", &n, &m);  // 读入节点数n,操作次数m
    int t, x, y;         // t:操作类型;x,y:操作参数
    // 初始化每个节点的权值(sum初始等于v,因为无孩子)
    for (int i = 1; i <= n; i++)
        scanf("%d", &tr[i].v);

    while (m--)  			// 处理m次操作
    {
        scanf("%d%d%d", &t, &x, &y);
        if (t == 0)    // 操作0:查询x到y路径的异或和
            output(x, y);
        else if (t == 1) // 操作1:连接x和y
            link(x, y);
        else if (t == 2) // 操作2:切断x和y之间的边
            cut(x, y);
        else              // 操作3:修改节点x的权值为y
            change(x, y);
    }
    return 0;
}