算法竞赛:如何用差分与前缀和刻画数组的局部操作

目录

前言

算法竞赛中经常出现一类数组题:对某个元素或一段区间进行一些看起来并不复杂的操作,但是观察后会发现这些操作难以找到规律。例如令 \(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种操作:

  1. \[diff_l+1,diff_{r+1}-1 \]

  2. \[diff_l+1 \]

  3. \[diff_l-1,diff_{r+1}+1 \]

  4. \[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_负|\)。答案加上本身即可。

#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;
}

CF1442A Extreme Subtraction

题意:给定长度为 \(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\) 的大小关系即可。

#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;
}

CF2252D Array Replacement

题意:给定数组 \(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}\) 奇偶性相同。所以我们只能交换差分数组中奇偶性相同的相邻两项。为了达到字典序最小,一定是越小的数出现在越前最优,因为还原成原数组时,是对差分数组做一次前缀和。而小的数出现在前,就可以保证累加时后面的数最小。

所以只需要对奇偶性相同的差分数组块排序,最终还原就是答案。

#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;
}

CF1110E Magic Stones

题意:给定数组 \(c,t\),支持将 \(a_i\) 修改为 \(a_{i-1}+a_{i+1}-a_i\)。问能否将 \(c\) 修改成 \(t\)

上题的双倍经验题。两数组完全相同的条件是:差分数组每项都相等。简单判定差分能否完全相等即可。特别注意:\(diff_1\) 永远无法被交换,因此需要单独判断。

#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;
}

CF1775E The Human Equation

题意:给定数组 \(a\),单次操作可以选择 \(a\) 中的任意一个子序列,对其进行一下二选一的操作:

  1. 子序列中奇数下标的数+1,偶数下标的数-1
  2. 子序列中奇数下标的数-1,偶数下标的数+1

问将原数组所有数变为0最少需要几次操作。

非常非常好的题。

首先分析这个操作,会发现这是个很复杂的东西。此时就要想到做转化了。如果把问题转化为差分操作,会发现变得更加复杂。那么遇到在原数组上做单点修改的问题,可以试试转化前缀和。这就是这道题与前几题不太一样的地方。

我们都知道,给 \(a_i+1\),等效于在前缀和 \(prefix[i,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。此时的最少操作次数是显然的:最大正数-最小负数。

#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;
}

2026陕西省赛B

题意:有 \(n\) 堆石子,初始每堆里有1颗石子,现在希望进行一些操作,使得第 \(i\) 堆中有 \(a_i\) 颗石子。一次操作是从某堆石子两边的堆各拿一个石子,放到中间的那堆里(如果在两侧,那么只移动一个石子)。求出最小操作数,并在题目要求时构造方案。

vp遇到的题目,我们队与出题人做法略有不同。这题不是我写的,就简单讲讲与本篇有关的转化。

做前缀和,原操作就是在前缀和上做:

  1. \(prefix_1+1\)
  2. $prefix_{i-1}-1,prefix_i+1 \ \ \ 2 \leq i < n $
  3. \(prefix_{n-1}-1\)

队友貌似在第一次前缀和基础上又做了一次前缀和,不是很明白他的思路。

#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\) 意义下都出现过。

#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;
}

总结

在这里,我们讨论差分或者前缀和,是为了简化题意或找到题目的不变量。我们可以将区间修改转化为在差分数组上的单点修改,也可以将单点修改转化为前缀和数组上的区间修改。

实际上,由于差分以及前缀和天然包含了数组元素的相对关系,因此涉及相对量的题目都可以尝试做转化。

posted @ 2026-08-12 20:37  GroundhogKing  阅读(26)  评论(0)    收藏  举报