ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

最大子数组和全解析:从动态规划到Kadane与线段树

最大子数组和全解析:从动态规划到Kadane与线段树 做了这么多年算法题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(vectorint 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(vectorint nums) { int n nums.size(); vectorint 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]必须单独初始化因为状态转移从 i1 开始。我们拿题目自带的示例来手推一遍。nums [-2, 1, -3, 4, -1, 2, 1, -5, 4]一步步来dp[0] -2ans -2i1max(-2 1, 1) 1ans 1。注意这里 dp 从 -2 跳到了 1说明我们果断丢掉了 -2从 1 重新开局。i2max(1 (-3), -3) -2ans保持 1。当前“以 -3 结尾的最大段”是 -2是负数。i3max(-2 4, 4) 4ans 4。继续丢掉负累赘从 4 重新开始。i4max(4 (-1), -1) 3ans 4。这里没有丢掉 -1因为dp[3]4还是正数有增益。i5max(3 2, 2) 5ans 5i6max(5 1, 1) 6ans 6i7max(6 (-5), -5) 1ans保持 6i8max(1 4, 4) 5ans保持 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(vectorint 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(vectorint nums) { return divide(nums, 0, nums.size() - 1); } int divide(vectorint 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: vectorint maxSubArrayWithRange(vectorint 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或使用了“cur0 直接清零再更新 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初始化为 0cur初始化为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我可能一直不理解为啥横跨中点的最大子数组和可以拆成后缀加前缀两个独立问题。这种“用不同视角反复看同一个问题”的练习比盲目刷十道新题管用得多。
返回列表