339D Xenia and Bit Operations 已经说明,线段树的核心不是“会写四倍数组”,而是找到一种能由左右孩子合并的区间摘要。本文继续用三道题检验这个观点:52C 给节点加上延迟更新,380C 的合并必须保持左右顺序,474F 则在同一节点里同时保存 GCD 与它的出现次数。
三题没有共享一份机械模板。每次都先回答三个问题:节点代表什么,两个相邻区间怎样合并,修改或查询能否只访问 O(log n) 个节点。
1. 官方信息与递进关系 以下题名、难度、标签和约束于 2026-09-14 通过 Codeforces 官方题目页核对。
三题分别改变线段树的一个维度:52C 改变更新方式,380C 改变节点含义,474F 改变从题意到摘要的推导。
flowchart LR
A[52C<br>min + lazy add] --> B[区间修改也只走树高]
C[380C<br>matched + open + close] --> D[合并必须保持左右顺序]
E[474F<br>gcd + count] --> F[数论条件压成复合摘要]
B --> G[同一原则<br>摘要可合并]
D --> G
F --> G
2. 第一题:52C Circular RMQ
图解 · 区间查询选中的是一组完整节点 用 8 个叶子的普通线段树示意闭区间 [2,5]:它分解成 [2,3] 与 [4,5],不必逐个扫描叶子。先把环形查询拆成普通区间,再按正文的摘要规则合并;这张图不表示所有查询都只需两个节点。
查看完整图示
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] 同时加给左右孩子并清零,这就是延迟传播。
flowchart TD
A[更新区间与当前节点相交] --> B{是否完整覆盖}
B -- 是 --> C[tree 与 lazy 同时加 v]
B -- 否 --> D[先把 lazy 下传]
D --> E[递归更新相交的孩子]
E --> F[tree 取左右孩子最小值]
C --> G[本层结束]
F --> G
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。操作行有两个或三个整数,因此用 getline 与 stringstream 判断类型。
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,因为那会让右括号出现在左括号之前。这个合并满足结合律,却不满足交换律;查询时必须保持区间从左到右的顺序。
flowchart LR
A[左段<br>matched=2 open=2 close=1] --> C[跨边界匹配]
B[右段<br>matched=1 open=1 close=3] --> C
C --> D[cross=min 2,3 = 2]
D --> E[matched=5<br>open=1 close=2]
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 的元素个数,答案为:
flowchart TD
A[查询区间] --> B[求所有力量的 GCD = g]
B --> C{区间中是否出现 g}
C -- 否 --> D[没有蚂蚁能整除所有对手<br>全部被吃]
C -- 是 --> E[每个等于 g 的位置都能得满分]
E --> F[答案 = 长度 - g 的出现次数]
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 可能没有在区间中出现
统一的设计步骤
先写出查询真正需要的答案,判断单个标量是否足够;
假设左右孩子摘要已经正确,手工推导合并式;
检查合并是否满足结合律,以及左右次序能否交换;
若有区间修改,推导它怎样直接作用于节点摘要;
用单点、整段、跨中点、重复值与数值上限验证边界。
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 的十万次区间操作、长度一百万的括号串,以及十万次蚂蚁查询。
线段树真正可迁移的能力,是把题意压缩成“足以合并的最小信息”。下一次遇到区间题时,先尝试在纸上合并两个相邻小区间;如果必须回看所有原始元素,说明摘要还不够,或这道题需要另一种结构。
区间摘要、差分与线段树 专题阅读 3 / 7
把同类区间题合在一条线里,先选择摘要,再决定能否整体更新。
查看本专题 7 篇文章 Codeforces 前缀和与差分四题:区间查询、覆盖与双层离线 Codeforces 339D Xenia and Bit Operations:交替 OR/XOR 线段树 Codeforces 线段树三题:懒标记、括号合并与 GCD 计数(当前文章) Codeforces 242E 区间异或 从按位计数到懒标记组合 Codeforces 438D 区间取模 从最大值剪枝到均摊线段树 Codeforces 86D 莫队算法 从频次平方到离线窗口移动 Codeforces 482B 区间 AND 从必要条件到最小构造 回到“区间摘要、差分与线段树”的整合路线 →