做了这么多年算法题,LeetCode 53 的“最大子数组和”绝对是我见过最值得反复咀嚼的一道。题目本身很简单:给你一个整数数组nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。但就是这么一道看似基础的题,背后藏着一整套从暴力、动态规划到分治、再到线段树合并的思路演变,把它吃透了,你再去啃“环形最大子数组和”、“乘积最大子数组”、“区间查询最大子段和”这些问题,会顺畅很多。
这篇文章我把这道题从里到外拆一遍,适合刚接触算法的同学建立动态规划直觉,也适合准备面试的同学查漏补缺,顺带分享一些我在实际编码和工程场景里踩过的坑。
1. 最大子数组和问题:先搞清楚题目在问什么
1.1 题面解读与核心概念
题目给的是一个普通数组,注意三个关键限定:“连续”、“非空”、“最大和”。连续意味着[1, -2, 3]的子数组可以是[1]、[-2]、[3]、[1, -2]、[-2, 3]、[1, -2, 3],但绝对不能是[1, 3]这种跳着取的形式。这一点和“最大子序列和”有本质区别,后者允许跳过中间元素,难度直接下降一个量级,因为遇到负数可以先扔掉,排序后从正数往大加就行。
我见过不少新手一上来就用“双指针滑动窗口”的思路去套这题,结果发现窗口收缩的条件根本没法定义——因为数组里既有正数又有负数,窗口变大不一定让和变大,窗口变小也不一定让和变小,滑动窗口那套“满足条件收缩”的模板在这儿完全失效。
把这个问题用生活化的类比来看就很好理解了:假设你在记录一家奶茶店每天的净利润,有赚有亏,现在你想知道“连续一段时间里,总体赚得最多的是哪一段”。注意这里的“一段时间”必须是连续的,不能今天、周三、周六拼在一起。那你就得想,今天我到底该“接着上一段的势头继续干”,还是“干脆从今天重新开始”,这就是最大子数组和问题的核心决策。
1.2 为什么说这是动态规划的入门必修课
很多人一谈动态规划就头疼,总觉得状态转移方程像是天上掉下来的。而最大子数组和恰好是解释“状态设计”为什么这样设计的最佳教材。
动态规划要求我们把一个大问题拆成有递推关系的小问题。这里的关键问题是:如果我们定义dp[i]表示“以nums[i]结尾的最大子数组和”,那dp[i]只会和dp[i-1]发生关系。为什么?因为子数组是连续的,以nums[i]结尾的子数组要么只包含nums[i]自己,要么就是“以nums[i-1]结尾的某个子数组”再加上nums[i]。不存在第三种情况,这个“连续性”把状态空间牢牢限制在相邻位置之间,递推关系自然就出来了。
这个“以某个位置结尾”的定义方式非常经典。以后你做“最长递增子序列”、“乘积最大子数组”、“打家劫舍”时都会反复用到。它和另一种常用定义方式——“前 i 个元素里选”——容易混淆,你得记住:当问题明确要求“连续”时,优先考虑以i结尾这种设计,因为它天然能把连续性编码进状态里。
2. 从暴力到动态规划:核心思路的演进
2.1 暴力搜索:当最朴素的想法遇到性能瓶颈
最直观的解法当然是把所有子数组都枚举一遍。外层循环固定起点i,内层循环让终点j从i一路扫描到数组末尾,顺手累加并更新答案。
class Solution { public: int maxSubArray(vector<int>& nums) { int n = nums.size(); int ans = INT_MIN; for (int i = 0; i < n; i++) { int sum = 0; for (int j = i; j < n; j++) { sum += nums[j]; ans = max(ans, sum); } } return ans; } };这个思路本身没有错,问题在于时间复杂度是 O(n^2)。数组长度在 10^4 以内还能勉强跑跑,一旦到了 10^5、10^6 级别,平方复杂度就会直接爆炸。
还有一点值得注意:内层循环里用一个sum变量边累加边更新,而不是每次都重新算i到j的和,这个过程本身就是一个“前缀和”思想的最小雏形。很多同学在这里会写三层循环——先枚举 i,再枚举 j,再用一个 k 从 i 加到 j,那就是 O(n^3) 了,完全不可接受。
不过暴力解法最大的价值不是“能跑”,而是给我们提供了一个绝对正确的基准答案。我实际做题时,遇到优化解法不确定对不对,常常会先写一个暴力版本,再用随机测试数据对拍。这种做法在面试里甚至可以主动提出来:先讲暴力思路,再分析瓶颈,然后引出更优解法,面试官会觉得你的思维链条非常完整。
2.2 状态转移方程的正确打开方式
动态规划解法的核心就一个公式:
dp[i] = max(dp[i - 1] + nums[i], nums[i])
这个公式的精髓在于“要不要接上前面的状态”。我们逐字拆开看:
- 如果
dp[i-1]是正数,说明“以 i-1 结尾的最大子数组和”对我们是有增益的,那nums[i]接上去一定比自己单干更大,所以dp[i] = dp[i-1] + nums[i]。 - 如果
dp[i-1]是负数,说明无论前面那一段具体是哪些数字,接过来只会拖累当前数字,不如“就此打住,从nums[i]开始新的一段”,所以dp[i] = nums[i]。
这里有一个非常容易混淆的点:dp[i-1]表示的是“以 i-1 结尾的最大子数组和”,不是“前 i-1 个元素的任意最大子数组和”。它身后的那段子数组一定是紧贴着 i-1 的,这样才能把nums[i]无缝隙地接上。如果你把状态定义搞成“前 i 个元素的最大子数组和”,那nums[i]和之前的最大段之间可能有空隙,递推公式就不成立了。
完整代码如下:
class Solution { public: int maxSubArray(vector<int>& nums) { int n = nums.size(); vector<int> dp(n); dp[0] = nums[0]; int ans = dp[0]; for (int i = 1; i < n; i++) { dp[i] = max(dp[i - 1] + nums[i], nums[i]); ans = max(ans, dp[i]); } return ans; } };注意两个细节:一是ans的初始值不能设为 0,因为如果整个数组全是负数,最大子数组和也是负数,初始化为 0 会得错误答案;二是dp[0]必须单独初始化,因为状态转移从 i=1 开始。
我们拿题目自带的示例来手推一遍。nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4],一步步来:
dp[0] = -2,ans = -2i=1,max(-2 + 1, 1) = 1,ans = 1。注意这里 dp 从 -2 跳到了 1,说明我们果断丢掉了 -2,从 1 重新开局。i=2,max(1 + (-3), -3) = -2,ans保持 1。当前“以 -3 结尾的最大段”是 -2,是负数。i=3,max(-2 + 4, 4) = 4,ans = 4。继续丢掉负累赘,从 4 重新开始。i=4,max(4 + (-1), -1) = 3,ans = 4。这里没有丢掉 -1,因为dp[3]=4还是正数,有增益。i=5,max(3 + 2, 2) = 5,ans = 5i=6,max(5 + 1, 1) = 6,ans = 6i=7,max(6 + (-5), -5) = 1,ans保持 6i=8,max(1 + 4, 4) = 5,ans保持 6
最终答案是 6,对应子数组[4, -1, 2, 1]。手推一遍你就会发现,整个过程只扫了一遍数组,没有任何回头操作,每个位置只做一次判断:接还是不接。
复杂度方面,时间 O(n),空间 O(n)。你以为结束了吗?并没有,因为dp数组里每个元素只依赖前一个元素,这个空间其实是可以压缩到 O(1) 的。
2.3 压缩空间的 Kadane 算法:从 O(n) 空间到 O(1) 空间
Kadane 算法(卡丹算法)是这个问题的最优解之一,Joseph Born Kadane 在 1984 年提出。它做的事情本质上和动态规划完全一样,只是把整个dp数组压缩成了两个变量:
class Solution { public: int maxSubArray(vector<int>& nums) { int cur = nums[0]; int ans = nums[0]; for (int i = 1; i < nums.size(); i++) { cur = max(cur + nums[i], nums[i]); ans = max(ans, cur); } return ans; } };这里的cur对应dp[i],ans则是历史所有dp值里的最大值。为什么可以这样压缩?因为dp[i]推导只用到dp[i-1],更早的状态在用完之后就彻底没用了。这个“滚动变量”思想在动态规划优化里极其常见,背包问题的一维数组优化本质上也是同样的道理。
我在这里要特别强调一个容易翻车的点:cur的更新必须发生在ans更新之前,而且cur和ans的初始值必须都是nums[0]而不是 0。这样写有一个微妙的好处:如果数组只有一个元素,循环根本不会执行,函数直接返回第一个元素,正确无误。
有同学会问:这个算法看上去和贪心很像,每一步都只做局部最优选择,怎么确定最终结果就是全局最优?这个问题问得很好。
Kadane 算法表面上很像贪心,但它背后有严密的数学逻辑支撑:任何一个最大子数组,必然有一个“终点位置”。如果我们针对每个终点位置,都计算出了“以它为终点的最大子数组和”,那全局最大子数组一定就是这些局部最大值中的最大者。这就像你在找一条路上最高的山峰,虽然你是一段一段看的,但你保证了每段山峰都是该段最高点,那所有段中最高的那个自然就是整条路的最高峰。这个“以每个位置为终点逐一枚举”的思路,靠的是状态转移的完整性,而不是贪心式的短视。
还有一个非常常见的“伪 Kadane”错误写法:有些人会把cur理解成“当前连续子数组的和”,一旦发现cur < 0就把cur重置为 0,最后返回ans。这种写法在数组存在正数时确实能得到正确答案,但遇上[-1, -2, -3]这种全负数组就会返回 0,因为重置把第一个负数也丢掉了,最后ans永远不更新。所以说,cur = max(cur + nums[i], nums[i])这个式子可以把“全负数组”的情况一并处理掉,因为它允许“当前段”就是单个负数本身。那为什么很多教科书里也写“如果 cur 为负就置零”的版本?因为那个版本需要额外的变量来记录真正的最大负值,或者约定返回值允许为 0。细节就差在这一行,错了全盘皆输。
3. 分治解法与广义化:同一问题的另一扇窗
3.1 分治策略的原理与实现
如果你觉得动态规划就是这道题的终点,那就太小看它了。其实最大子数组和还有另一种经典解法——分治法,而且这种解法在很多扩展问题里反而更有价值。
分治的思路是把数组从中间切成两半,那么最大子数组只可能出现在三个地方:完全在左半部分、完全在右半部分、或者横跨中点。前两种情况递归求解就行,麻烦的是第三种。
怎么求“横跨中点的最大子数组和”?关键从中点出发向两端扩散:从中点向左,计算出以中点为结尾的最大后缀和;从中点向右,计算出以中点后面一个位置为起点的最大前缀和;两者相加就是跨越中点的最大和。
实现代码:
class Solution { public: int maxSubArray(vector<int>& nums) { return divide(nums, 0, nums.size() - 1); } int divide(vector<int>& nums, int l, int r) { if (l == r) return nums[l]; int mid = (l + r) / 2; int leftMax = divide(nums, l, mid); int rightMax = divide(nums, mid + 1, r); int leftSuffix = INT_MIN; int sum = 0; for (int i = mid; i >= l; i--) { sum += nums[i]; leftSuffix = max(leftSuffix, sum); } int rightPrefix = INT_MIN; sum = 0; for (int i = mid + 1; i <= r; i++) { sum += nums[i]; rightPrefix = max(rightPrefix, sum); } return max(max(leftMax, rightMax), leftSuffix + rightPrefix); } };时间复杂度和归并排序一样是 O(n log n),空间复杂度是递归栈深度 O(log n)。这个解法在纯粹追求性能的场合不如 Kadane,但它的价值在于打开了“区间合并”的思维大门。
我在做这题的时候曾经有个误区:以为跨中点的最大和一定要既包含中点的左元素又包含中点的右元素,其实只要“从中点向左延伸一段,再从中点向右延伸一段”,两边都可以只取一个元素,这就够了。如果两侧取到的都是负数,那跨越中点的和还是负数,最终答案会从左右递归里选更大的,这也是为什么最终要比较三个值。
3.2 从最大子数组和到“线段树上最大子段和”
分治的思维再往前走一步,你会发现一个更强大的东西:线段树。
如果题目变成“随时支持单点修改数组的某个元素,然后立刻查询整个数组的最大子数组和”,Kadane 算法就无能为力了——因为每次修改后都要重新扫描一遍,O(n) 的代价在频繁查询时完全不可接受。这时候就要用到线段树每个节点维护四元组的办法。
每个线段树节点需要维护四个值:
sum:整个区间的元素和lsum:包含区间左端点的最大前缀和rsum:包含区间右端点的最大后缀和msum:整个区间的最大子数组和
合并两个相邻区间left和right时,新的节点值这样计算:
struct Node { int sum, lsum, rsum, msum; }; Node merge(Node left, Node right) { Node res; res.sum = left.sum + right.sum; res.lsum = max(left.lsum, left.sum + right.lsum); res.rsum = max(right.rsum, right.sum + left.rsum); res.msum = max(max(left.msum, right.msum), left.rsum + right.lsum); return res; }这四个更新式子的含义非常清晰:
sum直接相加,没什么好说的。lsum要么直接从左边区间的最大前缀拿,要么把左边区间整体加上右边区间的最大前缀。rsum对称处理。msum要么全在左,要么全在右,要么跨越中间——跨越的部分恰好是“左区间的最大后缀”接上“右区间的最大前缀”。
这其实就是把一个线段里所有可能的分段情况用四元组穷举完了。任何两个相邻区间的合并都遵循这个规则,线段树建树就是不断套用merge,查询某个区间的最大子数组和时,把覆盖该区间的若干线段树节点按顺序两两merge起来,最终节点的msum就是答案。
这个数据结构单独写出来就是 LeetCode 上另一道经典题“最大子段和”的通用解法,在很多涉及区间动态查询的场景里都能用,比如股票区间收益分析、基因序列比对中的相似性分段、金融时间序列的跳跃检测等等。理解它的关键在于:不是记住四个公式,而是理解“一个区间的答案信息,怎么完整地编码进四个数字里”,这是一种建模能力,比会背模板重要得多。
4. 实操经验:从 LeetCode 到真实工程场景
4.1 返回子数组下标:工程中最常见的需求变形
LeetCode 只要求返回最大和,但实际业务里几乎总是要“把这最大的一段找出来”——不管是做数据分析、异常检测,还是指标监控,你光知道一个数字是没用的,你得知道是哪一段区间。
要给 Kadane 算法加上区间追踪,需要多维护几个变量:当前临时区间的起点、答案区间的起点和终点。每当cur决定“从当前元素重新开始”时,临时起点就更新为当前位置;每当ans被刷新时,答案区间的起终点就更新为临时区间的起终点。
class Solution { public: vector<int> maxSubArrayWithRange(vector<int>& nums) { int n = nums.size(); int cur = nums[0], best = nums[0]; int start = 0, end = 0; // 答案区间 [start, end] int tempStart = 0; // 当前段起点 for (int i = 1; i < n; i++) { if (cur < 0) { // 接上不如重新开始 cur = nums[i]; tempStart = i; } else { cur += nums[i]; } if (cur > best) { best = cur; start = tempStart; end = i; } } return {best, start, end}; } };注意这里判断条件是cur < 0而不是cur + nums[i] < nums[i]。其实两者是等价的,但显式写if (cur < 0)在语义上更清楚:前面一段已经拖后腿了,我们应该弃暗投明,从当前位置重新开始。我第一次写的时候把cur < 0写成了nums[i] < 0,结果遇到[-1, -2, 3]这种例子就出错了——当前元素是负数并不意味着要舍弃,因为负数后面可能跟着更大的正数。
这个返回区间版本的代码我在面试中至少被考到过三次,每次都要求和下标一起返回。很多候选人能写出 Kadane,但一到追踪下标就乱了套,因为临时起点和最终起点的关系没想清楚。建议你在本地把[-2,1,-3,4,-1,2,1,-5,4]这个例子手动走一遍区间变化,体会一下 tempStart 是怎么一步一步“逼近”最终起点的。
4.2 真实业务里最大子数组和的影子
很多人觉得算法题就是面试那一关,过了就再也用不上了。我自己的工作经历告诉我,这种想法大错特错。
举几个我真实遇到过的例子:
第一个是股票/基金的数据分析。比如你有一份基金净值每日涨跌幅序列,经理想让你算“过去一年里,连续定投哪段时间累计收益最高”。如果用简单的两两比较,可能需要 O(n^2) 的时间,当数据量到十几万条时就显得笨重了。用最大子数组和的思路,一下子就能定位到最优定投区间。这个需求我是在一个量化分析脚本里实现的,当时用的就是 4.1 节的返回区间版本。
第二个是日志监控里的异常聚集检测。系统持续输出响应延迟数据,你想知道“哪一段时间内总延迟最大,可能是上游故障导致的堆积”。这个问题本质上就是最大子数组和,只不过把“和最大”改成了“绝对延迟最大”,数据形态稍有不同,核心算法完全一致。
第三个是图像处理里的最大连通能量区域。某些图像分割算法里需要找到能量累积最大的连续路径,如果只考虑一维情况,用的也是这个算法。扩展到二维时,需要配合前缀和做行压缩,再逐行调用 Kadane。
这些例子的共同点是:数据天然是连续的时间序列或空间序列,问题要求找出“累积最优”的一段连续区间。这类问题比“找最大值”复杂就复杂在“连续”二字上,而我见过太多同事面对这种需求时选择了双层循环硬算,一旦数据量上来就出问题。
做工程和刷题最大的不同在于,工程里你还要考虑数值溢出。LeetCode 的测试用例一般不会给你超过 int 范围的答案,但真实业务里如果你处理的是累计网络流量、总成交量这种数据,int 很容易溢出。我在一个数据处理脚本里就踩过这个坑——当时用了int存cur,数据量一大就变成负数,然后整个算法逻辑全乱了。解决方案很简单:用long long存中间结果,只在最后输出时判断能否转回int。
4.3 面试中怎么答这道题
如果你在准备面试,这道题几乎是必刷题,而且面试官通常不会只满足于你把代码写出来。他们想看的是:
第一,你能不能从暴力解法开始,逐步优化到 Kadane。面试节奏可以控制在“先说暴力思路,分析时间复杂度;再引出 dp 数组版本,解释状态转移方程;最后展示滚动变量优化”。这个过程比直接默写 Kadane 要好得多,因为面试官能从中看到你的思维过程而不是记忆能力。
第二,你能不能解释清楚“为什么只要状态里存的是以 i 结尾的最大和,那么全局最大就一定在某个 dp[i] 里”。这个问题的本质是数学归纳法和穷举性的结合:我们没有任何遗漏地枚举了每个可能的终点位置,所以答案一定在其中。
第三,如果面试官加变形——要求返回具体子数组、要求数组是环形、要求可以修改元素——你有没有后续预案。会线段树版本的合并思路绝对是加分项,你可以把分治法里的跨中点合并自然过渡到线段树节点的 pushUp 操作,显得知识体系非常完整。
我曾经在一次模拟面试里扮演面试官,遇到一个候选人,他写 Kadane 写得飞快,但当我问他“那如果让你求最大子数组的起止下标呢”,他愣了好一会儿。因为他从来没想过cur的更新和下标追踪之间的关系。这个问题我建议每个人都提前想明白,因为它直接检验你是否真正理解了算法,而不只是背熟了模板。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
我把这些年在这道题及相关变形上踩过的、帮别人排查过的坑整理成一张表,你在写代码或 review 别人代码时可以对照自查。
| 问题 | 现场表现 | 根因与解决 |
|---|---|---|
| 全负数组返回 0 | 输入[-1, -2]得到 0 | 初始值设成了 0,或使用了“cur<0 直接清零再更新 ans”的错误写法。修正:cur和ans都初始化为nums[0],或先更新 cur 再更新 ans |
| 单元素数组越界 | 输入[5]报数组越界 | 代码里访问了nums[1]而没有先判断长度。修正:提前返回nums[0],或循环从 1 开始 |
| 整数溢出 | 数据量一变大结果变负数 | 测试用例超出 int 范围。修正:中间计算用long long,输出时按需转换 |
| 返回区间时起点不对 | 最大和正确但区间位置错 | tempStart 没有在“重新开始”时更新,或输出时把 tempStart 当成 start。修正:用一个额外变量缓存“当前段起点”,只有当 best 被刷新时才同步给 start/end |
| 混淆“最大子序列” | 测试含负数的大样例过不了 | 把问题当成可跳元素处理,排序或分治时跳过了连续性。需要回到定义重新审题 |
| 把 53 题当成滑动窗口 | 死循环或漏解 | 窗口收缩条件无法定义。改用动态规划或分治 |
| 递归深度过深 | 分治解法在超长数组上栈溢出 | 递归深度是 log n,一般不会溢出。若溢出,检查是否误写了线性递归(比如递归调用在 for 循环里) |
| 合并线段树时顺序错误 | 查询区间答案与暴力对不上 | 节点合并必须按数组顺序,merge(left, right)不能交换参数。尤其查询跨节点区间时,要把左边界节点按顺序合并到右边界节点 |
这里面最常犯的就是第一个坑。很多人学 Kadane 时看到的伪代码可能是“if sum < 0: sum = 0; sum += nums[i]; ans = max(ans, sum)”,这种写法在数组不是全负时没问题,但一旦全负就崩。网上这类代码特别多,因为它们在国外论坛的讨论串里也经常出现,被初学者贴上博客后就成了错误样板。
我的建议是始终使用cur = max(cur + nums[i], nums[i])这个写法,它涵盖全负情况,逻辑也更统一。
5.2 边界条件与数据规模的经验谈
做算法题,边界条件比算法本身更容易栽跟头。最大子数组和这道题,至少要专门测试以下几类数据:
空数组。LeetCode 的约束里数组长度至少为 1,但工程上你一定会遇到空数组的输入。我建议在函数开头统一加一个if (nums.empty()) return 0;或者抛异常,取决于调用方的意图,避免后续访问nums[0]时直接段错误。
单元素数组。这种最简单,但恰好能暴露你的初始化逻辑是否正确。如果ans初始化为 0,cur初始化为nums[0],那结果就对;如果两个都初始化成 0,结果就错了。
全正数组。所有元素都是正数,那最大子数组和就是整个数组的和。这个测试用例能验证你的算法是否真的允许“从开头一直延伸到结尾”。
全负数组。最典型的是[-1, -2, -3],答案应该是 -1,因为子数组不能为空。很多错误实现会返回 0,明眼人一看就知道算法理解有偏差。
正负交替数组。比如[1, -1, 1, -1, 1],答案应该是 3(取整个数组),能帮你验证算法能否把中间的小负数“包容”进去。
数组里有零。[0, 0, 0]答案 0,[-2, 0, -1]答案 0,这些用例能测试零值是否被正确处理。
大数据量。生成一个长度 10^6 的随机数组,验证 Kadane 能在几十毫秒内算完,同时可以配合暴力法做对拍。我一般会在本地写一个测试脚本,随机生成 1000 组长度 1 到 100 的数组,分别用暴力法和 Kadane 算一遍,然后对比结果,用这种方式来验证优化版本的正确性。
除了测试数据,还有一个工程细节值得注意:数组长度很大时,nums.size()返回的是size_t(无符号 64 位整数),如果写成for (int i = 0; i < nums.size() - 1; i++)且nums为空,nums.size() - 1会变成巨大的正数,导致循环不会执行或行为异常。正确写法是先把n转成 int 或使用i + 1 < nums.size()这种安全判断。
关于空间复杂度的选择:如果你的算法是写在一个多次调用的服务里的,每次调用只处理一个小数组,那 O(n) 的 dp 数组也无所谓;但如果这个算法跑在流式数据上,每来一个数据就要更新一次,那 O(1) 空间的 Kadane 几乎是唯一选择。
还有一个有意思的扩展:如果数组是环形的(首尾相连),最大子数组和怎么求?思路是这样的:环形数组的最大子数组要么不是环形的,直接用 Kadane 求;要么是环形的,此时可以用“总和减去最小子数组和”来求。取这两种情况的较大者即可,但要注意一种特例:如果所有元素都是负数,那“最小子数组和”等于整个数组,总和减去它就变成 0,答案应该是最大的那个负数,所以这种情况需要单独判断。这个变形在 LeetCode 上是第 918 题,考的就是你对 Kadane 的理解够不够本质。
5.3 代码风格与写题习惯
最后聊一点写题习惯。我见过太多同学上来就写最优解,写完自己也讲不清楚为什么要这样。我的建议是:一道经典题最好在本地以“暴力 -> 动态规划 -> 滚动优化 -> 分治 -> 线段树”的顺序完整写一遍,每写一版就运行一遍,和其他版本的输出对拍。
这个过程本身就是在训练“多方案对比”的思维。以后你遇到未知问题时,脑子里会自动浮现出多种路径,而不是只记得一个模板,这对实际工程中的方案选型非常有用。工程上并不总是最优解胜出——有时候代码的简洁性比极致性能更重要,有时候能支持后续扩展的通用结构比紧贴当前需求的特殊优化更有价值,你只有手里掌握多种方案,才能在合适的场景做合适的选择。
我个人在写这道题的时候,从暴力版到线段树版一共写了四个版本。虽然最后提交的只有 Kadane 那一版,但其他版本帮我真正建立了对每个细节的信心。比如分治版里的leftSuffix + rightPrefix,如果我没写过线段树的pushUp,我可能一直不理解为啥横跨中点的最大子数组和可以拆成后缀加前缀两个独立问题。这种“用不同视角反复看同一个问题”的练习,比盲目刷十道新题管用得多。