【LeetCode Hot100 刷题日记(34/100)】23. 合并 K 个升序链表 —— 链表、分治、堆(优先队列)、归并排序 🧠

7 阅读9分钟

📌 题目链接:23. 合并 K 个升序链表 - 力扣(LeetCode) 🔍 难度:困难 | 🏷️ 标签:链表、分治、堆(优先队列)、归并排序
⏱️ 目标时间复杂度:O(kn log k)
💾 空间复杂度:O(log k) 或 O(k),取决于方法

✅ 链表操作中的经典难题,常出现在大厂面试中,尤其是对「多路合并」、「归并思想」和「堆结构应用」的考察。掌握本题不仅能提升你的链表处理能力,还能深入理解分治与优先队列在实际场景中的高效运用。

🎯 核心考点

  • 多链表合并的优化策略
  • 分治法的递归实现
  • 堆(优先队列)的灵活使用
  • 面试中如何权衡时间 vs 空间
  • 如何避免“暴力合并”的性能陷阱

🔍 题目分析

给你一个链表数组 lists,每个链表都已经按升序排列。
要求将所有链表合并成一个升序链表并返回。

示例说明:

输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
  • 每条链表本身有序,但整体无序。
  • 要求最终结果是一个全局有序的单链表。
  • 特殊情况:空数组、空链表等必须考虑。

关键观察:

  • 不能简单地遍历所有节点再排序(会破坏链表结构,且效率低)。
  • 每个链表内部有序 → 可以利用“局部有序”来减少比较次数。
  • 本质是 k 路归并问题(k-way merge),常见于数据库索引合并、文件归并等场景。

🧩 核心算法及代码讲解

本题有三种主流解法,我们从最朴素到最优逐步展开:


✅ 方法一:顺序合并(暴力法)—— ❌ 时间复杂度高,不推荐

💡 思路:

依次用 mergeTwoLists 合并两个链表,每次把当前结果与下一个链表合并。

⚠️ 缺点:

  • 第一次合并:长度 n
  • 第二次合并:长度 2n
  • ...
  • 第 i 次合并:长度 i×n
  • 总时间复杂度:O(k² × n)

这在 k 很大时非常慢,属于“逐个吞咽”式的低效做法。

🔧 代码(保留原内容):

ListNode* mergeTwoLists(ListNode *a, ListNode *b) {
    if ((!a) || (!b)) return a ? a : b;
    ListNode head, *tail = &head, *aPtr = a, *bPtr = b;
    while (aPtr && bPtr) {
        if (aPtr->val < bPtr->val) {
            tail->next = aPtr; aPtr = aPtr->next;
        } else {
            tail->next = bPtr; bPtr = bPtr->next;
        }
        tail = tail->next;
    }
    tail->next = (aPtr ? aPtr : bPtr);
    return head.next;
}

ListNode* mergeKLists(vector<ListNode*>& lists) {
    ListNode *ans = nullptr;
    for (size_t i = 0; i < lists.size(); ++i) {
        ans = mergeTwoLists(ans, lists[i]);
    }
    return ans;
}

💡 注:虽然逻辑清晰,但面试中若只写这个会被追问:“有没有更优的方法?”


✅ 方法二:分治合并(归并排序思想)—— ✔️ 推荐!时间复杂度优秀

💡 思路:

借鉴归并排序的思想,将 k 个链表两两配对合并,形成新的子问题,直到只剩一个链表。

  • [0, k-1] 区间划分为 [0, mid][mid+1, k-1]
  • 递归合并左右两部分
  • 最终合并两个已排好序的结果

🔄 分治过程示例:

k = 8: [0,1,2,3,4,5,6,7]
       → [0,3] + [4,7][0,1] + [2,3], [4,5] + [6,7]
       → ... 最终合并为一条链

✅ 优势:

  • 时间复杂度:O(kn log k) ,远优于顺序合并
  • 空间复杂度:O(log k) (递归栈深度)
  • 是典型的“分而治之”思想体现

🔧 代码(保留原内容):

ListNode* mergeTwoLists(ListNode *a, ListNode *b) {
    if ((!a) || (!b)) return a ? a : b;
    ListNode head, *tail = &head, *aPtr = a, *bPtr = b;
    while (aPtr && bPtr) {
        if (aPtr->val < bPtr->val) {
            tail->next = aPtr; aPtr = aPtr->next;
        } else {
            tail->next = bPtr; bPtr = bPtr->next;
        }
        tail = tail->next;
    }
    tail->next = (aPtr ? aPtr : bPtr);
    return head.next;
}

ListNode* merge(vector <ListNode*> &lists, int l, int r) {
    if (l == r) return lists[l];
    if (l > r) return nullptr;
    int mid = (l + r) >> 1;
    return mergeTwoLists(merge(lists, l, mid), merge(lists, mid + 1, r));
}

ListNode* mergeKLists(vector<ListNode*>& lists) {
    return merge(lists, 0, lists.size() - 1);
}

📌 行注释解析:

  • mergeTwoLists: 经典双指针合并,保证 O(n) 时间、O(1) 空间
  • merge(...): 分治函数,递归拆分区间
  • int mid = (l + r) >> 1;:位运算加速除法,等价于 (l+r)/2
  • 返回值是两个子区间的合并结果

✅ 方法三:优先队列(最小堆)—— ✔️ 面试最爱!

💡 思路:

维护一个最小堆,存储每个链表的头节点。每次取出堆顶元素(最小值),将其加入结果链表,并将该节点的下一个节点压入堆中。

🌟 为什么用堆?

  • 快速找到当前所有链表中最小的元素 → O(log k)
  • 动态更新候选集合 → 支持实时插入新节点
  • 类似“多路归并”的模拟

📊 过程演示:

初始堆:[1,1,2]  ← 来自三个链表的首节点
取 1 → 加入结果 → 插入 4 → 堆变为 [1,2,4]1 → 加入结果 → 插入 3 → 堆变为 [2,3,4]
...

✅ 优势:

  • 时间复杂度:O(kn log k) ,与分治相同
  • 空间复杂度:O(k) (堆最多存 k 个节点)
  • 更适合“动态合并”或“流式数据”场景

🔧 代码(保留原内容):

struct Status {
    int val;
    ListNode *ptr;
    bool operator < (const Status &rhs) const {
        return val > rhs.val; // 注意:C++ 堆默认最大堆,所以反向比较
    }
};

priority_queue <Status> q;

ListNode* mergeKLists(vector<ListNode*>& lists) {
    for (auto node: lists) {
        if (node) q.push({node->val, node});
    }
    ListNode head, *tail = &head;
    while (!q.empty()) {
        auto f = q.top(); q.pop();
        tail->next = f.ptr; 
        tail = tail->next;
        if (f.ptr->next) q.push({f.ptr->next->val, f.ptr->next});
    }
    return head.next;
}

📌 行注释解析:

  • struct Status: 自定义结构体,包含值和指针
  • operator <: 定义堆的比较规则,注意是 val > rhs.val 才能实现最小堆
  • for (auto node: lists):遍历所有链表头部,非空才入堆
  • tail->next = f.ptr: 把最小节点接上结果链表
  • if (f.ptr->next):如果还有后续节点,继续入堆

⚠️ 注意:不要直接用 std::priority_queue<int> ,因为我们需要同时保存值和指针!


🧠 解题思路(分步骤详解)

步骤内容
🔹 Step 1明确问题本质:k 路归并,即从 k 个有序序列中选出最小元素
🔹 Step 2分析边界条件:空链表、空数组、只有一个链表
🔹 Step 3设计基础模块:mergeTwoLists(合并两个有序链表)
🔹 Step 4选择策略: • 顺序合并 → 暴力,O(k²n) • 分治合并 → 优雅,O(kn log k) • 堆合并 → 实用,O(kn log k)
🔹 Step 5实现核心逻辑: • 分治:递归划分区间 • 堆:维护最小堆,不断弹出并插入

📈 算法分析

方法时间复杂度空间复杂度是否推荐适用场景
顺序合并O(k²n)O(1)❌ 不推荐仅用于教学理解
分治合并O(kn log k)O(log k)✅ 推荐面试常考,代码简洁
堆合并O(kn log k)O(k)✅ 强烈推荐实际工程中常用

💡 提示:面试官更喜欢看到你先提出顺序合并,然后指出其缺陷,再引出分治或堆方案,体现思维演进。


💻 代码(完整模板)

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// Definition for singly-linked list.
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) {}
};

// 方法一:顺序合并(不推荐)
ListNode* mergeTwoLists(ListNode *a, ListNode *b) {
    if ((!a) || (!b)) return a ? a : b;
    ListNode head, *tail = &head, *aPtr = a, *bPtr = b;
    while (aPtr && bPtr) {
        if (aPtr->val < bPtr->val) {
            tail->next = aPtr; aPtr = aPtr->next;
        } else {
            tail->next = bPtr; bPtr = bPtr->next;
        }
        tail = tail->next;
    }
    tail->next = (aPtr ? aPtr : bPtr);
    return head.next;
}

ListNode* mergeKLists_v1(vector<ListNode*>& lists) {
    ListNode *ans = nullptr;
    for (size_t i = 0; i < lists.size(); ++i) {
        ans = mergeTwoLists(ans, lists[i]);
    }
    return ans;
}

// 方法二:分治合并(推荐)
ListNode* merge(vector <ListNode*> &lists, int l, int r) {
    if (l == r) return lists[l];
    if (l > r) return nullptr;
    int mid = (l + r) >> 1;
    return mergeTwoLists(merge(lists, l, mid), merge(lists, mid + 1, r));
}

ListNode* mergeKLists_v2(vector<ListNode*>& lists) {
    return merge(lists, 0, lists.size() - 1);
}

// 方法三:优先队列(堆)(强烈推荐)
struct Status {
    int val;
    ListNode *ptr;
    bool operator < (const Status &rhs) const {
        return val > rhs.val;
    }
};

ListNode* mergeKLists_v3(vector<ListNode*>& lists) {
    priority_queue<Status> q;
    for (auto node : lists) {
        if (node) q.push({node->val, node});
    }
    ListNode head, *tail = &head;
    while (!q.empty()) {
        auto f = q.top(); q.pop();
        tail->next = f.ptr;
        tail = tail->next;
        if (f.ptr->next) q.push({f.ptr->next->val, f.ptr->next});
    }
    return head.next;
}

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    // 测试用例1
    vector<ListNode*> lists1;
    lists1.push_back(new ListNode(1));
    lists1[0]->next = new ListNode(4);
    lists1[0]->next->next = new ListNode(5);

    lists1.push_back(new ListNode(1));
    lists1[1]->next = new ListNode(3);
    lists1[1]->next->next = new ListNode(4);

    lists1.push_back(new ListNode(2));
    lists1[2]->next = new ListNode(6);

    auto result1 = mergeKLists_v3(lists1);
    while (result1) {
        cout << result1->val << " ";
        result1 = result1->next;
    }
    cout << endl;

    // 测试用例2:空数组
    vector<ListNode*> lists2;
    auto result2 = mergeKLists_v3(lists2);
    cout << (result2 ? "Not empty" : "Empty") << endl;

    // 测试用例3:空链表
    vector<ListNode*> lists3;
    lists3.push_back(nullptr);
    auto result3 = mergeKLists_v3(lists3);
    cout << (result3 ? "Not empty" : "Empty") << endl;

    return 0;
}

✅ 输出结果:

1 1 2 3 4 4 5 6
Empty
Empty

🧩 面试拓展知识点

❓ Q1:为什么堆比分治更好?

  • 堆更适合动态场景:比如链表长度不均、实时数据流
  • 分治需要递归栈:深嵌套可能引发栈溢出(虽概率小)
  • 堆可扩展性强:容易改造成“k 路归并读取文件”

❓ Q2:能不能用最大堆?

  • 可以,但要调整比较逻辑
  • 通常还是用最小堆更直观

❓ Q3:能否用 multiset 替代堆?

  • 可以,但插入删除是 O(log k),总复杂度仍 O(kn log k)
  • multiset 支持范围查询,灵活性更高

❓ Q4:有没有 O(kn) 的方法?

  • 理论上不可能,因为至少要比较 kn 个元素
  • 除非知道某些特殊性质(如所有链表长度相等)

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪


📣 下一期预告:LeetCode 热题 100 第35题 —— 146.LRU 缓存(中等)

🔹 题目:设计一个 LRU(Least Recently Used)缓存机制,支持 get(key)put(key, value) 操作,在容量满时自动淘汰最久未使用的项。

🔹 核心思路:结合 哈希表 + 双向链表 实现 O(1) 时间复杂度的访问与删除。

🔹 考点:数据结构设计、哈希表、双向链表、缓存淘汰策略。

🔹 难度:中等,但却是系统设计类题目的入门必修课,常用于考察“工程思维”。

💡 提示:不要只用 map,要学会用链表记录访问顺序!

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!


本篇文章已覆盖:

  • 三种主流解法对比
  • 详细代码行注释
  • 面试常见问题解答
  • 实际测试用例验证
  • 下期预告引导学习路径