前缀和与差分思想

前缀和

例题:P8218 [深进1.例1] 求区间和

给定 \(n\) 个正整数组成的数列 \(a_1,a_2,\cdots,a_n\)\(m\) 个区间 \([l_i,r_i]\),分别求这 \(m\) 个区间的区间和。
数据范围:\(n,m \le 10^5, \ a_i \le 10^4\)

分析:最直接的思路是对于每次询问,从 \(l_i\) 枚举到 \(r_i\),并将每个位置上的数加起来。这样做时间复杂度为 \(O(nm)\)

如果设 \(s_i\) 表示第 \(1\) 个数到第 \(i\) 个数的和,即 \(s_i = \sum_{i=1}^{n} a_i = a_1 + a_2 + \cdots + a_n\),记 \(s_0 = 0\)。在这里,\(\sum\) 是求和符号,计算所有 \(a_i\) 的累加和,其中 \(i\) 是从 \(1\)\(n\) 的所有整数。当 \(i \ge 1\) 时,\(s_i - s_{i-1} = a_i\),所以 \(s_i = s_{i-1} + a_i\)。这里的 \(s\) 数组记录的数值,称为前缀和,也就是对每个前缀区间求和。

image

观察可知,\(a_3+a_4 =(a_1+a_2+a_3+a_4)-(a_1+a_2)=a_3+a_4=s_4-s_2\),即 \(\sum_{i=l}^r a_i = \sum_{i=1}^r a_i - \sum_{i=1}^{l-1} a_i = s_r - s_{l-1}\)。求出 \(s\) 数组后,每次询问就可以 \(O(1)\) 求出任意一个区间的区间和。

此时预处理 \(s\) 数组的时间复杂度是 \(O(n)\),单次查询时间复杂度为 \(O(1)\),总时间复杂度被优化到了 \(O(n+m)\)

#include <cstdio>
const int N = 1e5 + 5;
int a[N], s[N];
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]); s[i] = s[i - 1] + a[i];
    }
    int m; scanf("%d", &m);
    for (int i = 1; i <= m; i++) {
        int l, r; scanf("%d%d", &l, &r);
        printf("%d\n", s[r] - s[l - 1]);
    }
    return 0;
}

习题:B3612 【深进1.例1】求区间和

解题思路

P8218 [深进1.例1] 求区间和

参考代码
#include <cstdio>
const int N = 1e5 + 5;
int a[N];
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
        a[i] += a[i - 1];
    }
    int m; scanf("%d", &m);
    for (int i = 1; i <= m; i++) {
        int l, r; scanf("%d%d", &l, &r);
        printf("%d\n", a[r] - a[l - 1]);
    }
    return 0;
}

例题:P1115 最大子段和

“最大子段和”问题是序列问题中的经典问题,其做法非常多。这里我们最终基于前缀和思想来解决它。

首先对于这个问题,我们可以想到直接模拟题意,因为要找区间和最大的子段,所以先枚举区间的两个端点,再利用一层循环去求和,找到每个区间和中的最大值。这么做合起来有三层循环,时间复杂度为 \(O(n^3)\)

参考代码 $O(n^3)$
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 2e5 + 5;
const int INF = 1e5;
int a[N];
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
    }
    int ans = -INF; // 理论上最大子段和的最小值是-1e4,因此初始化一个比-1e4小的极小值
    for (int i = 1; i <= n; i++) { // 枚举左端点
        for (int j = i; j <= n; j++) { // 枚举右端点
            int sum = 0; // 准备计算[i,j]的区间和
            for (int k = i; k <= j; k++) sum += a[k];
            ans = max(ans, sum); // 更新最大子段和
        }
    }
    printf("%d\n", ans);
    return 0;
}

实际上可以发现,当区间左端点固定时,依次向右枚举右端点时,区间和相当于不断新增一个数,因此求区间和不用单独再开一个循环计算,而是可以和右端点的移动过程融合。这么做省去了一次循环,时间复杂度降到 \(O(n^2)\)

参考代码 $O(n^2)$
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 2e5 + 5;
const int INF = 1e5;
int a[N];
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
    }
    int ans = -INF; // 理论上最大子段和的最小值是-1e4,因此初始化一个比-1e4小的极小值
    for (int i = 1; i <= n; i++) { // 枚举左端点
        int sum = 0;
        for (int j = i; j <= n; j++) { // 枚举右端点
            sum += a[j];
            ans = max(ans, sum);
        }
    }
    printf("%d\n", ans);
    return 0;
}

但是 \(O(n^2)\) 的算法依然不能通过这道题,这里我们利用前缀和来优化。原问题可以看作是找最大的一个区间 \([l,r]\),其前缀和之差 \(s_r - s_{l-1}\) 最大,这是一个最大化问题,其中 \(l\)\(r\) 都是不确定的。像这种问题,有一种比较经典的处理思路,通过枚举其中一项将其固定下来,然后利用某种性质快速求出另一项。这里假如我们固定区间的右端点 \(r\),要使 \(s_r - s_{l-1}\) 最大,此时 \(s_r\) 是个定值,则需要让 \(s_{l-1}\) 最小,而 \(l\) 的取值实际上是 \(1 \sim r\),即我们要找到 \(s_0, s_1, s_2, \dots, s_{r-1}\) 中的最小值。

这个形式和前缀和很像,实际上前缀和代表了一类预处理思想。前缀和的思想,除了可以用来求一个区间内的和以外,也可以用来预处理最大最小值。我们除了预处理前缀和以外,还去预处理每个前缀和的“前缀最小值”,则对于上面那个式子,只需要利用“前缀和的前缀最小值”就可以快速计算出右端点固定时的最大子段和。而不管是前缀和还是前缀和的前缀最小值都可以在输入原数组中顺便处理出来,这样我们后面的计算只需要枚举每种右端点的情况即可,总的时间复杂度为 \(O(n)\)

#include <cstdio>
#include <algorithm>
using std::max;
using std::min;
const int N = 2e5 + 5;
const int INF = 1e5;
int a[N], s[N], pre[N];
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
        s[i] = s[i - 1] + a[i]; // 前缀和
        pre[i] = min(pre[i - 1], s[i]); // 前缀和的前缀最小值,注意因为s[0]=0,所以pre[0]也是0
    }
    int ans = -INF; // 理论上最大子段和的最小值是-1e4,因此初始化一个比-1e4小的极小值
    for (int i = 1; i <= n; i++) { // 枚举的i固定了右端点
        ans = max(ans, s[i] - pre[i - 1]); // 右端点固定的情况下希望减去s[0]~s[i-1]里的最小值
    }
    printf("%d\n", ans);
    return 0;
}

习题:P3131 [USACO16JAN] Subsequences Summing to Sevens S

解题思路

首先将区间和用前缀和之差的形式表示,则区间和能被 \(7\) 整除意味着某两个位置上的前缀和对 \(7\) 取余后的余数相等,而前缀和对 \(7\) 取余只有 \(0 \sim 6\) 这七种情况,要使得这样的区间最长我们可以记录每种余数第一次出现的位置和最后一次出现的位置。最后把余数为 \(0 \sim 6\) 各自能形成的最长区间比较一下即可。注意前缀和 \(s_0 = 0\),也就是说前缀和除以 \(7\) 余数为 \(0\) 的最早出现位置就是 \(0\)

#include <cstdio>
#include <algorithm>
using std::max;
const int N = 5e4 + 5;
int first[7], last[7]; // first和last数组记录每种余数第一次和最后一次出现的位置
int main()
{
    int n; scanf("%d", &n);
    int s = 0;
    first[0] = last[0] = 0; // 注意0位置也有个前缀和0
    for (int i = 1; i < 7; i++) first[i] = last[i] = -1; // 其余几种余数暂时标记为没出现过
    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &x);
        s = (s + x) % 7; // 不需要记前缀和的值,记它除以7的余数即可
        if (first[s] == -1) first[s] = i;
        last[s] = i;
    }
    int ans = 0;
    for (int i = 0; i < 7; i++) ans = max(ans, last[i] - first[i]);
    printf("%d\n", ans);
    return 0;
}

习题:CF2104B Move to the End

解题思路

假设选择移动元素 \(a_i\),移动后,新数组的最后一个元素一定是 \(a_i\)。新数组的倒数第 \(2\) 到倒数第 \(k\) 个元素是哪些呢?这取决于 \(a_i\) 的原始位置。

把原数组分为两部分:

  • 后缀 \(B_k\):原数组的最后 \(k\) 个元素,即 \(a_{n-k+1}, \dots, a_n\)
  • 前缀 \(P_k\)\(B_k\) 前面的部分,即 \(a_1, \dots, a_{n-k}\)

考虑移动 \(a_i\) 的两种情况:

  • 情况一:从前缀 \(P_k\) 中移动元素(\(i \le n-k\)
    • \(a_i\) 移动到末尾,原后缀 \(B_k\) 中的元素会整体向左移动一个位置来填补 \(a_i\) 的空缺,新数组的最后 \(k\) 个元素会变成 \(a_{n-k+2}, \dots, a_n\)\(a_i\),此时的和为 \((a_{n-k+2} + \cdots + a_n) + a_i\)。为了让这个和最大,应该选择 \(P_k\) 中最大的那个 \(a_i\) 来移动。
  • 情况二:从后缀 \(B_k\) 中移动元素(\(i \ge n-k+1\)
    • \(a_i\) 移动到末尾,这只是改变了后缀 \(B_k\) 内部元素的顺序。新数组的最后 \(k\) 个元素仍然是 \(B_k\) 中的那些元素,只是排列不同。因此,它们的和不变,仍然是 \((a_{n-k+1} + \cdots + a_n)\)

对于一个固定的 \(k\),能得到的最大和就是上述两种情况的较大者。

情况一的和相当于 \((a_{n-k+1} + \cdots + a_n) - a_{n-k+1} + \max(a_1, \dots, a_{n-k})\),所以,对于给定的 \(k\),最终的最大和为 \((a_{n-k+1} + \cdots + a_n) + \max(0, \max(a_1, \dots, a_{n-k}) - a_{n-k+1})\)

为了加速,可以先用 \(O(n)\) 的时间计算出所有前缀的最大值。

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
using ll = long long;
const int N = 2e5 + 5;
int a[N], pre[N];
void solve() {
    int n;
    scanf("%d", &n);
    // pre[0] 默认为0,或者一个足够小的值
    pre[0] = 0;
    // 步骤1:一次遍历,读入数组 a,同时计算前缀最大值
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
        pre[i] = max(pre[i - 1], a[i]);
    }
    ll sum = 0; // 用于累加后缀和
    // 步骤2:反向遍历数组,计算并输出每个 k 对应的答案
    // i 从 n 到 1,对应的 k = n-i+1 从 1 到 n
    for (int i = n; i >= 1; i--) {
        // sum 累加 a[i],得到后缀 a[i...n] 的和
        sum += a[i];
        // 此时,正在计算 k = n-i+1 的情况
        // 公式为: ans = sum(a[i...n]) + max(0, max(a[1...i-1]) - a[i])
        printf("%lld ", sum + max(0, pre[i - 1] - a[i]));
    }
    printf("\n");
}
int main()
{
    int t;
    scanf("%d", &t);
    for (int i = 1; i <= t; i++) {
        solve();
    }
    return 0;
}

习题:P6067 [USACO05JAN] Moo Volume S

解题思路

原本要求的问题中包含像 \(|x_i - x_j|\) 这样带绝对值的式子,也就是说在无法确定 \(x_i\)\(x_j\) 的大小时,这个式子有两种情况。但是因为要求的是所有奶牛聊天音量的总和,也就是说每一头奶牛都要和另一只奶牛去计算 \(|x_i - x_j|\)。那我们不妨将 \(x\) 从小到大排序,这样一来绝对值怎么取就固定了。此时假设我们考虑排序后的奶牛中的第 \(i\) 头,它和左边的奶牛聊天的音量和可以被表示为 \((x_i - x_1) + (x_i - x_2) + \cdots + (x_i - x_{i-1}) = (i-1) x_i - (x_1 + x_2 + \cdots + x_{i-1})\),它和右边的奶牛聊天的音量和可以被表示为 \((x_{i+1}-x_i)+(x_{i+2}-x_i)+ \cdots + (x_n - x_i) = (x_{i+1} + x_{i+2} + \cdots + x_n) - (n-i) x_i\)。记 \(s\)\(x\) 的前缀和,则把两式相加可得 \((2i-1-n)x_i + s_n - s_i - s_{i-1}\)。因此,对 \(x\) 排序后按这个式子求和即可。

#include <cstdio>
#include <algorithm>
using ll = long long;
using std::sort;
const int N = 1e5 + 5;
int x[N];
ll s[N];
int main()
{
    int n; scanf("%d", &n);
    ll sum = 0;
    for (int i = 1; i <= n; i++) {
        scanf("%d", &x[i]); sum += x[i];
    }
    sort(x + 1, x + n + 1);
    ll ans = 0;
    for (int i = 1; i <= n; i++) {
        s[i] = s[i - 1] + x[i];
        ans += 1ll * x[i] * (2 * i - 1 - n) + sum - s[i] - s[i - 1];
    }
    printf("%lld\n", ans);
    return 0;
}

习题:P1114 “非常男女”计划

解题思路

题目的核心要求是找到一个最长的连续子区间,其中男生(1)和女生(0)的人数相等。直接统计和比较人数会很复杂,可以做一个巧妙的变换:将男生(1)计为 +1,将女生(0)计为 -1。经过这样的转换后,如果一个子区间里男女人数相等,那么这个区间所有元素的和就必然是 0。例如,子区间 \([1,0,1,0]\) 变为 \([+1,-1,+1,-1]\),其和为 0。因此,原问题就成功转换成了:寻找一个和为 0 的最长连续子区间

“子区间的和”是前缀和思想的经典应用场景。定义 \(sum_i\) 为从第 1 个人到第 i 个人(经过 +1/-1 转换后)的累加和,即前缀和。那么,一个从 j+1 到 i 的子区间的和就可以用前缀和快速计算:\(sum_i - sum_j\)。要找的条件是子区间和为 0,即 \(sum_i - sum_j = 0\),这等价于 \(sum_i = sum_j\)

现在问题再次被简化为:寻找两个索引 \(i\)\(j\)(其中 \(i \gt j\)),使得它们的前缀和 \(sum_i\)\(sum_j\) 相等,并求出 \(i-j\) 的最大值。为了让长度 \(i-j\) 最长,对于每一个 \(i\),都应该找到一个尽可能靠前(即索引值最小)的 \(j\)。因此,只需要记录每个前缀和的值第一次出现的位置

使用一个数组 \(pos\) 来记录每个前缀和 \(sum\) 值第一次出现的索引,即 \(pos_{sum} = index\)。因为前缀和 \(sum\) 的值可能是负数(当女生比男生多时),而数组索引不能为负,所以引入一个偏移量 \(n\),一个前缀和 \(s\) 对应存储在 \(pos_{s+n}\)。在遍历开始前(可以看作第 \(0\) 个位置),前缀和是 \(0\),所以预先设置 \(pos_{0+n}=0\) 作为基准。从 \(i=1\)\(n\) 遍历每个人,并实时计算当前的前缀和 \(sum\)。在第 \(i\) 步,检查 \(sum\) 这个值是否在 \(pos\) 数组中出现过。如果 \(pos_{sum+n}\) 已经被记录过,说明在之前的某个位置 \(j = pos_{sum+n}\) 处,也出现过同样的前缀和,就找到了一个合格的子区间,其长度为 \(i-j\),用这个长度去更新最大答案。如果 \(pos_{sum+n}\) 还是初始值,说明这是 \(sum\) 这个值第一次出现,记录下它的位置:\(pos_{sum+n}=i\)

通过这种方式,仅用一次遍历就解决了问题,时间复杂度为 \(O(n)\)

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
int main()
{
    int n;
    scanf("%d", &n);
    // 存储每个前缀和第一次出现的位置
    // 前缀和的范围是 [-n, n],所以数组大小需要 2*n+1
    // pos[sum + n] = index
    vector<int> pos(n * 2 + 1);
    // 1. 初始化 pos 数组为一个特殊值(例如-1),表示所有前缀和都“未出现过”
    for (int i = 0; i <= n * 2; i++) pos[i] = -1;
    // 2. 设置基准情况
    // 在遍历开始时(可以看作第0个位置),前缀和为0
    // 记录前缀和0第一次出现的位置是0,偏移后索引为 n
    pos[n] = 0;
    int ans = 0; // 存储最大长度
    int sum = 0; // 当前的前缀和
    // 3. 遍历每个人,计算前缀和并寻找答案
    for (int i = 1; i <= n; i++) {
        int x; scanf("%d", &x);
        // 将输入 1/0 转换为 +1/-1,并累加到前缀和 sum
        if (x == 1) {
            sum++;
        } else {
            sum--;
        }
        // 检查当前的前缀和 sum 是否在之前出现过
        if (pos[sum + n] != -1) { // pos[sum + n] != -1 表示之前在某个位置 j 也出现过这个 sum
            // 找到了一个 j,使得 sum[i] == sum[j]
            // 子区间 (j, i] 的和为0,其长度为 i - j
            ans = max(ans, i - pos[sum + n]); // 更新最大答案
        } else {
            pos[sum + n] = i; // 如果 sum 这个值是第一次出现,记录下它出现的位置 i
        }
    }
    printf("%d\n", ans);
    return 0;
}

习题:ABC334C Socks 2

解题思路

依然成对的袜子可以直接配对,奇妙度为 0,不用考虑。

证明

假设存在成对袜子不配对的方案,比如 \(x\)\(c_1\) 配对,\(y\)\(c_2\) 配对,则奇妙度为 \(|x-c| + |c-y|\)。而如果让成对的袜子配对,奇妙度为 \(|x-y|\)。根据数学中的三角不等式,对于任意实数 \(x,y,c\),有:\(|x-y| \le |x-c| + |c-y|\),所以后者是更优的方案。

由于输入的 \(A_i\) 已经排好序,所有不成对的袜子的颜色也是有序的。为了使颜色差值之和最小,一个基本且正确的贪心策略是总是将颜色相邻的袜子进行配对

根据不成对袜子数量 \(K\) 的奇偶性,问题分为两种情况:

情况 1:\(K\) 是偶数

有偶数只单独的袜子,可以完美地配成 \(K/2\) 对。根据贪心策略,应该将 \((A_1,A_2), (A_3, A_4), \dots, (A_{K-1}, A_K)\) 两两配对,总的奇妙度就是 \((A_2-A_1)+(A_4-A_3)+ \cdots + (A_K-A_{K-1})\)

情况 2:\(K\) 是奇数

有奇数只单独的袜子,配对后必然会剩下一只。问题变成了:应该丢掉哪一只袜子,使得剩下 \(K-1\) 只(偶数只)袜子配对后的总奇妙度最小?可以遍历每一只袜子 \(A_i\),计算如果丢掉它,剩下袜子的配对成本是多少,然后取这些成本中的最小值。

如何计算丢掉 \(A_i\) 后的成本?

如果 \(i\) 是奇数(丢掉 \(A_1, A_3, \dots\)),剩下 \(A_1, \dots, A_{i-1}\)(偶数只)和 \(A_{i+1}, \dots, A_K\)(偶数只)。这两组袜子可以独立地进行内部贪心配对,奇妙度等于两边各自的奇妙度之和。

如果 \(i\) 是偶数(丢掉 \(A_2, A_4, \dots\)),剩下 \(A_1, \dots, A_{i-1}\)(奇数只)和 \(A_{i+1}, \dots, A_K\)(奇数只)。\(A_1, \dots, A_{i-1}\) 内部配对后会剩下 \(A_{i-1}\)\(A_{i+1}, \dots, A_K\) 内部配对后会剩下 \(A_{i+1}\)。最后,必须将这两只剩下的 \(A_{i-1}\)\(A_{i+1}\) 配对,奇妙度等于 \(A_1, \dots, A_{i-2}\) 的配对奇妙度加上 \(A_{i+1} - A_{i-1}\) 加上 \(A_{i+2}, \dots A_K\) 的配对奇妙度。

为了快速计算,可以使用前缀和/后缀和来预处理:

  • \(l_i\):从左到右(前缀)对 \(A_1, \dots, A_i\) 进行配对的成本。
  • \(r_i\):从右到左(后缀)对 \(A_i, \dots, A_K\) 进行配对的成本。
参考代码
#include <cstdio>

const int N = 2e5 + 5;
int a[N]; // 存储 K 只不成对袜子的颜色
int l[N], r[N]; // l[i]: 前缀配对成本, r[i]: 后缀配对成本

/**
 * @brief 主函数,解决袜子配对问题
 *
 * 核心思路:贪心 + 预处理
 * 1. 只有不成对的 K 只袜子需要考虑配对,颜色为 A_1, ..., A_K。
 * 2. 最优策略总是将颜色相邻的袜子配对。
 * 3. 根据 K 的奇偶性分情况讨论:
 *    - K 为偶数:直接将 (A_1,A_2), (A_3,A_4)... 配对,成本是 (A_2-A_1)+(A_4-A_3)+...
 *    - K 为奇数:必须丢掉一只袜子。遍历丢掉每一只袜子 A_i 的情况,计算剩下 K-1 只的配对成本,取最小值。
 *      为了快速计算,用 l[i] 存前缀配对成本,r[i] 存后缀配对成本。
 */
int main()
{
    int n, k;
    scanf("%d%d", &n, &k);
    for (int i = 1; i <= k; i++) {
        scanf("%d", &a[i]);
    }

    if (k % 2 == 1) { // --- K 是奇数 ---
        // 预处理前缀配对成本
        // l[i] 表示将 a[1]...a[i] 配对的成本
        for (int i = 2; i <= k; i += 2) {
            l[i] = l[i - 2] + (a[i] - a[i - 1]);
        }

        // 预处理后缀配对成本
        // r[i] 表示将 a[i]...a[k] 配对的成本
        for (int i = k - 1; i >= 1; i -= 2) {
            r[i] = r[i + 2] + (a[i + 1] - a[i]);
        }

        int ans = -1;

        // 遍历所有要丢掉的袜子 a[i]
        for (int i = 1; i <= k; i++) {
            int current_cost;
            if (i % 2 == 1) { // 丢掉奇数位置的袜子 a[i]
                // 剩下 a[1...i-1] 和 a[i+1...k] 两组,均为偶数个
                // 它们的配对成本可以直接从预处理的 l 和 r 数组得到
                current_cost = l[i - 1] + r[i + 1];
            } else { // 丢掉偶数位置的袜子 a[i]
                // 剩下 a[1...i-1] 和 a[i+1...k] 两组,均为奇数个
                // a[1...i-2] 配对成本为 l[i-2]
                // a[i+2...k] 配对成本为 r[i+2]
                // 中间剩下的 a[i-1] 和 a[i+1] 必须配对
                current_cost = l[i - 2] + (a[i + 1] - a[i - 1]) + r[i + 2];
            }
            
            if (ans == -1 || current_cost < ans) {
                ans = current_cost;
            }
        }
        printf("%d\n", ans);

    } else { // --- K 是偶数 ---
        int sum = 0;
        // 直接将相邻的袜子配对
        for (int i = 1; i < k; i += 2) {
            sum += a[i + 1] - a[i];
        }
        printf("%d\n", sum);
    }
    return 0;
}

习题:P2671 [NOIP 2015 普及组] 求和

解题思路

\(x \lt y \lt z\)\(y-x=z-y\) 这个条件等价于 \(x+z=2y\),也就是说 \(x\)\(z\)奇偶性必须相同

暴力解法是枚举所有 \(x\)\(z\)(其中令 \(x \lt z\)),判断其是否满足条件 \(color_x = color_z\),然后计算 \(y = (x+z)/2\),再判断 \(y\) 是否为整数且 \(x \lt y \lt z\)。这个复杂度为 \(O(n^2)\),无法通过所有数据。

考虑每个格子的贡献,不去枚举三元组,而是遍历每个格子 \(i\),计算它能作为三元组中的 \(z\) 时,对总分数的贡献。

假设正在处理第 \(i\) 个格子,想找到所有的 \(j \lt i\),使得 \((j, k, i)\) 构成一个满足条件的三元组。其中条件 1 是 \(j\)\(i\) 的奇偶性必须相同,条件 2 是 \(color_j = color_i\)

如果 \(j\) 满足这两个条件,那么它就和 \(i\) 构成了一个合法的“端点对”,这个端点对的分数是 \((j+i) \times (number_j + number_i)\),需要对所有满足条件的 \(j\),把这个分数加起来。

总分数等于 \(\sum ((j+i) \times (number_j + number_i))\)(对于所有满足条件的 \(j \lt i\) 对)。

展开这个求和式:\(\sum (j \times number_j + j \times number_i + i \times number_j + i \times number_i)\)。将它拆成四部分,并把与 \(i\) 有关的项提出来,得到 \(\sum (j \times number_j) + number_i \times \sum (j) + i \times \sum (number_j) + i \times number_i \times \sum(1)\),这里的 \(\sum\) 都是对所有满足条件的 \(j \lt i\) 进行求和。

也就是说,在遍历到第 \(i\) 个格子时,如果能快速知道在它之前、与它同色、与它同奇偶性的所有格子 \(j\) 的以下四个值的和,就能在 \(O(1)\) 的时间内计算出 \(i\) 的贡献:

  1. \(\sum (j \times number_j)\):之前所有满足条件的 \(j\)\(j \times number_j\) 的和。
  2. \(\sum (j)\):之前所有满足条件的 \(j\) 的下标之和。
  3. \(\sum (number_j)\):之前所有满足条件的 \(j\) 的数字之和。
  4. \(\sum (1)\):之前所有满足条件的 \(j\) 的个数。

所以需要根据颜色奇偶性来分类维护上述四个和,二维数组是自然的选择。用 \(sum_{c,p}\) 存储颜色为 \(c\),奇偶性为 \(p\)(0 为偶,1 为奇)的所有格子的 \(i \times number_i\) 之和;用 \(sid_{c,p}\) 存储颜色为 \(c\),奇偶性为 \(p\) 的所有格子的下标 \(i\) 之和;用 \(snum_{c,p}\) 存储颜色为 \(c\),奇偶性为 \(p\) 的所有格子的数字 \(number_i\) 之和;用 \(cnt_{c,p}\) 存储颜色为 \(c\),奇偶性为 \(p\) 的所有格子的数量。

\(i = 1\)\(n\) 遍历每个格子,获取当前格子的颜色 \(c\) 和数字 \(number_i\),确定当前格子的奇偶性 \(p = i \bmod 2\)。根据上面的展开公式,利用已经存储的 \(sum_{c,p}, sid_{c,p}, snum_{c,p}, cnt_{c,p}\) 来计算当前格子 \(i\) 与所有它之前的、同色同奇偶性的格子 \(j\) 构成的分数总和。计算完贡献后,将当前格子 \(i\) 的信息更新到对应的 \(sum, sid, snum, cnt\) 数组中,以便后续的格子使用。

参考代码
#include <cstdio>
using namespace std;
using ll = long long;
const int N = 100005;
const int MOD = 10007;
int number[N]; // 存储每个格子的数字
// 四个核心数组,用于按“颜色”和“奇偶性”分类存储信息
// 第二个维度 [2] 中,0代表偶数下标,1代表奇数下标
int sid[N][2]; // sid[c][p]:颜色为c,奇偶性为p的格子的“下标i之和”
int snum[N][2]; // snum[c][p]:颜色为c,奇偶性为p的格子的“数字number[i]之和”
int sum[N][2]; // sum[c][p]:颜色为c,奇偶性为p的格子的“i * number[i]之和”
int cnt[N][2]; // cnt[c][p]:颜色为c,奇偶性为p的格子的“数量”
int main()
{
    int n, m;
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &number[i]);
        number[i] %= MOD; // 输入数据可能比模数大,先取模
    }
    int ans = 0; // 最终答案
    for (int i = 1; i <= n; i++) { // 主循环:从左到右遍历每个格子
        int c; scanf("%d", &c); // 当前格子的颜色 
        int p = i % 2; // 当前格子下标的奇偶性
        // 计算当前格子 i 的贡献
        // 它与所有 j < i 且同色同奇偶性的格子构成三元组
        ans = (ans + sum[c][p]) % MOD; // 对应公式 Σ(j * number_j)
        ans = (ans + 1ll * snum[c][p] * i % MOD) % MOD; // 对应公式 i * Σ(number_j)
        // 对应公式 number_i * Σ(j) + i * number_i * Σ(1)
        // 合并为 (Σj + i * cnt) * number_i
        ans = (ans + (1ll * cnt[c][p] * i % MOD + sid[c][p]) % MOD * number[i] % MOD) % MOD;
        // 更新状态,为后续格子做准备
        // 将当前格子 i 的信息加入到对应的累加器中
        sid[c][p] = (sid[c][p] + i) % MOD;
        snum[c][p] = (snum[c][p] + number[i]) % MOD;
        sum[c][p] = (sum[c][p] + 1ll * number[i] * i % MOD) % MOD;
        cnt[c][p]++;
    }
    printf("%d\n", ans);
    return 0;
}

习题:P8865 [NOIP2022] 种花

解题思路

暴力解法即直接枚举所有可能的 \(x_1, x_2, y_0, y_1, y_2\)(以及 F 形的 \(x_3\)),然后检查对应的线段是否全为 0,这个复杂度非常高,显然无法通过。

可以发现,C 形和 F 形都有一个共同的“拐点”或“交点”,即 \((x_2, y_0)\) 这个点。可以尝试枚举这个关键点,然后计算以它为“拐角”能构成多少个 C 形和 F 形。

如果枚举 \((i,j)\) 作为 \((x_2, y_0)\),即作为 C 形的左下角拐点时,需要知道:

  1. \((i,j)\) 向右能延伸多长(决定了 \(y_2\) 的选择范围)。
  2. \((i,j)\) 向上能延伸多长(决定了 \(x_1\) 的选择范围)。
  3. 对于每一个可能的 \(x_1\),从 \((x_1, j)\) 向右能延伸多长(决定了 \(y_1\) 的选择范围)。

为了快速获取上述信息,可以进行预处理:

  • \(d_{i,j}\):从 \((i,j)\) 开始,向下连续 0 的数量。
  • \(r_{i,j}\):从 \((i,j)\) 开始,向右连续 0 的数量。
  • \(u_{i,j}\):从 \((i,j)\) 开始,向上连续 0 的数量。

这些结果都可以通过 \(O(nm)\) 的预处理提前计算好。

C 形和 F 形的计算

若当前固定 \((i,j)\) 作为 \((x_2, y_0)\)

  • 下横线的选择数 \(mid = r_{i,j} - 1\)
  • 下横线下方的竖线的选择数 \(down = d_{i,j} - 1\)(用于 F 形)
  • 需要找到所有可能的上横线 \(x_1\)\(x_1\) 必须在 \([i - u_{i,j} + 1, i-2]\) 这个范围内,也就是至少要在两行之前,同时往上延伸的情况下空地不能中断。
  • 对于这个范围内的每一个 \(x_1\),上横线的选择数是 \(r_{x_1, j} - 1\)
  • 需要对于所有合法的 \(x_1\) 求和 \(\sum (r_{x_1,j} - 1)\)
  • 为了快速求和,可以预处理一个列方向上的前缀和 \(sumr_{i,j} = \sum \limits_{k=1}^{i} (r_{k,j} - 1)\)
  • 那么 \(\sum \limits_{x_1 = i - u_{i,j} + 1}^{i-2} (r_{x_1, j} - 1)\) 就可以通过 \(sumr_{i-2,j} - sumr_{i-u_{i,j},j}\)\(O(1)\) 时间内得到,这个和就是上横线的总选择数 \(up\)
  • 最终这个点作为拐点的 C 形数量就是 \(up \times mid\),F 形数量就是 \(up \times mid \times down\)

以上这个计算可以在一个 \(O(nm)\) 的循环内完成。

参考代码
#include <cstdio>
const int N = 1005;
const int MOD = 998244353;
// a:原始地图,1为土坑,0为空地
// r[i][j]:从(i,j)向右连续0的数量
// d[i][j]:从(i,j)向下连续0的数量
// u[i][j]:从(i,j)向上连续0的数量
// sumr[i][j]:在第j列,从第1行到第i行,所有(r[k][j]-1)的前缀和
int a[N][N], r[N][N], d[N][N], u[N][N], sumr[N][N];
int main()
{
    int t, id;
    scanf("%d%d", &t, &id);
    while (t--) {
        int n, m, c, f;
        scanf("%d%d%d%d", &n, &m, &c, &f);
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                scanf("%1d", &a[i][j]); // %1d 用于读取单个数字字符
            }
        }
        // 预处理
        // 计算向右(r),向上(u)的连续0,并同时计算列前缀和(sumr)
        for (int i = 1; i <= n; i++) {
            r[i][m + 1] = 0; // 边界
            for (int j = m; j >= 1; j--) {
                if (a[i][j] == 1) {
                    r[i][j] = 0; u[i][j] = 0;
                } else {
                    r[i][j] = r[i][j + 1] + 1;
                    u[i][j] = u[i - 1][j] + 1;
                }
                sumr[i][j] = sumr[i - 1][j] + (r[i][j] - 1); 
            }
        }
        // 计算向下的连续0
        for (int j = 1; j <= m; j++) {
            d[n + 1][j] = 0; // 边界
            for (int i = n; i >= 1; i--) {
                if (a[i][j] == 1) {
                    d[i][j] = 0;
                } else {
                    d[i][j] = d[i + 1][j] + 1;
                }
            }
        }

        int ansc = 0, ansf = 0; // C形和F形的答案
        // 枚举每个点(i,j)作为C/F形的左下角拐点
        for (int j = 1; j <= m; j++) {
            for (int i = 1; i <= n; i++) {
                if (a[i][j] == 0 && u[i][j] >= 3) { // 必须是空地,且向上至少有3格的空间
                    // 计算上横线的总选择数
                    // x1的范围是 [i - u[i][j] + 1, i - 2]
                    // 利用前缀和快速计算 Σ(r[x1][j]-1)
                    int up = (sumr[i - 2][j] - sumr[i - u[i][j]][j]);
                    int mid = r[i][j] - 1; // 计算下横线的选择数
                    int down = d[i][j] - 1; // 计算F形下方竖线的选择数
                    // 累加C形的方案数  
                    ansc += 1ll * up * mid % MOD;
                    ansc %= MOD;
                    // 累加F形的方案数
                    ansf += 1ll * up * mid % MOD * down % MOD;
                    ansf %= MOD;
                } 
            }
        }
        // 乘以题目给定的系数 c 和 f
        ansc = 1ll * c * ansc % MOD;
        ansf = 1ll * f * ansf % MOD;
        printf("%d %d\n", ansc, ansf);
    }
    return 0;
}

习题:P7715 「EZEC-10」Shape

解题思路

最朴素的想法是枚举所有可能的 \(x_1, x_2, y_1, y_2\) 组合,然后检查它们是否满足 H 形的条件(三条线段都是白色)。这种四重循环的暴力枚举,其复杂度至少是 \(O(n^2 m^2)\),对于 \(n,m\) 高达 2000 的数据规模,无法通过全部测试数据。

为了优化,不能同时枚举所有四个变量,一个常见的优化技巧是固定问题中的某个关键部分,然后高效地计算其他部分。对于 H 形,最关键的部分是它的中心横杠,选择枚举 H 形的中心横杠所在的行 \(i\)。根据题目条件,中心行 \(i = (x_1+x_2)/2\),这意味着,H 形的上下两端 \(x_1\)\(x_2\) 是关于中心行 \(i\) 对称的,如果设 H 形的“半高”为 \(h\),那么 \(x_1 = i - h\)\(x_2 = i + h\)。现在,问题转化为:对于每一个固定的中心行 \(i\),有多少个 H 形是以它为中心的?一个以 \(i\) 为中心的 H 形,由两个列 \(y_1, y_2\) 和一个半高 \(h\) 决定,它需要满足横杠 \((i,y_1)\)\((i,y_2)\)、左竖杠 \((i-h,y_1)\)\((i+h,y_1)\)、右竖杠 \((i-h,y2)\)\((i+h,y_2)\) 是全白的。

对于一个固定的中心行 \(i\) 和一个列 \(j\),它能支持的最大“半高” \(h\) 是多少?这个半高取决于点 \((i,j)\) 向上和向下能延伸的连续白色格子的数量。可以预处理两个数组,\(up_{i,j}\) 表示从 \((i,j)\) 向上(包括自身)的连续白色格子数,\(down_{i,j}\) 表示从 \((i,j)\) 向下(包括自身)的连续白色格子数。\(up\) 数组可以从上到下递推,\(down\) 数组可以从下到上递推,这个预处理的复杂度是 \(O(nm)\)。对于点 \((i,j)\),它能支持的最大半高 \(h\) 就是 \(\min(up_{i,j}, down_{i,j}) - 1\)(减一是因为半高不包括中心点自身)。

现在,对于固定的中心行 \(i\),已经知道了每个点 \(j\) 能支持的最大半高,称之为 \(h_j\)。一个 H 形由两根竖杠 \(y_1, y_2\) 组成,它的实际半高取决于较短的那根,即 \(\min(h_{y_1}, h_{y_2})\)。所以,对于固定的中心行 \(i\),总的 H 形数量就是 \(\sum \min(h_{y_1}, h_{y_2})\),其中 \(y_1\)\(y_2\) 在同一段连续的白色横杠上。直接枚举 \(y_1\)\(y_2\)\(O(m^2)\) 的,需要优化这个求和过程。这是一个经典问题:如何计算 \(\sum \limits_{j \lt k} \min(h_j, h_k)\)解决方法是排序,如果先将这段横杠上所有点的 \(h_j\) 值进行排序,得到新的序列 \(h'_1, h'_2, \dots\)。当遍历到排序后的第 \(k\) 个值 \(h'_k\) 时,它与前面所有 \(j \lt k\) 的值 \(h'_j\) 配对,\(\min(h'_j, h'_k)\) 的值就是 \(h'_j\)(因为 \(h'_j \le h'_k\))。所以,\(h'_k\) 的贡献就是 \(\sum \limits_{j=1}^{k-1} h'_j\)。可以在遍历排序后的 \(h'\) 数组时,维护一个前缀和,从而在 \(O(m)\) 的时间内完成计算,加上排序,总复杂度是 \(O(m \log m)\)

最终整体的时间复杂度为 \(O(nm \log m)\)

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
using ll = long long;
const int N = 2005;
// a:原始网格,0是白色,1是黑色
// up[i][j]:从(i,j)向上(包括自身)的连续白色格子数
// down[i][j]:从(i,j)向下(包括自身)的连续白色格子数
// tmp:用于临时存储和排序某一行上各点的最大半高
int a[N][N], up[N][N], down[N][N], tmp[N];
int main()
{
	int n, m; scanf("%d%d", &n, &m);
	for (int i = 1; i <= n; i++) 
		for (int j = 1; j <= m; j++)
			scanf("%d", &a[i][j]);
    // 1. 预处理up和down数组
    // 从上到下计算up数组
	for (int i = 1; i <= n; i++) 
		for (int j = 1; j <= m; j++)
			up[i][j] = (a[i][j] == 0) ? up[i-1][j] + 1 : 0;
    // 从下到上计算down数组
	for (int i = n; i >= 1; i--) 
		for (int j = 1; j <= m; j++)
			down[i][j] = (a[i][j] == 0) ? down[i+1][j] + 1 : 0;
	ll ans = 0; // 答案可能很大,使用long long
    // 2. 主循环,枚举H形的中心横杠所在的行 i
	for (int i = 2; i < n; i++) {
		int bg = 1; // 当前处理的白色横杠的起始列
		while (bg <= m) {
			while (bg <= m && a[i][bg] != 0) bg++; // 找到这段白色横杠的起点
            if (bg > m) break; // 如果找不到,说明该行处理完毕
			int ed = bg + 1; // 找到这段白色横杠的终点之后的位置
			while (ed <= m && a[i][ed] == 0) ed++;
			// 现在,[bg, ed-1] 是一段连续的白色横杠
			// 暴力枚举竖杠
			/* 
			for (int j = bg; j < ed; j++) {
				for (int k = j + 1; k < ed; k++)
					ans += min(min(up[i][j], down[i][j]), min(up[i][k], down[i][k])) - 1;
			}
			*/
			
            // 3. 计算并优化求和
            // a. 计算这段横杠上每个点的最大半高
			for (int j = bg; j < ed; j++) {
				tmp[j] = min(up[i][j], down[i][j]) - 1; // 半高 = min(向上延伸, 向下延伸) - 1(不含中心点)
			}
            // b. 对这段的半高进行排序
			sort(tmp + bg, tmp + ed);
            // c. 使用前缀和思想,O(m)计算 Σ min(h_y1, h_y2)
			long long sum = 0; // 相当于排序后半高值的前缀和
			for (int j = bg; j < ed; j++) {
				// 以j作为其中一个竖杠
                // 对于排序后的第k个值tmp[j],它与前面所有值的min都是前面的值
                // 所以它对总答案的贡献是前面所有值的和,即当前的前缀和sum
				ans += sum; sum += tmp[j]; // 更新前缀和
			}
			bg = ed; // 处理完这一段,从下一段开始继续
		}
	}
	printf("%lld\n", ans);
	return 0;
}

习题:P3105 [USACO14OPEN] Fair Photography S

解题思路

假设在一个选定的区间内,有 \(W\) 头白牛和 \(S\) 头斑点牛,可以画 \(p\) 头白牛,画完后,新的数量是 \(W' = W - p\)\(S' = S + p\),要使它们相等,即 \(W - p = S + p\),可以解得 \(W - S = 2p\)。这个等式透露了两个关键信息:\(W-S\) 必须是一个偶数(因为 \(2p\) 是偶数);\(W-S\) 必须大于等于 0(因为 \(p\) 不能是负数,不能把斑点牛变白),即 \(W \ge S\)更进一步转换,如果用 +1 代表白牛(W),-1 代表斑点牛(S),这样,一个区间内所有牛数值的和,就等于 \(W-S\)。现在,问题被彻底转换为:找到一个连续的奶牛子区间,使得其 +1/-1 和为一个非负偶数,并最大化该区间的尺寸

“子区间的和”是应用前缀和的经典信号。首先需要对所有奶牛按坐标 \(x\) 从小到大排序,这样,“连续区间”就变成了排序后数组的一个“连续子数组”。定义 \(sum_i\) 为从第 1 头牛到第 \(i\) 头牛(排序后)的 +1/-1 累加和。那么,从第 \(j\) 头牛到第 \(i\) 头牛这个子区间的和,就可以表示为 \(sum_i - sum_{j-1}\)。条件 \(W-S \ge 0\)\(W-S\) 为偶数,就变成了 \(sum_i - sum_{j-1} \ge 0\)\(sum_i - sum_{j-1}\) 为偶数。\(sum_i - sum_{j-1}\) 为偶数,等价于 \(sum_i\)\(sum_{j-1}\)奇偶性相同

先遍历一次所有奶牛,计算出每个前缀和第一次出现的位置 \(lpos\)最后一次出现的位置 \(rpos\),这是为了后续能快速找到一个前缀和值对应的最左和最右的牛。将所有可能的前缀和值按照奇偶性分成两组来处理。遍历所有可能的前缀和值(从 \(-n\)\(n\)),对于奇数前缀和,维护一个 pos_1,记录到目前为止遇到的所有奇数前缀和中最靠前的第一次出现位置。当遍历到当前的奇数前缀和 \(s\) 时,它的最后出现位置是 \(rpos_s\),那么 \(rpos_s\)\(pos_1 + 1\) 两个位置之间的距离就可以用来更新答案,因为 \(s\)\(pos_1\) 对应的前缀和都是奇数,它们的差是偶数;同时,因为是从小到大遍历 \(s\),保证了 \(pos_1\) 处的前缀和小于等于 \(rpos_s\)。对于偶数前缀和,用同样的方法处理。这样,通过一次预计算和一次遍历,就能找到满足所有条件的最优解。

参考代码
#include <cstdio>
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 100005;
struct Cow { // 定义奶牛结构体
    int x, c; // x:坐标,c:类型 (+1 for W, -1 for S)
}; 
Cow a[N];
int main()
{
    int n; cin >> n;
    // 读取输入并转换奶牛类型
    for (int i = 1; i <= n; i++) {
        char t;
        cin >> a[i].x >> t;
        a[i].c = (t == 'W' ? 1 : -1);
    }
    // 1. 按坐标对所有奶牛进行排序
    sort(a + 1, a + n + 1, [](Cow &lhs, Cow &rhs) {
        return lhs.x < rhs.x;
    });
    // 2. 预计算前缀和的首次和末次出现位置
    int sum = 0;
    vector<int> lpos(2 * n + 1, -1), rpos(2 * n + 1, -1); // lpos[s+n]:前缀和s第一次出现的索引,rpos[s+n]:前缀和s最后一次出现的索引
    lpos[n] = rpos[n] = 0; // 基准情况:在第0个位置,前缀和为0
    for (int i = 1; i <= n; i++) {
        sum += a[i].c; // 更新前缀和
        if (lpos[sum + n] == -1) lpos[sum + n] = i; // 如果是第一次遇到这个sum值,记录其首次出现位置
        rpos[sum + n] = i; // 每次都更新其最后一次出现的位置
    }
    // 3. 分奇偶遍历所有前缀和,寻找最优解
    int pos1 = -1; // 记录已遍历过的奇数前缀和中,最靠前的lpos
    int pos0 = -1; // 记录已遍历过的偶数前缀和中,最靠前的lpos
    int ans = 0;
    for (int i = 0; i <= 2 * n; i++) { // 遍历所有可能的前缀和值 i (i = sum + n)
        if (lpos[i] == -1) continue; // 如果这个前缀和值从未出现过,则跳过
        // 判断当前前缀和的奇偶性 (sum = i-n)
        // (i-n) % 2 == 0 <==> i和n的奇偶性相同
        if ((i % 2) == (n % 2)) { // 偶数前缀和
            // 更新偶数前缀和最靠前的位置
            if (pos0 == -1) pos0 = lpos[i];
            else pos0 = min(pos0, lpos[i]);
            // 左端点 j=pos0,右端点 k=rpos[i]
            // sum[k] >= sum[j] 且奇偶性相同,满足条件
            // 计算区间 [j+1, k] 的尺寸并更新答案
            if (pos0 < rpos[i]) ans = max(ans, a[rpos[i]].x - a[pos0 + 1].x);
        } else { // 奇数前缀和
            if (pos1 == -1) pos1 = lpos[i];
            else pos1 = min(pos1, lpos[i]);

            if (pos1 < rpos[i]) ans = max(ans, a[rpos[i]].x - a[pos1 + 1].x);
        }
    }
    cout << ans << "\n";
    return 0;
}

习题:P3745 [六省联考 2017] 期末考试

解题思路

所有学生的不愉快度都只和最终那个最晚出成绩的日期 \(x\) 有关,这意味着,如果能枚举这个最终的最晚日期 \(x\),并计算出当所有成绩都在第 \(x\) 天或之前公布时,所需要的最小操作代价,就能找到全局最优解。

定义一个函数 \(f(x)\),计算“强制让所有课程的成绩都在第 \(x\) 天或之前公布,所需要的最小总不愉快度”。\(f(x)\) 的总不愉快度由两部分构成:

  1. 学生不愉快度:所有希望在 \(t_i \lt x\) 天拿到成绩的学生,都会产生不愉快度,总和为 \(\sum \limits_{t_i \lt x} C(x - t_i)\)
  2. 操作不愉快度:所有原计划在 \(b_i \lt x\) 天公布成绩的课程,都必须被提前到 \(x\) 天,总共需要提前的天数是 \(\sum \limits_{b_i \lt x} (b_i - x)\),需要用最小的代价来凑够这些天数。

总共需要提前 \(\sum \limits_{b_i \lt x} (b_i - x)\) 天。可以通过两种方式获得“提前量”:

  1. 内部调节(操作 1):将那些原计划 \(b_j \lt x\) 天公布的课程,推迟到 \(x\) 天,这样,每推迟一天,就获得了一个可以用于其他课程的“提前一天”的名额。总共可以获得的内部调节量是 \(\sum \limits_{b_j \lt x} (x-b_j)\),每次操作代价为 \(A\)
  2. 外部雇佣(操作 2):如果内部调节的量不够,需要花钱增加老师,每次操作代价为 \(B\)

如果 \(A \lt B\),内部调节比外部雇佣便宜,会优先使用内部调节。可以用内部调节的量去抵消需要提前的总量,能抵消的数量是 \(\min(内部调节总量, 需要提前总量)\),这部分操作的总代价是 \(\min(\dots) \times A\),如果抵消后还需要提前,剩下的就必须用操作 2,代价是 \(剩余量 \times B\)。如果 \(A \ge B\),内部调节不比外部雇佣便宜(甚至更贵),那么完全没必要去推迟那些早出成绩的课程,所有需要提前的天数都直接用操作 2 解决,总代价是 \((需要提前总量) \times B\)

直接在 \(f(x)\) 内部循环计算 \(\sum \cdots\) 会导致整体复杂度过高,可以用前缀和的思想来优化。先用桶的思想,统计出 \(t_i\)\(b_i\) 出现的次数。然后计算四个前缀和数组:

  • \(cntt_i\):希望在 \(i\) 天及之前知道成绩的学生总数。
  • \(sumt_i\)\(t\) 的前缀和。
  • \(cntb_i\):原计划在 \(i\) 天及之前出成绩的课程总数。
  • \(sumb_i\)\(b\) 的前缀和。

利用这些前缀和数组,\(f(x)\) 中的所有求和 \(\sum \cdots\) 都可以用 \(O(1)\) 的时间计算出来。

  • \(\sum \limits_{t_i \lt x} (x - t_i) = x \cdot cntt_{x-1} - sumt_{x-1}\)
  • \(\sum \limits_{b_i - x} (b_i - x) = (sumb_{maxb}-sumb_x)-x(cntb_{maxb}-cntb_x)\)
  • \(\sum \limits_{b_j \lt x} (x - b_j) = x \cdot cntb_{x-1} - sumb_{x-1}\)

这样就有了一个 \(O(1)\)\(f(x)\) 函数,现在需要找到使它最小的 \(x\)\(x\) 的取值范围是从学生希望的最小天数 \(mint\) 到课程原计划的最大天数 \(maxb\)。注意有一组测试点 \(C\) 极大,此时进行 \(f(x)\) 的计算可能会溢出,此时实际上直接只需要考虑把时间 \(x\) 提到最早计算一次就可以了(不要让学生等待而产生不愉快度)。

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
using ll = long long;
const int MAXN = 100005;
// cntt[i]:希望在i天及之前知道成绩的学生数的前缀和
// sumt[i]:学生希望时间的加和的前缀和(Σt_k)
// cntb[i]:原计划在i天及之前出成绩的课程数的前缀和
// sumb[i]:课程原计划时间的加和的前缀和(Σb_k)
ll sumt[MAXN], cntt[MAXN], sumb[MAXN], cntb[MAXN], A, B, C;
int n, m, maxb;
/**
 * @brief 计算强制所有成绩在第x天或之前公布,所需的最小总不愉快度
 * @param x 最终的最晚出成绩日期
 * @return 最小总不愉快度
 */
ll f(ll x) {
    ll total_cost = 0;
    // 计算可以从早期课程调度的天数
    // Σ_{b_j < x} (x - b_j) = x * (Σ1) - (Σb_j)
    // = x * cntb[x-1] - sumb[x-1]
    ll supply = x * cntb[x - 1] - sumb[x - 1]; 
    // 计算总共需要提前的天数
    // Σ_{b_i > x} (b_i - x) = (Σb_i) - x * (Σ1)
    // = (sumb[maxb] - sumb[x]) - x * (cntb[maxb] - cntb[x])
    ll demand = sumb[maxb] - sumb[x] - x * (cntb[maxb] - cntb[x]);
    if (A < B) { // 根据A和B的代价,贪心计算最小操作成本
        ll move_days = min(supply, demand); // 内部调节更便宜,优先使用
        total_cost += move_days * A;
        demand -= move_days; // 减去已满足的需求
    }
    total_cost += demand * B; // 剩下的需求(或所有需求,如果A>=B)必须通过增加老师来满足
    // 加上学生不愉快度
    // Σ_{t_i < x} (x - t_i) * C
    // = (x * Σ1 - Σt_i) * C
    // = (x * cntt[x-1] - sumt[x-1]) * C
    total_cost += (x * cntt[x - 1] - sumt[x - 1]) * C;
    return total_cost;
}
int main()
{
    scanf("%lld%lld%lld%d%d", &A, &B, &C, &n, &m);
    // 使用桶来统计每个时间点有多少学生/课程
    // sumt/sumb在这里临时充当桶的角色
    int mint = MAXN;
    for (int i = 1; i <= n; i++) {
        int t;
        scanf("%d", &t);
        sumt[t]++; // t时刻的学生数+1
        mint = min(mint, t);
    }
    for (int i = 1; i <= m; i++) {
        int b;
        scanf("%d", &b);
        sumb[b]++; // b时刻的课程数+1
        maxb = max(maxb, b);
    }
    // 计算前缀和
    for (int i = 1; i < MAXN; i++) {
        cntt[i] = cntt[i - 1] + sumt[i]; // 学生数前缀和
        sumt[i] = sumt[i - 1] + sumt[i] * i; // 学生时间加和的前缀和
        cntb[i] = cntb[i - 1] + sumb[i]; // 课程数前缀和
        sumb[i] = sumb[i - 1] + sumb[i] * i; // 课程时间加和的前缀和
    }
    // 寻找最优的 x
    // 如果所有学生的要求都比最晚的课程要晚,则无需任何操作,成本为0
    ll ans = mint >= maxb ? 0 : f(mint);
    // 遍历所有可能的x来寻找最小值
    if (C < MAXN) for (int i = mint + 1; i <= maxb; i++) ans = min(ans, f(i));
    // 如果C很大,最优解x直接选mint
    printf("%lld\n", ans);
    return 0;
}

例题:P1719 最大加权矩形

有一个 \(n \times n \ (n \le 120)\) 的矩阵,矩阵中每个元素都有一个权值,权值是 \([-127,127]\) 之间的整数。从中找一矩形,矩形大小没有限制,要求其中包含的所有元素的和最大。

分析:最直接的做法是枚举左上角端点和右下角端点坐标,再将矩形内的元素求和,时间复杂度为 \(O(n^6)\)。如果可以在 \(O(1)\) 的时间复杂度内求出一个矩形内的元素和,时间复杂度可以降到 \(O(n^4)\)

与一维数组上的前缀和类似,设 \(s_{i,j}\) 表示以 \((1,1)\) 为左上角,\((i,j)\) 为右下角的矩形的元素和。

image

首先需要能够快速计算出 \(s_{i,j}\),计算 \(s_{i,j-1}\)\(s_{i,j-1}\) 之和,这样一来,绿色部分被加了一次,红色部分被加了两次,因此还需要减去多算的那一个部分。最后加上元素 \(a_{i,j}\) 的值,就可以得到 \(s_{i,j}\) 的结果。有 \(s_{i,j} = s_{i-1,j} + s_{i,j-1} - s_{i-1,j-1} + a_{i,j}\)

image

完成 \(s\) 的预处理之后,需要能够快速求出以 \((x_1,y_1)\) 为左上角,\((x_2,y_2)\) 为右下角的矩形内的元素和。首先考虑 \(s_{x_2,y_2}\),显然需要将多出的部分减掉,依次减掉 \(s_{x_2,y_1-1}\)\(s_{x_1-1,y_2}\) 后,红色部分被多减了一次,因此要加一份回来,所以加一份 \(s_{x_1-1,y_1-1}\)。则有 \(\sum_{i=x_1}^{x_2} \sum_{j=y_1}^{y2} a_{i,j} = s_{x_2,y_2} - s_{x_1-1, y_2} - s_{x_2, y_1 - 1} + s_{x_1-1,y_1-1}\)

#include <cstdio>
#include <algorithm>
using std::max;
const int N = 125;
int a[N][N], s[N][N];
int query(int x1, int y1, int x2, int y2) {
    return s[x2][y2] - s[x1 - 1][y2] - s[x2][y1 - 1] + s[x1 - 1][y1 - 1];
}
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            scanf("%d", &a[i][j]);
            s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + a[i][j];
        }
    }
    int ans = -127;
    for (int x1 = 1; x1 <= n; x1++) {
        for (int y1 = 1; y1 <= n; y1++) {
            for (int x2 = x1; x2 <= n; x2++) {
                for (int y2 = y1; y2 <= n; y2++) {
                    ans = max(ans, query(x1, y1, x2, y2));
                }
            }
        }
    }
    printf("%d\n", ans);
    return 0;
}

进一步优化,枚举上边界为第 \(i\) 行,下边界为第 \(j\) 行,此时如果把每一列的元素总和压成一个数(可以通过预处理每一列的前缀和实现快速计算),则相当于得到了一个长度为 \(n\) 的数组,在这个数组上求“最大子段和”就相当于求上下边界固定的最大子矩阵问题。这样一来,将时间复杂度降到 \(O(n^3)\)

#include <cstdio>
#include <algorithm>
using std::max;
using std::min;
const int N = 125;
int a[N][N], s[N][N]; // s[i][j]表示a[1][j]+a[2][j]+...+a[i][j]
int tmp[N], sum[N], pre[N]; 
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            scanf("%d", &a[i][j]);
            s[i][j] = s[i - 1][j] + a[i][j];
        }
    }
    int ans = -127;
    for (int i = 1; i <= n; i++) { // 枚举上边界
        for (int j = i; j <= n; j++) { // 枚举下边界
            // 将每一列压成一个数
            for (int k = 1; k <= n; k++) {
                tmp[k] = s[j][k] - s[i - 1][k];
                sum[k] = sum[k - 1] + tmp[k];
                pre[k] = min(pre[k - 1], sum[k]);
            }
            // 对tmp数组求最大子段和
            for (int k = 1; k <= n; k++) {
                ans = max(ans, sum[k] - pre[k - 1]);
            }
        }
    }
    printf("%d\n", ans);
    return 0;
}

习题:P2004 领地选择

解题思路

预处理二维前缀和,枚举正方形的左上角坐标,根据给定的边长求出右下角坐标,利用二维前缀和快速求出整个正方形的矩形和。

#include <cstdio>
#include <algorithm>
using std::max;
const int N = 1005;
int a[N][N], s[N][N];
int query(int x1, int y1, int x2, int y2) {
    return s[x2][y2] - s[x1 - 1][y2] - s[x2][y1 - 1] + s[x1 - 1][y1 - 1];
}
int main()
{
    int n, m, c; scanf("%d%d%d", &n, &m, &c);
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            scanf("%d", &a[i][j]);
            s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + a[i][j];
        }
    }
    int maxs = query(1, 1, c, c), ansx = 1, ansy = 1;
    for (int i = 1; i <= n - c + 1; i++) {
        for (int j = 1; j <= m - c + 1; j++) {
            int x = i + c - 1, y = j + c - 1; // 计算正方形右下角坐标
            int sum = query(i, j, x, y);
            if (sum > maxs) {
                maxs = sum; ansx = i; ansy = j;
            } 
        }
    }
    printf("%d %d\n", ansx, ansy);
    return 0;
}

习题:P2280 [HNOI2003] 激光炸弹

解题思路

先把输入数据里的目标标记到二维数组上,注意可能有多个目标在某个位置重合,因此价值要累加。然后求一遍二维前缀和,注意输入的 \(n\)\(m\) 并不是二维数组的尺寸,本题地图上的格子坐标取值范围是固定的,\(0 \le x_i,y_i \le 5 \times 10^3\),而前缀和相关的表达式中涉及到访问某个下标减一的位置,为了实现方便,可以将一开始输入的坐标全都加一。后面求炸弹能炸的最大价值的过程同上一题 P2004 领地选择

#include <cstdio>
#include <algorithm>
using std::max;
const int N = 5005;
int a[N][N], s[N][N];
int query(int x1, int y1, int x2, int y2) {
    return s[x2][y2] - s[x1 - 1][y2] - s[x2][y1 - 1] + s[x1 - 1][y1 - 1];
}
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        int x, y, v; scanf("%d%d%d", &x, &y, &v);
        // 由于原来的坐标范围是0~5000,不方便前缀和处理,所以统一加一
        a[x + 1][y + 1] += v; // 注意一个位置上存在多个目标,需要叠加
    }
    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] - s[i - 1][j - 1] + a[i][j];
        }
    }
    int ans = 0;
    for (int i = 1; i <= N - m; i++) {
        for (int j = 1; j <= N - m; j++) {
            int x = i + m - 1, y = j + m - 1;
            ans = max(ans, query(i, j, x, y));
        }
    }
    printf("%d\n", ans);
    return 0;
}

差分

例题:P2367 语文成绩

班上共有 \(n\) 个学生,语文老师在统计成绩的时候总是出错。语文老师需要对学生成绩进行 \(p\) 次修改,每次修改需要给第 \(x_i\) 个学生到第 \(y_i\) 个学生每人增加 \(z_i\) 分。语文老师想知道成绩修改后全班的最低分。
数据范围:\(n \le 5 \times 10^6, \ p \le n, \ 学生初始成绩 \le 100, \ z \le 100\)

分析:如果直接模拟修改,时间复杂度为 \(O(pn)\),无法完全通过。需要使用差分的方法。

对于数组 \(a\),定义 \(a\) 的差分数组为 \(b\),其中 \(b_1=a_1, \ b_i = a_i-a_{i-1} \ (2 \le i \le n)\)

image

如果我们对 \(b\) 数组求前缀和:\(\sum \limits_{i=1}^n b_i = a_1 + \sum \limits_{i=2}^n (a_i-a_{i-1}) = \sum \limits_{i=1}^n a_i - \sum \limits_{i=1}^{n-1} a_i = a_n\)。也就是说对差分数组求一遍前缀和相当于还原出原数组。

如果将 \(b_x\) 增加 \(1\),再对 \(b\) 数组求前缀和得到 \(a\) 数组,那么得到的 \(a\) 数组中 \(a_x, a_{x+1}, \cdots, a_n\) 均增加 \(1\)。若将 \(b_y\) 减少 \(1\),再对 \(b\) 数组求前缀和得到 \(a\) 数组,那么得到的 \(a\) 数组中 \(a_y, a_{y+1}, \cdots, a_n\) 均减少 \(1\)。例如,对一个全 \(0\) 的差分数组 \(b\),将 \(b_3\) 增加 \(1\),将 \(b_6\) 减少 \(1\),得到的 \(a\) 数组会是:

image

因此,将 \(a\) 数组中第 \(x\) 到第 \(y\) 个元素增加 \(z\) 的修改操作,可以转化为将 \(b_x\) 增加 \(z\)\(b_{y+1}\) 减少 \(z\),最后再到 \(b\) 数组求前缀和。这样,对于每次修改操作,只需要修改 \(2\) 个点,修改操作时间复杂度为 \(O(1)\)。同时,因为只有在最后一次修改操作后才需要得到 \(a\) 数组具体的值,所以只需要所有修改操作结束后求 \(1\) 次前缀和即可。

#include <cstdio>
#include <algorithm>
using std::min;
const int N = 5e6 + 5;
const int INF = 1e9;
int a[N], b[N];
int main()
{
    int n, p; scanf("%d%d", &n, &p);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
        b[i] = a[i] - a[i - 1]; // 求差分数组b
    }
    for (int i = 1; i <= p; i++) {
        int x, y, z; scanf("%d%d%d", &x, &y, &z);
        // 修改操作,将a[x],a[x+1],...,a[y]加上z
        b[x] += z; b[y + 1] -= z;
    }
    int ans = INF;
    for (int i = 1; i <= n; i++) {
        a[i] = a[i - 1] + b[i]; // 对差分数组做一遍前缀和复原出原数组
        ans = min(ans, a[i]);
    }
    printf("%d\n", ans);
    return 0;
}

习题:P11853 [CSP-J 2022 山东] 植树节

解题思路

利用差分数组高效处理区间更新。

参考代码
#include <cstdio> 

// 定义数组大小,需要比 b_i 的最大值 10^6 稍大,以容纳 c[b+1] 的访问
const int V = 1e6 + 5; 
// 定义差分数组 c
int c[V];

int main()
{
    int n;
    scanf("%d", &n); // 读取志愿者人数

    int maxb = 0; // 用于记录所有区间的最大右端点,以确定前缀和计算的范围
    
    // 循环读取 n 个区间,并构建差分数组
    for (int i = 1; i <= n; i++) {
        int a, b;
        scanf("%d%d", &a, &b);
        
        // 核心操作:将对区间 [a, b] 的增加操作转化为两个端点的修改
        // c[a]++ 表示从位置 a 开始,覆盖次数增加 1。这会影响 a 及其之后所有位置的最终计数值。
        c[a]++;
        // c[b+1]-- 表示在位置 b+1 时,抵消掉前面 c[a]++ 的影响,使得覆盖次数的增加效果仅限于区间 [a, b]。
        c[b + 1]--;
        
        if (b > maxb) {
            maxb = b;
        }
    }
    
    // ans 用于存储最大浇水次数,初始值为第 0 棵树的浇水次数。
    // 此时 c[0] 就是第 0 棵树的浇水次数,因为它的前缀和就是它自己。
    int ans = c[0];
    
    // 通过计算前缀和,将差分数组还原为每个点的实际浇水次数,并同时找到最大值
    for (int i = 1; i <= maxb; i++) {
        // c[i] += c[i-1] 计算前缀和。
        // 还原后,c[i] 存储的就是第 i 棵树被浇水的总次数。
        c[i] += c[i - 1];
        
        // 在计算过程中,不断更新最大浇水次数
        if (c[i] > ans) {
            ans = c[i];
        }
    }
    
    // 输出最终答案
    printf("%d\n", ans);
    
    return 0;
}

例题:P3397 地毯

\(n \times n \ (n \le 1000)\) 的格子上有 \(m \ (m \le 1000)\) 个地毯,给出地毯的信息,每块地毯覆盖的左上角是 \((x_1,y_1)\),右下角是 \((x_2,y_2)\),问每个点被多少个地毯覆盖。

分析:设数组 \(a\) 的差分数组为 \(b\),其中 \(a_{x,y} = \sum_{i=1}^x \sum_{j=1}^y b_{i,j}\),也就是说要实现二维差分。用前缀和的知识可知,\(b_{x,y} = a_{x,y} - a_{x-1,y} - a_{x,y-1} + a_{x-1,y-1}\)

image

考虑一次覆盖操作,地毯左上角为 \((x_1,y_1)\),右下角为 \((x_2,y_2)\),可以转化为将 \(b_{x_1,y_1}, b_{x_2+1, y_2+1}\) 增加 \(1\),将 \(b_{x_1,y_2+1}, b_{x_2+1,y_1}\) 减少 \(1\)

#include <cstdio>
const int N = 1005;
int a[N][N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int x1, y1, x2, y2;
        scanf("%d%d%d%d", &x1, &y1, &x2, &y2);
        // 二维差分
        a[x1][y1]++; a[x2 + 1][y2 + 1]++;
        a[x1][y2 + 1]--; a[x2 + 1][y1]--;
    }
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            // 在差分数组上重新求一遍前缀和
            a[i][j] = a[i - 1][j] + a[i][j - 1] - a[i - 1][j - 1] + a[i][j];
            printf("%d ", a[i][j]);
        }
        printf("\n");
    }
    return 0;
}

习题:P3406 海底高铁

解题思路

每一段地铁办卡还是不办卡取决于整个行程中坐到这段地铁的次数,而出差行程中的每一小段可以看作是对经过的一系列地铁乘坐次数区间加一,因此可以利用差分和前缀和技术求出每段地铁的乘坐次数。对于某段地铁来说,不办卡就是 \(A\) 乘以次数,办卡就是工本费 \(C\) 加上折后价 \(B\) 乘以次数,根据两者情况取更便宜的方案即可。

#include <cstdio>
#include <algorithm>
using ll = long long;
using std::min;
using std::max;
const int N = 1e5 + 5;
int p[N], cnt[N]; // cnt[i]维护i~i+1这段铁路的经过次数
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        scanf("%d", &p[i]);
        if (i > 1) {
            // p[i-1]---p[i] 注意p[i-1]和p[i]谁更大不一定
            // 利用差分思想
            int l = min(p[i - 1], p[i]), r = max(p[i - 1], p[i]);
            cnt[l]++; cnt[r]--; // 注意r-1~r才是最后一段铁路,所以差分减一的位置是r
        }
    }
    ll ans = 0;
    for (int i = 1; i < n; i++) {
        cnt[i] += cnt[i - 1]; // 对差分数组求前缀和得到实际次数
        int a, b, c; scanf("%d%d%d", &a, &b, &c);
        ans += min(1ll * a * cnt[i], 1ll * b * cnt[i] + c); // 选择便宜的方案
    }
    printf("%lld\n", ans);
    return 0;
}
posted @ 2023-07-28 16:51  RonChen  阅读(251)  评论(0)    收藏  举报