下面介绍单调队列的经典应用----最大子序和问题。
那么,什么是子序和呢?给定长度为n的整数序列A,他的“子序列”定义为A中非空的一段连续的元素。例如,序列(6,-1,5,4,7)中前4个元素的子序和为6 + (-1) + 5 + 4 = 14。
最大子序列和问题按子序列有无长度限制分为两种:
问题(1):不限制子序列的长度。在所有可能的子序列中找到一个子序列,该子序列和最大。
问题(2):限制子序列的长度。给定一个限制长度为m,找出一段长度不超过m的连续子序列,使它的子序和最大。
对于问题(1),我们以http://acm.hdu.edu.cn/中的1003题为例:
Max sum(hdu 1003)
问题描述:给定一个序列,求最大子序列和。
输入:第1行输入整数T,表示测试用例个数,1 <= T <= 20;后面T行中,每行第1个数输入N,后面输入N个数,1 <= N <= 100000,每个数在 [ -1000, 1000 ] 区间内。
输出:每个测试输出两行。第一行是 "Case #: ",其中 "#" 表示测试序列号;第二行输出3个数,第1个数是最大子序和,第2个和第3个数是开始和终止位置。两个测试点之间输出一个空行。
解法一:
贪心法。逐个扫描序列中的元素并累加。加到一个正数时,子序和会增加;加到一个负数时,子序和会减少。如果当前得到的和变成了负数,这个负数在接下来的累加中会减少后面的求和,所以抛弃它,从下一个位置重新开始求和。时间复杂度为O(n)
#include<bits/stdc++.h>
using namespace std;
const int INF = 0x7fffffff;
int main()
{
int t;
cin >> t;//输入测试用例数
for(int i = 1; i <= t; i++)
{
int n;
cin >> n;
int maxsum = -INF;//最大值,初始化为一个极小值
int start = 1,end = 1,p = 1;//起点、终点,扫描位置
int sum = 0;
for(int j = 1; j <= n; j++)
{
int a;
cin >> a;
sum += a;//读入一个元素,累加
if(sum > maxsum)
{
maxsum = sum;
start = p;
end = j;
}
if(sum < 0)
{//扫描到j时,若前面的最大子序和是负数,从下一个重新开始求和
sum = 0;
p = j + 1;//初始化扫描位置
}
}
cout << "Case " << i << ":" << endl;
cout << maxsum << " " << start << " " << end << endl;
if(i != t)
{
cout << endl;
}
}
return 0;
}
解法二:
动态规划。定义状态dp[ i ],表示以a[ i ]为结尾的最大子序和。dp[ i ]的计算有两种情况:
(1)dp[ i ]只包括一个元素,就是a[ i ];
(2)dp[ i ]包括多个元素,从前面某个a[ v ]开始,v < i,到a[ i ]结束,即dp[ i - 1] + a[ i ]。
取两者的最大值,得到状态转移方程dp[ i ] = max(dp[i - 1] + a[ i ], a[ i ])。在所有的dp[ i ]中,最大值就是题目的解。时间复杂度为O(n)
#include<bits/stdc++.h>
using namespace std;
int dp[100005];//dp[i]:以第i个数为结尾的最大值
int main()
{
int t;
cin >> t;
for(int k = 1; k <= t; k++)
{
int n;
cin >> n;
for(int i = 1; i <= n; i++)
{
cin >> dp[i];//用dp[]存储数据a[]
}
int start = 1,end = 1,p = 1;//起点、终点,扫描位置
int maxsum = dp[1];
for(int i = 2; i <= n; i++)
{
if(dp[i-1] + dp[i] >= dp[i])
{
dp[i] = dp[i-1] + dp[i];//dp[i-1] + a[i]比a[i]大
}
else
{
p = i;//若a[i]更大,那么dp[i]就是a[i]
}
if(dp[i] > maxsum)
{//dp[i]是一个更大的子序和
maxsum = dp[i];
start = p;//以p开始
end = i;//以i结尾
}
}
cout << "Case " << k << ":" << endl;
cout << maxsum << " " << start << " " << end << endl;
if(k != t)
{
cout << endl;
}
}
return 0;
}
下面是问题(2)的求解:
这题和上一个博文中的“滑动窗口”类似,可以用单调队列的“窗口、删头、去尾”解决问题(2)
首先求前缀和s[ i ]。s[ i ]是a[ 1 ] ~ a[ i ]的和,计算所有的s[ i ] ~ s[ n ],时间复杂度为O(n)
求完前缀和后,问题(2)实际上转化为:找出两个位置 i、k,使s[ i ] - s[ k ]最大,i - k <= m。
我们来思考用DP求解,把问题进一步转化为:首先固定一个 i,找到它左边的一个端点 k,i - k <= m,使s[ i ] - s[ k ]最大,定义这个最大值为dp[ i ];逐步扩大 i,求得所有的dp[ i ],其中的最大值就是问题的求解。如果简单地暴力检查,对每个 i 检查比它小的 m 个s[ k ],那么总的时间复杂度为O(mn),会超时
暴力检查的方法不可行,改用一个大小为 m 的窗口寻找最大子序和 ans。从头到尾依次把 s[ ] 的元素放入这个窗口。
(1)首先把 s[ 1 ] 放入窗口,并且记录 ans 的初始值 s[ 1 ]。
(2)接着把 s[ 2 ]放入窗口(假设窗口长度大于2),有两种情况:如果 s[ 1 ] <= s[ 2 ],那么更新 ans = max{ s[ 1 ],s[ 2 ],s[ 2 ] - s[ 1 ] };如果 s[ 1 ] > s[ 2 ],那么保持 ans = s[ 1 ]不变,然后从窗口中抛弃 s[ 1 ],只留下 s[ 2 ],因为后面再把新的 s[ i ’ ] - s[ 2 ] 比 s[ i ’ ] - s[ 1 ]更大(注意s为前缀和,s[ 1 ] > s[ 2] 实际上是 a[ 2 ]一定小于0,留下s[ 2 ]是为了跳过负数 a[ 2 ])
继续这个过程,直到所有的 s[ ] 处理结束
总结上面的思路,把新的 s[ i ] 放入窗口时:
(1)把窗口内比 s[ i ]大的所有 s[ j ]都抛弃,i - j <= m,因为这些 s[ j ]在处理 s[ i ] 后面的 s[ i ’ ] 时用不到了,s[ i ’ ] - s[ i ] 要优于 s[ i ’ ] - s[ j ],保留 s[ i ] 就可以了;
(2)若窗口内最小的是 s[ k ],此时肯定有 s[ k ] < s[ i ],检查 s[ i ] - s[ k ]是否为当前的最大子序列和,如果是,就更新最大子序列和ans;
(3)每个 s[ i ] 都会进入队列。
此时,最优策略是一个“位置递增、前缀和也递增”的序列,用单调队列最合适。s[ i ]进入队尾时,如果原队尾比 s[ i ] 大,则去尾;如果队头超过窗口 m,则去头,而最小的那个 s[ k ] 就是队头。算法跟“滑动窗口”差不多。
在这个单调队列中,每个 s[ i ] 只进出队列一次,计算复杂度为O(n)
#include<bits/stdc++.h>
using namespace std;
deque<int> dq;
int s[1000005];
int main()
{
int n,m;
cin >> n >> m;
for(int i = 1; i <= n; i++)
{
cin >> s[i];
}
for(int i = 1; i <= n; i++)
{//计算前缀和
s[i] = s[i] + s[i-1];
}
int ans = -1e8;
dq.push_back(0);
for(int i = 1; i <= n; i++)
{//同理,队列中存入下标
while(!dq.empty() && dq.front() < i - m)
{
dq.pop_front();//队头超过m的范围:删头
}
if(dq.empty())
{
ans = max(ans,s[i]);
}
else
{
ans = max(ans,s[i] - s[dq.front()]);//队头就是最小的s[k]
}
while(!dq.empty() && s[dq.back()] >= s[i])
{
dq.pop_back();//队尾大于s[i],去尾
}
dq.push_back(i);
}
cout << ans << endl;
return 0;
}