数据结构进阶总结

数据结构总结

分块

分块的基本思想是,通过对原数据的适当划分(通常是 \(\sqrt{len}\) 为一个单位),并在划分后的每一个块上预处理部分信息,从而较一般的暴力算法取得更优的时间复杂度。

分块更多用于在区间操作上,对于一个区间,在分块之后必然可以用 左边的散块+中间完整的块+右边的散块 来表达。每次对散块暴力处理,对于整块可以直接获取信息或查询。

放一个区修区查加和的分块code

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=5e5+5,M=305;
int n;
int a[N];
int pos[N];
int L[M],R[M],tag[M],sum[M];
void build(){
    int block=sqrt(n);
    for(int i=1;i<=block;i++){
        L[i]=(i-1)*block+1;
        R[i]=i*block;
    }
    if(R[block]<n) block++,L[block]=R[block-1]+1,R[block]=n;
    for(int i=1;i<=block;i++){
        for(int j=L[i];j<=R[i];j++){
            pos[j]=i;
            sum[i]+=a[j];
        }
    }
}
void change(int l,int r,int k){
    int x=pos[l],y=pos[r];
    if(x==y){
        for(int i=l;i<=r;i++)a[i]+=k,sum[x]+=k;
        return ;
    }
    for(int i=l;i<=R[x];i++)a[i]+=k,sum[x]+=k;
    for(int i=L[y];i<=r;i++)a[i]+=k,sum[y]+=k;
    for(int i=x+1;i<=y-1;i++){
        tag[i]+=k;
        sum[i]+=k*(R[i]-L[i]+1);
    }
}
int ask(int l,int r,int k){
   int x=pos[l],y=pos[r],ans=0;
    if(x==y){
        for(int i=l;i<=r;i++)ans=(ans+a[i]+tag[x])%k;
        return ans;
    }
    for(int i=l;i<=R[x];i++)ans=(ans+a[i]+tag[x])%k;
    for(int i=L[y];i<=r;i++)ans=(ans+a[i]+tag[y])%k;
    for(int i=x+1;i<=y-1;i++)ans=(ans+sum[i])%k;
    return ans;
}
signed main(){
	cin>>n;
    for(int i=1;i<=n;i++)cin>>a[i];
    build();
    for(int i=1;i<=n;i++){
        int opt,l,r,c;
        cin>>opt>>l>>r>>c;
        if(!opt)change(l,r,c);
        else cout<<ask(l,r,c+1)<<'\n';
    }
	return 0;
}

树状数组

树状数组是一种支持 单点修改区间查询 的,代码量小的数据结构。——OI Wiki

基础认识

什么是树状数组

顾名思义,就是用数组来模拟树形结构。那么衍生出一个问题,为什么不直接建树?答案是没必要,因为树状数组能处理的问题就没必要建树。

树状数组和线段树的区别

树状数组可以解决的问题都可以用线段树解决,线段树可以解决的问题树状数组不一定能解决。但是树状数组优点在于他码量小。

基本结构

\(lowbit\)函数去划分数组使其能构成一棵树的形式。如下图:

img

\(lowbit\) 函数的本质是获取当前数在二进制下最末尾的1和其后面所有的0组成的数\(2^{k}\)。使用位运算的代码就是这样的。

int lowbit(int x){
	return x & -x;
}

树状数组基本性质

  • 性质1: 对于 \(x \le y\) ,要么有 \(c_x\)\(c_y\) 不交,要么有 \(c_x\) 包含于 \(c_y\)
  • 性质2: \(c_x\) 真包含于 \(c_{x+lowbit(x)}\)
  • 性质3: 对于任意 \(x \le y\le x+lowbit(x)\) ,必然有\(c_x\)\(c_y\)不交。

单点修改

现在来考虑如何单点修改 \(a_x\)

我们的目标是快速正确地维护 \(c\) 数组。为保证效率,我们只需遍历并修改管辖了 \(a_x\) 的所有 \(c_y\) ,因为其他的\(c\)显然没有发生变化。

管辖 \(a_x\)\(c_y\) 一定包含 \(c_x\) 。因此我们从 \(x\) 开始不断跳父亲,直到跳得超过了原数组长度为止。

\(n\) 表示 \(a\) 的大小,不难写出单点修改 \(a_x\) 的代码:

void add(int x,int k){
    for(int i=x;i<=n;i++) c[i]+=k;
}

区间查询

对于一个区间查询问题 \([l,r]\) ,可以将其转化成 \([1,r]-[1,l-1]\) 的问题,从而用前缀和的思想进行处理。

那前缀查询怎么做呢?

每次往前跳一个管辖区间,一定是跳到现区间的左端点的左一位,作为新区间的右端点,这样才能将前缀不重不漏地拆分。

$eg. $当查询前缀和时的代码:

int ask(int x){
    int ans=0;
    for(int i=x;i;i-=lowbit(x)) ans+=c[i];
    return ans;
}

权值树状数组

我们知道,普通树状数组直接存贮的是原序列,而权值树状数组则是存贮了原序列中元素出现个数(即以元素信息为下标,每个点存贮该元素出现次数)

可以用权值树状数组解决一些经典问题。

单点修改,查询全局第k小

单点修改

只需将对原数列的单点修改转化为对权值数组的单点修改即可。具体来说,原数组的 \(a_x\)\(y\) 改为 \(z\) ,转化为在权值树状数组上就是 \(b_y\) 单点减1, \(b_z\) 单点加1。

查询第k小

\(x=0,sum=0\) ,枚举 \(i\)\(\log _{2} n\) 降为 0 :

  • 查询权值数组中 $ \left[x+1 \ldots x+2^{i}\right]$ 的区间和 \(t\)
  • 如果 \(sum+t<k\) ,扩展成功, \(x \leftarrow x+2^{i} ,sum \leftarrow sum+t\) ;否则扩展失败,不操作。

这样得到的 x 是满足 \([1 \ldots x]\) 前缀和 \(<k\) 的最大值,所以最终 \(x+1\) 就是答案。
code:

int kth(int k){
  int sum=0,x=0;
  for(int i=log2(n);i;i--){
    x+=1<<i;
    if(x>=n || sum+c[x]>=k) x-=1<<i;
    else sum+=c[x];
  }
  return x+1;
}
全局逆序对(全局二维偏序)

线段树

基础知识

线段树是一种二叉搜索树,可以在线维护修改以及查询区间上的最值,求和。更可以扩充到二维线段树和三维线段树。对于一维线段树来说,每次更新以及查询的时间复杂度为 \(O(logN)\)

在做操作之前,需要先建树

线段树的每个节点p所包含的信息有:

其左右儿子编号(p<<1,p<<1|1),这个节点的信息(视题目而定)

每次在修改时对其进行 \(push\_down\) (信息下放)或 \(push\_up\) (用两个儿子更新此节点)来完成对所有访问到的节点进行更新。

在区间查询时,对于被问题区间包含的节点,直接返回当前节点贮存信息,若未被完全包含,则注意问题区间所覆盖的是左儿子还是右儿子即可。

懒标记优化

在处理区间修改的时,对于一个节点,如果当前节点还没被查询,那就暂时不进行修改操作,制作一个标记放在当前节点上。知道在查询时,若需要访问该节点时,将标记下放到子节点,同时进行修改操作。这就是懒标记。

若对于多种操作,如加和乘,则需要不止1个懒标记同时标记,并且按照一些特定规则(如运算优先级)需要在\(push\_up\)\(push\_down\) 中做修改。

放个模板(区修区查 区间加和区间乘)code:

#include<bits/stdc++.h>
#define ll long long
using namespace std;
const int N=1e5+5,M=10007;
struct node {
    #define lc p<<1
    #define rc p<<1|1
	int l,r;
	ll sum,tag,mul;
} c[N<<2];
int n,m;
ll a[N];
inline int read(){
	int ans=0,j=1;
	char c=getchar();
	while(c>'9' or c<'0'){
		if(c=='-')j=-1;
		c=getchar();
	}
	while(c>='0' and c<='9'){
		ans=ans*10+c-'0';
		c=getchar();
	}
	return ans*j;
}
void build(int p,int l,int r) {
	c[p].l=l,c[p].r=r,c[p].mul=1,c[p].sum=0,c[p].tag=0;
	if(l==r) {
		c[p].sum=a[r];
		return ;
	}
	int mid=l+r>>1;
	build(lc,l,mid);
	build(rc,mid+1,r);
	c[p].sum=c[rc].sum+c[lc].sum;
}
void add_j(int p,int k) {
	c[p].sum+=(c[p].r-c[p].l+1)*k;
	c[p].sum%=M;
	c[p].tag+=k;
	c[p].tag%=M;
}
void add_c(int p,int k) {
	c[p].sum*=k;
	c[p].sum%=M;
	c[p].mul*=k;
	c[p].mul%=M;
	c[p].tag*=k;
	c[p].tag%=M;
}
void pushdown(int p) {
	if(c[p].mul!=1) {
		add_c(lc,c[p].mul);
		add_c(rc,c[p].mul);
		c[p].mul=1;
	}
	if(c[p].tag!=0) {
		add_j(lc,c[p].tag);
		add_j(rc,c[p].tag);
		c[p].tag=0;
	}
}
void change(int p,int x,int y,ll k,int fl) {
	if(x<=c[p].l && y>=c[p].r) {
		if(fl==0)add_j(p,k);
		else add_c(p,k);
		return ;
	}
	pushdown(p);
	int mid=c[p].l+c[p].r>>1;
	if(x<=mid)change(lc,x,y,k,fl);
	if(y>mid)change(rc,x,y,k,fl);
	c[p].sum=(c[lc].sum+c[rc].sum)%M;
}
ll ask(int p,int x) {
	if(c[p].l==c[p].r)return c[p].sum%M;
	pushdown(p);
	int mid=c[p].l+c[p].r>>1;
	if(x<=mid)return ask(lc,x);
	else return ask(rc,x);
}
int main() {
	scanf("%d",&n);
	for(int i=1; i<=n; i++)a[i]=read();
	build(1,1,n);
	for(int i=1; i<=n; i++) {
		int op,x,y;
		ll k;
		op=read(),x=read(),y=read();
		scanf("%lld",&k);
		if(op<=1) {
			change(1,x,y,k,op);
		} else {
			printf("%lld\n",ask(1,y));
		}
	}
	return 0;
}
标记永久化

主要作用: 不做标记下放,优化懒标记的传递开销,避免频繁 \(push\_down\) (标记下放)操作造成的过量时间。

实现 : 在访问时直接加上标记所带来的影响,通过累计标记值计算目标区间的真实结果。

动态开点

这是一个空间方面的优化,考虑到在我们一开始建树时是将每个点预处理出了他们的编号,但是有些实际上根本没有用过。那么我们可以使用动态开点,只对需要使用的(修改过的)点分配编号。每次在修改时进行这个操作就可以了。

他的有一个优点是可以通过修改子节点编号指向来达到更换子节点或移动子节点的目的。

权值线段树

与权值树状数组类似,贮存原序列的元素出现次数。与传统线段树的思路相同。同样用于处理第k小等问题。

线段树的合并与分裂

当用线段树维护多个集合时,可能会在以下情况使用线段树合并与分裂:

  • 需要将连个集合整合为一个大集合

  • 需要将一个集合按照标准分为两个集合。

  • 在这些操作之后仍然可以高效执行区间求和、第 k 大、前驱后继等问题的查询。

线段树合并

实际上就是建立一棵新的线段树,这棵线段树的每个节点都是两棵原线段树对应节点合并后的结果。这就是线段树合并。他常用于维护树上或图上的信息。

显然,我们不可能真的每次建满一颗新的线段树,因此我们需要使用动态开点线段树。

线段树合并的过程:

假设两颗线段树为 \(A\)\(B\) ,我们从根节点开始递归合并。

  • 递归到某个节点时,如果 \(A\) 树或者 \(B\) 树上的对应节点为空,直接返回另一个树上对应节点,这里运用了动态开点线段树的特性。

  • 如果递归到叶子节点,我们合并两棵树上的对应节点。

  • 最后,根据子节点更新当前节点并且返回。

int merge(int x,int y){
    if(x==0 || y==0) return x+y;
    sum[x]+=sum[y];
    lc[x]=merge(lc[x],lc[y]);
    rc[x]=merge(rc[x],rc[y]);
    pushup(x);
    return x;
}

线段树分裂

可以把线段树分裂当成线段树合并的逆过程。通常是有一个分裂的标准且序列必然是有序的,否则线段树分裂毫无意义。

分裂的过程:

对于一颗区间为 \([1,n]\) 的线段树,把区间 \([l,r]\) 分裂出来成为一颗新的线段树。

  • 从根节点开始查找,当节点不存在或当前区间与 \([l,r]\) 无交集时停止。
  • 当前区间与区间 \([l,r]\) 有交集时需要新开一个节点。
  • 当前区间包含于区间 \([l,r]\) ,直接将当前节点分到新树的下面(动态开点的性质)

P5494 [模板]线段树分裂 - 洛谷

code:

#include<bits/stdc++.h>
#define gc() getchar()
#define int long long
using namespace std;
inline int read(){
	int s=0,f=1;char ch=gc();
	while(ch<'0'||ch>'9'){if(ch=='-')f=-1;ch=gc();}
	while(ch>='0'&&ch<='9'){s=(s<<1)+(s<<3)+(ch^48);ch=gc();}
	return s*f;
}
const int N=12000000,M=2e5+5;
int n,m;
int root[M],lc[N],rc[N];
int sum[N],idx,tot=1;
void change(int &p,int l,int r,int x,int k){
	if(!p) p=++idx;
	sum[p]+=k;
	if(l==r) return ;
	int mid=l+r>>1;
	if(x<=mid) change(lc[p],l,mid,x,k);
	else change(rc[p],mid+1,r,x,k);
	sum[p]=sum[lc[p]]+sum[rc[p]];
}
int ask(int p,int l,int r,int x,int y){
	if(!p) return 0;
	if(x<=l && r<=y) return sum[p];
	int mid=l+r>>1;
	int res=0;
	if(x<=mid) res+=ask(lc[p],l,mid,x,y);
	if(y>mid) res+=ask(rc[p],mid+1,r,x,y);
	return res;
}
int kth(int p,int l,int r,int k){
	if(l==r) return l;
	int mid=l+r>>1;
	if(sum[lc[p]]>=k) return kth(lc[p],l,mid,k);
	else return kth(rc[p],mid+1,r,k-sum[lc[p]]);
}
//将p中大于等于x且小于等于y的值移动到q中
void split_xy(int &x,int &y,int l,int r,int pl,int pr){
	if(!x || !sum[x]) return ;
	if(pl<=l && r<=pr){
		y=x,x=0;
		return ;
	}
	if(!y)y=++idx;
	int mid=l+r>>1;
	if(pl<=mid) split_xy(lc[x],lc[y],l,mid,pl,pr);
	if(pr>mid) split_xy(rc[x],rc[y],mid+1,r,pl,pr);
	sum[x]=sum[lc[x]]+sum[rc[x]];
	sum[y]=sum[lc[y]]+sum[rc[y]];
}
int merge(int x,int y){//将y合并到x
	if(x==0 || y==0) return x+y;
	sum[x]+=sum[y];
	lc[x]=merge(lc[x],lc[y]);
	rc[x]=merge(rc[x],rc[y]);
	return x;
}
signed main(){
	n=read(),m=read();
	for(int i=1;i<=n;i++){
		int x=read();
		change(root[1],1,n,i,x);
	}
	while(m--){
		int opt=read(),Q=read();
		if(opt==0){//将可重集 Q 中大于等于 x 且小于等于 y 的值移动到一个新的可重集中
			int x=read(),y=read();
			split_xy(root[Q],root[++tot],1,n,x,y);
		}else if(opt==1){//将可重集 t 中的数放入可重集 Q
			int t=read();
			root[Q]=merge(root[Q],root[t]);
			root[t]=0;
		}else if(opt==2){//在 Q 这个可重集中加入 x 个 q
			int x=read(),q=read();
			change(root[Q],1,n,q,x);
		}else if(opt==3){//查询可重集 Q 中大于等于 x 且小于等于 y 的值的个数
			int x=read(),y=read();
			cout<<ask(root[Q],1,n,x,y)<<'\n';
		}else {//查询在 Q 这个可重集中第 k 小的数
			int k=read();
			if(sum[root[Q]]<k) cout<<"-1\n";
			else cout<<kth(root[Q],1,n,k)<<'\n';
		}
	}
	return 0;
}

可持久化数据结构

顾名思义,可持久化数据结构可以保存每一个历史状态且支持操作。

可持久化线段树

又称为主席树,主席树的主要思想就是:保存每次插入操作时的历史版本,以便查询区间第\(k\)小。

核心原理

  • 每个版本的主席树都继承线段树的结构,用于维护对应版本的区间信息(如区间第 k 小、区间和等)。
  • 每次数据更新时,不修改原版本的节点,只新建受影响的路径节点,复用未变化的节点,大幅节省空间。
  • 每个新版本的根节点指向新建路径,通过根节点集合可快速找到任意历史版本,实现多版本的区间查询。

空间效率:复用节点使得空间复杂度远低于保存多棵完整线段树,通常为 \(O(nlogn)\)

时间效率:建树、单次更新和单次查询的时间复杂度均为 \(O(nlogn)\) ,支持大量历史版本的快速操作。

适用场景:主要用于静态数组的多版本区间查询,比如区间第 k 小、区间众数、区间历史和等问题。

模板

code

#include<bits/stdc++.h>
#define ll long long
using namespace std;
const int N=1e6+5;
int n,m;
int a[N],idx;
int root[N];
int pos[N];
inline int read(){
    int f=1,s=0;
    char ch=getchar();
    while(ch<'0' || ch>'9'){
        if(ch=='-')f=-1;
        ch=getchar();
    }
    while(ch>='0' && ch<='9'){
        s=(s<<3)+(s<<1)+ch-'0';
        ch=getchar();
    }
    return f*s;
}
struct node{
    int lc,rc;
    int sum;
}t[N*64];
void build(int &p,int l,int r){
    p=++idx;
	if(l==r){
		t[p].sum=a[l];
		return ;
	}
    int mid=l+r>>1;
    build(t[p].lc,l,mid);
    build(t[p].rc,mid+1,r);
}
void change(int &p,int pre,int l,int r,int x,int k){
    p=++idx;
    t[p]=t[pre];
    if(l==r){
    	t[p].sum=k;
    	return ;
	}
    int mid=l+r>>1;
    if(x<=mid) change(t[p].lc,t[pre].lc,l,mid,x,k);
    else  change(t[p].rc,t[pre].rc,mid+1,r,x,k);
}
int ask(int p,int l,int r,int x){
	if(l==r)return t[p].sum;
    int mid=l+r>>1;
    if(x<=mid) return ask(t[p].lc,l,mid,x);
    else return ask(t[p].rc,mid+1,r,x);
}
int main(){
    n=read(),m=read();
	for(int i=1;i<=n;i++)a[i]=read();
    build(root[0],1,n);
    int v,op,u,k;
    for(int i=1;i<=m;i++){
        v=read(),op=read(),u=read();
        if(op==1){
            k=read();
            change(root[i],root[v],1,n,u,k);
        }else {
        	root[i]=root[v];
            printf("%d\n",ask(root[v],1,n,u));
        }
    }
	return 0;
}

树套树

二维线段树

通常在处理二维平面上的问题时,通常会用到二维线段树。

用一颗线段树存这是哪一行,第二颗线段树的每个叶子节点存行的具体信息的前缀和。在修改和查询时使用容斥原理即可。

P3810 【模板】三维偏序(陌上花开)

code:

#include<bits/stdc++.h>
using namespace std;
inline int read(){
	register int x=0,t=1;
	register char ch=getchar();
	while(!isdigit(ch)){
		if(ch=='-') t=-1;
		ch=getchar();
	}
	while(isdigit(ch)){
		x=(x<<1)+(x<<3)+(ch^48);
		ch=getchar();
	}
	return x*t;
}
const int N=100005;
int n,k,last=1,root;
int totp,tots;
int rt[N*20],plc[N*20],prc[N*20],slc[N*400],src[N*400],sum[N*400],f[N];
struct edge{
    int a,b,c;
    bool operator < (const edge &w) const{
        if(a!=w.a) return a<w.a;
        if(b!=w.b) return b<w.b;
        return c<w.c;
    }
}e[N];
void changeY(int &s,int l,int r,int y){
    if(!s) s=++tots;
    sum[s]++;
    if(l==r) return;
    int mid=l+r>>1;
    if(y<=mid) changeY(slc[s],l,mid,y);
    else changeY(src[s],mid+1,r,y);
}
void changeX(int &p,int l,int r,int x,int y){
    if(!p) p=++totp;
    changeY(rt[p],1,k,y);
    if(l==r) return;
    int mid=l+r>>1;
    if(x<=mid) changeX(plc[p],l,mid,x,y);
    else changeX(prc[p],mid+1,r,x,y);
}
int askY(int s,int l,int r,int x,int y){
    if(!s) return 0;
    if(x<=l && r<=y) return sum[s];
    int mid=l+r>>1,ans=0;
    if(x<=mid) ans+=askY(slc[s],l,mid,x,y);
    if(y>mid) ans+=askY(src[s],mid+1,r,x,y);
    return ans;
}
int askX(int p,int l,int r,int ax,int bx,int ay,int by){
    if(!p) return 0;
    if(ax<=l && r<=bx) return askY(rt[p],1,k,ay,by);
    int mid=l+r>>1,ans=0;
    if(ax<=mid) ans+=askX(plc[p],l,mid,ax,bx,ay,by);
    if(bx>mid) ans+=askX(prc[p],mid+1,r,ax,bx,ay,by);
    return ans;
}
int main(){
	n=read(),k=read();
	for(int i=1;i<=n;i++)e[i].a=read(),e[i].b=read(),e[i].c=read();
	sort(e+1,e+n+1);
	for(int i=1;i<=n;i++){
		changeX(root,1,k,e[i].b,e[i].c);
		if(e[i+1].a!=e[i].a){
			for(int j=last;j<=i;j++){
				int res=askX(root,1,k,1,e[j].b,1,e[j].c);
				f[res-1]++;
			}
			last=i+1;
		}
	}
	for(int i=0;i<n;i++)printf("%d\n",f[i]);
	return 0;
}

线段树套树状数组

线段树分治

线段树分治能解决形如操作对询问有区间影响的题目(并且要求操作可撤销),这种情况下,可以直接对询问建立一颗线段树,然后预处理出询问对哪些区间有影响,直接在线段树上区间修改(线段树节点上记录这个节点处的操作),注意这里需要修改的只有符合要求的最上层的点,修改完直接返回,后续也无需 \(pushdown\)

这么干的原因是我们在预处理完毕后会对线段树进行一次 \(DFS\),模拟进行操作的过程,每次进入一个节点就进行节点存储的操作,退出时就撤销操作,每次遍历到叶节点时就记录当前答案到对应询问,由于父节点的操作会对子节点产生影响,因此无需将操作下传。

进行能线段树分治的题目要求还是很严格的,首先需要保证操作可撤销,其次还要保证贡献能实时计算(或者以很低的复杂度算出来)。

\(cdq\)和整体二分

\(cdq\)

我们通常通过 \(cdq\) 分治,解决和点对有关的问题。

大致的形式是给定一个长度为 \(n\) 的序列,统计有一些特性的点对 \((i,j)\) 的数量。

操作过程:

  1. 每次操作取出序列中点 \(mid\) 并将每个 \((i,j)\) 分成三类:
  • \(i,j\le mid\)
  • \(i,j \ge mid\)
  • \(i\le mid \le j\)
  1. 将序列分成两段,对第1类和第2类的点对递归处理。
  2. 依据题目设法处理第3类点对。

eg:

P3810 【模板】三维偏序(陌上花开)

对于第一维直接按 \(a\) 排序处理,第二维使用 \(cdq\) 分治,递归处理第一二类点对。对于第三类点对,将剩下的两个区间按 \(b\) 排序,然后用权值树状数组进行对于 \(c\) 的判断。

#include<bits/stdc++.h>
using namespace std;
inline int read(){
	int f=1,s=0;
	char ch=getchar();
	while(ch<'0' || ch>'9'){
		if(ch=='-') f=-1;
		ch=getchar();
	}
	while(ch<='9' && ch>='0'){
		s=(s<<3)+(s<<1)+ch-'0';
		ch=getchar();
	}
	return s*f;
}
const int N=2e5+5;
int n,m,tot;
long long ans[N];
struct node{
	int a,b,c;
	int w,cnt;
}t[N],e[N]; 
int tr[N];
bool operator !=(node x,node y){
	if(x.a==y.a && x.b==y.b && x.c==y.c) return 0;
	return 1;
}
bool cmpa(node a,node b){
	if(a.a!=b.a) return a.a<b.a;
	if(a.b!=b.b) return a.b<b.b;
	return a.c<b.c;
}
bool cmpb(node a,node b){
	return a.b<b.b;
}
int lowbit(int x){
	return x &  (-x);
}
void add(int x,int val){
	while(x<=m){
		tr[x]+=val;
		x+=lowbit(x);
	}
}
int ask(int x){
	int res=0;
	for(int i=x;i>0;i-=lowbit(i)) res+=tr[i];
	return res;
}
void cdq(int l,int r){
	if(l==r) return ;
	int mid=l+r>>1;
	cdq(l,mid),cdq(mid+1,r);
	sort(t+l,t+mid+1,cmpb);
	sort(t+mid+1,t+r+1,cmpb);
	int i=l,j=mid+1;
	while(i<=mid && j<=r){
		if(t[i].b<=t[j].b) add(t[i].c,t[i].w),i++;
		else t[j].cnt+=ask(t[j].c),j++; 
	}
	while(i<=mid) add(t[i].c,t[i].w),i++;
	while(j<=r) t[j].cnt+=ask(t[j].c),j++; 
	for(int i=l;i<=mid;i++) add(t[i].c,-t[i].w);
}
signed main(){
	n=read(),m=read();
	for(int i=1;i<=n;i++) t[i].a=read(),t[i].b=read(),t[i].c=read(),t[i].w=1;
	sort(t+1,t+n+1,cmpa);
	int k=1;
	for(int i=2;i<=n;i++){
		if(t[i]!=t[k]) t[++k]=t[i];
		else t[k].w++;
	}
	cdq(1,k);
	for(int i=1;i<=k;i++) ans[t[i].cnt+t[i].w-1]+=t[i].w;
	for(int i=0;i<n;i++) printf("%lld\n",ans[i]);
	return 0;
} 

整体二分

对于一些问题,我们可以使用二分解决,但是在处理大量查询时每次都直接二分可能会 \(TLE\) 。整体二分的思想就是将所有问题一起解决。

可以使用整体二分解决的题目需要满足以下性质:

  1. 询问的答案具有可二分性
  2. 修改对判定答案的贡献互相独立,修改之间互不影响效果
  3. 修改如果对判定答案有贡献,则贡献为一确定的与判定标准无关的值
  4. 贡献满足交换律,结合律,具有可加性
  5. 题目允许使用离线算法

注意到在可以使用二分的题目时,在查询中,有些操作的贡献是一定的,有些操作是没用的。

比如在二分时间时,在 \(mid\) 之后的操作是无用的,在 \(mid\) 之前的贡献是一定的。

整体二分利用的就是这样一个性质。

正常我们写二分答案是这样的

inline int check(int mid){
  int num=0;
  for(int i=1;i<=m;i++)
    if(calc(i,mid)) num++;
  return num;
}

...

int l=...,r=...;
while(l<r){
  int mid=(l+r)>>1;
  if(check(mid)<k) l=mid;
  else r=mid-1;
}

如果有 \(q\) 次询问,时间复杂度就是是 \(O(qmlogn)\) 的。

换成整体二分

inline bool check(int mid){
  int t1=0,t2=0;
  for(int i=1;i<=m;++i){
    if(calc(i,mid)) que[1][++t1]=i;
    else que[2][++t2]=i;
  }
  if(t1>=k){
    m=t1;
    for(int i=1;i<=m;++i) opt[i]=que[1][i];
    return 1;
  }
  else{
    m=t2;
    for(int i=1;i<=m;++i) opt[i]=que[2][i];
    k-=t1;return 0;
  }
}

...

int l=...,r=...;
while(l<r){
  int mid=(l+r)>>1;
  if(check(mid)) r=mid-1;
  else l=mid;
}

分析起来复杂度似乎并没有什么改变......

但是如果把二分答案看成一棵二叉树,每个点(区间 \([l,r]\) )的权值为 \(check\) 的操作数。

把当前是第几次二分看成这个区间的深度。

每一层的区间相互没有交。

那么有一个优秀的性质:只有 \(log\) 层,每一层的点权和为 \(O(m)\)

可以看到在 \(check\) 中是对于多组询问一起处理,复杂度为 \(O((m+q)logn)\)

具体过程:

把没有用的操作扫进右边,和答案在[mid+1,r]的询问一起递归处理。

把有用的操作放进左边,减去不变的贡献,和答案在[l,mid]的一起递归处理。

注意: 答案在 \([mid+1,r]\) 的询问要算上放进了左边的操作的贡献,直接减掉就可以。

平衡树

替罪羊

替罪羊树 是一种依靠重构操作维持平衡的平衡树。替罪羊树会在插入、删除操作后,通过提前设定的 \(alpha\) 检测树是否发生失衡;如果失衡,将有重构以恢复平衡。

替罪羊树不支持区间操作,且无法持久化。

唯一的优点是代码简单好调吧……

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5;
const double alpha=0.7;
struct Scape_goat{
	#define ls tr[p].lc
	#define rs tr[p].rc
	int lc,rc;//左右儿子编号 
	int val;//实际值 
	int sz;//当前子树大小
	int fact;//当前子树有多少个未被删除 
	int cnt;//节点储存值的个数 
	int sum;//节点子树储存值的个数的总和 
}tr[N];
int opt,n;
int idx,root,tot;
int tmp[N];//用于重构是存数的数组 
int get_node(int x){//开一个值为x的位置 
	++idx;
	tr[idx]={0,0,x,1,1,1,1};
	return idx;
}
bool check(int p){//判断是否需要重构 
	if(tr[ls].sz > tr[p].sz*alpha) return 0; //条件1:左/右儿子的子树大小   >    当前子树大小*alpha 
	if(tr[rs].sz > tr[p].sz*alpha) return 0; 
	if(tr[p].fact<tr[p].sz*alpha) return 0;//条件2:当删除节点   >   当前子树大小*(1-alpha) 
	return 1;
} 
void pushup(int p){
	tr[p].sz=tr[ls].sz+tr[rs].sz+1;
	tr[p].sum=tr[ls].sum+tr[rs].sum+tr[p].cnt; 
	tr[p].fact=tr[ls].fact+tr[rs].fact+(tr[p].cnt?1:0);
}
void flatten(int p){//按照中序排序把原树拍成一个序列 
  if(!p) return;
  flatten(ls);
  if(tr[p].cnt>0) tmp[++tot]=p;
  flatten(rs);
}
int build(int l,int r){//重构建树 
	if(l>r) return 0;
	int mid=l+r>>1;
	int p=tmp[mid];
	ls=build(l,mid-1);
	rs=build(mid+1,r);
	pushup(p);
	return p;
}
void rebuild(int &p){//开始重构 
	tot=0;
	flatten(p);
	p=build(1,tot);
}
void ins(int &p,int x){
	if(p==0){//当x不存在时 
		p=get_node(x);
		return;	
	} 
	if(tr[p].val>x) ins(tr[p].lc,x);
	else if(tr[p].val<x) ins(tr[p].rc,x);
	else tr[p].cnt++;
	pushup(p);
	if(!check(p))  rebuild(p);
} 
void del(int &p,int x){
	if(!p) return;
	if(tr[p].val>x) del(ls,x);
	else if(tr[p].val<x) del(rs,x);
	else{//找到当前节点 
	    if(tr[p].cnt>1) tr[p].cnt--;
	    else{
		    //若节点真实存在而且只剩一个了 
	    	tr[p].cnt=0;//懒删除,打上标记表示当前节点已被删除 
	    	tr[p].sz--;
	    	tr[p].sum--;
	    	tr[p].fact--;
		}
	}
	pushup(p);
	if(!check(p)) rebuild(p);
}
int ask(int p,int x){
	if(!p)return 0;
	if(tr[p].val==x) return tr[tr[p].lc].sum;
	if(tr[p].val>=x) return ask(tr[p].lc,x);
	return ask(tr[p].rc,x)+tr[tr[p].lc].sum+tr[p].cnt;
}
int kth(int p,int x){
	if(!p) return 0;
	int ans=tr[tr[p].lc].sum;
	if(ans>=x) return kth(ls,x); 
	else if(ans+tr[p].cnt>=x) return tr[p].val;
	else return kth(rs,x-ans-tr[p].cnt); 
}
int main(){
	cin>>n;
	ins(root,INT_MAX);
	ins(root,INT_MIN);
	while(n--) {
		int x;
		cin>>opt>>x;
		if(opt==1) ins(root,x);
		if(opt==2) del(root,x);
		if(opt==3) printf("%d\n",ask(root,x));
		if(opt==4) printf("%d\n",kth(root,x+1));
		if(opt==5) prin=tf("%d\n",kth(root,ask(root,x)));
		if(opt==6) printf("%d\n",kth(root,ask(root,x+1)+1)); 
	}
	return 0;
}

\(FHQ\)

其核心操作在于它的分裂与合并,通过这两种操作,去实现其他的操作。

缺点在于每次操作时都要用很多次合并或分裂,常数大。

比起传统 \(Treap\),\(FHQ-Treap\) 更适合维护区间操作和可持久化。

#include<bits/stdc++.h>
using namespace std;
inline int read(){
	int s=0,f=1; char ch=getchar();
	while(!isdigit(ch)){if(ch=='-') f=-1;ch=getchar();}
	while(isdigit(ch)){s=(s<<3)+(s<<1)+(ch^48);ch=getchar();}
	return s*f;
}
const int N=1e5+5;
struct node{
	#define ls tr[p].lc
	#define rs tr[p].rc
	int lc,rc;
	int val,sz;
	int k;//二叉堆的随机优先级 
}tr[N];
int tot,root,n;
int get_node(int x){
	int p=++tot;
	tr[p]={0,0,x,1,rand()};
	return p;
}
void print(int x){
	if(!x) return;
	print(tr[x].lc);
	cout<<tr[x].val<<" ";
	print(tr[x].rc);	
}
void pushup(int p){
	tr[p].sz=tr[tr[p].lc].sz+tr[tr[p].rc].sz+1 ;
}
//按值分裂,将树分裂为两部分,左子树所有节点的值<=k
void split_val(int p,int k,int &x,int &y){
	if(!p) {
	    x=y=0;//原树中没有的节点新树也没有 
	    return ;
	}
	if(tr[p].val<=k){
		x=p;//分到左边的新树  
		split_val(rs,k,rs,y);
	}
	else{
		y=p;//分到右边的子树 
		split_val(ls,k,x,ls);
	}
	pushup(p);
}
// 按排名分裂,将树分裂为两部分,左子树大小为k
void split_rank(int p,int k,int &x,int &y){
	if(!p) {
		x=y=0;
		return ;
	}
	if(k<=tr[ls].sz){//按子树大小进行分裂 
		y=p;
		split_rank(ls,k,x,ls);
	}
	else{
		x=p;
		split_rank(rs,k-tr[ls].sz-1,rs,y);
	}
	pushup(p);
	
}
// 合并两个树
int merge(int x,int y){
	if(!x||!y) return x+y; 
	if(tr[x].k>tr[y].k){//维护二叉堆的性质 
		tr[x].rc=merge(tr[x].rc,y);
		pushup(x);
		return x;
	}
	else{
		tr[y].lc=merge(x,tr[y].lc);
		pushup(y);
		return y;
	}
}
void ins(int val){
	int x,y,z;
	split_val(root,val,x,y);
	z=get_node(val);
	root=merge(merge(x,z),y);
} 
void del(int val){
	int x,y,z;
	split_val(root,val,x,z);
	split_val(x,val-1,x,y);
    y=merge(tr[y].lc,tr[y].rc);
	root=merge(merge(x,y),z);
}
int ask(int val){
	int x,y;
	split_val(root,val-1,x,y);
	int ans=tr[x].sz;
	root=merge(x,y);
	return ans;
}
int kth(int val){
	int x,y,z;
	split_rank(root,val,x,z);
	split_rank(x,val-1,x,y);
	int ans=tr[y].val;
	root=merge(merge(x,y),z);
	return ans;
}
int main() {
	srand(time(0));
	n=read();
	ins(INT_MAX);
	ins(INT_MIN);
	while(n--){
		int opt=read(),x=read();
		if(opt==1) ins(x);
		if(opt==2) del(x);
		if(opt==3) printf("%d\n",ask(x));
		if(opt==4) printf("%d\n",kth(x+1));
		if(opt==5) printf("%d\n",kth(ask(x)));
		if(opt==6) printf("%d\n",kth(ask(x+1)+1)); 
	}
    return 0;
}

splay

P3391 【模板】文艺平衡树

#include<bits/stdc++.h>
using namespace std;
#define maxn 100010 
#define inf 1e9
int n,ch[maxn][2],fa[maxn],size[maxn],cnt[maxn],val[maxn],a[maxn],tot,ltag[maxn],root;
void pushup(int id){
	size[id]=size[ch[id][0]]+size[ch[id][1]]+cnt[id]; 
}
void pushdown(int id){
	if(ltag[id]==0) return ;
	int l=ch[id][0], r=ch[id][1];
	if(l) {  
		ltag[l]^=1;
		swap(ch[l][0],ch[l][1]);
	}
	if(r) {  
		ltag[r]^=1;
		swap(ch[r][0],ch[r][1]);
	}
	ltag[id]=0; 
}

int build(int l,int r){
	if(l>r) return 0; 
	int mid=(l+r)>>1;
	int now=a[mid];
	if(now==inf || now==-inf) now=++tot;
	val[now]=a[mid];
	cnt[now]=size[now]=1;
	if(l==r) return now;
	int lt=build(l,mid-1);
	ch[now][0]=lt; 
	if(lt) fa[lt]=now;
	int rt=build(mid+1,r); 
	ch[now][1]=rt;
	if(rt) fa[rt]=now;
	pushup(now);
	return now;
}

void rotate(int x){
	int y=fa[x],z=fa[y];
	int dx=(ch[y][0]==x)?0:1;
	int dy=(z && ch[z][0]==y)?0:1;  
	pushdown(y),pushdown(x);
	if(z) ch[z][dy]=x; 
	else root=x;
	fa[x]=z;
	ch[y][dx]=ch[x][1-dx];
	if(ch[x][1-dx]) fa[ch[x][1-dx]]=y;
	ch[x][1-dx]=y,fa[y]=x;
	pushup(y);pushup(x);
}

void splay(int x,int goal){
	while(fa[x]!=goal){
		int y=fa[x],z=fa[y];
		if(z==goal) rotate(x);
		else{
			int dx=(ch[y][0]==x)?0:1;
			int dy=(ch[z][0]==y)?0:1;
			if(dx==dy) rotate(y),rotate(x);
			else rotate(x),rotate(x);
		}
	}
}

int getval(int rank){
	rank--;
	int now=root;
	while(now){
		pushdown(now); 
		if(size[ch[now][0]]>rank) now=ch[now][0]; 
		else if(size[ch[now][0]]+cnt[now]>rank) return now;
		else{
			rank-=size[ch[now][0]]+cnt[now];
			now=ch[now][1];
		}
	}
	return 0;
}

void reverse(int a,int b){
	int x=getval(a),y=getval(b+2);
	splay(x,0),splay(y,root);
	int c=ch[y][0];
	ltag[c]^=1;
	swap(ch[c][0],ch[c][1]);
}

void putans(int now){
	if(!now) return;
	pushdown(now);
	if(ch[now][0]) putans(ch[now][0]);
	if(val[now]!=-inf && val[now]!=inf) cout<<val[now]<<" ";
	if(ch[now][1]) putans(ch[now][1]);
}

int main(){
	int m;
	cin>>n>>m;
	tot=n;
	a[1]=-inf,a[n+2]=inf;//守门员
	for(int i=2;i<=n+1;i++) a[i]=i-1;
	root=build(1,n+2);  
	int x,y;
	while(m--){
		cin>>x>>y;
		reverse(x,y);
	}
	putans(root);
	return 0;
}
posted @ 2025-11-08 09:30  Austin0928  阅读(7)  评论(0)    收藏  举报