news 2026/10/1 5:07:28

环形子数组的最大和:Kadane算法与边界处理全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
环形子数组的最大和:Kadane算法与边界处理全解析

环形子数组的最大和,第一次在力扣上看到这道题的时候,我其实没太当回事。毕竟“最大子数组和”几乎是动态规划入门必刷题,换个环形外壳能难到哪去?结果真被教做人了:普通版本的代码能一遍过,环形版本我连续交了三发,不是全负数用例没处理好,就是最终答案差一个边界。后来把这类题整理进算法训练营讲义时我才发现,环形子数组的最大和考察的根本不是“会不会写 Kadane”,而是“懂不懂状态到底在描述什么”。这篇文章就围绕这道题,把从暴力、优化到边界处理的完整链路讲清楚,适合准备面试、刷题进度的同学,也适合想把动态规划原理真正吃透的读者。

先看一眼题面:给定一个长度为 n 的整数数组,把它看成一个首尾相接的环形数组,求所有连续子数组中和的最大值。子数组至少包含一个元素,n 的范围通常到 10^5 级别,所以 O(n^2) 基本没戏。这里有个反直觉的地方:普通数组求最大子数组和只用一次线性扫描,为什么换成环形就要重新设计解法?答案就藏在“首尾相接”这四个字里。

1. 先分清两种子数组形态,别急着写代码

1.1 普通最大子数组和:Kadane 算法的底子

普通版本的问题很简单:在一个数组中找一段连续区间,让区间和最大。最经典的解法是 Kadane 算法,核心状态转移只有一行:

cur = max(num, cur + num)

我最早学这行代码的时候,只觉得它是“贪心”,后来才发现它是“状态定义”的胜利。维护一个变量 cur,表示以当前元素结尾的最大子数组和,那么每一轮只有两个选择:要么把当前元素接到前一个子数组后面,形成更长的连续段;要么抛弃前面所有累赘,从当前元素重新开始。取个 max,就是最优决策。

配合另一个变量 best,记录历史所有 cur 的最大值,最后返回 best,一次遍历搞定。这个算法的优点是时间 O(n)、空间 O(1),而且代码短到不容易出错。很多人刷题刷多了,Kadane 几乎是肌肉记忆,但我建议还是要把“为什么取 max 而不是一直累加”想明白,因为后面环形版本的坑,恰恰出在这种“以为懂了”的状态上。

1.2 环形到底改变了什么

普通数组是直线,子数组只能从头到尾顺着选;环形数组相当于把数组两端粘在一起,允许你从尾部某个位置开始,绕过头走到头部某个位置结束。比如数组 [5, -3, 5],普通最大子数组和是 5,但环形情况下,可以取末尾的 5 和开头的 5,中间跨过“边界”,答案是 10。这种“跨边界”的子数组,在普通数组里是选不出来的。

很多人第一反应是:既然需要跨边界,那把数组复制一倍接到后面,再用普通 Kadane 不就行了?这个思路方向是对的,但有个隐藏陷阱:复制一倍后跑普通 Kadane,可能会选出长度超过 n 的子数组。比如 [5, -3, 5] 复制成 [5, -3, 5, 5, -3, 5],普通 Kadane 会把中间的 5、-3、5、5、-3、5 全部加起来得到 14,等于把原数组绕了一圈还多,这在环形数组里是不合法的。子数组长度最多只能等于 n,所以“复制数组 + 普通 Kadane”不能直接用。

环形子数组只有两种形态:一种老老实实待在中间,不跨越首尾边界,本质就是普通最大子数组;另一种跨越了边界,此时它一定可以被描述成“数组总和的某个部分”。这个视角非常关键,它直接推导出最优解法。

2. 解法演进:从暴力到最优的必经路径

2.1 暴力枚举为什么不行

先算一笔复杂度账。最容易想到的做法是枚举起点和终点,把原数组当作环来取模访问,复杂度 O(n^2)。当 n = 10^5 时,最坏情况要执行 10^10 次运算,这在绝大多数判题环境下都是超时的。另一种“复制数组 + 双重循环”看起来简单,空间变成 O(n),时间还是 O(n^2),本质上没有改善。

暴力存在的意义不是给你 AC,而是帮你验证思路。我自己写题时有个习惯:拿到新题先想暴力,再用暴力输出结果和优化版本对拍。环形子数组的最大和尤其适合对拍,因为边界情况多,纯靠人肉推容易漏。保留一个 O(n^2) 的暴力函数作为“裁判”,是性价比很高的调试手段。

2.2 双情况法的公式怎么来的

把环形子数组分成两类考虑,就能绕开环形判题:

  • 情况一:最大子数组没有跨越首尾边界,它落在数组内部的某个区间。
  • 情况二:最大子数组跨越了首尾边界,比如 [5, -3, 5] 中的答案 10。

情况一直接用 Kadane 算。情况二怎么算?可以换个角度想:跨越边界的子数组,在数组内部被“舍弃”的恰恰是一段连续的子数组。比如 [5, -3, 5] 跨越边界取“末尾 5 + 开头 5”,中间被舍弃的就是 [-3]。要让跨边界的子数组和最大,等价于让这段被舍弃的连续子数组和最小。

于是情况二的最大值就是:数组总和 total - 最小子数组和。最小子数组和可以用 Kadane 的镜像版本求,把“最大”改成“最小”:

cur_min = min(num, cur_min + num)

最后答案取两种情况的最大值:

ans = max(best_max, total - best_min)

这个公式看起来简单,但有个前提:被舍弃的子数组不能是“整个数组”,否则跨边界的子数组就变成空数组了。空数组在题目里是不允许的。这个前提正好引出全负数用例的经典陷阱,后面专门讲。

2.3 为什么不能直接复制数组跑 Kadane:一个反例

上面提到复制数组会有问题,这里用一个更细的例子说明。假设数组是 [5, -3, 5, 1],复制成 [5, -3, 5, 1, 5, -3, 5, 1],普通 Kadane 可能选出一段长度 4 的子数组,也可能选出一段长度超过 4 的子数组,因为复制后“没有长度限制”。

有人会说:那我限制长度不超过 n 不就行了?确实可以,但那就不是简单的 Kadane 了,需要借助前缀和 + 单调队列,维护一个长度不超过 n 的窗口内的最大子数组和。这是另一种解法,我在第 5 部分会展开讲。双情况法的优势在于:不复制数组、不加长度限制,只需要跑两次线性遍历,代码更好写,推导也更符合直觉。

要提醒的是,“取两种情况最大值”的方法只适用于“子数组不允许为空”的设定。如果题目允许空子数组,那你得把公式改成 max(best_max, total - best_min, 0) 或者类似形式。刷题时一定先读清楚题面,很多边界错误其实是题意理解偏差。

3. 核心细节与代码实现:把公式变成能 AC 的代码

3.1 Python 实现与逐行注释

直接看我实际提交通过的版本,Python 代码很短,但每一行都有讲究:

def max_subarray_sum_circular(nums): if not nums: return 0 total = sum(nums) # Kadane:最大子数组和 cur_max = 0 best_max = float('-inf') for x in nums: cur_max = max(x, cur_max + x) best_max = max(best_max, cur_max) # 镜像 Kadane:最小子数组和 cur_min = 0 best_min = float('inf') for x in nums: cur_min = min(x, cur_min + x) best_min = min(best_min, cur_min) # 如果最大子数组和小于 0,说明全是负数,直接返回最大元素 if best_max < 0: return max(nums) return max(best_max, total - best_min)

这里有两个关键点。第一,cur_max 和 cur_min 的初始值。常见写法是初始化为 0,因为空数组的和是 0,Kadane 的“重新开始”选择天然包含了“从 0 开始”的语义。但 best_max 必须初始化为负无穷,否则全负数数组会被初始值 0 带偏,导致结果变成 0 而不是真正的负数最大值。第二,最后那个 if best_max < 0 的判断,是整个环形版本的点睛之笔。它的作用是处理“全是负数”的极端场景,此时 best_max 本身就是正确答案,不需要再和 total - best_min 比较。

3.2 全为负数时为什么答案不是 0

拿 [-3, -2, -1] 举例。普通 Kadane 得到 best_max = -1,这是正确答案,因为子数组至少要取一个元素。再看看 total - best_min:数组总和是 -6,最小子数组和是整个数组 -6,算出来 total - best_min = 0。如果直接取 max(best_max, total - best_min),就会得到 0,而题目要求子数组非空,正确答案是 -1。

为什么会出现这种情况?因为最小子数组和恰好等于整个数组的和,意味着如果要“舍弃”这一段,剩下的部分就是空数组。环形子数组不能为空,所以这种情况不能套用情况二。判别方法就是看 best_max 是否小于 0:如果最大子数组和都小于 0,说明数组里所有元素都是负数,此时任何一个非空子数组都是负数,不如直接取单个最大元素。这种情况直接返回 max(nums) 即可。

我在训练营里见过不少同学在这里翻车:不是没加 if,就是加了 if 但把 best_max 初始化为 0,导致全负数数组在进入 if 前就已经算错。把这两件事分开记:best_max 初始值影响 Kadane 本身,if 判断影响环形部分的公式,缺一不可。

3.3 其他语言的实现差异与坑

换到 C++ 或 Java,核心逻辑一模一样,但要额外注意两个语言层面的问题。

第一个是整数溢出。如果数组元素范围和 n 都很大,total、cur_max、cur_min 这些变量可能超过 int 的表示范围。C++ 里建议直接用 long long,Java 里用 long,避免求和过程中悄悄溢出变成负数,然后在比较时产生诡异结果。我见过有人用 int 写成 total - best_min,溢出后反而得到一个很大的正数,答案完全错乱,排查半天才意识到是类型问题。

第二个是语言库函数的边界。C++ 的 std::max 要求两个参数类型一致,如果 best_max 是 long long,传入 max(nums) 就会因为类型不一致编译报错,需要显式转换。Java 里 Collections.max 和数组处理方式也不同。这些小问题不影响算法本身,但在白板面试时很影响观感,建议提前准备好自己最熟语言的模板版。

下面是 C++ 版本参考:

int maxSubarraySumCircular(vector<int>& nums) { int n = nums.size(); long long total = 0; for (int x : nums) total += x; long long curMax = 0, bestMax = LLONG_MIN; long long curMin = 0, bestMin = LLONG_MAX; for (int x : nums) { curMax = max((long long)x, curMax + x); bestMax = max(bestMax, curMax); curMin = min((long long)x, curMin + x); bestMin = min(bestMin, curMin); } if (bestMax < 0) { return *max_element(nums.begin(), nums.end()); } return max(bestMax, total - bestMin); }

这里我把 curMax 和 curMin 放在同一个循环里更新,省一次遍历,但逻辑上还是两个独立的 Kadane。要注意的是,如果你在同一循环里更新 curMax 和 curMin,千万不要让它们互相干扰,否则边界会乱。

4. 常见错误与排查技巧实录

4.1 高频翻车场景和排查思路

我把自己和学员踩过的坑汇总成一张速查表,按出现频率排序:

症状可能原因排查方法
全负数数组返回 0best_max 初始化为 0,或缺少 best_max < 0 判断打印 best_max,检查是否被初始值污染
答案总比预期大复制数组后直接跑普通 Kadane,子数组长度超过 n改用双情况法,或加长度限制
结果正好少了一个元素最小子数组和计算错误,把空数组也算进去了检查 cur_min 初始值,确认每个元素都被访问
溢出导致的随机错误使用了 int 而数组求和超范围换成 long long,打印 total 验证
只有一个元素时答案不对全负数判断逻辑写反单独跑 [5] 和 [-5] 两个用例

排查这类问题,最好的工具不是脑补,而是对拍。写一个暴力版本,用随机小数组反复比较两种结果,一旦不一致就缩小数组规模,人工推演。环形数组的随机测试用例很容易暴露边界问题,我实测 100 组随机数据不到几秒就能覆盖绝大多数坑。

4.2 测试用例速查表与验证顺序

下面这组用例是我每次写完代码必跑的,按从简单到复杂的顺序:

输入数组预期结果验证点
[5]5长度 1 的基础情况
[-5]-5单元素全负数
[-3, -2, -1]-1全负数,防止返回 0
[5, -3, 5]10跨边界的经典用例
[1, -2, 3, -2]3最优解在中间,不跨边界
[8, -1, 6, -9, 2]14跨边界和普通段竞争
[1, 2, 3]6全正数,答案就是总和

特别注意最后一组全正数用例。此时 best_max = total = 6,而 best_min 可能是数组中的单个最小元素 1,total - best_min = 5,最后 max 取到 6。如果你发现代码在这种用例返回 total - best_min,那说明 min 的计算没有考虑“从正数开头会立即重来”的规则,需要回头检查 cur_min 的转移式。

4.3 面试官考这道题时真正想看什么

环形子数组的最大和在算法竞赛里不算难题,但面试官喜欢用它做“一题多考”。第一层考 Kadane 本身,确认候选人理解状态转移;第二层考环形抽象,确认候选人能不能把“跨越边界”转化为“总和减最小段”;第三层考边界条件,全负数这种用例很容易区分“背结论”和“真理解”。

我给你的建议是,面试时先慢下来,画一个环形图,把两种子数组形态讲清楚,再动手写代码。面试官通常更在意思路推导,而不是你背得多熟。写代码时先写 Kadane 部分,再补全负数判断,最后再合并答案,这样即使时间不够,也能拿到大部分分数。还有个小技巧:主动问面试官“子数组是否允许为空”,这个问题比答案本身更显专业。

复杂度分析也别忽略:双情况法的时间复杂度是 O(n),空间复杂度是 O(1)。有的面试官会追问,能不能不用 if 判断做到全负数也正确?可以,但代价是公式变得更复杂,实际工程意义不大,白板场景推荐保留 if 判断,代码更清晰。

5. 延伸:环形子数组还能怎么考

5.1 换成滑动窗口 + 单调队列的解法

除了双情况法,环形子数组还有一个通用解法:复制数组成两倍长度,再用“单调队列 + 前缀和”维护长度不超过 n 的窗口内最大子数组和。这个思路能处理“允许空子数组”或“限定子数组长度不超过 k”的变种,适用范围更广。核心代码结构如下:

from collections import deque def max_subarray_sum_circular_with_queue(nums): n = len(nums) arr = nums + nums prefix = [0] for x in arr: prefix.append(prefix[-1] + x) q = deque([0]) ans = float('-inf') for j in range(1, len(prefix)): while q and q[0] < j - n: q.popleft() if q: ans = max(ans, prefix[j] - prefix[q[0]]) while q and prefix[q[-1]] >= prefix[j]: q.pop() q.append(j) return ans

这个版本的时间也是 O(n),但空间是 O(n)。它比双情况法代码长,理解难度也更高,好处是统一处理了“长度限制”和“环形”两个约束。如果你刷题遇到类似的“环形滑动窗口”题,比如环形最大平均值、环形子数组恰好 k 个元素,单调队列方案更通用。我个人建议:作为面试主打解法,双情况法优先;作为延伸理解,单调队列值得掌握,因为它能解决一类问题而不是一道题。

5.2 从一维到二维:最大子矩阵的环形扩展

如果把一维数组换成二维矩阵,问题就变成“环形矩阵的最大子矩阵和”。这个升级版可以先对列做前缀和压缩,把二维问题压成一维,然后对每一行组合跑一遍环形子数组的最大和。复杂度和行数、列数的乘积相关,属于比较硬核的进阶题。这里不展开代码,但核心思想还是“压缩维度 + 环形处理”,和今天讲的题一脉相承。

所以你看,环形子数组的最大和这道题虽然本身难度不高,但它像一个小枢纽,一头连着动态规划经典模型,一头连着滑动窗口、前缀和、二维压缩这些进阶技巧。把它吃透,收获的不只是一个 AC 记录,而是一整套“环形数组”类问题的解题套路。

5.3 变种题:允许空子数组与固定长度子数组

最后补充两个常见变种。第一个变种是“允许空子数组”,这时双情况法的公式要改成 max(best_max, total - best_min, 0),并且全负数时答案就是 0。第二个变种是“子数组长度必须恰好为 k”,这个直接用双情况法解决不了,得靠单调队列或定长滑动窗口,因为你需要约束被舍弃段的长度不能超过 n - k。

这两种变种在竞赛题库里经常出现,面试现场倒不一定考,但如果你平时刷题有余力,建议顺手把单调队列版本也敲一遍。我自己的体验是,第一次写单调队列版本花了近一个小时,各种下标错误;搞清楚之后,再遇到“长度限制 + 数组”的题,基本十分钟内能写完。这种能力是会迁移的,属于典型的“一道题功力远超一道题”的类型。

与数据规模有关的经验总结

刷这类题的时候,我习惯先看一眼数据范围再决定策略。n 小于 100,暴力随便写,对拍也方便;n 到 10^5,必须 O(n);n 到 10^6,要小心 Python 在循环里的常数开销,尽量少用切片和冗余数组;如果数值范围特别大,注意 long long 和 Python 大整数的差异。数据规模和写代码的习惯是长期磨合出来的,见得多了自然就有手感。

回到最开始那个让我翻车的全负数用例。后来我把这道题作为训练营的必讲题目,每一期都能遇到学员在同一个地方卡住。这个现象其实说明,算法题的难点往往不在核心公式,而在边界意识的完整性。普通 Kadane 不会遇到“空子数组”的问题,因为大家都默认至少选一个元素;环形版本一旦用公式,空子数组就被“制造”出来,于是必须加判断。理解了这个因果链,你才算真正掌握了这道题。

如果让我给一个练习建议,那就是把双情况法和单调队列版本都各写一遍,然后互相随机对拍 1000 组数据。不要急着背代码,先用小例子把“为什么答案等于 max(best_max, total - best_min)”讲给自己听,讲得通,代码怎么写都不会偏。这个方法帮助我解决过很多类似的“环形 + 动态规划”题目,希望对你也有效。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 5:05:29

Python流程控制彻底讲透:从if/else、循环到match case实战

刚帮一个刚入门 Python 的朋友排查了一段代码&#xff0c;问题很简单——他用if判断用户输入时写成了if 1 < age < 18&#xff0c;逻辑上完全没错&#xff0c;但在实际业务里&#xff0c;年龄小于 0 或者大于 120 的数据他却没有处理。其实这不算 Bug&#xff0c;而是典型…

作者头像 李华
网站建设 2026/10/1 5:05:10

Google关键词研究实操:如何挖掘高转化词与长尾词

前阵子一个做独立站的读者来找我&#xff0c;开口就是&#xff1a;“我按教程找了几百个关键词&#xff0c;文章也在发&#xff0c;广告也在投&#xff0c;三个月愣是没出单。”我让他把关键词表发过来&#xff0c;两张表格拉完&#xff0c;满屏都是搜索量过万的泛词。我当时就…

作者头像 李华
网站建设 2026/10/1 5:05:10

DeepSeek本地部署全流程:Ollama+RAG知识库搭建与排错实战

我这次把DeepSeek本地部署完整跑通的方案整理出来&#xff0c;包括了Ollama的安装、模型拉取、知识库&#xff08;RAG&#xff09;流水线的搭建&#xff0c;还有3个我实际踩过的报错及完整排查过程。整个过程基于本地脚本和开源工具完成&#xff0c;机器是4070Ti Super&#xf…

作者头像 李华
网站建设 2026/10/1 5:05:04

Wine+FEX-Emu+DXMT:在iOS上运行x86-64 Windows图形程序的技术解析

1. 从“Madeira”说起&#xff1a;一个跨平台兼容层的真实需求第一次看到“Madeira”这个标题&#xff0c;加上热搜词里那一串 Wine、FEX-Emu、DXMT、iOS、x86-64&#xff0c;我脑子里第一反应是&#xff1a;这又是一个在“让不同架构、不同系统的程序互相跑起来”这件事上折腾…

作者头像 李华
网站建设 2026/10/1 5:04:55

马德拉酒:加热氧化成就的“不死之酒”,从工艺到品鉴一次说透

提到 Madeira&#xff0c;很多人第一反应是葡萄牙那个火山群岛&#xff0c;但真正让我这个酒柜玩家着迷的&#xff0c;是它背后那杯琥珀色的“不死之酒”——马德拉酒。如果你喜欢威士忌、雪莉桶的复杂感&#xff0c;或者对甜酒有好奇心&#xff0c;这篇内容你能直接用上。当然…

作者头像 李华
网站建设 2026/10/1 5:02:46

SLF4J 多绑定警告:Class path 冲突排查与依赖统一

做 Java 后端开发的&#xff0c;几乎没人能完全绕开控制台里那行红字&#xff1a;SLF4J: Class path contains multiple SLF4J bindings。它不像空指针那样直接把服务打挂&#xff0c;也不像端口占用那样让程序起不来&#xff0c;所以很多人的第一反应是“能跑就行&#xff0c;…

作者头像 李华