算法刷题笔记:链表题不会双指针?那真得练练了
第 3 篇:链表
这是我的刷题笔记第 3 篇,整理了链表相关的经典题目。链表题套路比较明显,双指针是灵魂。话不多说,直接开整!
1. 相交链表
题目
给你两个单链表的 headA 和 headB,请找出它们相交的起始节点。如果没有相交,返回 null。
思路过程
第一次尝试:暴力枚举
最直观的想法:遍历 A 的每个节点,对于每个节点都去遍历 B 看有没有相同的。
// 伪代码
for (Node a = headA; a != null; a = a.next) {
for (Node b = headB; b != null; b = b.next) {
if (a == b) return a;
}
}
问题:时间复杂度 O(mn),太慢了。
优化:哈希表
把 A 的所有节点存入 Set,遍历 B 时检查是否存在。
Set<Node> set = new HashSet<>();
for (Node a = headA; a != null; a = a.next) {
set.add(a);
}
for (Node b = headB; b != null; b = b.next) {
if (set.contains(b)) return b;
}
问题:需要 O(m) 额外空间。有没有办法不用额外空间?
最终解法:双指针交叉走 ✨
关键洞察:如果两个链表长度不同,直接对比肯定错过。
但如果我们让两个指针分别走完对方的长度呢?
A: a1 -> a2 -> c1 -> c2 (长度为 m + c)
B: b1 -> b2 -> b3 -> c1 -> c2 (长度为 n + c)
指针1走:A + B = a1->a2->c1->c2->b1->b2->b3->c1
指针2走:B + A = b1->b2->b3->c1->c2->a1->a2->c1
看!它们会在 c1 相遇!原理就是:两个指针都走了 m+n+c 的路程,最后那段公共部分会对齐。
代码
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
// 边界情况
if (headA == null || headB == null) {
return null;
}
ListNode pA = headA;
ListNode pB = headB;
// 当两个指针不相同时,继续走
// 走完自己的链表后,切换到对方的链表
while (pA != pB) {
// pA 走完 A 走 B
pA = (pA == null) ? headB : pA.next;
// pB 走完 B 走 A
pB = (pB == null) ? headA : pB.next;
}
// 相交返回交点,没相交返回 null(两者都是 null)
return pA;
}
}
复杂度分析
- 时间复杂度:O(m + n),每个指针最多走 m + n 步
- 空间复杂度:O(1),只用了两个指针
一句话总结
让两个指针分别走完自己的链表再走对方的链表,路程对齐后,相交点自然会重合。
2. 反转链表
题目
给你单链表的头节点 head,请你反转链表并返回反转后的头节点。
思路过程
第一次尝试:递归
递归的想法很自然:反转后面的链表,然后把当前节点接到后面。
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;
}
问题:递归深度太大时可能栈溢出,而且面试官可能想看迭代解法。
最终解法:迭代(双指针)✨
核心思想:逐个节点反转方向。
就像一根链条,我们从头部开始,一个一个把指针方向反过来。
初始: 1 -> 2 -> 3 -> null
↑
prev cur
第一步: null <- 1 2 -> 3 -> null
↑ ↑
prev cur
第二步: null <- 1 <- 2 3 -> null
↑ ↑
prev cur
第三步: null <- 1 <- 2 <- 3
↑ ↑
prev cur(null)
代码
public class Solution {
public ListNode reverseList(ListNode head) {
ListNode prev = null; // 上一个节点,初始为 null
ListNode curr = head; // 当前节点
while (curr != null) {
ListNode nextTemp = curr.next; // 先保存下一个节点
curr.next = prev; // 反转当前节点的指向
prev = curr; // prev 前进
curr = nextTemp; // curr 前进
}
// 循环结束后,prev 指向新的头节点
return prev;
}
}
复杂度分析
- 时间复杂度:O(n),遍历一次链表
- 空间复杂度:O(1),只用了几个指针变量
一句话总结
用 prev 和 curr 双指针,一边遍历一边把指针方向反过来,最后 prev 就是新的头。
3. 回文链表
题目
给你一个单链表,请判断它是否是回文链表(即正着读和反着读是一样的)。
思路过程
第一次尝试:存到数组里
把链表的值复制到数组,然后用双指针从两端往中间比较。
问题:需要 O(n) 额外空间。有没有办法 O(1) 空间搞定?
最终解法:快慢指针 + 反转 ✨
思路清晰,分三步走:
- 找中点:快指针一次走两步,慢指针一次走一步,慢指针到达中点
- 反转后半段:从中点开始反转后半段链表
- 比较:前半段和后半段(已反转)逐节点比较
原链表: 1 -> 2 -> 2 -> 1
第一步找中点:
快:1 -> 2 -> null
慢:1 -> 2 -> 2 (奇数个停在中间前,偶数个停在后半段起点前)
第二步反转后半段:
1 -> 2 -> null
<-
1 -> 2
第三步比较:
1 == 1 ✓
2 == 2 ✓
相同,是回文!
代码
public class Solution {
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;
}
// 此时 slow 指向中点(偶数)或中间前一个(奇数)
// slow.next 就是后半段的头
// 第二步:反转后半段
ListNode secondHalfHead = reverseList(slow.next);
// 第三步:比较前半段和后半段
ListNode p1 = head; // 前半段从头部开始
ListNode p2 = secondHalfHead; // 后半段从头(已反转)开始
boolean result = true;
while (p2 != null) { // 只需要比较后半段的长度
if (p1.val != p2.val) {
result = false;
break;
}
p1 = p1.next;
p2 = p2.next;
}
// 可选:恢复链表原状(面试时不恢复也行)
slow.next = reverseList(secondHalfHead);
return result;
}
// 反转链表的辅助方法
private 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;
}
}
复杂度分析
- 时间复杂度:O(n),遍历了链表常数次
- 空间复杂度:O(1),只用了几个指针,没有额外数组
一句话总结
快慢指针找中点,反转后半段,然后从两端往中间比较,轻松判断回文。
4. 环形链表
题目
给你一个链表的头节点 head,判断链表中是否有环。如果有环返回 true,否则返回 false。
思路过程
第一次尝试:哈希表记录
遍历链表,把每个节点存入 Set,如果遇到已存在的节点,说明有环。
问题:需要 O(n) 额外空间。
最终解法:快慢指针
两个人在操场上跑圈,一个跑得快一个跑得慢,如果有环,跑得快的人迟早会追上跑得慢的人。
就像运动会的套圈现象!
slow: 每次走1步
fast: 每次走2步
如果有环:
第1圈: slow=1, fast=3
第2圈: slow=2, fast=5
第3圈: slow=3, fast=7(追上slow了!)
代码
public class Solution {
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;
}
}
// fast 走到了 null,说明没环
return false;
}
}
复杂度分析
- 时间复杂度:O(n),最坏情况走完整个链表
- 空间复杂度:O(1),只用了两个指针
一句话总结
快慢指针一起跑,有环必相遇,没环 fast 先跑丢。
5. 合并两个有序链表
题目
将两个升序链表合并成一个升序链表,返回合并后的链表。
思路过程
第一次尝试:直接比较插入
同时遍历两个链表,每次比较当前节点的值,把较小的加入结果。
这思路没问题,但代码容易写乱。
最终解法:虚拟头节点 + 迭代 ✨
引入一个 dummy(虚拟头节点),用它来串联结果。
list1: 1 -> 3 -> 5 -> null
list2: 2 -> 4 -> 6 -> null
初始化:
dummy -> null
tail = dummy
第一次: 1 < 2, tail.next = 1, tail = 1
结果: dummy -> 1 -> null
第二次: 2 < 3, tail.next = 2, tail = 2
结果: dummy -> 1 -> 2 -> null
...依次类推
代码
public class Solution {
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
// 创建虚拟头节点,简化边界处理
ListNode dummy = new ListNode(-1);
ListNode tail = dummy; // tail 指向已排好序链表的最后一个节点
// 同时遍历两个链表
while (list1 != null && list2 != null) {
if (list1.val <= list2.val) {
tail.next = list1; // 把较小的节点接到结果后面
list1 = list1.next;
} else {
tail.next = list2;
list2 = list2.next;
}
tail = tail.next; // tail 前移
}
// 循环结束后,把剩余的节点直接接上
// 只需要处理一个链表,另一个已经是 null 了
tail.next = (list1 != null) ? list1 : list2;
return dummy.next; // 虚拟头节点的下一个才是真正的头
}
}
复杂度分析
- 时间复杂度:O(m + n),每个节点最多遍历一次
- 空间复杂度:O(1),只用了几个指针
一句话总结
用虚拟头节点串起来,每次挑小的接上去,剩下的直接全接。
6. 两数相加
题目
给你两个非空链表,表示两个非负整数。数字逆序存储,每个节点是一位数字。把两个数相加返回一个新链表。
输入: l1 = [2,4,3], l2 = [5,6,4]
输出: [7,0,8]
解释: 342 + 465 = 807
思路过程
第一次尝试:转成数字再相加
把两个链表转成整数,相加后再转成链表。
问题:数字可能非常长(比如 100 位),超过 long 能表示的范围,会溢出!
最终解法:逐位模拟加法 ✨
和小学竖式加法一样,从低位开始逐位相加,记得进位。
3 4 2
+ 5 6 4
------
7 0 8
逐位:
2 + 4 = 6, 进位 0
4 + 6 = 10, 结果0, 进位1
3 + 5 + 1(进位) = 9, 结果9, 进位0
代码
public class Solution {
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0); // 虚拟头节点
ListNode curr = dummy; // 当前节点
int carry = 0; // 进位,0 或 1
while (l1 != null || l2 != null || carry != 0) {
// 获取当前位的值,没有就取0
int x = (l1 != null) ? l1.val : 0;
int y = (l2 != null) ? l2.val : 0;
// 相加,记得加上进位
int sum = x + y + carry;
carry = sum / 10; // 新的进位
curr.next = new ListNode(sum % 10); // 当前位的值
// 移动指针
curr = curr.next;
if (l1 != null) l1 = l1.next;
if (l2 != null) l2 = l2.next;
}
return dummy.next;
}
}
复杂度分析
- 时间复杂度:O(max(m, n)),m 和 n 是两个链表的长度
- 空间复杂度:O(max(m, n)),结果链表的长度
一句话总结
竖式加法从低位算起,注意进位不要丢,链表短了当 0 来算。
7. 删除链表的倒数第N个结点
题目
给你一个链表,删除链表的倒数第 n 个结点,返回链表头节点。
思路过程
第一次尝试:先求长度
先遍历一遍求出链表长度 L,再遍历 L-n 次找到要删除的节点。
问题:需要遍历两遍,能不能一遍搞定?
最终解法:双指针(间隔 n)✨
让一个指针先走 n 步,然后两个指针一起走。当先走的指针到达末尾时,后面的指针正好指向待删除节点的前一个。
删除倒数第2个:
链表: 1 -> 2 -> 3 -> 4 -> 5
第一步: first 先走2步
first: 3 -> 4 -> 5
second: 1 -> 2 -> 3 -> 4 -> 5
第二步: 一起走
first: 5(null)
second: 4 -> 5
此时 second.next 指向待删除的节点(5的前一个4)
代码
public class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode first = dummy; // 先走的指针
ListNode second = dummy; // 后走的指针
// 第一步:first 先走 n + 1 步
// 这样 second 就会停在待删除节点的前一个位置
for (int i = 0; i <= n; i++) {
first = first.next;
}
// 第二步:两个指针一起走,直到 first 到达末尾
while (first != null) {
second = second.next;
first = first.next;
}
// 此时 second 指向待删除节点的前一个
second.next = second.next.next; // 跳过待删除节点
return dummy.next;
}
}
复杂度分析
- 时间复杂度:O(L),L 是链表长度,只遍历一次
- 空间复杂度:O(1),只用了两个指针
一句话总结
快慢指针拉开 n 步距离,一起走到终点,慢指针就停在待删节点前一位。
8. 排序链表
题目
给你链表的头节点,请按升序排列并返回排序后的链表。要求时间复杂度 O(n log n)。
思路过程
第一次尝试:转换成数组排序
把链表转成数组,用 Arrays.sort() 排序,再转回链表。
问题:时间复杂度 O(n log n) 满足,但面试官可能想考察链表归并排序。
最终解法:归并排序(链表版)✨
归并排序的核心是分治:
- 找中点:用快慢指针把链表分成两半
- 递归排序:分别对两半递归排序
- 合并:把两个有序链表合并
原链表: 4 -> 2 -> 1 -> 3
分解:
4 -> 2 | 1 -> 3
↓ ↓
排序: 2 -> 4 | 1 -> 3
合并: 1 -> 2 -> 3 -> 4
代码
public class Solution {
public ListNode sortList(ListNode head) {
// 递归终止条件:空链表或单节点
if (head == null || head.next == null) {
return head;
}
// 第一步:找链表中点,把链表分成两半
ListNode mid = getMid(head);
ListNode left = head;
ListNode right = mid.next;
mid.next = null; // 断开链表
// 第二步:递归排序左右两半
ListNode sortedLeft = sortList(left);
ListNode sortedRight = sortList(right);
// 第三步:合并两个有序链表
return merge(sortedLeft, sortedRight);
}
// 找链表中点(偶数个返回下中点)
private ListNode getMid(ListNode head) {
ListNode slow = head;
ListNode fast = head.next; // 注意是 next,这样奇数个停在真正的中点
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow;
}
// 合并两个有序链表
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;
}
}
复杂度分析
- 时间复杂度:O(n log n),每次二分 O(log n),每层合并 O(n)
- 空间复杂度:O(log n),递归栈的深度
一句话总结
快慢指针找中点切分,递归排序左右两半,最后合并成一个完整有序链表。
9. 合并K个升序链表
题目
给你一个链表数组,每个链表都是升序排列的。将所有链表合并成一个升序链表返回。
思路过程
第一次尝试:两两合并
依次合并两个链表,最终合并完所有链表。
问题:时间复杂度 O(kN),k 个链表,每个都要遍历多次,太慢了。
方法二:分治合并
两两合并太慢,用分治思想优化!
[1->4->5, 1->3->4, 2->6]
第一轮两两合并:
1->4->5 + 1->3->4 = 1->1->3->4->4->5
2->6 单独
第二轮合并:
1->1->3->4->4->5 + 2->6 = 1->1->2->3->4->4->5->6
方法三:优先队列(推荐)✨
把每个链表的当前节点都放进小根堆,每次取出最小的,把该链表的下一个节点再入堆。
PriorityQueue<ListNode> heap = new PriorityQueue<>(
(a, b) -> a.val - b.val
);
代码
public class Solution {
// 方法一:分治合并
public ListNode mergeKLists(ListNode[] lists) {
if (lists == null || lists.length == 0) {
return null;
}
return mergeSort(lists, 0, lists.length - 1);
}
private ListNode mergeSort(ListNode[] lists, int left, int right) {
if (left == right) {
return lists[left];
}
int mid = left + (right - left) / 2;
ListNode leftHead = mergeSort(lists, left, mid);
ListNode rightHead = mergeSort(lists, mid + 1, right);
return mergeTwoLists(leftHead, rightHead);
}
// 合并两个有序链表
private ListNode mergeTwoLists(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;
}
// 方法二:优先队列
public ListNode mergeKLists2(ListNode[] lists) {
if (lists == null || lists.length == 0) {
return null;
}
// 小根堆,按节点值排序
PriorityQueue<ListNode> heap = new PriorityQueue<>(
(a, b) -> a.val - b.val
);
// 把每个链表的第一个节点加入堆
for (ListNode node : lists) {
if (node != null) {
heap.offer(node);
}
}
ListNode dummy = new ListNode(0);
ListNode curr = dummy;
// 不断取出堆中最小的节点
while (!heap.isEmpty()) {
ListNode smallest = heap.poll();
curr.next = smallest;
curr = curr.next;
// 把该节点的下一个节点加入堆
if (smallest.next != null) {
heap.offer(smallest.next);
}
}
return dummy.next;
}
}
复杂度分析
-
时间复杂度:O(N log k),N 是总节点数,k 是链表数
-
空间复杂度:
- 分治:O(log k) 递归栈
- 优先队列:O(k) 堆的大小
一句话总结
分治两两合并或用小根堆逐个取最小,都是把「多路归并」问题简化成「二路归并」或「单点选择」。
10. LRU缓存
题目
设计和实现一个 LRU(最近最少使用)缓存机制。
要求:
- get(key):如果 key 存在返回 value,否则返回 -1
- put(key, value):如果 key 存在更新 value,如果不存在插入;如果缓存满,淘汰最久未使用的
实现 LRUCache 类,容量为 capacity。
思路过程
第一次尝试:HashMap + ArrayList
用 HashMap 存 key-value,ArrayList 按顺序存访问历史。
问题:ArrayList 删除是 O(n),而且不方便维护「最近使用」的顺序。
最终解法:HashMap + 双向链表
这是经典的 LRU 实现方式:
- HashMap:O(1) 查找
- 双向链表:维护访问顺序,头部是最新的,尾部是最久未使用的
缓存结构:
head <-> [最近使用] <-> ... <-> [最久未用] <-> tail
每次访问/插入:
1. 从 HashMap 找到节点
2. 把节点移到链表头部(表示最新使用)
淘汰时:
删除链表尾部节点(最久未用)
代码
public class LRUCache {
// 双向链表节点
class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
public DLinkedNode() {}
public DLinkedNode(int key, int value) {
this.key = key;
this.value = value;
}
}
private Map<Integer, DLinkedNode> cache; // HashMap 存数据
private int size; // 当前缓存大小
private int capacity; // 缓存容量
private DLinkedNode head, tail; // 虚拟头尾节点
public LRUCache(int capacity) {
this.size = 0;
this.capacity = capacity;
this.cache = new HashMap<>();
// 创建虚拟头节点和尾节点,简化边界处理
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null) {
return -1; // 不存在
}
// 存在,移动到链表头部(最近使用)
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if (node == null) {
// 新节点
DLinkedNode newNode = new DLinkedNode(key, value);
cache.put(key, newNode);
addToHead(newNode);
size++;
// 如果超出容量,删除最久未用的
if (size > capacity) {
DLinkedNode removed = removeTail();
cache.remove(removed.key);
size--;
}
} else {
// 已存在,更新值并移到头部
node.value = value;
moveToHead(node);
}
}
// 添加到链表头部
private void addToHead(DLinkedNode node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
// 删除节点
private void removeNode(DLinkedNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 移动到头部(先删除,再添加到头部)
private void moveToHead(DLinkedNode node) {
removeNode(node);
addToHead(node);
}
// 删除链表尾部(最久未用的)
private DLinkedNode removeTail() {
DLinkedNode removed = tail.prev;
removeNode(removed);
return removed;
}
}
复杂度分析
- 时间复杂度:O(1),HashMap 查找 O(1),双向链表移动/删除 O(1)
- 空间复杂度:O(capacity),最多存 capacity 个节点
一句话总结
HashMap 负责快速查找,双向链表负责维护使用顺序,头部最新,尾部最旧,满了就淘汰尾巴。
总结
这些链表题,套路真的很明显:
表格
| 技巧 | 适用题目 |
|---|---|
| 双指针(快慢) | 环形链表、回文判断、找中点 |
| 双指针(间距) | 删除倒数第 N 个节点 |
| 虚拟头节点 | 合并链表、删除节点 |
| 反转链表 | 反转、回文判断 |
| 归并排序 | 排序链表、合并 K 个链表 |
| 哈希 + 双向链表 | LRU 缓存 |
链表题最重要的是画图!画图!画图!重要的事情说三遍。纸上画一画,指针怎么走的就清楚了。
下一篇文章我们继续刷「树」相关的题目,敬请期待!
如果你觉得这篇笔记有帮助,欢迎点赞收藏!有问题可以在评论区交流。