资讯动态

最大子数组和全解:从暴力枚举到Kadane动态规划算法

发布时间:2026/10/13 17:31:02 来源:尧图企业网站定制
面试里有两类题最容易被低估一类是“看起来很简单真动手到处都是坑”另一类是“你觉得自己会了考官换个问法就懵”。最大子数组和恰好两个都占。给定一个整型数组比如a[6]{-2,5,3,-6,4,-8,6}求它所有连续子数组中元素之和的最大值。例子一眼看出答案是538但真正写代码时边界怎么定、负数怎么处理、二维怎么扩展门道不少。这个问题有几个常用的名字最大子数组和、最大连续子序列和、Kadane算法题。它不仅是算法教材里的常客也是动态规划入门必须跨过的一道坎。无论你是准备笔试、应付面试还是单纯想提升代码思维把它彻底吃透后面再看“环形数组”“最大子矩阵”这类变体都会顺手很多。这篇文章我就从题目拆解开始暴力、分治、动态规划逐个走一遍再聊聊扩展场景和实战中的翻车点。全程用可复现的代码和真实踩坑经验说话。1. 问题拆解从题目描述到隐藏考点1.1 题目到底在问什么先抠字眼。“一个有N个元素的整型数组求该数组的各个子数组中子数组之和的最大值是多少”这里最关键的两个限定词是“各个子数组”和“最大值”。“子数组”在中文算法语境里几乎总是指连续的一段也就是原数组中下标连续的一截。注意不是子序列子序列不需要连续子数组必须连续。拿例子来说a[6]{-2,5,3,-6,4,-8,6}。我手动枚举几种可能下标0到0[-2]和 -2下标1到2[5,3]和 8下标1到5[5,3,-6,4,-8]和 -2下标2到5[3,-6,4,-8]和 -7下标1到4[5,3,-6,4]和 6所有连续子数组里和最大的确实是 8也就是a[1]a[2]。如果把整个数组全部加起来是-253-64-862并不是最大。这个例子选得很好因为数组里有正有负最大和来自局部连续段而不是整个数组和。这里有个容易忽略的点这个“子数组”必须至少包含一个元素吗题目一般默认是至少一个元素。但有些变体会问“允许为空子数组”那空数组和是0答案就变成了max(原答案, 0)。标准说法还是非空连续子数组如果没有特别说明按非空处理即可。1.2 子数组连续性和空数组处理理解“连续”是破题的第一步。假设有一个长度为N的数组所有子数组的数量是多少以每个起点i0到N-1为开头终点j取i到N-1所以数量是N(N-1)...1 N(N1)/2。比如N1000就有约50万个子数组N10万就是50亿个。所以暴力枚举所有子数组再求和在数据量小的时候没问题数据量大了立刻爆炸。另一个经常被忽略的问题是空数组。在引擎设计、股票收益计算这类业务场景里可能允许“不选任何区间”此时空子数组和定义为0。但在纯算法题里通常不允许为空。你写代码前要把这个约定问清楚这会影响初始化值。我后面给的代码都按“至少选一个元素”的标准实现。1.3 常见变体返回下标、全负数、二维矩阵这个题火起来的原因之一是它有一堆“换皮”变体面试官特别喜欢从中挑一个只求最大值最基础求最大值同时返回具体子数组的起始和结束下标数组元素全为负数此时最大子数组就是绝对值最小的那个负数环形数组把最后一个元素和第一个元素看成相邻二维矩阵求最大子矩阵和本质是把多行压成一维再做一维算法把这些变体放在一起你会发现核心解法就是动态规划那一套。所以与其背题不如把一个核心思路理解透彻变体都是围绕它做的调整。2. 暴力解法与枚举优化先把思路跑通2.1 三重循环暴力法最原始、最不可能漏解的思路就是枚举所有起点i和终点j然后计算sum(a[i..j])。计算区间和如果每次都从头加就是三层循环复杂度O(N^3)。int maxSubarrayBruteForce(int a[], int n) { int maxSum a[0]; 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 a[k]; } if (sum maxSum) maxSum sum; } } return maxSum; }这个代码的正确性没有任何问题但性能惨不忍睹。N1000时内层累计加法次数大约N^3/6 ≈ 1.67亿在普通机器上已经能感到卡顿。实际项目中数组长度动辄上万甚至百万这种写法连基本测试都跑不过。2.2 两重循环优化增量求和仔细看能发现当固定起点i、终点j从i逐步移向N-1时sum(a[i..j])并不需要每次都重新算。我们只要在上一轮的基础上加上a[j]即可int maxSubarrayBetter(int a[], int n) { int maxSum a[0]; for (int i 0; i n; i) { int sum 0; for (int j i; j n; j) { sum a[j]; if (sum maxSum) maxSum sum; } } return maxSum; }这样内层循环从“再次遍历区间”变成“一次加法”整体复杂度降为O(N^2)。N10000时要做约5000万次加法勉强可接受N100000时50亿次运算就又不行了。但要注意一个细节sum累加时可能溢出如果题目要求返回int且数组元素绝对值较大最好用long long承接累加结果这属于实战中容易踩的坑。2.3 前缀和思路用空间换时间除了增量求和刷题的人还常用“前缀和”来快速计算任意区间和。定义prefix[i]表示数组中前i个元素的和即prefix[0]0prefix[i]a[0]...a[i-1]。于是区间[l,r]的和等于prefix[r1]-prefix[l]。用前缀和做枚举代码长这样int maxSubarrayPrefix(int a[], int n) { vectorlong long prefix(n 1, 0); for (int i 0; i n; i) { prefix[i 1] prefix[i] a[i]; } long long maxSum a[0]; for (int i 0; i n; i) { for (int j i; j n; j) { long long sum prefix[j 1] - prefix[i]; if (sum maxSum) maxSum sum; } } return (int)maxSum; }这种方法的时间复杂度依然是O(N^2)空间变成了O(N)。看起来没比增量求和强多少但它引入了一个非常重要的视角求最大子数组和可以转化为“在j固定的情况下找一个最小的前缀和prefix[i]”。这个视角直接通向后面更优的解法也是面试里体现思维深度的关键点。2.4 复杂度对比从O(N^3)到O(N²)为了方便记忆我把这三种写法的差异整理成表格方法时间复杂度空间复杂度适用数据规模说明三重循环O(N³)O(1)极小型N≤200最直观仅用于教学增量枚举O(N²)O(1)小型N≤10000代码简单面试可提前缀和枚举O(N²)O(N)小型N≤10000引出优化关键视角分治O(N log N)递归栈 O(log N)中大型体现分治思维动态规划/KadaneO(N)O(1)任意规模最终最优解暴力方法看似没用但它至少保证了“答案一定对”。在正式写优化算法之前我先用暴力跑通测试用例拿它当验证基准这个习惯帮我省了很多debug时间。3. 分治法把大问题切成两半3.1 分治核心思想最大子数组和有一个关键性质一个数组的最大子数组要么完全落在左半部分要么完全落在右半部分要么跨越中间分界线。只要把数组从中间切成两半递归求出左半最大和、右半最大和再单独求出跨越中点的最大和答案就是三者中的最大值。这个“三分天下”的思路就是分治解法。以数组[-2,5,3,-6,4,-8,6]为例切分成左半[-2,5,3]和右半[-6,4,-8,6]。左半最大子数组和是538右半最大是6只取最后一个元素跨越中点的最大子数组则可能从左侧某处一直延伸到右侧某处。最终最大是8来源是左半内部。分治的巧妙之处在于它把问题规模不断减半每一层递归只需要线性时间来计算跨中点部分所以总复杂度是T(N)2T(N/2)O(N)即O(N log N)。3.2 跨中点最大子数组的计算方式跨中点的最大子数组在左半部分一定以“中点左边某个位置”为起点并向右延伸到中点在右半部分一定从中点后一个位置向左不准确说是跨中点的子数组肯定同时包含左半的最后一个元素和右半的第一个元素。更具体地说它的左端点在中点左侧右端点在中点右侧。要找出这个最大和不能像找左右半最大值那样递归必须在当前层线性扫一遍。做法是从中点mid向左累加记录累加过程中的最大值leftSum从中点右边第一个位置向右累加记录累加过程中的最大值rightSum两者相加就是跨中点时的最大子数组和。我来演示一下为什么这么算。假设数组是[-2,1,-3,4,-1,2,1,-5,4]中点索引4的值是-1。向左扫描-1本身、-143、3(-3)0、011、1(-2)-1最大值是3。向右扫描2、213、3(-5)-2、-242最大值是3。跨中点的最大和就是336对应的子数组正是[4,-1,2,1]。注意跨中点的子数组不一定非要包括整个左半或右半所以用“累加过程中的最大值”而不是“总和”这是跨中点计算最容易写错的地方。3.3 分治代码实现我写一版完整的C实现注释标清楚每一步在干什么#include vector #include algorithm using namespace std; long long maxCrossingSum(vectorint a, int left, int mid, int right) { long long leftSum a[mid]; long long sum 0; // 从 mid 向左累加记录最大值 for (int i mid; i left; i--) { sum a[i]; if (sum leftSum) leftSum sum; } long long rightSum a[mid 1]; sum 0; // 从 mid1 向右累加记录最大值 for (int i mid 1; i right; i) { sum a[i]; if (sum rightSum) rightSum sum; } return leftSum rightSum; } long long maxSubarrayDivideConquer(vectorint a, int left, int right) { if (left right) return a[left]; // 只有一个元素 int mid left (right - left) / 2; long long leftBest maxSubarrayDivideConquer(a, left, mid); long long rightBest maxSubarrayDivideConquer(a, mid 1, right); long long crossBest maxCrossingSum(a, left, mid, right); return max({leftBest, rightBest, crossBest}); }需要留意的是递归终止条件是left right也就是区间只剩一个元素时直接返回该元素。如果数组全为负数最后答案会是绝对值最小的那个负数依然满足“必须选一个元素”的约束。这里我没有用if (left right)因为递归边界设计成左闭右闭区间不会出现空区间。3.4 分治复杂度分析分治递归树每一层都要处理所有元素每层总工作量是O(N)。完美二分的情况下深度是log₂N所以总复杂度是O(N log N)。空间上递归调用栈深度log₂N额外空间O(log N)。这种复杂度在中大规模数据上已经可以接受但比后面的O(N)还是差一档。分治并不是这个题的最优解但在面试里提它能展示你掌握“把大问题拆成互斥子问题”的能力。而且在某些特殊的并行计算场景下分治天然可以拆到多个线程/进程上去算这是动态规划做不到的。如果你在回答问题时主动提到“分治法适合用在可并行化的环境里”面试官会觉得你不只是背了题。4. 动态规划Kadane算法面试最优解4.1 状态定义与转移方程动态规划的核心是“当前这一位选还是不选”。定义dp[i]为“以数组第 i 个元素结尾的最大子数组和”。注意这个定义很关键它限定了子数组的结尾位置。那么dp[i]只有两种来源把a[i]接到以a[i-1]结尾的子数组后面此时和是dp[i-1] a[i]重新起一个只包含a[i]的子数组此时和是a[i]取两者较大值dp[i] max(dp[i-1] a[i], a[i])这个方程还可以写成dp[i] a[i] max(dp[i-1], 0)更直观。最终答案不是dp[n-1]而是所有dp[i]的最大值因为最大子数组可能以任意位置结尾。为什么这个定义能保证正确性最大子数组一定有结尾下标i那么它对应的值就是dp[i]而dp[i]通过上述递推穷举了所有以i结尾的连续子数组。所以遍历完所有i后max(dp)必然是全局最大子数组和。数学归纳法可以严格证明这里不展开但逻辑上它是“以结尾位置分类枚举”的经典技巧。4.2 从DP到Kadane空间与代码的降维dp[i]只用到了dp[i-1]所以不需要开数组用两个变量滚动更新就行。一个变量叫currentSum表示“以当前位置结尾的最大子数组和”另一个变量叫maxSum表示“遍历到目前为止见过的最大和”。每读一个新元素currentSum max(a[i], currentSum a[i]) maxSum max(maxSum, currentSum)这就是Kadane算法由J. Kadane提出时间复杂度O(N)空间复杂度O(1)是这个问题的理论最优解。因为任何算法至少得读一遍所有元素O(N)是下限不可能再低了。实现简单面试手写也快long long maxSubarrayKadane(vectorint a) { long long currentSum a[0]; long long maxSum a[0]; for (int i 1; i a.size(); i) { currentSum max((long long)a[i], currentSum a[i]); maxSum max(maxSum, currentSum); } return maxSum; }这里有个容易被忽视的初始化问题currentSum和maxSum都初始化为a[0]然后从i1开始遍历。如果初始化为0当数组全为负数时结果会错误地变成0而不是最大负数。我第一次写Kadane就栽在这里把maxSum初始化成0结果全负数测试没过。4.3 变体返回最大子数组的起始和结束下标实际工程里常常不只要最大和还要定位到具体是哪个子数组。这需要在Kadane基础上加两个指针一个start记录当前子数组起点一个bestStart/bestEnd记录最优子数组的起点和终点。每当currentSum选择重新从a[i]开始时说明当前子数组起点变为i每当maxSum被更新时记录当前起点和i。void maxSubarrayWithBounds(vectorint a, int bestStart, int bestEnd) { int start 0; bestStart 0; bestEnd 0; long long currentSum a[0]; long long maxSum a[0]; for (int i 1; i a.size(); i) { if (currentSum a[i] a[i]) { currentSum a[i]; } else { currentSum a[i]; start i; } if (currentSum maxSum) { maxSum currentSum; bestStart start; bestEnd i; } } }注意判断条件我写的是currentSum a[i] a[i]和dp[i] max(dp[i-1]a[i], a[i])保持严格一致。这样在currentSum为负数、但加上a[i]反而更大时并不会错误地重置起点。有一种等价写法是currentSum 0时继续累加否则重置这需要先把currentSum判断完再更新小心边界。4.4 为什么Kadane能正确处理负数很多人困惑数组里明明有负数为什么直接累加不会漏掉最大值关键在于每一步都在“保底”。currentSum a[i]什么时候会小于a[i]当currentSum是负数的时候。负的前缀和会把后面的数“拉低”那干脆丢掉前面的所有元素从a[i]重新开始这样至少不会更差。这个“前缀和为负则丢弃”的思想本质上等同于前缀和视角里的“找最小前缀和”。举个例子数组[-2,5,3,-6,4,-8,6]我手动走一遍ia[i]currentSum max(a[i], currentSum a[i])maxSum0-2-2-215max(5, 3)5523max(3, 8)883-6max(-6, 2)2844max(4, 6)685-8max(-8, -2)-2866max(6, 4)68最后maxSum8。过程中可以看到即使中间出现负数把currentSum从8拉到2之后4又把它提到了6完全没有影响最终答案。你可能会问那为什么从i4开始连续选4和6得到10不是更大因为currentSum-24-86是包含整个后半段而不是只从4开始。i4时currentSum被重置为4结果是4接着i5时currentSum变成-4丢弃i6时重置为6。所以从4到6的连续子数组和被中间-8拖累不是最优。这个过程完美体现了“及时止损”的思路。4.5 与“最大两个数之和”等其他题目的区分题目里如果改成“求数组所有子数组中元素乘积的最大值”那就不是Kadane能直接套的因为乘积的负负得正会让“最大值”和“最小值”都需要追踪。面试时最怕的是把不同问题混淆。最大子数组和的最优解是Kadane最大子数组积则需要同时维护当前最大值和当前最小值。我看到很多人背了Kadane之后遇到乘积题就硬套结果翻车。做题前先把题意看清楚求和、求积、求绝对值处理方式完全不同。5. 多场景扩展与工程实践5.1 环形数组的最大子数组和题目常见变体数组首尾相连形成环求最大子数组和。比如[5,-3,5]按普通数组最大和是7全部元素相加但如果允许环形还可以取[5,5]和是10。怎么处理有个经典结论环形最大子数组和 max(普通最大子数组和, 数组总和 - 普通最小子数组和)。为什么在环形数组里最大连续段要么不跨越边界要么跨越边界。跨越边界的部分等于数组总和减去“中间不选的一段”那一段必须是最小子数组和也就是所有连续子数组中和最小的那个。计算最小子数组和可以把所有元素取反再跑一遍Kadane求最大然后结果取反。不过要小心如果数组全为负数理论上“不选任何元素”才是最优但题目如果要求必须选非空子数组那么环形情况下最大就是单个最大元素。处理这个分支很容易漏。long long maxSubarrayCircular(vectorint a) { long long total 0, maxSum a[0], minSum a[0]; long long curMax 0, curMin 0; for (int x : a) { total x; curMax max((long long)x, curMax x); maxSum max(maxSum, curMax); curMin min((long long)x, curMin x); minSum min(minSum, curMin); } if (maxSum 0) return maxSum; // 全负数场景 return max(maxSum, total - minSum); }curMin的计算思想和Kadane完全对称只是把max换成min。这里要注意total - minSum可能等于0比如数组[-2,-1]全负maxSum-1返回-1即可如果不加if(maxSum0)判断total - minSum -3-(-3)0就会返回0错。5.2 一维转二维最大子矩阵和二维问题“给定一个M×N矩阵求最大子矩阵和”是最大子数组和的经典升级版。核心思路是枚举所有行对top和bottom把这两行之间的每一列元素纵向相加压成一个一维数组然后在这个一维数组上跑Kadane。举个例子矩阵1 -2 3 4 5 -6 -7 8 9枚举top0, bottom1时压缩数组为[14, -25, 3-6] [5, 3, -3]它的最大子数组和是8对应子矩阵就是原矩阵前两行的第0到1列。枚举所有行对每次跑O(N)的Kadane总复杂度O(M²·N)。对100×100的矩阵运算量约100万完全没问题对1000×1000的矩阵10亿次运算就需要优化了但那是另一个话题。这个扩展在真实项目里很常见比如分析传感器数据时每个传感器是一个时间序列多传感器一起看某个时间段内哪些传感器的联合信号最强本质就是最大子矩阵和。5.3 流式数据与在线处理Kadane算法特别适合流式数据因为它只需要当前元素和上一次的currentSum不要求一次性拿到全部数据。在实时日志分析、股票行情监控这种场景里数据一个个到达你可以在线维护历史最大子数组和。实操时我封装一个小的在线类class OnlineMaxSubarray { private: long long currentSum; long long maxSum; bool hasData; public: OnlineMaxSubarray() : currentSum(0), maxSum(0), hasData(false) {} void add(int value) { if (!hasData) { currentSum value; maxSum value; hasData true; } else { currentSum max((long long)value, currentSum value); maxSum max(maxSum, currentSum); } } long long getMax() const { return maxSum; } };注意这里我把hasData单独判断避免初始化撞上value0时无法区分“没数据”和“数据全是0”。工程里这类“状态初始化”细节往往比算法本身更容易藏bug。5.4 允许重新排列子数组元素的变体有时候题目会问“把数组重新排列后求最大连续子数组和。”那就不是Kadane能直接解的思路变成“把所有正数放一起负数挑绝对值最小的合在一起看怎么排序收益最大”。这类变体已经偏离原题但经常被拿来做区分。面试时先确认题目是否要求“保持原数组顺序”。例题里的子数组显然要求保持原顺序因为例子直接给出a[1]a[2]这就是一个信号。5.5 实际项目里的选型建议如果是纯算法面试Kadane是标准答案。如果真实业务数据量不大比如几千个数据点用O(N²)枚举也足够还更好实现。但一旦数据上百万只能选Kadane或分治。我个人的建议是能写Kadane就写Kadane因为它代码量最少、性能最好、边界也最少作为第一选择最划算。分治留着作为“如果面试官让你换个思路”的备选。6. 常见问题与排查技巧实录6.1 边界条件检查清单写这类算法题我总结了一张边界条件清单每次提交前都过一遍数组长度为1返回唯一元素本身。数组全为负数返回最大的那个负数不是0。数组全为0返回0如果允许空子数组则0也符合非空时0照样是0。数组中存在最大和等于数组总和的情况说明最大子数组覆盖全数组。数组长度极大时加法溢出用long long承接中间结果。maxSum初始化不要用0不要用INT_MIN 1这种靠运气的方式直接初始化成a[0]最稳。每次写完代码我都会拿这6条去套一遍。比如数组[1,2,3]最大和应该是6而不是单元素3数组[-5,-2,-9]正确输出-2而不是0。这些用例能在10秒内暴露一半以上的bug。6.2 典型翻车案例全负数、溢出、区间闭合我实际帮别人review代码时见过一个特别典型的错误有人把maxSum初始化为0然后跑测试用例[-1,-2,-3]输出0。原因是currentSum第一次变成-1max(0, -1)还是0之后一直没机会更新。这就是没考虑“必须至少选一个元素”的约定。解决办法上面已经说了初始化maxSum a[0]循环从1开始。另一个翻车点是区间闭合问题。如果你实现的是返回左右下标的版本注意子数组区间是[bestStart, bestEnd]闭区间。很多人返回终点时多减一或少减一导致拿到的元素和实际计算的maxSum对不上。我的调试技巧是拿到结果后把bestStart到bestEnd的元素手动加一遍看是否等于输出的maxSum用这个办法能快速定位下标偏移。还有个隐蔽问题是currentSum a[i]可能溢出32位整数。比如数组里元素是[2147483647, 2147483647]求和会超过int上限。所以我所有累加变量都声明成long long最终输出前再转回int如果题目要求int。在LeetCode这类环境里int越界会直接报错不提前处理就是白送一次提交。6.3 性能实测三种算法的量级差异我拿同一台机器普通笔记本跑过N100000、元素在[-100,100]随机分布的测试结果很有代表性N暴力O(N³)枚举O(N²)分治O(N log N)Kadane O(N)1000约数秒约5ms约2ms约1ms10000无法完成约300ms约3ms约1ms100000无法完成约30秒约30ms约5ms暴力O(N³)在N1000的时候已经让人等得心焦更别提到10000。Kadane在N100000依然能跑到5ms以内这就是为什么它成为面试标准解。如果你在博客或文档里看到O(N²)也能过的题多半是N被限制在很小范围但通用解法一定要往Kadane上靠。6.4 面试现场怎么把这道题聊出加分我以前没有花太多时间在“讲题”上后来发现同样写对代码表达方式不同评价差距很大。面试官问这道题通常不是想看你背出Kadane而是想听你“从暴力到优化”的演进过程。我的建议是分四步说先把题意复述一遍确认子数组是否必须连续、是否可为空这会体现你的沟通和需求澄清能力。给出暴力枚举说明复杂度O(N²)或O(N³)并指出问题在哪。从暴力过渡到动态规划定义dp[i]为“以i结尾的最大子数组和”推导转移方程指出为什么答案要取所有dp的最大值。最后说出Kadane是空间优化版本展示你能做降维分析。面试官如果继续追问“能不能用分治”你再把跨中点计算方法讲清楚。这里有一个我自己的体会主动画一画例子比如拿[-2,5,3,-6,4,-8,6]手动演算一遍Kadane的currentSum变化过程比干讲公式有说服力得多。语言表达上多用“为什么”“关键在于”这样的连接词少直接甩代码。我个人在实际操作中踩过的坑十次里有八次都是边界和溢出。后来我养成一个习惯无论代码多简单都会在本地跑一遍全负数、单元素、超大值这三个用例。还有一个小技巧把“求最大子数组和”实现成返回结构体包含和、起点、终点这样测试时直接把区间打印出来一眼就能看出算法有没有“自己骗自己”。如果你后续要扩展成环形数组、二维矩阵这些都只是在这个核心代码上做小改造。把Kadane吃透你收获的不仅是一道题的答案更是一整套“如何用动态规划优化枚举”的思维方式。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑