Java基础数据结构之LinkedList与链表

一.链表

链表的形式从如下三个角度考虑:

1.有头还是无头

对于有头的,head节点的value域为空,而且插入数据时,必须插在head之后(不一定紧挨着),对于无头的,差在哪里都可以

2.单向还是双向

3.循环还是非循环

若循环,则是首尾相连

我们只讨论俩种链表:无头单向非循环链表和无头双向链表(我们即将提到的LinkedList在JDK1.7以后就是无头双向非循环链表)

1.无头单向非循环链表的模拟实现

首先要有一个内部类来表示各个节点,再在外部将节点组织起来

虽然这是个无头链表,但我们也需要定义一个节点,通过它来标记链表的开始,所以要定义一个head,只要它的val域不为空即可

头插法:

其实不加if的判断也可以,直接进行else也不会出现空指针异常的情况,只不过加上了显得考虑更全面

尾插法:

尾插法的关键是找到最后一个元素,所以要用cur.next!=null来判断,注意不是cur!=null;

那么上面的代码正确吗?想一下head==null的情况,此时就不能直接访问cur的next域,否则就会空指针异常,所以要如下修改:

指定下标插入元素:

先写一个雏形

首先要检查下标的合法性,不合法时要抛出一个自定义的异常:

其次,这个代码的关键是找到要插入下标的前一个节点,我们来看看前面这段代码对不对:

cur是记录当前节点,而prev是记录前驱节点,index--,直到index为0时,cur所在位置就是新节点该插入到位置,prev就是它的前驱节点。但想一个特殊情况,head=null时,首先检查下标合理性,如果能执行到这一步,就说明index=0,所以就直接出了while语句,进行下一个语句,但是,prev指向null,就会出现空指针异常的情况,所以要加一条判断head是否为空:

有人发现了,最后也没有用到cur,那可不可以改成下面的代码?

全面考虑,当head为null时,这段代码会出现bug吗?会不会由空指针的现象呢?答案是不会的。假设head==null,首先,能进入到这一步,就说明下标是合法的,所以index==0;但注意,按理说index已经等于0了,就不应该进入while语句了,但index-1是-1,的确不是0,就会进入while语句,就出错了,所以首先要进行如下修改:

然后再想,如果head==null,那么prev就是null,就不能访问next域了,否则就会空指针异常。所以这个代码也要加上判断head是否为空的代码,如下:

此时,如果可以进入到prev=head这一块,就说明head不为空,但又要考虑,这段代码支持在0下标添加元素吗?显然不支持,所以还得进行如下修改:

这样就没问题了,所以代码最终为:

不过,代码还可以继续优化。仔细想想,之后进行指定下标删除元素时也要进行寻找前驱节点的操作,所以我们可以把这一操作封装成一个方法,如下修改:

查找是否包含某元素:

删除第一次出现的数字域为val的节点:

这个写法对吗?首先判断是否包含val,如果包含,就进入if语句。那么,假如head.val==val,那还正确吗?不正确了,此时cur就是head,prev就是null,所以就没有prev.next,否则会有空指针异常。所以要进行如下修改:

这回就对了

删除所有值为key的节点:

首先判断链表是否为空,然后判断是否需要删除头节点。但有一个问题,如果删除了头节点后,新的头节点也是需要删除的,那该怎么办?这就需要将第二个if改成循环,而且不能return!!!因为是要删除所有key的节点,如下:

走到prev=head后,头节点就不是要删除的节点了,所以就可以进入到下一步,逐个寻找前驱节点。这样就对了。

后面寻找前驱节点这一段代码也可以修改为:

清除链表:

注意,这种写法只适用于value域是基本数据类型的。如果存放的是引用类型,就没有这么简单了,代码就应该如下修改:

也就是要加一句,将cur的value域置为null。

遍历并打印所有value:

2.无头双向非链表的模拟实现(LinkedList的模拟实现)

首先要有以上这几个成员变量

头插法:

首先判断链表是否为空,如果是空,就要对first和last都进行更新,反之进行头插。

尾插法:

指定位置插入:

首先还是检查下标是否合法,然后看链表是否为空,如果不为空,看看是不是头插或者尾插,如果不是,就去找下标对应的位置,最后要注意几个量的更新顺序,共有四个量要进行更新,分别是上一个节点的next域,新节点的prev和next域,cur节点的prev域,一般是从前往后更新,这样就能保证寻找到所有节点

查找是否包含某元素:

删除第一次出现的值为val的节点:

首先看是否包含该节点,如果包含,再看链表中是不是只有一个元素,如果是,则直接将first,last置为null;

再看是不是头节点和尾节点。这里的代码要注意,头节点的prev域一定是null,尾节点的next域一定是null,当修改了头节点和尾节点后,一定要记得修改这俩个域的值。

如果都不是上述情况,则就是一般情况。首先要找到这个待删除的节点,然后修改它上一个节点的next域和它下一个节点的prev域。

删除所有值为value的节点:

仔细看,会发现这段代码有错。如果包含这些节点,就会进入if,首先处理只有一个节点的情况,其次进行头节点的删除,这里就出现问题了

我们为什么要先处理只有一个节点的情况呢?就是为了防止空指针的情况。而当删除头节点到只剩一个节点且此节点也是需要删除的节点时,while代码块中first=first.next可以正常执行,但first.prev就不行了,因为当执行了第一条语句后,first就指向空了,就没有prev域了,所以还要进行修改:

把判断只有一个元素的情况放到删除头节点的代码内部。这样就对了。

遍历并打印所有val:

清除链表:

如果存放的是引用类型,则加上cur.val=null。

二.LinkedList的使用

1.基本成员

首先,它有一个静态内部类,用来表示每个节点,因为是双向的,所以会有prev域和next域。

然后有first和last,来记录链表的首尾。

2.LinkedList的构造

(1).无参构造

(2).有参构造

参数是一个实现了Collection接口的集合,它里面的元素必须是我们的LinkedList中存放的元素的子类或同类

3.插入元素

第一个是头插法,第二个是尾插法,都调用了另一个方法。先看

头插法:

看这个头插法,它一上来并没有先判断是否为空。而是先把它变成头节点,这时不会涉及到空指针问题。然后判断f是否为空,因为如果不为空,就要更新它的prev域。

尾插法:

尾插法也同理,先变成尾节点,再判断要不要更新prev域。

然后再看

在指定位置插入元素:

首先检查下标合法性,然后,判断是否为尾插法,反之使用LinkBefore,为啥要判断是否为尾插法呢?我们先看一下LinkBefore:

首先把newNode插入到succ前面,即让newNode的prev域指向succ.prev(这里不会有空指针异常问题,因为既然能走到这一步,就说明下标合法且下标不等于size,也就是说这个下标一定对应一个元素),让prev的next域指向succ。然后更新succ的prev域(也不会空指针异常)

然后就该更新succ的前一个节点的next域了,这时,succ一定有,但它的prev可不一定有,所以要判断一下,如果有,才更新,反之不更新。

添加全部元素:

先看前半部分,主要是在确定插入时的起始位置(succ)以及它的上一个节点(pred)。

首先检查下标是否合法,如果合法,再看是否是size,如果是的话,succ指向的就是last.next即空节点,pred指向的就是last。接着,走到下一步就说明index一定指向某个节点,所以succ就是node(index),perd就是它的前一个节点。

到这里,上半部分就执行完毕了,有几种可能,一种是prev指向last,一种是succ指向first,还有就是两者分别指向一个节点。

然后开始插入元素,主要分为两步,首先在prev这边添加元素,并不断向后移动prev;然后将这一段链表和succ连接上。具体请看代码:

通过for循环来添加元素,每一个新节点创建时,它的prev域都指向pred,next域都指向null,然后更新pred的next域,但在更新前要判断pred是否为null,避免出现空指针异常的问题,最后更新pred。

for循环走完,链表就基本上创建好了,然后就是把它和succ相连,也就是把pred的next域指向succ,再把succ的prev域指向pred,但在进行这一步时,要判断succ是否为null,如果是null,说明新链表的结尾就是原始链表的结尾了,就直接更新last即可。

最后还有一个在旧链表的末尾添加元素,也是调用上述方法:

4.删除元素

指定value域进行删除:

首先要判断value是否为null,如果是,则去找链表中value为空的节点去删除

如果不是空才能进入到else语句。为什么要判断是否为空?注意else中,会用到对象的比较,只有当o不为空时,才可以调用equals方法

删除节点时用到了unlink方法:

删除时还是要判断删除的是否是头节点或者尾节点,要删除一个节点,主线任务还是更新prev的next域以及next的prev域,然后还要讲待删除节点的prev和next置为null。起始就可以分为前半部分和后半部分,前半部分就是prev.next和x.prev

要想更新prev.next,首先要判断prev是否为空,如果prev==null,就说明在删除头节点,这就很简单了,直接更新first即可,这时也不用写x.prev=null了;如果不为空,就要更新,让prev.next指向原来的next,然后x.prev=null。

之后进入到后半部分,next.prev和x.next。逻辑还是同上。

注意,如果删除的是头节点,那么一定是进入第一个if和第二个else,如果是尾节点,就是第一个else和第二个if,反之就是俩个else

指定下标进行删除:

直接调用unlink即可

删除头节点或尾节点:

都调用了另一个函数:

首先看删除头节点:

因为它的返回值是被删除节点的value域,所以在删除之前,要先将该val记录下来,并把下一个节点记录下来,以防找不到下一个节点。然后把value域和next域置为空,也就是与链表断开联系。然后将first后移。然后该更新头结点的prev域为null,但在更新之前,要看first现在是不是空,如果是空,说明链表空了,就没有节点了,就得把last给更新了

再看删除尾节点:

和上面的思路类似,首先要记录下value域和前一个节点,然后将value域和prev域置为空,然后更新last,然后看是否需要更新当前last的next域为空,这就要看prev是否为空,如果是,就说明此时链表为空,就要把头节点也置为空,反之更新当前last的next域

5.获取某一结点

获取指定下标的节点:

调用了node函数:

大方向:如果在链表的前半部分,就从头开始查找,如果在链表的后半部分,就从尾开始查找

获取头节点或尾节点:

注意是否为空的判断。

6.修改某一结点的value域

这里不是插入元素,而是修改该节点的value域

7.返回指定元素对应的索引

返回指定元素第一次出现时的索引:

这个和node方法的思路类似,只不过用到了equals方法。

返回指定元素最后一次出现时的索引:

这个是从后往前找

三.LinkedList的遍历

和arrayList很像

第一种:

第二种:

第三种,用迭代器正向遍历:

注,这里是和ArrayList不同的,他用的是ListIterator接口,调用了LinkedList的listIterator方法(该方法可以生成一个迭代器)

第四种,用迭代器反向遍历:

为了了解正反遍历的区别,我们来看一下源码:

首先会new一个ListItr对象,这是LinkedList内部的一个内部类。

这是它的一些成员变量

lastReturned记录的是该对象所拥有的最后一次访问到的节点的记录,next表示的是下一次即将访问到的元素(注意,可不一定是下一个,也有可能是下一个,因为LinkedList是一个双向链表)

这是这个迭代器的构造方法,参数是一个下标,可以表示你想让迭代器从哪里开始遍历,当index==size时,就是指向了末尾后面,反之就调用node方法,注意,这个node方法是LinkedList的一个普通成员方法,这是静态内部类调用外部类的成员方法。

这一组方法是正向遍历的实现,首先是hasNext判断是否有下一个节点,然后用next返回下一个节点的value域主要就是更新lastReturned和next引用,都是向后走一个

这一组方法是反向遍历。注意,首先检查下标的合法性,然后看看是否有上一个元素,如果有,就开始更新lastReturned和next。注意,要是用来previous方法,就说明是从后开始遍历,那么,最初的next就指向null(这是构造方法给出的)所以第一次调用previous,更新后的lastReturned和next都指向last,并且最后返回last的value域。

反向遍历和正向遍历有区别,反向遍历时lastReturned和next指向的都是最后一次访问到的节点,而正向遍历时,next指向的时即将访问的节点。这是区别。

然后再看它的remove方法:

这个方法是删除最后访问到的元素,也就是删除lastReturned节点。这个方法就很好的告诉我们正向遍历和反向遍历时,为什么lastReturned和next的分布不同。我们来深度解析一下这个方法:

首先检查lastReturned是否为null,因为要删除的就是它,所以只有它不为空才能删除。如果不为空,就走向下一步,记录下待删除节点的下一个节点,并调用外部类的unlink方法去删除节点,然后就到了关键的一步,更新nextindex。如果删除时遍历是使用的正向遍历,那么nextindex就是next所在的位置,而所删除的节点在next之前,也就是nextindex之前少了一个下标,所以随之nextindex就要减一,同时,next不用进行更新,因为删完lastReturned之后就正好轮到了当前的next,这就是else语句;而如果遍历时是使用的反向遍历,那么删除的节点既是lastReturned,又是next,所以要更新next的值为lastnext,同时,不需要更新nextindex,因为nextindex应该指向next,而删了lastReturned之后,lastnext就补到了这个位置。

分析源码发现remove方法没有让lastReturned更新,仅仅是让它成了空。所以在使用remove之前都要用到next或previous方法

以上就是对ListItr这个内部类的分析。

讲到最后,有同学就有疑问了,正向遍历的时候,我们在调用listIterator方法时没有传参呀,可这个LinkedList只有一个有参数的listIterator方法呀?这是因为,它用到了父类List的listIterator方法,在接口List中,有一个无参的listIterator方法,如下:

四.LinkedList与ArrayList的区别

1.前者逻辑空间上连续,但物理空间上不一定炼狱;后者物理空间连续。

2.前者在指定下标插入删除元素时不需要进行对其他元素的移动,后者需要移动其他元素。但注意,这并不代表Linked List插入元素的效率就高,因为它还是得遍历链表从而找到要删除或插入的位置,时间复杂度是O(N),而ArrayList在指定下标时,虽然不用遍历数组,但还需要重新拷贝数组,时间复杂度也是O(N)。

3.前者头插和尾插时的时间复杂度为O(1);中间插入的时间复杂度为O(N),因为要遍历列表找到指定位置

后者在末尾添加元素的时间复杂度为O(1),指定下标或规定节点时的时间复杂度为O(N),主要复杂在数组拷贝上。

4.前者不用考虑容量,后者在空间不够时需要扩容,可能会有空间的浪费。

5.当随机想要访问某个下标的值时,前者需要从头或从尾遍历,时间复杂度为O(N),而后者直接访问即可,时间复杂度为O(1)。

6.前者适用于频繁地在任意位置插入删除节点的场景,后者适用于元素高效的顺序存储以及频繁地通过下标进行访问

相关推荐
腥臭腐朽的日子熠熠生辉20 分钟前
解决maven失效问题(现象:maven中只有jdk的工具包,没有springboot的包)
java·spring boot·maven
ejinxian21 分钟前
Spring AI Alibaba 快速开发生成式 Java AI 应用
java·人工智能·spring
杉之27 分钟前
SpringBlade 数据库字段的自动填充
java·笔记·学习·spring·tomcat
云 无 心 以 出 岫1 小时前
贪心算法QwQ
数据结构·c++·算法·贪心算法
圈圈编码1 小时前
Spring Task 定时任务
java·前端·spring
俏布斯1 小时前
算法日常记录
java·算法·leetcode
27669582921 小时前
美团民宿 mtgsig 小程序 mtgsig1.2 分析
java·python·小程序·美团·mtgsig·mtgsig1.2·美团民宿
爱的叹息1 小时前
Java 连接 Redis 的驱动(Jedis、Lettuce、Redisson、Spring Data Redis)分类及对比
java·redis·spring
程序猿chen1 小时前
《JVM考古现场(十五):熵火燎原——从量子递归到热寂晶壁的代码涅槃》
java·jvm·git·后端·java-ee·区块链·量子计算
松韬2 小时前
Spring + Redisson:从 0 到 1 搭建高可用分布式缓存系统
java·redis·分布式·spring·缓存