链表

学习链表之前,先来讨论一个经典的链表应用场景,那就是 LRU 缓存淘汰算法。

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

缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该保存,这时就需要缓存淘汰策略来决定。

常见的缓存淘汰策略有三种:

  • 先进先出策略 FIFO(First In,First Out);
  • 最少使用策略 LFU(Least Frequently Used);
  • 最近最少使用策略 LRU(Least Recently Used);

链表结构

相对于数组,链表是一种稍微复杂的数据结构。

我们可以先从底层的存储结构来看。为了直观对比,先看下面这张图。

array_vs_linkedlist.png

从上图可以看到,数组需要一块连续的内存空间来存储,对内存的要求比较高。链表并不需要一块连续的内存空间,它通过 “指针” 将一组零散的内存块串联起来使用。

如果我们申请一个 100MB 大小的数组,当内存中没有联系的、足够大的存储空间时,即便内存的剩余总可用空间大于 100 MB,仍然会申请失败。链表则不会这样,所以当我们申请的是 100 MB 大小的链表,不会考虑是否存在连续的空间,不会存在申请失败的问题。

链表的结构有很多,今天主要介绍三种常见的链表结构,它们分别是:单链表、双向链表、循环链表。

单链表

上面刚刚讲到,链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的 “结点”。

为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。我们把这个记录下个结点地址的指针叫做后继指针 next。

single_linked_list.png

在单链表中,有两个结点是比较特殊的,分别是第一个结点和最后一个结点。

我们习惯地把第一个结点叫做头结点,把最后一个结点叫做尾结点。其中,头结点用来记录链表的基地址,有了它,我们就可以遍历得到整条链表。尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。

和数组相同,链表也支持数据的查找、插入和删除操作。

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

链表的插入和删除操作,只需要考虑相邻结点的指针改变,对应的时间复杂度是 O(1)。

single_linkedlist_insert.png

single_linkedlist_delete.png

但是,有利就有弊。链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针对的一个结点一个结点地一次遍历,直接找到对应的结点。

可以把链表想象成一个队伍,队伍的每个人都知道自己后面的人是谁,所以当我们希望知道排在第 k 位的人是谁的时候,需要从第一个人开始,一个一个地往下数。所以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。

循环链表

循环链表是一种特殊的单链表。循环链表也很简单,它跟单链表唯一的区别就在于尾结点。

单链表的尾结点指针指向空地址,空地址代表是最后的结点。
循环链表的尾结点指针指向链表的头节点,它像一个环一样首尾相连,所以叫做 ”循环“ 链表。

cycle_linkedlist.png

循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。
比如著名的 ”约瑟夫问题“ 。尽管用单链表可以实现,但是用循环链表实现的话,代码就会简洁很多。

双向链表

单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。
双向链表,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。

twoway_linkedlist.png

双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。

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

双向循环链表

上面已经说了循环链表和双向链表,如果把这两种链表整合在一起,就是双向循环链表。

twoway_cycle_linkedlist.png

双向链表为什么比单链表高效

你可能会问,刚才说到单链表的插入、删除操作的时间复杂度已经是 O(1) 了,双向链表为什么比单链表更加高效?

先来看删除操作。

实际软件开发中,链表删除一个数据通常存在两种情况:

  • 删除结点中 ”值等于某个给定值“ 的结点;
  • 删除给定指针指向的结点。

对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点一个一个一次遍历对比,直到找到值等于给定值的结点,然后将其删除。尽管单纯的删除操作时间复杂度是 O(1),但遍历查找的时间是主要的耗时点,对应的时间复杂度为 O(n)。根据时间复杂度分析中的加法法则,删除值等于给定值的结点对应的链表操作的时间复杂度为 O(n)。

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

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

除了插入、删除操作有优势之外,对于一个有序链表,双向链表的按值查询的效率也要比单链表高一些。因为,我们可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p 大小关系,决定是向前还是向后查找,所以平均只需要查询一半的数据。

现在,你是不是也觉得双向链表比单链表更高效?这就是为什么在实际的软件开发中,双向链表尽管比较费内存,但还是比单链表的应用更加广泛的原因。如果你熟悉 java 语言,肯定知道 LinkedHashMap 这个容器。如果你深入研究 LinkedHashMap 的实现原理,就会发现其中就用到了双向链表的这种数据结构。

空间换时间的设计思想

当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高,但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间或空间的设计思路。

继续说下开篇缓存的例子。缓存实际上就利用了空间换时间的设计思想。如果我们把数据存储到硬盘上,会比较节省内存,但每次查找数据都要询问一次硬盘,会比较慢。但如果我们通过缓存技术,事先将数据加载到内存中,虽然会比较耗费内存空间,但是每次数据查询的速度就大大提高了。

所以,对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化。反之,消耗过多内存的程序,可以通过消耗更多时间(时间换空间)来降低内存的消耗。

链表 VS 数据

数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别,它们插入、删除、随机访问操作的时间复杂度正好相反。

数组 链表
插入删除 O(n) O(1)
随机访问 O(1) O(n)

不过,数组和链表的对比,不能局限于时间复杂度。而且,在实际的软件开发过程中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。

数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。
链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。

CPU 在内存中读取数据时,会把读取到的数据加载到 CPU 的缓存中。CPU 每次从内存中读取数据会读取一个数据块并保存到 CPU 缓存中,然后下次访问内存数据的时候会优先从 CPU 缓存开始查找,如果找不到才从内存中取。这样就实现了比内存访问速度更快的机制,也就是 CPU 缓存存在的意义。对于数组来说,存储空间是连续的,所以在加载某个下标时可以把后面的几个元素也加载到 CPU 缓存中,这样执行速度就会快于存储空间不连续的链表存储。

数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的内存空间分配给它,导致 ”内存不足(out of memory)“ 。如果声明的数组过小,则可能出现不够用的情况。这时,只能再申请一个最大的内存空间,把原数组拷贝进去,非常费时。
链表本身没有大小的限制,天然地支持动态扩容,这也是它与数组最大的区别。

你可能会反驳,Java 中的 ArrayLIst 容器,也可以支持动态扩容啊?

如果我们往支持动态扩容的数组中插入一个数据时,如果数组中没有空闲空间,就会申请一个更大的空间,将数据拷贝过去,而数据拷贝的操作是非常耗时的。举个比较极端的例子,如果我们用 ArrayList 存储了 1GB 大小的数据,这个时候已经没有空闲空间,当我们再插入数据的时候,ArrayList 会申请一个 1.5 GB 大小的存储空间,并且把原来 1 GB 的数据拷贝到新申请的空间上。听起来是不是就很耗时?

除此之外,如果你的代码对内存的使用非常苛刻,那数组就更适合你。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗也会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,有可能导致频繁的 GC(Garbage Collection,垃圾回收)。

所以,在我们实际的开发中,针对不同类型的项目,跟根据具体情况,权衡究竟是选择数组还是链表。

链表代码编写技巧

理解指针或引用含义

事实上,看懂链表的结构并不难,但是一旦把它和指针混在一起,就很难理解。所以要想写对链表代码,首先就要理解好指针。

实际上,对于指针的理解,只需要记住下面这句话就可以了:

将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。

我们可以结合链表代码的编写过程,慢慢分析。

在编写链表代码的时候,经常会有这样的代码:p.next = q。这行代码是说,p 结点的 next 指针存储了 q 结点的内存地址。

还有一个更复杂的,也是我们写链表代码经常遇到的:p.next = p.next.next 。这行代码表示,p 结点的 next 指针存储了 p 结点的下下一个结点的内存地址。

掌握了指针或引用的概念,你应该可以很轻松地看懂链表代码。

警惕指针丢失和内存泄漏

不知道你是否存在这样的感觉,写链表代码的时候,指针指来指去,一会儿就不知道指到哪里了。
所以,我们在写链表代码的时候,一定注意不要弄丢了指针。

指针往往是怎么弄丢的?这里举一个例子。

linkedlist_insert.png

如图,我们希望在结点 a 和相邻的结点 b 之间插入结点 x,假设当前指针 p 指向结点 a。
如果我们将代码实现变成下面这样,就会发生指针丢失和内存泄漏。

p.next = x; x.next = p.next;

初学者经常会在儿犯错。p.next 指针在完成第一步操作之后,已经不再指向结点 b,而是指向结点 x。第二行代码相当于将 x 赋值给 x.next,即自己指向自己。因此,整个链表就断为两半,从结点 b 往后的所有结点都无法访问到了。

对于有些语言来说,例如 C 语言,内存管理是程序员负责的,如果没有手动释放结点对应的内存空间,就会产生内存泄漏。
所以,我们插入结点时,一定要注意操作顺序,要先将结点 x 的 next 指针指向结点 b,再把结点 a 和 next 指针指向结点 x,这样才不会丢失指针,导致内存泄漏。

x.next = p.next; p.next = x;

同理,删除链表结点时,也一定记得手动释放内存空间,否则,也会出现内存泄漏的问题。

利用哨兵简化实现难度

首先,我们来回顾一下单链表的插入和删除操作。如果我们在结点 p 后面插入一个新的结点,只需要下面两行代码就可以搞定。

new_node.next = p.next; p.next = new_node;

但是,当我们向一个空链表中插入第一个结点,刚刚的逻辑就不能用。我们需要进行下面的特殊处理,其中 head 表示链表的头结点。
所以,从这段代码可以看出,对于单链表的插入操作,第一个结点和其它节点的插入逻辑是不同的。

if (head === null) { head = new_node; }

再看下单链表结点的删除操作。如果要删除结点 p 的后继结点,只需要一行代码就可以搞定。

p.next = p.next.next;

但是,如果我们要删除链表中的最后一个节点,前面的删除代码就不能工作。跟插入结点类似,我们也需要对这种情况单独处理。

if (head.next === null) { head = null; }

综上所述,针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全儿出错。那么如何解决这个问题?

那就要引出哨兵这个概念。哨兵,解决的是国家之前的边界问题。在链表中也是如此,这里说的哨兵也是解决 ”边界问题“ 的,不直接参与业务逻辑。

如果我们引入哨兵结点,在任何时候,不管链表是否为空,head 指针都会一直指向这个哨兵结点。我们把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表叫做不带头链表。

带头链表可以参考下图。哨兵结点是不存储数据的。因为哨兵结点一直存在,所以插入第一个结点和插入其它结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑。

with_head_linkedlist.png

实际上,这种利用哨兵简化编程难度的技巧,在很多代码中实现中都有用到,比如插入排序、归并排序、动态规划等。

为了便于理解,再举一个非常简单的例子。

const str = '12345', len = str.length; console.log(find(str, len, 3));
function find (str, n, key) { if (str === null || n <= 0) { return -1; } let i = 0; while (i < n) { if (str[i] == key) { return i; } i++; } return -1; }
function find (str, n, key) { if (str === null || n <= 0) { return -1; } if (str[n - 1] == key) { return n - 1; } const temp = str[n - 1]; str[n - 1] = key; let i = 0; while (str[i] != key) { i++; } str[n - 1] = temp; if (i == n - 1) { return -1; } else { return i; } }

对比两段代码,在字符串 str 很长的时候,比如几万、几十万,你觉得哪段代码运行得更快?

答案是代码二,因为两段代码中执行次数最后就是 while 循环那一部分。第二段代码中,我们通过一个哨兵 a[n - 1] = key,成功省掉了一个比较语句 i < n,不要小看这一条语句,当累积执行万次、十几万次时,积累的时间就很明显了。

当然,这只是为了举例说明哨兵的作用,写代码的时候千万不要写第二段那样的代码,因为可读性太差了。大部情况下,我们并不需要如此追求极致的性能。

留意边界条件处理

软件开发过程中,代码在一些边界或者异常情况下,最容易产生 Bug。链表代码也不例外。要实现没有 Bug 的链表代码,一定要在编写的过程中以及编写完成之后,检查边界条件是否考虑全面,以及代码在边界条件下是否能正常运行。

经常用来检查链表代码是否正确的边界条件如下:

  • 链表为空的情况
  • 链表只包含一个结点的情况
  • 链表只包含两个结点的情况
  • 代码逻辑处理头节点和尾结点的情况

当你写完链表代码之后,除了看下你写的代码在正常情况下能否工作,还要看在上面列举的几个边界条件下能否正确工作。
如果这些边界条件下都没有问题,那基本上可以认为没有问题。

当然,边界条件不止我列举的那些。针对不同的场景,还有特定的边界条件,这个需要你自己去思考。

实际上,不光是写链表代码,在写任何代码时,一定要多想想,可能会遇到哪些边界情况或者异常情况,这样写出来的代码才够健壮、

举例画图,辅助思考

对于稍微复杂的链表操作,比如单链表反转,指针一会儿指这,一会儿指那,一会儿就被绕晕了。
所以这个时候就可以考虑使用举例法和画图法。

你可以找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。
比如向单链表中插入一个数据,可以把各种情况都举一个例子,画出链表插入前和插入后的链表变化。

多写多练,没有捷径

如果你已经理解并掌握前面所讲的方法,但是手写链表代码还是会出各种各样的错误,也不要着急。
把常见的链表操作都自己多写几遍,出问题就一点一点调试,熟能生巧。

常见的链表操作

206:单链表反转

141:链表中环的检测

21:两个有序的链表合并

19:删除链表倒数第 n 个结点

876:求链表的中间节点

总结

写链表代码是最考验逻辑思维能力的。因为,链表代码到处都是指针的操作、边界条件的处理,稍有不慎就容易产生 Bug。
链表代码写的好坏,可以看出一个人写代码是否够细心,考虑问题是否全面,思维是否缜密。所以这也是很多面试官喜欢让人手写链表代码的原因。

技术拓展

JS 实现单链表

function Node (val) { this.val = val; this.next = null; } function LinkedList () { this.head = new Node('head'); } LinkedList.prototype.find = function (val) { let currNode = this.head; while (currNode.val != val) { currNode = currNode.next; } return currNode; } LinkedList.prototype.insert = function (newVal, val) { const newNode = new Node(newVal); const current = this.find(val); newNode.next = current.next; current.next = newNode; } LinkedList.prototype.print = function () { let currNode = this.head; while (currNode.next != null) { console.log(currNode.next.val); currNode = currNode.next; } } LinkedList.prototype.findPrevious = function (val) { let currNode = this.head; while (currNode.next != null && currNode.next.val != val) { currNode = currNode.next; } return currNode; } LinkedList.prototype.remove = function (val) { const prevNode = this.findPrevious(val); if (prevNode.next != null) { prevNode.next = prevNode.next.next; } } module.exports = { LinkedList }

如何基于链表实现 LRU 缓存淘汰算法

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

如果此数据之前已经被缓存在链表中,我们可以遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。

如果此数据没有在缓存链表中,可以分为两种情况:

  • 如果此时缓存未满,则将此结点直接插入到链表的头部;
  • 如果此时缓存已满,则将链表尾结点删除,将新的数据结点插入到链表的头部。

这样,我们就可以用链表实现一个 LRU 缓存,是不是很简单?

下面,我们来分析一下缓存访问的时间复杂度是多少。

不管缓存有没有满,我们都需要遍历一遍链表,所以这种基于链表的实现思路,缓存访问的时间复杂度为 O(n)。

实际上,我们可以继续优化这个实现思路,比如引入散列表(Hash table)来记录每个数据的位置,将缓存访问的时间复杂度降为 O(1)。

vue 的 keep-alive 内置组件查找缓存也使用了 LRU 缓存算法,是基于数组和对象实现的。

单链表实现的字符串,判断是不是回文串

可以使用快慢指针法。快指针每步两格走,到达链表末端时,慢指针刚到到达中心。

慢指针到达中心前,将走过的节点反向,在中心点开辟一个新的指针向回走,慢指针继续继续向前,慢指针扫完整个链表时,就可以判断这是回文串,否则就提前退出。总的时间复杂度为 O(n),空间复杂度是 O(1)。

function Node (val) { this.val = val; this.next = null; } function LinkedList (values) { if (values) { this.head = new Node(values.shift()); currNode = this.head; values.forEach(val => { currNode.next = new Node(val); currNode = currNode.next; }); } }
const list = new LinkedList(['a', 'b', 'c', 'c', 'b', 'a']); const head = list.head; console.log(isPalindrome(head)); function isPalindrome (head) { let slow = head, fast = head, prev = null; while (fast != null && fast.next != null) { fast = fast.next.next; const next = slow.next; slow.next = prev; prev = slow; slow = next; } if (fast != null) { slow = slow.next; } while (slow != null) { if (slow.val != prev.val) { return false; } slow = slow.next; prev = prev.next; } return true; }

哨兵是否还有其他应用场景

。。。