做了这么多年技术,面试过不少候选人,也带过不少新人,有一个算法题几乎每次聊到动态规划都会绕不开——最大连续子序列和。LeetCode上叫Maximum Subarray,剑指Offer里是第42题,各平台的高频题榜单里常年有它。这道题有意思的地方在于:代码写起来可能连十行都不到,但能把这个题讲明白的人,往往对动态规划的本质理解得很透彻。我见过不少候选人在黑板上哼哧哼哧写出了标准解法,一问“dp数组为什么这么定义”,立马卡壳。别小看这一问,它能牵出整个动态规划思维模式的底层逻辑。
接下来的内容,我会从拿到题目怎么分析开始,一路讲到状态定义、转移方程、代码实现、空间优化、变种题型以及实际场景里的应用。全程跟着我的思路走一遍之后,你会发现在这道题的背后,其实藏着一套解决“连续型序列问题”的通用方法论。
1. 把题目读懂,比急着写代码更重要
1.1 题目到底在说什么
题目描述很简短:给定一个整数数组 nums,找到一个具有最大和的连续子序列(子数组),返回其最大和。
这里的几个关键词值得先掰扯清楚。
第一个是“连续”。连续意味着你不能跳着选元素。比如数组 [1, -2, 3, 4],你可以选 [3, 4],但不能选 [1, 3, 4],因为 1 和 3 之间隔了一个 -2,选出来就不是原数组的连续片段了。
第二个是“子序列”。在很多算法教材里,“子序列”不要求连续,“子数组”或“子串”才要求连续。但在这道题的语境下,我们讨论的是连续的版本,也就是 substring / subarray 的概念。所以如果你在面试时听到“最大连续子序列”,可以直接看成“最大子数组和”,不要被名词绕晕。
第三个是“和最大”。这个“最大”是全局最大,不一定以数组末尾结尾,这也是后面代码里为什么需要维护一个全局答案而不是直接返回 dp 最后一个值的原因。
1.2 暴力解法为什么撑不住
没有系统学过动态规划的人,第一反应往往是暴力。枚举所有可能的起点 i 和终点 j,计算从 i 到 j 的和,然后取最大。三层循环可以直接做到 O(n^3),稍微优化一下,枚举起点后用累加的方式滚动求和,能压到 O(n^2),但还是不够。
我经常用一个生活例子来解释这个复杂度差距:假设你有 10 万条交易数据,想找出最大盈利区间,O(n^2)意味着最多要跑 100 亿次运算,在常规机器上得几十秒到几分钟。而用动态规划的 O(n) 解法,一次遍历就能出结果,连 1 毫秒都用不到。
暴力解法的真正问题在于:它把大量区间重复求和的中间结果浪费了。比如算区间 [2, 5] 的和,其实可以在算区间 [2, 4] 的基础上加一个 nums[5],但暴力枚举的思路不会刻意去复用这些结果。这恰恰是动态规划的切入点——把子问题的结果存下来,供后续决策使用。
1.3 拿到题后的两个灵魂拷问
我自己的做题习惯是,看到这种“最优化”问题,先问自己两个问题。
第一个问题:子问题能不能拆?对于最大连续子序列来说,一个长度为 n 的问题,能不能借助长度为 n-1 的问题推导出来?如果 A 是某个以第 i 个元素结尾的最优解,那么它能怎么帮助计算以第 i+1 个元素结尾的最优解?这个问题的答案,直接指向状态转移方程。
第二个问题:无后效性是否满足?所谓无后效性,就是过去的状态不会影响未来的决策,未来只关心当前状态的值,而不关心这个值是怎么来的。对于这道题,如果我知道了以第 i 个元素结尾的最大和是 5,那我去算第 i+1 个元素结尾的最大和时,只需要这个 5,至于这 5 是从哪个起点开始累加的,完全不重要。这就是无后效性的体现。
这两个问题想通了,状态定义就是水到渠成的事。
2. DP状态设计:最大的坑其实在定义上
2.1 一个经典错误:把dp[i]定义成“前i个元素的最大子序列和”
很多初学者会试着把 dp[i] 定义为“数组前 i 个元素里,最大连续子序列的和”。这个定义听起来很自然,但走到转移方程时就会发现完全走不动。因为最大子序列可能以任意位置结尾,你知道了前 i-1 个元素的最大和,怎么推出前 i 个元素的最大和?没法推。要么新子序列包含第 i 个元素,要么不包含,但如果不包含第 i 个元素,根本不知道之前的最大子序列结尾在哪里、值是多少,也就无法判断加上第 i 个元素后是赚了还是亏了。
我自己的经验是:对于连续区间类的问题,只要看到“连续”“子数组”“子串”这些限定词,状态定义十有八九要落在“以某个位置结尾”上。这几乎成了肌肉记忆。原因也简单:只有强制规定了子序列的结尾位置,才能在尾部延伸出一个新的元素,形成递推关系。
2.2 正确的状态定义与转移方程推导
正确的状态定义是:
dp[i] 表示以 nums[i] 这个元素作为结尾的连续子序列,其最大和是多少。
注意这里的关键点:必须包含 nums[i],而且必须以 nums[i] 结尾。这样一来,dp[i] 的候选值就只有两个来源。
第一种:把 nums[i] 接在 dp[i-1] 对应的那个子序列后面。如果 dp[i-1] 是正的,说明前面那趟累加在帮我们赚更多,接上去肯定比单独拿 nums[i] 更大。第二种:直接抛弃前面的所有累加结果,从 nums[i] 重新开始。如果 dp[i-1] 是负的,说明再接上去只会拖累整体和,这时候断臂求生才是最优选择。
所以转移方程就出来了:
dp[i] = max(dp[i-1] + nums[i], nums[i])这也是 Kadane 算法的核心公式。
很多人会觉得“负的就丢弃”这个规则太简单,但它其实精妙得很。它隐式地处理了子序列的起点问题:每当 dp[i-1] 为负时,子序列的起点就从 i 重新开始。从这个角度看,遍历过程中我们其实是在不断尝试“换起点”和“保持起点”之间的最优权衡,而不是真的去记录起点在哪。
2.3 最终答案为什么不是dp[n-1]
状态定义决定了 dp[n-1] 仅仅是“以最后一个元素结尾”的最大连续子序列和。但整个数组的最大连续子序列,完全可能结束在中间某个位置。比如数组 [3, -10, 100],以第三个元素 100 结尾的子序列和是 100,整个数组最大值是 100,但如果是 [5, -1, -2, 10],以最后一个元素 10 结尾的最大子序列是 10,而真正的最大子序列是 [5, -1, -2, 10],整体和是 12,这就大于 dp[3] 吗?其实不是,因为以最后一个元素 10 结尾的最大子序列包含前面所有元素时,和是 12,所以 dp[3] 也是 12。但为了保险,我们需要再想一个例子,比如 [1, -2, 3],以最后一个元素 3 结尾的最大子是 3,全局最大也是 3,貌似一致。那有没有 dp[n-1] 不等于全局最大值的情况?有,比如 [3, -2, 5, -100],以最后一个元素 -100 结尾的最大子序列和是 -100,但全局最大是 [3, -2, 5] = 6。所以答案是遍历过程中维护一个 best,随时更新为 max(best, dp[i]),而不是傻傻地取 dp 的最后一个值。
这个细节是很多第一次写这道题的选手最容易翻车的地方。
3. 从朴素DP到滚动变量:代码怎么一步步写出来
3.1 朴素动态规划版本
先给出一个最容易理解的版本,用完整 dp 数组保存所有中间状态。
我用 Python 写一个示例:
def max_subarray_sum(nums): n = len(nums) if n == 0: return 0 dp = [0] * n dp[0] = nums[0] best = nums[0] for i in range(1, n): dp[i] = max(dp[i - 1] + nums[i], nums[i]) best = max(best, dp[i]) return best这个版本的优点是直观,每一个 dp[i] 都表示以 i 结尾的最大子序列和,调试时可以把 dp 数组打出来看变化过程,特别适合学习阶段。
看一个具体例子,数组 [-2, 1, -3, 4, -1, 2, 1, -5, 4]:
i=0, nums[0]=-2, dp[0]=-2, best=-2 i=1, nums[1]=1, dp[1]=max(-2+1, 1)=1, best=1 i=2, nums[2]=-3, dp[2]=max(1-3, -3)=-2, best=1 i=3, nums[3]=4, dp[3]=max(-2+4, 4)=4, best=4 i=4, nums[4]=-1, dp[4]=max(4-1, -1)=3, best=4 i=5, nums[5]=2, dp[5]=max(3+2, 2)=5, best=5 i=6, nums[6]=1, dp[6]=max(5+1, 1)=6, best=6 i=7, nums[7]=-5, dp[7]=max(6-5, -5)=1, best=6 i=8, nums[8]=4, dp[8]=max(1+4, 4)=5, best=6最终结果是 6,对应的子序列是 [4, -1, 2, 1],和确实是 6。在整个推导过程中可以看到,当 dp[i-1] 为负数时,比如 i=2 时 dp[2] = -2,等 i=3 时直接抛弃了前面的累加结果,从 4 重新开始。这就是“断臂求生”的实际含义。
3.2 滚动变量:从O(n)空间到O(1)空间
在做题和实际开发中,保存整个 dp 数组往往是没有必要的,因为 dp[i] 只依赖 dp[i-1]。这是一个非常典型的“仅依赖前一项”的递推关系,完全可以用两个变量滚动更新。
优化后的代码也简单:
def max_subarray_sum(nums): cur = nums[0] best = nums[0] for x in nums[1:]: cur = max(x, cur + x) best = max(best, cur) return best这个版本就是经典的单层循环 Kadane 算法,时间复杂度 O(n),空间复杂度 O(1)。代码只有四行核心逻辑。
我见过有些面试者会觉得这个版本是偷机取巧或者“背答案”,其实不是。它就是利用滚动变量消除冗余空间的常规操作,和斐波那契数列用两个变量滚动代替整个数组是同一个思想。真正理解朴素版本之后,优化版自然就出来了,不建议直接背优化版代码,否则一个变种题就能把你打回原形。
3.3 其他语言的实现
C++ 版本的写法也很常见,面试时写这个更符合多数公司的技术栈:
int maxSubArray(vector<int>& nums) { if (nums.empty()) return 0; int cur = nums[0]; int best = nums[0]; for (int i = 1; i < nums.size(); ++i) { cur = max(nums[i], cur + nums[i]); best = max(best, cur); } return best; }如果面试官问了边界情况,比如“数组为空时返回什么”,这里的处理是返回 0。但不同题目定义里可能要求不同,有的要求返回 INT_MIN,有的要求抛异常,尽量先问清楚再动手。如果题目没说空数组,通常默认 n >= 1,那 cur 直接初始化为 nums[0] 就不会有问题。
4. 边界条件与常见误区,每一个都能写成一份面经
4.1 全负数数组怎么处理
我拿这个题面试过很多人,发现最容易纠结的就是“数组全为负数”的场景。比如 [-3, -5, -1, -4],正确答案应该是 -1,也就是选一个最大的负数。但有些第一版代码会在循环里加一句“if cur < 0 then cur = 0”,这本是求“最大非负和”的思路,用在答案必须 >= 0 的问题上没问题,但用在允许负数的原始题目上就会挂掉。
正确做法是一开始就把 cur 和 best 都初始化为数组的第一个元素,然后从第二个元素开始递推。这样当全负数时,cur = max(nums[i], cur + nums[i]) 的结果就是在“从当前元素重新开始”和“接上前面一段可能更负的和”中选最大的,最终 cur 总是保持从当前位置往前连续子序列里的最大和,即使它是负数,best 也会记录下最大的那个负数。
这里有一个经验:初始化不能用 0,而要用数组第一个元素。用 0 初始化的后果是,全负数数组要么返回 0 这种错误答案,要么需要额外打补丁特判。很多初学者的代码就是在这里翻车的。
4.2 “只取正数再累加”的陷阱
有人会想,既然要找最大和,那我只挑正数不就行了?不行,因为“连续”这个限制是硬约束。考虑 [8, -2, 5] 这个例子,如果只取正数那最大和是 13,但 8 和 5 不连续,中间隔了 -2。实际子序列 [8, -2, 5] 的和是 11,这才是正确答案。所以必须容忍那些“小的负数”,因为它们是实现连续性的“桥梁”。如何判断一个负数能不能忍,关键就看它接上前面的子序列后,总体值是不是还大于从它本身重新开始的值。状态转移方程做的就是这件事。
4.3 关于空数组和溢出问题
有些题目会给出空数组的用例,这时候需要直接返回 0 或者题目约定的最小值。但如果约定 n >= 1,就不用特判。我个人习惯先写防御式代码,开头加一个“if nums 为空”的分支,因为在实际生产环境里,你很难保证输入数据不会为空。
另一个容易忽视的点是整数溢出。如果数组元素很大、长度很长,累加和可能超出 int 范围。像 C++ 里 int 是 32 位的,最大只能表示约 21 亿,如果数组里有十几亿级别的数,连续加上几次就会溢出。面试时可以跟面试官确认数值范围,或者直接改用 long long。刷题网站一般不会用极端大数据卡这个,但真实业务里的数据没法保证,思想上要有防溢出的意识。
5. 进阶问题:光求最大值不够,我还想要子序列本身
5.1 用起点和终点记录区间
很多时候,面试官会在你背完 Kadane 之后随即追加一个要求:不仅返回最大和,还要返回对应的连续子序列。例如输入 [-2, 1, -3, 4, -1, 2, 1, -5, 4],最大和是 6,子序列是 [4, -1, 2, 1],这时候下标区间是 [3, 6]。如果再要求返回起止下标,又该怎么改?
思路很简单:在滚动过程中,用变量记录当前的左端点 start 和右端点 end,每次更新 cur 时,如果决定“从 x 重新开始”,那么临时起点就跳到当前位置;如果决定“接上前面的 cur”,临时起点保持不变。每次 best 被刷新时,就把当前的临时起点和当前位置记为最终答案的起止点。
5.2 代码实现:记录起止点版本
直接上代码:
def max_subarray_with_index(nums): if not nums: return 0, -1, -1 cur = nums[0] best = nums[0] temp_start = 0 start = 0 end = 0 for i in range(1, len(nums)): if cur + nums[i] > nums[i]: cur = cur + nums[i] else: cur = nums[i] temp_start = i if cur > best: best = cur start = temp_start end = i return best, start, end核心变化就一句话:当 cur + nums[i] 比 nums[i] 小的时候,意味着前面的累加是负收益,我们选择从 nums[i] 重新开始,所以临时起点更新为 i;否则继续接上。只有当 cur 刷新了 best 的时候,才把临时起点和当前终点写入最终结果。
这个变体在真实业务里很有用,因为知道最大和本身的价值往往不如知道哪段时间区间贡献了这个最大值。比如在监控系统里,找到了最大流量连续上涨的区间,就能定位到对应的日志或者指标时间段。
5.3 变体一:最大连续子序列乘积
还有一道同样高频的变体题,求最大连续子序列乘积。比如 [2, 3, -2, 4],最大乘积是 2×3=6。表面看起来比求和复杂,因为负数乘以负数会变正数,导致你不能只维护一个最大值。解决方法是同时维护两个状态:当前乘积最大值 curMax 和当前乘积最小值 curMin。因为最小值可能是负数,乘以一个负数后反而会变成最大值。
def max_product(nums): if not nums: return 0 cur_min = nums[0] cur_max = nums[0] best = nums[0] for x in nums[1:]: candidates = (x, cur_max * x, cur_min * x) cur_max = max(candidates) cur_min = min(candidates) best = max(best, cur_max) return best这道题其实是最大连续子序列和的直接扩展。理解清楚了“为什么状态定义要加上结尾位置”这一点,就能自然理解乘积变体为什么需要维护两个值。
5.4 变体二:环形数组上的最大连续子序列和
如果题目把数组改成上是首尾相连的环形数组,比如 [5, -3, 5],线性数组的最大子序列和是 7,但环形数组里还可以跨过首尾,取 [5, 5] 的和是 10,这就更大了。解法是求“最大值”和“最小值”两个答案取大的思路:环形数组的最大子序列和要么在数组内部,不跨越边界;要么跨越边界,等价于总和减去最小子序列和。所以可以写两次 Kadane,一次求最大子序列和,一次求最小子序列和,两者取 max,再加上特判“全部为负数”的情况。这里先不展开代码,因为单列出来又能写一整篇文章,但思路值得记住。
6. 这类DP思路在真实场景中能做什么
6.1 股票买卖的最大单次收益
以股票为例,如果你只能买卖一次,那本质上就是找最小的买入价和之后最大的卖出价之间的价差。把相邻两天的价格差做成一个差分数组,最大收益就是“差分数组的最大连续子序列和”。比如每日价格 [100, 80, 120, 130, 70],差分是 [-20, 40, 10, -60],最大连续子序列和是 50,对应 80 买入、130 卖出。
我实际做过类似的数据分析业务,从行情数据接口拿几千只股票的历史价格,用 Kadane 算法批量算每只股票在某段时间里的最大涨幅区间,单次遍历就能跑完,性能完全不是瓶颈。这比写一堆 pandas 条件筛选要清爽得多。
6.2 监控告警与异常区间定位
在运维监控场景里,经常要看一段连续时间段里的指标变化。比如某一台服务器的网络流量在一连串的时间点上出现了持续上涨,你希望自动识别出上涨最猛的那一段,方便回溯是不是有异常任务在跑。把流量数据取出来后,对每相邻两个时间点计算差值,再用最大连续子序列算法找出累计上涨最大的时间窗口,比人工盯着图表找要高效得多。实际业务里我处理过类似问题,一个接口的访问延迟数据在某个版本发布后连续多天爬升,靠这算法一键定位到了异常窗口,再结合发布记录分钟级就找到了原因。
6.3 图像处理和其他信号处理领域的小尝试
在图像处理中,求最大子矩形和这类问题,也可以先压缩行或列,转化为一维的最大连续子序列和问题再求解,这是经典的降维手段。虽然听起来有点远,但它说明了一个道理:算法题的模型一旦吃透,迁移到真实业务里只是改个壳的事。
6.4 为什么说状态定义是动态规划的魂
我做了这么多年技术,也带过不少新人入门算法,最大体会是:看不懂动态规划,百分之七十是卡在状态定义上。状态定义包含了这个问题最本质的结构信息,定义好了,转移方程其实是一步步推理的结果;定义不好,背再多代码也没用。最大连续子序列和就是一个绝佳的入门题,它规模小、形式简单,但完整展示了“定义状态 -> 推导转移 -> 空间优化 -> 变体迁移”的思考过程。把这一题的思路吃透,后面再看背包、LIS、编辑距离这些经典DP,思维路径都是相似的。
7. 给后来者的一点建议
如果让我从这道题里提炼出最值得记住的一条经验,那就是:遇到“连续子数组/子序列”相关的最值问题,优先考虑“以每个位置结尾作为状态”这个套路。它像一个万能钥匙,可以直接打开一大类问题。
第二个建议是,别急着写优化版。我见过很多刷题选手上来就背 Kadane 算法的四行代码,结果问到怎么输出子序列就懵了,问到为什么 cur 要为 0 还是 nums[0] 也说不清。先老老实实写 dp 数组版本,把每一步递推过程在纸上推演一遍,再随手优化成滚动变量版本,这样形成的记忆才是牢固的。
第三个建议是,多想想变体。最大连续子序列和、最大连续子序列乘积、输出起止下标、环形数组版本、二维矩阵最大子矩阵和。每想一个变体,你就对原问题多一层理解。等到面试时面试官从主问题拐到变体,你能快速迁移,那这道题才算真正过关。
最后分享一个我实际调试时的小技巧。如果你不确定自己的 Kadane 算法写的对不对,拿一个全正数数组试一下,应该返回整个数组的和;再拿一个全负数数组试一下,应该返回最大的那个负数;再拿一个类似 [-1, 2, 3, -2, 5] 的混合数组,自己手算一遍再跑代码。这三个测试用例能过滤掉绝大多数实现错误。我至今写这一类题都会在脑内先过一遍这三个例子,算是多年养成的习惯。