数据结构(5) 线段树应用

线段树是一种非常好用且拓展性强的数据结构,这里将介绍一些线段树的其他操作。

线段树分治

使用场景

假设我们存在以下问题:

  • 给定一些操作,每个操作仅在给定的时间里生效。

  • 给定一些查询,每次查询是针对某时间的状态进行询问。

我们发现这种问题并不好用在线方法解决,我们考虑将它离线下来,用线段树分治解决。

基本思想

线段树分治通常情况先并非一种算法,而更像是一种技巧。

我们建一棵线段树维护时间轴,即线段树上的每个节点都代表了某一段时间,我们将在某段时间里的操作视作区间修改,并不把节点信息下放。如果我们要查询某个时间点的操作,我们记录下从根节点开始到叶子节点的操作,操作之和就是对答案的贡献。

我们注意到,由于我们在记录的时候是从上往下纪录的,所以在遍历所有时间点时,我们的撤销操作只需要撤销最后一步或几步即可。

例题解析

题目链接:P5787 二分图 /【模板】线段树分治

我们考虑线段树分治,令每个点维护的信息变成加边操作,然后遍历每个叶子节点,统计当前已有的所有边是否可以构成一个二分图。我们发现因为我们还要遍历其他叶子节点,这个操作我们不好直接建图染色判断,所以我们可以选择拓展域并查集判断二分图,然后选择可撤销并查集,可以优化掉枚举叶子节点反复加相同边后遍历染色的时间复杂度。

Code.
#include<bits/stdc++.h>
#define endl "\n"
#define pb push_back
#define mkp make_pair
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
//value 
const int inf=2147483647;
const int mod=1e9+7;
const int N=1e5+5;
stack<pair<pair<int,int>,bool> >s;
int fa[N<<1],h[N<<1];
vector<int>t[N<<2];
vector<pair<int,int> >g;
int n,m,k;


//function 
int find(int x){
	//找父亲
	if(fa[x]==x)return x;
	return find(fa[x]);
}
void merge(int x,int y){
	//并查集启发式合并
	int t1=find(x),t2=find(y);
	if(h[t1]>h[t2])swap(t1,t2);
	s.push(mkp(mkp(t1,t2),h[t1]==h[t2]));
	fa[t1]=t2;
	if(h[t1]==h[t2])h[t2]++;
}
void change(int l,int r,int L,int R,int k,int o){
	//线段树区间修改
	if(l>=L && r<=R){
		t[o].push_back(k);
		return;
	}
	int mid=(l+r)>>1;
	if(L<=mid)change(l,mid,L,R,k,o<<1);
	if(mid<R)change(mid+1,r,L,R,k,o<<1|1);
}
void Delete(int lst){
	//并查集回退
	int top=s.size();
//	cout<<top<<endl;
	while(top>lst){
		auto tmp=s.top().first;
		if(s.top().second)h[fa[tmp.first]]--;
		fa[tmp.first]=tmp.first;
		s.pop();
		top--;
	}
}
void solve(int l,int r,int o){
	int lst=s.size();
	for(auto i:t[o]){
		int u=g[i].first,v=g[i].second;
		int t1=find(u),t2=find(v);
		if(t1==t2){
			for(int j=l;j<=r;j++)cout<<"No"<<endl;
			Delete(lst);
			return;
		}
		merge(g[i].first,g[i].second+n);
		merge(g[i].second,g[i].first+n);
	}
	if(l==r)cout<<"Yes"<<endl;
	else {
		int mid=(l+r)>>1;
		solve(l,mid,o<<1);
		solve(mid+1,r,o<<1|1);
	}
	Delete(lst);
	
	return;
}


 
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0); 
	
//	freopen(".in","r",stdin);
//	freopen(".out","w",stdout);

	cin>>n>>m>>k;
	g.push_back(mkp(0,0));
	for(int i=1;i<=m;i++){
		int x,y,l,r;
		cin>>x>>y>>l>>r;
		g.push_back(mkp(x,y));
		if(l==r)continue;
		change(1,k,l+1,r,i,1);
	}
	for(int i=1;i<=n*2;i++)fa[i]=i;
	for(int i=1;i<=n*2;i++)h[i]=1;
	
	solve(1,k,1);
	
	
	
	return 0;
}

线段树合并

使用场景

假设我们存在以下问题:

  • 可能需要给每个点都开一个线段树

  • 一些点的贡献可以由其他节点的贡献合并得出

我们发现这种问题显然不可能真的给每个点单开一颗线段树,我们考虑对于每个点分别计算贡献,最后将点的贡献合并得出,即线段树合并。

基础操作

其实比较建议看这个大佬的博客,这里仅是我对线段树合并的浅显认识。

线段树合并的前置知识包括动态开点线段树和权值线段树,有关权值线段树,可以类比权值树状数组

线段树合并就是将原本分散在两棵线段树上的信息合并到了同一棵线段树,合并方式就是优先合并叶子节点(在动态开点线段树上的),然后利用叶子节点上的信息去更新整棵线段树。

int merge(int a, int b, int l, int r) {
  if (!a) return b;
  if (!b) return a;
  if (l == r) {
    // do something...
    return a;
  }
  int mid = (l + r) >> 1;
  tr[a].l = merge(tr[a].l, tr[b].l, l, mid);
  tr[a].r = merge(tr[a].r, tr[b].r, mid + 1, r);
  pushup(a);
  return a;
}

严肃搬OI-wiki

在这一合并过程中,我们可以将父节点直接连接到无需修改的节点或修改过的节点,能够有效 (实则并非) 节省空间。

注意到我们需要使用动态开点,数组大小应开 \(2n-1 + q \log n\) ,即数据范围的 \(2^5\) 倍左右。

关于时间复杂度的证明,可以看上面引的大佬的博客。

例题解析

题目连接:P4556 [Vani有约会] 雨天的尾巴 /【模板】线段树合并

我们可以假设我们对于每个点是都有一颗权值线段树的,那么我们每次在对某条链修改的时候,我们可以将对于整条链上对线段树的贡献,通过树上差分拆分到 \(u\)\(v\)\(lca(u,v)\)\(fa_{lca(u,v)}\) 共计四个点上,在他们的线段树上进行修改,其中 \(u\)\(v\) 是该类救济粮加一,\(lca(u,v)\)\(fa_{lca(u,v)}\) 是该类救济粮减一。注意到在统计答案时,将所有子树上的权值线段树合并到该节点,直接查询该点最大值的种类即为答案,因为在线段树合并时我们事实上将差分数组进行了前缀和。

Code.
#include<bits/stdc++.h>
#define endl "\n"
#define pb push_back
#define mkp make_pair
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
//value 
const ll inf=2147483647;
const ll mod=1e9+7;
const ll N=1e5+5;
ll dfn[N],fa[N][25];
vector<ll>g[N];
ll t[N*80],lson[N*80],rson[N*80],ans[N],rt[N],kd[N*80];
ll tot;




//function 
void solve(){
	
	
	
	return;
}
void dfs(ll x,ll fat){
	dfn[x]=dfn[fat]+1;
	fa[x][0]=fat;
	for(auto i:g[x]){
		if(i==fat)continue;
		dfs(i,x);
	}
}
ll lca(ll x,ll y){
	if(dfn[x]>dfn[y])swap(x,y);
	for(ll i=23;i>=0;i--){
		if(dfn[fa[y][i]]>=dfn[x])y=fa[y][i];
	}
	if(x==y)return x;
	for(ll i=23;i>=0;i--){
		if(fa[y][i]==fa[x][i])continue;
		y=fa[y][i];
		x=fa[x][i];
	}
	return fa[x][0];
}
void update(ll id1,ll id2){
	t[id1]=t[id2];
	kd[id1]=kd[id2];
}
void push_up(ll id){
	if(!lson[id])update(id,rson[id]);
	else if(!rson[id])update(id,lson[id]);
	else if(t[lson[id]]>=t[rson[id]])update(id,lson[id]);
	else update(id,rson[id]);
}
void change(ll &id,ll l,ll r,ll w,ll val){
	if(!id)id=++tot;//动态开点
	if(l==r){
		t[id]+=val;
		kd[id]=w;
		return;
	}
	ll mid=(l+r)>>1;
	if(w<=mid)change(lson[id],l,mid,w,val);
	else change(rson[id],mid+1,r,w,val);
	push_up(id);
}
ll merge(ll id1,ll id2,ll l,ll r){
	//将id2合并到id1
	if(!id1)return id2;
	if(!id2)return id1;
	if(l==r){
		t[id1]+=t[id2];
		kd[id1]=l;
		return id1;
	}
	ll mid=(l+r)>>1;
	lson[id1]=merge(lson[id1],lson[id2],l,mid);
	rson[id1]=merge(rson[id1],rson[id2],mid+1,r);
	push_up(id1);
	return id1;
	
}
void get(ll u,ll fa){
	for(auto v:g[u]){
		if(v==fa)continue;
		get(v,u);
		rt[u]=merge(rt[u],rt[v],1,N);
	}
	if(t[rt[u]]==0)ans[u]=0;
	else ans[u]=kd[rt[u]];
}
 
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0); 
	
//	freopen(".in","r",stdin);
//	freopen(".out","w",stdout);
	
	ll n,q;
	cin>>n>>q;
	for(ll i=1;i<n;i++){
		ll u,v;
		cin>>u>>v;
		g[u].push_back(v);
		g[v].push_back(u);
	}
	dfs(1,0);
	for(ll i=1;i<=23;i++){
		for(ll j=1;j<=n;j++){
			fa[j][i]=fa[fa[j][i-1]][i-1];
		}
	}
	
	while(q--){
		ll u,v,w;
		cin>>u>>v>>w;
		ll tmp=lca(u,v);
		change(rt[u],1,N,w,1);
		change(rt[v],1,N,w,1);
		change(rt[tmp],1,N,w,-1);
		change(rt[fa[tmp][0]],1,N,w,-1);
	}
	get(1,0);
	
	for(ll i=1;i<=n;i++)cout<<ans[i]<<endl;
	
	
	return 0;
}

线段树分裂

使用场景

假设我们存在以下问题:

  • 给定一些操作,要求将某些符合条件的元素分出来单独归一个集合。

  • 给定一些查询,每次查询是针对某集合中的元素进行区间或单点询问。

我们有一个最暴力的思想就是将符合条件的元素在集合中删除,再将他单独分到一个新集合,这样做的时间复杂度显然是很劣的。这种问题我们可以使用线段树分裂解决。

基础操作

我们考虑建立一个初始的线段树,由于线段树分裂会产生大量的线段树,所以我们需要使用动态开点线段树。我们通过维护线段树的不同信息或将每次的分裂条件拆分,使我们每次分裂都是分裂线段树上的某个区间。

对于我们将要被分裂的线段树称作 \(T_1\),分裂出的线段树称为 \(T_2\)。(不严谨叫法,这里只为了方便解释线段树分裂的操作)

我们每次考虑对 \(T_1\) 上的一个节点进行考虑:

  • 如果该节点完全符合分裂条件,则我们将这个点以及他的子树直接变为 \(T_2\) 的子树。

  • 如果该节点部分符合分裂条件,则我们分别考虑它的左右子树是否存在分裂区间。

我们发现这个操作其实还是很线段树的,在进行具体的子树转接时,我们先将该节点变为 \(T_2\) 中的子树,再将 \(T_1\) 中该节点所在的子树位置清零即可 (感觉也没说的很清楚) ,代码如下:

void split(int &p, int &q, int s, int t, int l, int r) {
  if (t < l || r < s) return;
  if (!p) return;
  if (l <= s && t <= r) {
    q = p;
    p = 0;
    return;
  }
  if (!q) q = New();
  int m = s + t >> 1;
  if (l <= m) split(ls[p], ls[q], s, m, l, r);
  if (m < r) split(rs[p], rs[q], m + 1, t, l, r);
  push_up(p);
  push_up(q);
}

严肃抄OI-Wiki

注意到我们需要使用动态开点,数组大小应开到数据范围的 \(2^5\) 倍左右。

例题解析

题目链接:P5494 【模板】线段树分裂

我们很容易的注意到这题应该使用权值线段树,由于数据范围的问题,我们甚至不需要将它离散化。

让我们分别思考每个操作:

  1. 一个线段树分裂即可解决。

  2. 一个线段树合并即可解决。

  3. 线段树的单点修改操作。

  4. 线段树的区间查询操作。

  5. 由于本题我们使用的是权值线段树,我们可以直接二分得到。

我们发现这题就是一个线段树操作合集,只需要熟练掌握线段树的各种操作即可,也可作为线段树操作的练手题。(当然本题还是有不少细节的)

注意到我们将线段树合并和会出现一些使用过的废弃节点,如果不进行重复利用的话我们可以发现再空间复杂度上是很亏的。我们可以开一个数组,将已经废弃的节点清空并记录下来,下次开点时如果有废弃节点则先用废弃节点。

Code.
#include<bits/stdc++.h>
#define endl "\n"
#define pb push_back
#define mkp make_pair
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
//value 
const int inf=2147483647;
const int mod=1e9+7;
const int N=2e5+5;
ll tot,tot1,tot2=1;
ll t[N<<5],ls[N<<5],rs[N<<5],ru[N<<5],rt[N];
ll a[N];



//function 
void solve(){
	
	
	
	return;
}
void push_up(ll o){
	t[o]=t[ls[o]]+t[rs[o]];
}
void build(ll l,ll r,ll &o){
	if(!o)o=++tot;
	if(l==r){
		t[o]=a[l];
		return;
	}
	int mid=(l+r)>>1;
	build(l,mid,ls[o]);
	build(mid+1,r,rs[o]);
	push_up(o);
}
void change(ll l,ll r,ll k,ll x,ll &o){
	if(!o){
		if(tot1)o=ru[tot1--];
		else o=++tot;
	}
	if(l==r){
		t[o]+=x;
		return;
	}
	ll mid=(l+r)>>1;
	if(k<=mid)change(l,mid,k,x,ls[o]);
	else change(mid+1,r,k,x,rs[o]);
	push_up(o);
}
ll query1(ll l,ll r,ll L,ll R,ll o){
	if(l>=L && r<=R)return t[o];
	ll mid=(l+r)>>1;
	ll res=0;
	if(L<=mid)res+=query1(l,mid,L,R,ls[o]);
	if(mid<R)res+=query1(mid+1,r,L,R,rs[o]);
	return res;
}
ll query2(ll l,ll r,ll k,ll o){
	if(t[o]<k)return -1;
	if(l==r)return l;
	ll mid=(l+r)>>1;
	if(t[ls[o]]>=k)return query2(l,mid,k,ls[o]);
	else return query2(mid+1,r,k-t[ls[o]],rs[o]);
}
void split(ll &id1,ll &id2,ll l,ll r,ll L,ll R){
	//从id1上分裂出id2
	if(!id1)return;
	if(l>=L && r<=R){
		id2=id1;
		id1=0;
		return;
	}
	if(!id2){
		if(tot1)id2=ru[tot1--];
		else id2=++tot;
	}
	ll mid=(l+r)>>1;
	if(L<=mid)split(ls[id1],ls[id2],l,mid,L,R);
	if(mid<R)split(rs[id1],rs[id2],mid+1,r,L,R);
	push_up(id1);
	push_up(id2);
}
void del(int id){
	ls[id]=rs[id]=t[id]=0;
	ru[++tot1]=id;
}
ll merge(ll id1,ll id2,ll l,ll r){
	//把id2合并到id1
	if(!id1)return id2;
	if(!id2)return id1;
	if(l==r){
		t[id1]+=t[id2];
		del(id2);
		return id1;
	}
	ll mid=(l+r)>>1;
	ls[id1]=merge(ls[id1],ls[id2],l,mid);
	rs[id1]=merge(rs[id1],rs[id2],mid+1,r);
	push_up(id1);
	del(id2); 
	return id1;
}


 
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0); 
	
//	freopen(".in","r",stdin);
//	freopen(".out","w",stdout);
	
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++)cin>>a[i];
	build(1,n,rt[1]);
	while(m--){
		int opt;
		cin>>opt;
		if(opt==0){
			ll p,x,y;
			cin>>p>>x>>y;
			split(rt[p],rt[++tot2],1,n,x,y);
		}
		else if(opt==1){
			ll p,t;
			cin>>p>>t;
			rt[p]=merge(rt[p],rt[t],1,n);
		}
		else if(opt==2){
			ll p,x,q;
			cin>>p>>x>>q;
			change(1,n,q,x,rt[p]);
		}
		else if(opt==3){
			ll p,x,y;
			cin>>p>>x>>y;
			cout<<query1(1,n,x,y,rt[p])<<endl;
		}
		else if(opt==4){
			ll p,k;
			cin>>p>>k;
			cout<<query2(1,n,k,rt[p])<<endl;
		}
	}
	
	
	
	
	return 0;
}


可持久化线段树

使用场景

假设我们存在以下问题:

  • 单点修改。

  • 查询过往某次修改后的某元素值。

由于是单点修改、单点查询过往值,我们不好直接用裸的线段树维护,我们对线段树进行拓展,使用可持久化线段树。又称主席树。

基础操作

首先观察下图:

persistent-seg

我们尝试在线段树上进行单点修改,我们可以注意到每次往左右子节点跳的时候,我们总是只更改一半,另一半完全不做改变,最后进行修改的点形成了一条链,并且只有 \(\log n\) 条。我们可以想到新建一个根节点表示不同的版本,如果令一边节点表示被修改后的,另一边节点直接连向未修改的子树。

注意到我们依然需要使用动态开点,且由于每次修改后都会多出 \(\log n\) 个点,我们的数组大小应开 \(2n-1 + q \log n\) ,即数据范围的 \(2^5\) 倍左右。

学完可持久化线段树,我们就可以来做模板题了。

Code.
#include<bits/stdc++.h>
#define endl "\n"
#define pb push_back
#define mkp make_pair
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
//value 
const int inf=2147483647;
const int mod=1e9+7;
const int N=1e6+5;
int a[N],t[N<<5],ls[N<<5],rs[N<<5],rt[N];
int tot,tot1;


//function 
void solve(){
	
	
	
	return;
}
void push_up(int o){
	t[o]=t[ls[o]]+t[rs[o]];
}
void build(int l,int r,int &o){
	o=++tot;
	if(l==r){
		t[o]=a[l];
		return;
	}
	int mid=(l+r)>>1;
	build(l,mid,ls[o]);
	build(mid+1,r,rs[o]);
	push_up(o);
}
void change(int l,int r,int v,int k,int x,int &o){
	o=++tot;
	ls[o]=ls[v];
	rs[o]=rs[v];
	t[o]=t[v];
	if(l==r){
		t[o]=x;
		return;
	}
	int mid=(l+r)>>1;
	if(k<=mid)change(l,mid,ls[v],k,x,ls[o]);
	else change(mid+1,r,rs[v],k,x,rs[o]);
	push_up(o);
}
int query(int l,int r,int k,int o){
	if(l==r)return t[o];
	int mid=(l+r)>>1;
	if(k<=mid)return query(l,mid,k,ls[o]);
	else return query(mid+1,r,k,rs[o]);
}


 
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0); 
	
//	freopen(".in","r",stdin);
//	freopen(".out","w",stdout);
	
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++)cin>>a[i];
	build(1,n,rt[0]);
	for(int i=1;i<=m;i++){
		int v,opt;
		cin>>v>>opt;
		if(opt==1){
			int p,c;
			cin>>p>>c;
			change(1,n,rt[v],p,c,rt[i]);
		}
		else{
			int p;
			cin>>p;
			cout<<query(1,n,p,rt[v])<<endl;
			rt[i]=rt[v];
		}
	}
	
	
	
	return 0;
}


标记永久化

思考了一下还是给加上了,但是标记永久化不是线段树的具体操作,而是一种技巧。

假设我们维护了一棵线段树,该线段树所维护的信息在懒标记下放时会很难进行或会产生较大的时间或空间常数,我们就放弃懒标记下方操作,进行标记永久化。

我们在修改的时候,直接将懒标记打在节点上,在统计答案的时候,将求的范围的懒标记对答案的贡献一并统计。

posted @ 2025-07-28 19:22  -Delete  阅读(15)  评论(0)    收藏  举报