💥 暴力解 LeetCode 导致 OOM:10000 行数据撑爆 V8 堆内存的完整排查
摘要 :一道 LeetCode 中等题,我自信满满写了双重循环暴力解,结果提交后直接
FATAL ERROR: heap out of memory。本文记录我从"堆爆炸"到理解 V8 内存机制,再到掌握 Kadane 算法和分治法的完整心路历程。
📌 前言
你以为暴力解只是慢?不,它可能直接让 Node.js 崩溃。
昨天刷 LeetCode 53. 最大子数组和,我心想这不简单嘛,双重循环暴力枚举,稳了。结果提交后------暴力解,真的会"爆炸"。
事情是这样的:题目要求找出一个整数数组中具有最大和的连续子数组。我心想,这不简单嘛,双重循环枚举所有子数组,把和都存起来,最后排序取最大值,完美!
结果提交后,控制台给我甩了一堆天书般的报错,最后一行赫然写着:
objectivec
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
我的代码,把 Node.js 的堆内存给撑爆了。
这篇文章,就记录一下我从"这啥玩意?"到"原来如此!"的完整解题过程,顺便把 V8 引擎的内存机制、堆栈区别、Kadane 算法这些知识点一次性讲透。
🎯 本文适合谁
- 正在刷力扣、对算法复杂度概念模糊的同学
- 想了解 JavaScript/V8 引擎内存管理机制的前端开发者
- 对"堆"和"栈"傻傻分不清楚的同学
- 想掌握 Kadane 算法(动态规划入门经典)的同学
🐛 我的暴力解(反面教材)
先看看我最初写的"爆炸代码":
javascript
var maxSubArray = function(nums) {
const len = nums.length;
let arr = [];
for (let i = 0; i < len; i++) {
let sum = 0;
for (let j = i; j < len; j++) {
sum += nums[j];
arr.push(sum); // 💣 问题根源:把所有子数组的和都存起来
}
}
arr.sort((a, b) => a - b); // 💣 还要排序,雪上加霜
return arr[arr.length - 1];
};
思路很朴素:枚举所有子数组 → 存到一个大数组里 → 排序 → 取最大值。
看起来没毛病对吧?但当测试用例是一个长度为 10000 的数组时,灾难就来了。

💥 堆爆炸:到底发生了什么?
报错信息拆解
sql
<--- Last few GCs --->
[22:0x1550b000] 1519 ms: Mark-Compact (reduce) 390.0 (399.5) -> 390.0 (392.5) MB
last resort; GC in old space requested
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
翻译成人话:
- Mark-Compact (reduce):V8 引擎正在执行最耗时的"标记-压缩"垃圾回收(终极手段)
390.0 (399.5) -> 390.0 (392.5) MB:回收前总内存 399.5 MB,存活对象 390 MB;回收后只腾出了 2.5 MB。GC 忙活了半天,几乎没释放出任何内存last resort:最后的挣扎,引擎已经用尽所有常规回收手段FATAL ERROR:正式宣告------内存分配失败,进程阵亡
数学层面:为什么 O(n²) 空间会炸?
对于长度为 n 的数组,连续子数组的总数是:n × (n + 1) / 2
| 数组长度 n | 子数组数量 | 存储预估(8字节/数字) |
|---|---|---|
| 1,000 | ~50 万 | ~4 MB |
| 10,000 | ~5,000 万 | ~400 MB |
| 100,000 | ~50 亿 | ~40 GB(直接不可能) |
当 n = 10000 时,光是存这些数字就要 400 MB ,再加上 sort() 底层用的是 TimSort(归并排序变种),需要额外申请一块等大的临时内存用于合并操作。
致命链条 :400MB 存储 + 400MB 排序临时空间 = 800MB → 突破 Node.js 默认堆上限(~1.4GB)→ GC 拼命压缩但 arr 始终被引用,根本回收不了 → 💥 崩溃。
🧠 知识点:堆内存 vs 栈内存
这次爆炸让我彻底搞懂了"堆"和"栈"的区别,用一个比喻来说:栈就像"便利贴"(随写随撕),堆就像"大仓库"(需要专门管理)。
对比表
| 对比维度 | 栈内存 (Stack) | 堆内存 (Heap) |
|---|---|---|
| 存储内容 | 基本数据类型(number、string、boolean 等)、函数调用帧 | 引用类型(对象 {}、数组 []、函数等) |
| 管理方式 | 自动分配与释放,LIFO 原则 | 动态分配,由 GC(垃圾回收器)管理 |
| 空间大小 | 极小且固定(几 MB) | 非常大(Node.js 默认 ~1.4GB,可调) |
| 速度 | 极快(CPU 寄存器直接操作) | 较慢(涉及 GC 暂停、内存碎片整理) |
| 报错特征 | Maximum call stack size exceeded |
JavaScript heap out of memory |
结合我的代码理解
javascript
let arr = []; // arr 这个变量名 → 存在栈里(只是一个"门牌号")
arr.push(数据); // arr 里的所有元素 → 存在堆里(实体货物全在仓库)
当 arr 大到 390 MB 且一直被引用时,仓库(堆)被占满了,GC 拼命压缩也腾不出空间,最后 Node.js 直接崩溃------这完全是堆内存的事,和栈没有半毛钱关系。
两种经典崩溃场景
javascript
// 1. 栈溢出(无限递归)
function a() { a(); }
a(); // 报错:Maximum call stack size exceeded(栈爆了)
// 2. 堆溢出(大数组占满内存)
let arr = [];
while (true) { arr.push(new Array(1000000)); }
// 报错:heap out of memory(堆爆了,正是我遇到的)
🔧 改进方案:从暴力到优雅
方案一:暴力优化(不存数组,空间 O(1))
如果面试时被要求先说暴力思路,至少别把所有和都存起来,用"在线比较法":
javascript
var maxSubArray = function(nums) {
const len = nums.length;
let maxSum = -Infinity;
for (let i = 0; i < len; i++) {
let sum = 0;
for (let j = i; j < len; j++) {
sum += nums[j];
if (sum > maxSum) maxSum = sum; // 直接比较,不存数组
}
}
return maxSum;
};
// 时间 O(n²),空间 O(1) ------ 不会爆内存,但会超时
⚠️ 这个写法空间复杂度 O(1),不会爆内存了,但时间复杂度 O(n²) 在 LeetCode 上会超时(TLE)。所以最终提交一定要用下面的最优解。
方案二:Kadane 算法(最优解 ✅)
核心思想:一次遍历,判断"是继续累加当前元素,还是从当前元素重新开始"。
javascript
var maxSubArray = function(nums) {
let maxSum = nums[0]; // 全局最大值
let currentSum = nums[0]; // 当前累计和
for (let i = 1; i < nums.length; i++) {
// 核心决策:继续累加 vs 重新开始
currentSum = Math.max(nums[i], currentSum + nums[i]);
// 更新全局最大值
maxSum = Math.max(maxSum, currentSum);
}
return maxSum;
};
// 时间 O(n),空间 O(1) ------ 完美!
执行过程图解
以 nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4] 为例:
| 步骤 i | numsi | currentSum 决策 | currentSum | maxSum |
|---|---|---|---|---|
| 0 | -2 | 初始化 | -2 | -2 |
| 1 | 1 | max(1, -2+1=-1) = 1(重新开始) | 1 | 1 |
| 2 | -3 | max(-3, 1-3=-2) = -2(继续累加) | -2 | 1 |
| 3 | 4 | max(4, -2+4=2) = 4(重新开始) | 4 | 4 |
| 4 | -1 | max(-1, 4-1=3) = 3(继续累加) | 3 | 4 |
| 5 | 2 | max(2, 3+2=5) = 5(继续累加) | 5 | 5 |
| 6 | 1 | max(1, 5+1=6) = 6(继续累加) | 6 | 6 |
| 7 | -5 | max(-5, 6-5=1) = 1(继续累加) | 1 | 6 |
| 8 | 4 | max(4, 1+4=5) = 5(继续累加) | 5 | 6 |
最终结果 :maxSum = 6,对应子数组 [4, -1, 2, 1]。
方案三:分治法(进阶思路 ✅)
除了 Kadane 算法,还有一种经典解法------分治法(Divide and Conquer)。虽然这道题最优解是 Kadane,但分治的思想在面试中经常被追问,而且它能帮你理解"最大子数组和"这个问题的结构。
核心思想 :把数组一分为二,最大子数组和只可能出现在三种位置------左半部分 、右半部分 、横跨中点。递归求解,取三者最大值。
javascript
var maxSubArray = function(nums) {
// 辅助递归函数:计算区间 [left, right] 内的最大子数组和
function divideAndConquer(left, right) {
// 1. 递归终止条件:区间内只有一个元素
if (left === right) {
return nums[left];
}
// 2. 分:计算中点
const mid = Math.floor((left + right) / 2);
// 3. 治:递归获取左、右半部分的最大值
const leftMax = divideAndConquer(left, mid);
const rightMax = divideAndConquer(mid + 1, right);
// 4. 合:计算横跨中点 (mid) 的最大和
// 4.1 从中点向左扩展,找最大累加和(必须包含 nums[mid])
let leftCrossSum = -Infinity;
let currentLeftSum = 0;
for (let i = mid; i >= left; i--) {
currentLeftSum += nums[i];
if (currentLeftSum > leftCrossSum) {
leftCrossSum = currentLeftSum;
}
}
// 4.2 从中点向右扩展,找最大累加和(必须包含 nums[mid + 1])
let rightCrossSum = -Infinity;
let currentRightSum = 0;
for (let i = mid + 1; i <= right; i++) {
currentRightSum += nums[i];
if (currentRightSum > rightCrossSum) {
rightCrossSum = currentRightSum;
}
}
const crossMax = leftCrossSum + rightCrossSum;
// 5. 返回三者中的最大值
return Math.max(leftMax, rightMax, crossMax);
}
// 边界处理
if (nums.length === 0) return 0;
return divideAndConquer(0, nums.length - 1);
};
// 时间 O(n log n),空间 O(log n)(递归栈)
分治法图解
以 nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4] 为例:
css
[-2, 1, -3, 4, -1, 2, 1, -5, 4]
↙ ↓ ↘
[-2,1,-3,4] [横跨中点] [-1,2,1,-5,4]
↙ ↘ ↙ ↘
[-2,1] [-3,4] [-1,2] [1,-5,4]
↙ ↘ ↙ ↘ ↙ ↘ ↙ ↘
[-2] [1] [-3] [4] [-1] [2] [1] [-5,4]
- 左半部分最大值 :递归得到
4(子数组[4]) - 右半部分最大值 :递归得到
6(子数组[2,1]或[-1,2,1]等) - 横跨中点最大值 :从 mid 向左取
4,向右取2,合计6
最终结果 :max(4, 6, 6) = 6 ✅
分治法 vs Kadane:怎么选?
| 维度 | Kadane 算法 | 分治法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n log n) |
| 空间复杂度 | O(1) | O(log n)(递归栈) |
| 代码难度 | 简单(5 行核心) | 中等(30+ 行) |
| 面试价值 | 最优解,必须掌握 | 展示分治思想,常被追问 |
| 适用场景 | 直接求解 | 适合举一反三(如最近点对、归并排序) |
💡 面试建议:先说 Kadane 算法(最优解),如果面试官追问"还有没有其他解法",再讲分治法。这样既展示了你知道最优解,又展示了你的算法思维广度。
💡 经验教训
1. 做题前先算复杂度
LeetCode 的硬约束(数组长度可达 10^5)决定了 O(n²) 空间必死无疑。
以后做题,先估算一下:
- 数据规模 10^3 → O(n²) 能过
- 数据规模 10^5 → O(n²) 大概率超时,O(n log n) 才安全
- 数据规模 10^6+ → 必须 O(n) 或 O(n log n)
2. 涉及"所有组合"大概率需要优化
如果题目涉及"所有子数组"、"所有子序列"、"所有排列组合",千万别傻乎乎地全存起来 。大概率需要用动态规划(DP)或贪心来优化掉存储过程。
3. 理解 V8 内存机制很重要
- 栈内存:存基本类型和函数调用,自动管理,极小极快
- 堆内存:存引用类型(对象、数组),由 GC 管理,大但慢
- GC 不是万能的:如果对象一直被引用,GC 根本回收不了
4. 遇到 OOM 怎么排查?
- 应急 :
node --max-old-space-size=4096 your_script.js临时扩大内存上限(治标不治本) - 根治:检查代码中是否有一口气加载全量的操作,改成流式处理
- 排查:使用 Chrome DevTools 连接 Node 进程,抓取 Heap Snapshot 对比,找到内存占用大户
📊 四种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 能否通过 | 备注 |
|---|---|---|---|---|
| 暴力(存所有和) | O(n²) | O(n²) | ❌ 堆爆炸 | 我最初的写法 |
| 暴力(在线比较) | O(n²) | O(1) | ❌ 超时 | 改进版,不爆内存但超时 |
| 分治法 | O(n log n) | O(log n) | ✅ 通过 | 进阶思路,展示分治思想 |
| Kadane 算法 | O(n) | O(1) | ✅ 通过 | 最优解,一次遍历 |
🎤 面试中如何讲解这道题?
面试官问到最大子数组和时,建议按这个节奏讲:
- 先说暴力思路 (展示基本功):双重循环枚举所有子数组,O(n²) 时间 O(1) 空间。注意:别说"把所有和存起来再排序",这会暴露你没考虑空间复杂度。
- 主动指出暴力的缺陷(展示思考深度):时间复杂度太高,有没有更优的解法?
- 引出 Kadane 算法(展示核心能力):核心思想是"如果前面的累加是负收益,果断抛弃,从当前元素重新开始",一次遍历 O(n) 搞定。
- 主动补充分治法(展示算法广度):如果面试官追问"还有没有其他思路",可以讲分治法------把数组一分为二,最大子数组和只可能在左半部分、右半部分或横跨中点,递归求解 O(n log n)。
- 举一个具体例子走一遍 (展示表达能力):用
[-2,1,-3,4,-1,2,1,-5,4]走一遍执行过程,让面试官跟着你的思路走。
💡 加分项 :如果面试官追问,可以提到这道题本质上是动态规划 的简化版------状态转移方程就是
dp[i] = max(nums[i], dp[i-1] + nums[i]),只是因为只依赖前一个状态,所以可以用一个变量代替数组,空间优化到 O(1)。
📌 核心收获(四句话总结)
- 做题前先算复杂度:数据规模超过 10^4 时,O(n²) 空间必死无疑,涉及"所有组合"大概率需要 DP 或贪心优化。
- 暴力解也要写对:就算先说暴力思路,也别把所有中间结果存起来------在线比较、即时更新最大值,空间 O(1) 才是合格的暴力。
- 一题多解是加分项:Kadane 算法是最优解 O(n),分治法是进阶思路 O(n log n),面试时主动展示多种解法能体现算法思维广度。
- 理解 V8 内存机制:数组存堆不存栈,GC 回收不了被引用的对象,OOM 时先排查谁在"持有"那些大对象。
🔗 参考资料
💬 交流讨论
你有没有遇到过类似的"暴力解翻车"经历?你平时刷 LeetCode 用什么语言?欢迎在评论区分享你的故事和解法!
如果这篇文章帮到了你,点个赞👍收藏⭐关注👆,后续会持续分享更多算法踩坑经验和前端底层原理!
🏷️ 推荐标签 :
前端开发·JavaScript·算法·LeetCode·V8引擎·分治法
markdown
- **排查**:使用 Chrome DevTools 连接 Node 进程,抓取 Heap Snapshot 对比,找到内存占用大户
yaml
---
shell
## 📊 四种解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 能否通过 | 备注 |
ruby
|:---:|:---:|:---:|:---:|:---|
scss
| 暴力(存所有和) | O(n²) | O(n²) | ❌ 堆爆炸 | 我最初的写法 |
scss
| 暴力(在线比较) | O(n²) | O(1) | ❌ 超时 | 改进版,不爆内存但超时 |
scss
| 分治法 | O(n log n) | O(log n) | ✅ 通过 | 进阶思路,展示分治思想 |
scss
| Kadane 算法 | O(n) | O(1) | ✅ 通过 | 最优解,一次遍历 |
yaml
---
shell
## 🎤 面试中如何讲解这道题?
面试官问到最大子数组和时,建议按这个节奏讲:
scss
1. **先说暴力思路**(展示基本功):双重循环枚举所有子数组,O(n²) 时间 O(1) 空间。**注意:别说"把所有和存起来再排序",这会暴露你没考虑空间复杂度。**
markdown
2. **主动指出暴力的缺陷**(展示思考深度):时间复杂度太高,有没有更优的解法?
markdown
3. **引出 Kadane 算法**(展示核心能力):核心思想是"如果前面的累加是负收益,果断抛弃,从当前元素重新开始",一次遍历 O(n) 搞定。
bash
4. **主动补充分治法**(展示算法广度):如果面试官追问"还有没有其他思路",可以讲分治法------把数组一分为二,最大子数组和只可能在左半部分、右半部分或横跨中点,递归求解 O(n log n)。
markdown
5. **举一个具体例子走一遍**(展示表达能力):用 `[-2,1,-3,4,-1,2,1,-5,4]` 走一遍执行过程,让面试官跟着你的思路走。
scss
> 💡 **加分项**:如果面试官追问,可以提到这道题本质上是**动态规划**的简化版------状态转移方程就是 `dp[i] = max(nums[i], dp[i-1] + nums[i])`,只是因为只依赖前一个状态,所以可以用一个变量代替数组,空间优化到 O(1)。
yaml
---
shell
## 📌 核心收获(四句话总结)
markdown
1. **做题前先算复杂度**:数据规模超过 10^4 时,O(n²) 空间必死无疑,涉及"所有组合"大概率需要 DP 或贪心优化。
markdown
2. **暴力解也要写对**:就算先说暴力思路,也别把所有中间结果存起来------在线比较、即时更新最大值,空间 O(1) 才是合格的暴力。
💥 暴力解 LeetCode 导致 OOM:10000 行数据撑爆 V8 堆内存的完整排查
摘要 :一道 LeetCode 中等题,我自信满满写了双重循环暴力解,结果提交后直接
FATAL ERROR: heap out of memory。本文记录我从"堆爆炸"到理解 V8 内存机制,再到掌握 Kadane 算法和分治法的完整心路历程。
📌 前言
你以为暴力解只是慢?不,它可能直接让 Node.js 崩溃。
昨天刷 LeetCode 53. 最大子数组和,我心想这不简单嘛,双重循环暴力枚举,稳了。结果提交后------暴力解,真的会"爆炸" 。
事情是这样的:题目要求找出一个整数数组中具有最大和的连续子数组。我心想,这不简单嘛,双重循环枚举所有子数组,把和都存起来,最后排序取最大值,完美!
结果提交后,控制台给我甩了一堆天书般的报错,最后一行赫然写着:
objectivec
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
我的代码,把 Node.js 的堆内存给撑爆了。
这篇文章,就记录一下我从"这啥玩意?"到"原来如此!"的完整解题过程,顺便把 V8 引擎的内存机制、堆栈区别、Kadane 算法这些知识点一次性讲透。
🎯 本文适合谁
- 正在刷力扣、对算法复杂度概念模糊的同学
- 想了解 JavaScript/V8 引擎内存管理机制的前端开发者
- 对"堆"和"栈"傻傻分不清楚的同学
- 想掌握 Kadane 算法(动态规划入门经典)的同学
🐛 我的暴力解(反面教材)
先看看我最初写的"爆炸代码":
ini
var maxSubArray = function(nums) {
const len = nums.length;
let arr = [];
for (let i = 0; i < len; i++) {
let sum = 0;
for (let j = i; j < len; j++) {
sum += nums[j];
arr.push(sum); // 💣 问题根源:把所有子数组的和都存起来
}
}
arr.sort((a, b) => a - b); // 💣 还要排序,雪上加霜
return arr[arr.length - 1];
};
思路很朴素:枚举所有子数组 → 存到一个大数组里 → 排序 → 取最大值。
看起来没毛病对吧?但当测试用例是一个长度为 10000 的数组时,灾难就来了。
!屏幕截图 2026-07-17 003317(E:\OneDrive\OneDrive\图片\Screenshots\屏幕截图 2026-07-17 003317.png)
💥 堆爆炸:到底发生了什么?
报错信息拆解
sql
<--- Last few GCs --->
[22:0x1550b000] 1519 ms: Mark-Compact (reduce) 390.0 (399.5) -> 390.0 (392.5) MB
last resort; GC in old space requested
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
翻译成人话:
- Mark-Compact (reduce) :V8 引擎正在执行最耗时的"标记-压缩"垃圾回收(终极手段)
390.0 (399.5) -> 390.0 (392.5) MB:回收前总内存 399.5 MB,存活对象 390 MB;回收后只腾出了 2.5 MB。GC 忙活了半天,几乎没释放出任何内存last resort:最后的挣扎,引擎已经用尽所有常规回收手段FATAL ERROR:正式宣告------内存分配失败,进程阵亡
数学层面:为什么 O(n²) 空间会炸?
对于长度为 n 的数组,连续子数组的总数是:n × (n + 1) / 2
| 数组长度 n | 子数组数量 | 存储预估(8字节/数字) |
|---|---|---|
| 1,000 | ~50 万 | ~4 MB |
| 10,000 | ~5,000 万 | ~400 MB |
| 100,000 | ~50 亿 | ~40 GB(直接不可能) |
当 n = 10000 时,光是存这些数字就要 400 MB ,再加上 sort() 底层用的是 TimSort(归并排序变种) ,需要额外申请一块等大的临时内存用于合并操作。
致命链条 :400MB 存储 + 400MB 排序临时空间 = 800MB → 突破 Node.js 默认堆上限(~1.4GB)→ GC 拼命压缩但 arr 始终被引用,根本回收不了 → 💥 崩溃。
🧠 知识点:堆内存 vs 栈内存
这次爆炸让我彻底搞懂了"堆"和"栈"的区别,用一个比喻来说:栈就像"便利贴"(随写随撕),堆就像"大仓库"(需要专门管理)。
对比表
| 对比维度 | 栈内存 (Stack) | 堆内存 (Heap) |
|---|---|---|
| 存储内容 | 基本数据类型(number、string、boolean 等)、函数调用帧 | 引用类型(对象 {}、数组 []、函数等) |
| 管理方式 | 自动分配与释放,LIFO 原则 | 动态分配,由 GC(垃圾回收器)管理 |
| 空间大小 | 极小且固定(几 MB) | 非常大(Node.js 默认 ~1.4GB,可调) |
| 速度 | 极快(CPU 寄存器直接操作) | 较慢(涉及 GC 暂停、内存碎片整理) |
| 报错特征 | Maximum call stack size exceeded |
JavaScript heap out of memory |
结合我的代码理解
ini
let arr = []; // arr 这个变量名 → 存在栈里(只是一个"门牌号")
arr.push(数据); // arr 里的所有元素 → 存在堆里(实体货物全在仓库)
当 arr 大到 390 MB 且一直被引用时,仓库(堆)被占满了,GC 拼命压缩也腾不出空间,最后 Node.js 直接崩溃------这完全是堆内存的事,和栈没有半毛钱关系。
两种经典崩溃场景
javascript
// 1. 栈溢出(无限递归)
function a() { a(); }
a(); // 报错:Maximum call stack size exceeded(栈爆了)
// 2. 堆溢出(大数组占满内存)
let arr = [];
while (true) { arr.push(new Array(1000000)); }
// 报错:heap out of memory(堆爆了,正是我遇到的)
🔧 改进方案:从暴力到优雅
方案一:暴力优化(不存数组,空间 O(1))
如果面试时被要求先说暴力思路,至少别把所有和都存起来,用"在线比较法":
ini
var maxSubArray = function(nums) {
const len = nums.length;
let maxSum = -Infinity;
for (let i = 0; i < len; i++) {
let sum = 0;
for (let j = i; j < len; j++) {
sum += nums[j];
if (sum > maxSum) maxSum = sum; // 直接比较,不存数组
}
}
return maxSum;
};
// 时间 O(n²),空间 O(1) ------ 不会爆内存,但会超时
⚠️ 这个写法空间复杂度 O(1),不会爆内存了,但时间复杂度 O(n²) 在 LeetCode 上会超时(TLE) 。所以最终提交一定要用下面的最优解。
方案二:Kadane 算法(最优解 ✅)
核心思想:一次遍历,判断"是继续累加当前元素,还是从当前元素重新开始"。
ini
var maxSubArray = function(nums) {
let maxSum = nums[0]; // 全局最大值
let currentSum = nums[0]; // 当前累计和
for (let i = 1; i < nums.length; i++) {
// 核心决策:继续累加 vs 重新开始
currentSum = Math.max(nums[i], currentSum + nums[i]);
// 更新全局最大值
maxSum = Math.max(maxSum, currentSum);
}
return maxSum;
};
// 时间 O(n),空间 O(1) ------ 完美!
执行过程图解
以 nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4] 为例:
| 步骤 i | numsi | currentSum 决策 | currentSum | maxSum |
|---|---|---|---|---|
| 0 | -2 | 初始化 | -2 | -2 |
| 1 | 1 | max(1, -2+1=-1) = 1(重新开始) | 1 | 1 |
| 2 | -3 | max(-3, 1-3=-2) = -2(继续累加) | -2 | 1 |
| 3 | 4 | max(4, -2+4=2) = 4(重新开始) | 4 | 4 |
| 4 | -1 | max(-1, 4-1=3) = 3(继续累加) | 3 | 4 |
| 5 | 2 | max(2, 3+2=5) = 5(继续累加) | 5 | 5 |
| 6 | 1 | max(1, 5+1=6) = 6(继续累加) | 6 | 6 |
| 7 | -5 | max(-5, 6-5=1) = 1(继续累加) | 1 | 6 |
| 8 | 4 | max(4, 1+4=5) = 5(继续累加) | 5 | 6 |
最终结果 :maxSum = 6,对应子数组 [4, -1, 2, 1]。
方案三:分治法(进阶思路 ✅)
除了 Kadane 算法,还有一种经典解法------分治法(Divide and Conquer) 。虽然这道题最优解是 Kadane,但分治的思想在面试中经常被追问,而且它能帮你理解"最大子数组和"这个问题的结构。
核心思想 :把数组一分为二,最大子数组和只可能出现在三种位置------左半部分 、右半部分 、横跨中点。递归求解,取三者最大值。
ini
var maxSubArray = function(nums) {
// 辅助递归函数:计算区间 [left, right] 内的最大子数组和
function divideAndConquer(left, right) {
// 1. 递归终止条件:区间内只有一个元素
if (left === right) {
return nums[left];
}
// 2. 分:计算中点
const mid = Math.floor((left + right) / 2);
// 3. 治:递归获取左、右半部分的最大值
const leftMax = divideAndConquer(left, mid);
const rightMax = divideAndConquer(mid + 1, right);
// 4. 合:计算横跨中点 (mid) 的最大和
// 4.1 从中点向左扩展,找最大累加和(必须包含 nums[mid])
let leftCrossSum = -Infinity;
let currentLeftSum = 0;
for (let i = mid; i >= left; i--) {
currentLeftSum += nums[i];
if (currentLeftSum > leftCrossSum) {
leftCrossSum = currentLeftSum;
}
}
// 4.2 从中点向右扩展,找最大累加和(必须包含 nums[mid + 1])
let rightCrossSum = -Infinity;
let currentRightSum = 0;
for (let i = mid + 1; i <= right; i++) {
currentRightSum += nums[i];
if (currentRightSum > rightCrossSum) {
rightCrossSum = currentRightSum;
}
}
const crossMax = leftCrossSum + rightCrossSum;
// 5. 返回三者中的最大值
return Math.max(leftMax, rightMax, crossMax);
}
// 边界处理
if (nums.length === 0) return 0;
return divideAndConquer(0, nums.length - 1);
};
// 时间 O(n log n),空间 O(log n)(递归栈)
分治法图解
以 nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4] 为例:
css
[-2, 1, -3, 4, -1, 2, 1, -5, 4]
↙ ↓ ↘
[-2,1,-3,4] [横跨中点] [-1,2,1,-5,4]
↙ ↘ ↙ ↘
[-2,1] [-3,4] [-1,2] [1,-5,4]
↙ ↘ ↙ ↘ ↙ ↘ ↙ ↘
[-2] [1] [-3] [4] [-1] [2] [1] [-5,4]
- 左半部分最大值 :递归得到
4(子数组[4]) - 右半部分最大值 :递归得到
6(子数组[2,1]或[-1,2,1]等) - 横跨中点最大值 :从 mid 向左取
4,向右取2,合计6
最终结果 :max(4, 6, 6) = 6 ✅
分治法 vs Kadane:怎么选?
| 维度 | Kadane 算法 | 分治法 |
|---|---|---|
| 时间复杂度 | O(n) | O(n log n) |
| 空间复杂度 | O(1) | O(log n)(递归栈) |
| 代码难度 | 简单(5 行核心) | 中等(30+ 行) |
| 面试价值 | 最优解,必须掌握 | 展示分治思想,常被追问 |
| 适用场景 | 直接求解 | 适合举一反三(如最近点对、归并排序) |
💡 面试建议:先说 Kadane 算法(最优解),如果面试官追问"还有没有其他解法",再讲分治法。这样既展示了你知道最优解,又展示了你的算法思维广度。
💡 经验教训
1. 做题前先算复杂度
LeetCode 的硬约束(数组长度可达 10^5)决定了 O(n²) 空间必死无疑。
以后做题,先估算一下:
- 数据规模 10^3 → O(n²) 能过
- 数据规模 10^5 → O(n²) 大概率超时,O(n log n) 才安全
- 数据规模 10^6+ → 必须 O(n) 或 O(n log n)
2. 涉及"所有组合"大概率需要优化
如果题目涉及"所有子数组"、"所有子序列"、"所有排列组合",千万别傻乎乎地全存起来 。大概率需要用动态规划(DP)或贪心来优化掉存储过程。