LeetCode Hot 100 链表专题(五)· 2 题(Java)

本篇共 2 题:23. 合并 K 个升序链表、146. LRU 缓存。

每题统一按 ① 题目分析与解题思路 → ② 常见解法(附代码)及讲解 → ③ 知识点总结 组织。


23. 合并 K 个升序链表

一、题目分析与解题思路

1. 题意梳理

  • 输入 :一个链表数组 lists,数组里每一条链表本身已经升序。
  • 输出 :把全部节点合并成一条升序链表。
  • 边界情况 :
    • lists 为空数组 [] → 返回空;
    • lists = [null](数组里只有空链表)→ 返回空;
    • 节点总数最多 10^4。

2. 核心思路

基础:我们知道【合并两个有序链表】是基础题。现在是 k 条,有两个自然方向:

  1. 不断两两合并(分治,类似归并排序);
  2. 用优先队列(小根堆),每次取出所有链表当前头节点里最小的那个。

暴力思路:把所有节点值拿出来排序再重建链表(能过,但不是链表题想考察的链表操作思路)。

3. 常见出错点(先看坑,再写码)

  1. 空链表判断遗漏 :lists 数组为空、或者数组里面某个链表是 null,访问 .val 直接空指针报错。
  2. 分治写法区间写错 :left == right 的终止条件、mid 划分错误,造成死递归。
  3. 堆写法没有过滤空链表 :把 null 放进优先队列,取 val 时报错。
  4. 合并两条链表时,新链表没有哑结点(dummy),头节点不好维护。
  5. 堆里存的是节点引用,移动指针时忘记更新堆:取出一个节点之后,要把它下一个节点入堆。
  6. 递归深度过大(k 很大时纯逐个合并会超时,时间复杂度劣化)。

二、常见解法及讲解(Java)

链表节点定义统一:

java 复制代码
class ListNode {
    int val;
    ListNode next;
    ListNode() {}
    ListNode(int val) { this.val = val; }
    ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}

解法一:分治合并(推荐,面试高频)

✅ 思路:归并思想,对半拆分链表数组,先合并左半部分、右半部分,最后把左右结果合并。相当于:

merge(lists, l, r) = mergeTwo( merge(lists, l, mid), merge(lists, mid+1, r) )

  • 时间复杂度:O(Nlog⁡k)O(N\log k)O(Nlogk)(N 是总节点数,k 是链表数量)
  • 空间复杂度:O(log⁡k)O(\log k)O(logk)(递归栈)
java 复制代码
class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        // 若是空链表( [] 或者 [null] )就返回 null
        if (lists == null || lists.length == 0) {
            return null;
        }
        return merge(lists, 0, lists.length - 1);
    }

    // 归并区间 [left, right]
    private ListNode merge(ListNode[] lists, int left, int right) {
        if (left == right) {
            // 先拆到区间只有一条链表,直接返回这条链表
            return lists[left];
        }

        int mid = left + (right - left) / 2;
        ListNode l1 = merge(lists, left, mid);
        ListNode l2 = merge(lists, mid + 1, right);

        return mergeTwo(l1, l2);
    }

    // 回溯时不断执行 合并两个升序链表(基础模板)
    private ListNode mergeTwo(ListNode l1, ListNode l2) {
        // 哑结点,固定头,方便操作
        ListNode dummy = new ListNode(-1);
        ListNode cur = dummy;

        while (l1 != null && l2 != null) {
            if (l1.val <= l2.val) {
                cur.next = l1;
                l1 = l1.next;
            } else {
                cur.next = l2;
                l2 = l2.next;
            }
            cur = cur.next;
        }

        // 剩下没走完的链表直接接在尾部
        cur.next = (l1 == null) ? l2 : l1;
        return dummy.next;
    }
}

讲解:

  • 先拆到区间只有一条链表,直接返回这条链表;
  • 回溯阶段不断执行【合并两条有序链表】;
  • 优势:不需要额外堆结构,链表原地合并,效率稳定。
拿样例走一遍流程

样例:lists = [1->4->5, 1->3->4, 2->6],数组下标:0,1,2。调用 merge(lists, 0, 2)

  1. left=0, right=2 → mid = 0 + (2-0)/2 = 1
    • 左:merge(0,1)
    • 右:merge(2,2) → 触发终止条件,直接返回 2->6
  2. 进入 merge(0,1),left=0, right=1 → mid=0
    • 左:merge(0,0) → 返回 1->4->5
    • 右:merge(1,1) → 返回 1->3->4
    • 合并这两条:得到 1->1->3->4->4->5
  3. 现在 l1 = 1->1->3->4->4->5,l2 = 2->6,合并两个链表:1->1->2->3->4->4->5->6,就是答案。

拆分树结构

plain 复制代码
            merge(0,2)
           /          \
    merge(0,1)      merge(2,2)
   /        \
merge(0,0) merge(1,1)
复杂度分析

设总节点数 N,链表数量 k:

  • 时间复杂度 :O(Nlog⁡k)O(N\log k)O(Nlogk)。每一层递归,所有合并操作加起来一共遍历全部 N 个节点;一共 log⁡k\log klogk 层(二分拆分)。
  • 空间复杂度 :O(log⁡k)O(\log k)O(logk),是递归调用栈的深度,等于二分的层数;如果是迭代版分治,可以做到 O(1)O(1)O(1) 额外空间。
这个解法的坑点(重点)
  1. mid 不要写成 (left+right)/2 。虽然本题数组长度不大没问题,但 left + (right-left)/2 是防止 left+right 溢出的规范写法。

  2. 边界判断 :外层入口 mergeKLists 要先判断 lists == null 或者 lists.length == 0,否则 merge(0, -1) 直接报错。

  3. 数组里存在 null 链表 :比如 lists 里面有 null,mergeTwo 本身支持 l1 或者 l2 是 null,mergeTwo 里 cur.next = (l1 == null) ? l2 : l1 天然处理空链表,所以不用额外过滤。

    即 mergeTwo(null, 1->2) 直接返回 1->2,mergeTwo(null, null) 返回 null。

补充 :

为什么递归里只写 if (left == right)、不用处理 left > right?

因为 left <= mid < mid+1 <= right 恒成立,左右子区间都非空且不越界,永远不会走到 left > right------这也是该分治写法不会死递归的根本原因。


解法二:优先队列(小根堆)

✅ 思路:

  1. 把所有非空链表的头节点 加入小根堆(堆按节点 val 升序);
  2. 每次弹出堆顶最小节点,接到结果链表上;
  3. 如果弹出的节点还有后继节点,把后继节点入堆;
  4. 堆空结束。
  • 时间复杂度:O(Nlog⁡k)O(N\log k)O(Nlogk)
  • 空间复杂度:O(k)O(k)O(k)(堆最多存 k 个节点)
java 复制代码
class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        // 小根堆:按节点 val 从小到大
        PriorityQueue<ListNode> heap = new PriorityQueue<>((o1, o2) -> o1.val - o2.val);
        // 把所有非空头节点入堆
        for (ListNode node : lists) {
            if (node != null) {
                heap.add(node);
            }
        }

        ListNode dummy = new ListNode(-1);
        ListNode cur = dummy;
        while (!heap.isEmpty()) {
            ListNode minNode = heap.poll(); // 取出当前最小节点
            cur.next = minNode;
            cur = cur.next;
            // 当前节点后面还有节点,继续放进堆
            if (minNode.next != null) {
                heap.add(minNode.next); // 这个节点还有后继,后继入堆
            }
        }
        return dummy.next;
    }
}

讲解:

  • 直观好理解,适合口述思路;
  • ⚠️ 重点:入堆前必须判断 node != null;
  • 适合 k 不大的场景;Java PriorityQueue 不允许放 null;
  • 补充:代码里的 heap.add(node) 与 heap.offer(node) 等价,按下文「刷题标准使用习惯」,统一用 offer 更规范(容量受限时返回 false 而非抛异常)。
这行代码逐句拆解
java 复制代码
PriorityQueue<ListNode> heap = new PriorityQueue<>((o1, o2) -> o1.val - o2.val);

PriorityQueue 在 Java 里默认是小根堆(最小优先队列),底层是数组实现的堆结构。

核心:比较器返回负数 → o1 排在 o2 前面。

(o1, o2) -> o1.val - o2.val 是 Lambda 形式的比较器 Comparator:

  • 如果 o1.val - o2.val < 0:o1 的值更小,o1 优先级更高,放在堆顶;
  • 如果 o1.val - o2.val == 0:两者相等,顺序无所谓;
  • 如果 o1.val - o2.val > 0:o2 更小,o2 放在堆顶。

✅ 效果:堆顶永远是 val 最小的那个 ListNode 节点。

⚠️ 两个大坑(本题高频报错点)

  1. 不能往里面 add null !heap.add(null),后面取 .val 直接空指针;所以入堆前要判断 if (node != null)。
  2. 比较器依赖 o1.val,节点必须是非空,这也是为什么循环入堆的时候要过滤空链表头。

反过来,如果写 (o1, o2) -> o2.val - o1.val,就是大根堆,堆顶是最大值。

举个直观例子:依次加入节点 val:5, 1, 3,堆内部会自动调整,poll() 出来顺序是:1, 3, 5。


解法三:暴力收集 + 排序重建(简单,不推荐面试写)

✅ 思路:遍历全部链表,把所有值放进 List 排序,再新建链表串起来。

  • 时间复杂度:O(Nlog⁡N)O(N\log N)O(NlogN)
  • 空间复杂度:O(N)O(N)O(N)
java 复制代码
class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        List<Integer> vals = new ArrayList<>();
        for (ListNode node : lists) {
            while (node != null) {
                vals.add(node.val);
                node = node.next;
            }
        }
        Collections.sort(vals);
        ListNode dummy = new ListNode(-1);
        ListNode cur = dummy;
        for (int v : vals) {
            cur.next = new ListNode(v);
            cur = cur.next;
        }
        return dummy.next;
    }
}

讲解:

  • 写起来最短,但没有考察链表操作,面试官一般不认可这个作为最优解;只能用来验证答案。

三种解法对比总结

方法 时间 空间 面试推荐度
分治归并 O(Nlog⁡k)O(N\log k)O(Nlogk) O(log⁡k)O(\log k)O(logk) ⭐⭐⭐⭐⭐(首选)
小根堆 O(Nlog⁡k)O(N\log k)O(Nlogk) O(k)O(k)O(k) ⭐⭐⭐⭐(思路好说)
暴力排序 O(Nlog⁡N)O(N\log N)O(NlogN) O(N)O(N)O(N) ⭐(仅快速调试)

三、知识点总结

1. PriorityQueue 常用方法速查(面试高频)

注意:PriorityQueue 实现的是队列接口 Queue,方法分为两组:抛异常 / 返回 null。

方法 作用 返回值 特点
add(E e) 添加元素到堆 boolean 添加失败(容量限制)抛出 IllegalStateException
offer(E e) 添加元素到堆 boolean 添加失败返回 false,推荐优先用 offer
poll() 删除并取出堆顶元素 E 队列为空返回 null ✅ 刷题最常用
remove() 删除并取出堆顶元素 E 队列为空抛出 NoSuchElementException
peek() 查看堆顶元素(不取走) E 队列为空返回 null ✅ 刷题常用
element() 查看堆顶元素 E 队列为空抛出 NoSuchElementException
isEmpty() 判断堆是否为空 boolean 空返回 true
size() 返回堆中元素个数 int
clear() 清空堆 void
contains(Object o) 判断是否包含某个元素 boolean 底层线性扫描,O(n) 很慢

刷题标准使用习惯(LeetCode 优先队列通用写法):

  1. 添加:heap.offer(x)
  2. 取堆顶、移除:heap.poll()
  3. 只看不移除:heap.peek()
  4. 循环条件:while (!heap.isEmpty())

为什么推荐 offer / poll / peek:刷题时经常遇到空堆,它们返回 null 而不是抛异常,代码更稳。

2. PriorityQueue 重要特性(很多人踩坑)

  1. PriorityQueue 的迭代器不是有序遍历!
java 复制代码
PriorityQueue<Integer> q = new PriorityQueue<>();
q.offer(3); q.offer(1); q.offer(2);
for (int x : q) {
    System.out.println(x); // 输出不一定是 1,2,3!底层数组不是有序的,只是堆结构
}

想要有序输出,只能不断 poll():

java 复制代码
while (!q.isEmpty()) {
    System.out.println(q.poll()); // 1,2,3
}
  1. 初始容量只是底层数组初始大小,不是上限,会自动扩容:
java 复制代码
new PriorityQueue<>(10); // 初始容量 10,不够会自动扩容
  1. 元素修改陷阱:如果已经在堆里的对象内部的值发生变化,堆不会自动重排!

本题场景刚好规避这个问题:我们取出节点之后,不会修改节点 val,而是把下一个新节点入堆;如果是修改堆内对象属性,堆顺序不会更新,会出错。

一句话选型 :多路有序合并,用分治归并(省空间)或小根堆(好口述),复杂度同为 O(Nlog⁡k)O(N\log k)O(Nlogk);暴力排序只用来对答案。


146. LRU 缓存

一、题目分析与解题思路

1. 题意梳理

我们要实现一个缓存,有两个核心操作 get(key)、put(key, value),两个操作平均时间复杂度都必须是 O(1),规则:

  1. get(key):key 存在 → 返回 value,并且这个 key 变成最近使用;不存在返回 -1。
  2. put(key, value):
    • key 已存在:更新 value,同时标记为最近使用;
    • key 不存在:新增这条记录;如果缓存满了,删掉最久没被使用的那条记录。

LRU 全称:Least Recently Used,最近最少使用(淘汰最久没有访问的)。

2. 核心矛盾

  • HashMap :O(1) 查找,但是无法维护访问顺序,不知道谁最久没用;
  • 普通链表 :可以维护顺序,但是查找节点是 O(n),不符合 O(1) 要求。

✅ 解决方案:哈希表 + 双向链表(哈希链表)

  • HashMap:key → 双向链表节点,用来 O(1) 定位节点;
  • 双向链表:维护使用时序。
    • 链表头部:最近刚使用的节点;
    • 链表尾部:最久未使用,优先被淘汰的节点。

双向链表优势:拿到一个节点后,可以 O(1) 删除自身、O(1) 移动到头部;单向链表做不到(删节点需要找前驱)。

3. 定义 4 个基础内部操作(先抽象出来,写代码会清晰很多)

  1. addToHead(node):把节点加到双向链表头部(标记为最近使用);
  2. removeNode(node):从链表中移除某个指定节点(双向链表 O(1));
  3. moveToHead(node):先删掉这个节点,再把它加到头部(get / 更新时调用);
  4. removeTail():删除尾节点,返回被删掉的节点(缓存满了淘汰时调用)。

技巧:虚拟头结点 dummyHead、虚拟尾结点 dummyTail。不用频繁判空、处理头尾边界,所有节点都夹在两个虚拟节点中间,大幅减少 bug。

链表结构:dummyHead <-> 最新节点 <-> ... <-> 最久节点 <-> dummyTail

4. 常见出错点

  1. 只用单向链表:删除节点的时候找不到前驱,时间退化成 O(n),超时。
  2. 忘记 get 命中之后,把节点移到头部(很多新手只更新 put,get 不更新时序,LRU 逻辑直接错)。
  3. 双向链表操作时,指针改漏、改错:只改 node 的 next,忘了改 prev,造成链表断链、循环链表。
  4. 淘汰尾节点的时候:只删链表节点,忘记同步删除 HashMap 里面的 key → 哈希表脏数据,内存泄漏、查询异常。
  5. put 更新已有 key 的时候:只改 value,不 moveToHead。
  6. 虚拟头尾初始化漏掉,每次插入删除都要大量判 null,容易空指针。
  7. 容量 capacity = 1 的边界没考虑,新增直接淘汰原有元素。
  8. 线程安全不用管(LeetCode 单线程调用,不用加锁)。

二、常见解法及讲解(Java)

解法一:手写双向链表 + HashMap(面试标准写法,⭐⭐⭐⭐⭐,面试官最爱)

java 复制代码
class LRUCache {
    // 双向链表结点
    static 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 = new HashMap<>();
    private int size;
    private int capacity;
    private DLinkedNode dummyHead, dummyTail;

    public LRUCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        // 初始化虚拟头尾结点
        dummyHead = new DLinkedNode();
        dummyTail = new DLinkedNode();
        dummyHead.next = dummyTail;
        dummyTail.prev = dummyHead;
    }

    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 tail = removeTail();
                cache.remove(tail.key);
                size--;
            }
        } else {
            // Key 存在,更新值 + 移到头部
            node.value = value;
            moveToHead(node);
        }
    }

    // 把结点加到虚拟头后面(最新位置)
    private void addToHead(DLinkedNode node) {
        node.prev = dummyHead;
        node.next = dummyHead.next;
        dummyHead.next.prev = node;
        dummyHead.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 res = dummyTail.prev;
        removeNode(res);
        return res;
    }
}

讲解:

  • 节点里面一定要存 key! 重点!淘汰尾节点的时候,我们拿到链表节点,要用 node.key 去删除 HashMap 里的记录;如果节点只存 value,拿不到 key,无法清理 map。这个是高频坑!
  • 所有链表操作基于 4 个小函数,职责拆分清晰,不容易写错指针;
  • 时间复杂度:get、put 都是 O(1),哈希查找 + 双向链表增删;
  • 空间复杂度:O(capacity),最多存 capacity 个节点。

补充(重难点):

addToHead 四行指针顺序不能乱------先用 node.prev / node.next 把新节点挂上链,再改 dummyHead.next.prev 和 dummyHead.next。

因为有 dummyHead,链表为空时(dummyHead.next == dummyTail)也无需判空,逻辑完全统一。


解法二:直接用 Java 内置 LinkedHashMap(刷题快速写法,面试不建议直接写这个)

LinkedHashMap 底层就是【哈希表 + 双向链表】,自带顺序维护,有一个构造参数 accessOrder:

  • accessOrder = true:访问顺序 ,get / put 命中都会把节点移到末尾(刚好适配 LRU);
  • accessOrder = false:插入顺序(默认)。

我们只需要重写 removeEldestEntry,定义什么时候淘汰最老元素。

java 复制代码
class LRUCache extends LinkedHashMap<Integer, Integer> {
    private int capacity;

    public LRUCache(int capacity) {
        super(capacity, 0.75f, true); // accessOrder=true 开启访问排序
        this.capacity = capacity;
    }

    public int get(int key) {
        return super.getOrDefault(key, -1);
    }

    public void put(int key, int value) {
        super.put(key, value);
    }

    // 返回 true 代表要淘汰最久的元素
    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity;
    }
}

讲解:

  • ✅ 优点:代码极短,比赛 / 刷题可以快速写出来;
  • ❌ 缺点:面试写这个,面试官大概率会让你手写底层双向链表 ,LinkedHashMap 属于封装好的工具,考察不到你对 LRU 底层的理解,只能作为备选。

补充(易混点) :

方向相反、语义一致 ------手写版"最新"放头部 、淘汰尾部 ;LinkedHashMap(accessOrder=true) 把"最新"移到尾部 ,removeEldestEntry 淘汰的是头部最老元素 。

另外 getOrDefault 命中时同样会触发 afterNodeAccess 重排(JDK 8 已实测),可放心使用。


走一遍样例帮助理解时序(capacity = 2)

  1. put(1,1) → 链表:[1]
  2. put(2,2) → 链表:[2, 1](头是最新)
  3. get(1) → 1 被访问,移到头部 → [1, 2]
  4. put(3,3) → 容量满,淘汰尾部 2 → [3, 1]
  5. get(2) → -1
  6. put(4,4) → 容量满,淘汰尾部 1 → [4, 3]
  7. get(1) → -1
  8. get(3) → 3 移头部 → [3, 4]
  9. get(4) → 4 移头部 → [4, 3]

两种解法对比

方案 时间 面试推荐度 备注
手写双向链表+HashMap O(1) 平均 ⭐⭐⭐⭐⭐ 标准回答,必须掌握
LinkedHashMap O(1) 平均 ⭐⭐ 刷题偷懒,面试慎用

三、知识点总结

1. 哈希链表的四个关键点

  • 结构 = HashMap(key → 节点) + 双向链表(维护时序) + 双哨兵(消除判空);查找靠 map,顺序靠链表,两者缺一不可。
  • 节点必须存 key :淘汰尾部时要用 node.key 反查并 cache.remove,否则哈希表留脏数据。
  • 两个必查点 :get 命中、put 更新都要 moveToHead;淘汰尾部必须同步删 map。
  • 指针顺序 :先接新节点的 prev/next,再改原有节点的指针,避免断链或成环。

2. 复杂度

get / put 均为 O(1) 平均 (哈希查找 + 常数次指针操作);空间 O(capacity)。

3. 延伸记忆

  • 一句话理解 LRU:"最新用的放一头,淘汰从另一头拿",哈希表只负责"秒找到"。
  • 工程对应:LinkedHashMap(Java)、OrderedDict(Python)、Redis allkeys-lru、Caffeine / Guava Cache。
  • 同源进阶 LFU (LeetCode 460):再加一层"频率分桶",并维护 minFreq。
相关推荐
黑马程序员毕设40 分钟前
基于 Java 的餐饮点餐系统设计与应用报告
java·开发语言
SL_staff42 分钟前
从文档工具到知识中枢:基于JVS数字底座构建企业级统一内容管理平台的技术实践
java·开源·全栈
SL_staff1 小时前
从‘连得上’到‘管得好’:JVS-IOT双引擎驱动设备运维闭环
java·物联网·产品
w_zero_one3 小时前
递归算法(一)
java·开发语言·算法
圣保罗的大教堂3 小时前
leetcode 3736. 最小操作次数使数组元素相等 III 简单
leetcode
SL_staff4 小时前
私有化低代码不是减配版开发:JVS双模式建模如何重建IT交付信任
java·低代码·spring cloud
墨着染霜华4 小时前
Redocly 生成 OpenAPI 接口文档踩坑实录|Node 版本兼容 + 接口地址错误问题解决
java
文丑颜不良啊4 小时前
LeetCode1881~1990题目解析
java·leetcode
风影同志783245 小时前
20 个比特能装下多少行情?——实测拆解 Kronos 的 K 线分词器
算法