浅说线性差分和树上差分

线性差分

当我们这里有\(n\)个数,现在我要对其中一段进行操作,每次可能加上一个数,也有可能减去一个数,请输出最终的数列。
(\(0\le n \le 10^6\))

正常思路

我们正常来看,如果想要对一个区间进行操作,我们只能遍历这个区间,一个一个的去修改,但是这样的的时间复杂度是\(O(n^2)\),是过不了的,所以我们要换个思路。(当然如果你只想骗点分也是可以的

#include<bits/stdc++.h>
using namespace std;
const int INF=1e7;
int a[INF]
int main(){
	int n,m;
	cin>>n>>m;
	for (int i=1;i<=n;i++){
		cin>>a[i];
	}
	for (int i=1;i<=m;i++){
		int l,r,x;
		cin>>l>>r>>x;
		for (int j=l;j<=r;j++){
			a[j]+=x;
		}
	}
	for (int i=1;i<=n;i++){
		cout<<a[i]<<" ";
	}
	return 0;
}

差分思路

虽然我不知道这个思路是怎么来的,但是不重要,它其实就是一个思想,也就是利用以小带大的思想去操作。
差分就是对相邻的两个数求差值,然后这些差值的前缀和就是这个数,也就是前缀和是差分的逆运算。我们可以来看一个例子:
在这里插入图片描述
此时差分数组的前缀和就是原数组,当我们要修改一个区间时,只需要更改差分数组对应区间的首相和最后一项的下一位的值就行了。比如我们想要更改2~4这个区间的值,我们只需要将第三位的-3+1,并且将第五位的-2-1就行了,这样我们在进行前缀和的时候就不会多加一段了。
因此,我们得到了区间修改的核心代码

//ans表示差分数组
void change(int l,int r,int x){
	ans[l]+=x;
	ans[r+1]-=x;
	return;
}

下面我们将以一道例题,来给大家称述完整的代码

Descirption
输入一个长度为 n 的整数序列。
接下来输入 m 个操作,每个操作包含三个整数 l,r,c,表示将序列中 [l,r] 之间的每个数加上 c。
请你输出进行完所有操作后的序列。

分析题意后发现,这就是最简单的最标准的差分,话不多说,直接上代码:

#include<bits/stdc++.h>
using namespace std;
const int INF=200000+1000;
long long a[INF],b[INF];
int main(){
	int n,m;
	cin>>n>>m;
	for (int i=1;i<=n;i++){
		cin>>a[i];
		b[i]=a[i]-a[i-1];//计算差分数组
	}
	for (int i=1;i<=m;i++){
		int l,r,c;
		cin>>l>>r>>c;
		b[l]+=c;//核心代码
		b[r+1]-=c;
	}
	for (int i=1;i<=n;i++){
		b[i]+=b[i-1];//计算前缀和
		cout<<b[i]<<" ";
	}
	return 0;
}

我们还是老样子,上例题。

在这里插入图片描述
虽然我很想说,我不能帮帮她,分析题意可知,这也是一道一模一样的差分裸题,我们只需要在最后求解前缀和的时候,找个最小值就行了,一点也不难。

#include<bits/stdc++.h>
using namespace std;
const int INF=5*1e6+10;
int a[INF],b[INF];
int minn=INT_MAX;
int main(){
	int n,p;
	cin>>n>>p;
	for (int i=1;i<=n;i++){
		cin>>a[i];
		b[i]=a[i]-a[i-1];
	} 
	for (int i=1;i<=p;i++){
		int x,y,z;
		cin>>x>>y>>z;
		b[x]+=z;
		b[y+1]-=z;
	}
	for (int i=1;i<=n;i++){
		b[i]+=b[i-1];
		minn=min(b[i],minn);
	}
	cout<<minn;
	return 0;
}

在这里插入图片描述
这道题很有意思,在洛谷上是道绿题 题目传送门

因为是区间整体加减1,所以我们很自然的就可以想到用差分求解。

这道题可以看做求出原序列的差分之后,将 S[2...n] 全部变为0所需的最少的步数和可以使 S[1] 变为多少种不同的数。

很明显的,在我们求出的差分数组中,有正数也有负数,要消除这些数,使得它们全部归零,我们有以下3种可行的操作:

选取一个正数(X)和一个负数(Y),使正数减1,负数加1,这样经过N次操作,我们一定可以以最少的代价将绝对值较小的一方归零,代价为\(abs(min(X,Y))\)
选取一个正数(X),使其与 S[1] 配对,这样,我们经过N次操作,一定可以将它归零,代价为:abs(X)
选取一个负数(Y),使其与 S[n+1] 配对,这样,我们经过N次操作,一定可以将它归零,代价为:abs(Y)
经过上述分析,我们就能够推导出本题的解题公式:

\[ans=abs(min(X,Y))+abs(X−Y) \]

也就是

\[ans=abs(max(X,Y)) \]

需要注意的是这里的X,Y是差分数组中所有正数的和与所有负数的和的绝对值
最后我们还要求能构成几组解,这很容易可以推出:

\[ans=abs(X−Y)+1 \]

那么我们就可以直接写啦!

#include<bits/stdc++.h>
using namespace std;
const int INF=1e5+10;
long long a[INF];
long long ans1,ans2;
int main(){
	int n;
	cin>>n;
	for (int i=1;i<=n;i++){
		cin>>a[i];
		if (i==1)continue;
		long long c=a[i]-a[i-1];//求解差值
		if (c>0)ans1+=c;//判断是正是负
		else ans2+=abs(c);
	}
	cout<<max(ans1,ans2)<<endl;
	cout<<abs(ans1-ans2)+1;
	return 0;
}

我们上节课学了一维的差分,但其实还有二维差分,只是比较难写。

二维差分的定义

二维差分是指对于一个n*m的矩阵a,要求支持操作pro(x1,y1,x2,y2,a),表示对于以(x1,y1)为左上角,(x2,y2)为右下角的矩形区域,每个元素都加上常数a。求修改后的矩阵a。

二维差分的解释

这样说似乎还是有点抽象,我们不妨以一道例题来看看

在 n×n 的格子上有 m 个地毯。
给出这些地毯的信息,问每个点被多少个地毯覆盖。

其实不难发现,这个和普通的递推好像有点相似之处,我们其实可以画个图来解释一下本图来源于网络
注:上图来源于网络

不难发现,二维差分的关键就是在于:

mp[x1][y1]++;
mp[x1][y2+1]--;
mp[x2+1][y1]--;
mp[x2+1][y2+1]++;

所以我们直接上例题吧

例题1 地毯

在 n×n 的格子上有 m 个地毯。
给出这些地毯的信息,问每个点被多少个地毯覆盖。

这道题是十分简单的差分,直接套公式就行了

#include<bits/stdc++.h>
using namespace std;

int mp[2100][2100];
int main(){
	int n,m;
	cin>>n>>m;
	for (int i=1;i<=m;i++){
		int x1,x2,y1,y2; 
		cin>>x1>>y1>>x2>>y2;
		mp[x1][y1]++;
		mp[x1][y2+1]--;
		mp[x2+1][y1]--;
		mp[x2+1][y2+1]++;
	}
	for (int i=1;i<=n;i++){
		for (int j=1;j<=n;j++){
			mp[i][j]+=mp[i-1][j]+mp[i][j-1]-mp[i-1][j-1];
			cout<<mp[i][j]<<" ";
		}
		cout<<endl;
	}
	return 0;
}

现在的话我们就把他放到树上来做。因为这是树,所以会有点和边之分,所以树上差分也会分为 点差分边差分

树上差分引入

树上差分其实和线性差分没有什么区别,只不过是放到了树上的两点,而他们之间的最简路径就是可以类比成线性的两点之间的线段。
在这里插入图片描述
所以我们如果要对一条曲线进行操作的话,就是在树上跑差分,那么树上差分数组究竟是什么呢,他又代表着什么意思呢?这是一个值得深思的问题,也是困扰我很久的问题。

树上差分数组本质上存储的是 操纵方式,而不是简单的差值。例如一个点 \(x\) 的差分数组 \(p[x]=-3\) 代表这个点或这个点所对应的边被减去了3,而不是这个点或这个边等于-3,这是一个易错也易混的点。

相同的,对线性差分数组进行求前缀和的话,我们就可以得到真实的答案的值,那么类似的,在 树上差分数组中求子树和,就可以的到这个点的操纵方式,不过为什么要求子树和,而不求其它的,我也不知道,我也不敢问。
在这里插入图片描述

点差分

如果我们现在要对树上的一条路径上的点进行统一操作,比如说 +1 或 -2,我们应该如何完成?
首先是不是很容易想到暴力 dfs ?但是这样的时间复杂度是 \(\cal O(n^2)\) 的,不优秀。但是我们在学什么,是不是在学差分,那为什么不从差分的视角来考虑考虑?

如果我们要对一条 \(A \rightarrow B\) 的路径进行+1的修改操作,那么首先我们肯定要在点 \(A\)\(B\) 两个位置进行修改对吧,又因为我们最后是通过求子树和来求得每个点的修改方式,所以不能影响 点 \(A\) 和 点 \(B\) 所对应的子树,所以我们可以非常明了的得到

\[p[A]++,p[B]++ \]

然后我们的目光不断向上看,发现基本上都没有问题,但是在他们的最近公共祖先那里除了岔子,为什么呢?因为二者是从两端慢慢爬上来的,这就会导致他们的 LCA 会被增加两次,所以我们又可以得到

\[p[lca(A,B)]-- \]

我们继续往上看,发现点 \(A\) 和点 \(B\) 的最近公共祖先的父亲在计算子树和的时候任然会被增加一次,但是他应该是不能被修改的,所以我们还可以得到

\[p[fa(lca(A,B))]-- \]

继续往上看,发现没有问题了,说明我们的操作就成功的完成了!(看不懂的,看下面的图示)
在这里插入图片描述
我们把上述的公式总结一下就是 两个点自己加,他们的lca和lca的爸爸都要减,把它写成代码就是

//dp 为倍增数组,lca为求最近公共祖先的函数
int root=lca(a,b);
p[a]++,p[b]++;
p[root]--,p[dp[root][0]]--;

那么当我们要求所有的答案的时候,也就是要求子树和的时候,我们就可以使用一个时间复杂度为 \(\cal O(n)\) 的 dfs 函数来求解:

//mp为邻接表存储
void get(int x, int fa) {
    int len = mp[x].size();
    for (int i = 0; i < len; i++) {
        if (mp[x][i] == fa) continue;
        int t = mp[x][i];
        get(t, x);
        p[x] += p[t];//差分数组求子树和
    }
}

看完后是不是感觉非常简单,就只是在树上倍增的基础上对一个数组进行了一点点操作,那么好,我们上实战!

例题1——wwx的出玩

wwx给她的衣柜的有 n 个隔间,隔间编号为1到 n 。她有 k 天要和她的男朋友出去玩,第 i 次玩耍wwx会穿隔间 si 到隔间 ti 的衣服,每次穿这些衣服,都会给它们带来一个单位的损坏,你作为她的男朋友,请计算损坏程度最大的衣服的损坏程度是多少。

分析与解答

不难发现,这道题就是一道裸的点差分问题,所以我们直接套模板,顺便也把模板给你了。

#include<bits/stdc++.h>
using namespace std;
const int INF=1e5;
vector<int> mp[INF];
int ans,dp[INF][20],deep[INF],num[INF];//num为差分数组

void prepare(int x,int fa){
	for (int j=1;(1<<j)<=deep[x]-1;j++){
		dp[x][j]=dp[dp[x][j-1]][j-1];
	} 
	int len=mp[x].size();
	for (int i=0;i<len;i++){
		if (mp[x][i]==fa)continue;
		int t=mp[x][i];
		deep[t]=deep[x]+1,dp[t][0]=x;
		prepare(t,x); 
	}
}

int lca(int x,int y){
	if (deep[x]<deep[y])swap(x,y);
	int index=__lg(deep[x]-deep[y]);
	for (int i=index;i>=0;i--){
		if (deep[dp[x][i]]>=deep[y])x=dp[x][i];
		if (deep[x]==deep[y])break;
	}
	if (x==y)return x;
	for (int i=18;i>=0;i--){
		if (dp[x][i]!=dp[y][i])x=dp[x][i],y=dp[y][i];
	}
	return dp[x][0];
}

void get(int x,int fa){
	int len=mp[x].size();
	for (int i=0;i<len;i++){
		if (mp[x][i]==fa)continue;
		int t=mp[x][i];
		get(t,x);
		num[x]+=num[t];
	}
	ans=max(ans,num[x]);//求得最大值
}
int main(){
	int n,k;
	cin>>n>>k;
	for (int i=1;i<n;i++){
		int u,v;
		cin>>u>>v;
		mp[u].push_back(v);
		mp[v].push_back(u);
	} 
	deep[1]=1;
	prepare(1,-1);
	for (int i=1;i<=k;i++){
		int s,t;
		cin>>s>>t;
		int root=lca(s,t);
		num[s]++,num[t]++,num[root]--,num[dp[root][0]]--;
	}
	get(1,-1);
	cout<<ans;
	return 0;
}

例题2——松鼠的新家

题目传送门

分析与解答

这道题其实也是裸题,我们稍微分析一下就知道它的那个参观路线是可以被分成多个区间修改的,但是这样的话,他们的端点的位置,会被重复计算,所以要特判

#include<bits/stdc++.h>
using namespace std;
const int INF=3e5+10;
vector<int> mp[INF];
int ans,dp[INF][20],deep[INF];
int a[INF],num[INF];

void prepare(int x,int fa){
	for (int j=1;(1<<j)<=deep[x]-1;j++){
		dp[x][j]=dp[dp[x][j-1]][j-1];
	} 
	int len=mp[x].size();
	for (int i=0;i<len;i++){
		if (mp[x][i]==fa)continue;
		int t=mp[x][i];
		deep[t]=deep[x]+1,dp[t][0]=x;
		prepare(t,x); 
	}
}

int lca(int x,int y){
	if (deep[x]<deep[y])swap(x,y);
	int index=__lg(deep[x]-deep[y]);
	for (int i=index;i>=0;i--){
		if (deep[dp[x][i]]>=deep[y])x=dp[x][i];
		if (deep[x]==deep[y])break;
	}
	if (x==y)return x;
	for (int i=18;i>=0;i--){
		if (dp[x][i]!=dp[y][i])x=dp[x][i],y=dp[y][i];
	}
	return dp[x][0];
}

void get(int x,int fa){
	int len=mp[x].size();
	for (int i=0;i<len;i++){
		if (mp[x][i]==fa)continue;
		int t=mp[x][i];
		get(t,x);
		num[x]+=num[t];
	}
	ans=max(ans,num[x]);
}
int main(){
	int n;
	cin>>n;
	for (int i=1;i<=n;i++){
		scanf("%d",&a[i]);
	}
	for (int i=1;i<n;i++){
		int u,v;
		scanf("%d%d",&u,&v);
		mp[u].push_back(v);
		mp[v].push_back(u);
	} 
	deep[1]=1;
	prepare(1,-1);
	for (int i=1;i<n;i++){
		int s=a[i],t=a[i+1];
		int root=lca(s,t);
		num[s]++,num[t]++,num[root]--,num[dp[root][0]]--;
	}
	get(1,-1);
	for (int i=2;i<=n;i++){
		num[a[i]]--;
	}
	for (int i=1;i<=n;i++){
		printf("%d\n",num[i]);
	}
	return 0;
}

边差分

其实这种差分的思路和点差分的思路差不多,只不过因为是边,所以我们不能直接操作,因此我们要把这个边放到这条边所连接的两个点中的更深一层的点。(例如:\(deep[x]<deep[y] \rightarrow p[y]=mp[x][y]\)
我们还是一步一步的推,相信在有上节课中教你的推导方式,这个公式也是呼之欲出的,所以我就不做示范了,下来你可以自己试试。

//lca为找最大公共祖先的函数
int root=lca(x,y);
p[x]++,p[y]++;
p[root]-=2;

这个的话,总结一下就是 两个点自己加,他们的祖先减两倍
至于求得最后的答案,也是和点差分是一样的,所以就不做过多的赘述了。

例题1——边差分模版

给出一棵n个点的树,每条边有一个边权,给定m个操作u,v,x,表示把u-->v这条边的边权增加x,最后询问最大的边权。

分析与解答

其他不说,也是非常简单的一道模版题,所以直接就给代码了,也算是把模版给你们了。

#include<bits/stdc++.h>
using namespace std;
const int INF=2e5+10;
struct Node{
	int point,num;
};
vector<Node> mp[INF];
long long a[INF],p[INF],ans=INT_MIN;
long long deep[INF],dp[INF][20];

void prepare(int x,int fa){
	for (int j=1;(1<<j)<=deep[x]-1;j++){
		dp[x][j]=dp[dp[x][j-1]][j-1];
	}
	int len=mp[x].size();
	for (int i=0;i<len;i++){
		if (mp[x][i].point==fa)continue;
		int t=mp[x][i].point;
		a[t]=mp[x][i].num;
		dp[t][0]=x,deep[t]=deep[x]+1;
		prepare(t,x);
	}
}

int lca(int x,int y){
	if (deep[x]<deep[y])swap(x,y);
	int index=__lg(deep[x]-deep[y]);
	for (int i=index;i>=0;i--){
		if (deep[dp[x][i]]>=deep[y])x=dp[x][i];
		if (deep[x]==deep[y])break;
	}
	if (x==y)return x;
	for (int i=18;i>=0;i--){
		if (dp[x][i]!=dp[y][i])x=dp[x][i],y=dp[y][i];
	}
	return dp[x][0];
}

void get(int x,int fa){
	int len=mp[x].size();
	for (int i=0;i<len;i++){
		if (mp[x][i].point==fa)continue;
		int t=mp[x][i].point;
		get(t,x);
		p[x]+=p[t];
	} 
	ans=max(ans,p[x]+a[x]);
}
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	int n,m;
	cin>>n>>m;
	for (int i=1;i<n;i++){
		int u,v,x;
		cin>>u>>v>>x;
		mp[u].push_back({v,x});
		mp[v].push_back({u,x}); 
	}
	deep[1]=1;
	prepare(1,-1);
	for (int i=1;i<=m;i++){
		int u,v,x;
		cin>>u>>v>>x;
		int root=lca(u,v);
		p[u]+=x,p[v]+=x,p[root]-=2*x;
	}
	get(1,-1);
	cout<<ans;
	return 0;
}

例题2——运输计划

题目传送门

分析与解答

首先请注意哈,在进入提高组以后,算法一般情况下是不会出现的模版题或者是单独的一种算法的,一般情况都是把很多种算法融合在一起来考,所以我们思路也要多方面的入手。
首先拿到这个题,把题面看完以后,就会发现这是一道最大值最小的问题,那么自然而然的就要使用二分答案,那么接下来就是如何check的问题。
因为我们二分的是最终的答案,所以但是每一次运输的距离有长有短,因此我们要从中找到不符合当前答案的路径有哪些,有多少,并且通过一种标记手段表示这条路径是要被减去一定长度的,那么这种标记手段我们就可以用到差分。
在这里插入图片描述

现在我们已经找到了所有的不符合条件的路径了,但是题目中要求了只能对一条边开虫洞,所以我们还要找到到底要开那条边,那么怎么找呢?
这时候差分数组有排上用场了,不难发现,如果我们对差分数组进行求子树的操作时,每条边所对应的点的差分值就是当前边被多少条边经过的,这是非常显然的对吧!那么我们是不是只需要在求子树和的时候,判断一下当前点是否被所有边经过不就行了吗?然后从这些边中选一条最大的边来开虫洞,是不是就行了?
最后我们在让所有路径中最大的一条来减去当前选取的最大的一条所的得到的值来和当前所二分到的答案进行判断,是不是就可以了?思路讲完了,但是还是有一些细节上的东西我就不在这里细说了,自己看代码吧,我会尽量的注释详细的。

#include<bits/stdc++.h>
using namespace std;
const int INF=3e5+10;

struct Node{
	int point,num;
};
vector<Node> mp[INF];
int a[INF],b[INF],dp[INF][30],w[INF][30],p[INF],edge[INF];
int deep[INF],dis[INF];
int maxlen=INT_MIN,maxtot=INT_MIN,ans=INT_MAX;
int maxn=0,tim=0;
void prepare(int x,int fa){
	for (int j=1;(1<<j)<=deep[x]-1;j++){
		dp[x][j]=dp[dp[x][j-1]][j-1];
		w[x][j]=w[dp[x][j-1]][j-1]+w[x][j-1];//维护树上距离
	}
	int len=mp[x].size();
	for (int i=0;i<len;i++){
		if (mp[x][i].point==fa)continue;
		int t=mp[x][i].point;
		edge[t]=mp[x][i].num;//存储以t结尾的那条边
		deep[t]=deep[x]+1,dp[t][0]=x,w[t][0]=mp[x][i].num;
		prepare(t,x); 
	}
}

int lca_length(int x,int y){
	int tot=0;
	if (deep[x]<deep[y])swap(x,y);
	int index=__lg(deep[x]-deep[y]);
	for (int i=index;i>=0;i--){
		if (deep[dp[x][i]]>=deep[y])tot+=w[x][i],x=dp[x][i];
		if (deep[x]==deep[y])break;
	}
	if (x==y)return tot;
	for (int i=20;i>=0;i--){
		if (dp[x][i]!=dp[y][i])tot+=w[x][i]+w[y][i],x=dp[x][i],y=dp[y][i];
	}
	tot+=w[x][0]+w[y][0];
	return tot;
}

int lca_root(int x,int y){
	if (deep[x]<deep[y])swap(x,y);
	int index=__lg(deep[x]-deep[y]);
	for (int i=index;i>=0;i--){
		if (deep[dp[x][i]]>=deep[y])x=dp[x][i];
		if (deep[x]==deep[y])break;
	}
	if (x==y)return x;
	for (int i=20;i>=0;i--){
		if (dp[x][i]!=dp[y][i])x=dp[x][i],y=dp[y][i];
	}
	return dp[x][0];
}

void get(int x,int fa){
	int len=mp[x].size();
	for (int i=0;i<len;i++){
		if (mp[x][i].point==fa)continue;
		int t=mp[x][i].point;
		get(t,x);
		p[x]+=p[t];
	}
	if (p[x]==tim)maxn=max(maxn,edge[x]);//如果当前点的差分值等于不符合条件的值,那么就取最大值
} 

int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	int n,m;
	cin>>n>>m;
	for (int i=1;i<n;i++){
		int u,v,w;
		cin>>u>>v>>w;
		mp[u].push_back({v,w});
		mp[v].push_back({u,w});
		maxlen=max(maxlen,w);//找到最大的边来求上下边界的值
	}
	deep[1]=1;
	prepare(1,-1);
	for (int i=1;i<=m;i++){
		cin>>a[i]>>b[i];
		dis[i]=lca_length(a[i],b[i]);//维护路径
		maxtot=max(dis[i],maxtot);//找到最大的路径来求上下边界的值
	}
	int l=maxtot-maxlen,r=300000000;
	while (l<=r){
		maxn=0,tim=0;
		memset(p,0,sizeof(p));
		int mid=(l+r)>>1;
		for (int i=1;i<=m;i++){
			if (dis[i]>mid){//不符合条件,开始差分
				int root=lca_root(a[i],b[i]);
				p[a[i]]++,p[b[i]]++,p[root]-=2;
				tim++;//记录次数
			}
		}
		if (tim==0){//如果都符合,那么当前答案自然可以
			ans=mid;
			r=mid-1;
			continue; 
		}
		get(1,-1);//求子树和
		if (maxtot-maxn<=mid){//判断
			ans=mid;
			r=mid-1;
		}else l=mid+1;
	}
	cout<<ans;
	return 0;
}
posted @ 2025-03-22 21:29  CylMK  阅读(165)  评论(0)    收藏  举报