最大子段和与单调队列
1定义
最大子段和是指,对于一段数列来说,有区间\([l,r]\)使得\(a_i+a_{i+1}+...a_r\)最大,这个最大的和被称为最大子段和。
扩展内容是求解最大子矩阵和。
2算法
2.1 朴素算法
通过枚举l和r,来枚举所有可能的情况,暴力计算l到r的所有和。
代码:
for(int i=1;i<=n;i++)
for(int j=i;j<=n;j++){
int sum=0;
for(int k=i;k<=j;k++) sum+=a[k];
maxx=Max(maxx,sum);
}
时间复杂度\(O(n^3)\)
2.2优化
我们可以发现,其实在计算若干个数的和时,有重复计算,我们可以提前预处理前缀和来避免这个问题,优化时间复杂度。
代码:
for(int i=1;i<=n;i++) sum[i]=sum[i-1]+a[i];
for(int i=1;i<=n;i++)
for(int j=i;j<=n;j++)
maxx=Max(maxx,sum[j]-sum[i-1]);
时间复杂度\(O(n^2)\)
2.2.1优化前缀和算法
我们发现,如果我们定住一个左节点,我们的决策集合是只减少,不增多,这提示我们如果我们改而定住一个右节点,那么我们的决策集合应该是只增多不减少,这是一个重要的性质,尤其是在dp中,可以使复杂度降低一个量级,利用这个性质的方法是:我们用一个变量来存储前面所有决策集合的最小值,因为当我们定住一个右端点,我们要找的是位于它左边的一个最小值。
代码:
int minn=0,ans=-INF;
for(int i=1;i<=n;i++){
ans=Max(ans,sum[i]-minn);
minn=Min(minn,sum[i]);
}
2.3 DP
这个问题可以通过动态规划来解决。
我们发现,如果在我们已经知道以i结尾的最大子段和,那么下一步的决策只有两个:
- 我们把第i+1个数接在后面。
- 我们让第i+1个数自己成为一个序列。
我们设\(f_i\) 表示以i结尾的最大子段和。
状态转移:\(f_i=max(f_{i-1}+a_i,a_i)\)
最终答案为:\(max(f[i]),i=1,2,...n\)
可以发现,状态\(f_i\)已经包括了所有的状态空间,所以最终答案合法。
代码:
for(int i=1;i<=n;i++) f[i]=Max(f[i-1]+a[i],a[i]);
for(int i=1;i<=n;i++) maxx=Max(maxx,f[i]);
时间复杂度:\(O(n)\)
3扩展
3.1最大子矩阵问题
http://ybt.ssoier.cn:8088/problem_show.php?pid=1282
思路:我们枚举两行:\(l,r\),并把l到r之间的所有数,压成一个一维数组,即\(c_i=\sum ^r_{j=l} a_{j,i}\),跑一遍最大字段和,对所有结果取max。
代码:
#include<iostream>
#include<cstdio>
#include<cmath>
#include<algorithm>
#include<cstring>
#include<sstream>
#include<queue>
#include<map>
#include<vector>
#include<set>
#include<deque>
#include<cstdlib>
#include<ctime>
#define dd double
#define ll long long
#define ull unsigned long long
#define N 101
#define M number
using namespace std;
const int INF=0x3f3f3f3f;
int n,a[N][N],sum[N][N],b[N],f[N],maxx=-INF;
inline int Max(int a,int b){
return a>b?a:b;
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++){
scanf("%d",&a[i][j]);
sum[i][j]=sum[i-1][j]+a[i][j];
}
for(int i=1;i<=n;i++)
for(int j=i;j<=n;j++){
for(int k=1;k<=n;k++) b[k]=sum[j][k]-sum[i-1][k];
for(int k=1;k<=n;k++){
f[k]=Max(f[k-1]+b[k],b[k]);
maxx=Max(maxx,f[k]);
}
}
printf("%d",maxx);
return 0;
}
3.2 两段最大字段和问题
http://ybt.ssoier.cn:8088/problem_show.php?pid=1305
这个问题的基本思路是正着扫一遍,反着扫一遍,枚举断点,求断点在何处时和最大。
注意:枚举断点时必要的,因为我们的结果一定能够被分成两个区间,而且这两个区间一定是从1到n种枚举断点后断点两边区间的最大字段和所在区间,否则就会有更优的答案
#include<iostream>
#include<cstdio>
#include<cmath>
#include<algorithm>
#include<cstring>
#include<sstream>
#include<queue>
#include<map>
#include<vector>
#include<set>
#include<deque>
#include<cstdlib>
#include<ctime>
#define dd double
#define ld long double
#define ll long long
#define ull unsigned long long
#define N 50010
#define M number
using namespace std;
const int INF=0x3f3f3f3f;
int t,n,a[N],f[N][5];
inline int Max(int a,int b){
return a>b?a:b;
}
int main(){
scanf("%d",&t);
while(t--){
int ans=-INF;
n=0;memset(a,0,sizeof(a));
memset(f,-INF,sizeof(f));
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%d",&a[i]);
f[i][0]=Max(f[i-1][0]+a[i],a[i]);
f[i][1]=Max(f[i][0],f[i-1][1]);
}
for(int i=n;i>=1;i--){
f[i][2]=Max(f[i+1][2]+a[i],a[i]);
f[i][3]=Max(f[i][2],f[i+1][3]);
}
for(int i=1;i<=n-1;i++){
ans=Max(ans,f[i][1]+f[i+1][3]);
}
printf("%d\n",ans);
}
return 0;
}
3.3 长度不超过m的最大子段和。
一个想法是在再我们原先dp的基础上再加一维状态,表示长度,但这样即使可能可做(笔者还没有实现),时间负责度仍然是 \(O(n^2)\)的,而我们dp做最大字段和是\(O(n)\)的,我们能否将复杂度优化到\(O(n)\)呢?
我们有两个思路,优化dp做法,优化前缀和做法。
很明显,如果我们要利用dp的话,条件“不超过m”明显成了一个附加状态,但是如果我们选择前缀和的话,会发现这个条件实际上减少了前缀和算法(朴素算法)的时间开支,进一步缩小了枚举的范围。
所以我们尝试优化前缀和。
我们发现,当我们处理出前缀和后,对于每一个右端点r,我们都要找到一个l,在符合两者之间距离不超过m+1的前提下(因为是前缀和,原数组的m到这里就是m+1)使得sum[l]最小。
简单考虑一下就会发现,如果有一个k,sum[k]大于等于sum[l],并且k小于l,那么k这个决策就是完全无用的,他不可能对后面的状态做贡献,因为如果k能做贡献的,l一定也能做贡献,并且l还比k更优。
综上,所以我们的决策集合一定是一个下表单调递增,sum单调递增的序列,我们可以用一个双端队列来保存这个序列,即单调队列。
单调队列的思想是在决策集合中及时排除一定不是最优解的选择,可以优化dp。
其主要步骤有三个:
- 排除队头超出m范围的决策。
- 此时队头就是我们要取的值。
- 用此时的sum[i]去更新,不断删除队尾决策,知道队尾的值小于sum[i]
- 把sum[i]入队。
注:
- 代码head为队头元素的前一个元素,tail为队尾元素
- 队列中只存下标即可。
#include<iostream>
#include<cstdio>
#include<cmath>
#include<algorithm>
#include<cstring>
#include<sstream>
#include<queue>
#include<map>
#include<vector>
#include<set>
#include<deque>
#include<cstdlib>
#include<ctime>
#define dd double
#define ld long double
#define ll long long
#define ull unsigned long long
#define N 300010
#define M number
using namespace std;
inline int Max(int a,int b){
return a>b?a:b;
}
const int INF=0x3f3f3f3;
int q[N*3];
int n,m,a[N],sum[N];
int head,tail=1,maxx=-INF;
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++) scanf("%d",&a[i]),sum[i]=sum[i-1]+a[i];
q[1]=0;
for(int i=1;i<=n;i++){
while(head<tail&&q[head+1]<i-m) head++;
maxx=Max(maxx,sum[i]-sum[q[head+1]]);
while(head<tail&&sum[q[tail]]>=sum[i]) tail--;
q[++tail]=i;
}
printf("%d\n",maxx);
return 0;
}
使用单调队列而不是用一个变量来储存信息的原因是加上了限制条件“长度不超过m”。而能够使用单调队列条件是,决策集合是一个区间,且区间中的所有不可能的决策去掉后是单调的。故能够使用单调队列来优化。
由于每一个元素进出区间各只有一次,所以,维护单调队列的时间负责度是\(O(n)\)的,也就是说,整个算法的时间复杂度是\(O(n)\)