1955年,Allen Newell、Cliff Shaw和Herbert A. Simon在RAND Corporation和卡内基梅隆大学开发Information Processing Language(IPL)时,创造了链表这一数据结构。这个发明最初是为了支持早期人工智能程序——Logic Theory Machine和General Problem Solver。有趣的是,链表在诞生之初就是为解决复杂问题而生,而今天它却成了面试中最基础却又最容易出错的考题之一。
链表问题看起来简单:无非是改改指针指向。但正是这种"简单",让无数开发者在面试中栽了跟头。一个指针指向错了,整个链表就断了;忘记保存下一个节点,节点就丢了;边界条件没处理好,空指针异常就来了。这篇文章将系统梳理链表的核心操作模式,从虚拟头节点到快慢指针,从反转链表到环检测,配合LeetCode经典题目的完整Java实现,帮助你彻底掌握这一面试必考的数据结构。
链表的内存真相:为什么它比数组更"难搞"
理解链表的第一步是搞清楚它和数组在内存中的根本区别。数组在内存中是连续存储的,访问第i个元素只需计算一个地址:base_address + i * element_size。这种连续性带来了两个重要后果:一是$O(1)$的随机访问,二是极好的缓存局部性——当CPU读取一个元素时,相邻元素很可能已经被一起加载到缓存行中。
链表则完全不同。每个节点独立存储在堆内存的某个位置,节点之间通过指针链接。这种非连续存储意味着:
- 随机访问不可能:要找第i个节点,必须从头开始一个一个遍历,时间复杂度$O(n)$
- 缓存不友好:相邻节点在内存中可能相距甚远,每次访问都可能触发缓存未命中
- 额外空间开销:每个节点除了存储数据,还需要存储指向下一个节点的指针(单链表)或前后两个指针(双向链表)

但链表也有其独特优势:在任意位置插入或删除节点只需$O(1)$时间(前提是已经持有该位置的指针),而数组需要移动后续所有元素。这就是为什么链表在需要频繁插入删除的场景中仍然有一席之地。
虚拟头节点:消除边界条件的利器
链表操作中最容易出错的就是处理头节点。删除头节点、在头部插入、处理空链表……每种情况都可能需要特殊处理。虚拟头节点(Dummy Node)是一个不存储有效数据的哨兵节点,它的next指针指向真正的头节点。
看看删除链表中所有值为val的节点(LeetCode 203)这个问题。如果不用虚拟头节点:
public ListNode removeElements(ListNode head, int val) {
// 先处理头节点可能需要删除的情况
while (head != null && head.val == val) {
head = head.next;
}
if (head == null) return null;
// 再处理中间节点
ListNode curr = head;
while (curr.next != null) {
if (curr.next.val == val) {
curr.next = curr.next.next;
} else {
curr = curr.next;
}
}
return head;
}
使用虚拟头节点后,代码变得简洁:
public ListNode removeElements(ListNode head, int val) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode curr = dummy;
while (curr.next != null) {
if (curr.next.val == val) {
curr.next = curr.next.next;
} else {
curr = curr.next;
}
}
return dummy.next;
}
虚拟头节点的价值在于统一了所有节点的处理方式——不再有"头节点需要特殊处理"这个概念。这个技巧在合并链表、删除节点等场景同样适用。
反转链表:指针操作的教科书
反转链表(LeetCode 206)是链表操作的基础,也是面试中出现频率最高的题目之一。有两种经典实现方式。
迭代法:三指针轮转
核心思想是用三个指针prev、curr、next,依次将每个节点的next指针反向:
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;
}
时间复杂度$O(n)$,空间复杂度$O(1)$。关键点在于先保存curr.next再修改它,否则会丢失后续链表。
递归法:从后往前
递归的思路是先反转后面的链表,再把当前节点接到末尾:
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;
}
递归法的时间复杂度同样是$O(n)$,但空间复杂度为$O(n)$(递归调用栈)。理解递归的关键是:假设reverseList(head.next)已经正确返回了反转后的链表头,现在只需要把head接到这个链表的末尾。
快慢指针:链表的神器
快慢指针是链表算法中最强大的技巧之一。两个指针从同一位置出发,快指针每次走两步,慢指针每次走一步。这个简单的规则可以解决一系列看似复杂的问题。
找链表中点(LeetCode 876)
当快指针到达链表末尾时,慢指针正好在中间:
public ListNode middleNode(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
注意循环条件的顺序:fast != null在前,否则fast.next可能抛出空指针异常。当链表长度为偶数时,这个实现返回右中点(即第二个中间节点)。
环检测(LeetCode 141)
如果链表有环,快指针最终会追上慢指针:
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;
}
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
return true;
}
}
return false;
}
为什么快指针一定会追上慢指针?想象两个人在环形跑道上跑步,快的人速度是慢的人的两倍。快的人每跑一圈,就会比慢的人多跑一圈,最终一定会从后面追上慢的人。数学上可以证明:如果有环,快指针最多走$O(n)$步就会与慢指针相遇。
找环的入口(LeetCode 142)
这个问题更进一步:不仅要检测环,还要找到环的入口节点。这需要一点数学推导。
设链表头到环入口的距离为$a$,环入口到相遇点的距离为$b$,环的长度为$c$。当快慢指针相遇时:
- 慢指针走了$a + b$步
- 快指针走了$a + b + k \cdot c$步(绕了$k$圈)
- 快指针步数是慢指针的两倍:$a + b + k \cdot c = 2(a + b)$
化简得:$a = k \cdot c - b$,即$a = (k-1) \cdot c + (c - b)$。
这意味着:从链表头到环入口的距离,等于从相遇点绕$(k-1)$圈后再走$(c-b)$步的距离。代码实现:
public ListNode detectCycle(ListNode head) {
if (head == null) return null;
ListNode slow = head;
ListNode fast = head;
boolean hasCycle = false;
// 第一阶段:检测是否有环
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
hasCycle = true;
break;
}
}
if (!hasCycle) return null;
// 第二阶段:找环入口
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
双指针技巧:距离的艺术
快慢指针是双指针的一种特殊形式。双指针更广泛的应用是"距离固定"的场景。
删除倒数第N个节点(LeetCode 19)
直接思路是先遍历得到链表长度,再遍历到倒数第$n+1$个节点进行删除。但双指针可以一次遍历完成:
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode fast = dummy;
ListNode slow = dummy;
// fast先走n步
for (int i = 0; i < n; i++) {
fast = fast.next;
}
// 同时移动,fast到末尾时slow正好在倒数第n+1个位置
while (fast.next != null) {
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummy.next;
}
这里虚拟头节点再次发挥作用:当要删除的是头节点时,没有虚拟头节点就需要特殊处理。
相交链表(LeetCode 160)
判断两个链表是否相交的关键观察是:如果两个链表相交,从交点开始到末尾的部分完全相同。设链表A长度为$a$,链表B长度为$b$,公共部分长度为$c$。
技巧是让两个指针分别从A和B出发,走到末尾后跳到另一个链表继续走。每个指针都走$a + b - c$步,如果有交点,它们会在交点相遇:
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
if (headA == null || headB == null) return null;
ListNode pA = headA;
ListNode pB = headB;
while (pA != pB) {
pA = (pA == null) ? headB : pA.next;
pB = (pB == null) ? headA : pB.next;
}
return pA;
}
如果没有交点,两个指针会同时到达null,循环也会终止。
复合操作:多个技巧的组合
很多链表题目需要组合使用多种技巧。
回文链表(LeetCode 234)
要求$O(n)$时间和$O(1)$空间的解法,需要组合三个步骤:找中点、反转后半部分、比较:
public boolean isPalindrome(ListNode head) {
if (head == null || head.next == null) return true;
// 第一步:找中点
ListNode slow = head;
ListNode fast = head;
while (fast.next != null && fast.next.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 第二步:反转后半部分
ListNode secondHalf = reverseList(slow.next);
// 第三步:比较
ListNode p1 = head;
ListNode p2 = secondHalf;
boolean result = true;
while (p2 != null) {
if (p1.val != p2.val) {
result = false;
break;
}
p1 = p1.next;
p2 = p2.next;
}
// 可选:恢复链表
slow.next = reverseList(secondHalf);
return result;
}
private ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
return prev;
}
注意找中点时用fast.next != null && fast.next.next != null,这样当链表长度为偶数时,slow停在左中点,方便后续处理。
重排链表(LeetCode 143)
将链表从$L_0 \to L_1 \to \cdots \to L_{n-1} \to L_n$重排为$L_0 \to L_n \to L_1 \to L_{n-1} \to \cdots$:
public void reorderList(ListNode head) {
if (head == null || head.next == null) return;
// 第一步:找中点
ListNode slow = head;
ListNode fast = head;
while (fast.next != null && fast.next.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 第二步:反转后半部分
ListNode secondHalf = reverseList(slow.next);
slow.next = null; // 断开两半
// 第三步:交替合并
ListNode p1 = head;
ListNode p2 = secondHalf;
while (p2 != null) {
ListNode next1 = p1.next;
ListNode next2 = p2.next;
p1.next = p2;
p2.next = next1;
p1 = next1;
p2 = next2;
}
}
排序链表(LeetCode 148)
要求$O(n \log n)$时间和$O(1)$空间,归并排序是唯一选择。关键在于如何$O(1)$空间找到中点并分割:
public ListNode sortList(ListNode head) {
if (head == null || head.next == null) return head;
// 找中点并断开
ListNode slow = head;
ListNode fast = head.next; // 让slow停在左中点
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
ListNode mid = slow.next;
slow.next = null;
// 递归排序两半
ListNode left = sortList(head);
ListNode right = sortList(mid);
// 合并
return merge(left, right);
}
private ListNode merge(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode curr = dummy;
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
curr.next = l1;
l1 = l1.next;
} else {
curr.next = l2;
l2 = l2.next;
}
curr = curr.next;
}
curr.next = (l1 != null) ? l1 : l2;
return dummy.next;
}
K个一组翻转链表(LeetCode 25)
这是反转链表的进阶版本,需要处理剩余不足k个的情况:
public ListNode reverseKGroup(ListNode head, int k) {
// 检查剩余是否有k个节点
ListNode curr = head;
int count = 0;
while (curr != null && count < k) {
curr = curr.next;
count++;
}
if (count < k) return head; // 不足k个,不翻转
// 翻转前k个
ListNode prev = null;
ListNode curr2 = head;
for (int i = 0; i < k; i++) {
ListNode next = curr2.next;
curr2.next = prev;
prev = curr2;
curr2 = next;
}
// head现在是翻转后的尾节点,递归处理剩余部分
head.next = reverseKGroup(curr2, k);
return prev; // prev是翻转后的头节点
}
特殊场景:随机指针与复杂结构
复制带随机指针的链表(LeetCode 138)
这道题的难点在于:复制节点时,随机指针可能指向尚未创建的节点。有两种解法。
哈希表法需要$O(n)$额外空间:
public Node copyRandomList(Node head) {
if (head == null) return null;
Map<Node, Node> map = new HashMap<>();
// 第一遍:创建所有节点
Node curr = head;
while (curr != null) {
map.put(curr, new Node(curr.val));
curr = curr.next;
}
// 第二遍:设置指针
curr = head;
while (curr != null) {
map.get(curr).next = map.get(curr.next);
map.get(curr).random = map.get(curr.random);
curr = curr.next;
}
return map.get(head);
}
$O(1)$空间解法的巧妙之处在于将新节点"穿插"在原链表中:
public Node copyRandomList(Node head) {
if (head == null) return null;
// 第一步:在每个原节点后插入复制节点
Node curr = head;
while (curr != null) {
Node copy = new Node(curr.val);
copy.next = curr.next;
curr.next = copy;
curr = copy.next;
}
// 第二步:设置random指针
curr = head;
while (curr != null) {
if (curr.random != null) {
curr.next.random = curr.random.next;
}
curr = curr.next.next;
}
// 第三步:分离两个链表
Node copyHead = head.next;
curr = head;
while (curr != null) {
Node copy = curr.next;
curr.next = copy.next;
if (copy.next != null) {
copy.next = copy.next.next;
}
curr = curr.next;
}
return copyHead;
}
链表问题的通用解题思路
-
画图:链表操作最容易出错的就是指针丢失。在写代码前,先画出节点和指针的变化过程。
-
虚拟头节点:当操作可能影响头节点时,先创建虚拟头节点。
-
保存后续节点:修改
next指针前,先保存原来的next。 -
处理边界条件:空链表、单节点链表、双节点链表是测试的重点。
-
快慢指针场景识别:中点、环、倒数第n个,都是快慢指针的典型应用。
-
组合思维:复杂问题往往是多个基础操作的组合——找中点+反转+合并。
链表算法的核心不是背诵代码模板,而是理解指针操作的底层逻辑。每一步赋值操作都要问自己:这个指针原来的值是什么?新值从哪里来?原来的值还需要吗?养成这种思维习惯,链表问题就不再是难题。
参考题目索引
| 题号 | 题目名称 | 核心技巧 |
|---|---|---|
| 206 | 反转链表 | 双指针、递归 |
| 141 | 环形链表 | 快慢指针 |
| 142 | 环形链表II | 快慢指针+数学推导 |
| 21 | 合并两个有序链表 | 双指针、递归 |
| 19 | 删除链表的倒数第N个节点 | 双指针、虚拟头节点 |
| 876 | 链表的中间节点 | 快慢指针 |
| 160 | 相交链表 | 双指针 |
| 234 | 回文链表 | 快慢指针+反转 |
| 143 | 重排链表 | 快慢指针+反转+合并 |
| 148 | 排序链表 | 归并排序+快慢指针 |
| 25 | K个一组翻转链表 | 反转+递归 |
| 138 | 复制带随机指针的链表 | 哈希表/穿插法 |
| 2 | 两数相加 | 模拟加法 |
| 203 | 移除链表元素 | 虚拟头节点 |
| 86 | 分隔链表 | 双链表法 |
| 61 | 旋转链表 | 双指针+成环 |
| 328 | 奇偶链表 | 双指针分离 |
| 707 | 设计链表 | 综合实现 |
参考资料
- Wikipedia - Linked list: https://en.wikipedia.org/wiki/Linked_list
- GeeksforGeeks - Linked List Data Structure: https://www.geeksforgeeks.org/dsa/linked-list-data-structure/
- GeeksforGeeks - Reverse a Linked List: https://www.geeksforgeeks.org/dsa/reverse-a-linked-list/
- GeeksforGeeks - Floyd’s Cycle Finding Algorithm: https://www.geeksforgeeks.org/dsa/floyds-cycle-finding-algorithm/
- LeetCode Discuss - Linked List Study Guide: https://leetcode.com/discuss/post/2725900/linked-list-study-guide-by-sunyingbao-yi2w/
- Tech Interview Handbook - Linked List: https://www.techinterviewhandbook.org/algorithms/linked-list/
- NeetCode - LinkedList Solutions: https://neetcode.io/
- CP-Algorithms - Tortoise and Hare Algorithm: https://cp-algorithms.com/others/tortoise_and_hare.html