数据结构与算法之 —— 数组&链表

1,703 阅读6分钟

0x01 基本概念

Array 数组

数组存储一系列同一类型的数值,但是在 JavaScript 里面可以存储不同类型的数值,但还是要遵守最佳实践(因为大多数语言的数组都不能存储多种类型)。

创建和初始化

// 方法一:使用 new 关键字
var week = new Array();
var week = new Array(7); // 创建一个长度为 7 的空数组
var week = new Array('Sunday', 'Monday');

// 方法二:建议直接使用 [] 来创建一个数组
var week = [];
var week = ['Sunday', 'Monday'];

访问

直接使用中括号传递数值,如 week[1]

插入

使用索引为 2 的位置插入字符串 g,对应索引为 2,3,4 的元素需要后移。所以数组中插入元素的时间复杂度为 O(n)

// 一、在尾部添加
var numbers = [1,2,3,4];
numbers[numbers.length] = 5;
numbers.push(6)
// output: [1,2,3,4,5,6]

// 二、在头部添加
numbers.unshift(0)
// output: [0, 1,2,3,4,5,6]

// 三、任意位置添加
numbers.splice(2, 0, 222); // MDN - Array.prototype.shift
// output:[0, 1, 222, 2, 3, 4, 5]

删除

// 一、头部删除
numbers.shift() // MDN - Array.prototype.shift()
// output:[, 1, 222, 2, 3, 4, 5]

// 二、尾部删除
numbers.pop() // MDN - Array.prototype.pop()
// output:[1, 222, 2, 3, 4, ]

// 三、任意位置删除
numbers.splice(1, 1); // MDN - Array.prototype.splice()
// output:[1, 2, 3, 4]

其他方法

除了刚才数组操作必备的 pushpopshiftunshiftsplice 之外,其他数组相关的方法也需要熟悉:

  1. concat:连接 2 个或更多数组
  2. 迭代相关:every、some、forEach 、filter、map、reduce
  3. 与字符串间的转换相关:join、toString、valueOf
  4. 搜索:indexOf、lastIndexOf
  5. 顺序改变:reverse、sort

ES6 & ES7 新增 (参考:BABEL-ES2015

  1. copyWithin
  2. entries
  3. find
  4. findIndex
  5. fill
  6. from
  7. keys
  8. of
  9. values
  10. includes

Linked List 链表

常见的链表

  1. singly-Linked List 单链表
  2. doubly-Linked List 双链表
  3. 循环链表
  4. 双向循环链表
  5. ...

单链表

示意图:

使用 JavaScript 实现一个单链表的大致骨架:

// 单链表节点
class Node {
    constructor(value, next){
        this.value = (value === undefined ? 0 : value);
        this.next = (next === undefined ? null : next);
    }
}

class SinglyLinkedList {
    constructor() {
        this.head = null; // 头虚拟节点
        this.size = 0; // 链表元素数
    }

    // 其他方法 - 可自己实现
    append(element){}; // 尾部添加
    insert(positon, element){}; // 指定位置添加
    remove(element){}; // 移除
    removeAt(positon){}; // 制定位置移除
    isEmpty(){}; // 是否为空
    indexOf(){}; // 返回索引,如果没有返回 -1
    size(){}; // 返回链表中包含的元素个数
    ...
}

访问

相对于数组类型,链表的好处在于,添加或者删除元素的时候,不需要移动其他元素。数组的另一个细节是可以直接任何位置的任何元素,而链表访问一个中间元素,需要从起点(HEAD 表头)开始迭代,直到找到所需元素,如访问链表中的第一个元素可以表示为 HEAD.next,第二个元素可以表示为 HEAD.next.next (如下图)。

  • 访问链表中第一个元素,为最优时间复杂度 O(1),只进行一次查询;
  • 访问链表中的最后一个元素,为最差时间复杂度为 O(n),需要迭代整个链表;
  • 访问链表中间的元素,为平均时间复杂度为 O(n/2),去掉常数部分为 O(n);

插入

在 prev 节点 和 current 节点中间插入一个新的 node 的节点,可以这样做:

  1. 第一步:使 node.next 的值指向 current 节点
  2. 第二步:使 prev.next 的值设为 node 节点

这样链表中就多了一个节点,进行 2 次操作,时间复杂度为 O(2) ≈ O(1)

删除

如删除 prev 节点和 current 节点中间的节点,可以这样做:

  1. 使 prevnext 指针指向 current 节点

即可完成对中间节点的删除,时间复杂度为 O(1)

双链表

/**
* 双链表骨架
* 案例:yallist: https://github.com/isaacs/yallist/blob/1649cc57394b5affeca2c573943ebe3ed7d39119/yallist.js#L7
*/

// 双链表节点
class Node {
    constructor(value, next, prev){
        this.value = value;
        this.next = (next === undefined ? null : next);
        this.prev = (prev === undefined ? null : prev); // 比单链表新增
    }
}

class DoublyLinkedList {
    constructor() {
        this.head = null;
        this.tail = null; // 比单链表新增
        this.size = 0;
    }
    
    // 其他方法 - 参照 yallist: https://github.com/isaacs/yallist/blob/1649cc57394b5affeca2c573943ebe3ed7d39119/yallist.js#L7 实现
    removeNode(){};
    unshiftNode(){};
    pushNode(){};
    push(){};
    unshift(){};
    pop(){};
    shift(){};
    forEach(){};
    forEachReverse(){};
    get(){};
    getReverse(){};
    ...
}

具体代码实现可以参考 node-lru-cache 的底层依赖,一个双链表的实现 :yallist

关于这个库的官方简介是:

For when an array would be too big, and a Map can't be iterated in reverse order

循环链表

循环链表与单链表唯一的区别在于:最后一个元素指向下一个节点不是 null,而是第一个节点。

双向循环链表

双向循环链表和双链表区别在于:HEAD 节点的 prev 指针之前 TAILTAIL 节点的 next 指针指向 HEAD

数组与链表时间复杂度对比

下方表格中只表示平均情况下 Array 和 Linked List 时间复杂度对比情况

数据结构Access - AVGSearch - AVGInsertion - AVGDeletion - AVG
Array 数组O(1)O(n)O(n)O(n)
Linked List 链表O(n)O(n)O(1)O(1)

0x02 使用场景简介

  1. LRU cache - 双链表:yallist 源码
  2. React Dom Tree Diff - 单链表: 源码
  3. Redis 中的链表类型 - 双链表

0x03 算法分析

第一题:Array 删除数组中重复的项

💡 考察的点

  1. 前面一直再提时间复杂度,本题主要考察对 空间复杂度 ****的理解;
  2. 熟练使用 JavaScript 数组提供的 splice 方法,进行原地插入、删除、添加操作;
  3. 理解的双指针法;

题目:给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。

不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。

输入:nums = [0,0,1,1,1,2,2,3,3,4]

输出:5, nums = [0,1,2,3,4] 思路:

  1. 如果新增数组,或利用 Map、Set 等特性来对数组进行去重,会引入额外的空间复杂度,不满足题目需求,只能在原数组基础上进行操作;
  2. 【利用 JavaScript 语言特性】Array 提供的 splice 方法,可以对原数组进行操作,并返回原数组;
  3. 【通用方法】使用双指针法;

方法一:使用 splice 方法,进行原地删除

var removeDuplicates = function(nums) {
    let i = 0;
    while(i < nums.length){
        if(nums[i] == nums[i+1]) {
            nums.splice(i+1 ,1); // 在i+1的位置删除一个元素
        } else {
            i++
        }
    }
}
// 时间复杂度:O(n)
// 空间复杂度:O(1)

方法二:双指针法

思路:

  1. 初始化两个指针:i = 0 ; j = 1
  2. 每次循环,比较 i,j 对应的元素
    1. 如果相同:则 i 不变,j 往后移动一位
    2. 如果不同:则 i + 1 位置的元素等于 j 当前位置的元素,然后 i + 1 , j + 1
  3. j=5,超出数组长度,停止遍历
var removeDuplicates = function(nums) {
    let i = 0, j = 1;
    while(j < nums.length){
        if(nums[i] !== nums[j]){
            nums[i + 1] = nums[j]
            i++;
        }
        j++;
    }
    return i+1;

};
// 时间复杂度:O(n)
// 空间复杂度:O(1)

第二题:Array 两数之和

💡 考察的点

  1. 时间复杂度的优化
  2. 熟悉 Map 的特性
  3. 可以用空间复杂度来换取时间复杂度的优化

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 的那 两个 整数,并返回它们的数组下标。

输入:nums = [2,7,11,15], target = 9

输出:[0,1]

解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1]

题目及答案地址:两数之和 - 力扣(LeetCode)

方法一:暴力破解 思路:两层循环,逐个遍历,缺点是时间复杂度高。

for (i = 0; i < length; i++){
    for (j = i + 1; j < length; j++){
        if(nums[i] + nums[j] === target) return [i , j]
    }
}
// 时间复杂度 O(n^2)
// 空间复杂度 O(1)

方法二:利用 Map 的特性 减少一次循环,时间复杂度由 O(n^2) 降低为 O(n),但空间复杂度由于多了 Map 的开销由 O(1) 增加为 O(n) 思路:

  1. target - nums[i] 存入 Map 中
    1. 第一次循环
      1. 判断 Map 中是否存在 2
        1. 不存在则:target=9,nums[i]=2,把 9-2=7 存入 Map中
    2. 第二次循环
      1. 判断 Map 中是否存在 7
        1. 存在,则返回下标
let myMap = new Map();

for (i = 0; i < length; i++){
    if(myMap.has(nums[i])){
        return [myMap.get(nums[i]), i]
    } else {
        myMap.set(target - nums[i], i);
    }
}
// 时间复杂度 O(n)
// 空间复杂度 O(n)

第三题:Array 数组的交集

💡 考察的点

  1. JS Array API 的熟悉程度
  2. 熟悉 Set 的特性 本地有很多种解决办法,不小于 10 种,下面对众多方法进行分类。

第一类:【通用方法】排序 + 双指针 只需要将 2 个数组先进行 sort 方法进行排序,使用第一题的双指针方法,去掉重复的元素即可。

第二类:【利用 JavaScript 语言特性】 1、使用 Set 中值不重复的特性

var intersection = function(nums1, nums2) {
    return Array.from(new Set(nums1.filter(v => nums2.includes(v))))
};

2、使用 includes判断是否存在。lastIndexOf判断是否重复

var intersection = function(nums1, nums2) {
    return nums1.filter((v, i) => nums2.includes(v) && nums1.lastIndexOf(v) === i)
};

3、使用 indexOf判断是否存在。lastIndexOf判断是否重复

var intersection = function(nums1, nums2) {
    return nums1.filter((v, i) => nums2.indexOf(v) > -1 && nums1.lastIndexOf(v) === i)
};

4、 等等

总结

  1. 可以牺牲一定的空间复杂度来对时间复杂度进行提速
  2. 通用方法:双指针法的应用
    1. 数组去重
    2. 链表找环
  3. 语言特性也可以解决很多问题,日wj常开发中,对 JavaScript 数组的方法一定要熟记,甚至可以做到不查文档知道所有常用 API 及参数

0x04 推荐工具

  1. 手绘风格 - 画图工具 (文稿中的图都是用这个工具绘制的)
  2. Big-O 速查表
  3. 练习题
    1. 反转链表
    2. 合并两个有序链表
    3. 试着实现一个单链表以及相关方法