线段树(Segment Tree)也称区间树(Interval Tree)、范围树(Range Tree),是一种用于区间信息的维护与查询的特殊数据结构。线段树使用分治思想,将连续区间递归分解成若干小区间,在处理范围修改查询等操作时能够通过整合小区间的信息减少运算量从而实现快速修改与查询。
线段树的构造
线段树的主要思想是使用一棵二叉树(Binary Tree)来存储整个区间的信息,其中每个非叶子结点[a,b]表示一个连续区间(Interval)即线段(Segment),该结点中可存放此区间内的一些整体信息如区间和、区间最值、区间长度等,它的左子结点[a,(a+b)/2]和右子结点[(a+b)/2+1,b]分别表示该区间的左半区间和右半个区间,线段树的根节点存放完整区间的信息。当需要修改或查询区间内容时,从根结点向下递归,若当前结点完全被操作区间覆盖,则将该结点信息向上传递,同时该结点不再向下递归;若当前结点被操作区间覆盖一部分,则向对应的子树进行递归直到某结点被操作区间覆盖。图中给出了有十个元素的线段树划分示例。
从图中可以看出,线段树是一种二叉平衡树,记为T[a,b],参数a,b表示区间[a,b],其中b-a称为区间的长度L,则线段树T[a,b]可递归定义:
①若L>1,则T[a,(a+b)/2]为T的左子树,T[(a+b)/2+1,b]为T的右子树。
②若L=1,则T为叶子结点。
线段树的平分构造其实是利用了二分的方法,若根节点为T[a,b],那么它的深度为h=log2(b-a+1)+1(向上取整),假定根结点的长度L=2h,不难发现,第i层有2i个结点,每个结点对应一个2h-i的区间,结点总数为2h+1-1,略小于区间长度的2倍。当区间长度不为2的整数幂时,仍满足上述结论。
线段树可以通过递归进行建树,具体思想是利用分治法,从根结点开始递归建树。访问一个结点时,先判断当前结点是否为叶子结点,若是叶子结点,则将区间对应元素赋值给该结点的区间和、最值等元素;若当前结点不是叶子结点,则分别递归构造它的左子树和右子树,构造结束后,合并两子树的区间和、最值等元素。
为了在后续维护线段树的过程中降低运行时间,在结点中保存一个懒惰标记sign,懒惰标记的使用方法将在后续内容中详解,算法1.1给出了线段树的结点定义及建树过程实现方法。
【算法 1.1】线段树建树
template <class T> class SegmentTree{//线段树类定义 public: int L,R;//存放左右端点 T sum,minv,maxv;//存放区间和最大值最小值 T sign;//懒惰标记 void Init(T a)//结点初始化函数 { sum=minv=maxv=a; sign=0; } }; template <class T> void BuildTree(SegmentTreeTree[],int a[],int o,int l,int r) {//Tree代表线段树数组,a为元素数组,o为当前结点下标,l,r为区间范围 if(l>r)return;//左端点大于右端点,参数有错 Tree[o].L=l;//初始化结点区间 Tree[o].R=r; if(l==r)Tree[o].Init(a[l]);//若当前结点为叶子结点则赋初值 else { int mid=(l+r)/2;//取当前区间中点 BuildTree(Tree,a,o+o,l,mid);//递归构建左右子树 BuildTree(Tree,a,o+o+1,mid+1,r); PushUp(Tree,o);//合并子树信息 } }
算法1.1使用数组存放线段树,根据二叉树的性质,当前元素的左孩子下标Ol为当前结点下标O的2倍,Ol=2O,右孩子下标为Or=2O+1,也可以使用指针来存放左右子树。
分析线段树的建树过程可知,每次递归向下将数组分解成大致相等的两部分,分解只需要单位时间即可完成,在向上合并的过程中也需要单位时间来合并,因此可推导线段树建树递归式: T(n)=2T(n/2)+O(1)
线段树的结点数略小于数组长度的2倍,因此空间复杂度为O(n)。
RMQ问题
区间最值(Range minimum query)问题即RMQ问题可使用线段树快速解决。具体实现思路为:当查询区间[L,R]的最值时,从线段树的根结点向子树递归寻找,若当前结点被查询区间覆盖一部分,则向对应子树递归;若当前结点被查询区间完全覆盖,则将当前结点保存的最值返回给上一层,向上合并的过程中通过比较两子树返回的最值确定当前结点的返回值,不断向上合并直到根结点返回最终结果,下图给出了查询[4,6]区间内最值的递归树。
分析上图的查询递归树可看出查询过程中会遇到三种情况:
①查询区间与当前结点左区间有交集,需要向左子树递归查询。
②查询区间与当前结点右区间有交集,需要向右子树递归查询。
③当前结点代表的区间全部位于查询区间内部,此时直接返回当前区间最值,不再向下递归。
对上述三种情况的处理方法非常重要,是线段树的核心思想之一,在查询、修改、插入和删除操作中都要用到。综合线段树查询最值的思想与需要注意的情况,不难写出线段树的RMQ算法。
【算法 1.2】线段树区间最小值查询
template <class T> T RMQmin(SegmentTreeTree[],int o,int l,int r) { if(l<=Tree[o].L&&r>=Tree[o].R)//若当前结点范围被查询区间覆盖 { PushUp(Tree,o);//更新该结点信息 return Tree[o].minv;//返回当前区间最小值 } else { int mid=(Tree[o].L+Tree[o].R)/2;//取当前区间中点 T minx,min1,min2;//保存最小值 min1=min2=MAXV;//初始化为最大值 if(l<=mid)min1=RMQmin(Tree,o+o,l,r);//左子区间与操作区间有交集 if(r>mid)min2=RMQmin(Tree,o+o+1,l,r);//右子区间与操作区间有交集 minx=(min1 //比较子树的返回值,选出区间最小值 PushUp(Tree,o);//更新结点 return minx;//返回当前区间最小值 } }
分析算法及图可以发现,虽然查询过程中有分叉,但是每层最多只有2个结点向下延伸,因此递归查询访问的结点总数不超过2h。这实际上是将带查询线段分解成不超过2h个不相交线段的并集。因此查询所需的时间为:O(log n)
懒惰标记
区间修改需要用到懒惰标记。懒惰标记的处理方法是线段树能够快速修改区间元素的关键思想。
分析线段树的特点不难发现,任意区间都可以被线段树中不超过2h个结点表示,若需要将某一区间的值增加x,可以只修改这不超过2h个结点。为此引入一个懒惰标记sign来记录结点需要改变的量,若某一结点懒惰标记改变了x,说明该结点表示区间[l,r]内所有值都应改变x,此时需要将该结点的最值改变x,区间和改变(r-l+1)。仔细分析不难发现,若将区间和与最值信息全部改变,表明该区间已完成修改,不再需要懒惰标记,此时可将sign置零,但该结点的子区间尚未更改,若下次查询时访问了子区间会导致结果出错。
考虑到后续可能的查询操作,不妨将修改懒惰标记的操作随查询操作一起执行保证正确性。由于查询操作是自顶向下的,因此可以携带祖先结点的懒惰标记给当前结点,根据当前查询操作是否需要访问子树来决定是否要将懒惰标记继续下放,当无需再下放懒惰标记时,可更新该结点并逐步更新它的祖先结点。结合刚才的结论不难发现,当懒惰标记下放给子树并计算完毕后,准确的区间信息能够通过调取子树信息来整合,此时可将sign置零,视为失去懒惰标记。
由于修改操作也需要查询来确定修改结点的位置,因此修改时向下递归的过程也可视为查询操作。当修改操作需要访问当前结点的子树时,当前结点视作得到懒惰标记后又失去懒惰标记,对应的子树则获得懒惰标记,根据这个规则递归下去,直到某一结点的子树不被访问,该结点保留懒惰标记。
综合上述结论,总结结点懒惰标记变化情况,设修改或查询区间为[p,q],当前结点表示区间为[l,r],改变值为x:
①当前结点被修改操作访问,且修改区间[p,q]完全覆盖当前结点表示区间[l,r],获得懒惰标记x。
②当前结点被修改操作访问,且修改区间[p,q]与左子树[l,(l+r)/2]有交集,此时向左子树递归,待递归返回后,区间和改变x(q-(l+r)/2+1)。
③当前结点被修改操作访问,且修改区间[p,q]与右子树[(l+r)/2+1,r]有交集,此时向右子树递归,待递归返回后,区间和改变x((l+r)/2+1-p+1)。
④当前结点被查询操作访问,失去懒惰标记,区间和改变x(r-l+1)。
分析上述四种情况,可发现②③可能同时发生,这时左右子树都会被访问。
上述情况没有给出修改最值的操作,主要原因是若获得懒惰标记的情况为②、③时区间最值的更新情况不确定,需要递归到最深层后才能逐步返回准确的最值信息,因此应设置一个整合操作放在返回操作之前,保证区间最值的正确性。
现在已经解决了懒惰标记获得和失去的情况以及修改结点信息的步骤,修改与查询的过程中,参照上述四个情况与信息修改方法即可保证查询返回结果的正确性,下图给出了将区间[3,9]内所有元素值增加1后懒惰标记的保留情况。
修改操作从根结点开始,向下递归修改结点。其详细操作过程如下:
(1)访问[1,10],修改[3,9]属于情况②③
(2)访问[1,5],修改[3,5]属于情况②③
(3)访问[1,3],修改[3,3]属于情况③
(4)访问[3,3],修改[3,3]属于情况①,保留懒惰标记
(5)[3,3]更新区间信息返回给[1,3],[1,3]更新区间信息返回给[1,5]
(6)访问[4,5],修改[4,5]属于情况①,保留懒惰标记
(7)[4,5]更新区间信息返回给[1,5],[1,5]更新区间信息返回给[1,10]
(8)访问[6,10],修改[6,9]属于情况②③
(9)访问[6,8],修改[6,8]属于情况①,保留懒惰标记
(10)[6,8]更新区间信息返回给[6,10]
(11)访问[9,10],修改[9,9]属于情况②
(12)访问[9,9],修改[9,9]属于情况①,保留懒惰标记
(13)[9,9]更新区间信息返回给[9,10],[9,10]更新区间信息返回给[6,10],[6,10]更新区间信息返回给[1,10],[1,10]更新区间信息
仔细观察可发现修改操作结束后,保留懒惰标记的结点区间的并集与修改区间一致。下面给出了懒惰结点下放步骤的算法1.3,由于最值等信息需要等到递归返回时才能获得准确信息,因此下放操作没有其他计算步骤:
【算法 1.3】懒惰标记下放
void PushDown(SegmentTreeTree[],int o)//懒惰标记下放,o为当前结点 { if(Tree[o].L //若当前结点不是叶子结点,下放懒惰标记 { Tree[o+o].sign+=Tree[o].sign; Tree[o+o+1].sign+=Tree[o].sign; Tree[o].sign=0; } else Tree[o].sign=0;//若当前结点是叶子结点,无需下放标记直接置零 }
分析整合区间信息过程,给出算法1.4:
【算法 1.4】区间信息整合
template <class T> void PushUp(SegmentTreeTree[],int o)//整合函数,o为当前结点 { int lc=o*2,rc=o*2+1;//记录左右子树 if(Tree[o].L //若当前结点不是叶子结点,重整当前结点信息 { Tree[o].sum=Tree[lc].sum+Tree[rc].sum; Tree[o].minv=(Tree[lc].minv Tree[lc].minv:Tree[rc].minv); Tree[o].maxv=(Tree[lc].maxv>Tree[rc].maxv?Tree[lc].maxv:Tree[rc].maxv); } if(Tree[o].sign!=0)//若懒惰标记不为0,更新信息 { Tree[o].sum+=Tree[o].sign*(Tree[o].R-Tree[o].L+1); Tree[o].minv+=Tree[o].sign; Tree[o].maxv+=Tree[o].sign; PushDown(Tree,o);//更新结束即可下放懒惰标记 } }
合并与下放仅需做有限次操作,且都能在单位时间内完成。
区间修改与查询
参考下图并综合之前的结论,使用算法1.3与算法1.4作为辅助函数,给出线段树区间修改的算法1.5:
【算法 1.5】线段树区间修改
template <class T> void Modify(SegmentTreeTree[],int o,int l,int r,int x) {//[l,r]区间内所有元素增加x if(l<=Tree[o].L&&r>=Tree[o].R)//若当前结点被修改区间覆盖 { Tree[o].sign+=x;//更新当前结点懒惰标记 PushUp(Tree,o);//重整该结点信息 return;//不再向下递归 } else { //若当前结点不被操作区间覆盖 PushDown(Tree,o);//视为访问该结点,下放懒惰标记 int mid=(Tree[o].L+Tree[o].R)/2;//取当前区间中点 if(l<=mid)Modify(Tree,o+o,l,r,x);//左子区间与操作区间有交集 if(r>mid)Modify(Tree,o+o+1,l,r,x);//右子区间与操作区间有交集 PushUp(Tree,o);//更新当前结点 } }
除了算法1.5所描述的将区间改变x的情况外,还有将区间置为某一数值的操作,这时一个懒惰标记已不能满足需求,此时可引入另一对懒惰标记flag与sign2,用flag表示该区间是否需要设置为某一值,sign2用来存储设置的值。在修改区间信息时要先判断flag,若不需要重设区间,再判断sign是否需要改动区间。设置操作与修改操作非常相似,修改操作是改变sign值,而设置操作是将flag置为真,再设置sign2。为了满足设置操作的需求,在PushUp和PushDown函数中各加入一个判断flag是否为真并修改区间的操作即可。设置区间与修改区间的算法极为相似,仅有几行改动,因此不再给出具体实现。
区间和查询需要注意处理如何向子区间递归以及处理子区间返回值的方法,与RMQ问题相似,需要考虑四种情况,结合算法1.2,给出线段树查询区间和的算法1.6:
【算法 1.6】线段树区间查询
template <class T> T InternalSum(SegmentTreeTree[],int o,int l,int r) { if(l<=Tree[o].L&&r>=Tree[o].R) {//若当前区间在查询区间之内,更新信息返回当前区间和 PushUp(Tree,o); return Tree[o].sum; } else {//若当前区间不在查询区间内 PushDown(Tree,o);//懒惰标记下放 T sum=0;//保存返回的区间和 int mid=(Tree[o].L+Tree[o].R)/2; if(l<=mid)sum+=InternalSum(Tree,o+o,l,r);//左子区间包含查询区间 if(r>mid)sum+=InternalSum(Tree,o+o+1,l,r);//右子区间包含查询区间 PushUp(Tree,o);//更新结点信息 return sum;//返回当前区间和 } }
分析算法1.5与1.6可知修改与查询的结点不多于2h,访问结点不多于4h,这与算法1.2仅存在常数项的差别,因此可直接推导出线段树区间修改的时间复杂度为O(log n)。
除了查询与修改操作外,线段树还可进行批量插入与删除操作,与修改查询操作思路相似,引入一对新的懒惰标记,表示是否需要插入删除,以及插入删除后该结点表示区间的偏移量,同时结点子区间的分界点不能再通过去中点的方式而是用特定值存储。插入操作会影响线段树的平衡性,若插入m个元素,那么该线段树的深度h就会增加g=log2(m),这时最好使用指针来存储子树,否则存储开销将会增加2g,可考虑借鉴AVL树的思想进行优化。
线段树在算法竞赛中的应用
线段树的应用非常广泛,区间修改查询等操作在统计类问题中非常常见。在计算几何中,通过线段树套用来表示二维三维空间,可以实现对几何元素进行快速移动修改等操作。此外线段树还可用于对内存的高效管理等。
ACM/ICPC Asia-Nanjing 2007
题目链接:https://icpcarchive.ecs.baylor.edu/index.php?option=com_onlinejudge&Itemid=8&page=show_problem&category=22&problem=1939&mosmsg=Submission+received+with+ID+2539428
After doing Ray a great favor to collect sticks for Ray, Poor Neal becomes very hungry. In return for Neal’s help, Ray makes a great dinner for Neal. When it is time for dinner, Ray arranges all the dishes he makes in a single line (actually this line is very long, the dishes are represented by 1, 2, 3...).You make me work hard and don’t pay me! You refuse to teach me Latin Dance! Now it is time for you to serve me", Neal says to himself.
Every dish has its own value represented by an integer whose absolute value is less than 1,000,000,000.Before having dinner, Neal is wondering about the total value of the dishes he will eat. So he raisesmany questions about the values of dishes he would have.
For each question Neal asks, he will first write down an interval [a; b] (inclusive) to represent allthe dishes a,a+ 1,...,b, where aand bare positive integers, and then asks Ray which sequence of consecutive dishes in the interval has the most total value. Now Ray needs your help.
Input
The input file contains multiple test cases. For each test case, there are two integers nand min the first line (n; m < 500000). n is the number of dishes and mis the number of questions Neal asks.
Then n numbers come in the second line, which are the values of the dishes from left to right. Next m lines are the questions and each line contains two numbers a, b as described above. Proceed to the end of the input file.
Output
For each test case, output m lines. Each line contains two numbers, indicating the beginning position and end position of the sequence. If there are multiple solutions, output the one with the smallest beginning position. If there are still multiple solutions then, just output the one with the smallest end position. Please output the result as in the Sample Output.
Sample Input
3 1
1 2 3
1 1
Sample Output
Case 1:
1 1
【分析】
该题目实际上是求解动态最大连续和问题,此类问题有多种解法,其中动态规划法与分治法结合的算法较为实用。该方法解决最大连续和问题分为如下步骤:
(1)分解:将问题分解为大致相等的两个字问题,递归分解直到不可再分
(2)处理:对每个子问题,存储它的区间和sum,最大连续和Nmax及其起始Nmaxs和结束位置Nmaxe,左端最大连续和Lmax及其结束位置Lmaxe,右端最大连续和Rmax及其开始位置Rmaxs。
(3)合并:若当前结点为叶子结点,其存储的所有和为该结点元素值,所有位置为该结点位置;若当前结点为非叶子结点,最大连续和Nmax为左子结点的最大连续和NmaxL、右端最大连续和RmaxL与其右子结点的最大连续和NmaxR、左端最大连续和LmaxR相组合的状态转移方程:Nmax=(NmaxL,NmaxR,RmaxL+LmaxR),剩余两个连续和状态转移方程与之相似,根据各方程的结果修改对应位置值。
分析该解法可发现每个结点都会被处理一次,而结点总数为2n-1,因此查询时间开销为。该方法仅适用于静态连续最大和,若查询区间为动态的,每次查询都将重复计算,n次查询时间复杂度为O(n2)。
结合线段树相关理论不难发现,分解过程即是线段树建树过程;处理方法通过增加结点存储元素即可;合并过程修改PushUp函数即可完成。
根据上述思路修改线段树,给出题目的实现方法 程序1.1
【程序1.1】动态连续最大和
#include#include using namespace std; const int maxn=500005;//最大盘子数 class Tree{ //线段树 public: long long sum,Nmax,Lmax,Rmax;//区间和等和元素 int Nmaxs,Nmaxe,Lmaxe,Rmaxs;//各区间和端点 int l,r;//区间范围 void eq(Tree a)//赋值函数 { sum=a.sum;Nmax=a.Nmax;Lmax=a.Lmax;Rmax=a.Rmax; Nmaxs=a.Nmaxs;Nmaxe=a.Nmaxe;Lmaxe=a.Lmaxe;Rmaxs=a.Rmaxs; l=a.l;r=a.r; } }; void PushUp(Tree &o,Tree &l,Tree &r)//整合函数 { if(o.l //若当前结点不是叶子结点 { o.l=l.l;o.r=r.r; o.sum=l.sum+r.sum;//求区间和 if(l.sum+r.Lmax>l.Lmax)//求左端最大连续和及其端点 { o.Lmax=l.sum+r.Lmax; o.Lmaxe=r.Lmaxe; } else { o.Lmax=l.Lmax; o.Lmaxe=l.Lmaxe; } if(r.sum+l.Rmax>=r.Rmax)//求有段最大连续和及其端点 { o.Rmax=r.sum+l.Rmax; o.Rmaxs=l.Rmaxs; } else { o.Rmax=r.Rmax; o.Rmaxs=r.Rmaxs; } if(l.Nmax>=r.Nmax)//求最大连续和及其端点 { o.Nmax=l.Nmax; o.Nmaxs=l.Nmaxs; o.Nmaxe=l.Nmaxe; } else { o.Nmax=r.Nmax; o.Nmaxs=r.Nmaxs; o.Nmaxe=r.Nmaxe; } if(o.Nmax<=l.Rmax+r.Lmax) { if(o.Nmax==l.Rmax+r.Lmax)//题目要求区间尽量靠前 { if(o.Nmaxs return; if(o.Nmaxs==l.Rmaxs&&o.Nmaxe return; } o.Nmax=l.Rmax+r.Lmax; o.Nmaxs=l.Rmaxs; o.Nmaxe=r.Lmaxe; } } } void Build(Tree T[],long long a[],int o,int l,int r) {//线段树构建函数 if(l>r)return; T[o].l=l;T[o].r=r; if(l==r) { T[o].sum=T[o].Nmax=T[o].Lmax=T[o].Rmax=a[l]; T[o].Nmaxs=T[o].Nmaxe=T[o].Lmaxe=T[o].Rmaxs=l; return; } int mid=(l+r)/2; Build(T,a,o+o,l,mid); Build(T,a,o+o+1,mid+1,r); PushUp(T[o],T[o+o],T[o+o+1]); return; } Tree query(Tree T[],int o,int l,int r) {//查询函数 if((l<=(T[o].l))&&(r>=(T[o].r)))return T[o]; int mid=(T[o].l+T[o].r)/2; Tree a,b,c; int fa=0,fb=0; if(l<=mid){fa=1;a.eq(query(T,o+o,l,r));} if(r>mid){fb=1;b.eq(query(T,o+o+1,l,r));} if(fa&&fb){c.l=a.l;c.r=b.r;PushUp(c,a,b);} else if(fa)c.eq(a); else c.eq(b); return c; } Tree t[4*maxn]; long long a[maxn]; int main() { int n,m; int ca=1; int l,r; Tree T; while(scanf("%d%d",&n,&m)!=EOF) { for(int i=1;i<=n;i++)scanf("%lld",&a[i]); Build(t,a,1,1,n); printf("Case %d:\n", ca++) ; while(m--) { scanf("%d%d",&l,&r); T.eq(query(t,1,l,r)); printf("%d %d\n",T.Nmaxs,T.Nmaxe) ; } } return 0; }
由于各结点保存了区间信息,查询时只需整合不超过线段树深度4倍的结点即可完成运算,时间复杂度为O(log n)。
分治算法不仅在常见的排序查找算法中有广泛应用,对于特定的数学问题也是不可或缺的处理手段,同时在一些数据结构与并行运算模型中也要用到分治思想,是算法设计中最重要的思想之一,仍有许多未知的道路需要探索。
【未经允许 禁止转载】