小伙伴因 unshift 插入数据被批,未曾想到找我诉苦竟梅开二度

·  阅读 1926
小伙伴因 unshift 插入数据被批,未曾想到找我诉苦竟梅开二度

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第3天,点击查看活动详情

背景

事情是这样的,今天小伙伴跟我诉苦,说写的代码被批了,原因是效率太低了,简单问了一下需求,就是将几千条数据倒序插入到数组中,他是通过循环搭配 unshift 实现的,听完我也批了他一顿。

微信图片_20220808104151.png

小伙伴说:你行你上啊。

我:可以通过 push 添加,所有数据添加完之后来一手 reverse ,不就是正序了?而且效率高很多。

小伙伴:凭什么这样效率更高?理论是要数据作为支撑的,show me the code!

行,那我就让他心服口服。

微信图片_20201117133851.jpg

知其所以然

要想知道这两种方案谁的效率高,我们首先要知道它们具体是怎么实现的,那么如果让你实现 pushunshiftreverse,你会怎么去实现呢?

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。

QQ图片20220810181111.jpg

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) 不知道慢了多少。

微信图片_20220818195830.jpg

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 索引位,是不是就已经完成了数组的整体翻转了?

微信图片_20201123160741.jpg

还有的小伙伴对交换的这段代码感兴趣: [a, b] = [b, a],这是 ES6 新增语法,可以很简洁的实现对两个值交换。当然我们使用临时变量、异或、加减的方法实现一样可以的,大家根据个人习惯即可。

这里额外提一下三个 交换值 的方法,已经懂的小伙伴可以跳过:

异或

let a = 1, b = 12;
a ^= b;
b ^= a;
a ^= b;
console.log(a, b); // 12  1
复制代码

临时变量

let a = 1, b = 12;
let temp = b;
b = a;
a = temp;
console.log(a, b); // 12  1
复制代码

加减

let a = 1, b = 12;
a += b;
b = a - b;
a = a - b;
console.log(a, b); // 12  1
复制代码

数据不会骗人

即使是手动实现了这几个方法,在没有看到数据前,小伙伴仍持怀疑态度,我们话不多说,上数据。

注:所有数据都是 多次运行取均值 得出的。

插入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
复制代码

可以发现数据量小的时候差不多嘛,用哪个都问题不大。

微信图片_20220727162741.jpg

插入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
复制代码

当数据量稍微大了一点的时候,就有点微妙了。

微信图片_20201117140204.jpg

插入10000条数据

console.time();
let arr = [];
for (let i = 0; i < 10000; ++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 次,效率快的多。

微信图片_20220727220717.jpg

源码

这三个 API 在 ECMAScript 规范中的实现实际上会更复杂,因为它会对数据边界、类数组做一些容错处理,感兴趣的小伙伴点击下方链接查看。

实际案例

我们来看看一个使用 push + reverse 代替 unshift 的实际案例。

微信截图_20220930103250.png

这是 big.js 里的一段源码,注意看注释:reverse faster than unshifts,底下的代码逻辑也是使用 reverse + push 的方式去实现首部插入的。

微信截图_20220930103640.png

这是 big.js 的周下载量。

结束语

通过我的一番论证,小伙伴最终也是虚心接受了,程序员嘛,很单纯的,数据摆在面前胜过千言万语,我们在输出知识的同时要给出数据支撑,才能让别人心服口服。

本文通过特定场景下两种不同的插入方式,带大家了解了 unshift 可能带来的效率问题。同时希望大家不要停留于本文所讲,而是在面对问题时可以发散思维,寻找不一样的解决方案。难题没有万能解,只有因地制宜能得到最优解。

收藏成功!
已添加到「」, 点击更改