前缀和与差分思想

前缀和

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

给定 n 个正整数组成的数列 a1,a2,,anm 个区间 [li,ri],分别求这 m 个区间的区间和。
数据范围:n,m105, ai104

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

如果设 si 表示第 1 个数到第 i 个数的和,即 si=i=1nai=a1+a2++an,记 s0=0。在这里, 是求和符号,计算所有 ai 的累加和,其中 i 是从 1n 的所有整数。当 i1 时,sisi1=ai,所以 si=si1+ai。这里的 s 数组记录的数值,称为前缀和,也就是对每个前缀区间求和。

image

观察可知,a3+a4=(a1+a2+a3+a4)(a1+a2)=a3+a4=s4s2,即 i=lrai=i=1raii=1l1ai=srsl=1。求出 s 数组后,即可快速求出任意一个区间的区间和。

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

例题:P1115 最大子段和

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

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

参考代码 O(n3)
#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(n2)

参考代码 O(n2)
#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(n2) 的算法依然不能通过这道题,这里我们利用前缀和来优化。原问题可以看作是找最大的一个区间 [l,r],其前缀和之差 srsl1 最大,这是一个最大化问题,其中 lr 都是不确定的。像这种问题,有一种比较经典的处理思路,通过枚举其中一项将其固定下来,然后利用某种性质快速求出另一项。这里假如我们固定区间的右端点 r,要使 srsl1 最大,此时 sr 是个定值,则需要让 sl1 最小,而 l 的取值实际上是 1r,即我们要找到 s0,s1,s2,,sr1 中的最小值。

这个形式和前缀和很像,实际上前缀和代表了一类预处理思想。前缀和的思想,除了可以用来求一个区间内的和以外,也可以用来预处理最大最小值。我们除了预处理前缀和以外,还去预处理每个前缀和的“前缀最小值”,则对于上面那个式子,只需要利用“前缀和的前缀最小值”就可以快速计算出右端点固定时的最大子段和。而不管是前缀和还是前缀和的前缀最小值都可以在输入原数组中顺便处理出来,这样我们后面的计算只需要枚举每种右端点的情况即可,总的时间复杂度为 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 取余只有 06 这七种情况,要使得这样的区间最长我们可以记录每种余数第一次出现的位置和最后一次出现的位置。最后把余数为 06 各自能形成的最长区间比较一下即可。注意前缀和 s0=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;
}

习题:P6067 [USACO05JAN] Moo Volume S

解题思路

原本要求的问题中包含像 |xixj| 这样带绝对值的式子,也就是说在无法确定 xixj 的大小时,这个式子有两种情况。但是因为要求的是所有奶牛聊天音量的总和,也就是说每一头奶牛都要和另一只奶牛去计算 |xixj|。那我们不妨将 x 从小到大排序,这样一来绝对值怎么取就固定了。此时假设我们考虑排序后的奶牛中的第 i 头,它和左边的奶牛聊天的音量和可以被表示为 (xix1)+(xix2)++(xixi1)=(i1)xi(x1+x2++xi1),它和右边的奶牛聊天的音量和可以被表示为 (xi+1xi)+(xi+2xi)++(xnxi)=(xi+1+xi+2++xn)(ni)xi。记 sx 的前缀和,则把两式相加可得 (2i1n)xi+snsisi1。因此,对 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;
}

例题:P1719 最大加权矩形

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

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

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

image

首先需要能够快速计算出 si,j,计算 si,j1si,j1 之和,这样一来,绿色部分被加了一次,红色部分被加了两次,因此还需要减去多算的那一个部分。最后加上元素 ai,j 的值,就可以得到 si,j 的结果。有 si,j=si1,j+si,j1si1,j1+ai,j

image

完成 s 的预处理之后,需要能够快速求出以 (x1,y1) 为左上角,(x2,y2) 为右下角的矩形内的元素和。首先考虑 sx2,y2,显然需要将多出的部分减掉,依次减掉 sx2,y11sx11,y2 后,红色部分被多减了一次,因此要加一份回来,所以加一份 sx11,y11。则有 i=x1x2j=y1y2ai,j=sx2,y2sx11,y2sx2,y11+sx11,y11

#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(n3)

#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] 激光炸弹

解题思路

先把输入数据里的目标标记到二维数组上,注意可能有多个目标在某个位置重合,因此价值要累加。然后求一遍二维前缀和,注意输入的 nm 并不是二维数组的尺寸,本题地图上的格子坐标取值范围是固定的,0xi,yi5×103,而前缀和相关的表达式中涉及到访问某个下标减一的位置,为了实现方便,可以将一开始输入的坐标全都加一。后面求炸弹能炸的最大价值的过程同上一题 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 次修改,每次修改需要给第 xi 个学生到第 yi 个学生每人增加 zi 分。语文老师想知道成绩修改后全班的最低分。
数据范围:n5×106, pn, 100, z100

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

对于数组 a,定义 a 的差分数组为 b,其中 b1=a1, bi=aiai1 (2in)

image

如果我们对 b 数组求前缀和:i=1nbi=a1+i=2n(aiai1)=i=1naii=1n1ai=an。也就是说对差分数组求一遍前缀和相当于还原出原数组。

如果将 bx 增加 1,再对 b 数组求前缀和得到 a 数组,那么得到的 a 数组中 ax,ax+1,,an 均增加 1。若将 by 减少 1,再对 b 数组求前缀和得到 a 数组,那么得到的 a 数组中 ay,ay+1,,an 均减少 1。例如,对一个全 0 的差分数组 b,将 b3 增加 1,将 b6 减少 1,得到的 a 数组会是:

image

因此,将 a 数组中第 x 到第 y 个元素增加 z 的修改操作,可以转化为将 bx 增加 zby+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;
}

例题:P3397 地毯

n×n (n1000) 的格子上有 m (m1000) 个地毯,给出地毯的信息,每块地毯覆盖的左上角是 (x1,y1),右下角是 (x2,y2),问每个点被多少个地毯覆盖。

分析:设数组 a 的差分数组为 b,其中 ax,y=i=1xj=1ybi,j,也就是说要实现二维差分。用前缀和的知识可知,bx,y=ax,yax1,yax,y1+ax1,y1

image

考虑一次覆盖操作,地毯左上角为 (x1,y1),右下角为 (x2,y2),可以转化为将 bx1,y1,bx2+1,y2+1 增加 1,将 bx1,y2+1,bx2+1,y1 减少 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 @   RonChen  阅读(97)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?
点击右上角即可分享
微信分享提示