前缀和和差分思路理解以及典题题解

0 阅读13分钟

前言

        笔者是ACM选手,已经学习了一段时间算法,苦苦刷完大神拉的算法题单,感觉前缀和和差分是最简单的,所以想要写一篇理解前缀和和差分的文章,并为我刷的题单写题解,以检验对算法的掌握程度

        本篇文章讲述了一维、二维前缀和和差分的思路以及代码实现,还会附一道典题题解,完全不会前缀和的读者也可以阅读

一维前缀和

(1)思路理解

        一维前缀和数组存储的是一维数组前n个数的和,举一个简单的例子可以让大家看到显著的效率提升:

        1 2 3 4 5 6,这个数组,要算出第二位到第四位、第三位到第六位、第一位到第五位……等等位的数之和,朴素版代码应当是这样的:

vector<ll>num{1,2,3,4,5,6};
	ll sum=0;
	for(int i=1;i<4;i++)
	{
		sum+=num[i];
	}
	sum=0;
	for(int i=2;i<5;i++)
	{
		sum+=num[i];
	}
	sum=0;
	for(int i=0;i<6;i++)
	{
		sum+=num[i];
	}
	sum=0;

也就是每次求的时候,都一次一次遍历,再累加

如果数据很小这样做没问题,但是N如果到了1e7那个地步,这样算的话将会非常慢

前缀和的效率暂且不说,先说思路:

前缀和用到一个代码里非常重要的“预处理”思想,即提前将数据处理,进而大幅度减轻后续的负担。

核心思路是开一个数组,与原数组下标对应,前缀和数组下标存的是原数组前i个元素之和

即:第一个元素存原数组第一个元素,第二个元素存原数组前两个元素之和,第三个元素存原数组前三个元素之和……

那么这样做有什么用呢?试想一下,第二位到第四位的元素之和,是不是前四位元素之和减去前一位元素之和?第三位到第六位元素之和,是不是前六位元素之和减去前两位元素之和?

由此我们便得出了前缀和数组的用法:

sum[2-4]=sum[4]-sum[1],sum[3-6]=sum[6]-sum[2]

由此可以看到,前缀和的时间复杂度是O(n),后续只需要O(1)的减法即可,非常快

那么这个前缀和数组怎么用代码实现呢?

很明显,前i个元素之和等于前i-1个元素之和加上第i个元素的值,所以sum[i]=sum[i-1]+arr[i]

(2)代码实现

使用代码完整实现一次:

vector<ll>num(7);
	for(int i=1;i<=6;i++)
	{
		cin>>num[i];
	}
	vector<ll>sum(7);
	for(int i=1;i<=6;i++)
	{
		sum[i]=sum[i-1]+num[i];
	}
	cout<<sum[4]-sum[1]<<endl;
	cout<<sum[6]-sum[2]<<endl;
	cout<<sum[5]-sum[0]<<endl;

注意:因为前缀和涉及到很多i-1操作,所以为了方便,我们直接设定起始下标是1,0下标为0,也不对后边造成干扰

(3)典题题解——求区间和

题目出处:P8218 【深进1.例1】求区间和 - 洛谷

思路:这道题完全就是一维前缀和的模板题,输入所有数据,计算前缀和,然后每次输入l和r处理即可

AC代码:

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

void solve() 
{
	ll n;
	cin>>n;
	vector<ll>arr(n+1);
	for(int i=1;i<=n;i++) cin>>arr[i];
	ll m;
	cin>>m;
	vector<ll>s(n+1);
	s[1]=arr[1];
	for(int i=2;i<=n;i++)
	{
		s[i]=s[i-1]+arr[i];
	}
	while(m--)
	{
		ll l,r;
		cin>>l>>r;
		cout<<s[r]-s[l-1]<<endl;
	}
}

int main() 
{
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	int t=1;
	//cin>>t;
	while (t--) 
	{
		solve();
	}
	return 0;
}

(4)常用技巧——使用前缀和判断括号串

直接上题目:

题目出处:P1739 表达式括号匹配 - 洛谷

暴力做法是一遍一遍搜,非常慢且不优雅,前缀和有一个很常用的技巧:设"("的权重是1,")"的权重是-1,扫一遍字符串,如果前缀和小于0,说明有一个")"没有对应的"(",如果最终前缀和不等于0,说明肯定数量不对应,也是不匹配。如此只需要扫一遍即可

AC代码:

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

void solve() 
{
	string s;
	cin>>s;
	ll sum=0;
	for(int i=0;i<s.size();i++)
	{
		if(s[i]=='(')
		{
			sum++;
		}
		if(s[i]==')')
		{
			sum--;
		}
		if(sum<0)
		{
			cout<<"NO"<<endl;
			return;
		}
	}
	if(sum!=0)
	{
		cout<<"NO"<<endl;
		return;
	}
	cout<<"YES"<<endl;
}

int main() 
{
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	int t=1;
	//cin>>t;
	while (t--) 
	{
		solve();
	}
	return 0;
}

使用前缀和处理括号串是一个很常用的做法

二维前缀和

(1)思路理解

二维前缀和和一维前缀和的作用一样,存的是二维数组元素之和。

还是使用一个例子来引入:

有这样一个矩形:

1 -2 3 -4 5

5 -3 2 -4 -1

4 -5 -6 3 2

要求出所有可能存在的矩形中元素之和最大的部分,画个图方便大家理解题意:

在这个大矩形中可以任意圈数字,像黄色圈住的一样,要求找到这样能圈住的有最大元素之和矩形

有没有暴力思路?

可想而知,如果暴力做的话,一定是相当慢且非常麻烦的。

所以我们再用一下刚才一维前缀和的思路,使用s[i][j]存从(0,0)到(i,j)的矩形面积大小,那么任意的矩形面积都可以算出来了。

我们先推出s[i][j]的求法:首先s[1][1]一定是arr[1][1],s[i][j]的求法也可以通过递归的思想,通过已经求出的s数组存的内容来算

再画个图表示:

我们想算图中黄笔圈住的部分怎么做呢?这个部分是s[2][3],首先我们要想清楚:s数组的遍历一定是下标从小到大的,也就是说算到s[2][3]的时候s[1][2]、s[2][1]这些下标更小的部分一定是已经算出来的,所以我们可以直接用这些数据。

图中粉笔圈住的部分比当前要求的要小,一定是已经求好的。我们可以观察到这两个部分加起来,再减去重叠部分,再加上arr[2][3],直接就是黄笔圈住的面积了。

所以s[i][j]=s[i-1][j]+s[i][j-1]-s[i-1][j-1]+arr[i][j]

使用这个普适的式子,我们可以尝试从s[1][1]这个已知点遍历来证明其正确性,s[1][2]=s[0][2]+s[1][1]-s[0][1]+arr[1][2],因为带0下标的都是0,不会对前缀和造成影响,所以s[1][2]=s[1][1]+arr[1][2],依次往后推,会发现每个点都可以正确计算

如果这里你理解了,那么二维前缀和你已经理解了一半

另一半就是例子中的问题了:计算任意矩形的元素之和

比如图中这个黄色圈住的部分怎么算呢:

注意我们没有上帝视角,可以直接2+(-4),我们能用的只有s数组,即我们只知道从(1,1)到(i,j)的面积,我们可以这样算:

有三个用粉笔圈住的部分,这三个部分分别是s[2][4]、s[2][2]、s[1][4],很容易可以观察到,使用这个大矩形减去两个小矩形,再加上小矩形重叠的部分,就是我们要求的黄色矩形内的元素之和。

(2)代码实现

s[1][1]=arr[1][1];
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=n;j++)
		{
			s[i][j]=s[i-1][j]+s[i][j-1]+arr[i][j]-s[i-1][j-1];
		}
	}

(3)典题题解——求最大加权矩形

题目出处:P1719 最大加权矩形 - 洛谷

思路:这道题和我刚才举的例子完全是一回事,观察到n的范围只到120,所以循环多一些也没关系,所以“任意”矩形可以直接遍历边长来控制。

AC代码:

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

void solve() 
{
	ll n;
	cin>>n;
	vector<vector<ll>>arr(n+1,vector<ll>(n+1));
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=n;j++)
		{
			cin>>arr[i][j];
		}
	}
	vector<vector<ll>>s(n+1,vector<ll>(n+1));
	s[1][1]=arr[1][1];
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=n;j++)
		{
			s[i][j]=s[i-1][j]+s[i][j-1]+arr[i][j]-s[i-1][j-1];
		}
	}
	ll maxNum=s[1][1];
	for(int x=1;x<=n;x++)
	{
		for(int y=1;y<=n;y++)
		{
			for(int i=x;i<=n;i++)
			{
				for(int j=y;j<=n;j++)
				{
					maxNum=max(maxNum,s[i][j]+s[x-1][y-1]-s[x-1][j]-s[i][y-1]);
				}
			}
		}
	}
	cout<<maxNum<<endl;
}

int main() 
{
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	int t=1;
	//cin>>t;
	while (t--) 
	{
		solve();
	}
	return 0;
}

一维差分

(1)思路理解

开门见山,差分就是前缀和的逆运算,用非常直观的式子来讲解:

前缀和是s[i]=s[i-1]+arr[i],那么差分就是p[i]=arr[i]-arr[i-1]

将这两个式子移项,得到arr[i]=s[i]-s[i-1],arr[i]=arr[i-1]+p[i]

我们可以很清楚地看到,原数组是前缀和数组的差分数组,同时是差分数组的前缀和数组

因为差分数组完全就是通过前缀和数组推导出来的,所以它给人实际的感觉并不强烈,因此初学者可能理解不了差分。

理解不了也没有关系,我们只需要知道它的应用场景:

给定一段数据,现在要把其中若干段加n或减n

暴力做法就是一遍一遍扫,每一遍都计算一次,这样做在数据很大的情况下自然也很慢,差分在这里就可以极大提高效率

要理解差分为什么可以实现快速进行区间修改,就要牢记“差分的前缀和数组就是原数组”这一点

我们最后可以利用差分数组计算原数组,所以如果差分数组的某一处+n,那么原数组这一处后边所有元素都会+n,如果差分数组某一处-n,那么原数组这一出后边所有元素都会-n。

我们立刻就能想到,给定主数组的某一段起止位置,差分数组起始位置+n,结束位置-n,最后用差分的到的原数组这一段就+n,而对后续不会造成影响

差分起作用的方式就是:先用原数组得到差分数组,再根据要求对差分数组操作,再用差分数组得到原数组

(2)代码实现

vector<ll>d(n+1);
	for(int i=1;i<=n;i++)
	{
		d[i]=m[i]-m[i-1];
	}
	while(p--)
	{
		ll x,y,z;
		cin>>x>>y>>z;
		d[x]+=z;
		if(y<n)
		{
			d[y+1]-=z;
		}
	}
	for(int i=1;i<=n;i++)
	{
		m[i]=m[i-1]+d[i];
	}

(3)典题题解——区间修改

出处:P2367 语文成绩 - 洛谷

思路:这道题明显就是一个简单的区间修改,是差分的模板题,直接套模板做即可

AC代码:

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

void solve() 
{
	ll n,p;
	cin>>n>>p;
	vector<ll>m(n+1);
	for(int i=1;i<=n;i++)
	{
		cin>>m[i];
	}
	m[0]=1000000000;
	vector<ll>d(n+1);
	for(int i=1;i<=n;i++)
	{
		d[i]=m[i]-m[i-1];
	}
	while(p--)
	{
		ll x,y,z;
		cin>>x>>y>>z;
		d[x]+=z;
		if(y<n)
		{
			d[y+1]-=z;
		}
	}
	for(int i=1;i<=n;i++)
	{
		m[i]=m[i-1]+d[i];
	}
	sort(m.begin(),m.end());
	cout<<m[0]<<endl;
}

int main() 
{
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	int t=1;
	//cin>>t;
	while (t--) 
	{
		solve();
	}
	return 0;
}

二维差分

(1)思路理解

二维差分自然是以平面为单位的修改,即一片一片修改

而区域修改也需要考虑终止影响的位置,在起始位置加上这个数,在横坐标相同纵坐标结束位置再减去这个数,纵坐标相同横坐标结束位置再减去这个数,然后再结束位置加上这个数抵消一次

画图来辅助理解:

在左上角加1,变成-1,按照前缀和的算法计算,后续原数组所有位置都会+1,在粉圈右上角-1,右半部分就回归正常,在左下角+1,下半部分就回归正常,但是右下角位置减了两次1,所以需要+1

(2)代码实现

vector<vector<ll>>d(n+1,vector<ll>(n+1));
	while(m--)
	{
		ll x1,x2,y1,y2;
		cin>>x1>>y1>>x2>>y2;
		d[x1][y1]+=1;
		if(y2<n) d[x1][y2+1]-=1;
		if(x2<n) d[x2+1][y1]-=1;
		if(x2<n&&y2<n) d[x2+1][y2+1]+=1;
	}
	vector<vector<ll>>s(n+1,vector<ll>(n+1));
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=n;j++)
		{
			s[i][j]=d[i][j]-s[i-1][j-1]+s[i][j-1]+s[i-1][j];
		}
	}

(3)典题题解——区域修改

出处:P3397 地毯 - 洛谷

思路:n*n的矩阵,初始值全部为1,如果某一个地方有地毯,就+1,地毯是一片区域,所以要区域修改,因此用二维差分,很好做

AC代码:

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

void solve() 
{
	ll n,m;
	cin>>n>>m;
	vector<vector<ll>>d(n+1,vector<ll>(n+1));
	while(m--)
	{
		ll x1,x2,y1,y2;
		cin>>x1>>y1>>x2>>y2;
		d[x1][y1]+=1;
		if(y2<n) d[x1][y2+1]-=1;
		if(x2<n) d[x2+1][y1]-=1;
		if(x2<n&&y2<n) d[x2+1][y2+1]+=1;
	}
	vector<vector<ll>>s(n+1,vector<ll>(n+1));
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=n;j++)
		{
			s[i][j]=d[i][j]-s[i-1][j-1]+s[i][j-1]+s[i-1][j];
		}
	}
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=n;j++)
		{
			cout<<s[i][j]<<" ";
		}
		cout<<endl;
	}
}

int main() 
{
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	int t=1;
	//cin>>t;
	while (t--) 
	{
		solve();
	}
	return 0;
}

变式题目

出处:P3406 海底高铁 - 洛谷

题意:有n-1条线,可以选择办卡再买票,花费是卡费+办卡后的票价次数;也可以选择直接买票,花费是办卡前的票价次数,问总共的最小花费

这道题的核心在于计算每条线的使用次数,从a到b,小的那个站到大的那个站区间内所有线次数均需+1,也是区间修改,可以用一维差分来做,统计出数据贪心一下,每条线选择最小花费即可

AC代码:

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

void solve()
{
	ll n,m;
	cin>>n>>m;
	vector<ll>p(m+1);
	for(int i=1;i<=m;i++) cin>>p[i];
	vector<ll>a(n),b(n),c(n);
	for(int i=1;i<=n-1;i++)
	{
		cin>>a[i]>>b[i]>>c[i];
	}
	vector<ll>tp(n+1);
	vector<ll>num(n+1);
	for(int i=1;i<m;i++)
	{
		ll t=p[i];
		ll s=p[i+1];
		if(t<s)
		{
			tp[s]--;
			tp[t]++;
		}
		else
		{
			tp[s]++;
			tp[t]--;
		}
	}
	for(int i=1;i<n;i++)
	{
		num[i]=num[i-1]+tp[i];
	}
	ll sum=0;
	for(int i=1;i<n;i++)
	{
		ll x=min(num[i]*a[i],c[i]+num[i]*b[i]);
		sum+=x;
	}
	cout<<sum<<endl;
}

int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);
	cout.tie(0);
	ll t=1;
	//cin>>t;
	while(t--)
	{
		solve();
	}
	return 0;
}

写在最后

笔者会在《数据结构和算法》这一专栏陆续更新一些算法,顺序由笔者主观难度评定决定(我理解了就写)现在笔者很菜,不过后续会不断努力的