一文带你拿下前端必备数据结构 -- 链表 !!

Posted 小丞同学

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文带你拿下前端必备数据结构 -- 链表 !!相关的知识,希望对你有一定的参考价值。

嘟嘟嘟~~发车啦,快来和博主一起飙车啦!😊文末附上力扣相关题目

在这里插入图片描述

不止图文解释,注释更加清晰噢!👍

1. 什么是链表

链表是一组由节点组成的集合,每个节点都有一个指针指向它的下一个节点。举个栗子来说,就像上图的小火车一样,每一节车厢之间都通过绳索相连接,每节车厢都是一个节点,车厢间的连接就是指针❤️

在这里插入图片描述

那了解了什么是链表之后,很多小伙伴就会想,这和数组有什么区别呢?数组操作不是更方便吗?

数组的大小是固定的,从数组的起点或中间插入或移除项的操作成本很高,因为需要移动元素(尽管我们已经学过很多的API,但背后的情况同样是这样)

1.1 链表的优点

相对于传统的数组,链表的一个好处在于,添加或移除元素的时候不需要移动其他元素。这样添加、移除操作的时间复杂度就为O(1)。下图就是一个单向链表插入节点的示意图,我们只需要改变前一个节点的next指针并且修改新节点的next指针是链表连接起来即可完成
在这里插入图片描述

1.2 链表的缺点

相对于数组而言,数组在访问一个元素的时候,可以直接通过索引来直接访问,而对于链表而言访问其中的一个元素,需要从起点开始迭代整个链表直到找到所需的元素。因此访问的时间复杂度落在O(1)-O(n)之间

1.3 单向链表与数组各个操作时间复杂度对比

链表操作最大时间复杂度数组操作最大时间复杂度
search(访问)O(n)search(访问)O(1)
insert(插入)O(1)insert(插入)O(n)
remove(删除)O(1)remove(删除)O(n)
append (添加)O(1)append (添加)O(1)

1.4 链表的分类

有三种:单向链表、双向链表、循环链表

1.4.1 单向链表

在这里插入图片描述

1.4.2 双向链表

在这里插入图片描述

1.4.3 循环链表

在这里插入图片描述

2. 使用JS实现链表

了解了什么是链表,接下来我们使用js来实现链表的相关操作

2.1 单向链表

2.1.1 创建一个单向链表

以下是一个LinkedList的基本骨架


class LinkedList {
    constructor() {
        // 链表的长度size
        this.size = 0;
        this.head = null;
    }
    //添加链表项
    append(element) { }
    //插入链表项
    appendAt(position, element) { }
    // 删除指定列表项
    remove(element) { }
    //删除指定位置链表项
    removeAt(position) { }
    // 查找指定元素索引
    indexOf(element) { }
    // 翻转链表
    reserve() { }
    // 获取节点
    getNode(index) {}
}
//测试
let l1 = new LinkedList();

链表数据结构还需要一个Node辅助类。Node类表示要加入列表的项。它包含一个element属性,即要添加到列表的值,以及一个next属性,即指向列表中下一个节点
项的指针。

// node类
class Node {
    constructor(element) {
        this.element = element;
        // 添加一个指针
        this.next = null
    }
}

另一个重要的点是,我们还需要存储第一个节点的引用。为此,可以把这个引用存储在一个称为head的变量当中,接下来我们就要来实现LinkedList类中为填写的方法。

  • append(element) :向链表尾部添加一个新的项
  • appendAt(position, element) : 向链表的特定位置插入一个新的项
  • remove(element):从列表中移除一项
  • removeAt(position):从列表的特定位置移除一项
  • getNode(index):获取某个位置的节点
  • reserve():反转链表

2.1.2 获取链表中的节点

先写这个是因为后面的很多方法中都有使用到这个函数!其实也可以不用单独封装成一个函数,存粹个人习惯

传入一个需要查找的节点位置,通过for循环,不断地让current指向下一位,直至到达index的位置,跳出for循环,返回当前current节点🍊

在这里插入图片描述

getNode(index) {
    // 边界判断 传入的参数是否在链表的长度范围之内
    if (index < 0 || index >= this.size) {
        throw new Error('out')
    }
    let current = this.head;
    // 不断的让current移到下一位,直到到达index - 1位置
    // 也就是循环遍历的过程
    for (let i = 0; i < index; i++) {
        current = current.next;
    }
    // 最后返回节点
    return current
}

2.1.3 向链表尾部追加元素

有两种场景:

  1. 列表为空,添加的是第一个元素
  2. 列表不为空,向其追加元素

下面是我们实现的append方法,通过上一部分的getNode方法,获取到链表的最后一个节点,让最后一个节点的next指针指向新创建的节点node,使得链表串联起来,最后让链表长度size自加,即可实现 ☘️

append(element) {
    // 通过实例化添加属性
    let node = new Node(element);
    // 添加操作
    if (this.head === null) {
        this.head = node;
    } else {
        let current = this.getNode(this.size - 1);
        // 最后一个节点的next指向新创建的节点
        current.next = node
    }
    //添加链表项后,size自增
    this.size++;
}

注意:这里的节点创建是通过new操作符实现的,构造出来的node是一个对象,带有了自身的值elementnext指针,新创建的节点next指针默认指向null

2.1.4 向链表的任意位置插入一个元素

因为是通过位置来插入元素,所以首先要判断该位置是否越界。如果越界了,可以直接抛出错误。同样的有2种场景:

第一种场景:需要在列表的起点添加一个元素,也就是第一个位置,让新创建的节点next指针指向头节点,也就是this.head,再将新创建的节点设为头节点🎉

在这里插入图片描述

第二种场景:非起点插入。首先需要找到插入位置的前一个节点pre,让新创建的节点指向pre的下一个节点pre.next,再让pre节点的next指针指向新创建的节点🍎

在这里插入图片描述

//插入链表项
appendAt(position, element) {
    // 边界判断
    if (position < 0 || position > this.size) {
        throw new Error('position out range')
    }
    // 实例化创建节点
    let node = new Node(element);
    //起点插入
    if (position === 0) {
        node.next = this.head;
        this.head = node;
    } else {
        // 找到需要插入位置的前一个节点
        let pre = this.getNode(position - 1);
        // 让新创建的节点指向当前前一个节点的下一个节点
        node.next = pre.next;
        // 让前一个节点指向新创建的节点
        pre.next = node;
    }
}

2.1.5 从链表中移除元素(根据特定位置移除)

移除元素也有两种场景:第一种是移除第一个元素,第二种是移除第一个以外的任一元素。同样的我们需要先进行边界判断,在链表长度外的抛出错误即可。🍌

第一种场景非常简单,由于移除的是第一个节点,只需要让head指向列表的第二个元素

在这里插入图片描述

现在,假设我们要移除列表的最后一项或者中间某一项。首先我们需要获取到被删除节点的前一个节点,让该节点的next指针指向被删除节点的下一个节点。这样,被遗弃的元素就会被丢弃在计算机内存中,等着被垃圾回收器清除。🍐

在这里插入图片描述

//删除指定位置链表项
removeAt(position) {
    // 边界判断,超出链表长度,抛出错误
    if (position < 0 || position >= this.size) {
        throw new Error('position out range')
    }
    let current = this.head
    if (position === 0) {
        // 如果删除的是第一个节点,则让head指向下一位即可
        this.head = current.next;
    } else {
        // 获取删除位置的前一个节点
        let pre = this.getNode(position - 1); 
        // current为被删除节点
        current = pre.next;
        // 让前一个节点指向删除节点的下一个节点
        pre.next = current.next;
    }
    // 长度自减
    this.size--;
}

2.1.6 查找元素在链表的位置

我们需要一个变量来帮助我们循环访问列表,也就是代码中的current,它的初始值是head。通过for循环来遍历链表,判断当前位置的值是否等于查找值,如果是,就返回它的位置;如果不是,就继续向下访问。如果for循环结束还未弹出,说明到达了链表的尾部,也就是说链表中不存在该元素,返回-1。💟

// 查找指定元素索引
indexOf(element) {
    // 当前链表头节点
    let current = this.head; 
    // 遍历整个链表
    for (let i = 0; i < this.size; i++) {
        if (current.element === element) {
            return i
        }
        // 依次向下移动
        current = current.next; 
    }
    return -1;
}

2.1.7 根据元素值移除元素

现在有了indexOf方法,我们可以传入元素的值,找到它的位置,然后调用removeAt方法并传入找到的位置,就能实现移除元素🌳

remove(element) {
    // 获取需要删除元素在链表的位置
    let index = this.indexOf(element)
    if(index!= -1 ) {
        // 通过下面的removeAt方法删除指定位置节点
        this.removeAt(index)
    }else {
        // 删除不存在的元素,抛出错误
        throw new Error('element no found')
    }
}

2.1.8 反转链表

反转链表在leetcode中经常会遇到,在各个面试题中也都会发现它的身影。所以,这里就顺带写以下

首先定义两个指针:prevcurrent,current 在前 prev 在后。每次让 currentnext指针指向prev,实现一次局部反转,局部反转完成之后,prevcurrent同时往前移动一个位置,循环这个过程,直到current到达链表尾部,实现动画如下

在这里插入图片描述

reserve() {
    let prev = null;
    let current = this.head;
    while (current) {
        // 保存当前的下一位
        let next = current.next;
        // 让当前节点指向前面的节点prev
        current.next = prev;
        // 交换完之后,让prev移到下一位也就是current
        prev = current;
        current = next;
    }
    //返回翻转的链表
    this.head = prev
}

2.2 双向链表

双向链表和单向链表的区别在于,单向链表一个节点只有链向下一个节点的指针,而在双向链表中,有两个指针,一个指向前一个元素,一个指向下一个元素,示意图如下:

在这里插入图片描述

2.2.1 创建一个双向链表

相较于单向链表多了一个指向前一个元素的指针,所以在代码中要进行一些修改

//一个链表节点
class Node {
    constructor(element) {
        this.element = element
        this.next = null
        this.prev = null//新增
    }
}
//双向链表
class doubleLinkedList {
    constructor () {
        this.size = 0
        this.head = null
        this.tail = null//新增
    }
    // 这里是接下来写的方法
}

双向链表的优点:可以访问一个特定节点的下一个或前一个元素。在单向链表中,如果迭代链表时错过了要查找的元素,就需要回到链表的起点重新开始迭代😓

注意:在doubleLinedList类中有保存对列表最后一项的引用的tail属性。

2.2.2 获取链表中的节点

根据位置获取链表的方法和单向链表中的是相同,忘记了记得跳回去看看噢!❤️

// 获取某个位置的节点,和单向链表相同
getNode(index) {
    if (index < 0 || index >= this.size) {
        throw new Error('out')
    }
    let current = this.head
    for(let i = 0;i < index;i++) {
        current = current.next
    }
    return current
}

2.2.3 向链表尾部追加节点

相信看到这里的你,应该知道要先干嘛了吧!分两种情况

第一种情况:链表为空

第二种情况:链表不为空

对于第一种情况而言,我们只需要让headtail指向新创建的节点即可

对于第二种情况,相对于单向链表而言,有一点不一样的地方,我们需要设置前驱指针。

首先我们需要让最后一个节点的next指针指向新节点,再让新节点的prev指针指向前一个节点(也就是连接前的最后一个节点),最后记得将tail移向最后一个节点,同时size也要自增!下面是示意图,清晰明了👍

在这里插入图片描述

append(element) {
    // 创建节点
    let node = new Node(element)
    // 保存当前指向最后一个元素的指针
    let tail = this.tail
    // 如果链表为空则新节点将作为第一个
    if(this.head === null) {
        // head和tail都为node
        this.head = node
        this.tail = node
    }else {
        // 常规尾插,让最后一个元素的next指针指向node  
        tail.next = node
        // 再让node的前驱指针指向最后一个节点tail
        node.prev = tail
        // 最后更新tail指向最后一个元素node
        this.tail = node
    }
    this.size++
}

2.2.4 向链表中插入节点

向双向链表中插入一个新节点和单向链表非常相似。区别在于,单向链表只需要控制一个next指针,而双向链表则要同时控制nextprev两个指针

首先来分析第一种场景:在链表的第一个位置插入一个新节点。如果链表为空,只需要把headtail都指向这个新节点。如果不为空,current变量将保存的是链表中第一个元素的引用,那么只需让新节点的next指针指向current,让current节点的prev指针指向新节点,最后让head指向第一个节点即可,演示过程如下:

在这里插入图片描述

接下来是第二种场景在尾部插入,这个和上一个方法有点类似,可以查看上一小节,这里就不重复赘述了

最后一个场景也是相对复杂一点点的:在链表的中间部分插入

通过前面写的getNode方法,获取到需要插入位置的前一个节点preNode以及下一个节点current,我们将在currentpreNode元素之间插入新元素。首先,node节点的next指针指向current,再让nodeprev指针指向preNode节点,再处理剩下两个指针分别指向node即可,演示图如下:

在这里插入图片描述

// 插入节点
insert(position, element) {
    // 边界判断,输入的position需要在范围之内
    if (position < 0 || position > this.size) {
        throw new Error('position out range')
    }
    // 创建节点
    let node = new Node(element)
    // 保存第一个节点,为一个变量
    let current = this.head
    // 第一种情况,在最头部插入
    if (position === 0) {
        // 链表为空的情况
        if (!this.head) {
            this.head = node
            this.tail = node
        } else {
            // 链表不为空
            // 让node的next指针指向第一个节点
            node.next = current
            // 第一个节点的前驱指针指向node
            current.prev = node
            // head指向新的第一个节点
            this.head = node
        }
        this.size++
    } else if (position === this.size) {
        // 在最后插入
        // current变量保存最后一个节点
        current = this.tail
        // 最后一个节点的next指针指向新节点
        current.next = node
        // 新节点的前驱指针指向前一个节点
        node.prev = current
        // tail移至node
        this.tail = node
        this.size++
    } else {
        // 在中间插入
        // 获取插入位置的前一个节点
        let preNode = this.getNode(position - 1)
        // current变量保存preNode节点的下一个节点
        current = preNode.next
        // 1. 让node节点的next指针指向后一个节点current
        node.next = current
        // 2. 让node节点的prev指针指向前一个节点preNode
        node.prev = preNode
        // 3. 让preNode节点的next指针指向新节点
        preNode.next = node
        // 4. 让current节点的prev指针指向新节点
        current.prev = node
        this.size++
    }
}

注意:在我们封装的getNode方法中,无论如何都是从头开始遍历的,实际上我们可以优化这个过程,当我们要找的position大于size的一半时,我们可以从尾部开始遍历,这样可以提高性能。

2.2.5 从链表中的特定位置删除元素

双向链表的操作其实都和单向链表相似,只是多了一个前驱指针,要多操作一个指针而已,对于这个删除特定位置元素的方法,我们需要知道最重要的一点就是将被删除的节点从链表中移出,再将链表连接完好即可

同样的我们需要分成3种情况

第一种情况:删除第一个节点

current变量保存链表中第一个节点的引用,也就是我们想要移除的第一个节点。首先要改变的是 head 的引用,将其从current改为current.next。但我们还需要更新current.next指向上一个元素的指针,因此需要判断链表的长度是否为1,如果为1说明删除第一个节点之后链表就为空了,这时候就需要将tail设为null,如果不为1,则把head.prev的引用改为null即可。演示图如下:

在这里插入图片描述

第二种情况:删除最后一个节点

因为有了最后一个节点的引用tail,我们不需要通过getNode来获取最后一个节点,这样我们也就可以把tail的引用赋给current变量。接下来,需要把tail的引用更新为列表中倒数第二个元素,同时将next指针指向null,这个过程可以展示为下图:

在这里插入图片描述

第三种情况:删除中间的节点

首先我们需要通过getNode方法找到要删除的节点,用current变量保存,再用preNode表示要删除节点的前一个节点,。那么要移除它,我们可以通过更新prev.nextcurrent.next.prev的引用,在链表中跳过它,因此,将prevnext指针指向current.next,而current.nextprev指针指向prev,如下图演示:

在这里插入图片描述

removeAt(position) {
    // 边界判断,输入的position需要在范围之内
    if (position < 0 || position > this.size) {
        throw new Error('position out range')
    }
    let current = this.head
    // 如果删除的是第一个节点
    if (position === 0) {
        // 让head指向下一位
        this.head = current.next
        // 如果链表的长度为1,删除后末尾应当为null
        if(this.size === 1) {
            this.tail = null
        }else {
            // 不为1 删除节点后第一个节点(current.next)指向null
            this.head.prev = null
        }
        this.size--
    }else if (position === this.size -1) {
        // 删除最后一个节点
        // 先让current保存我们要删除的节点
        current = this.tail
        // 让tail移向删除节点的前一位
        this.tail = current.prev
        // 再让tail的next指针指向null,这样最后一个节点就被丢弃了
        this.tail.next = null
        this.size--
    }else {
        // 被删除的节点
        let current = this.getNode(position)
        // 删除节点的前一个节点
        let preNode = current.prev
        // 删除节点的前一个节点next指向它的后一个节点
        preNode.next = current.next
        // 后一个节点的prev指向preNode
        current.next.prev = preNode
        this.size--
    }
}

2.2.6 查找元素在链表中的位置

和单向链表的处理方式相同,没有做什么改动🍔

indexOf(以上是关于一文带你拿下前端必备数据结构 -- 链表 !!的主要内容,如果未能解决你的问题,请参考以下文章

一文带你了解前端性能优化之HTTP缓存系列

猿创征文|一文带你了解前端开发者工具

干货文章一文带你了解软件测试必备技能!

一文带你了解啥是Web前端

一文带你理解14个K8S必备基础概念

一文带你理解14个K8S必备基础概念