文章目录
- [从合并两个链表到 K 个链表:分治思想的递进与堆优化](#从合并两个链表到 K 个链表:分治思想的递进与堆优化)
-
- 一、问题引入:从简单场景开始
-
- [1.1 合并两个升序链表](#1.1 合并两个升序链表)
- [1.2 扩展:合并 K 个升序链表](#1.2 扩展:合并 K 个升序链表)
- 二、初步尝试:迭代两两合并
-
- [2.1 思路分析](#2.1 思路分析)
- [2.2 问题:效率低下](#2.2 问题:效率低下)
- 三、优化思路:分治思想的应用
-
- [3.1 为什么分治能优化?](#3.1 为什么分治能优化?)
- [3.2 分治流程示例](#3.2 分治流程示例)
- [3.3 代码实现:递归分治](#3.3 代码实现:递归分治)
- [3.4 复杂度分析](#3.4 复杂度分析)
- 四、解法三:优先队列(最小堆)解法(另一种高效思路)
-
- [4.1 思路分析](#4.1 思路分析)
- [4.2 代码实现](#4.2 代码实现)
- [4.3 关键细节解析](#4.3 关键细节解析)
- [4.4 复杂度分析](#4.4 复杂度分析)
- 五、总结:从特殊到一般的思维迁移
- [六、 补充](#六、 补充)
-
- 优先队列:定义、原理与实战案例
-
- 一、什么是优先队列?
-
- [1.1 核心定义](#1.1 核心定义)
- [1.2 底层实现:为什么用堆?](#1.2 底层实现:为什么用堆?)
- [1.3 两种常见类型](#1.3 两种常见类型)
- 案例
-
- 一、优先队列基础操作(最大堆与最小堆)
- 二、自定义元素类型与优先级(结构体案例)
- [三、算法应用:合并 K 个升序链表](#三、算法应用:合并 K 个升序链表)
- [四、算法应用:数据流中的第 K 大元素](#四、算法应用:数据流中的第 K 大元素)
从合并两个链表到 K 个链表:分治思想的递进与堆优化
在链表操作中,"合并有序链表" 是一个经典问题。从最初的 "合并两个升序链表",到进阶的 "合并 K 个升序链表",解法思路的演变不仅体现了算法效率的优化,更蕴含了 "分治思想" 从特殊到一般的应用逻辑。本文将一步步拆解问题,从基础解法到高效优化,带你理解如何用分治思维解决复杂问题。

题目链接
一、问题引入:从简单场景开始
1.1 合并两个升序链表
先从最基础的问题入手:给定两个升序链表,如何合并为一个升序链表?
示例:
plaintext
输入:l1 = 1->4->5, l2 = 1->3->4
输出:1->1->3->4->4->5
解法思路:双指针遍历
合并两个有序链表的核心是 "比较与拼接":
- 用两个指针分别遍历两个链表,每次取当前值较小的节点接入结果链表;
- 当一个链表遍历结束后,直接拼接另一个链表的剩余部分。
为了简化头节点的处理,通常会引入 "哨兵节点"(dummy node),避免空指针判断的繁琐。
代码实现:
cpp
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
};
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
// 哨兵节点,简化头节点处理
ListNode* dummy = new ListNode();
ListNode* cur = dummy; // 游标指针,指向结果链表的尾部
// 双指针遍历两个链表,取较小值拼接
while (l1 && l2) {
if (l1->val < l2->val) {
cur->next = l1; // 接入l1的当前节点
l1 = l1->next; // l1指针后移
} else {
cur->next = l2; // 接入l2的当前节点
l2 = l2->next; // l2指针后移
}
cur = cur->next; // 结果链表尾部后移
}
// 拼接剩余节点(其中一个链表已遍历完)
cur->next = l1 ? l1 : l2;
ListNode* result = dummy->next;
delete dummy; // 释放哨兵节点,避免内存泄漏
return result;
}
复杂度分析:
- 时间复杂度:O (M+N),其中 M、N 分别为两个链表的长度(每个节点最多被访问一次)。
- 空间复杂度:O (1),仅使用常数级额外空间(哨兵节点可释放,不算入有效空间)。
1.2 扩展:合并 K 个升序链表
当问题从 "2 个" 扩展到 "K 个",情况变得复杂:给定 K 个升序链表,如何合并为一个升序链表?
示例:
plaintext
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:1->1->2->3->4->4->5->6
最直接的思路是 "复用合并两个链表的逻辑",但如何高效复用呢?
二、初步尝试:迭代两两合并
2.1 思路分析
既然能合并两个链表,那么可以依次将 K 个链表合并到一个结果链表中:
- 初始化结果链表为 null;
- 遍历链表数组,每次将当前结果链表与下一个链表合并,更新结果;
- 最终得到合并后的总链表。
代码实现:
cpp
ListNode* mergeKLists_Iterative(vector<ListNode*>& lists) {
if (lists.empty()) return nullptr; // 空数组直接返回null
ListNode* result = nullptr;
for (ListNode* list : lists) {
// 每次合并当前结果与下一个链表
result = mergeTwoLists(result, list);
}
return result;
}
2.2 问题:效率低下
假设 K 个链表的总节点数为 T(每个链表平均长度为 T/K),分析时间复杂度:
- 第 1 次合并:结果链表长度为 0 + len (lists [0]) → O (len (lists [0]));
- 第 2 次合并:结果链表长度为 len (lists [0]) + len (lists [1]) → O (len (lists [0]) + len (lists [1]));
- ...
- 第 K 次合并:结果链表长度为前 K-1 个链表的总长度 → O (T)。
总时间复杂度为 O (T + T/2 + T/3 + ... + T/K) ≈ O (K*T),当 K 较大时(如 K=10^4),效率极低。
三、优化思路:分治思想的应用
3.1 为什么分治能优化?
分治思想的核心是 "将大问题拆解为小问题,再合并小问题的解"。对于 K 个链表的合并:
- 若 K=1,直接返回该链表;
- 若 K>1,将链表数组分成左右两部分,分别合并这两部分得到两个链表,再合并这两个链表即可。
通过分治,每次合并的规模减半,最终需要合并的次数从 K 次降为 logK 次,大幅减少重复操作。
3.2 分治流程示例
以 K=4 个链表为例:
plaintext
初始链表:[l0, l1, l2, l3]
第1次分治:合并 [l0, l1] → m0;合并 [l2, l3] → m1
第2次分治:合并 [m0, m1] → 最终结果
3.3 代码实现:递归分治
cpp
class Solution {
public:
// 合并两个链表(复用之前的实现)
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
if (!l1) return l2;
if (!l2) return l1;
ListNode* dummy = new ListNode();
ListNode* cur = dummy;
while (l1 && l2) {
if (l1->val < l2->val) {
cur->next = l1;
l1 = l1->next;
} else {
cur->next = l2;
l2 = l2->next;
}
cur = cur->next;
}
cur->next = l1 ? l1 : l2;
ListNode* res = dummy->next;
delete dummy;
return res;
}
// 分治递归函数:合并 [left, right] 范围内的链表
ListNode* merge(vector<ListNode*>& lists, int left, int right) {
if (left == right) { // 递归终止:只有一个链表
return lists[left];
}
if (left > right) { // 空范围(如偶数拆分时可能出现)
return nullptr;
}
// 中间位置,拆分左右两部分
int mid = left + (right - left) / 2;
// 递归合并左半部分和右半部分,再合并结果
return mergeTwoLists(merge(lists, left, mid), merge(lists, mid + 1, right));
}
// 入口函数
ListNode* mergeKLists(vector<ListNode*>& lists) {
if (lists.empty()) return nullptr;
return merge(lists, 0, lists.size() - 1);
}
};
3.4 复杂度分析
-
时间复杂度:O (TlogK)。
每次递归将问题规模减半,递归深度为 logK;每一层的总合并操作需要遍历所有 T 个节点,因此总复杂度为 TlogK。
-
空间复杂度:O (logK)。递归调用栈的深度为 logK(忽略结果链表本身的存储空间)。
四、解法三:优先队列(最小堆)解法(另一种高效思路)
4.1 思路分析
合并 K 个链表的核心问题是:如何快速从 K 个链表的当前头节点中找到最小值。优先队列(最小堆)正好解决这个问题:
- 堆的顶部始终是最小元素,可在 O (1) 时间内获取;
- 插入和删除操作的时间复杂度为 O (logK)(堆的大小不超过 K)。
具体步骤:
- 初始化最小堆,将 K 个链表的非空头节点入堆;
- 每次从堆顶取出最小节点,接入结果链表;
- 若取出的节点有下一个节点,将其入堆;
- 重复步骤 2-3,直到堆为空,此时所有节点均已合并。
4.2 代码实现
cpp
#include <queue>
#include <vector>
using namespace std;
class Solution {
public:
ListNode* mergeKLists(vector<ListNode*>& lists) {
// 定义最小堆的比较器(C++默认是最大堆,需自定义为最小堆)
auto cmp = [](ListNode* a, ListNode* b) {
return a->val > b->val; // 值大的节点优先级低,堆顶为最小值
};
// 初始化最小堆,底层容器为vector,比较器为cmp
priority_queue<ListNode*, vector<ListNode*>, decltype(cmp)> minHeap(cmp);
// 所有链表的头节点入堆(跳过空链表)
for (ListNode* list : lists) {
if (list) { // 避免空指针入堆
minHeap.push(list);
}
}
// 哨兵节点,简化结果链表的构建
ListNode* dummy = new ListNode();
ListNode* cur = dummy;
// 循环取堆顶最小节点,构建结果链表
while (!minHeap.empty()) {
// 取出当前最小节点
ListNode* smallest = minHeap.top();
minHeap.pop();
// 接入结果链表
cur->next = smallest;
cur = cur->next;
// 若该节点有下一个节点,入堆参与后续比较
if (smallest->next) {
minHeap.push(smallest->next);
}
}
ListNode* result = dummy->next;
delete dummy;
return result;
}
};
4.3 关键细节解析
- 最小堆的实现 :C++ 的
priority_queue默认是最大堆,通过自定义比较器cmp(返回a->val > b->val),使堆顶始终为最小元素。 - 空链表处理:入堆前判断链表是否为空,避免空指针访问。
- 节点更新:每次取出节点后,若其有后续节点,立即入堆,保证堆中始终包含所有链表的 "当前最小节点"。
4.4 复杂度分析
-
时间复杂度:O (TlogK)。
每个节点入堆和出堆各一次,共 T 次操作;每次堆操作的时间为 O (logK)(堆的大小不超过 K),因此总复杂度为 T
logK。
-
空间复杂度:O (K)。堆中最多同时存储 K 个节点(每个链表一个当前头节点)
五、总结:从特殊到一般的思维迁移
- 基础是关键:合并 K 个链表的所有解法都依赖于 "合并两个链表" 的基础逻辑,掌握基础才能进阶。
- 效率优化的核心 :从 O (KT) 到 O (TlogK) 的突破,本质是减少了 "找最小值" 的重复操作 ------ 分治通过减少合并次数,优先队列通过数据结构直接优化查找过程。
- 算法选择:实际应用中,分治和优先队列都是高效解法,可根据场景选择(如递归深度限制、内存限制等)。
六、 补充
优先队列:定义、原理与实战案例
优先队列是一种特殊的队列结构,它打破了普通队列 "先进先出(FIFO)" 的规则,而是按照元素的优先级决定出队顺序 ------ 优先级高的元素始终先出队。它在算法优化、任务调度等场景中应用广泛,是解决 "动态选最优" 问题的核心工具。
一、什么是优先队列?
1.1 核心定义
优先队列(Priority Queue)是一种抽象数据结构,其核心特性是:
- 存储元素:每个元素都带有一个 "优先级"(可通过元素自身值或自定义规则确定);
- 出队规则 :每次出队的不是队列头部的元素,而是当前所有元素中优先级最高的元素;
- 入队规则:元素按正常顺序插入,但插入后会自动调整结构,确保 "优先级最高元素在顶端" 的特性。
它本质是 "队列的抽象功能" 与 "堆(Heap)的底层实现" 的结合 ------ 堆是实现优先队列的高效数据结构(时间复杂度最优),因此通常说 "优先队列" 时,默认指 "基于堆实现的优先队列"。
1.2 底层实现:为什么用堆?
优先队列的核心需求是 "快速找最优(最大 / 最小)" 和 "快速插入新元素",而堆(完全二叉树结构)恰好满足这两个需求:
- 找最优 :堆的顶端(根节点)就是优先级最高的元素,访问时间复杂度 O(1);
- 插入元素 :新元素插入堆尾后,通过 "上浮" 调整到正确位置,时间复杂度 O(logN)(N 为元素总数);
- 删除最优元素 :删除根节点后,用堆尾元素补位并 "下沉" 调整,时间复杂度 O(logN)。
若用数组或链表实现优先队列,"找最优" 需遍历所有元素(O (N)),效率远低于堆,因此堆是优先队列的标准实现方式。
1.3 两种常见类型
根据 "优先级高" 的定义不同,优先队列分为两类:
| 类型 | 核心特性 | 堆的实现方式 | 应用场景举例 |
|---|---|---|---|
| 最大优先队列 | 优先级最高 = 元素值最大 | 最大堆(根是最大值) | 任务调度(优先级数值大的任务先执行) |
| 最小优先队列 | 优先级最高 = 元素值最小 | 最小堆(根是最小值) | 合并有序链表、最短路径算法 |
注意:优先级规则可自定义(如 "任务截止时间早的优先级高"),不一定依赖元素自身值。
案例
下面通过几个具体的 C++ 代码案例,展示优先队列(std::priority_queue)在不同场景下的使用方法,涵盖基础操作、自定义优先级、经典算法应用等。
一、优先队列基础操作(最大堆与最小堆)
C++ 标准库的 std::priority_queue 默认是最大堆 (优先级最高的元素是最大值),通过自定义比较器可实现最小堆。
cpp
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
int main() {
// 1. 默认最大堆(元素类型为int,底层容器vector,比较器less<int>)
priority_queue<int> max_heap;
max_heap.push(3);
max_heap.push(1);
max_heap.push(4);
max_heap.push(2);
cout << "最大堆元素出队顺序:";
while (!max_heap.empty()) {
cout << max_heap.top() << " "; // 每次取最大值
max_heap.pop();
}
// 输出:4 3 2 1
// 2. 最小堆(需自定义比较器greater<int>)
priority_queue<int, vector<int>, greater<int>> min_heap;
min_heap.push(3);
min_heap.push(1);
min_heap.push(4);
min_heap.push(2);
cout << "\n最小堆元素出队顺序:";
while (!min_heap.empty()) {
cout << min_heap.top() << " "; // 每次取最小值
min_heap.pop();
}
// 输出:1 2 3 4
return 0;
}
关键说明:
priority_queue模板参数:priority_queue<元素类型, 底层容器, 比较器>,底层容器默认是vector;- 比较器
less<int>()对应最大堆(a < b时b优先级高); - 比较器
greater<int>()对应最小堆(a > b时b优先级高)。
二、自定义元素类型与优先级(结构体案例)
当元素是自定义结构体时,需通过重载运算符 或自定义比较器指定优先级规则。
案例:任务调度(优先级高的任务先执行)
cpp
#include <iostream>
#include <queue>
#include <string>
using namespace std;
// 任务结构体:包含任务名和优先级(数值越大优先级越高)
struct Task {
string name;
int priority;
Task(string n, int p) : name(n), priority(p) {}
};
// 自定义比较器(用于最大堆,优先级高的任务先出队)
// 规则:若a.priority < b.priority,则b优先级更高,应排在堆顶
struct TaskCmp {
bool operator()(const Task& a, const Task& b) {
return a.priority < b.priority; // 最大堆(优先级高的在前)
}
};
int main() {
// 优先队列存储Task,使用自定义比较器TaskCmp
priority_queue<Task, vector<Task>, TaskCmp> task_queue;
// 插入任务
task_queue.emplace("紧急修复", 10); // emplace直接构造对象,更高效
task_queue.emplace("日常维护", 5);
task_queue.emplace("数据备份", 3);
task_queue.emplace("系统升级", 8);
// 执行任务(按优先级从高到低)
cout << "任务执行顺序:\n";
while (!task_queue.empty()) {
Task t = task_queue.top();
task_queue.pop();
cout << "任务:" << t.name << ",优先级:" << t.priority << endl;
}
/* 输出:
任务:紧急修复,优先级:10
任务:系统升级,优先级:8
任务:日常维护,优先级:5
任务:数据备份,优先级:3
*/
return 0;
}
关键说明:
- 自定义比较器是一个结构体,需重载
operator(),返回bool类型; - 比较逻辑:
return a.priority < b.priority表示 "b优先级高于a",因此堆顶是优先级最高的元素; - 若要实现 "优先级低的任务先执行"(最小堆),只需将比较器改为
return a.priority > b.priority。
三、算法应用:合并 K 个升序链表
这是优先队列的经典应用,用最小堆快速获取 K 个链表中的最小节点。
cpp
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
// 链表节点定义
struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next(nullptr) {}
};
class Solution {
public:
ListNode* mergeKLists(vector<ListNode*>& lists) {
// 自定义比较器:最小堆(val小的节点优先级高)
auto cmp = [](ListNode* a, ListNode* b) {
return a->val > b->val; // 注意:这里用>表示最小堆
};
// 优先队列存储链表节点指针,使用自定义lambda比较器
priority_queue<ListNode*, vector<ListNode*>, decltype(cmp)> min_heap(cmp);
// 初始化:将所有非空链表的头节点入堆
for (ListNode* list : lists) {
if (list != nullptr) { // 跳过空链表
min_heap.push(list);
}
}
// 哨兵节点,简化结果链表的构建
ListNode* dummy = new ListNode(0);
ListNode* cur = dummy;
// 循环取堆顶最小节点,构建结果链表
while (!min_heap.empty()) {
ListNode* smallest = min_heap.top(); // 取当前最小节点
min_heap.pop();
cur->next = smallest; // 接入结果链表
cur = cur->next;
// 若该节点有下一个节点,入堆参与后续比较
if (smallest->next != nullptr) {
min_heap.push(smallest->next);
}
}
ListNode* result = dummy->next;
delete dummy; // 释放哨兵节点,避免内存泄漏
return result;
}
};
// 辅助函数:打印链表
void printList(ListNode* head) {
while (head != nullptr) {
cout << head->val << "->";
head = head->next;
}
cout << "nullptr" << endl;
}
int main() {
// 构建测试用例:3个升序链表
ListNode* l1 = new ListNode(1);
l1->next = new ListNode(4);
l1->next->next = new ListNode(5);
ListNode* l2 = new ListNode(1);
l2->next = new ListNode(3);
l2->next->next = new ListNode(4);
ListNode* l3 = new ListNode(2);
l3->next = new ListNode(6);
vector<ListNode*> lists = {l1, l2, l3};
Solution sol;
ListNode* merged = sol.mergeKLists(lists);
cout << "合并后的链表:";
printList(merged); // 输出:1->1->2->3->4->4->5->6->nullptr
return 0;
}
关键说明:
- 用 lambda 表达式作为比较器,需用
decltype(cmp)声明类型,并在构造队列时传入cmp; - 堆中始终存储各链表的 "当前头节点",每次取出最小值后,将其下一个节点入堆,确保堆中始终有候选的最小节点;
- 时间复杂度 O (T log K)(T 为总节点数,K 为链表数),效率远高于暴力法。
四、算法应用:数据流中的第 K 大元素
用大小为 K 的最小堆维护 "前 K 大元素",堆顶即为第 K 大元素。
cpp
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
class KthLargest {
private:
int k;
priority_queue<int, vector<int>, greater<int>> min_heap; // 最小堆,大小不超过k
public:
KthLargest(int k, vector<int>& nums) : k(k) {
// 初始化:将所有元素入堆,再弹出多余元素,保留前k大
for (int num : nums) {
min_heap.push(num);
if (min_heap.size() > k) {
min_heap.pop(); // 超过k个元素时,弹出最小值(保证堆内是前k大)
}
}
}
int add(int val) {
// 新元素入堆
min_heap.push(val);
// 若堆大小超过k,弹出最小值
if (min_heap.size() > k) {
min_heap.pop();
}
// 堆顶即为当前第k大元素
return min_heap.top();
}
};
int main() {
vector<int> nums = {4, 5, 8, 2};
KthLargest kthLargest(3, nums); // 初始化:找第3大元素
cout << kthLargest.add(3) << endl; // 插入3后,前3大是4、5、8 → 输出4
cout << kthLargest.add(5) << endl; // 插入5后,前3大是5、5、8 → 输出5
cout << kthLargest.add(10) << endl; // 插入10后,前3大是5、8、10 → 输出5?不,是8?
// 修正:插入10后堆内元素为5、8、10,堆顶是5?不,最小堆的堆顶是5,但第3大是5?
// 哦不,第3大是排序后第3个元素:[2,3,4,5,5,8,10] → 第3大是5?不,是从大到小排:10,8,5,5,4,3,2 → 第3大是5。
// 因此输出5是对的。
return 0;
}
关键说明:
- 堆的大小始终保持为 K,插入新元素后若超过 K,则弹出最小值,确保堆内是当前 "前 K 大元素";
- 堆顶是这 K 个元素中的最小值,即整个数据流中的第 K 大元素;
- 每次插入和查询的时间复杂度为 O (log K),适合高频插入场景。