文章目录
前言
这篇文章是链表系列下篇,复盘 LeetCode 148、23、146 三道题的解题思路和真实踩坑经历,帮你避开我犯过的那些低级错误。
上篇讲了链表基础操作,这篇难度上了一个台阶:归并排序、多路合并、LRU 缓存,每道题都能单独出一道面试题。
一、148 链表排序 ------ 归并排序
思路
链表不支持随机访问,快排不适合。归并排序天然适配:
1->4->2->3->5
↓ 找中点切断
1->4->2 3->5
↓ 递归排序
1->2->4 3->5
↓ 合并
1->2->3->4->5
找中点用快慢指针,合并用双指针。
代码
java
public ListNode sortList(ListNode head) {
if (head == null || head.next == null) return head;
ListNode slow = head, fast = head.next; // fast 从 head.next 出发
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
ListNode second = slow.next;
slow.next = null;
return merge(sortList(head), sortList(second));
}
public ListNode merge(ListNode l, ListNode r) {
ListNode dummy = new ListNode(0), now = dummy;
while (l != null && r != null) {
if (l.val < r.val) { now.next = l; l = l.next; }
else { now.next = r; r = r.next; }
now = now.next; // 别忘了推进指针
}
now.next = (l == null) ? r : l;
return dummy.next;
}
踩坑复盘
坑1:fast 从 head 出发 → 2节点链表死循环
fast=head 时,2节点链表循环结束后 slow 停在 node2,second=null,first 还是完整的两节点链表,下一次递归入参不变,无限循环栈溢出。
fast=head.next 的作用是给 slow 留"刹车距离":fast 提前一步,slow 就提前一步停在前半段末尾。
fast = head: slow 停在后半段开头 ← 错
fast = head.next: slow 停在前半段末尾 ← 对
坑2:merge 尾部条件写反
java
if(l==null) now.next=l; // ❌ l用完了接l,剩余节点全丢
else now.next=r;
// 正确写法
now.next = (l == null) ? r : l; // ✅
坑3:merge 循环里忘了 now=now.next
每次 now.next=l 后没有 now=now.next,指针一直停在 dummy,最终链表只有一个节点。
二、23 合并K个升序链表
三种方法
方法 时间复杂度 特点
──────────────────────────────────────────
顺序合并 O(Nk) 最简单,链表越来越长越来越慢
分治归并 O(N log k) 思路优雅,类似归并排序
优先队列 O(N log k) 面试首选,常数最小
顺序合并
java
public ListNode mergeKLists(ListNode[] lists) {
ListNode res = null;
for (ListNode list : lists) res = merge(res, list);
return res;
}
分治归并
java
public ListNode mergeKLists(ListNode[] lists) {
if (lists.length == 0) return null;
if (lists.length == 1) return lists[0];
int mid = lists.length / 2;
ListNode left = mergeKLists(Arrays.copyOfRange(lists, 0, mid));
ListNode right = mergeKLists(Arrays.copyOfRange(lists, mid, lists.length));
return merge(left, right);
}
优先队列(推荐)
java
public ListNode mergeKLists(ListNode[] lists) {
PriorityQueue<ListNode> pq = new PriorityQueue<>((a, b) -> a.val - b.val);
for (ListNode node : lists) if (node != null) pq.offer(node);
ListNode dummy = new ListNode(0), cur = dummy;
while (!pq.isEmpty()) {
ListNode node = pq.poll();
cur.next = node;
cur = cur.next;
if (node.next != null) pq.offer(node.next);
}
return dummy.next;
}
优先队列为什么是 O(N log k) 而不是 O(N log N)
关键在于:堆里始终只放 k 个节点,不是 N 个。
每次弹出一个节点,把它的下一个节点放进来,堆的大小始终 ≤ k。每次堆操作 O(log k),共 N 个节点,总计 O(N log k)。
如果把所有节点全倒进堆再排,那才是 O(N log N),没有利用链表已有序这个条件。
三、146 LRU Cache
数据结构
哈希表 + 双端链表,两个结构各司其职:
哈希表:O(1) 定位节点
双端链表:O(1) 移动节点位置、删除尾部
head <-> [最近使用] <-> ... <-> [最久未用] <-> tail
↑ 新节点/访问后插这里 ↑ 容量满时删这里
完整手写模板
java
class LRUCache {
int max, cnt;
Map<Integer, Node> map = new HashMap<>();
Node head = new Node(0, 0), tail = new Node(0, 0);
public LRUCache(int capacity) {
max = capacity;
head.next = tail;
tail.prev = head;
}
public int get(int key) {
if (!map.containsKey(key)) return -1;
Node node = map.get(key);
remove(node);
insertToHead(node);
return node.val;
}
public void put(int key, int value) {
if (map.containsKey(key)) {
Node node = map.get(key);
node.val = value;
remove(node);
insertToHead(node);
} else {
Node node = new Node(key, value);
map.put(key, node);
insertToHead(node);
if (++cnt > max) {
Node del = tail.prev;
remove(del);
map.remove(del.key);
cnt--;
}
}
}
void remove(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
void insertToHead(Node node) {
node.next = head.next;
head.next.prev = node;
head.next = node;
node.prev = head; // 最容易忘的一行
}
}
class Node {
int key, val;
Node prev, next;
Node(int k, int v) { key = k; val = v; }
}
4个必须注意的点
1. 节点入 map 前必须先插链表
先 insertToHead,再 map.put。反过来的话,get 从 map 拿到的节点 prev/next 都是 null,remove 时直接 NPE。
2. insertToHead 四条指针一条不能少
java
node.next = head.next; // ① node 指向原第一个
head.next.prev = node; // ② 原第一个的 prev 指回 node
head.next = node; // ③ head 指向 node
node.prev = head; // ④ node 的 prev 指向 head ← 最容易忘
少了第④条,连续两次 get 同一个 key,第二次 node.prev 还是旧值,链表自环崩掉。
3. put 已有 key 时,位置也要更新
key 存在时不能只改值,节点必须移到头部(表示最近使用),否则这个 key 还留在原来的位置,可能被错误淘汰。
4. 淘汰时同步删 map
java
map.remove(del.key); // 节点存 key 就是为了这行
只删链表不删 map,下次查这个 key 还能 hit,内存也泄漏了。
测试边界别忘了
| 场景 | 预期行为 |
|---|---|
| capacity=1,put 第二个 key | 第一个被淘汰 |
| put 已存在的 key | cnt 不变,值更新,位置移到头部 |
| 连续两次 get 同一个 key | 第二次结果一致,不崩溃 |
| 淘汰后 get 被淘汰的 key | 返回 -1 |
偷懒写法(非面试场合)
Java 内置 LinkedHashMap 三行实现 LRU:
java
class LRUCache extends LinkedHashMap<Integer, Integer> {
int max;
public LRUCache(int capacity) { super(capacity, 0.75f, true); max = capacity; }
public int get(int key) { return super.getOrDefault(key, -1); }
public void put(int key, int value) { super.put(key, value); }
protected boolean removeEldestEntry(Map.Entry e) { return size() > max; }
}
面试还是得手写,LinkedHashMap 用来对拍验证结果。
总结
三道题用到的核心思维:
哨兵节点:dummy head 和 dummy tail 避免边界判断,链表题标配。
封装基本操作 :LRU 把 remove 和 insertToHead 单独抽出来,put/get 逻辑清晰不出错。直接在 put/get 里写指针操作,一乱就漏。
双指针找中点 :fast 比 slow 多走一步,是为了让 slow 停在正确位置。记住结论:fast 从 head.next 出发,slow 停在前半段末尾。
链表题最容易错的不是思路,是指针连接顺序。写完每个操作,把四个方向的指针默念一遍,比 debug 快多了。