5 条题解
-
0
测评测试数据
太棒了!自己动手造数据是深入理解算法边界情况的最好方法。这也是出题人(Setter)和验题人(Tester)的必备技能。
为了能精准区分不同的算法时间复杂度,并考察各种常见的“坑点”(如全负数、全部为0等),我为你设计了以下 10 个测试点的生成策略:
- 测试点 1-4:。专门用来给 算法放行,送出 40% 的部分分。包含了单元素、纯随机、全负数等。
- 测试点 5-10:。用来卡掉 ,只有 能过。
- 包含“全正数”、“全负数”(专门卡把
ans初始化为 0 的萌新)、“大量正负交替”、“极大极小值突变”等边界情况。 - 生成文件时每行数字用空格隔开,最大文件大约在 1.2MB 左右,完全符合你 < 2MB 的理想要求。
- 包含“全正数”、“全负数”(专门卡把
以下是完整且可独立运行的数据生成器(C++14 标准):
#include <iostream> #include <fstream> #include <vector> #include <random> #include <string> using namespace std; // 使用基于 Mersenne Twister 的高质量随机数生成器 mt19937 rng(1337); // 固定种子保证每次生成的数据一致,方便复现调试 // 获取指定范围内的随机整数 [L, R] int get_rand(int L, int R) { uniform_int_distribution<int> dist(L, R); return dist(rng); } // 标准的满分算法:用于计算输出文件的标准答案 O(n) long long solve_standard(const vector<int>& a) { long long ans = -1e18; long long current_sum = -1e18; for (int x : a) { if (current_sum > 0) { current_sum += x; } else { current_sum = x; } if (current_sum > ans) { ans = current_sum; } } return ans; } // 生成单个测试点的核心函数 void generate_testcase(int id, int n, int type) { vector<int> a(n); int MAX_VAL = 10000; for (int i = 0; i < n; ++i) { if (type == 0) { // 类型0:完全随机[-10000, 10000] a[i] = get_rand(-MAX_VAL, MAX_VAL); } else if (type == 1) { // 类型1:全负数 [-10000, -1] (卡 ans = 0 的错解) a[i] = get_rand(-MAX_VAL, -1); } else if (type == 2) { // 类型2:全正数 [1, 10000] a[i] = get_rand(1, MAX_VAL); } else if (type == 3) { // 类型3:大量负数,极少量的正数穿插其中 (极易引诱错误贪心) int chance = get_rand(1, 100); if (chance <= 5) a[i] = get_rand(1, MAX_VAL); else a[i] = get_rand(-MAX_VAL, -1); } else if (type == 4) { // 类型4:正负交替波动 if (i % 2 == 0) a[i] = get_rand(1, MAX_VAL); else a[i] = get_rand(-MAX_VAL, -1); } else if (type == 5) { // 类型5:含有大量的 0 int chance = get_rand(1, 100); if (chance <= 70) a[i] = 0; else a[i] = get_rand(-MAX_VAL, MAX_VAL); } } // 写入输入文件 x.in string in_filename = to_string(id) + ".in"; ofstream fin(in_filename); fin << n << "\n"; for (int i = 0; i < n; ++i) { fin << a[i]; if (i != n - 1) fin << " "; } fin << "\n"; fin.close(); // 计算标准答案并写入输出文件 x.out long long ans = solve_standard(a); string out_filename = to_string(id) + ".out"; ofstream fout(out_filename); fout << ans << "\n"; fout.close(); cout << "Testcase " << id << " generated: " << in_filename << " and " << out_filename << " | N = " << n << " | Ans = " << ans << "\n"; } int main() { // 优化终端输出速度 ios::sync_with_stdio(false); cin.tie(nullptr); cout << "Start generating test data..." << "\n"; // 测试点 1-4:满足 N <= 2000 (给 O(n^2) 放行 40分) generate_testcase(1, 1, 0); // 边界测试:N=1 generate_testcase(2, 50, 0); // 小数据随机 generate_testcase(3, 2000, 1); // 2000规模全负数 generate_testcase(4, 2000, 3); // 2000规模少量正数 // 测试点 5-10:满足 10^5 <= N <= 2*10^5 (必须 O(n) 满分) generate_testcase(5, 100000, 2); // 全正数 generate_testcase(6, 150000, 5); // 大量0存在 generate_testcase(7, 200000, 4); // 正负极限交替 generate_testcase(8, 200000, 1); // 满规模全负数陷阱 generate_testcase(9, 200000, 3); // 满规模绝望沙海中找绿洲(大量负数带点正数) generate_testcase(10, 200000, 0); // 满规模纯随机数据 cout << "All test data generated successfully!\n"; return 0; }💡 教练的数据生成思路解析与提醒:
- 防止越界与 Exception 保护:在计算答案
ans时,最大的可能结果是全正数情况,。虽然这刚好还在 32 位有符号整数范围(大约 21.4 亿)内,但为了绝对的安全,生成器内置的solve_standard依然采用了long long承载计算过程,有效避免了越界造成的意外。 - 随机数生成的坑:老派的
rand()在 Windows 下最大值通常只有 32767,且低位随机性极差。这里我采用了 NOIP 新时代常用的基于梅森旋转算法的std::mt19937引擎。此外,设定了固定种子 (1337),这样做的好处是无论你执行多少次代码,生成的x.in数据一定是相同的,非常方便后续如果发现数据出了问题,进行回溯调试。 - 性能极致释放:
- 此生成器生成一个 的文件并且跑出答案,大约只需要
10ms左右,极快! - 没有使用任何递归逻辑,天然避开了递归层数爆炸导致的
Stack Overflow风险。 - 输入输出使用了
ofstream而非freopen来避免对全局流的影响,使我们在跑批量的for循环造数据时能一口气无缝完成。
- 此生成器生成一个 的文件并且跑出答案,大约只需要
-
0
动态规划解法
你好!我是你的OI教练。这道题是动态规划(DP)乃至贪心思想中最经典的NOI入门题之一,接下来我们用启发式的思维来一步步拆解它。
🧠 启发式思考过程
第一步:最直接的想法是什么? 如果我们要找一个最大子段和,最“暴力”的方法是枚举这段序列的“起点”和“终点”,然后求和。 教练提问: 这样做时间复杂度是多少呢? 思考: 枚举起点和终点需要 ,再求和可能要 (哪怕用前缀和优化也只能降到 )。 教练追问: 看看数据规模 ,如果用 的算法,大概要执行 次操作,肯定会超时(TLE)。我们必须找一个 的解法!
第二步:换个角度看问题(定义状态) 与其同时盯着一段区间的“起点”和“终点”,不如我们只盯着“终点”看! 教练提问: 假设现在我们处理到了第 个数字 ,并且我们只关心以 为结尾的连续子段。你能想到它和前一个状态的关系吗?
第三步:分情况讨论(状态转移方程) 假设我们已经算出了“以第 个数字结尾的最大子段和”,我们叫它 。现在要把当前的数 接上去。 教练引导: 面前只有两条路:
- 和前面的队伍接轨: 前提是前面的队伍是个“神队友”。也就是说,只要 是个正数(大于 0),加上它肯定能让现在的和变得更大。
- 自己另起炉灶: 如果前面的队伍是个“猪队友”( 小于等于 0),带上他们只会拖累现在的和!那我们还不如丢下前面的,从 自己重新开始算起。 核心结论: 以第 个数结尾的最大和 。
第四步:全局最优解在哪里? 教练提问: 我们算出了所有“以各个位置结尾”的局部最大和,那整个序列的最终答案是谁? 思考: 最大子段可以在序列的任何位置结束,所以我们需要在整个遍历的过程中,用一个全局变量
ans把历史上出现过的最大的 给记录下来。第五步:空间能不能再省一点? 教练点拨: 我们看上面的转移方程,计算 的时候,仅仅用到了 ,之前的 到 完全用不上了。 因此,在 C++ 编码时,我们连数组都不用开,只用一个变量
dp记录前一步的值就行了,这样空间复杂度就从 优化到了极致的 !
💻 算法伪代码流程图 (符合 C++14 NOI标准逻辑)
我们使用流程图来表示上述动态规划的核心代码逻辑。 (注:为了避免 Mermaid 引擎解析冲突报错,图表节点中以中文描述替代了大于、小于、冒号等特殊编程符号)
graph TD Start((开始程序)) --> InputN(读入序列长度 n) InputN --> Init(初始化 全局最大值 ans 为负无穷极小值, 局部和 dp 为 0, 计数器 i 为 1) Init --> LoopCondition{i 小于等于 n 吗} LoopCondition -- 是 --> ReadA(读入当前数字 a) ReadA --> CheckDP{前置局部和 dp 大于 0 吗} CheckDP -- 是 --> AddDP(将 a 加到 dp 上, 延续队伍) CheckDP -- 否 --> ResetDP(将 dp 赋值为 a, 另起炉灶) AddDP --> CheckAns{当前的 dp 大于 ans 吗} ResetDP --> CheckAns CheckAns -- 是 --> UpdateAns(更新 ans 为当前的 dp) CheckAns -- 否 --> NextI(计数器 i 加上 1) UpdateAns --> NextI NextI --> LoopCondition LoopCondition -- 否 --> OutputAns(输出最终的全局最大值 ans) OutputAns --> End((结束程序))🎯 教练总结
在这道题中,我们通过固定子段终点将复杂的区间问题简化成了线性问题,然后用贪心/动态规划的思想果断决定是“延续前一段”还是“重新开始”。只需跑一遍 的循环即可完美 AC(Accept)本题。根据这个流程图,你可以尝试动手用 C++ 把真正的代码实现出来了!
你好!教练很高兴看到你有探究到底的精神。
在OI(信息学奥赛)中,我们解决问题通常遵循**“先想暴力,再想优化”**的原则。下面我将带你从最简单粗暴的方法开始,一步步打怪升级,最终写出能拿到满分且时间和空间都达到最优的代码。
所有的代码均严格遵循 NOIP C++14 竞赛标准,均可独立运行。
版本一:最朴素的暴力枚举 () —— 新手村级别
思考过程: 既然是求“连续一段”的和,最直白的想法就是:我把所有的“起点”和所有的“终点”都试一遍。确定了起点 和终点 后,我再用一个循环把 到 的数字加起来,最后取一个最大值。
#include <iostream> #include <vector> #include <algorithm> using namespace std; const long long INF = 1e18; // 使用极小值作为初始答案,防止序列全为负数时出错 int main() { // 优化输入输出流,NOIP竞赛常规操作 ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; vector<int> a(n + 1); for (int i = 1; i <= n; ++i) { cin >> a[i]; } long long ans = -INF; // 关键点:一定要初始化为极小值!不能是0! // 枚举起点 i for (int i = 1; i <= n; ++i) { // 枚举终点 j for (int j = i; j <= n; ++j) { long long current_sum = 0; // 计算从 i 到 j 的和 for (int k = i; k <= j; ++k) { current_sum += a[k]; } // 更新全局最大值 ans = max(ans, current_sum); } } cout << ans << "\n"; return 0; }- 时间复杂度:。三层嵌套循环。当 时,计算量约 ,必然超时(只能拿到 0-20 分)。
- 空间复杂度:。需要存储原序列。
- 优化建议:最内层的循环(从 加到 )做了很多重复工作。我们算 到 的和时,其实就是在 到 的和的基础上加了一个 。为什么要重新跑一遍循环呢?这就引出了版本二。
版本二:前缀和优化 () —— 进阶级别
思考过程: 为了快速求出任意区间 的和,我们可以使用前缀和 (Prefix Sum) 技巧。预处理出前 个数的和
sum[i],那么区间 的和就可以在 的时间内通过sum[j] - sum[i-1]瞬间算出来。#include <iostream> #include <vector> #include <algorithm> using namespace std; const long long INF = 1e18; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; vector<long long> sum(n + 1, 0); // 前缀和数组 for (int i = 1; i <= n; ++i) { int x; cin >> x; sum[i] = sum[i - 1] + x; // 关键点:一边读入,一边计算前缀和 } long long ans = -INF; // 枚举起点 i for (int i = 1; i <= n; ++i) { // 枚举终点 j for (int j = i; j <= n; ++j) { // 利用前缀和 O(1) 求区间和 long long current_sum = sum[j] - sum[i - 1]; ans = max(ans, current_sum); } } cout << ans << "\n"; return 0; }- 时间复杂度:。去掉了最内层的循环。当 时(40%的数据),计算量约 ,可以在时限内通过,拿到 40 分。
- 空间复杂度:。存储前缀和数组。
- 优化建议:对于 的全量数据, 的操作次数达到 ,依然会 TLE(超时)。我们要思考:能不能不枚举起点?我们能不能在扫描一遍数组 的过程中就直接得出答案?这就引出了版本三。
版本三:一维动态规划 () —— 满分级别
思考过程: 正如我们在启发式推导中所讲,我们定义状态
dp[i]为:以第i个数结尾的最大子段和。 转移方程:dp[i] = max(dp[i-1] + a[i], a[i])。 无论如何,最大子段一定是以序列中某一个数结尾的,所以我们在所有的dp[i]中挑一个最大值,就是我们要的最终答案。#include <iostream> #include <vector> #include <algorithm> using namespace std; const long long INF = 1e18; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; vector<int> a(n + 1); for (int i = 1; i <= n; ++i) { cin >> a[i]; } // dp[i] 表示以第 i 个数结尾的最大子段和 vector<long long> dp(n + 1, 0); long long ans = -INF; for (int i = 1; i <= n; ++i) { // 易错点提示:如果前一个子段和 dp[i-1] 是正数,加上它肯定更优;如果是负数,不如自己单飞。 dp[i] = max((long long)a[i], dp[i - 1] + a[i]); // 全局最大值可能在任意位置取得,每次都要更新 ans = max(ans, dp[i]); } cout << ans << "\n"; return 0; }- 时间复杂度:。只遍历了数组一次,可以完美通过 的数据,斩获 100 分。
- 空间复杂度:。开辟了原数组
a和状态数组dp。 - 优化建议:在真正的赛场上,能写出这个版本已经足够满分了。但作为一个追求极致的 Coder,我们可以发现
dp[i]每次更新只依赖dp[i-1],不需要保存前面所有的状态。同时,输入数据a[i]也可以边读边处理,不需要全部存下来。能否把空间压榨到极限?
版本四:滚动变量优化动态规划 ( 时间, 空间) —— 极致最优解
思考过程: 这其实也是很多人所说的“贪心法”。我们只用一个变量
current_sum来代替整个dp数组。读入一个数就处理一个数,处理完就丢弃。#include <iostream> #include <algorithm> using namespace std; const long long INF = 1e18; int main() { // 必加:关闭流同步,在处理大规模数据读入时防止超时 ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; long long ans = -INF; // 记录全局最大值 long long current_sum = -INF; // 记录以“上一个数”结尾的最大子段和(等效于上一版的 dp[i-1]) for (int i = 1; i <= n; ++i) { long long a; cin >> a; // 边读边处理,完全抛弃了数组 // 核心转移逻辑: // 如果 current_sum < 0,说明前面的和是累赘,不如从现在的 a 开始重新累加 // 此时 current_sum + a 必定小于 a,max 自动会选择 a if (current_sum > 0) { current_sum += a; } else { current_sum = a; // 前面的是负资产,抛弃! } // 更新历史上出现过的最大值 if (current_sum > ans) { ans = current_sum; } } cout << ans << "\n"; return 0; }- 时间复杂度:。最完美的线性扫描。
- 空间复杂度:。只开了常数级别(几个)变量,没有使用任何数组。这是本题理论上的极致最优解!
教练的贴心叮嘱(避坑指南):
- 初始化陷阱:最大值
ans千万千万不要初始化为0!如果序列全是负数(比如-4 -2 -9),最大子段和应该是-2。如果初始为0,答案就会输出0导致错误。一定要初始化为极小的负数! - 数据类型:本题单个数字 ,,极限情况下的和是 ,正好卡在 C++
int(最大 ) 的上限边缘。养成好习惯,涉及到求和的时候,果断开long long防爆int!
-
0
解释kadane算法
当然,很乐意为你详细解释Kadane 算法。这是一种非常巧妙、高效的算法,专门用来解决最大子段和 (Maximum Subarray Sum) 问题。
我们可以从三个层面来理解它:直观的比喻、算法的步骤 和 正确性的证明。
1. 直观的比喻:一场“接力赛”
想象一下,数组中的每个数字代表赛道上一段路程的收益(正数)或成本(负数)。你要组织一支接力队,从起点跑到终点,目标是找到一段连续的路程,使得跑完这段路的总收益最大。
-
current_max(当前选手的成绩):- 一个选手从某个起点开始跑。他手里的“成绩”就是他跑过的路段的收益总和。
- 每跑过一段
a[i],他的成绩就更新为成绩 + a[i]。
-
global_max(历史最佳成绩):- 这是记录板,记下所有选手跑出的历史最佳成绩。
- 每当一个选手跑完一段路,我们都把他的当前成绩和记录板上的最佳成绩比较一下,更新记录板。
-
核心决策:何时“交接棒”?
- 一个选手跑着跑着,如果发现自己的累计成绩变成了负数,这意味着他跑的这段路是“亏本”的。
- 关键点:如果下一个选手从他这里接棒,就相当于背负了他的负资产!这显然不划算。
- 所以,明智的策略是:一旦某个选手的累计成绩变为负数,就让他立刻放弃,下一个选手从全新的起点(当前位置的下一段)开始跑,成绩从 0 重新计算。
Kadane 算法的本质,就是模拟这场接力赛。
current_max就是当前选手的成绩,global_max就是记录板。当current_max变为负数时,就相当于交接棒,current_max被重置。
2. 算法的步骤
Kadane 算法非常简洁,只需要两个变量和一次遍历。
输入:一个整数数组
A,长度为n。 输出:A的最大子段和。变量:
global_max(全局最大和):用来存储最终结果。初始化为数组的第一个元素A[0]或一个极小的数(比如负无穷)。current_max(当前子段和):用来累加当前正在考虑的连续子段的和。初始化为0或A[0]。
流程:
// 初始化 global_max = A[0] current_max = A[0] // 从第二个元素开始遍历 for i from 1 to n-1: // 决策:是继续累加,还是从当前元素 a[i] 重新开始? // 这等价于 DP 方程:dp[i] = max(a[i], dp[i-1] + a[i]) current_max = max(A[i], current_max + A[i]) // 更新全局最大值 global_max = max(global_max, current_max) // 返回结果 return global_max另一种等价的贪心写法(更符合“接力赛”比喻):
// 初始化 global_max = -infinity current_max = 0 // 遍历所有元素 for x in A: // 累加当前元素 current_max = current_max + x // 更新全局最大值 if current_max > global_max: global_max = current_max // 决策:如果当前累加和变成了负数,就放弃它 if current_max < 0: current_max = 0 // 特殊情况处理:如果数组全是负数 // 上述循环会得到 0,但正确答案是最大的那个负数。 // 所以需要在循环结束后或初始化时处理。 // 一个简单的处理方法是 global_max 初始化为 A[0],然后从 A[1] 开始循环。 // 最健壮的写法是上面第一种,因为它天然处理了全负数的情况。以数组
[-2, 1, -3, 4, -1, 2, 1, -5, 4]为例,走一遍流程 (第一种写法):iA[i]current_max + A[i]current_max = max(A[i], ...)global_max = max(global_max, ...)0 -2 (init) -2 (init) 1 -2 + 1 = -1 1 (因为 1 > -1) max(-2, 1) = 1 2 -3 1 + (-3) = -2 -2 (因为 -2 > -3) max(1, -2) = 1 3 4 -2 + 4 = 2 4 (因为 4 > 2) max(1, 4) = 4 4 -1 4 + (-1) = 3 3 (因为 3 > -1) max(4, 3) = 4 5 2 3 + 2 = 5 5 (因为 5 > 2) max(4, 5) = 5 6 1 5 + 1 = 6 6 (因为 6 > 1) max(5, 6) = 6 7 -5 6 + (-5) = 1 1 (因为 1 > -5) max(6, 1) = 6 8 4 1 + 4 = 5 5 (因为 5 > 4) max(6, 5) = 6 最终
global_max是 6。
3. 正确性的证明(为什么这个贪心是对的?)
我们可以用反证法来理解。
假设 Kadane 算法找到的最大子段和是
S,它对应的子数组是A[i...j]。但假设存在一个真正的最大子段和S',它对应的子数组是A[p...q],并且S' > S。-
q和j的关系:- Kadane 算法在遍历到
q时,会计算出以q结尾的最大子段和,记为current_max_q。 global_max变量会记录下遍历到q时所见过的所有current_max的最大值。- 真正的以
q结尾的最大子段和就是S'本身 (A[p...q])。 - Kadane 算法的
current_max_q一定等于S'。为什么?因为current_max_q是通过max(A[q], current_max_{q-1} + A[q])递推得到的。如果A[p...q-1]的和是正的,那么current_max_{q-1}就会包含这部分和,current_max_q也就等于A[p...q]的和。如果A[p...q-1]的和是负的,那么真正的最大子段和就不可能是从p开始,而应该是从q之后的某个位置开始,与假设矛盾。 - 既然
current_max_q = S',那么global_max在q这一步之后,至少会是S'。这与我们假设的S' > S(S是最终的global_max) 矛盾。
- Kadane 算法在遍历到
-
核心思想:
- 算法的关键在于维护了这样一个循环不变式 (Loop Invariant):在遍历到第
i个元素时,current_max变量中存储的是以A[i]结尾的最大子段和。 - 全局最大子段和,必然是所有“以
A[i]结尾的最大子段和”中的那个最大值。 - 因此,我们只需要在每次计算完
current_max后,用它来更新global_max,就能保证在遍历结束后,global_max就是我们要求的最终答案。
- 算法的关键在于维护了这样一个循环不变式 (Loop Invariant):在遍历到第
总结:Kadane 算法通过一次遍历,巧妙地利用了“如果一个子段的和为负,它对后面的任何子段都只会产生负贡献”这一贪心性质,从而实现了 时间复杂度和 空间复杂度的求解。
-
-
0
你好!很高兴你对这个问题进行了深入的思考。用双指针的思想来理解【最大子段和】的 解法,是一个非常深刻且能直击算法本质的视角。
我们平常学习的最大子段和解法,通常是两种:
- DP 解法:
dp[i] = max(a[i], dp[i-1] + a[i]) - 贪心解法:维护一个
current_sum,如果current_sum变成负数,就把它重置为 0。
这两种解法都非常简洁高效,但有时会让人觉得有点“神奇”,不够直观。现在,我们用“动态调整起点的双指针”这个模型来重新剖析它,你会发现,它完美地解释了上述两种解法的内在逻辑。
1. 暴力双指针: 的起点
首先,我们来思考这个问题的最暴力、最直观的解法。 一个连续子段由起点
l和终点r决定。我们可以枚举所有的起点l和终点r。// O(N^3) 暴力 long long max_sum = -INF; for (int l = 0; l < n; l++) { for (int r = l; r < n; r++) { long long current_sum = 0; for (int k = l; k <= r; k++) { current_sum += a[k]; } max_sum = max(max_sum, current_sum); } }用前缀和可以优化到 ,但这仍然不够快。问题出在:我们固定了
l,然后移动r,这种方式无法利用之前计算的信息来优化l的选择。
2. 解法的双指针视角
现在,我们换一个思路。我们只用一个指针
r从左到右扫描,作为子段的终点。对于每一个固定的终点r,我们都希望能快速找到一个最佳的起点l( ),使得sum(l, r)最大。核心问题:当
r向右移动到r+1时,最佳起点l会如何变化?让我们在草稿纸上推演。 数组
a:[-2, 1, -3, 4, -1, 2, 1, -5, 4]-
r = 0(a = -2)- 终点是 0。最佳起点
l只能是 0。 - 最大和是
sum(0, 0) = -2。
- 终点是 0。最佳起点
-
r = 1(a = 1)- 终点是 1。起点
l可以是 0 或 1。 sum(0, 1) = -2 + 1 = -1sum(1, 1) = 1- 最佳起点是
l=1,最大和是1。
- 终点是 1。起点
-
r = 2(a = -3)- 终点是 2。起点
l可以是 0, 1, 2。 sum(0, 2) = -2 + 1 - 3 = -4sum(1, 2) = 1 - 3 = -2sum(2, 2) = -3- 最佳起点是
l=1,最大和是-2。
- 终点是 2。起点
等等!我们好像发现了什么规律!
当我们计算以
r为终点的最大和时,sum(l, r) = sum(l, r-1) + a[r]。 为了让sum(l, r)最大,我们其实就是在找一个l,让sum(l, r-1)最大。 这不就是上一步r-1的结果吗?并不完全是。因为
sum(l, r-1)是以r-1结尾的最大和,而我们需要的l是一个全局最佳起点。真正的突破口在这里:
让我们定义
prefix_sum[i]为a[0...i]的和。 那么sum(l, r) = prefix_sum[r] - prefix_sum[l-1]。对于固定的终点
r,要最大化sum(l, r),就等价于最小化prefix_sum[l-1],其中l-1 < r。所以,当指针
r从左到右扫描时,我们只需要在r的左边,实时维护一个到目前为止的最小前缀和min_prefix_sum。算法流程:
- 初始化
max_so_far = -INF(全局最大和)。 - 初始化
min_prefix_sum = 0(代表一个空前缀,即l=0时prefix_sum[-1]的情况)。 - 初始化
current_prefix_sum = 0。 - 用指针
r从0遍历到n-1: a.current_prefix_sum += a[r]。 b. 以r为终点的最大子段和就是current_prefix_sum - min_prefix_sum。 c. 用这个值更新max_so_far。 d. 用current_prefix_sum更新min_prefix_sum。
这个算法就是 Kadane 算法的“前缀和”版本!
3. 连接到经典的贪心/DP解法
现在,我们把上面的“前缀和”版本稍微变形一下。
令
current_max_ending_at_r表示以r结尾的最大子段和。current_max_ending_at_r = current_prefix_sum - min_prefix_sum_before_r那么,
current_max_ending_at_r+1是什么呢?current_max_ending_at_r+1 = current_prefix_sum_at_r+1 - min_prefix_sum_before_r+1= (current_prefix_sum_at_r + a[r+1]) - min(min_prefix_sum_before_r, current_prefix_sum_at_r)这个公式太复杂了。我们回到更直观的定义。
令
current_sum表示以当前r结尾的最大子段和。当r移动到r+1时:- 新的“以
r+1结尾的最大子段和”有两种可能:- 自立门户:就只包含
a[r+1]自己。 - 加入组织:在“以
r结尾的最大子段和”后面,再接上a[r+1]。
- 自立门户:就只包含
- 我们取这两者中较大的那个:
new_current_sum = max(a[r+1], current_sum + a[r+1])。
这,就是DP方程
dp[i] = max(a[i], dp[i-1] + a[i])的完美解释!那贪心解法呢?
current_sum = max(a[i], current_sum + a[i])如果current_sum + a[i]比a[i]自己还小,说明什么?说明current_sum一定是负数! 所以,这个max判断,等价于:current_sum += a[i]if (current_sum < a[i]) { current_sum = a[i] }这又等价于我们贪心解法里的:current_sum += a[i]- 如果之前的和是负的,
current_sum就被重置了。
回到双指针的视角:
- 右指针
r:就是我们循环的i,它一直在前进,探索新的终点。 - 左指针
l:它在哪里?它隐藏在current_sum这个变量里!- 当
current_sum保持为正并累加时,意味着最佳起点l保持不变。 - 当
current_sum因为累加了一个负数而即将变为负数时,我们发现current_sum + a[i]<a[i],于是我们执行current_sum = a[i]。 - 这个操作,在双指针的视角下,就相当于我们放弃了旧的起点
l,把新的最佳起点l移动到了i!
- 当
总结
- 右指针
r:固定,从左到右扫描,作为区间的终点。 - 左指针
l:动态调整。它指向当前最优子段的起点。- 只要以
r结尾的累加和current_sum还是正的,就说明从l到r这个子段是有“正贡献”的,值得保留。所以l不动。 - 一旦
current_sum变成了负数,就说明从l到r这段已经成了“负资产”,对后面的累加只会起到拖累作用。我们必须果断地**“断臂求生”**,把这段负资产扔掉,将新的起点l移到r+1的位置,从一个新的、干净的状态开始。
- 只要以
这个“当累加和为负时就重置起点”的策略,就是最大子段和 解法的精髓,也是 Kadane 算法的核心思想。它完美地体现了双指针技巧中“根据单调性(这里是和的正负)来移动指针以维护最优解”的理念。
- DP 解法:
- 1
信息
- ID
- 4894
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 3
- 标签
- 递交数
- 4
- 已通过
- 1
- 上传者