资讯动态

最大子串和问题全解析:从暴力枚举到Kadane动态规划

发布时间:2026/10/17 5:12:50 来源:尧图企业网站定制
聊到算法里的“定海神针”最大子串和绝对排得上号。我第一次看到这个题的时候还以为是字符串匹配那类问题结果它其实是个数组问题从一个整数数组里找一段连续区域让这一段的累加和最大。别看题干只有一句话它背后藏着一条从O(n³)暴力到O(n)动态规划的完整进化链。无论你是在准备面试、打比赛还是处理股票收益曲线、监控指标波动这类数据场景都会跟它打交道。这个问题最经典的一个例子是这样的给定数组 [-2, 1, -3, 4, -1, 2, 1, -5, 4]最大子串是 [4, -1, 2, 1]累加和为 6。注意这里的“子串”要求连续不是让你随便挑几个数拼起来这是整个问题的核心约束。我见过不少刚入门的朋友一上来就当组合问题做最后怎么算都不对其实就是被“连续”这两个字绊住了。1. 问题定义与暴力解的起点1.1 先把最大子串和翻译成人话用正规一点的说法给定一个整数数组nums需要找到一个连续子数组通常长度至少为 1使得这个子数组的元素之和最大返回这个最大值。这个定义本身不复杂但里面藏了三个容易忽略的点。第一“子数组”必须是连续的。也就是说你可以选择nums[i]到nums[j]这一段其中 i 可以等于 j但不能跳过中间的元素。第二“长度至少为 1”这个要求看起来理所当然但有些变种题会允许空数组导致最终答案可能是 0两者的处理方式完全不同。第三题目通常只说“返回最大值”不要求你输出是哪一段但实际工程里往往需要你把区间一起拿出来这个我后面会专门讲。为什么这个问题值得认真学因为它几乎是所有连续区间类问题的母题。后面你遇到的最大子矩阵和、最长递增子序列、股票买卖时机、信号处理中的峰值检测很多都能从这个模型延伸出去。可以说把这一道题吃透等于给后续若干个算法题打了地基。1.2 为什么 O(n³) 暴力也值得亲手写一遍很多教程上来就给你最优解但我的经验是暴力解一定要自己写一遍尤其是 O(n³) 这个版本。它虽然慢但逻辑最直白能帮你建立“枚举所有可能区间”的基本直觉。写法很简单枚举左端点 i再枚举右端点 j然后从 i 到 j 把这段区间累加一遍每次累加完都更新一下全局最大值。代码大概长这样def max_subarray_bruteforce(nums): n len(nums) ans float(-inf) for i in range(n): for j in range(i, n): cur 0 for k in range(i, j 1): cur nums[k] ans max(ans, cur) return ans三层循环对应三个问题区间从哪里开始、在哪里结束、区间内部怎么求和。时间复杂度是 O(n³)空间复杂度是 O(1)。为什么说它也有价值因为你在实现任何更“高级”的算法时都需要一个简单正确的版本做对拍。把暴力解跑一遍拿到标准答案再去跑 Kadane 算法比对输出是否一致这是我在开发环境里常用的验证手段。另外暴力解在 n 很小的时候其实完全够用。比如处理一段只有几十个点的传感器数据O(n³) 和 O(n) 的差距根本感知不到但代码的简单程度却直接决定了维护成本。所以不要盲目鄙视暴力解它是兜底方案也是你理解问题的起点。1.3 用前缀和把 O(n³) 压到 O(n²)三层循环里最傻的操作是从 i 到 j 一次次重复累加。明明这个区间和可以提前算好只要维护一个前缀和数组。前缀和的定义很简单prefix[0] 0prefix[i] nums[0] ... nums[i-1]那么区间[i, j]的和就是prefix[j1] - prefix[i]。优化后的代码变成两层循环def max_subarray_prefix(nums): n len(nums) prefix [0] * (n 1) for i in range(n): prefix[i 1] prefix[i] nums[i] ans float(-inf) for i in range(n): for j in range(i, n): ans max(ans, prefix[j 1] - prefix[i]) return ans这样时间复杂度降到 O(n²)关键是让你意识到一件事很多“求和”问题都可以先用前缀和把 O(n) 的费用摊到预处理里后面用到的时候只需要 O(1) 查询。这个思想本身比题目更值钱。不过 O(n²) 仍然不够快当 n 到十万级别O(n²) 就意味着百亿次运算线上环境基本不可能接受。到了这一步就该请出分治和动态规划了。2. 分治解法把区间一切两半2.1 分治模型的核心假设分治的思路是一个区间要么完全在左半边要么完全在右半边要么横跨中点。把数组从中间分成左右两部分递归地求左半边最大子串和、右半边最大子串和再单独计算横跨中点的最大子串和三者取最大值就是整个数组的答案。递归终点很好处理当区间只剩下一个元素时最大子串和就是它本身。这个思路的巧妙之处在于它把“复杂区间”的问题拆成“两半区间 一个跨中点特例”并且跨中点的部分有一个固定套路从中点向左延伸记录过程中能得到的最大累加和从中点向右延伸同样记录最大累加和两者相加就是横跨中点的最优解。为什么一定要从中点开始向两边扩展因为横跨中点的子串必然包含nums[mid]和nums[mid1]所以左端点只能在中点左边取右端点只能在中点右边取。2.2 代码实现与复杂度验证def max_subarray_divide(nums, left, right): if left right: return nums[left] mid (left right) // 2 left_max max_subarray_divide(nums, left, mid) right_max max_subarray_divide(nums, mid 1, right) # 计算横跨中点的最大和 left_sum float(-inf) cur 0 for i in range(mid, left - 1, -1): cur nums[i] left_sum max(left_sum, cur) right_sum float(-inf) cur 0 for i in range(mid 1, right 1): cur nums[i] right_sum max(right_sum, cur) cross_max left_sum right_sum return max(left_max, right_max, cross_max)时间复杂度的递归式是T(n) 2T(n/2) O(n)根据主定理结果就是 O(n log n)。这个复杂度虽然不如 O(n)但在算法面试中仍然是很好的答案因为分治展示了你的递归功底。我个人觉得分治版本至少有三个值得记住的工程点。第一计算跨中点部分时每次向左或向右扩展都是独立进行的不能把左边扩展完的结果直接接到右边否则就不是“横跨中点”的最优组合。第二递归终点必须覆盖空区间所以left right直接返回当前值正好隐式处理了所有单元素区间。第三分治代码天然支持“结果区间”的回溯只要在每个递归层记录最优区间位置最后就能拼出完整的子串这在某些需要输出区间的场景下反而比 Kadane 更直观。3. Kadane 算法三行状态转移的威力3.1 状态定义是这个算法的灵魂动态规划的关键永远在状态设计。Kadane 算法创建了一个非常巧妙的定义dp[i]表示以nums[i]结尾的最大子串和注意“以 i 结尾”这个约束它意味着子串必须包含nums[i]而且只能往前连续延伸。为什么这个定义有效因为任何一个连续子串总有一个右端点所以只要我能算出每个位置作为右端点时的最优值再对所有位置取最大值就一定能得到全局最优解。这个把“所有区间”压缩成“所有右端点”的想法是整道题从 O(n²) 降到 O(n) 的本质。状态转移也极其简单dp[i] max(nums[i], dp[i-1] nums[i])。翻译成直觉就是站在当前位置上你有两个选择。要么把当前元素加到之前那个区间的尾巴上即dp[i-1] nums[i]要么抛弃之前所有累加重新从当前元素开始即nums[i]。3.2 空间优化后的终版代码上面这个转移方程只依赖dp[i-1]所以根本不需要存储整个 dp 数组用一个变量滚动维护即可。这就是大多数人看到的 Kadane 算法def max_subarray_kadane(nums): cur 0 ans nums[0] # 注意初始值 for x in nums: cur max(x, cur x) ans max(ans, cur) return ans这段代码的缺点是藏着一个小陷阱如果nums[0]是负数而后面某个子串的和是正数算法依然能正确找到最大值因为每一次cur都会被当前元素重新校准。比如数组全是负数cur会一路变成最大的那个负数最后ans取到的就是最接近 0 的负数符合“子串长度至少为 1”的要求。如果你把ans初始化为 0那全是负数的场景就直接输出 0 了这是不对的后面我会专门讲这个坑。3.3 它是贪心还是动态规划关于 Kadane 算法面试官常问的一个问题是这算是贪心还是动态规划我的理解是它本质上是动态规划因为状态转移基于一个明确的子问题最优解也就是“以 i 结尾的最大子串和”。但由于它每一轮只做一次局部决策很多人会误以为是贪心。有一个不容易注意到的点当cur x x时也就是之前累加已经变成负收益时我们选择从 x 重新开始。这个行为表面看是“贪心地丢掉”但背后的数学依据是负的前缀和只会让后续结果变差因为只要它小于 0加上任何正数都不如直接丢弃它。换句话说如果一段区间的累计和已经是负的那它作为前缀没有任何保留价值这是“丢弃”的正确性来源。我自己在做这个推导的时候最大的感悟是Kadane 算法把“枚举左右端点”降维成“只枚举右端点”通过纪录“到当前位置为止最好的开头”把二维的搜索空间压成了一维。这个降维思想在后续很多区间问题里都会反复出现。4. 变种问题与真实工程影响4.1 子串必须非空时的边界处理刚才说过有些变种题允许返回空子串比如“最大子串和可以为 0”这时候代码就变成把cur清零、ans初始化为 0。但如果原题要求子串至少含一个元素零初始化就是致命的。我有个习惯遇到最大子串和类问题先确认题目里有没有“至少一个元素”这个限定。如果没有明说标准解通常按非空处理保证ans初始化为第一个元素。这个边界差异在 LeetCode 风格题和 ACM 风格题里还不一样读题时花十秒钟确认能省很多调试时间。还有一种常见变体是“子串和最大且可以丢弃任意前缀”这基本就是 Kadane 算法的标准场景直接套代码即可。4.2 输出子串本身怎么办实际工程里“返回最大和”往往不够你还需要知道是哪一段区间。此时只需要在更新cur时记录当前子串起点在更新ans时记录最优起点和终点def max_subarray_with_indices(nums): cur 0 ans nums[0] start end 0 temp_start 0 for i, x in enumerate(nums): if cur x x: cur cur x else: cur x temp_start i if cur ans: ans cur start temp_start end i return ans, nums[start:end 1]这个版本清晰地体现了状态转移的决策点什么时候“从当前元素重开”什么时候“继续延伸”。记录区间后你就能直接定位到收益率最高的一段时间或者传感器数据里最异常的一段波动。4.3 环形数组、二维矩阵与最大积扩展掌握了基础版之后下面这几个方向非常值得延伸。环形数组最大子串和。数组可以首尾相接比如[5, -3, 5]的最大环形子串和是 7也就是整个数组。解法是先求普通最大子串和再求普通最小子串和答案取max(普通最大值, 总和 - 普通最小值)。需要特别注意边界如果整个数组都是负数那“总和 - 普通最小值”会得到 0 甚至负值此时要退回普通最大值。二维矩阵最大子矩阵和。这就是把最大子串和从一维扩到二维。先枚举行范围的上下边界然后按列压缩成一维数组再对这个一维数组跑 Kadane。时间复杂度是 O(m²n)其中 m 是行数。这个方法在图像处理和金融风控里都常用因为你要找的是“某几行、某几列”构成的矩形区域。最大子数组乘积。因为负数乘负数会变正数单靠“以 i 结尾的最大值”不够还需要同时维护一个最小值。状态转移变成max_here max(x, max_here * x, min_here * x)以及min_here min(x, max_here * x, min_here * x)。这道题是 Kadane 思想的强化版非常考验状态定义的完备性。4.4 应用场景从股票收益到异常检测最大子串和不是只在课本里出现。做股票收益分析时把每日收益率转换成数组最大子串和对应的就是“连续持有收益最大”的区间这个区间往往对应一波趋势行情。我见过一个简化策略就是用 Kadane 找出历史最佳持有区间再用最近一段数据的走势做对比判断当前是否处于类似的启动位置。在数据库监控和系统告警领域它也很有用。假设你有一系列按时间排序的指标增量可能正可能负小于零说明指标在下降。用最大子串和可以定位出“指标持续恶化最严重”的那段时间段直接锁定故障窗口。很多监控系统里“异常区间检测”的雏形其实就是这个算法。在生物信息学里DNA 序列的“局部高得分片段”分析也用类似思想寻找连续序列中分值最高的片段。可以说最大子串和虽然短小但它在时序数据分析里的地位相当于“排序”在数据处理里的地位是很多复杂算法的地基。5. 常见问题、调试心得与复杂度选型5.1 新手最容易踩的五个坑第一个坑是初始值设置错误。把ans初始化成 0然后输入[-1, -2, -3]输出会错误地变成 0。正确做法是初始化成第一个元素或者初始化成float(-inf)。第二个坑是混淆“子序列”和“子串”。子序列可以不连续子串必须连续。如果按子序列做复杂度就变成排序取正数完全不是同一个问题。第三个坑是忽略长度为 1 的子串。有些新手在做转移时只考虑dp[i-1] nums[i]忘了还有nums[i]本身导致数组全是负数时输出错误。第四个坑是分治实现里跨中点部分写错。常见的错误是在扩展左侧时直接把右边也一起加了或者把左侧最优解和右侧最优解相加这得到的可能并不是横跨中点的完整区间。第五个坑是盲目把 Kadane 用到二维问题上。二维最大子矩阵的解法要对行范围做枚举不能只做两遍 Kadane。这个坑我在实际项目中踩过最后调试了很久才发现是维度处理出了问题。5.2 各版本复杂度对比与选型建议方法时间复杂度空间复杂度适用场景朴素暴力O(n³)O(1)数据量极小对拍验证前缀和优化O(n²)O(n)数据量中等更好理解区间和分治O(n log n)O(log n) 递归栈面试展示递归思维输出区间较直观Kadane 动态规划O(n)O(1)生产环境首选绝大多数场景选型建议很简单如果 n 小于 1000不管用哪个都能跑完如果 n 是十万级分治可能还可以接受但 Kadane 明显更稳如果 n 是千万级甚至更大那就只有 O(n) 方案值得上线。Kadane 算法唯一需要担心的不是速度而是处理特殊边界时的逻辑正确性。5.3 实战中的一点个人体会我最后想分享一个在真实调试中特别有用的技巧当你面对一个区间类数据时可以先写一个暴力解作为参照再用随机大数据对 Kadane 或分治版本做对拍。我曾经遇到过一个数据处理任务表面看只是找峰值区间实际上数据里混了大量重复值和负值因为我的暴力版本一直保留着才很快定位到是“重开”条件写成了cur x 0而不是cur x x这个差异在正数数组里完全体现不出来只有负数扎堆时才会暴露。如果你打算把这个算法用在流式数据上Kadane 还有一个天然优势它逐元素更新数据可以一批一批进来不需要一次性加载全量数组。这意味着你可以把它嵌进某个监控程序里一边接收指标数据一边实时更新“到目前为止的最大区间”内存占用永远是常量级。最大子串和这道题从暴力到分治再到动态规划每一步都对应着一种算法思考方式。学它的时候别只看最优解把那几条进化路径都亲手敲一遍你才算真正入门了区间类问题。

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

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

免费获取报价 →
↑