☰
最大连续子段和全解:暴力、分治与动态规划对比
2026/10/14 8:29:06 网站建设 项目流程

前两天有个读者给我发私信,顺手甩来一道《算法设计与分析》的课后习题,编号正好是习题12.1。我点开题面一看,是最经典的最大连续子段和。这类题几乎在所有算法课里都会出现,但真正能一口气讲清楚“我是怎么想出来的”的人并不多。不是代码难写,而是动键盘之前的几步分析——读题、建模、估复杂度——太容易被跳过了。这篇文章就把习题12.1完整拆一遍,用一条可复制的分析链路,把暴力枚举、分治、动态规划放到同一道题里对比着看。整篇不绑定具体教材版本,也不限定编程语言,正在学《算法设计与分析》的本科生,或者准备机试、面试的选手,都能从这套流程里拿到直接能用的东西。

1. 拿到题目先别急着敲代码:拆题、建模与复杂度预判

1.1 先把这三件事写在草稿纸上

很多同学拿到习题12.1的第一反应是打开编译器,这恰恰是效率最低的起手式。做算法题和做数学证明题一样,第一件事是把已知条件和目标写清楚。对于最大连续子段和这道题,我建议在草稿纸上写三行:

  • 输入:一个长度为 n 的整数数组 a。
  • 输出:所有非空连续子段中,加和的最大值。
  • 约束:题目没有明说 n 的范围时,先按最坏情况考虑,通常可能会到 10^5 甚至更大。

这三行看着简单,实际操作中价值很大。比如“连续”两个字直接否定了排序和贪心选元素的路子;“非空”意味着全负数时也要硬着头皮选一个,这个特判会在代码阶段反复纠缠你。我在批改课后作业时,见过太多同学因为没写清这三行,写完一段代码才发现自己默认数组里必须有正数,结果一测全负数据就崩了。所以,哪怕题目再熟,也花三十秒把 I/O 和约束写出来,这个习惯能省掉后面大量的返工。

还有一个容易被忽视的点:题面里的“段”和“子序列”是两回事。子段要求连续,子序列只要求保持相对顺序,这直接决定了状态定义和转移方式。习题12.1里的“连续二字”,其实是出题人留给你的最大提示,能不能抓住它,区分的就是“背模板”和“会分析”两种学习水平。

1.2 看到“最大/最小+连续子段”,先列题型

算法课复习到后期,你会发现课后习题的考点是可以从关键词反推的。这不是玄学,而是因为教材章节本身就在按算法设计策略组织:分治、动态规划、贪心、回溯、分支限界、近似算法,每章的习题都是在考察该章的核心思想。我在拿到一道题时,会在心里快速过一张题型对照表:

  • 最大/最小 + 子段/子序列:优先考虑动态规划,其次是分治。
  • 最优解 + 元素可选可不选:贪心或 DP,关键看局部最优能不能导向全局最优。
  • 要求列出所有解/组合:回溯或 DFS,重点在读题时看有没有“所有”字样。
  • 图上的最短路/连通性:Dijkstra、Bellman-Ford、Floyd 一类的图算法。
  • 排序后问题变简单的:先排序再双指针或二分。

对习题12.1来说,候选方案就是分治和动态规划。为什么不是贪心?因为局部“看起来有利”的选择并不能保证全局最大,你无法只靠当前元素决定要不要加入前面某段。想明白这一点,就把贪心排除了。排除了贪心,剩下的问题就变成:分治怎么做,DP怎么做,以及这两者背后的适用条件各是什么。

1.3 数据范围决定算法上限

复杂度分析的价值不在考试卷面上写个 O(n log n),而在于它能帮你在写代码之前就淘汰掉注定超时的方案。以数组长度 n 为锚点,我常用下面这套粗略标准:

  • n ≤ 20:指数级算法可以接受,回溯、暴力枚举随便写。
  • n ≤ 1000:O(n^2) 可行,两重循环不会太痛。
  • n ≤ 10^5:O(n log n) 或 O(n) 才安全,O(n^2) 就是 10^10 次运算,任何普通比赛环境都撑不住。
  • n ≤ 10^7:基本只有 O(n) 甚至更优的算法能碰一碰。

回到习题12.1,如果题目给的数据范围是 n=10^5,那暴力枚举起点的 O(n^2) 解直接出局。O(n log n) 的分治解法可以胜任,O(n) 的动态规划是更优的选择。这个预判过程花不了两分钟,却能让你的思考范围从“所有可能解法”缩到“两个候选解法”。很多人做题慢,不是因为代码写得慢,而是因为他们在不合适的解法上反复试错。

2. 从暴力到动态规划:三种解法的完整推导

2.1 暴力的天花板在哪里

暴力解法不是用来交的,是用来当参照物的。我建议先把最朴素的思路写出来:枚举所有可能的左端点 i,再枚举右端点 j,然后把 a[i] 到 a[j] 全部加一遍。三重循环,复杂度 O(n^3)。写完这版,你马上会发现求和那层循环可以优化掉——用一个前缀和数组 pre,让 pre[k] 表示前 k 个元素的和,那么子段 a[i..j] 的和就是 pre[j] - pre[i-1]。于是内层求和变成 O(1),整体变成两重循环 O(n^2)。代码大概是这个形状:

vector<long long> pre(n + 1, 0); for (int i = 1; i <= n; i++) pre[i] = pre[i - 1] + a[i]; long long ans = LLONG_MIN; for (int i = 1; i <= n; i++) { for (int j = i; j <= n; j++) { ans = max(ans, pre[j] - pre[i - 1]); } }

这样写有没有问题?没有,至少在小数据下完全正确。但 n 一上到 10^5,平方复杂度就是灾难。我见过有的同学提交后收到“Time Limit Exceeded”,第一反应是换编译器或者调输入输出缓冲,其实真正的问题出在算法等级不够。这也是为什么我坚持让初学者把复杂度预判放在编码之前:如果连目标复杂度都没想清楚,优化 IO 只是杯水车薪。

2.2 分治:一分为二,再看跨过中点的情况

分治解决这个问题的思路很直观:把数组从中间切成左右两半,那么最大连续子段和只有三种可能位置——完全在左半部分、完全在右半部分、跨过中间点向左向右延伸。前两种情况交给递归处理,第三种情况需要额外计算。

计算跨中点最大和时,要从中间点 mid 出发向左累加,记录累加过程中出现的最大值;再从 mid+1 出发向右累加,同样记录最大值。注意,这里比较的是“累加过程中曾经出现的最大值”,而不是“最后累加到的值”,因为跨中点的最优子段并不一定延伸到左半部分的最左边。两个最大值相加,就是跨中点的答案。递归边界是区间只有一个元素时,直接返回这个元素。

long long maxSubArray(vector<long long>& a, int l, int r) { if (l == r) return a[l]; int mid = (l + r) / 2; long long leftAns = maxSubArray(a, l, mid); long long rightAns = maxSubArray(a, mid + 1, r); long long leftSum = LLONG_MIN, cur = 0; for (int i = mid; i >= l; i--) { cur += a[i]; leftSum = max(leftSum, cur); } long long rightSum = LLONG_MIN; cur = 0; for (int i = mid + 1; i <= r; i++) { cur += a[i]; rightSum = max(rightSum, cur); } return max({leftAns, rightAns, leftSum + rightSum}); }

这段代码的递归式是 T(n) = 2T(n/2) + O(n),用主定理算出来就是 O(n log n)。很多初学者卡在这个解法的“合并”环节:跨中点的最大值算漏了,或者初始化方法不对。比如有些人把 leftSum 初始化为 0,向左累加时如果全是负数,最后结果会变成 0 而不是负数,这个坑我后面会专门展开。分治解法真正要体会的是“三种情况不重不漏”这一思想,这个思想后面学归并排序、学树分治时还会反复出现。

2.3 动态规划:把答案藏进状态定义里

如果说分治是靠“切位置”来分情况,动态规划就是靠“选不选当前元素”来递推。对于最大连续子段和,我习惯先定义状态:dp[i] 表示以 a[i] 为右端点,并且必须包含 a[i] 的连续子段的最大和。

有了这个定义,转移就很清晰了。以 a[i] 结尾的子段只有两种构成方式:第一种是只有 a[i] 自己,也就是前面什么都不接;第二种是把 a[i] 接到某个以 a[i-1] 结尾的子段后面。第二种情况要想让和最大,前面接的应该是 dp[i-1] 对应的那个最优子段。于是:

dp[i] = max(a[i], dp[i-1] + a[i])

全局答案不是 dp[n],而是 dp[0] 到 dp[n-1] 里的最大值。因为最大子段不一定非要结束在最后一个元素上,它可能提前就结束了。这个细节经常有人弄错,直接返回 dp[n],样例可能恰好过了,遇到一个“最大和出现在数组中部”的测试用例就挂了。

空间上还能继续优化。观察转移式,dp[i] 只依赖 dp[i-1],所以不需要开一整条数组,只需要一个变量来滚动保存上一个状态。这也是最终版代码看起来很短的原因:状态压缩后,时间 O(n),空间 O(1)。

2.4 三种解法横向对比:什么场景用什么

我把三种解法放在一起比较,最重要的不是记住谁快谁慢,而是理解它们各自适用的场景:

解法时间复杂度空间复杂度代码复杂度适用场景
暴力前缀和O(n^2)O(n)最低小数据、验证正确答案时用
分治O(n log n)O(log n)(递归栈)中教学演示“分而治之”思想、数据量中等
动态规划O(n)O(1)低绝大多数实际场景、面试与竞赛首选

我的个人建议很直接:刷题阶段三种都要写一遍,因为暴力帮你建立正确答案的直觉,分治帮你练递归和合并,DP帮你学状态设计。但真正比赛或面试时,优先写 DP,它既快又不容易写错。如果面试官问你“还能不能优化”,你可以从暴力 O(n^2) 讲到分治 O(n log n),再滑到 DP O(n),这条递进线本身就是一套很好的展示材料。

3. 实操全程:从手算样例到提交通过

3.1 拿一个具体数组把转移走一遍

空谈转移方程容易飘,我习惯用一个具体例子手算一遍。就拿最常见的教学样例来说,设数组:

a = [-2, 1, -3, 4, -1, 2, 1, -5, 4]

按照 dp[i] = max(a[i], dp[i-1] + a[i]) 逐个算:

ia[i]dp[i] 计算过程dp[i]当前最大答案
0-2max(-2, -∞)-2-2
11max(1, -2+1)11
2-3max(-3, 1-3)-21
34max(4, -2+4)44
4-1max(-1, 4-1)34
52max(2, 3+2)55
61max(1, 5+1)66
7-5max(-5, 6-5)16
84max(4, 1+4)56

最大答案是 6,对应子段是第 3 到第 6 个元素:[4, -1, 2, 1]。手算这一步非常关键,它让你对状态定义产生信任感。如果你发现自己手算的结果和程序输出不一致,问题大概率出在处理“负数”和“答案更新时机”上,而不是算法本身。

3.2 为什么转移方程是对的:一段简短的证明

要让“状态定义”站得住脚,需要给它一个严谨的说法。我教学生时,常用一种“以位置 i 结尾”的归纳视角:任意一个以 a[i] 结尾的连续子段,要么只包含 a[i],要么由某个以 a[i-1] 结尾的子段再加上 a[i] 组成。前者对应第一种选择,后者对应第二种选择。假设 dp[i-1] 是所有以 i-1 结尾的子段里的最大和,那么在第二种选择里,想要拼接后最大,就应该接上这个最优子段,而不是随便接一段。这一步把“接在前面哪一段”的问题转化成了一个已经计算过的最优子问题,所以状态转移正确。

再用归纳法看整体:dp[0] 对单个元素显然成立;如果 dp[i-1] 正确,dp[i] 由两个候选取最大值,两个候选都基于以 i 结尾的子段结构,因此 dp[i] 也正确。最终答案是所有 dp[i] 的最大值,覆盖了所有非空子段以任意下标结尾的情况,所以全局正确。这段证明不需要写进提交代码里,但写题解、应付面试、应付笔试推导时非常有用。

3.3 代码实现与提交细节

最终版代码很短,核心就一个更新公式。C++ 版本我习惯写成下面这样:

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; long long cur = 0, ans = LLONG_MIN; for (int i = 0; i < n; i++) { long long x; cin >> x; cur = max(x, cur + x); ans = max(ans, cur); } cout << ans << '\n'; return 0; }

有几个细节值得说。第一,cur 初始化为 0 没有关系,因为第一次循环时 max(x, 0+x) 就等于 x。第二,ans 必须初始化成 LLONG_MIN,而不是 0,否则遇到全是负数的数组,答案会被错误地输出成 0。第三,所有和相关的变量都用 long long,因为两个 10^9 量级的数相加就可能超过 2^31-1,int 根本扛不住。

Python 版本同样简单:

n = int(input()) cur = 0 ans = -10**18 for x in map(int, input().split()): cur = max(x, cur + x) ans = max(ans, cur) print(ans)

提交时还有一些环境层面的习惯要养成:多组数据时确认要不要循环处理;输入可能跨多行时,不要假设一行正好 n 个数;读入用 sys.stdin 而不是 input 其实差别不大,但当数据量巨大时,sys.stdin.buffer 读分会更快。这些都不是算法核心,却是真实 OJ 上让人掉分的隐形杀手。

3.4 提交前必跑的边界用例清单

我给学生列过一张“最大子段和专用测试单”,每次写完代码先过一遍再提交,基本能挡住九成低级错误。

场景输入期望输出原因
全正数[1,2,3,4]10应该选整个数组
全负数[-3,-1,-2]-1不能不选,必须挑最大的负数
单元素[5]5最简单的边界
正负交替[-2,1,-3,4,-1,2,1,-5,4]6标准样例
大数溢出两个接近 10^9 的正数接近 2*10^9验证 long long 是否到位
最大值出现在开头[100,-1,-2]100验证 ans 更新不是只在循环末尾

全负数这个用例最容易出错。很多同学的答案初始化为 0,遇到全负数组会返回 0,理由可能是“空子段的和是 0”。如果题目没有明确允许空子段,那这个 0 就是错的。判断标准就一句话:题目说的到底是“非空子段”还是“可以为空子段”。习题12.1 的标准版本要求非空,所以我才用 LLONG_MIN 兜底。

4. 做题路上的经典坑与排查技巧实录

4.1 分治的跨中点扩展:初始化用 0 必出错

我在前面留了一个伏笔,说跨中点最大和的初始化有坑。这里展开讲。求左侧最大值时,有些人会写:

long long leftSum = 0, cur = 0; for (int i = mid; i >= l; i--) { cur += a[i]; leftSum = max(leftSum, cur); }

当左半部分有正数时,这段代码碰巧是对的。可如果左半部分全是负数,每次累加 cur 都会变小,leftSum 始终保持初始值 0,最后跨中点答案就会偏大,甚至被错误输出成 0。正确做法是把 leftSum 初始化为一个极小值,或者更稳一点,第一次进入循环时直接取 a[mid]:

long long leftSum = a[mid], cur = 0; for (int i = mid; i >= l; i--) { cur += a[i]; leftSum = max(leftSum, cur); }

这个坑的根源和 DP 版“ans 不能初始化为 0”如出一辙:算法题里只要涉及“必须至少选一个元素”,所有初始值都要围绕“不存在合法解”来设计。

4.2 看着是 int 范围,算着算着就爆了

很多初学者提交后收到 Wrong Answer,死活看不出算法哪里错,最后发现是类型撑爆了。最大连续子段和题目的数据范围经常是两个极端:元素个数 n 能到 10^5,每个元素绝对值能到 10^9,那么任意一段的和最大约 10^14,远远超过 int 的范围。不要觉得“题目看起来是整数,所以用 int 就行”,算法题里的整数范围从来不是用眼睛看出来的,而是用数据范围乘一乘算出来的。遇到求和类问题,我现在的习惯是默认直接用 long long,如果数据范围更大还有 __int128 可以兜底。

4.3 全负数时“非空”这个隐含要求

有一种很阴险的改法,把题目原文改成“子段可以为空”,这样答案就会从“最大的负数”变成 0。做题时一定要看清题面有没有这一句。如果确定非空,那处理方式就是上面代码里那样:cur 每轮至少是 x,ans 每轮至少是 cur,所以即使全是负数,也会选出一个最不差的负数来。我在帮人 debug 时,几乎每条“最大子段和为什么全负输出 0”的问题,最后都归结为初始化问题,而不是转移方程问题。

4.4 用暴力程序当裁判:对拍的正确姿势

代码写完,样例通过,不代表所有数据都正确。以前我傻乎乎地手工造边界数据,后来学会了“对拍”这个技能,效率直接翻倍。思路很简单:写一个必然正确但很慢的暴力程序,再写一个要验证的高效程序,然后不断生成随机小规模测试数据,同时喂给两个程序,比对输出。只要输出不一致,就说明高效程序里藏着 bug。

对拍的最小配置是三个文件:暴力代码、被测代码、随机数据生成器。随机生成器在本地用 Python 写特别顺手,几行就能生成一组随机数组:

import random n = 20 print(n) print(' '.join(str(random.randint(-1000, 1000)) for _ in range(n)))

然后把生成的数据同时喂给暴力版和高效版,脚本循环跑几百次。这个习惯对任何算法题都通用,尤其是分治、DP 这种“样例过了但数据一复杂就翻车”的类型。遇到对拍不一致,再配合二分缩小数据集,很快就能定位到是初始化、边界还是转移漏了情况。

4.5 会做不等于会讲:口述演练法

我见过一些同学,代码能过,但你问他“dp 为什么这样定义,换一种定义行不行”,他就卡住了。这其实是没真正理解。学习方法上,我特别推荐“口述演练”:每做完一道题,合上代码,对着空气或者对着朋友说一遍思路——状态是什么,为什么转移,初始值怎么设,边界怎么处理。如果能流畅讲清楚,说明你真的内化了;如果讲的时候开始支支吾吾,那这些支吾点就是你下次考试会丢分的地方。这个方法对笔试论述题和面试都极其有效,因为它逼你把脑中模糊的“感觉”转成明确的语言逻辑。我后来做技术分享时的表达能力,很大程度就是靠这个笨办法练出来的。

5. 说点实在的

习题12.1本身只是一个切口,真正值钱的是你为了解它走完的那套流程:先写 I/O 约束,再预估复杂度,再选算法策略,再验证边界,最后还要能开口讲清楚每一步为什么。我在实际带人的过程中,最深的体会是:算法课成绩好的学生,往往不是代码敲得最多的,而是分析记录做得最勤的。他们会为每道课后题留一小段笔记,记下“一上来想到哪几种解法”“为什么淘汰了某一种”“最终版的边界处理有什么特别之处”,看起来琐碎,积累两三个月后效果惊人。

最后再分享一个我用到现在的小习惯:动手敲代码之前,先在心里或者草稿纸上给出算法的时间复杂度预测,并小声把转移说一遍。如果说不顺,就先别打开编辑器,回去再想。很多人觉得这是在浪费时间,实际上它恰恰是做题最快的路径。一道题从“看到题”到“分析完成”只占整体用时的一半,剩下的一半本该属于编码和调试;但跳过分析的人,往往把所有时间都赔在了“写了删、删了写”上。把这道习题12.1吃透,往后任何一道“最大 xxx 子段和”“最长连续 xxx”的题目,对你来说都只是往这条分析链条里套新参数而已。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询