LeetCode 23. 合并 K 个升序链表:两种最优解法详解(最小堆 + 分治)

0 阅读9分钟

LeetCode 中等难度经典题——合并 K 个升序链表,这道题是链表操作的高频考点,也是面试中常被问到的题目,核心考察对链表、堆、分治思想的综合运用。下面会从题目分析入手,一步步讲解两种最优解法(最小堆解法 + 分治解法),附上完整代码和详细注释,新手也能轻松看懂。

一、题目回顾

题目描述:给你一个链表数组,每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中,返回合并后的链表。

示例(辅助理解):

输入:lists = [[1,4,5],[1,3,4],[2,6]]

输出:[1,1,2,3,4,4,5,6]

二、核心思路分析

首先明确核心需求:将 K 个“升序链表”合并为一个“整体升序链表”。关键在于「高效找到每次的最小元素」,因为每个链表本身是升序的,所以每个链表的头节点是当前链表的最小值,我们只需要在这 K 个头节点中找最小的,依次拼接即可。

基于这个核心,有两种高效思路:

  1. 最小堆(优先队列):用最小堆维护所有链表的头节点,每次弹出堆顶(当前最小元素),再将该节点的下一个节点加入堆,循环直至堆空,拼接成结果链表。

  2. 分治思想:将 K 个链表拆分成两个子问题,分别合并两个子问题的结果,最终合并为一个链表(本质是“合并两个升序链表”的升级版,递归拆分 + 回溯合并)。

先补充链表节点的定义(题目已给出,这里统一梳理,方便后续理解代码):

class ListNode {
  val: number
  next: ListNode | null
  constructor(val?: number, next?: ListNode | null) {
    this.val = (val === undefined ? 0 : val)
    this.next = (next === undefined ? null : next)
  }
}

三、解法一:最小堆解法(直观高效,易理解)

3.1 解法思路

  1. 初始化一个最小堆,将所有非空链表的头节点加入堆中(堆会自动维护最小值在堆顶);

  2. 初始化一个虚拟头节点(dummy 节点),用于拼接结果链表,避免处理头节点为空的边界情况;

  3. 循环弹出堆顶元素(当前最小节点),将其接入结果链表,然后将该节点的下一个节点(如果存在)加入堆中;

  4. 当堆为空时,所有节点都已拼接完成,返回 dummy.next 即为合并后的链表。

3.2 完整代码(带详细注释)

// 实现最小堆(优先队列),用于维护链表头节点的最小值
class MyMinHeap {
  private heap: ListNode[];

  constructor() {
    this.heap = []; // 堆的底层存储结构:数组
  }

  // 插入节点:插入到堆尾,再向上调整堆(维护最小堆性质)
  push(node: ListNode) {
    this.heap.push(node);
    this.bubbleUp(this.heap.length - 1); // 从最后一个元素开始向上调整
  }

  // 弹出堆顶(最小值):将堆顶与堆尾元素交换,弹出堆尾,再向下调整堆
  pop(): ListNode | null {
    if (this.isEmpty()) return null; // 堆空返回null
    if (this.heap.length === 1) return this.heap.pop()!; // 只有一个元素,直接弹出

    const top = this.heap[0]; // 堆顶(最小值)
    this.heap[0] = this.heap.pop()!; // 堆尾元素移到堆顶
    this.bubbleDown(0); // 从堆顶开始向下调整
    return top;
  }

  // 判断堆是否为空
  isEmpty(): boolean {
    return this.heap.length === 0;
  }

  // 向上调整堆:当插入元素时,确保父节点始终小于子节点(最小堆核心)
  private bubbleUp(index: number) {
    while (index > 0) { // 索引大于0,说明不是根节点
      const parentIndex = Math.floor((index - 1) / 2); // 父节点索引 = (子节点索引 - 1) // 2
      // 如果当前节点值 < 父节点值,交换两者(维护最小堆)
      if (this.heap[index].val < this.heap[parentIndex].val) {
        [this.heap[index], this.heap[parentIndex]] = [this.heap[parentIndex], this.heap[index]];
        index = parentIndex; // 继续向上调整父节点
      } else {
        break; // 满足最小堆性质,停止调整
      }
    }
  }

  // 向下调整堆:当弹出堆顶后,确保堆顶是最小值
  private bubbleDown(index: number) {
    const length = this.heap.length;
    while (true) {
      let leftChildIdx = 2 * index + 1; // 左孩子索引 = 2*父节点索引 + 1
      let rightChildIdx = 2 * index + 2; // 右孩子索引 = 2*父节点索引 + 2
      let smallestIdx = index; // 初始化最小值索引为当前节点

      // 找到当前节点、左孩子、右孩子中的最小值索引
      if (leftChildIdx < length && this.heap[leftChildIdx].val < this.heap[smallestIdx].val) {
        smallestIdx = leftChildIdx;
      }
      if (rightChildIdx < length && this.heap[rightChildIdx].val < this.heap[smallestIdx].val) {
        smallestIdx = rightChildIdx;
      }

      // 如果最小值不是当前节点,交换并继续向下调整
      if (smallestIdx !== index) {
        [this.heap[index], this.heap[smallestIdx]] = [this.heap[smallestIdx], this.heap[index]];
        index = smallestIdx; // 继续向下调整子节点
      } else {
        break; // 满足最小堆性质,停止调整
      }
    }
  }
}

// 合并K个升序链表(最小堆解法)
function mergeKLists_1(lists: Array<ListNode | null>): ListNode | null {
  const minHeap = new MyMinHeap();

  // 1. 将所有非空链表的头节点加入最小堆
  for (const list of lists) {
    if (list) { // 排除空链表,避免堆中存入null
      minHeap.push(list);
    }
  }

  // 2. 初始化虚拟头节点,用于拼接结果链表
  const dummy = new ListNode(0);
  let current = dummy; // current指针用于遍历结果链表,拼接节点

  // 3. 循环弹出堆顶,拼接链表
  while (!minHeap.isEmpty()) {
    const minNode = minHeap.pop()!; // 弹出当前最小节点
    current.next = minNode; // 将最小节点接入结果链表
    current = current.next; // current指针向后移动

    // 如果当前最小节点有下一个节点,将其加入堆中
    if (minNode.next) {
      minHeap.push(minNode.next);
    }
  }

  // 4. 返回合并后的链表(跳过虚拟头节点)
  return dummy.next;
};

3.3 复杂度分析

时间复杂度:O(N log K),其中 N 是所有链表的总节点数,K 是链表的个数。

  • 每个节点都需要入堆和出堆各一次,每次入堆/出堆的时间复杂度是 O(log K)(堆的大小最多为 K);

  • 总共有 N 个节点,因此总时间复杂度是 O(N log K)。

空间复杂度:O(K),堆的大小最多为 K(存储所有链表的头节点),虚拟头节点的空间可忽略不计。

四、解法二:分治解法(最优时间,空间更优)

4.1 解法思路

分治思想的核心是“分而治之”,将复杂问题拆分成多个简单子问题,解决子问题后再合并结果:

  1. 拆分:将 K 个链表拆分成两个部分,左半部分和右半部分;

  2. 递归:分别递归合并左半部分和右半部分,得到两个升序链表;

  3. 合并:将两个升序链表合并为一个升序链表(复用“合并两个升序链表”的逻辑);

  4. 终止条件:当拆分到只剩 1 个链表时,直接返回该链表;当拆分到 0 个链表时,返回 null。

补充:合并两个升序链表是这道题的子问题,也是 LeetCode 21 题,思路很简单——双指针遍历两个链表,每次拼接较小的节点。

4.2 完整代码(带详细注释)

// 合并K个升序链表(分治解法)
function mergeKLists_2(lists: Array<ListNode | null>): ListNode | null {
  // 分治核心函数:合并lists[start..end]范围内的链表
  const merge = (lists: Array<ListNode | null>, start: number, end: number): ListNode | null => {
    if (start == end) { // 只剩一个链表,直接返回
      return lists[start];
    }
    if (start > end) { // 没有链表,返回null(边界情况:lists为空)
      return null;
    }
    const mid = (start + end) >> 1; // 中间索引,等价于Math.floor((start+end)/2)
    // 递归合并左半部分和右半部分,再合并两个结果
    return mergeTwoLists(merge(lists, start, mid), merge(lists, mid + 1, end));
  }

  // 辅助函数:合并两个升序链表(LeetCode 21题解法)
  const mergeTwoLists = (a: ListNode | null, b: ListNode | null): ListNode | null => {
    if (a == null || b == null) { // 一个链表为空,直接返回另一个
      return a != null ? a : b;
    }
    let head: ListNode = new ListNode(0); // 虚拟头节点
    let tail: ListNode = head; // tail指针用于拼接节点
    let curA: ListNode | null = a; // 遍历链表a的指针
    let curB: ListNode | null = b; // 遍历链表b的指针

    // 双指针遍历,拼接较小的节点
    while (curA != null && curB != null) {
      if (curA.val < curB.val) {
        tail.next = curA;
        curA = curA.next;
      } else {
        tail.next = curB;
        curB = curB.next;
      }
      tail = tail.next; // tail指针向后移动
    }

    // 拼接剩余节点(其中一个链表已遍历完)
    tail.next = (curA != null ? curA : curB);
    return head.next; // 返回合并后的链表(跳过虚拟头节点)
  }
  
  // 调用分治函数,合并整个lists数组
  return merge(lists, 0, lists.length - 1);
};

4.3 复杂度分析

时间复杂度:O(N log K),与最小堆解法一致,但常数项更小,实际运行更快。

  • 拆分过程:将 K 个链表拆分成 log K 层(类似二叉树的高度);

  • 合并过程:每一层的合并总节点数都是 N,每一层的时间复杂度是 O(N);

  • 总时间复杂度 = 层数 × 每一层的时间 = O(log K × N) = O(N log K)。

空间复杂度:O(log K),主要是递归调用栈的空间,递归深度为 log K(拆分 K 个链表的深度)。如果用迭代实现分治,空间复杂度可优化到 O(1)。

五、两种解法对比与选择

解法时间复杂度空间复杂度优点缺点
最小堆解法O(N log K)O(K)思路直观,易实现,适合新手需要额外维护堆,空间开销稍大
分治解法O(N log K)O(log K)(递归)/ O(1)(迭代)空间更优,实际运行效率更高递归思路需要理解分治思想,相对抽象

选择建议:

  1. 面试时,若要求空间优化,优先写分治解法(递归版即可,迭代版可作为加分项);

  2. 日常练习或追求代码简洁,优先写最小堆解法,思路更直接,不易出错;

  3. 两种解法都需要掌握,因为它们分别考察了堆和分治两种核心算法思想,都是面试高频考点。

六、边界情况测试

刷题时一定要考虑边界情况,避免代码漏洞,这道题的常见边界情况如下:

  1. lists 为空(K=0):返回 null;

  2. lists 中有空链表(如 [null, [1,2]]):跳过空链表,只处理非空链表;

  3. 只有一个链表(K=1):直接返回该链表;

  4. 所有链表只有一个节点(如 [[1], [2], [3]]):两种解法都能正常处理,堆解法更直观。

七、总结

合并 K 个升序链表的核心是「高效找到每次的最小元素」,两种最优解法分别对应两种思路:

  1. 最小堆:用数据结构(堆)直接维护最小值,适合直观理解,代码易实现;

  2. 分治:用算法思想(分治)拆分问题,空间更优,效率更高。

这道题的本质是“合并两个升序链表”的延伸,掌握了子问题和两种核心思想,就能轻松解决。建议大家两种解法都动手写一遍,理解堆的维护逻辑和分治的拆分合并过程,既能巩固链表操作,也能加深对堆和分治思想的理解。