📌 题目链接:23. 合并 K 个升序链表 - 力扣(LeetCode) 🔍 难度:困难 | 🏷️ 标签:链表、分治、堆(优先队列)、归并排序
⏱️ 目标时间复杂度:O(kn log k)
💾 空间复杂度:O(log k) 或 O(k),取决于方法✅ 链表操作中的经典难题,常出现在大厂面试中,尤其是对「多路合并」、「归并思想」和「堆结构应用」的考察。掌握本题不仅能提升你的链表处理能力,还能深入理解分治与优先队列在实际场景中的高效运用。
🎯 核心考点:
- 多链表合并的优化策略
- 分治法的递归实现
- 堆(优先队列)的灵活使用
- 面试中如何权衡时间 vs 空间
- 如何避免“暴力合并”的性能陷阱
🔍 题目分析
给你一个链表数组 lists,每个链表都已经按升序排列。
要求将所有链表合并成一个升序链表并返回。
示例说明:
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
- 每条链表本身有序,但整体无序。
- 要求最终结果是一个全局有序的单链表。
- 特殊情况:空数组、空链表等必须考虑。
关键观察:
- 不能简单地遍历所有节点再排序(会破坏链表结构,且效率低)。
- 每个链表内部有序 → 可以利用“局部有序”来减少比较次数。
- 本质是 k 路归并问题(k-way merge),常见于数据库索引合并、文件归并等场景。
🧩 核心算法及代码讲解
本题有三种主流解法,我们从最朴素到最优逐步展开:
✅ 方法一:顺序合并(暴力法)—— ❌ 时间复杂度高,不推荐
💡 思路:
依次用 mergeTwoLists 合并两个链表,每次把当前结果与下一个链表合并。
⚠️ 缺点:
- 第一次合并:长度 n
- 第二次合并:长度 2n
- ...
- 第 i 次合并:长度 i×n
- 总时间复杂度:O(k² × n)
这在 k 很大时非常慢,属于“逐个吞咽”式的低效做法。
🔧 代码(保留原内容):
ListNode* mergeTwoLists(ListNode *a, ListNode *b) {
if ((!a) || (!b)) return a ? a : b;
ListNode head, *tail = &head, *aPtr = a, *bPtr = b;
while (aPtr && bPtr) {
if (aPtr->val < bPtr->val) {
tail->next = aPtr; aPtr = aPtr->next;
} else {
tail->next = bPtr; bPtr = bPtr->next;
}
tail = tail->next;
}
tail->next = (aPtr ? aPtr : bPtr);
return head.next;
}
ListNode* mergeKLists(vector<ListNode*>& lists) {
ListNode *ans = nullptr;
for (size_t i = 0; i < lists.size(); ++i) {
ans = mergeTwoLists(ans, lists[i]);
}
return ans;
}
💡 注:虽然逻辑清晰,但面试中若只写这个会被追问:“有没有更优的方法?”
✅ 方法二:分治合并(归并排序思想)—— ✔️ 推荐!时间复杂度优秀
💡 思路:
借鉴归并排序的思想,将 k 个链表两两配对合并,形成新的子问题,直到只剩一个链表。
- 将
[0, k-1]区间划分为[0, mid]和[mid+1, k-1] - 递归合并左右两部分
- 最终合并两个已排好序的结果
🔄 分治过程示例:
k = 8: [0,1,2,3,4,5,6,7]
→ [0,3] + [4,7]
→ [0,1] + [2,3], [4,5] + [6,7]
→ ... 最终合并为一条链
✅ 优势:
- 时间复杂度:O(kn log k) ,远优于顺序合并
- 空间复杂度:O(log k) (递归栈深度)
- 是典型的“分而治之”思想体现
🔧 代码(保留原内容):
ListNode* mergeTwoLists(ListNode *a, ListNode *b) {
if ((!a) || (!b)) return a ? a : b;
ListNode head, *tail = &head, *aPtr = a, *bPtr = b;
while (aPtr && bPtr) {
if (aPtr->val < bPtr->val) {
tail->next = aPtr; aPtr = aPtr->next;
} else {
tail->next = bPtr; bPtr = bPtr->next;
}
tail = tail->next;
}
tail->next = (aPtr ? aPtr : bPtr);
return head.next;
}
ListNode* merge(vector <ListNode*> &lists, int l, int r) {
if (l == r) return lists[l];
if (l > r) return nullptr;
int mid = (l + r) >> 1;
return mergeTwoLists(merge(lists, l, mid), merge(lists, mid + 1, r));
}
ListNode* mergeKLists(vector<ListNode*>& lists) {
return merge(lists, 0, lists.size() - 1);
}
📌 行注释解析:
mergeTwoLists: 经典双指针合并,保证 O(n) 时间、O(1) 空间merge(...): 分治函数,递归拆分区间int mid = (l + r) >> 1;:位运算加速除法,等价于(l+r)/2- 返回值是两个子区间的合并结果
✅ 方法三:优先队列(最小堆)—— ✔️ 面试最爱!
💡 思路:
维护一个最小堆,存储每个链表的头节点。每次取出堆顶元素(最小值),将其加入结果链表,并将该节点的下一个节点压入堆中。
🌟 为什么用堆?
- 快速找到当前所有链表中最小的元素 → O(log k)
- 动态更新候选集合 → 支持实时插入新节点
- 类似“多路归并”的模拟
📊 过程演示:
初始堆:[1,1,2] ← 来自三个链表的首节点
取 1 → 加入结果 → 插入 4 → 堆变为 [1,2,4]
取 1 → 加入结果 → 插入 3 → 堆变为 [2,3,4]
...
✅ 优势:
- 时间复杂度:O(kn log k) ,与分治相同
- 空间复杂度:O(k) (堆最多存 k 个节点)
- 更适合“动态合并”或“流式数据”场景
🔧 代码(保留原内容):
struct Status {
int val;
ListNode *ptr;
bool operator < (const Status &rhs) const {
return val > rhs.val; // 注意:C++ 堆默认最大堆,所以反向比较
}
};
priority_queue <Status> q;
ListNode* mergeKLists(vector<ListNode*>& lists) {
for (auto node: lists) {
if (node) q.push({node->val, node});
}
ListNode head, *tail = &head;
while (!q.empty()) {
auto f = q.top(); q.pop();
tail->next = f.ptr;
tail = tail->next;
if (f.ptr->next) q.push({f.ptr->next->val, f.ptr->next});
}
return head.next;
}
📌 行注释解析:
struct Status: 自定义结构体,包含值和指针operator <: 定义堆的比较规则,注意是val > rhs.val才能实现最小堆for (auto node: lists):遍历所有链表头部,非空才入堆tail->next = f.ptr: 把最小节点接上结果链表if (f.ptr->next):如果还有后续节点,继续入堆
⚠️ 注意:不要直接用
std::priority_queue<int>,因为我们需要同时保存值和指针!
🧠 解题思路(分步骤详解)
| 步骤 | 内容 |
|---|---|
| 🔹 Step 1 | 明确问题本质:k 路归并,即从 k 个有序序列中选出最小元素 |
| 🔹 Step 2 | 分析边界条件:空链表、空数组、只有一个链表 |
| 🔹 Step 3 | 设计基础模块:mergeTwoLists(合并两个有序链表) |
| 🔹 Step 4 | 选择策略: • 顺序合并 → 暴力,O(k²n) • 分治合并 → 优雅,O(kn log k) • 堆合并 → 实用,O(kn log k) |
| 🔹 Step 5 | 实现核心逻辑: • 分治:递归划分区间 • 堆:维护最小堆,不断弹出并插入 |
📈 算法分析
| 方法 | 时间复杂度 | 空间复杂度 | 是否推荐 | 适用场景 |
|---|---|---|---|---|
| 顺序合并 | O(k²n) | O(1) | ❌ 不推荐 | 仅用于教学理解 |
| 分治合并 | O(kn log k) | O(log k) | ✅ 推荐 | 面试常考,代码简洁 |
| 堆合并 | O(kn log k) | O(k) | ✅ 强烈推荐 | 实际工程中常用 |
💡 提示:面试官更喜欢看到你先提出顺序合并,然后指出其缺陷,再引出分治或堆方案,体现思维演进。
💻 代码(完整模板)
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
// Definition for singly-linked list.
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
// 方法一:顺序合并(不推荐)
ListNode* mergeTwoLists(ListNode *a, ListNode *b) {
if ((!a) || (!b)) return a ? a : b;
ListNode head, *tail = &head, *aPtr = a, *bPtr = b;
while (aPtr && bPtr) {
if (aPtr->val < bPtr->val) {
tail->next = aPtr; aPtr = aPtr->next;
} else {
tail->next = bPtr; bPtr = bPtr->next;
}
tail = tail->next;
}
tail->next = (aPtr ? aPtr : bPtr);
return head.next;
}
ListNode* mergeKLists_v1(vector<ListNode*>& lists) {
ListNode *ans = nullptr;
for (size_t i = 0; i < lists.size(); ++i) {
ans = mergeTwoLists(ans, lists[i]);
}
return ans;
}
// 方法二:分治合并(推荐)
ListNode* merge(vector <ListNode*> &lists, int l, int r) {
if (l == r) return lists[l];
if (l > r) return nullptr;
int mid = (l + r) >> 1;
return mergeTwoLists(merge(lists, l, mid), merge(lists, mid + 1, r));
}
ListNode* mergeKLists_v2(vector<ListNode*>& lists) {
return merge(lists, 0, lists.size() - 1);
}
// 方法三:优先队列(堆)(强烈推荐)
struct Status {
int val;
ListNode *ptr;
bool operator < (const Status &rhs) const {
return val > rhs.val;
}
};
ListNode* mergeKLists_v3(vector<ListNode*>& lists) {
priority_queue<Status> q;
for (auto node : lists) {
if (node) q.push({node->val, node});
}
ListNode head, *tail = &head;
while (!q.empty()) {
auto f = q.top(); q.pop();
tail->next = f.ptr;
tail = tail->next;
if (f.ptr->next) q.push({f.ptr->next->val, f.ptr->next});
}
return head.next;
}
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
// 测试用例1
vector<ListNode*> lists1;
lists1.push_back(new ListNode(1));
lists1[0]->next = new ListNode(4);
lists1[0]->next->next = new ListNode(5);
lists1.push_back(new ListNode(1));
lists1[1]->next = new ListNode(3);
lists1[1]->next->next = new ListNode(4);
lists1.push_back(new ListNode(2));
lists1[2]->next = new ListNode(6);
auto result1 = mergeKLists_v3(lists1);
while (result1) {
cout << result1->val << " ";
result1 = result1->next;
}
cout << endl;
// 测试用例2:空数组
vector<ListNode*> lists2;
auto result2 = mergeKLists_v3(lists2);
cout << (result2 ? "Not empty" : "Empty") << endl;
// 测试用例3:空链表
vector<ListNode*> lists3;
lists3.push_back(nullptr);
auto result3 = mergeKLists_v3(lists3);
cout << (result3 ? "Not empty" : "Empty") << endl;
return 0;
}
✅ 输出结果:
1 1 2 3 4 4 5 6 Empty Empty
🧩 面试拓展知识点
❓ Q1:为什么堆比分治更好?
- 堆更适合动态场景:比如链表长度不均、实时数据流
- 分治需要递归栈:深嵌套可能引发栈溢出(虽概率小)
- 堆可扩展性强:容易改造成“k 路归并读取文件”
❓ Q2:能不能用最大堆?
- 可以,但要调整比较逻辑
- 通常还是用最小堆更直观
❓ Q3:能否用 multiset 替代堆?
- 可以,但插入删除是 O(log k),总复杂度仍 O(kn log k)
- 但
multiset支持范围查询,灵活性更高
❓ Q4:有没有 O(kn) 的方法?
- 理论上不可能,因为至少要比较 kn 个元素
- 除非知道某些特殊性质(如所有链表长度相等)
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📣 下一期预告:LeetCode 热题 100 第35题 —— 146.LRU 缓存(中等)
🔹 题目:设计一个 LRU(Least Recently Used)缓存机制,支持
get(key)和put(key, value)操作,在容量满时自动淘汰最久未使用的项。🔹 核心思路:结合 哈希表 + 双向链表 实现 O(1) 时间复杂度的访问与删除。
🔹 考点:数据结构设计、哈希表、双向链表、缓存淘汰策略。
🔹 难度:中等,但却是系统设计类题目的入门必修课,常用于考察“工程思维”。
💡 提示:不要只用 map,要学会用链表记录访问顺序!
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!
✅ 本篇文章已覆盖:
- 三种主流解法对比
- 详细代码行注释
- 面试常见问题解答
- 实际测试用例验证
- 下期预告引导学习路径