算法竞赛中最大子段和问题解法总结与拓展
本人第一次写博客,多多包涵
算法竞赛中最大子段和问题解法总结与拓展
题意总结
给出一段序列,在其中找出最大的连续且非空的子段。序列长度 \(n \le 2 \times 10^5\), \(|a_i| \le 10^4\)
解法1:动态规划
容易注意到,当我们考虑每个数字时,我们希望知道这个数是跟在之前的段里面,还是从这个数开始新开一个子段,还是不去使用这个数字
考虑动态规划,使用 \(dp_i\) 表示将第 \(i\) 个数作为子段结尾的最大子段和,如果能够依靠这种方式 dp 出来结果,这个数组的最大值就是本题答案,因为对每个数都进行了考虑,所以这样就处理掉了不使用这个数这种情况
接下来我们只需要考虑两种情况,将 \(a_i\) 当作结尾,还是延续前面的子段
-
第一种情况,新的子段和为 \(dp_{i-1}+a_i\)
-
第二种情况,新的子段和就是 \(a_i\)
于是可以写出状态转移方程
\(dp_i = max(dp_{i-1}+a_i,a_i)\)
在 dp 的过程中,动态维护 \(dp_i\) 的最大值,就可以得到本题答案
顺便一提,因为这道题只需要依靠 \(dp_{i-1}\) 进行转移,所以可以压缩一个维度,写成这样:
\(dp = max(dp+a_i,a_i)\)
void solve()
{
int n;
cin >> n;
vector<int> a(n);
for(int i = 0;i < n;i++)
cin >> a[i];
int ans = -2e9;// 答案的极小值
int dp = -2e9;
for(int i = 0;i < n;i++)
{
dp = max(dp+a[i],a[i]);
ans = max(ans,dp);
}
cout << ans << '\n';
}
时间复杂度:\(O(n)\)
解法2:前缀和+取区间最值
我们先考虑朴素解法
枚举所有的连续子段,对每一个子段求和,取最大值
分析时间复杂度:枚举首尾端点 \(O(n^2)\) ,朴素方法求和 \(O(n)\) ,合起来就是 \(O(n^3)\)
考虑改用前缀和求子段和,用 \(pre_i\) 表示从 \(a_1\) 到 \(a_i\) 的和
可以得到求任意区间 \([i,j]\) 的公式 \(pre_j - pre_{i-1}\)
预处理 \(O(n)\) ,每次查询 \(O(1)\) 。总复杂度优化为 \(O(n^2)\)
然后,我们考虑有没有什么办法来优化枚举断点带来的 \(O(n^2)\)
分析一下枚举的过程,枚举一个尾端点 \(j\),左端点 \(i\) 在 \([1,j]\) 之间寻找使得总和最小的区间
回顾一下区间和计算公式 \(pre_j-pre_{i-1}\),因为在枚举每个 \(j\) 的时候,\(pre_j\) 是不会变的。所以这个问题的本质,其实就是:数组 \(pre\) 在\([0,j-1]\) 之间的最小值
也就是说,我们要依次计算 \(pre\) 在区间 \([0,0],[0,1],……,[0,n-1]\) 的区间最小值
也就是说,我们只需要在枚举 \(j\) 的时候去维护 \(pre\) 的前 \(j-1\) 项的最小值就行了
于是我们得到了 \(O(n)\) 的解法
void solve()
{
int n;
cin >> n;
vector<int> a(n+1);// 涉及到前缀和,用1-based处理更方便
for(int i = 1;i <= n;i++)
cin >> a[i];
vector<int> pre(n+1);
for(int i = 1;i <= n;i++)
pre[i] = pre[i-1] + a[i];
int ans = INT_MIN;
int mn = pre[0];
for(int i = 1;i <= n;i++)
{
ans = max(ans,pre[i] - mn);
mn = min(mn,pre[i]);
}
cout << ans << '\n';
}
时间复杂度:\(O(n)\)
解法3:分治法
考虑使用分治法解决问题,将序列一分为二,对左右两段递归下去来求解,组合起来得到最终答案
将一整个序列分成左右两边,最大子序列会有3种情况
- 最大子序列在左边取到
- 最大子序列在右边取到
- 最大子序列跨过了中间点,同时占据左右两边
对于前两种情况,可以通过递归来求解
对于第三种情况,可以分解成
- 左边半段中,求所有以 \(mid\) 为尾端的最大子序列和最大值
- 右边半段中,求所有以 \(mid+1\) 为首端的最大子序列和最大值
通过枚举,可以 \(O(n)\) 复杂度解决
然后把这俩加起来
对这三种情况取最大值,就是本区间答案
分治复杂度 \(O(logn)\),总复杂度 \(O(nlogn)\),本题不会超时
// 在算法竞赛中用到函数时,使用全局变量可以免去传参的麻烦
int n;
vector<int> a;
int find_max_cross_sunarray(int l,int mid,int r)
{
int left_sum = LLONG_MIN,right_sum = LLONG_MIN;
int sum = 0;
for(int i = mid;i >= l;i--)
{
sum += a[i];
left_sum = max(left_sum,sum);
}
sum = 0;
for(int i = mid + 1;i <= r;i++)
{
sum += a[i];
right_sum = max(right_sum,sum);
}
return left_sum + right_sum;
}
int find_max_subarray(int l,int r)
{
if(l == r)
return a[l];
else
{
int mid = l + (r-l)/2;
int left_sum = find_max_subarray(l,mid);
int right_sum = find_max_subarray(mid+1,r);
int cross_sum = find_max_cross_sunarray(l,mid,r);
return max({left_sum,right_sum,cross_sum});
}
}
void solve()
{
cin >> n;
a.resize(n);
for(auto &i : a)
cin >> i;
cout << find_max_subarray(0,n-1) << '\n';
}
时间复杂度:\(O(nlogn)\)
这三种解法,谁更好?
首先排除分治法,这种方法对注意力要求太高了,复杂度还更大,递归的coding难度又高
而在 动态规划 和 前缀和+维护区间最值 这两种方法之间。个人更倾向于后者
虽然这两种方法时间复杂度一致,但是在思路上,动态规划解法需要将问题转化为"以\(j\)结尾最值",再想到分成"延续前面"和"新开一段"这两种情况。这其实需要一定注意力,而太依赖注意力往往也意味着解法难以拓展。
前缀和+维护区间最值的解法为什么更好?
因为这种解法揭示了 "最大子段和" 这个问题的本质,其实就是求 \(n\) 次区间最值
而"求区间最值"这个问题,在算法竞赛中有着非常丰富的处理手段
| 查询特征 | 运用算法 | 时间复杂度 |
|---|---|---|
| 固定一个端点,单调移动另外一个 | 打擂台 | \(O(1)\) |
| 两个端点都单调移动 | 单调队列 | 均摊\(O(1)\) |
| 随机查询 | ST表 | 预处理\(O(nlogn)\) 查询\(O(1)\) |
| 随机查询 | 线段树 | 预处理\(O(n)\) 查询\(O(logn)\) |
| …… | …… | …… |
以本题为例,这个问题的 "求区间最值" 是固定一个端点,从左到右移动另一个端点,只要用维护一个变量打擂台的方法就可以解答此题
而对于这类问题的其他变体,可能就需要运用其他求区间最值的方法。但是万变不离其宗,无论怎么变化,都可以套用 求前缀和+枚举尾端点+求区间最值 这个思路。这是动态规划所无法揭示的
最大子段和的拓展
题意总结
给定一个长度为 \(n\) 只包含 \(−1,0,1\) 的序列,求出往 \(0\) 的位置上填 \(k\) 个 \(1\),其余填 \(−1\) 后最大子段和的最大值。\(1 \le n,k \le 10^7\)
题解
注意这道题的数据范围,这是一道卡掉 \(O(nlogn)\),通过 \(O(n)\) 的题目,这在算法竞赛中非常少见,因为这种数据范围非常容易使得 \(O(n)\) 解法被卡常。所以要特别注意常数优化
按照我们之前总结的套路,这个问题一样可以转化为 求前缀和+枚举尾端点+求区间最值
假设首端点为 \(i\),尾端点为 \(j\) 。求解的区间范围就是 \([0,j-1]\)
定义原数组前缀和为 \(pre_i\),区间和记作 \(pre_{i,j}\)
所以,接下来的问题就在于,怎么求区间最值
容易想到,我们希望一个子段最大。应该尽量把这个子段里面的 \(0\) 都变成 \(1\)。如果这里面 \(0\) 的数量大于 \(k\),那剩下的 \(0\) 就只能填 \(-1\)
我们定义 \(cnt0_{i,j}\) 为区间 \([i,j]\) 里 \(0\) 的数量
分类讨论
- 当 \(cnt0_{i,j} \le k\) 时,最大子段和 = \(pre_{i,j} + cnt0_{i,j}\)
- 当 \(cnt0_{i,j} > k\)时,最大子段和 = \(pre_{i,j} + k + (k - cnt0_{i,j}) = pre_{i,j} + 2k - cnt0_{i,j}\)
考虑如何找出这两种情况的临界点
实际上,这个临界点,就是从当前的 \(j\) 开始,往前数 \(k\) 个 \(0\) 后,第 \(k+1\) 个 \(0\) 的位置。
建一个 \(pos0\) 数组,代表整个数组的第 \(i\) 个 \(0\) 出现在原数组的 \(pos0_i\) 位置
计算公式:\(p = pos0_{max(0,cnt0_j-k)}\)
然后可以转化为
- 当 \(i > p\) 时,最大子段和 = \(pre_{i,j} + cnt0_{i,j}\)
- 当 \(i \le p\) 时,最大子段和 = \(pre_{i,j} + k + (k - cnt0_{i,j}) = pre_{i,j} + 2k - cnt0_{i,j}\)
参数还是有点多,想想能不能合并一下
注意到:
- \(pre_{i,j} + cnt0_{i,j}\),其实就是把所有的 \(0\) 全部填 \(1\) 以后得到的数组前缀和
- \(pre_{i,j} - cnt0_{i,j}\),其实就是把所有的 \(0\) 全部填 \(-1\) 以后得到的数组前缀和
我们定义
- 把所有的 \(0\) 全部填 \(1\) 以后得到的数组前缀和 为 \(prep_i\)
- 把所有的 \(0\) 全部填 \(-1\) 以后得到的数组前缀和 为 \(pren_i\)
转化为
- 当 \(i > p\) 时,最大子段和 = \(prep_{i,j}\)
- 当 \(i \le p\) 时,最大子段和 = \(pren_{i,j} + 2k\)
这样,我们的求解思路就是分别求两个区间最小值
- \(prep\) 在区间 \([p+1,j-1]\) 最小值
- \(pren\) 在区间 \([0,p]\) 最小值
对于第二种情况,就是一个端点固定,另外一个从左到右移动,打擂台就行
对于第一种情况,它完美符合我们之前提过的 两个端点都单调移动 的情况,想到单调队列维护区间最值
取两种情况的最大值,就是当前 \(j\) 位置的答案。所有 \(j\) 的最大值,就是本题答案
哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈哈
最大子段和的真理,我已解明!
void solve()
{
int n,k;
cin >> n >> k;
vector<int> a(n+1);
for(int i = 1;i <= n;i++)
cin >> a[i];
vector<int> pren(n+1),prep(n+1),pos0;
pos0.push_back(-1);
for(int i = 1;i <= n;i++)
{
if(a[i] == 0)
{
pren[i] = pren[i-1] - 1;
prep[i] = prep[i-1] + 1;
pos0.push_back(i);
}
else
{
pren[i] = pren[i-1] + a[i];
prep[i] = prep[i-1] + a[i];
}
}
int cnt0 = 0;
int ans = -1;
int mn = 0;
deque<pii> q;
q.push_back({0,0});
int lsp = -1;
for(int i = 1;i <= n;i++)
{
if(a[i] == 0)
cnt0++;
int p = pos0[max(0,cnt0-k)];
while(!q.empty() && q.front().second < p+1)
q.pop_front();
while(!q.empty() && q.back().first > prep[i])
q.pop_back();
q.push_back({prep[i],i});
ans = max(ans,prep[i] - q.front().first);
if(p != lsp)
{
for(int k = lsp+1;k <= p;k++)
mn = min(mn,pren[k]);
lsp = p;
}
if(p != -1)
ans = max(ans,pren[i] - mn + 2*k);
}
cout << ans << endl;
}
时间复杂度:\(O(n)\)

对于算法竞赛中最大子段和问题总结三种不同的做法,并比较三种做法的优劣。进而探讨最大子段和问题的本质,并以一道变体题为例深入研究
浙公网安备 33010602011771号