数据结构和算法之美-第四篇-链表

Posted by Kylen on 2019-08-05

链表

链表也是一种最基础的数据结构

链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度

也就是说链表使用零散的内存块,链表中的每个节点中存储下一个节点的指针。它就像是一种和数组“相反”的数据结构。从内存结构上看,数组使用一段连续的内存空间。如下图所示:
data-structures-algorithm 2019-08-01 下午2.28.13.jpg
通过结构我们可粗略的想一下和数组相比,链表的优劣势。
链表使用零散空间,自身对内存要求低,灵活度高,但同时为了找到后继节点需要存储后继节点的地址,单个节点的存储信息增大了。因为内存不连续,无法缓存,性能会差点等等。下面我们整体详细的分析一下。

链表的“变体”很多。最基本的链表是单链表。
data-structures-algorithm 2019-07-30 下午5.54.13.jpg
从单链表图中,你应该可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头节点,把最后一个结点叫作尾节点。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。
与数组一样,链表也支持数据的查找、插入和删除操作。

我们知道,在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移,所以时间复杂度是 O(n)。而在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的。

从图中我们可以看出,针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O(1)。
data-structures-algorithm 2019-08-01 下午2.38.13.jpg
但是,有利就有弊。链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。

循环链表

循环链表是一种特殊的单链表。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从循环链表图中,你应该可以看出来,它像一个环一样首尾相连,所以叫作“循环”链表。
data-structures-algorithm 2019-08-01 下午2.42.13.jpg
和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题

双向链表

双向链表实际开发中最常用的链表
单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。
data-structures-algorithm 2019-08-01 下午3.04.13.jpg
从图中可以看出来,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。那相比单链表,双向链表适合解决哪种问题呢?

从结构上来看,双向链表可以支持 O(1) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效。

你可能会说,我刚讲到单链表的插入、删除操作的时间复杂度已经是 O(1) 了,双向链表还能再怎么高效呢?别着急,刚刚的分析比较偏理论,很多数据结构和算法书籍中都会这么讲,但是这种说法实际上是不准确的,或者说是有先决条件的。我再来带你分析一下链表的两个操作。

删除操作

在实际的软件开发中,链表删除的操作无非两种情况

  • 删除链表中“值等于给定值”的节点
  • 删除给定指针指向的节点

对于第一种情况,不管是单链表还是双向链表,首先都需要一个查询操作,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过指针操作将其删除。
尽管单纯的删除操作时间复杂度是 O(1),但遍历查找的时间是主要的耗时点,对应的时间复杂度为 O(n)。所有删除值等于给定值的结点对应的链表操作的总时间复杂度为 O(n)。

对于第二种情况,我们已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点。
但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。所以,针对第二种情况,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了!

插入操作

同理,如果我们希望在链表的某个指定结点前面插入一个结点,双向链表比单链表有很大的优势。双向链表可以在 O(1) 时间复杂度搞定,而单向链表需要 O(n) 的时间复杂度。你可以参照删除操作自行分析。

可以看出,双向链表在实际的软件开发中,比单链表更高效。虽然比较耗内存,但还是比单链表的应用更加广泛。这也是一种空间换时间的思想。

双向循环链表

data-structures-algorithm 2019-08-01 下午3.25.13.jpg

使用链表实现LRU缓存淘汰算法

缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。

缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。

LFU比历史访问次数,LRU比最近的访问时间

这些策略你不用死记,打个比方你很容易就明白了。假如说,你买了很多本技术书,但有一天你发现,这些书太多了,太占书房空间了,你要做个大扫除,扔掉一些书籍。那这个时候,你会选择扔掉哪些书呢?对应一下,你的选择标准是不是和上面的三种策略神似呢?

基本思路:维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。

  1. 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
  2. 如果此数据没有在缓存链表中,又可以分为两种情况:
    • 如果此时缓存未满,则将此结点直接插入到链表的头部;
    • 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。

哨兵

在了解了链表的结构之后,我们知道在节点p之后插入一个新的节点代码为:

1
2
newNode->next = p->next;
p->next = newNode;

但如果链表是空,只有一个head指针上面的逻辑就不对了,而应该是:

1
2
3
if(head === null){
head = newNode;
}

同样我们知道删除链表中节点p的后继节点的代码为:

1
p->next = p->next->next;

然而若是删除尾节点的话,上面的代码则又不适用了,应该写为:

1
2
3
if(p->next === null){
p = null
}

总结一下,对于链表的插入第一个节点和删除最后一个节点这两个操作我们需要特殊处理。

为了简化操作,就有了哨兵的策略。这里不是指漫威漫画中的哨兵,而是哨兵位于前线,以警示敌军来袭。在链表里我们也可以选择性的添加哨兵,用来解决“边界问题”

JS模拟链表