算法刷题这事儿,放在 JavaScript 语境下,总给人两种极端印象:要么觉得 JS 写算法不正经,要么觉得用 JS 刷题就是写业务逻辑的延伸,随便搞搞就行。我在 LeetCode 和牛客上摸爬滚打这几年,最大的感受是——语言只是表达工具,但工具手册做得好不好,直接决定你刷题是“有效积累”还是“无效内耗”。JavaScript 在刷题场景里其实有它独特的优势:数组方法齐全、对象当哈希表用极其顺手、原型链和闭包反而能帮你做出一些很优雅的解法。但这背后也暗藏不少坑,比如隐式类型转换、sort 的默认字符串排序、位运算按 32 位处理等等,这些细节如果不整理成册,每次都要重新踩一遍。
我写这篇文章,就是想把我自己整理的一套“算法刷题 JavaScript 工具手册”沉淀下来。它不是教材,不是源码仓库,而是一份拿来即用的实战清单——从本地调试环境、核心方法选型、内置对象性能分析,到模板代码、输入输出处理、常见报错排查,我都会分门别类地拆开讲。适合正在用 JS 刷题的人、准备面试但被 JS 细节劝退的人,以及想提升解题效率但不想被工具束缚的人。
1. 思路与准备:为什么 JS 刷题需要一份“自己的工具册”
1.1 刷题不是写业务代码,工具链要提前收敛
很多人打开 LeetCode 就直接开写,写到一半开始纠结:“这个数组去重是用 Set 还是手动遍历?”“这个斐波那契是递归还是迭代?”“为什么我明明逻辑对了,但提交超时?”这些问题本质上是同一个问题——没有在动手前确定一套稳定的工具选择。
业务代码讲究可维护性、可读性、扩展性,但刷题讲究的是:时间、空间复杂度可控、边界条件不遗漏、代码写得快。这三件事听起来简单,但落到 JavaScript 里就需要提前做几个“收敛”决定:
- 默认优先使用什么数据结构?我的习惯是优先数组和对象(Map),它们覆盖了 80% 以上的题目场景。
- 默认怎么处理边界条件?比如空数组、负整数、极大整数、字符串为空、对象键冲突,这些必须有固定写法。
- 默认用什么遍历方式?for 循环还是 forEach 还是 reduce?每种的性能、语义、可读性差异很大。
这些问题不是靠脑子记住就行的。我强烈建议你把自己常用的判断逻辑、模板代码、工具函数全部写在一个单独的文件里,每次刷题前先翻一遍,比临时去搜索引擎查“JS 数组去重”要快得多。
1.2 工欲善其事:本地调试环境搭建
LeetCode 自带的在线编辑器适合简单的题,但遇到复杂题目,尤其是需要调试递归、动规或者链表操作时,在线编辑器的体验非常折磨。我个人的做法是:在本地维护一个刷题项目,根目录结构大概是这样的:
text复制leetcode-js/
├── templates/ # 模板代码:链表、二叉树、堆、并查集等
├── utils/ # 工具函数:取随机数组、测速、对比数组等
├── solutions/ # 按题号组织的题解
└── test/ # 可以手动执行测试脚本的目录
本地环境我用最朴素的 Node.js,不配框架,不搞复杂构建。因为刷题需要的只是“能跑、能测、能看结果”。具体做法是:
bash复制npm init -y
node test.js
然后在 test.js 里直接写:
javascript复制const { generateArray } = require('./utils/array')
const { twoSum } = require('./solutions/0001.two-sum')
const nums = generateArray(10, 0, 100)
const target = 50
console.log(twoSum(nums, target))
这套环境十分钟就能搭好,但带来的收益是长期的。你可以在本地快速跑大测试集,可以直接打断点,也可以很方便地把出错用例保存下来反复回归。在线编辑器做不到这些事。
1.3 明确目标:这本文档不是语法速查,是“解题决策手册”
很多同学拿了一本《JavaScript 权威指南》就当作刷题手册,这其实是个误区。刷题手册的价值不在于罗列“字符串有哪些方法”,而在于帮你做决策——遇到这道题,应该优先用哪个方法、什么数据结构、什么遍历方式,以及底层会有什么性能问题。
我整理的这套工具手册,核心围绕六个决策点展开:
- 输入数据是数组还是链表还是字符串?类型不同,处理思路完全不一样。
- 需要去重、查找、排序还是统计?这决定了你要用 Set、Map、sort 还是手动哈希。
- 数据规模多大?如果 n 到 10^6 级别,那 O(n²) 算法基本没戏。
- 是否需要修改原数组?这决定了你用 splice、slice 还是原地交换。
- 是否涉及递归或回溯?这决定了要不要显式用栈来避免调用栈溢出。
- 是否涉及数字极限?超出了 Number.MAX_SAFE_INTEGER 就要换 BigInt 或者数字字符串处理。
把这六个问题的答案写死在工具册里,每次刷题前快速过一遍,思路会清晰很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 JavaScript 数组方法选型:哪些值得用,哪些是坑
数组是刷题里最常用的数据结构,但 JavaScript 数组不像 C++ 的 vector 那样纯粹。它的底层实现是动态数组,插入、删除、访问的复杂度其实有讲究。这里我整理了一张方法选型表,是我在实际刷题中反复验证过的:
| 场景 | 推荐方法 | 时间复杂度 | 注意事项 |
|---|---|---|---|
| 尾部追加 | push |
均摊 O(1) | 最常用,别用 concat 代替 |
| 尾部弹出 | pop |
O(1) | 模拟栈时用 |
| 头部追加 | unshift |
O(n) | 慎用,会导致整个数组重新搬移 |
| 头部弹出 | shift |
O(n) | 慎用,队列场景建议用双指针模拟 |
| 遍历 | for 循环 |
O(n) | 性能最好,可用 let i = 0 |
| 遍历(函数式) | forEach |
O(n) | 代码简洁,但无法 break,return 也不能中断 |
| 映射 | map |
O(n) | 适合生成新数组,但要注意回调中不要产生副作用 |
| 过滤 | filter |
O(n) | 适合筛选,但会创建新数组,大数组场景注意内存 |
| 归约 | reduce |
O(n) | 强大但可读性差,多用可能影响面试官阅读体验 |
| 排序 | sort(compareFn) |
O(n log n) | 必须传比较函数,默认按字符串排序是巨坑 |
| 查找索引 | indexOf |
O(n) | 适合数组元素为基本类型时 |
| 查找对象索引 | findIndex |
O(n) | 回调返回找到的索引 |
| 判断是否有满足 | some |
O(n) | 短路,提前返回 |
| 判断是否全部满足 | every |
O(n) | 短路,提前返回 |
重点说下 sort 的坑。我在刚刷题的时候,在 LeetCode 上遇到一道简单的按大小排序数组的题,直接 nums.sort(),结果排序结果是错的。原因就是:默认 sort() 会把元素转为字符串,然后按 UTF-16 码点排序。比如 [10, 9, 100] 会排成 [10, 100, 9],因为在字符串比较里 "100" 小于 "9"。所以,所有数字排序场景必须写成:
javascript复制nums.sort((a, b) => a - b) // 升序
nums.sort((a, b) => b - a) // 降序
2.2 对象 vs Map:哈希表选择的五个判断标准
JavaScript 里能做哈希表的东西有 Object、Map、Set,很多人随手就用了,但刷题到一定量之后你会发现,选错了数据结构,调试困难且性能不稳定。我总结了五个判断标准:
- 键是否容易冲突:如果键可能是
"constructor"、"__proto__"这类特殊字符串,直接用普通对象会踩原型链的坑。这时候必须用Map,因为它不携带原型链的键。 - 键是否经常增删:
Map的增删性能优于普通对象。Object在频繁添加动态属性时会有隐式优化失效的风险。 - 是否需要有序遍历:
Map保持插入顺序,遍历结果可预测;普通对象的键顺序在某些场景下不保证。 - 是否需要快速取 size:
Object.keys(obj).length是 O(n),map.size是 O(1)。在循环里频繁取长度时,Map完胜。 - 是否涉及数字键:普通对象的数字键会自动转成字符串并排序,容易造成混乱,比如
obj[10]和obj["10"]无法区分,Map则严格区分类型。
我现在的习惯是:凡是需要在循环里动态增删键值对的,一律用 Map。凡是要做去重的,一律用 Set。只有构建一个定长的、键为业务枚举的映射时,才用普通对象。比如做罗马数字转整数,可以这样:
javascript复制const romanMap = {
I: 1,
V: 5,
X: 10,
L: 50,
C: 100,
D: 500,
M: 1000
}
这种固定键的对象没问题。但如果是“给定一个整数数组,返回每个数出现的次数”这种动态统计,直接用 Map:
javascript复制function countOccurrences(arr) {
const countMap = new Map()
for (const num of arr) {
countMap.set(num, (countMap.get(num) || 0) + 1)
}
return countMap
}
2.3 字符串处理:不只是 split 和 join
字符串在 JS 刷题里占据极其重要的位置,尤其是翻转、回文、子串、模拟计算器等题目。我总结了几个高频操作和它们的“为什么”:
- 字符串转数组:
s.split('')是按 Unicode 码点拆吗?不是,它有码点边界问题。如果字符串里含 emoji 或者生僻字,split('')会把代理对拆开。严格处理应该用Array.from(s)或扩展运算符[...s]。 - 子串截取:
slice(start, end)和substring(start, end)参数含义几乎一样,但slice支持负数索引,substring不支持;substr已废弃,别用。 - 大小写转换:
toUpperCase()、toLowerCase()在处理英文字母回文时很常用。 - 正则匹配:
match(/[a-zA-Z0-9]/g)这种写法在做“有效回文”类题目时非常方便,但要注意/[^a-zA-Z0-9]/g在刷题里用得少,因为效率不如手写isalnum判断。 - 字符串比较:直接
===是值比较,不用new String()包装,那是对象比较。
还有一个高频场景:把字符串中的每个字符转成数字。常见的两种写法:
javascript复制const digit = s.charCodeAt(i) - 48 // '0' 的 charCode 是 48
const digit = Number(s[i]) // 直观但稍慢
我倾向于用 charCodeAt 做,因为 charCodeAt 是 O(1) 且不产生新字符串。千万别用 parseInt(s[i]),它内部会做一定的解析,性能最差。
2.4 隐式类型转换与比较:刷题最容易翻车的地方
JavaScript 的隐式类型转换在业务开发里也许是“灵活”,在刷题里几乎全是坑。重点要注意:
==和===:刷题一律用===。1 == true为 true,null == undefined也为 true,但这在算法题里很容易掩盖逻辑错误。+运算符:如果是字符串加数字,结果是字符串拼接;如果是减乘除,又会自动转数字。比如"5" - 1 === 4,但"5" + 1 === "51"。这个坑在做“字符串表达式计算”类题目时非常致命。- 空数组参与运算:
[] + [] === "",[] + {} === "[object Object]",这些如果你在刷题时遇到了,说明数据解析出了问题,但绝大多数题目不会往这种极端走。 - NaN:
NaN === NaN为 false,判断一个值是否为 NaN 要用Number.isNaN()。 - 四舍五入:
Math.round(1.5)是 2,但Math.round(-1.5)是 -1,不是 -2。做“银行家舍入”类题目时,要注意这个行为。
这些细节非常琐碎,我建议直接写进工具手册的“坑清单”里,每次刷题前快速扫一遍,形成肌肉记忆。
3. 实操过程与核心环节实现
3.1 模板库:我是怎么封装链表、二叉树、堆和并查集的
算法题离不开数据结构的模板,但 JavaScript 里不内置链表、二叉树、堆这些。如果不提前准备好模板,每次做题都手写,又慢又容易出错。我在工具手册里维护了一套模板,这里挑几个最常用的分享。
链表
javascript复制function ListNode(val, next) {
this.val = val === undefined ? 0 : val
this.next = next === undefined ? null : next
}
function createLinkedList(arr) {
const dummy = new ListNode(-1)
let cur = dummy
for (const val of arr) {
cur.next = new ListNode(val)
cur = cur.next
}
return dummy.next
}
function printLinkedList(head) {
const res = []
let cur = head
while (cur) {
res.push(cur.val)
cur = cur.next
}
console.log(res.join(' -> '))
}
这里用 dummy 节点(虚拟头节点)是链表题的核心技巧。它能帮你避免“头节点为空”和“删除头节点”等特殊情况的单独判断。创建链表、反转链表、合并链表几乎都会用到 dummy。
二叉树
javascript复制function TreeNode(val, left, right) {
this.val = val === undefined ? 0 : val
this.left = left === undefined ? null : left
this.right = right === undefined ? null : right
}
按层次遍历建树,是最常见的调试辅助手段:
javascript复制function createTree(arr) {
if (!arr || arr.length === 0) return null
const root = new TreeNode(arr[0])
const queue = [root]
let i = 1
while (i < arr.length) {
const node = queue.shift()
if (arr[i] !== null && arr[i] !== undefined) {
node.left = new TreeNode(arr[i])
queue.push(node.left)
}
i++
if (i < arr.length && arr[i] !== null && arr[i] !== undefined) {
node.right = new TreeNode(arr[i])
queue.push(node.right)
}
i++
}
return root
}
堆(优先队列)
JavaScript 没有内置堆,所以最小堆 / 最大堆是刷题必备模板。我维护了一个最简最小堆:
javascript复制class MinHeap {
constructor() {
this.heap = []
}
getSize() {
return this.heap.length
}
peek() {
return this.heap[0]
}
push(val) {
this.heap.push(val)
this._siftUp(this.heap.length - 1)
}
pop() {
if (this.heap.length === 1) return this.heap.pop()
const top = this.heap[0]
this.heap[0] = this.heap.pop()
this._siftDown(0)
return top
}
_siftUp(idx) {
while (idx > 0) {
const parent = Math.floor((idx - 1) / 2)
if (this.heap[parent] <= this.heap[idx]) break
;[this.heap[parent], this.heap[idx]] = [this.heap[idx], this.heap[parent]]
idx = parent
}
}
_siftDown(idx) {
const n = this.heap.length
while (true) {
let smallest = idx
const left = idx * 2 + 1
const right = idx * 2 + 2
if (left < n && this.heap[left] < this.heap[smallest]) smallest = left
if (right < n && this.heap[right] < this.heap[smallest]) smallest = right
if (smallest === idx) break
;[this.heap[smallest], this.heap[idx]] = [this.heap[idx], this.heap[smallest]]
idx = smallest
}
}
}
注意,堆模板的 siftUp 和 siftDown 逻辑必须一次性写对,否则调试堆相关题目时会非常痛苦。我在实战中发现,这里最容易错的地方是交换时没有用临时变量或解构的边界条件,导致数组访问越界。建议你在模板文件里写上几个基础测试用例,比如 [5, 2, 4, 1, 3] pop 一次看是不是 1。
并查集
并查集适合“连通性”判断、岛屿数量、朋友圈等题目。核心代码很短:
javascript复制class UnionFind {
constructor(n) {
this.parent = Array.from({ length: n }, (_, i) => i)
this.rank = new Array(n).fill(1)
this.count = n
}
find(x) {
if (this.parent[x] !== x) {
this.parent[x] = this.find(this.parent[x])
}
return this.parent[x]
}
union(x, y) {
const rootX = this.find(x)
const rootY = this.find(y)
if (rootX === rootY) return
if (this.rank[rootX] < this.rank[rootY]) {
this.parent[rootX] = rootY
} else if (this.rank[rootX] > this.rank[rootY]) {
this.parent[rootY] = rootX
} else {
this.parent[rootY] = rootX
this.rank[rootX] += 1
}
this.count -= 1
}
}
这里的路径压缩 + 按秩合并是标准写法,能保证几乎所有查询操作的平均复杂度接近常数。把模板预先写好后,并查集题目可以从 5 分钟压缩到 1 分钟以内。
3.2 快读快写与输入输出:应对 ACM 模式下的 I/O 压力
LeetCode 核心代码模式是不用处理输入输出的,但牛客、华为 OD、一些公司机试都是 ACM 模式,需要自己处理标准输入。JavaScript 在 ACM 模式下的输入处理是所有语言里最啰嗦的之一,但一旦写好封装,反而很稳定。
我常用的输入处理模板:
javascript复制const readline = require('readline')
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
const lines = []
rl.on('line', (line) => {
lines.push(line)
})
rl.on('close', () => {
// 在这里解析 lines
const [n, ...arr] = lines
console.log(solve(n, arr))
})
有些场景下,输入可能只有一行或者少量几行,用 process.stdin 也可以:
javascript复制const input = require('fs').readFileSync(0, 'utf-8').trim().split('\n')
我个人的偏好是 readline,因为它在牛客网上跑得稳,也不会一次性把整个输入文件读进内存,适合大型输入场景。fs.readFileSync(0) 在本地跑没问题,但在部分 OJ 上可能有兼容性差异。
此外,输出的时候尽量一次性拼好再 console.log,不要用 console.log 在循环里多次输出。频繁调用 console.log 会造成 IO 阻塞,大循环下容易超时。正确做法是:
javascript复制const res = []
for (let i = 0; i < n; i++) {
res.push(compute(i))
}
console.log(res.join('\n'))
3.3 测试与调试的小工具:测速、随机用例、对比函数
刷题过程中最痛苦的其实是“用例对了但提交错了”或者“本地跑半天没结果”。所以我专门写了一些小工具,用来在本地模拟各种测试场景。
生成随机数组
javascript复制function generateArray(len, min = 0, max = 100) {
return Array.from({ length: len }, () => Math.floor(Math.random() * (max - min + 1)) + min)
}
测性能
javascript复制function measure(fn, ...args) {
const start = performance.now()
const result = fn(...args)
const end = performance.now()
console.log(`耗时 ${(end - start).toFixed(3)} ms`)
return result
}
Node.js 里可以使用 performance.now(),浏览器里也支持。它比 Date.now() 精度高得多,适合测量短时间操作。
对比两个算法结果是否一致
这是最实用的调试方法。比如你写了一个优化解法,但不确定对不对,可以拿一个暴力解法做基准,双跑然后对比结果:
javascript复制function assertSame(expectFn, targetFn, testCases) {
for (let i = 0; i < testCases.length; i++) {
const input = testCases[i]
const res1 = JSON.stringify(expectFn(input))
const res2 = JSON.stringify(targetFn(input))
if (res1 !== res2) {
console.error(`不匹配的用例: ${JSON.stringify(input)}`)
console.error(`期望: ${res1}`)
console.error(`实际: ${res2}`)
return
}
}
console.log('全部通过')
}
这种对比方法最适合从暴力解法优化到高级解法的场景。先写一个通常能正确但复杂度高的暴力版本,再写一个复杂版本,跑几百个随机用例,可以有效找回刷题信心。
3.4 主流程:从 LeetCode 页面到本地调试的完整示例
我用一道简单的“求两数之和”来演示整个工具手册的实际用法。题目是:给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回它们的数组下标。
我的标准流程是:
- 看到题目,先判断类型:这是查找类问题,可以用哈希表优化。
- 确定数据结构:用 Map 存“值 -> 下标”。
- 写核心逻辑:
javascript复制function twoSum(nums, target) {
const map = new Map()
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i]
if (map.has(complement)) {
return [map.get(complement), i]
}
map.set(nums[i], i)
}
return []
}
- 本地用测试工具快速验证:
javascript复制const arr = generateArray(5, 0, 10)
const target = 8
console.log('原始数组:', arr)
console.log('结果:', twoSum(arr, target))
- 再跑几个边界用例——数组只有两个元素、负数、重复数字、无解情况。
这套流程熟练之后,一道简单题从读题到通过,时间能压到五分钟以内。这个速度在面试或机试里的价值不言而喻。
4. 常见问题与排查技巧实录
4.1 JavaScript 运行时报错排查:从报错信息到定位逻辑
刷题时最常见的报错,我整理了一张速查表,基本上覆盖了我踩过的坑:
| 报错信息 | 原因 | 解决办法 |
|---|---|---|
TypeError: Cannot read properties of undefined (reading 'xxx') |
访问了 undefined 的属性 | 大概率是数组越界或对象键不存在,先检查索引和返回值 |
ReferenceError: xxx is not defined |
使用了未声明的变量 | 检查是不是拼写错误,或者变量作用域泄漏 |
RangeError: Maximum call stack size exceeded |
递归太深 | 检查递归终止条件;必要时改写成循环 |
TypeError: nums.sort is not a function |
对非数组调用了数组方法 | 确认输入类型,可能是字符串判断失误 |
SyntaxError: Unexpected token |
语法错误 | 检查括号、分号、引号是否成对 |
Time Limit Exceeded |
算法复杂度过高 | 需要优化,不能只调代码 |
Memory Limit Exceeded |
内存占用过大 | 可能创建了过多数组/对象,考虑滚动数组或压缩状态 |
NaN 出现在结果里 |
操作了非数字类型 | 检查是否包含空字符串转换、undefined 参与加减 |
其中“Maximum call stack size exceeded”是 JS 刷题里独有的、也是最容易卡住人的一个问题。递归深度超过约一万层就会爆栈,很多纯递归思路在 JS 里跑不通,必须改写成显式栈。比如“二叉树前序遍历”用递归无所谓,但“N 皇后”这类回溯如果在某种极端数据下递归很深,可能会触发爆栈。这时候要用显式栈模拟递归,或者换成动态规划。
4.2 为什么同样是方法,forEach 就是比 for 慢
这个问题我在刷题时遇到过很多次,原因很复杂,但核心分析如下:
forEach、map、filter 这些高阶方法底层都依赖回调函数,每次迭代都会产生一次函数调用。函数调用本身有开销,包括创建执行上下文、作用域链查找、参数传递等。而 for 循环只是在同一个执行上下文里做条件判断和自增操作,开销要小得多。如果遍历 10 万条数据,差距可能只有几毫秒,但在复杂题目里,这种差距会被放大到超时边缘。
所以,我在所有需要极致性能的场景里,比如大数组遍历、双层循环、DP 状态转移,都是优先 for 循环。只有做题时间充裕、且逻辑本身不复杂时,才用函数式方法提高可读性。
但有一点要说明:for...of 在遍历数组时的性能略低于普通 for 循环,因为它要创建迭代器对象。不过比 forEach 好一些。如果不需要索引,for...of 的可读性比普通 for 好,我偶尔也会用,但性能敏感层仍然用普通 for。
4.3 位运算:JS 的位运算只有 32 位,你知道后果吗
JavaScript 的位运算符(&、|、^、~、<<、>>)会把操作数转成 32 位有符号整数,再进行运算。这意味着,如果你用位运算处理超过 32 位的整数,结果可能和预期不符。
举例:
javascript复制console.log(2147483647 << 1) // -2
console.log(Math.pow(2, 31)) // 2147483648
Math.pow(2, 31) 本身是正常的,但 2147483647 << 1 会溢出成 -2,因为 2147483647 的二进制是 31 个 1,左移一位后符号位变成 1,变成负数。
所以刷题时如果题目数据范围较大(超过 2^31 - 1),不要用位运算。常见的使用位运算的场景主要是:状态压缩 DP 里表示子集、操作二进制字符串、判断奇偶、交换变量、清零元素等。在这些场景下,数据本身是有限的,位运算才安全。
我自己的经验是:一般只有在做“只出现一次的数字”“集合子集”之类的题目时才用位运算;凡是涉及到数值计算,就回到普通算术。
4.4 边界条件:这些小用例能帮你避免 90% 的提交失败
刷题踩坑最多的地方,不是解法思路,而是边界条件。我总结了一套每次提交前必跑的“边界用例模板”,不同题型对照执行:
- 数组类题目:空数组、单元素数组、全相同元素数组、已排序数组、倒序数组、含负数数组、含 0 数组。
- 字符串类题目:空字符串、单字符字符串、全相同字符字符串、带空格字符串、Unicode 字符串。
- 二叉树类题目:空树、只有根节点、只有左子树、只有右子树、完全二叉树、单链退化树。
- 动态规划类题目:n = 0、n = 1、n = 2、n = 很小的极端值。
- 数学类题目:最大值、最小值、0、1、负数、除数为 0 的异常。
跑完这些用例,再去提交,效率会高很多。很多同学刷题时比较依赖在线 judge 的报错信息,但这样反复提交不仅浪费时间,还容易焦躁。自己先跑边界用例,是更成熟的刷题习惯。
4.5 超时与内存优化:从 TLE/MLE 到 AC 的三个步骤
超时和内存超限是刷题人的噩梦。遇到 TLE,我一般按以下三个步骤排查:
- 先确认时间复杂度的量级是否合适。如果 n 是 10^5,你的代码是 O(n²),再怎么优化常数也救不回来。这时候必须换思路,比如双指针、哈希表、排序后二分、前缀和、单调栈等。
- 再查代码里的“算法内无效操作”。比如循环里用了
shift()、unshift(),或者在循环里每次array.indexOf(),这些操作会让复杂度凭空多一个 n。 - 最后查 I/O 或 console 输出。如果是 ACM 模式,
console.log在循环里反复调用,也会拖慢速度,务必改成整体拼接一次性输出。
遇到 MLE,我一般这样处理:
- 用
Map替代普通对象,减少隐藏开销(对象可能存储更多元数据)。 - 不用
JSON.stringify做深拷贝,尤其是递归场景。 - 能用数组模拟栈/队列,就别用
Array.from生成过多中间数组。 - 滚动数组优化 DP,把二维数组压缩成一维。
这套方法虽然朴素,但胜在稳定。每次遇到性能问题,我都是按这个流程逐步排查,而不是靠感觉瞎试。
5. 整理一份属于自己的“手写工具手册”
5.1 用免费工具快速搭建手册内容
刷题遇到不会的题,大家都会上网查题解。但查过的题解,如果没有自己的笔记沉淀,过几天又会忘。所以,我强烈建议每个人自己整理一份“算法刷题 JavaScript 工具手册”,内容不用多么华丽,重点是记录你在刷题过程中遇到的问题、解法模板、踩坑记录。
整理手册的方式,现在非常灵活。不需要懂复杂的排版系统,用支持 Markdown 的工具就行。比如本地用 VS Code 配 Markdown 插件,或者在线用笔记软件、Git 仓库托管,都可以。我个人的做法是:把工具手册内容直接放在刷题项目的 docs/ 目录下,用 Markdown 维护,这样代码和文档放在一起,查阅起来非常方便。
如果你需要更轻量的方式,也可以直接利用在线文档工具,建一个“JS刷题工具手册”的文档,把内容分章节写清楚,随时打开查阅。我见过有些同学甚至用网盘同步笔记,也能达到效果。重点不是工具,而是坚持维护。
5.2 手册的分章节结构和内容模板
这里我分享一下我自己的手册目录结构,供你参考:
markdown复制1. 字符串与数组高频方法速查
- split(), join(), slice(), splice()
- charCodeAt(), fromCharCode()
2. 哈希表选型
- Map 与 Object 对比
- Set 去重与差集并集实现
3. 链表与树模板
- 链表创建/反转/删除
- 二叉树遍历(前/中/后/层序)
4. 堆模板与优先队列
- 最小堆/最大堆
- TopK 问题
5. 并查集模板
6. 动态规划滚动数组模板
7. 快读快写模板
8. 边界用例清单
9. 常见报错与排查
10. 性能分析工具箱
每个章节内,我会预留一段“心得”区域,记录用这道题时踩过什么坑、换过什么思路、面试官可能会怎么追问。
5.3 如何“喂”给这个手册:从零散笔记到系统工具
有了手册框架之后,接下来就是持续往里补充内容。我给自己定了一条规矩:每天刷完题,至少往手册里塞三条内容——一条模板、一条坑记录、一条优化心得。一个月下来,手册就会从空壳变成真正的创作成果。
更重要的是,这个手册会慢慢变成你面试前的“定海神针”。不少公司面试手写算法题,问的问题和 LeetCode 不完全一样,但考察的点高度重叠。带着这本手册去复习,你不需要再翻几十篇博客,也不需要对着几百题重复刷。
我个人在做完一本手册之后最大的体会是:刷题不是拼题量,而是拼“沉淀”。同样是做了 300 题的人,有没有整理工具手册,差距是巨大的。有手册的人遇到陌生题,能快速定位到熟悉的数据结构和模板;没手册的人即使做过类似题,也可能因为细节遗忘而卡壳。
5.4 从工具手册到解题思维的跃迁
工具手册积累到一定程度,你会发现自己对题目的敏锐度提升了。你不再盲目地尝试各种解法,而是会下意识地做系统性决策:这题适合哈希表还是双指针?数据规模支持什么复杂度?是否需要排序?递归空间会不会爆?这种决策速度,就是刷题高手的核心竞争力。
所以,与其说这是一本工具手册,不如说是一套训练思维的框架。它把零散的知识点、模板、细节、经验组合成一个决策系统,让你在面对任何一道算法题时,都能按部就班地分析和实现。
最后再分享一个小技巧:工具手册里一定要有一份“我不会的题”清单。每次遇到卡壳半小时以上的题,记录下来,隔三差五回头重做。重做时不要看题解,而是自己从“工具手册”里找线索,这样学到的才是你自己的。久而久之,你的刷题效率会超过绝大多数人。
