前端也要懂链表

645 阅读12分钟

做的技艺来自做的过程


在学习数据结构和面试的时候经常碰到一个很基础的数据结构,那就是链表。今天我们就从以下几个方面来梳理一下关于链表的知识。
  • 什么是链表
  • 怎么实现链表
  • 和数组比它有什么优势
  • 链表的应用
  • ​链表相关的经典问题

什么是链表


链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。 - 维基百科

所以,可以从定义中总结出几个关键词
1)线性表
2)不按照线性的顺序存储既 在物理存储单元上非连续、非顺序。
3)每个节点里存储下一个节点的指针
下面我们通过几种常见的链表来理解这几个关键词。链表有很多种不同的类型:单向链表,双向链表以及循环链表。
  • 单向链表
链表中最简单的一种是单向链表。由图上可以看出每个节点实际上是一个单独的对象,它包含两个域,一个信息域和一个指针域。

指针域存储一个链接指向下一个节点的连接,而最后一个节点则指向一个空值。

单向链表的节点只保存指向下一个节点的地址,所以只可以向一个方向遍历。这就导致链表的查找就需要从第一个节点开始每次访问下一个节点,一直访问到需要的位置。
单向链表在实际应用中并不常见,因为还有一种链表可以实现单链表的功能而且更加高效,那就是双向链表。
  • 双向链表
双向链表每个节点存储两个指针:一个指向前一个节点;而另一个指向下一个节点。

一个双向链表有三个整数值:数值,向后的节点链接,向前的节点链接这样可以从任何一个节点访问前一个节点,当然也可以访问后一个节点,以至整个链表。
无论对于单向链表还是双向链表,有的时候第一个节点可能会被删除或者在之前添加一个新的节点,这时候每次都要修改指向首个节点的指针。
有一种方便的可以消除这种特殊情况的方法是在最后一个节点之后、第一个节点之前储存一个永远不会被删除或者移动的虚拟节点,形成一个下面说的循环链表。这个虚拟节点之后的节点就是真正的第一个节点。这种情况通常可以用这个虚拟节点直接表示这个链表。
  • 循环链表
在一个 循环链表中, 首节点和末节点被连接在一起。它的第一个节点之前就是最后一个节点,反之亦然。


循环链表的无边界使得在这样的链表上设计算法会比普通链表更加容易。对于新加入的节点应该是在第一个节点之前还是最后一个节点之后可以根据实际要求灵活处理,实际区别并不大。
链表的这种通过每个节点存储下一个节点地址的特点也使得在内存中并不需要按照线性的顺序存储。
主要分下面这几种具体的存储方法:

共享存储空间

链表的节点和其它的数据共享存储空间,优点是可以存储无限多的内容(不过要处理器支持这个大小,并且存储空间足够的情况下),不需要提前分配内存;
缺点是:由于内容分散,有时候可能不方便调试。

独立存储空间

一个链表或者多个链表使用独立的存储空间,一般用数组或者类似结构实现,优点是可以自动获得一个附加数据:唯一的编号,并且方便调试;
缺点是:不能动态的分配内存。

如何实现链表
我们已经知道了链表是一种抽象的数据结构,每个节点有当前的信息和下一个节点的地址。概念都是通用的,无论怎么实现,只要满足定义就可以了。

要实现链表数据结构,关键在于保存head元素(即链表的头元素)以及每一个元素的next
指针,有这两部分我们就可以很方便地遍历链表从而操作所有的元素。这里介绍两种实现的方法。
  • 对象实现
  var node = {    value: '',    next: ''  }
我们首先定义一个对象节点,包括两个属性。一个是value用来存储当前节点的值,另一个是next存储下一个节点的地址。下面我们定义一个类,来具体实现链表相关的一下方法

class LinkedList {  constructor () {    this.node = {      value: '',      next: ''    }  }    // 查找链表中索引所对应的元素  getElementAt (index) {    if (index < 0) return null;    let currNode = this.node;    for (let i = 0; i < index; i++) {        if (!currNode.next) return null        currNode = currNode.next;    }    return currNode;  }​  // 在头部插入元素  appendAtHead(value) {    if (this.node && !(this.node.value === undefined || this.node.value === null)) {      this.node = { value, next: this.node }    } else {      this.data = { value, next: null }    }  }    // 在尾部插入元素  appendAtTail(value) {    let currNode = this.node    while (currNode.next) {      currNode = currNode.next    }    currNode.next = { value, next: null }  }    // 在某个位置后插入新元素  insert(value, index) {    const currNode = this.getElementAt(index)    if (!currNode.value) return null    currNode.next = {      value,      next: currNode.next.next    }  }    // 删除某位置的元素  remove(index){    if (index < 0) return null    if (index === 0) return this.node = this.node.next    const currNode = this.node    for (let i = 0; i < index - 1; i++) {      if (!currNode.next || !currNode.next.next) return null      currNode = currNode.next    }    if (!currNode.next) return null    if (!currNode.next.next) return currNode.next = null    currNode.next = currNode.next.next  }}

  • 数组模拟

使用对象实现链表还是很复杂的,还有一种简单的方式,使用数组来实现,叫做模拟链表。
链表中的每一个结点只有两个部分。我们可以用一个数组data来存储链表中的每一个 数。那怎么存储数的下一个节点呢?

上图的两个数组中,第一个数组data是用来存放序列中具体数字的,另外一个数组next
是用来存放当前序列中每一个元素右边的元素在数组data中位置的。比如:next[1]中的值为2,就表示data[1]中元素下一个值存储在data[2]中。如果是 0,例如 next[7]的值为 0,就表示data[7]中元素的右边没有元素。
如果需要在c前面插入一个z,只需将 z 直接存放在数组 data 的末尾即 data[8]=z,然后将next[2]中的值改为8,指向data[8],next[8]=3,指向data[3],即c。
这样我们通过next数组就可以 从头到尾遍历整个序列了(序列的每个元素的值存放在对应的数组 data 中),如下。

和数组相比有什么优势
数组是我们在日常编码中常用的结构,无论是查找还是增删数据,语言中封装好的api都很好的帮我们完成了任务,那还需要链表做什么?

首先我们来看一下数组和链表在内存中的存储结构。

由图中可以看出数组的存储需要一块连续的内存,而链表则是非连续的。正是因为内存存储的区别,它们插入、删除、随机访问操作的时间复杂度正好相反。
数组在物理内存上是连续存储的,所以硬件上支持“随机访问”,所谓随机访问,就是你访问一个a[3]的元素与访问一个a[10000],使用数组下标访问时,这两个元素的时间消耗是一样的。所以时间复杂度是O(1)。
而对于链表,在实现部分,我们可以发现,由于链表节点的访问是通过存在上一个节点中的指针地址实现的,所以要查找一个元素就需要从头开始遍历,直到找到满足我们条件的节点,这样的搜索时间复杂度是O(n)。
所以对于访问节点,数组和链表时间复杂度分别是O(1)与O(n),一种是“随机访问”,一种是“顺序访问”。
而对于数据的插入和删除:由于数组是顺序存储的,如果要在某个节点之前插入元素,那么这个节点以及之后的节点都需要依次向后移动一位。删除同理,删除节点之后的元素都需要向前移动一个元素,保证数组的连续。由于涉及到元素的移动,数组的插入、删除操作时间复杂度是O(n)。
链表的节点删除,只需要将删除节点的前一个节点的指针地址指向下一个删除节点的下一个节点,不涉及位置的移动。时间复杂度是O(1)。
所以
数组和链表的详细对比:

链表的优点除了「插入删除不需要移动其他元素」之外,还在于它是一个局部化结构。就是说当你拿到链表的一个
node
之后,不需要太多其它数据,就可以完成插入,删除的操作。而其它的数据结构不行。比如说 数组,你只拿到一个节点是断不敢做插入删除的。

前端业务中的应用

链表作为基础的数据结构可以用来构建许多其它数据结构,如堆栈,队列和他们的派生。
但是在业务上,我们作为前端开发更多关心的是数据的逻辑结构而非数据的存储结构,而且数组有很多好用的现成APIs (如:unshift / push / shift / pop / splice 等)可以完成插入与删除节点的操作。似乎「链表」跟 我们没什么关系,其实不然。
一个优秀的程序员就是要能够处理极端情况的性能问题并且做出最合理的设计。因为数组(顺序存储结构)的 unshift & shift & splice 的算法时间复杂度是 O(n),这情况可能「链表」是更好的选择。
由之前的性能对比来看,它非常善于处理检索较少,而删除、添加、遍历较多的数据。
比如:

我们使用的富文本编辑器,插入和撤销操作。在编辑过程中会频繁操作,插入和撤销处理,这种大频率的数据处理,使用链表处理会对性能有很大的提升。

另外,同学们是否用过语雀或者其它文本编辑,我们在发表新的文档时,可以选择一个现有的文档,将新的文档插入到它的后面,这种是不是很像在一个节点后面插入一个新节点。

实际上,很多同学在写常规业务的时候,很少使用列表,而且对于
JavaScript
来说也没有对于链表操作的封装,前端的数据处理量使用数组完全可以满足而不会有太明显的速度影响。但是充分理解和熟悉链表可以在遇到特殊场景或者极端情况下,找到好的解决办法。

链表相关的经典问题
有了数据结构,就要涉及到算法了,我们来看一下lettcode上和链表相关的几个常见算法问题。

我们先看一下链表问题解决的几种算法。
  • 双指针
有文章中将双指针技巧分为两类,一类是快慢指针,一类是左右指针。前者解决主要解决链表中的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。这里我们主要介绍快慢指针
快慢是指移动步数的长短,也就是每次向前移动速度的快慢。就像两名以不同速度在环形跑道上跑步的运动员,通过使用不同快慢的指针遍历链表,如果不存在环,最终快指针将最先到达尾部,我们可以返回false,如果存在环,在移动多步之后,两个指针将相遇。

  • 递归
递归是我们在处理数据时常用的一种算法,基本上每个同学都知道什么是递归,不过在遇到一些问题时却不知道怎么分解问题,用递归来解决。详细的思想推荐大家搜索看这篇文章“递归的五种定式”,我们在这里就不赘述了。

  • 迭代
迭代也是一种很通用的思想。迭代是重复反馈过程的活动,其目的通常是为了逼近所需目标或结果。每一次对过程的重复称为一次“迭代”,而每一次迭代得到的结果会作为下一次迭代的初始值。
下面看几个问题:
给定一个链表,判断链表中是否有环。

// 快慢指针var hasCycle = function(head) {    if(!head || !head.next) return false;    let fast = head.next;    let slow = head;    while(fast != slow){        if(!fast || !fast.next){            return false;        }        fast = fast.next.next;        slow = slow.next;    }    return true;};
反转一个单链表。

迭代:在遍历列表时,将当前节点的 next 指针改为指向前一个元素。由于节点没有引用其上一个节点,因此必须事先存储其前一个元素。在更改引用之前,还需要另一个指针来存储下一个节点。不要忘记在最后返回新的头引用!

// 迭代function reverseList(head) {    let prev = null;    let curr = head;    while(curr != null){        let nextTemp = curr.next;        curr.next = prev;        prev = curr;        curr = nextTemp;    }    return prev;}

递归:递归的关键在于反向工作。假设列表的其余部分已经被反转,我们该如何反转它前面的部分?

function reverseList(head) {    if (head == null || head.next == null) return head;    let node = reverseList(head.next);    head.next.next = head;    head.next = null;    return node;}

好啦,关于链表我们今天就到这里,链表是一个很基础也很重要的数据结构,本文中也只是粗略的介绍了一些信息,详细内容大家可以寻找资料认真研究一下。拜拜!!

参考文章:
  1. 常用的双指针技巧
  2. 用JS实现链表的数据结构
  3. leetCode
  4. 啊哈!算法