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

相关推荐
神仙别闹30 分钟前
基于java的改良版超级玛丽小游戏
java
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭1 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫1 小时前
泛型(2)
java
超爱吃士力架1 小时前
邀请逻辑
java·linux·后端
南宫生1 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石1 小时前
12/21java基础
java
李小白662 小时前
Spring MVC(上)
java·spring·mvc
GoodStudyAndDayDayUp2 小时前
IDEA能够从mapper跳转到xml的插件
xml·java·intellij-idea
菜鸡中的奋斗鸡→挣扎鸡2 小时前
滑动窗口 + 算法复习
数据结构·算法
装不满的克莱因瓶2 小时前
【Redis经典面试题六】Redis的持久化机制是怎样的?
java·数据库·redis·持久化·aof·rdb