背景
事情是这样的,今天小伙伴跟我诉苦,说写的代码被批了,原因是效率太低了,简单问了一下需求,就是将几千条数据倒序插入到数组中,他是通过循环搭配 unshift
实现的,听完我也批了他一顿。
小伙伴说:你行你上啊。
我:可以通过 push
添加,所有数据添加完之后来一手 reverse
,不就是正序了?而且效率高很多。
小伙伴:凭什么这样效率更高?理论是要数据作为支撑的,show me the code!
行,那我就让他心服口服。
知其所以然
要想知道这两种方案谁的效率高,我们首先要知道它们具体是怎么实现的,那么如果让你实现 push
、 unshift
和 reverse
,你会怎么去实现呢?
Array.prototype._push
知己知彼,百战不殆,要想实现 push
,首先要知道 push
做了什么。
分析
我们回忆一下 push
的细节:
- 作用向数组末尾添加元素,可以同时添加多个,用逗号隔开。* 返回值返回数组的新的长度。### 实现
Array.prototype._push = function (...items) {for (let i = 0; i < items.length; ++i) {this[this.length] = items[i];}return this.length;
}
let arr = [1, 2, 3];
arr._push(4, 5);
console.log(arr); // 1,2,3,4,5
一般来说,这里的 this
指向的就是调用该方法的数组,所以我们能够通过 this.length
获取数组的长度。有的小伙伴可能会好奇,this.length
的值不是一直没更新吗,那 for 循环里赋值的不一直都是同一个位置吗?
我们来看看规范里怎么说的:
The “length” property of an Array instance is a data property whose value is always numerically greater than the name of every configurable own property whose name is an array index. —— sec-properties-of-array-instances-length | ECMAScript® 2023 Language Specification (tc39.es)
简单点说就是: length
返回或设置一个数组中的元素个数,且 length
总是大于数组最高项的下标。
那我们可不可以这么理解,每当我们试图做一些操作使得数组的长度增加时,数组的 length 属性都会 自动更新 ,且更新的索引为最新一次插入时的索引 + 1。
push
是通过 索引赋值 实现的,效率是 O(1)。
Array.prototype._unshift
老规矩,我们首先看看 unshift
做了什么。
分析
我们回忆一下 unshift
的细节:
- 作用将一个或多个元素添加到数组的开头,用逗号隔开。* 返回值返回数组的新的长度。### 实现
let arr = [1, 2, 3];
Array.prototype._unshift = function (...items) {const lens = items.length;for (let i = this.length - 1; i >= 0; i--) {this[i + lens] = this[i];}for (let i = 0; i < lens; ++i) {this[i] = items[i];}return this.length;
}
arr._unshift(4);
console.log(arr); // 4,1,2,3
unshift
是通过 数组后移 让位,新增元素从头开始赋值实现的。
第一个循环从最后一个位置开始,所有元素都向后移动 lens = items.length
个位置,为后续新增的元素让出位置。
有的小伙伴可能不知道为什么是让出 lens
个位置,因为我们需要向头部插入 lens
个新元素,为了让数据之间不被覆盖,前面必然要空出 lens
个位置。
而第二个循环就是为了将新元素一个个 按顺序 从索引 0 的位置开始赋值(插入)。
我们可以发现,通过 unshift
进行头部插入,每调用一次该方法,所有元素都要执行一次后移操作,这效率比通过索引赋值的时间复杂度 O(1) 不知道慢了多少。
Array.prototype._reverse
看看 reverse
做了什么。
分析
我们回忆一下 reverse
的细节:
- 作用将数组翻转,会修改原数组。* 返回值返回翻转后的数组。### 实现
let arr = [1, 2, 3, 4];
Array.prototype._reverse = function () {const lens = Math.floor(this.length / 2);for (let i = 0; i < lens; ++i) {[this[i], this[this.length - i - 1]] = [this[this.length - i - 1], this[i]];}return this;
}
arr._reverse();
console.log(arr); // 4,3,2,1
这里通过 lens = Math.floor(this.length / 2)
获取数组一半的长度,然后遍历 前半部分 实现翻转。
可能有的小伙伴不懂啊,我将整个数组翻转,为啥只要遍历一半?
我们可以换个角度思考:想要将数组翻转,我们只需要将第一个索引位的元素和最后一个索引位的元素进行交换,将第二个索引位上的元素和倒数第二个索引位上的二元素进行交换,以此类推,实际上只要处理到 len
索引位,是不是就已经完成了数组的整体翻转了?
还有的小伙伴对交换的这段代码感兴趣: [a, b] = [b, a],这是 ES6 新增语法,可以很简洁的实现对两个值交换。当然我们使用临时变量、异或、加减的方法实现一样可以的,大家根据个人习惯即可。
这里额外提一下三个 交换值 的方法,已经懂的小伙伴可以跳过:
异或:
let a = 1, b = 12;
a ^= b;
b ^= a;
a ^= b;
console.log(a, b); // 121
临时变量:
let a = 1, b = 12;
let temp = b;
b = a;
a = temp;
console.log(a, b); // 121
加减:
let a = 1, b = 12;
a += b;
b = a - b;
a = a - b;
console.log(a, b); // 121
数据不会骗人
即使是手动实现了这几个方法,在没有看到数据前,小伙伴仍持怀疑态度,我们话不多说,上数据。
注:所有数据都是 多次运行取均值 得出的。
插入100条数据
console.time();
let arr = [];
for (let i = 0; i < 100; ++i) {arr.push(i);
}
arr.reverse();
console.timeEnd(); // 0.093ms
console.time();
let arr = [];
for (let i = 0; i < 100; ++i) {arr.unshift(i);
}
console.timeEnd(); // 0.094ms
可以发现数据量小的时候差不多嘛,用哪个都问题不大。
插入1000条数据
console.time();
let arr = [];
for (let i = 0; i < 1000; ++i) {arr.push(i);
}
arr.reverse();
console.timeEnd(); // 0.127ms
console.time();
let arr = [];
for (let i = 0; i < 1000; ++i) {arr.unshift(i);
}
console.timeEnd(); // 0.389ms
当数据量稍微大了一点的时候,就有点微妙了。
插入10000条数据
console.time();
let arr = [];
for (let i = 0; i < 1000; ++i) {arr.push(i);
}
arr.reverse();
console.timeEnd(); // 0.553ms
console.time();
let arr = [];
for (let i = 0; i < 10000; ++i) {arr.unshift(i);
}
console.timeEnd(); // 9.784ms
测试发现,插入数据越多,效率差距越明显。
因为通过 unshift
需要后移数组中所有元素,耗费太多时间。而 push
通过索引添加,最后一次 reverse
也只要遍历 lens
次,效率快的多。
源码
这三个 API 在 ECMAScript 规范中的实现实际上会更复杂,因为它会对数据边界、类数组做一些容错处理,感兴趣的小伙伴点击下方链接查看。
- reverse
- push
- unshift
实际案例
我们来看看一个使用 push + reverse 代替 unshift 的实际案例。
这是 big.js 里的一段源码,注意看注释:reverse faster than unshifts,底下的代码逻辑也是使用 reverse + push 的方式去实现首部插入的。
这是 big.js 的周下载量。
结束语
通过我的一番论证,小伙伴最终也是虚心接受了,程序员嘛,很单纯的,数据摆在面前胜过千言万语,我们在输出知识的同时要给出数据支撑,才能让别人心服口服。
本文通过特定场景下两种不同的插入方式,带大家了解了 unshift 可能带来的效率问题。同时希望大家不要停留于本文所讲,而是在面对问题时可以发散思维,寻找不一样的解决方案。难题没有万能解,只有因地制宜能得到最优解。
最后
为大家准备了一个前端资料包。包含54本,2.57G的前端相关电子书,《前端面试宝典(附答案和解析)》,难点、重点知识视频教程(全套)。
有需要的小伙伴,可以点击下方卡片领取,无偿分享