刷LeetCode刷到数组类题目时,我经常会冒出同一个念头:很多操作明明每次都在重复,比如求和、统计出现次数、初始化二维数组,为什么不能像内置方法一样直接 数组.xxx() 呼来就用?有一次我连续写了两道题,发现前一道刚写完一个“统计数组中每个元素出现次数”的遍历,后一道又得原封不动再写一遍。于是我做了一个决定:既然刷题本身就是练手,那就顺手给Array整体加一个方法,把它做成一套属于自己的数组工具层。
这篇内容不是什么标准答案,也不涉及复杂算法,而是一个很具体的练题实验:我如何给JavaScript的Array对象“整体”扩展一批方法、中途踩过哪些坑、以及最后又是怎么用这些方法反过来解LeetCode真题的。如果你也在刷题时写过大量重复的数组工具函数,或者想搞清楚直接给内置对象挂方法到底合不合理,这篇应该能给你一点参考。
1. 刷题刷到重复代码,我决定对Array原型下手
1.1 先说我为什么会对原型方法动心思
我刷题的语言是JavaScript,数组操作频率极高。LeetCode的数组题里,最常用的其实来回就那么几招:求和、找最大最小值、过滤条件元素、统计频次、构建Map映射、初始化一个二维数组。这些操作如果用原生写法,每道题都得重新组织一遍代码。比如求和,你至少要写:
javascript复制let sum = 0;
for (let i = 0; i < nums.length; i++) {
sum += nums[i];
}
很多题解里会用 reduce 一行搞定,这已经很简洁了,但问题在于:如果你每道题都要写 reduce,遇到需要多个数组的题目,代码里还是会充满重复的回调函数。真正让我下决心的场景是“统计频次”。我在同一天做了两道题,一道是“有效的字母异位词”,一道是“多数元素”,两道题都需要遍历数组并统计元素出现次数。第一道我写了一个 for 循环,第二道我又写了一个几乎一模一样的循环。那一刻我想,为什么不直接给数组加一个 countBy() 方法?以后刷题遇到同类需求,直接一行调用。
这就是我这次实验的起点。给Array整体加一个方法,本质上不是炫技,而是把刷题过程中反复出现的逻辑抽象成公共能力,让后续解题更聚焦在题目本身的算法思考上。
1.2 给Array挂方法和写独立函数,到底差在哪
动手之前,我先做过一轮对比。最常见的做法是在文件里定义独立函数:
javascript复制function sum(nums) {
return nums.reduce((acc, cur) => acc + cur, 0);
}
这样做当然没问题,但用到的时候需要手动传入数组,函数多了以后所有工具函数堆在一起,调用起来有一种“游离在外”的感觉。挂到Array原型上之后,语法变成了:
javascript复制nums.sum();
代码的可读性会明显不一样,特别是链式操作场景。比如我想过滤出偶数再求和,原生写法是 nums.filter(n => n % 2 === 0).reduce((a, b) => a + b, 0)。如果 sum() 是内置方法,链式就是 nums.filter(...).sum(),语义更直观。
不过我也清楚,给内置对象挂方法并不是没有代价的。直接写 Array.prototype.sum = ... 会污染 for...in 遍历,还可能和未来的方法名冲突。所以这个实验其实包含了一个很关键的分支:怎么把“直接挂方法”这件事做得更规范。这部分的思考我放在第二章细讲。
1.3 哪些数组方法最适合“练题期”封装
不是所有操作都值得封装。我给自己定了一个筛选标准:至少在两道以上不算重复的题里用过,才有资格成为候选方法。按这个标准,我第一轮选出三个候选:
sum():数组求和。两数之和、子数组相关、区间和统计都会用到。countBy():统计元素出现次数。字母异位词、多数元素、字符频率类题目都是高频场景。matrix():快速生成多维数组。动态规划类题目基本每次都要初始化二维DP表。
这三个方法对应了刷题时的三类重复劳动:遍历累加、分组统计、容器初始化。把它们封装好以后,我刷题的体验确实提升了不少。后续如果你想扩展,也可以按照这个标准继续加,比如 frequency()、chunk()、unique() 等方法,都可以考虑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前必须想清楚的三个问题
2.1 扩展内置原型,最大的风险不是崩溃而是“串味”
很多人一听到给 Array.prototype 挂方法,第一反应是“不要修改内置对象”。这个担心有道理,但需要分场景。浏览器环境里,如果你用 Array.prototype.sum = ... 这种方式直接赋值,这个新属性默认可枚举。那么代码里如果存在 for (let key in array) 这种老式遍历,就会多出 sum 这个key,造成不可预料的逻辑错误。这就是我所说的“串味”——不是你的代码崩了,而是别人代码的行为变了。
但刷题场景其实没有“别人代码”的问题,LeetCode 判题时每个提交都运行在独立环境里,原型污染不会影响其他提交。所以在 LeetCode 上直接扩展原型,风险是可接受的。
不过作为一个习惯,我还是推荐用更安全的方式来做这件事。至少不要把属性搞成可枚举的,这样既能享受原型扩展的便利,又不会对 for...in 造成影响。
2.2 一个 defineProperty 解决 for...in 被污染的隐患
想在添加方法时让属性不可枚举,标准做法是 Object.defineProperty。比如给数组加一个 sum 方法:
javascript复制Object.defineProperty(Array.prototype, 'sum', {
value: function() {
return this.reduce((acc, cur) => acc + cur, 0);
},
writable: true,
configurable: true,
enumerable: false
});
这里最关键的就是 enumerable: false。设成 false 之后,for...in 和 Object.keys() 都不会看到这个方法,但直接调用 nums.sum() 没有任何问题。这个细节让我很放心,在刷题时随便用,也不用担心污染问题。
我在代码里把 writable 和 configurable 都设为 true,这样如果将来我觉得方法需要改进,仍然可以覆盖或者重新定义。如果想把方法锁死,也可以把这两个设为 false,但要考虑后续维护成本,我不建议一开始就锁死。
2.3 批量注册自定义方法,这样维护起来不乱
只加一个 sum 方法还好,如果要加 countBy、matrix 等多个方法,逐个写 Object.defineProperty 就会显得啰嗦。我通常的做法是把所有方法写进一个对象,然后用一个循环批量注册:
javascript复制const arrayExtras = {
sum() {
return this.reduce((acc, cur) => acc + cur, 0);
},
evens() {
return this.filter(num => num % 2 === 0);
},
countBy(fn) {
const counter = {};
this.forEach((item, index) => {
const key = typeof fn === 'function' ? fn(item, index) : String(item);
counter[key] = (counter[key] || 0) + 1;
});
return counter;
},
matrix(rows, cols, fill = 0) {
return Array.from({ length: rows }, () => Array(cols).fill(fill));
}
};
Object.keys(arrayExtras).forEach(name => {
Object.defineProperty(Array.prototype, name, {
value: arrayExtras[name],
writable: true,
configurable: true,
enumerable: false
});
});
这样一来,以后想加新方法,只需要在 arrayExtras 对象里加一个函数即可。每次刷题前把这套代码粘贴到代码块顶部,就等于给当前环境里的所有数组装上了专属工具包。
这里有一个经验:虽然 countBy 的第二个参数我看似没用上,但保留了 index 参数,这样回调里如果要用到索引也能拿到。设计扩展方法时,最好向原生数组方法的签名习惯看齐,尽量和 map、filter 这类方法的参数顺序保持一致,会更容易记忆。
3. 第一期实战:我封装了三个高频 Array 方法
3.1 sum():随手求和,省掉每次写 reduce
sum() 的实现非常简单:
javascript复制sum() {
return this.reduce((acc, cur) => acc + cur, 0);
}
但要考虑到刷题中偶尔会遇到包含字符串数字的情况,比如 ['1', '2', '3'] 想求和。暴力做法是 this.reduce((a, b) => a + (+b), 0),我封装时直接让回调里做一次隐式转换也不行,因为 reduce 累加器会持续变成字符串。更稳妥的写法是把每个元素显式转成数字:
javascript复制sum() {
return this.reduce((acc, cur) => acc + Number(cur), 0);
}
这样纯数字数组求和没问题,字符串数字数组也能正确相加。实际刷题时大多数场景都是纯数字,这个兼容处理算是锦上添花,但写都写了,干脆一步到位。
用法示例:
javascript复制const nums = [1, 2, 3, 4, 5];
const total = nums.sum(); // 15
在LeetCode 724“寻找数组的中心下标”这类题目里,sum() 可以直接拿到总和,让左右两侧和的比较变得非常简洁。
3.2 countBy():分组计数,异位词和众数题都是热门需求
countBy() 是我这次封装里价值最高的一个方法,它能按指定规则把元素分组并计数。最经典的场景是统计数组中每个元素的出现次数:
javascript复制const nums = [1, 2, 2, 3, 3, 3];
const counter = nums.countBy(num => num);
// { '1': 1, '2': 2, '3': 3 }
它的实现思路是:遍历数组,对每个元素调用回调函数拿到一个分组键,然后以键为对象属性名进行累加。如果不传回调,就直接以元素本身作为键。这个设计来自我平时用 lodash.countBy 的习惯。
这里有个细节我要提醒一下:对象键会自动被转成字符串。所以 nums.countBy(num => num) 返回的 counter 对象里,数字1的键实际上是字符串 '1'。如果你需要在后续代码里把键当作数字使用,记得用 Number(key) 做一次转换。我在第四章用 countBy() 解“多数元素”题时会展示这个坑的应对方式。
countBy() 还能接受回调函数,从而按“区间”或“属性”分组,比如统计一个数组里有几个正数、几个负数、几个零:
javascript复制const nums = [1, -2, 3, 0, -5, 0];
const result = nums.countBy(num => {
if (num > 0) return 'positive';
if (num < 0) return 'negative';
return 'zero';
});
// { positive: 2, negative: 2, zero: 2 }
这种灵活性在真实刷题时比想象中有用,尤其是处理分类统计类题目时,一行方法直接省略掉整个 for 循环。
3.3 matrix():快速初始化二维数组,动态规划的刚需
动态规划题里最常写的一行代码就是“初始化一个二维数组”。比如创建3行4列、所有元素都是0的二维数组:
javascript复制const dp = Array.from({ length: 3 }, () => Array(4).fill(0));
这行代码本身没问题,但每次写DP题都要重复一遍,而且要是想创建不同默认值的数组,还得改。我把这个逻辑封装成了 matrix() 方法:
javascript复制matrix(rows, cols, fill = 0) {
return Array.from({ length: rows }, () => Array(cols).fill(fill));
}
直接挂在 Array.prototype 上以后,我甚至可以这么写:
javascript复制const grid = [].matrix(3, 4, 0);
由空数组调用 matrix 方法,返回新的二维数组。其实也可以做成静态方法,但为了统一调用风格,我放在了原型上。刷题时最常用的是初始化二维DP表,比如:
javascript复制const dp = [].matrix(m, n, 0);
一道最长公共子序列的题,初始化部分就结束了。这个方法我最满意的点是:传参顺序和语义非常直白,rows 先行、cols 后列,完全符合日常书写习惯。
需要留意的是 fill 如果传的是引用类型,比如 [].matrix(2, 2, []),每一行都会指向同一个空数组。刷题时如果遇到这种需求,建议用循环手动逐行创建,避免共用引用导致的数据混乱。
4. 拿新方法去解 LeetCode 真题:两数之和和多数元素的完整链路
4.1 两数之和:自带索引表的哈希解法
LeetCode第1题“两数之和”要求从数组中找出两个数,使它们的和等于目标值。最基础的暴力解法是两次循环,复杂度O(n^2),数据量一大就会超时。更常见的做法是用一个哈希表记录“每个数最后一次出现的位置”,然后一遍遍历完成查找。
我扩展了 toIndexMap() 方法,专门把数组转成一个“值到索引”的Map,方便哈希解法直接用:
javascript复制Object.defineProperty(Array.prototype, 'toIndexMap', {
value: function() {
const map = new Map();
this.forEach((value, index) => {
if (!map.has(value)) {
map.set(value, index);
}
});
return map;
},
writable: true,
configurable: true,
enumerable: false
});
然后两数之和的答案就可以写成:
javascript复制var twoSum = function(nums, target) {
const indexMap = nums.toIndexMap();
for (let i = 0; i < nums.length; i++) {
const need = target - nums[i];
if (indexMap.has(need) && indexMap.get(need) !== i) {
return [i, indexMap.get(need)];
}
}
return [];
};
toIndexMap() 方法在这里承担了“构建哈希表”的全部职责。判题环境里执行效率没什么问题,代码可读性也清晰了很多。这里留了一个小的设计取舍:只记录元素第一次出现的索引。因为两数之和找的是两个不同位置的下标,记录第一个出现的索引就够用了,而且还能避免在处理重复元素时覆盖关键位置。
4.2 多数元素:countBy 一行拿到统计结果
LeetCode 169“多数元素”要求找出数组中出现次数大于 n/2 的元素。常规解法有摩尔投票、排序取中位数等。既然我刚封装了 countBy(),自然想试试用它来解。
直接思路是:先统计每个元素出现次数,再找出值最大的键。代码如下:
javascript复制var majorityElement = function(nums) {
const counter = nums.countBy(num => num);
let result = 0;
let maxCount = 0;
for (const key in counter) {
if (counter[key] > maxCount) {
maxCount = counter[key];
result = Number(key);
}
}
return result;
};
这里有一个我不能不提的细节:counter 的键是字符串,所以取最大值之后,要把 key 转回数字,否则返回的是字符串,LeetCode 判题时会类型不匹配。以前我写这种代码总忘转换,这次用 countBy() 之后这个坑依然存在,但至少整个统计过程只剩一行方法了。
当然,用 countBy() 解“多数元素”并不是最优解,时间复杂度O(n),空间复杂度O(n),不如摩尔投票法的O(1)空间。但如果你是刷题初学者,用这种方法先把思路跑通,再去优化空间复杂度,也未尝不是一种有效路径。工具方法的意义本来就应该是降低思考门槛,让核心逻辑更清晰。
4.3 实测效果:代码量下降了多少
我用这套扩展方法重新写了5道数组题,分别是“两数之和”“多数元素”“寻找数组的中心下标”“有效的字母异位词”“杨辉三角”。
对比了一下代码量:改造前每道题平均要写35~50行工具逻辑,改造后核心逻辑基本可以控制在10~20行。比如“寻找数组的中心下标”,用 sum() 方法后整个解题过程非常紧凑:
javascript复制var pivotIndex = function(nums) {
const total = nums.sum();
let leftSum = 0;
for (let i = 0; i < nums.length; i++) {
if (leftSum === total - leftSum - nums[i]) {
return i;
}
leftSum += nums[i];
}
return -1;
};
不需要手写累加循环,不需要单独定义辅助函数,所有细节都隐藏在 sum() 方法里。判题结果也都通过了,时间复杂度没有任何额外增加。这个实验至少证明了一件事:在刷题场景下,合理封装数组方法并不会拖累解题效率,反而会让代码更聚焦于算法本身。
5. 扩展原型过程中踩过的坑,以及我现在习惯怎么用
5.1 原型扩展在不同环境里的“独立性”问题
LeetCode 的在线编辑器每次提交都在独立沙盒中运行,你提交的代码里包含 Array.prototype.sum 的定义,完全没问题,不会影响其他人提交。但如果是在本地跑测试,就要注意原型扩展的“全局效应”。
我自己踩过一个小坑:有一次我在本地建了一个 utils.js 文件,里面定义了 Array.prototype.sum,在测试文件里 require 了它。后来另一个测试文件也运行在同一个 Node 进程里,那个文件里的数组突然多了一个 sum 方法。当时我用了 for...in 遍历对象,结果就把 sum 打印了出来。排查了很久才想到是原型污染。
从那以后,我在本地的做法是:任何数组原型扩展都写在单独的模块里,只在需要的地方显式引入,并且在写遍历代码时尽量避免使用 for...in 遍历数组。在LeetCode提交场景下,因为每次运行都是独立的,所以这个隐患不太容易出现,但它依然是一个值得注意的习惯。
5.2 TypeScript 环境里的类型补充
如果你用TypeScript刷题,扩展原型方法之后会立刻遇到一个报错:Property 'sum' does not exist on type 'number[]'。这是因为 TypeScript 不知道你在原型上新增了方法,需要做声明合并。
以一个 sum 方法为例,你可以在文件顶部这样声明:
typescript复制declare global {
interface Array<T> {
sum(): number;
countBy<K extends string | number>(
fn: (item: T, index: number) => K
): Record<K, number>;
matrix(rows: number, cols: number, fill?: number): number[][];
}
}
这里用 declare global 是因为通常这些代码会写在模块文件中,没有全局声明就无法让 TypeScript 识别。写好之后,[1, 2, 3].sum() 就不再报错了。
如果你用 Object.defineProperty 注册的 toIndexMap,还需要声明返回类型为 Map<T, number>:
typescript复制interface Array<T> {
toIndexMap(): Map<T, number>;
}
我在实际使用中,通常会把声明和实现放在同一个 arrayExtras.ts 文件里。这样每次刷题时直接引入文件,类型和实现就都齐了。
5.3 我现在的做法:把数组扩展统一维护成代码片段
经过这一轮练题实验,我沉淀了一套自己的使用习惯。我把所有自定义数组方法统一放在一个名为 arrayExtras 的对象里,通过 Object.defineProperty 循环注册,然后在编辑器里把这个注册代码保存为代码片段(snippet)。每次刷题时输入 arrayExtras 就会自动带出全套扩展。
这样做的好处很直接:不同题目里使用相同的方法,写起来不需要重复思考;方法本身经过多轮复用,也相对稳定可靠。缺点是提交代码会多出一段工具代码,但LeetCode允许在解答里包含这些定义,并不会影响判题结果。
如果你担心面试时提交代码显得“累赘”,也可以根据题目按需精简:只在需要 sum() 的题里粘贴 sum() 的定义,而不是一次性粘贴所有方法。我自己通常会在提交前把用不到的方法注释掉,保持解答的干净度。
这一套方法我已经用了挺长一段时间,后来我的 arrayExtras 里又陆续加入了 unique()(去重)、chunk()(按大小分块)、without()(排除指定值)等方法。每次新加方法都会先确认至少在两三道题里真的能用上,避免为了封装而封装。
如果你也在刷LeetCode,我的建议是不妨从最常用的三五个数组操作开始,给Array整体加上你自己的方法。这个过程本身就是在复盘“哪些代码我一直在重复写”,想清楚了这件事,刷题效率自然就上来了。
