LinkedList底层是链表实现的,那么我们首先说下什么是链表。
和上篇文章的数组相比,链表要相对于更复杂一点,两者也是非常基础、常用,而且在面试中同时出现的概率也是很大的。
上篇文章我们说到,数据是需要连续的内存空间来存储的,而链表刚好与它相反,链表是不需要连续的内存空间的,它是通过将好多的零散的内存使用“指针”串联起来使用,如果数组和链表都想在计算机中申请大小为10M的内存,而计算机中只有10M的零散内存,那么数组就会申请内存失败,链表就会成功。
既然链表在内存中都是零散的块,那么我们是怎么称呼这些小块块呢?
我们把这些块叫做“结点”,为了把每个结点都串联起来,结点中不仅会存储数据,还有存储下一个结点的地址。
那我们怎么称呼记录下个结点地址的指针呢?
我们把他们叫做“后继指针next”。
链表中最特殊的是头结点和尾结点,顾名思义,也就是链表的第一个结点和最后一个结点,第一个结点保存着链表的基地址,通过这个结点,我们就能定位到它,最后一个结点的指针指向的是NULL,说明这个是链表的最后一个结点。
LinkedList中元素的所有操作都是在这样的数据结构上进行的,大家可以脑补下。
链表也可以进行插入、删除和查询操作。数组在进行插入和删除操作的时候,会进行数据的搬移操作,因为数据要保证他的内存空间是连续的,而链表则不需要,因为他的内存空间本就不是连续的 ,它只需要改变相邻结点的指针改变就够了,所以速度是非常快的。
但是查询就不会那么快了,链表查询数据要一个一个的遍历结点,直到找到相应的结点。
链表还有双向链表和循环链表,我们要说的LinkedList就是一个双向链表,上面说到的单向链表是只存了后一个结点的地址,而双向链表呢,是同时保存了前一个和后一个共两个元素的地址,所以就可以很方便的获取到一个结点的前后两个元素,而且我们也可以从前后两个方向来遍历。
接下来我们看下LinkedList的源码:
首先是继承关系:
public class LinkedList<E>extends AbstractSequentialList<E>implements List<E>, Deque<E>, Cloneable, java.io.Serializable
也是同时继承了Cloneable 和 Serializable ,Deque是一个双端队列,说明在LinkedList中同时也支持对队列的操作。
LinkedList的属性只有三个:
transient int size = 0;transient Node<E> first;transient Node<E> last;
Node是它的一个内部类,用来保存数据:
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;}}
接下来,我们看下核心的 add方法,这次我还是在每行代码的上面添加上我的注释,帮助大家能够更好的理解。因为我的读者水平不一,所以必须要照顾到所有人:
/*** Appends the specified element to the end of this list.** <p>This method is equivalent to {@link #addLast}.** @param e element to be appended to this list* @return {@code true} (as specified by {@link Collection#add})*/public boolean add(E e) {linkLast(e);return true;}/*** Links e as last element.*/void linkLast(E e) {//保存 last 尾结点final Node<E> l = last;//将要保存的元素放到 新建一个结点中,final Node<E> newNode = new Node<>(l, e, null);// 这样这个新的节点就变成 了尾结点last = newNode;// 判断下如果这个尾结点为空,就说明这个链表是空的//那么这个新的结点就是 首结点。if (l == null)first = newNode;//如果不是空的,那么之前旧的尾结点的 next 保存的就是这个新结点elsel.next = newNode;size++;modCount++;}
接下来,addAll 方法有两个重载函数,前一个是调用的后一个,所以我们只说一个:
public boolean addAll(Collection<? extends E> c) {return addAll(size, c);}public boolean addAll(int index, Collection<? extends E> c) {//首先进行下标合理性检查,下面有这个方法checkPositionIndex(index);//将集合转换为 Object 数组Object[] a = c.toArray();int numNew = a.length;if (numNew == 0)return false;//定义下标位置的前置结点和后继结点Node<E> pred, succ;if (index == size) {//从尾部添加,前置结点是 之前的尾结点,后继结点为nullsucc = null;pred = last;} else {//从指定位置添加,后继结点是下标是index的结点;//前置结点是下标位置的前一个结点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;}private void checkPositionIndex(int index) {if (!isPositionIndex(index))throw new IndexOutOfBoundsException(outOfBoundsMsg(index));}
我们再说下根据下标来获取元素的方法 get
/*** Returns the element at the specified position in this list.** @param index index of the element to return* @return the element at the specified position in this list* @throws IndexOutOfBoundsException {@inheritDoc}*/public E get(int index) {//元素的下标检查checkElementIndex(index);return node(index).item;}/*** Returns the (non-null) Node at the specified element index.*/Node<E> node(int index) {// assert isElementIndex(index);//如果下标位置靠近链表前半部分,从头开始遍历if (index < (size >> 1)) {Node<E> x = first;for (int i = 0; i < index; i++)x = x.next;return x;//如果下标位置靠近链表后半部分,从尾部开始遍历} else {Node<E> x = last;for (int i = size - 1; i > index; i--)x = x.prev;return x;}}
从代码中可以看得出来,链表获取指定元素效率还是很低的,需要将元素遍历才能找到目标元素。需要的时间复杂度是O(n)的,但是我们在工作中不能仅仅利用复杂度分析就决定使用哪个数据结构。
数组简单易用,在实现上使用的是连续的内存空间,可以通过CPU的缓存机制,来实现预读,访问速度会比较快。而链表,由于内存不是连续的,所以不能通过这种方法来实现预读。链表本身没有大小限制,天然支持动态扩容,这也是和数组最大的区别。
如果你的程序对内存使用要求很高,那么就可以选择数组,因为链表中的每一个结点都需要消耗额外的内存去存储指向下一个结点的指针,所以内存消耗会翻倍,而且对于链表的频繁删除和插入,会导致频繁的内存申请和释放,造成内存碎片,就会引起频繁的GC(Garbage Collection 垃圾回收)。
414

被折叠的 条评论
为什么被折叠?



