Part 1 引入
先看下面一个例子:
题面:
给定一个长度为
n
n
n 数组的
a
a
a 进行
q
q
q 次询问。
每次询问有两种操作:
操作1:给定
l
,
r
l,r
l,r,将
a
l
a_{l}
al 的值改为
r
r
r。
操作2:给定
l
,
r
l,r
l,r,求区间
l
,
r
{l,r}
l,r 内最大值。
输入格式:
第一行:两个正整数
n
,
q
n,q
n,q
第二行:
n
n
n 个整数第
i
i
i 个数表示
a
i
a_{i}
ai 的值
接下来
q
q
q 行:输入三个正整数
o
p
,
l
,
r
op,l,r
op,l,r,
o
p
op
op 表示操作几。
输出格式:
对于每一个操作2,输出一个整数表示答案。
数据范围:
a
i
≤
1
0
9
,
n
≤
1
0
5
,
q
≤
1
0
5
a_{i} \le 10^9,n \le 10^5,q \le 10^5
ai≤109,n≤105,q≤105
时限:
1000
m
s
1000ms
1000ms
对于这个题目,我们的普通暴力已经无能为力了.
这时线段树就要出现了!!
Part 2 实现原理(Segment tree)
具体的线段树咋实现呢?
看这组数据:
输入样例:
8 4
1 2 3 4 5 6 7 8
2 1 2
2 1 8
输出样例:
2
8
话不多说,先上图。
我们可以发现区间
[
1
,
8
]
[1,8]
[1,8] 的答案就是区间
[
1.2
]
[1.2]
[1.2] 的答案与区间
[
3
,
8
]
[3,8]
[3,8] 的答案的最大值。
于是我们可以将区间
[
1
,
8
]
[1,8]
[1,8] 分成区间
[
1
,
4
]
[1,4]
[1,4] 和区间
[
5
,
8
]
[5,8]
[5,8]。
同样区间
[
1
,
4
]
[1,4]
[1,4] 同样可以拆成区间
[
1
,
2
]
[1,2]
[1,2] 和区间
[
3
,
4
]
[3,4]
[3,4]。
对于区间
[
l
,
r
]
[l,r]
[l,r],都可以分为区间
[
l
,
m
i
d
]
[l,mid]
[l,mid] 和区间
[
m
i
d
+
1
,
r
]
[mid + 1,r]
[mid+1,r] 其中
m
i
d
mid
mid 为
l
+
r
2
\dfrac{l +r} 2
2l+r。
并且这个区间的答案就是两个子区间的答案的最大值。
Part 3 建树(build)
那么具体如何实现呢?就像前文说的一样,我们可以使用递归解决,每次传入一个区间,表示当前函数处理的区间。
根据递归的原理,本函数的边界值就是当当前区间长度为 1 时,不再需要继续递归,直接将当前节点的值初始化,在本题中就是记为 a i a_i ai。
而每次递推,就是将当前区间 [ l , r ] [l,r] [l,r],推到 [ l , m i d ] [l,mid] [l,mid] 及 [ m i d + 1 , r ] [mid+1,r] [mid+1,r],然后更新当前节点的值,在本题中即为取子节点的最大值。
最后,我们建树时直接传入区间 [ 1 , n ] [1,n] [1,n] 即可,时间复杂度 O ( n ) O(n) O(n),证明留给读者。
代码:
void build(int u, int l, int r) {
if (l == r) {
maxv[u] = a[l];
return;
}
int mid = l + r >> 1;
build(u << 1, l, mid);
build(u << 1 | 1, mid + 1, r);
maxv[u] = max(maxv[u << 1], maxv[u << 1 | 1]);
}
Part 4 修改(update)
修改与建树类似,但是因为这里只需要修改实际上的一个点,即使在线段树中,也只对应 l o g 2 n log_2n log2n 级别的点。
所以我们不再需要在每次递推过程中同时递推左右两边的区间,因为显然目标点只会在其中一个区间内,我们只需要递推一个即可。
边界值也和建树一样,但是因为当前位置的 a i a_i ai 已被修改,所以我们可以直接赋上新的值。同样需要记得更新上面的节点。
代码:
void update(int u, int l, int r) {
if (l > r) return;
if (l == r && l == x) {
maxv[u] = v;
return;
}
int mid = l + r >> 1;
if (mid >= x) update(u << 1, l, mid);
if (mid < x) update(u << 1 | 1, mid + 1, r);
maxv[u] = max(maxv[u << 1], maxv[u << 1 | 1]);
}
Part 5 查询(query)
查询就是线段树的精髓所在了。
如果我们也像建树和修改一样,那么时间复杂度是查询区间的长度乘上一只 l o g log log,比暴力的常数还大。
注意到,如果递推到当前节点时,当前区间已经是查询区间的子区间时,我们根本不需要再下传,为什么呢?
因为此时我们已经处理过了整个区间的总值(在本题中即区间最大值),所以我们直接返回当前区间节点存储的值即可。
此时我们本质上就是将查询区间拆分成了 l o g 2 n log_2 n log2n 级别个小区间,因为每个小区间已经处理过,所以每个小区间都是 O ( 1 ) O(1) O(1) 查询的。当查询区间长度与 n n n 同阶时,单次查询时间复杂度 O ( l o g 2 n ) O(log_2 n) O(log2n)。
代码:
int query(int u, int l, int r) {
if (l > r) return 0;
if (L <= l && r <= R) {
return maxv[u];
}
int ret = 0;
int mid = l + r >> 1;
if (mid >= L) ret = max(ret, query(u << 1, l, mid));
if (mid < R) ret = max(ret, query(u << 1 | 1, mid + 1, r));
return ret;
}
Part 6 习题&小结(problems)
看完这么多,我们可以发现线段树解决的就是区间问题,其本质就是将一个区间,拆分为两个区间。但是当我们需要求的无法简单拆分时,这个方法就行不通了,以下是一些例子。
线段树不可行:区间和为 x x x 的数对个数,区间逆序对数,区间最长不讲子序列长度等。
线段树可行:区间和,区间积,区间异或和等。
习题:
P1531
P3374
P1198
2022 提高组 T2(P8818)
附例题主函数:
int main() {
int n, q;
cin >> n >> q;
for (int i = 1; i <= n; ++i) cin >> a[i];
build(1, 1, n);
while (q--) {
int op;
cin >> op;
if (op == 1) {
cin >> x >> v;
update(1, 1, n);
} else {
cin >> L >> R;
cout << query(1, 1, n) << endl;
}
}
return 0;
}