线段树
首先,如果你在这之前不知道线段树的话,那么希望我的文章对你有帮助;
如果您已经会了,那么请移步,这里只介绍线段树最基本的概念;
引入
~先来看引入:
给定一个整数序列,每次操作会修改序列上某个位置的数,或者是询问序列中某个区间内的所有数和。
如果我们采取暴力的话,那么单点修改的复杂度为O(1);
但是询问区间和的单次复杂度为O(n)(假设区间长度为n的话)
如果我们用前缀和的话,会发现询问区间和以及单点修改的复杂度与暴力相反;
也就是说,假设我们询问次数为m的话
那么总共的复杂度就是O(mn)而n和m一般是10^5级别的;
所以两种算法都失效;
上述这个例子同时也是树状数组的引例(树状能干的线段树基本能干)
~再来看一个例子
给定一个长度为n的序列ai,有m次操作,
操作一共分为3种:
操作1.把某个数加上x;
操作2.询问一段区间中的最大/小值。
操作3.询问一段区间的和。
数据范围:1<=n,m<=200000,|ai|<=10的9次方,|x|<= 10的9次方 ,时限1s
对于最大/最小值,很多人可能会想到树状数组+差分(如果不知道树状数组也没关系)
但是对于这题来说已经不适用了,怎么办呢?
概念
那么我们进入正题,一个更通用的数据结构——线段树!
线段树其实是一颗二叉树,树上每个节点对应序列上的一段区间;
它类似于树状数组,也是利用分治的思想,把区间操作转移到二叉树上进行的一种数据结构。
它和树状数组有共同点,也有不同之处。
每个节点记录一些必要的信息,比如说区间和、最值。
如上图所示
- 我们会发现假设一个序列长度为n;
- 根节点对应的是整个区间[1,n];
- 假设一个节点所代表的区间为[l,r]
- 当l==r时,很明显这个节点就是叶节点(看图);
- 否则它一定有两个孩子,记mid=(l+r)>>1(就是l+r/2);
- 会发现左孩子代表的区间是(l,mid),而右孩子所代表的为(mid+1,r);
- 另外,我们也可以看出线段树中所有的节点数是n+n/2+n/4+……+1=2n-1(一层层往上数)
- 并且假设线段树高度为h,那么很显然h只有(logn)级别;
- 假设我们维护的序列长度不是如图所示的10,而是2的整数次幂的话,线段树必定是一棵满二叉树;
- 其他情况的话,线段树前h-1层是满二叉树,最后一层有可能不满。(也就是二叉树的性质)
应用
线段树初始化
给定一个序列a0一直到an-1,接下来有m次操作,其中有两种操作,给定i,x将ai修改为x,或是给定l,r求序列[l,r]的最小值(一本通例1)
(注:图不是上面那一张)
我们会发现树上的每一个点维护了它所对应的的区间最小值,我们可以通过递归来得到这棵线段树,用build(k,l,r)来构建区间为(l,r)的线段树
k是这个区间的标号(也就是节点的编号)
如果l==r我们会发现它就是一个叶节点,所以直接把最小值设成自己就可以了
否则,我们新建一个节点,接下来递归得到它的两个孩子(k*2,l,mid)以及(k*2+1,mid+1,r);
而它的最小值其实也就是它的两个孩子的较小的那一个
因为初始化线段树只需要访问所有结点一次,所以它的复杂度与结点个数同阶O(n)
此外要注意线段树的数组要开到4*n而不是2*n,虽然结点个数是2n-1,可实际上最后一层产生了空余,所以保存线段树的数组长度要不超过4n才不会越界(具体可以手画一下)
我们来看一下初始建线段树的具体代码实现
void build(ll k,ll l,ll r) {
if(l==r) {
m[k]=a[l];
return ;
}//如果是叶节点,最小值为本身
ll mid=l+r>>1;
build(k<<1, l, mid);
build(k<<1|1, mid+1, r);
m[k]=min(m[k<<1],m[k<<1|1]);//m[k*2],m[k*2+1]
}
询问区间
接下来是区间的询问
我们会发现较靠近上方的节点对应较大的区间,通过节点就能知道大部分值的最小值;
我们可以通过证明发现,需要访问的节点个数是O(logn)级别的(后续更新)
当一开始访问根节点,接下来的访问节点对应区间与询问的区间,我们会发现它们间的关系仅有三种:
1.当前区间和询问区间完全没有交集,那么我们就完全不需要再次递归了(因为越靠上面区间范围越大,既然已经没有交集,孩子更不可能有交集),除此之外我们可以返回一个不影响结果的最大值;
2.如果当前的询问区间是包含当前区间,那么直接返回当前区间维护好的最小值(之前已经维护无需递归)
3.以上都不是,那么两个孩子递归处理,返回两个结果里的最小值;
以上就是全部思路了,代码实现:
ll query(ll k,ll l,ll r,ll x,ll y) {
if(l>y||r<x) return 2147483647;//没有任何交集,返回一个极大值
if(l>=x&&r<=y) return m[k];//维护最小值,不需要递归
ll mid=l+r>>1;
return min(query(k<<1, l, mid, x, y),query(k<<1|1, mid+1, r, x, y));否则递归两个孩子取min
}
单点修改操作
我们会发现对于一个结点ai如果要修改更新它,那么包含这个i位置的结点的值都需重新计算。
假设我们修改的值是v那么我们可以通过递归来实现这一操作
1.当递归到叶节点时,同上面的思路,叶节点自身修改即可;
2.否则当前最多只有一个孩子包含位置i,继续递归修改,然后将当前结点代表的区间最小值也更新一下
我们会发现因为线段树的层数是O(logn)级别的,所以我们一次区间访问用递归实现的复杂度同样是O(logn);
实现代码:
void change(ll k,ll l,ll r,ll id,ll v) {//id是原序列的位置,v为需要更新的值 if(l>id|| r<id) return ; if(l==r&& l==id) { m[k]=v//访问到叶节点直接修改 return ; } ll mid=l+r>>1; change(k<<1, l, mid, id, v); change(k<<1|1, mid+1, r, id, v); m[k]=min(m[k<<1],m[k<<1|1]);//更新 }
复杂度
我们将以上的三步复杂度整合,因为操作的次数为m次,所以线段树的总复杂度为O(mlogn)
延迟标记
我们会发现,当我们要修改一个区间的时候,如果某个节点被修改区间[L,R]完全覆盖那么以该节点为根的整棵子树的所有节点信息都会发生,如果逐一更新,那么复杂度就会变成O(n),显然无法接受。
我们再换个角度思考,如果我们现在修改了某个区间,点p所代表的区间[pl,pr]被区间[l,r]完全覆盖,逐一更新子树p中的所有节点
可是当后面查询指令的时候全完全没有用到这个子区间[L,R],那么更新整棵子树都是徒劳的。
所以我们可以在回溯之前给p打上一个标记,意思为“该节点信息已被修改完但其子节点仍未更新”,这样的话如果后续的指令,需要p向下递归,我们再判断p是否有标记,如果有的话,就通过这个标记更新p的两个子节点,同时清除p的标记,给两个孩子打标记。
这样一来,每条修改或者查询区间的时间复杂度都降到了O(logN),延迟标记(懒惰标记)使得线段树从上到下传递信息,这对于今后解决问题也提供了一个重要思路。
注:前文提到的标记永久化在二维线段树与可持久化线段树有所应用,超出本篇概念的范围,之后会更新)
那么请看代码如何实现这一操作!
(以区间和为例)
//r(p)p的右孩子 //l(p)左孩子 void spread(int p) { if(add(p))//如果有标记 { sum(p<<1)+=add(p)*(r(p<<1)-l(p<<1)+1); sum(p<<1|1)+=add(p)*(r(p<<1|1)-l(p<<1|1)+1); //更新信息 add(p<<1)+=add(p); add(p<<1|1)+=add(p);//左右孩子打标记 add(p)=0; //清除标记 } }
注意在修改区间的时候记得要给节点打上标记就好啦!
后续
学完以上内容,我们就可以做一些线段树的基础模板题。
比如说LOJ的130树状数组(事实上也可以通过线段树来实现)
POJ的3468,洛谷线段树的模板题,
有了最基础的框架,之后就是要学会运用啦
我将在后面的随笔中继续更新
参考:信息学奥赛一本通,OI wiki,算法竞赛进阶指南
如有错误请指正!
~未完待续。。。