古典叙事 · 技术札记

序章

山河有卷
人间有声

写代码,也写长风、旧城与未熄的灯。
愿每一篇随笔,都有自己的山水与回声。
阅览随笔

线段树合并

适用位置

线段树的合并与分裂是线段树的常用技巧,常见于权值线段树维护可重集的场景。

例如,树上某些结点处有若干操作,如果需要自下而上地将子节点信息传递给亲节点,而单个结点处的信息又方便用线段树维护时,就可以应用线段树合并的技巧控制整体的复杂度。
例如下面这个模板题

P4556 [Vani有约会] 雨天的尾巴 /【模板】线段树合并

模板题

题目背景

深绘里一直很讨厌雨天。

灼热的天气穿透了前半个夏天,后来一场大雨和随之而来的洪水,浇灭了一切。

虽然深绘里家乡的小村落对洪水有着顽固的抵抗力,但也倒了几座老房子,几棵老树被连根拔起,以及田地里的粮食被弄得一片狼藉。

无奈的深绘里和村民们只好等待救济粮来维生。

不过救济粮的发放方式很特别。

题目描述

村落里一共有 \(n\) 座房屋,并形成一个树状结构。然后救济粮分 \(m\) 次发放,每次选择两个房屋 \((x, y)\),然后对于 \(x\)\(y\) 的路径上(含 \(x\)\(y\))每座房子里发放一袋 \(z\) 类型的救济粮。

然后深绘里想知道,当所有的救济粮发放完毕后,每座房子里存放的最多的是哪种救济粮。

输入格式

输入的第一行是两个用空格隔开的正整数,分别代表房屋的个数 \(n\) 和救济粮发放的次数 \(m\)

\(2\) 到 第 \(n\) 行,每行有两个用空格隔开的整数 \(a, b\),代表存在一条连接房屋 \(a\)\(b\) 的边。

\((n + 1)\) 到第 \((n + m)\) 行,每行有三个用空格隔开的整数 \(x, y, z\),代表一次救济粮的发放是从 \(x\)\(y\) 路径上的每栋房子发放了一袋 \(z\) 类型的救济粮。

输出格式

输出 \(n\) 行,每行一个整数,第 \(i\) 行的整数代表 \(i\) 号房屋存放最多的救济粮的种类,如果有多种救济粮都是存放最多的,输出种类编号最小的一种。

如果某座房屋没有救济粮,则输出 \(0\)

输入输出样例 #1

输入 #1

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

输出 #1

2
3
3
0
2

说明/提示

  • 对于 \(20\%\) 的数据,保证 \(n, m \leq 100\)
  • 对于 \(50\%\) 的数据,保证 \(n, m \leq 2 \times 10^3\)
  • 对于 \(100\%\) 测试数据,保证 \(1 \leq n, m \leq 10^5\)\(1 \leq a,b,x,y \leq n\)\(1 \leq z \leq 10^5\)

题意分析

这个题目要我们处理一个树上差分问题,但是现在有一个问题是,树上同一个节点需要维护多个差分数组,所以我们维护多个线段树,因为我们只需要找到最大的那一个,所以我们可以对线段树进行合并。

思路过程

分析一下变量

// 全局变量定义
i64 n, m;                  // n表示节点数,m表示操作数
i64 ans[100005];           // 存储每个节点的答案(出现次数最多的颜色)
vector<i64> t[200005];     // 存储树的邻接表
i64 fa[200005][63];        // 用于LCA算法的倍增表,fa[x][k]表示x的2^k倍祖先
i64 dep[200005];           // 存储每个节点的深度
i64 rt[200005];            // 每个节点对应的动态开点线段树根节点

// 动态开点线段树相关数组
i64 sum[5000005];          // 存储线段树节点对应的颜色出现次数
i64 cnt = 0;               // 线段树节点计数器,用于动态分配节点
i64 res[500005];           // 存储线段树节点对应的出现次数最多的颜色
i64 ls[5000005];           // 线段树节点的左孩子
i64 rs[5000005];           // 线段树节点的右孩子


对于更新操作,我们让当前节点保存出现次数最多的颜色

// 更新线段树节点信息,确保当前节点保存的是出现次数最多的颜色
void update(i64 p) {
    // 比较左右子树的颜色出现次数,取较大的一方
    if (sum[ls[p]] < sum[rs[p]]) {
        res[p] = res[rs[p]];  // 记录右子树中出现次数最多的颜色
        sum[p] = sum[rs[p]];  // 记录最大出现次数
    }
    else {
        res[p] = res[ls[p]];  // 记录左子树中出现次数最多的颜色
        sum[p] = sum[ls[p]];  // 记录最大出现次数
    }
}

首先我们需要,做一个树上差分,先写一个lca倍增算法


// 深度优先搜索,预处理LCA所需的倍增表和节点深度
void dfs(i64 x) {
    // 预处理倍增表,计算x的2^k倍祖先
    for (int i = 0; i <= 20; i++) {
        fa[x][i + 1] = fa[fa[x][i]][i];
    }

    // 遍历x的所有邻接节点
    for (auto it : t[x]) {
        // 跳过父节点,避免重复访问
        if (it != fa[x][0]) {
            dep[it] = dep[x] + 1;  // 设置子节点深度
            fa[it][0] = x;         // 设置子节点的直接父节点
            dfs(it);               // 递归处理子节点
        }
    }
}

// 计算两个节点x和y的最近公共祖先(LCA)
i64 lca(i64 x, i64 y) {
    // 确保x的深度不小于y,方便后续处理
    if (dep[x] < dep[y]) {
        swap(x, y);
    }

    // 将x上移到与y相同的深度
    for (i64 len = dep[x] - dep[y], w = 0; len; len >>= 1, w++) {
        if (len & 1) {  // 如果二进制第w位为1,则上移2^w步
            x = fa[x][w];
        }
    }

    // 如果x和y已经相同,说明此时x(y)就是LCA
    if (x == y) {
        return x;
    }

    // 从最大的步数开始尝试,找到LCA的前一步
    for (i64 i = 20; i >= 0; i--) {
        if (fa[x][i] != fa[y][i]) {
            x = fa[x][i];
            y = fa[y][i];
        }
    }

    // 最终LCA是x的直接父节点
    return fa[x][0];
}

然后我们动态开点建一个线段树,(为什么要用动态开点呢,因为我们要维护1e5个线段树,不然会爆内存)
我们用每一个节点代表这个救济粮的根节点。

// 向动态开点线段树中添加(或修改)颜色的出现次数
// 参数:p-当前节点,x,y-当前区间,co-颜色值,val-增减的数量
i64 add(i64 p, i64 x, i64 y, i64 co, i64 val) {
    // 如果当前节点为空,动态创建一个新节点
    if (!p) p = ++cnt;

    // 如果到达叶子节点(对应具体颜色),更新该颜色的出现次数
    if (x == y) {
        sum[p] += val;  // 更新出现次数
        res[p] = co;    // 记录当前颜色
        return p;       // 返回当前节点
    }

    // 计算中间点,划分左右区间
    i64 mid = (x + y) >> 1;

    // 根据颜色值决定递归左子树还是右子树
    if (co <= mid) {
        ls[p] = add(ls[p], x, mid, co, val);
    }
    else {
        rs[p] = add(rs[p], mid + 1, y, co, val);
    }

    // 更新当前节点信息
    update(p);
    return p;  // 返回当前节点
}

我们现在对于每一个颜色节点都建立了一颗树,并且动态的开出了他的子节点。
现在我们对这些线段树进行合并操作

// 合并两棵动态开点线段树,将y合并到x中
// 参数:x,y-待合并的两棵树的根节点,x1,x2-当前区间范围
i64 merge(i64 x, i64 y, i64 x1, i64 x2) {
    // 如果其中一棵树为空,直接返回另一棵树
    if (!x) return y;
    if (!y) return x;

    // 如果到达叶子节点,合并颜色出现次数
    if (x1 == x2) {
        sum[x] += sum[y];  // 累加出现次数
        return x;          // 返回合并后的节点
    }

    // 计算中间点,划分左右区间
    i64 mid = (x1 + x2) >> 1;

    // 递归合并左右子树
    ls[x] = merge(ls[x], ls[y], x1, mid);
    rs[x] = merge(rs[x], rs[y], mid + 1, x2);

    // 更新当前节点信息
    update(x);
    return x;  // 返回合并后的根节点
}

// 后序遍历计算每个节点的答案
void cacl(i64 x) {
    // 遍历x的所有子节点
    for (auto it : t[x]) {
        if (it != fa[x][0]) {  // 跳过父节点
            cacl(it);          // 递归处理子节点
            // 将子节点的线段树合并到当前节点
            rt[x] = merge(rt[x], rt[it], 1, 100000);
        }
    }

    // 记录当前节点的答案(出现次数最多的颜色)
    ans[x] = res[rt[x]];
    // 如果没有颜色出现(或总次数为0),答案设为0
    if (sum[rt[x]] == 0) ans[x] = 0;
}
AC代码
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;

// 定义64位整数类型,用于处理可能的大数值
using i64 = long long;

// 全局变量定义
i64 n, m;                  // n表示节点数,m表示操作数
i64 ans[100005];           // 存储每个节点的答案(出现次数最多的颜色)
vector<i64> t[200005];     // 存储树的邻接表
i64 fa[200005][63];        // 用于LCA算法的倍增表,fa[x][k]表示x的2^k倍祖先
i64 dep[200005];           // 存储每个节点的深度
i64 rt[200005];            // 每个节点对应的动态开点线段树根节点

// 动态开点线段树相关数组
i64 sum[5000005];          // 存储线段树节点对应的颜色出现次数
i64 cnt = 0;               // 线段树节点计数器,用于动态分配节点
i64 res[500005];           // 存储线段树节点对应的出现次数最多的颜色
i64 ls[5000005];           // 线段树节点的左孩子
i64 rs[5000005];           // 线段树节点的右孩子

// 深度优先搜索,预处理LCA所需的倍增表和节点深度
void dfs(i64 x) {
    // 预处理倍增表,计算x的2^k倍祖先
    for (int i = 0; i <= 20; i++) {
        fa[x][i + 1] = fa[fa[x][i]][i];
    }

    // 遍历x的所有邻接节点
    for (auto it : t[x]) {
        // 跳过父节点,避免重复访问
        if (it != fa[x][0]) {
            dep[it] = dep[x] + 1;  // 设置子节点深度
            fa[it][0] = x;         // 设置子节点的直接父节点
            dfs(it);               // 递归处理子节点
        }
    }
}

// 计算两个节点x和y的最近公共祖先(LCA)
i64 lca(i64 x, i64 y) {
    // 确保x的深度不小于y,方便后续处理
    if (dep[x] < dep[y]) {
        swap(x, y);
    }

    // 将x上移到与y相同的深度
    for (i64 len = dep[x] - dep[y], w = 0; len; len >>= 1, w++) {
        if (len & 1) {  // 如果二进制第w位为1,则上移2^w步
            x = fa[x][w];
        }
    }

    // 如果x和y已经相同,说明此时x(y)就是LCA
    if (x == y) {
        return x;
    }

    // 从最大的步数开始尝试,找到LCA的前一步
    for (i64 i = 20; i >= 0; i--) {
        if (fa[x][i] != fa[y][i]) {
            x = fa[x][i];
            y = fa[y][i];
        }
    }

    // 最终LCA是x的直接父节点
    return fa[x][0];
}

// 更新线段树节点信息,确保当前节点保存的是出现次数最多的颜色
void update(i64 p) {
    // 比较左右子树的颜色出现次数,取较大的一方
    if (sum[ls[p]] < sum[rs[p]]) {
        res[p] = res[rs[p]];  // 记录右子树中出现次数最多的颜色
        sum[p] = sum[rs[p]];  // 记录最大出现次数
    }
    else {
        res[p] = res[ls[p]];  // 记录左子树中出现次数最多的颜色
        sum[p] = sum[ls[p]];  // 记录最大出现次数
    }
}

// 向动态开点线段树中添加(或修改)颜色的出现次数
// 参数:p-当前节点,x,y-当前区间,co-颜色值,val-增减的数量
i64 add(i64 p, i64 x, i64 y, i64 co, i64 val) {
    // 如果当前节点为空,动态创建一个新节点
    if (!p) p = ++cnt;

    // 如果到达叶子节点(对应具体颜色),更新该颜色的出现次数
    if (x == y) {
        sum[p] += val;  // 更新出现次数
        res[p] = co;    // 记录当前颜色
        return p;       // 返回当前节点
    }

    // 计算中间点,划分左右区间
    i64 mid = (x + y) >> 1;

    // 根据颜色值决定递归左子树还是右子树
    if (co <= mid) {
        ls[p] = add(ls[p], x, mid, co, val);
    }
    else {
        rs[p] = add(rs[p], mid + 1, y, co, val);
    }

    // 更新当前节点信息
    update(p);
    return p;  // 返回当前节点
}

// 合并两棵动态开点线段树,将y合并到x中
// 参数:x,y-待合并的两棵树的根节点,x1,x2-当前区间范围
i64 merge(i64 x, i64 y, i64 x1, i64 x2) {
    // 如果其中一棵树为空,直接返回另一棵树
    if (!x) return y;
    if (!y) return x;

    // 如果到达叶子节点,合并颜色出现次数
    if (x1 == x2) {
        sum[x] += sum[y];  // 累加出现次数
        return x;          // 返回合并后的节点
    }

    // 计算中间点,划分左右区间
    i64 mid = (x1 + x2) >> 1;

    // 递归合并左右子树
    ls[x] = merge(ls[x], ls[y], x1, mid);
    rs[x] = merge(rs[x], rs[y], mid + 1, x2);

    // 更新当前节点信息
    update(x);
    return x;  // 返回合并后的根节点
}

// 后序遍历计算每个节点的答案
void cacl(i64 x) {
    // 遍历x的所有子节点
    for (auto it : t[x]) {
        if (it != fa[x][0]) {  // 跳过父节点
            cacl(it);          // 递归处理子节点
            // 将子节点的线段树合并到当前节点
            rt[x] = merge(rt[x], rt[it], 1, 100000);
        }
    }

    // 记录当前节点的答案(出现次数最多的颜色)
    ans[x] = res[rt[x]];
    // 如果没有颜色出现(或总次数为0),答案设为0
    if (sum[rt[x]] == 0) ans[x] = 0;
}

int main() {
    // 加速输入输出
    ios::sync_with_stdio(false);

    // 读取节点数和操作数
    cin >> n >> m;

    // 读取树的边,构建邻接表
    for (i64 i = 1; i < n; i++) {
        i64 x, y;
        cin >> x >> y;
        t[x].push_back(y);
        t[y].push_back(x);
    }

    // 初始化LCA相关信息(从根节点1开始)
    dfs(1);

    // 处理m次操作
    for (i64 i = 1; i <= m; i++) {
        i64 x, y, z;
        cin >> x >> y >> z;  // 读取路径端点和颜色

        // 在x和y处增加颜色z的出现次数
        rt[x] = add(rt[x], 1, 100000, z, 1);
        rt[y] = add(rt[y], 1, 100000, z, 1);

        // 计算x和y的LCA
        i64 lc = lca(x, y);

        // 在LCA处和LCA的父节点处减少颜色z的出现次数(差分思想)
        rt[lc] = add(rt[lc], 1, 100000, z, -1);
        rt[fa[lc][0]] = add(rt[fa[lc][0]], 1, 100000, z, -1);
    }

    // 计算每个节点的答案
    cacl(1);

    // 输出所有节点的答案
    for (i64 i = 1; i <= n; i++) {
        cout << ans[i] << endl;
    }

    return 0;
}
posted @ 2025-09-23 17:21  Morphis‘  阅读(23)  评论(0)    收藏  举报

特别策划 · CINEMATIC NOTES

风沙与孤骑

风起塞外,
胜负在刀剑之前。

“把复杂拆成秩序,把未知写成答案。”
Morphis · 山河一卷 愿你从这里出发,仍能听见山风。 影像:farfarSébastien Goldberg / Unsplash