线段树(Segment Tree)是一种基于分治思想的二叉树结构,用于在区间上进行信息统计。与按照二进制位(2的次幂)进行区间划分的树状数组相比,线段树是一种更加通用的结构:
1.线段树的每一个节点都表示一个区间。
2.线段树具有唯一的根节点,代表的区间是整个统计范围,如[1,N]。
3.线段树的每一个叶节点都代表一个长度为1的元区间[x,x]。
4.对于每个内部节点[l,r],它的左子节点是[l,mid],右子节点是[mid+1,r]。其中mid=(l+r)/2(向下取整)。
在线段树所展示的图中,可以发现,出去树的最后一层,整棵线段树一定是一颗完全二叉树,树的深度为O(logN)。因此,我们可以按照与二叉堆类似的"父子二倍"节点编号方法:1.根节点标号为1。
2.编号为x的节点的左子节点编号为 x*2,右子节点编号为 x*2+1。
这样一来,我们就能简单地使用一个struct 数组来保存线段树。当然,树的最后一层节点在数组中保存的位置不是连续的,直接空出数组中多余的位置即可。在理想情况下,N个叶节点的满二叉树有N+N/2+N/4+……+2+1=2N-1个节点。因为在上述存储方式下,最后还有一层产生了空余,所以保存线段树的数组长度要不小于4N才能保证不会越界。
线段树的建树
线段树的基本用途是对序列进行维护,支持查询与修改指令。给定一个长度为N的序列A,我们可以在区间[1,N]上建立一颗线段树,每个节点[i,i]保存A[i]的值。线段树的二叉树结构可以很方便地从上往下传递信息。以区间最大值问题为例,记dat(l,r)等于max{A[i]}(l<=i<=r),显然dat(l,r)=max(dat(l,mid),dat(mid+1,r))。下面这段代码建立了一颗线段树并在每个节点上保存了对应区间的最大值。
struct SegmentTree
{
int l,r;
int data;
} t[SIZE*4]; //struct 数组存储线段树
void build(int p,int l,int r)
{
t[p].l=l,t[p].r=r; //节点p代表区间[l,r]
if(l==r){t[p].dat=a[1];return;} //叶节点
int mid=(l+r)/2; //折半
build(p*2,l,mid); //左子节点[l,mid],编号p*2
build(p*2+1,mid+1,r); //右子节点[mid+1,r],编号p*2+1
t[p].dat=max(t[p*2].dat,t[p*2+1].dat); //从下往上传递信息
}
built(1,l,n); //调用入口
线段树的单点修改
单点修改是一条形如"C x v"的指令,表示把A[x]的值修改为v。在线段树中,根节点(编号为1的节点)是执行各种指令的入口。我们需要从根节点出发,递归找到区间[x,x]的叶节点,然后从下往上更新[x,x]以及他的所有祖先节点上保存的信息,时间复杂度为O(logN)。
void change(int p,int x,int v){
if(t[p].l==t[p].r) {t[p].dat=v;return;} //找到叶节点
int mid=(t[p].l+t[p].r)/2;
if(x<=mid) change(p*2,x,v); //x属于左半区间
else change(p*2+1,x,v); //x属于右半区间
t[p].dat=max(t[p*2].dat,t[p*2+1].dat); //从下往上更新信息
}
change(1,x,v); //调用入口
线段树的区间查询
区间查询是一条形如"Q l r"的指令,例如查询序列A在区间[l,r]上的最大值,即max{A[i]}(l<=i<=r)。我们只需要从根节点开始,递归执行以下过程:
1.若[l,r]完全覆盖了当前节点代表的区间,则立即回溯,并且该节点的dat值为候选答案。
2.若左子节点与[l,r]有重叠部分,则递归访问左子节点。
3.若右子节点与[l,r]有重叠部分,则递归访问右子节点。
int ask(int p,int l,int r){
if(l<=t[p].l&&r>=t[p].r) return t[p].dat; //完全包含
int mid=(t[p].l+t[p].r)/2;
int val=-(1<<30);//负无穷大
if(l<=mid) val=max(val,ask(p*2,l,r)); //左子节点有重叠
if(l>mid) val=max(val,ask(p*2+1,l,r)); //右子节点有重叠
return val;
}
cout<<ask(1,l,r)<<endl; //调用入口
该查询过程会把询问区间[l,r]在线段树上分成O(logN)个节点,取他们的最大值作为答案。为什么是O(logN)个呢?仔细分析上述过程,在每个节点[pl,pr]上,设mid=(pl+pr)/2(向下取整),可能会出现以下几种情况:
1.l<=pl<<pr<<r,即完全覆盖了当前节点,直接返回。
2.pl<=l<=pr<=r,即只有l处于节点之内。
(1) l>mid, 只会递归右子树。
(2) l<=mid,虽然递归两棵子树,但是右子节点会在递归后直接返回。
3.l<=pl<=r<=pr,即只有r处于节点之内,与情况2类似。
4.pl<=l<=r<=pr,即l于r都位于节点之内。
(1) l,r都位于mid的一侧,只会递归一颗子树。
(2) l,r分别位于mid的两侧,递归左右两颗子树。
也就是说,只有情况4(2)会真正产生对左右两棵树的递归。因此,上述查询的时间复杂度为O(2logN)=O(logN)。从宏观上理解,相当于l,r两个端点分别在线段树上划分出一条递归访问路径,情况4(2)在两条路径在从下往上的第一次交汇处产生。
至此,线段树能处理区间最值问题,并且还支持动态修改某个数的值。同时,线段树也能支持树状数组的
单点增加与查询前缀和指令。
延迟标记
在线段数的“区间查询”指令中,每当遇到被询问区间[l,r]完全覆盖的节点时,可以立即把该节点上的存储信息作为候选答案返回。我们已经证明,被询问区间[l,r]在线段树上会被分成O(logN)个小区间(节点),从而在O(logN)的时间内求出答案。不过,在“区间修改”指令中,如果某个节点被修改区间[l,r]完全覆盖,那么以该节点为根的整个子树中的所有节点存储信息都会发生变化,若逐一进行更新,将使得一次区间修改指令的时间复杂度增加到O(N),这是我们不能接受的。
试想,如果我们在一次修改指令中发现节点 P 代表的区间[pl,pr]被修改区间[l,r]完全覆盖,并且逐一更新了子树P中的所有节点,但是在之后的查询中却根本没有用到[l,r]的子区间作为候选答案,那么更新P的整颗子树就是徒劳的。
换言之,我们在执行修改指令时,同样可以在l<=pl<=pr<=r的情况下立即返回,只不过在回溯之前向节点P增加一个标记,标识“该节点曾经被修改,但其子节点尚未被更新”。
如果在后续的指令中,需要从节点P向下递归,我们再检查P是否有标记。如有标记,就根据标记信息
更新P的两个子节点,同时为P的两个子节点增加标记,然后清除P的标记。
也就是说,除了在修改指令中直接划分的O(logN)个节点之外,对任意节点的修改都延迟到“在后续操作中递归进入他的父节点时”再执行。这样一来,每条查询或修改指令的时间复杂度都降到了O(logN)。这些标记就称为“延迟标记”。延迟标记提供了线段树中从上往下传递信息的方式。这种“延迟”也是设计算法和解决问题的一个重要思路。