这节我们学习最基础的动态数据结构—链表。
前两篇我们学的线性数据结构动态数组
, 栈
, 队列的底层都是依托于静态数组,靠我们写的
resize`解决固定容量问题。
链表则是真正的动态数据结构。
链表是最简单的动态数据结构
对链表的理解能更深入的理解引用(或者指针)
能够更深入的理解递归
链表能够辅助组成其他数据结构
链表 Linked List
什么是链表呢?维基百科解释 – 链表
链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针)(Pointer)。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,而顺序表相应的时间复杂度分别是O(logn)和O(1)。
链表把数据存储在节点(Node)中,节点主要有两部分内容
1 | class Node { |
优点:真正的动态,不需要处理固定容量的问题。
缺点:丧失了随机访问的能力(数组分配的空间在内存中是连续分配的,可以直接通过索引的偏移获得数据存储数据的内存地址获得数据)
数组和链表的对比:
数组最好用于索引有语义的情况。scores[2]
最大的优点:支持快速查询
链表不适合用于索引有语义的情况。
最大的优点:动态
在链表中添加元素
在链表中如果我们想访问所有的元素,我们需要将链表的头存储起来。
之前我们在往数组中新增元素的时候一般都是直接在尾部直接追加元素,那是因为我们有维护一个size
变量来指向下一个元素存放位置。在往链表中新增元素的时候一般是将元素存放到链表的头部。
如上图所示,我们往链表中新增数据节点为66的node
,直接将node
设置为链表的头部即可。
如何在链表中间添加元素呢?
我们如果想将666插入到位置为2的索引,则先找到插入位置的前一个node
,然后将前一个node
的next
设置为666的node
,接着将666的node.next
设置为之前node
的next
。
问题的关键是如何查找到对应的prev
即插入前的那个node
。
注意: node.next = prev.next prev.next = node 这两个顺序不能变
1 | public class LinkedList<E> { |
为链表设立虚拟头结点
在上面添加元素到链表的时候,会遇到一种情况就是对添加到第一个位置做特殊处理。原因就是head
之前没有node
了。
我们可以给head
新增一个虚拟的头(dummyHead
)来当做第一个node
,但是第一个元素实际是dummyHead.next
对应的元素。
这个虚拟头结点对用户是不可见的。
1 | public class LinkedList<E> { |
测试一下
1 | public class Main { |
列表元素的删除
因为删除涉及到第一个节点,因此我们同样新增虚拟头节点。
和新增的逻辑类似,我们要找到要删除节点的前一个节点prev
,然后将要删除节点delNode
的next
赋值给prev.next
。为了方便垃圾回收,我们需要将delNode
的next
设置为NULL
。
新增以下删除方法
1 | // 从链表中删除index(o-based)位置的元素 返回删除的元素 |
链表的时间复杂度分析
添加操作:O(n)
1 | addLast(e) O(n) |
删除操作: O(n)
1 | removeLast(e) O(n) |
修改操作:O(n)
1 | set(index, e) O(n) |
查找操作:O(n)
1 | get(indeex) O(n) |
列表中不再实现 find方法 返回对应索引 因为即使有索引 也不能通过索引直接取值
我们看到整体的时间复杂度都是O(n)
。
但是如果我们只对链表头进行操作则是O(1)
只查链表头的元素则是O(1)
使用链表实现栈
我们知道如果只对链表的头进行新增和删除,时间复杂度都是O(1)
的。这样的操作很符合栈数据结构。
我们可以将链表的头作为栈的栈顶进行操作,从而生成一个栈的数据结构。
我们按照之前Array
实现栈的接口来实现按照链表实现的栈。
在之前学习栈的项目新增
1 | public class LinkedListStack<E> implements Stack<E> { |
比较使用Array
实现的栈和链表实现的栈:
1 | import java.util.Random; |
对于时间复杂度都是O(1)
的情况下,两种方式的差别不是很大。
在数组栈中扩容的时候需要将数据拷入到新的数组中,这一步是耗时。
但是当数据量大的时候链表栈就会因为多次创建node
节点需要开辟更多的空间。这个开辟新的空间是比较耗时的。
带有尾指针的链表:使用链表实现队列
我们前面分析过,向链表的尾部插入数据的时间复杂度是O(n)
。当我们直接使用上面的链表生成队列就会出现入队和出队的时间复杂度不一致问题。
为什么在链表的头部是O(1)
呢?
这是因为在链表的头部我们有一个标识head
,如果我们想在链表的尾部同样达到O(1)
,同理也需要一个标识tail
。
当我们在链表的尾部新增了标识tail
之后,这样在两端新增元素都是非常容易的,但是从尾部删除数据则还是需要遍历查找到tail
之前的节点,这样的时间复杂度是O(n)
。但是从链表的头部删除节点却是很容易的。
因此我们将head
端作为队首进行出队操作,tail
端作为队尾进行入队操作。
注意:链表为空的情况
我们在之前的项目工程中进行操作
1 | public class LinkedListQueue<E> implements Queue<E> { |
我们将数组队列,循环队列,和链表队列进行一下比较
1 | public static void main(String[] args) { |
我们看到不同时间复杂度的差别还是很大的。
链表本身具有天然的递归性质,一起来学习链表与递归吧。😊