线段树是什么?
有一类区间问题可以抽象成如下模型。
给定包含 n n n 个数的数组 a 1 , a 2 , ⋯ a_1, a_2, \cdots a1,a2,⋯。有两种操作
查询区间 [ l , r ] [l, r] [l,r] 最小的数。
修改第 a i a_i ai为 x x x。
这里,为了解决这个问题,我们介绍一种灵活的数据结构——线段树。
我们用一棵二叉树来表示线段树,线段树中的每个结点都表示一个区间。每个非叶子结点都有左右两棵子树,分别对应区间的 “左半” 和 “右半”。为了方便起见,我们给根结点编号为 1 1 1。对于每个结点,其左结点的编号为 2 i 2i 2i,其右结点的编号为 2 i + 1 2i+1 2i+1。
对于一个结点,如果其表示的区间为 [ l , r ] [l, r] [l,r]。分情况如果 l = r l = r l=r,那么这个是一个叶子结点。否则令 m i d = ⌊ l + r 2 ⌋ mid = \lfloor \frac{l+r}{2} \rfloor mid=⌊2l+r⌋,左儿子对应的区间为 [ l , m i d ] [l, mid] [l,mid],右儿子对应的区间为 [ m i d + 1 , r ] [mid + 1, r] [mid+1,r],这一思想有点类似二分。下面就是 n = 10 n = 10 n=10 的时候的线段树。
↑点击图片观看
假定根结点表示长度为 2 h 2^h 2h的区间,不难发现,树的第 i i i 层有 2 i 2^i 2i个结点,每个结点对应一个长度为 2 h − i 2^{h-i} 2h−i的区间。最大层的编号为 h h h,结点总数为 1 + 2 + 4 + 8 + ⋯ + 2 h = 2 h + 1 − 1 1+2+4+8+\cdots + 2^h=2^{h+1}-1 1+2+4+8+⋯+2h=2h+1−1,略小于区间长度的两倍。而当整个区间长度不是 2 2 2 的整数幂时,虽然叶子结点不在同一层,但树的最大层编号和结点总数仍满足上述结论。
有一个需要特别注意的地方,虽然线段树中总的结点数是区间长度的两倍,但是实际上,我们结点的编号不一定是连续的,所以需要开更多的内存。即 4 4 4 倍
为什么是4倍,我们来大致的理解一下
首先,如果区间长度为2的正整数幂,我们会发现这棵树是一棵满二叉树,他的节点个数为
2
n
−
1
2n-1
2n−1
但是当我们的n为2的幂数+1时,我们的树就成了这样:
我们发现层数直接多了一层,这样一来我们就需要31个节点,但是这一层的使用率却很低,现在我们来想想到底应该开多大(比较严谨下)
首先,若n为2的整数次幂,最后一层空间可以刚好用完,只需 2 n − 1 2n-1 2n−1个节点
但是,当n不为2的整数次幂,我们会凭空多出一层,对于这一层,如果我们全部利用上,不就相当又一个满二叉树了嘛,那么这时候对应的的n也就是最小的且大于之前n的2的整数次幂1
而这个数一定小于等于 2 n 2n 2n,所以我们把n乘个2再开空间一定没问题
那就是2n了吗?
别急,现在我们假设2n已经是2的整数次幂了,那么节点数为 2 ( 2 n ) − 1 = 4 n − 1 2(2n)-1=4n-1 2(2n)−1=4n−1
所以我们开4倍空间一定够啦
注意,4倍空间是线段树一个很重要的特点,在程序阅读题中看到4倍空间就要想到线段树,在用线段树做题的时候也一定要记住
OK,让我们来看一些基本操作
建立
再使用之前,我们先要对线段树建立,得到一个初始的树,这是我们才可以对他进行各种操作
前面构建的线段树,只是展示了线段树中各结点所对应的区间,但是对于用到线段树的大部分题目来说,这些线段所拥有的附加信息才是重头戏。比如要维护区间最小值问题,我们用一个额外的数组minv记录每个结点对应的区间的最小值。
对于叶子结点,最小值就是一个数。而对于非叶子结点,区间的最小值就是左儿子的最小值和右儿子最小值中的最小值。
比如 n = 10 n = 10 n=10, a = 1 , 3 , 5 , 7 , 9 , 10 , 2 , 4 , 8 , 6 a = 1, 3, 5, 7, 9, 10, 2, 4, 8, 6 a=1,3,5,7,9,10,2,4,8,6 的时候,对应的线段树如下
↑点击图片观看
可以发现这个构建过程是一个递归的过程,父节点的信息需要用子节点去更新,所以我们需要先递归的构建好左右子树。见下面代码。
void build(int id,int l,int r){
if(l==r){
minv[id]=a[l];
return;
}
int mid=(l+r)>>1;
build(id<<1,l,mid);
build(id<<1|1,mid+1,r);
minv[id]=min(minv[id<<1],minv[id<<1|1]);
}
所以,整个建树的过程,时间复杂度是 O ( n ) \mathcal{O}(n) O(n)。
单点更新
接着我们看如何进行单点更新。如果是把整棵树推倒了全部重建,那未免也太不划算了。仔细分析,一个点的修改,只会影响到包含这个点的区间,而包含这个点的区间在树上实际上是一条链,比如我们更新 a 6 = 1 a_6 = 1 a6=1,那么我们更新的方式如下
↑点击图片观看
也就是先递归找到这个单点对应的子节点,然后在回溯的过程中顺带更新一波,还是比较好理解
一般,我们我们可以认为线段树的最大深度为 log n \log n logn,所以这条链的最大长度也是 log n \log n logn,所以一次更新时间复杂度是 O ( log n ) \mathcal{O}(\log n) O(logn)。
代码如下:
void update(int id,int l,int r,int x,int v){
if(l==r){
minv[id]=v;
return;
}
int mid=(l+r)>>1;
if(x<=mid){
update(id<<1,l,mid,x,v);
}
else{
update(id<<1|1,mid+1,r,x,v);
}
minv[id]=min(minv[id<<1],minv[id<<1|1]);
}
区间查询
对于查询的区间 [ x , y ] [x, y] [x,y] 我们可以划分为线段树上的结点,这些结点的区间合并起来就可以得到所需信息。
比如查询区间 [ 3 , 6 ] [3, 6] [3,6] 就是由下面这些红点合并起来的,其中绿点表示与被查询的区间 [ x , y ] [x, y] [x,y] 有交集的结点。
↑点击图片观看
我们发现,查询到一个红色的结点,可以直接返回,因为此时,区间 [ x , y ] [x, y] [x,y] 是完全覆盖红点所在的区间的,而一个绿色的结点我们还需要继续递归;
不过我们可以发现,每一层最多只会有两个绿点,最左边一个和最右边一个,这两个绿点中间的点都能被区间 [ x , y ] [x, y] [x,y] 完全覆盖,所以最多只会有 2 log n 2\log n 2logn 个绿点,所以一次区间查询的复杂度也是 log n \log n logn。
代码:
int query(int id,int l,int r,int x,int y){
if(x<=l && r<=y){//如果完全包含,则直接返回
return minv[id];
}
int mid=(l+r)>>1;
int ans=inf;
if(x<=mid){
ans=min(ans,query(id<<1,l,mid,x,y));
}
if(y>mid){//注意这里不是else if,因为我们有可能两边都要往下走
ans=min(ans,query(id<<1|1,mid+1,r,x,y));
}
return ans;
}
eg最小的且大于3的2的整数次幂是4
最小的且大于9的2的整数次幂是16 ↩︎