链表与 LinkedList
链表是什么
链表也是一个比较基础的数据结构,数组与ArrayList 中提到数组占用连续的一片内存空间,在空间不足时进行扩容需要一块更大的连续的内存空间,但很多时候我们的空闲内存并不是连续的,因此可能出现空闲的总空间足以存储这么多的元素,但找不到一个连续的可以存储这么多元素的空闲空间。
链表对物理空间的连续性不做要求,每一个节点使用 next 和 pre 两个指针指向后面和前面的元素,因此链表对空间的利用率没有数组高,因为每个节点不仅仅要存储数据,还要多两个指针的大小。
链表的种类五花八门,链表也可以只存储 next 指针,这样就只能从前向后遍历,而且无法在指定节点的前面插入元素,所以为了方便一般使用的都是存储前后节点指针的链表,也叫双向链表。如果尾节点的 next 指针指向头节点,那么这就是一个循环链表。
以下就是一个常规链表节点的定义:
java
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;
}
}
对于一个链表而言,如果是在给定指针的位置添加或删除元素,那么速度很快,因为只需要更改指针指向的空间即可,不需要像数组那样迁移数据:
bash
1 -> 2 -> 3 -> 4 -> 5
| 2 的位置添加 6
1 -> 2 -> 6 -> 3 -> 4 -> 5 # 这里的元素 6 可以在内存中的任意位置
删除也是同理
bash
# 当前节点的前一个节点的下一个指针指向当前节点的下一个节点
current.prev.next = current.next
# 当前节点的下一个节点的上一个指针指向当前节点的上一个节点
current.next.prev = current.prev
所以如果在指定元素位置添加或删除元素,时间复杂度为 O(1),这里强调了指定元素,如果是根据值来进行添加或删除,找到这个值所在的位置也需要时间,而且链表不像数组那样有那么多的查找方式(例如有序数组中的二分查找)。
即使都需要查找 n 个元素才能找到某个值对应的节点,一般数组也比链表要快,因为 cpu 缓存的原因,数组一个访问可能把后续 n 个元素都加载进了 cpu 缓存,而链表通常每次访问都需要去内存操作。
Java 中的链表实现
java
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
// 链表元素数量
transient int size = 0;
// 头节点指针
transient Node<E> first;
// 尾节点指针
transient Node<E> last;
}
Node 其实就是每一个元素,其定义已经在上面介绍过了,使用 first 和 last 的原因是通常我们需要判断如果首尾节点为 null,则特殊处理,例如删除首节点时如果首节点为 null,则把首节点的 next 节点设为首节点。为了使首尾节点可以统一处理,LinkedList 设置了专门的首尾节点,这样在进行操作时就无需特殊处理了。
前面其实强调过,链表只在指定元素的前后或首尾节点进行插入删除操作时时间复杂度才为 O(1),否则由于需要遍历找到对应元素的原因,时间复杂度并不乐观,当然我们使用的时候也不能只看时间复杂度,举例:ArrayList 在扩容时默认是当前容量的 1.5 倍,那么如果当前元素 3GB,则下次会直接申请4.5GB的空间,即使不考虑剩余的内存大小,仅仅是拷贝当前的 3GB 元素也很耗时。
如果不考虑 ArrayList 的扩容,其实 ArrayList 在尾部新增元素也未必比 LinkedList 差,因为 LinkedList 需要 new 一个 Node 并交换指针,而 ArrayList 空间已经分配好了。
同时还需要注意,LinkedList 在遍历时一定要使用迭代器而不是普通 for 循环,普通 for 循环每次都需要从链表头部开始查找,性能很差。