今天我们来聊聊“链表 LinkedList”这个数据结构,学习链表有什么用呢,我们先来讨论一个经典的链表使用场景,那就是LRU缓存淘汰算法。
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。
缓存的大小有限,当缓存被占满时,那些数据应该被清理出去,那些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有这么三种:先进先出策略FIFO(First In First Out)、最少使用策略LFU(Least Frequently Used)、最近最少使用策略LRU(Least Recently Used)。
今天我们的问题是,怎样用链表来实现一个LRU缓存淘汰策略?
相比数组,链表是一种稍微复杂一点的数据结构,掌握起来也要比数组要困难一些。数组和链表是两个非常基础、非常常用的数据结构。所以要掌握甚至精通,同时理解其思想。
我们先从底层存储结构来看一下二者的区别:
为了直观的对比,我画了一张图,从图中可以看到,数组需要一块连续的内存空间来存储,对内存的要求比较高,如果我们申请一个100MB大小的内存空间,当内存中没有连续的、足够大的内存空间时,即便剩余的总空间大于100MB,仍然会申请失败。
而链表恰恰相反,它并不需要一块连续的内存空间,他通过“指针”将一组零散的内存块连接起来使用,所以申请一块大小是100MB的链表,根本不会有问题。
链表的结构五花八门,今天我们着重介绍三种最常用的链表结构:单链表、双向链表、循环链表。
单链表
首先来看最简单、最常用的单链表。我们刚讲到,链表是用指针将一组零散的内存块串联在一起,其中,我们把内存块称为链表的“结点”。为了使所有的节点串联起来,每个链表的结点出了需要保存数据之外,还需要记录链上下一个结点的地址,如图所示,我们把这个记录下一个结点指针地址的指针叫做后继指针 next。
从上面单链表的结构图中,可以发现,单链表中有两个结点是比较特殊的,分别是第一个节点和最后一个结点,我们习惯性的把第一个结点称为头结点,最后一个节点称为尾结点。其中头结点用来记录链表的基地址,我们可以通过它遍历得到整个链表。而尾结点的特殊之处在于,指针不是指向下一个结点,二是指向了一个空地址null,表示这是链表的最后一个结点。
与数组一样,链表也支持数据的插入、查找、删除操作。我们知道在进行数组的插入、删除操作时,为了保持内存的连续性,需要做大量的数据搬移操作,所以时间复杂度是O(n)。而在链表中插入或者删除一个数据,我们并不需要保持内存的连续性而搬移结点,因为链表本身的存储空间就不是连续的。所以在链表中插入删除一个数据是非常快的。
为了方便理解,我画了一张图,从图中我们可以看出,针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度为O(1)。
但是有利就有弊,链表想要随机访问第K个元素就没有数组那么高效了。因为链表中的数据并非是连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就可以直接计算出对应的内存地址,而是需要一个一个结点依次遍历,直到找到对应的结点。
你可以把链表想象成一个队伍,每个人都知道自己前面的人是谁,所以当我们希望知道排在第K为的人是谁的时候,就需要从第一个人开始,一个一个往下数。所以链表随机访问的性能没有数组好,时间复杂度为O(n)。
好了,单链表了解了,下面来看看另外两个复杂的链表:循环链表和双向链表。
循环链表
循环链表是一种特殊的单链表。实际上,循环链表也很简单,它和单链表唯一的区别就在尾结点。我们知道,单链表的尾结点是指向空地址,表示这是最后的节点了,而循环链表的尾结点的指针是指向链表的头结点。从下图中可以看出,循环链表想一个环一样首尾相连,所以叫循环链表。
和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环形结构特点时,就特别适合采用循环链表,比如著名的约瑟夫问题。尽管用单链表也可以实现,但是用循环链表的话,代码就会简洁很多。
双线链表
接下来再看一个稍微复杂,在实际的软件开发中,也更加常见的链表结构:双向链表。
单链表只有一个方向,节点只有一个后继指针,next指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。
从上图可以看出,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单向链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表的操作灵活性。那相比单向链表,双向链表适合解决哪种问题呢?
从结构上来看,双向链表可以支持O(1)时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的删除、插入操作比单链表要简单、高效。
你可能会说,单链表的插入、删除操作的时间复杂度都已经是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的实现原理,就会发现其中就用到了双向链表这种数据结构。
实际上,这里有一个更重要的知识点需要你掌握,那就是用空间换时间的设计思想。当内存空间充足时,如果我们更追求代码的执行速度,我们就可以选择空间复杂度相对较高,但时间复杂度相对较低的算法和数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单机片中,这个时候,就要反过来用时间换空间的涉及思路。
还是开篇缓存的例子,缓存实际上就是利用了空间换时间的例子。虽然我们将数据存放在磁盘上,会比较节省内存,但是每次查询数据都要查询一遍磁盘,会比较慢。但是我们通过缓存技术,事先将数据加载在内存中,虽然会比较耗费内存空间,但是每次查询数据的速度就大大提高了。
所以对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)进行优化;而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。你还能想到其他时间换空间或者空间换时间的例子吗?
了解了循环链表和双向链表,如果把这两种链表整合在一起就是一个新的版本:双向循环链表。我想不需要我多讲,你应该知道双向循环链表长什么样子了吧?
通过前面的学习,你应该知道,数组和链表是两种截然不同的内存组织方式,正是因为内存存储的区别,他们插入、删除、随机访问的时间复杂度正好相反。
| 时间复杂度 | 数组 | 链表 |
|---|---|---|
| 插入删除 | O(n) | O(1) |
| 随机访问 | O(1) | O(n) |
不过,数组和链表的对比,并不能局限于时间复杂度。而且,在实际的软件开发中,不能仅仅利用复杂度分析就能决定使用那哪个数据结构来存储数据。
数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对CPU缓存并不好,没办法有效预读。
数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,可能没有足够的连续内存空间分配给它,导致“内存不足”。如果声明的数组过小,则可能出现不够用的情况,这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然的支持动态扩容,我觉得这也是它与数组最大的区别。
你可能会说,Java中也有ArrayList容器,也可以支持动态扩容啊?我们上一节已经讲过,当我们往支持动态扩容的数组中插入一个数据时,如果数组中没有空闲空间了,就会申请一个更大的空间,将原数组拷贝过去,而数据拷贝的操作是非常耗时的。
我举一个稍微极端的例子。如果我们用ArrayList存储了1GB大小的数据,这个时候已经没有空闲空间了,当我们再插入数据的时候,ArrayList会申请一个1.5GB的存储空间,并且把原来那1GB的数据拷贝到新申请的空间上,听起来是不是就很耗时。
除此之外,如果你的代码对内存的使用非常苛刻,那数组就更适合你。因为链表中的每个结点都需要消耗额外的内存空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是java语言,就有可能会导致频繁的GC(Garbage Collection 垃圾回收)。
所以在实际的开发项目中,要根据不同的项目情况,权衡究竟是选择数组还是链表。
好了,我们现在回过头来看,如何基于链表实现LRU缓存淘汰算法?
我的思路是这样的:我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新数据被访问时,我们从链表头部开始顺序遍历链表。
如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,再插入到链表的头部。
如果此数据没有缓存在链表中,又可以分为两种情况:
- 如果此时缓存未满,则将此结点直接插入到链表的头部;
- 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。
这样我们就实现了一个LRU缓存,是不是很简单。
现在我们来看下缓存访问的时间复杂度是多少。因为不管缓存有没有满,我们都需要遍历一遍链表,所以这种基于链表的实现思路,缓存访问的时间复杂度为O(n)。
实际上,我们可以继续优化这个实现思路,比如引入哈希表(hash table)来记录每个数据的位置,将缓存访问的时间复杂度降到O(1)。这个优化方案,等讲到哈希表的时候再讲。
基于链表的实现思路,实际上还可以用数组来实现LRU缓存淘汰策略。如何利用数组实现LRU缓存淘汰策略?
今天我们讲了一种跟数组“相反”的数据结构,链表。他跟数组一样,也是非常基础、非常常用的数据结构。不过链表要比数组稍微复杂,从普通链表衍生出来好几种链表结构,比如双向链表、循环链表、双向循环链表。
和数组相比,链表更适合插入、删除操作频繁的场景,查询的时间复杂度较高。不过在具体的软件开发中,要对数组和链表的各种性能进行对比,综合来使用两者中的一个。
如何判断一个字符串是否是回文字符串呢?今天的思考题就是基于这个问题的改造版本。如果字符串是通过单链表来存储的,那如何来判断是一个回文串呢?相应的时间空间复杂度是多少。
本章代码:GitHub
带头单链表代码:
1 | import java.util.NoSuchElementException; |
基于链表的LRU缓存代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82public interface LRUCache<T> {
void put(T val);
T get(T val);
int Size();
}
class ListLRUCache<T> implements LRUCache<T> {
private SinglyLinkedList<T> lruList;
private static final int DEFAULT_CAP=10;
// 缓存容量
private int cap;
// 缓存使用大小
private int size;
public ListLRUCache(){
this(DEFAULT_CAP);
}
public ListLRUCache(int cap){
this.cap = cap;
this.lruList = new SinglyLinkedList<>();
}
public void put(T value) {
// 1、缓存满了
// 如果该列表中没有该数据
if (size == cap){
// 1、缓存满了
// 删除最后一个节点
lruList.delete(size-1);
// 将该数据插入到链表头部
lruList.push(value);
}else {
// 2、缓存未满
// 直接在列表头部插入该数据
lruList.push(value);
size++;
}
}
public T get(T val) {
T result = null;
if (lruList.contains(val)){
// 在list中,从list中获取该数据
int index = lruList.indexOf(val);
result = lruList.get(index);
System.out.println("从缓存中获取");
// 将该节点插入到链表头部
lruList.delete(index);
lruList.push(val);
}else{
// 如果该列表中没有该数据
System.out.println("缓存中没有该数据!");
if (size == cap){
// 1、缓存满了
// 删除最后一个节点
lruList.delete(size-1);
// 将该数据插入到链表头部
lruList.push(val);
System.out.println("缓存已满!将该数据插入到缓存");
}else {
// 2、缓存未满
// 直接在列表头部插入该数据
lruList.push(val);
size++;
System.out.println("将该数据直接插入到缓存");
}
// 如果有数据库,该数据从数据库中获取
result = val;
}
return result;
}
public int Size(){
return size;
}
}
字符串是否是回文字符串:1
2