今天要讨论的是链表,我认为链表是整个数据结构的灵魂,对链表理解透彻了,的后面的树结构以及相对复杂的图论的学习都会有很大的帮助。
相信有一些开发经验的人都知道缓存的重要性,比如业务层的数据缓存,比如TCP/IP的滑动窗口也可以理解成一种缓存技术,再比如我们买电脑要关注CPU的1、2级缓存等等,缓存是一种提高性能最直接有效的方式。
但是,由于缓存资源相对于持久化存储的成本是比较高的,相同容量的内存和硬盘(即便是最新的SSD)成本相差可能是好几倍,甚至几十倍。在我的工作经历中就遇到过这类问题,我们每个月平均有5亿条左右的热数据需要跑在我们的redis集群里,由于一开始为了让key名看起来容易理解,redis的key设计得比较长,光是key就占用了很大的空间,很快就达到了预警值。我们面临着两个选择,第一,对redis进行升级扩容,但这样公司光是在redis缓存服务上面将会额外花费每个月数十万的费用。第二,对缓存数据进行瘦身,这样虽然有一些开发成本,但相比第一种方案几乎可以忽略不计。最后我的同事通过分析热点key的使用情况,将key的命名进行了简化,从而节省了大量的缓存空间。也为公司每个月节省了数十万的费用。当然,除了对key进行缩容,其实还有一些方法也可以对缓存进行很好的管理,比如:LRU算法。
上面我分享了一个真实例子。可以看到,在数据量庞大的系统里,任何一个设计上的失误都会导致成本大幅上升。在开发的过程中时刻考虑节省成本也是一个程序员的优秀特质之一。
缓存的设计是很复杂的一件事,我们今天要讲的主要还是链表,所以这里不做过多讨论了。LRU算法是缓存淘汰算法中的一种,常用的缓存淘汰算法有FIFO(First in First out)先进先出策略、LFU(Least Frequently Used)最少使用策略和LRU(Least Recently Used)最近最少使用策略。
在弄明白LRU缓存淘汰算法是怎么回事之前我们先进入今天的正题——链表。计算机科学发展到现在,出现了五花八门的各种链表,但今天我们只讨论其中几种最常用的:单链表、双向链表、循环链表,相信掌握了这几种链表,再去学习其它类型的链表是很容易的。
在正式开始介绍链表之前,我们同步几个重要的术语,链表每个小的单位叫节点,第一个节点叫做头节点,最后一个节点叫尾节点。
我在上一篇"数组真的很简单吗?"那篇文章中提到,数组在物理上是连续的,声明一个数组在计算机内存上是一段相领的内存块。而每次我们声明数组的时候都必须要有一块能够容纳整个数组大小的一块连续的内存空间。而今天我们要说的链表则打破了这个限制,链表每次分配内存时所需要的空间只要能容纳下每个节点就可以了。当然,相比数组链表需要额外的一个指针元素来指向下一个节点,所以链表节点占用的空间是要比数组元素占用的空间大的,图(1)描述了一个单链表。
图中的next和data共同组成一个节点,链表的第一个节点的data可以承载数据,也可以为null,在有数据的情况下,它既是链表里的节点,又是链表的头节点。当第一个节点的data==null的时候,这时候它只表示链表的头节点,链表的实际内容从它的下一个节点开始,这两种实现的区别只是代码的上细微差别,选择哪一种都可以。链表的最后一个节点指向一个空指针,用于表示这是链表的最后一个元素,也就是尾节点。下面我们以go语言为例,看一下如何使用代码定义一个节点:
type node struct {
data interface{}
next *node
}
我们定义了一个node结构本,成员data设置成了interface,表示可以设置成任何类型的数据,next是一个node类型的指针,用于指向下一个节点。
接下来我们分析一下链表插入及访问元素的时间复杂度,一般情况下我们都是在链表的头尾进行插入,由于不需要考虑空间不够的情况,所以不需要进行数据的搬移,而且我们可以通过记录尾节点的地址来快速定位尾节点。所以链表的插入我们通常认为是O(1)的时间复杂度。链表的访问不能像数组那样通过地址的加减快速得到元素的地址,而是需要从头到尾遍历一遍,直到找到对应的元素所在的节点,所以访问元素的时间复杂度是O(n)。下面是数组和链表的时间复杂度的对比:
| 插入删除 | 随机访问元素 | |
|---|---|---|
| 数组 | O(n) | O(1) |
| 链表 | O(1) | O(n) |
循环单链表,在单链表基础上稍微做些小调整,就能实现一个循环单链表。把链表最后一个节点的next指针指向第一个节点就实现了一个简单的循环链表。但是,这里要注意一个细节,前面我们说第一个节点的data可以承载数据,也可以为null,仔细想想如果循环链表第一个元素的data!=null会发生什么?答案是,这样我们就找不到谁是头节点了。那怎么办呢?我们稍微做些改变,让链表第一个节点的data==null,也就是让其不承载具体的数据,只表示链表的头节点。修改之后data==null的节点就一定是头节点,指向data==null的节点就是尾节点了。这样,我们就实现了一个循环单链表,如图(2)
图(2)
在一些面试题,或者像LeetCode网站上,有一类面试题,问:如何判断一个链表有环?当然了,根据我们上面实现的循环链表你很快就知道了,我们只需要找到data==null的那个节点,然后一直遍历下去,如果能回到data==null的节点就说明有环,否则就没有环。但是,假如我们的头节点的data!=null也就是头节点承载了数据,这时候它既是头节点也是数据结点,或者链表的环发生在头节点以外的节点呢?这里的发生在头节点以外的节点是什么意思?举个例子:现在假设有下面这样一个链表:
2->4->5->6->9->4
看出来了吗?这个链表的环发生在链表第一个节点以外的节点也就是4->5->6->9->4形成了一个环,那么这种场景该如何判断链表有环呢?这里直接给出两个基本的答案。第一,给程序设置一个最大执行时间,如果在这个时间内回到前面的节点,说明找到了环,这个适合数据量小的链表。第二,使用快慢指针,当快慢指针重合的时候说明找到了环。这类题的解法LeetCode上都有详细说明,下面给出了两道LeetCode的题大家有兴趣可以去挑战一下。
环形链表:
循环链表有什么用呢?如果你有去了解过比如MySQL的redo log,就会有似曾相识的感觉,redo log具体实现细节这里篇幅的原因不展开,大致讲一下它做的事情。
MySQL在update的时候并不是直接去操作表空间将数据落盘。而是设计了一个类似小帐本的东西,其原理就是首尾相连的一块一块的磁盘空间,每次有update的时候就会把变更内容记录到这个小本子上,等到一定的时候就把小本子上的内容批量写入到表空间持久化到磁盘上。这有点类似于平时工作中,我们使用便签快速记录重要事项,等到空下来了再将便签上的内容整理到我们的工作记录或者周报上。这样做的目的主要是为了提升效率,如图(3)。
图(3)
其中write pos表示当前要写入的位置,erase pos表示当前要擦除并写入到表空间的位置。在系统比较闲的时候或者小本子已经满了的时候,就会进行擦除并持久化到表空间中去。这样做的好处就是不用每次update都去浩如烟海的表空间找到合适的位置进行数据的更新,而是先把update这个操作记录下来,等到系统不忙的时候再写入到表空间,这个写的过程是通过一个独立的线程进行操作的,可以大幅提高系统的吞吐量,这也是为什么MySQL会那么高效的原因。怎么样,当你理解了循环链表的原理之后再回过头来看redo log是不是豁然开朗呢?
在计算机系统里还有很多类似的场景,这也是为什么我们要学习数据结构的原因,在掌握了数据结构之后再去学习各种框架及系统的时候就有了思维框架,学习起来就会容易很多。这就像学会了骑自行车,再去骑摩托车。
链表的操作,链表最复杂的就是插入和删除操作了,这里面涉及了指针的很多操作,代码很容易写错,而链表代码又是很难调试的。
我们先来看怎么在链表里插入一个节点,链表的插入大致上可以分为三步:
- 创建一个新的节点NewNode
- 将新节点的next指向要插入位置的下一个节点
- 将要插入位置前一个节点指向新节点
核心代码如下:
...
// 找到插入位置前面的那个节点
pre := ll.headNode
for i := 0; i < index; i++ {
pre = pre.next
}
// 创建一个新节点
newNode := node{
data: data,
}
// 将新节点指向插入位置前一节点的下个节点
newNode.next = pre.next
// 插入位置的上一个点的下个节点指向新节点
pre.next = &newNode
...
思考一下,在上面的步骤中,如果2和3交换一下会发生什么?这是一开始学习链表经常会犯的错误。这时候就变成先将pre节点指向新节点,然后将新节点指向pre节点的下一个节点,问题来了,这时候pre节点的下一个节点是谁?是新建的节点NewNode,然后我们看到整个链表从新节点NewNode开始就断开了,后面的节点就找不到了,在C语言中,这不仅会造成节点丢失,还会造成内存泄漏,因为原链表pre节点之后的节点的内存已经没有机会进行释放了。
删除节点,链表删除节点相对插入是比较简单的,但还是有些细节要注意,删除一个节点只需要找到要删除的节点,然后将这个节点的上一个节点指向下一个节点就可以了。但要注意,在很多静态语言中,内存是需要手动释放的,我们还需要将要删除节点的指针赋值为null,图(4)描述了插入和删除节点的操作。
核心代码如下:
...
// 找到要删除节点的上一个节点
pre := ll.headNode
for i := 0; i < index; i++ {
pre = pre.next
}
// 要删除的节点
delNode := pre.next
// 将要删除节点的上一个节点指向删除节点的下一个节点
pre.next = delNode.next
// 将要删除节点指针置为null
delNode.next = nil
...
图(4)
双向链表,双向链表是对单链表的一个拓展,我们知道,单链表是在一个方向上从头到尾串起来的一组数据,这里的方向指的就是一个节点只能指向下一个节点,而不能指向它的上一个节点。假如我们要查询其中的某个元素,当我们遍历到要查找的元素所在的节点的时候我们是不知道当前节点的地址的,一个比较简单的方法是新开一个指针来记录当前节点的上一个节点的地址,当我们查找到要找的节点,就用上一个节点的next指针找到当前节点,从而拿到节点里的数据。
但还有一种比较优雅的方式就是使用双向链表,双向链表的每个节点除了data、next指针之外还要再维护一个prev指针,用于指向当前节点的上一个节点,这里说明一下双向链表要注意的事项,图(5)描述了一个双向链表的结构。
- 双向链表头节点同样可以包含数据,也可以不包含数据
- 头节点的prev指向null
图(5)
双向链表的node定义如下:
type node struct {
data interface{}
next *node
prev *node
}
图(6)描述了双向链表的插入和删除操作
图(6)
双向链表插入操作比单链表要复杂一些,基本步骤如下:
- 创建一个新节点newNode
- 将新节点newNode的prev指向要插入位置节点
- 将新节点newNode的next指向要插入位置下一个节点
- 将要插入位置的节点下一个节点的prev指向新节点newNode
- 将要插入位置的节点的next指向新节点newNode
核心代码如下:
...
// 找到要插入的位置
pre := ll.headNodefor i := 0; i < index; i++ {
pre = pre.next
}
// 创建新节点
newNode := node{data: data}
// 将新节点的next指向要插入位置节点的下一个节点
newNode.next = pre.next
// 将新节点的prev指向要插入位置节点
newNode.prev = pre
// 将要插入位置的下一个节点的prev指向新节点
pre.next.prev = &newNode
// 将要插入位置的上一个节点next指向新节点
pre.next = &newNode
...
双向链表的删除,可以分为以下几步:
- 找到要删除的节点
- 将删除节点的下一个节点的prev指向要删除节点的上一个节点
- 将删除节点上一个节点的next指向删除节点的下一个节点
- 将删除节点的prev、next都置为null
核心代码如下:
pre := ll.headNode
for i := 0; i < index; i++ {
pre = pre.next
}
delNode := pre.next
delNode.next.prev = pre
pre.next = delNode.next
// 要将被删除的节点指针置为null
delNode.next = nil
delNode.prev = nil
上面,我们找到单链表和双向链表待删除位置的时候是找的被删除节点的上一个节点,你可以思考一下,如果直接在被删除的节点操作可以实现吗?为什么?
双向循环链表,有了前面循环单链表和双向链表的操作作为铺垫,双向循环链表还是很简单的,区别在于,第一,每个节点都有两个指针next和prev。第二,最后一个节点的next指针指向第一个节点。第三,第一个节点的prev指针指向最后一个节点。如图(6)
图(6)
上面我们其实还遗漏了一块内容,就是链表的遍历,遍历相比插入可能是一个更加高频的操作,但是遍历相对来讲还是比较简单的,这里只是大概给出一个思路。首先,我们需要一层循环,结束条件就是node.data==null。其次,链表遍历需要借助一个临时节点,来保存当前遍历到的节点。最后,每一重循环,我们将下一个节点赋值给临时节点。通过这三步就可以实现链表的遍历了,下面给出一段伪代码:
curNode = node
while(culNodedata==null):
print(curNode.data)
curNode = curNode.next
怎么样,是不是很简单?
接下来,回到今天一开始的问题,怎么通过链表实现一个LRU缓存淘汰算法呢?
- 维护一个单向且有序的链表,并且给他一个最大长度maxSize,表示最大可以容纳的缓存数。
- 当有一个新数据被访问了,并且这个数据在链表中,则将其从当前位置删除,同时将其插入到链表头部。
- 当有一个新数据被访问了,并且这个数据不在链表中,这时分两种情况:
- 此时缓存还没满,则将数据插入到链表头部
- 如果缓存已经满了,则删除链表最后一个节点,并且将数据插入到链表头部
下面给出伪码:
node {
data
next
}
cache {
head node
size int
maxSize
}
addToFirst(data):
...
removeNode(index):
...
getCache(data):
index=0
curNode = cache.head;
[while]if (curNode != null):
if curNode.data == data:
removeNode(index)
addToFirst(data)
return
curNode = curNode.next
index++
if cache.size >= cache.maxSize:
removeNode(cache.size)
addToFirst(data-1)
else
removeNode(index)
addToFirst(data)
上面的addToFirst和removeNode在前面的添加元素和删除元素已经讲过了,逻辑基本上是一样的,你可以回到上面回顾一下,相信可以很容易写出来。
总结下LRU实现的过程,给定一个长度固定的单向有序链表,如果缓存已经在链表中存在,则删除数据在链表中位置所在的节点,然后将数据插入链表的开头。如果数据不在链表中,则分两种情况。第一,链表已经达到最大长度,则先删除队尾的节点再将数据插入到链表开头。第二,链表还没有达到最大长度,则直接将数据插入到链表的开头。其核心思想就是给定一个固定的长度,通过对链表的增删操作,让缓存数据的数量始终保持在设定的最大链表长度之内。这样,最新访问的数据永远在最前面,一旦超过了链表的最大长度那么链表最后一条缓存数据就被淘汰了,这便是LRU缓存的核心思想。其实很多优秀的软件也都使用了LRU算法,例如我们常常打交道的MySQL,下面是MySQL官方文档对LRU算法使用的描述:
最后,我想扩展一下,LRU算法是否可以用循环单链表实现呢?上述LRU伪代码是否还有优化的空间?如果有,如何优化呢?
结尾
想说些题外话,这个系列文章其实很早就在准备了,主要的目的是为了对以往工作学习的一个回顾,如果有幸能被屏幕前的你看到,并且能给到你一些启发和帮助那将是对我最好的激励。
在数据结构算法这个系列文章里,主要会包括以下内容:
数组、链表、队列与堆栈、集合与映射、Hash表、二分搜索树、AVL树(平衡二叉对)、Trie树(字典树)、并查集、红黑树、图论、排序算法、分布式系统Raft算法。
在第二季可能会写机器学习算法或者计算机网络系列文章。在学习数据结构算法的路上我也是踩了很多的坑,这里我要特别感谢的是刘宇波 波波老师。推荐大家去慕课网购买波波老师的课程,他的《玩转数据结构》和《图论精讲》是目前为止我所能找到的所有资料和视频里讲得最好的两门课。
写文章对于一个上班族来讲确实不是一件容易的事情,我自己也会一遍遍的check,每一遍check下来都会发现一些小的瑕疵,如果出现一些错误,还希望你能够指出来。
上一篇文章发出来之后,我自己也读了很多遍,总是觉得排版不够美观,我也去参考了很多公号和一些付费知识系统的排版风格,从这一篇文章开始,在排版上也做了一些调整。比如,首行不再缩进了,插图加入了米黄色的背景颜色等等,也希望大家能够提出更好的建议!