339D Xenia and Bit Operations 已经说明,线段树的核心不是“会写四倍数组”,而是找到一种能由左右孩子合并的区间摘要。本文继续用三道题检验这个观点:52C 给节点加上延迟更新,380C 的合并必须保持左右顺序,474F 则在同一节点里同时保存 GCD 与它的出现次数。

三题没有共享一份机械模板。每次都先回答三个问题:节点代表什么,两个相邻区间怎样合并,修改或查询能否只访问 O(log n) 个节点。

1. 官方信息与递进关系

以下题名、难度、标签和约束于 2026-09-14 通过 Codeforces 官方题目页核对。

题目 难度 官方标签 主要约束 本文训练点
52C · Circular RMQ 2200 data structures n,m≤2×10^5 环形区间拆分、区间加、懒标记、区间最小值
380C · Sereja and Brackets 2000 data structuresschedules ` s
474F · Ant colony 2100 data structuresmathnumber theory n,t≤10^5s[i]≤10^9 GCD 性质、复合摘要、频次统计

三题分别改变线段树的一个维度:52C 改变更新方式,380C 改变节点含义,474F 改变从题意到摘要的推导。

2. 第一题:52C Circular RMQ

2.1 题目与朴素方法

长度为 n 的环形数组支持两种操作:给环形区间 [l,r] 的每个元素加 v,或查询该区间最小值。下标从 0 开始;当 l>r 时,区间经过数组末尾再绕回开头,例如 n=5[3,1] 表示下标 3,4,0,1

逐元素修改或扫描一次需要 O(n),最多二十万次操作会退化到 O(nm)。普通前缀最小值也不适用,因为数组会反复修改,而且“最小值”不能像区间和那样通过两个前缀相减得到。

环形本身不需要新数据结构。先把它拆成普通区间:

条件 环形区间对应的普通区间
l≤r 一个区间 [l,r]
l>r 两个区间 [l,n-1][0,r]

更新分别执行两次;查询取两段最小值的较小者。真正困难的部分变成普通数组上的“区间加 + 区间最小值”。

2.2 节点摘要与懒标记

对每个节点保存:

  • tree[node]:这个节点区间当前的最小值,已经包含作用在它上面的全部更新;
  • lazy[node]:整段都应增加、但还没有下传给孩子的增量。

若一次更新完整覆盖节点区间,区间中所有数同时加 v,最小值也恰好加 v。因此只需执行:

1
2
tree[node] += v
lazy[node] += v

暂时不访问孩子。只有后续操作需要进入孩子时,才把 lazy[node] 同时加给左右孩子并清零,这就是延迟传播。

2.3 为什么不能只改 tree

假设节点 [0,3] 整段加 5。如果只令根的最小值加 5,却不记录 lazy,随后查询 [0,1] 时会进入左孩子;左孩子仍保存旧值,更新就像从未发生过。

反过来,只记录 lazy 而不更新 tree 也不行:若后续查询恰好完整覆盖 [0,3],算法会直接返回根摘要,却得到加法前的最小值。tree 保证当前节点可直接回答,lazy 保证需要下钻时孩子能够补上历史。

2.4 官方样例手算

初始数组为 [1,2,3,4]

操作 拆分 操作后的数组或答案
查询 [3,0] [3,3][0,0] min(4,1)=1
[3,0]-1 两个单点区间 [0,2,3,3]
查询 [0,1] 不跨界 min(0,2)=0
查询 [2,1] [2,3][0,1] 全数组最小值为 0

2.5 正确性证明

节点不变量: tree[node] 始终等于该节点区间应用全部已发生更新后的最小值,lazy[node] 等于尚未写入孩子摘要的整段增量。

建树时叶子等于原数组,内部节点取孩子最小值,不变量成立。完整覆盖更新时,区间所有元素同加 v,其最小值也同加 v;把 v 累加进懒标记,准确记录尚未下传的影响。部分覆盖前先下传,再更新相交孩子并重新取最小值,因此不变量继续成立。

查询完整覆盖时由不变量可直接返回。部分覆盖前下传,递归结果分别是相交子区间的最小值,取两者较小值即为目标区间最小值。环形区间拆分包含且只包含原操作下标,所以一次或两次普通查询都正确。

2.6 完整 C++17 实现

更新值累积后可能达到约 2×10^11,数组、树和懒标记都使用 long long。操作行有两个或三个整数,因此用 getlinestringstream 判断类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
#include <algorithm>
#include <iostream>
#include <limits>
#include <sstream>
#include <string>
#include <vector>

using namespace std;

class SegmentTree {
public:
explicit SegmentTree(const vector<long long>& values)
: size(static_cast<int>(values.size())), tree(4 * size), lazy(4 * size, 0) {
build(1, 0, size - 1, values);
}

void add(int left, int right, long long value) {
add(1, 0, size - 1, left, right, value);
}

long long minimum(int left, int right) {
return minimum(1, 0, size - 1, left, right);
}

private:
int size;
vector<long long> tree;
vector<long long> lazy;

void build(int node, int begin, int end, const vector<long long>& values) {
if (begin == end) {
tree[node] = values[begin];
return;
}
int middle = (begin + end) / 2;
build(node * 2, begin, middle, values);
build(node * 2 + 1, middle + 1, end, values);
tree[node] = min(tree[node * 2], tree[node * 2 + 1]);
}

void apply(int node, long long value) {
tree[node] += value;
lazy[node] += value;
}

void push(int node) {
if (lazy[node] == 0) return;
apply(node * 2, lazy[node]);
apply(node * 2 + 1, lazy[node]);
lazy[node] = 0;
}

void add(int node, int begin, int end, int left, int right, long long value) {
if (left <= begin && end <= right) {
apply(node, value);
return;
}
push(node);
int middle = (begin + end) / 2;
if (left <= middle) add(node * 2, begin, middle, left, right, value);
if (right > middle) add(node * 2 + 1, middle + 1, end, left, right, value);
tree[node] = min(tree[node * 2], tree[node * 2 + 1]);
}

long long minimum(int node, int begin, int end, int left, int right) {
if (left <= begin && end <= right) return tree[node];
push(node);
int middle = (begin + end) / 2;
long long answer = numeric_limits<long long>::max();
if (left <= middle) answer = min(answer, minimum(node * 2, begin, middle, left, right));
if (right > middle) answer = min(answer, minimum(node * 2 + 1, middle + 1, end, left, right));
return answer;
}
};

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

int n;
cin >> n;
vector<long long> values(n);
for (long long& value : values) cin >> value;

SegmentTree tree(values);
int operations;
cin >> operations;
string line;
getline(cin, line);

while (operations--) {
getline(cin, line);
stringstream input(line);
vector<long long> parts;
long long value;
while (input >> value) parts.push_back(value);

int left = static_cast<int>(parts[0]);
int right = static_cast<int>(parts[1]);
if (parts.size() == 3) {
long long increment = parts[2];
if (left <= right) {
tree.add(left, right, increment);
} else {
tree.add(left, n - 1, increment);
tree.add(0, right, increment);
}
} else {
long long answer;
if (left <= right) {
answer = tree.minimum(left, right);
} else {
answer = min(tree.minimum(left, n - 1), tree.minimum(0, right));
}
cout << answer << '\n';
}
}
}

每个普通区间操作为 O(log n);环形操作最多拆成两段,数量级不变。建树 O(n),总时间 O(n+m log n),空间 O(n)

3. 第二题:380C Sereja and Brackets

3.1 为什么区间和不够

给定只含左右括号的字符串,每次查询子串 [l,r] 中最长合法括号子序列的长度。子序列可以删除字符,但不能改变剩余字符的相对顺序。

把左括号记为 +1、右括号记为 -1,区间和只能告诉我们数量差,不能告诉顺序。) (( ) 的和都为 0,前者无法组成合法括号,后者答案为 2。

一个区间处理完内部能匹配的括号后,只需保留三项:

  • matched:已经匹配的括号对数;
  • open:仍未匹配的左括号数;
  • close:仍未匹配的右括号数。

3.2 左右孩子怎样合并

设左孩子为 L,右孩子为 R。新的跨边界匹配只能使用左侧未匹配左括号与右侧未匹配右括号:

1
2
3
4
cross = min(L.open, R.close)
matched = L.matched + R.matched + cross
open = L.open + R.open - cross
close = L.close + R.close - cross

不能反过来用 R.open 匹配 L.close,因为那会让右括号出现在左括号之前。这个合并满足结合律,却不满足交换律;查询时必须保持区间从左到右的顺序。

3.3 合并为何得到最大值

左右区间内部的最优匹配可以先独立保留。跨边界时,左段未匹配的左括号都早于右段未匹配的右括号,所以任取一对都满足顺序;最多能新增两者数量的较小值 cross

任何跨边界合法括号对也只能来自这两个集合,因此不可能超过 cross。内部最优值与最大跨边界值相加,正是合并区间的最优匹配对数。

3.4 手工合并

字符串片段 (()))( 分别摘要为:

区间 已匹配对 未匹配左括号 未匹配右括号
(() 1 1 0
))( 0 1 2

跨边界可以再匹配 min(1,2)=1 对,合并结果为 matched=2, open=1, close=1,最长合法括号子序列长度是 2×matched=4

3.5 正确性证明

叶子 ( 的摘要为 (0,1,0),叶子 )(0,0,1),显然正确。假设左右孩子摘要都准确记录各自最大内部匹配及剩余括号,根据上一节的上界与构造,合并新增的最大匹配数恰为 min(L.open,R.close),扣除这些括号后剩余计数也准确。因此归纳可知所有节点摘要正确。

查询把目标区间拆成按原顺序排列的若干节点,再依次使用同一合并。结合律保证不同树形分组不改变结果,保持左右顺序则保证括号先后关系不被破坏,最终 2×matched 就是最长合法括号子序列长度。

3.6 完整 C++17 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

using namespace std;

struct Node {
int matched = 0;
int open = 0;
int close = 0;
};

Node mergeNodes(const Node& left, const Node& right) {
int cross = min(left.open, right.close);
return {
left.matched + right.matched + cross,
left.open + right.open - cross,
left.close + right.close - cross,
};
}

class SegmentTree {
public:
explicit SegmentTree(const string& brackets)
: size(static_cast<int>(brackets.size())), tree(4 * size) {
build(1, 0, size - 1, brackets);
}

Node query(int left, int right) const {
return query(1, 0, size - 1, left, right);
}

private:
int size;
vector<Node> tree;

void build(int node, int begin, int end, const string& brackets) {
if (begin == end) {
tree[node] = brackets[begin] == '(' ? Node{0, 1, 0} : Node{0, 0, 1};
return;
}
int middle = (begin + end) / 2;
build(node * 2, begin, middle, brackets);
build(node * 2 + 1, middle + 1, end, brackets);
tree[node] = mergeNodes(tree[node * 2], tree[node * 2 + 1]);
}

Node query(int node, int begin, int end, int left, int right) const {
if (left <= begin && end <= right) return tree[node];
int middle = (begin + end) / 2;
if (right <= middle) return query(node * 2, begin, middle, left, right);
if (left > middle) return query(node * 2 + 1, middle + 1, end, left, right);
Node leftResult = query(node * 2, begin, middle, left, right);
Node rightResult = query(node * 2 + 1, middle + 1, end, left, right);
return mergeNodes(leftResult, rightResult);
}
};

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

string brackets;
cin >> brackets;
SegmentTree tree(brackets);

int queries;
cin >> queries;
while (queries--) {
int left, right;
cin >> left >> right;
Node answer = tree.query(left - 1, right - 1);
cout << 2 * answer.matched << '\n';
}
}

建树 O(n),每次查询 O(log n),总时间 O(n+m log n)。三个 int 数组随树节点一起占 O(n) 空间,在 n=10^6 时仍低于 256 MB 限制。

4. 第三题:474F Ant colony

4.1 从战斗规则提取数论条件

一次查询选择 [l,r]。区间内每对蚂蚁都战斗;若蚂蚁 i 的力量 s[i] 能整除对手力量,它就在这场战斗得分。只有对区间内其他每只蚂蚁都能得分的个体会被放走,求被吃掉的数量。

直接为每只蚂蚁检查所有对手,一次查询最坏为区间长度平方。关键是把“整除所有数”与区间 GCD 联系起来。

设区间最大公约数为 g。一只力量为 x 的蚂蚁能够整除区间所有力量,当且仅当 x=g

  • x 整除所有数,x 是公共因数,所以 x 整除 g
  • g 又整除区间每个数,当然也整除 x
  • 两个正整数互相整除,只能相等;
  • 反过来,力量恰为 g 时,按 GCD 定义它一定整除所有数。

所以幸存数就是区间内等于 GCD 的元素个数,答案为:

1
区间长度 - GCD 在区间中的出现次数

4.2 节点为什么要保存两个量

节点摘要为 (gcdValue,count)gcdValue 是整段 GCD,count 是段内恰好等于该 GCD 的元素数。

合并左右孩子时,先求:

1
g = gcd(left.gcdValue, right.gcdValue)

若某个孩子的 GCD 等于 g,它内部等于自身 GCD 的那些位置也等于合并后的 g,应把计数加入。若孩子 GCD 大于 g,孩子中不可能存在值恰为 g:孩子的每个元素都是其 GCD 的正倍数。

1
2
count = (left.gcdValue == g ? left.count : 0)
+ (right.gcdValue == g ? right.count : 0)

4.3 官方样例手算

力量为 [1,3,2,4,2]

查询 区间 GCD 等于 GCD 的个数 被吃数量
[1,5] 1 1 5-1=4
[2,5] 1 0 4-0=4
[3,5] 2 2 3-2=1
[4,5] 2 1 2-1=1

第二行说明“区间 GCD 是 1”不代表一定有力量为 1 的蚂蚁;因此节点不能只保存 GCD,还要同时保存其真实出现次数。

4.4 正确性证明

叶子力量为 x,摘要 (x,1) 显然正确。假设左右孩子摘要正确,合并后的 GCD 由最大公约数结合律得到。一个元素等于合并 GCD,只可能来自 GCD 同样等于该值的孩子;相应孩子的 count 已准确统计这些元素,条件相加便得到完整频次。因此所有节点摘要归纳成立。

查询按区间顺序合并覆盖节点,GCD 的结合律以及上述计数规则保证得到目标区间的 GCD 和出现次数。根据“能整除全部力量当且仅当自身等于区间 GCD”的等价关系,用区间长度减去该频次,正是被吃掉的蚂蚁数。

4.5 完整 C++17 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <iostream>
#include <numeric>
#include <vector>

using namespace std;

struct Node {
int gcdValue = 0;
int count = 0;
};

Node mergeNodes(const Node& left, const Node& right) {
int value = gcd(left.gcdValue, right.gcdValue);
int count = 0;
if (left.gcdValue == value) count += left.count;
if (right.gcdValue == value) count += right.count;
return {value, count};
}

class SegmentTree {
public:
explicit SegmentTree(const vector<int>& values)
: size(static_cast<int>(values.size())), tree(4 * size) {
build(1, 0, size - 1, values);
}

Node query(int left, int right) const {
return query(1, 0, size - 1, left, right);
}

private:
int size;
vector<Node> tree;

void build(int node, int begin, int end, const vector<int>& values) {
if (begin == end) {
tree[node] = {values[begin], 1};
return;
}
int middle = (begin + end) / 2;
build(node * 2, begin, middle, values);
build(node * 2 + 1, middle + 1, end, values);
tree[node] = mergeNodes(tree[node * 2], tree[node * 2 + 1]);
}

Node query(int node, int begin, int end, int left, int right) const {
if (left <= begin && end <= right) return tree[node];
int middle = (begin + end) / 2;
if (right <= middle) return query(node * 2, begin, middle, left, right);
if (left > middle) return query(node * 2 + 1, middle + 1, end, left, right);
Node leftResult = query(node * 2, begin, middle, left, right);
Node rightResult = query(node * 2 + 1, middle + 1, end, left, right);
return mergeNodes(leftResult, rightResult);
}
};

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

int n;
cin >> n;
vector<int> strength(n);
for (int& value : strength) cin >> value;

SegmentTree tree(strength);
int queries;
cin >> queries;
while (queries--) {
int left, right;
cin >> left >> right;
Node result = tree.query(left - 1, right - 1);
cout << right - left + 1 - result.count << '\n';
}
}

建树 O(n),每次查询访问 O(log n) 个节点;每次合并做常数次 GCD 与比较,总时间 O(n+t log n),空间 O(n)

5. 三题放在一起比较

题目 节点摘要 合并是否交换 是否修改 最容易漏掉的条件
52C 区间最小值、待下传增量 min 可交换 区间加 环形区间要拆分,增量需用 long long
380C 已匹配对、剩余左括号、剩余右括号 不可交换 左段右括号不能与右段左括号倒序配对
474F 区间 GCD、等于 GCD 的频次 可交换 GCD 可能没有在区间中出现

统一的设计步骤

  1. 先写出查询真正需要的答案,判断单个标量是否足够;
  2. 假设左右孩子摘要已经正确,手工推导合并式;
  3. 检查合并是否满足结合律,以及左右次序能否交换;
  4. 若有区间修改,推导它怎样直接作用于节点摘要;
  5. 用单点、整段、跨中点、重复值与数值上限验证边界。

6. 错误清单

场景 常见错误 后果
52C l>r 当成空区间或交换端点 修改、查询了错误的下标集合
52C 完整覆盖 只改 tree,不累计 lazy 后续子区间查询丢失更新
52C 多次大增量 使用 int 累积结果溢出
380C 合并 使用 min(left.close,right.open) 把顺序相反的括号错误配对
380C 输出 输出匹配对数 题目要字符长度,应乘 2
380C 查询 交换左右部分的合并顺序 非交换摘要被破坏
474F 判断幸存 只判断区间最小值 最小值未必整除所有元素
474F 只存 GCD 默认 GCD 一定出现 [6,10] 的 GCD 2 不在区间中
474F 单点查询 输出 1 唯一蚂蚁无需被吃,答案为 0

7. 独立验证

tests/verify-segment-tree-three-article.cjs 会提取本文三段 C++17,以 -Wall -Wextra -pedantic 编译并要求零警告。三套参考方法刻意不复用正文线段树:

  • 52C 在小数组上逐元素执行环形修改并直接扫描最小值;
  • 380C 对查询子串从左到右贪心配对括号;
  • 474F 为区间内每只蚂蚁逐一检查它是否整除所有其他力量。

验证覆盖官方样例、随机操作、跨数组末尾的区间、负增量、全左或全右括号、GCD 不在区间中、重复力量与单点查询。大数据还覆盖 n=2×10^5 的十万次区间操作、长度一百万的括号串,以及十万次蚂蚁查询。

线段树真正可迁移的能力,是把题意压缩成“足以合并的最小信息”。下一次遇到区间题时,先尝试在纸上合并两个相邻小区间;如果必须回看所有原始元素,说明摘要还不够,或这道题需要另一种结构。