前言
我们通常会去想,学习链表有啥用呢?其实链表在实际的开发中应用非常广泛,比如经典的 LRU 缓存淘汰算法
,比如Objective-c 中的 autoreleasepool
底层实现,包括常用的 hash、图等等均用到了链表这个数据结构?
那你可能还会问,有数组了为啥还要用链表?下面我将着重围绕这几个问题进行分析。
链表是什么
数组需要一块连续
的内存空间来存储,对内存的要求比较高。如果我们申请一个 100MB 大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 100MB,仍然会申请失败。而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组零散
的内存块串联起来使用,所以如果我们申请的是 100MB 大小的链表,根本不会有问题。
链表又包括:单链表、双向链表和循环链表
链表的数据结构
单链表
单向链表的节点通常由两个部分构成,一个是节点储存的值val,另一个就是节点的指针next。双向链表
单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。
双向链表的缺点就是比单向链表占用更多的内存空间,我们从后面也可以知道单链表的删除和插入的时间复杂度是 O(1)的,但是实际的应用场景确是要遍历得到结点,才能删除,所以依旧需要先查询,单向链表则是要从头开始查询,耗费时间资源,而双向链表却可以选择从双向进行遍历
,这样找到尾节点的时间复杂度就是O(1),双向链表比单向链表更加灵活循坏链表
循环链表是一种特殊的单链表。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。它像一个环一样首尾相连,所以叫作“循环”链表。
链表和数组一样,都支持查询、删除、插入操作
链表的查询
单向链表的查找操作通常是这样的:
从头节点进入,开始比对节点的值,如果不同则通过指针进入下一个节点
重复上面的动作,直到找到相同的值,或者节点的指针指向null
链表的查找性能与数组一样,都是时间复杂度为O(n)。
注:数组查找特定位置的时间复杂度是O(n)
链表的插入和删除
链表与数组最大的不同就在于删除、插入的性能优势,由于链表是非连续的内存,所以不用像数组一样在插入、删除操作的时候需要进行大面积的成员位移,比如在a、b节点之间插入一个新节点c,链表只需要:
- a断开指向b的指针,将指针指向c
- c节点将指针指向b,完毕
这个插入操作仅仅需要移动一下指针即可,插入、删除的时间复杂度只有O(1).
用链表如何实现 LRU 淘汰算法?
我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
- 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
- 如果此数据没有在缓存链表中,又可以分为两种情况:如果此时缓存未满,则将此结点直接插入到链表的头部;如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。这样我们就用链表实现了一个 LRU 缓存,是不是很简单?
现在我们来看下 m 缓存访问的时间复杂度是多少。因为不管缓存有没有满,我们都需要遍历一遍链表,所以这种基于链表的实现思路,缓存访问的时间复杂度为 O(n)。
实际上,我们可以继续优化这个实现思路,比如引入散列表(Hash table)来记录每个数据的位置,将缓存访问的时间复杂度降到 O(1)。
“数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。” 这里的CPU缓存机制指的是什么?为什么就数组更好了?
LRU 淘汰算法的实现来源于王铮老师的数据结构与算法之美
专栏
总结
CPU在从内存读取数据的时候,会先把读取到的数据加载到CPU的缓存中。而CPU每次从内存读取数据并不是只读取那个特定要访问的地址,而是读取一个数据块(这个大小我不太确定)。并保存到CPU缓存中,然后下次访问内存数据的时候就会先从CPU缓存开始查找,如果找到就不需要再从内存中取。这样就实现了比内存访问速度更快的机制,也就是CPU缓存存在的意义:为了弥补内存访问速度过慢与CPU执行速度快之间的差异而引入。
对于数组来说,存储空间是连续的,所以在加载某个下标的时候可以把以后的几个下标元素也加载到CPU缓存这样执行速度会快于存储空间不连续的链表存储。