[SDOI2014] 旅行

GitHub跳转原题关系图返回列表

树链剖分把路径变区间,宗教过滤给出三种实现:按宗教动态开点线段树(主解)、离线离散化的普通线段树、单树每节点挂 map,附实测对比。

OJ: luogu

题目 ID: P3313

难度:提高+/省选-

标签:重链剖分动态线段树离散化路径查询

创建: 2026-07-17 02:00

更新: 2026-10-30 08:41

RBook 文章:
树链剖分
动态开点线段树

形式化题目

给定一棵 nn 个点的树,每个点 vv 带两个整数属性:颜色 cvc_v 与权值 wvw_v(wvw_v 为正整数)。记树上 uu 到 vv 的唯一简单路径上的点集为 P(u,v)P(u, v)。依次处理 mm 条操作:

  1. CC x c:把点 xx 的颜色改成 cc,即 cx←cc_x \leftarrow c;
  2. CW x w:把点 xx 的权值改成 ww,即 wx←ww_x \leftarrow w;
  3. QS x y:在路径 x→yx \to y 上,把所有颜色等于 cxc_x 的点的权值相加并输出,即求 ∑v∈P(x,y), cv=cxwv\sum_{v \in P(x, y),\ c_v = c_x} w_v;
  4. QM x y:在路径 x→yx \to y 上,取所有颜色等于 cxc_x 的点的权值最大值并输出,即求 max⁡{ wv∣v∈P(x,y), cv=cx }\max\{\, w_v \mid v \in P(x, y),\ c_v = c_x \,\}。

数据保证每次查询满足 cx=cyc_x = c_y。原题把颜色称为「宗教」、把权值称为「评级」,下文遇到时按这两个叫法理解,指的是同一组属性。

解法总览

三个解法共享前一半:树链剖分把路径变成 O(log⁡n)O(\log n) 段 dfn 连续区间。它们在「怎么按宗教过滤」上分成三条路。

树链剖分部分:两次 DFS 求每个点的重儿子与链头,再按「先重儿子」的顺序重新编号(dfn)。样例的树很小,剖分结果可以直接摊开看:点 11 有两个儿子 22、33,33 的子树有 33 个点、22 的子树只有 11 个,所以重儿子是 33;33 的重儿子是 44。

样例树的剖分结果与重编号后的 dfn 数组

图的左半边是树:蓝色粗线是重儿子边,它们连成重链 1→3→41 \to 3 \to 4;灰色虚线是轻边。每个点里同时写着它的 dfn 值。右半边把同一份信息摊成 dfn 数组,可以看到重链上的点恰好挨在一起——1,3,41, 3, 4 占住 dfn 1..31..3,两条单点链分别占 dfn 44 和 dfn 55。

QS 5 1 这条路径(5→3→15 \to 3 \to 1)因此被拆成两段:先取 55 所在的单点链,得到 dfn 区间 [4,4][4,4];再取剩下的 3→13 \to 1,得到 dfn 区间 [1,2][1,2]。两段都是连续区间,可以交给线段树。

三条分支的差别只在下一层:

解法 过滤宗教的手段 时间(预处理 / 单点 / 查询) 空间 实测(n=m=105n = m = 10^5) 对应代码
一(推荐提交) 每种宗教一棵线段树,共享节点池、动态开点 O(nlog⁡n)O(n\log n) / O(log⁡n)O(\log n) / O(log⁡2n)O(\log^2 n) O((n+m)log⁡n)O((n+m)\log n) 169–293 ms,33–61 MiB main.cpp
二 每种宗教一棵普通线段树,坐标先离线离散化成「该宗教历史上出现过的 dfn」 O((n+m)log⁡n)O((n+m)\log n) / O(log⁡n)O(\log n) / O(log⁡2n)O(\log^2 n) O(n+m)O(n+m) 162–276 ms,20–28 MiB main2.cpp
三 只开一棵 dfn 线段树,每个节点挂一张「宗教 → 摘要」的 map O(nlog⁡nlog⁡C)O(n\log n\log C) / O(log⁡nlog⁡C)O(\log n\log C) / O(log⁡2nlog⁡C)O(\log^2 n\log C) O(nlog⁡n)O(n\log n) 个表项 1.8–6.3 s(换哈希表 1.2–3.6 s),95–119 MiB main3.cpp

三者的关系:解法一和解法二是同一个「按宗教拆树」模型,差别只在坐标空间怎么来(在线开点 vs 离线压缩);解法三完全不拆树,把宗教当成线段树节点里的一维映射,写法最短、最慢。解法一不需要离线、常数和空间都适中,所以作为推荐提交的主解。

暴力解法

适用范围

n⩽100n \leqslant 100,即 brute.cpp 里 MAXN = 105 的上界。它的用途有两个:把题意写成可执行的说明书,以及作为三个解法的对拍基准。

思路

先用一次 BFS 把树定根(根取 11),得到每个点的父亲 parent 与深度 depth。查询时只做一件事:把两个端点沿父亲往上爬,逐点判断宗教。

具体是三步:先让较深的一端单独往上爬,直到两端深度相同;然后两端一起往上爬,直到相遇;路径上的每个点(包括起点和终点)都检查一次宗教是否等于 cxc_x,符合就累加评级、更新最大值。CC 与 CW 直接改数组,O(1)O(1)。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-08-12 22:59
 * update_at: 2026-08-12 23:00
 */
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 查询时沿路径逐点向上爬,只统计与起点同宗教的城市评级。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 105;

int n, q;
int w[MAXN];    // w[i] 城市 i 的评级
int col[MAXN];  // col[i] 城市 i 的宗教

vector<int> g[MAXN]; // 树的邻接表

int parent[MAXN]; // 以 1 为根的父亲
int depth[MAXN];  // 深度

// 从根 1 出发 BFS,得到每个节点的父亲与深度,供路径逐点爬使用。
void build_root(int root) {
    queue<int> que;
    que.push(root);
    parent[root] = 0;
    depth[root] = 1;
    while (!que.empty()) {
        int u = que.front();
        que.pop();
        for (int v : g[u]) {
            if (v == parent[u]) continue;
            parent[v] = u;
            depth[v] = depth[u] + 1;
            que.push(v);
        }
    }
}

// 朴素路径查询:让较深的端点不断向上爬,逐点判断宗教是否相同。
// 爬的过程中把符合条件的评级累加进和、更新最大值。
void path_query(int x, int y, int rel, int &tsum, int &tmax) {
    while (depth[x] > depth[y]) {
        if (col[x] == rel) {
            tsum += w[x];
            tmax = max(tmax, w[x]);
        }
        x = parent[x];
    }
    while (depth[y] > depth[x]) {
        if (col[y] == rel) {
            tsum += w[y];
            tmax = max(tmax, w[y]);
        }
        y = parent[y];
    }
    // 现在 x、y 同深度,一起向上爬直到相遇。
    while (x != y) {
        if (col[x] == rel) {
            tsum += w[x];
            tmax = max(tmax, w[x]);
        }
        if (col[y] == rel) {
            tsum += w[y];
            tmax = max(tmax, w[y]);
        }
        x = parent[x];
        y = parent[y];
    }
    // x == y,是路径的公共祖先,只统计一次。
    if (col[x] == rel) {
        tsum += w[x];
        tmax = max(tmax, w[x]);
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> col[i];
    }
    for (int i = 1; i < n; i++) {
        int x, y;
        cin >> x >> y;
        g[x].push_back(y);
        g[y].push_back(x);
    }

    build_root(1);

    while (q--) {
        string op;
        int x, y;
        cin >> op >> x >> y;
        if (op == "CC") {
            col[x] = y;
        } else if (op == "CW") {
            w[x] = y;
        } else {
            int tsum = 0, tmax = 0;
            path_query(x, y, col[x], tsum, tmax);
            if (op == "QS") {
                cout << tsum << '\n';
            } else {
                cout << tmax << '\n';
            }
        }
    }

    return 0;
}

复杂度与瓶颈

  • 时间:预处理 O(n)O(n);单次查询走的点数等于路径长度,最坏 O(n)O(n),mm 次查询合计 O(nm)O(nm)。n=m=105n = m = 10^5 时是 101010^{10} 量级,不可行。
  • 空间:O(n)O(n)。

瓶颈是查询本身:路径上的每一个点都要亲自走一遍、亲自判一次宗教。链状树上路径长度就是 nn,这条代价躲不掉。要让查询变快,就得同时解决两件事:把「路径」换成擅长区间查询的结构,把「只统计同宗教」这个过滤条件也交给数据结构去扛。

解法一:树链剖分 + 按宗教拆分的动态开点线段树

思路

QS/QM x y 全程只关心 cxc_x,别的宗教的点全部视而不见。既然查询是「在一段区间里只看某一类点」,那就干脆按宗教把点分家:每种宗教一棵线段树,只存当前属于该宗教的点,叶子下标仍然用 dfn。

样例初始状态按宗教拆开是这样的(55 个点的评级依次是 3,2,1,3,53,2,1,3,5,宗教依次是 1,3,2,3,11,3,2,3,1):

text
城市:         1   3   4   5   2
dfn 位置:     1   2   3   4   5
评级:         3   1   3   5   2
宗教:         1   2   3   1   3

按宗教拆成三棵独立的线段树(下标仍是 dfn 位置):
  宗教 1 的树:  [3] [ ] [ ] [5] [ ]
  宗教 2 的树:  [ ] [1] [ ] [ ] [ ]
  宗教 3 的树:  [ ] [ ] [3] [ ] [2]

同一个 dfn 位置只在一棵树上取得到值——它在哪棵树的哪片叶子上,就代表那个城市当前属于哪个宗教。于是「查路径上宗教 11 的评级和」就变成了「在宗教 11 的树上查两段区间」,城市 11 与 55 命中,3+5=83 + 5 = 8。CC 3 1 之后,评级 11 从宗教 22 的树搬到宗教 11 的树,同样两段查询就得到 3+1+5=93 + 1 + 5 = 9,与样例一致。

第一个障碍是空间:宗教数 CC 可达 10510^5,如果每种宗教都开一棵 4n4n 的线段树,需要 4nC=4×10104nC = 4\times10^{10} 个节点,按每个节点一个 int 也有 160 GB,而限制是 512 MiB。可是任意时刻只有 nn 个点有值,分布在所有宗教树上,其余位置全是空的。所以让所有宗教树共享一个节点池、按需开点:只给真正被写入过的位置建节点。

这里有一个容易算错的地方:不能拿「当前只有 nn 个点有值」直接当空间上界。动态开点的节点只增不删,一处清 00 之后,它到根的那条路径仍然占着位置,所以上界必须按「历史上所有单点写入」来数:

  • 初始化:nn 次插入,每次新建一条从根到叶的路径。n⩽105n \leqslant 10^5 时树高不超过 1717,路径上最多 1+⌈log⁡2n⌉=181 + \lceil \log_2 n \rceil = 18 个节点,合计不超过 18n18n。
  • CW:这个 dfn 位置早就在本宗教树里,路径已经存在,一个节点都不新建。
  • CC:在旧树上清 00 走的是已有路径,不新建;往新树写入时,若该 dfn 在这棵树里还没有路径,最多新建 1818 个。

于是整个过程新建的节点总数不超过

18n+18m≈3.6×106,18n + 18m \approx 3.6\times10^6,

这就是 MAXNODE = 4000005 的来历。如果只按「当前 nn 个点」估成 18n18n,在全是 CC 的数据上会低估一半,节点池直接越界。

四个操作因此各自落在很明确的位置上:

操作 做法
CC x c 在旧宗教树上把 dfn[x] 一叶清 00,改 col[x],在新宗教树上把 dfn[x] 写成 wxw_x
CW x w 改 wxw_x,在当前宗教树上把 dfn[x] 一叶覆盖成 ww
QS x y HLD 拆成 O(log⁡n)O(\log n) 段,每段在 roots[col[x]] 上查区间和,累加
QM x y 同上,查区间最大值,取最大

实现上有两个必须写对的地方:

  • 空节点 00 全局共享。 range_query 一进来遇到 node == 0 就直接返回,表示这棵宗教树在这段区间里没有任何城市。评级是正整数,所以「空」既可以当成和 00、也可以当成最大值 00,两个都是安全的中性元。
  • point_set 的根要传引用。 一个宗教第一次出现时 roots[c] == 0,函数里边必须新建根节点并把编号写回 roots[c],否则后续所有操作都找不到这棵树。

代码

代码按 rbook 模板 hld(全局数组、两次 DFS)与 segtree-dynamic(节点池 + 引用传根的动态线段树)组织;模板的剖分部分用显式栈避免链形树递归,这里因为 n⩽105n \leqslant 10^5、判题栈足够大,直接写成递归。

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-08-12 22:59
 * update_at: 2026-10-23 21:00
 */
// main.cpp:树链剖分 + 每种宗教一棵动态开点线段树。
// 把路径查询按宗教过滤:只统计路径上同宗教城市的评级和 / 最大值。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
const int MAXNODE = 4000005; // 初始 N 次与至多 Q 次改宗教插入,每次至多 18 个节点

int n, q;
int w[MAXN];    // w[i] 城市 i 的评级
int col[MAXN];  // col[i] 城市 i 的宗教

vector<int> g[MAXN]; // 树的邻接表

// ---- 树链剖分相关 ----
int parent[MAXN]; // 父亲
int depth[MAXN];  // 深度
int sz[MAXN];     // 子树大小
int heavy[MAXN];  // 重儿子
int top[MAXN];    // 重链链头
int dfn[MAXN];    // 剖分后的新编号(连续编号)
int timer;        // dfn 计数器

// 第一次 dfs:计算 parent、depth、sz、heavy。
void dfs1(int u, int f) {
    parent[u] = f;
    depth[u] = depth[f] + 1;
    sz[u] = 1;
    heavy[u] = 0;
    for (int v : g[u]) {
        if (v == f) continue;
        dfs1(v, u);
        sz[u] += sz[v];
        if (heavy[u] == 0 || sz[v] > sz[heavy[u]]) {
            heavy[u] = v;
        }
    }
}

// 第二次 dfs:先走重儿子让重链编号连续,再开新链。
void dfs2(int u, int chain_top) {
    top[u] = chain_top;
    dfn[u] = ++timer;
    if (heavy[u] != 0) {
        dfs2(heavy[u], chain_top);
    }
    for (int v : g[u]) {
        if (v == parent[u] || v == heavy[u]) continue;
        dfs2(v, v);
    }
}

// ---- 动态开点线段树(每种宗教一棵,共享节点池) ----
int lc[MAXNODE]; // 左儿子,0 表示空节点
int rc[MAXNODE]; // 右儿子,0 表示空节点
int seg_sum[MAXNODE]; // 区间和
int seg_max[MAXNODE]; // 区间最大值
int cnt;         // 已使用的节点个数
int roots[MAXN]; // roots[c] 宗教 c 的线段树根

// 新建一个节点,返回节点编号。
int new_node() {
    cnt++;
    return cnt;
}

// 在 root 为根的线段树中把位置 pos 的叶子设为 val,并回溯更新祖先。
// root 传引用,因为新建根时要把新根写回。
void point_set(int &root, int l, int r, int pos, int val) {
    if (root == 0) {
        root = new_node();
    }
    if (l == r) {
        seg_sum[root] = val;
        seg_max[root] = val;
        return;
    }
    int mid = (l + r) >> 1;
    if (pos <= mid) {
        point_set(lc[root], l, mid, pos, val);
    } else {
        point_set(rc[root], mid + 1, r, pos, val);
    }
    seg_sum[root] = seg_sum[lc[root]] + seg_sum[rc[root]];
    seg_max[root] = max(seg_max[lc[root]], seg_max[rc[root]]);
}

// 在 node 上查询区间 [ql, qr] 的和与最大值,结果累加到 tsum / tmax。
// 空节点(node == 0)表示这段区间内没有任何该宗教的城市,直接返回。
void range_query(int node, int l, int r, int ql, int qr, int &tsum, int &tmax) {
    if (node == 0) {
        return;
    }
    if (ql <= l && r <= qr) {
        tsum += seg_sum[node];
        tmax = max(tmax, seg_max[node]);
        return;
    }
    int mid = (l + r) >> 1;
    if (ql <= mid) {
        range_query(lc[node], l, mid, ql, qr, tsum, tmax);
    }
    if (qr > mid) {
        range_query(rc[node], mid + 1, r, ql, qr, tsum, tmax);
    }
}

// 在宗教 rel 的线段树上查询路径 x -> y 的评级和与最大值。
void path_query(int x, int y, int rel, int &tsum, int &tmax) {
    int root = roots[rel];
    while (top[x] != top[y]) {
        if (depth[top[x]] < depth[top[y]]) {
            swap(x, y);
        }
        range_query(root, 1, n, dfn[top[x]], dfn[x], tsum, tmax);
        x = parent[top[x]];
    }
    if (depth[x] > depth[y]) {
        swap(x, y);
    }
    range_query(root, 1, n, dfn[x], dfn[y], tsum, tmax);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> col[i];
    }
    for (int i = 1; i < n; i++) {
        int x, y;
        cin >> x >> y;
        g[x].push_back(y);
        g[y].push_back(x);
    }

    dfs1(1, 0);
    dfs2(1, 1);

    // 初始:把每个城市按宗教插入对应的线段树。
    for (int i = 1; i <= n; i++) {
        point_set(roots[col[i]], 1, n, dfn[i], w[i]);
    }

    while (q--) {
        string op;
        int x, y;
        cin >> op >> x >> y;
        if (op == "CC") {
            // 从旧宗教树中删除(叶子置 0),再插入新宗教树。
            point_set(roots[col[x]], 1, n, dfn[x], 0);
            col[x] = y;
            point_set(roots[col[x]], 1, n, dfn[x], w[x]);
        } else if (op == "CW") {
            w[x] = y;
            point_set(roots[col[x]], 1, n, dfn[x], y);
        } else {
            int tsum = 0, tmax = 0;
            path_query(x, y, col[x], tsum, tmax);
            if (op == "QS") {
                cout << tsum << '\n';
            } else {
                cout << tmax << '\n';
            }
        }
    }

    return 0;
}

同一个算法的 Python 实现放在 main.py,结构与 C++ 版本一一对应,用来对照阅读(range_query 改成了返回二元组、dfs2 改成显式栈)。它的常数更大,n=m=105n = m = 10^5 时实测 3.6–6.2 s,按「题目时限 3 倍」的 3000 ms 口径仍然超时,只作参考实现,不建议提交。

python
import sys
from array import array


sys.setrecursionlimit(1_000_000)
input = sys.stdin.buffer.readline
n, operations = map(int, input().split())
weight = [0] * (n + 1)
color = [0] * (n + 1)
for node in range(1, n + 1):
    weight[node], color[node] = map(int, input().split())
graph = [[] for _ in range(n + 1)]
for _ in range(n - 1):
    u, v = map(int, input().split())
    graph[u].append(v)
    graph[v].append(u)

parent = [0] * (n + 1)
depth = [0] * (n + 1)
order = [1]
for node in order:
    for neighbor in graph[node]:
        if neighbor != parent[node]:
            parent[neighbor] = node
            depth[neighbor] = depth[node] + 1
            order.append(neighbor)
subtree = [1] * (n + 1)
subtree[0] = 0
heavy = [0] * (n + 1)
for node in reversed(order[1:]):
    subtree[parent[node]] += subtree[node]
    if subtree[node] > subtree[heavy[parent[node]]]:
        heavy[parent[node]] = node
top = [0] * (n + 1)
dfn = [0] * (n + 1)
timer = 0
chains = [(1, 1)]
while chains:
    node, chain_top = chains.pop()
    while node:
        top[node] = chain_top
        timer += 1
        dfn[node] = timer
        for neighbor in graph[node]:
            if neighbor != parent[node] and neighbor != heavy[node]:
                chains.append((neighbor, neighbor))
        node = heavy[node]

# Node 0 is the shared empty node. Each religion owns one root in the same
# dynamic segment-tree pool.
left_child = array("i", [0])
right_child = array("i", [0])
node_sum = array("q", [0])
node_max = array("i", [0])
roots = array("i", [0]) * 100_001


def new_node():
    left_child.append(0)
    right_child.append(0)
    node_sum.append(0)
    node_max.append(0)
    return len(left_child) - 1


def point_set(root, position, value):
    if root == 0:
        root = new_node()
    node = root
    left, right = 1, n
    path = []
    while left < right:
        path.append(node)
        middle = (left + right) // 2
        if position <= middle:
            if left_child[node] == 0:
                left_child[node] = new_node()
            node = left_child[node]
            right = middle
        else:
            if right_child[node] == 0:
                right_child[node] = new_node()
            node = right_child[node]
            left = middle + 1
    node_sum[node] = value
    node_max[node] = value
    for node in reversed(path):
        node_sum[node] = node_sum[left_child[node]] + node_sum[right_child[node]]
        node_max[node] = max(node_max[left_child[node]], node_max[right_child[node]])
    return root


def range_query(node, left, right, query_left, query_right):
    if node == 0:
        return 0, 0
    if query_left <= left and right <= query_right:
        return node_sum[node], node_max[node]
    middle = (left + right) // 2
    total = maximum = 0
    if query_left <= middle:
        total, maximum = range_query(left_child[node], left, middle, query_left, query_right)
    if middle < query_right:
        right_sum, right_max = range_query(right_child[node], middle + 1, right, query_left, query_right)
        total += right_sum
        maximum = max(maximum, right_max)
    return total, maximum


for node in range(1, n + 1):
    roots[color[node]] = point_set(roots[color[node]], dfn[node], weight[node])


def path_query(x, y, religion):
    total = maximum = 0
    root = roots[religion]
    while top[x] != top[y]:
        if depth[top[x]] < depth[top[y]]:
            x, y = y, x
        part_sum, part_max = range_query(root, 1, n, dfn[top[x]], dfn[x])
        total += part_sum
        maximum = max(maximum, part_max)
        x = parent[top[x]]
    if depth[x] > depth[y]:
        x, y = y, x
    part_sum, part_max = range_query(root, 1, n, dfn[x], dfn[y])
    return total + part_sum, max(maximum, part_max)


answers = []
for _ in range(operations):
    operation, x, value = input().split()
    x, value = int(x), int(value)
    if operation == b'CC':
        roots[color[x]] = point_set(roots[color[x]], dfn[x], 0)
        color[x] = value
        roots[color[x]] = point_set(roots[color[x]], dfn[x], weight[x])
    elif operation == b'CW':
        weight[x] = value
        roots[color[x]] = point_set(roots[color[x]], dfn[x], value)
    else:
        total, maximum = path_query(x, value, color[x])
        answers.append(str(total if operation == b'QS' else maximum))
print("\n".join(answers))

复杂度

  • 预处理:两次 DFS O(n)O(n);nn 次初始插入,每次 O(log⁡n)O(\log n),合计 O(nlog⁡n)O(n \log n)。
  • CC:两次单点写入 O(log⁡n)O(\log n);CW:一次单点写入 O(log⁡n)O(\log n)。
  • QS / QM:路径拆成 O(log⁡n)O(\log n) 段,每段在宗教树上做一次区间查询 O(log⁡n)O(\log n),合计 O(log⁡2n)O(\log^2 n)。
  • 空间:树剖数组 O(n)O(n);节点池 O((n+m)log⁡n)O((n + m)\log n),上界 18n+18m18n + 18m(推导见上文「思路」),n=m=105n = m = 10^5 时约 3.6×1063.6\times10^6 个节点,四个 int 数组约 58 MiB,实测峰值 61 MiB。

n=m=105n = m = 10^5 的实测(-O2、cin 关闭同步、含读入,限制 1000 ms / 512 MiB):随机树、链、菊花、完全二叉树、全 CC、全查询六组数据在 169–293 ms 之间;最坏的一组是「全同宗教 + 完全二叉树 + 叶子互查」,350 ms、21 MiB。峰值内存 61 MiB 出现在全 CC 那组,与节点池只增不减相符。

解法二:离线离散化 + 每个宗教一棵普通线段树

思路

解法一的空间浪费来自「为还没出现的位置预留节点」。如果能把每种宗教的坐标空间压到它真正可能出现的那些 dfn 上,普通线段树就够了,连动态开点都不需要。

未来是已知的:题目把所有操作一次性给出,没有加密、没有交互。于是先读入全部操作,模拟一遍宗教变更,就能收集到「宗教 cc 历史上出现过的所有 dfn」。把这些位置排序去重,得到大小 kck_c 的坐标表,第 cc 棵普通线段树只需要 4kc4k_c 个节点。因为每个城市初始占一个位置、每次 CC 最多给新宗教增加一个位置,所以

∑ckc⩽n+m,\sum_c k_c \leqslant n + m,

也就是所有宗教树的节点总数不超过 4(n+m)+4C4(n + m) + 4C,约 1.2×1061.2\times10^6 个,不到解法一的三分之一。

剩下的问题是「dfn 值 → 该宗教内部的下标」。这个映射用一次二分就能完成:在 pos[c] 上 lower_bound。

路径查询也照旧拆段,只是每段多一步转换:dfn 区间 [L,R][L, R] 先变成内部下标区间 [ lower_bound(L), upper_bound(R)−1 ][\,\text{lower\_bound}(L),\ \text{upper\_bound}(R)-1\,],再到第 cc 棵树上查。若某段里没有该宗教的点,下标区间会落空,直接跳过。

代价有两个,都很实际:

  • 必须离线。要先把全部操作读进来才能知道每棵树的坐标表。如果题目改成强制在线(比如操作依赖上一次答案),这条路直接失效,只能回到解法一。
  • 每次操作多一次二分。dfn 换成内部下标是 O(log⁡kc)O(\log k_c),与线段树的 O(log⁡n)O(\log n) 同阶,实测差别不大。初始化也多了一步:排序去重所有宗教的坐标表,合计 O((n+m)log⁡(n+m))O((n + m)\log(n + m))。

CC 与 CW 的语义和边界(空位置、成对清除)与解法一相同,只是「清 0」变成了「往坐标表里定位再覆盖」。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-30 07:51
 * update_at: 2026-10-30 07:51
 */
// main2.cpp:解法二 —— 离线读入所有操作,把「每个宗教历史上出现过的 dfn」离散化,
// 于是每种宗教都能用一棵普通(静态数组)线段树,节点总数 O(n + q),完全不用动态开点。
// 代价是要先把全部操作读进来,并多做一次 dfn -> 该宗教内部下标的映射。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
const int MAXC = 100005; // 宗教编号上限

int n, q;
int w[MAXN];    // w[i]:城市 i 的评级
int col[MAXN];  // col[i]:城市 i 的宗教

vector<int> g[MAXN]; // 树的邻接表

// ---- 树链剖分 ----
int parent_[MAXN];  // parent_[u]:u 的父亲
int depth[MAXN];    // depth[u]:u 的深度(根为 1)
int sz[MAXN];       // sz[u]:u 的子树大小
int heavy[MAXN];    // heavy[u]:u 的重儿子,0 表示没有
int top[MAXN];      // top[u]:u 所在重链的链头
int dfn[MAXN];      // dfn[u]:u 剖分后的编号
int timer;          // dfn 计数器

// 一次操作:type 是 CC / CW / QS / QM,x 是城市,y 是参数(宗教 / 评级 / 终点)
struct Op {
    char type[3];
    int x;
    int y;
};
Op ops[MAXN]; // ops[i]:第 i 条操作
int cur[MAXN]; // cur[i]:离线模拟时城市 i 当前的宗教

// 每种宗教的离散化坐标:pos[c] 是宗教 c 历史上的所有 dfn,升序去重
vector<int> pos[MAXN];
int base[MAXN];     // base[c]:宗教 c 的线段树在池里的起点
int kcnt[MAXN];     // kcnt[c]:宗教 c 的坐标个数,线段树下标是 1..kcnt[c]
vector<int> seg_sum; // 节点池:区间和
vector<int> seg_max; // 节点池:区间最大值

// 第一次 dfs:求父亲、深度、子树大小、重儿子。
void dfs1(int u, int f) {
    parent_[u] = f;
    depth[u] = depth[f] + 1;
    sz[u] = 1;
    heavy[u] = 0;
    for (auto v : g[u]) {
        if (v == f) {
            continue;
        }
        dfs1(v, u);
        sz[u] += sz[v];
        if (heavy[u] == 0 || sz[v] > sz[heavy[u]]) {
            heavy[u] = v;
        }
    }
}

// 第二次 dfs:先走重儿子,让每条重链的 dfn 连续。
void dfs2(int u, int chain_top) {
    top[u] = chain_top;
    dfn[u] = ++timer;
    if (heavy[u] != 0) {
        dfs2(heavy[u], chain_top);
    }
    for (auto v : g[u]) {
        if (v == parent_[u] || v == heavy[u]) {
            continue;
        }
        dfs2(v, v);
    }
}

// 把宗教 c 的第 idx 个坐标写成 val(val 为 0 表示清除)。
void point_set(int c, int node, int l, int r, int idx, int val) {
    int p = base[c] + node;
    if (l == r) {
        seg_sum[p] = val;
        seg_max[p] = val;
        return;
    }
    int mid = (l + r) >> 1;
    if (idx <= mid) {
        point_set(c, node << 1, l, mid, idx, val);
    } else {
        point_set(c, node << 1 | 1, mid + 1, r, idx, val);
    }
    int lp = base[c] + (node << 1);
    int rp = lp + 1;
    seg_sum[p] = seg_sum[lp] + seg_sum[rp];
    seg_max[p] = max(seg_max[lp], seg_max[rp]);
}

// 在宗教 c 的线段树上查内部下标区间 [ql, qr] 的和与最大值。
void range_query(int c, int node, int l, int r, int ql, int qr, int &tsum, int &tmax) {
    if (ql <= l && r <= qr) {
        int p = base[c] + node;
        tsum += seg_sum[p];
        tmax = max(tmax, seg_max[p]);
        return;
    }
    int mid = (l + r) >> 1;
    if (ql <= mid) {
        range_query(c, node << 1, l, mid, ql, qr, tsum, tmax);
    }
    if (qr > mid) {
        range_query(c, node << 1 | 1, mid + 1, r, ql, qr, tsum, tmax);
    }
}

// 把 dfn 值 d 在宗教 c 的坐标表里定位成内部下标;不在表里返回 0。
int find_index(int c, int d) {
    int p = lower_bound(pos[c].begin(), pos[c].end(), d) - pos[c].begin();
    if (p == (int)pos[c].size() || pos[c][p] != d) {
        return 0;
    }
    return p + 1;
}

// 在宗教 rel 的树上前缀查询路径 x -> y:先按 HLD 拆段,每段再换成内部下标区间。
void path_query(int x, int y, int rel, int &tsum, int &tmax) {
    while (top[x] != top[y]) {
        if (depth[top[x]] < depth[top[y]]) {
            swap(x, y);
        }
        int l = lower_bound(pos[rel].begin(), pos[rel].end(), dfn[top[x]]) - pos[rel].begin() + 1;
        int r = upper_bound(pos[rel].begin(), pos[rel].end(), dfn[x]) - pos[rel].begin();
        if (l <= r) {
            range_query(rel, 1, 1, kcnt[rel], l, r, tsum, tmax);
        }
        x = parent_[top[x]];
    }
    if (depth[x] > depth[y]) {
        swap(x, y);
    }
    int l = lower_bound(pos[rel].begin(), pos[rel].end(), dfn[x]) - pos[rel].begin() + 1;
    int r = upper_bound(pos[rel].begin(), pos[rel].end(), dfn[y]) - pos[rel].begin();
    if (l <= r) {
        range_query(rel, 1, 1, kcnt[rel], l, r, tsum, tmax);
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> col[i];
    }
    for (int i = 1; i < n; i++) {
        int x, y;
        cin >> x >> y;
        g[x].push_back(y);
        g[y].push_back(x);
    }
    for (int i = 1; i <= q; i++) {
        cin >> ops[i].type >> ops[i].x >> ops[i].y;
    }

    dfs1(1, 0);
    dfs2(1, 1);

    // 第一遍离线:按当前宗教把每个城市可能出现过的 dfn 收集起来。
    for (int i = 1; i <= n; i++) {
        cur[i] = col[i];
        pos[cur[i]].push_back(dfn[i]);
    }
    for (int i = 1; i <= q; i++) {
        if (ops[i].type[0] == 'C' && ops[i].type[1] == 'C') {
            cur[ops[i].x] = ops[i].y;
            pos[cur[ops[i].x]].push_back(dfn[ops[i].x]);
        }
    }

    // 每个宗教排序去重、分配一段连续节点池
    int used = 0;
    for (int c = 1; c < MAXC; c++) {
        if (pos[c].empty()) {
            continue;
        }
        sort(pos[c].begin(), pos[c].end());
        pos[c].erase(unique(pos[c].begin(), pos[c].end()), pos[c].end());
        kcnt[c] = (int)pos[c].size();
        base[c] = used;
        used += 4 * kcnt[c] + 4; // 普通线段树开 4 倍大小
    }
    seg_sum.assign(used, 0);
    seg_max.assign(used, 0);

    // 初始状态:把每个城市的评级写进它宗教树里对应的坐标
    for (int i = 1; i <= n; i++) {
        point_set(col[i], 1, 1, kcnt[col[i]], find_index(col[i], dfn[i]), w[i]);
    }

    // 第二遍在线:按顺序处理操作
    for (int i = 1; i <= q; i++) {
        int x = ops[i].x;
        int y = ops[i].y;
        char t0 = ops[i].type[0];
        char t1 = ops[i].type[1];
        if (t0 == 'C' && t1 == 'C') {
            int c = col[x];
            point_set(c, 1, 1, kcnt[c], find_index(c, dfn[x]), 0);
            col[x] = y;
            point_set(y, 1, 1, kcnt[y], find_index(y, dfn[x]), w[x]);
        } else if (t0 == 'C') {
            w[x] = y;
            int c = col[x];
            point_set(c, 1, 1, kcnt[c], find_index(c, dfn[x]), y);
        } else {
            int tsum = 0;
            int tmax = 0;
            path_query(x, y, col[x], tsum, tmax);
            if (t0 == 'Q' && t1 == 'S') {
                cout << tsum << '\n';
            } else {
                cout << tmax << '\n';
            }
        }
    }

    return 0;
}

复杂度

  • 预处理:两次 DFS O(n)O(n);离线模拟收集坐标 O(n+m)O(n + m);排序去重 O((n+m)log⁡(n+m))O((n + m)\log(n + m));nn 次初始插入 O(nlog⁡n)O(n\log n)。
  • CC:两次「定位 + 单点写入」,每次 O(log⁡n)O(\log n);CW:一次定位 + 一次单点写入,O(log⁡n)O(\log n)。
  • QS / QM:O(log⁡n)O(\log n) 段,每段一次二分 + 一次区间查询,O(log⁡2n)O(\log^2 n)。
  • 空间:树剖数组与坐标表 O(n+m)O(n + m);节点池 4∑ckc⩽4(n+m)+4C4\sum_c k_c \leqslant 4(n + m) + 4C。

实测同样是六组 n=m=105n = m = 10^5 的数据:162–276 ms,峰值内存 20–28 MiB。时间与解法一同级,内存只有解法一的一半左右——省下来的正是动态开点为「将来可能用到」预留的那部分节点。

解法三:一棵 dfn 线段树 + 每个节点挂宗教映射

思路

前两个解法都在「按宗教拆树」。换个方向想:树不拆,只在每个线段树节点上额外记一维「宗教 → 摘要」,也就是给每个节点挂一张 map。

text
dfn 线段树的每个节点各自持有一张「宗教 -> (和, 最大值)」的表:

                  [1,5]
        ┌──────────┴──────────┐
      [1,3]                 [4,5]
     ┌───┴───┐            ┌───┴───┐
   [1,2]   [3,3]        [4,4]   [5,5]

  节点 [1,2] 的表:{ 宗教1: (3, 3) }      节点 [4,5] 的表:{ 宗教1: (5, 5), 宗教3: (2, 2) }
  节点 [1,5] 的表:{ 宗教1: (8, 5), 宗教2: (1, 1), 宗教3: (2, 2) }

这样一来:

  • 单点写:叶子节点上把宗教 cc 的项设成 (w,w)(w, w)(清 0 就是删掉这一项),回溯时只需要重算宗教 cc 这一项——别的宗教没被改过,不必动。
  • 区间查询:区间拆成 O(log⁡n)O(\log n) 个整节点,每个节点查一次 map 取该宗教的摘要,把和相加、最大值取 max⁡\max。某个节点上没有该宗教的项,就等价于空,贡献 00。

写法确实最短:没有节点池,没有坐标映射,也没有「空节点」这种特殊情况——map 里没有这一项就是空。

但代价很直接。一次查询要访问 O(log⁡n)O(\log n) 段(HLD)× O(log⁡n)O(\log n) 个节点,每个节点再做一次 O(log⁡C)O(\log C) 的红黑树查找,合计 O(log⁡2nlog⁡C)O(\log^2 n \log C);n=m=105n = m = 10^5 时是 $10^5 \times 17 \times 17 $ 量级的节点访问,每次又带一层 map 查找。而每个节点各持一张 map,4n4n 个 map 对象加 O(nlog⁡n)O(n\log n) 个表项,也把内存推到 100 MiB 以上。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-30 07:51
 * update_at: 2026-10-30 07:51
 */
// main3.cpp:解法三 —— 只开一棵 dfn 线段树,每个节点挂一个 map:宗教 -> (区间内该宗教的评级和, 最大值)。
// 不用树链剖分以外的任何技巧,路径查询照旧拆段,只是「按宗教过滤」交给每个节点上的 map。
// 写法最短,但每个节点一次 map 查找都要 O(log C),常数和内存都比前两个解法大。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
const int MAXC = 100005;

int n, q;
int w[MAXN];    // w[i]:城市 i 的评级
int col[MAXN];  // col[i]:城市 i 的宗教

vector<int> g[MAXN]; // 树的邻接表

// ---- 树链剖分 ----
int parent_[MAXN]; // parent_[u]:u 的父亲
int depth[MAXN];   // depth[u]:u 的深度(根为 1)
int sz[MAXN];      // sz[u]:u 的子树大小
int heavy[MAXN];   // heavy[u]:u 的重儿子
int top[MAXN];     // top[u]:u 所在重链的链头
int dfn[MAXN];     // dfn[u]:u 剖分后的编号
int timer;         // dfn 计数器

// 一个节点里,某个宗教的摘要
struct Info {
    int sum; // 该宗教在这些位置上的评级和
    int mx;  // 该宗教在这些位置上的评级最大值
};

map<int, Info> node_of_color[MAXN << 2]; // node_of_color[p]:节点 p 的区间里「宗教 -> 摘要」

void dfs1(int u, int f) {
    parent_[u] = f;
    depth[u] = depth[f] + 1;
    sz[u] = 1;
    heavy[u] = 0;
    for (auto v : g[u]) {
        if (v == f) {
            continue;
        }
        dfs1(v, u);
        sz[u] += sz[v];
        if (heavy[u] == 0 || sz[v] > sz[heavy[u]]) {
            heavy[u] = v;
        }
    }
}

void dfs2(int u, int chain_top) {
    top[u] = chain_top;
    dfn[u] = ++timer;
    if (heavy[u] != 0) {
        dfs2(heavy[u], chain_top);
    }
    for (auto v : g[u]) {
        if (v == parent_[u] || v == heavy[u]) {
            continue;
        }
        dfs2(v, v);
    }
}

// 取节点 p 在宗教 c 上的和 / 最大值,没有该宗教就是 0。
int get_sum(int p, int c) {
    auto it = node_of_color[p].find(c);
    return it == node_of_color[p].end() ? 0 : it->second.sum;
}

int get_max(int p, int c) {
    auto it = node_of_color[p].find(c);
    return it == node_of_color[p].end() ? 0 : it->second.mx;
}

// 把位置 pos 上的宗教 c 的评级写成 val;val 为 0 表示这个位置不再属于宗教 c。
// 递归回来后只需要重算宗教 c 这一项,别的宗教没被改过。
void point_set(int p, int l, int r, int pos, int c, int val) {
    if (l == r) {
        if (val == 0) {
            node_of_color[p].erase(c);
        } else {
            Info info;
            info.sum = val;
            info.mx = val;
            node_of_color[p][c] = info;
        }
        return;
    }
    int mid = (l + r) >> 1;
    if (pos <= mid) {
        point_set(p << 1, l, mid, pos, c, val);
    } else {
        point_set(p << 1 | 1, mid + 1, r, pos, c, val);
    }
    int sum = get_sum(p << 1, c) + get_sum(p << 1 | 1, c);
    if (sum == 0) {
        node_of_color[p].erase(c); // 这个宗教在本节点的区间里已经空了
        return;
    }
    Info info;
    info.sum = sum;
    info.mx = max(get_max(p << 1, c), get_max(p << 1 | 1, c));
    node_of_color[p][c] = info;
}

// 在区间 [ql, qr] 里查宗教 c 的和与最大值。
void range_query(int p, int l, int r, int ql, int qr, int c, int &tsum, int &tmax) {
    if (ql <= l && r <= qr) {
        tsum += get_sum(p, c);
        tmax = max(tmax, get_max(p, c));
        return;
    }
    int mid = (l + r) >> 1;
    if (ql <= mid) {
        range_query(p << 1, l, mid, ql, qr, c, tsum, tmax);
    }
    if (qr > mid) {
        range_query(p << 1 | 1, mid + 1, r, ql, qr, c, tsum, tmax);
    }
}

// HLD 拆段后,每段都用宗教 rel 去查。
void path_query(int x, int y, int rel, int &tsum, int &tmax) {
    while (top[x] != top[y]) {
        if (depth[top[x]] < depth[top[y]]) {
            swap(x, y);
        }
        range_query(1, 1, n, dfn[top[x]], dfn[x], rel, tsum, tmax);
        x = parent_[top[x]];
    }
    if (depth[x] > depth[y]) {
        swap(x, y);
    }
    range_query(1, 1, n, dfn[x], dfn[y], rel, tsum, tmax);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    for (int i = 1; i <= n; i++) {
        cin >> w[i] >> col[i];
    }
    for (int i = 1; i < n; i++) {
        int x, y;
        cin >> x >> y;
        g[x].push_back(y);
        g[y].push_back(x);
    }

    dfs1(1, 0);
    dfs2(1, 1);

    for (int i = 1; i <= n; i++) {
        point_set(1, 1, n, dfn[i], col[i], w[i]);
    }

    while (q--) {
        string op;
        int x, y;
        cin >> op >> x >> y;
        if (op == "CC") {
            point_set(1, 1, n, dfn[x], col[x], 0);
            col[x] = y;
            point_set(1, 1, n, dfn[x], col[x], w[x]);
        } else if (op == "CW") {
            w[x] = y;
            point_set(1, 1, n, dfn[x], col[x], y);
        } else {
            int tsum = 0;
            int tmax = 0;
            path_query(x, y, col[x], tsum, tmax);
            if (op == "QS") {
                cout << tsum << '\n';
            } else {
                cout << tmax << '\n';
            }
        }
    }

    return 0;
}

复杂度

  • 预处理:nn 次单点写入,每次 O(log⁡nlog⁡C)O(\log n\log C),合计 O(nlog⁡nlog⁡C)O(n\log n\log C)。
  • CC:两次单点写入 O(log⁡nlog⁡C)O(\log n\log C);CW:一次 O(log⁡nlog⁡C)O(\log n\log C)。
  • QS / QM:O(log⁡2n)O(\log^2 n) 个节点,每个节点一次 map 查找 O(log⁡C)O(\log C),合计 O(log⁡2nlog⁡C)O(\log^2 n\log C)。
  • 空间:O(nlog⁡n)O(n\log n) 个 map 表项,约 1.7×1061.7\times10^6 项,加上 4n4n 个 map 对象本身。

实测(同样六组 n=m=105n = m = 10^5,限制 1000 ms / 512 MiB):1.8–6.3 s、95–119 MiB,六组全部超时。把 map 换成 unordered_map 能把常数压到 1.2–3.6 s,仍然超时——省掉的是 O(log⁡C)O(\log C) 那一层,而 O(log⁡2n)O(\log^2 n) 个节点的访问次数没有变。

所以这个解法适合当「同一个模型的编码对照」:它说明动态开点 / 离线离散化省下来的到底是什么(不是算法复杂度,而是每次操作要碰的节点数和内存),但不宜作为正式提交。

复杂度对比

三个解法的实测都来自同一批数据:n=m=105n = m = 10^5,随机树 / 链 / 菊花 / 完全二叉树 / 全 CC / 全查询六组,命令是 roj-local-judge-lite --time 1000 --memory 512。

指标 解法一(动态开点) 解法二(离线离散化) 解法三(单树 + map)
时间 169–293 ms 162–276 ms 1.8–6.3 s(TLE)
峰值内存 33–61 MiB 20–28 MiB 95–119 MiB
是否要求离线 不要求 要求,先读完所有操作 不要求
代码量(行) 178 231 190
主要风险 节点池上界要估准 离线前提;多一层二分 常数与内存都超预算

选法很简单:默认用解法一(在线、够快、代码最短之一);如果题目本来就允许离线、又卡内存,解法二更划算;解法三当成理解「按宗教过滤」本质的中间形态,不作为提交方案。

总结

这道题的骨架是「一维路径区间化 + 第二维按宗教过滤」,前半段三种解法完全一样,差别全在第二维怎么落地:

  • 树链剖分把路径变成 O(log⁡n)O(\log n) 段 dfn 连续区间,解决「路径不是区间」这个障碍;
  • 解法一把第二维拆成很多棵树,用动态开点把空间压到 O((n+m)log⁡n)O((n + m)\log n);
  • 解法二同样拆树,但用离线已知的操作把坐标空间压缩成 O(n+m)O(n + m),于是普通线段树就够,内存还更省;
  • 解法三不拆树,把第二维放进每个节点的 map,写法最短,代价是每次操作要碰 O(log⁡2n)O(\log^2 n) 个节点、每个节点再查一次 map。

两个操作语义值得单独记住:CC 是「一清一写」,相当于在两个集合之间搬家;CW 是单点覆盖。它们都只碰 O(log⁡n)O(\log n) 个节点,这正是这几个结构最省的地方。

图示解析

下面这张 ASCII 图串起暴力和三条解法,标出每一步消除的瓶颈。

text
暴力(brute.cpp)
  定根后让较深的一端向上爬,逐点判断宗教、累加评级
  单次查询 O(路径长),总计 O(n*m)
        |
        | 共同的第一步:路径上的点要一个一个走
        v
树链剖分(三个解法共用)
  两次 DFS 求重儿子与链头,按 dfn 重编号
  任意路径 = O(log n) 段 dfn 连续区间
        |
        | 剩下的事:区间里混着各种宗教,怎么只统计其中一种
        +------------------------+-------------------------+
        v                        v                         v
解法一:按宗教拆树            解法二:拆树 + 离线压缩      解法三:不拆树
  每宗教一棵树,共享节点池      每宗教一棵普通树            单棵 dfn 线段树
  动态开点,O((n+m)log n)      坐标为历史 dfn,O(n+m)      每节点挂 map<宗教, 摘要>
  CC/CW  O(log n)              CC/CW  O(log n)             CC/CW  O(log n log C)
  QS/QM  O(log^2 n)            QS/QM  O(log^2 n)           QS/QM  O(log^2 n log C)
  169-293 ms, 33-61 MiB        162-276 ms, 20-28 MiB       1.8-6.3 s, 95-119 MiB

图中三处分支比较的是同一件事的三种落地方式。解法一和解法二的差别只有「坐标空间从哪来」(在线开点 vs 离线压缩),所以时间同级、内存差一倍;解法三把过滤搬进节点内部,省掉了拆树,却让每次操作多乘一个 log⁡C\log C,于是从 300 ms 掉到秒级。

参考与致谢

下列公开题解提供了本文所比较的解法思路来源;本文未复用其原文或代码。题目建模说明、边界论证、三个解法的代码和验证由本文独立整理。

作者与题解 来自原题解 本文独立补充
Fading(uid 20309),题解 P3313 【[SDOI2014]旅行】 按宗教分组维护动态开点线段树,并用树链剖分处理路径 重新说明共享空节点、赋值式修改与路径拆分的适用条件;解法一代码独立实现
Link_Cut_Y(uid 519384),P3313 [SDOI2014] 旅行 题解 提供按树剖序分块的替代路线及块长讨论 核查其数组维度和实际内存开销,指出此处所示实现不宜作为较简单主解
斯德哥尔摩(uid 49998),题解 P3313 【[SDOI2014]旅行】 按宗教分别维护动态线段树,并在路径分段后查询 补充分析其节点池开销与操作语义,独立复核主路线
喵仔牛奶(uid 560516),P3313 [SDOI2014]旅行分块题解 分块摘要维护各宗教在块内的和与最大值 对照其二维数组的最坏容量,说明实现空间不适合作为本题短小主解
半仙胡小桃(uid 19842),题解 P3313 【[SDOI2014]旅行】 树链剖分配合按宗教动态线段树 本文独立解释每个操作如何映射到点覆盖和区间摘要,不沿用代码

解法二(离线离散化)与解法三(节点挂 map)是本文按「能不能不开点」「能不能不拆树」两个方向自行推导并实现的对照方案,未参考上述题解。