链表
github:https://github.com/iguyi/data-structures-and-algorithms
代码持续更新中
概述
链表是一种线性数据结构,在内存上是非连续的,由若干个节点(结点)组成。每个节点都有两个部分:数据域和指针域。
链表可以分为 3 类:单向链表、双向链表和有环链表。这三类的链表的数据都是存储在数据域中的,不同之处在于对指针域的处理方式。
单向链表
单向链表结构
单向链表的指针域只存储下一个节点的引用。
在单向链表中:
- 如果节点的引用不被任何节点的指针域存储,则表示这个节点是头结点(单向链表中的第一个节点)。
- 如果节点中的指针域没有存储任何对象的引用,则表示这个节点是尾节点(单向链表中的最后一个节点)。
如果有了一个单向链表,我们使用时,需要先找到链表的头结点。为了能够找到单向链表的头节点,我们需要一个辅助变量来保存头节点的引用,示意图如下:
注意:这里的辅助变量存储的一定是整个头节点,而不是头结点的数据域或者指针域
由于单向链表中的节点中只存储了下一个节点的引用,因此在遍历单向链表时,只能从头节点向尾节点的方向遍历。
实现单向链表
了解了单向链表的结构后,我们来实现一个简单的单向链表。
定义单向链表类
先来定义一个单向链表类 SinglyLinked
,因为链表是由一个一个的节点构成的,因此需要在 SinglyLinked
类中创建节点内部类 Node
。
根据上面的示意图:
- 我们需要在
SinglyLinked
中定义一个用于存储头节点的辅助变量headNode
; - 对于每个节点都需要有数据域
data
和指针域,因此要在内部类Node
中设置数据域和指针域相关的变量:- 数据域:因为我们不知道将来单向链表具体会用来存储什么类型的数据,因此需要使用泛型(
SinglyLinked
和Node
都应该使用泛型)。 - 指针域:单向链表中的指针域只需要用一个变量
next
来存储下一个节点即可。
- 数据域:因为我们不知道将来单向链表具体会用来存储什么类型的数据,因此需要使用泛型(
/**
* 单向链表
*
* @param <T> - 数据域存储的数据类型
*/
class SinglyLinked<T> {
/**
* 头节点
*/
private Node<T> headNode;
/**
* 节点内部类
*
* @param <T> - 数据域存储的数据类型
*/
private static class Node<T> {
/**
* 数据域
*/
private T data;
/**
* 指针域 - 始终执行当前节点的下一个节点
*/
private Node<T> next;
// 构造方法、setter 和 getter 方法自行添加
}
}
这里将内部类
Node
定义为private
是因为使用者只需要关心自己存储的数据是什么类型的,而不需要关心如何创建一个合适的节点来存储数据。
向单向链表中添加节点
现在我们来实现向单向链表中添加数据的功能,在 SinglyLinked
中添加 add()
。
当使用者向单向链表中添加内容时,应该只要给 add()
传递需要存储的数据,而不是自己将节点创建好,然后将数据存入节点后传给单向链表对象。因此,add()
方法应该接受的是要存入数据域中的数据,也就是这样定义:add(T data)
,然后在使用者调用这个方法时,由 add()
创建一个新节点并将 data
存入数据域中。
现在我们来讨论如何将新节点插入单向链表。添加新节点的需要考虑以下情况:
- 在单向链表的什么位置插入新节点?这里我们先使用尾插(在链表最后添加)的方式向单向链表中插入新节点。
- 如果单向链表为空,我们应该将
headNode
指向新节点 - 如果单向链表不为空,那么
headNode
不用动,将最后一个节点的next
指向新节点。
单向链表的尾插法的示意图如下:
链表为空时:
链表不为空时:
思考:当链表不为空时,我们需要遍历整个单向链表来找到尾节点,再将尾节点的 next
指向新节点吗?
如果这样做的话,插入的时间复杂度将是 O(n),效率很低。我们可以在 SinglyLinked
中添加一个辅助变量,这个辅助变量用于记录当前单向链表中尾节点的位置:
private Node<T> tailNode;
添加了记录尾节点的辅助变量后,向单向链表插入新节点的逻辑要做出一点小改动:
- 如果单向链表为空,我们应该将
headNode
和tailNode
都指向新节点 - 如果单向链表不为空,那么
headNode
不用动,将tailNode
指向的节点的next
指向新节点,再将tailNode
指向新节点。
这里给出第 2 点的图解:
具体的代码实现如下:
/**
* 向单向链表的最后添加数据
*
* @param data - 实际数据
*/
public void add(T data) {
// 1. 方法被调用时创建新节点并将数据存入数据域中
Node<T> node = new Node<>(data);
if (headNode == null) { // 链表为空
headNode = node;
} else { // 链表不为空
tailNode.next = node;
}
// 无论链表是否为空, tailNode 都要指向新节点
tailNode = node;
}
获取链表中的数据
这里使用下标的形式来获取链表中的数据,使用者传入一个 index
,然后我们遍历出第 inedx
个节点(头节点对应的 index
为 0),然后将这个节点的数据域返回给使用者。
问题:如果链表中只有 5 个节点,那么 maxIndex = 4
,但是使用者传入的 index
大于 4,循环遍历单向链表会出现 NPE,怎么办?难道要每通过次遍历的时候都判断当前节点的 next
是否指向 null
,如果是,就抛异常?
其实,我们可以通过在 SinglyLinked
添加一个辅助变量 size
来记录当前单向链表的长度(节点数),如果使用者传入的 index
大于或等于 size
,就直接抛异常。
private int size;
此时,需要在 add()
方法的最后添加一行代码
// 链表插入新节点后,链表节点的大小一定要增加的
size++;
获取链表的数据的代码实现如下:
/**
* 获取链表指定索引的节点中存储的数据
*
* @param index - 目标元素的索引位置
* @return 数据
*/
public T get(int index) {
return getNode(index).data;
}
/**
* 获取链表指定索引的节点
*
* @param index - 目标元素的索引位置
* @return 节点
*/
private Node<T> getNode(int index) {
if (index >= size) {
throw new IndexOutOfBoundsException();
}
Node<T> currentNode = headNode;
for (int i = 1; i <= index; i++) {
currentNode = currentNode.next;
}
return currentNode;
}
这里之所以不将
getNode()
中的逻辑直接写在get()
方法中,原因是后面的删除单向链表中也要用到这段代码。
删除单向链表中的节点
假设现在有一个单向链表:
如果需要将 B 节点删除,那么我们只需要将 A 节点的 next
指向 C 节点即可:
C 节点的引用在 B 节点的
next
中可以获取到。
由于 B 节点不被任何内容引用,其会被 GC 回收,不需要 B 节点进行额外的操作。
现在再来看看需要注意的点:
- 如果被删除节点是头节点时,只需要将
headNode
指向第二个节点即可。 - 如果当前链表中只有一个节点并要删除这个节点时,只需要将
headNode
和tailNode
的值都设置为null
即可。 - 如果删除节点不是第一个节点,实际要获取到两个变量:**被删除节点 **和 被删除节点的上一个节点。因为我们需要将 **被删除节点的上一个节点 **的
next
指向 被删除节点 的 下一个节点(可以从 **被删除节点 **的next
得到)。 - 如果被删除节点是尾节点时,
tailNode
要前移,也就是指向被删除节点的上一个节点。 - 成功删除链表节点后,链表大小要更新,即
size
要减一。
这里仍然是通过下标的方式来确定需要删除的节点,具体代码实现如下:
/**
* 删除链表指定索引的节点并返回节点中存储的数据
*
* @param index - 目标节点的索引位置
* @return 被删除节点中存储的数据
*/
public T remove(int index) {
Node<T> node = getNode(index); // 获取被删除节点
Node<T> lastNode = getNode(index - 1); // 删除节点的上一个节点
lastNode.next = node.next;
if (node == tailNode) { // 被删除节点是最后一个节点时
tailNode = lastNode;
}
size--;
return node.data;
}
修改单向链表中的数据
这里有两种方式:
1)一种方式是通过遍历找到对应的节点,然后将目标节点的 data
修改为新的数据**(推荐)**
public void update(int index, T data) {
if (index >= size) {
throw new IndexOutOfBoundsException();
}
Node current = head;
while(index-- != 0) { // 如果 index 减到 0, 就找到对应的节点了
current = current.next;
}
current.data = data;
}
2)另一种方式是创建一个新的节点,然后通过遍历找到对应的节点,然后用新节点替换旧节点。实现过程是先找到对应的节点,将其从链表中删除,然后将新节点插入到对应位置。这种方式这里不做实现。
双向链表
双向链表结构
双向链表与单向链表的区别在于指针域。
在双向链表中,指针域中存在两个指针:
- 后指针:指向下一个节点。如果后指针指向的是
null
,表示当前节点是尾节点。 - 前指针:指向上一个节点。如果前指针指向的是
null
,表示当前节点是头结点。
双向链表结构图:
因为双向链表可以拿到当前节点的上一个节点和下一个节点,因此遍历方式可以从头节点向尾节点遍历,也可以从尾节点向头节点遍历。
实现双向链表
定义双向链表
先来定义一个双向链表类 DoublyLinked
,和单向链表一样,需要在 DoublyLinkeded
类中创建节点内部类 Node
。
根据上面的示意图:
- 我们需要在
DoublyLinked
中定义一个用于存储头节点的辅助变量headNode
和用于存储尾节点的辅助变量tailNode
。 - 对于每个节点都需要有数据域
data
和指针域,因此要在内部类Node
中设置数据域和指针域相关的变量:- 数据域:因为我们不知道将来单向链表具体会用来存储什么类型的数据,因此需要使用泛型(
DoublyLinked
和Node
都应该使用泛型)。 - 指针域中需要辅助变量:
last
来存储上一个节点;next
来存储下一个节点;
- 数据域:因为我们不知道将来单向链表具体会用来存储什么类型的数据,因此需要使用泛型(
/**
* 双向链表
*
* @param <T> - 数据域存储的数据类型
*/
class DoublyLinked<T> {
/**
* 头结点
*/
private Node<T> headNode;
/**
* 尾节点
*/
private Node<T> tailNode;
/**
* 双向链表大小
*/
private int size;
/**
* 节点
*
* @param <T> - 数据域存储的数据类型
*/
private static class Node<T> {
/**
* 存储的数据
*/
private T data;
/**
* 上一个节点
*/
private Node<T> last;
/**
* 下一个节点
*/
private Node<T> next;
public Node(T data) {
this.data = data;
}
}
}
向双向链表中添加节点
现在我们来实现向双向链表中添加数据的功能,在 DoublyLinked
中添加 add()
。
和单向链表一样,add()
方法的参数是要存入节点数据域中的数据。
这里我们仍然使用尾插法来向双向链表中添加新节点,具体做法整体上和单向链表的处理方式差不多,双向链表的尾插法的示意图如下:
代码实现
/**
* 向双向链表尾部添加元素
*
* @param data - 实际数据
*/
public void add(T data) {
Node<T> newNode = new Node<>(data);
if (headNode == null) {
headNode = newNode;
} else {
tailNode.next = newNode;
newNode.last = tailNode;
}
tailNode = newNode;
size++;
}
/**
* 获取链表指定索引的节点
*
* @param index - 目标元素的索引位置
* @return 数据
*/
private Node<T> getNode(int index) {
if (index >= size) {
throw new IndexOutOfBoundsException();
}
Node<T> target = headNode;
for (int i = 1; i <= index; i++) {
target = target.next;
}
return target;
}
删除双向链表中的节点
假设有如下双向链表:
现在要删除其第二个节点,那么我们需要将第一个节点的 netx
指向第三个节点,然后将第三个节点的 last
指向第一个节点:
需要注意的点:
- 当删除的是第一个节点时,我们除了要将
heaNode
指向第二个节点,还要将第二个节点的last
指向null
- 当删除的是最后一个节点时,我们除了要将
tailNode
指向倒数第二个节点,还要将倒数第二个节点的next
指向null
- 由于可以通过当前节点获取到其上一个节点和下一个节点,因此我们没必要刻意记录当前节点的上一个节点,可以等到变量到要删除的节点时,通过
last
获取。
具体代码实现:
/**
* 删除链表指定索引的节点并返回节点中存储的数据
*
* @param index - 目标节点的索引位置
* @return 被删除节点中存储的数据
*/
public T remove(int index) {
if (index >= size) {
throw new IndexOutOfBoundsException();
}
Node<T> removeNode = headNode;
if (headNode == tailNode) {
headNode = null;
tailNode = null;
} else {
removeNode = getNode(index);
Node<T> last = removeNode.last;
last.next = removeNode.next;
Node<T> next = removeNode.next;
if (next != null) {
next.last = removeNode.last;
}
}
size = (size == 0) ? 0 : (size - 1);
return removeNode.data;
}
获取或者修改双向链表中的数据
做法和单向链表是完全一致的这里不做讨论。
有环链表
有环链表是指链表的 “尾节点” 的 next
指向了当前链表的其他节点或者自己:
注意这里的 “尾节点” 是有引号的,因为在有环链表中不存在真正的尾巴。
当尾节点的的 next
指向的是 “头节点” 时,会形成循环链表:
对于双向链表而言:
- “头节点” 的
last
指向 “尾节点”,- “尾节点” 的
next
指向 “头节点”。
对于环形链表,我们需要能够代码来判断出一个链表是否是有环的。
这里推荐两道 LeetCode 的题目: