线段树
线段树的基本结构:
- 线段树中的每个节点都代表一个区间(可以理解为线段),每个节点维护的是父亲的区间二等分后的其中一个子区间
- 线段树具有唯一的根节点,根节点维护的是整个区间,代表的区间是整个统计范围,比如有序列a[1]~a[N],那么根节点所代表的区间就是[1,N]。
- 线段树的每个叶子节点都代表一个长度为1的元区间[x,x]
- 对于 每个内部节点[l,r],它的左子节点是[l,mid],右子节点是[mid+1,r],其中mid=(l+r)/2向下取整
- 当有n个元素时,对区间的操作可以在 O ( l o g n ) O(logn) O(logn)的时间内完成,空间复杂度为 O ( n ) O(n) O(n)。
线段树的基本用途:
- 维护区间信息
- 合并区间信息
- 对序列进行维护,支持查询和修改操作
对于一棵线段树来说,除去树的最后一层,整棵线段树一定是一棵完全二叉树,树的深度为 O ( l o g n ) O(logn) O(logn)。因此,我们可以按照与二叉堆类似的 "父子2倍"节点编号方法:
- 根节点编号为1
- 编号为x的节点的左子节点编号为u*2,右子节点编号 u × 2 + 1 u\times 2+1 u×2+1
我们可以用一个结构体数组来保存线段树。树的最后一层节点在数组中保存的位置是不连续的,直接空出数组中多余的位置即可。在理想的情况下, n n n个叶子节点的满二叉树有2n-1。为什么呢?由二叉树的性质可知, N = n 0 + n 1 + n 2 N=n_0+n_1+n_2 N=n0+n1+n2,再由 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1,因为是满二叉树,没有度为1的节点,所以 n 1 = 0 n_1=0 n1=0,因此 N = n 0 + n 0 − 1 = 2 n 0 − 1 N=n_0+n_0-1=2n_0-1 N=n0+n0−1=2n0−1。如果在上述存储方式下,最后一层产生了空余,由于倒数第二层是满的,设为n个节点,那么最坏情况下,每个节点会引出两个孩子节点,因此最后一层可能会有2n个节点。因此,总体有2n-1+2n=4n-1个节点。所以,保存线段树的结构体数组长度要不小于4N才能保证不会越界。
线段树和RMQ算法的区别
常用解决RMQ问题有ST算法,二者预处理时间都是O(NlogN),而且ST算法的单次查询操作是O(1),看起来比线段树好多了,但二者的区别在于线段树支持在线更新值,而ST算法不支持在线操作。这里也存在一个误区,刚学线段树的时候就以为线段树和树状数组差不多,用来处理RMQ问题和求和问题,但其实线段树的功能远远不止这些。不要带着线段树只是为了解决区间问题的数据结构。事实上,是线段树多用于解决区间问题,并不是线段树只能解决区间问题。首先,我们得先明白几件事情:每个结点存什么,结点下标是什么,如何建树。
下面以一个简单的区间最大值来阐述上面的三个概念。
对于A[1:6] = {1,8,6,4,3,5}来说,线段树如上所示,红色代表每个结点存储的区间,蓝色代表该区间最值。可以发现,每个叶子结点的值就是数组的值,每个非叶子结点的度都为二,且左右两个孩子分别存储父亲一半的区间。每个父亲的存储的值也就是两个孩子存储的值的最大值。
对于一个区间[l,r]来说,最重要的数据当然就是区间的左右端点l和r,但是大部分的情况我们并不会去存储这两个数值,而是通过递归的传参方式进行传递。这种方式用指针好实现,定义两个左右子树递归即可,但是指针表示过于繁琐,而且不方便各种操作,大部分的线段树都是使用数组进行表示,那这里怎么快速使用下标找到左右子树呢。
对于上述线段树,我们增加绿色数字为每个结点的下标。
则每个结点下标如上所示,这里你可能会问,为什么最下一排的下标直接从9跳到了12,道理也很简单,中间其实是有两个空间的呀!!虽然没有使用,但是他已经开了两个空间,这也是为什么无优化的线段树建树需要 2 ∗ 2 k ( 2 k − 1 < n < 2 k ) 2*2k(2k-1 < n < 2k) 2∗2k(2k−1<n<2k)空间,一般会开到4*n的空间防止RE。
仔细观察每个父亲和孩子下标的关系,有发现什么联系吗?不难发现,每个左子树的下标都是偶数,右子树的下标都是奇数且为左子树下标+1,而且不难发现以下规律:
- l = fa*2 (左子树下标为父亲下标的两倍)
- r = fa*2+1(右子树下标为父亲下标的两倍+1)
具体证明也很简单,把线段树看成一个完全二叉树(空结点也当作使用)对于任意一个结点k来说,它所在此二叉树的 l o g 2 k log_2k log2k 层,则此层共有 2 l o g 2 k 2log_2k 2log2k个结点,同样对于k的左子树那层来说有 2 l o g 2 k + 1 2log_2k+1 2log2k+1个结点,则结点k和左子树间隔了 2 ∗ 2 l o g 2 k − k + 2 ∗ ( k − 2 l o g 2 k ) 2*2log_2k-k + 2*(k-2log_2k) 2∗2log2k−k+2∗(k−2log2k)个结点,然后这就很简单就得到 k + 2 ∗ 2 l o g 2 k − k + 2 ∗ ( k − 2 l o g 2 k ) = 2 ∗ k k+2*2log_2k-k + 2*(k-2log_2k) = 2*k k+2∗2log2k−k+2∗(k−2log2k)=2∗k的关系了吧,右子树也就等于左子树结点+1。
是不是觉得其实很简单,而且因为左子树都是偶数,所以我们常用位运算来寻找左右子树:
- k<<1(结点k的左子树下标)
- k<<1|1(结点k的右子树下标)
线段树的建树
struct SegmentTree{
int l,r;
int val;
}tr[4*N];
//更新函数,这里是实现最大值 ,同理可以变成,最小值,区间和等
void pushup(int u)
{
tr[u].val=max(tr[u*2].val,tr[u*2+1].val);
}
//u为当前需要建立的结点,l为当前需要建立区间的左端点,r则为右端点
void build(int u,int l,int r)//递归方式建树 build(1,1,n);
{
tr[u].l=l;
tr[u].r=r;
//左端点等于右端点,即为叶子节点,直接赋值即可
if(l==r)
{
tr[u].val=a[l]; //此时l==r,也可以写成tr[u].val=a[r];
return; //回溯
}
int mid=(l+r)/2; //mid则为中间点,左儿子的结点区间为[l,mid],右儿子的结点区间为[mid+1,r]
build(u*2,l,mid); //递归构造左儿子结点
build(u*2+1,mid+1,r);//递归构造右儿子结点
//一般build后面都要跟着pushup,具体要看题意
pushup(u);//更新父节点 从下往上传递信息
}
线段树的单点修改
如何实现单点修改,我们先不急看代码,还是对于上面那个线段树,假使我把a[3]+7,则更新后的线段树应该变成:
更新了a[3]后,则每个包含此值的结点都需要更新,那么有多少个结点需要更新呢?根据二叉树的性质,不难发现是log(k)个结点,这也正是为什么每次更新的时间复杂度为O(logN)。
单点修改时一条形如:"C x v"的指令,表示把A[x]的值修改为v。在线段树中,根节点(编号为1的节点)是执行各种指令的入口。我们需要从根节点出发,递归找到代表区间[x,x]的叶子节点,然后从下往上更新[x,x]以及它的所有祖先节点上保存的数据信息。
//更新函数,这里是实现最大值 ,同理可以变成,最小值,区间和等
void pushup(int u)
{
tr[u].val=max(tr[u*2].val,tr[u*2+1].val);
}
//递归方式更新
void modify(int u,int x,int v) //u是当前节点,x是我们想要修改的节点的下标,v是修改的值
{
//左端点等于右端点,即为叶子结点,直接修改为v即可
if(tr[u].l==tr[u].r)
{
tr[u].val=v;
return; //回溯
}
//mid则为中间点,左儿子的结点区间为[l,mid],右儿子的结点区间为[mid+1,r]
int mid=(tr[u].l+tr[u].r)/2;
//如果需要更新的结点x在左子树区间
if(x<=mid)
modify(u*2,x,v);
//如果需要更新的结点在右子树区间
else
modify(u*2,x,v);
//从下往上更新信息
pushup(u);
}
线段树的区间查询
我们知道线段树的每个结点存储的都是一段区间的信息 ,如果我们刚好要查询这个区间,那么则直接返回这个结点的信息即可,比如对于上面线段树,如果我直接查询[1,6]这个区间的最值,那么直接返回根节点信息返回13即可,但是一般我们不会凑巧刚好查询那些区间,比如现在我要查询[2,5]区间的最值,这时候该怎么办呢,我们来看看哪些区间是[2,5]的真子集:
一共有5个区间,而且我们可以发现[4,5]这个区间已经包含了两个子树的信息,所以我们需要查询的区间只有三个,分别是[2,2],[3,3],[4,5],到这里你能通过更新的思路想出来查询的思路吗? 我们还是从根节点开始往下递归,如果当前结点是要查询的区间的真子集,则返回这个结点的信息且不需要再往下递归了,这样从根节点往下递归,时间复杂度也是O(logN)。
区间查询时一条形如"Q l r"的指令,例如查询序列A在区间[L,R]上的最大值,即 m a x L ≤ i ≤ R ( A [ i ] ) max_L\leq i\leq R(A[i]) maxL≤i≤R(A[i])。我们只需要从根节点出发,递归执行以下过程:
- 若待查询区间[L,R]已经完全覆盖了当前节点所代表的区间,即当前树中的这个节点所代表的区间[ t r [ u ] . l tr[u].l tr[u].l, t r [ u ] . r tr[u].r tr[u].r] ⊆ [ L , R ] \subseteq [L,R] ⊆[L,R],则立即回溯,并且该节点的val值为候选答案。
- 若左子节点所代表的区间与待查询区间[L,R]有重叠部分,则递归访问左子节点。
- 若右子节点所代表的区间与待查询区间[L,R]有重叠部分,则递归访问右子节点。
递归方式区间查询区间[L,R]
int query(int u,int L,int R)//[L,R]即为要查询的区间,u是当前节点
{
//如果当前结点的区间真包含于要查询的区间内,则返回结点信息且不需要往下递归
if(L<=tr[u&].l&tr[u].r<=R)
return tr[u].val;
int ans=-INF; //返回值变量,根据具体线段树查询的什么而自定义
int mid=(tr[u].l+tr[u].r)/2; //mid则为中间点,左儿子的结点区间为[l,mid],右儿子的结点区间为[mid+1,r]
//如果左子树和需要查询的区间交集非空,即左子节点和需要查询的区间有重叠
if(L<=mid)
ans=max(ans,query(u*2,L,R));
//如果右子树和需要查询的区间交集非空,即右子节点和需要查询的区间有重叠
if(r>mid)
ans=max(ans,query(u*2+1,L,R));
return res; //返回当前结点得到的信息
}
线段树的区间修改
区间更新是指更新某个区间内的叶子节点的值,因为涉及到的叶子节点不止一个,而叶子节点会影响其非叶的父节点,那么回溯需要更新的非叶节点也会有很多,如果一次性更新完,操作的时间复杂度肯定不止 O (logn),例如当我们更新区间 [1,7] 内的值,就需要更新下图所示标红的所有节点:
为此引入线段树的延迟标记概念,也叫 lazy tag,这个标记一般用于处理线段树的区间更新。
延迟标记:节点结构体中新增一个标记,记录这个节点是否会进行某种修改,对于任意区间的修改,我们先按照区间查询的方式将其划分成线段树中的节点,然后修改这些节点的信息,并给这些节点打上标记。在修改和查询的时候,如果我们到了一个节点 P,并且要继续查看其子节点,那么我们就要看看节点 P 是否被标记,如果有,则需要按照其标记首先修改子节点的信息,并且给子节点都打上相同的标记,同时取消节点 P 的标记,这一操作称为标记下放,也叫 pushdown。举个栗子:
假设爷爷要给两个孙女压岁钱,所以爷爷就先把总的压岁钱给自己的儿子(女儿的爸爸),让儿子(女儿的爸爸)给女儿,但是儿子觉得自己的女儿还太小了,暂时用不到,于是就先保存着。突然有一天爷爷准备要询问孙女拿到压岁钱了没有,此时爸爸着急了,就赶紧把压岁钱给了女儿。
具体在modify函数中的操作就是,如果当前更新的区间为 [l,r],我走到节点 P 对应的区间是 [ t r [ u ] . l , t r [ u ] . r ] [tr[u].l,tr[u].r] [tr[u].l,tr[u].r],如果 [ t r [ u ] . l , t r [ u ] . r ] ⊆ [ l , r ] [tr[u].l,tr[u].r]\subseteq [l,r] [tr[u].l,tr[u].r]⊆[l,r],那就先更新当前节点 P,然后给 P 打上标记,P 的子节点就不管了,直接 return,如果以后进行查询或者更新操作的时候,发现当前节点有标记,才将标记下放。除了 pushdown,还需要 pushup,pushdown 的作用是将标记下放,而 pushup 的作用是从下往上更新根节点的信息,因为子节点值改变了,根节点也会变,所以必须要更新根节点的信息。
线段树在进行区间更新的时候,为了提高更新的效率,所以每次更新,只会更新到待更新区间 [ l , r ] [l,r] [l,r]完全覆盖线段树结点区间 [ t r [ u ] . l , t r [ u ] . r ] [tr[u].l,tr[u].r] [tr[u].l,tr[u].r]就停止了,这样就会导致被更新结点的子孙结点的区间得不到需要更新的信息,所以在被更新结点上打上一个标记,称为lazy-tag,等到下次访问这个结点的子结点时再将这个标记传递给子结点,所以也可以叫延迟标记。
也就是说递归更新的过程,更新到结点区间为需要更新的区间的真子集不再往下更新,下次若是遇到需要用这下面的结点的信息,再去更新这些结点,所以这样的话使得区间更新的操作和区间查询类似,复杂度为O(logN)。换言之,我们在执行 区间修改指令时,设节点p所维护的区间为 [ p l , p r ] [p_l,p_r] [pl,pr],设待查询区间为 [ L , R ] [L,R] [L,R],同样可以在 L ≤ p l ≤ p r ≤ R L\leq p_l\leq p_r\leq R L≤pl≤pr≤R的情况下就立即返回,只不过在回溯之前要想该节点p增加一个标记,该标记的含义是:该节点曾经被修改过,但是它的子节点还没有得到更新。即延迟标记标识的是子节点等待更新的情况。因此一个节点被打上"延迟标记"的同时,它自身保存的信息应该已经被修改完毕了。如果在后续的指令中,需要从节点p想向下递归,我们需要先再检查节点p是否有了标记,若有标记,就根据标记信息更新p的两个子节点,同时把p的标记下放给它的两个子节点,然后清空p的标记(可以理解为爸爸钱给女儿后,爸爸就没有存款了)。
也就是说, 除了在修改指令中直接划分成的 O ( l o g N ) O(logN) O(logN)个节点之外,对任意节点的修改都延迟到“在后续操作中递归进入它的父节点时”再执行。这样一来,每条查询或修改指令的时间复杂度就都降低都了 O ( l o g N ) O(logN) O(logN)。
struct NOde{
int l,r;
int val;
int tag;
}tr[4*N];
//父节点把标记下放给左右节点
void pushdown(int u)
{
Node &root=tr[u],&left,&right; //root是父节点,left是左子节点,right是右子节点
//如果父节点有标记 (可以理解爷爷把给孙女的压岁钱,给爸爸先存放了)
if(root.tag!=0)
{
left.tag+=root.tag;//更新左子树的tag值 即爸爸把压岁钱下放给了女儿
right.tag+=root.tag;//更新右子树的tag值 即爸爸把压岁钱下放给了女儿
left.val+=root.tag;//左子树的最值加上tag值 即女儿的压岁钱又多了,多的这部分压岁钱来源于父亲给的tag
right.val+=root.tag;//右子树的最值加上tag值 即女儿的压岁钱又多了,多的这部分压岁钱来源于父亲给的tag
root.tag=0; //爸爸把钱给女儿后,自己就没有存款了
}
}
//递归更新区间 u是父节点,更新区间为[L,R],更新值为v
void modify(int u,int L,int R,int v)
{
//如果当前结点的区间真包含于要更新的区间内
if(L<=tr[u].l&&tr[u].r<=R)
{
tr[u].tag+=v; //给当前u节点打上标记
tr[u].val+=v; //最大值加上v之后,此区间的最大值也肯定是加v
return;
}
//重难点,查询tag标记,更新子树
pushdown(u); //下放延迟标记
int mid=(tr[u].l+tr[u].r)2;
//如果左子树和需要更新的区间交集非空
if(L<=mid)
modify(u*2,L,R,v);
//如果右子树和需要更新的区间交集非空
if(R>mid)
modify(u*2+1,L,R,v);
//更新父节点
pushup(u);
}
//递归方式区间查询
int query(int u,int L,int R)
{
if(L<=tr[u].l&&tr[u].r<=R)
return tr[u];
//爷爷开始来询问爸爸有没有把钱给女儿了,那么爸爸才想起来,所以爸爸要把钱给女儿了
pushdown(u);/**每次都需要更新子树的tag标记*/
int res = -INF; //返回值变量,根据具体线段树查询的什么而自定义
//mid则为中间点,左儿子的结点区间为[l,mid],右儿子的结点区间为[mid+1,r]
int mid=(tr[u].l+tr[u].r)/2;
//如果左子树和需要查询的区间交集非空,说明有部分信息在左子树中,递归查询左孩子区间
if(L<=mid)
res=max(res,query(u*2,L,R));
//如果右子树和需要查询的区间交集非空,说明有部分信息在右子树中,递归查询右孩子区间
if(R>mid)
res=max(res,query(u*2+1,L,R));
return res;//返回当前结点得到的信息
}
注意看pushdown这个函数,也就是当需要查询某个结点的子树时,需要用到这个函数,函数功能就是更新子树的tag值,可以理解为平时先把事情放着,等到哪天要检查的时候,就临时再去做,而且做也不是一次性做完,检查哪一部分它就只做这一部分。值得注意的是,使用了Lazy_tag后,我们再进行区间查询也需要改变。
注意点
"区间内的最大值"和"区间的累加和"的query函数形式是一样的,设为A;"区间内的最大公约数"和"最大连续子段和"的qeury函数形式是一样的,设为B。但是A和B的形式有所不同
对于A类,与B类的不同点
int query(int u,int L,int R)
{
//此处省略
//
//
int mid=(tr[u].l+tr[u].r)/2;
if(L<=mid)//如果左子树和需要查询的区间交集非空,说明有部分信息在左子树中,递归查询左孩子区间
//递归进入左子树
if(R>mid)//如果右子树和需要查询的区间交集非空,说明有部分信息在右子树中,递归查询右孩子区间
//递归进入右子树
}
对于B类,与A类的不同点
Node query(int u,int L,int R)
{
//此处省略
//
//
int mid=(tr[u].l+tr[u].r)/2;
//如果待查询区间完全在节点所维护区间的左区间
if(R<=mid)
return query(u*2,L,R);
//如果待查询区间完全在节点所维护区间的右区间
else if(L>mid)
return query(u*2+1,L,R);
//如果待查询区间横跨在节点所维护区间的左右区间
else
{
Node left=query(u*2,L,R); //左子节点
Node right=query(u*2+1,L,R); //右子节点
Node res; //res是left和right的父节点
pushup(res,left,right);
return res;
}
}
为什么会有这种写法的不同呢?
其实,这是根据问题的性质来决定的。其实A类和B类的最大不同在于 是否考虑终点对最终问题的影响。对于"区间的最大公约数"和"最大连续子段和",如果我们想要查询的区间是横跨左右子树的,那么我们就要分别去左右子树查询,然后再把左右子树的结果合并送给父节点。如果我们向A类一样写的话,return max(ask(p1,l,mid), ask(p2,mid+1,r)) 假设这个式子会返回区间[L,R]中的最大连续子段和,那么这句话的实际含义是只比较了p1的最大子段和 和 p2的最大子段和,漏掉了跨过mid的最大子段和。return max(ask(p1,l,mid), ask(p2,mid+1,r)) 假设这个式子会返回区间[L,R]中的最大公约数,那么这句话的实际含义是只比较了p1的最大公约数和p2的最大公约数,漏掉了跨过mid的最大公约数。
但是对于A类来说,即便我们把查询区间[L,R]拆成了两部分,return max(ask(p1,l,mid), ask(p2,mid+1,r)),假设这个式子可以返回区间[L,R]中的最大值,那么这句话的实际含义是比较了p1的最大值和p2的最大值,即便忽略了跨过mid的最大值也不要紧,因为左右两边的最大值就已经可以确定整个区间[L,R]的最大值了,即使你加入了跨过mid的这种情况下的区间,那么假设最大值是左侧区间[L,mid],那么加入了跨过mid的这种情况下的区间[L,mid+t],那最大值不还是由左侧区间决定嘛。对于查询区间[L,R]内的累加和也是同样的道理。
单点修改函数和区间修改函数也是有不同点的:
单点修改函数:
void modify(int u,int x,int v)
{
if(tr[u].l==x&&tr[u].r==x)
{
//此处省略
return;
}
int mid=(tr[u].l+tr[u].r)/2;
if(x<=mid)
modify(u*2,x,v);
else
modfify(u*2+1,x,v);
pushup(u);
}
区间修改函数
void modify(int u,int L,int R,int v)
{
if(L<=tr[u].l&&tr[u].r<=R)
{
//此处省略;
return;
}
pushdown(u);
int mid=(tr[u].l+tr[u].r)/2;
if(L<=mid)
modify(u*2,L,R,v);
if(R>mid)
modify(u*2+1,L,R,v);
pushup(u);
}