【知识点】浅入线段树与区间最值问题
前言:这又是一篇关于数据结构的文章。
今天来讲一下线段树和线段树的基本应用。线段树 (Segment Tree),是一种非常高效且高级的数据结构,其主要用于区间查询和与区间更新相关的问题,例如进行多次查询区间最大值、最小值、更新区间等操作。
区间最值问题引入
常见的线段树题型就是 区间最值问题 (Range Maximum/Minimum Query, RMQ)。通常来说,区间最值问题会给定用户一个长度为
常见的区间最值算法(数据结构)有很多,但线段树在某些情况下一定是最优解。以下是不同 RMQ 算法的优势和劣势:
- 暴力枚举 Brute Force:实现难度非常简单,适合数据量较小的情况。但查询效率极其低下。
- 树状数组 Binary Indexed Tree:实现相对简单,查询和更新效率较高。只能处理前缀区间的问题,对于任意区间查询(例如,区间最值)需要进行一些变形和额外处理,不如线段树灵活。
- 稀疏表 Sparse Table:适合处理静态数据,即数据在预处理之后不再发生改变。如果要频繁实现在线区间修改/单点修改的操作,ST表就非常的耗时。
- 线段树 Segment Tree:查询和更新效率高,适合动态数据,支持快速更新。但缺点是实现较为复杂,代码量较大。
综上所述,每种算法(数据结构)都有自己的优势和劣势,我们应该根据实际情况选用最合适的方案。
线段树的底层是基于 二叉树 (Binary Tree) 来实现的,因此在线段树相关的操作中,大多数操作的时间复杂度可以被优化到
线段树的基本结构
线段树是一棵二叉树,因此对于每一个节点而言至多只有两个子节点。与此同时,线段树的每一个节点都存储了一个区间的信息,通常是这个区间的某种 统计量(如最大值、最小值、总和等)。每个节点的区间是它的两个子节点区间的并集,根节点表示我们需要维护的整一个区间。
举一个形象的例子。例如我们想要构造一棵线段树来维护一个区间
知道了线段树的基本结构,那么维护每一个节点所记录的状态也会变得特别简单。以维护区间最大值为例子,如果区间
因此,线段树的一个局限性就是维护的数据必须具有可传递性,说白了,就是必须可以通过两个小区间所记录的值来推导出某一个大区间所记录的值。
以下代码将以维护区间的总和为例子来展开:
线段树的存储
arr
数组是我们需要维护区间每个位置的原始数值。
我们通过 tree
数组来存储整一棵线段树,由于线段树属于一种平衡二叉树,在最坏的情况下,线段树的大小将会是
为了加速计算,我们可以使用位运算的方式来实现(本文将不详细阐述位运算的过程,有需要的人可以自行上网查阅):
- 将一个数
x
乘上 ,可以写为x << n
。因此 可以写成n << 2
。 - 将一个数偶数加上
,可以通过x | 1
或运算来实现。
struct node{
int sum;
} tree[(n << 2) + 5];
int arr[n + 5]
线段树的构建
线段树的构建过程跟普通的二叉树构建过程类似,都是通过递归的方式来实现的。如果我们要构建一个长度为
void push_up(int root){
tree[root].sum = tree[root << 1].sum + tree[root << 1|1].sum;
return ;
}
线段树的初始化(构建)代码如下。其中 root
变量表示当前节点在 tree
数组中的索引。变量 l
与 r
分别表示所维护区间的左边界和右边界。对于每一层递归来说,我们要维护一个长度为 l == r
时,则证明区间的长度正好为
void build_tree(int l, int r, int root){
if (l == r){
tree[root] = (node){arr[l], 0};
return ;
}
int mid = (l + r) >> 1;
build_tree(l, mid, root << 1);
build_tree(mid+1, r, root << 1|1);
push_up(root);
return ;
}
通过代码可以看出,初始化一棵线段树的时间复杂度为
线段树的区间查询
与线段树的构建相同,查询线段树也是通过 递归+二分 的方式来实现的。给定一个查询的区间
例如,如果我们要查询区间
实现线段树上区间查询的代码如下,其中变量 l
和 r
表示当前所查询的区间边界,root
为当前的根节点索引。
int interval_query(int l, int r, int L, int R, int root){
int sum = 0;
if (L <= l && r <= R)
// 与区间完全匹配,因此可以直接返回结果。
return tree[root].sum;
int mid = (l + r) >> 1;
int llen = mid - l + 1;
int rlen = r - mid;
// 如果所查询的部分/所有结果存在于左半边,那么就递归计算左半边的结果。
if (L <= mid) sum += interval_query(l, mid, L, R, root << 1);
// 如果所查询的部分/所有结果存在于右半边,那么就再递归计算右半边的结果。
if (R > mid) sum += interval_query(mid + 1, r, L, R, root << 1|1);
return sum;
}
当然,如果要实现单点查询的话,只需要令所查询的左边界等于右边界,即令
线段树的区间更新
更新线段树同样是一个递归的过程。给定一个需要更新的位置和新的值,从根节点开始,如果当前节点表示的区间包含需要更新的位置,则递归更新左右子树,并将结果合并。区间更新的操作与区间查询的操作几乎类似。区间更新的时间复杂度也约为
同时,在更新区间后应该再次使用 push_up()
函数来保证父节点的数据被正确更新了。
线段树区间更新的代码如下,变量 v
表示该区间所有元素要新增的值,其余变量的意义与上述保持不变:
void interval_update(int l, int r, int L, int R, int v, int root) {
if (l == r) {
tree[root].sum += v;
return;
}
int mid = (l + r) >> 1;
if (L <= mid) interval_update(l, mid, L, R, v, root << 1);
if (R > mid) interval_update(mid + 1, r, L, R, v, root << 1 | 1);
// 更新当前节点的值
push_up();
return ;
}
当然,如果要实现单点更新的话,只需要令所更新的左边界等于右边界,即令
线段树的进一步优化 - 懒标记 (Lazy Tag)
在实际应用中,线段树常常使用 懒标记 (Lazy Tag) 来优化某些操作。懒标记技术可以延迟对某些节点的更新,直到必须访问这些节点时才进行更新,从而提高效率。
懒标记的概念:懒标记是一种延迟更新的技巧,用于处理区间更新问题。基本思想是对于一个更新操作,不立即更新所有受影响的节点,而是将更新信息记录下来,等到需要查询这些节点的值时再执行更新。
例如,假设我们要更新区间
在下放懒标记的时候,我们每次只向下下放一次即可,也并不必需要更新到叶子结点。因此,在进行区间查询和区间更新的时候,需要调用 push_down()
函数。
懒标记下放
void push_down(int root, int inten, int rlen){
// 如果存在懒标记就更新,否则就不更新。
if (tree[root].lazy_tag){
// 将父节点的懒标记遗传给子节点。
tree[root << 1].lazy_tag += tree[root].lazy_tag;
tree[root << 1|1].lazy_tag += tree[root].lazy_tag;
tree[root << 1].sum += inten * tree[root].lazy_tag;
tree[root << 1|1].sum += rlen * tree[root].lazy_tag;
// 清除父节点的懒标记。
tree[root].lazy_tag = 0;
}
return ;
}
线段树完整代码
以下是洛谷题目 P3372 【模板】线段树 1 的完整代码,改代码包含本文所阐述的所有代码且应用了懒标记的思想:
#include <iostream>
#include <algorithm>
using namespace std;
#define int long long
const int MAXN = 2e5 + 5;
int n, m, t;
int x, y, k;
struct node{
int sum;
int lazy_tag;
} tree[MAXN << 2];
int arr[MAXN];
// 更新父节点
void push_up(int root){
tree[root].sum = tree[root << 1].sum + tree[root << 1|1].sum;
return ;
}
// 懒标记下放
void push_down(int root, int inten, int rlen){
if (tree[root].lazy_tag){
tree[root << 1].lazy_tag += tree[root].lazy_tag;
tree[root << 1|1].lazy_tag += tree[root].lazy_tag;
tree[root << 1].sum += inten * tree[root].lazy_tag;
tree[root << 1|1].sum += rlen * tree[root].lazy_tag;
tree[root].lazy_tag = 0;
}
return ;
}
// 构造线段树
void build_tree(int l, int r, int root){
if (l == r){
tree[root] = (node){arr[l], 0};
return ;
}
int mid = (l + r) >> 1;
build_tree(l, mid, root << 1);
build_tree(mid+1, r, root << 1|1);
push_up(root);
return ;
}
// 单点修改
void single_update(int l, int r, int k, int v, int root){
if (l == r){
tree[l].sum += v;
return ;
}
int mid = (l + r) >> 1;
if (k <= mid) single_update(l, mid, k, v, root << 1);
else single_update(mid + 1, r, k, v, root << 1|1);
push_up(root);
return ;
}
// 区间修改
void interval_update(int l, int r, int L, int R, int v, int root){
if (L <= l && r <= R){
tree[root].lazy_tag += v;
tree[root].sum += (r - l + 1) * v;
return ;
}
int mid = (l + r) >> 1;
int inten = mid - l + 1;
int rlen = r - mid;
// 下放懒标记
push_down(root, inten, rlen);
if (L <= mid) interval_update(l, mid, L, R, v, root << 1);
if (R > mid) interval_update(mid+1, r, L, R, v, root << 1|1);
push_up(root);
return ;
}
// 区间查询
int interval_query(int l, int r, int L, int R, int root){
int sum = 0;
if (L <= l && r <= R) return tree[root].sum;
int mid = (l + r) >> 1;
int inten = mid - l + 1;
int rlen = r - mid;
// 下放懒标记
push_down(root, inten, rlen);
if (L <= mid) sum += interval_query(l, mid, L, R, root << 1);
if (R > mid) sum += interval_query(mid + 1, r, L, R, root << 1|1);
return sum;
}
signed main(){
scanf("%lld %lld", &n, &m);
for (int i=1; i<=n; i++) scanf("%lld", &arr[i]);
build_tree(1, n, 1);
while(m--){
scanf("%lld", &t);
if (t == 1){
scanf("%lld %lld %lld", &x, &y, &k);
interval_update(1, n, x, y, k, 1);
} else{
scanf("%lld %lld", &x, &y);
int ans = interval_query(1, n, x, y, 1);
printf("%lld\n", ans);
}
}
return 0;
}
小结
线段树是一个相对复杂的数据结构,因此必然存在许多线段树的变形题目,需要我们在做题时随机应变。我们应该通过多刷题来提升对线段树的熟练程度。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 在鹅厂做java开发是什么体验
· 百万级群聊的设计实践
· WPF到Web的无缝过渡:英雄联盟客户端的OpenSilver迁移实战
· 永远不要相信用户的输入:从 SQL 注入攻防看输入验证的重要性
· 全网最简单!3分钟用满血DeepSeek R1开发一款AI智能客服,零代码轻松接入微信、公众号、小程