链表
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用。
缓存大小有限,当缓存被用满时,需要缓存淘汰策略决定数据删除和保留。常见策略有:
- 先进先出FIFO(First In, First Out)
- 最少使用策略LFU(Least Frequently Used)
- 最近最少使用策略LRU(Least Recently Used)
单链表
链表通过指针将一组零散的内存块串联起来使用。每个链表由结点组成,结点中除了存储数据外,还有一个指向下一个结点的后继指针。单链表如下所示:
从图中可以看出,有两个结点比较特殊:
- 头结点:用来记录链表的基地址
- 尾结点:指向一个空地址NULL,用来表示链表的结束
链表的插入和删除
链表的插入和删除都只需要考虑相邻结点的指针改变,其对应的时间复杂度是O(1). 示意图如下:
链表的随机访问
由于链表并不是一段连续的内存空间,因此不能像数组一样直接使用地址寻址找到第K个元素,只能遍历链表,因此平均时间复杂度是O(n).
循环链表
循环链表是一种特殊的单链表,单链表的尾结点指向一个NULL,循环链表不一样,是指向链表的尾结点指针是指向链表的头结点,如一个环一样首尾相连,所以叫做“循环”链表。
循环链表优点:从链尾到链头比较方便。
双向链表
双向链表指的是支持两个方向的操作,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。示意图如下:
如果存储相同的数据,双向链表要比单向链表占用更多的内存空间,但是也带来了双向链表操作的灵活性。因为双向链表可以在O(1)的时间复杂度下找到前驱结点,使得双向链表在某些情况下的插入、删除等操作都要比单链表要简单、高效。
双向链表体现了用空间换取时间的设计思想。对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化;而消耗过多内存的程序,可以用过消耗更多的时间(时间换空间)来降低内存的消耗。
结合循环链表和双向链表的特点,就是双向循环链表:
数组 VS 链表
数组和链表是两种截然不同的组织方式,它们的插入、删除、随机访问操作的时间复杂度刚好相反:
数组使用连续的内存空间,借助CPU的缓存机制,可以预读数组中的数据,所以访问效率更高,链表由于不是连续存储,对CPU缓存不够友好,没办法有效预读。
数组需要固定空间,过大,导致内存不足,过小,满足不了需求,尽管有动态数组,但是有申请新空间和拷贝数据的操作,费时。链表本身没有大小的限制,天然地支持动态扩容。
链表的每个结点需要额外的内存空间,如果内存的使用非常严苛的情况下,使用数组更好。如果对链表频繁的插入、删除操作,会导致频繁的内存申请和释放,容易产生内存碎片,如果是Java,可能会导致频繁的GC(Garbage Collection,垃圾回收)。
链表适合插入、删除操作频繁的场景,查询的时间复杂度较高。
如何实现LRU缓存淘汰算法
维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的,当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
- 如果此数据之前已经存在链表中,遍历得到这个结点并且从原来的位置删除,然后再插入到链表的头部
- 如果此数据没有在缓存链表中,分为两种情况:
- 如果此时缓存未满,则将此结点直接插入到链表的头部
- 如果此时缓存已满,则将链表尾结点删除,将新的数据结点插入链表的头部
不管缓存有没有满,都需要遍历一遍链表,所以这种基于链表的实现思路,缓存访问的时间复杂度是O(n).
如何正确写出正确的链表代码
技巧一:理解指针或引用的含义
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。
技巧二:警惕指针丢失和内存泄露
插入结点时,一定要注意操作的顺序,要先将插入结点x的next指针指向结点a的下一个结点b,再把结点a的next指针指向结点x,这样才不会丢失指针,导致内存泄露。
删除链表结点时,也一定要记得手动释放内存空间。
技巧三:利用哨兵简化实现难度
针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。
# 在链表p后面插入一个新结点
new_node.next = p.next
p.next = new_node
# 空链表插入第一个结点
if (head == null):
head = new_node
# 链表头和中间删除
p.next = p.next.next
# 尾结点删除
if (current.next == None):
current = None
上面的过程过于复杂,不够简洁,因此我们引入哨兵,在任何时候,不管链表是不是空,head指针都会一直指向这个哨兵结点,把这种带有哨兵结点的链表叫做带头链表;相反,没有哨兵结点的链表就叫做不带头链表。带头链表的示意图如下:
举例:在一个数组中查找key值的位置,代码一:
// 在数组 a 中,查找 key,返回 key 所在的位置
// 其中,n 表示数组 a 的长度
int find(char* a, int n, char key) {
// 边界条件处理,如果 a 为空,或者 n<=0,说明数组中没有数据,就不用 while 循环比较了
if(a == null || n <= 0) {
return -1;
}
int i = 0;
// 这里有两个比较操作:i<n 和 a[i]==key.
while (i < n) {
if (a[i] == key) {
return i;
}
++i;
}
return -1;
}
代码二:
// 在数组 a 中,查找 key,返回 key 所在的位置
// 其中,n 表示数组 a 的长度
// 我举 2 个例子,你可以拿例子走一下代码
// a = {4, 2, 3, 5, 9, 6} n=6 key = 7
// a = {4, 2, 3, 5, 9, 6} n=6 key = 6
int find(char* a, int n, char key) {
if(a == null || n <= 0) {
return -1;
}
// 这里因为要将 a[n-1] 的值替换成 key,所以要特殊处理这个值
if (a[n-1] == key) {
return n-1;
}
// 把 a[n-1] 的值临时保存在变量 tmp 中,以便之后恢复。tmp=6。
// 之所以这样做的目的是:希望 find() 代码不要改变 a 数组中的内容
char tmp = a[n-1];
// 把 key 的值放到 a[n-1] 中,此时 a = {4, 2, 3, 5, 9, 7}
a[n-1] = key;
int i = 0;
// while 循环比起代码一,少了 i<n 这个比较操作
while (a[i] != key) {
++i;
}
// 恢复 a[n-1] 原来的值, 此时 a= {4, 2, 3, 5, 9, 6}
a[n-1] = tmp;
if (i == n-1) {
// 如果 i == n-1 说明,在 0...n-2 之间都没有 key,所以返回 -1
return -1;
} else {
// 否则,返回 i,就是等于 key 值的元素的下标
return i;
}
}
技巧四:重点留意边界条件处理
在处理链表时,经常用来检查链表代码是否正确的边界条件有:
- 如果链表为空
- 如果链表只有一个结点
- 如果链表只包含两个结点
- 代码逻辑在处理头结点和尾结点的时候,是否正确
技巧五:举例画图,辅助思考
利用画图将思路画出来,针对单链表插入一个结点的操作示意图如下:
技巧六:多写多练,没有捷径
链表5个常见操作:
- 单链表反转
- 链表中环的检测
- 两个有序的链表合并
- 删除链表倒数第n个结点
- 求链表的中间结点
注:本文是《数据结构与算法之美》的读书笔记,配套代码地址GitHub。本文仅限个人学习,请勿用作商业用途,谢谢。
Note: Cover Picture