前言:想必有过算法竞赛学习经验的同学及读者都对线段树有所耳闻,对于线段树的难写与难调程度咬牙切齿。但是偏偏线段树还是算法竞赛中最重要的数据结构之一,用途更是非常广泛,比st表,树状数组等简单的数据结构的适用范围大很多很多。本文竭尽作者对于线段树的理解和代码经验,希望能帮助读者了解线段树的实现,建树,操作的原理。
然后本文后半部分会对线段树的进阶操作进行详细的讲解,主要分为线段树的懒标记(区间修改操作),扫描线等难度和代码量更大的操作。
目录
线段树的适用问题:
线段树可以解决的问题包括:很多的区间信息统计问题,比如区间和,区间最值,区间最大公约数等等可重复贡献的问题。
线段树的基本思想和原理:
线段树(Segment Tree)是一种基于分治思想的二叉树结构,用于在区间上进行信息的统计。与按照二进制位(2的次幂)进行区间划分的树状数组相比,线段树是更加通用且好理解得多的一种数据结构。下面是线段树的4条极为重要的性质:
1.顾名思义,线段树的每个节点代表一条线段,即一个区间
2.线段树具有唯一的根节点,代表的是区间的整个统计范围,比如[1,N]
3.线段树的每个叶节点都代表长度为1的元区间(线段)[x,x]
4.根据完全二叉树的基本性质,每一个父节点u的左儿子在树中的节点编号为2 * u (作者习惯的写法为u << 1),右儿子的在树中的节点编号为2 * u + 1(作者习惯写为u << 1 | 1),每一个儿子节点p的父节点的下标为p / 2(作者习惯写为p >> 1)
5.它的存储方式是以满二叉树的形式存储的,很类似堆的存储方式,所以根节点一律编号为1
为了能够更加直观的展示线段树的性质和其具体结构,附图一张
通过这张图,大家应该能够更直观的了解线段树的具体结构了。
接下来就到了线段树的结构定义和建树、pushup.修改(modify)、查询(query)操作了
线段树的结构体定义:
struct Node
{
int l, r;
int val;
}tr[N * 4];
这里有几个需要重点说明的细节
1.线段树一般开多少的节点合适:数组的元素个数是多少,就开它的四倍就好了,具体的证明涉及到满二叉树的性质,可以参考如下链接的文章https://oi-wiki.org/ds/seg/
2.l和r具体的意义:有一些初学线段树的读者可能把l和r直接就当成了当前这个节点的左右儿子的编号(事实上主席树的结构定义才是这样的),其实不然。这里的l和r表示的是当前这个节点表示的区间在原来数组中下标的左端点和右端点。如果以后做题的时候还是搞混,可以自己画张图,如果这里理解出了问题,后面学起来会出大问题,甚至做题时很多时候维护的不是下标区间而是数值区间,或者离散化之后的数值区间。
线段树的pushup和建树操作
其实pushup操作的本质是在递归建树或修改的时候,我们先更新了儿子节点,现在返回递归到父节点的时候,去利用子节点的信息去更新父节点的一个操作
void pushup(int u) //u表示的是当前节点的编号
{
//由于不同的线段树中每个节点存储的信息类型是不一样的,所以这里用区间最大值举例
tr[u].val = max(tr[u << 1].val, tr[u << 1 | 1].val);
//这里的更新思路是:大区间的最大值等于两个子区间的最大值之间的最大值
}
void build(int u, int l, int r)
{
if (l == r) tr[u] = { l ,r ,a[i] }; //l == r表示当前节点是叶子结点
else
{
tr[u] = { l,r }; //先把当前区间表示的范围初始化
int mid = l + r >> 1;
//先递归建立左右子树
build(u << 1, l, mid);
build(u << 1 | 1, mid + 1, r);
//最后再更新父节点的信息
pushup(u);
}
}
//建树时main函数中的操作
build(1 , 1 , n);
线段树的查询操作
线段树一般的查询操作都是区间查询,查询原数组某一区间的信息
int query(int u, int l, int r) //注意这里的l和r表示的是所要查询的区间的下标左端点和右端点
{
//如果当前这个区间被查询区间完全覆盖,就直接返回它的信息
if (l <= tr[u].l && r >= tr[u].r) return tr[u].val;
else //表示没有完全覆盖上
{
int mid = tr[u].l + tr[u].r >> 1;
int res = -1e9;
if (l <= mid) res = query(u << 1, l, r); //表示当前的区间和左儿子有交集
if (r > mid) res = max(res, query(u << 1 | 1, l, r); //表示的是当前区间和右儿子有交集
return res;
}
}
//查询时main函数的操作
int res = query(1 , l ,r);
线段树的单点修改操作
单点修改和查询操作是有点相似的,也是找到叶子结点后一次向上递归,不断修改父节点的val
void modify(int u, int x, int y) //表示的是要将原数组中下标为x的数改为y
{
//如果搜到叶子结点了,因为只有a[x]这一个点,就直接修改即可
if (tr[u].l == x && tr[u].r == x) tr[u].val = y;
else
{
int mid = tr[u].l + tr[u].r >> 1;
//如果这个点在左儿子表示的区间,就先改左儿子,否则在右儿子,就先修改右儿子
if (x <= mid) modify(u << 1, x, y);
else modify(u << 1 | 1, x, y);
//最后利用修改之后的左右儿子的信息修改父节点的信息
pushup(u);
}
}
//main函数中的操作
modify(1 , x ,y);
以上的结构定义和四个函数就是线段树最最基本的板子了,建议OIer及ACMer全部背下来,做到一字不差,因为线段树写错了非常难改难调,所以最好的方法就是一次写对!!
下面就是线段树的初步进阶操作了,即线段树的懒标记(负责支持区间修改操作)
建议先把基础掌握牢固再继续学习。
区间修改:线段树的懒标记和pushdown操作
在本文的前半部分,详细介绍了线段树最基本的操作,但是只有这些操作还是不够的。大部分的题目不是需要做单点修改操作,而是需要做区间修改,比如直接将整个区间内的所有数都加上一个相同的数,或者开平方等等。如果还是按照我们之前的单点修改来做的话,假设修改区间的长度为d,那么区间修改的时间复杂度为o(dlogn),因为需要对每个元素都做一遍单点修改。当d的值很大的时候就必定会TLE。
所以我们需要用一种新的方法呢能够在o(logn)的时间复杂度之内完成区间修改操作。这就是懒标记的应用。
懒标记:我们以最简单的操作为例,假设我们现在要将下标区建在[l ~ r]范围内的所有数都加上x,那么我们在一个节点上的懒标记add表示的是以当前节点为根的子树(不包括根节点)的每个节点表示的全部元素都需要加上add这个数。
当修改操作进行到一个节点的时候,如果发现当前节点表示的区间是完全包含在修改区间内部的,我们就直接更新当前区间的信息,之后修改这个根节点上的懒标记,不再处理子节点了。此思想的灵感来源于区间查询操作,因为在区间查询的过程中,一旦发现当前节点的区间是包含在查询区间内部的,我们就直接返回当前区间的信息,不再向下递归了。修改也是同理的,当根节点已经被保安的时候,对子节点的修改可以暂时不做,视为寄存在根节点中,一旦后序的操作范围不包含在这个根节点中的时候,就把懒标记传递给儿子节点,这个操作就是pushdown操作。这个方法就可以直接将区间修改的时间复杂度降为log(n)了
代码实现如下,我们同时维护每个节点的两个信息,一个是最大值,另一个是区间和
本题的其实是洛谷上的P3372板子题,可以去自己多写几遍感受一下
#include<iostream>
#include<cstring>
#include<algorithm>
#include<cstdio>
using namespace std;
typedef long long LL;
const int N = 1e5 + 10;
struct Node
{
int l, r;
LL maxv, sum;
LL add; //懒标记
}tr[N * 4];
int n, q;
LL a[N];
void pushup(int u)
{
tr[u].maxv = max(tr[u << 1].maxv, tr[u << 1 | 1].maxv);
tr[u].sum = tr[u << 1].sum + tr[u << 1 | 1].sum;
}
void build(int u, int l, int r)
{
if (l == r) tr[u] = { r , r , a[r], a[r]};
else
{
tr[u] = { l,r };
int mid = l + r >> 1;
build(u << 1, l, mid), build(u << 1 | 1, mid + 1, r);
pushup(u);
}
}
void pushdown(int u)
{
//作者选择使用引用型参数,这样更方便一些
Node &root = tr[u], &left = tr[u << 1], &right = tr[u << 1 | 1];
//先分别对左右儿子进行更新
if (root.add) //判断父节点的懒标记是不是0,如果为0就没有操作的必要了
{
left.sum += root.add * (left.r - left.l + 1); //区间和需要累加每个数增加的量
left.maxv += root.add; //最大值直接加上懒标记即可
right.sum += root.add * (right.r - right.l + 1); //区间和需要累加每个数增加的量
right.maxv += root.add; //最大值直接加上懒标记即可
//把懒标记传递给儿子节点
left.add += root.add;
right.add += root.add;
root.add = 0; //清空根节点的懒标记
}
}
LL query(int u, int l, int r)
{
if (l <= tr[u].l && r >= tr[u].r) return tr[u].sum;
else
{
//这里为什么要下传懒标记呢?是因为我们当前的区间没有被查询区间包含在内,所以必须分裂区间一次
//这就需要将子节点先更新到最新的版本,这样才能保证查询的正确性
pushdown(u);
int mid = tr[u].l + tr[u].r >> 1;
LL res = 0;
if (l <= mid) res += query(u << 1, l, r);
if (r > mid) res += query(u << 1 | 1, l, r);
return res;
}
}
void modify(int u, int l, int r, LL x) //表示将区间l到r之间的所有数都加上x
{
if (l <= tr[u].l && r >= tr[u].r)
{
tr[u].maxv += x;
tr[u].add += x;
tr[u].sum += x * (tr[u].r - tr[u].l + 1);
}
else
{
//同样的思想了,如果需要分裂当前的区间,就得先下传懒标记
pushdown(u);
int mid = tr[u].l + tr[u].r >> 1;
if (l <= mid) modify(u << 1, l, r , x);
if (r > mid) modify(u << 1 | 1, l, r ,x);
//最后再利用更新好的子节点的信息更新父节点的信息
pushup(u);
}
}
int main()
{
scanf("%d%d", &n , &q);
for (int i = 1; i <= n; i++) scanf("%lld", &a[i]);
build(1, 1, n);
//假设有q次操作,每行代表一次
//如果起始字符为'C',表示修改操作,再读入l , r , x
//如果其实数字为'Q',表示查询操作,读入l~r,输出其中的所有数的和
while (q--)
{
char op[2]; int l, r;
scanf("%s%d%d", &op, &l, &r);
if (*op == 'C')
{
LL x;
scanf("%lld", &x);
modify(1, l, r, x);
}
else
{
LL res = query(1, l, r);
printf("%lld\n", res);
}
}
return 0;
}
本篇博客到这里就差不多结束了,真的写了很久,差不多5000字,如果对各位读者有帮助的话,求大家点点赞啦。博主还会持续更新有关OI和ACM的更多算法的详解,线段树接下来还会更新进阶难度的扫描线和动态开点,欢迎关注~~