本篇共 2 题:23. 合并 K 个升序链表、146. LRU 缓存。
每题统一按 ① 题目分析与解题思路 → ② 常见解法(附代码)及讲解 → ③ 知识点总结 组织。
23. 合并 K 个升序链表

一、题目分析与解题思路
1. 题意梳理
- 输入 :一个链表数组
lists,数组里每一条链表本身已经升序。 - 输出 :把全部节点合并成一条升序链表。
- 边界情况 :
lists为空数组[]→ 返回空;lists = [null](数组里只有空链表)→ 返回空;- 节点总数最多 10^4。
2. 核心思路
基础:我们知道【合并两个有序链表】是基础题。现在是 k 条,有两个自然方向:
- 不断两两合并(分治,类似归并排序);
- 用优先队列(小根堆),每次取出所有链表当前头节点里最小的那个。
暴力思路:把所有节点值拿出来排序再重建链表(能过,但不是链表题想考察的链表操作思路)。
3. 常见出错点(先看坑,再写码)
- 空链表判断遗漏 :
lists数组为空、或者数组里面某个链表是null,访问.val直接空指针报错。 - 分治写法区间写错 :
left == right的终止条件、mid划分错误,造成死递归。 - 堆写法没有过滤空链表 :把
null放进优先队列,取val时报错。 - 合并两条链表时,新链表没有哑结点(dummy),头节点不好维护。
- 堆里存的是节点引用,移动指针时忘记更新堆:取出一个节点之后,要把它下一个节点入堆。
- 递归深度过大(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(Nlogk)O(N\log k)O(Nlogk)(N 是总节点数,k 是链表数量)
- 空间复杂度:O(logk)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)
left=0, right=2→mid = 0 + (2-0)/2 = 1- 左:
merge(0,1) - 右:
merge(2,2)→ 触发终止条件,直接返回2->6
- 左:
- 进入
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
- 左:
- 现在
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(Nlogk)O(N\log k)O(Nlogk)。每一层递归,所有合并操作加起来一共遍历全部 N 个节点;一共 logk\log klogk 层(二分拆分)。
- 空间复杂度 :O(logk)O(\log k)O(logk),是递归调用栈的深度,等于二分的层数;如果是迭代版分治,可以做到 O(1)O(1)O(1) 额外空间。
这个解法的坑点(重点)
-
mid不要写成(left+right)/2。虽然本题数组长度不大没问题,但left + (right-left)/2是防止left+right溢出的规范写法。 -
边界判断 :外层入口
mergeKLists要先判断lists == null或者lists.length == 0,否则merge(0, -1)直接报错。 -
数组里存在 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------这也是该分治写法不会死递归的根本原因。
解法二:优先队列(小根堆)
✅ 思路:
- 把所有非空链表的头节点 加入小根堆(堆按节点
val升序); - 每次弹出堆顶最小节点,接到结果链表上;
- 如果弹出的节点还有后继节点,把后继节点入堆;
- 堆空结束。
- 时间复杂度:O(Nlogk)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 节点。
⚠️ 两个大坑(本题高频报错点)
- 不能往里面 add null !
heap.add(null),后面取.val直接空指针;所以入堆前要判断if (node != null)。 - 比较器依赖
o1.val,节点必须是非空,这也是为什么循环入堆的时候要过滤空链表头。
反过来,如果写 (o1, o2) -> o2.val - o1.val,就是大根堆,堆顶是最大值。
举个直观例子:依次加入节点 val:5, 1, 3,堆内部会自动调整,poll() 出来顺序是:1, 3, 5。
解法三:暴力收集 + 排序重建(简单,不推荐面试写)
✅ 思路:遍历全部链表,把所有值放进 List 排序,再新建链表串起来。
- 时间复杂度:O(NlogN)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(Nlogk)O(N\log k)O(Nlogk) | O(logk)O(\log k)O(logk) | ⭐⭐⭐⭐⭐(首选) |
| 小根堆 | O(Nlogk)O(N\log k)O(Nlogk) | O(k)O(k)O(k) | ⭐⭐⭐⭐(思路好说) |
| 暴力排序 | O(NlogN)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 优先队列通用写法):
- 添加:
heap.offer(x) - 取堆顶、移除:
heap.poll() - 只看不移除:
heap.peek() - 循环条件:
while (!heap.isEmpty())
为什么推荐 offer / poll / peek:刷题时经常遇到空堆,它们返回 null 而不是抛异常,代码更稳。
2. PriorityQueue 重要特性(很多人踩坑)
- 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
}
- 初始容量只是底层数组初始大小,不是上限,会自动扩容:
java
new PriorityQueue<>(10); // 初始容量 10,不够会自动扩容
- 元素修改陷阱:如果已经在堆里的对象内部的值发生变化,堆不会自动重排!
本题场景刚好规避这个问题:我们取出节点之后,不会修改节点 val,而是把下一个新节点入堆;如果是修改堆内对象属性,堆顺序不会更新,会出错。
一句话选型 :多路有序合并,用分治归并(省空间)或小根堆(好口述),复杂度同为 O(Nlogk)O(N\log k)O(Nlogk);暴力排序只用来对答案。
146. LRU 缓存

一、题目分析与解题思路
1. 题意梳理
我们要实现一个缓存,有两个核心操作 get(key)、put(key, value),两个操作平均时间复杂度都必须是 O(1),规则:
get(key):key 存在 → 返回 value,并且这个 key 变成最近使用;不存在返回 -1。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 个基础内部操作(先抽象出来,写代码会清晰很多)
addToHead(node):把节点加到双向链表头部(标记为最近使用);removeNode(node):从链表中移除某个指定节点(双向链表 O(1));moveToHead(node):先删掉这个节点,再把它加到头部(get/ 更新时调用);removeTail():删除尾节点,返回被删掉的节点(缓存满了淘汰时调用)。
技巧:虚拟头结点 dummyHead、虚拟尾结点 dummyTail。不用频繁判空、处理头尾边界,所有节点都夹在两个虚拟节点中间,大幅减少 bug。
链表结构:dummyHead <-> 最新节点 <-> ... <-> 最久节点 <-> dummyTail
4. 常见出错点
- 只用单向链表:删除节点的时候找不到前驱,时间退化成 O(n),超时。
- 忘记
get命中之后,把节点移到头部(很多新手只更新 put,get 不更新时序,LRU 逻辑直接错)。 - 双向链表操作时,指针改漏、改错:只改 node 的
next,忘了改prev,造成链表断链、循环链表。 - 淘汰尾节点的时候:只删链表节点,忘记同步删除 HashMap 里面的 key → 哈希表脏数据,内存泄漏、查询异常。
- put 更新已有 key 的时候:只改 value,不
moveToHead。 - 虚拟头尾初始化漏掉,每次插入删除都要大量判 null,容易空指针。
- 容量
capacity = 1的边界没考虑,新增直接淘汰原有元素。 - 线程安全不用管(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)
put(1,1)→ 链表:[1]put(2,2)→ 链表:[2, 1](头是最新)get(1)→ 1 被访问,移到头部 →[1, 2]put(3,3)→ 容量满,淘汰尾部 2 →[3, 1]get(2)→ -1put(4,4)→ 容量满,淘汰尾部 1 →[4, 3]get(1)→ -1get(3)→ 3 移头部 →[3, 4]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)、Redisallkeys-lru、Caffeine / Guava Cache。 - 同源进阶 LFU (LeetCode 460):再加一层"频率分桶",并维护
minFreq。