ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

最大子数组和与Kadane算法:从暴力到线段树的完整演进

最大子数组和与Kadane算法:从暴力到线段树的完整演进 这题我太熟了。LeetCode 53题“最大子数组和”几乎是所有数组类问题里面出场率最高的一道面试问过别人几十次自己也在不同语言、不同业务场景下写过不下十遍。表面上看就是“给一个整数数组找出具有最大和的连续子数组返回最大和”但真正能把这道题从暴力写到O(n)再把扩展思路讲清楚的人其实不多。这篇文章直接把这条线彻底走一遍先理解题意再按复杂度从低到高推进中间会穿插我踩过的坑、面试官真正想听的点以及C/Java/TypeScript几种写法的差异。无论你是刚入门动态规划还是准备面试想系统过一遍都能直接拿去用。1. 题目解读与核心场景1.1 题目到底在问什么先看最基础的定义。输入是一个整数数组nums比如[-2,1,-3,4,-1,2,1,-5,4]要求输出一个整数也就是所有连续子数组里面元素和最大的那个值。这里有几个关键词必须抠清楚连续子数组意思是nums[i]到nums[j]这一整段中间不能跳元素i j。非空至少包含一个元素不能返回0。返回的是“最大和”这个值而不是子数组本身。很多人在第一步就栽了把“子数组”和“子序列”混为一谈。子序列可以跳着选子数组必须连着选这是完全不同的两个问题。比如[1,2,3,4]最大子数组和是10全部加起来但如果改成子序列你可以随便挑语义就不一样了。还有个容易忽略的地方数组里允许有负数而且可能全是负数。比如[-3,-1,-2]正确答案是-1不是0。这个边界条件会直接淘汰掉一大半“把初始值设为0”的写法。1.2 这类问题在很多真实场景里都会出现有些读者可能觉得这题纯粹为了面试实际工作用不上。我自己的体会是这类“连续区间的最大累计值”问题在业务里出现频率非常高只是包装不一样。举几个我实际见过的场景金融流水分析给定一段时间的每日盈亏求连续一段时间的最大累计盈利用来判断最优持有周期。日志监控服务每秒钟的请求量波动找出连续时间段内累计请求量峰值用于定位流量尖峰。信号处理采样序列里找能量最集中的连续片段。游戏数据玩家连续在线时长、连续登录收益的最大累计值。这些问题的底层模型和LeetCode 53其实是同一套只是字段名从nums换成了amount、count、score。理解了这道题等于掌握了一种“找最优连续区间”的通用思维。2. 解法演进从暴力到优雅2.1 暴力三重循环先确保能做对拿到题目先别追求最优解能把问题做对本身就很有价值。最直接的思路是枚举所有子数组分别求和再取最大。int maxSubArray(vectorint nums) { int n nums.size(); int best INT_MIN; for (int i 0; i n; i) { for (int j i; j n; j) { int sum 0; for (int k i; k j; k) { sum nums[k]; } best max(best, sum); } } return best; }这个写法的时间复杂度是O(n^3)空间O(1)。三重循环分别枚举起点、终点、区间内累加。对n10^3已经扛不住n10^5基本超时但它的逻辑最直观适合作为“确认题意”的第一版。面试时如果直接秒出这段也不算错但一定要能自己指出问题在哪。面试官更看重的是你能不能从这里继续优化。2.2 前缀和优化空间换时间三重循环的问题在于每次计算区间和都会重复累加。我们可以用一个前缀和数组pre[i]表示nums[0]到nums[i-1]的和这样任意区间[i, j]的和就是pre[j1] - pre[i]求区间和降到了O(1)。int maxSubArray(vectorint nums) { int n nums.size(); vectorint pre(n 1, 0); for (int i 1; i n; i) { pre[i] pre[i - 1] nums[i - 1]; } int best INT_MIN; for (int i 0; i n; i) { for (int j i; j n; j) { int sum pre[j 1] - pre[i]; best max(best, sum); } } return best; }这段的时间复杂度是O(n^2)空间O(n)。相比暴力版已经快了很多但对n10^5仍然不够。它真正的价值在于让我建立了“区间和 前缀和的差”这个映射后面很多题都用得上。如果面试只要求“先给一版能跑的”我会写这段因为它比暴力优雅又不会一下子把最优解说出来。接下来再顺势引出优化。2.3 贪心/动态规划O(n)的核心思路真正面试里等着的是这个O(n)解法也就是Kadane算法。核心思想特别简单遍历数组时维护两个变量cur以当前位置结尾的最大子数组和best遍历到目前为止所有cur里的最大值。对每个元素只有两种选择要么“接着前面累加”要么“从当前元素重新开始”。判断标准是谁更大所以cur max(nums[i], cur nums[i]) best max(best, cur)写成代码也很短int maxSubArray(vectorint nums) { int cur 0, best nums[0]; for (int x : nums) { cur max(x, cur x); best max(best, cur); } return best; }这里有个陷阱要注意cur的初始值写成0还是nums[0]直接决定全负数用例会不会挂。如果用cur 0遇到[-3,-1,-2]第一次cur max(-3, 0 (-3)) -3结果其实是能算对的但如果在某些实现里先更新best再判断就容易出错。稳妥做法是cur和best都初始化为nums[0]然后从i1开始遍历。时间复杂度O(n)空间O(1)这已经是单次查询的最优解。3. 动态规划与贪心的细节拆解3.1 DP状态设计与转移方程O(n)解法从动态规划角度看状态定义是dp[i] 表示以 nums[i] 结尾的最大子数组和为什么是“以i结尾”而不是“前i个元素中的最大子数组和”因为状态转移需要依赖上一个状态的连续性。如果我们定义成“前i个元素里的最大子数组和”那dp[i]和dp[i-1]之间没有直接递推关系因为最大子数组不一定以i-1结尾。转移方程dp[i] max(nums[i], dp[i-1] nums[i])翻译成人话就是每个位置只有两条路要么自立门户从自己开始要么跟在前面那个连续段后面。取大者。最后答案不是dp[n-1]而是max(dp[0..n-1])这一点特别容易记错。因为最大子数组可能结束在任意位置不一定结束在数组末尾。用一个例子走一遍nums [-2, 1, -3, 4, -1, 2, 1, -5, 4]dp[0] -2dp[1] max(1, -21) 1dp[2] max(-3, 1-3) -2dp[3] max(4, -24) 4dp[4] max(-1, 4-1) 3dp[5] max(2, 32) 5dp[6] max(1, 51) 6dp[7] max(-5, 6-5) 1dp[8] max(4, 14) 5dp数组是[-2, 1, -2, 4, 3, 5, 6, 1, 5]最大值是6对应子数组[4,-1,2,1]答案是6和题目示例一致。3.2 贪心角度怎么理解Kadane算法也经常被归为贪心只要发现“当前累加和是负数”就直接丢弃它因为负数无论加到后面的哪个位置都只会拖累总和。这就是那句经典说法的来源如果cur 0就重置cur 0再开始累加。这种写法逻辑上等价于上面的转移方程我实测下来代码也很短int maxSubArray(vectorint nums) { int cur 0, best nums[0]; for (int x : nums) { cur x; best max(best, cur); if (cur 0) cur 0; } return best; }但这里有个隐患把“当前累加和是否小于0”作为是否重置的判据其实没有考虑到“当前累加和可能是正数但小于之前最优值”的情况。好在这种情况不需要重置因为后面还有机会再涨起来。最核心的一点就是负的前缀一定要丢掉正的前缀可以留着慢慢积累。面试官如果追问“为什么负了就一定丢掉万一后面有超级大的正数呢”答案是因为负数会导致总和变小而丢掉这个前缀意味着我们选择从后面的元素重新开始这是一种更优策略。但如果前缀是正数呢那就要继续保留因为加上正数至少不会变差。3.3 为什么用滚动变量而不是dp数组很多教程里会先给一版完整的dp[n]数组int maxSubArray(vectorint nums) { int n nums.size(); vectorint dp(n, 0); dp[0] nums[0]; int best dp[0]; for (int i 1; i n; i) { dp[i] max(nums[i], dp[i-1] nums[i]); best max(best, dp[i]); } return best; }这个版本理论上是标准DP但实际比赛和面试里很少这么写因为dp[i]只用到了dp[i-1]历史状态完全用不到没必要为所有状态开一段额外空间。这就引出了“滚动变量”的必要性用cur替代dp[i]用best替代“对dp数组取max”空间从O(n)降到O(1)。另外在刷题环境里开一个长度为n的dp数组本身还要考虑数组初始化的问题。C的vector默认会按元素类型初始化为0但如果你用原生动态数组比如int* dp new int[n]数据就是随机值很容易埋雷。这里额外提一句如果你在C里用unique_ptrint[] dp(new int[n])来管理动态数组记得它生成的是原始数组指针可以像普通数组一样用dp[i]访问但dp本身不是char*那种类型别混着用。4. 分治与线段树/树状数组扩展4.1 分治法的思路与实现O(n)的Kadane已经最优了为什么还要学分治一方面是因为面试官喜欢连环追问另一方面是分治思路可以推广到“区间查询最大子段和”这类更复杂的问题也是线段树的基础。分治的核心思路很清晰把数组从中间劈成两半最大子数组和只会出现在三种位置完全在左半边完全在右半边跨越中点的连续段。前两种情况递归求解第三种情况需要从中点向左累加找到最大后缀再从中点向右累加找到最大前缀两者相加。int crossSum(vectorint nums, int l, int r, int mid) { int leftSum INT_MIN; int sum 0; for (int i mid; i l; i--) { sum nums[i]; leftSum max(leftSum, sum); } int rightSum INT_MIN; sum 0; for (int i mid 1; i r; i) { sum nums[i]; rightSum max(rightSum, sum); } return leftSum rightSum; } int divideConquer(vectorint nums, int l, int r) { if (l r) return nums[l]; int mid (l r) / 2; int left divideConquer(nums, l, mid); int right divideConquer(nums, mid 1, r); int cross crossSum(nums, l, r, mid); return max(left, max(right, cross)); }分治法的时间复杂度是O(n log n)因为每一层递归都要扫描一遍数组。空间复杂度O(log n)来自递归栈。这里容易写错的地方是跨中点求和。很多人会直接把左半部分最大后缀和右半部分最大前缀和分开算没问题但要注意跨越中点的子数组必须同时包含nums[mid]和nums[mid1]边界不能错。4.2 从“单次查询”到“区间查询”线段树/树状数组如果你问“树状数组能不能解这题”我的回答是树状数组擅长维护前缀信息、区间和、前缀最值这类可差分或支持增减操作的问题但“最大子段和”不是简单的可差分信息直接用树状数组并不自然。真正自然的扩展是线段树。线段树的每个节点需要维护四个值sum区间总和lmax以区间左端点为起点的最大前缀和rmax以区间右端点为终点的最大后缀和max区间内的最大子段和。合并两个子节点时核心公式是sum left.sum right.sum lmax max(left.lmax, left.sum right.lmax) rmax max(right.rmax, right.sum left.rmax) max max(left.max, right.max, left.rmax right.lmax)这套合并规则就是分治思路的在线化版本。一旦支持了区间合并就可以做“区间查询最大子段和”“单点更新”等操作复杂度都是O(log n)。这也是从“一道面试题”走向“工程数据结构”的关键一步。如果你真的要在竞赛或工程里用“树状数组”硬做有一种变通思路是把它用来维护前缀最小值配合前缀和求区间差的最大值等价于求最大连续和。但这个写法绕而且维护起来不如线段树直观我一般不建议。5. 多语言实现与代码细节5.1 C实现与边界处理C版本最常见也是我面试时写得最多的。除了核心逻辑还有几个点容易出事。第一是整数类型。如果数组长度很长、元素绝对值很大cur x可能溢出int。LeetCode原题没有特别大的数字但我在实际业务里遇到过单日流水很大的场景稳妥起见可以用long long。class Solution { public: long long maxSubArray(vectorint nums) { long long cur 0, best nums[0]; for (int x : nums) { cur maxlong long(x, cur x); best max(best, cur); } return best; } };第二是数组初始化问题。如果你在局部写int dp[n]这种变长数组在C标准里其实不算合法部分编译器会支持但可移植性差。正规做法是vectorint dp(n, 0)或者unique_ptrint[] dp(new int[n])。第三是遍历方式。基于范围的for (int x : nums)最简洁但如果你需要同时知道下标就得用普通for循环。个人经验是在纯算法实现里能避免下标就越少出错因为很多bug都来自下标越界。5.2 Java实现数组引用与复制Java版核心逻辑和C一样但Java数组天生是对象传参、赋值都要注意引用问题。class Solution { public int maxSubArray(int[] nums) { int cur 0, best nums[0]; for (int x : nums) { cur Math.max(x, cur x); best Math.max(best, cur); } return best; } }这里有个Java容易被坑的点如果面试时你需要把数组作为参数传进方法实际上传的是引用方法内部对数组元素的修改会影响到原数组。如果你不希望在原数组上操作就需要显式复制。复制数组有几种方式int[] copy nums.clone();最简洁int[] copy Arrays.copyOf(nums, nums.length);最常用System.arraycopy(nums, 0, copy, 0, nums.length);最快但代码最啰嗦。另外int[]数组默认初始化为0Integer[]数组默认是null如果你写的是对象数组而不是基础类型数组别直接拿去做数值累加会抛空指针异常。5.3 JavaScript/TypeScript实现数组方法与类型前端面试现在也爱考这题JS版常见的坑是有人想用reduce来写但reduce的回调里维护外部变量比较别扭。我建议直接用for...of最清晰var maxSubArray function (nums) { let cur 0; let best nums[0]; for (const x of nums) { cur Math.max(x, cur x); best Math.max(best, cur); } return best; };TypeScript版稍微有点区别因为要处理类型。如果nums是从接口拿到的动态数据可能不是标准数组需要先做类型收窄或者转换成数组。常用方法包括Array.from()、Array.isArray()判断。顺便提一句网上搜“数组去重”时经常看到用Set或reduce的方法但这道题完全不适用。最大子数组和要求保持元素顺序和连续性任何去重、排序、改变原数组的操作都会把事情搞砸。如果你在调试时看到结果不对先检查是不是自己用了sort或者Set这是实战里真实发生过的事。5.4 解法复杂度与代码量对比解法时间复杂度空间复杂度核心思想适用场景暴力三重循环O(n^3)O(1)枚举所有区间仅仅确认题意前缀和优化O(n^2)O(n)区间和转为前缀差分数据量小、理解前缀和贪心/DPO(n)O(1)滚动变量维护局部最优单次查询首选分治O(n log n)O(log n)跨中点三段合并扩展线段树思路线段树O(log n)每次O(n)区间合并信息动态修改区间查询从实际面试角度写O(n)的Kadane是最稳妥的。但如果你能主动把分治和线段树思路也讲出来绝对是个加分项。6. 常见错误与排查技巧实录6.1 初始化成0的全负数惨案这个错误在我见过的代码里出现率最高。错误写法长这样int best 0; for (int x : nums) { cur Math.max(x, cur x); best Math.max(best, cur); // 全负数时这里永远是0 }当输入是[-3,-1,-2]时cur分别是-3,-1,-2best始终max(0, 负数)0最后返回0正确答案应该是-1。解决办法很简单best必须初始化为nums[0]。这是全负数用例的经典陷阱也是面试官最喜欢设置的测试点。6.2 只记dp公式但忘记“以i结尾”含义还有一类错误是理解了转移方程但把答案写错。有人会写成int dp nums[0]; for (int i 1; i n; i) { dp Math.max(nums[i], dp nums[i]); } return dp;问题在于dp被整体当成“最大子数组和”来滚动最后返回的dp只是“以最后一个元素结尾的最大子数组和”而不是全局最大。只有当最优解恰好结束在末尾时才正确。所以必须额外维护一个best来记录历史最大值。6.3 整数溢出与类型转换LeetCode原题数字一般不会溢出但如果你用C的int做累加遇到很大的测试数据就可能越界变成负数导致结果完全乱掉。比如数组元素都是10^9级别长度是10^5那最大累计值可能在10^14级别必须用long long。Python不需要考虑这个问题因为它的大整数是自动扩展的但Java和C都必须留意。如果要排查是不是溢出我常用的一个土办法是把数组转成字符串打印出来再看累加过程的中间值。C里可以用循环手动拼接Java用Arrays.toString(nums)JS用JSON.stringify(nums)直观看到每一步cur的变化基本能定位问题。另外在C里如果想把数组元素直接参与运算时要小心类型提升。比如int和long long混用最好显式static_castlong long避免隐式转换带来的意外截断。6.4 调试建议从样例到反例刷这题的时候我建议你至少准备好这几组测试用例[1]单元素数组验证边界。[-1,-2,-3]全负数验证初始化。[-2,1,-3,4,-1,2,1,-5,4]题目标准样例验证整体逻辑。[5,-1,-2,3]验证负数中间段是否被正确丢弃。[8,-19,5,-4,20]验证最优解在数组中间偏后位置的情况。每写完一版解法都用这几组数据过一遍能快速发现自己写的到底对不对。7. 面试现场与扩展思考7.1 面试官会怎么追问一道LeetCode 53能问出的东西远比表面多我自己面试别人时常从这几个角度连环追问“为什么负数前缀要丢弃请从贪心角度解释一下。”“如果数组是环形的也就是首尾相连怎么求最大子数组和”“如果改成二维矩阵求最大子矩阵的和你能降到什么复杂度”“如果给你一个数组频繁修改某个元素还要频繁查询区间最大子段和怎么做”这些问题单独拎出来都能成为一道新题但底层思路全都能从这题延伸出去。比如“寻找两个正序数组的中位数”这类题虽然也涉及数组但核心是二分排除和这边的“连续区间最优”不太一样别混在一起记。7.2 环形数组的最大子数组和环形数组场景下最大子数组和分成两种情况不跨过边界就是普通的最大子数组和跨过边界等价于“数组总和 - 最小子数组和”因为跨环部分实际上绕过了中间一段最不适合保留的元素。所以解法是maxSum max(普通最大子数组和, 总和 - 最小子数组和)但要注意一种特殊情况如果所有元素都是负数总和 - 最小子数组和算出来有可能是0此时正确答案应该是数组里的最大值而不是0。这是很容易翻车的一个点需要单独判断。7.3 二维矩阵最大子矩阵和如果把数组换成二维矩阵要求找出一块连续的子矩阵使和最大思路是把行压成一维。固定子矩阵的上边界top和下边界bottom把每一列在top到bottom之间的元素累加成一个一维数组然后对这个一维数组调用一次最大子数组和算法。枚举所有top bottom组合复杂度是O(n^2 * m)这也是很多在线编程题的标准解法。这类“降维”思维在实际工作中非常有用把二维问题拆成多回合的一维问题每一个回合都复用已有函数代码量少也容易测试。7.4 差分数组和树状数组的适用边界最后补一句关于数据结构选型的体会。数组类问题里除了这题还有一类常见的是“区间修改、区间查询”比如差分数组能高效处理多次整体增减树状数组适合动态维护前缀和、单点更新、区间查询。但“最大子段和”这个指标并不满足普通可差分性质所以用树状数组会很别扭。正确的工具选择是线段树因为线段树天然支持区间合并信息。选择数据结构不是背模板而是看“信息能不能合并、支不支持动态修改”这是我做过很多题之后最深刻的感受。写在最后的一些个人体会刷算法题这么多年最大子数组和是我认为最适合用来“检验是否真正理解动态规划”的题目。它代码量很短但把状态定义、转移方程、滚动变量优化、边界处理全占了。我的建议是不要只背Kadane算法每一步的“为什么”都要能解释清楚。遇到全负数用例时想想为什么best要从nums[0]开始遇到环形场景时想想跨环和最小子数组之间的联系遇到频繁修改场景时想想线段树的四元组怎么合并。能把这一连串问题想明白比单纯刷十道重复题有用得多。最后再分享一个小技巧每次写完这题我都会顺手把[-2,1,-3,4,-1,2,1,-5,4]手算一遍既能验代码也能巩固状态转移的直觉。
返回列表