目录
线段树的使用:
1.线段树的建立:
线段树的思想:
线段树是一种能维护区间性质的数据结构,最典型的应用就是维护区间最值与区间连续和。其功能与树状数组(不懂树状数组的可以先看我的树状数组的博客:高级数据结构:树状数组,懂了树状数组但是不知道树状数组的一些例如"区间修改,区间查询"等操作的也可看看)相比略有重合,但线段树的适用范围要更大,即能用树状数组解决的题一定能用线段树解决(除非是卡时间)。线段树的每个结点都对应着一个区间(或者叫线段,这也是线段树名称的由来),维护的值为该区间内的最优解,而得到全局最优解的核心思想实际上是分治,一个能用分治解决的问题一般就能用线段树来维护。
线段树一般能维护区间最值与区间和,下面举例说明线段树是如何维护这些信息的:
对如下一组数据,询问其区间的最小值与区间和:
2 | 4 | 6 | 8 | 10 | 3 | 5 | 7 |
线段树的每个结点对应一段区间,它的左儿子对应区间[begin , mid],右儿子对应区间[mid+1 ,end](mid=(begin + end)/2),其存储的数据分别为该区间上的最小值于该区间上所有数的和。
我们将其存入线段树中,即可得到下面的树形结构(括号内表示对应的区间):
与树状数组的建立一样,线段树同样可以通过两种方式建立,首先是较方便的一种:直接通过修改操作建树(修改操作见下文),即对每个数据都执行一次修改操作,每次修改时间复杂度为logn,所以这样建树总时间复杂度为O(nlogn)。
for(int i=1; i<=n; ++i){
cin >> x;
modify(1, 1, n, i, x);
}
同样我们能更高效的建立一颗线段树,建树过程可以递归定义,每个结点只操作一次,所以时间复杂度仅为O(n):
const int INF = 0x7fffffff;
const int N = 100005;
//注意线段树的空间要开四倍
int st[4*N]; //存储区间最值
long long sum[4*N];//存储区间和
int num[N];
//建立一颗线段树
void build(int k, int l, int r) {
if(l == r) {
sum[k] = num[l];
st[k] = num[l];
return;
}
int mid = (l + r) >> 1;
build(k << 1, l, mid); //建立左子树
build(k << 1 | 1, mid + 1, r); //建立右子树
sum[k] = sum[k << 1] + sum[k << 1 | 1];
st[k] = min(st[k << 1], st[k << 1 | 1]);
}
关于线段树的空间要开4倍的问题,其实网上有很多探讨。这里简单的提及一下,我们知道线段树的结点数=2n-1,所以很大一部分人下意识的认为数组的下标不会超过结点数,即开2倍的空间就足够了,然而实际上我们在构建线段树时,并不是数组的每个下标都用上了,中间可能存在着没有使用浪费着的空间!例如,考虑这样一颗线段树,深度为h,它的h-1层是满二叉树,最后一层不满,请思考:最后一层的结点一定是按顺序接在h-1层的第一个结点上的吗?—— 很显然只有当最后一层的结点按顺序依次接在h-1层的第1、2···个结点上,此时数组的空间是全部用上了的,数组下标不会超过结点数。然而,最后一层的结点不一定会按顺序接上h-1层,比如最后一层只多出两个结点,而它们接在的是h-1层的最后一个结点上,此时数组的下标按照2k+1计算的话,中间跳过了大量的数组下标,将有大量的空间没有用上!事实上,此时的情况就为线段树空间的最坏情况,而这个下标的范围经计算=4n-5(具体计算可以去网上搜搜,有很多),因此,我们的空间为了应对最坏情况要开出4倍的大小。
2.区间询问:
函数query (k,begin,end,x,y):k为数组下标,(begin,end)为拆分区间,(x,y)为所求区间
注意区间的提取规则,按相同的方式拆分区间,并每次与所求区间相匹配:
1.若所求区间与拆分区间存在交集(但不完全包含),则继续拆分拆分区间;
2.若所求区间完全包含了拆分区间,(不继续递归)则直接返回该结点上的值;
3.区间最值:若所求区间与拆分区间没有交集,则返回一个不影响答案的极大值(求最大值就返回一个极小值);
区间和:若所求区间与拆分区间没有交集,则返回0。
//查询答案:区间最值
int query(int k, int l, int r, int x, int y){
if(y<l || x>r) return INF; //规则3
if(x <= l && y >= r) return st[k]; //规则2
int mid = (l + r) >> 1; //规则1
return min(query(k << 1, l, mid, x, y),query(k << 1 | 1, mid + 1, r, x, y));
}
//查询答案:区间和
long long query(int k, int l, int r, int x, int y){
if(y<l || x>r) return 0;
if(x <= l && y >= r) return sum[k];
int mid = (l + r) >> 1;
long long ans = 0;
ans += query(k << 1, l, mid, x, y);
ans += query(k << 1 | 1, mid + 1, r, x, y);
return ans;
}
如果不理解以上规则,我们再来看看按上述流程是怎么得到最小值的:
假设现在要求[3,4]与[2,8]区间内的最小值(注意整个过程是递归的!要弄清执行顺序):
解释一下流程,以[3,4]为例:首先匹配区间[1,8],所求区间与拆分区间存在交集,执行规则1,继续拆分区间为[1,4]、[5,8]。匹配区间[1,4]所求区间与拆分区间存在交集,执行规则1,继续拆分区间为[1,2]、[3,4];同时匹配区间[5,8],所求区间与拆分区间没有交集,执行规则3,返回一个极大值。匹配区间[1,2],所求区间与拆分区间没有交集,执行规则3,返回一个极大值;同时匹配区间[3,4],所求区间完全包含了拆分区间,执行规则2,返回储存值6。递归结束,开始回溯:[3,4]区间返回值6要小于[1,2]区间返回值0x7fffffff,因此区间[1,4]的返回值为6,小于区间[5,8]的返回值0x7fffffff,于是函数的最终返回值就是6.
其实我们大可不必这么麻烦地分析,因为我们在建树的时候就已经知道且保存了[3,4]区间上的最小值,所以我们很明确结果就是6。不过这里我只是想说明一下整个查询函数的执行过程,对于那种没有直接保存了对应区间的询问(比如区间[2,8]),只要执行同样的分析就能弄懂它的原理了,建议读者自己试着分析下。
3.单点修改:
这个很简单,递归找到要修改的位置,直接修改,然后回溯时重新将不同区间维护的最值更新一下就行了。
//单点修改
void modify(int k, int l, int r, int x, int v){
if(r<x || l>x) return;
if(l == r && l == x){
sum[k] += v;
st[k] += v;
return;
}
int mid = (l + r) >> 1;
modify(k << 1 | 1, mid + 1, r, x, v ); //修改右子区间
modify(k << 1, l, mid, x, v ); //修改左子区间
//更新最值
sum[k] = sum[k << 1] + sum[k << 1 | 1];
st[k] = min( st[k << 1], st[k << 1 | 1] );
}
4.区间修改:
和树状数组一样,我们分"区间修改,单点询问"和"区间修改,区间询问"讨论:
区间修改,单点询问
要进行区间修改,最简单的做法就是直接拆分区间为单点,然后修改,然后向上更新结点值,但是很明显这样做会非常慢,修改一个值的时间复杂度为logn,一次区间修改最坏就是nlogn,执行m次得到最终时间复杂度为O(mnlogn)!这样甚至不如直接暴力修改!
但现在我们换一个思路修改,考虑在单点修改的时候,我们不直接修改结点的值,而是在每个结点上单独维护一个值add,表示该结点要增加的值。这样我们在单点查询时,经过一个区间就将结果加上add值,直到查询到我们要找的单点,此时该单点的值就是该结点的值加上查询过程中累积的add值。这里的标记add事实上就是在线段树中常用到的Lazy懒标记。
过程比较好理解,这里演示一下:
最后的结果就是:8+4-2=10 。
区间修改,区间询问
在学习区间修改,区间询问前,我们先回顾一下已经掌握的两种修改情况:
- 单点修改,区间询问:
操作:递归找到要修改的位置,直接修改,然后回溯时重新将不同区间维护的最值更新。
缺陷:不支持区间修改,原因是单次修改时间复杂度为logn,进行一次区间修改,最坏时间复杂度为nlogn,运行太慢!
- 区间修改,单点询问:
操作:维护一个标记值,在查询时累加答案。
缺陷:不支持区间询问,原因是实际上这个操作完全没有更新维护值,区间查询的答案是不正确的!
分别了解了“单点修改,区间询问”与“区间修改,单点询问”的操作以及缺陷所在后,我们来正式思考如何实现“区间修改,区间询问”。首先,“区间修改,单点询问”维护标记值的操作无疑是很巧妙的,我们仍可以借鉴,所以现在应该考虑的是如何维护结点上的值。父结点是很好解决的,在回溯时更新即可,而对于子结点,却不是很好操作了,因为子结点的数目太多,一个一个去维护时间复杂度必然无法承受,所以现在问题的关键就在于如何维护子结点的信息。
下面给出了两种解决方案:
-
标记下传
标记下传的思想在于,当且仅当我需要用到该结点的时候,我才去更新它的信息,否则不去管它,以此来降低时间复杂度,为此我们在修改或者查询过程中如果遇到标记,就处理标记在本结点上的影响,然后将标记传递到它的子结点上。
//给[l,r]区间内的数+v,add[k]为标记数组
void update(int k, int l, int r, int v){
add[k] += v; //打标记
sum[k] += (long long)(r - l + 1) * v; //维护区间和
st[k] += v; //维护区间最小值
}
//标记下传函数
void pushdown(int k, int l, int r, int mid){
if(add[k] == 0) return; //无标记则不管
update(k << 1, l, mid, add[k]); //下传到左子树
update(k << 1 | 1, mid + 1, r, add[k]);//下传到右子树
add[k] = 0; //清空当前标记
}
//区间修改
void modify(int k, int l, int r, int x, int y, int v){
if(y < l || x > r) return;
if(l >= x && r<= y) return update(k, l, r, v);//当前区间被完全覆盖,就打上标记
int mid = (l + r) >> 1;
pushdown(k, l, r, mid); //多次修改时,遇到之前的标记就将其下传
modify(k << 1, l, mid, x, y, v); //修改左子树
modify(k << 1 | 1, mid + 1, r, x, y, v);//修改右子树
//注意维护当前结点值
sum[k] = sum[k << 1] + sum[k << 1 | 1];
}
//区间查询:区间和
long long query(int k, int l, int r, int x, int y) {
if (y < l || x > r) return 0;
if (l >= x && r <= y) return sum[k];
int mid = (l + r) >> 1;
pushdown(k, l, r, mid); //遇到标记就将其下传
long long ans = 0;
ans += query(k << 1, l, mid, x, y);
ans += query(k << 1 | 1, mid + 1, r, x, y);
return ans;
}
//区间查询:区间最值
int query(int k, int l, int r, int x, int y){
if(y<l || x>r) return INF;
if (l >= x && r <= y) return st[k];
int mid = (l + r) >> 1;
pushdown(k, l, r, mid); //遇到标记就将其下传
return min(query(k << 1, l, mid, x, y),query(k << 1 | 1, mid + 1, r, x, y));
}
- 标记永久化
标记永久化不移动标记的位置,而是在修改操作时对每个结点都维护好子结点对其的影响,在查询操作时维护每个遇到的标记对当前询问的影响。(稍微说一下,标记永久化似乎不好维护区间最值,具体来说是不好维护子结点的影响,区间和能部分增加,但是区间最值则不能。但是或许只是因为我水平的原因实现不了,要是有哪位大佬来打脸,我会修正)
void modify(int k, int l, int r, int x, int y, int v){
if(y < l || x > r) return;
if(l >= x && r <= y) {
add[k] += v;
return;
}
sum[k] += (long long)(min(r, y) - max(l, x) + 1) * v; //维护子结点的影响
int mid = (l + r) >> 1;
modify( k << 1, l, mid, x, y, v );
modify((k << 1) + 1, mid + 1, r, x, y, v);
}
long long query(int k,int l, int r, int x, int y){
if(y < l || x > r) return 0;
//需要维护本结点上的标记的影响
if(l >= x && r <= y) return sum[k] + (long long)(r - l + 1) * add[k];
int mid = (l + r) >> 1;
//父结点的标记也要累加进来
long long ans = (long long)(min(r, y) - max(l, x) + 1) * add[k];
ans += query(k << 1 ,l ,mid ,x ,y);
ans += query((k << 1) +1 ,mid + 1 ,r ,x ,y);
return ans;
}
例题及其补充:
1.区间和(https://ac.nowcoder.com/acm/contest/967/A)
线段树维护区间和的模板题,可以来检测一下自己模板的正确性(也可以用树状数组来做)。
2.最大数(https://ac.nowcoder.com/acm/contest/967/C)
线段树维护区间最值的模板,同样可以来检测一下模板正确性。
3.A Simple Problem with Intergers(https://ac.nowcoder.com/acm/contest/967/B)
"区间修改,区间查询"的模板题,也是套模板就行了,至于用哪种方法可以自己选择(也能用树状数组来做,属于树状数组的扩展应用,详情参见我的树状数组博客,链接在本博客开头)。
4.维护序列(https://ac.nowcoder.com/acm/contest/967/E)
本题在区间修改上加大了难度,增加了区间乘操作。但本题的修改操作仍然是容易实现的:sum[k]*=v 就能维护区间乘了,没有特别困难的地方。