【java-数据结构】模拟双向无头非循环链表,掌握背后的实现逻辑

📢编程环境:idea
📢在链表中,单向链表是基础,双向链表是对单向链表的优化。

1. 先回忆一下:

1.1 内存分布中的堆和虚拟机栈

内存是一段连续的存储空间,主要用来存储程序运行时的数据的。乱存数据肯定不行,所以为了更好的管理内存,jvm根据内存功能,对内存进行了如下划分:在这里插入图片描述

  • 堆:堆是jvm所管理的最大内存区域。使用new创建的对象都是在堆上保存的。堆是随着程序开始运行时而创建的,随着程序的退出而销毁。堆中的数据只要还有在使用,堆就不会被销毁。
  • 虚拟机栈:每个方法在执行时,都会先创建一个栈帧。虚拟机栈中主要保存与方法调用相关的信息。比如局部变量就会被保存在栈帧中。当方法运行结束后,栈帧就被销毁了,即栈帧中保存的数据也被销毁了。

1.2基本类型变量和引用类型变量的区别

  • 基本数据类型创建的变量,称为基本变量,通常存储单个数据,该变量空间中直接存放数据本身。

  • 引用数据类型创建的变量,称为对象的引用,通常存储一组数据,该变量空间中存储的是对象所在空间的地址。

比如在下列代码中:在这里插入图片描述

执行main()方法的时候,会先在虚拟机栈上创建一个栈帧,栈帧中主要保存与方法调用相关的信息,也就是基本变量a,基本变量b,引用类型的变量arr。但是变量arr中存的地址,指向的数据是存储在堆上面的。在这里插入图片描述从上图可以看出:引用类型变量并不直接存储对象本身,可以简单理解为:引用类型变量中存储的是对象在堆中空间的起始地址。通过该地址,引用类型变量可以操作对象。

1.3 两个引用类型变量之间的赋值运算

在如下代码中:在这里插入图片描述

在这里插入图片描述
在这里插入图片描述如上图所示:array1 = array2;的意思是:让array1去引用array2引用的数组的空间。此时,array1 和array2实际上是一个数组,无论是array1还是array2,都能对数组进行增删改查。不能只按照代码的表面意思把array1 = array2;理解成“引用指向引用”,这是错误的!

2. 链表

2.1 链表是啥

官方是这样定义链表的:

链表是一种物理结构上不一定连续,逻辑结构上连续的线性结构。链表是线性表的链式存储结构。

物理结构上不一定连续指的是:用链表存储一组元素,其中每个元素都存在一个引用类型的变量空间中,这个变量空间又被称为结点。每存储一个元素,都要在堆上申请一个结点空间,从堆上申请的空间,是按照一定的策略来分配的,两次申请的空间可能连续,也可能不连续。所以链表的物理结构不一定连续。

以双向链表为例,逻辑结构上连续指的是:

  • 结点中不但要存储该元素,
  • 还要存储下一个元素所在空间的地址,通过这个地址,可以找到下一个元素。
  • 还要存储上一个元素所在空间的地址,通过这个地址,可以找到上一个元素。

n个节点链接成一个链表,处于当前结点时,既知道上一个结点,也知道下一个结点。所以链表的逻辑结构是连续的。
在这里插入图片描述

比如:用双向链表存储下列数据:12,23,34,45,56
在这里插入图片描述

2.2 链表的分类

链表的结构非常多样,包括单向链表,双向链表,带头链表,不带头链表,循坏链表,非循坏链表。以上情况通过排列组合,得到以下八种类型的链表。

在这里插入图片描述

  1. 链表是单向的还是双向的,这取决于链表的结点中存储了几个地址。
  • 双向链表的结点空间中,不仅存储了当前元素,还存储了下一个元素的地址和上一下元素的地址。

  • 单向链表的结点空间中,同时存储了当前元素和下一个元素的地址。

  • 比如:分别用单向链表和双向链表存储下列数据:12,23,34,45,56在这里插入图片描述

  1. 链表是带头的还是不带头的,取决于该链表有没有头指针。
  • 头指针是一个引用类型变量,变量中存储着链表第一个结点的地址。带头链表有头指针,不带头链表没有头指针。头指针是带头链表的必要元素。
    头结点是为了更方便的操作链表,在链表的第一个结点前附设一个结点。头结点中除了存储链表第一个结点的地址,还可以存链表的其他信息。头结点根据需要而存在。
    头指针和头结点是完全两个概念。在这里插入图片描述

  • 比如:分别用不带头链表和带头链表存储下列数据:12,23,34,45,56
    在这里插入图片描述

  1. 链表是循坏的还是非循坏的,取决于链表的最后一个结点中,有没有存储第一个结点的地址。
  • 比如:分别用非循坏链表和循坏链表存储下列数据:12,23,34,45,56
    在这里插入图片描述

本篇要模拟实现的是:不带头双向非循环链表
在这里插入图片描述

3. 用java语言模拟实现一个无头双向非循坏链表(以存int类型元素为例)

用java语言模拟实现一个无头双向非循坏链表,首先必定要创建一个类来表示这个链表,类里面有一个静态内部类,两个成员变量,若干个成员方法。静态内部类是为了描述结点;成员变量是附设的头结点和尾结点,头结点永远指向链表中第一个结点,尾结点永远指向链表中最后一个结点;通过成员方法对链表进行增删改查。

public class MyLinkedList {
    static class ListNode{
        private int val;//元素
        private ListNode prev;//上一个结点的地址
        private ListNode next;//下一个结点的地址
        public ListNode(int val){
            this.val = val;//每存储一个元素,都要在堆上申请一个结点空间
        }
    }
    public ListNode head;//头结点
    public ListNode last;//尾结点

    //显示链表中的所有元素
    public void display(){

    }

    //得到链表的长度
    public int size(){

    }
    //查找关键字key是否在链表当中
    public boolean contains(int key){
        
    }

    //头插法
    public void addFirst(int data){ 
        
    }
    //尾插法
    public void addLast(int data){
        
    }
    //任意位置插入,第一个数据节点为1号下标
    public void addIndex(int index,int data){
        
    }
    //删除第一次出现关键字为key的节点
    public void remove(int key){
        
    }
    public void removeAllKey(int key){
        
    }
    //清空链表
    public void clear1(){
        
    }
    public void clear2(){

   }

}

3.1 遍历链表

  1. 怎么正向遍历?
    从头结点head开始,一路head.next,直到头结点head==null说明遍历结束。
  2. 不能直接拿头结点head遍历。因为如果直接拿head遍历,当遍历结束的时候,head==null,此时头结点head中国存的不是第一个结点的地址了。
    新建一个引用变量cur,让cur也指向第一个结点,就能让cur代替head遍历链表。这样既遍历了链表,也不会影响头结点head。
  3. 反向遍历链表同理。
  4. 时间复杂度:O(n)

3.11 显示链表中的所有元素

思路:

  1. 从前向后遍历链表中的所有节点,每遍历一个结点,打印结点中的元素。
  2. 从后向前遍历链表中的所有结点,每遍历一个结点,打印结点中的元素。
//显示链表中的所有元素
    public void display1(){//正向遍历
        ListNode cur = head;
        while (cur != null){
            System.out.print(cur.val+" ");
            cur = cur.next;
        }
    }
    public void display2(){//反向遍历
        ListNode cur = last;
        while(cur!=null){
            System.out.println(cur.val+" ");
            cur = cur.prev;
        }
    }

3.12 得到链表的长度

思路:创建一个计数器count从0开始,每遍历一个元素,计数器就+1。

//得到链表的长度
    public int size(){
        int count = 0;
        ListNode cur = head;
        while(cur != null){
            count++;
            cur = cur.next;
        }
        return count;
    }

3.13 查找关键字key是否在链表当中

思路:遍历链表中的结点,每遍历一个结点,把当前结点中的元素和key作比较。

//查找关键字key是否在链表当中
    public boolean contains(int key){
        ListNode cur = head;
        while(cur != null){
            if(cur.val == key){
                return true;
            }
            cur = cur.next;
        }
        return false;
    }

3.2 向链表中插入元素

3.21 头插

以下面这个双向链表为例,如何把元素100插入到链表的第一个位置?
在这里插入图片描述

思路:先让元素100所在的结点绑定链表的原结点,再更新头结点和尾结点。

  1. 先创建一个结点node,把元素存储到接结点node中
  2. 把头结点的地址存储到node中
  3. 把node的地址存储到头结点中
  4. 更新头结点和尾结点
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

要注意的是:

  1. 链表为空的情况需要单独拎出来处理:
    当链表不为空时,在链表中头插元素不影响尾结点。但是当链表为空时,在链表中头插元素后,头结点和尾结点都要更新。
    时间复杂度:O(1)。
//头插法
    public void addFirst(int data){
        ListNode node = new ListNode(data);
        //链表为空
        if(head == null){
            head = node;
            last = node;
            return;
        }
        //链表不为空
        node.next = head;
        head.prev = node;//检查:head不能为null,否则head.prev会空指针异常
        head = node;
    }

3.22 尾插

以下面这个双向链表为例,如何把元素100插入到链表的最后一个位置?
在这里插入图片描述

思路:

  1. 先创建一个结点node,把元素存储到接结点node中
  2. 尾结点中存储node的地址
  3. node中存储尾结点的地址
  4. 更新头结点和尾结点。
    在这里插入图片描述在这里插入图片描述在这里插入图片描述

要注意的是:

  1. 链表为空的情况需要单独拎出来处理:
    当链表不为空时,在链表中尾插元素不影响头结点。但是当链表为空时,在链表中尾插元素后,头结点和尾结点都要更新。

时间复杂度:O(1)。

//尾插法
    public void addLast(int data){
        ListNode node = new ListNode(data);
        //链表为空时
        if(head == null){
            head = node;
            last = node;
        }else{
            //链表不为空时
            last.next = node;
            node.prev = last;
            last = node;
        }
    }

3.33 已知结点的位置index,读取该结点的地址(假设头结点是一号位置)

该方法用private修饰,是提供给双向链表的内部方法addIndex()使用的。

思路:已知结点的位置是index,从一号位置开始遍历,向后遍历index-1步,就能到达要当前结点了。

//已知结点的位置index,读取该结点的地址(假设头结点是一号位置)
    private ListNode findIndexSubOne(int index){
        ListNode cur = head;
        for (int i = 1; i <= index-1; i++) {
            cur = cur.next;
        }
        return cur;
    }

3.34 任意位置index插入元素data

以下面这个单链表为例,如何把元素100插到链表的第三个结点前面?也就是如何让元素100所在的结点成为链表的第三个结点,链表中以原来第三结点为首的结点们跟在元素100所在的结点后面?假设第一个结点为1号位置,类推。
在这里插入图片描述

思路:

  1. 先创建一个结点node,把元素100存储到node中。
  2. 找到要插入的位置indexfindIndex(int index),这意味着得到了该位置的地址,该位置前驱结点的地址。
  3. 把node结点插入到index位置。先连接next域,再连接prev域。连接顺序都是先和后面节点绑定,再和前面结点绑定。
    在这里插入图片描述在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

要注意的是:

  1. 检查index的合法性:
    假设第一个结点是1位置,所以index的范围[1,size()+1]。
    • index等于size()+1包括两种情况,一种是链表为空时,插入元素;一种情况是在链表的末尾插入元素。以上两种情况下直接尾插元素更方便。
    • index等于1时,代表要插入位置是1号位置。一号位置没有前驱,此时相当于头插。
      在这里插入图片描述

时间复杂度:O(n)。

//任意位置插入,第一个数据节点为1号下标
    public void addIndex(int index,int data){
        //检查index是否合法
        if(index<1||index>size()+1){
            System.out.println("插入位置不合法");
            return;
        }
        //头插
        if(index == 1){
            addFirst(data);
            return;
        }
        //尾插
        if(index == size()+1){
            addLast(data);
            return;
        }
        //index在2号位置到最后一号位置之间
        ListNode node = new ListNode(data);
        //找要插入的位置cur
        ListNode cur = findIndexSubOne(index);
        node.next = cur;
        cur.prev.next = node;//检查:cur.prev不能为null,否则会报错空指针异常
        node.prev = cur.prev;
        cur.prev = node;
    }
    //已知结点的位置index,读取该结点的地址(假设头结点是一号位置)
    private ListNode findIndex(int index){
        ListNode cur = head;
        for (int i = 1; i <= index-1; i++) {
            cur = cur.next;
        }
        return cur;
    }

下面是测试:在这里插入图片描述

3.3 删除链表中的元素

3.31 已知元素key,读取元素key所在的结点

该方法用private修饰,是提供给双向链表的内部方法remove()使用的。

思路:遍历链表中的结点,每遍历一个结点,判断当前结点中存储的元素和元素key是否相等。
要注意法的是:
如果返回值是null,说明链表中没有元素key。

//已知元素key,读取元素key所在的结点
    private ListNode searchNode(int key){
        ListNode cur = head;
        while(cur != null){
            if(cur.val == key){
                return cur;
            }
            cur = cur.next;
        }
        return null;
    }

3.32 删除第一次出现关键字为key的节点

以下面双向链表为例,如何删除元素56?
在这里插入图片描述

思路:

  1. 先找到要删除的结点:这意味着同时还知道了要删除结点的前驱和后继。
    • 在代码中体现时,该结点的前驱和后继都不能为null。
    • 从前到后遍历链表,找到要删除的结点就停止。
  2. 连接要删除结点的前驱和后继:先让前驱结点的next域存储要删除节点的next域,再让后继结点的prev域存储要删除结点的prev域。在这里插入图片描述
    在这里插入图片描述

要注意的是:

  1. 一号结点没有前驱,在找要删除的结点的时候,虽然是在一号位置和最后一号位置之间找的,但是,当要删除结点是一号位置时,执行删除操作会报空指针异常。所以,要先单独判断一号位置是否是要删除的节点。
  2. 当链表中只有一个结点时,该结点还是要删除的结点,该一号结点既没有前驱也没有后继。删完以后链表为空,要更新尾结点last。
  3. 最后一号结点没有后继,在找要删除的结点的时候,虽然是在一号位置和最后一号位置之间找的,但是,当要删除结点是最后一个位置时,执行删除操作会报空指针异常。所以,前面元素判断过后,还要单独判断最后一号位置是否是要删除的节点。
  4. 删除操作的前提:链表不能为空。

时间复杂度:O(n)。

//删除第一次出现关键字为key的节点
    public void remove(int key){
        //链表为空时
        if(head == null){
            System.out.println("链表为空,操作无效");
            return;
        }

        //要删除结点是一号结点时
        if(head.val == key){
            head = head.next;
            //如果链表中只有一个结点,删完以后链表为空,要更新last
            if(head == null){
                last = null;
                return;
            }//链表中不止一个结点
            head.prev = null;
            return;
        }

        //要删除结点在二号位置和最后一号位置之间
        ListNode cur = searchNode(key);
        if(cur!=last){
            if(cur == null){
                System.out.println("要删除的元素不在链表中");
            }else{
                cur.prev.next = cur.next;//检查:cur.prev不能为空
                cur.next.prev = cur.prev;//检查:cur.next不能为空
            }
        }else{//要删除结点是最后一个结点时
            last = last.prev;
            last.next = null;
        }
    }
    //已知元素key,读取元素key所在的结点
    private ListNode searchNode(int key){
        ListNode cur = head;
        while(cur != null){
            if(cur.val == key){
                return cur;
            }
            cur = cur.next;
        }
        return null;
    }

测试用例:
在这里插入图片描述

3.33 删除存储了元素key的所有结点

思路:

  1. 依次遍历链表中的所有结点
  2. 若当前结点中的元素和key相等,就删除当前结点(删除的具体操作同3.32),删除后继续向后遍历链表。
  3. 若当前结点中的元素与key不相等, 也继续向后遍历链表。

要注意的是:

  1. 空链表不能进行删除
  2. 当要删除一号结点时,
    • 链表中只有一个结点时,删除完元素后链表为空,要更新尾结点。
    • 当链表中有多个结点,一号节点没有前驱,需要单独处理
  3. 当要删除的结点是最后一号结点时,该结点没有后继。这种情况要单独处理。
    时间复杂度:O(n)。
//删除值为key的所有节点
    public void removeAllKey(int key){
        //链表为空时:
        if(head == null){
            System.out.println("空链表");
            return;
        }
        //遍历链表中的所有结点
        ListNode cur = head;
        while(cur!=null){
            if(cur.val == key){//删除结点,并继续向后遍历
                //删除头结点
                if(cur == head){
                    head = head.next;
                    if(head!=null){
                        head.prev = null;
                    }else{
                        last = null;//链表中只有一个结点,删完后链表为空,更新头结点
                    }
                }else{//删除中间节点和尾巴结点
                    if(cur.next!=null){
                        cur.prev.next = cur.next;//检查:cur.prev不能为空
                        cur.next.prev = cur.prev;//检查:cur.next不能为空
                    }else{
                        last = last.prev;
                        last.next = null;
                    }
                }
                cur = cur.next;
            }else{//继续向后遍历
                cur = cur.next;
            }
        }

3.4 清空双向链表

思路:直接把头结点和尾结点都设置为null

要注意的是:
这虽然做到了清空链表的所有结点,即下一次用链表存储元素,该元素所在的结点是链表的第一个结点。但是链表中的原结点依然在堆中占据内存,这些内存并没有真正被释放。
如果想要删除链表中的结点并释放内存,需要遍历链表的所有结点,把每个结点中的引用数据类型变量都设置为空。

//清空链表
    public void clear1(){
        this.head = null;
        this.last = null;
    }
    
    public void clear2(){
        ListNode cur = head;
        while(cur != null){
            ListNode curNext = cur.next;
            cur.prev = null;
            cur.next = null;
             cur = cur.next;
        }
        head = null;
        last = null;
    }

4. 链表的优缺点以及应用场景

链表的缺点
单链表的物理结构不连续,没办法做到随机访问链表中的元素。

链表的优点

  1. 随用随分配,不会浪费空间。
  2. 向链表中插入或删除元素不需要挪元素。

链表的使用场景
链表适合存储动态数据,即经常对数据进行插入和删除的操作。

5. 附完整源码

在这里插入图片描述

public class MyLinkedList {
    static class ListNode{
        private int val;//元素
        private ListNode prev;//上一个结点的地址
        private ListNode next;//下一个结点的地址
        public ListNode(int val){
            this.val = val;//每存储一个元素,都要在堆上申请一个结点空间
        }
    }
    public ListNode head;
    public ListNode last;

    //得到链表的长度
    public int size(){
        int count = 0;
        ListNode cur = head;
        while(cur != null){
            count++;
            cur = cur.next;
        }
        return count;
    }
    //显示链表中的所有元素
    public void display1(){//正向遍历
        ListNode cur = head;
        while (cur != null){
            System.out.print(cur.val+" ");
            cur = cur.next;
        }
    }
    public void display2(){//反向遍历
        ListNode cur = last;
        while(cur!=null){
            System.out.println(cur.val+" ");
            cur = cur.prev;
        }
    }
    //查找关键字key是否在链表当中
    public boolean contains(int key){
        ListNode cur = head;
        while(cur != null){
            if(cur.val == key){
                return true;
            }
            cur = cur.next;
        }
        return false;
    }

    //头插法
    public void addFirst(int data){
        ListNode node = new ListNode(data);
        //链表为空
        if(head == null){
            head = node;
            last = node;
            return;
        }
        //链表不为空
        node.next = head;
        head.prev = node;//检查:head不能为null,否则head.prev会空指针异常
        head = node;
    }
    //尾插法
    public void addLast(int data){
        ListNode node = new ListNode(data);
        //链表为空时
        if(head == null){
            head = node;
            last = node;
        }else{
            //链表不为空时
            last.next = node;
            node.prev = last;
            last = node;
        }
    }
    //任意位置插入,第一个数据节点为1号下标
    public void addIndex(int index,int data){
        //检查index是否合法
        if(index<1||index>size()+1){
            System.out.println("插入位置不合法");
            return;
        }
        //头插
        if(index == 1){
            addFirst(data);
            return;
        }
        //尾插
        if(index == size()+1){
            addLast(data);
            return;
        }
        //index在2号位置到最后一号位置之间
        ListNode node = new ListNode(data);
        //找要插入的位置cur
        ListNode cur = findIndex(index);
        node.next = cur;
        cur.prev.next = node;//检查:cur.prev不能为null,否则会报错空指针异常
        node.prev = cur.prev;
        cur.prev = node;
    }
    //已知结点的位置index,读取该结点的地址(假设头结点是一号位置)
    private ListNode findIndex(int index){
        ListNode cur = head;
        for (int i = 1; i <= index-1; i++) {
            cur = cur.next;
        }
        return cur;
    }

    public static void main(String[] args) {
        MyLinkedList myLinkedList = new MyLinkedList();
        myLinkedList.addLast(12);
        myLinkedList.addLast(23);
        myLinkedList.addLast(34);
        myLinkedList.addLast(45);
        myLinkedList.display1();
        System.out.println();

        System.out.println("*****测试删除操作*****");
        myLinkedList.remove(12);//删第一个元素
        myLinkedList.display1();
        System.out.println();
        myLinkedList.remove(34);//删中间元素
        myLinkedList.display1();
        System.out.println();
        myLinkedList.remove(45);//删最后一个元素
        myLinkedList.display1();
        System.out.println();
        myLinkedList.remove(23);//删第一个元素,且链表中只有这个元素
        myLinkedList.display1();
        System.out.println();
        myLinkedList.remove(23);//空表时进行删除操作
    }

    //删除第一次出现关键字为key的节点
    public void remove(int key){
        //链表为空时
        if(head == null){
            System.out.println("链表为空,操作无效");
            return;
        }

        //要删除结点是一号结点时
        if(head.val == key){
            head = head.next;
            //如果链表中只有一个结点,删完以后链表为空,要更新last
            if(head == null){
                last = null;
                return;
            }//链表中不止一个结点
            head.prev = null;
            return;
        }
        //能走到这说明一号结点中没有存储key
        //要删除结点在二号位置和最后一号位置之间
        ListNode cur = searchNode(key);
        if(cur!=last){
            if(cur == null){
                System.out.println("要删除的元素不在链表中");
            }else{
                cur.prev.next = cur.next;//检查:cur.prev不能为空
                cur.next.prev = cur.prev;//检查:cur.next不能为空
            }
        }else{//要删除结点是最后一个结点时
            last = last.prev;
            last.next = null;
        }
    }
    //已知元素key,读取元素key所在的结点
    private ListNode searchNode(int key){
        ListNode cur = head;
        while(cur != null){
            if(cur.val == key){
                return cur;
            }
            cur = cur.next;
        }
        return null;
    }
    //删除值为key的所有节点
    public void removeAllKey(int key){
        //链表为空时:
        if(head == null){
            System.out.println("空链表");
            return;
        }
        //遍历链表中的所有结点
        ListNode cur = head;
        while(cur!=null){
            if(cur.val == key){//删除结点,并继续向后遍历
                //删除头结点
                if(cur == head){
                    head = head.next;
                    if(head!=null){
                        head.prev = null;
                    }else{
                        last = null;//链表中只有一个结点,删完后链表为空,更新头结点
                    }
                }else{//删除中间节点和尾巴结点
                    if(cur.next!=null){
                        cur.prev.next = cur.next;//检查:cur.prev不能为空
                        cur.next.prev = cur.prev;//检查:cur.next不能为空
                    }else{
                        last = last.prev;
                        last.next = null;
                    }
                }
                cur = cur.next;
            }else{//继续向后遍历
                cur = cur.next;
            }
        }
    }
    //清空链表
    public void clear1(){
        this.head = null;
        this.last = null;
    }
    public void clear2(){
        ListNode cur = head;
        while(cur != null){
            ListNode curNext = cur.next;
            cur.prev = null;
            cur.next = null;
             cur = cur.next;
        }
        head = null;
        last = null;
    }
}
  • 21
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值