【1】手写JS数组内置方法并理解ADT的实现原理-JavaScript学习数据结构

300 阅读7分钟

数组是最常见的数据结构,常见到参考资料上都懒得去题数组的抽象数据类型实现,因为几乎所有的编程语言都内置了很多的方法,让我们很容易的就能实现数组的增添、删除、插入等操作。JavaScript中就内置了大量的方法供我们使用。

在刚开始学习的时候,我会花时间去研究每个方法是干嘛的,要传入什么参数,会返回什么。当好不容易知道了基本那些增删改查方法的用法之后,原来函数也可以作为参数传入,随后forEachmapfilterreduce等又让人眼花缭乱,感觉数组的方法这么多,怎么掌握得了。学习一段时间之后,尤其是现在在学数据结构基本知识,让我意识到,数组和其他的数据结构一样,这些方法(基本操作)都是可以自己去实现的,并且非常简单,因为我知道数组是最简答的数据结构,其他的数据结构的实现会更复杂。

对于数组的抽象数据类型,我没找到对于它的基本操作的标准定义,但应该不外呼增删改查这些常用的操作。所以有必要先梳理一下JavaScript里内置的那些方法,然后自己去实现这些方法。

JavaScript数组方法总结

数组的方法有很多,我想把它弄全是不现实的,同时也是没必要的,这里可以参考MDN的数组方法。大致有个印象即可,这里我仅仅按照自己的理解来对它们进行分类。

  • 增:unshift,push
  • 删:shift,pop,slice(截取部分)
  • 多功能增删:splice(这个东西功能强大,后面我手写时将其拆成了insertremove两个方法)
  • 查:indexOf,lastIndexOf,find(f),includes
  • 排序:reverse,sort(compare)
  • 数组变成字符串:join(自己加分隔符),toString(分隔符为,)
  • 拼接:concat
  • 迭代方法(参数为函数):every,some,filter, find,map,forEach,reduce
  • 创建和初始化时:fill,Array.from,

大概就能写出这么多了,肯定不全,但是目前就经常接触这些,其他的慢慢积累吧,还有ES6新增的一些,有了解,但自己还没怎么用过。下面为数组方法的注意事项:

  • @@iterator返回一个包含键值对的迭代器对象
  • entrieskeysvalues方法:使用集合、字典、散列表等数据结构时,能够取出键值对是很有用的。
  • sort传入比较函数(大于还是小于0),自定义排序

刚学JavaScript的时候,感觉数组的东西很多,现在想想,首先的明白自己的数组操作的目的,然后再朝这个想办法。诚然,一个数组,对其颠来复去方法有很多种方法,但是我们真的要全部掌握吗,剪刀能干的事情刀子未必不行。将来可能还会有各种各样新的方法出现,但万变不离其宗。我想,如果自己能够写出这些自带的数组方法,那意义应该更大吧,其中的逻辑才是最重要的。

手写数组方法

增加元素

  • insertFirst开头插入,改变原数组,返回长度(同unshift)
  • insertLast结尾插入,改变原数组,返回长度(同push)
  • insert任意位置插入,改变原数组,返回长度(同splice的插入功能)
//增:开头插入,改变原数组,返回长度
Array.prototype.insertFirst = function (value) {
    let offsetIndex = arguments.length;
    this.length += offsetIndex;
    for (let i = this.length - 1; i > offsetIndex - 1; i--) {
        this[i] = this[i - offsetIndex];
    }
    for (let i = 0; i < offsetIndex; i++) {
        this[i] = arguments[i];
    }
    return this.length;
}

//增:末尾插入,改变原数组,返回长度
Array.prototype.insertLast = function (value) {
    let len = this.length;
    for (let i = len; i < (len + arguments.length); i++) {
        this[i] = arguments[i - len];
    }
    len += arguments.length;
    return len
}

//增:任意地方插入(开始索引,新元素,……),返回新元素数组
Array.prototype.insert = function (value) {
    let oldLen = this.length;
    let addLen = arguments.length - 1;
    let start = arguments[0];
    this.length += addLen;
    for (let i = this.length - 1; i >= start + addLen; i--) {
        this[i] = this[i - addLen]
    }
    for (let i = start; i < start + addLen; i++) {
        this[i] = arguments[i - start + 1]
    }
    let addArr = [];
    for (let i = 0; i < addLen; i++) {
        addArr[i] = arguments[i + 1];
    }
    return addArr;

}

删除元素

  • removFirst删除开头元素,改变原数组,返回删除掉的东西(同shift
  • removeLast删除末尾元素,改变原数组,返回删除掉的东西(同pop
  • remove删除任意位置元素,改变原数组,返回删除掉的东西组成的数组(同splice的删除功能)
//删:删除开头,改变原数组,返回删除掉的东西
Array.prototype.removeFirst = function () {
    let res = this[0];
    for (let i = 0; i < this.length - 1; i++) {
        this[i] = this[i + 1];
    }
    this.length--;
    return res;
}

//删:删除结尾,改变原数组,返回删除掉的东西
Array.prototype.removeLast = function () {
    let res = this[this.length - 1];
    this.length--;
    return res;
}

//删:任意位置(开始索引,删除几个),返回删除元素组成的数组
Array.prototype.remove = function (value) {
    let start = arguments[0];
    let removeNum = arguments[1];
    if (removeNum > (this.length - start)) {
        let newArr = [];
        for (let i = 0; i < (this.length - start); i++) {
            newArr[i] = this[i + start];
        }
        this.length = start;
        return newArr;
    } else if (removeNum > 0) {
        let newLen = this.length - removeNum;
        for (let i = start; i < newLen; i++) {
            this[i] = this[i + removeNum];
        }
        let newArr = [];
        for (let i = start; i < removeNum; i++) {
            newArr[i] = this[i + start];
        }
        this.length = newLen;
        return newArr;
    }

}

迭代方法

迭代方法的功能很强大,据说是函数式编程的基础(但这目前我也不懂),学过数学,我知道f(x),传入固定的因变量肯定会出来固定的因变量,同样的数学中允许我们写高阶函数,即f(g(x))。在JavaScript中,函数也是一个变量,可以传入另外一个函数中作为参数,这就是迭代方法。

刚开始接触这些迭代方法的时候有点乱,其实理一理就很简单了:

  • 返回一个Boolean:every,some,
  • 返回一个新数组:map,filter,
  • 遍历做某件事:forEach,
  • 返回符合条件的第一个值:find,findIndex(返回索引)
  • 高级迭代,最终返回一个值:reduce,

返回一个Boolean

  • allTrue:所有元素都满足要求才返回真,同every
  • someTrue:只有有一个元素满足要求就返回真,同some
//同every,全为真才行
Array.prototype.allTrue = function (f) {
    let res = true;
    for (let i = 0; i < this.length; i++) {
        let itemRes = f(this[i]);
        res = res && itemRes;
    }
    return res;
}

//验证
let res1 = [1, 2, 3].allTrue(x => x > 0)
console.log(res1) //true

let res2 = [-1, -2, -3].allTrue(x => x > 0)
console.log(res2) //false
//同some,有一个为真就行了
只需要把上面的allTrue里的
res = res && itemRes;
改为
res = res || itemRes;即可

返回一个新数组

  • newAll:所有元素加工一下,返回新数组(个数不变),同map
  • newSome:过滤一部分,自然符合要的通过,返回新数组(个数减少),同filter
//同map
Array.prototype.newAll = function (f) {
    let newArr = new Array(this.length);
    for (let i = 0; i < this.length; i++) {
        newArr[i] = f(this[i])
    }
    return newArr;
}

//例子:数组里的元素平方
let arr = [1, 3, 4]
result1 = arr.map(x => x**2)
result2 = arr.newAll(x => x**2)

//都返回 [1,9,16]
//同filter
Array.prototype.newSome = function (f) {
    let newArr = [];
    for (let i = 0; i < this.length; i++) {
        if (f(this[i])) {
            newArr.push(this[i])
        }
    }
    return newArr;
}

//例子:返回数组里的偶数
let arr = [1, 2, 3, 4, 5, 6];
let result1 = arr.filter(x => x%2===0);
let result2 = arr.newSome(x => x%2===0)

//都返回 [2, 4, 6]

遍历做某件事

forEach是把数组里的每个元素都让它出来做某件事,这不就是一个for循环的事情吗,是的。就是这样的,不过JavaScript把我们封装好了,只暴露出来一个接口(API、方法、函数……怎么说都行,总是是让我们很容易做某件事),而不用去管这个接口里面是怎么实现的,这就慢慢理解抽象数据类型的抽象。最好的例子就是插座,我们不用管里面的线路如何复杂,我们只知道插上这个接口就能用,那就行了。目前,这也是我所理解的ADT。

只管暴露出来的接口

//定义一个allDo方法,同forEach
Array.prototype.allDo = function (f) {
    for (let i = 0; i < this.length; i++) {
        f(this[i]);
    }
}

//测试:
let people = ["刘备", "关羽", "张飞"]
people.allDo(x => {
    console.log(`hello,${x}`)
})
//控制台输出
hello,刘备
hello,关羽
hello,张飞

手写reduce

要自己写一个reduce方法,那首先就要清楚reduce是怎么工作的,reduce可以说是数组这些迭代方法中最难的一个了,所以有必要好好捋一捋,先来用reduce写一个计算平方。

let arr = [1, 2, 3];
let result = arr.reduce((x, y) => (x + y ** 2))
//14

用遍历的方式自然可以同样写出,下面的代码能实现同样的功能,但是代码量明显多了很多。向reduce这样的很多方法,像上面我手写的这些数组方法,实际上都不是必须的,没有他们完全可以写出相同功能的程序,之所以多了这么多方法,只是提高我们的代码效率,写起来更快,更方便而已。

function squSum(arr) {
    let res = 0;
    for (let i = 0; i < arr.length; i++) {
        res += (arr[i] ** 2);
    }
    return res;
}
let result = squSum(arr)

reduce工作过程

reduce()  方法將一個累加器及陣列中每項元素(由左至右)傳入回呼函式,將陣列化為單一值。

let result = [1, 2, 3, 4, 5].reduce((x, y) => (x + y**2));

//过程:
(1, 2)作为参数 (x, y) => (x + y**2) = 1 + 2**2 = 5;
(5, 3)作为参数 (x, y) => (x + y**2) = 5 + 3**2 = 14;
(14, 4)作为参数 (x, y) => (x + y**2) = 14 + 4**2 = 30;
(30, 3)作为参数 (x, y) => (x + y**2) = 30 + 5**2 = 55;
最终result = 55;

每次从数组里传入两个最为参数(previous,current),这两个在callback运算之后的返回值作为第一个参数,再从数组里的下一个元素作为第二个参数,两参数再次传入callback中,一直这样下去,直到数组中所有元素都参与callback运算,最终返回一个结果(值);

理解reduce的操作过程之后,我们完全就可以自己写一个reduce了,我给取名叫huigun,即中文“回滚”方法。

手写reduce方法

Array.prototype.huigun = function (f) {
    let res;
    for (let i = 0; i < this.length - 1; i++) {
        res = f(this[i], this[i + 1])
        this[i + 1] = res
    }
    return res;
}

其实代码非常简单,这里来验证一下,是不是达到了JavaScript中reduce的同样功能。

//求和
let arr = [1, 2, 4, 5];
let result1 = arr.reduce((x, y) => (x + y));
let result2 = arr.huigun((x, y) => (x + y));

console.log(result1, result2) // 12, 12

理解了这个累加求和,其实相加的不仅仅有数字,String也是可以加的,于是reduce可以充当join方法的功能了。

let arr = ['hello', 'beautiful', 'world'];
let result1 = arr.join(',');   //或者arr.toString;
let result2 = arr.huigun((x, y) => (x + ',' + y));  //或者reduce

//两者都为:
hello,beautiful,world

总结

数据是最基础的数据结构,也是其他抽象数据类型的基础。虽然任何一名编程语言都内置了很多现成的方法供我们使用,但我们应该清楚实质是什么,实质就是它们只是最简单的数据类型(原始数据类型)和最简单的操作(基本运算符、基本语句)组合实现的。那到了更复杂的数据结构,它们也不例外,都是有简单的数据结构封装起来的。

所以,最难的可能是for循环,或者最简单的加减乘除运算符是如何实现的,没有它们,那很合程序都是无从下手的。越到了上面,至于像reduce这样的东西,它很高级,但是缺它不妨,不会影响程序的编写和实现。

这就是关于JavaScript中数组方法的一些简单实现,下面就在这基础上学一些复杂的数据结构。how hard can it be?