【洛谷】 P1115 最大子段和
问题描述
给出一段序列,选出其中连续且非空的一段使得这段和最大。
输入格式
第一行是一个正整数 N ( 0 < N < 200000 ) N(0<N<200000) N(0<N<200000),表示了序列的长度。
第二行包含 N N N 个绝对值不大于 10000 10000 10000 的整数 a i a_i ai,描述了这段序列。输出格式
一个整数,为最大的子段和。子段的最小长度为1。输入样例
7
2 -4 3 -1 2 -4 3输出样例
4
样例说明
{ 2 , − 4 , 3 , − 1 , 2 , − 4 , 3 } \{2,−4,3,−1,2,−4,3\} {2,−4,3,−1,2,−4,3} 中,最大的子段和为 4,该子段为 { 3 , − 1 , 2 } \{3,−1,2\} {3,−1,2}。
题解
一、前缀和求解
此题是一道贪心算法的题目。在寻找最大子段时,我们当然可以通过枚举来寻找,即通过两重循环一一枚举该序列的所有子序列,并分别求其和(这里又是一重循环)再寻找其中的最大值。因此暴力枚举方法的时间复杂度为 O ( n 3 ) O\left(n^3\right) O(n3)。这在题目给出的数据范围下,必定超时无疑。这时候,可能你会马上想到一种数据结构——前缀和(对此数据结构尚不清楚的,可查看我前面写的专栏 ☞ 传送门),它的存在可将子段和转化为前缀和相减的形式。但不幸的是,其也只能将上面思路中求每个子序列和的那重循环降至常数级。因此,采取这样的方法求解的时间复杂度为 O ( n 2 ) O\left(n^2\right) O(n2) ,依然不能拿到满分。
由于我们希望找到和最大的子序列,因此对于序列中的每个数而言,其被选中一定要带来正收益(此处体现为“是一个正数”)。所以,任何想要被加入其中的数都必须是正数。但是有一类情况:对于某些负数,其加入必定会使得当前的序列总和降低,但是它可能具有链接作用,即可能在这之后会有很大的正数,能弥补由于添加该负数而导致的临时的整个序列总和降低。对于这类数,我们肯定是允许其加入序列的。那要对甄别这种情况呢?这依然可以用前缀和!
首先要知道一件事,前缀和数组求子序列(假设序列区间为
[
i
+
1
,
j
]
[i+1,j]
[i+1,j] )和的公式为:
S
u
m
[
i
+
1
,
j
]
=
∑
k
=
i
+
1
j
a
i
=
p
r
e
f
i
x
[
j
]
−
p
r
e
f
i
x
[
i
]
Sum_{[i+1,j]}=\sum_{k=i+1}^ja_i=prefix[j]-prefix[i]
Sum[i+1,j]=k=i+1∑jai=prefix[j]−prefix[i]
因此对 prefix[ ] 而言,prefix[j] 和 prefix[i] 差距越大,就表示对应序列 [i+1,j] 的总和越大。下面用题目给出的数据进行举例:
{ 2 , − 4 , 3 , − 1 , 2 , − 4 , 3 } \{2, -4, 3, -1, 2, -4, 3\} {2,−4,3,−1,2,−4,3}
首先构建关于此数列的前缀和序列如下(这里假设前构建的缀和数组为 prefix[ ],索引从 1 开始):
{ 2 , − 2 , 1 , 0 , 2 , − 2 , 1 } \{2, -2, 1, 0, 2, -2, 1\} {2,−2,1,0,2,−2,1}
接下来我们扫描这里面的最小值,可以得到 minGap = prefix[2]=-2,同时可得到最大值 maxGap = prefix[5]=2(注意,实际在代码里这个查找过程是按序执行的,第一次得到的最大的 prefix[1]=2 会被舍弃)。因此最终认为,原数组中具有最大子段和的序列区间为 [3,5] ,即 { 3 , − 1 , 2 } \{3,-1,2\} {3,−1,2},其总和为 3 + ( − 1 ) + 2 = 4 3+(-1)+2=4 3+(−1)+2=4。
基于这样的思路,可写出以下代码(已 AC):
#include<bits/stdc++.h>
using namespace std;
const int MAX = 2e+5;
int ary[MAX],prefix[MAX];
int main()
{
int n; cin>>n;
for(int i=1;i<=n;i++){
// 输入原始序列
cin>>ary[i];
// 构建前缀和数组
prefix[i] = prefix[i-1] + ary[i];
}
// 查找最大子序列
int minGap = 0x7fffffff, maxSum = 0x80000000;
for(int i=1;i<=n;i++) {
// 得到索引 i 之前的最优决策点(即最小的前缀和)
minGap = min(minGap,prefix[i-1]);
// 计算在当前最优决策下的子序列总和,并总是将最大值保存
maxSum = max(maxSum, prefix[i]-minGap);
}
// 输出最大子序列的总和
cout<<maxSum<<endl;
return 0;
}
上面的代码多用了一个数组来保存原始序列,但是这个序列在用于计算前缀和数组后就再也没用过了,因此我们还能对以上代码的空间用量进行进行优化,于是可得到:
#include<bits/stdc++.h>
using namespace std;
const int MAX = 2e+5;
int prefix[MAX];
int main()
{
int n, num, minGap = 0x7fffffff, maxSum = 0x80000000;
cin>>n;
for(int i=1;i <=n; i++){
// 输入原始序列中的数字
cin>>num;
// 构建前缀和数组
prefix[i] = prefix[i-1] + num;
// 得到索引 i 之前的最优决策点(即最小的前缀和)
minGap = min(minGap,prefix[i-1]);
// 计算在当前最优决策下的子序列总和,并总是将最大值保存
maxSum = max(maxSum, prefix[i]-minGap);
}
// 输出最大子序列的总和
cout<<maxSum<<endl;
return 0;
}
二、动态规划求解
实际上这道题还能用动态规划的思想进行求解。
若设转移数组 d p [ i ] dp[i] dp[i] 表示“以序号 i i i 结尾的子数列中的最大连续子段和”,则对输入的整个序列( a r y [ ] ary[\ ] ary[ ])而言,每个 d p [ i ] dp[i] dp[i] 的最低取值即为 a r y [ i ] ary[i] ary[i]。此时,每个子序列都取第i个元素构成单元素序列。在这样的定义下,最终我们要求的就是 m a x i ∈ [ 1 , n ] d [ i ] max_{i∈[1,n]}d[i] maxi∈[1,n]d[i]。
接下来讨论此模型的动态转移方程。由于我们每次更新 d p [ i ] dp[i] dp[i] 时, d p [ i − 1 ] dp[i-1] dp[i−1] 都已经存储好了“以 i − 1 i-1 i−1 结尾的最大连续子段和”,因此, d p [ i ] dp[i] dp[i] 要想取得“以 i i i 结尾的子数列中的最大连续子段和”就只需加上 d p [ i − 1 ] dp[i-1] dp[i−1] 即可。但是,加上的 d p [ i − 1 ] dp[i-1] dp[i−1] 必须大于0,否则这样的 “加上” 实际上会成为 “减少” 。所以该模型的动态转移方程即为(其中, a r y [ ] ary[\ ] ary[ ]为原序列数组, i i i为循环遍历指针):
if(dp[i-1] >= 0)
dp[i] = ary[i]+dp[i-1];
注:在实际编码时,可直接令
d
p
[
]
=
a
r
y
[
]
dp[\ ]=ary[\ ]
dp[ ]=ary[ ],则此时转移方程为:
d
p
[
i
]
=
d
p
[
i
]
+
d
p
[
i
−
1
]
dp[i] = dp[i]+dp[i-1]
dp[i]=dp[i]+dp[i−1]。
接下来只需要遍历
d
p
[
]
dp[\ ]
dp[ ]数组并取出其中的最大值即可。
下面给出利用动态规划求解本题的完整代码:
#include<bits/stdc++.h>
using namespace std;
const int MAX = 2e5+5;
int dp[MAX];
int main( )
{
// 录入数据
int n;cin>>n;
for(int i=0;i<n;i++)
cin>>dp[i];
// 初始化结果
int ans = dp[0];
// 状态转移
for(int i=1;i<n;i++){
if(dp[i-1] >= 0)
dp[i] += dp[i-1];
ans = max(ans, dp[i]);
}
// 输出结果
cout<<ans<<endl;
return 0;
}
甲方:有没有更节约内存的解决办法?
我:***
下面来探寻这个问题的本质。
由于现在要求具有最大子段和的序列,那对该序列而言,其被选中的数只能是以下两种:
- 正数,直接带来正收益;
- 负数,但是其加入还不足以将前面积累的正数和给削减完(即加上当前这个负数该子序列的总和仍然为正),这时我们给它一个机会让他留,因为我很贪心,我想着可能后面会来一个大的正数,其足以弥补该负数带来的削弱(商人思维:舍不得孩子套不了狼)。
基于这样的思路,我们甚至连数组都不需要,仅用三个寄存器就能完成上面的算法。
- sum:用于存放 “以前输入的数据总和” ,一旦这个值低于 0 ,就说明前面的序列可以舍弃了,接下来需要重新计数;
- max:用于保存当前设定的子序列总和;
- now:用于接受程序当前的输入。
下面给出基于以上分析得到的完整代码。
#include<iostream>
using namespace std;
int main()
{
// sum 用于记录前缀和,max 用于记录最大值,now 用于记录当前输入的值
int n,sum,max,now;
// 初始化:将输入序列的第一个作为sum
cin>>n>>sum;
// 初始化:将sum的值作为max的值
max = sum;
while(--n)
{
cin>>now;
// 判断 sum 的正负以决定是否保留该序列(这一步体现了贪心的思想)
sum = sum>0?sum:0;
// 无论怎样,都加上当前输入的值以对比max
sum += now;
// 试图更新 max
max=max>sum?max:sum;
}
cout<<max<<endl;
return 0;
}
进阶题目:【蓝桥杯】 历届试题 最大子阵