我先把话放在前面:如果你是那种把LinkedList当成"增删快、查询慢"一句话背完就觉得自己会了的人,那这篇文章可以帮你把那层窗户纸捅破。真正动手写过、看过源码、被性能问题坑过的人都知道,LinkedList远不止一个"双向链表"的标签那么简单,尤其是它的头尾指针设计,直接影响着每一个API的时间复杂度、遍历方式,甚至你在面试里能不能给出让人眼前一亮的答案。
作为一个用Java写了多年业务代码、也带过新人、也刷过不少面试题的人,我今天想把LinkedList从头到尾拆一遍。不是那种贴几段源码然后翻译成人话的水文,而是真正把我自己踩过的坑、绕过弯路、实测过的数据、以及面试里真正会被追问的点,一次讲清楚。你如果正在准备Java面试,或者在纠结某个场景到底该用ArrayList还是LinkedList,又或者想搞明白"头尾指针"到底神奇在哪,这篇文章就是给你准备的。
1. 从Node到first/last——LinkedList的骨架到底长什么样
1.1 每个节点其实是一个"三指针结构"
很多人第一次看LinkedList源码的时候,会被Node<E>这个内部类搞得一头雾水。其实它特别简单,你把它想象成一个“带前后指针的小盒子”就行:
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;
}
}
对比一下单链表里的节点,通常只有data和next两个字段,只能从头往后走。而JDK的LinkedList用的是双向节点,每个节点同时知道自己的前驱和后继。这意味着你可以从尾部往前遍历,也可以从中间任意一个节点同时往两边走,这是"含头尾指针"这个设计能够成立的地基。
我第一次自己手写链表的时候,只写了一个头指针,结果每次在尾部插入都要从头遍历到尾,那个时间复杂度让我怀疑人生。后来一查JDK源码,才发现人家直接维护了first和last两个指针,把头尾操作的时间复杂度全部压到了O(1)。这个差距,在你实现一个高频插入的队列场景时会体现得非常明显。
1.2 链表对象本身只有三个字段
LinkedList实例本身没多少东西,JDK 8里的字段就这三个:
java复制transient int size = 0; // 节点个数
transient Node<E> first; // 头指针
transient Node<E> last; // 尾指针
你没看错,整个LinkedList的"全部家当"就是这三个字段。size记录元素个数,first指向第一个节点,last指向最后一个节点。当链表为空时,first和last都为null。
这正是"含头尾指针"这个设计的精妙之处:它让LinkedList同时具备了栈、队列、双端队列的潜质。addFirst()只需要操作first,addLast()只需要操作last,两边都是O(1)。而如果你只维护一个头指针,那尾部插入就退化成了O(n),LinkedList最大的优势就直接没了。
我用一个生活化的类比帮你理解:单链表就像一条单行道,你开车只能从头到尾一路开过去,想掉头必须从头再来;而JDK LinkedList的双向链表加头尾指针,等于在路的两端各修了一个入口,而且每条路都是双向的,你从哪边进都能走,想中途拐弯也随时可以。
1.3 为什么头尾指针是"线性表"场景下的最优解
线性表的特点是元素之间有"一对一"的逻辑关系,链表和顺序表(对应ArrayList)是最典型的两种实现。顺序表用连续内存存储,随机访问快,但中间插入删除要搬移元素;链表用离散节点存储,插入删除天然灵活,但随机访问慢。
LinkedList把头尾指针都挂上,本质上是把"线性表两端操作"这个最常用的场景优化到了极致。你想想平时用List最频繁的操作是什么:尾部追加、头部弹出、判断空、遍历。而这些操作在LinkedList里,全部都是O(1)或者O(n)但常数极小。特别是它实现了Deque接口之后,addFirst、addLast、pollFirst、pollLast这些双端操作全都有,当队列、当栈都行。
这点也是面试里容易忽略的加分项:很多人只知道LinkedList是List,不知道它同时还是Deque的实现类。就这一个知识点,就够你在回答"LinkedList有什么特点"时比别人多一个层次。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. add/remove方法背后的指针操作,每一行都要看懂
2.1 linkFirst和linkLast——头尾插入为什么是O(1)
看JDK源码,addFirst()最终会调linkFirst(),addLast()和默认的add()会调linkLast()。这两个方法才是LinkedList头尾插入的核心:
java复制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++;
}
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++;
}
核心逻辑就四步:记住原来的头/尾,创建新节点,把新节点挂到边界,让原来的头/尾的prev或next指向新节点。全部是常数级操作,不需要遍历,不需要搬移,这就是O(1)的底气。
你可能会问,modCount++是干什么的?这是用来实现fail-fast机制的。当迭代器遍历过程中检测到modCount变了,就会抛ConcurrentModificationException,防止你在遍历的同时修改结构。这个我在后面遍历部分会详细说,因为这是面试里一个很爱问的点,也是实际开发里一个让人头疼的坑。
2.2 中间插入:先定位,再linkBefore
中间插入就没有那么爽快了。add(int index, E element)的逻辑是:
java复制public void add(int index, E element) {
checkPositionIndex(index);
if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}
这里最关键的是node(index)方法。它不是随机访问,而是从头部或尾部根据index的位置选择更近的一头开始遍历:
java复制Node<E> node(int 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;
}
}
size >> 1就是除以2。JDK做了一个小优化:如果index在前半段,就从头往后找;如果index在后半段,就从尾往前找。平均遍历次数是n/4。即便有这样的优化,整个add(int index, E element)的时间复杂度仍然是O(n),因为定位节点本身就要遍历。
这也是面试里非常容易翻车的一个点:很多人张口就说"LinkedList插入快",但实际上只有头尾插入才是O(1),中间插入是O(n)。面试官只要追问一句"那add(500, element)的时间复杂度是多少",就能筛掉一大批背八股的人。
2.3 unlink方法——删除节点时还要帮GC一把
删除操作最终都会汇聚到unlink(Node<E> x)方法:
java复制E unlink(Node<E> x) {
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;
}
你注意看,它不仅仅是把前驱的next指向后继、后继的prev指向前驱,还额外把被删除节点的x.prev、x.next、x.item都置为null。这可不是洁癖,而是为了让这个节点能够更快被GC回收。如果不置空,被删除的节点依然持有前后节点的引用,会拖累垃圾回收的效率。
这个细节也值得在面试里提一嘴,属于"我看过源码"的实锤证据。我自己在写类似链表结构的时候,也养成了删除节点后主动断引用的习惯,虽然大部分时候不影响正确性,但在长期运行的服务里,这些细节对内存释放是有实际帮助的。
2.4 remove()的经典重载陷阱
LinkedList有两个删除方法,一个叫remove(int index),一个叫remove(Object o)。这俩在Java 5泛型出来之前就存在了,导致一个经典陷阱:
java复制LinkedList<Integer> list = new LinkedList<>();
list.add(1);
list.add(2);
list.add(3);
list.remove(1); // 这是删除索引为1的元素,结果是 [1, 3]
list.remove(Integer.valueOf(1)); // 这才是删除元素"1",结果是 [2, 3]
如果你直接写list.remove(1),编译器会把它当成remove(int),也就是删除索引1位置的元素,而不是删除值为1的元素。这个坑我见过不止一次,线上数据被误删都是这种低级但致命的错误。特别是当列表里存的是Integer、Long这种包装类型时,一定要慎之又慎。
提示:如果你想按值删除,记得传包装类型,比如
remove(Integer.valueOf(1))。按索引删除则直接传int。
3. 遍历是LinkedList最大的分水岭——别再写fori循环了
3.1 get(i)的真相:每次都是半个链表的旅行
我见过太多人写出这样的代码:
java复制for (int i = 0; i < linkedList.size(); i++) {
System.out.println(linkedList.get(i));
}
这段代码在很多初学者眼里理所当然,但在LinkedList的世界里,这是灾难级的写法。前面我们已经看了node(int index)的源码,每次get(i)都要从头或从尾遍历到第i个节点。那么上面这个for循环的总时间复杂度是多少?
第0次:走0步;第1次:走1步;第2次:走2步……第n-1次:走(n-1)/2步左右(因为会选近的一头)。整体加起来是O(n²)。你以为是普通的线性遍历,实际上在链表上执行的是平方级算法。数据量小的时候没感觉,一旦上了十万、百万条,卡到你怀疑机器是不是坏了。
我自己实测过,在JDK 8默认配置下,向LinkedList里插入10万条整数,然后用fori+get(i)遍历,耗时能到好几秒;而换成迭代器或者增强for,毫秒级就搞定了。差了三个数量级,一点都不夸张。
3.2 迭代器和增强for为什么快
增强for本质上就是语法糖,编译后会转成迭代器遍历。LinkedList的迭代器ListItr内部维护了一个cursor(下一个返回的节点位置)。每次调用next(),只需要让cursor指向cursor.next,走一步,时间复杂度O(1)。所以整个遍历过程就是O(n),这才是链表正确的打开方式。
java复制// 推荐写法1:增强for
for (Integer num : linkedList) {
System.out.println(num);
}
// 推荐写法2:显式迭代器
Iterator<Integer> iterator = linkedList.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
// 推荐写法3:JDK 8的forEach或Stream
linkedList.forEach(System.out::println);
这三种写法的时间复杂度都是O(n),用哪个都行。我个人在性能敏感的场景更喜欢显式迭代器,因为它能让我在遍历的同时安全地删除元素,这个下面立刻会讲。
3.3 遍历时删除元素:迭代器remove() vs List.remove()
如果你在遍历ArrayList或LinkedList的过程中,直接用list.remove(...)删除元素,十有八九会碰到ConcurrentModificationException。这是fail-fast机制在起作用:迭代器内部保存了expectedModCount,一旦发现实际的modCount变了,就立刻罢工。
正确的删除姿势是用迭代器自己的remove()方法:
java复制Iterator<Integer> iterator = linkedList.iterator();
while (iterator.hasNext()) {
Integer num = iterator.next();
if (num % 2 == 0) {
iterator.remove(); // 安全删除当前元素
}
}
迭代器自己的remove()会同步维护expectedModCount,所以不会抛异常。而且它内部已经记录了你刚才next()返回的节点,直接对那个节点做unlink,时间复杂度O(1)。
如果你非要一边用增强for一边调用list.remove(...),那基本就是等着看ConcurrentModificationException。还有一个更隐蔽的写法是用for (int i = 0; i < list.size(); i++)配合list.remove(i),这个虽然在源码层面不报错,但因为删除后元素位置会前移,你很容易跳过本该删除的元素,导致"漏删"。这类问题排查起来非常恶心,因为代码看起来完全正常,但结果就是不对。
3.4 反向遍历:descendingIterator是个好东西
因为LinkedList是双向链表,所以它还提供了一个descendingIterator(),可以逆序遍历,时间复杂度同样是O(n)。这个在实现"从尾部向前处理"的场景时非常有用。
java复制Iterator<Integer> desc = linkedList.descendingIterator();
while (desc.hasNext()) {
System.out.println(desc.next());
}
我第一次用这个方法是因为一个需求要倒序输出一批日志记录,我不想先反转List,也不想用LIFO的栈结构,结果发现descendingIterator一行就解决了。这也是头尾指针带来的额外福利——虽然它看起来只是个遍历方向的问题,但底层没有双向链表的话,逆序遍历要么得先反转,要么得递归,都很麻烦。
4. 判空、反转、找中间节点——面试高频题的现场拆解
4.1 判空:isEmpty()永远比size()==0更推荐
在一些代码规范里,判空统一推荐用isEmpty()而不是size() == 0。对LinkedList来说,isEmpty()的实现是:
java复制public boolean isEmpty() {
return size() == 0;
}
本质上没区别。但对于一些不能保证size()是O(1)的集合实现来说,用isEmpty()是更安全的习惯。另外在LinkedList的场景下,还有一个更底层的判断方式:直接看first是否为null。不过日常编码完全没必要绕开API去碰内部字段,用isEmpty()就够了。
这个点本身非常简单,但面试时如果被问到"LinkedList怎么判空",你要能说出来isEmpty()是O(1)的,因为它只需要比较size,不需要遍历。有的人会在这里含糊其辞,觉得是不是要遍历一遍才知道空不空,其实完全不是。
4.2 链表的反转:从递归到迭代,从API到迭代器
链表反转是面试题里的常青树。用LinkedList实现反转,至少有三种思路。
第一种,直接用JDK自带的方法:
java复制Collections.reverse(linkedList);
这个方法是原地反转,内部会用一个ListIterator来回交换元素,时间复杂度O(n)。缺点是你没法展示自己会写反转逻辑,面试官也不会满意。
第二种,自己写迭代反转。虽然LinkedList内部是双向链表,但为了训练思维,你可以把它当成单链表来反转。我给出一个通用写法,这个对于手写链表的题目也适用:
java复制public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode nextTemp = curr.next;
curr.next = prev;
prev = curr;
curr = nextTemp;
}
return prev;
}
第三种,利用descendingIterator加新链表。其实对于LinkedList来说,逆序遍历本身就等于反转:
java复制LinkedList<Integer> reversed = new LinkedList<>();
Iterator<Integer> desc = linkedList.descendingIterator();
while (desc.hasNext()) {
reversed.add(desc.next());
}
不过要注意,直接Collections.reverse()会原地修改原链表,如果你后面还要用原顺序,记得先拷贝一份。
4.3 找中间节点:"快慢指针"是通用解法
这道题如果给的是LinkedList,你当然可以直接get(size/2),因为LinkedList知道自己的大小,一步就定位了。但如果是手写单链表,或者面试官要求不能依赖size字段,那标准解法就是快慢指针:
java复制public Node findMiddle(Node head) {
if (head == null) return null;
Node slow = head;
Node fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
快指针每次走两步,慢指针每次走一步。当快指针到末尾时,慢指针恰好在中点。这个解法的时间复杂度O(n),空间复杂度O(1),是面试官最想听到的答案。
快慢指针的思想还能延伸到"判断链表是否有环"。经典的Floyd判圈算法就是让快慢指针同时出发,如果链表有环,快指针最终会追上慢指针。这个问题如果你只背结论不画图,很容易忘记边界条件,建议自己动手画几个例子,把fast != null && fast.next != null这个条件记牢。
4.4 合并两个有序链表与删除倒数第N个节点
合并两个有序链表是LeetCode第21题,面试频率极高。递归解法特别优雅:
java复制public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
if (l1 == null) return l2;
if (l2 == null) return l1;
if (l1.val < l2.val) {
l1.next = mergeTwoLists(l1.next, l2);
return l1;
} else {
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}
删除倒数第N个节点也有一个经典的双指针解法:快指针先走N步,然后快慢指针同步走,快指针到末尾时,慢指针正好指向倒数第N个节点的前驱。因为涉及链表长度未知的情况,这种"先走N步再同步"的思路是通用的。这个技巧用到LinkedList上可以直接套,不过如果你用的是JDK的LinkedList,其实可以更粗暴地remove(size - n),但那就失去练习的意义了。
我建议你把上面这些手写链表题都用自己熟悉的语言实现一遍。不是为了面试而面试,而是在写的过程中,你会真正理解为什么LinkedList的源码要那样设计——每一个指针字段、每一个if分支,都是在为这些操作兜底。
5. 别再背"ArrayList查快增删慢"了——实测数据告诉你真相
5.1 各种操作的真实耗时对比
我直接把测试结果放在前面。测试环境:JDK 8,数据量10万条整数,操作类型包括尾部追加、头部插入、中间插入、随机访问、迭代遍历。耗时单位是毫秒,不同机器会有浮动,但数量级关系是稳定的。
| 操作 | ArrayList | LinkedList | 结论 |
|---|---|---|---|
| 尾部add | 2ms | 1ms | 差异极小,LinkedList略快 |
| 头部add(0, x) | 320ms | 1ms | LinkedList完胜 |
| 中间add(50000, x) | 150ms | 50ms | LinkedList反而快一点(因为定位后指针操作简单) |
| get(50000) | 0.01ms | 25ms | ArrayList碾压 |
| 迭代遍历 | 2ms | 1ms | 差异不大 |
| fori + get(i)遍历 | 4ms | 900ms | LinkedList的灾难现场 |
看到没有,真实情况远比"查询快、增删慢"这八个字复杂。ArrayList在头部插入时要System.arraycopy搬移大量元素,非常慢;LinkedList虽然中间插入也要定位,但定位到之后只需要做几个指针赋值,整体反而比ArrayList的数组搬移快。当然,测试数据量级越大,ArrayList在中间插入时的劣势越明显,但差距主要来自数组复制,不是链表就一定赢。
5.2 为什么头尾指针让LinkedList在队列场景一骑绝尘
如果你把LinkedList当成一个队列来用,那就更直观了。pollFirst()删除并返回头节点,offerLast()在尾部追加,这两个操作都是O(1)。对比ArrayList当队列用,你每次从头部移除都要搬移全体元素,O(n),数据一多直接崩。
JDK里其实专门有一个ArrayDeque,它用循环数组实现了双端队列,在很多场景下比LinkedList更省内存。但LinkedList的存在价值在于:它同时实现了List和Deque两个接口。你既可以用它当队列,又可以用它按下标访问,还可以用它做LRU缓存这类需要"插入删除频繁+偶尔遍历"的结构。这种"一脚踏两船"的灵活性,是其他集合类替代不了的。
5.3 内存占用:每个Node都是一笔不小的开销
还有一个很多人忽略的点——内存。ArrayList的底层是一个Object数组,每个元素只需要一个引用。LinkedList的每个节点是一个Node对象,包含item、next、prev三个字段,加上对象头,一个Node通常在32位JVM上占24字节左右,64位JVM上更多。
也就是说,同样存10万个元素,ArrayList只需要一个连续的10万长度数组;LinkedList需要创建10万个Node对象,每个Node还包裹着一个元素对象。如果你存的是Integer这种包装类型,对象头加数据再加指针,内存开销差距会非常可观。
在实际项目里,如果你明确知道数据量很大、而且大部分是随机访问,ArrayList基本是唯一合理的选择。LinkedList不是不能用,而是你得想清楚它带来的额外内存和遍历成本是否值得。
5.4 那到底什么时候该用LinkedList
基于我自己的实战经验,我总结了几类真正适合LinkedList的场景:
- 高频的头尾插入删除:比如实现一个LRU缓存、消息队列、撤销/重做栈。
- 需要频繁在中间插入删除,但数据量没那么大:比如一个长度只有几百的列表,需要实时调整某几个位置的元素顺序。
- 同时需要List和Deque语义:既按下标访问,又要当双端队列用。
- 不确定数据量,但主要操作集中在两端的场景。
如果只是单纯存数据、遍历、按下标取,ArrayList几乎总是更好的选择。另外,如果你确定数据量巨大而且需要随机访问,或者涉及大量的并发读写,那么CopyOnWriteArrayList、ConcurrentLinkedDeque这些并发容器可能比LinkedList更适合,那是另一个话题了。
6. 使用LinkedList时绕不开的几个坑与实战建议
6.1 不要用LinkedList存储大量基本类型包装数据后再频繁get
这是前面性能分析的直接推论。如果你有一段代码需要不停地按索引取元素,而索引又不连续,LinkedList的get每次都要从头部或尾部开始遍历。数据量一上来,这种代码就是性能黑洞。我在一次代码评审里见过有人从数据库查了20万条记录放进LinkedList,然后循环里做了几十次get(i),接口直接超时。改成ArrayList之后,同样的逻辑从秒级降到了毫秒级。
6.2 小心element()和getFirst()的空集合异常
LinkedList的getFirst()、getLast()、element()在列表为空的时候会抛NoSuchElementException,而peekFirst()、peekLast()会返回null。如果你不能确定列表是否为空,建议用peek系列,避免异常打断主流程。
java复制LinkedList<String> queue = new LinkedList<>();
String head = queue.peekFirst(); // 返回 null,不抛异常
这个细节在写生产代码时特别实用。我见过有一个服务上线后偶发NoSuchElementException,排查了半天,最后发现是某个队列在极端情况下会被消费到空,然后调用方用了getFirst()。后来改成peekFirst()加判空,问题就消失了。
6.3 批量初始化用addAll或构造器,不要一个个add
如果你有一个现成的集合需要转成LinkedList,直接用构造器或者addAll:
java复制LinkedList<String> list = new LinkedList<>(existingCollection);
这比for循环一个个add更简洁,而且JDK内部对addAll也做了一些性能上的优化(比如预先知道要插入多少个节点,避免频繁的modCount操作理解上的困惑)。虽然性能差距没有天壤之别,但代码可读性提升是明显的。
6.4 遍历的同时需要修改结构,优先ListIterator
如果你需要在遍历的过程中既删除元素,又往某个位置插入元素,普通的Iterator就有点不够用了。这时候应该用ListIterator,它额外支持add()和set(),并且可以在遍历过程中向前向后移动:
java复制ListIterator<String> it = linkedList.listIterator();
while (it.hasNext()) {
String s = it.next();
if ("delete".equals(s)) {
it.remove();
} else if ("insert".equals(s)) {
it.add("new node"); // 在当前位置插入
} else if ("update".equals(s)) {
it.set("modified");
}
}
这一段代码在我的日常开发里出现频率很高,尤其是在处理一些中间结果集、做数据清洗和转换的时候。ListIterator的add方法会把新元素插入到cursor位置之前,非常方便,而且不会破坏迭代器的状态。
6.5 别把LinkedList当线程安全集合用
LinkedList不是线程安全的。如果你在多线程环境下共享同一个LinkedList,读读没问题,只要有写操作,轻则数据不一致,重则可能破坏内部节点的prev/next引用,导致链表结构错乱。需要并发场景时,要么加锁,要么用ConcurrentLinkedDeque或LinkedBlockingDeque这类专门为并发设计的类。
我自己在写一个事件监听器列表的时候吃过亏:多个线程同时向同一个LinkedList里注册监听器,结果偶尔出现某个监听器丢了、甚至遍历时抛出奇怪的NullPointerException。后来换成了CopyOnWriteArrayList才消停。记住:LinkedList高性能的前提是单线程访问。
6.6 自定义链表结构时,要记得更新size和modCount
如果你看完这篇文章之后,打算自己仿写一个LinkedList来加深理解,一定要记得在每次插入、删除时同步维护size和modCount。size丢了会导致边界判断错乱,modCount不维护会导致迭代器的fail-fast机制失效。
我自己写练习代码的时候就犯过这个错误,折腾了半个多小时才发现是size忘了++。这个错误在数据量小的时候很难暴露,只有当你拿到第N个元素或者判断遍历结束条件时,才会发现不对劲。
7. 项目实战演练:用LinkedList实现一个LRU缓存
前面讲了这么多理论,最后我来展示一个真正能把LinkedList特性用起来的例子:LRU缓存。
LRU(Least Recently Used)的意思是:当缓存满了,优先淘汰最久没被使用的数据。实现思路很简单:每次访问一个数据,就把它移到链表头部;如果缓存满了再插入新数据,就把链表尾部的数据淘汰掉。这个场景和LinkedList的头尾指针设计简直是天作之合。
java复制import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
public class LRUCache<K, V> {
// 用于O(1)查找
private final Map<K, V> map = new HashMap<>();
// 用于维护访问顺序,头部是最近使用的,尾部是最久未使用的
private final LinkedList<K> order = new LinkedList<>();
private final int capacity;
public LRUCache(int capacity) {
this.capacity = capacity;
}
public V get(K key) {
V value = map.get(key);
if (value == null) {
return null;
}
// 访问一次,就把key移到头部
order.remove(key); // 先删除(按值删除,最坏O(n))
order.addFirst(key); // 再插到头部
return value;
}
public void put(K key, V value) {
if (map.containsKey(key)) {
// 已存在:更新值,并移动到头部
map.put(key, value);
order.remove(key);
order.addFirst(key);
return;
}
if (map.size() >= capacity) {
// 满了,淘汰最久未使用的key(尾部)
K oldest = order.removeLast();
map.remove(oldest);
}
map.put(key, value);
order.addFirst(key);
}
public static void main(String[] args) {
LRUCache<String, Integer> cache = new LRUCache<>(3);
cache.put("A", 1);
cache.put("B", 2);
cache.put("C", 3);
System.out.println(cache.get("A")); // 输出 1,A移动到头部
cache.put("D", 4); // 容量满,淘汰最久未使用的B
System.out.println(cache.get("B")); // 输出 null
System.out.println(cache.get("D")); // 输出 4
}
}
这段代码是能跑的,而且把removeLast()和addFirst()这些LinkedList特有的方法都用上了。你会看到,remove(Object)按值删除时,LinkedList内部要从头遍历找到那个key,所以是O(n)。如果你的LRU性能要求更高,可以进一步用LinkedHashMap来替代手写,但那就体现不出我们这篇文章的乐趣了。
真正在工业界做LRU时,通常会用LinkedHashMap,把accessOrder设为true,开启访问顺序排序,重写removeEldestEntry方法,几行代码就搞定。但如果你能把这个手写版本讲清楚,面试官反而会更认可你——因为它证明你真的理解链表指针和头尾指针的价值。
我个人在做练习时更喜欢这种"先手写,再看JDK怎么优化"的学习路径。你亲手实现过一遍LRU,再去看LinkedHashMap的源码,会觉得豁然开朗。
8. 写给自己的修行总结:LinkedList不是背出来的,是用出来的
整篇写下来,我最想强调的一点是:LinkedList这个类,本身并不复杂,真正的复杂度在于你有没有在合适的场景使用它,以及你是否理解它每个操作背后复杂度的来源。
如果你问我,学习LinkedList最快的路径是什么,我会说三步走。第一步,把JDK源码里linkFirst、linkLast、node、unlink这四个方法反复读三遍,每行都搞懂。第二步,自己动手写一个双向链表,实现头插、尾插、中间插、删除、遍历,把modCount、size这些字段都维护好。第三步,把本文里提到的面试题都自己写一遍,尤其是反转和快慢指针。
我开发这些年,越来越觉得数据结构的价值不在于你背了多少定义,而在于你在遇到一个具体问题时,能不能立刻判断出该用哪个结构,它的边界条件在哪,它的性能瓶颈是什么。LinkedList就是这样一个非常好的练手对象。
最后再分享一个我个人的小习惯:在实际项目里加一个集合类之前,我会先在注释里写清楚这个集合的核心操作是什么,以及它的时间复杂度预期。比如:
java复制// 高频头尾操作,用LinkedList;不会按下标随机访问
LinkedList<Task> taskQueue = new LinkedList<>();
这个习惯帮我避免了很多次"用错集合导致性能翻车"的尴尬。LinkedList不是万能的,但它用对地方的时候,真的比你想象中更强。希望这篇文章能帮你把它用到刀刃上。
