目录
前言
最近偶然间听说,做自己觉得困难的麻烦的事情,才能进步的更快,我想对我来说最难的,就是基于学过的模块进行总结和复盘,所以今天咱们来总结梳理一下前缀和和差分的部分。
算法概述
前缀和与差分其实是逆运算的两种方法,用来处理大段数据的运算问题,利用这个方法可以大大降低时间复杂度,在算法竞赛中时常遇到。
前缀和就是用数组的i个位置存储前i个数的乘积或者和,而差分呢,就是通过与前缀和相反的操作,将数组转变成差分数组,然后通过简单操作改变边缘的值,再利用前缀和变为普通数组,这样好处在于,只要进行简单的操作即可实现对数组中指定区域元素的更新。
算法原理
通俗来讲,就是利用数组的空间暂存某几个数的计算结果,避免大量的重复计算。
前缀和的主要作用就是在O(1)内计算数组中任意区间[l,r]的和。
差分数组的作用就是在O(1)内对原数组任意区间进行批量加减操作
核心区别:
前缀和用于高效查询,而差分用于高效更新。
题目示例
一维前缀数组
题目:大学里的树木要维护
这个题目作为标准的前缀和的题目,首先应该创建前缀数组,
cpp
for(int i=1;i<=n;i++){
ll t;
cin>>t;
bef[i]=t;
}
bef[0]=0;
for(int i=1;i<=n;i++){
bef[i]+=bef[i-1];
}
然后利用差分的思想,将对应的两个前缀和相减,就得到了对应的答案。
cpp
int get(ll l,ll r){
return bef[r]-bef[l-1];//应该是l-1因为要包含左端点
}
完整代码
cpp
#include <bits/stdc++.h>
using namespace std;
#define ll long long
// vector<ll> tree(100000,0);
vector<ll> bef(100000,0);
int get(ll l,ll r){
return bef[r]-bef[l-1];//应该是l-1因为要包含左端点
}
int main()
{
// 请在此输入您的代码
std::ios::sync_with_stdio(false);
std::cin.tie(NULL);
ll n,m;
cin>>n>>m;
for(int i=1;i<=n;i++){
ll t;
cin>>t;
bef[i]=t;
}
bef[0]=0;
for(int i=1;i<=n;i++){
bef[i]+=bef[i-1];
}
for(int i=0;i<m;i++){
ll l,r;
cin>>l>>r;
cout<<get(l,r)<<endl;
}
return 0;
}
二维前缀数组
题目:二维前缀和
关于二维数组的题目,我采用的是先横向遍历求前缀和,再纵向遍历求前缀和的方法,其实就类似于一维前缀数组的求法:
cpp
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
ll t;
cin>>t;
ret[i][j]=t;
sum[i][j]=sum[i][j-1]+ret[i][j];//先算一维前缀和
}
}
现在就相当于一列的前缀和数组,然后我们进行纵向的遍历:
cpp
//再扩展到二维前缀和
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
sum[i][j]=sum[i-1][j]+sum[i][j];
}
这样我们的二维前缀和数组就创建完毕了,大概就是这样:

然后根据题目要求,我们需要求出给出两坐标点围成矩形内的点的总和,其实跟前面的题目是一样的,只不过变成了二维,一维的时候是用两个前缀和相减得到中间的部分,而对于二维的来说,就有点变化了,我举一个例子:

如果说在这个矩阵里面,我们要求中间四个位置加起来的和,我们应该如何考虑呢,大家先思考议会儿,再往后看。
我们根据容斥的思想,如图所示:

我们可以看到,sum[3][3]-sum[3][1]-sum[1][3]+sum[1][1]就是我们最终想要的结果,也就是用大的正方形减去两个小矩形最后加上小正方形,这里为什么最后要加上个小正方形呢,因为刚刚减了两次这个位置,所以要加回来。
是不是很简单呀,下面看一下具体的代码实现:
完整代码
cpp
#include <bits/stdc++.h>
using namespace std;
#define ll long long
vector<vector<ll>> ret;
vector<vector<ll>> sum;
ll n,m,q;
int main()
{
// 请在此输入您的代码
cin>>n>>m>>q;
ret.assign(n+1,vector<ll>(m+1,0));
sum.assign(n+1,vector<ll>(m+1,0));
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
ll t;
cin>>t;
ret[i][j]=t;
sum[i][j]=sum[i][j-1]+ret[i][j];//先算一维前缀和
}
}
//再扩展到二维前缀和
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
sum[i][j]=sum[i-1][j]+sum[i][j];
}
}
for(int i=0;i<q;i++){
ll x1,y1,x2,y2;
cin>>x1>>y1>>x2>>y2;
cout<<sum[x2][y2]-sum[x1-1][y2]-sum[x2][y1-1]+sum[x1-1][y1-1]<<endl;
}
return 0;
}
差分数组
题目:棋盘
这是一道考察差分数组非常经典的题目,因为我们一开始并不需要对给出的矩阵转化成差分数组,我们只需要把原数组当成拆分数组进行操作即可,因为矩阵中元素初始化全部为0,第一步我们根据题目给出的信息,对元素进行更新,根据题目,初始为白色,即为0,被选中以后会被进行一次取反,即白变黑,黑变白,这里一共要改变四个位置的元素:即
(x1,y1),(x1,y2+1),(x2+1,y1),(x2+1,y2+1),这可能看着有点蒙,为什么是改变这四个位置的元素呢,下面我呈现我的理解:

一开始没有进行前缀和的计算是这样的,然后我们首先进行横向遍历:

横向遍历以后我们可以发现,角上的元素作为一个结界,避免更新操作惠及到其他的元素,及时终止了操作;
然后纵向遍历:

结束以后咱们就会发现右下角的元素被第一次遍历时莫名减了一次,所以做+1处理,就抵消掉了,最终就呈现出目标区域集体加一的情况。
看懂了,差分数组是如何更新值的,下一步我们结合题目想一件事,取反1次,相比取反11次,111次,结果是否是一样的,思考一下。
没错,其实两次取反以后又回到了原本的值,所以我们只需要计算每个位置最终结果是奇数还是偶数,即可确定最终是白棋还是黑棋。这里有个小技巧,我们可以利用位运算来判断奇偶,这样比较灵活。
下面呈现完整代码:
完整代码
cpp
#include<bits/stdc++.h>
using namespace std;
#define ll long long
vector<vector<ll>> ret;
int main(){
ll n,m;
cin>>n>>m;
ret.assign(n+2,vector<ll>(n+2,0));
for(ll i=0;i<m;i++){
ll x1,y1,x2,y2;
cin>>x1>>y1>>x2>>y2;
ret[x1][y1]++;
ret[x1][y2+1]--;
ret[x2+1][y1]--;
ret[x2+1][y2+1]++;
}
for(ll i=1;i<=n;i++){
for(ll j=1;j<=n;j++){
ret[i][j]+=ret[i-1][j];
}
}
for(ll i=1;i<=n;i++){
for(ll j=1;j<=n;j++){
ret[i][j]+=ret[i][j-1];
}
}
for(ll i=1;i<=n;i++){
for(ll j=1;j<=n;j++){
if(ret[i][j]&1){//代表被操作了奇数次
cout<<1;
}else{
cout<<0;
}
}cout<<endl;
}
return 0;
}
前缀和与位运算相结合的题目想必也是一个比较恶心的地方,这里也找一个题目讲一下:
前缀和与位运算组合问题
题目:异或和
这道题目对我来说简直可以用焦头烂额来形容,一句一句的去理解,现在才逐渐看懂,这也许是我这阵子遇到最困难的事情吧。
这个题目的主要思路是利用拆位的思想来解决这个异或计算结果太大的问题,拆位是什么意思呢,就是把一个十进制的数字通过一系列处理,变成一个二进制的数,然后每次只讨论其中的某一位,最终把所有位的结果整合到一起,就避免了结果太大的问题。
这个题目的思路大致是,首先咱们创建nums数组存储传入的元素,然后利用双重的for循环,内层用来遍历每一个数,外层用来遍历数的每一位,
完整代码
cpp
#include <iostream>
#include <vector>
using namespace std;
void print_int128(__int128 n) {
if (n < 0) {
cout << '-';
n = -n;
}
if (n >= 10) {
print_int128(n / 10);
}
cout << (char)(n % 10 + '0');
}
int main() {
ios::sync_with_stdio(false);
int n, i, j;
__int128 ans = 0;
cin >> n;
vector<long long> nums(n + 1);
for (i = 1; i <= n; i++)
cin >> nums[i];
for (i = 0; i <= 20; i++) {
long long bit_mask = 1ll << i;//不仅可用来充当2^k,而且可用来提取数的某一位
long long counts[2] = {0, 0};
long long sums[2] = {0, 0};
for (j = 1; j <= n; j++) {
//首先利用与bit_mask异或将除目标位数以外的地方变成0,然后利用!!
long long bit = !!(bit_mask & nums[j]);//将非零数变为1
ans += sums[bit ^ 1] * bit_mask;
counts[bit]++;
sums[0] += counts[0];
sums[1] += counts[1];
}
}
print_int128(ans);
cout << '\n';
}
经过大概一周的沉淀,这个题目我终于有了较为完整的认识,现在来分享一下我的理解,这个题目我们要计算的呢是:

但是在实际运行中,计算过程中必然会因为结果太大而溢出,所以这里我们采用拆位的思想,这是什么意思呢,就是把异或运算的两个数变成二进制,把一个数按位变成20个数来分别计算,最终加和,得到最后的结果,于是就转化成这个式子:

然后呢,在实际代码编写时,我们要对这个式子进行变形,方便我们实现代码:

bj代表的是拆位以后某一个数的某一位的值,Sj-1[bj^1]代表同一位上前面与当前这个数异或结果为1的索引与当前索引的差的和,也就是

这就是大体的思路,然后代码中的具体实现中,sums数组代表的就是前面提到的索引差的和,然后counts数组代表的是前面数与当前数异或结果为1的数量,然后这个sum[i]+=counts[i]怎么理解呢,是这样:

然后这个题目还有一个难点,就是这个题目的测试点非常大,所以即使是long long的数据类型也无法完全通过,所以我们需要加入一个新的东西**_int128,**
_int128
**_int128是一个扩展类型,用来表示128位有符号整数,范围是-2^127~2^127-1,**利用这个数据类型,我们可以避免溢出的情况发生。
但是这种数据类型在使用时需要注意:
1._int128不可以直接用cin/cout输入输出。必须自己手动编写输入输出函数;
2.从_int128向其他小范围类型转换时存在风险,但是从小范围类型向它转换是安全的;
3.相比long long速度较慢;
4.一些数学函数abs,pow等不适用。
于是我们设计出输出函数:
cpp
void print_int128(__int128 n) {
if (n < 0) {
cout << '-';
n = -n;
}
if (n >= 10) {
print_int128(n / 10);
}
cout << (char)(n % 10 + '0');
}
在这个函数中,首先利用递归将数的每一位拆开,然后将数以字符的类型输出出去,最后一位位拼接成我们的结果,+'0'操作代表的是加0的ASCII码变成当前数对应的ASCII码,输出出来就是对应的数字。
大功告成(后续会继续补充题目和理解,用心整理求点赞)