对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎
LeetCode 23. 合并 K 个升序链表
1. 题目描述
给你一个链表数组,每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中,并返回合并后的链表。
示例 1:
输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
解释:链表数组如下:
[
1->4->5,
1->3->4,
2->6
]
将它们合并到一个有序链表中得到。
1->1->2->3->4->4->5->6
示例 2:
输入:lists = []
输出:[]
示例 3:
输入:lists = [[]]
输出:[]
提示:
k == lists.length0 <= k <= 10^40 <= lists[i].length <= 500-10^4 <= lists[i][j] <= 10^4lists[i]按 升序 排列lists[i].length的总和不超过10^4
2. 问题分析
在前端开发中,我们经常需要处理多个有序数据流的合并,例如:
- 多来源数据聚合:从多个API接口获取已排序的数据,需要合并展示
- 日志合并:多个服务的按时间排序的日志需要合并分析
- 虚拟列表优化:多个有序数据源合并后渲染长列表
这个问题本质上是多路归并问题,是归并排序的扩展。对于前端开发者来说,理解此问题有助于掌握分治、优先队列等思想,在处理大数据流、实现高效渲染时非常有用。
3. 解题思路
3.1 思路概览
我们有几种主要解决方案:
- 顺序合并法:依次合并每个链表
- 分治法:借鉴归并排序思想,两两合并
- 优先队列(最小堆)法:维护当前所有链表头节点的最小值
- 暴力解法:将所有节点值收集后排序,再构建链表
从复杂度分析看,优先队列法 和分治法是最优解,时间复杂度都是O(Nlogk),其中N是总节点数,k是链表数量。
3.2 各思路详解
3.2.1 顺序合并法
逐个链表合并,每次合并两个有序链表。简单直观,但效率较低。
3.2.2 分治法
采用归并排序的思想,将k个链表配对并两两合并,重复这一过程直到合并成一个链表。
3.2.3 优先队列法(最优解之一)
维护一个大小为k的最小堆,每次从堆中取出最小节点,将该节点的下一个节点加入堆中,直到堆为空。
3.2.4 暴力解法
收集所有节点值到数组,排序后构建新链表。简单但失去了链表的特性优势。
4. 各思路代码实现
4.1 顺序合并法
javascript
/**
* 合并两个有序链表
*/
const mergeTwoLists = (l1, l2) => {
const dummy = new ListNode(0);
let 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 || l2;
return dummy.next;
};
/**
* 顺序合并K个链表
*/
const mergeKLists = function(lists) {
if (lists.length === 0) return null;
let result = lists[0];
for (let i = 1; i < lists.length; i++) {
result = mergeTwoLists(result, lists[i]);
}
return result;
};
4.2 分治法
javascript
/**
* 分治法合并K个链表
*/
const mergeKLists = function(lists) {
if (lists.length === 0) return null;
const merge = (start, end) => {
if (start === end) return lists[start];
if (start > end) return null;
const mid = Math.floor((start + end) / 2);
const left = merge(start, mid);
const right = merge(mid + 1, end);
return mergeTwoLists(left, right);
};
return merge(0, lists.length - 1);
};
/**
* 迭代版本的分治法
*/
const mergeKListsIterative = function(lists) {
if (lists.length === 0) return null;
let interval = 1;
const n = lists.length;
while (interval < n) {
for (let i = 0; i < n - interval; i += interval * 2) {
lists[i] = mergeTwoLists(lists[i], lists[i + interval]);
}
interval *= 2;
}
return lists[0];
};
4.3 优先队列法(最小堆)
javascript
/**
* 优先队列法(最小堆实现)
*/
class MinHeap {
constructor() {
this.heap = [];
}
size() {
return this.heap.length;
}
push(node) {
this.heap.push(node);
this.bubbleUp(this.heap.length - 1);
}
pop() {
if (this.size() === 0) return null;
const min = this.heap[0];
const last = this.heap.pop();
if (this.size() > 0) {
this.heap[0] = last;
this.sinkDown(0);
}
return min;
}
bubbleUp(index) {
const node = this.heap[index];
while (index > 0) {
const parentIndex = Math.floor((index - 1) / 2);
const parent = this.heap[parentIndex];
if (node.val >= parent.val) break;
this.heap[parentIndex] = node;
this.heap[index] = parent;
index = parentIndex;
}
}
sinkDown(index) {
const length = this.size();
const node = this.heap[index];
while (true) {
let leftChildIndex = 2 * index + 1;
let rightChildIndex = 2 * index + 2;
let swap = null;
let leftChild, rightChild;
if (leftChildIndex < length) {
leftChild = this.heap[leftChildIndex];
if (leftChild.val < node.val) {
swap = leftChildIndex;
}
}
if (rightChildIndex < length) {
rightChild = this.heap[rightChildIndex];
if (
(swap === null && rightChild.val < node.val) ||
(swap !== null && rightChild.val < leftChild.val)
) {
swap = rightChildIndex;
}
}
if (swap === null) break;
this.heap[index] = this.heap[swap];
this.heap[swap] = node;
index = swap;
}
}
}
const mergeKLists = function(lists) {
if (lists.length === 0) return null;
const minHeap = new MinHeap();
const dummy = new ListNode(0);
let cur = dummy;
// 将所有链表的头节点加入最小堆
for (let list of lists) {
if (list) {
minHeap.push(list);
}
}
// 不断从堆中取出最小节点
while (minHeap.size() > 0) {
const node = minHeap.pop();
cur.next = node;
cur = cur.next;
// 如果该节点还有下一个节点,加入堆中
if (node.next) {
minHeap.push(node.next);
}
}
return dummy.next;
};
/**
* 使用JavaScript内置的优先队列(如果环境支持)
*/
const mergeKListsWithPriorityQueue = function(lists) {
if (lists.length === 0) return null;
const dummy = new ListNode(0);
let cur = dummy;
// 使用优先队列,按节点值排序
const pq = new PriorityQueue({
compare: (a, b) => a.val - b.val
});
// 初始化优先队列
for (let list of lists) {
if (list) {
pq.enqueue(list);
}
}
// 处理队列
while (!pq.isEmpty()) {
const node = pq.dequeue();
cur.next = node;
cur = cur.next;
if (node.next) {
pq.enqueue(node.next);
}
}
return dummy.next;
};
4.4 暴力解法
javascript
/**
* 暴力解法
*/
const mergeKLists = function(lists) {
const nodes = [];
// 收集所有节点值
for (let list of lists) {
while (list) {
nodes.push(list.val);
list = list.next;
}
}
// 排序
nodes.sort((a, b) => a - b);
// 构建新链表
const dummy = new ListNode(0);
let cur = dummy;
for (let val of nodes) {
cur.next = new ListNode(val);
cur = cur.next;
}
return dummy.next;
};
5. 各实现思路的复杂度、优缺点对比
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 顺序合并 | O(kN) | O(1) | 实现简单,无需额外空间 | 效率低,重复遍历节点多 | k较小或链表长度较短 |
| 分治法 | O(Nlogk) | O(logk) | 效率高,递归思路清晰 | 递归栈空间开销 | 通用场景,尤其适合链表数量多 |
| 优先队列 | O(Nlogk) | O(k) | 效率高,逻辑清晰 | 需要维护堆结构 | 实时数据流处理,k较大 |
| 暴力解法 | O(NlogN) | O(N) | 实现极其简单 | 破坏链表结构,额外空间大 | 快速实现,不关心性能 |
说明:
- N:所有链表的总节点数
- k:链表数量
- 最优解:分治法和优先队列法都是最优时间复杂度,优先选择
- 前端场景推荐:优先队列法,逻辑清晰且易于理解和维护
6. 总结
6.1 核心要点
- 问题本质:多路归并问题,是归并排序从两路到多路的扩展
- 关键思想:分治与优先队列(堆)是解决此类问题的核心思想
- 最优复杂度:O(Nlogk),无法再优化,因为每个节点都需要处理,且每次选择最小值需要logk时间
6.2 实际应用场景
-
前端数据处理:
- 多个API返回的有序数据合并展示
- 多来源日志的时间线合并
- 搜索引擎的多索引结果合并
-
性能优化:
- 虚拟滚动列表的数据流合并
- 大文件分片下载后的有序合并
- 实时聊天消息的多会话合并
-
系统设计:
- 多个有序数据源的流式处理
- 分布式系统中的有序日志合并
- 时间序列数据库的多查询结果合并