一、OverView

image-20201003182530832

快速预览:

  • 首先要和上一节的数组进行横向对比,来看一下链表独有的特性
  • 第二小节会介绍一下常见的几种链表,以及为什么要衍生出这些链表
  • 第三小节主要关注于链表的基操,(勿6) 以及在链表中的一些注意事项,如何保持链表不断链,为什么要引入哨兵
  • 最后一个小节主要结合 Java 中的链表 LinkedList 来加深对链表的理解

二、特性

上一节中已经介绍过数组了,可以将数组和链表在结构上进行对比:

image-20201003201157816

  • 在数组中,是需要一块连续且足够存储所有数据的内存空间。如果你的数据大小是 1MB,但是你的内存空间中可能只有两个大内存块,且大小分别为 512 KB,还有若干小内存块,那么这块数据是不能存储在内存中的
  • 在链表中,是通过 next 指针将离散的内存空间全部利用起来,例如上面的情况中,就可以将这 1MB 大小的数据全部装入内存中

注:在实际的应用层开发中,是不用在乎那一点的空间换时间;但是在底层开发中,这种对空间要求非常高的开发中,就要考虑到多方面了

对比:

时间复杂度 数组 链表
查询 O(1) O(n)
插入、删除 O(n) O(1)

三、几种常规链表

3.1 单链表

image-20201003214901489

  • 指向 w 的结点的结点称为头结点
  • 指向 NULL 的结点 g 称为尾结点
  • 每一个结点需要额外的空间存储下一个结点的地址

3.2 循环链表

image-20201003214213008

  • 不仅具有单链表的特点,且加上它的尾结点指向的不是 NULL,而是指向了头结点
  • 相比单链表的优点:可以通过尾结点找到头结点

3.3 双向链表

image-20201003214231020

  • 每一个结点除了需要额外的空间存储下一个结点的地址,还需要额外的空间指向前一个结点的地址
  • 相比单链表的优点:可以通过任一结点可以找到上一个结点和下一个结点

3.4 双向循环链表

image-20201003214247489

  • 不仅具有双向链表的特点,且加上头结点的 pre 指针指向尾结点,尾结点的 next 指针指向头结点
  • 相比双向链表的优点:可以通过头结点找到尾结点,也可以通过尾结点找到头结点

四、链表的基操和注意事项

这里对于链表的操作只在单链表中进行,其余的链表同理:

image-20201003221950242

前提:在 a 结点后面增加一个 x 结点,p 指向 a 结点

  • 第一步:x –> next = p –> next
  • 第二步:p –> next = x

注:

此时就要注意 “链不断” 理论:在上面的例子中就是,第一步和第二步的顺序不能颠倒,不然就会出现下面这种情况:

  • 第一步:p –> next = x
  • 第二步:x –> next = p –> next

如果 p –> next 指向了 x,那么在第二步中,x –> next 指向了自己

image-20201003223434117

删除操作的话,就只需要:p –> next = p –> next –> next 即可

注:删除结点要记得释放内存,除了一些自带垃圾回收的语言外,例如 Java、python、Go等

哨兵模式

既然在链表中出现了这个模式,那必然是有运用的场景的,看个例子:

在上面单链表中增加一个结点时

image-20201004085304248

1
2
x --> next = p --> next
p --> next = x

如果出现下面几种情况:

  • 空链表,插入第一个结点
  • 删除最后一个结点
  • 又或是 p 指针指向的是两个结点的前一个结点 (在上面的例子中是 a),但是如果出现要插入的是头结点前面 (也就是说没有前面那个结点了) 呢?

image-20201004090746697

此时就可以发现,p 指针无处可指,这时候就可以引入”哨兵”了。(虽然在双向链表中可以轻易解决这个问题)

image-20201004100823076

也就是说不管在什么情况下,都有一个哨兵的存在,其主要目的就是简化代码,不需要考虑带很多情况,可以看一下下面的例子:

首先需要的前提就是:head 指针是永远指向哨兵的

  • 空链表中插入第一个结点,就可以:
1
2
3
if (head -> next == null) {
p = head;
}

只需要判断哨兵指针的 next 是否为空,如果为空的的话,只需要将 p = head,就可以进行和上面一样的插入代码了

四、Java中的链表

​ 首先来回忆一下在 Java 中对数组的操作,直接分配一块连续的内存给数组,这种情况的不好之处就是需要连续且内存足够的空间;随后又提出了 Java 中动态数组 ArrayList ,这个类的确可以进行动态的自动扩容,但是它不能进行自动缩容 (注:ArrayList 是支持手动扩容、缩容),而且它的扩容是将原来所有的数据复制一份到已经进行扩容到原来 1.5 倍的新数组中,当数据量过大时,这种操作可能就需要大量时间来进行了

​ 而在这一小节中,可以看一下,在 Java 中链表类:LinkedList,它的底层是基于双向链表的,日常开发很少使用

首先看一下,继承关系:

image-20201004123937678

可以和上一节 ArrayList 类的继承图进行对比,可以发现:

  • 少了:LinkedList 少实现了 RandomAccess 接口,具体看参考RandomAccess 这个空架子有何用?
  • 改了:ArrayList 是继承了 AbstractList,而 LinkedList 继承了 AbstractSequentialList (它是 AbstractList 的子类)
  • 多了:LinkedList 还实现了 Deque 接口 ====> 因为实现 Deque 即可以作为队列使用,也可以作为栈使用。当然,作为双端队列,也是可以的。

由于源码过多,此内容将放在 JDK 源码分析系列中。

评论