目录
前言
算法竞赛中经常出现一类数组题:对某个元素或一段区间进行一些看起来并不复杂的操作,但是观察后会发现这些操作难以找到规律。例如令 \(a_i = a_{i-1} + a_{i+1} - a_i\),这个式子看起来没什么道理,并且元素之间彼此影响,无从下手。
于是引入一种新的思考方式:比起观察原数组,可以观察这些操作在差分数组或前缀和数组上的表现。
以上述操作为例,假设我要对 \(a_2\) 进行操作
操作前:
原数组: a1 a2 a3
差分数组: a1 a2-a1 a3-a2
操作后:
原数组: a1 a1+a3-a2 a3
差分数组: a1 a3-a2 a2-a1
注意到,一次操作实际上就是把差分数组上相邻位置的元素交换。针对这个规律,我们就简单刻画出了这个操作在差分数组上的表现形式。
为什么想到转化差分?打个比方,我们都知道,当需要对区间进行加减时,只需要将差分数组上对应的两个位置修改即可。修改两个位置显然比对整个区间操作要方便。因此我们考虑逆向思维,只要知道了某个操作在差分上的表现,那么在差分数组上操作,最后再还原回原数组是否会更好做?这就是本篇我想讲的东西。
例题
洛谷P4552 Poetize6 IncDec Sequence
题面很简单,这是一道经典题目。看到区间加1或减1操作,立刻想到做差分。对区间 \(l,r\) 加1,可以刻画成给差分数组的 \(diff_l+1,diff_{r+1}-1\)。区间减1同理。特别地,对区间 \(l,n\) 的操作,可以看做只修改 \(diff_l\),因为此时 \(diff_{r+1}\) 越界,没有意义。
因此我们可以在差分数组上做4种操作:
\diff_l+1,diff_{r+1}-1 \\
\diff_l+1 \\
\diff_l-1,diff_{r+1}+1 \\
\diff_l-1 \\
现在要使数组中所有元素相等,可以如何操作差分数组?
首先要明白,当数组元素全部相等时,差分数组第2项开始全都为0。记录差分数组中正数和与负数和,由操作1或3可以将正数与负数两两消去,最后剩下要么全是正数,要么全是负数,此时再用操作2或4单独消去。所以需要的最少步骤即为:\(min(sum_正,sum_负)+|sum_正-sum_负|\)。
再看第二问,求最终得到的数组有几种。由于 \(diff_1 = a_1-a_0 = a_1\),因此差分第一项表示的就是原数组第一项本身。这个数并不是没有意义的。本题中,由于最终数组完全相同,因此要求数组种数,实际上就是求 \(diff_1\) 有几种。这个答案就是 \(|sum_正-sum_负|+1\)。
为什么?考虑当操作1或3都执行完时,可以用2或4将剩下的数都消掉,实际上也可以通过 \(diff_1+1,diff_i-1\) 的方式消去。因为我们只关心差分中第二项开始均为0,而 \(diff_1\) 的值无论是多少是不重要的。因此 \(diff_1\) 的种类数就是单独消去的步数,即 \(|sum_正-sum_负|\)。答案加上本身即可。
cpp
#include <iostream>
#include <math.h>
using namespace std;
const int N = 1e5 + 10;
long long a[N],diff[N];
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int n;
cin >> n;
for (int i = 1 ; i <= n ; i++) cin >> a[i];
for (int i = 1 ; i <= n ; i++) diff[i] = a[i] - a[i-1];
// for (int i = 2 ; i <= n ; i++) cout << diff[i] << " ";
// cout << endl;
long long sum1 = 0;
long long sum2 = 0;
for (int i = 2 ; i <= n ; i++)
{
if (diff[i] > 0) sum1 += diff[i];
else sum2 += -diff[i];
}
cout << max(sum1,sum2) << endl;
cout << abs(sum1-sum2) + 1 << endl;
return 0;
}
题意:给定长度为 \(n\) 的数组 \(a\),每次可以选择一个长度任意的前缀或者后缀,给这段区间减1,问能否将数组所有元素变成0。
看到区间操作首先想到差分:
对前缀 \(1,i\) 减1,就是 \(diff_1-1,diff_{i+1}+1\)
对后缀 \(i,n\) 减1,就是 \(diff_i-1,diff_{n+1}+1\),由于 \(diff_{n+1}\) 没有意义,后缀操作可以简单理解为选一个差分值-1。
要把数组全部变成0,就是把差分数组全部变成0。由于我们有后缀操作,因此差分数组中所有正数都可以被简单消去。考虑如何处理负数,我们发现每次前缀操作在选择一个值+1的同时都会使 \(diff_1-1\),所以 \(diff_1\) 就是我们最多能进行的前缀操作次数。
于是只需要判断差分数组中负数绝对值之和与 \(diff_1\) 的大小关系即可。
cpp
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
typedef pair<ll,int> PII;
const int N = 1e6 + 10;
void solve()
{
int n;
cin >> n;
vector<int> a(n+10),b(n+10);
ll sum = 0;
for (int i = 1 ; i <= n ; i++)
{
cin >> a[i];
b[i] = a[i] - a[i-1];
if (b[i] < 0) sum -= b[i];
}
if (b[1] >= sum) cout << "YES" << endl;
else cout << "NO" << endl;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}
题意:给定数组 \(a\),支持一种操作:选择下标 \(i(2\leq i \leq n-1)\),若 \(a_{i-1}\) 与 \(a_{i+1}\) 奇偶性相同,则可以将 \(a_i\) 修改为 \(a_{i-1}+a_{i+1}-a_i\)。求最终能得到的字典序最小的数组。
出烂了的套路。由开篇的分析:进行一次操作,实际上就是在差分数组上将两数交换。但由于只有 \(a_{i-1}\) 与 \(a_{i+1}\) 奇偶性相同才可操作,还需要分析什么样的数是可以交换的。
当 \(diff_i 与 diff_{i+1}\) 奇偶性相同时,\(a_{i-1}\) 与 \(a_{i+1}\) 奇偶性相同。所以我们只能交换差分数组中奇偶性相同的相邻两项。为了达到字典序最小,一定是越小的数出现在越前最优,因为还原成原数组时,是对差分数组做一次前缀和。而小的数出现在前,就可以保证累加时后面的数最小。
所以只需要对奇偶性相同的差分数组块排序,最终还原就是答案。
cpp
#include <bits/stdc++.h>
using namespace std;
typedef pair<int,int> PII;
typedef bitset<10> bt;
using ll = long long;
const int N = 2e4 + 10;
const int MOD = 1e9 + 7;
void solve()
{
int n;
cin >> n;
vector<ll> a(n+10),b(n+10);
for (int i = 1 ; i <= n ; i++) cin >> a[i];
for (int i = 1 ; i <= n ; i++) b[i] = a[i] - a[i-1];
vector<int> vis(n+10);
int p = 2;
for (int i = 2 ; i < n ; i++)
{
if (vis[i]) continue;
p = i;
while (p <= n && abs(b[p]) % 2 == abs(b[i]) % 2) p++;
sort(b.begin()+i,b.begin()+p);
for (int j = i ; j < p ; j++) vis[j] = true;
}
for (int i = 1 ; i <= n ; i++)
{
a[i] = a[i-1] + b[i];
cout << a[i] << " ";
}
cout << endl;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}
题意:给定数组 \(c,t\),支持将 \(a_i\) 修改为 \(a_{i-1}+a_{i+1}-a_i\)。问能否将 \(c\) 修改成 \(t\)。
上题的双倍经验题。两数组完全相同的条件是:差分数组每项都相等。简单判定差分能否完全相等即可。特别注意:\(diff_1\) 永远无法被交换,因此需要单独判断。
cpp
#include <bits/stdc++.h>
using namespace std;
typedef pair<int,int> PII;
typedef bitset<10> bt;
using ll = long long;
const int N = 2e4 + 10;
const int MOD = 1e9 + 7;
void solve()
{
int n;
cin >> n;
vector<ll> a(n+10),b(n+10),d1(n+10),d2(n+10);
for (int i = 1 ; i <= n ; i++)
{
cin >> a[i];
d1[i] = a[i] - a[i-1];
}
for (int i = 1 ; i <= n ; i++)
{
cin >> b[i];
d2[i] = b[i] - b[i-1];
}
sort(d1.begin()+2,d1.begin()+n+1);
sort(d2.begin()+2,d2.begin()+n+1);
for (int i = 1 ; i <= n ; i++)
{
if (d1[i] != d2[i])
{
cout << "No" << endl;
return;
}
}
cout << "Yes" << endl;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}
题意:给定数组 \(a\),单次操作可以选择 \(a\) 中的任意一个子序列,对其进行一下二选一的操作:
- 子序列中奇数下标的数+1,偶数下标的数-1
- 子序列中奇数下标的数-1,偶数下标的数+1
问将原数组所有数变为0最少需要几次操作。
非常非常好的题。
首先分析这个操作,会发现这是个很复杂的东西。此时就要想到做转化了。如果把问题转化为差分操作,会发现变得更加复杂。那么遇到在原数组上做单点修改的问题,可以试试转化前缀和。这就是这道题与前几题不太一样的地方。
我们都知道,给 \(a_i+1\),等效于在前缀和 \(prefixi,n+1\)。对于减是同理的。
对于操作1,它在前缀和上的表现是什么样的?假设我们在长度为 \(7\) 的数组上取下标序列 \(1\ 3\ 4\ 7\),观察前缀和数组的变化。
下标 :1 2 3 4 5 6 7
前缀和:1 1 0 1 1 1 0
也就是说,执行一次操作1,实际上就是在前缀和数组上给多段区间加1。操作2则是给多段区间减1。由于子序列任选,这些区间也可以任选。
此时可以发现,我们可以任意操作这个前缀和数组,一个操作可以给任何数加上1或者减去1。那么把前缀和所有数消成0,就等价于原数组全为0。此时的最少操作次数是显然的:最大正数-最小负数。
cpp
#include <iostream>
#include <algorithm>
#include <vector>
#include <cmath>
#include <queue>
using namespace std;
using ll = long long;
void solve()
{
int n;
cin >> n;
vector<int> a(n+10);
vector<ll> sum(n+10);
for (int i = 1 ; i <= n ; i++)
{
cin >> a[i];
sum[i] = sum[i-1] + a[i];
}
ll maxn = 0, minn = 0;
for (int i = 1 ; i <= n ; i++)
{
maxn = max(maxn,sum[i]);
minn = min(minn,sum[i]);
}
cout << maxn-minn << endl;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int T;
cin >> T;
while (T--) solve();
return 0;
}
题意:有 \(n\) 堆石子,初始每堆里有1颗石子,现在希望进行一些操作,使得第 \(i\) 堆中有 \(a_i\) 颗石子。一次操作是从某堆石子两边的堆各拿一个石子,放到中间的那堆里(如果在两侧,那么只移动一个石子)。求出最小操作数,并在题目要求时构造方案。
vp遇到的题目,我们队与出题人做法略有不同。这题不是我写的,就简单讲讲与本篇有关的转化。
做前缀和,原操作就是在前缀和上做:
- \(prefix_1+1\)
- prefix_{i-1}-1,prefix_i+1 \\ \\ \\ 2 \\leq i \< n
- \(prefix_{n-1}-1\)
队友貌似在第一次前缀和基础上又做了一次前缀和,不是很明白他的思路。
cpp
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N = 2e5 + 10;
struct node
{
int val,pos,t;
};
struct cmp
{
bool operator()(const node& a, const node& b) const
{
return a.val < b.val;
};
};
int n,typ;
int a[N],b[N];
int now[N];
int cnt[N];
int dfn[N];
priority_queue<node,vector<node>,cmp> q;
void solve() {
cin>>n>>typ;
for(int i=1;i<=n;i++)cin>>a[i];
for(int i=1;i<=n;i++)b[i]=b[i-1]+a[i];
for(int i=1;i<=n;i++)b[i]=b[i-1]+b[i];
for(int i=1;i<=n;i++)now[i]=i*(i+1)/2;
int ans=0;
for(int i=1;i<=n;i++)ans=max(ans,b[i]-now[i]);
cnt[1]=ans;
for(int i=1;i<=n;i++)now[i]+=ans;
for(int i=1;i<n;i++)ans+=now[i]-b[i];
for(int i=1;i<n;i++)cnt[i+1]=now[i]-b[i];
cout<<ans<<endl;
if (!typ) return;
for (int i = 1 ; i <= n ; i++)
{
dfn[i] = 1;
q.push({a[i],i,1});
}
vector<int> opt;
for (int i = 1 ; i <= ans ; i++)
{
node tt;
while (1)
{
tt = q.top();
if (tt.t != dfn[tt.pos]) q.pop();
else break;
}
auto [val,pos,t] = tt;
opt.push_back(pos);
dfn[pos]++;
if (pos == 1 || pos == n) a[pos]--;
else a[pos] -= 2;
q.push({a[pos],pos,dfn[pos]});
if (pos-1 != 0)
{
a[pos-1]++;
dfn[pos-1]++;
q.push({a[pos-1],pos-1,dfn[pos-1]});
}
if (pos+1 != n+1)
{
a[pos+1]++;
dfn[pos+1]++;
q.push({a[pos+1],pos+1,dfn[pos+1]});
}
}
// for (int i = 1 ; i <= n ; i++) cout << a[i] << " ";
// cout << endl;
for (int i = opt.size()-1 ; i >= 0 ; i--) cout << opt[i] << " ";
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(0);
int T = 1;
// cin >> T;
while (T--)solve();
return 0;
}
我们的一道校赛题
题意:长度为 \(n\) 的数组 \(a\),一次操作可以选择一个下标 \(i\),令 \(a_i-(n-1),a_j+1(j \neq i)\)。问是否存在操作方案,使数组排序后形成形如 \(x,x+1,x+2,...,x+n-1\) 的序列,其中 \(x\) 为任意非负整数。
初见觉得这个操作没什么道理,但是它涉及了区间操作,尝试转化差分。
手玩一下操作,观察原数组和差分数组的变化:
假设在长度为4的序列对下标3进行操作
下标: 1 2 3 4
原数组:+1 +1 -(n-1) +1
差分: +1 0 -n +n
也就是说,一次操作就是令 \(diff_1+1,diff_i-n,diff_{i+1}+n\)。差分首项并不包含元素的相对关系,只决定原数组首项的值,可以不用考虑。
那么我们要怎么判断原数组可以变成题意要求的形式?值得注意的是,操作在模 \(n\) 意义下等价于什么都没做。令所有 \(a_i = a_i\ mod\ n\)。要使数组成为等差为1的序列,一定是将 \(a_i\) 从小往大填,每一项恰好差1。这实际上就是在说:\(0,n-1\) 中每个数在模 \(n\) 意义下都出现过。
cpp
#include <iostream>
#include <algorithm>
#include <vector>
#include <set>
using namespace std;
using ll = long long;
const int MOD = 1e9 + 7;
const int N = 1e5 + 10;
void solve()
{
int n;
cin >> n;
vector<int> a(n+10),b(n+10);
set<int> st;
for (int i = 1 ; i <= n ; i++)
{
cin >> a[i];
st.insert(a[i]%n);
}
sort(a.begin()+1,a.begin()+n+1);
if (st.size() == n) cout << "YES" << endl;
else cout << "NO" << endl;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}
总结
在这里,我们讨论差分或者前缀和,是为了简化题意或找到题目的不变量。我们可以将区间修改转化为在差分数组上的单点修改,也可以将单点修改转化为前缀和数组上的区间修改。
实际上,由于差分以及前缀和天然包含了数组元素的相对关系,因此涉及相对量的题目都可以尝试做转化。