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

文章目录

前言

这篇文章是链表系列下篇,复盘 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=nullfirst 还是完整的两节点链表,下一次递归入参不变,无限循环栈溢出。

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 把 removeinsertToHead 单独抽出来,put/get 逻辑清晰不出错。直接在 put/get 里写指针操作,一乱就漏。

双指针找中点 :fast 比 slow 多走一步,是为了让 slow 停在正确位置。记住结论:fast 从 head.next 出发,slow 停在前半段末尾。

链表题最容易错的不是思路,是指针连接顺序。写完每个操作,把四个方向的指针默念一遍,比 debug 快多了。