本篇文章的主要内容包括:
-
为什么会有链表
-
链表的实现
-
链表的常见操作
-
- 链表新增节点
- 链表删除节点
- 链表技巧:虚拟节点(哨兵节点)
- 链表的查询和修改
-
双向链表
-
- 双向链表的优缺点
- 双向链表的实现
为什么会有链表
我们前面讲解了数组,知道了数组具有如下两个特点:
- 数组是一个线性表结构
- 数组用一组连续的内存空间,存储相同类型的数据
因为数组需要申请连续的内存空间,所以:
- 数组一旦初始化,它的长度就不能变了,从这里也可以看出数组是一种静态数据结构
- 如果内存中没有足够的连续内存空间的话,那么数组的初始化将会失败
比如,假设堆内存空间的使用情况如下:
上图中,各种各样的红色块块表示已经占用的内存空间。这个时候我们需要初始化一个大小为 10 M 的数组,因为数组占用的存储空间是连续的,所以,初始化后的结果如下:
可以看出,因为堆内存中有足够的连续内存空间,所以数组的初始化可以成功。
但是如果申请的空间不是 10M ,而是 100M 的内存空间,因为堆内存中没有足够的连续空间,所以这次数组的初始化是会失败的。
从上图可以看出,虽然没有足够的连续内存空间,但是非连续的剩余内存空间的大小加起来还是足够的,现在的问题就变成了,能不能设计一种数据结构,能合理的充分的利用非连续的内存空间呢?
要解决这个问题,我们还是得回到造成这个问题的原因上,因为数组是需要连续的内存空间,所以导致对非连续内存空间的使用不够充分
那么我们能不能设计一种数据结构,这种数据结构的数据都是存储在非连续的内存空间上呢?
比如,如下图:
我们将数据存储在上图中的非连续的内存空间 (绿色块块表示的内存空间),这样就可以充分利用连续的内存空间了。
但是这样带来的问题是:我们怎样去访问分布在非连续内存空间的数据呢?我们可以通过内存地址来解决这个问题。
每块内存空间除了存储数据外,还需要记录下一块内存空间的地址,如下图:
这样,我们就可以通过栈内存中的第一块内存地址来访问第一块内存空间的数据
然后通过第一块内存空间存储的第二块内存空间地址来访问第二块内存空间的数据,以此类推,我们就可以访问到所有分散在非连续内存空间的数据了。
这样就解决了非连续内存空间没有充分利用的问题了,这个时候即使没有足够的连续内存空间,只要你整个没有使用的内存空间足够的话,我们也是可以存储指定的数据的。
这种数据结构,我们就称为链表。相比于数组,链表具有如下的特点:
- 链表也是一个线性表结构。
- 链表不需要连续的内存空间,所以链表可以充分的利用内存空间。
因为链表不需要连续的内存空间,所以:
- 链表初始化完后,它的长度是可以动态的改变的
- 我们可以在链表中动态的插入其他数据的,所以说链表是一种动态数据结构
综上可以看出,链表相对数组的两大优点:
- 链表天然的支持动态扩容,而数组的话需要进行 resize 才能达到扩容的功能
- 链表可以充分的利用非连续的内存空间,提高内存的利用率。
但是链表相对于数组也有一个缺点:
- 链表的每块内存空间除了需要存储数据外,还需要存储下一块内存空间的地址,这样使得每块内存空间的使用大小变大
- 而数组的每个元素就只存储值,没有额外的内存开销。所以说链表比数组耗内存。
数组和链表都是数据结构中最基本的数据结构,到底使用数组还是链表,需要从性能、内存等多方面来考察,选择最合适的数据结构。
链表的实现
前面我们讲解了链表的结构,如下图:
我们可以使用节点来表示上图中链表的每块内存空间,节点中不仅存储数据,而且存储下一个节点的地址,这个地址,如果是 Java 的话,就使用引用来表示,如果是 C 语言的话,就是用指针来表示。
我们这里使用 Java 来实现,所以这个节点我们可以抽象成如下的类:
class Node {
E e;
Node next;
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
public Node(E e) {
this(e, null);
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return e.toString();
}
}
对上面代码的解释:
- 上面的类 Node 就表示链表中的一个节点
- Node 中的 e 表示需要存储的数据,链表需要支持存储任意类型的数据,所以这里需要使用泛型
- Node 中的 next 表示当前节点下一个节点的引用,用于遍历链表的。
有了 Node 的概念后,我们看看下图的链表表示:
上面的链表中,我们可以发现,其中有两个节点是比较特殊的,它们分别是第一个节点和最后一个节点,我们一般称第一个节点叫做头节点,把最后一个节点叫做尾节点
其中,头节点用来记录链表的基地址,有了它,我们就可以遍历得到整条链表。而尾节点特殊的地方是:指针不是指向下一个节点,而是指向一个空地址 null,表示这是链表上的最后一个节点
接下来,我们实现一个链表类,这个链表类的名字称为 LinkedList,需要支持泛型。
对于一个链表来说,除了需要 Node 类,还需要两个成员变量:
- 记录这个链表的头节点,用于遍历访问链表,这个头节点我们可以作为链表的成员变量
- 记录这个链表存储元素的个数,也就是链表的长度,我们可以使用成员变量 size 来记录
包含成员变量的 LinkedList 代码实现如下:
public class LinkedList<E> {
private class Node {
E e;
Node next;
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
public Node(E e) {
this(e, null);
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return e.toString();
}
}
// 记录聊表的头结点
private Node head;
// 记录链表的长度
private int size;
public LinkedList() {
head = null;
size = 0;
}
}
接下来我们分别来实现链表的 CRUD,即增加、查询、修改以及删除操作。
链表的常见操作
一:链表增加节点
按照增加节点的位置,可以将增加节点分为三个场景:
- 在链表头位置进行增加节点
- 在链表中间进行增加节点
- 在链表尾巴进行增加节点
接下来,我们分别来看看三个场景的代码实现。
场景一:在链表头位置进行增加节点
思路:
- 创建一个新的节点
- 将新的节点的 next 指向头结点
- 将头结点指向新的节点
- 维护好 size
代码如下:
public void addFirst(E e) {
// 创建一个新节点,这个节点的 next 指向 head
// 然后将新节点赋值给 head
head = new Node(e, head);
size++;
}
场景二:在链表中间进行增加节点
在链表中实际上是没有索引的概念的,但是为了方便我们表达,我们在链表中也是用索引来表达链表中的第几个节点,比如索引为 0 表示的是第一个节点,索引为 4 表示的是第 5 个节点,如下图:
现在我们想往上面的链表中索引为 2 的位置插入一个值为 22 的节点,思路如下:
- 创建新的节点 node
- 找到要插入节点位置的前一个节点 prev ,即需要找到索引为 1 的节点作为 prev 节点
- 将 node 的 next 指向 prev 的 next 节点
- 将 prev 的 next 指向 node
- 维护好 size 属性
代码如下:
/**
* 在指定索引的位置插入新的节点
* @param index 需要插入的位置
* @param e 需要插入的数据
*/
public void add(int index, E e) {
// 检查 index 的合法性
if (index < 0 || index > size)
throw new IllegalArgumentException("add failed, index must >= 0 and <= size");
// 如果是在表头插入节点的话,需要做特殊处理,因为头结点的 prev 是为空的、
if (index == 0) {
addFirst(e);
} else {
Node prev = head;
// 找到 index 的前一个节点
for (int i = 0; i < index - 1; i++) {
prev = prev.next;
}
Node node = new Node(e);
node.next = prev.next;
prev.next = node;
// 以上 3 行代码可以简写成:prev.next = new Node(e, prev.next);
size++;
}
}
场景三:在链表尾巴进行增加节点
有了前面代码的铺垫,这个功能就很好实现了,可以直接调用 add 方法了:
/**
* 在链表的末尾添加一个元素
* @param e
*/
public void addLast(E e) {
add(size, e);
}
二:链表删除节点
删除链表节点有三个场景:
- 删除表头节点
- 删除链表中间节点
- 删除链表尾巴节点
我们分别来讲解。
场景一:删除表头节点
思路:
- 先将头节点临时存储在 delNode 中
- 将 head 指向 head.next 节点
- 将 delNode 从链表中断掉
- 维护 size
代码如下:
/**
* 删除链表的头节点
* @return
*/
public E removeFirst() {
if (head == null) return null;
Node delNode = head;
head = head.next;
delNode.next = null;
size--;
return delNode.e;
}
场景二:删除链表中间节点
思路:
- 找到待删除节点的前一个节点 prev
- 临时存储待删除的节点 Node delNode = prev.next
- 将待删除节点的前一个节点的 next 指向删除节点的 next
- 将删除节点从链表中断掉连接
- 维护 size
代码如下:
/**
* 删除指定索引的节点,并返回删除的节点的值
* @param index
* @return
*/
public E remove(int index) {
// 检查 index 的合法性
if (index < 0 || index >= size)
throw new IllegalArgumentException("remove failed, index must >= 0 and < size");
// 表头的节点因为没有 prev 节点,所以需要特殊考虑
if (index == 0) {
return removeFirst();
} else {
Node prev = head;
for (int i = 0; i < index - 1; i++) {
prev = prev.next;
}
Node removeNode = prev.next;
prev.next = removeNode.next;
removeNode.next = null;
size--;
return removeNode.e;
}
}
场景三:删除链表尾巴节点
/**
* 删除链表尾巴节点
* @return
*/
public E removeLast() {
return remove(size - 1);
}
三:链表技巧:虚拟节点(哨兵节点)
在实现链表的 add 和 remove 逻辑中,都需要对头结点进行特殊处理,这或多或少还是给编程带来了复杂度
那么我们有没有方法使得处理头结点和处理其他节点的逻辑一样呢?也就是说在逻辑上统一头结点和其他节点的处理
要统一处理头结点和其他节点的逻辑,我们先必须弄清楚是什么导致它们的逻辑不一致的。
我们知道不管是增加和删除节点,我们都需要先找到新增节点或者删除节点位置的前一个节点,但是对于头结点来说,它是没有前一个节点的,这个就是导致头节点和其他节点处理逻辑不一致的原因了。
那么,现在为了统一头节点和其他节点的处理逻辑,我们可以在头节点前设置一个虚拟节点 ,这个虚拟节点没有任何含义,也不存储有意义的值,只是在链表头之前占一个位置而已,这个虚拟节点的 next 需要指向链表的真正的头结点,如下:
这个虚拟的头节点表示成 dummyNode,有的时候这个虚拟节点也叫做哨兵节点。
有了虚拟节点后,之前链表的头结点就和其他的普通节点一样了,这样就可以统一头结点和其他节点的处理逻辑了。具体的实现需要分几步来完成:
- 将之前的 head 变量修改为 dummyNode,并且在构造方法中进行初始化,如下代码:
// 虚拟节点
private Node dummyNode;
public LinkedList() {
dummyNode = new Node(null);
size = 0;
}
- 修改 add 方法,去掉头结点的特殊处理,统一处理逻辑,如下:
/**
* 在指定索引的位置插入新的节点
* @param index 需要插入的位置
* @param e 需要插入的数据
*/
// 时间复杂度:O(n)
public void add(int index, E e) {
// 检查 index 的合法性
if (index < 0 || index > size)
throw new IllegalArgumentException("add failed, index must >= 0 and <= size");
// 从虚拟节点开始遍历,因为虚拟机点不是空的
// 所以就可以保证头结点肯定是有前一个节点的
Node prev = dummyNode;
// 找到 index 的前一个节点,这里需要改成 index
for (int i = 0; i < index; i++) {
prev = prev.next;
}
//Node node = new Node(e);
//node.next = prev.next;
//prev.next = node;
prev.next = new Node(e, prev.next);
size++;
}
这个时候 addFirst 的代码也可以修改为:
/**
* 在链表表头新增节点
* @param e 新增节点需要存储的数据
*/
// 时间复杂度:O(1)
public void addFirst(E e) {
add(0, e);
}
- 同样,修改 remove 方法,统一头结点和其他节点的处理逻辑
/**
* 删除指定索引的节点,并返回删除的节点的值
* @param index
* @return
*/
// 时间复杂度:O(n)
public E remove(int index) {
// 检查 index 的合法性
if (index < 0 || index >= size)
throw new IllegalArgumentException("remove failed, index must >= 0 and < size");
Node prev = dummyNode;
for (int i = 0; i < index; i++) {
prev = prev.next;
}
Node removeNode = prev.next;
prev.next = removeNode.next;
removeNode.next = null;
size--;
return removeNode.e;
}
同时也可以修改 removeFirst 的代码:
/**
* 删除链表的头节点
* @return
*/
public E removeFirst() {
return remove(0);
}
现在 add 和 remove 的代码比之前看起来要简洁点,统一了头节点和其他节点的处理逻辑,代码变得更加优雅
四:查询指定索引节点的值
/**
* 查询指定索引的节点的值
* @param index
* @return
*/
// 时间复杂度:O(n)
public E get(int index) {
// 检查 index 的合法性
if (index < 0 || index >= size)
throw new IllegalArgumentException("get failed, index must >= 0 and < size");
Node curr = dummyHead.next;
for (int i = 0; i < index; i++) {
curr = curr.next;
}
return curr.e;
}
// 时间复杂度:O(1)
public E getFirst() {
return get(0);
}
// 时间复杂度:O(n)
public E getLast() {
return get(size - 1);
}
五:修改指定索引节点的值
/**
* 修改指定索引的节点元素
* @param index
* @param e
*/
// 时间复杂度:O(n)
public void set(int index, E e) {
// 检查 index 的合法性
if (index < 0 || index >= size)
throw new IllegalArgumentException("get failed, index must >= 0 and < size");
Node curr = dummyHead.next;
for (int i = 0; i < index; i++) {
curr = curr.next;
}
curr.e = e;
}
六:链表 LinkedList 的完整代码
package com.douma.line.linkedlist;
/**
* @微信公众号 : 抖码课堂
* @官方微信号 : bigdatatang01
* @作者 : 老汤
*/
public class LinkedList<E> {
private class Node {
E e;
Node next;
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
public Node(E e) {
this(e, null);
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return e.toString();
}
}
// 虚拟头节点
private Node dummyHead;
// 长度
private int size;
public LinkedList() {
dummyHead = new Node();
size = 0;
}
public int getSize() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
/**
* 查询指定索引的节点的值
* @param index
* @return
*/
// 时间复杂度:O(n)
public E get(int index) {
// 检查 index 的合法性
if (index < 0 || index >= size)
throw new IllegalArgumentException("get failed, index must >= 0 and < size");
Node curr = dummyHead.next;
for (int i = 0; i < index; i++) {
curr = curr.next;
}
return curr.e;
}
// 时间复杂度:O(1)
public E getFirst() {
return get(0);
}
// 时间复杂度:O(n)
public E getLast() {
return get(size - 1);
}
/**
* 修改指定索引的节点元素
* @param index
* @param e
*/
// 时间复杂度:O(n)
public void set(int index, E e) {
// 检查 index 的合法性
if (index < 0 || index >= size)
throw new IllegalArgumentException("get failed, index must >= 0 and < size");
Node curr = dummyHead.next;
for (int i = 0; i < index; i++) {
curr = curr.next;
}
curr.e = e;
}
/**
* 在链表表头新增节点
* @param e 新增节点需要存储的数据
*/
// 时间复杂度:O(1)
public void addFirst(E e) {
add(0, e);
}
// 时间复杂度:O(n)
public void addLast(E e) {
add(size, e);
}
/**
* 在指定索引的位置插入新的节点
* @param index 需要插入的位置
* @param e 需要插入的数据
*/
// 时间复杂度:O(n)
public void add(int index, E e) {
// 检查 index 的合法性
if (index < 0 || index > size)
throw new IllegalArgumentException("add failed, index must >= 0 and <= size");
Node prev = dummyHead;
for (int i = 0; i < index; i++) {
prev = prev.next;
}
prev.next = new Node(e, prev.next);
size++;
}
/**
* 删除链表的头节点
* @return
*/
// 时间复杂度:O(1)
public E removeFirst() {
return remove(0);
}
// 时间复杂度:O(n)
public E removeLast() {
return remove(size - 1);
}
/**
* 删除指定索引的节点,并返回删除的节点的值
* @param index
* @return
*/
// 时间复杂度:O(n)
public E remove(int index) {
// 检查 index 的合法性
if (index < 0 || index >= size) {
throw new IllegalArgumentException("remove failed, index must >= 0 and < size");
}
Node prev = dummyHead;
for (int i = 0; i < index; i++) {
prev = prev.next;
}
Node delNode = prev.next;
prev.next = delNode.next;
delNode.next = null;
size--;
return delNode.e;
}
public void removeElement(E e) {
if (dummyHead.next == null)
throw new IllegalArgumentException("removeElement failed, LinkedList is Empty");
Node prev = dummyHead;
Node curr = dummyHead.next;
while (curr != null) {
if (curr.e.equals(e)) {
break;
}
prev = curr;
curr = curr.next;
}
prev.next = curr.next;
curr.next = null;
}
/**
* 判断链表中是否存在指定元素
* @param e
* @return
*/
// 时间复杂度:O(n)
public boolean contains(E e) {
Node curr = dummyHead.next;
while (curr != null) {
if (curr.e.equals(e)) {
return true;
}
curr = curr.next;
}
return false;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
Node curr = dummyHead.next;
while (curr != null) {
sb.append(curr + "=>");
curr = curr.next;
}
sb.append("null");
return sb.toString();
}
}
双向链表
前面我们讲的链表的每个节点都要记住下一个节点的引用,如下图:
因为这种链表是单方向的,所以我们称为单向链表。
在实际的开发中,还有一种常用的链表,那就是双向链表。故名思意,双向链表也就是说每个节点不但需要记住后一个节点的引用,还需要记住前一个节点的引用,如下图:
接下来我们先来实现一个双向链表,代码如下:
public class DoubleLinkedList<E> {
private class Node {
E e;
Node prev;
Node next;
public Node(Node prev, E e, Node next) {
this.e = e;
this.next = next;
this.prev = prev;
}
public Node(E e) {
this(null, e, null);
}
}
// 链表的第一个节点
private Node first;
// 链表的最后一个节点
private Node last;
// 链表大小
private int size;
public DoubleLinkedList() {
first = last = null;
size = 0;
}
public int getSize() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
}
以上代码因为实现的是双向链表,所以以下几点需要注意:
- 节点 Node 类中的成员变量,不仅需要记录 next 引用,还需要记录 prev 引用
- DoubleLinkedList 类的成员变量需要记录链表的第一个节点 (first) 以及最后一个节点 (last)
双向链表的优缺点
可以看出,双向链表支持往前和往后遍历两个方向的遍历,虽然比单向链表耗内存,但是一般情况下性能比单向链表的要好。比如我们想查询指定索引的节点,我们可以这样提升性能:\
- 当 index 小于链表长度一半的时候,我们就从链表的第一个节点开始遍历搜索
- 当 index 大于等于链表长度一半的时候,我们就从链表的最后一个节点开始遍历搜素
这样的话,我们在查询一个节点的时候就可以提升一半的性能了。如下图:
接下来我们实现查找指定索引 index 所在的节点的元素值:
/**
* 找到指定索引 index 所在的节点
* @param index
* @return
*/
public E get(int index) {
if (index < 0 || index >= size)
throw new IllegalArgumentException("index failed, index must >= 0 and < size");
// 如果 index 小于链表长度的一半,则从 first 开始遍历查找
if (index < size / 2) {
Node curr = first;
for (int i = 0; i < index; i++)
curr = curr.next;
return curr.e;
} else { // 如果 index 大于等于链表长度的一半,则从 last 开始遍历查找
Node curr = last;
for (int i = 0; i < size - index - 1; i++)
curr = curr.next;
return curr.e;
}
}
双向链表还有一个优点,那就是对表头和表尾的操作的时间复杂度都是 O(1) 级别的,比如可以在 O(1) 的时间复杂度找到头节点和尾节点:
public Node getFirst() {
return first;
}
public Node getLast() {
return last;
}
以下是双向链表的完整代码实现 (带有注释):
package com.douma.line.linkedlist;
import java.util.NoSuchElementException;
/**
* @微信公众号 : 抖码课堂
* @官方微信号 : bigdatatang01
* @作者 : 老汤
*/
public class DoubleLinkedList<E> {
private class Node {
E e;
Node prev;
Node next;
public Node(Node prev, E e, Node next) {
this.e = e;
this.next = next;
this.prev = prev;
}
public Node(E e) {
this(null, e, null);
}
@Override
public String toString() {
return e.toString();
}
}
private Node first; // 头节点
private Node last; // 尾节点
private int size;
public DoubleLinkedList() {
first = last = null;
size = 0;
}
public int getSize() {
return size;
}
public boolean isEmpty() {
return size == 0;
}
/**
* 找到指定索引 index 所在的节点的元素值
* @param index
* @return
*/
// 时间复杂度是 O(n)
public E get(int index) {
Node node = node(index);
if (node == null) {
throw new IllegalArgumentException("index failed, index must >= 0 and < size");
}
return node.e;
}
public Node getFirst() {
return first;
}
public Node getLast() {
return last;
}
// 时间复杂度是 O(n)
private Node node(int index) {
if (index < 0 || index >= size)
return null;
Node curr = null;
// 如果 index 小于链表长度的一半,则从 first 开始遍历查找
if (index < size / 2) {
curr = first;
for (int i = 0; i < index; i++) {
curr = curr.next;
}
} else { // 如果 index 大于等于链表长度的一半,则从 last 开始遍历查找
curr = last;
for (int i = 0; i < size - index - 1; i++) {
curr = curr.prev;
}
}
return curr;
}
// 时间复杂度是 O(n)
public void set(int index, E e) {
// 先找到需要修改的节点
Node node = node(index);
if (node == null) {
throw new IllegalArgumentException("index failed, index must >= 0 and < size");
}
node.e = e;
}
/**
* 往链表的表头插入节点
* @param e
*/
public void addFirst(E e) {
Node newNode = new Node(e);
if (first == null) {
// 如果头节点为空,说明链表中没有一个节点
// 那么新插入的节点既是头节点,又是尾节点
last = newNode;
} else {
// 将新节点作为头节点
newNode.next = first;
first.prev = newNode;
}
first = newNode;
size++;
}
/**
* 往链表尾巴插入新节点
* @param e
*/
public void addLast(E e) {
Node newNode = new Node(e);
if (last == null) {
// 如果尾节点为空,说明链表中没有一个节点
// 那么新插入的节点既是头节点,又是尾节点
first = newNode;
} else {
// 将新节点作为尾节点
newNode.prev = last;
last.next = newNode;
}
last = newNode;
size++;
}
/**
* 往指定索引位置插入节点
* @param index
* @param e
*/
// 时间复杂度是 O(n)
public void add(int index, E e) {
if (index < 0 || index > size)
throw new IllegalArgumentException("add failed, index must >= 0 and <= size");
if (index == size) {
addLast(e);
} else if (index == 0) {
addFirst(e);
} else {
// 1. 找到要插入的位置,并记住这个位置的节点
Node oldNode = node(index);
Node prev = oldNode.prev;
// 2. 新建节点,将它的 next 指向 oldNode,将它的 prev 指向 oldNode.prev
Node newNode = new Node(prev, e, oldNode);
// 3. 将新建节点设置为 oldNode 的 prev
oldNode.prev = newNode;
// 4. 将新建节点设置 oldNode 之前的 prev 的 next
prev.next = newNode;
size++;
}
}
public E removeFirst() {
if (first == null) {
throw new NoSuchElementException();
}
E e = first.e;
// 拿到头节点的下一个节点
Node next = first.next;
// 如果 next 为空,说明整个链表只有一个节点
if (next == null) {
first = null;
last = null;
} else {
// 将头节点从链表中断开
first.next = null;
next.prev = null;
// 将 next 设置为头节点
first = next;
}
size--;
return e;
}
public E removeLast() {
if (last == null) {
throw new NoSuchElementException();
}
E e = last.e;
// 拿到尾节点的前一个节点
Node prev = last.prev;
// 如果 prev 为空,说明整个链表只有一个节点
if (prev == null) {
last = null;
first = null;
} else {
// 将尾节点从链表中断开
last.prev = null;
prev.next = null;
// 将 prev 设置为尾节点
last = prev;
}
size--;
return e;
}
// 时间复杂度是 O(n)
public E remove(int index) {
if (index < 0 || index >= size)
throw new IllegalArgumentException("index failed, index must >= 0 and < size");
if (index == 0) {
return removeFirst();
} else if (index == size - 1) {
return removeLast();
}
// 1. 找到要删除的节点
Node delNode = node(index);
E e = delNode.e;
// 2. 记住要删除节点的 prev 和 next 节点
Node prev = delNode.prev;
Node next = delNode.next;
// 3. 将删除节点的前后节点联系起来
prev.next = next;
next.prev = prev;
// 4. 将删除节点从链表中断开
delNode.next = null;
delNode.prev = null;
size--;
return e;
}
}
一个程序员 5 年内需要的数据结构与算法知识都在这里,系统学习:数据结构与算法