从逐一合并到多路归并:力扣23「合并K个升序链表」的三种解法进化之路

从逐一合并到多路归并:力扣23「合并K个升序链表」的三种解法进化之路

当两条链表的合并变成 K 条链表的混战时,你的武器库需要从"单兵突进"升级为"集团作战"。这道题,正是对归并思想和优先级队列的终极检验。

前言

在力扣21「合并两个有序链表」中,我们学会了如何优雅地将两条有序链表合并成一条。那如果这个数字从 2 变成了 K 呢?今天我们要攻克的,正是这道大名鼎鼎的力扣23. 合并K个升序链表(Merge k Sorted Lists)

这道题在LeetCode上标记为困难(Hard) ,但它的解法思路其实清晰明了。它之所以被评为 Hard,是因为它考察的不是单一的算法技巧,而是你对多种数据结构与算法范式的综合运用能力 。在字节跳动、腾讯、Google、Amazon的面试中,这道题几乎是"必刷清单"上的常客。

题目要求你合并 k 个升序链表,返回一个升序链表。这 K 条链表的总节点数为 N

面对 K 条链表,最 naive 的想法无非是"一条一条地合并",但这样效率堪忧。今天的文章,我们就沿着**"从低效到高效"、"从直观到精妙"的路径,一步步拆解三种主流解法:顺序合并分治归并优先队列(最小堆)**。读完这篇,你不仅会做这道题,更会理解"多路归并"问题的通用解法范式。

题目回顾

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

示例:

rust 复制代码
输入: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

约束条件: k == lists.length0 <= k <= 10^40 <= lists[i].length <= 500,总节点数 N 不超过 10^4

核心难点:从"一对一"到"一对多"的质变

在力扣21中,我们有两个指针分别指向两个链表的当前节点,每次取较小的那个。这相当于一场擂台赛,只有两个选手,每次比较即可出结果。

但现在有 K 个选手(链表)。每次我们要从 K 个链表的头节点中选出最小的那个 。这就涉及到一个核心问题:如何高效地从 K 个候选者中取出最小值?

  • 方案一:每次遍历所有 K 个头节点找最小值。复杂度 O(K),太慢。
  • 方案二:把所有节点收集起来排序。O(N log N),浪费了链表有序的特性。
  • 方案三:使用优先级队列(小根堆),让堆来帮我们维护 K 个头节点的顺序。O(log K) 的取数效率。
  • 方案四:两两归并,把 K 个链表的合并问题转化为 log K 轮的两两合并问题。

今天,我们就从最"笨"的方案一开始,一步一个脚印。

第一层:顺序合并(暴力复用21题) ------ 最直观的"逐一击破"

如果 K=2 我会做,那 K=3 呢?把前两个合并的结果,再和第三个合并。K=4 呢?以此类推。

这就是顺序合并 :维护一个结果链表 ans,初始为 null 或者第一个链表,然后遍历 lists 数组,不断将 anslists[i] 合并(调用力扣21的 mergeTwoLists)。

java 复制代码
class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        if (lists == null || lists.length == 0) return null;
        ListNode ans = null;
        for (ListNode list : lists) {
            ans = mergeTwoLists(ans, list);
        }
        return ans;
    }
    
    // 复用 21 题的标准解法
    private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        ListNode dummy = new ListNode(0);
        ListNode tail = dummy;
        while (l1 != null && l2 != null) {
            if (l1.val <= l2.val) {
                tail.next = l1;
                l1 = l1.next;
            } else {
                tail.next = l2;
                l2 = l2.next;
            }
            tail = tail.next;
        }
        tail.next = (l1 == null) ? l2 : l1;
        return dummy.next;
    }
}

复杂度分析:

  • 时间复杂度: 设总节点数为 Nk 为链表条数。第一次合并长度 N1,第二次合并长度 N1+N2......总耗时约为 O(k * N)。在最坏情况下(每条链表长度均匀),N = k * m,复杂度是 O(k^2 * m),也就是 O(kN)。当 k 很大时,这个算法会超时。
  • 空间复杂度: O(1)

点评: 这个解法虽然慢,但它是逻辑最安全 的保底方案。如果你在面试中一时想不起堆或分治,写出这个并指出它的时间复杂度瓶颈,面试官通常会引导你优化。千万不要觉得它简单就不屑于写------能跑通的代码永远优于写不出的完美代码。

第二层:分治法(归并思想) ------ 教科书级的"两两归并"

我们刚才的瓶颈在于:ans 链表变得越来越长,每次合并都要遍历整个 ans,导致大量重复遍历。

如果借鉴归并排序的思想:先把 K 条链表两两配对,合并成 K/2 条,再两两配对,合并成 K/4 条......直到剩下最后一条。 这个过程就像一棵二叉树,每条链表只参与了 log K 次合并,且每次合并的总代价是 O(N),整体复杂度骤降。

这就是分治归并法。它把"长链表的反复合并"变成了"多对短链表的均衡合并"。

核心实现方式(递归分治)

java 复制代码
class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        if (lists == null || lists.length == 0) return null;
        return merge(lists, 0, lists.length - 1);
    }
    
    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 mergeTwoLists(l1, l2);
    }
    
    // 同样复用 21 题
    private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
        // ...(同上)
    }
}

执行过程图解(以 4 条链表为例):

rust 复制代码
第1轮:合并(0,1) -> list01,合并(2,3) -> list23
第2轮:合并(list01, list23) -> 结果

这形成了完美的平衡二叉树,高度为 log k

复杂度分析:

  • 时间复杂度: O(N log k)。其中 N 是总节点数,k 是链表条数。每一层归并都要处理所有 N 个节点,共有 log k 层。
  • 空间复杂度: O(log k)。递归调用栈的深度。

点评: 这是面试中最推荐的解法 。它完美地结合了力扣21(合并两个链表)和归并排序的分治思想,代码清晰,逻辑严谨。如果你担心递归栈空间,不用担心,这里 log k 最大也不过 14(当 k=10000 时),完全安全。

第三层:优先队列(最小堆) ------ 最优雅的"多路归并"

如果不想写递归,还有另一种极其优雅的解法:最小堆(PriorityQueue)

既然我们每次都要从 K 个链表的头节点中找最小的,为什么不把这 K 个头节点放进一个自动排序的容器 里呢?Java 中的 PriorityQueue 正好提供了 O(log K) 的插入和弹出操作。

核心思想

  1. 创建一个最小堆,堆中存储 ListNode 节点,并按照节点的 val 值排序。
  2. 遍历 lists 数组,将所有非空链表的头节点放入堆中。
  3. 当堆不为空时:
    • 弹出堆顶节点(即当前最小的节点),接入结果链表的尾部。
    • 如果该节点有 next 节点,将 next 节点放入堆中(继续参与后续的竞争)。
  4. 返回结果链表。

这个思路就像一场"淘汰赛":K 条链表的头节点站在擂台上,最小的出列,然后它的下一位替补上场。

java 复制代码
class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        if (lists == null || lists.length == 0) return null;
        
        // 定义小根堆的比较器
        PriorityQueue<ListNode> pq = new PriorityQueue<>(
            (a, b) -> a.val - b.val
        );
        
        // 将 K 个头节点入堆
        for (ListNode head : lists) {
            if (head != null) pq.offer(head);
        }
        
        ListNode dummy = new ListNode(0);
        ListNode tail = dummy;
        
        while (!pq.isEmpty()) {
            ListNode minNode = pq.poll(); // 取出最小值
            tail.next = minNode;
            tail = tail.next;
            
            // 将取出节点的下一个节点入堆
            if (minNode.next != null) {
                pq.offer(minNode.next);
            }
        }
        return dummy.next;
    }
}
python 复制代码
class Solution:
    def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
        import heapq
        # 堆中存储 (val, index, node),因为 ListNode 不可比较,需要 index 防止比较报错
        heap = []
        for i, head in enumerate(lists):
            if head:
                heapq.heappush(heap, (head.val, i, head))
        
        dummy = ListNode(0)
        tail = dummy
        
        while heap:
            val, i, node = heapq.heappop(heap)
            tail.next = node
            tail = tail.next
            if node.next:
                heapq.heappush(heap, (node.next.val, i, node.next))
        
        return dummy.next

复杂度分析:

  • 时间复杂度: O(N log k)。每个节点都会经历一次入堆和一次出堆,每次操作的复杂度是 O(log k)
  • 空间复杂度: O(k)。堆中最多同时存储 k 个节点。

点评: 这是最符合人类直觉的"多路归并"实现。它不需要递归,逻辑清晰直白,而且在处理动态数据流(比如 K 个无限流)时,这是唯一的解法。在面试中,如果你能解释清楚为什么用 PriorityQueue 而不是自己维护数组,面试官会认为你对数据结构的理解非常到位。

第四层:深度辨析 ------ 分治 vs 堆,谁才是最优解?

很多同学会纠结:分治和堆的时间复杂度都是 O(N log k),到底选哪个?

维度 分治归并法 优先队列法
时间复杂度 O(N log k) O(N log k)
空间复杂度 O(log k)(递归栈) O(k)(堆大小)
代码风格 递归,结构清晰,树形思维 迭代,逻辑直观,流式思维
稳定性 稳定,不依赖比较器 需要注意节点不可比较的问题(要用索引绕开)
适用场景 链表数组固定,一次性合并 也适合流式数据(如实时合并多个数据流)
面试推荐度 ⭐⭐⭐⭐⭐(分治更显功力) ⭐⭐⭐⭐⭐(堆更显数据结构功力)

我的建议是:两种都要会。 如果面试官先问你"合并两个有序链表",然后追问"如果是 K 个呢",你从分治法的角度切入("我把 K 个分成两半,递归合并"),会显得非常自然。如果面试官问你"如果要处理流式数据怎么办",堆法就派上了用场。

深度总结:三种解法进化图谱

解法 时间复杂度 空间复杂度 核心思想 面试推荐度
顺序合并(逐一合并) O(kN) O(1) 复用21题,循环合并 ⭐⭐(仅用于引入问题)
分治归并法 O(N log k) O(log k) 两两配对,树形归并 ⭐⭐⭐⭐⭐(首选,经典)
优先队列法 O(N log k) O(k) 小根堆维护K个头节点 ⭐⭐⭐⭐⭐(优雅,通用)

从这道题中我们学到了什么?

  1. "分治"是解决 K 个同类子问题的通用范式。 无论是合并 K 个有序链表,还是计算 K 个数的最大值,分治都能把复杂度从 O(K * N) 降为 O(N log K)

  2. 优先队列是"动态多路归并"的利器。 当数据是流式输入、无法一次性获取全貌时(比如合并多个实时日志流),堆几乎是最优解。

  3. 写代码前先评估"数据规模"。如果 K 很小(比如 K=3),顺序合并完全够用;如果 K 很大,必须上堆或分治。面试中,分析完复杂度再动手,是专业的表现。

  4. 复用已有的工具。 力扣21的 mergeTwoLists 是我们的"轮子"。这道题无论是分治还是顺序合并,都在反复利用这个轮子。工程化思维:永远不要重复造轮子。

最后的一些心里话

力扣23是一道经典的"多路归并"问题,它让我们看到,当问题的维度从 2 升维到 K 时,单纯的暴力会失效,我们必须引入更高级的数据结构或算法范式。

刷题的一大乐趣,就是看到不同的解法如何从同一个问题中生长出来。顺序合并像"愚公移山",分治像"兵团作战",而优先队列像"精英擂台"。三种解法,三种世界观,但它们最终都指向同一个正确答案。

当你能够在面试中,根据自己的状态(写得顺手哪种就写哪种)和面试官的追问,灵活地在分治和堆之间切换时,这道题你就真的完全拿下了。

记住今天的口诀:

K链合并并不难,顺序合并最直观。 若要效率翻几番,分治堆排任你选。 分治递归两两合,堆顶弹出小优先。


如果你觉得这篇题解帮你彻底搞懂了合并K个有序链表,欢迎点赞、收藏、转发!我们接下来准备进入一个全新的数据结构篇章------二叉树,你准备好了吗? 🚀

相关推荐
Lazy_zheng1 小时前
TDD 实战:Claude Code Superpowers 保姆级教程14 个 Skills 全解析 + 实战开发
前端·claude·vibecoding
名字还没想好☜1 小时前
React 受控与非受控组件:表单场景怎么选
前端·javascript·react.js·react·表单·受控组件
Damai1 小时前
Radix UI导致右侧白条
前端
KaifuZeng1 小时前
模电面试问题汇总三
单片机·嵌入式硬件·面试·模电
weedsfly1 小时前
前端开发中的策略模式——告别 if-else 的优雅之道
前端·javascript·面试
不坑老师1 小时前
怎么在Word中制作汉字书写笔顺——不坑盒子汉字笔顺步骤在小学低段识字教学中的应用
前端·数据库·人工智能·word·excel·office
许我半盏清茶1 小时前
基于 Ollama + Vectra + MCP 从零搭建本地 RAG 知识库
javascript·人工智能·mcp
anyup1 小时前
30 分钟用 Trae Work + 魔珐星云做出会讲故事的 AI 3D 具身交互智能数字人
前端·ai编程·trae
IT_陈寒1 小时前
Vue的响应式竟然在这常见操作下失效了
前端·人工智能·后端