线段树
线段树的思想和分治思想很相像。
线段树的每一个节点都储存着一段区间
这样一来,每一次修改、查询的时间复杂度都只为
但是,可以用线段树维护的问题必须满足区间加法,否则是不可能将大问题划分成子问题来解决的。
区间加法
一个问题满足区间加法,仅当对于区间
经典的区间加法问题有:
- 区间求和
- 区间最值
不满足区间加法的问题有:
- 区间的众数
- 区间的最长不下降子序列
原理
线段树主要是把一段大区间平均地划分成两段小区间进行维护,再用小区间的值来更新大区间。这样既能保证正确性,又能使时间保持在
下图就是一棵

可以发现,这棵线段树的最大深度不超过 $ [log_2(n−1)]+2
(其中
实现
注:所有代码中的
存储
线段树的存储有很多种,既可以用数组存,也可以用结构体存,但是线段树的存储原理和堆基本相同,即线段树满足以下几条性质:
- 编号为
的节点的左儿子编号为 ,右儿子编号为 ,父节点编号为 k>>1 $ ;(堆的性质) - 线段树每个点存储的是一段区间,每次用过调用区间的左右端点来实现操作。
通常线段树每个点存这几个东西:区间左端点,区间右端点,区间特征值,下传标记(后面会讲到)。
我们以求一段区间的和为例:
struct node
{
int l;//左端点
int r;//右端点
int sum;//区间的和
int lazy;//下传标记(后面会讲到)
}tree[maxn<<2];//开4倍大小
上传状态
假如修改了线段树的子节点,由于其父节点包含这个子节点,所以父节点也要更新,故从当前位置一次向上更新。
void pushup(int rt)//rt表示当前位置
{
tree[rt].sum=tree[rt<<1].sum+tree[rt<<1|1].sum;//父节点的区间和等于其子节点区间和的和
}
初始化
线段树的初始化只用二分向下查找,直到叶结点,查找过程中给当前区间包含的信息赋值即可。
void build(int l,int r,int rt,int val)//l,r代表区间的左右端点,rt代表当前位置,val代表赋的值
{
tree[rt].l=l;
tree[rt].r=r;
if(l==r)//找到叶结点
{
tree[rt].sum=val;
return ;
}
int mid=(l+r)>>1;
build(l,mid,rt<<1,val);
build(mid+1,r,rt<<1|1,val);//向下二分查找
pushup(rt);//上传状态
}
单点修改
当我们要把下标为
void update(int p,int val,int rt)//p是要修改节点的编号,val是要修改成的值,rt当前节点
{
if(tree[rt].l==tree[rt].r)//如果当前区间只包含一个元素,那么该元素一定就是我们要修改的。
{
tree[rt].sum=val;
return ;
}//由于该区间的sum一定等于编号为x的数字,所以直接修改sum就可以了。
int mid=(tree[rt].l+tree[rt].r)>>1;
if(p<=mid)
update(p,val,rt<<1);
else
update(p,val,rt<<1|1);
pushup(rt);
}
区间修改
其实如果会了单点修改的话,区间修改就不会太难理解了。
区间修改大体可以分为两步:
- 找到区间中全部都是要修改的点的线段树中的区间
- 修改这一段区间的所有点
先来解决第一步:
我们先从根节点出发(根节点一定包含所有的点,包括被修改区间),一直往下走,直到当前区间中的元素全部都是被修改元素。
当左区间包含整个被修改区间时,我们就递归到左区间;
当右区间包含整个被修改区间时,我们就递归到右区间;
否则,情况一定就如下图所示:

怎么办?这种情况似乎有些难了。
不过,通过思考,我们可以发现,被修改区间中的元素间,两两之间都不会产生影响。
所以,我们可以把被修改区间分解成两段,使得其中的一段完全在左区间,另一端完全在右区间。
很明显,直接在mid的位置将该区间切开是最好的。如下图所示:

通过一系列的玄学操作,我们成功地把修改区间分解成一段一段的。但问题来了:我们怎样修改这些区间呢?
最暴力的做法是每一次都像建树一样,遍历区间内的所有节点,一一修改。但是这样的时间复杂度显然
这里就要引入一样新的神奇的东西——懒惰标记!
懒惰标记
标记的含义:本区间已经被更新过了,但是子区间却没有被更新过,被更新的信息是什么(区间求和只用记录有没有被访问过,而区间加减乘除等多种操作的问题则要记录进行的是哪一种操作)
这里再引入两个很重要的东西:相对标记和绝对标记。
相对标记和绝对标记
相对标记指的是可以共存的标记,且打标记的顺序与答案无关,即标记可以叠加。 比如说给一段区间中的所有数字都
绝对标记是指不可以共存的标记,每一次都要先把标记下传,再给当前节点打上新的标记。这些标记不能改变次序,否则会出错。 比如说给一段区间的数字重新赋值,或是给一段区间进行多种操作。
有了懒惰标记这种神奇的东西,我们区间修改时就可以偷一下懒,先修改当前节点,然后直接把信息挂在节点上就可以了!
如下面这棵线段树,当我们要修改区间

这样一来,我们每一次修改区间时只要找到目标区间就可以了,不用再向下递归到叶节点。
下面是区间
void range_update(int l,int r,int rt,int val)//当前到了编号为rt的节点,要把[l..r]区间中的所有元素的值+val
{
if(a[rt].l==l&&a[rt].r==r)//如果找到了全部元素都要被修改的区间
{
a[rt].sum+=(r-l+1)*x;//更新该区间的sum
a[rt].lazy+=x;//懒惰标记叠加
return;
}
int mid=(a[rt].l+a[rt].r)>>1;
if(r<=mid)
range_update(l,r,rt<<1,val);
//如果被修改区间完全在左区间
else if(l>mid)
range_update(l,r,rt<<1|1,val);
//如果被修改区间完全在右区间
else
{
range_update(lson,x);
range_update(rson,x);
}//如果都不在,就要把修改区间分解成两块,分别往左右区间递归
pushup(rt);//记得更新点k的值
}
请注意:某些题目的懒惰标记属于绝对标记(如维护区间平方和),一定要先下传标记,再向下递归。
下传标记
碰到相对标记这种容易欺负的小朋友,我们只用打一下懒惰标记就可以了。
但是,遇到绝对标记,或是下文提到的区间查询,简单地打上懒惰标记就明显
于是,懒惰标记的下传操作就诞生了。
顾名思义,下传标记就是把一个节点的懒惰标记传给它的左右儿子,再把该节点的懒惰标记删去。
我们先来回顾一下标记的含义:
标记的含义:本区间已经被更新过了,但是子区间却没有被更新过,被更新的信息是什么。
显然,父区间是包含子区间的,也就是对于父区间的标记和子区间是有联系的。在大多数情况下,父区间和子区间的标记是相同的。因此,我们可以由父区间的标记推算出子区间应当是什么标记。
注意:以下所说的问题都是指区间赋值,除非有什么特别的申明。
如果要给一个节点中的所有元素重新赋值为x,那么它的儿子也必定要被赋值成x。所以,我们直接在子节点处修改sum值,再把子节点的标记改变一下就可以了(由于区间赋值要用绝对标记,因此当子节点已经有标记时,要先下传子节点的标记,再下穿该节点的标记。但是区间赋值会覆盖掉子节点的值,因此在这个问题中,直接修改标记就可以了)
代码如下:
void pushdown(int rt)//将点k的懒惰标记下传
{
if(a[rt].l==a[rt].r)
{
a[rt].lazy=0;
return;
}//如果节点k已经是叶节点了,没有子节点,那么标记就不用下传,直接删除就可以了
a[rt<<1].sum=(a[rt<<1].r-a[rt<<1].l+1)*a[rt].lazy;
a[rt<<1|1].sum=(a[rt<<1|1].r-a[rt<<1|1].l+1)*a[rt].lazy;//给k的子节点重新赋值
a[rt<<1].lazy=a[rt<<1|1].lazy=a[rt].lazy;//下传点k的标记
a[rt].lazy=0;//记得清空点k的标记
}
区间查询
上面我们很轻松地解决了修改的问题,于是我们就维护了一个完整的在线线段树了。但是光有维护是没用的,我们还要处理询问的问题。最常见的莫过于区间查询了,如询问区间[l…r]中所有数的和。
这其实和区间修改是类似的。我们也分类讨论:
当查找区间在当前区间的左子区间时,递归到左子区间;
当查找区间在当前区间的右子区间时,递归到右子区间;
否则,这个区间一定是跨越两个子区间的,我们就把它切成2块,分在两个子区间查询。最后把答案合起来处理就可以了(如查询区间和时就把两块区间的和加起来,查询最大值时就返回两块区间的最大值)
最后强调一个细节:记得在查询之前下传标记!!!
下面贴上查询区间和的代码:
int query(int l,int r,int rt)//当前到了编号为rt的节点,查询[l..r]的和
{
if(a[rt].l==l&&a[rt].r==r)
return a[rt].sum;//如果当前区间就是询问区间,完全重合,那么显然可以直接返回
int mid=(a[rt].l+a[rt].r)>>1;
if(a[rt].lazy)
pushdown(rt);//如果当前节点被打上了懒惰标记,那么就把这个标记下传,这一句其实也可以放在下一语句的后面
if(r<=mid)
return query(rt<<1,l,r);//如果询问区间包含在左子区间中
if(l>mid)
return query(rt<<1|1,l,r);//如果询问区间包含在右子区间中
return query(lson)+query(rson);//如果询问区间跨越两个子区间
}
扫描线
扫描线,就是一根假想的线,从左到右的一条竖线扫描过去。
扫描线的用途很简单,就是通过算出几何图形中每一条边来得到这个几何图形的面积或者周长。
比如说下面这个图形:

计算这两个矩形的面积等价于计算红色,绿色,蓝色三块的面积的和。
我们用扫描线在竖直方向上扫,为了避免横坐标范围过大,所以先进行离散化。
在进行扫描前。要保存好全部矩形的上边和下边。而且依照它们所处的高度进行排序,另外我们给上边赋值为
然后将扫描线从下往上扫描。每遇到一条上边或者下边就停下来,将这条线段增加到总区间上,下边赋值是
然后用下一条边的高度减去当前这条边的高度,乘上总区间被覆盖的长度,就能得到矩形的面积。
来一道板子题:FZOJ1850图形面积
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
#define lson l,mid,rt<<1
#define rson mid+1,r,rt<<1|1
using namespace std;
long long n;
long long x[201];
struct tree
{
long long l;//左端点
long long r;//右端点
long long h;//高度
long long mark;//判断上下边的标记
}e[1010];
struct node
{
long long l;//左端点
long long r;//右端点
long long len;//有效覆盖长度
long long mark;//判断当前区间是否完全被覆盖
}s[1010];
long long cmp(tree a,tree b)
{
return a.h<b.h;
}
void build(long long l,long long r,long long rt)
{
s[rt].l=l;
s[rt].r=r;
s[rt].len=0;
s[rt].mark=0;
if(l==r)
return ;
long long mid=(s[rt].l+s[rt].r)>>1;
build(lson);
build(rson);
}
void pushup(long long rt)
{
if(s[rt].mark)
s[rt].len=x[s[rt].r+1]-x[s[rt].l];
else if(s[rt].l==s[rt].r)
s[rt].len=0;
else
s[rt].len=s[rt<<1].len+s[rt<<1|1].len;
}
void update(long long l,long long r,long long rt,long long flag)
{
if(s[rt].l==l&&s[rt].r==r)
{
s[rt].mark+=flag;
pushup(rt);
return ;
}
long long mid=(s[rt].l+s[rt].r)>>1;
if(r<=mid)
update(l,r,rt<<1,flag);
else if(l>mid)
update(l,r,rt<<1|1,flag);
else
{
update(lson,flag);
update(rson,flag);
}
pushup(rt);
}
int main()
{
scanf("%lld",&n);
long long x1,y1,x2,y2;
long long cnt=0;
for(int i=1;i<=n;i++)
{
scanf("%lld%lld%lld%lld",&x1,&y1,&x2,&y2);
e[cnt].l=e[cnt+1].l=x1;
e[cnt].r=e[cnt+1].r=x2;
e[cnt].h=y1;
e[cnt+1].h=y2;
e[cnt].mark=1;
e[cnt+1].mark=-1;
x[cnt]=x1;
x[cnt+1]=x2;
cnt+=2;
}
sort(e,e+cnt,cmp);
sort(x,x+cnt);
long long tot=1;
long long ans=0.0;
for(int i=1;i<cnt;i++)
if(x[i]!=x[i-1])
x[tot++]=x[i];
build(0,tot-1,1);
for(int i=0;i<cnt;i++)
{
long long l=lower_bound(x,x+tot,e[i].l)-x;
long long r=lower_bound(x,x+tot,e[i].r)-x-1;
update(l,r,1,e[i].mark);
ans+=(e[i+1].h-e[i].h)*s[1].len;
}
printf("%lld",ans);
return 0;
}
动态开点
其实原理很简单,就是什么时候用到这个点再开点,防止一些卡空间的题。
例如我们以
void pushdown(int l,int r,int rt)
{
if(lazy[rt])
{
int mid=(l+r)>>1;
if(l!=r)
{ //不是叶子节点则需要开辟左节点或右节点
if(s[rt].l==0)
s[rt].l=++cnt; //从而把标记传下去
if(s[rt].r==0)
s[rt].r=++cnt;
s[rt<<1].lazy=s[rt].lazy;
s[rt<<1|1].lazy=s[rt].lazy;
s[rt<<1].sum=(mid-l+1)*s[rt].lazy;
s[rt<<1|1].sum=(r-mid)*s[rt].lazy;
}
s[rt].lazy=-1;
}
}
void range_update(int L,int R,int p,int l,int r,int &rt)
{
if(!rt)
rt=++cnt; //动态开点就是什么时候用什么时候开点
if(L<=l&&r<=R)
{
s[rt].lazy=p;
s[rt].sum=p*(r-l+1);
return;
}
pushdown(l,r,rt);
int mid=(l+r)>>1;
if(L<=mid)
range_update(L,R,p,lson);
if(R>mid)
range_update(L,R,p,rson);
s[rt].sum=s[rt<<1]+sum[rt<<1|1];
}
权值线段树
权值线段树之所以会带上“权值”二字,是因为它是记录权值的线段树。因此需要用到离散化操作来处理

其中
其实就相当于一个桶。
线段树合并
线段树合并,一般来说是与线段树动态开点一起用的,它的作用将两棵线段树所统计的信息整合到一棵线段树上。(由于多棵线段树会导致空间复杂度极高,所以线段树合并一般会与动态开点一起使用)。
线段树通过用并查集的思想合并。
我们在合并线段树的时候,肯定不可能一个点一个点的合并,那么我们就需要将一些无用的合并操作去掉。由于我们是动态开点,那么无用的操作指的就是,两棵线段树中有一棵未开辟的节点(或两棵都未开辟),那么直接返回已经开辟了的那一棵就可以了。
int merge(int x,int y)
{
if(x==0||y==0)//若这棵线段树还没开辟,直接返回开辟那一棵的节点
return x+y;//因为其中一棵编号为0,直接用另外一棵加上0就可以了。
tree[x].l=merge(e[x].l,e[y].l);
tree[x].r=merge(e[x].r,e[y].r);//合并
pushup(x);
return x;
}
一道合并的题FZOJ2048永无乡
#include<stdio.h>
#include<string.h>
#define ll long long
#define lson l,mid,rt<<1
#define rson mid+1,r,rt<<1|1
struct node
{
int u;
int v;
}s[201010];
struct tree
{
int l;
int r;
int sum;
}e[2001010];
int num;
int root[201010];
int rank[201010],w[201010],fa[201010];
int n,m,q,cnt;
int getfa(int x)
{
if(fa[x]==x)
return x;
return fa[x]=getfa(fa[x]);
}
void pushup(int rt)
{
e[rt].sum=e[e[rt].l].sum+e[e[rt].r].sum;
}
void add(int l,int r,int &rt,int x)
{
if(!rt)
rt=++cnt;
if(l==r)
{
e[rt].sum=1;
return ;
}
int mid=(l+r)>>1;
if(x<=mid)
add(l,mid,e[rt].l,x);
else
add(mid+1,r,e[rt].r,x);
pushup(rt);
}
int query(int l,int r,int rt,int val)
{
if(l==r)
return l;
int mid=(l+r)>>1;
if(e[e[rt].l].sum>=val)
return query(l,mid,e[rt].l,val);
else
return query(mid+1,r,e[rt].r,val-e[e[rt].l].sum);
}
int merge(int x,int y)
{
if(x==0||y==0)
return x+y;
e[x].l=merge(e[x].l,e[y].l);
e[x].r=merge(e[x].r,e[y].r);
pushup(x);
return x;
}
void Union(int u,int v)
{
u=getfa(u);
v=getfa(v);
if(u==v)
return ;
root[u]=merge(root[u],root[v]);
fa[v]=u;
}
int main()
{
char s[31];
int u,v,cnt;
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++)
{
scanf("%d",&w[i]);
fa[i]=i;
rank[w[i]]=i;
}
for(int i=1;i<=n;i++)
add(1,n,root[i],w[i]);
for(int i=1;i<=m;i++)
{
scanf("%d%d",&u,&v);
Union(u,v);
}
scanf("%d",&q);
for(int i=1;i<=q;i++)
{
scanf("%s",s);
scanf("%d%d",&u,&v);
if(s[0]=='B')
Union(u,v);
else
{
cnt=root[getfa(u)];
if(e[cnt].sum<v)
printf("%d\n",-1);
else
printf("%d\n",rank[query(1,n,cnt,v)]);
}
}
return 0;
}
资料引用来源
-
“Alexander__菜鸡”大佬线段树详解
-
“Stupid_Turtle”大佬权值线段树、主席树学习
-
“一个不愿透露姓名的OIER”大佬线段树合并的思想和应用
-
“lxjshuju”大佬线段树 + 扫描线加深具体解释
感谢以上大佬的博客提供知识来源