动态分配点的线段树(动态开点线段树)

前言

另一种架构的线段树,对比普通线段树来说可以适应大区间范围的操作问题。
它是线段树分裂合并操作的必须架构,也在权值线段树——这个天生大区间范围的数据结构中应用广泛。

1. 原理

普通线段树中一定要在进行操作前调用一个 build 函数,目的是把线段树初始化。

在普通线段树的学习中,我们知道对于一个长为 \(n\) 的区间,普通线段树会构造出至多 \(4n\) 个点来,这就造成普通线段树起手就有 \(O(4n)\) 的空间复杂度,但如果 \(n\le 1\times 10^9\) 甚至 \(\le 1\times 10^{18}\),普通线段树就没法处理了。

但问题是:普通线段树开的这些点会全部用上吗?如果操作数量有限(\(m\le n\)),答案是否定的,那么为什么要开这些点来浪费空间?于是我们引入线段树的一个新的架构。

在动态开点线段树中,我们把树存在一个 vector t 中,原因是它长度可变。之后我们对结构体中的元素稍作改动:l,r 代表左和右子区间代表的点在 vector 中的下标,若左右子区间暂时未开放,我们一般令其为 \(0\)
我们再引入一个变量 cnt,它代表着 vector 中元素的最大下标(当然它可以替换为 t.size()-1)。

建树

建树过程很简单:向 t 中分别压入一个哨兵和一个根节点:

t.push_back(seg{0,0,...});  // 位于下标 0 的哨兵
t.push_back(seg{0,0,...});  // 位于下标 1 的根节点
cnt=1;

注意到哨兵就是我们上文”若左右子区间暂时未开放,我们一般令其为 \(0\)“ 的下标 \(0\) 位置。哨兵的取值在整个动态开点线段树中起到一个初始(未定义)区间的作用,这样在下面的操作中可以不用判断左右区间是否存在,省去一些判断的代码复杂度。因此要合理安排哨兵的取值。

注意:在进行完成两个操作后,一定要将 cnt 设置为 \(1\)。这种 bug 的难受程度不亚于普通线段树中的不调用 build

建新点

将建新点专门封装为一个函数:

void malln(){
  cnt++;
  t.push_back(seg{0,0,0,0});
  return;
}

这样,在要建新点时,只需要:

malln();
...=cnt;

此时的 cnt 就是刚建好的那个点的下标,可以将这个值赋给有需要的变量,如 t[p].lt[p].r
这样写的好处是可以全局更改赋值的初始值。

但是这个 malln 函数不能通过引用传参和返回 cnt 之类的方法来省下之后的那一行赋值,原因与 cpp 的机制有关,不想再去研究这个了。

懒标记的下传

传参时要另外传两个要下传的点代表的区间。
在向两个子区间下传的过程中,要确保两个区间都建过点才可以进行下传:
且若当前点代表的区间为叶子,即左端点等于右端点,无需进行下传。
下面例子使用区间加法线段树举例:

void pushdown(int p,int l,int r){
  if(t[p].lzt==0||l==r){
    return;
  }
  int k=t[p].lzt;
  if(t[p].l==0){
    malln();
	  t[p].l=cnt;
  }
  if(t[p].r==0){
    malln();
	  t[p].r=cnt;
  }
  int mid=l+r>>1;
  int ls=t[p].l;
  int rs=t[p].r;
  t[ls].lzt+=k;
  t[ls].var+=(mid-l+1)*k;
  t[rs].lzt+=k;
  t[rs].var+=(r-(mid+1)+1)*k;
  t[p].lzt=0;
  return;
}

修改操作

关于修改的函数中也要传递更多的参数,一般如下:

int foo(int p,int rl,int rr,int l,int r,...);

上述函数中:

  • p:当前节点在 vector 中的下标。
  • rlrr:操作的目标区间。
  • l,r:当前节点代表的区间。

正因为动态开点线段树中记录左右儿子,因此需要额外的多两个变量记录左右端点。

当向两个子区间分发时:

int mid=l+r>>1;
if(rl<=mid){
  if(t[p].l==0){
    malln();
    t[p].l=cnt;
  }
  foo(t[p].l,rl,rr,l,mid);
}
if(mid<rr){
  if(t[p].r==0){
    malln();
    t[p].r=cnt;
  }
  foo(t[p].r,rl,rr,mid+1,r);
}

若目标区间中没有点,则先把点建出后再分发操作。
注意操作中何时用 rlrr;何时用 lr

查询操作

对于区间查询值的操作:

if(rl<=mid){
  if(t[p].l==0){
    sub+=defaultvar;
  }
  else sub+=query(t[p].l,rl,rr,l,mid);
}
if(mid<rr){
  if(t[p].rr==0){
    sub+=defaultvar;
  }
  else sub+=query(t[p].r,rl,rr,mid+1,r);
} 

若查询的区间中没有点,则当作默认值处理即可,若有,才继续向下查询。

对于查询时要合并区间的操作:

if(rr<=mid)return query(t[p].l,rl,rr,l,mid);
if(rl>mid)return query(t[p].r,rl,rr,mid+1,r);
if(t[p].r==0)return defaultseg+query(t[p].l,rl,rr,l,mid);
if(t[p].l==0)return defaultseg+query(t[p].r,rl,rr,mid+1,r);  
seg sub;
seg le,ri;
if(rl<=mid&&mid<rr){
	le=query(t[p].l,rl,rr,l,mid);
	ri=query(t[p].r,rl,rr,mid+1,r);
	return le+ri;
}

这里两个结构体的加操作已经在结构体中重载。上述代码很简单原理不再叙述。

2. 例题

我们先来尝试把一些普通线段树改写为动态开点线段树:

洛谷 P2781 传教

其实是 洛谷 P3372 【模板】线段树 1\(n\) 加强 \(m\) 弱化版。这里直接给出代码:

#define psb push_back
#define mkp make_pair
#define rep(i,a,b) for( int i=(a); i<=(b); ++i)
#define per(i,a,b) for( int i=(a); i>=(b); --i)
#define rd read()
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
#define int ll
ll read(){
  ll x=0,f=1;
  char c=getchar();
  while(c>'9'||c<'0'){if(c=='-') f=-1;c=getchar();}
  while(c>='0'&&c<='9'){x=(x<<3)+(x<<1)+(c^48);c=getchar();}
  return x*f;
}
int n,m;
struct seg{
  int l;
  int r;
  int var;
  int lzt;
};
vector<seg> t;
int cnt=1;
void malln(){
  cnt++;
  t.push_back(seg{0,0,0,0});
  return;
}
void pushup(int p){
  int ls=t[p].l;
  int rs=t[p].r;
  t[p].var=t[ls].var+t[rs].var;
  return ;
}
void pushdown(int p,int l,int r){
  if(t[p].lzt==0||l==r){
    return;
  }
  int k=t[p].lzt;
  if(t[p].l==0){
    malln();
    t[p].l=cnt;
  }
  if(t[p].r==0){
    malln();
    t[p].r=cnt;
  }
  int mid=l+r>>1;
  int ls=t[p].l;
  int rs=t[p].r;
  t[ls].lzt+=k;
  t[ls].var+=(mid-l+1)*k;
  t[rs].lzt+=k;
  t[rs].var+=(r-(mid+1)+1)*k;
  t[p].lzt=0;
  return;
}
void add(int p,int rl,int rr,int l,int r,int k){
  if(rl<=l&&r<=rr){
    t[p].lzt+=k;
    t[p].var+=(r-l+1)*k;
    return ;
  }
  pushdown(p,l,r);

  int mid=l+r>>1;
  if(rl<=mid){
    if(t[p].l==0){
      malln();
	    t[p].l=cnt;
    }
    add(t[p].l,rl,rr,l,mid,k);
  }
  if(mid<rr){
    if(t[p].r==0){
      malln();
	    t[p].r=cnt;
    }
    add(t[p].r,rl,rr,mid+1,r,k);
  }
  pushup(p);
  
  return;
}
int query(int p,int rl,int rr,int l,int r){
  if(rl<=l&&r<=rr){
    return t[p].var;
  }
  pushdown(p,l,r);
  int mid=l+r>>1;
  int sub=0;
  if(rl<=mid&&t[p].l!=0){
    sub+=query(t[p].l,rl,rr,l,mid);
  }
  if(mid<rr&&t[p].r!=0){
    sub+=query(t[p].r,rl,rr,mid+1,r);
  } 
  return sub;
}
signed main(){
  
  cin>>n>>m;
  t.push_back(seg{0,0,0,0});
  t.push_back(seg{0,0,0,0});
  rep(i,1,m){
    int op;
    cin>>op;
    if(op==1){
      int l,r,k;
      cin>>l>>r>>k;
      add(1,l,r,1,n,k);
    } 
    if(op==2){
      int l,r;
      cin>>l>>r;
      cout<<query(1,l,r,1,n)<<'\n';
    }
  }
  
  return 0;
}

洛谷 P4513 小白逛公园 动态开点 ver.

来试试把小白逛公园改写成动态开点版本吧!

但是改的时候你会发现,如果题目中已经对每个点有一个初始值,那么你初始化动态开点线段树时候相当于已经建好了一个普通线段树 /fad

所以,动态开点线段树适合解决初始值相同的问题,那么不同怎么办?

……那个出题人会给 \(10^9\) 个数赋不同的初始值啊喂?

所以不用担心啦。

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
#define rd read()
#define defN seg{0,0,0,0,0,0}
ll read(){
	ll x=0,f=1;
	char c=getchar();
	while(c>'9'||c<'0'){if(c=='-') f=-1;c=getchar();}
	while(c>='0'&&c<='9'){x=(x<<3)+(x<<1)+(c^48);c=getchar();}
	return x*f;
}
const int N=5e5+5;
ll a[N];
struct seg{
	ll l,r;
	ll v;
	ll lmax,rmax,vmax;
};
vector<seg> t;
int cnt=1;
void malln(){
  cnt++;
  t.push_back(defN);
  return ;
}
void pushup(int p){
  int ls=t[p].l;
  int rs=t[p].r;
  t[p].v=t[ls].v+t[rs].v;
	t[p].vmax=max({t[ls].vmax,t[rs].vmax,t[ls].rmax+t[rs].lmax});
	t[p].lmax=max(t[ls].lmax,t[ls].v+t[rs].lmax);
	t[p].rmax=max(t[rs].rmax,t[rs].v+t[ls].rmax);
}
void cover(ll p,ll poi,ll l,ll r,ll k){
	if(l==r&&l==poi){
		t[p].lmax=k;
		t[p].rmax=k;
		t[p].v=k;
		t[p].vmax=k;
		return;
	}
	ll mid=l+r>>1;
	if(poi<=mid){
    if(t[p].l==0){
      malln();
      t[p].l=cnt;
    }
		cover(t[p].l,poi,l,mid,k);
	}
	else{
		if(t[p].r==0){
      malln();
      t[p].r=cnt;
    }
		cover(t[p].r,poi,mid+1,r,k);
	}
	pushup(p);

  return ;
}
seg query(ll p,ll rl,ll rr,ll l,ll r){
	if(rl<=l&&r<=rr){
		return t[p];
	}
	ll mid=l+r>>1;
	if(rr<=mid||t[p].r==0)return query(t[p].l,rl,rr,l,mid);
	if(rl>mid||t[p].l==0)return query(t[p].r,rl,rr,mid+1,r); 
	seg sub;
	seg le,ri;
	if(rl<=mid&&mid<rr){
		le=query(t[p].l,rl,rr,l,mid);
		ri=query(t[p].r,rl,rr,mid+1,r);
		sub.l=le.l;
		sub.r=ri.r;
		sub.v=le.v+ri.v;
		sub.vmax=max({le.vmax,ri.vmax,le.rmax+ri.lmax});
		sub.lmax=max(le.lmax,le.v+ri.lmax);
		sub.rmax=max(ri.rmax,ri.v+le.rmax);
		return sub;
	}
  
  return defN;
}
int main(){
	
	ll n,m;
	cin>>n>>m;
  t.push_back(defN);
  t.push_back(defN);
	for(int i=1;i<=n;i++){
		int var;
    var=rd;
    cover(1,i,1,n,var);
	}
	for(int i=1;i<=m;i++){
		int op;
		op=rd;
		if(op==1){
			ll l,r;
			l=rd;r=rd;
			if(l>r)swap(l,r);
			cout<<query(1,l,r,1,n).vmax<<'\n';
		}
		if(op==2){
			ll p,k;
			p=rd;k=rd;
			cover(1,p,1,n,k);
		}
	}
	return 0;
}

3. 动态开点线段树的复杂度分析和应用

令总区间长为 \(n\),总操作数为 \(q\),则:

  • 对于一般的操作,动态开点线段树的总时间复杂度为 \(O(q\log n)\)
  • 对于运行过程中,动态开点线段树的总空间消耗最坏为 \(O(q\log n)\)

普通线段树
动态开点权值线段树
(上面这个东西是我系统整理动态开点线段树以前写的,所以代码风格有些不统一,以后的动态开点线段树就都像这个文章中那么写了。)

合并和分裂以后会写的 qwq

posted @ 2025-02-21 20:50  hm2ns  阅读(122)  评论(0)    收藏  举报