百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 热门文章 > 正文

Java集合之LinkedList,数据结构原理,与ArrayList的区别对比

bigegpt 2024-09-11 01:05 17 浏览

一、LinkedList概述

1.初识LinkedList

LinkedList是基于链表实现的,所以先讲解一下什么是链表。链表原先是C/C++的概念,是一种线性的存储结构,意思是将要存储的数据存在一个存储单元里面,这个存储单元里面除了存放有待存储的数据以外,还存储有其下一个存储单元的地址(下一个存储单元的地址是必要的,有些存储结构还存放有其前一个存储单元的地址),每次查找数据的时候,通过某个存储单元中的下一个存储单元的地址寻找其后面的那个存储单元。

这么讲可能有点抽象,先提一句,LinkedList是一种双向链表,双向链表我认为有两点含义:

1、链表中任意一个存储单元都可以通过向前或者向后寻址的方式获取到其前一个存储单元和其后一个存储单元

2、链表的尾节点的后一个节点是链表的头结点,链表的头结点的前一个节点是链表的尾节点

2.LinkedList数据结构原理

LinkedList底层的数据结构是基于双向循环链表的,且头结点中不存放数据,如下:

既然是双向链表,那么必定存在一种数据结构——我们可以称之为节点,节点实例保存业务数据、前一个节点的位置信息和后一个节点位置信息,如下图所示:

3.私有属性

LinkedList中定义了两个私有属性:

private transient Entry<E> header = new Entry<E>(null, null, null);
private transient int size = 0;

header是双向链表的头节点,它是双向链表节点所对应的类Entry的实例。Entry中包含成员变量: previous, next,element。其中,previous是该节点的上一个节点,next是该节点的下一个节点,element是该节点所包含的值。

size是双向链表中节点实例的个数。

首先来了解节点类Entry类的代码。

private static class Entry<E> {
 E element;
 Entry<E> next;
 Entry<E> previous;
 Entry(E element, Entry<E> next, Entry<E> previous) {
 this.element = element;
 this.next = next;
 this.previous = previous;
 }
}

看到LinkedList的Entry中的"E element",就是它真正存储的数据。"Entry<E> next"和"Entry<E> previous"表示的就是这个存储单元的前一个存储单元的引用地址和后一个存储单元的引用地址。用图表示就是:

4.构造函数

LinkedList提供了两个构造方法,如下所示:

public LinkedList() {
 header.next = header.previous = header;
}
public LinkedList(Collection<? extends E> c) {
 this();
 addAll(c);
}

第一个构造方法不接受参数,将header实例的previous和next全部指向header实例(注意,这个是一个双向循环链表,如果不是循环链表,空链表的情况应该是header节点的前一节点和后一节点均为null),这样整个链表其实就只有header一个节点,用于表示一个空的链表。

执行完构造函数后,header实例自身形成一个闭环,如下图所示:

第二个构造方法接收一个Collection参数c,调用第一个构造方法构造一个空的链表,之后通过addAll将c中的元素全部添加到链表中。

5.四个关注点在LinkedList上的答案

关 注 点结 论LinkedList是否允许空允许LinkedList是否允许重复数据允许LinkedList是否有序有序LinkedList是否线程安全非线程安全

二、添加元素

首先看下LinkedList添加一个元素是怎么做的,假如我有一段代码:

1 public static void main(String[] args)
2 {
3 List<String> list = new LinkedList<String>();
4 list.add("111");
5 list.add("222");
6 }

逐行分析main函数中的三行代码是如何执行的,首先是第3行,看一下LinkedList的源码:

 1 public class LinkedList<E>
 2 extends AbstractSequentialList<E>
 3 implements List<E>, Deque<E>, Cloneable, java.io.Serializable
 4 {
 5 private transient Entry<E> header = new Entry<E>(null, null, null);
 6 private transient int size = 0;
 7 
 8 /**
 9 * Constructs an empty list.
10 */
11 public LinkedList() {
12 header.next = header.previous = header;
13 }
14 ...
15 }

看到,new了一个Entry出来名为header,Entry里面的previous、element、next都为null,执行构造函数的时候,将previous和next的值都设置为header的引用地址,还是用画图的方式表示。32位JDK的字长为4个字节,而目前64位的JDK一般采用的也是4字长,所以就以4个字长为单位。header引用地址的字长就是4个字节,假设是0x00000000,那么执行完"List<String> list = new LinkedList<String>()"之后可以这么表示:

接着看第4行add一个字符串"111"做了什么:

1 public boolean add(E e) {
2 addBefore(e, header);
3 return true;
4 }
1 private Entry<E> addBefore(E e, Entry<E> entry) {
2 Entry<E> newEntry = new Entry<E>(e, entry, entry.previous);
3 newEntry.previous.next = newEntry;
4 newEntry.next.previous = newEntry;
5 size++;
6 modCount++;
7 return newEntry;
8 }

addBefore(E e,Entry<E> entry)方法是个私有方法,所以无法在外部程序中调用(当然,这是一般情况,你可以通过反射上面的还是能调用到的)。

addBefore(E e,Entry<E> entry)先通过Entry的构造方法创建e的节点newEntry(包含了将其下一个节点设置为entry,上一个节点设置为entry.previous的操作,相当于修改newEntry的“指针”),之后修改插入位置后newEntry的前一节点的next引用和后一节点的previous引用,使链表节点间的引用关系保持正确。之后修改和size大小和记录modCount,然后返回新插入的节点。

第2行new了一个Entry出来,可能不太好理解,根据Entry的构造函数,我把这句话"翻译"一下,可能就好理解了:

1、newEntry.element = e;

2、newEntry.next = header;

3、newEntry.previous = header.previous;

header的引用地址为0x00000000,header.previous上图中已经看到了,也是0x00000000,那么假设new出来的这个Entry的地址是0x00000001,继续画图表示:

一共五步,每一步的操作步骤都用数字表示出来了:

1、新的entry的element赋值为111;

2、新的entry的next是header的引用地址,header的引用地址是0x00000000,所以新的entry的next即0x00000000;

3、新的entry的previous是header的previous,header的previous是0x00000000,所以新的entry的next即0x00000000;

4、"newEntry.previous.next = newEntry",首先是newEntry的previous,由于newEntry的previous为0x00000000,所以newEntry.previous表示的是header,header的next为newEntry,即header的next为0x00000001;

5、"newEntry.next.previous = newEntry",和4一样,把header的previous设置为0x00000001;

为什么要这么做?还记得双向链表的两个特点吗,一是任意节点都可以向前和向后寻址,二是整个链表头的previous表示的是链表的尾Entry,链表尾的next表示的是链表的头Entry。现在链表头就是0x00000000这个Entry,链表尾就是0x00000001,可以自己看图观察、思考一下是否符合这两个条件。

最后看一下add了一个字符串"222"做了什么,假设新new出来的Entry的地址是0x00000002,画图表示:

还是执行的那5步,图中每一步都标注出来了,只要想清楚previous、next各自表示的是哪个节点就不会出问题了。

至此,往一个LinkedList里面添加一个字符串"111"和一个字符串"222"就完成了。从这张图中应该理解双向链表比较容易:

1、中间的那个Entry,previous的值为0x00000000,即header;next的值为0x00000002,即tail,这就是任意一个Entry既可以向前查找Entry,也可以向后查找Entry。

2、头Entry的previous的值为0x00000002,即tail,这就是双向链表中头Entry的previous指向的是尾Entry。

3、尾Entry的next的值为0x00000000,即header,这就是双向链表中尾Entry的next指向的是头Entry。

三、查看元素

看一下LinkedList的代码是怎么写的:

public E get(int index) {
 return entry(index).element;
}

 1 // 获取双向链表中指定位置的节点 
 2 private Entry<E> entry(int index) { 
 3 if (index < 0 || index >= size) 
 4 throw new IndexOutOfBoundsException("Index: "+index+ 
 5 ", Size: "+size); 
 6 Entry<E> e = header; 
 7 // 获取index处的节点。 
 8 // 若index < 双向链表长度的1/2,则从前向后查找; 
 9 // 否则,从后向前查找。 
10 if (index < (size >> 1)) { 
11 for (int i = 0; i <= index; i++) 
12 e = e.next; 
13 } else { 
14 for (int i = size; i > index; i--) 
15 e = e.previous; 
16 } 
17 return e; 
18 }

get(int)方法首先判断位置信息是否合法(大于等于0,小于当前LinkedList实例的Size),然后遍历到具体位置,获得节点的业务数据(element)并返回。

注意:为了提高效率,需要根据获取的位置判断是从头还是从尾开始遍历。

这段代码就体现出了双向链表的好处了。双向链表增加了一点点的空间消耗(每个Entry里面还要维护它的前置Entry的引用),同时也增加了一定的编程复杂度,却大大提升了效率。

由于LinkedList是双向链表,所以LinkedList既可以向前查找,也可以向后查找,第10行~第16行的作用就是:当index小于数组大小的一半的时候(size >> 1表示size / 2,使用移位运算提升代码运行效率),从前向后查找;否则,从后向前查找

这样,在我的数据结构里面有10000个元素,刚巧查找的又是第10000个元素的时候,就不需要从头遍历10000次了,向后遍历即可,一次就能找到我要的元素。

注意细节:位运算与直接做除法的区别。先将index与长度size的一半比较,如果index<size/2,就只从位置0往后遍历到位置index处,而如果index>size/2,就只从位置size往前遍历到位置index处。这样可以减少一部分不必要的遍历。

四、删除元素

看完了添加元素,我们看一下如何删除一个元素。和ArrayList一样,LinkedList支持按元素删除和按下标删除,前者会删除从头开始匹配的第一个元素。用按下标删除举个例子好了,比方说有这么一段代码:

1 public static void main(String[] args)
2 {
3 List<String> list = new LinkedList<String>();
4 list.add("111");
5 list.add("222");
6 list.remove(0);
7 }

也就是我想删除"111"这个元素。看一下第6行是如何执行的:

1 1 public E remove(int index) {
2 2 return remove(entry(index));
3 3 }

 1 private E remove(Entry<E> e) {
 2 if (e == header)
 3 throw new NoSuchElementException();
 4 // 保留将被移除的节点e的内容
 5 E result = e.element;
 6 // 将前一节点的next引用赋值为e的下一节点
 7 e.previous.next = e.next;
 8 // 将e的下一节点的previous赋值为e的上一节点
 9 e.next.previous = e.previous;
10 // 上面两条语句的执行已经导致了无法在链表中访问到e节点,而下面解除了e节点对前后节点的引用
11 e.next = e.previous = null;
12 // 将被移除的节点的内容设为null
13 e.element = null;
14 // 修改size大小
15 size--;
16 modCount++;
17 // 返回移除节点e的内容
18 return result;
19 }

当然,首先是找到元素在哪里,这和get是一样的。接着,用画图的方式来说明比较简单:

比较简单,只要找对引用地址就好了,每一步的操作也都详细标注在图上了。

由于删除了某一节点因此调整相应节点的前后指针信息,如下:

e.previous.next = e.next;//预删除节点的前一节点的后指针指向预删除节点的后一个节点。

e.next.previous = e.previous;//预删除节点的后一节点的前指针指向预删除节点的前一个节点。

清空预删除节点:

e.next = e.previous = null;

e.element = null;

交给gc完成资源回收,删除操作结束。

与ArrayList比较而言,LinkedList的删除动作不需要“移动”很多数据,从而效率更高。

这里我提一点,第3步、第4步、第5步将待删除的Entry的previous、element、next都设置为了null,这三步的作用是让虚拟机可以回收这个Entry

五、LinkedList和ArrayList的对比

老生常谈的问题了,这里我尝试以自己的理解尽量说清楚这个问题,顺便在这里就把LinkedList的优缺点也给讲了。

1、顺序插入速度ArrayList会比较快,因为ArrayList是基于数组实现的,数组是事先new好的,只要往指定位置塞一个数据就好了;LinkedList则不同,每次顺序插入的时候LinkedList将new一个对象出来,如果对象比较大,那么new的时间势必会长一点,再加上一些引用赋值的操作,所以顺序插入LinkedList必然慢于ArrayList

2、基于上一点,因为LinkedList里面不仅维护了待插入的元素,还维护了Entry的前置Entry和后继Entry,如果一个LinkedList中的Entry非常多,那么LinkedList将比ArrayList更耗费一些内存

3、有些说法认为LinkedList做插入和删除更快,这种说法其实是不准确的:

(1)LinkedList做插入、删除的时候,慢在寻址,快在只需要改变前后Entry的引用地址

(2)ArrayList做插入、删除的时候,慢在数组元素的批量copy,快在寻址

所以,如果待插入、删除的元素是在数据结构的前半段尤其是非常靠前的位置的时候,LinkedList的效率将大大快过ArrayList,因为ArrayList将批量copy大量的元素;越往后,对于LinkedList来说,因为它是双向链表,所以在第2个元素后面插入一个数据和在倒数第2个元素后面插入一个元素在效率上基本没有差别,但是ArrayList由于要批量copy的元素越来越少,操作速度必然追上乃至超过LinkedList

从这个分析看出,如果你十分确定你插入、删除的元素是在前半段,那么就使用LinkedList;如果你十分确定你删除、删除的元素在比较靠后的位置,那么可以考虑使用ArrayList。如果你不能确定你要做的插入、删除是在哪儿呢?那还是建议你使用LinkedList吧,因为一来LinkedList整体插入、删除的执行效率比较稳定,没有ArrayList这种越往后越快的情况;二来插入元素的时候,弄得不好ArrayList就要进行一次扩容,记住,ArrayList底层数组扩容是一个既消耗时间又消耗空间的操作。

相关推荐

最全的MySQL总结,助你向阿里“开炮”(面试题+笔记+思维图)

前言作为一名编程人员,对MySQL一定不会陌生,尤其是互联网行业,对MySQL的使用是比较多的。对于求职者来说,MySQL又是面试中一定会问到的重点,很多人拥有大厂梦,却因为MySQL败下阵来。实际上...

Redis数据库从入门到精通(redis数据库设计)

目录一、常见的非关系型数据库NOSQL分类二、了解Redis三、Redis的单节点安装教程四、Redis的常用命令1、Help帮助命令2、SET命令3、过期命令4、查找键命令5、操作键命令6、GET命...

netcore 急速接入第三方登录,不看后悔

新年新气象,趁着新年的喜庆,肝了十来天,终于发了第一版,希望大家喜欢。如果有不喜欢看文字的童鞋,可以直接看下面的地址体验一下:https://oauthlogin.net/前言此次带来得这个小项目是...

精选 30 个 C++ 面试题(含解析)(c++面试题和答案汇总)

大家好,我是柠檬哥,专注编程知识分享。欢迎关注@程序员柠檬橙,编程路上不迷路,私信发送以下关键字获取编程资源:发送1024打包下载10个G编程资源学习资料发送001获取阿里大神LeetCode...

Oracle 12c系列(一)|多租户容器数据库

作者杨禹航出品沃趣技术Oracle12.1发布至今已有多年,但国内Oracle12C的用户并不多,随着12.2在去年的发布,选择安装Oracle12c的客户量明显增加,在接下来的几年中,Or...

flutter系列之:UI layout简介(flutter-ui-nice)

简介对于一个前端框架来说,除了各个组件之外,最重要的就是将这些组件进行连接的布局了。布局的英文名叫做layout,就是用来描述如何将组件进行摆放的一个约束。在flutter中,基本上所有的对象都是wi...

Flutter 分页功能表格控件(flutter 列表)

老孟导读:前2天有读者问到是否有带分页功能的表格控件,今天分页功能的表格控件详细解析来来。PaginatedDataTablePaginatedDataTable是一个带分页功能的DataTable,...

Flutter | 使用BottomNavigationBar快速构建底部导航

平时我们在使用app时经常会看到底部导航栏,而在flutter中它的实现也较为简单.需要用到的组件:BottomNavigationBar导航栏的主体BottomNavigationBarI...

Android中的数据库和本地存储在Flutter中是怎样实现的

如何使用SharedPreferences?在Android中,你可以使用SharedPreferencesAPI来存储少量的键值对。在Flutter中,使用Shared_Pref...

Flet,一个Flutter应用的实用Python库!

▼Flet:用Python轻松构建跨平台应用!在纷繁复杂的Python框架中,Flet宛如一缕清风,为开发者带来极致的跨平台应用开发体验。它用最简单的Python代码,帮你实现移动端、桌面端...

flutter系列之:做一个图像滤镜(flutter photo)

简介很多时候,我们需要一些特效功能,比如给图片做个滤镜什么的,如果是h5页面,那么我们可以很容易的通过css滤镜来实现这个功能。那么如果在flutter中,如果要实现这样的滤镜功能应该怎么处理呢?一起...

flutter软件开发笔记20-flutter web开发

flutterweb开发优势比较多,采用统一的语言,就能开发不同类型的软件,在web开发中,特别是后台式软件中,相比传统的html5开发,更高效,有点像c++编程的方式,把web设计出来了。一...

Flutter实战-请求封装(五)之设置抓包Proxy

用了两年的flutter,有了一些心得,不虚头巴脑,只求实战有用,以供学习或使用flutter的小伙伴参考,学习尚浅,如有不正确的地方还望各路大神指正,以免误人子弟,在此拜谢~(原创不易,转发请标注来...

为什么不在 Flutter 中使用全局变量来管理状态

我相信没有人用全局变量来管理Flutter应用程序的状态。毫无疑问,我们的Flutter应用程序需要状态管理包或Flutter的基本小部件(例如InheritedWidget或St...

Flutter 攻略(Dart基本数据类型,变量 整理 2)

代码运行从main方法开始voidmain(){print("hellodart");}变量与常量var声明变量未初始化变量为nullvarc;//未初始化print(c)...