【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 MySingleLinkedList {
    static class ListNode{
        public int val;//存储当前元素,int类型
        public ListNode next;//存储下一个元素的地址
        public ListNode(int val){
            this.val = val;
        }
    }
    public ListNode head;//附设的头结点

    //穷举法创建一个链表
    public void createList(){
    
    }
    
    //显示链表中的所有元素
    public void display(){
        
    }
    //获取单链表的长度
    public int size(){
        
    }
    //查找单链表中是否包含元素key
    public boolean contains(int key){
        
    }
    //头插
    public void addFirst(int data){
        
    }
    //尾插
    public void addLast(int data){
        
    }
    //已知结点的位置,读取该结点的前一个结点
    private ListNode findIndexSubOne(int index){
       
    }
    //在任意位置插入元素,假设第一个结点为1号下标
    public void addIndex(int index,int data){
        
    }
   
    //找到关键字key的前驱
    private ListNode searchPrev(int key){

    }
    //删除第一次出现关键字为key的节点
    public void remove(int key){

    }
    //删除所有值为key的节点
    public void removeAllKey(int key){

    }
    //清空链表
    public void clear(){
        
    }
}

3.1穷举法创建一个链表

实际中基本不会用穷举法创建链表,通常都是通过头插尾插创建一个链表。这里用穷举法先创建一个链表,是为了方便理解链表的遍历,头插尾插的基础都是遍历链表。

public void createList(){
        ListNode node1 = new ListNode(12);
        ListNode node2 = new ListNode(23);
        ListNode node3 = new ListNode(34);
        ListNode node4 = new ListNode(45);
        ListNode node5 = new ListNode(56);
        node1.next = node2;
        node2.next = node3;
        node3.next = node4;
        node4.next = node5;
        this.head = node1;
    }

在这里插入图片描述

3.2 显示链表中的所有元素

思路:遍历链表的所有结点,每遍历一个结点,打印结点中的元素。

要注意的是:

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

时间复杂度:O(n)。

//显示链表中的所有元素
    public void display(){
        ListNode cur = head;
        while(cur != null){
            System.out.print(cur.val + " ");
            cur = cur.next;
        }
    }

3.3 获取单链表的长度

思路:创建一个计数器count从0开始,每遍历一个元素,计数器就+1。
时间复杂度:O(n)。

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

3.4查找单链表中是否包含元素key

思路:遍历链表中的结点,每遍历一个结点,把当前结点中的元素和key作比较。
时间复杂度:O(n)。

//查找单链表中是否包含元素key
    public boolean contains(int key){
        ListNode cur = head;
        while(cur!=null){
            if(cur.val == key){
                return true;
            }
            cur = cur.next;
        }
        return false;
    }

3.5 头插

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

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

  1. 先创建一个结点node,把元素data存储到结点node中
  2. 再把头结点的地址存储到node中
  3. 让头结点和node都指向元素data所在的空间。
    在这里插入图片描述
    在这里插入图片描述
    时间复杂度:O(1)。
//头插
    public void addFirst(int data){
        ListNode node = new ListNode(data);
        node.next = head;//把头结点的地址存储到node中
        head = node;//让头结点和node都指向元素data所在的空间。
    }

3.6 尾插

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

思路:

  1. 先创建一个结点node,把元素100存储到node中。
  2. 找到链表的最后一个结点,把node的地址存到最后一个结点中。在这里插入图片描述

要注意的是:

  1. 怎么找到链表的最后一个结点?
    链表的最后一个结点中,存储着一个null地址。也就是链表最后一个结点的地址域为null。
    从第一个结点开始遍历,当遍历到链表的最后一个结点时,遍历停止。继续遍历的条件是:cur.next!=null;遍历停止的条件是:cur.next==null
  2. cur不能为null,否则cur.next会空指针异常。即当链表为空的情况要特殊处理。
  3. 遍历链表的代码中,区分cur==nullcur.next==null
  • 变量cur里面存储的是当前结点的地址,指向当前结点。当cur==null时,cur不指向任何结点,可能代表链表的每个结点都遍历完了,也可能是当前链表为空。
  • cur.next里面存的是当前结点里面存储的地址。当cur.next ==null时,代表cur现在处于链表的最后一个结点。

时间复杂度:O(n)。

//尾插
    public void addLast(int data){
        ListNode node = new ListNode(data);
        ListNode cur = head;
        //特殊情况处理:当链表为空时,cur等于null,cur.next会越界。
        if(cur == null){
            head = node;
            return;
        }
        //找到链表中的最后一个结点
        while(cur.next!=null){//检查:cur不能为null
            cur = cur.next;
        }
        //把node的地址存到最后一个结点中
        cur.next = node;
    }

3.7 链表的读取(假设第一个结点为1号位置)

以下两个方法都是为了服务MySingleLinkedLis类的内部方法存在的,所以用了private进行封装。

3.71 已知结点的位置,读取该结点的前一个结点

思路:若当前结点的位置是index,从一号位置开始遍历,向后遍历index-2步,就能到达当前结点的前一个结点了。
要注意的是:

  1. 第一个结点没有前驱。下面代码能返回第二节点到最后一个结点之间的前驱,包括返回第二结点的前驱和最后一个结点的前驱。
//已知结点的位置,读取该结点的前一个结点
    private ListNode findIndexSubOne(int index){
        //判断位置是否合法:
        if(index<2||index>size()){
            System.out.println("位置不合法");
            return null;
        }
        ListNode cur = head;
        for (int i = 1;i <= (index-2);i++){
            cur = cur.next;
        }
        return cur;
    }

3.72 已知结点中存储的元素key,读取该结点的前一个结点

思路:遍历链表中结点,每遍历一个结点,判断存储在当前结点中的地址指向的对象的值是否和元素相等。
要注意的是:

  1. 第一个结点没有前驱。下面代码能返回第二节点到最后一个结点之间的前驱,包括返回第二结点的前驱和最后一个结点的前驱。
  2. 返回null,表示链表中,遍历完第二结点到最后一个结点,都没有找到元素key的前驱,也就是该链表中key不存在。
//找到关键字key所在结点的前驱
    private ListNode searchPrev(int key){
        ListNode cur = head;
        while(cur.next != null){
            if(cur.next.val == key){//检查:cur.next不能为null
                return cur;
            }
            cur = cur.next;
        }
        return null;
    }

3.8 在任意位置index插入元素

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

思路:先找到第二个结点(二号位置),从而能得到第二结点的地址和第三结点的地址。再让新节点先和第三结点链接,再和第二结点链接。

  1. 先创建一个结点node,把元素100存储到node中
  2. 找到要插入位置的前一个位置findIndexSubOne(int index):得到该位置的地址,和该位置中存储的下一个位置的地址。
  3. 把node结点插到链表的第三个位置。让node结点先和后面结点链接,再和前面结点链接
    在这里插入图片描述
    在这里插入图片描述

要注意的是:

  1. 检查index是否合法。
    index等于size()+1也是合法位置。index等于size()+1包括两种情况,一种是链表为空时,插入元素;一种情况是在链表的末尾插入元素。以上两种情况下直接尾插元素更方便。
    在这里插入图片描述
  2. 若要插入位置是一号位置,一号位置没有前驱,所以这种情况要单独拎出来。相当于头插。

时间复杂度:O(n)。

//在任意位置插入元素,假设第一个结点为1号下标
    public void addIndex(int index,int data){
        //判断位置是否合法:
        if(index<1||index>size()+1){
            System.out.println("位置不合法");
            return;
        }
        //头插
        if(index == 1){
            addFirst(data);
            return;
        }
        //尾插
        if(index == size()+1){
            addLast(data);
            return;
        }
        //其他位置插
        ListNode node = new ListNode(data);
        ListNode subNode = findIndexSubOne(index);
        node.next = subNode.next;
        subNode.next = node;
    }
    //已知结点的位置,读取该结点的前一个结点,设第一个结点为1号下标
    private ListNode findIndexSubOne(int index){
        ListNode cur = head;
        //让cur走index-2步
        for (int i = 1;i <= (index-2);i++){
            cur = cur.next;
        }
        return cur;
    }

3.9 删除首次出现的存储了元素key的结点

以下面点链表为例:怎么删除存储了元素60的结点?
在这里插入图片描述

思路:先确定当前结点的前驱,然后把当前结点中存储的后继的地址存储到当前结点的前驱中。

  1. 找到要删除结点的前驱searchPrev(int key):前驱结点中存储着要删除结点的地址。该地址指向要删除的结点。要删除的结点中又存储着要删除结点的后继结点的地址
    所以找到要删除结点的前驱意味着:得到了要删除结点的地址,和要删除结点的后继结点地址。
  2. 连接要删除结点的前后结点:让要删除结点的前驱结点中存储要删除结点中存储的后继结点的地址

在这里插入图片描述

要注意的是:

  1. 一号节点没有前驱,所以这种情况要单独拎出来讨论。
  2. 当链表为空时,不能执行删除操作。

时间复杂度:O(n)。

   //找到关键字key所在结点的前驱
    private ListNode searchPrev(int key){
        ListNode cur = head;
        while(cur.next != null){
            if(cur.next.val == key){//检查:cur.next不能为null
                return cur;
            }
            cur = cur.next;
        }
        return null;//链表中没有该元素
    }
    //删除第一次出现关键字为key的节点
    public void remove(int key){
        //key在头结点,删除头结点
        if(head.val == key){
            //head = null;
            head = head.next;
            return;
        }
        //链表为空时
        if(head == null){
            System.out.println("链表为空");
            return;
        }
        //key在第二结点到最后一个结点之间
        ListNode cur = searchPrev(key);//找到要删除结点的前驱
        if(cur == null){
            System.out.println("没有你要删除的元素");
            return;
        }
        ListNode del = cur.next;//前驱结点cur中存储着要删除结点的地址
        cur.next = del.next;//连接要删除结点的前后结点
    }

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

以下面单链表为例,如何把链表中所有存储了元素34的结点删掉?(只能遍历链表一遍)
在这里插入图片描述

思路:

  1. 遍历链表中所有结点
  • 若当前结点中的元素和key相等,就删除当前结点,删除后继续向后遍历链表。
  • 若当前结点中的元素与key不相等, 也继续向后遍历链表。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

要注意的是:

  1. 怎么删除当前结点:先确定当前结点的前驱,然后把当前结点中存储的后继的地址存储到当前结点的前驱中。
  2. 从链表的第二个结点开始遍历,而且每遍历一个元素,不仅要确定当前元素是否是要删除的元素,还要确定当前元素的前驱。此时若当前元素是要删除的元素,执行删除该元素的操作就非常方便。
  3. 头结点没有前驱,当要删除的元素是头结点时,这种情况需要最后单独处理
    • 头结点为什么必须放到最后处理:如果先判断头结点,再判断后面的元素,若头结点是要删除的元素,删除头结点后,在删后面元素的时候,还是会从第二结点(也就是新的头结点)开始遍历。此时若新的头结点还是要删除的元素,删完头结点后,新的头结点依然会遗漏。如果非要先判断头结点再判断后面元素,就搞个循坏,先删头结点,直到新的头结点不是要删除的元素,再删后面的元素。

时间复杂度:O(n)。

//删除所有值为key的节点
    public void removeAllKey(int key){
        //链表为空时
        if(head == null){
            System.out.println("链表为空");
            return;
        }
        //要删除的元素在第二结点到最后一个结点之间
        ListNode cur = head.next;
        ListNode prev = head;
        while(cur != null){
            if(cur.val == key){
                prev.next = cur.next;//删除当前结点
                cur = cur.next;//继续向后遍历
            }else {
                prev = cur;
                cur = cur.next;
            }
        }
        //若头结点也是要删除的元素时
        if(head.val == key){
            head = head.next;
        }
    }

3.11清空链表

思路:直接把头结点设置为null。

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

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

4. 分析单链表插入和删除操作的时间复杂度

单链表的插入和删除操作,都是

  1. 先遍历链表,找到要插入位置或删除结点的前驱,时间复杂度O(n)。
  2. 然后插入或删除元素,时间复杂度O(1)。

从整个插入算法或删除算法来说,插入和删除操作的时间复杂度都是O(n)。

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

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

链表的优点

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

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

6. 附完整源码

在这里插入图片描述

public class MySingleLinkedList {
    static class ListNode{
        public int val;//存储当前元素
        public ListNode next;//存储下一个元素的地址
        public ListNode(int val){
            this.val = val;
        }
    }
    public ListNode head ;//附设的头结点
    //穷举法创建一个链表
    public void createList(){
        ListNode node1 = new ListNode(12);
        ListNode node2 = new ListNode(23);
        ListNode node3 = new ListNode(34);
        ListNode node4 = new ListNode(45);
        ListNode node5 = new ListNode(56);
        node1.next = node2;
        node2.next = node3;
        node3.next = node4;
        node4.next = node5;
        this.head = node1;
    }
    //显示链表中的所有元素
    public void display(){
        ListNode cur = head;
        while(cur != null){
            System.out.print(cur.val + " ");
            cur = cur.next;
        }
    }
    //得到单链表的长度
    public int size(){
        int count = 0;
        ListNode cur = head;
        while(cur!=null){
            count++;
            cur = cur.next;
        }
        return count;
    }
    //查找单链表中是否包含元素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);
        node.next = head;
        head = node;
    }
    //尾插
    public void addLast(int data){
        ListNode node = new ListNode(data);
        ListNode cur = head;
        //特殊情况处理:当链表为空时,cur等于null,cur.next会越界。
        if(cur == null){
            head = node;
            return;
        }
        while(cur.next!=null){
            cur = cur.next;
        }
        cur.next = node;
    }
    //已知结点的位置,读取该结点的前一个结点,设第一个结点为1号下标
    private ListNode findIndexSubOne(int index){
        //判断位置是否合法:
        if(index<2||index>size()){
            System.out.println("位置不合法");
            return null;
        }
        ListNode cur = head;
        for (int i = 1;i <= (index-2);i++){
            cur = cur.next;
        }
        return cur;
    }

    //在任意位置插入元素,假设第一个结点为1号下标
    public void addIndex(int index,int data){
        //判断位置是否合法:
        if(index<1||index>size()+1){
            System.out.println("位置不合法");
            return;
        }
        //头插
        if(index == 1){
            addFirst(data);
            return;
        }
        //尾插
        if(index == size()+1){
            addLast(data);
            return;
        }
        //其他位置插
        ListNode node = new ListNode(data);
        ListNode subNode = findIndexSubOne(index);
        node.next = subNode.next;
        subNode.next = node;
    }
    

    //找到关键字key所在结点的前驱
    private ListNode searchPrev(int key){
        ListNode cur = head;
        while(cur.next != null){
            if(cur.next.val == key){//检查:cur.next不能为null
                return cur;
            }
            cur = cur.next;
        }
        return null;//链表中没有该元素
    }
    //删除第一次出现关键字为key的节点
    public void remove(int key){
        //key在头结点,删除头结点
        if(head.val == key){
            //head = null;
            head = head.next;
            return;
        }
        //链表为空时
        if(head == null){
            System.out.println("链表为空");
            return;
        }
        //key在第二结点到最后一个结点之间
        ListNode cur = searchPrev(key);//找到要删除结点的前驱
        if(cur == null){
            System.out.println("没有你要删除的元素");
            return;
        }
        ListNode del = cur.next;//前驱结点cur中存储着要删除结点的地址
        cur.next = del.next;//连接要删除结点的前后结点
    }
    //删除所有值为key的节点
    public void removeAllKey(int key){
        //链表为空时
        if(head == null){
            System.out.println("链表为空");
            return;
        }
        //要删除的元素在第二结点到最后一个结点之间
        ListNode cur = head.next;
        ListNode prev = head;
        while(cur != null){
            if(cur.val == key){
                prev.next = cur.next;//删除当前结点
                cur = cur.next;//继续向后遍历
            }else {
                prev = cur;
                cur = cur.next;
            }
        }
        //若头结点也是要删除的元素时
        if(head.val == key){
            head = head.next;
        }
    }
    //清空链表
    public void clear(){
        this.head = null;
    }
    public void clear2(){
        ListNode cur = head;
        while( cur!= null){
            ListNode curNext = cur.next;
            cur.next = null;
            cur = curNext;
        }
        head = null;
    }
}
  • 31
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值