最大连续子序列问题全解析:从暴力到Kadane算法

发布时间:2026/10/17 21:13:49
最大连续子序列问题全解析:从暴力到Kadane算法 1. 题目拆解最大连续子序列到底在考什么1.1 原题描述与输入输出要求老牌OJ平台上编号1231的这道题圈内一般叫“最大连续子序列”或者“最大子段和”是每个算法新手都绕不过去的一道经典。题目本身很直接给你一个长度为N的整数序列可能有正数、负数和零让你在这个序列里找出一段连续的子序列使它的和最大并且把这一段的首元素和尾元素也输出来。这里有两个关键字值得圈起来一个是“连续”也就是说你不能跳着选选出来的元素在原始序列里必须是挨着的另一个是“子序列和”意味着问题关心的是数值之和而不是个数的多少。输入是多组测试数据每组第一行给一个正整数N第二行给N个整数N为0时结束。输出结果是一行三个数最大和、对应的首元素、对应的尾元素。若所有整数都是负数则最大和被定义为0同时输出整个序列的第一个数和最后一个数。从算法角度讲这题完美对应了动态规划里的一个最基础模型——最大子段和。很多学校的算法课程、ACM训练、考研复试机试里都有它的身影。如果你刚接触动态规划把这道题吃透后续再遇到“最长递增子序列”“最大子矩阵”这类问题思路迁移会顺畅很多。1.2 为什么说这是动态规划的入门必修题我第一次接触这道题的时候第一反应是暴力枚举——把所有可能的起点i和终点j都试一遍求中间这段的和。当时觉得这个思路无懈可击但写出来一提交就超时因为复杂度是O(N^3)。后来才知道这道题从暴力到最优解恰好串起了“枚举优化”和“状态定义”两条主线是理解动态规划最平滑的台阶之一。动态规划这件事很多人觉得难是因为动不动就听到“状态”“转移方程”这种词。其实放在这道题里它的核心思想就一句话不要总想着“从哪儿开始选”而是换一个角度——如果以第i个数作为子序列的结尾那么以它结尾的所有连续子序列里最大和是多少一旦这个想通了转移方程就顺手写出来了。另外一个让我觉得这题很值的原因是它和很多经典问题的内核完全一致。你在LeetCode上会看到英文名叫Maximum Subarray的题在《编程珠玑》里叫最大子向量在陈越老师的数据结构课里甚至把它讲成了算法效率对比的经典案例。同一个问题在不同地方反复出现刷透它等于把多个场景的共性规律一起拿下了。1.3 想拿满分先学会换一种枚举方式暴力枚举的痛点在于我们一直在追问“从哪个位置开始”。可实际上“开始位置”是一个很让人头疼的变量因为它会随着前面选的元素不断变化。如果你把目光固定在第i个数上只问“包含第i个数、且以第i个数结尾的子段最大和是多少”问题就瞬间变得清晰了。我习惯用一个生活化的类比去理解这个思维转变你把某段时间的理财收益记录看成一串数字正数代表赚钱负数代表亏钱。如果某天亏惨了之后你是继续“连本带利”地追回亏损还是干脆今天收手、从明天重新记账显然如果之前的累计收益已经是负的再把它带到下一次交易里只会拖后腿理性的做法是“清零重来”。这个“清零重来”的决策就是最大子段和问题的关键所在也是后面O(N)算法的灵魂。2. 三个版本的解法从O(N^3)一路砍到O(N)2.1 用三层循环暴力枚举起止点最直白的写法是先把所有可能的子段列出来外层循环固定起点i中层循环固定终点j内层循环从i累加到j求出这段的和再和当前最大值比较。这个方法思路清晰也能保证正确答案但时间复杂度是O(N^3)N稍微大一点就彻底歇菜。很多人可能会说“我优化一下第二层循环每次累加一个数就不用第三层了。”这确实能让复杂度降到O(N^2)。可问题是即便到了O(N^2)当N达到10000甚至50000的时候仍然会超时。我记得早期做这题的时候N的数据范围虽然不算极端但暴力写法稳挂评测系统可不管你思路多朴素。暴力版本真正的价值在于它能帮你验证自己对题意的理解。如果你拿不准输出格式先写个暴力程序跑几个小样例往往能帮你把细节理顺。但提交之前必须找到更高效的解法。2.2 用前缀和把复杂度降到O(N^2)暴力版本的内层累加其实做了大量重复工作。更聪明一点的做法是把原数组预处理成前缀和数组让数组第i项表示原序列前i个数之和。这样任何一段区间的和都能通过前缀和相减得到sum[j] - sum[i-1]而且这个过程是O(1)的。于是两重循环就能搞定枚举起点i和终点j用前缀和之差算出这段和再更新最大值。这个版本的复杂度是O(N^2)很多初学者会发现这是自己最容易接受也最快写出来的优化版本。它可以稳稳通过一些数据规模不大的测试点但离“优雅”还差得远。前缀和的思路本身非常值得掌握。后续会遇到很多区间求和的题目前缀和几乎是最常用的预处理手段。不过它并不能解决所有问题因为O(N^2)的枚举仍然没有摆脱“必须要尝试所有起终点组合”的思维惯性。想突破这一点就必须真正理解下一节说的贪心策略。2.3 贪心扫描负数前缀直接丢掉O(N)的核心做法是维护两个变量cur表示“以当前位置为结尾的子段所能达到的最大和”best表示“到目前为止出现的最大子段和”。每扫描到一个新数a[i]时我们做一次判断如果cur已经是个负数那么cur a[i]一定比a[i]更小说明前面那段不配被续进来直接让cur a[i]从当前位置重新开始否则把a[i]加进cur。这个判断对应的状态转移公式就是cur max(cur a[i], a[i])best max(best, cur)为什么这个简单的规则是对的因为任何负的前缀都不可能给后面的子段带来正贡献。假如有一个子段从某个位置开始是最大的那么从那个位置一直累加到后面某个点任意中间位置的前缀和一定不能小于零否则去掉这段负前缀之后剩余部分的和反而更大这就矛盾了。沿着这个逻辑扫描一遍数组的同时不断调整起始点就能在O(N)时间内找到全局最大子段。我第一次理解这个算法时觉得它像极了生活中“止损再出发”的决策模型手里一直攥着一个负收益的包袱永远追不回利润果断扔下它轻装上阵后面的正收益才能跑出真正的净值。这个比喻虽然朴素但极其有用因为很多DP题里“要不要把前面带过来”的决策本质上都是在做类似的取舍。3. 这道题真正的坎输出子序列端点3.1 记录起止下标的正确姿势如果只求最大和上一节的贪心扫描已经完美收工。但OJ上这道题多了一个要求输出对应的首元素和尾元素。这个看似简单的加码坑了不少人我第一次就在这上面WA了好几次。正确做法是额外开几个变量来追踪位置。tmpStart用来记录“当前待定起点”。每当cur被重置为a[i]时说明我们从i位置开始构建新子段此时tmpStart i。每当cur刷新了best说明以当前位置结尾的这个子段是目前最优的那么最终答案的起点ansL tmpStart终点ansR i。这里有一个非常容易写错的地方有人会把“扩展cur的时候”也去更新ansL结果起点的追踪就被之前已经丢弃的旧起点污染了。记住tmpStart只在cur重新开始时更新其他时候一动不动。而ansL、ansR只在best被刷新时更新。两者分工明确缺一不可。我自己调试时的经验是把整个过程写下来模拟一遍。比如序列 [-2, 11, -4, 13, -5, -2] 扫到第5个元素13时cur变成20best从11刷新为20此时tmpStart指向11所在的下标1ansR指向13所在的下标3所以最终输出20 11 13。这个模拟过程能让你一眼看清每个变量的职责。3.2 全负数时千万别直接输出0了事原题有一个特殊规定如果序列中所有整数都是负数则最大和为0输出整个序列的首元素和尾元素。这里的坑在于很多人实现时只想着“最好情况找不到正数就输出0”却忘了首尾元素输出的是a[0]和a[n-1]不是0或者某个负数值。一个稳妥的做法是在扫描过程中把best初始化为一个很小的负数比如-0x3f3f3f3f。如果最后best仍然小于0说明整个序列确实找不出非负的子段此时单独输出0 a[0] a[n-1]即可。之所以不直接在扫描时输出0是因为如果序列里有0但其他全是负数最大和其实是0此时按题目逻辑应该输出“0 所在的那个元素”而不是整个序列首尾。这两种情况容易被混为一谈很多题解在这个细节上都没有讲透。我踩坑之后总结了一个判断口诀先扫一遍找最大值如果最大值都小于0那就是全负否则正常走Kadane流程。这样处理起来逻辑最清晰也最不容易出错。3.3 关于等号多解时要保住最靠前的解原题往往有一个隐藏要求如果存在多个和相同的最大子序列输出最先出现的那个。这个“多个解”的场景很容易被忽略但它直接决定了代码里要不要加等号。假设当前cur等于best即我们找到了一个和当前最优解一样大的子段那么应不应该更新ansL和ansR答案是不应该。因为题目要求最靠前的解而“最靠前”意味着起点下标尽可能小。如果在相等条件下还更新就可能把答案悄悄移到后面。举个例子序列 [1, -1, 1] 的最大和是1对应的子序列既可以是[1]第1个元素也可以是[1]第3个元素。按“最靠前”的要求应该输出第1个元素。所以判断代码里必须使用cur best的严格大于条件而不是cur best。这个等号细节是很多新手花了半天才想明白的。后来我把这个小经验写进了自己的笔记里遇到“输出方案”的DP题先看看题目是否要求最靠前或最靠后再决定等号怎么写能省不少调试时间。4. 完整可提交代码与多组测试验证4.1 最终C实现与注释把上面的思路合并成一份完整的可提交代码我习惯写成下面这样。注意输入部分用scanf循环处理多组数据遇到EOF或N为0就停止。这里不推荐把整个数组读进来之后再处理边读边算也能行但为了代码阅读性我先读入数组再统一扫描。#include cstdio #include algorithm using namespace std; int a[10005]; int main() { int n; while (scanf(%d, n) ! EOF n ! 0) { for (int i 0; i n; i) { scanf(%d, a[i]); } int cur 0, best -0x3f3f3f3f; int tmpStart 0, ansL 0, ansR 0; bool hasPositive false; for (int i 0; i n; i) { if (cur 0) { cur a[i]; tmpStart i; } else { cur a[i]; } if (cur best) { best cur; ansL tmpStart; ansR i; } } if (best 0) { printf(0 %d %d\n, a[0], a[n - 1]); } else { printf(%d %d %d\n, best, a[ansL], a[ansR]); } } return 0; }这个版本里有个小设计best初始化为一个很小的负数而不是0。因为如果序列里有0但没有正数最大和仍然是0用0当初始值会导致端点记录失效。用负无穷初始化后即使只有0cur0也有机会刷新best并记录对应位置输出结果才不会错乱。4.2 手写测试用例逐条过拿到代码别急着交先在本地跑几个自己构造的样例确认思路和输出格式都没问题。我常用的测试用例有以下这些基本能覆盖各种边界情况输入序列期望输出说明1 2 3 4 515 1 5全正取整个序列最大和15-1 -2 -3 -4 -50 -1 -5全负输出0和序列首尾元素-2 11 -4 13 -5 -220 11 13经典混合序列最大段在中间0 -1 00 0 0有0且全非正应输出0对应的元素1 -1 11 1 1多解时输出最靠前的解-1 0 22 0 2最大段从0开始到2起点是0所在位置最后一个用例很容易写错。序列-1 0 2的子段0 2的和是2它是最大和对应输出应该是2 0 2。如果你的代码因为某个细节输出的起点是2本身也就是输出2 2 2那大概率是端点追踪的变量被错误更新了。每一条用例跑通之后再回头看代码逻辑能更好地理解每个变量为什么存在。4.3 其他语言实现的思路迁移如果你习惯Java或Python思路完全一致无非是输入输出的写法不同。Java可以用BufferedReader按行读再用StringTokenizer切分Python可以直接用list(map(int, input().split()))把整行读成列表。核心扫描逻辑直接平移过去就行。Python版本尤其简洁核心部分可以写成这样n int(input()) a list(map(int, input().split())) cur 0 best -10**9 tmpStart ansL ansR 0 for i, val in enumerate(a): if cur 0: cur val tmpStart i else: cur val if cur best: best cur ansL tmpStart ansR i if best 0: print(0, a[0], a[-1]) else: print(best, a[ansL], a[ansR])Python在这个数据规模下跑得毫无压力如果你正在刷题准备复试或面试完全可以用它练习算法思路。不过正式比赛前还是建议用C多练练手感毕竟编译型语言在大数据量下的稳定性更高。5. 踩坑记录与常见问题排查5.1 新手常见的几个WA原因第一次做这类题WA几乎是必经之路。我总结了一下大多数人的问题集中在下面这几个方向数组开得不够大导致读取数据时越界。这题N的上限我记得是10000但为了保险我一般直接开10005给一点余量。有些人不放心开100000其实也没问题。输入没有正确处理多组数据。OJ题特别喜欢让你读到EOF为止如果只处理了一组数据那第二组、第三组测试用例全都白给。用scanf的返回值判断EOF或者用while(cin n n ! 0)这种写法都是正确的。没有区分“全负”和“存在0”这两种情况。前面提到过全负时输出序列首尾元素但存在0时输出0所在的位置。很多人把这两种情况混成一个特判直接导致样例能过、一发提交就WA。输出格式多打了空格或者换行不对。这种问题通常报PEPresentation Error虽然不算严格意义上的WA但对于还在刷题的阶段最好也注意一下。三个数之间用一个空格隔开行末有换行不要在末尾多打印空格。5.2 从最大子段和延伸出去二维矩阵与环形数组刷完这道题之后你会发现一个神奇的规律很多看起来完全不同的题目本质上都是最大子段和的马甲。比如二维平面的最大子矩阵问题做法是把每一列的元素纵向累加把矩阵“压扁”成一个一维数组再在那一维上用最大子段和的算法扫一遍。这个技巧在很多经典的OJ题目里都是标配。另外一种是环形数组的最大子段和常见解法的思路是“要么答案在原数组内部要么答案跨越了尾部与头部”。对于后者可以转化成求整个数组的总和减去内部最小子段和。你看一个基础模型稍微变变场景就衍生出了三四种考法。把最底层的一维模型吃透后面的进阶题至少能看出个大概方向。如果要往深处走还可以试着把这道题改造成“要求输出最长的那段最优子序列”或者“加上长度不小于K的限制”。这些变体确实更费脑子不过在动手之前先把今天的最大连续子序列写得滴水不漏性价比是最高的。很多学长经验丰富的判断其实很简单一道题你能不能一次过掉所有特殊边界基本代表了你的基础扎不扎实。5.3 一点个人体会做这类题踩过几次坑之后我的体会是拿到任何一道“看似简单”的动态规划题先画状态、写转移、考虑端点追踪再下手写代码。很多WA的根源不是算法不够高级而是边界情况没有提前想清楚。尤其是这种需要输出方案、又带全负特判的题哪怕只是一行等号写错就能让你排查到怀疑人生。最后再分享一个小技巧如果本地测试总是过不去可以在循环里临时打印cur、best、tmpStart、ansL、ansR这几个变量的变化过程亲眼盯着它们扫一遍数组通常一眼就能看出哪一步更新出了岔子。调试完再把这些打印删掉提交治各种疑难杂症都很管用。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询