这次我们接着上篇往下聊。上篇主要解决了链表的基础认知问题,包括什么是链表、单链表的核心结构以及最基本的遍历和查找思路。这篇重点放在实战层面,围绕Java里的LinkedList展开,同时把不同语言(C++、Python)下链表的常见写法、经典面试题和实际开发中容易踩的坑一并梳理清楚。如果你正在准备面试,或者刚接触链表想找一份能直接照做的实操笔记,这篇应该能帮上忙。
先提前说一句,这篇文章不是API文档的翻译版。LinkedList的每个方法怎么用,官方文档写得很清楚,但“什么时候该用LinkedList、什么时候千万别用”、“为什么遍历时删除会抛异常”、“为什么明明只是改了一个节点,整个链表却乱了”这类问题,文档里不会告诉你。我要聊的就是这些实操层面的东西。
1. 内容整体设计与思路拆解
1.1 先搞懂LinkedList到底是个什么东西
LinkedList在Java里是一个双向链表,这是理解它一切行为的基础。它实现了List接口和Deque接口,意味着它既能当列表用,也能当队列或者双端队列用。每个节点(Node)持有三个字段:item(存储的数据)、next(指向下一个节点的引用)、prev(指向上一个节点的引用)。
从内存布局来看,ArrayList底层是连续数组,逻辑上相邻的元素在物理内存里也是相邻的;LinkedList不一样,节点散落在堆内存的不同位置,靠引用串起来。这个差异直接导致了后续所有性能表现上的不同。
很多人误以为LinkedList插入删除一定比ArrayList快,这其实是个需要分情况讨论的结论。如果是按索引插入,LinkedList需要先遍历到目标位置,复杂度是O(n),然后修改前后节点的引用,这一步是O(1);ArrayList按索引插入需要移动后续所有元素,复杂度是O(n)但常数很大。所以理论上LinkedList每次插入都是“遍历O(n)+修改引用O(1)”,而ArrayList是“查下标O(1)+移动元素O(n)”,在小数据量下两者差距并不明显,大数据量下LinkedList在中间位置插入时反而可能更慢,因为遍历链表的开销比数组的批量移动更昂贵。
1.2 为什么上篇讲单链表,这篇直接切LinkedList
上篇用的单链表结构是理解链表概念的基石,但实际工程中,纯粹自己手写的单链表很少直接用于生产代码,因为它的能力太有限:只能从头往尾单向遍历,删除节点时必须知道前驱节点。而LinkedList是双向链表,解决了很多单链表的痛点。
从学习路径来说,先掌握单链表的指针操作逻辑,再上手LinkedList,会顺畅很多。因为你已经理解了“通过引用跳转访问下一个节点”的核心机制,看到LinkedList源码里的prev和next两个引用时,不会觉得陌生,反而会想“哦,这就是单链表多了一个反向指针”。
另一方面,面试中考察“手写链表反转”“判断链表是否有环”这类题目时,通常要求你自己定义节点类,而不是直接用LinkedList。所以我在这篇里会把“API使用”和“手写实现”两条线都覆盖到,前者解决实际开发问题,后者解决面试和底层理解问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 判空操作:isEmpty() 与 size() == 0 的选择
先看一个很多人忽略的细节:判断LinkedList是否为空,到底该用isEmpty()还是size() == 0?
从功能上看,两者结果完全一致。从源码上看,isEmpty()判断的是size == 0,而size()返回的就是size字段。两者本质是一样的,时间复杂度都是O(1)。但非要选一个,我建议用isEmpty(),理由是语义更清晰,代码读起来像是“这个集合是否是空的”,而size() == 0多了一层“先获取大小再比较”的思考成本,虽然很小,但在代码评审时显得不够干净。
还有一个更隐蔽的问题:如果你在遍历LinkedList的过程中修改了它的结构(比如删除了元素),然后再次调用size(),返回的可能是更新后的值,但如果你之前已经把一个旧的size值存在变量里,就会得到过期的结果。举个例子:
java复制LinkedList<String> list = new LinkedList<>(Arrays.asList("a", "b", "c"));
int oldSize = list.size();
list.removeFirst();
// oldSize 仍然是3,但实际list.size()已经是2
这种问题不常见,但排查起来非常隐蔽。我的习惯是:凡是需要在遍历过程中动态判断空或者大小,一律直接用list.isEmpty()或list.size(),不缓存旧值。
2.2 遍历的三种姿势与性能差异
LinkedList的遍历方式大致有三种:for循环配合get(i)、增强for循环(底层是迭代器)、显式使用Iterator。在很多初学者的代码里,会看到这样的写法:
java复制for (int i = 0; i < linkedList.size(); i++) {
System.out.println(linkedList.get(i));
}
这段代码功能没错,但性能极差。原因在于get(i)的源码实现里有一个node(int index)方法,它会从链表的头部或尾部开始遍历,直到找到第index个节点。也就是说,每调用一次get(i),都要从头(或者从尾部,取决于index更靠近哪一端)走一遍。整体时间复杂度是O(n^2),数据量一旦上到十万级别,程序会有肉眼可见的卡顿。
正确的遍历方式是用增强for循环或者迭代器。增强for循环本质上是迭代器的语法糖,遍历时每个节点只经过一次,时间复杂度是O(n)。显式用Iterator的好处是可以在遍历过程中安全地删除元素,后面会专门讲。
还有一种方式:forEach方法配合Lambda表达式,底层同样是基于迭代器,但因为引入了Lambda表达式的调用开销,在极大数据量下会比普通增强for循环略慢一点,不过差距并不明显,日常使用无需纠结。
2.3 遍历时删除元素:Iterator.remove() 是正解
这是LinkedList实操中最经典的一个坑。很多人写代码时想边遍历边删除符合条件的元素,自然写出这样的代码:
java复制for (String s : list) {
if (s.equals("delete")) {
list.remove(s);
}
}
运行起来会抛出ConcurrentModificationException。原因在于LinkedList内部维护了一个modCount字段,每次结构性修改(add、remove、clear等)都会让它自增。迭代器初始化时会记录当前的modCount到expectedModCount,每次调用next()时都会检查这两个值是否一致,如果不一致就抛出异常,以防止在迭代过程中出现无法预期的行为。
正确做法是使用迭代器自身的remove()方法:
java复制Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String s = iterator.next();
if (s.equals("delete")) {
iterator.remove();
}
}
迭代器的remove()方法会同步更新expectedModCount,所以不会触发并发修改异常。如果你使用的是JDK 8及以上版本,还可以直接用removeIf:
java复制list.removeIf(s -> s.equals("delete"));
removeIf内部同样使用迭代器完成过滤删除,是更简洁的写法。
2.4 插入和删除:头尾操作的优势
LinkedList实现了Deque接口,因此提供了addFirst、addLast、removeFirst、removeLast等方法。由于是双向链表,这些操作的时间复杂度都是O(1),这也是LinkedList和ArrayList相比最大的优势场景:作为队列或栈使用。
如果你的需求里涉及频繁的队头操作,用LinkedList很合适。Queue<Integer> queue = new LinkedList<>()这种写法是Java文档里推荐的,因为LinkedList既能从尾部入队(offer或add),也能从头部出队(poll或remove),天然就是一个队列。
但如果只是随机位置的插入删除,LinkedList并不占明显优势。例如在一个长度为10万的LinkedList中间删除一个元素,需要先遍历5万个节点找到目标,这个开销不能忽略。
3. 实操过程与核心环节实现
3.1 用LinkedList实现一个简单的缓存淘汰策略
作为实践环节,我们用LinkedList实现一个简易的LRU缓存思路。这里不引入LinkedHashMap,纯用LinkedList来演示“访问时移动节点”这个核心操作。
要求:容量固定为3,每次访问某个key,如果存在则把它移动到链表头部;如果不存在,则插入头部;如果容量已满,先删除尾部节点。
java复制import java.util.LinkedList;
public class SimpleLRU {
private final int capacity;
private final LinkedList<String> list;
public SimpleLRU(int capacity) {
this.capacity = capacity;
this.list = new LinkedList<>();
}
public void access(String key) {
if (list.remove(key)) {
// 如果存在,先移除再插入头部,相当于把它移到最前面
list.addFirst(key);
} else {
if (list.size() >= capacity) {
list.removeLast();
}
list.addFirst(key);
}
}
public void print() {
System.out.println(list);
}
public static void main(String[] args) {
SimpleLRU cache = new SimpleLRU(3);
cache.access("A");
cache.access("B");
cache.access("C");
cache.print(); // [C, B, A]
cache.access("A");
cache.print(); // [A, C, B]
cache.access("D");
cache.print(); // [D, A, C]
}
}
这段代码虽然不能算生产级的LRU(真正的LRU需要O(1)的查找,LinkedList的remove(key)是O(n)),但用来理解链表的有序性、头插尾删操作是非常直观的。
3.2 手写单链表反转的两种思路
面试题中最常见的就是“反转单链表”。这里我给出两种方案:迭代法和递归法。
节点定义:
java复制public class ListNode {
int val;
ListNode next;
ListNode(int val) {
this.val = val;
}
}
迭代法的核心思想:遍历链表时,把当前节点的next指向前一个节点。关键在于要提前保存下一个节点,否则指针一旦改变,就找不到后续节点了。
java复制public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next; // 先保存下一个节点
curr.next = prev; // 指针反转
prev = curr; // prev后移
curr = next; // curr后移
}
return prev; // 最后prev就是新链表的头
}
递归法需要变换一下思路:先反转后面的链表,再把当前节点的next的next指向当前节点,最后把当前节点的next置空。
java复制public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode newHead = reverseList(head.next);
head.next.next = head;
head.next = null;
return newHead;
}
这里容易绕晕的是head.next.next = head这一句。在递归回溯时,head是当前节点,head.next是已经反转完毕的子链表的尾节点,把尾节点的next指向当前节点,就完成了反转。调试时可以自己画图,只看代码很难懂,画一遍就清楚了。
3.3 判断链表是否有环:快慢指针
另一个高频题是判断链表是否有环。面试官通常期望的解法是快慢指针:快指针每次走两步,慢指针每次走一步,如果链表有环,两者一定会在环中相遇。
java复制public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head.next;
while (slow != fast) {
if (fast == null || fast.next == null) {
return false;
}
slow = slow.next;
fast = fast.next.next;
}
return true;
}
这个算法的时间复杂度是O(n),空间复杂度是O(1)。基于这个思路还可以继续问“找到环的入口节点”,感兴趣的话可以自己推导一下:当快慢指针相遇后,把一个指针挪回头部,两个指针都走一步,再次相遇的位置就是环的入口。这里推导过程不展开了,网上有很多清晰的图解。
3.4 C++结构体链表的基本写法
C++里链表通常用结构体配合指针实现,理解了这个写法,再看C#、Go等语言的实现都会觉得似曾相识。基本定义如下:
cpp复制struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
插入节点的操作要特别注意指针顺序。假设要在当前节点cur后面插入新节点newNode,正确顺序是:
cpp复制newNode->next = cur->next;
cur->next = newNode;
如果顺序反了,先执行cur->next = newNode,那么原来的下一个节点就丢了。这个顺序问题在单链表操作中反复出现,建议在纸上画两个节点练习几遍,熟练后就不会再错。
删除节点的操作则需要借助前驱节点。单链表无法直接拿到当前节点的前驱,所以必须从头遍历,拿到前驱后再执行:
cpp复制prev->next = target->next;
delete target;
这也是为什么实际工程中更倾向于使用双向链表的原因,删除操作会更灵活。
3.5 Python里实现链表逆序
Python的实现思路和C++完全一致,只是语法上更简洁。注意Python里没有指针的概念,变量赋值本质上就是引用的重新绑定。
python复制class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def reverse_list(head):
prev = None
curr = head
while curr:
next_node = curr.next
curr.next = prev
prev = curr
curr = next_node
return prev
写Python版时最容易踩的坑是把next_node = curr.next漏了,导致死循环或者丢失后续节点。还有一个细节:在修改curr.next之前必须保存原值,这个操作顺序和C++是一致的。
4. ArrayList与LinkedList怎么选?
4.1 常见的性能误解
关于ArrayList和LinkedList的对比,网上有很多说法,但有些是过时或者片面的。我们直接看实际场景:
- 频繁按索引访问:ArrayList完胜,O(1);LinkedList是O(n)。这一点没有任何争议。
- 频繁在头部插入删除:LinkedList胜出,O(1);ArrayList需要移动所有元素,O(n)。
- 频繁在尾部插入删除:两者都是O(1),但ArrayList的扩容偶尔有额外开销,均摊下来仍然是O(1)。
- 频繁在中间插入删除:查找开销(LinkedList遍历O(n) vs ArrayList移动O(n))加修改开销,实际表现需要测试,数据量小的时候差别不大。
有人会提到“LinkedList的Node对象本身有额外内存开销”,这是对的。每个Node对象除了存储数据,还需要两个引用字段(prev和next),加上对象头,内存占用明显高于ArrayList的连续数组。
4.2 一个实际的性能对比
我做一个简单的测试:向两种List的头部插入10万个整数,看耗时差异。
java复制long start = System.nanoTime();
List<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < 100000; i++) {
linkedList.add(0, i);
}
System.out.println("LinkedList cost: " + (System.nanoTime() - start) / 1_000_000 + " ms");
这段代码LinkedList大概在几毫秒内完成。换成ArrayList,同样的代码会慢到十几秒,因为每次add(0, i)都要把后面所有元素整体后移一位,10万次操作就是10万次的整体搬移。这种场景下LinkedList的优势是压倒性的。
反过来,按索引随机访问10万个元素,LinkedList的耗时大约是ArrayList的千倍以上,这也是O(n)和O(1)的真实差距。
我个人的选型原则很简单:如果拿不准,默认用ArrayList;只有明确需要频繁的队头操作,或者需要作为队列/双端队列使用时,才选择LinkedList。这个原则同样适用于绝大多数生产环境。
5. 链表在芯片设计中的应用场景
5.1 为什么EDA工具里会用到链表
你可能会好奇,热搜词里怎么会有“链表 芯片设计”。这其实是个很专业的交叉话题。在芯片设计流程中,EDA(电子设计自动化)工具需要处理大量的网表(netlist)数据,网表里有大量的逻辑门、引脚、信号线等元素,它们之间是图状的连接关系。链表在这里的价值是管理“同一信号连接了哪些引脚”这类一对多的关系。
典型场景:某个输出引脚需要连接到多个输入引脚,工具在解析网表时,把这些连接关系组织成一个链表,每个节点代表一个连接点。后续做布线优化时需要频繁遍历某个信号的所有扇出(fanout),链表提供了一种简单的管理方式。
5.2 和软件领域的区别
芯片设计里的链表实现通常是定制的内存池链表,不直接使用标准库里的LinkedList。原因在于:
- EDA工具处理的数据量极大(百万级节点起步),标准库链表每个节点独立分配内存的开销和碎片化问题会非常突出。
- 芯片设计的算法往往需要批量分配和释放节点,内存池可以一次分配一大块,然后从池中取出节点使用,避免频繁的malloc/free。
- 某些算法需要相邻节点在内存中尽量连续,以提高缓存命中率,这时候会退回去用数组加索引的方式(隐式链表)而不是传统指针链表。
这个案例给我们的启发是:链表不只是软件工程师的面试题,在需要频繁插入删除、数据规模庞大的算法场景中,它确实有实际的生产价值,只是实现方式会根据场景做调整。
6. 常见问题与排查技巧实录
6.1 经典问题速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
遍历时抛ConcurrentModificationException |
迭代过程中调用list的remove/add方法,modCount不匹配 | 使用Iterator.remove()或removeIf |
| for循环用get(i)遍历LinkedList非常慢 | get(i)每次都是O(n)遍历,整体O(n^2) | 改用增强for循环或迭代器 |
| 反转链表时出现循环引用或丢失节点 | 修改next前没有保存原值 | 先保存nextNode,再修改当前节点的指向 |
| C++删除节点后内存泄漏 | 只修改指针,没有delete节点 | 使用delete释放内存,并把前置节点的next置为null或指向后继 |
| 自定义链表访问越界 | 没有处理头节点为null的情况 | 在循环条件里显式判断while (node != null) |
| 用LinkedList当列表用按索引访问慢 | 底层是链表,不支持随机访问 | 改用ArrayList,或改用迭代器方式 |
6.2 一个真实项目里的坑:缓存队列的脏数据
一次做消息转发模块,有个需求是维护一个最近接收消息的ID列表,超过阈值就剔除最老的。最初用ArrayList实现,每次剔除最老的消息要从头部remove,数据量到几千条时系统延迟明显上升。后来换成LinkedList,头部删除变成O(1),问题立刻解决。
但新问题出现了:在遍历这个LinkedList判断消息是否重复时,用for (int i = 0; i < list.size(); i++) 的方式去get每个元素,导致整体性能还是差。排查后发现是两个问题叠加:LinkedList的get遍历是O(n^2),同时每次遍历都会创建新的迭代器。改成增强for循环之后,耗时进一步下降了。
这个经历想说明的是:选对数据结构只是一个开始,配套的操作方式同样重要。链表选好了,却用数组的思维去遍历,等于白选。
6.3 手写链表时的三条经验
第一,任何节点操作之前,先把可能被覆盖的引用保存下来。这是链表操作的黄金法则,写完代码后可以自查一遍,看有没有哪一行把某个next覆盖了但后面还在用。
第二,操作头节点时,一定要考虑头节点为null的情况。很多初学者写的反转方法在空链表或单节点链表上会直接崩溃,因为忽略了边界条件。
第三,画图比写代码更重要。我在面试前复习链表时,习惯先在草稿纸上画出三个节点,一步步模拟指针变化,写到代码里就不容易错。这个习惯在大学上数据结构课时管用,工作多年后依然管用。
7. 写在最后的实操心得
从LinkedList的源码到不同语言的手写实现,再到芯片设计中的特殊用途,链表的本质一直是“用引用的跳转来组织数据”。理解这一点之后,不管是面试题里的反转链表,还是工程里的缓存队列,本质上都是同一个思维模型。
我自己带新人时经常说一句话:链表的代码看完不算懂,必须自己在IDE里跑一遍,打断点看每一步的next指向哪里。等你能预估出每一步的输出时,这个知识点才算真正消化了。
如果你正在准备面试,建议把这篇里提到的反转链表、判断环、手写节点类这三件事在纸上独立画一遍再写一遍代码。如果你是在做实际项目,记住一条就够:频繁头尾操作选LinkedList,随机访问选ArrayList,迭代过程中别乱改结构。
后面有机会的话,我打算再写一篇关于LinkedHashMap和LRU缓存完整实现的文章,把链表和哈希表结合的典型工程案例拆开聊一聊,那是另一个非常有意思的话题。
