一、定义
线段树(Segment Tree)是一棵完全二叉树。从他的名字可知,树中每个节点都代表一个线段,或者说一个区间。事实上,树的根节点代表整体区间,左右子树分别代表左右子区间。一个典型的线段树如下图所示:
线段树主要有三个性质:
(1)长度范围为[1,L]的一棵线段树的的深度不超过log2(L-1)+1 (根节点深度为0)。
(2)线段树上的节点个数不超过2L个。
(3)线段树把区间上的任意一条线段都分成不超过2log2(L)段。
线段树的结构在这,可是有啥用呢?通常,树上每个节点都维护相应区间的一些性质,如区间最大值,最小值,区间和等,这些维护的信息才是重头戏。
二、操作
(1)创建线段树:
由线段树的结构,很容易构造出树节点的结构,并且写出线段树的递归构造算法。
树节点:
class Node{
int left,right;
Node leftchild;
Node rightchild;
int minval; // 区间的最小值
int maxval; // 区间的最大值
int sum; // 区间和
int delta; // 区间延时标记
Node(int left,int right)
{
this.left=left;
this.right=right;
this.sum=0;
this.delta=0;
leftchild=null;
rightchild=null;
}
}
创建算法:
public void buildSegTree(Node root)
{
if(root.left==root.right){
root.minval=num[root.left];
root.maxval=num[root.left];
root.sum=num[root.left];
return;
}
int mid=root.left+(root.right-root.left)/2;
Node left=new Node(root.left,mid);
Node right=new Node(mid+1,root.right);
root.leftchild=left;
root.rightchild=right;
buildSegTree(root.leftchild);
buildSegTree(root.rightchild);
root.minval=min(root.leftchild.minval,root.rightchild.minval);
root.maxval=max(root.leftchild.maxval,root.rightchild.maxval);
root.sum=root.leftchild.sum+root.rightchild.sum;
}
(2)修改和查询:
对线段树的操作主要有两种,一种是点修改和对应的查询,另一种是区间修改和对应的查询。
(2.1)点修改问题的一个典型例子是RMQ(范围最小值问题),给定有n个元素的数组A1、A2……An,定义以下两操作:
Update(x,v):把Ax修改为V
Query(L,R):计算min{AL,AL+1,……AR}
若只对数组进行线性维护,每次用一个循环来计算最小值,时间复杂度是线性的。下面来看看在线段树上进行维护的方法。
更新线段树时,显然要更新线段[x,x]对应的节点的信息(最小值),而线段[x,x]中的最小值就是更改后的v,然后还要更新所有祖
先节点的信息,祖先节点的最小值可以由左右子孙节点的最小值综合得到。这样,递归的算法如下:
void update(Node root,int pos,int res) //将pos位置的值更新为res
{
int mid=root.left+(root.right-root.left)/2;
if(root.right==root.left) //叶节点,直接更新pos
{ root.minval=res; //最小值
root.maxval=res; //最大值
root.sum=res; //区间和
return;
}
else{
if(pos<=mid) //先递归更新左子树或右子树
update(root.leftchild,pos,res);
else
update(root.rightchild,pos,res);
root.minval=min(root.leftchild.minval,root.rightchild.minval); //最后计算本节点的信息
root.maxval=max(root.leftchild.maxval,root.leftchild.maxval);
root.sum=root.leftchild.sum+root.rightchild.sum;
}
}
可以看出,更新的时间复杂度为O(logn)。
在查询时,沿着根节点从上到下找到待查询线段的左边界和右边界,则夹在中间的所有叶子节点不重复地覆盖了整个查询线段。举
个例子,查询[0,3]段的最小值。从根节点开始向下查找,最后落到[0,2]和[3,3]两个节点上,整段的最小值可由这两个子段综合得到。
查询时,树的左右各有一条“主线”,每层最多有两个节点向下衍伸,因此最后查询到的子节点不超过2h个(性质3)。这其实就是将查询线段分解为不超过2h个不相交的并。再通过这些子区间的信息得到整体区间的信息。代码如下:
int query(Node root,int l,int r)
{
if(l<=root.left&&r>=root.right) //区间[l,r]将目前查询的节点范围包括进去了
{
return root.minval;
}
int ans=Integer.MAX_VALUE;
int mid=root.left+(root.right-root.left)/2;
if(l<=mid) ans=min(ans,query(root.leftchild,l,r)); //当前节点没有被[l,r]包括,但是[l,r]和节点左半部分有交集
if(r>mid) ans=min(ans,query(root.rightchild,l,r)); //当前节点没被[l,r]包括,但是[l,r]和节点右半部分有交集
return ans;
}
可以看出,查询操作的时间复杂度为O(logn)。
(2.2)区间修改查询问题,要比点修改复杂一些,定义如下两个操作:
Add(L,R,v):把AL、AL+1……AR的值全部加上v
Query(L,R):计算AL、AL+1……AR区间和
点修改最多修改logn个节点,但是区间修改最坏情况下会影响到数中的所有节点。这里要意识到,其实区间修改也是针对区间的操作。可以使用上面查询区间的思想,将一个区间分成多个子区间,再对每个子区间进行修改,而这些子区间覆盖的子区间节点则不进行修改。这样可以保证修改的时间复杂度也为O(logn)。
这些选定的子节点被更改后,其父亲节点的信息只要简单综合,就能得到正确的修改。但是,其子节点的信息却没有得到更改。
还是用开头那个图的来举例子:
(1).给[0,3]区间里的每个值增加1。从根节点遍历下去,可以选取[0,2]和[3,3],对这两个区间维护的区间和进行修改。[3,4]的区间和可以得到正确更新([3,3]+[4,4]),进一步,[0,4]的区间和也可以得到更新[0,2]+[3,4]。
(2).在(1)的操作基础上,给[2,3]区间中的每个值加1,还是从根节点往下找,会找到[2,2]和[3,3]这两个区间,其中[3,3]区间中的值直接加1就可以了。但是,[2,2]区间中的值要加2,因为在(1)中,只更新了[0,2],更新信息没有在[2,2]和[0,0]中体现出来。
这里,要引入非常关键的延时标记,其关键思想是:我决定更新一个选定的区间,但子区间先不更新。等到要访问子区间的时候,再将父亲区间中累计的更新应用到子区间上。
这需要在每个节点上维护一个延时标记,每次更新操作都要记录到其中。若是下次要访问子节点,需要将父亲节点的更改累计到左右子节点的延时标记上,并根据父亲节点的延时标记对左右子节点的信息进行更改。之所以要将父节点的更改累计而不是直接替换到左右子节点上,是因为可能有其他的操作对子节点进行了更新。若是直接替换,可能将其他操作的更新覆盖了,下次访问子节点的子节点时,会产生不正确的更新。
void pushDown(Node root) { if(root.delta>0) { root.leftchild.delta+=root.delta; root.rightchild.delta+=root.delta; root.leftchild.sum+=(root.leftchild.right-root.leftchild.left+1)*root.delta; root.rightchild.sum+=(root.rightchild.right-root.rightchild.left+1)*root.delta; root.delta=0; } } void matain(Node root) { root.sum=root.leftchild.sum+root.rightchild.sum; } void segmentAdd(Node root,int l,int r,int a) { if(root.left>=l&&root.right<=r) { root.delta+=a; root.sum+=(root.right-root.left+1)*a; return; } pushDown(root); //要访问子节点,若本节点延时标记有记录之前的更新,将信息传递到子节点中 int mid=root.left+(root.right-root.left)/2; if(l<=mid) segmentAdd(root.leftchild,l,r,a); if(r>mid) segmentAdd(root.rightchild,l,r,a); matain(root); //回头更新本节点信息 }
针对区间修改的查询和点修改的查询的基本思想一样。只是要注意,节点延时标记信息的传递。例如,上例中(1)将[0,2]区间的值加1,紧接着查询[2,2]区间的区间和。这时就要注意将[0,2]节点中的延时标记传递到[2,2]和[0,0]中。
int segmentQuery(Node root,int l,int r) { if(l<=root.left&&r>=root.right) { return root.sum; } pushDown(root); int mid=root.left+(root.right-root.left)/2; int sum=0; if(l<=mid) sum+=segmentQuery(root.leftchild,l,r); if(r>mid) sum+=segmentQuery(root.rightchild,l,r); return sum; }