简介
链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针)(Pointer)。由于不必须按顺序存储,链表在插入的时候可以达到 O(1) 的复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要 O(n) 的时间,而顺序表相应的时间复杂度分别是 O(logn) 和 O(1)。
使用链表结构可以克服数组需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了节点的指针域,空间开销比较大。
在计算机科学中,链表作为一种基础的数据结构可以用来生成其它类型的数据结构。链表通常由一连串节点组成,每个节点包含任意的实例数据(data fields)和一或两个用来指向上一个/或下一个节点的位置的链接(”links”)。链表最明显的好处就是,常规数组排列关联项目的方式可能不同于这些数据项目在记忆体或磁盘上顺序,数据的访问往往要在不同的排列顺序中转换。而链表是一种自我指示数据类型,因为它包含指向另一个相同类型的数据的指针(链接)。链表允许插入和移除表上任意位置上的节点,但是不允许随机存取。链表有很多种不同的类型:单向链表,双向链表以及循环链表。
链表可以在多种编程语言中实现。像Lisp和Scheme这样的语言的内建数据类型中就包含了链表的访问和操作。程序语言或面向对象语言,如 C/C++和 Java 依靠易变工具来生成链表。
特性
- 最简单的动态数据结构
- 不一定按顺序存储
- 没有随机访问的能力
- 可以辅助组成其他数据结构
- 数据存储在“节点”(Node) 中
- 真正的动态,不需要处理固定容量的问题
图解
设计思路
从设计上而言,链表存取数据的方法如上图,和在第一章的数组中基本一致,只是内部实现的代码,由于链表的结构特殊性,需要进行额外处理。
链表的核心在于理解下其节点的概念和为什么需要使用虚拟头节点即可。
代码实现
如何通过代码实现一个链表,并提供数据的存储和访问功能?
下面就我们进行代码实现。
节点
操作链表的前提是理解链表的节点(重点是如何理解指针),下面就定义了一个叫做节点的内部类:
1 | // 节点 |
初看代码好像挺难理解,为什么节点 Node 里面又有一个名为 next 的 Node?这个节点到底该如何理解呢?
其实答案很简单,这两个 Node 不同,第一个 Node 是真正形态,第二个名为 next Node 是一个代指,有名无实。1
2
3Node nullNode = new Node();
// new Node() 是 Node 的真正形态,而 nullNode 是一个代指
Node head = new Node("1", nullNode);
举个例子,这就和老式的火车厢一样,一个火车厢包括两部分:
- 车厢:存放真正的数据
- 挂钩:用于连接下一节火车厢
即火车厢
= 车厢
+挂钩
–> Node = 数据域 + 指针域。
初版代码实现链表的插入
若我们想在链表中添加一个节点,代码需要这么编写:
1 | // 节点代码与上文相同,省略 |
初版代码的缺陷
初版代码存在了一些缺陷性问题,什么问题呢?下面我们分析一下:
链表头插入数据
对于在链表头部插入数据的过程是这样的:
- ① 创建一个新节点并进行数据域赋值
- ② 让新节点的节点域指向原先的头节点 (在火车头加一个车厢,把挂钩连到火车头上):
- ③ 将头指针指向新创建的节点使其成为头节点 (告诉火车你车头换了,用新的别用旧的啦):
链表中插入数据
在链表中其他节点插入数据需要这样:
- ① 创建一个新节点并进行数据域赋值
- ② 让新节点的节点域指向需要插入的节点位置(把新的车厢挂钩连到插入位置车厢的挂钩上):
- ③ 将插入节点的前一节点的节点指针指向新节点(把插入位置车厢的挂钩连到新的车厢挂钩上):
不同位置插入数据的差异性
经过上述分析,可以发现一个结论:在链表中,对于不同部位的插入操作,代码实现起来却并不一样。
注:不仅仅是插入操作这么麻烦,删除操作同样如此。
那么能不能不这么麻烦,在头节点插入数据时,能否和其他节点插入数据时,代码实现保持一致统一,减少阅读难度呢?
当然是可以的,此时我们就不得不需要借助到虚拟头节点了。
虚拟头节点
链表的增加和删除操作,一定要通过待处理节点的前一个节点来实现,为了解决头节点没有前继节点引起的特殊性操作,可以给链表添加一个虚拟头节点。
因此,我们可以新建一个用于索引的指针dummyHead
,指向待处理节点的前继节点,则待处理节点为dummyHead.next
,初始化dummyHead
则指向链表的虚拟头节点。
注意:虚拟头节点不存储任何数据,只作为一个标识。
其中,虚拟头节点的图示:
进行添加操作时:先新建一个节点指针prev
,令其指向dummyHead
节点,prev
指针经循环操作后到要添加的节点的前一个位置,然后进行如下操作:
- 令添加节点(
node
)的节点域(node.next
)指向前一个节点(prev
)的节点域(prev.next
) - 令前一个节点的节点域指向添加的节点
进行删除操作时:先新建一个节点指针prev
,令其指向dummyHead
节点,prev
指针经循环操作后到要删除的节点的前一个位置,然后进行如下操作:
- 获取要删除的节点(
delNode
):为前一个节点(prev
)的节点域(prev.next
) - 让前一个节点的节点域指向要删除节点的节点域
- 将要删除的节点节点域置为空
问
:为什么在链表表尾添加数据和删除数据传入的参数不同?
因为添加和删除操作均要操作待处理节点的前一个节点来实现,在表尾添加数据时依托于链表中存在的最后一个节点 (将其作为前继节点),而表尾删除数据时删除的是最后一个节点,此时依托倒数于第二个节点(将其作为前继节点)。
代码实现
1 | public class LinkedList<E> { |
测试代码
1 |
|
测试结果
1 | linkedlist:0-->NULL |
时间复杂度
- 添加操作:O(n)
操作 | 时间复杂度 |
---|---|
addLast(e) | O(n) |
addFirst(e) | O(1) |
add(index,e) | O(n/2) = O(n) |
- 删除操作:O(n)
操作 | 时间复杂度 |
---|---|
removeLast(e) | O(n) |
removeFirst(e) | O(1) |
remove(index,e) | O(n/2) = O(n) |
- 修改操作:
操作 | 时间复杂度 |
---|---|
set(index,e) | O(n) |
- 查询操作:
操作 | 时间复杂度 |
---|---|
get(index) | O(n) |
contains(e) | O(n) |
总结:
- 对于添加和删除操作,如果只队链表头进行操作,则时间复杂度为 O(1)
- 对于查询操作,如果只查链表头的元素,则时间复杂度为 O(1)
数组 VS 链表
- 数组优点:支持快速查询
- 数组最好用于索引有语义的情况
- 链表优点:动态
- 链表不适合用于索引有语义的情况
文章信息
时间 | 说明 |
---|---|
2018-12-04 | 初稿 |