链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
单向链表
动态数组在扩容时,如果新扩出来的空间没有使用到,就造成了内存空间的大量浪费。而链表则是用到多少就申请多少内存空间。
线性表的链式存储表示的特点是用一组任意的存储单元存储线性表的数据元素。这组存储单元可以是连续的,也可以是不连续的。
链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
自定义链表
链表的设计
定义 size 记录链表的大小,定义 firt 指向第一个节点(头结点)。每个节点里定义 element 存储数据,定义 next 指向下一个节点(存储下一个结点地址)。
接口设计
创建一个接口类 List,用来定义 ArrayList 和 LinkedList 的公共接口(右键 -> new -> Interface):
创建一个抽象类 AbstractList,实现 ArrayList 和 LinkedList 有相同实现的方法:
让 ArrayList 和 LinkedList 继承自 AbstractList:
ps:
实现 List 接口的快捷方式:
clear
|
|
图解:
get/set
定义 node(int index)
方法,查找指定 index 处的 node 并返回:
add
添加新的 newNode:
index == 1
要添加一个 node 到 index == 1 的位置,只需要找到 index - 1 处的 prev,那么 index == 1 处就是 prev.next。让新添加的 newNode 的 next 指向 prev.next,再让 prev 的 next 指针指向 newNode:
index == size
要添加一个 node 到 index == size 的位置,只需要找到 index - 1 处的 prev,那么 index == size 处就是 prev.next(null)。让新添加的 newNode 的 next 指向 prev.next,再让 prev 的 next 指针指向 newNode:
index == 0
要添加一个 node 到 index == 0 的位置,只需要让新添加的 newNode 的 next 指向 first,再让 first 指向 newNode:
代码实现
|
|
remove
- 要删除 index == 1 处的 node,只需要找到 index - 1 出的 prev,让
prev.next = prev.next.next
。 - 要删除 index == 0 处的 node,只需要让
first = first.next
。1234567891011121314public E remove(int index) {rangeCheck(index);Node<E> node = first;if (index == 0) {first = first.next;} else {Node<E> prev = node(index - 1);node = prev.next;prev.next = node.next;}size--;return node.element;}
图解:
indexOf
|
|
ps:VisuAlgo 以动画的方式展示了数据结构基本操作的原理,还有代码实现。
练习
链表的题目会用到一个 ListNode 节点类,所以先创建一个节点类:
删除链表中的节点
基本操作:
- 以题目的类型和题目名称分别创建“链表”包和“_237_删除链表中的节点”文件;
- 保存改题目的连接:237. 删除链表中的节点,方便以后查看;
- 拷贝网页中的条件及题目;
分析:删除指定节点 node,可以将 node 下一个节点的内容覆盖掉 node 的内容,让后将 node 的下一个节点删除掉 node.next = node.next.next
:
图解:
反转一个链表
递归
[5, 4, 3, 2, 1] 传入4,reverseList(4) -> [1, 2, 3, 4]:
代码实现:
迭代
newHead, head -> [5, 4, 3, 2, 1]
newHead -> [5], head -> [4, 3, 2, 1]
newHead -> [5, 4], head -> [3, 2, 1]
…
代码实现:
判断一个链表是否有环
141. 环形链表
快慢指针:slow 移动1个元素/次,fast 移动2个元素/次,当 slow 和 fast 相遇的时候,说明链表有环。
代码实现:
移除链表元素
203. 移除链表元素
删除排序链表中的重复元素
83. 删除排序链表中的重复元素
链表的中间节点
876. 链表的中间结点
虚拟结点
在链表的最前面增加一个虚拟头结点(不存储数据),可以统一所有节点的处理逻辑,让代码更加精简:
复杂度分析
动态数组
add(E element)
最好情况复杂度:在 index == size
处添加元素,不需要移动任何元素,复杂度 O(1);
最坏情况复杂度:在 index == size
处添加元素时容量不够需要扩容,所有元素都要移动到新扩容的数组里,复杂度 O(n);
平均情况复杂度:所有情况的移动次数相加再除以元素个数:(1 + 1 + 1 + ... + 1 + n) / n
-> O(2n/2),复杂度 O(1);
均摊复杂度:O(1)。
均摊复杂度:假设最大容量是4,扩容相当于每次 add 的操作次数是 2,也就是O(1)复杂度:
在经过连续的多次复杂度比较低的情况后,出现个别复杂度比较高的情况时,适合均摊复杂度。
add(int index, E element)
最好情况复杂度:在 index == size
处添加元素,不需要移动任何元素,复杂度 O(1);
最坏情况复杂度:在 index == 0
处添加元素,所有元素都要向后移动一位,复杂度 O(n);
平均情况复杂度:所有情况的移动次数相加再除以元素个数:(1 + 2 + 3 + ... + n) / n
-> O(n/2),复杂度 O(n)。
remove
最好情况复杂度:删除 index == size - 1
处的元素,不需要移动任何元素,复杂度 O(1);
最坏情况复杂度:删除 index == 0
处的元素,所有元素都要向前移动一位,复杂度 O(n);
平均情况复杂度:所有情况的移动次数相加再除以元素个数:(1 + 2 + 3 + ... + n) / n
-> O(n/2),复杂度 O(n)。
set & get
数组的随机访问速度非常快,elements[n]
的效率与 n 是多少无关。
单向链表
add
最好情况复杂度:在 index == 0
处添加元素,只需要创建一个新的节点,并让 first 指向该节点,复杂度 O(1);
最坏情况复杂度:在 index == size - 1
处添加元素,需要遍历所有节点,才能找到最后一个节点,并让最后一个节点指向新创建的节点,复杂度 O(n);
平均情况复杂度:所有情况的移动次数相加再除以元素个数:(1 + 2 + 3 + ... + n) / n
-> O(n/2),复杂度 O(n)。
remove
最好情况复杂度:删除 index == 0
处的元素,只需要找到 first,并让 first 指向该节点的下一个节点,复杂度 O(1);
最坏情况复杂度:删除 index == size - 1
处的元素,需要遍历所有节点,才能找到倒数第二个节点,并让倒数第二个节点指向 null,复杂度 O(n);
平均情况复杂度:所有情况的移动次数相加再除以元素个数:(1 + 2 + 3 + ... + n) / n
-> O(n/2),复杂度 O(n)。
set & get
最好情况复杂度:查找 index == 0
处的元素,不需要遍历,直接通过 first.next
就可以找到第一个节点,复杂度 O(1);
最坏情况复杂度:查找 index == size - 1
处的元素,需要遍历所有节点,才能找到最后一个节点,复杂度 O(n);
平均情况复杂度:所有情况的移动次数相加再除以元素个数:(1 + 2 + 3 + ... + n) / n
-> O(n/2),复杂度 O(n)。
双向链表
双向链表的代码实现,是在单向链表的代码基础上进行以下修改👇。
set&get
只需要修改 node()
方法即可:
add
|
|
在 0 < index < size
处添加元素:
在 index == 0
处添加元素:
在 index == size
处添加元素,分为首次添加元素和非首次添加元素:
首次添加元素:
非首次添加元素:
remove
|
|
删除 0 < index < size
处的元素:
删除 index == 0
处的元素:
测试
修改 LinkedList 的 toString()
方法:
在 class Node<E>
里重写 toString()
方法:
在 Main.java 里添加测试代码:
打印结果:
双向链表 vs 单向链表
单向链表:1 + 2 + 3 + ... + n = (1 + n) * n/2 = n/2 + (n^2)/2
,除以 n 平均一下是 1/2 + n/2
;
双向链表:(1 + 2 + 3 + ... + n/2) * 2 = ((1 + n/2) * n/2)/2 * 2 = n/2 + (n^2)/4
,除以 n 平均一下是 1/2 + n/4
。
双向链表 vs 动态数组
动态数组:开辟、销毁内存空间的次数相对较少,但可能造成内存空间浪费(可以通过缩容解决);
双向链表:开辟、销毁内存空间的次数相对较多,但不会造成内存空间的浪费。
源码分析
成员变量
|
|
add
|
|
remove
|
|
set&get
|
|
单向循环链表
单向循环链表中只有一个元素的情况:
在单向链表的代码基础上进行修改,需要修改的有 add()
和 remove()
方法。
add
单向循环链表的添加,主要是在 index == 0
处的逻辑需要修改:
- 让最后面节点的 next 指向新添加的节点;
- 让 first 指向新添加的节点;123456789101112131415public void add(int index, E element) {rangeCheckForAdd(index);if (index == 0) {Node<E> newFirst = new Node<>(element, first);// 拿到最后一个节点Node<E> last = (size == 0) ? newFirst : node(size - 1);last.next = newFirst;first = newFirst;} else {Node<E> prev = node(index - 1);prev.next = new Node<>(element, prev.next);}size++;}
remove
单向循环链表的删除,主要是在 index == 0
处的逻辑需要修改:
- 如果链表中只有一个节点,直接让
first = null
; - 让 first 指向
index == 1
处的元素; - 让 last 指向
index == 1
处的元素(last.next = first
);1234567891011121314151617181920public E remove(int index) {rangeCheck(index);Node<E> node = first;if (index == 0) {if (size == 1) {first = null;} else {Node<E> last = node(size - 1); // 必须在 first = first.next 前获取first = first.next;last.next = first;}} else {Node<E> prev = node(index - 1);node = prev.next;prev.next = node.next;}size--;return node.element;}
双向循环链表
在双向链表的代码基础上进行修改,需要修改的有 add()
和 remove()
方法。
add
|
|
双向循环链表中只有一个节点的情况:
remove
|
|
练习 - 约瑟夫问题
从1开始数,数到三就去掉:
为了发挥循环链表的最大威力,增设1个成员变量、3个方法:
current
:用于指向某个节点;void reset()
:让current
指向头结点first
;E next()
:让current
往后走一步,也就是current = current.next
;E remove()
:删除current
指向的节点,删除成功后让current
指向下一个节点;
相关代码:
测试:
打印结果:
静态链表
单向链表、双向链表、单向循环链表和双向循环链表,都是依赖于指针实现的。在早期的编程语言中,有些是没有指针的,比如 BASIC、FORTRAN 语言。它们通过数组来模拟链表,成为静态链表。
在一个数组中,每个元素存放两个数据,一个是数值,一个是下一个元素的索引。比如第一个元素是的值是 11,下一个元素是索引为 3 的元素(44):
相当于下面这个链表:
- 思考:如果数组的每个元素只能存放1个数据呢?
使用2个数组,一个数组存放索引关系,另一个数组存放值。
动态数组优化
定义变量 int first
,标记首元素的索引。如获取 index 的元素可以通过 first + index % elements.length
对应元素的坐标。
remove
删除首元素
当前数组 first == 0
:
删除首元素,让 first = 1
:
删除首元素,让 first = 2
:
删除中间元素
当前数组 first == 0
:
删除1处的元素,让0处的元素向后移动一位,覆盖掉1处的元素,并将0处的元素清空,最后让 first = 1
:
代码实现:
add
在末尾添加
当前数组 first == 2
:
在数组末尾添加,此时不需要修改 first, first = 2
:
在头部添加
在数组的头部添加元素,让 first = 1
:
在数组的头部添加元素,让 first = 0
:
在数组的头部添加元素,让 first = 8
:
在中间添加
当前数组 first == 1
:
在 3 处插入99,只需要让1、2处的元素往前移动一位,让 first = 0
:
代买实现:
set&get
通过 first + index % elements.length
对应元素的坐标,然后获取元素:
扩容
当数组装满后,新创建一个容量扩大1.5倍的数组,将原数组里的元素移动到新数组里,从0开始填充,first = 0
。
缩容
当数组满足缩容条件时,新创建一个容量按规则缩小的数组,将原数组里的元素移动到新数组里,从0开始填充,first = 0
。
后期优化
|
|