线段树&树状数组(上)
前言
之前在暑假的时候处于兴趣和几个小伙伴一起报名参加了北京大学的暑期ACM课程,由于个人基础比较差,所以当堂课基本消化不了,前一段时间又忙于其他事情,近一段时间才回来继续投入学习,刚刚复习消化完第一部分的内容所以在这里做一个总结以免遗忘的时候再来复习复习...
一、线段树
1.结构描述
线段树是一种二叉搜索树,与区间树相似,它将一个区间划分成一些单元区间,每个单元区间对应线段树中的一个叶结点。对于线段树中的每一个非叶子节点[a,b],它的左儿子表示的区间为[a,(a+b)2],右儿子表示的区间为[(a+b)2+1,b]。因此线段树是平衡二叉树,最后的子节点数目为N,即整个线段区间的长度。使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为O(logN)。而未优化的空间复杂度为2N。(摘自百度百科)
如下图就是一颗区间范围为[1,9]的线段树:
2.线段树的基本用途
线段树适用于和区间统计有关的问题。比如某些数据可以按区间进行划分,按区间动态进行修改,而且还需要按区间多次进行查询,那么使用线段树可以达到较快查询速度。
举例:给你一个数的序列A1、A2……An。并且可能多次进行下列两个操作:
①列里面的某个数进行加减。
②询问这个序列里面任意一个连续子序列Ai~Aj的和是多少。
考虑到数据量和时间问题,希望第二个操作每次能在log(n)时间内完成,所以采用线段树来进行操作,代码如下:
#include<iostream>
using namespace std;
class Add
{
public:
int left,right,mid,all;
};
Add a[40000];//一般三倍就足够,四倍最保险
int x[10000];
void BuildTrea(int l,int r,int n)//建树
{
int mid=(l+r)/2;
a[n].left=l;
a[n].right=r;
a[n].mid=mid;
if(l==r)
return;
BuildTrea(l,mid,n*2+1);
BuildTrea(mid+1,r,n*2+2);
}
int AddNumber(int n)//插入数据
{
if(a[n].left==a[n].right)
{
a[n].all=x[a[n].left];
return a[n].all;
}
a[n].all=AddNumber(n*2+1)+AddNumber(n*2+2);
return a[n].all;
}
int Query(int l,int r,int n)//查询
{
int s=0;
if(a[n].left==l&&a[n].right==r)
return a[n].all;
if(r<=a[n].mid)
s+=Query(l,r,n*2+1);
else if(l>a[n].mid)
s+=Query(l,r,n*2+2);
else
s+=Query(l,a[n].mid,n*2+1)+Query(a[n].mid+1,r,n*2+2);
return s;
}
void Change(int n,int c,int root)//数据更新
{
a[root].all+=c;
if(a[root].left==a[root].right)
return;
if(n<=a[root].mid)
Change(n,c,root*2+1);
else
Change(n,c,root*2+2);
}
int main()
{
int n,r,l,o,p;
cin>>n;
for(int i=1;i<=n;i++)
cin>>x[i];
BuildTrea(1,n,0);
AddNumber(0);
cin>>o>>p;
Change(o,p-x[o],0);
x[o]=p;
while(1)
{
cin>>l>>r;
cout<<Query(l,r,0)<<endl;
}
return 0;
}
3.应用技巧一(选择合理的节点存储数据)
如果是对区间所对应的一些数据进行修改,过程和查询类似,关键就是要想清楚每个节点要存哪些信息(当然区间起终点,以及左右子节点指针是必须的),以及这些信息如何高效更新,维护,查询。不要一更新就更新到叶子节点,那样更新效率最坏就可能变成O(n) 的了。先建树,然后插入数据,然后更新、查询。
案例一:POJ 3264 Balanced Lineup,题目的大概意思是给定Q(1<=Q<=200000)个数A1,A2……AQ,多次求任一区间Ai~Aj中最大数和最小数的差。
于是本题的树节点结构可以定为:
struct CNode
{
int L,R; //区间起点和终点
int minV,maxV; //本区间里的最大最小值
CNode* pLeft, * pRight;
};
案例二:POJ 3468 A Simple Problem withIntegers ,题目大意是给定Q(1<=Q<=100000)个数A1,A2……AQ,以及可能多次进行的两个操作:
①对某个区间Ai…Aj的每个数都加n(n可变)。
②求某个区间Ai…Aj的每个数的和。
如果只存每个节点区间内的和,会导致每次加数的时候都要更新到叶子节点,速度太慢(O(nlogn)),这是必须要避免的。
所以本体的树节点结构可以定义为:
struct CNode
{
int L ,R;
CNode * pLeft, * pRight;
long long nSum; //原来的和
long long Inc; //增量c的累加
}; //本节点区间的和实际上是nSum+Inc*(R-L+1)
按照此结构完整代码如下:
#include<iostream>
#include<cstdio>
#include<stdlib.h>
using namespace std;
class CNode
{
public:
int left,right,mid;
CNode *pl,*pr;
long long sum,inc;
};
void BuildTrea(int l,int r,CNode **root);
long long Insert(CNode *root);
void Increase(int l,int r,long long k,CNode *root);
long long Query(int l,int r,CNode *root);
int x[100002];
int main()
{
char c;
int N,M,l,r;
long long k;
scanf("%d%d",&N,&M);
for(int i=1;i<=N;i++)
scanf("%d",&x[i]);
CNode *root;
BuildTrea(1,N,&root);
Insert(root);
while(M--)
{
getchar();
scanf("%c",&c);
if(c=='C')
{
scanf("%d%d%I64d",&l,&r,&k);
Increase(l,r,k,root);
}
else
{
scanf("%d%d",&l,&r);
printf("%I64d\n",Query(l,r,root));
}
}
return 0;
}
void BuildTrea(int l,int r,CNode **root)
{
(*root)=(CNode*)malloc(sizeof(CNode));
(*root)->left=l;
(*root)->right=r;
(*root)->mid=(l+r)/2;
(*root)->inc=0;
(*root)->pl=(*root)->pr=NULL;
if(l==r)
return;
BuildTrea(l,(*root)->mid,&(*root)->pl);
BuildTrea((*root)->mid+1,r,&(*root)->pr);
}
long long Insert(CNode *root)
{
if(root->left==root->right)
{
root->sum=x[root->left];
return root->sum;
}
root->sum=Insert(root->pl)+Insert(root->pr);
return root->sum;
}
void Increase(int l,int r,long long k,CNode *root)
{
if(l==root->left&&r==root->right)
{
root->inc+=k; /****完整覆盖更新inc值即可****/
return;
}
root->sum+=k*(r-l+1); /****不完整覆盖更新sum值****/
if(r<=root->mid)
Increase(l,r,k,root->pl);
else if(l>root->mid)
Increase(l,r,k,root->pr);
else
{
Increase(l,root->mid,k,root->pl);
Increase(root->mid+1,r,k,root->pr);
}
}
long long Query(int l,int r,CNode *root)
{
long long s=0;
if(l==root->left&&r==root->right)
return root->sum+(r-l+1)*root->inc;
if(root->inc!=0)
{
root->sum+=(root->right-root->left+1)*root->inc;
root->pl->inc+=root->inc;
root->pr->inc+=root->inc;
root->inc=0;
}
if(r<=root->mid)
s+=Query(l,r,root->pl);
else if(l>root->mid)
s+=Query(l,r,root->pr);
else
s+=Query(l,root->mid,root->pl)+Query(root->mid+1,r,root->pr);
return s;
}
在计算Inc值时如果不更新到子节点清零Inc值的话也可以将Inc值带入子节点计算后再恢复Inc值:
long long Query(int l,int r,CNode *root)
{
long long s=0;
if(l==root->left&&r==root->right)
return root->sum+(r-l+1)*root->inc;
if(r<=root->mid)
{
root->pl->inc+=root->inc;
s+=Query(l,r,root->pl);
root->pl->inc-=root->inc;
}
else if(l>root->mid)
{
root->pr->inc+=root->inc;
s+=Query(l,r,root->pr);
root->pr->inc-=root->inc;
}
else
{
root->pl->inc+=root->inc;
root->pr->inc+=root->inc;
s+=Query(l,root->mid,root->pl)+Query(root->mid+1,r,root->pr);
root->pl->inc-=root->inc;
root->pr->inc-=root->inc;
}
return s;
}
4.应用技巧二(区间离散化)
有时,区间的端点不是整数,或者区间太大导致建树内存开销过大MLE,那么就需要进行“离散化”后再建树。所谓离散化,就是将各个不一定相等的区间看成是一个单位长度,然后设置一个与原区间相映射的hash值,这样就能解决区间端点不是整数或者区间太大导致建树内存开销过大导致MLE的问题。
案例一: POJ 2528 Mayor's posters,先来看一下区间太大需要离散化的问题,题目的大意是给定一些海报,可能互相重叠,告诉你每个海报宽度(高度都一样)和先后叠放次序,问没有被完全盖住的海报有多少张。海报最多10,000张,但是墙有10,000,000块瓷砖长。海报端点不会落在瓷砖中间。如下图所示:
如果每个叶子节点都代表一块瓷砖,那么线段树会导致MLE,即单位区间的数目太多。实际上,由于最多10,000个海报,共计20,000个端点,这些端点把墙最多分成19,999个单位区间(题意为整个墙都会被盖到)。每个单位区间的瓷砖数目可以不同。我们只要对这19,999个区间编号,然后建树即可。这就是离散化。
按上图的离散化方法,求每张海报覆盖了哪些单位区间,写起来稍麻烦,更好的离散化方法,是将所有海报的端点瓷砖排序,把每个海报的端点瓷砖都看做一个单位区间,两个相邻的端点瓷砖之间的部分是一个单位区间这样最多会有20000 + 19999个单位区间。如下图所示:
本体的关键还有一点,就是插入数据的顺序------从上往下依次插入每张海报,这样后插入的海报不可能覆盖先插入的海报,因此插入一张海报时,如果发现海报对应区间有一部分露出来,就说明该海报部分可见。
故写出代码如下:
#include<iostream>
#include<algorithm>
#include<cstdio>
using namespace std;
class Post
{
public:
int left,right;
};
Post posts[10005];
int x[20010];
int hash[10000010];
class CNode
{
public:
int left,right,mid;
bool beCovered;
CNode *pl,*pr;
};
CNode Tree[80040];
int numNode=0;
void BuildTree(CNode *root,int l,int r)
{
int mid=(l+r)/2;
root->left=l;
root->right=r;
root->mid=mid;
root->pl=root->pr=NULL;
root->beCovered=false;
if(l==r)
return;
numNode++;
root->pl=Tree+numNode;
numNode++;
root->pr=Tree+numNode;
BuildTree(root->pl,l,mid);
BuildTree(root->pr,mid+1,r);
}
bool post(CNode *root,int l,int r)
{
bool result;
if(root->beCovered)
return false;
if(l==root->left&&r==root->right)
{
root->beCovered=true;
return true;
}
if(r<=root->mid)
result=post(root->pl,l,r);
else if(l>root->mid)
result=post(root->pr,l,r);
else
{
bool m=post(root->pl,l,root->mid);
bool n=post(root->pr,root->mid+1,r);
result=m||n;
}
if(root->pl->beCovered&&root->pr->beCovered)
root->beCovered=true;
return result;
}
int main()
{
int m;
scanf("%d",&m);
while(m--)
{
numNode=0;
int i,n,d=0,numSeen=0,numHash=1;
scanf("%d",&n);
for(i=0;i<n;i++)
{
scanf("%d%d",&posts[i].left,&posts[i].right);
x[d++]=posts[i].left;
x[d++]=posts[i].right;
}
sort(x,x+d);
d=unique(x,x+d)-x;
for(i=0;i<d;i++) //建立hash值离散化
{
hash[x[i]]=numHash;
if(i<d-1)
{
if(x[i+1]-x[i]==1)
numHash++;
else
numHash+=2;
}
}
BuildTree(Tree,1,numHash);
for(i=n-1;i>=0;i--)
{
if(post(Tree,hash[posts[i].left],hash[posts[i].right]))
numSeen++;
}
printf("%d\n",numSeen);
}
return 0;
}
在这题中只用到了线段树的建树和插入过程,并没有用到查询,所以我们可以看出,线段树用的灵活的话是能省略很多步骤的,这一点我在后面还会提到的。
案例二:POJ 1151 Atlantis,再来看一下区间不是整数的情况,题目大意是给定n个矩形(n<= 100),其顶点坐标是浮点数,可能互相重叠,问这些矩形覆盖到的面积是多大。用线段树做,就先要离散化。
这一题运用线段树解决就需要选择一个坐标轴x(y),然后以扫描的思想顺着另外一个坐标轴从按一个方向扫描过去,在过程中通过离散化的线段树插入删除x(y)轴上矩形边长,这样线段树根节点区间内的和就代表扫描线当前时间被矩形所包含的长度从而逐步计算一个个分割区间然后求和即可。
代码如下:
#include<iostream>
#include<algorithm>
#include<cstdio>
using namespace std;
class Cline
{
public:
double x,y1,y2;
bool isLeft;
};
Cline lines[500];
double hash[500];
class CNode
{
public:
int L,R,mid;
CNode *pl,*pr;
double len;
int nCovered;
};
int nNode=0;
CNode Tree[1000];
void BuildTree(CNode *root,int left,int right)
{
root->L=left;
root->R=right;
root->mid=(left+right)/2;
root->nCovered=0;
root->len=0.0;
root->pl=root->pr=NULL;
if(left==right)
return;
nNode++;
root->pl=Tree+nNode;
nNode++;
root->pr=Tree+nNode;
BuildTree(root->pl,left,root->mid);
BuildTree(root->pr,root->mid+1,right);
}
void Insert(CNode *root,int left,int right)
{
if(root->L==left&&root->R==right)
{
root->len=hash[right+1]-hash[left];
root->nCovered++;
return;
}
if(right<=root->mid)
Insert(root->pl,left,right);
else if(left>root->mid)
Insert(root->pr,left,right);
else
{
Insert(root->pl,left,root->mid);
Insert(root->pr,root->mid+1,right);
}
if(root->nCovered==0)
root->len=root->pl->len+root->pr->len;
}
void Delete(CNode *root,int left,int right)
{
if(root->L==left&&root->R==right)
{
root->nCovered--;
if(root->nCovered==0)
{
if(root->L==root->R)
root->len=0.0;
else
root->len=root->pl->len+root->pr->len;
}
return;
}
if(right<=root->mid)
Delete(root->pl,left,right);
else if(left>root->mid)
Delete(root->pr,left,right);
else
{
Delete(root->pl,left,root->mid);
Delete(root->pr,root->mid+1,right);
}
if(root->nCovered==0)
root->len=root->pl->len+root->pr->len;
}
template <class F,class T>
F bin_search(F s, F e, T val)
{
F L = s;
F R = e-1;
while(L <= R )
{
F mid = L + (R-L)/2;
if( !( * mid < val || val < * mid ))
return mid;
else if(val < * mid)
R = mid - 1;
else
L = mid + 1;
}
return e;
}
bool cmp(Cline a,Cline b)
{
return a.x<b.x;
}
int main()
{
int n,num=0;
while(scanf("%d",&n)&&n!=0)
{
int i,nline=0;
double x1,y1,x2,y2,area=0.0;
for(i=0;i<n;i++)
{
scanf("%lf%lf%lf%lf",&x1,&y1,&x2,&y2);
lines[nline].isLeft=true;
lines[nline].x=x1;
lines[nline].y1=y1;
lines[nline].y2=y2;
hash[nline++]=y1;
lines[nline].isLeft=false;
lines[nline].x=x2;
lines[nline].y1=y1;
lines[nline].y2=y2;
hash[nline++]=y2;
}
sort(hash,hash+nline);
sort(lines,lines+nline,cmp);
BuildTree(Tree,0,nline-2);
for(i=0;i<nline-1;i++)
{
int L=bin_search(hash,hash+nline,lines[i].y1)-hash;
int R=bin_search(hash,hash+nline,lines[i].y2)-hash-1;
if(lines[i].isLeft)
Insert(Tree,L,R);
else
Delete(Tree,L,R);
area+=Tree[0].len*(lines[i+1].x-lines[i].x);
}
num++;
printf("Test case #%d\n",num);
printf("Total explored area: %.2f\n\n",area);
//G++编译环境中只能用%f输出double,否则会报错
}
return 0;
}
5.应用技巧三(发现线段树)
有时,不一定能够一眼看出什么是“区间”,这就要靠仔细观察,造出“区间”来。例如:
案例:POJ 3321 Apple Tree,题目意思是在一颗苹果树中有多个分叉点,每个分叉点及末梢可能有苹果(最多1个),每次可以摘掉一个苹果,或有一个苹果新长出来,随时查询某个分叉点往上的子树里,一共有多少个苹果(分叉点数量:100,000)。
题目乍一看好像没有发现什么能用得上线段树的地方,但其实我们可以配合深度优先遍历的特性来去构造一个个连续的区间,根据深度优先遍历的特性我们可以为每一个叉点编号,并且可以知道每一个叉点上面有多少叉点,设当前叉点编号为n,叉点上面有m个叉点,则一定会有包括当前叉点的连续区间[n,n+m]表示当前叉点及子树中所有的叉点。所以可以先深度遍历整个苹果树,然后再用线段树解决问题。
代码如下:
#include<iostream>
#include<cstdio>
#include<climits>
using namespace std;
class Add
{
public:
int left,right,mid,tallest,shortest;
};
void BuildTrea(int l,int r,int n);
void InsertNumber(int n);
void Query(int l,int r,int n,int *max,int *min);
Add a[200040];
int x[50010];
int main()
{
int max,min;
int n,m,i,r,l;
scanf("%d%d",&n,&m);
for(i=1;i<=n;i++)
scanf("%d",&x[i]);
BuildTrea(1,n,0);
InsertNumber(0);
while(m--)
{
max=INT_MIN;
min=INT_MAX;
scanf("%d%d",&l,&r);
Query(l,r,0,&max,&min);
printf("%d\n",max-min);
}
return 0;
}
void BuildTrea(int l,int r,int n)
{
int mid=(l+r)/2;
a[n].left=l;
a[n].right=r;
a[n].mid=mid;
a[n].shortest=INT_MAX;
a[n].tallest=INT_MIN;
if(l==r)
return;
BuildTrea(l,mid,n*2+1);
BuildTrea(mid+1,r,n*2+2);
}
void InsertNumber(int n)
{
if(a[n].left==a[n].right)
{
a[n].shortest=a[n].tallest=x[a[n].left];
return;
}
InsertNumber(n*2+1);
InsertNumber(n*2+2);
if(a[n*2+1].tallest>a[n*2+2].tallest)
a[n].tallest=a[n*2+1].tallest;
else
a[n].tallest=a[n*2+2].tallest;
if(a[n*2+1].shortest<a[n*2+2].shortest)
a[n].shortest=a[n*2+1].shortest;
else
a[n].shortest=a[n*2+2].shortest;
}
void Query(int l,int r,int n,int *max,int *min)
{
if(l==a[n].left&&r==a[n].right)
{
if(a[n].tallest>*max)
*max=a[n].tallest;
if(a[n].shortest<*min)
*min=a[n].shortest;
return;
}
if(*min<=a[n].shortest&&*max>=a[n].tallest)
return;
if(r<=a[n].mid)
Query(l,r,n*2+1,max,min);
else if(l>a[n].mid)
Query(l,r,n*2+2,max,min);
else
{
Query(l,a[n].mid,n*2+1,max,min);
Query(a[n].mid+1,r,n*2+2,max,min);
}
}
//max和min设置成全局变量OJ编译错误
注:以上总结部分内容来自于北京大学ACM暑期课程资料,总结只是为了方便自己查阅&和大家交流=.=
本文固定链接:http://blog.youkuaiyun.com/fyfmfof/article/details/39678235