最大子段和问题:从暴力法到优化的算法解析
题目链接
题目描述
给出一个长度为 nn 的序列 aa,选出其中连续且非空的一段使得这段和最大。
输入格式
- 第一行是一个整数,表示序列的长度 n。
- 第二行有 n 个整数,第 i 个整数表示序列的第 ii 个数字 ai。
输出格式
输出一行一个整数表示答案。
输入输出样例
输入 #1:
7
2 -4 3 -1 2 -4 3
输出 #1:
4
样例解释:
选取子数组 {3, −1, 2},其和为 4。
数据规模与约定
- 对于 40% 的数据,保证 n≤2×103n \leq 2 \times 10^3。
- 对于 100% 的数据,保证 1≤n≤2×1051 \leq n \leq 2 \times 10^5,且 −104≤ai≤104−10^4 \leq a_i \leq 10^4。
算法解析:从暴力法到优化的全面分析
本问题的核心是求解一个数组中 连续子数组 的最大和。为了帮助大家更好地理解如何优化算法,我们将从最基础的暴力法出发,逐步介绍一些优化策略,最后给出最优解法。每一种算法都有其优点和局限,适用于不同规模的数据。
第一步:暴力法(最直观的解法)
暴力法的基本思路非常简单:枚举数组中所有可能的子数组,计算它们的和,并找出最大的那个子数组和。
步骤:
- 对于每个子数组的起始位置 ii,遍历所有可能的结束位置 jj。
- 计算子数组 a[i]…a[j]a[i] \dots a[j] 的和。
- 不断更新最大子数组和。
代码实现:
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
int a[n];
// 输入数组
for (int i = 0; i < n; i++) {
cin >> a[i];
}
int ret = INT_MIN;
// 暴力法:枚举所有子数组
for (int i = 0; i < n; i++) {
int sum = 0;
for (int j = i; j < n; j++) {
sum += a[j]; // 累加子数组元素
ret = max(ret, sum); // 更新最大子段和
}
}
cout << ret << endl; // 输出最大子段和
return 0;
}
时间复杂度:
- 时间复杂度:O(n²),因为我们需要枚举所有子数组,内外两层循环。
- 空间复杂度:O(1),只需要常数空间。
优缺点:
- 优点:简单直观,适用于理解问题的基本思路。
- 缺点:效率低下,无法应对大规模数据。因为在最坏情况下,可能要处理 n(n+1)/2n(n+1)/2 个子数组。
第二步:前缀和优化(减少重复计算)
前缀和优化的基本思想是通过 前缀和 数组来避免重复计算子数组的和。前缀和是一种空间换时间的技巧,它将每个子数组的和存储在一个数组中,查询时可以快速得到结果。
步骤:
- 前缀和:先计算一个前缀和数组,其中 f[i]f[i] 表示数组 aa 从头到 ii 的和。
- 对于任意的子数组 a[i]…a[j]a[i] \dots a[j],可以通过 f[j]−f[i−1]f[j] - f[i-1] 来计算其和,这样避免了重复计算。
代码实现:
#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 10;
int a[N];
long long f[N]; // 前缀和
long long ret = INT_MIN;
int n;
int main() {
cin >> n;
// 输入数组并计算前缀和
for (int i = 1; i <= n; i++) {
cin >> a[i];
f[i] = f[i - 1] + a[i]; // 前缀和
}
// 枚举所有可能的子数组
for (int i = 1; i <= n; i++) {
for (int j = i; j <= n; j++) {
ret = max(ret, f[j] - f[i - 1]); // 通过前缀和计算子数组和
}
}
cout << ret << endl;
return 0;
}
时间复杂度:
- 时间复杂度:O(n²),虽然我们使用了前缀和,但仍然需要枚举所有子数组。
- 空间复杂度:O(n),使用了一个前缀和数组。
优缺点:
- 优点:通过前缀和优化了子数组和的计算。
- 缺点:时间复杂度仍然是 O(n²),不能有效处理大数据。
第三步:分治法(递归分解问题)
分治法通过递归的方式,将问题分解成较小的子问题,并计算左右子数组和,最后合并结果。这种方法借鉴了“分而治之”的思想。
步骤:
- 递归分解:将问题分解成左右两个子数组,分别求解最大子段和。
- 合并结果:计算跨越中点的子数组和,并与左右两边的子数组和比较,返回最大的那个。
代码实现:
#include <bits/stdc++.h>
using namespace std;
int a[100000];
int n;
int dfs(int left, int right) {
if (left == right) return a[left]; // 基本情况,只有一个元素时返回其值
int mid = (left + right) / 2;
// 分别计算左右两部分的最大子段和
int left_max = dfs(left, mid);
int right_max = dfs(mid + 1, right);
// 计算跨越中点的最大子段和
int left_sum = 0, right_sum = 0;
int lmax = INT_MIN, rmax = INT_MIN;
for (int i = mid; i >= left; i--) {
left_sum += a[i];
lmax = max(lmax, left_sum);
}
for (int i = mid + 1; i <= right; i++) {
right_sum += a[i];
rmax = max(rmax, right_sum);
}
return max({
left_max, right_max, lmax + rmax}); // 返回三者中的最大值
}
int main() {
cin >> n;
for (int i = 0; i < n; i++) cin >> a[i];
cout << dfs(0, n - 1) << endl;
return 0;
}
时间复杂度:
- 时间复杂度:O(n log n),因为每次递归将问题分成两部分,递归深度为log n,且每层遍历O(n)。
- 空间复杂度:O(log n),递归栈深度为log n。
优缺点:
- 优点:递归思想优雅,分治法可以高效地处理一些复杂问题。
- 缺点:相较于动态规划,时间复杂度较高,且递归栈消耗较大。
第四步:贪心算法(局部最优到全局最优)
贪心算法通过每一步选择最优的局部解,期望最终得到全局最优解。对于最大子段和问题,贪心算法选择每次更新当前子数组和,如果当前和为负,则从当前元素开始一个新的子数组。
步骤:
- 从左到右遍历数组,对于每个元素,选择是否将其加入当前子数组。
- 如果当前子数组和为负,则从当前元素开始一个新的子数组。
- 更新最大子数组和。
代码实现:
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
int a[n];
for (int i = 0; i < n; i++) {
cin >> a[i];
}
int ret = INT_MIN, cur = 0;
for (int i = 0; i < n; i++) {
cur = max(a[i], cur + a[i]); // 如果当前和为负,则从当前元素开始
ret = max(ret, cur); // 更新最大子段和
}
cout << ret << endl;
return 0;
}
时间复杂度:
- 时间复杂度:O(n),遍历一次数组。
- 空间复杂度:O(1),只用常数空间。
优缺点:
- 优点:非常高效,适用于大规模数据,时间复杂度 O(n),空间复杂度 O(1)。
- 缺点:不保证每次局部最优解能得到全局最优解。
第五步:动态规划(普通动态规划)
普通动态规划通过记录每个位置的最大子数组和,并利用已计算的状态来更新当前状态。
步骤:
- 定义状态
dp[i]
表示以第 ii 个元素为结尾的最大子数组和。 - 状态转移方程为
dp[i] = max(dp[i - 1] + a[i], a[i])
。 - 更新全局最大子数组和。
代码实现:
#include <bits/stdc++.h>
using namespace std;
int dp[100000]; // dp[i]表示以第i个元素结尾的最大子段和
int n;
int main() {
cin >> n;
int ret = INT_MIN;
cin >> dp[0]; // 初始化第一个元素的子数组和
ret = dp[0];
for (int i = 1; i < n; i++) {
int a;
cin >> a;
dp[i] = max(dp[i - 1] + a, a); // 当前子数组和最大值
ret = max(ret, dp[i]); // 更新全局最大子数组和
}
cout << ret << endl;
return 0;
}
时间复杂度:
- 时间复杂度:O(n),遍历一次数组。
- 空间复杂度:O(n),需要额外的
dp[]
数组。
优缺点:
- 优点:通过动态规划保存中间结果,能够高效处理大规模数据。
- 缺点:空间复杂度较高,需要额外的数组。
第六步:Kadane 算法(动态规划的优化)
Kadane 算法是动态规划的一种优化,通过减少不必要的空间使用来提高效率。
步骤:
- 维护两个变量:
cur
(当前子数组和)和ret
(全局最大子数组和)。 - 每次遍历时,更新
cur
为当前元素和cur + a[i]
的较大值。 - 如果
cur
为负数,则从当前元素重新开始计算子数组和。
代码实现:
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
int ret = INT_MIN, cur = 0;
for (int i = 0; i < n; i++) {
int a;
cin >> a;
cur = max(a, cur + a); // 如果当前和为负,则从当前元素开始
ret = max(ret, cur); // 更新最大子段和
}
cout << ret << endl;
return 0;
}
时间复杂度:
- 时间复杂度:O(n),每个元素遍历一次。
- 空间复杂度:O(1),常数空间。
优缺点:
- 优点:最优解法,时间复杂度 O(n),空间复杂度 O(1),适用于大规模数据。
- 缺点:相对较难理解,尤其对于初学者。
总结与对比
算法 | 时间复杂度 | 空间复杂度 | 优缺点 |
---|---|---|---|
暴力法 | O(n²) | O(1) | 简单直观,适用于小规模数据,但时间复杂度高。 |
前缀和优化 | O(n²) | O(n) | 通过前缀和优化暴力法,但依然时间复杂度较高,适用于小规模数据。 |
分治法 | O(n log n) | O(log n) | 递归优雅,适合复杂问题,但时间复杂度较高,空间开销大。 |
贪心算法 | O(n) | O(1) | 高效,适用于大规模数据,但不一定得到最优解。 |
普通动态规划 | O(n) | O(n) | 能处理大规模数据,但空间复杂度较高。 |
Kadane 算法 | O(n) | O(1) | 最优解法,适用于大规模数据,时间空间复杂度最低。 |
从表中可以看出,Kadane 算法是最优解法,适用于大规模数据。对于小规模数据,暴力法、前缀和优化和分治法可以作为参考,而贪心算法能高效解决问题,但并不适用于所有情况。