\(AcWing\) \(135\). 最大子序和
一、题目描述
给定一个长度为 \(n\) 的序列 \(a\),找出其中 元素总和最大 且 长度 不超过 \(m\) 的 连续子区间
二、先想一下暴力怎么做
如果只要长度为\(m\)的连续子区间元素总和最大,那就是个简单的滑动窗口,我们先计算一下前缀和,然后维护一个滑动窗口,一边加来一边减就行了。
此题就难度提升了,因为是一个 长度不超过\(m\)的连续子区间!
窗口长度不固定,那我们就考虑枚举每个位置做为终点,然后从终点向回走,最长枚举\(m\)个长度,这样纯暴力的思路就出来了!
暴力大法
\(TL\)E掉\(3\)个点
通过了 \(11/14\)个数据
#include <cstdio>
#include <cstring>
#include <iostream>
using namespace std;
// Brute-force 暴力
const int N = 300010;
const int INF = 0x3f3f3f3f;
int s[N];
int n, m;
//普通DP
int main() {
//加快读入
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> s[i], s[i] += s[i - 1];
int res = -INF;
//遍历每一个终点
for (int i = 1; i <= n; i++)
//从终点向前,找出所有区间在m之内的数字,通过前缀和计算出区间的累加和,保留最大值
//在刚刚出发不久,长度不够m的时候,即i<m,则i-m会出现负数
// j的物理含义:从i出发,倒序走到离i长度为m的范围内,
// 栗子1: i=10 m=3 ,有效的数据范围:j∈[10,9,8],最后面的8理解为i-m+1,也可写成j>i-m或j>=i-m+1
// 上面的计算办法不是万能的,在起始边界会有问题:
// 栗子2: i=1 m=3 ,有效的数据范围:j∈[1],此时,再用i-m+1=1-3+1=-1就是无意义的,不可能遍历到小于数字1的下标
// 所以,这个终止条件还要是细心记忆一下 j && j > i-m
for (int j = i; j && j > i - m; j--)
res = max(res, s[i] - s[j - 1]); // j>0 && j> i-m --> j> max(0,i-m)
printf("%d\n", res);
return 0;
}
三、想下怎么优化
本题的数据范围是\(30w\),上面平方级别复杂度的代码显然会超时。
状态表示
\(f[i]\):以第\(i\)个数字结尾的长度不超过\(m\)的子序列的最大和
\(s[i]\):数组的前缀和,则\(a[l] + ... + a[r] = s[r] - s[l-1]\)
状态转移方程为\(f[i] = max(s[i] - s[j-1])\),其中\(i - j\)大于等于\(0\)并且不超过\(m\)
一图胜千言:
在单调队列的实现时,需要先在队列中加入哨兵结点\(s[0]\),后面就是解决四个问题了。
-
第一,何时出队头,枚举到第\(i\)个数字时,第\(i\)个数字还没加入队列时,队头元素的下标允许的最小值是\(i - m\),所以当\(i - q[hh] > m\)时就需要出队头了;
-
第二,何时出队尾,我们需要队头的元素是\(s[q[hh]]\)最小的元素,所以当\(s[i] <= s[q[tt]]\)时,出队尾元素;
-
第三,何时加入新元素,队尾元素该出的出完了就可以将\(i\)加入到队列中了;
-
第四,何时更新我们要求的长度不超过\(m\)的子序列的和的最大值\(res\),只要在确保队列中的元素个数不超过\(m\)时就可以尝试更新\(res\)了。
时间复杂度
使用单调队列优化\(DP\)的方法时间复杂度是\(O(n)\)。
四、哨兵实现方式
#include <cstdio>
#include <cstring>
#include <iostream>
using namespace std;
const int N = 300010;
const int INF = 0x3f3f3f3f;
int n, m;
int q[N];
//单调队列,本质记录的是下标序号,因为只有记录了下标,才能知道是否离i的距离长度超过m了没有。
//同时,记录了下标的话,想要其它的信息都是可以表示出来的,比如s[q[hh]]
int s[N]; //前缀和
int res = -INF; //预求最大,先设最小
int main() {
//加快读入
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> s[i], s[i] += s[i - 1]; //前缀和
/*
举个栗子:i=10,m=3 则应该是
(1)m=3 ->8 9 10 a[8]+a[9]+a[10]=s[10]-s[7]
(2)m=2 ->9 10 a[9]+a[10]=s[10]-s[8]
(3)m=1 ->10 a[10]=s[10]-s[9]
由于当前遍历到的是i=10,所以s[10]是固定值,变化的是后面的s[j], i-m<=j<=i-1
我们在计算以10为终点的区间最大和时,队列中存入的应该是 7,8,9的索引号
想要求最大值,就转化为求s[j]的最小值
状态表示: s[i]-min(s[i-1],s[i-2],...,s[i-m])
其中最小值的下标记录在维护好的单调队列队头中!
这里需要考虑一下边界情况,比如i=1,也就是刚刚开始枚举第1个数,此时如果队列是空的,那么第1个数将无法
取得队列头,因为队列是空的嘛!此时程序就会有问题,需要特判!这样太复杂了,一般的通用作法是:事先安排进去
一个哨兵,它的作用就是解决边界情况,解决特判问题。
哨兵的值视情况而定,比如本题,就是想解决第1个数字查询它前面的最小前缀和对应的索引号,也就是s[0]=0,
我们直接把哨兵=0手动入入队列即可。
*/
int hh = 0, tt = 0; //等价于增加了一个哨兵节点,也就是把下标为0的前缀和s[0]=0加入了滑动窗口中,描述1号节点向前m个节点的前缀和最小值是0
// 枚举右边界
for (int i = 1; i <= n; i++) {
// 1、老掉牙的需要去世
while (hh <= tt && q[hh] < i - m) hh++;
// 2、此时单调队列中保存就是i以前、长度最长为m的单调上升的一个序列,队列头保存的就是i前面前缀和的最小值
// 此时i还没有进入队列,单调队列其实在枚举数字i的左侧
res = max(res, s[i] - s[q[hh]]);
// 3、i入队列
//比i老没它小的都去死吧~
while (hh <= tt && s[q[tt]] >= s[i]) tt--;
// i入队列,本轮结束
q[++tt] = i;
}
//输出结果
printf("%d\n", res);
return 0;
}
五、基础课单调队列版本
#include <iostream>
using namespace std;
const int INF = 0x3f3f3f3f;
const int N = 3e5 + 10;
int s[N], q[N];
int hh, tt;
int n, m;
/*
目标是s[i] - s[i-j], j = 1, 2, ..., m 的最大值 <=> 等价于求 s[i-j], j = 1, 2, ..., m 的最小值,
即s的下标取区间 [i-m, i-1] 内的最小值,即滑动窗口求最小值问题,可以用单调队列来优化。
y老师的代码与基础课的代码不大一样,实际上是把第一轮循环的更新操作放到循环之前去了,所以看起来有一点费解,
这里给出一个与基础课对应的版本。
*/
int main() {
//加快读入
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> s[i], s[i] += s[i - 1];
int res = -INF;
/*
1、我是i,我要计算max(s[i]-s[j]) j∈[i-1,i-m],此时s[i]是固定值,s[j]越小,值越大,转为求min(s[j]) j∈[i-1,i-m]
2、在一个区间内的最小值,联想到单调队列维护区间最小值。
3、需明确,是i左边最长宽度是m的一个窗口,还包括i
4、讨论i时,需要保证的是i的前一位i-1应该已经加入到单调队列(就算它值大,不划算,但它最起码年轻,肯定得活下来)
* 在保证前序加入到单调队列中时,单调队列最起码有一个元素,即s[i-1]存在
* 这样的逻辑顺序就是在判断以i结尾的区间最大值时,先保证i-1在队列中,然后再利用单调队列的s[q[hh]]获取前m个长度窗口中的最小值
* 总结:要想搝(qiu 3声),先得有!核心思想是保证单调队列中有东西,你才能去取,不能在边界时空着也硬去取~
*/
hh = 0, tt = -1;
for (int i = 1; i <= n; i++) { //枚举的是右边界
// 1、保持队列长度
while (hh <= tt && q[hh] < i - m) hh++; //[i-1 ~ i-m]
// 2、i-1 号数据 进入队列,以保证后续s[i]可以通过s[q[hh]]获取到前序的最小值
while (hh <= tt && s[q[tt]] >= s[i - 1]) tt--;
q[++tt] = i - 1;
// 3、区间最大值
res = max(res, s[i] - s[q[hh]]);
}
//输出结果
printf("%d\n", res);
return 0;
}
六、疑惑的问题
不知道别人啥情况,反正我在学习这道题时,一直很疑惑: 为什么要加哨兵,像基础课一样不加哨兵就不行吗?
后来慢慢想明白了,现在解释一下:
从实际含义解释
我们准备用一个单调上升队列(维护最小值)来保存前缀和(保存的是下标),需要注意边界情况,比如第\(1\)个,按照计算式来讲,要想求它的区间和,需要用到\(s[1]\)减去滑动窗口的队头元素对应的前缀和,即\(s[1]-s[q[hh]]\),滑动窗口中保存的应该是\([i-m,i-1]\)的情况,但是因为它本身是第\(1\)个,它没有前序,窗口中没有数据,它还想去取,与理不合啊!
我们的口号是:
要想搝(\(qiu\) \(3\)声),先得有!
核心思想:保证单调队列中有东西,你才能去取,不能在边界时空着也硬去取~