一、LinkedList链表的存储图解
1.LinkedList底层存储数据由三部分组成,分别为:上一个节点的地址值(prev),下一个节点的地址值(next),存储的数据(data)。如下图所示:
二、LinkedList在Java中的底层实现
(一)LinkedList的常用的父接口及其祖宗接口
在Java源代码中,可以看出其常用的父接口有List<E>接口,而List<E>接口又继承于Collection<E>接口,由此可以推断出:LinkedList类父接口为List<E>,祖宗接口为Collection<E>。由于实现接口就要重写接口中的全部方法,可以推断出LiskedList<E>中具有List<E>与Collection<E>接口中的所有方法。
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
public interface List<E> extends Collection<E>
(二)LinkedList存储方式
由于LinkedList存储数据的特殊性,Java中没有满足该条件的基础数据类型,所以Java底层实现LinkedList时,使用内部类(Node<E>)的方式对数据进行封装。(注:内部类是供外部类使用的)底层代码如下:该内部类中提供有参构造,为该节点中的变量赋值。
private static class Node<E> {
//存储的数据
E item;
//下一个节点的地址值
Node<E> next;
//上一个节点的地址值
Node<E> prev;
//有参构造(为成员变量赋值)
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
(三)LinkedList成员变量
有三个成员变量,size用来记录集合的大小(存放了多少个元素),first记录第一个节点,last记录最后一个节点。
//该集合的大小
transient int size = 0;
//集合第一个元素
transient Node<E> first;
//集合最后一个元素
transient Node<E> last;
(四)LinkedList构造方法
有两个一个无参构造、一个有参构造,重点介绍一下有参构造。参数是Collection或其子类,而参数的泛型只能是E(创建LinkedList<E>时你所规定E的类型)或其子类型。
public LinkedList() {
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
在使用有参构造时会调用addAll(c)方法,而后在该方法中又调用重载的addAll(int index, Collection<? extends E> c)方法进行操作。
public boolean addAll(Collection<? extends E> c) {
return addAll(size, c);
}
public boolean addAll(int index, Collection<? extends E> c) {
checkPositionIndex(index);
Object[] a = c.toArray();
int numNew = a.length;
if (numNew == 0)
return false;
Node<E> pred, succ;
if (index == size) {
succ = null;
pred = last;
} else {
succ = node(index);
pred = succ.prev;
}
for (Object o : a) {
@SuppressWarnings("unchecked") E e = (E) o;
Node<E> newNode = new Node<>(pred, e, null);
if (pred == null)
first = newNode;
else
pred.next = newNode;
pred = newNode;
}
if (succ == null) {
last = pred;
} else {
pred.next = succ;
succ.prev = pred;
}
size += numNew;
modCount++;
return true;
}
以上可以拆分为6个详细的步骤:
1.检验索引合法性
该方法又套用以下三个方法,主要作用确保index在有效范围内。
checkPositionIndex(index);
//判断index是否在0到size之间
private void checkPositionIndex(int index) {
if (!isPositionIndex(index))
//如果下标越界,则程序中断抛出异常并输出outofBoundsMsg(int index)方法的返回值
throw new IndexOutOfBoundsException(outOfBoundsMsg(index));
}
//用来判断索引是否合法
private boolean isPositionIndex(int index) {
return index >= 0 && index <= size;
}
//当索引异常时,返回该索引值与集合大小
private String outOfBoundsMsg(int index) {
return "Index: "+index+", Size: "+size;
}
2.将集合转化为数组
将集合c转化为对象数组,并判断数组是否为空,为空则返回false添加失败。
Object[] a = c.toArray();
int numNew = a.length;
if (numNew == 0)
return false;
3.定位插入点(即index)前后的元素
根据插入位置的不同,定位插入点的前一个节点pred和后一个节点succ
Node<E> pred, succ;
if (index == size) {
succ = null;
pred = last;
} else {
succ = node(index);
pred = succ.prev;
}
(1)如果index==size则说明插入的元素实在最后,则将last地址值赋给pred,succ赋为null。
(2)否则先调用node(index)方法找到该位置上面的节点,并将地址值赋给succ,并将succ节点中的prev(前一个节点的地址值)赋值给pred。
Node<E> node(int index) {
//size>>1 向右偏移一位,相当于缩小2倍
//判断index在集合中间的左边还是右边
if (index < (size >> 1)) {
//在集合中间的左边,从第一个开始查该节点的next查到index值的前一个即可
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
//在集合中间的右边,从最后一个开始查该节点的prev查到index值的后一个即可
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
4.插入新节点
遍历2步骤中的对象数组,通过@SuppressWarnings("unchecked")注解消除强转警告(由于泛型类型擦除,编译器无法完全确认此转换的类型安全性,因此会生成未经检查的警告)。
将遍历后元素创建一个新的节点newNode对象。
先对3步骤中的pred节点进行判断:
- pred为空,说明是集合中没有元素,该newNode节点为第一个,将该newNode节点的地址值赋给first节点,并将该newNode节点的地址值赋给pred节点。(以上可以看出第一次循环的时候pred的地址值可能为空,当第二次时pred地址值一定不为空)
- pred不为空,则将newNode的地址值赋给pred的next(相当于将该链表中前一个节点与后一个节点相关联)
for (Object o : a) {
@SuppressWarnings("unchecked") E e = (E) o;
Node<E> newNode = new Node<>(pred, e, null);
if (pred == null)
first = newNode;
else
pred.next = newNode;
pred = newNode;
}
if (succ == null) {
last = pred;
} else {
pred.next = succ;
succ.prev = pred;
}
5.更新最后一个节点的指针
插入完成后,处理链表尾部:
- 如果succ为null,说明插入的是链表的末尾,更新last指向最后一个新节点。
- 否则,连接新节点和原来的后续节点,将pred的next指针指向succ,并将succ的prev指针指向pred。
if (succ == null) {
last = pred;
} else {
pred.next = succ;
succ.prev = pred;
}
6.更新链表大小和修改计数器
更新链表的size(增加新插入元素的数量),并增加修改计数器modCount,然后返回true表示插入成功。
size += numNew;
modCount++;
return true;
上面解释可能很抽象,以下是图解:
(五)常用方法
1.在开头添加元素public void addFirst(E e),将添加新的newNode节点的next指向first,并将原来的first节点的prev指向newNode即可,增加集合大小,增加修改计数器。
public void addFirst(E e) {
linkFirst(e);
}
private void linkFirst(E e) {
final Node<E> f = first;
final Node<E> newNode = new Node<>(null, e, f);
first = newNode;
if (f == null)
last = newNode;
else
f.prev = newNode;
size++;
modCount++;
}
2.在最后添加元素public void addLast(E e),原理与在开头添加元素差不多。
public void addLast(E e) {
linkLast(e);
}
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
3.获取指定下标的元素public E get(int index),其中用到的checkElementIndex(index)方法与node(index)方法在(四)LinkedList构造方法
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}
4.判断指定元素是否在集合中public int indexOf(Object o),从第一个节点找到最后一个节点,并对每个值与输入的值进行判断,如果存在让返回该位置索引,不存在则返回-1。(注意:由于该集合可以存null值,要对输入的值进行判断)
public int indexOf(Object o) {
int index = 0;
if (o == null) {
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null)
return index;
index++;
}
} else {
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item))
return index;
index++;
}
}
return -1;
}
5.根据指定索引删除元素public E remove(int index),首先对index索引进行校验,校验通过后调用unlink(Node<E> x)方法,判断要删除的元素所处的位置,进行不同的操作,最后把要删除的元素清空,减小集合大小,增加修改计数器。
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
删除的位置共有三种如图所示:
三、优缺点
优点
1.动态大小:
LinkedList是动态数据结构,不需要指定初始大小,可以根据需要动态地增加或减少元素。
2.高效的插入和删除操作:
在链表中进行插入和删除操作时,只需调整相应节点的引用,不需要像数组那样移动元素。因此,在链表头部和中间位置插入或删除元素时,操作时间复杂度为O(1)。
3.双向遍历:
由于是双向链表,LinkedList可以从头部和尾部两个方向进行遍历,增加了操作的灵活性。
缺点
1.高内存开销:
每个元素都存储在一个节点对象中,节点对象不仅包含元素本身,还包含指向前后节点的引用。因此,LinkedList的内存开销比ArrayList高。2.较慢的随机访问:
LinkedList不支持高效的随机访问,查找特定索引位置的元素需要从头部或尾部开始遍历,时间复杂度为O(n)。这使得它在频繁随机访问的场景中性能较差。
3.额外的引用操作:
在插入或删除节点时,虽然不需要移动元素,但需要调整节点的前后引用,对于较长的链表,这些操作可能带来一定的额外开销。4.两端访问的不安全性:
由于是双向链表,可以从头部和尾部进行操作,但在多线程环境下,双向访问可能导致并发修改问题。如果没有适当的同步机制,可能会出现数据不一致或意外行为。
总结
LinkedList在需要频繁插入和删除操作的场景中表现优异,尤其适合于不知道数据大小或者需要动态调整大小的应用场景。然而,其内存开销较高且随机访问性能较差,因此在需要频繁随机访问的情况下,ArrayList可能是更好的选择。同时,由于双向链表的结构,在多线程环境中需要额外注意同步问题,确保操作的线程安全。理解这些优缺点有助于在具体应用中选择合适的数据结构,从而优化程序性能和资源利用。