拓冰建站拓冰建站
首页 / 资讯中心 / 正文

合并K个升序链表:多路归并与优先队列的工程实践

LeetCode 23这道题我刷了三遍才敢说真正弄懂。题目标题通常写的是“合并K个升序链表”但不少人习惯叫它“合并K个升序链表的数组”——其实这两种叫法都对因为输入就是一个装着K个链表头节点的数组C里就是vectorListNode*。题目要求很直白把K个已经各自升序排列的链表合并成一个仍然升序的大链表。但“很直白”三个字背后串起来的考点可一点都不少。单链表的遍历、数组容器的边界处理、多路归并的思想、优先队列和分治的应用全在这道题里集中出现。我见过不少候选人一看到“合并K个链表”就条件反射开始写两两合并结果复杂度聊崩了也见过有人能背出堆解法的代码但问一句“为什么堆的空间是O(K)”就卡住。所以这篇东西不是单纯给你贴一份题解而是把这题的几种主流思路、复杂度推导、实现细节、还有本地怎么调试一次性讲透。适合准备校招/社招面试的开发者也适合刷题到链表阶段想进阶多路归并的读者。哪怕你暂时不面试多路归并的思想在日志合并、外部排序、分片数据汇总这些真实场景里也会反复用到。1. 题目拆解与审题陷阱很多人拿到这道题就急着写代码其实第一步应该先确认输入形态。什么叫“合并K个升序链表的数组”简单说你有一个数组数组每个元素是一个链表头指针每个链表内部的节点值从小到大排列但链表之间没有任何顺序保证。最终要返回一个新链表的头节点新链表包含所有节点且整体升序。1.1 输入到底是什么先看链表数组的形态用C写就是这样的结构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) {} }; vectorListNode* lists;lists可能为空数组也就是一个链表都没有此时应该返回空指针nullptr。lists里也可能有某个元素是nullptr代表那条“链路”不存在。各个链表的长度也不一定相同有的长有的短甚至有的链表只有一个节点。还有一个很容易被忽略的点题目要求的是“合并”不是“新建”。你不需要new出一堆新节点直接调整现有节点的next指针指向就行。很多人习惯性地创建一个新链表再逐个拷贝值这样既不省事面试时还会被追问“为什么不原地操作”。1.2 三条常规路线怎么选我见过的主力解法就三种顺序合并、分治合并、优先队列小顶堆。各自对应着不同的思维角度。顺序合并先把第一条和第二条合并得到的结果再和第三条合并一轮一轮往下滚。分治合并把K条链表两两配对先各自合并再把合并结果继续两两配对直到剩一条。优先队列小顶堆把K条链表的当前头节点都丢进一个最小堆每次弹出全局最小的节点然后从对应链表补一个节点进堆。这三种方法我用一个日常生活类比来解释。假设有K个有序的队伍每个队伍按身高从低到高排好现在要合并成一个总队伍。顺序合并就是请你当“总调度”先把前两个队伍合并好再拿这个结果和第三个队伍合并分治合并就是搞“小组赛”每两个队伍先合并胜者进入下一轮优先队列就是搞“冠军候选池”每轮从每个队伍队首挑一个最矮的最后全场最小的那个人出列。三种思路没有绝对优劣但面试场景下分治和优先队列是我最推荐优先讲的。1.3 复杂度地图假设总共有K条链表每条链表的平均长度是n总节点数NK×n。三个方案的时间复杂度和空间复杂度分别如下解法时间复杂度空间复杂度优缺点顺序合并O(K² × n)O(1)实现最直白但节点多时非常慢分治合并O(Kn × log K)O(log K)递归栈稳定手写不容易错面试最推荐优先队列O(Kn × log K)O(K)堆时间最优但比较器细节容易写歪为什么顺序合并会到O(K² × n)因为每次合并后结果链表会变长下次再合并时就要遍历这个已经变长的链表。第一次合并遍历约2n个节点第二次约3n个节点最后一次约Kn个节点加起来就是(23...K)×n量级确实在O(K²n)。这个数学推导是后面所有优化的动机。2. 解法一顺序合并思路最直但效率最差别小看顺序合并。就算你最后决定在面试里讲分治或堆也最好能手写一遍顺序合并。它既能让你理解“合并两个有序链表”这个基础操作也是一个很好的复杂度反面教材。2.1 顺序合并的思路与实现核心逻辑就两步写一个mergeTwoLists函数合并两条升序链表然后遍历整个lists数组把当前结果和下一个链表头传进合并函数滚动更新。class Solution { public: ListNode* mergeKLists(vectorListNode* lists) { if (lists.empty()) return nullptr; ListNode* res nullptr; for (ListNode* head : lists) { res mergeTwoLists(res, head); } return res; } ListNode* mergeTwoLists(ListNode* a, ListNode* b) { ListNode dummy(0); ListNode* tail dummy; while (a b) { if (a-val b-val) { tail-next a; a a-next; } else { tail-next b; b b-next; } tail tail-next; } tail-next a ? a : b; return dummy.next; } };写的时候有几个细节要特别注意。第一dummy节点哨兵节点是链表题里的老演员了它的作用就是帮你省去对头节点为空的单独判断。第二a b循环结束后一定还有一条链表剩余直接用tail-next a ? a : b接上不要再去while循环遍历剩余节点那样代码会啰嗦不少还容易漏边界。2.2 为什么面试官一定会追问你这版代码如果你面试时只给出顺序合并面试官大概率会继续追问“这个方案的时间复杂度是多少还能再优化吗”这时候就看你有没有提前算过账。第一轮合并res是空的实际就是返回lists[0]遍历了n个节点。第二轮合并res长度为2n传入mergeTwoLists时两个链表加起来要遍历3n个节点。等到第K轮两个链表长度分别是(K-1)n和n遍历Kn个节点。所以总遍历节点数大约是n×(23...K)也就是O(K²n)。当K很大、n也很大时这个平方级的增长会让性能急剧恶化。比如K1000、每条链表平均1000个节点顺序合并跑起来非常吃力刷题平台很容易直接超时。我之前带过的新人经常问“那为什么很多题解里顺序合并也能过”能过的情况通常是K很小比如只有几条链表或者n非常短。LeetCode的测试用例覆盖面很广K可以很大所以靠顺序合并硬莽并不稳妥。3. 解法二分治合并手写最稳的解法分治合并是我个人在面试中最推荐优先展示的方案。因为它的思路清晰代码稳定性高不会被优先队列比较器的细节坑到。而且它和归并排序长得几乎一模一样只要你对归并排序有印象就一定能顺下来。3.1 化多路为两路分治的核心思想是不急着把所有链表一次性合并而是先把数组对半切开左边一半合并成一条右边一半合并成一条最后再合并这两条。左半边和右半边的内部又继续用同样的方式递归。这个思路可以类比“擂台赛轮流打”和“分组淘汰赛”的区别。顺序合并是让当前冠军一直站在台上每一轮都迎接一个新对手越到后面对手越强冠军消耗越大分治是先把选手分成小组组内决出胜者胜者再继续打每轮的对手规模更均衡总比赛场次也少很多。3.2 递归实现细节与代码分治的代码比顺序合并稍微长一点但核心只有两个函数一个负责把数组区间[l, r)内的链表合并起来另一个就是复用的mergeTwoLists。class Solution { public: ListNode* mergeKLists(vectorListNode* lists) { if (lists.empty()) return nullptr; return mergeRange(lists, 0, lists.size()); } ListNode* mergeRange(vectorListNode* lists, int l, int r) { if (r - l 1) return lists[l]; if (r - l 2) return mergeTwoLists(lists[l], lists[r - 1]); int mid l (r - l) / 2; ListNode* left mergeRange(lists, l, mid); ListNode* right mergeRange(lists, mid, r); return mergeTwoLists(left, right); } ListNode* mergeTwoLists(ListNode* a, ListNode* b) { ListNode dummy(0); ListNode* tail dummy; while (a b) { if (a-val b-val) { tail-next a; a a-next; } else { tail-next b; b b-next; } tail tail-next; } tail-next a ? a : b; return dummy.next; } };我写这段代码时踩过一个坑区间边界的开闭。上面的写法统一用左闭右开区间[l, r)好处是递归调用时天然规避了mid重复处理的问题。如果你习惯用闭区间[l, r]也能写但基准条件要改成l r时返回空、l 1 r时返回lists[l]否则很容易出现无限递归。还有一个小点为什么要单独处理r - l 2其实不单独处理也行因为r - l 2时mid会取到l1左区间[l, l1)递归返回lists[l]右区间[l1, r)递归返回lists[l1]再合并也是一样的。但基准条件多一点递归深度会少一层也能避免读者看到mergeRange里同时出现lists[l]和lists[l1]时的困惑。我习惯把这两种情况都显式写出来可读性更好。3.3 复杂度推演分治合并的时间复杂度推导非常漂亮。每一轮合并所有链表都会被两两结对每一对合并的代价是两条链表的长度和。第一轮有K/2对每对合并长度2n总代价Kn第二轮有K/4对每对长度4n总代价还是Kn。每一轮总代价都是Kn一共有logK轮所以总代价是Kn×logK。空间复杂度主要看递归栈深度是O(logK)比优先队列的O(K)更省。当然如果你用迭代方式两两合并可以做到O(1)的额外空间但代码会稍微绕一点。我自己刷题时更喜欢递归版本逻辑一目了然面试时手写不容易慌。4. 解法三优先队列用最小的堆空间做全局归并优先队列解法是时间效率上的最优解之一也是很多语言内置数据结构展示“优雅”的绝佳例子。但它在C里有一个经典陷阱priority_queue默认是大顶堆而且自定义比较器的语义反直觉很多人写错了还不知道。4.1 为什么想到用小顶堆回忆一下mergeTwoLists的过程每次比较两条链表的头节点谁小谁出列。现在变成K条链表其实也是一样——比较K个头节点选最小的那个出列。但如果每次都线性扫描K个头节点找最小值时间复杂度会变成O(KN)那就没有必要了。这里就是堆的主场。小顶堆可以在O(logK)时间内完成“取最小”和“插入新元素”两个操作。我们把K条链表的当前头节点都放进堆堆顶就是全局最小节点弹出堆顶再把该节点的next节点入堆一路循环到堆为空所有节点就按升序串起来了。4.2 priority_queue 的坑与完整实现C里写最小堆优先队列最容易翻车的是比较器。std::priority_queue的第三个模板参数是一个比较器但这个比较器不是直接表达“谁小谁优先”而是表达“谁应该排在后面”。默认的less会把大元素放在前面所以你要反转比较逻辑让值更大的节点被认为“排后面”堆顶才会是最小节点。class Solution { public: ListNode* mergeKLists(vectorListNode* lists) { auto cmp [](ListNode* a, ListNode* b) { return a-val b-val; // 注意这里是大于号 }; priority_queueListNode*, vectorListNode*, decltype(cmp) pq(cmp); for (ListNode* head : lists) { if (head) pq.push(head); } ListNode dummy(0); ListNode* tail dummy; while (!pq.empty()) { ListNode* cur pq.top(); pq.pop(); tail-next cur; tail cur; if (cur-next) pq.push(cur-next); } return dummy.next; } };这段代码里最关键的就是return a-val b-val。很多第一次写的人会下意识写a-val b-val结果发现堆顶变成了最大值链表顺序全反了。我的经验是在C的priority_queue里greater对应小顶堆less对应大顶堆如果你想用lambda就要把逻辑写成“值更大的节点优先级更低”。另外要注意初始化堆时不能把空指针放进去。for循环里的if (head)就是在过滤空链表。在弹出节点后也要先检查cur-next是否为空为空就不需要再入堆了否则空指针进堆会导致运行时错误。4.3 三种解法怎么选如果面试官让你只写一种我的建议是优先队列最不容易被后续追问卡死——因为它既展示了你对堆数据结构的掌握又直观体现了O(logK)的选择复杂度。但如果面试现场气氛比较紧张或者你对C的priority_queue比较器不够自信分治合并是更稳妥的选择。它不依赖heap的语义陷阱只要会把区间二分就能写对。顺序合并也不是不能提但你最好主动说出它的复杂度问题然后顺势引出优化方案。这样反而能给面试官留下“这人有复杂度意识”的好印象。千万不要只丢一个顺序合并上去就停下来那大概率会被认为算法功底不够扎实。5. 真实场景、调试经验与面试实战很多人刷算法题刷完就忘觉得“这题面试过了就行”。但LeetCode 23背后的多路归并思路在真实工程里出现频率很高把它理解到位价值远不止应付一场面试。5.1 这题在真实业务里到底有什么用最典型的场景是外部排序。当数据量大到内存装不下时会把大文件切分成多个可以载入内存的小文件每个小文件内部排好序然后就需要把这些有序文件合并成一个更大的有序文件。你可以把每个小文件想象成一条“链表”文件指针就是链表的next用小顶堆逐条取出最小值写入输出文件这就是堆解法在磁盘IO场景下的直接应用。另一个常见场景是日志归并。微服务架构下同一个用户请求的日志可能分散在多个服务节点上每台机器按时间戳本地有序。排查问题时要把所有日志按时间顺序聚合这就是典型的多路归并。我自己就经常写类似的脚本只是语言从C换成了Python但核心数据结构还是堆。还有分库分表后的数据汇总、多路有序流合并、K路有序数组合并本质上都是这题的变体。所以我才说这题值得多花点时间把它彻底弄明白而不是背个代码就完事。5.2 本地调试怎么造链表测试数据刷题平台会帮你构造好链表数组但本地调试时你得自己写工具函数。我发现很多人卡在“不会造测试数据”反而影响了排错效率。这里分享一个我常用的快速构造方式ListNode* makeList(initializer_listint vals) { ListNode dummy(0); ListNode* tail dummy; for (int v : vals) { tail-next new ListNode(v); tail tail-next; } return dummy.next; } void printList(ListNode* head) { while (head) { cout head-val - ; head head-next; } cout null endl; } vectorListNode* lists { makeList({1, 4, 5}), makeList({1, 3, 4}), makeList({2, 6}) }; printList(mergeKLists(lists));在本地调试时我强烈建议你专门试几组边界数据空的lists、只有一个元素的lists、包含空链表的lists、所有链表都只有一个节点的lists。这些边界情况在面试手写代码时最容易翻车提前在本地跑一遍脑子里的边界感会强很多。我自己踩过的坑是本地构造链表时用了裸new程序结束没有释放内存虽然不影响刷题但如果你用Valgrind或ASan检查会报内存泄漏。面试写题不用纠结内存释放但平时练习可以顺手在析构函数里清理养成好习惯。5.3 面试里怎么答得漂亮面试官抛出这道题时建议你不要闷头就写。先花30秒把思路说清楚“这道题可以用顺序合并但复杂度是O(K²n)我倾向于用分治合并先把数组二分递归合并再两两merge或者用一个小顶堆每次取K个头节点里的最小值堆解法时间也是O(Kn logK)但空间略大。”这个开场白的好处是你已经主动展示了复杂度意识、方案对比能力面试官后续大概率不会揪着基础细节穷追猛打而是会顺着你的思路深入到某一个方案里聊。常见变体题也值得提前准备。如果输入不是链表数组而是K个有序数组让你返回一个合并后的大数组解法思路完全一样只是把next指针换成数组下标加一。如果K特别大、但每个链表的节点数特别少堆解法就会更有优势因为分治的递归深度logK也会增大。如果链表节点带额外字段合并时就需要自定义比较逻辑这时候优先队列的cmp就比普通值比较更适合扩展。最后一个建议别只听我说自己把三种解法都写一遍跑同一组测试数据对比耗时和内存。不用太纠结具体数值重点是感受不同方案在K增大时的曲线差异。等你亲手体会到顺序合并变卡、分治和堆依然稳这题才算真正吃透了。根据我个人的刷题经验LeetCode 23是“一道题顶五道题”的典型代表。它把链表操作、数组边界、分治思想、堆的应用全部串在一起而且每个解法都能聊出深度。面试前花一个晚上把顺序合并、分治合并、优先队列三种写法都练熟比刷十道简单链表题都管用。我到现在偶尔去面试候选人遇到这道题时最欣慰的并不是对方把代码写出来而是能把“为什么选堆”“空间复杂度是多少”“真实场景里怎么用”也讲清楚。希望你也能达到这个状态。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门