【Java & 数据结构】LinkedList介绍

LinkedList介绍

回顾ArrayList类

学习 ArrayList 类的时候, 我们了解过其底层的数据结构是一个顺序表, 同时我们也了解过顺序表的优缺点. 其中我们提到了以下三个缺点

  1. 插入和删除操作效率较低
  2. 扩容可能会浪费空间
  3. 扩容操作消耗较大

这也使得以顺序表为底层的 ArrayList 类天生就比较不适合经常插入和删除的场景, 为了解决这个问题, Java 的集合框架中就提供了一种底层数据结构更加擅长插入删除的集合类, 这个集合类的名称就是 LinkedList.

那么这个 LinkedList 的底层数据结构是什么呢? 这个数据结构为什么又更加擅长进行插入和删除的操作呢? 这两个问题我们就会在下面学习链表的过程中解答.

链表

链表是什么

链表是 LinkedList 底层的数据结构, 它主要就是通过引用/指针的方式, 将各个存储数据的节点进行连接, 从而形成逻辑上的连续存储.

要了解链表, 首先我们需要了解上面提到的节点是什么, 节点实际上就是一块区域, 它会用于存储数据, 以及用于指向其他节点的引用. 而在 Java 中, 它则体现为一个类, 会有成员用于存储数据, 也会有成员用于存储对象的引用, 用于指向下一个/上一个节点对象, 如下所示.

java 复制代码
class ListNode {
    // 存储数据的区域
    private int value;
	// 存储引用
    private ListNode prev;
    private ListNode next;
}

而链表就是将这样的一个一个节点连起来组成的结构, 并且其的所有节点(除了首尾节点)都只能有一个节点在前和后. 下面就是一个链表的图示

链表的分类

实际上, 链表的结构十分多样, 例如我们上面展示的一个链表就是一个带头单向非循环链表, 同理自然也就有不带头的, 双向的, 循环的链表. 下面依次简单了解以下这些链表的分类

首先是单向或者双向的, 指的是链表的节点的指针是否同时指向前后, 还是只指向后方. 如下图所示


接下来就是是否带头, 指的就是其是否有一个头节点. 如下图所示

此时可能有人要问了, 这两个有什么区别, 我为什么感觉不出来呢?

回答这个问题前, 首先我们需要了解一下这个头节点是干什么的. 这个头节点实际上和寻常的节点没有什么两样, 但是它的数据域, 是不存储真正的数据的, 你可以在这个数据域中存储任意的数据, 例如存储链表的长度, 或者根本不使用这个数据域都是可以的. 它的作用就是用于作为链表的头, 便于链表的操作和管理而设计的.

同时, 当链表为空的时候, 如果没有头节点, 那么此时链表中是看作一个节点都没有的, 但是如果有头节点, 那么此时就会有一个头节点.

此时可能有人还是比较疑惑, 还是弄不明白这两者的区别. 但是不要着急, 在了解链表的实现之前, 头节点的具体作用确实没有那么的直观, 后面我们实现链表操作的时候, 会再次进行提及, 那个时候就能够更加深刻的理解头节点的妙用.

但是关于头节点, 这里还有一点要进行说明. 对于头节点的这个定义, 实际上是比较模糊的, 有些人也会称无头节点链表的第一个节点为头节点. 因此我们后续提及有头节点中的头节点时, 为了不要弄混, 我们会使用虚拟头节点 或者是哨兵节点来称呼真正的头节点.


最后一个就是是否循环, 它主要指的就是最后一个节点是指向空还是指向头. 如下图所示

上面提到的三个分类, 我们进行排列组合就可以得到不同结构的链表, 例如单向无头循环链表, 双向有头非循环链表等.

其中我们主要关注的是单向无头非循环链表和双向无头链表, 单向无头非循环链表由于其较为简单, 适合用于进行初步的学习, 因此接下来我们会先实现这个链表. 随后就是双向无头循环链表, 它是 LinkedList类的底层实现 ,我们也会进行了解和简单的实现.

单向无头链表模拟实现

接下来我们就来实现一个单向无头链表, 来更加深入的理解链表这个数据结构

初始化与基本方法

首先依旧是创建一个类, 用于实现链表的各种操作, 同时我们先创建一个静态内部类, 用于描述节点. 同时书写其的构造方法

java 复制代码
public class MySingleList {
    // 描述节点
    private static class ListNode{
        private int data;
        private ListNode next;
        
        public ListNode(int data){
            this.data = data;
            this.next = null;
        }
    }
}

此时可能有人要问了, 这里为什么要使用静态内部类呢?

首先我们要想这个节点和链表是什么关系? 是不是节点属于链表, 这就是为什么要使用内部类, 是为了描述节点这个东西是属于链表的. 同时节点的这个类型, 应该是整体属于链表这个类的, 而不是对应每一个链表, 它的节点都是不一样的, 因此这里使用了静态的内部类.


接下来, 我们创建一个头节点, 用于指向第一个节点.

java 复制代码
public class MySingleList {
    private static class ListNode{
        private int data;
        private ListNode next;
        
        public ListNode(int data){
            this.data = data;
            this.next = null;
        }
    }
       
    private ListNode head;
}

那么为什么这里需要这个 head 引用呢? 实际上这个问题也非常简单, 我们可以想到数组, 如果一个数组没有了数组的引用, 我们又如何去进行下标访问的操作呢? 这个问题也是同理的, 如果没有这个 head 的引用, 后续我们又要如何找到其他的节点呢?

那么此时有人又要问了, 我不用头节点, 用中间的节点行不行?

根据我们上面的学习, 我们知道链表是一个接一个的连接起来的, 同时我们这里的链表是一个单向的. 那么此时, 我们存储哪一个节点的引用才能够保证能够访问到所有节点呢? 此时想不清楚也没关系, 我们可以进行画图

如图所示, 如果我们的 head 不指向头节点, 那么此时很明显我们可以发现, 前面的节点, 我们是无论如何都找不到的, 那么此时我们自然就需要去让其指向头节点了.

同时值得一提的是, 对于链表的实现来说, 头节点是一个比较特殊的存在, 因此即使我们的节点中有一个引用会指向前面的节点, 我们这里依旧会采用 head 去指向第一个节点的这种做法.


接下来我们就来实现一个构造方法, 这个构造方法的用途是, 根据一个数组创建一个链表. 那么根据我们上面的学习, 我们知道链表是由一个一个的节点组成的, 一个节点就对应着一个数据. 也就是说, 节点就相当于数组中的一个位置, 那么我们就可以遍历数组, 然后根据数组中的元素来创建各个节点.

并且我们也需要将链表中的 next 来进行链接, 因为链表的节点并不会自动连接起来. 下面是一个图示

后面的都是同理的, 我们就不演示了.

但是此时实际上还是有一个问题的, 在我们画图模拟的时候, 我们是一个全局的视角, 可以直接看到链表的尾巴, 所以我们可以直接尾插. 那如果在代码中呢?

因此我们需要一个引用指向链表的尾巴, 并且每插一个节点, 他也会自动的往后走一步.

此时可能有人就想: 我如果让这个 head 跟着往后走, 是否可行呢?

答案是不行的, 因为如果我们的 head 没有指向第一个节点, 而是往后走了, 此时第一个节点/前面的节点就失去了引用, 此时就会被 Java 的垃圾回收机制作为不可达的对象自动回收掉. 关于 Java 的垃圾回收机制, 我们暂时只需要简单理解为没有引用就会被回收, 具体的我们在这里不过多介绍, 感兴趣可自行了解.

那么总结一下, 我们的思路如下:

  1. 使用数组的第一个元素创建头节点, 并且创建一个尾插引用
  2. 遍历数组, 创建节点, 使用尾插引用进行尾插

同时还有一个细节问题, 就是如果数组为空, 那么则无法创建第一个节点, 此时需要直接返回.

java 复制代码
// 初始构造方法
public MySingleList(){
    this.head = null;
}

// 根据数组创建链表, 便于测试
public MySingleList(int[] array){
    // 如果数组为空,则初始化链表为空
    if(array.length == 0){
        this.head = null;
        return;
    }
    // 初始化头节点
    this.head = new ListNode(array[0]);
    ListNode cur = this.head;

    // 遍历数组, 生成链表
    for(int i = 1; i < array.length; i++){
        // 令当前节点的next为创建的新节点, 即可实现尾插
        cur.next = new ListNode(array[i]);
        cur = cur.next;
    }
}

实现了一个创建链表的基本方法, 我们接下来再来看一个遍历链表的方法, 就是获取链表的长度.

思考获取链表长度前, 我们可以思考另外一个问题, 假如有一个数组不支持获取长度的操作, 我们应该如何获取其长度(假设已知结尾的最后一个元素是什么, 且结尾元素唯一).

我们岂不是就可以使用一个循环, 从第一个元素遍历到最后一个元素, 然后使用计数器计数即可. 那么此时对于链表也是同理的, 我们也可以从第一个元素遍历到最后一个元素, 并且计数. 那么我们如何确定当前元素是否是最后一个元素呢? 看下图

假设我有一个这样的链表, 那么节点 3 此时没有任何指向, 那么理所应当, 它的 next 指向的应该是一个 null. 而前面的节点 1 和 节点 2, 很明显都是有后继的, 因此它们的 next 都是有指向的.

因此我们可以通过判断当前节点的 next 是否为空, 来确认是否当前节点是最后一个节点. 或者是, 我们可以通过看当前节点是否为空, 来确认我们所处的节点是否是一个有效的节点. 如下图所示

上面说的两种思路都是可以实现的, 只不过第二种方法要处理的细节问题更少, 因此我们这里就采用第二种思路来实现. 具体处理的是什么细节问题, 可以自行书写一下第一种思路的代码来进行探究

java 复制代码
// 获取长度
public int size(){
    // 记录长度
    int length = 0;
    // 遍历指针
    ListNode cur = head;
    // 当前节点不为空时, 计数
    while(cur != null){
        length++;
        cur = cur.next;
    }
    // 返回长度
    return length;
}

接下来我们再来重写一个 toString() 方法, 便于打印. 实际上实现的思路和上面的size()一样, 只不过节点的操作从计数变为了字符串的拼接, 因此我们这里不细致讲解了.

java 复制代码
@Override
public String toString(){
    // 创建StringBuilder用于拼接
    StringBuilder stringBuilder = new StringBuilder();
    // 遍历指针
    ListNode cur = head;
    // 当前节点不为空时, 拼接
    while(cur != null){
        stringBuilder.append(cur.value).append(" ");
        cur = cur.next;
    }
    // 返回
    return stringBuilder.toString();
}

查询

首先来实现一个查询下标元素的方法, 当我们理解了上面的遍历思路后, 实际上还是比较简单的. 只需要创建一个遍历指针, 然后下标是几就走几步, 我们就可以轻松实现这个方法

java 复制代码
// 查找下标元素
public int get(int index){
    // 检验下标合法性
    if(index < 0 || index >= this.size()){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }

    // 遍历指针
    ListNode cur = head;
    // 走 index 步
    for(int i = 0; i < index; i++){
        cur = cur.next;
    }
    // 返回数据
    return cur.value;
}

接下来来实现一个, 查看是否包含元素. 实际上还是我们的老朋友遍历来实现, 还记得我们上面的size()toString()的实现吗?

它们的操作实际上就分为: 1. 创建遍历指针 2. 节点不为空时, 遍历并处理每一个节点 3. 返回结果

那么很明显, 它们实际上的区别就是处理节点的操作和返回的结果不一样. 这一题也是同理的, 这里我们的操作就主要修在 2 和 3, 对于处理节点, 我们这里就是判断当前节点是否为目标节点, 如果是就直接返回 true.

那么如果遍历完了, 还没有找到节点, 就证明没有这个元素, 此时我们就需要返回 false. 那么此时我们就可以分为三步

  1. 创建遍历指针
  2. 节点不为空时, 遍历节点, 判断当前节点是否为目标节点, 是则返回 true
  3. 遍历完了, 没找到, 返回 false
java 复制代码
// 查看是否包含元素
public boolean contains(int val){
    
    // 创指针
    ListNode cur = head;
    // 遍历处理
    while(cur != null){
        // 找到了, 返回true
        if(cur.value == val) return true;
        cur = cur.next;
    }
    // 走完了还没有找到结果, 返回false
    return false;
}

修改

接下来就是实现修改的方法, 我们首先实现一个修改下标元素, 可以发现它的操作实际上和查找下标节点没什么多大的差别, 就是多了一个找到节点后要进行修改的操作而已, 因此不多介绍

java 复制代码
// 修改下标元素
public void set(int index, int val){
    // 检验下标合法性
    if(index < 0 || index >= this.size()){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }

    // 遍历查找
    ListNode cur = head;
    for(int i = 0; i < index; i++){
        cur = cur.next;
    }

    // 执行修改
    cur.value = val;
}

当然我们这里也可以封装出一个方法来实现代码复用, 修改代码如下

java 复制代码
// 返回下标引用
private ListNode getIndexNode(int index){
    // 0. 检验下标合法性
    if(index < 0 || index >= this.size()){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }

    // 1. 遍历查找
    ListNode cur = head;
    for(int i = 0; i < index; i++){
        cur = cur.next;
    }

    // 2. 返回节点
    return cur;
}

// 查找下标元素
public int get(int index){
    // 返回数据
    return getIndexNode(index).value;
}

// 修改下标元素
public void set(int index, int val){
    getIndexNode(index).value = val;
}

增加

接下来就来到了链表比较有特色的插入操作, 我们首先来看一个头插的操作. 头插的要求就是需要我们创建一个节点, 然后作为新的头节点.

那么此时可能有人就写出了如下代码

java 复制代码
// 头插
public void addFirst(int val){
    // 创建一个新节点
    ListNode node = new ListNode(val);
    // 让新节点的next为头节点
    node.next = head;
}

那么这个可行吗? 我们画一个图看看是否可行

可以发现, 我们剩下的节点直接丢了, 并没有连接起来, 因此我们再转移 head 之前, 我们还需要让新节点指向 head 才行, 最后代码如下

java 复制代码
// 头插
public void addFirst(int val){
    // 创建一个新节点
    ListNode node = new ListNode(val);
    // 让新节点的next为头节点
    node.next = head;
    // 改变head的指向, 让新节点成为头节点
    head = node;
}

实现了头插接下来就是尾插了, 实际上也是比较好理解的, 找到最后一个节点插入即可. 还记得我们之前讲解size()方法时说的, 如何确定当前节点是最后一个节点吗?

就是通过看当前节点的 next 是否为空, 因此我们这里的循环条件就和前面的遍历不一样了, 不是cur != null 而是cur.next != null. 那么此时我们就可以写一个代码

java 复制代码
// 尾插
public void addLast(int val){
    // 找到结尾位置
    ListNode cur = head;
    while(cur.next != null){
        cur = cur.next;
    }
    // 创建并且插入节点
    cur.next = new ListNode(val);
}

但是此时这个代码并没有完成, 此时如果我们尝试往一个空链表插入则会抛出空指针异常. 空指针异常的解决办法也是非常简单的, 哪里为空看哪里即可.

通过编译器点过去我们可以看到, 这个 cur 是空

为什么是空? 也非常简单, 链表是空, head 此时自然指向的就是空, 此时 cur 拿到的值自然也就是空了. 那么此时我们就需要特殊情况特殊处理, 判断一下 head 是否为空, 如果为空, 则直接创建节点并且赋值给 head.

java 复制代码
public void addLast(int val){
    // 0. 判断head是否为空, 为空直接给head赋值并且返回
    if(head == null){
        head = new ListNode(val);
        return;
    }

    // 1. 找到结尾位置
    ListNode cur = head;
    while(cur.next != null){
        cur = cur.next;
    }
    // 2. 创建并且插入节点
    cur.next = new ListNode(val);
}

接下来我们实现一个指定位置插入的方法, 此时可能有人想着: 这不是很简单吗? 我找到对应位置插入不就行了? 然后写出了如下代码后, 发现卡壳了

java 复制代码
// 任意位置插入
public void add(int index, int val){
    // 确认位置是否合法, 是否大于等于0, 是否符合链表的长度
    if(index < 0 || index > this.size()){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }
    // 遍历找到位置
    ListNode cur = head;
    for (int i = 0; i < index; i++){
        cur = cur.next;
    }
    // 创建节点并且插入

}

此时发现, 插入操作似乎无法进行了, 为什么呢? 我们依旧是通过画图来看一下

很明显如果要插入一定要前一个位置的引用, 因为要令其 next 指向当前节点, 因此我们需要找到前一个节点. 那么我们如何找到前一个节点呢? 能否到达当前节点后往前呢? 答案是当然不行, 因为我们的引用只有一个 next , 只能往后不能往前.

假设前一个位置的引用为 prev, 那么实际上, 我们只需要让 prev 跟着 cur 走就行了. cur 每要走一步前, 我们就让 prev 先指向 cur, 那么此时 cur 走到对应位置的时候, prev就会在前一个位置

那么有了思路, 下面就是代码

java 复制代码
// 任意位置插入
public void add(int index, int val){
    // 确认位置是否合法, 是否大于等于0, 是否符合链表的长度
    if(index < 0 || index > this.size()){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }
    // 遍历找到位置和前一个位置
    ListNode cur = head;
    ListNode prev = null;
    for (int i = 0; i < index; i++){
        prev = cur;
        cur = cur.next;
    }
    // 创建节点并且插入
    //   1) 创建新节点, 让其next指向cur
    ListNode listNode = new ListNode(val);
    listNode.next = cur;
    //   2) 让prev的next指向新节点
    prev.next = listNode;
}

此时发现, 实际上cur可以由prev.next代替, 此时修改如下. 但是此时 cur 没了, 我们需要一个新的方法来确定前一个节点, 因此我们采用让 prev 走 index - 1 步的方法. prev走 index - 1步, 此时自然就是我们目标位置 index 处的前一个位置了, 因此我们可以直接通过遍历走 index - 1 来找到目标位置

java 复制代码
// 任意位置插入
public void add(int index, int val){
    // 0. 确认位置是否合法, 是否大于等于0, 是否符合链表的长度
    if(index < 0 || index > this.size()){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }
    // 1. 走到前一个位置, 走index - 1步即可
    ListNode prev = head;
    for (int i = 0; i < index - 1; i++){
        prev = prev.next;
    }
    // 2. 创建节点并且插入
    //   1) 创建新节点, 让其next指向cur
    ListNode listNode = new ListNode(val);
    listNode.next = prev.next;
    //   2) 让prev的next指向新节点
    prev.next = listNode;
}

但是这样省略了一个引用, 是否真的更好呢? 答案是不一定的. 假如我们此时修改一下最后两个设置引用的步骤, 此时就会发生问题.

假设测试如下代码

java 复制代码
public class Main {
    public static void main(String[] args) {
        int[] arr = {1,2,3,4,5};
        MySingleList mySingleList = new MySingleList(arr);
        mySingleList.add(5,999);

        System.out.println(mySingleList);
    }
}

此时发现内存溢出, 抛出异常

那么在链表的操作中, 如果遇到了内存溢出, 很有可能就是你的链表中有一个环. 那么到底是什么情况呢, 我们来画图看看.

很明显, 当我们先修改 prev 的 next 后, 此时再用 prev 的 next 就会有问题. 因此在这个代码中, 引用的修改顺序是固定的, 但是假如我们采用了 cur.

那么此时就不会产生这个问题, 因为我们每一个节点都有一个仅仅属于自己的临时引用, 不可能会出现修改了谁的 next 导致引用失败的情况.

总而言之, 多创建一个临时变量, 不仅可以增加代码的可阅读性, 同时还可以避免一些细节情况的处理, 因此在链表的操作中, 多创建临时变量也是一个很好用的技巧.


此时我们上面的代码还是没有完成, 因为如果要找 prev , 很明显得要这个节点有 prev 才行, 但是我们的第一个节点就没有 prev , 那么此时很明显就无法处理插入第一个位置的情况, 因此特殊情况, 特殊处理

最终代码如下

java 复制代码
// 任意位置插入
public void add(int index, int val){
    // 确认位置是否合法, 是否大于等于0, 是否符合链表的长度
    if(index < 0 || index > this.size()){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }
    // 如果插入的位置是 0, 单独处理
    if(index == 0){
        addFirst(val);
        return;
    }

    // 走到前一个位置, 走index - 1步即可
    ListNode prev = head;
    for (int i = 0; i < index - 1; i++){
        prev = prev.next;
    }
    // 创建节点并且插入
    //   1) 创建新节点, 让其next指向cur
    ListNode listNode = new ListNode(val);
    listNode.next = prev.next;
    //   2) 让prev的next指向新节点
    prev.next = listNode;
}

删除

接下来是删除对应位置元素的方法, 有了前车之鉴, 我们这里当然也就知道这个删除应该也是要前一个节点的引用的. 因为要删除当前节点, 则需要当前一个节点的引用指向当前节点的后一个节点, 如下图

同样的, 由于第一个节点没有 prev , 也就需要特殊处理.

java 复制代码
// 删除对应位置元素
public void removeIndex(int index) {
    // 0. 确认位置是否合法, 是否大于等于0, 是否符合链表的长度
    if (index < 0 || index >= this.size()) {
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }

    // 1. 处理第一个节点的情况
    if (index == 0) {
        head = head.next;
        return;
    }

    // 2. 找到下标位置的前一个位置
    ListNode prev = head;
    for (int i = 0; i < index - 1; i++) {
        prev = prev.next;
    }
    // 3. 删除
    prev.next = prev.next.next;
}

接下来就是一个删除第一次出现的对应值元素, 就是给你一个 val, 然后删除链表中的第一个值为 val 的节点. 那么此时我们依旧是, 先遍历找, 然后删即可.

没有什么新的点, 因此不多细讲, 书写代码如下

java 复制代码
// 删除对应值元素
public boolean removeValue(int val){
    // 判断链表是否为空
    if(head == null){
        return false;
    }
    // 处理头节点情况
    if(head.value == val){
        head = head.next;
        return true;
    }
    // 删除节点
    //   1) 遍历查找
    ListNode prev = head;
    while(prev.next != null){
        // 2)如果找到了, 执行删除并且返回true
        if(prev.next.value == val){
            prev.next = prev.next.next;
            return true;
        }

        prev = prev.next;
    }
    // 3) 没有找到, 返回false;
    return false;
}

接下来我们来基于这个基础, 写一个稍微进阶一点的操作, 这个操作的要求是, 移除链表中值为 val 的所有元素.

那么此时可能有人想了, 这不简单吗? 我直接让我们在删除元素的时候, 不要直接 return 不就行了吗?

那么我们就把那个 return 的语句全部去掉, 返回值改成 void, 看看是否能成功.

java 复制代码
// 删除对应值元素
public void removeAllValue(int val){
    // 判断链表是否为空
    if(head == null){
        return;
    }
    // 处理头节点情况
    if(head.value == val){
        head = head.next;
    }
    // 删除节点
    //   1) 遍历查找
    ListNode prev = head;
    while(prev.next != null){
        // 2) 如果找到了, 执行删除
        if(prev.next.value == val){
            prev.next = prev.next.next;
        }

        prev = prev.next;
    }
}

测试一下下面代码

java 复制代码
public class Main {
    public static void main(String[] args) {
        int[] arr = {1, 1, 1, 2, 3, 4, 1, 1, 1, 1};
        MySingleList mySingleList = new MySingleList(arr);
        mySingleList.removeAllValue(1);
        System.out.println(mySingleList);
    }
}

可以发现, 只删了一部分, 但是没有删干净

那么是为什么呢? 我们可以看一下中间的情况

此时可以发现, 我们删除后, 直接回跳过下一个相同的节点. 那么修改方式也很简单, 如果删除了, 就不要往后走了, 给 prev 的后移加在 else 中执行即可.

但是此时还是有问题, 我们来看第一个节点的情况

可以看到, 这样删了和没删一样. 那么我们是否可以使用一个 while 循环来删呢? 当然可以, 但是这样依旧还有许多问题

假如链表中全都是同一个节点/只有一个节点, 那么此时会抛出空指针异常

那么很明显, 此时要处理的细节问题实在太多, 有点烦人, 因此我们可以换一个思路.

既然删前面相同的节点, 这么麻烦, 那么我最后一个删它不就行了吗? 因为我只是暂时不删第一个节点, 剩余的节点都可以正常的删除.

那么此时我们的最终代码如下

java 复制代码
// 删除所有对应值元素
public void removeAllValue(int val){
    // 判断链表是否为空
    if(head == null){
        return;
    }

    // 删除节点
    //   1) 遍历查找
    ListNode prev = head;
    while(prev.next != null){
        if(prev.next.value == val){
            // 2)如果找到了, 执行删除并且返回true
            // 此时不能往后走, 因为删除的时候可能下一个值也需要删除, 如果此时往后走了则会跳过这一个值
            prev.next = prev.next.next;
        }else{
            // 3) 如果没有找到, 才能往后走
            prev = prev.next;
        }
    }

    // 处理头节点情况
    if(head.value == val){
        head = head.next;
    }
}

那么这个代码是否会有前面节点相同导致的空指针情况呢?

我们画图来看

很明显, 此时就不会发生这样的问题了.

这里是一道相关题目的链接: 移除链表元素, 可以用其测试自己的代码是否正确


接下来是最后一个操作, 清空链表. 也非常简单, 我们直接将 head 置空即可. 因为我们说过, 如果一个节点没有了引用, 自然就会被 JVM 自动回收掉. 因此这样就可以达到删除的效果

java 复制代码
public void clear(){
    this.head = null;
}

双向无头链表模拟实现

上面我们模拟实现了一个单向无头链表, 接下来我们可以更进一步的学习 LinkedList 相关的东西了. 当然我们依旧是先进行模拟实现, 由于其底层是一个双向的无头链表, 因此我们这里就是先实现一个双向的无头链表

初始化与基本方法

首先依旧是老步骤, 写好节点的定义, 创建引用.

只不过对于双向链表来说, 首先节点我们不仅仅需要一个 next 也需要一个 prev. 同时对于链表的引用来说, 我们不仅仅需要第一个节点的引用, 也需要最后一个节点的引用, 我们这里就设定为 first 和 last. 同时我们这里也设置一个 size 用于表示链表大小

java 复制代码
public class MyLinkedList {
    private static class ListNode {
        private int value;

        private ListNode prev;
        private ListNode next;

        public ListNode(int value){
            this.value = value;
        }

        public ListNode(int value, ListNode prev, ListNode next) {
            this.value = value;
            this.prev = prev;
            this.next = next;
        }
    }

    // 代表第一个节点
    private ListNode first;

    // 代表最后一个节点
    private ListNode last;

    // 链表大小
    private int size;
}

初始化好了, 接下来就是实现上面我们实现过的老朋友, 首先就是获取长度, 这里由于我们存储了 size, 直接返回即可

java 复制代码
public int size(){
    return size;
}

然后就是重写 toString() 方法, 便于打印. 这个和单链表没有什么区别, 如果想要区别, 可以从尾巴往前遍历, 但是我们这里就不书写了.

java 复制代码
    @Override
    public String toString(){
        StringBuilder stringBuilder = new StringBuilder();
        ListNode cur = first;
        while(cur != null){
            stringBuilder.append(cur.value).append(" ");
            cur = cur.next;
        }
        return stringBuilder.toString();
    }

使用数组创建, 和单链表类似, 但是有一些区别, 就是我们需要修改每一个节点的 prev, 同时需要让 last 后移. 如下图

java 复制代码
public MyLinkedList(int[] array){
    // 如果数组为空, 初始化为空(实际上由于默认为空, 也可以选择直接返回)
    if(array.length == 0) return;

    // 初始化第一个节点
    first = last = new ListNode(array[0]);

    // 遍历数组, 进行尾插
    for (int i = 1; i < array.length; i++){
        // 创建节点
        ListNode newNode = new ListNode(array[i]);
        // 让last的next指向新节点
        last.next = newNode;
        // 让新节点的prev指向last节点
        newNode.prev = last;
        // last后移
        last = last.next;
    }
    // 不要忘记了修改大小
    size = array.length;
}

查询

查询和单链表没有区别, 我们这里就不详细介绍了, 直接看代码

首先是查找下标元素

java 复制代码
// 查找下标元素
public int get(int index){
    // 检验下标合法性
    if(index < 0 || index >= this.size()){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }

    // 遍历查找
    ListNode cur = first;
    for(int i = 0; i < index; i++){
        cur = cur.next;
    }
    // 返回数据
    return cur.value;
}

然后是查看是否包含元素

java 复制代码
// 查看是否包含元素
public boolean contains(int val){
    // 1. 遍历链表
    // 2. 查找并且返回结果
    
    // 遍历链表
    ListNode cur = first;
    while(cur != null){
        // 找到了, 返回true
        if(cur.value == val) return true;
        cur = cur.next;
    }
    // 走完了还没有找到结果, 返回false
    return false;
}

修改

修改本质上就是查找 + 修改查找出的元素, 因此修改下标元素相较于单链表也没有任何变化, 也不多阐述

java 复制代码
// 修改下标元素
public void set(int index, int val){
    // 检验下标合法性
    if(index < 0 || index >= this.size()){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }

    // 遍历查找
    ListNode cur = head;
    for(int i = 0; i < index; i++){
        cur = cur.next;
    }

    // 执行修改
    cur.value = val;
}

增加

从增加开始, 剩余的方法开始和单链表的实现不太一样了, 那么我们依旧是老套路先看头插.

实际上步骤也非常简单:

  1. 创建新节点
  2. 连接, 修改当前 fisrt 节点的 prev 和新节点的 next
  3. first 前移

如下图所示

java 复制代码
public void addFirst(int val) {
    // 如果链表为空, 直接创建节点并且赋值
    if (size == 0) {
        first = last = new ListNode(val);
        size++;
        return;
    }

    // 创建新节点
    ListNode newNode = new ListNode(val);
    // 修改 first 的 prev
    first.prev = newNode;
    // 修改新节点的 next
    newNode.next = first;
    // first向前走
    first = first.prev;

    // 不要忘记修改大小
    size++;
}

尾插和头插本质上是相同的. 实际上由于双向链表又有 prev 又有 next, 又有 first 和 last. 所以其实我们这个链表无论是从头到尾看, 还是从尾到头看, 都是可以看作一样的, 只不过引用名字不同而已.

下面是尾插的代码

java 复制代码
public void addLast(int val) {
    // 如果链表为空, 直接赋值
    if (size == 0){
        first = last = new ListNode(val);
        size++;
        return;
    }

    // 1. 创建新节点
    ListNode newNode = new ListNode(val);
    // 2. 修改 last 的 next
    last.next = newNode;
    // 3. 修改新节点的prev
    newNode.prev = last;
    // 4. last后移
    last = last.next;

    size++;
}

指定下标插入, 这里要指定位置插入, 根据我们对于单链表的理解, 我们肯定要找到插入位置的前一个节点. 那么此时我们既可以找到要插入的位置, 也可以直接找到前一个位置. 因为我们现在既有 prev 也有 next , 可以在链表中自由穿梭.

但是对于双链表来说, 我们即需要前一个节点, 也需要对应位置的节点, 因为我们还需要修改它的 prev.

当然, 这里我们也是可以通过不创建 cur 而是使用一个 prev 的方式来插入的, 但是我们在前面也说过, 这样的方式实际上限制于一些顺序, 同时可读性不高, 因此我们这里就不采用那种方法了.

那么我们就可以先写出这样的代码

java 复制代码
public void add(int index, int val){
    // 判断index是否合法
    if(index < 0 || index > size){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }

    // 找到要插入的前一个位置
    ListNode prev = first;
    for(int i = 0; i < index - 1; i++){
        prev = prev.next;
    }
    // 创建临时变量 cur 便于插入
    ListNode cur = prev.next;

    // 创建新节点并且插入
    ListNode newNode = new ListNode(val);
    newNode.next = cur;
    newNode.prev = prev;
    prev.next = cur.prev = newNode;

    size++;
}

当然此时还没有结束, 我们的细节问题还没有处理, 一个就是插入第一个位置的时候, 没有 prev. 插入最后一个位置的时候, 没有 cur. 但是由于其分别就相当于头插和尾插, 因此我们可以直接调用方法实现.

最终代码如下

java 复制代码
public void add(int index, int val) {
    // 判断index是否合法
    if (index < 0 || index > size) {
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }
    if (index == 0) {
        // 如果插入的位置是 0 位置, 调用addFirst
        addFirst(val);
        return;
    } else if (index == size) {
        // 如果插入的位置是 size位置, 则调用addLast
        addLast(val);
        return;
    }

    // 找到要插入的前一个位置
    ListNode prev = first;
    for (int i = 0; i < index - 1; i++) {
        prev = prev.next;
    }
    // 创建临时变量 cur 便于插入
    ListNode cur = prev.next;

    // 创建新节点并且插入
    ListNode newNode = new ListNode(val);
    newNode.next = cur;
    newNode.prev = prev;
    prev.next = cur.prev = newNode;

    size++;
}

删除

由于双向链表的删除, 还是比较繁琐的, 因此我们就先实现一个通用的方法, 这个方法是删除下标元素.

首先我们来看通用情况, 即删除中间的节点, 如下图所示

那么此时代码部分如下

java 复制代码
public void removeIndex(int index) {
    if(index < 0 || index >= size){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }

    // 删除中间节点
    ListNode cur = first;
    for (int i = 0; i < index; i++) {
        cur = cur.next;
    }
    // 拿到前一个节点和后一个节点
    ListNode prevNode = cur.prev, nextNode = cur.next;
    // 连接
    prevNode.next = nextNode;
    nextNode.prev = prevNode;

    size--;
}

同时依旧是细节问题的处理:

  1. 删除第一个节点
  2. 删除最后一个节点

那么此时我们特殊情况特殊处理, 添加代码如下

java 复制代码
public void removeIndex(int index) {
    if(index < 0 || index >= size){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }

    // 只有一个节点, 特殊情况特殊处理
    if(size == 1){
        first = last = null;
        size--;
        return;
    }

    // 删除中间节点
    ListNode cur = first;
    for (int i = 0; i < index; i++) {
        cur = cur.next;
    }
    // 拿到前一个节点和后一个节点
    ListNode prevNode = cur.prev, nextNode = cur.next;
    // 连接
    prevNode.next = nextNode;
    nextNode.prev = prevNode;

    size--;
}

实际上这里还会有一个细节问题: 如果只有一个节点, 现在的所有代码都会去尝试找前一个/后一个引用并且修改指向, 那么此时就会抛出空指针异常, 因此还需要进行特殊处理

最终代码如下

java 复制代码
public void removeIndex(int index) {
    if(index < 0 || index >= size){
        throw new IndexOutOfBoundsException(OutOfBoundMsg(index));
    }

    // 只有一个节点, 特殊情况特殊处理
    if(size == 1){
        first = last = null;
        size--;
        return;
    }

    if (index == 0) {
        // 删除第一个节点
        first = first.next;
        first.prev = null;
        size--;
        return;
    } else if (index == size - 1) {
        // 删除最后一个节点
        last = last.prev;
        last.next = null;
        size--;
        return;
    }

    // 删除中间节点
    ListNode cur = first;
    for (int i = 0; i < index; i++) {
        cur = cur.next;
    }
    // 拿到前一个节点和后一个节点
    ListNode prevNode = cur.prev, nextNode = cur.next;
    // 连接
    prevNode.next = nextNode;
    nextNode.prev = prevNode;

    size--;
}

当然这里也可以和添加一样, 封装出头删和尾删方法. 我们这里就不演示了


接下来是两个移除值的方法, 一个是移除出现的第一个值, 另一个则是移除所有值.

我们这里就借助删除下标元素的方法实现, 遍历链表, 找到对应值就把下标给删除下标节点方法, 还是比较简单的

java 复制代码
public void removeAll(int val) {
    ListNode cur = first;
    // 遍历找下标
    int index = 0;
    while(cur != null){
        if(cur.value == val){
            // 如果相等传入删除
            removeIndex(index);
        }
        cur = cur.next;
        index++;
    }
}

public boolean removeValue(int val) {
    ListNode cur = first;
    // 遍历找下标
    int index = 0;
    while(cur != null){
        if(cur.value == val){
            // 如果相等传入删除
            removeIndex(index);
            return true;
        }
        cur = cur.next;
        index++;
    }
    return false;
}

这里当然也可以直接实现, 但是同样的, 也需要处理那些特殊情况. 因此如果想要手动实现, 非常推荐将上面的特殊情况删除的方法进行封装, 便于这里使用, 我们这里就不细讲了.


接下来总算来到了最后一个方法, 就是清空链表, 我们当然也是可以通过直接置空让垃圾回收机制自动回收的, 如下所示

java 复制代码
public void clear(){
    first = last = null;
}

但是这里我们去看看真正的 LinkedList 是如何做的, 如下所示

可以看到, 它遍历清空了. 同时它给出了一系列解释.

简单的说, 我们手动的将其置空, 一个是更有利于 GC (是垃圾回收) 来回收掉这些节点. 另一个则是当存在迭代器的时候, 也可以保证清空. 大体我们目前只需要知道这样做是更好的, 其余的了解即可.

因此下面我们也给出这样更好的做法

java 复制代码
public void clear(){
    ListNode cur = first;
    while(cur != null){
        ListNode curNext = cur.next;
        cur.next = null;
        cur.prev = null;
        cur = curNext;
    }
    first = last = null;
    size = 0;
}

LinkedList类

LinkedList类是什么

学习了有关链表结构的大量内容后, 我们总算到达了了解 LinkedList 这一步了. 实际上 LinkedList 类就和 ArrayList类类似, 都是一个工具类, 只不过 ArrayList 类底层存储数据的数据结构是顺序表, 而 LinkedList 则是以双向链表为底层数据结构的类.

回顾List接口

LinkedList 和 ArrayList 一样, 都实现了 List 接口. 在日常使用 LinkedList 的时候, 如果把其当作为一个线性表来使用, 则通常是会向上转型到 List 接口来使用的. 换句话说, List 接口实际上也就代表着数据结构中的线性表结构.

那么线性表指的又是什么呢? 线性表实际上指的就是类似于顺序表和链表这样的, 类似于一根线一样从头走到尾的结构. 并且其要求除了首元素和尾巴元素外, 中间的所有元素都必须有且仅仅有一个前驱和后继. 换句话说, 中间的元素的前面和后面必须有其他元素, 并且只能有一个.

LinkedList使用

下面的方法使用还是非常简单的, 和 ArrayList 都差不多. 我们这里就不细致介绍了.

构造方法

方法名, 参数 说明
LinkedList() 初始化一个LinkedList
LinkedList(Collection<? extends E> c) 根据提供的集合类型构造ArrayList

常用方法

返回值, 方法名, 参数 说明
boolean add(E e) 尾插 e
void add(int index, E element) 将 e 插入到 index 位置
boolean addAll(Collection<? extends E> c) 尾插 c 中的元素
E remove(int index) 删除 index 位置元素
boolean remove(Object o) 删除遇到的第一个 o
E get(int index) 获取下标 index 位置元素
E set(int index, E element) 将下标 index 位置元素设置为 element
void clear() 清空
boolean contains(Object o) 判断是否含有o元素
int indexOf(Object o) 返回第一个o元素所在下标
int lastIndexOf(Object o) 返回最后一个o元素所在下标
List<E> subList(int fromIndex, int toIndex) 截取下标从from到to部分的list

LinkedList的遍历

下面我们来看一下如何遍历 LinkedList, 分为三个遍历方法

java 复制代码
public class Main {
    public static void main(String[] args) {
        List<Integer> linkedList = new LinkedList();
        linkedList.add(1);
        linkedList.add(2);
        linkedList.add(3);
        linkedList.add(4);
        linkedList.add(5);
        linkedList.add(6);
        // for循环遍历
        for (int i = 0; i < linkedList.size(); i++) {
            System.out.println(linkedList.get(i));
        }

        // for-each循环遍历
        for (Integer i : linkedList) {
            System.out.println(i);
        }

        // 迭代器遍历
        // 获取迭代器
        Iterator<Integer> iterator = linkedList.iterator();
        // 遍历
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }
}

可以看到, 对于我们来说比较新颖的一种方法就是通过迭代器来遍历. 要通过迭代器来遍历, 首先我们需要通过iterator()方法获取迭代器. 那么这个方法是哪来的呢?

实际上就来源于在集合框架中, 位于 Collection接口上面的 Iterable接口

可以看到, 我们使用迭代器, 就和我们使用 Scanner 的方式十分类似, 都是通过hasNext()这样的方法来进行判断有没有剩余元素, 然后通过 next() 来访问下一个元素的. 实际上, 迭代器还提供了一个 remove() 方法, 用于移除当前指向的元素.

那么此时可能有人要问了, 为什么我明明可以自己遍历, 却还需要迭代器来呢? 这种东西有什么优势呢?

实际上迭代器的优势就在于, 它不关注底层的集合类是什么, 而是直接通过迭代器本身来进行遍历或者其他的一些操作. 如下所示

java 复制代码
public class Main {
    // 迭代器遍历
    public static void display(Iterator<Integer> iterator){
        while (iterator.hasNext()) {
            System.out.print(iterator.next() + " ");
        }
    }
    
    public static void main(String[] args) {
        // 创建ArrayList
        List<Integer> arrayList = new ArrayList();
        arrayList.add(1);
        arrayList.add(2);
        arrayList.add(3);
        arrayList.add(4);
        
        // 创建LinkedList
        List<Integer> linkedList = new LinkedList(arrayList);
        display(arrayList.iterator());
        display(linkedList.iterator());
    }
}

此时可以发现, 我们只需要将迭代器传过去, 而不需要将集合类本身传过去.

ArrayList 和 LinkedList 的区别

  1. ArrayList 的底层数据结构是一个顺序表, 物理结构是连续的. 而 LinkedList 的底层数据结构则是一个链表, 物理上不一定连续
  2. ArrayList 擅长进行随机下标访问的操作, 而 LinkedList 擅长任意位置插入和删除的操作
  3. ArrayList 在插入元素的时候, 如果空间不够需要扩容, 而 LinkedList 不需要进行扩容, 可以看作是"无容量上限"
  4. ArrayList 适用于频繁随机访问的情景, 而 LinkedList 适用于多插入和删除的情景
相关推荐
全栈开发帅帅9 分钟前
基于springboot+vue实现的博物馆游客预约系统 (源码+L文+ppt)4-127
java·spring boot·后端
LeonNo1113 分钟前
golang , chan学习
开发语言·学习·golang
上海拔俗网络21 分钟前
“AI应急管理系统:未来城市安全的守护者
java·团队开发
2401_8582861125 分钟前
109.【C语言】数据结构之求二叉树的高度
c语言·开发语言·数据结构·算法
天之涯上上27 分钟前
JAVA开发Erp时日志报错:SQL 当 IDENTITY_INSERT 设置为 OFF 时,不能为表 ‘***‘ 中的标识列插入显式值
java·开发语言·sql
m0_7482370528 分钟前
web的五个Observer API
java·前端·javascript
huapiaoy32 分钟前
数据结构---Map&Set
数据结构
南宫生32 分钟前
力扣-数据结构-1【算法学习day.72】
java·数据结构·学习·算法·leetcode
yuanbenshidiaos36 分钟前
数据结构---------二叉树前序遍历中序遍历后序遍历
数据结构
666和77738 分钟前
C#的单元测试
开发语言·单元测试·c#