线段树及其常见扩展

线段树

概述

线段树是维护区间问题的利器,可以完成许许多多的区间问题,轻松完成区间修改区间统计,前提是只能维护满足区间可加性的信息

线段树基于分治思想,即将一个\([1,n]\)的区间按照对半分,这样分成\(O(n)\)个节点。在线段树上,每一个节点代表一个区间,维护其内的信息,于是按照对半分的思想,我们可以建立出一颗线段树

struct node{  
    int l,r,mx,sum;//维护区间最大值和区间和
}
#define ls x<<1
#define rs x<<1|1
void pushup(int x){  
    t[x].sum=t[ls].sum+t[rs].sum;
    t[x].mx=max(t[ls].mx,t[rs].mx);
}
void build(int x,int l,int r){
    t[x].l=l,t[x].r=r;    
    if(l==r){  
       t[x].mx=t[x].sum=a[l];
       return ;
    }
    int mid=l+r>>1;
    build(ls,l,mid);
    build(rs,mid+1,r);
    pushup(x);
}

在具体实现的过程中,由于线段树是一颗接近满二叉树的形态,最多只会浪费最底下的节点,于是我们可以按照堆的处理方式,以\(2x\)\(x\)的左儿子,\(2x+1\)\(x\)的右儿子

基本操作

线段树的基本操作有:

  1. 区间查询
  2. 单点修改
  3. 区间修改(见lazy标记部分)
  4. 区间信息统计
    与树状数组不同的是,线段树可以自由的维护和,最大值,积……修改也不仅仅支持加,也支持×,开方,……,几乎只要是满足区间可加性的信息都可以使用线段树维护

区间查询

对于区间查询,我们的步骤为:找到对应所需区间的节点,对这些区间的信息进行拼凑得出答案
下面的程序以查询区间最大值为例

int find(int x,int l,int r){  
    if(l<=t[x].l&&t[x].r<=r)return t[x].mx;
    int ans=0xcfcfcfcf;
    int mid=t[x].l+t[x].r>>1;
    if(l<=mid)ans=max(ans,find(ls,l,r));
    if(mid<r)ans=max(ans,find(rs,l,r));
    return ans;
}

单点修改

我们只需要递归找到线段树中这个点,进行修改,然后回溯的时候更新信息即可

void update(int x,int xb,int d){  
    if(t[x].l==t[x].r){  
        t[x].mx=t[x].sum=d;
        return ;
    }
    int mid=t[x].l+t[x].r>>1;
    if(xb<=mid)update(ls,xb,d);
    else update(rs,xb,d);
    pushup(x);
}

lazy标记

懒标记是我们为了方便区间修改引入的一个特殊东东,对于懒标记的出现原因是这样的,我们很多时候有可能修改了一个地方就不用了,这时候暴力去修改整个区间就显得很浪费,于是就需要使用一种延迟修改的思想,懒标记线段树的大常数应运而生。

概述,懒标记是我们的一个标记,根据懒标记的信息可以使得我们知道整个区间下一步该如何修改,需要注意的是,懒标记存放的节点,这个节点自身是修改完毕的,只是这个节点的子节点没有修改,在较为复杂的题目中,懒标记可能有多个,此时需要注意各个懒标记之间相互影响的关系

为了代码的方便,我们一般使用一个专门的函数下放懒标记

以一个最简单的为例,区间加法懒标记,对于整个区间加法,我们使用一个懒标记,就可以知道整个区间需要加上多少

//在node定义里加一个lz,作为懒标记
#define len(x) (t[x].r-t[x].l+1)
void pushdown(int x){  
    if(!t[x].lz)return ;
    int z=t[x].lz;
    t[x].lz=0;//消除懒标记
    t[ls].lz+=z,t[rs].lz+=z;//下放懒标记,需要注意的是因为加法是具有交换律,于是+=,对于区间赋值的问题,需要=,以题目具体分析
    t[ls].mx+=z,t[rs].mx+=z;//统计影响
    t[ls].sum+=z*len(ls),t[rs].sum+=z*len(rs);
}
//初始化懒标记为0
void change(int x,int l,int r,int d){//区间[l,r]全部+d  
    if(l<=t[x].l&&t[x].r<=r){  
        t[x].sum+=len(x)*d;
        t[x].mx+=d;
        t[x].lz+=d;
        return ;
    }
    pushdown(x);//递归子节点之前下放懒标记
    int mid=t[x].l+t[x].r>>1;
    if(l<=mid)change(ls,l,r,d);
    if(mid<r)change(rs,l,r,d);
    pushup(x);//下放之后向上统计,及时更新
}

在执行统计修改等操作时,必须在递归子节点之前pushdown(x)

扫描线

例题1:矩形面积并
给定\(n\)个四元组\((x_1,y_1,x_2,y_2)(0\le x_1<x_2,0\le y_1<y_2)\),每一个四元组描述一个矩形,这些矩形之间可能会相互重合,求所有矩形的面积并

对于这个问题,我们有一个经典的思路,即:
我们发现,若我们将所有的四元组中的\(x\)坐标进行排序,从小到大分别记为\(x_1,x_2……x_{2n}\),那么由于矩形的面积计算公式:\(S=|x_1-x_2|\times |y_1-y_2|\),也即长乘宽。若我们在\(|x_1-x_2|\)相同的情况下,可以利用乘法分配律计算多个矩形的面积和(无重合),那么我们可以这样思考,若我们找到两个点\(x_i,x_j\),其中\(x_i\)\(x_j\)的严格前驱,也即\(x_i\)是在\(x_j\)前面且离其最近的一个点,那么试想一下,若有一条直线\(x=c\),其中\(c\)为一个常数,那么在\(x_i\le c\le x_j\)的时候,可以看作将直线\(x=x_i\)不断向后平移,直到\(x=x_j\),此时平移过程中,直线\(x=c\)与整个图形重合的线段的长度始终没有变化,因为\(x_i\)\(x_j\)的严格前驱保证了这个性质,下面这张图就是一个例子

于是,我们在排序后的\(x\)序列中\(\forall i\in[1,2n-1]\),都有一对\(x_i,x_{i+1}\)满足上述条件,且正好不重不漏覆盖完整个图形,这样我们就可以设计出一个算法

  1. 将所有的\(x\)按从小到大排序
  2. 从小到大扫描\(\forall i\in[1,2n-1]\),对于每一个二元组\((x_i,x_{i+1})\),统计有多少个矩形\((x_j,x_k,y_p,y_q)\),满足\(x_i\le x_j<x_k\le x_{i+1}\),将所有满足要求的矩形的\(|y_p-y_q|\)累加起来,记为\(sum\)
  3. 将答案累加上\((x_{i+1}-x_i)\times sum\)

最终便可以得到此题的答案,对于这个做法,排序的复杂度是\(O(n\log n)\),而第二步的复杂度是\(O(n)\)的,需要进行\(2n-1\)次,于是便需要\(O(n^2)\)的时间,这个算法仍然不够优秀,有着优化的空间

试想,若我们能够高效维护第二步,这个算法的效率便会有所改善,仔细观察,我们可以发现,对于每一个二元组\((x_i,x_{i+1})\),在统计完成之后对于下一个二元组的答案集合的改动很小,因为每一个\(x\)都代表一条线段,这条线段是所属矩形的前后哪一条边就会影响决策集合的改动,而因为从小到大扫描,我们只需要每一次更改\(x_{i}\)即可,所以说最多增删一条线段,于是乎,我们的问题就变成了如何高效维护关于一条线段的插入删除,而线段树便适合解决此类问题

具体的,线段树的区间修改操作本就相当于是对线段的插入删除,而查询\(sum\)就等于是询问根节点下面所有被覆盖的区间的总长度,至于每一次对\(x_{i+1}\)做出的到底是增还是删就可以使用结构体保存一条线段,四个参数,分别是两个\(y\)一个\(x\)以及一个标识是前面条边还是后面(1/-1),这样我们就可以得到一个\(O(n\log V)\)\(V\)是值域的做法,不过这个做法在空间和时间上仍然有优化空间,即采用离散化的方法,将所有的\(y\)进行离散化,记离散化后的\(y\)值为\(b(y)\)\(raw(i)\)表示被离散化为\(i\)\(y\)值,直接在离散化的时候进行统计即可

需要注意的一个地方是,在线段树的底部会出现长度为1的区间,而在我们的线段覆盖中,长度为1的区间可以看作一个点,那么对于线段与点,势必会存在冲突,于是我们需要充分发扬人类智慧,想办法去消除这个冲突

一个好的解决办法是:对于这个冲突,我们让长度为1的区间\([l,l]\)转为维护实际上的区间\([raw(l),raw(l+1)]\),那么我们在插入线段的时候就需要把较大的\(b(y)\)值减一再插入,而区间\([l,r]\)代表的实际长度也转为\(raw(r+1)-raw(l)\).

对于线段树对线段的统计,我们附加两个信息\(cnt,len\),其中\(cnt\)表示区间\([l,r]\)被整体覆盖的次数,\(len\)表示被覆盖的总长度,那么统计的时候伪代码就是:

if(t.cnt)t.len=raw(t.r+1)-raw(l);
else t.len=lc(t).len+rc(t).len;

本题虽然涉及到区间,但是由于题目的特殊性,不需要懒标记进行维护,因为对于包含同样的\((y_p,y_q)\)的四元组有且只有两个,分别对应了增和删,在增后删前,无论这个区间的子区间进行了怎样的改动,都无法影响到这个区间,从底往上层层推可以得到,子区间的修改无法对父区间产生影响,那么懒标记也就失去了存在的意义

综上,我们得到了一个\(O(n\log n)\)的算法,流程如下:

  1. 建立一颗可以维护线段插入删除的线段树
  2. 将所有的\(y\)排序,并将\(y\)进行离散化,统计\(b,raw\)两个数组,对于所有的\(2n\)条平行于\(y\)轴的线段,使用两个四元组成对存储,记录属于同一个矩形的左还是右线段(\(flag\)变量1/-1)
  3. 将所有的四元组按照以\(x\)为第一关键字从小到大,以\(flag\)为第二关键字从大到小排序
  4. 对于\(i\in[1,2n-1]\),执行先插入以\(x_i\)为横坐标的线段,然后按照\((x_{i+1}-x_i)\times t[1].len\)累加答案,其中\(t[1]\)代表线段树根节点
  5. \(i\)从小到大的顺序重复执行第四步,直到\(i\)越界

在代码实现中,由于例题的原因,\(x,y\)坐标都是浮点数,于是使用\(double\)类型存储

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define int long long
#define db double
using namespace std;
db raw[200005],val[200005];int n,tot,cnt,m,qq;db ans;
struct node{
	int l,r,cnt;db len;
}t[500005];
struct line{
	db yh,yl,x;int flag;//y_high,y_low也即yh>yl
}a[500005];
void pushup(int x){
	if(t[x].cnt)t[x].len=raw[t[x].r+1]-raw[t[x].l];
	else t[x].len=t[x<<1].len+t[x<<1|1].len;
}
void build(int x,int l,int r){
	t[x].l=l,t[x].r=r;
	if(l==r){
		return ;
	}
	int mid=l+r>>1;
	build(x<<1,l,mid);
	build(x<<1|1,mid+1,r);
	t[x].cnt=0,t[x].len=0;
}
void add(int x,int l,int r,int d){
	if(t[x].l>=l&t[x].r<=r){
		t[x].cnt+=d;
		pushup(x); 
		return ;
	}
	int mid=t[x].l+t[x].r>>1;
	if(mid>=l)add(x<<1,l,r,d);
	if(mid<r)add(x<<1|1,l,r,d);
	pushup(x);
}
bool cmp(line a,line b){
	return a.x==b.x?a.flag>b.flag:a.x<b.x;
}
void init(){
	ans=0;tot=cnt=0;
	memset(raw,0,sizeof raw);
	memset(val,0,sizeof val);
	memset(t,0,sizeof t);
	memset(a,0,sizeof a);
}
signed main(){
//	freopen("P5490_3.in","r",stdin);
	while(~scanf("%d",&n)&&n){
		++qq;
		init();
		build(1,1,n<<1);
		db u1,u2,v1,v2;
		for(int i=1;i<=n;i++){
			cin>>u1>>v1>>u2>>v2;
			if(u1>u2)swap(u1,u2);
			if(v1>v2)swap(v1,v2);
			val[++cnt]=v1;
			val[++cnt]=v2;
			a[++tot]={v2,v1,u1,1};
			a[++tot]={v2,v1,u2,-1};
		}
		sort(val+1,val+1+cnt);
		m=unique(val+1,val+1+cnt)-val-1;
		for(int i=1;i<=n<<1;i++){
			int pos1=lower_bound(val+1,val+m+1,a[i].yh)-val;
			int pos2=lower_bound(val+1,val+m+1,a[i].yl)-val;
			raw[pos1]=a[i].yh;
			raw[pos2]=a[i].yl;
			a[i].yh=pos1;
			a[i].yl=pos2;
		}
		sort(a+1,a+n+n+1,cmp);
		for(int i=1;i<n<<1;i++){
			add(1,a[i].yl,a[i].yh-1,a[i].flag);
			ans+=(a[i+1].x-a[i].x)*t[1].len;
		}
		printf("Test case #%d\n",qq);
		printf("Total explored area: %.2f\n\n",ans);
	}
}

这样的以线段扫描思想解决问题的方法被称为扫描线法
例题2:矩形周长并
在求面积并的过程中,实则我们对每一次查询到的\(t[1].len\)值进行求和的话,最终这个值的两倍便是关于矩形与\(y\)轴平行的边的总长度,类似的再以\(x\)离散化之后再次扫描一次的话,两次结果相加便是答案

有一个更简单的办法是,统计我们插入线段树的线段中有多少个不一样的端点(被包含的不算),累加答案的时候将端点数乘上\(x_{i+1}-x_i\),最后就可以得到与\(x\)轴平行的边的总长度(无需×2).

#include <iostream>
#include <cstdio>
#include <algorithm>
#define lson (x << 1)
#define rson (x << 1 | 1)
using namespace std;
const int MAXN=2e4;
int n,X[MAXN<<1];
int x1,y1,x2,y2,pre=0; /* 先初始化为 0 */
struct ScanLine {
	int l,r,h,mark;
	bool operator<(const ScanLine rhs)const{
		if(h==rhs.h)
			return mark>rhs.mark;
	    return h<rhs.h;
	}
//		如果出现了两条高度相同的扫描线,也就是两矩形相邻
//		那么需要先扫底边再扫顶边,否则就会多算这条边
//		这个对面积并无影响但对周长并有影响
} line[MAXN];
struct SegTree {
	int l,r,sum,len,c;
//  c表示区间线段条数
    bool lc,rc;
//  lc,rc分别表示左、右端点是否被覆盖
//  统计线段条数(tree[x].c)会用到
} tree[MAXN << 2];
void build_tree(int x,int l,int r) {
	tree[x].l=l,tree[x].r=r;
	tree[x].lc=tree[x].rc=false;
	tree[x].sum=tree[x].len=0;
	tree[x].c=0;
	if(l == r)
		return;
	int mid=(l+r) >> 1;
	build_tree(lson,l,mid);
	build_tree(rson,mid+1,r);
}
void pushup(int x) {
	int l=tree[x].l,r=tree[x].r;
	if(tree[x].sum) {
		tree[x].len=X[r+1]-X[l];
		tree[x].lc=tree[x].rc=true;
		tree[x].c=1;
//      做好相应的标记
	}
	else {
		tree[x].len=tree[lson].len+tree[rson].len;
		tree[x].lc=tree[lson].lc,tree[x].rc=tree[rson].rc;
		tree[x].c=tree[lson].c+tree[rson].c;
//      如果左儿子左端点被覆盖,那么自己的左端点也肯定被覆盖;右儿子同理
		if(tree[lson].rc && tree[rson].lc)
			tree[x].c -= 1;
//      如果做儿子右端点和右儿子左端点都被覆盖,
//      那么中间就是连续的一段,所以要 -= 1
	}
}
void edit_tree(int x,int L,int R,int c) {
	int l=tree[x].l,r=tree[x].r;
	if(X[l]>=R||X[r+1]<=L)
		return;
	if(L<=X[l]&&X[r+1]<=R) {
		tree[x].sum+=c;
		pushup(x);
		return;
	}
	edit_tree(lson,L,R,c);
	edit_tree(rson,L,R,c);
	pushup(x);
}
int main() {
	scanf("%d",&n);
	for(int i=1; i<=n; i++) {
		scanf("%d%d%d%d",&x1,&y1,&x2,&y2);
		line[i*2-1]={x1,x2,y1,1};
		line[i*2]={x1,x2,y2,-1};
		X[i*2-1]=x1,X[i*2]=x2;
	}
	n <<= 1;
	sort(line+1,line+n+1);
	sort(X+1,X+n+1);
	int tot=unique(X+1,X+n+1)-X-1;
	build_tree(1,1,tot-1);
	int res=0;
	for(int i=1;i<n;i++) {
		edit_tree(1,line[i].l,line[i].r,line[i].mark);
		res+=abs(pre-tree[1].len);
		pre=tree[1].len;
//      统计横边
		res+=2*tree[1].c*(line[i+1].h-line[i].h);
//      统计纵边
	}
	res+=line[n].r-line[n].l;
//  特判一下枚举不到的最后一条扫描线
	printf("%d",res);
	return 0;
}

扩展应用

+×标记混合双打

例题:线段树模板2

如题,已知一个数列,你需要进行下面三种操作:

  • 将某区间每一个数乘上 \(x\)

  • 将某区间每一个数加上 \(x\)

  • 求出某区间每一个数的和

第一行包含三个整数 \(n,m,p\),分别表示该数列数字的个数、操作的总个数和模数。

第二行包含 \(n\) 个用空格分隔的整数,其中第 \(i\) 个数字表示数列第 \(i\) 项的初始值。

接下来 \(m\) 行每行包含若干个整数,表示一个操作,具体如下:

操作 \(1\): 格式:1 x y k 含义:将区间 \([x,y]\) 内每个数乘上 \(k\)

操作 \(2\): 格式:2 x y k 含义:将区间 \([x,y]\) 内每个数加上 \(k\)

操作 \(3\): 格式:3 x y 含义:输出区间 \([x,y]\) 内每个数的和对 \(p\) 取模所得的结果

分析

简单的说,我们需要维护两个懒标记,其实两个懒标记并不复杂,我们只需要找到两个之间的关系即可,即我们需要解决三个问题

  1. 新加入加法懒标记的时候

  2. 新加入乘法懒标记的时候

  3. 下传标记的时候
    下面我们来分别解决这些问题

新加入加法懒标记的时候,直接累加上原来的加法懒标记即可,无需管乘法
2.
新加入乘法懒标记的时候,需要将加法懒标记和乘法懒标记都乘上这个值
3.
下传的时候先乘再加,因为加法懒标记已经乘过了

inline void push_down(ll i){
    tree[i<<1].sum=(ll)(tree[i].mul*tree[i<<1].sum+((tree[i<<1].r-tree[i<<1].l+1)*tree[i].add)%mod)%mod;//先将原来的和乘,然后加
    tree[i<<1|1].sum=(ll)(tree[i].mul*tree[i<<1|1].sum+((tree[i<<1|1].r-tree[i<<1|1].l+1)*tree[i].add)%mod)%mod;
    tree[i<<1].mul=(ll)(tree[i<<1].mul*tree[i].mul)%mod;
    tree[i<<1|1].mul=(ll)(tree[i<<1|1].mul*tree[i].mul)%mod;
    tree[i<<1].add=(ll)(tree[i<<1].add*tree[i].mul+tree[i].add)%mod;
    tree[i<<1|1].add=(ll)(tree[i<<1|1].add*tree[i].mul+tree[i].add)%mod;
    tree[i].mul=1; tree[i].add=0;
}
inline void add(ll i,ll l,ll r,ll k){
    if (tree[i].l>=l && tree[i].r<=r){
        tree[i].add=(ll)(tree[i].add+k)%mod;
        tree[i].sum=(ll)(tree[i].sum+k*(tree[i].r-tree[i].l+1))%mod;
        return;
    }
    push_down(i);
    if (tree[i<<1].r>=l)add(i<<1,l,r,k);
    if (tree[i<<1|1].l<=r)add(i<<1|1,l,r,k);
    tree[i].sum=(tree[i<<1].sum+tree[i<<1|1].sum)%mod;
}
inline void mult(ll i,ll l,ll r,ll k){
    if (tree[i].l>=l && tree[i].r<=r){
        tree[i].mul=(tree[i].mul*k)%mod;
        tree[i].add=(tree[i].add*k)%mod;
        tree[i].sum=(tree[i].sum*k)%mod;
        return;
    }
    push_down(i);
    if (tree[i<<1].r>=l)mult(i<<1,l,r,k);
    if (tree[i<<1|1].l<=r)mult(i<<1|1,l,r,k);
    tree[i].sum=(tree[i<<1].sum+tree[i<<1|1].sum)%mod;
}

区间最大子段和

注:以下的最大子段和都是连续一段
例题:小白逛公园

以区间可加性的方向思考,想想在合并时,我们的区间最大子段和是怎么来的,很明显,存在三种可能性,也即左儿子的区间最大子段和,右儿子的区间最大子段和,以及左右儿子拼起来的中间部分的最大子段和

我们重点讨论第三种拼凑情况,很明显,最优的解法就是左儿子的以左儿子维护区间的右端点为起点往左走的最大子段和,右儿子的以右儿子的左端点为起点向右的最大子段和将其拼起来,这启发我们维护一个区间从左起的最大子段和,从右起的最大子段和以及整体的最大子段和,我们设\(sum,maxx,lmax,rmax\)分别表示区间和,区间最大子段和,区间从左起最大子段和,区间从右起最大子段和,明显有

\[t[x].maxx=max(t[ls].rmax+t[rs].lmax,max(t[ls].maxx,t[rs].maxx)) \]

\[t[x].lmax=max(t[ls].lmax,t[ls].sum+t[rs].lmax) \]

\[t[x].rmax=max(t[rs].rmax,t[rs].sum+t[ls].rmax) \]

\[t[x].sum=t[ls].sum+t[rs].sum \]

注:以上式子中的\(ls,rs\)表示左右儿子
这样便可以维护出最大子段和,若允许不选择的话,需要在\(max\)选项中加一个0

这样的做法本质上就支持单点修改

若涉及到区间修改,在不增加复杂度的情况下,只能支持区间赋值
对于区间赋值操作,直接判断正负值来修改\(lmax,rmax,maxx\),最后合并即可

/*
在代码中我直接使用了ls,rs,ms表示lmax,rmax,maxx,主要是懒
*/
struct node {
	int l, r, ls, rs, ms, sum;
}tree[5000005];
void pushup(int s) {
	tree[s].sum = tree[s << 1].sum + tree[s << 1 | 1].sum;
	tree[s].ls = max(tree[s << 1].ls, tree[s << 1 | 1].ls + tree[s << 1].sum);
	tree[s].rs = max(tree[s << 1 | 1].rs, tree[s << 1].rs + tree[s << 1 | 1].sum);
	tree[s].ms = max(max(tree[s << 1].ms, tree[s << 1 | 1].ms),tree[s<<1].rs+tree[s<<1|1].ls);
}
void build(int l, int r, int s) {
	tree[s].l = l, tree[s].r = r;
	if (l == r) {
		int a;
		cin >> a;
		tree[s].ls = tree[s].rs = tree[s].sum = tree[s].ms = a;
		return;
	}
	int mid = l + r >> 1;
	build(l, mid, s << 1);
	build(mid + 1, r, s << 1 | 1);
	pushup(s);
}
void update(int dis, int k,int s) {
	if (tree[s].l == tree[s].r) {
		tree[s].ls = tree[s].rs = tree[s].ms = tree[s].sum = k;
		return; 
	}
	if (dis <= tree[s << 1].r && dis >= tree[s << 1].l) {
		update(dis, k, s << 1);
	}
	else {
		update(dis, k, s << 1 | 1);
	}
	pushup(s);
}

权值线段树

例题:普通平衡树
题目描述
您需要写一种数据结构(可参考题目标题),来维护一些数,其中需要提供以下操作:
插入x数
删除x数(若有多个相同的数,因只删除一个)
查询x数的排名(排名定义为比当前数小的数的个数+1。若有多个相同的数,因输出最小的排名)
查询排名为x的数
求x的前驱(前驱定义为小于x,且最大的数)
求x的后继(后继定义为大于x,且最小的数)

分析
类似于权值树状数组,权值线段树也是对于值域进行维护的线段树结构,可以支持值域上的线段树操作

对于以上六种操作,我们分别阐述

  1. 权值线段树基本操作,单点修改加一
  2. 基本操作,单点修改,位置权值减一
  3. 直接查找这个数前面的数的总数,再加一即可
  4. 这个因为线段树本身的分治结构,可以从根节点开始类似平衡树的二分
  5. 二分查找即可
  6. 二分查找即可

总得来说,对于操作4,5,6需要我们进行套一个二分,这就使得我们操作很麻烦,需要付出额外的\(O(\log n)\)倍的时间

于是总的复杂度为\(O(n\log^2 n)\)
需要注意进行一次离散化

#include<bits/stdc++.h>
using namespace std;
#define ll long long
#define lson l,m,rt<<1
#define rson m+1,r,rt<<1|1
typedef pair<int,int>P;
const int INF=0x3f3f3f3f;
const int N=100005;
int num[N];
struct A{
    int opt,x;
}q[N];
int tree[N<<2];
 
void push_up(int rt){
    tree[rt]=tree[rt<<1]+tree[rt<<1|1];
}
 
void update(int p,int c,int l,int r,int rt){
    if(l==r){
        tree[rt]+=c;
        return ;
    }
    int m=(l+r)>>1;
    if(p<=m)update(p,c,lson);
    else update(p,c,rson);
    push_up(rt);
}
 
int query1(int L,int R,int l,int r,int rt){//区间求和
    if(L<=l&&R>=r){
        return tree[rt];
    }
    int m=(l+r)>>1;
    int ans=0;
    if(L<=m)ans+=query1(L,R,lson);
    if(R>m)ans+=query1(L,R,rson);
    return ans;
}
 
int query2(int k,int l,int r,int rt){//查询排名为k的数
    if(l==r){
        return l;
    }
    int m=(l+r)>>1;
    if(k<=tree[rt<<1])return query2(k,lson);
    else return query2(k-tree[rt<<1],rson);
}
 
 
int main(){
    int m,k=0;
    scanf("%d",&m);
    for(int i=0;i<m;i++){
        scanf("%d%d",&q[i].opt,&q[i].x);
        if(q[i].opt!=4)num[k++]=q[i].x;
    }
    sort(num,num+k);
    int n=unique(num,num+k)-num;
 
    for(int i=0;i<m;i++){
        int x=lower_bound(num,num+n,q[i].x)-num+1;
        if(q[i].opt==1){//插入
            update(x,1,1,n,1);
        }
        if(q[i].opt==2){//删除
            update(x,-1,1,n,1);
        }
        if(q[i].opt==3){//查询x的排名
            if(x-1==0)printf("1\n");
            else printf("%d\n",query1(1,x-1,1,n,1)+1);
        }
        if(q[i].opt==4){//查询排名为x的数
            printf("%d\n",num[query2(q[i].x,1,n,1)-1]);
        }
        if(q[i].opt==5){//求小于x的最大的数的值
            int rk=query1(1,x-1,1,n,1);
            printf("%d\n",num[query2(rk,1,n,1)-1]);
        }
        if(q[i].opt==6){//求大于x的最小的数的值
            int sum=query1(1,x,1,n,1);
            printf("%d\n",num[query2(sum+1,1,n,1)-1]);
        }
    }

维护\(\sqrt{[l,r]}\)

例题:上帝造题的七分钟2

即为支持区间开根号操作,以及区间求和操作
第一行一个整数 \(n\),代表数列中数的个数。

第二行 \(n\) 个正整数,表示初始状态下数列中的数。

第三行一个整数 \(m\),表示有 \(m\) 次操作。

接下来 \(m\) 行每行三个整数 \(k\) \(l\) \(r\)

\(k=0\) 表示给 \([l,r]\)中的每个数开平方(下取整)。

\(k=1\) 表示询问 \([l,r]\) 中各个数的和。

数据中有可能 l>r,所以遇到这种情况请交换 l 和 r。

数列中的数最大不超过\(10^{12}\),最小不小于1

由于根号操作不具有批量操作性,所以说并不好处理,不过开根号然后下取整这个操作具有一定的可操作性

注意到数最大也就是\(10^{12}\)而已,这个范围内的值若是开根号向下取整,都可以在不超过7次内变成1(因为\(10^{12}\)接近\(2^{2^6}\),这个值是开根号开得最多的值),所以说实际如果变成1的数直接忽略的话,我们总的开根号的次数不会超过\(7n\),所以说我们便需要快速找到所有值为1的区间进行跳过,然后对其他区间进行遍历到子节点暴力修改,为了平衡查找与修改的复杂度,我们采用线段树进行操作,刚刚也说了,总的暴力修改的次数是\(O(n)\)的,所以说再加上排除全是1的区间的单次\(O(\log n)\),我们仍然可以在\(O(n\log n)\)复杂度内维护本题(其实采用树状数组也可以的)

#include<bits/stdc++.h>
#define int long long
#define ls k<<1
#define rs k<<1|1
using namespace std;
const int maxn=1000005;
int n,m,a[maxn],maxx[maxn<<2],sum[maxn<<2];
int read(){
    int x=0,w=1;
    char ch=getchar();
    for(;ch>'9'||ch<'0';ch=getchar()) if(ch=='-') w=-1;
    for(;ch>='0'&&ch<='9';ch=getchar()) x=x*10+ch-'0';
    return x*w;
}
void up(int k){
    maxx[k]=max(maxx[ls],maxx[rs]);
    sum[k]=sum[ls]+sum[rs];
}
void build(int k,int l,int r){
    if(l==r){
        sum[k]=maxx[k]=a[l];
        return;
    }
    int mid=(l+r)>>1;
    build(ls,l,mid);
    build(rs,mid+1,r);
    up(k);
}
void change(int k,int l,int r,int L,int R){
    if(l==r && l>=L && r<=R){
        sum[k]=maxx[k]=sqrt(sum[k]);
        return ;
    }   
    int mid=(l+r)>>1;
    if(L<=mid && maxx[ls]>1) change(ls,l,mid,L,R);
    if(mid<R && maxx[rs]>1) change(rs,mid+1,r,L,R);
    up(k);
}
int query(int k,int l,int r,int L,int R){
    if(L<=l && r<=R) return sum[k];
    int mid=(l+r)>>1;
    int ans=0;
    if(L<=mid) ans+=query(ls,l,mid,L,R);
    if(mid<R) ans+=query(rs,mid+1,r,L,R);
    return ans;
}
signed main(){
        n=read();
        memset(sum,0,sizeof sum);
        memset(maxx,0,sizeof maxx);
        for(int i=1;i<=n;i++) a[i]=read();
        build(1,1,n);
        m=read();
        while(m--){
            int op=read(),l=read(),r=read();
            if(l>r) swap(l,r);
            if(op==0) change(1,1,n,l,r);
            else cout<<query(1,1,n,l,r)<<endl;
        }
        cout<<endl;
}

动态开点与线段树合并

动态开点

动态开点是指在空间限制比较紧的时候,容不得我们浪费最底下一层的空间浪费,此时我们需要动态开点节约空间,即每一个节点记录自己的两个孩子节点编号

为了节约空间,我们可以省略掉原来每一个节点上存储的\(t[x].l,t[x].r\),改为在递归函数中直接计算,并且我们也并不建树,而是改为插入的形式,递归到哪里,哪里没有建树,我们再进行建树

// 动态开点的线段树
struct SegmentTree{
    int lc,rc; // 左右子节点的编号
	int mx;
} t[SIZE<<1];
int root,tot;
int build(){ // 新建一个节点
	t[++tot].lc=t[tot].rc=t[tot].mx=0;
	return tot;
}
// 在main函数中
tot=0;
root=build(); // 根节点
// 单点修改,在val位置加delta,维护区间最大值
void insert(int p,int l,int r,int val,int delta) {
    if(l==r){
        t[p].mx+=delta;
        return;
    }
    int mid=(l+r)>>1; // 代表的区间[l,r]作为递归参数传递
    if(val<=mid){
        if(!t[p].lc)t[p].lc=build(); // 左子树不存在,动态开点
        insert(t[p].lc,l,mid,val,delta);
    }
    else{
        if(!t[p].rc)t[p].rc=build(); // 右子树不存在,动态开点
        insert(t[p].rc,mid+1,r,val,delta);
    }
    t[p].mx=max(t[t[p].lc].mx,t[t[p].rc].mx);
}
// 调用
insert(root,1,n,val,delta);

线段树合并

如果有着若干个长度相同,都为\([1,n]\)的序列,如果将其全部建立成线段树,那么很明显这些线段树关于区间的划分很明显是相同的,若有若干次对不同序列的单点修改操作,我们希望执行完成后对序列的合并,合并即为对应位置相加,同时维护区间最大值,而线段树合并算法就能让整个过程更加高效

详细的说,我们按照两颗线段树同时从根节点出发,记两个指针为\(p,q\),我们按照深度优先遍历的方式对两颗线段树进行同步的遍历,因为是动态开点线段树,所以有可能若在合并的某一步发现\(p/q\)其中一个为空的时候,就直接以另一个作为新的合并后的子树,返回。可以发现,这样做的复杂度与各颗线段树之间重合的点的数量有关,假设我们进行了\(m\)次单点修改,就至多会创建\(O(m\log n)\)个节点,所以复杂度至多为\(O(m\log n)\)

int merge(int p,int q,int l,int r) {
    if(!p)return q; // p,q之一为空
    if(!q)return p;
    if(l==r){ // 到达叶子
        t[p].mx+=t[q].mx;
        return p;
    }
    int mid=(l+r)>>1;
    t[p].lc=merge(t[p].lc,t[q].lc,l,mid); // 递归合并左子树
    t[p].rc=merge(t[p].rc,t[q].rc,mid+1,r); // 递归合并右子树
    t[p].mx=max(t[t[p].lc].mx,t[t[p].rc].mx); // 更新最值
    return p; // 以p为合并后的节点,相当于删除q
}

posted @ 2022-11-30 22:27  spdarkle  阅读(84)  评论(0编辑  收藏  举报