<线段树>
之前线段树学的比较浅,没有深入研究,所以就又看了一下线段树的知识点。
引入
线段树是算法竞赛中常用的用来维护 区间信息 的数据结构。
线段树可以在O(log(n)) 的时间复杂度内实现单点修改、区间修改、区间查询(区间求和,求区间最大值,求区间最小值)等操作。
核心概念与特性
-
数据结构: 一种二叉树结构(通常是完全二叉树或近似完全二叉树)。
-
节点含义: 每个节点代表原始数组中的一个区间
[l, r]
。-
叶子节点: 代表单个元素(即区间
[i, i]
)。 -
非叶子节点: 代表其左右子节点所代表区间的合并(例如,
[l, mid]
和[mid+1, r]
)。
-
-
节点存储内容: 节点存储的是其代表区间的某个聚合信息,这个信息需要满足可合并性。
-
常见聚合信息:区间和、区间最小值、区间最大值、区间乘积、区间GCD/LCM等。
-
可合并性:父节点的信息必须能够由其左右子节点的信息计算得出
-
-
核心目的:
-
区间查询: 查询任意区间
[L, R]
的聚合信息(例如,[2, 5]
的和、最小值)。 -
区间更新: 更新原始数组中某个元素的值,或者对某个区间内的所有元素执行相同的修改操作(例如,给
[1, 4]
的所有元素加 3)。
-
-
时间复杂度: (假设数组长度为
n
)-
构建树:
O(n)
-
单点查询/更新:
O(log n)
-
区间查询:
O(log n)
-
区间更新:
O(log n)
(需要延迟标记支持)
-
-
空间复杂度:
O(n)
。通常使用大小为4*n
的数组来存储(保守估计,确保足够空间)。也可以动态开点优化空间。
基本操作
-
建树:
-
从根节点(代表整个数组
[0, n-1]
)开始递归。 -
递归地将区间一分为二(
mid = (l + r) / 2
),分别构建左右子树。 -
到达叶子节点时(
l == r
),用原始数组元素初始化节点数据。 -
回溯时,利用子节点的信息计算父节点的信息(
pushUp
操作)。
-
-
区间查询:
-
从根节点开始递归。
-
查询区间
[L, R]
与当前节点区间[l, r]
的关系:-
完全覆盖:
[l, r]
完全包含在[L, R]
内 -> 直接返回当前节点存储的聚合信息。 -
部分重叠:
-
如果
L <= mid
,则递归查询左子树[l, mid]
。 -
如果
R > mid
,则递归查询右子树[mid+1, r]
。
-
-
无重叠: 返回不影响查询结果的单位值(例如,求和时返回0,求最小值返回无穷大)。
-
-
将左右子树的查询结果合并(
combine
)后返回。
-
-
单点更新:
-
找到目标位置
pos
所在的叶子节点。 -
更新该叶子节点的值。
-
回溯更新所有受影响的父节点的聚合信息(
pushUp
操作)。
-
-
区间更新: (关键!需要延迟标记/懒惰标记)
-
问题: 直接像单点更新一样递归到叶子节点更新整个区间,时间复杂度会退化为
O(n)
。 -
解决方案:延迟标记 (Lazy Propagation)
-
核心思想: 当需要更新一个区间
[L, R]
时,如果当前节点[l, r]
被[L, R]
完全覆盖,则:-
更新当前节点存储的聚合信息(根据更新操作计算)。
-
给当前节点打上一个延迟标记 (
lazy
),记录这个更新操作(例如,“这个区间里的所有元素都要加delta
”)。 -
停止递归! 不立即更新其子节点。
-
-
标记下传: 在后续的查询或更新操作访问到当前节点的子节点之前,必须:
-
根据
lazy
标记的值更新左右子节点的聚合信息。 -
将
lazy
标记的值下传给左右子节点(如果它们还不是叶子节点,也需要打上lazy
标记)。 -
清除当前节点的
lazy
标记。
-
-
-
带延迟标记的区间更新步骤:
-
查询或更新访问到某个节点时,先检查并下传该节点的延迟标记(
pushDown
操作)。 -
如果当前节点区间
[l, r]
完全被更新区间[L, R]
覆盖:-
根据更新操作修改当前节点的聚合信息。
-
给当前节点打上延迟标记(记录更新操作)。
-
返回。
-
-
否则(部分重叠):
-
如果
L <= mid
,递归更新左子树。 -
如果
R > mid
,递归更新右子树。 -
更新当前节点的聚合信息(
pushUp
操作)。
-
-
-
关键点与技巧
-
区间划分: 通常使用
mid = (l + r) / 2
(整数除法),左子树管[l, mid]
,右子树管[mid+1, r]
。 -
存储结构:
-
数组存储: 最常用。根节点下标为
1
。节点i
的左子节点下标为2*i
(i << 1
),右子节点下标为2*i + 1
(i << 1 | 1
)。需要开4 * n
大小的数组。 -
为什么:
-
指针/结构体存储: 更灵活,支持动态开点。
-
-
聚合信息的可合并性: 这是线段树能够工作的基础。必须定义好如何合并两个相邻区间的信息 (
combine
函数)。 -
延迟标记的设计:
-
标记需要记录未下传的更新操作。
-
需要定义好如何用标记更新节点数据 (
apply
函数)。 -
需要定义好如何将标记下传给子节点(通常是累加或覆盖,取决于操作类型)。
-
复杂操作(如区间加乘混合)需要更精细的标记设计(例如,维护
(add, mul)
两个标记,并定义好运算顺序)。
-
-
查询/更新时的递归终止条件: 通常是
l > R || r < L
(无交集)或L <= l && r <= R
(完全覆盖)。 -
离散化: 当原始数组的值域很大(如坐标范围很大)但元素个数相对较少时,可以将坐标映射到较小的连续整数范围,再用线段树处理。常用于处理“区间覆盖”、“矩形面积并”等问题。
常见应用场景
-
区间求和、区间乘积。
-
区间最小值/最大值查询(Range Minimum/Maximum Query - RMQ)。
-
区间赋值、区间加减(带延迟标记)。
-
区间合并(如最长连续1、最大子段和)。
-
区间GCD/LCM。
-
扫描线算法(解决矩形面积并、矩形周长并等问题的基础)。
-
二维线段树(处理矩阵问题,如子矩阵查询/更新,但更复杂)。
优缺点
-
优点:
-
区间查询和区间更新的时间复杂度优异 (
O(log n)
)。 -
通用性强,能处理多种区间聚合操作和更新操作(只要满足可合并性)。
-
结构清晰。
-
-
缺点:
-
代码实现相对复杂,尤其是带延迟标记的区间更新。
-
空间开销较大(通常
4*n
)。 -
对于某些特定问题(如纯RMQ),有更简洁的替代方案(如稀疏表Sparse Table)。
-
基本模板:
区间修改:
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<iostream>
#include<bits/stdc++.h>
using namespace std;// 线段树节点结构体
struct ss {int x, y; // 节点表示的区间范围 [x, y]int sum = 0; // 区间和(本题中表示区间内1的数量)int p = 0; // 懒惰标记(记录该区间需要翻转的次数,模2处理)
}a[400005]; // 线段树数组(通常开4倍空间)int n, m; // n: 灯的数量, m: 操作次数// 初始化线段树
// l: 当前区间左边界, r: 当前区间右边界, i: 当前节点在数组中的下标
void ChuXianDuanShu(int l, int r,int i) {if (r == l) { // 到达叶子节点(区间长度为1)a[i].x = l; // 记录区间左端点a[i].y = l; // 记录区间右端点return;}int mid = (l+r)>>1; // 计算区间中点// 递归构建左子树ChuXianDuanShu(l, mid, i * 2);// 递归构建右子树ChuXianDuanShu(mid + 1, r, i * 2 + 1);// 初始化当前节点a[i].sum = 0; // 初始和为0(全为0状态)a[i].x = l; // 记录区间左端点a[i].y = r; // 记录区间右端点
}// 处理懒惰标记(实际是执行翻转操作并传递标记)
// i: 当前节点下标
void QingLanDuoBiaoJi(int i) {// 执行翻转操作:区间内1的数量 = 区间长度 - 当前1的数量a[i].sum = a[i].y - a[i].x + 1 - a[i].sum;// 翻转标记取反(模2处理,翻转两次等于不翻转)a[i].p = (++a[i].p) % 2;
}// 区间修改(翻转操作)
// l,r: 要修改的区间, i: 当前节点下标, zhi: 未使用的参数(可移除)
void Xiu(int l, int r,int i,int zhi) {// 当前节点区间完全包含在修改区间内if (a[i].x >= l && a[i].y <= r) {// 直接翻转当前区间a[i].sum = a[i].y - a[i].x + 1 - a[i].sum;// 更新懒惰标记(翻转次数+1并模2)a[i].p = (++a[i].p) % 2;return;}// 若存在未下传的懒惰标记(翻转1次未处理)if (a[i].p == 1) {a[i].p = (++a[i].p) % 2; // 清除当前节点标记(先+1再模2变为0)// 下传标记到左孩子QingLanDuoBiaoJi(i * 2);// 下传标记到右孩子QingLanDuoBiaoJi(i * 2 + 1);}int mid =((a[i].y + a[i].x) >> 1); // 计算当前节点区间中点// 若修改区间与左子树有重叠if (l <= mid) {Xiu(l, r, i * 2, zhi);}// 若修改区间与右子树有重叠if (mid < r) { // 注意:这里使用mid<r而不是r>mid,避免边界问题Xiu(l, r, i * 2 + 1, zhi);}// 更新当前节点的区间和(左右子树和相加)a[i].sum = a[i * 2].sum + a[i * 2 + 1].sum;
}// 区间查询(查询1的数量)
// l,r: 查询区间, i: 当前节点下标
int Cha(int l, int r, int i) {// 当前节点区间完全包含在查询区间内if (a[i].x >= l && a[i].y <= r) {return a[i].sum; // 直接返回当前区间和}// 若存在未下传的懒惰标记if (a[i].p == 1) {a[i].p = (++a[i].p) % 2; // 清除当前节点标记// 下传标记到左孩子QingLanDuoBiaoJi(i * 2);// 下传标记到右孩子QingLanDuoBiaoJi(i * 2 + 1);}int mid = a[i].x + ((a[i].y - a[i].x) >> 1); int q = 0; // 若查询区间与左子树有重叠if (l <= mid) {q += Cha(l, r, i * 2);}// 若查询区间与右子树有重叠if (mid < r) { // 注意边界处理(右开区间)q += Cha(l, r, i * 2 + 1);}return q;
}
单点修改是不需要懒惰标记的。
例:P3870 [TJOI2009] 开关
题目描述
现有 n 盏灯排成一排,从左到右依次编号为:1,2,……,n。然后依次执行 m 项操作。
操作分为两种:
- 指定一个区间 [a,b],然后改变编号在这个区间内的灯的状态(把开着的灯关上,关着的灯打开);
- 指定一个区间 [a,b],要求你输出这个区间内有多少盏灯是打开的。
灯在初始时都是关着的。
输入格式
第一行有两个整数 n 和 m,分别表示灯的数目和操作的数目。
接下来有 m 行,每行有三个整数,依次为:c、a、b。其中 c 表示操作的种类。
- 当 c 的值为 0 时,表示是第一种操作。
- 当 c 的值为 1 时,表示是第二种操作。
a 和 b 则分别表示了操作区间的左右边界。
输出格式
每当遇到第二种操作时,输出一行,包含一个整数,表示此时在查询的区间中打开的灯的数目。
输入输出样例
输入 #1复制
4 5 0 1 2 0 2 4 1 2 3 0 2 4 1 1 4
输出 #1复制
1 2
说明/提示
数据规模与约定
对于全部的测试点,保证 2≤n≤105,1≤m≤105,1≤a,b≤n,c∈{0,1}。
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<iostream>
#include<bits/stdc++.h>
using namespace std;// 线段树节点结构体
struct ss {int x, y; // 节点表示的区间范围 [x, y]int sum = 0; // 区间和(本题中表示区间内1的数量)int p = 0; // 懒惰标记(记录该区间需要翻转的次数,模2处理)
}a[400005]; // 线段树数组(通常开4倍空间)int n, m; // n: 灯的数量, m: 操作次数// 初始化线段树
// l: 当前区间左边界, r: 当前区间右边界, i: 当前节点在数组中的下标
void ChuXianDuanShu(int l, int r,int i) {if (r == l) { // 到达叶子节点(区间长度为1)a[i].x = l; // 记录区间左端点a[i].y = l; // 记录区间右端点return;}int mid = (l+r)>>1; // 计算区间中点// 递归构建左子树ChuXianDuanShu(l, mid, i * 2);// 递归构建右子树ChuXianDuanShu(mid + 1, r, i * 2 + 1);// 初始化当前节点a[i].sum = 0; // 初始和为0(全为0状态)a[i].x = l; // 记录区间左端点a[i].y = r; // 记录区间右端点
}// 处理懒惰标记(实际是执行翻转操作并传递标记)
// i: 当前节点下标
void QingLanDuoBiaoJi(int i) {// 执行翻转操作:区间内1的数量 = 区间长度 - 当前1的数量a[i].sum = a[i].y - a[i].x + 1 - a[i].sum;// 翻转标记取反(模2处理,翻转两次等于不翻转)a[i].p = (++a[i].p) % 2;
}// 区间修改(翻转操作)
// l,r: 要修改的区间, i: 当前节点下标, zhi: 未使用的参数(可移除)
void Xiu(int l, int r,int i,int zhi) {// 当前节点区间完全包含在修改区间内if (a[i].x >= l && a[i].y <= r) {// 直接翻转当前区间a[i].sum = a[i].y - a[i].x + 1 - a[i].sum;// 更新懒惰标记(翻转次数+1并模2)a[i].p = (++a[i].p) % 2;return;}// 若存在未下传的懒惰标记(翻转1次未处理)if (a[i].p == 1) {a[i].p = (++a[i].p) % 2; // 清除当前节点标记(先+1再模2变为0)// 下传标记到左孩子QingLanDuoBiaoJi(i * 2);// 下传标记到右孩子QingLanDuoBiaoJi(i * 2 + 1);}int mid =((a[i].y + a[i].x) >> 1); // 计算当前节点区间中点// 若修改区间与左子树有重叠if (l <= mid) {Xiu(l, r, i * 2, zhi);}// 若修改区间与右子树有重叠if (mid < r) { // 注意:这里使用mid<r而不是r>mid,避免边界问题Xiu(l, r, i * 2 + 1, zhi);}// 更新当前节点的区间和(左右子树和相加)a[i].sum = a[i * 2].sum + a[i * 2 + 1].sum;
}// 区间查询(查询1的数量)
// l,r: 查询区间, i: 当前节点下标
int Cha(int l, int r, int i) {// 当前节点区间完全包含在查询区间内if (a[i].x >= l && a[i].y <= r) {return a[i].sum; // 直接返回当前区间和}// 若存在未下传的懒惰标记if (a[i].p == 1) {a[i].p = (++a[i].p) % 2; // 清除当前节点标记// 下传标记到左孩子QingLanDuoBiaoJi(i * 2);// 下传标记到右孩子QingLanDuoBiaoJi(i * 2 + 1);}int mid = a[i].x + ((a[i].y - a[i].x) >> 1); int q = 0; // 若查询区间与左子树有重叠if (l <= mid) {q += Cha(l, r, i * 2);}// 若查询区间与右子树有重叠if (mid < r) { // 注意边界处理(右开区间)q += Cha(l, r, i * 2 + 1);}return q;
}
int main(){ios::sync_with_stdio(false); // 禁用同步cin.tie(nullptr); // 解除cin与cout绑定cin >> n >> m;ChuXianDuanShu(1, n, 1);int l, r, f;for (int i = 0; i < m; i++) {cin >> f >> l >> r;if (f == 0) {Xiu(l, r, 1, 0);}else {cout << Cha(l, r, 1) << endl;}}return 0;
}