链表刷题下篇:排序、合并K链表与LRU Cache的踩坑全记录

链表刷题下篇:排序、合并K链表与LRU Cache的踩坑全记录

文章目录

前言

[一、148 链表排序 ------ 归并排序](#一、148 链表排序 —— 归并排序)

思路

代码

踩坑复盘

[二、23 合并K个升序链表](#二、23 合并K个升序链表)

三种方法

顺序合并

分治归并

优先队列(推荐)

[优先队列为什么是 O(N log k) 而不是 O(N log N)](#优先队列为什么是 O(N log k) 而不是 O(N log N))

[三、146 LRU Cache](#三、146 LRU Cache)

数据结构

完整手写模板

4个必须注意的点

测试边界别忘了

偷懒写法(非面试场合)

总结

前言

这篇文章是链表系列下篇,复盘 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 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 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 {

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 快多了。

相关推荐

京东兑换流量在哪里领?强卡如何领取流量?
365bet平台网址

京东兑换流量在哪里领?强卡如何领取流量?

📅 07-22 👁️ 2157
iPhone 5 尾插更换
365bet日博娱乐

iPhone 5 尾插更换

📅 01-01 👁️ 5461
将14厘米转换为英寸
365bet日博娱乐

将14厘米转换为英寸

📅 10-25 👁️ 4605