一个初学者用 C++ 解答合并 K 个升序序列的心路历程

6 阅读6分钟

写在前面:

大家好,我最近刚刚开始接触 C++,也是刚刚开始接触算法。

这几天在学习 C++ 链表时,我的心情可以说是像过山车一样。以前习惯了 C 语言里苦哈哈地用 malloc 分配内存、小心翼翼地处理 NULL 指针的我,在接触到 C++ 的 构造函数虚拟头节点 后,虽然说刚开始接受起来还是有点困难,但好在最终还是克服了。

趁着热乎劲,我在 LeetCode 上用 C++ 轻松搞定了第 21 题 合并两个有序链表。我自信满满地点击了下一题,实不相瞒,这道 合并 K 个升序序列 题目的 Hard 标签确实吓到我了,我还不觉得自己有独立解决困难题目的能力。

但经过一番挣扎,我发现 只要转变思维,借用 C++ 的优势,初学者也完全可以对 Hard 题进行降维打击!

今天,我就从一个初学者的视角,一步步撕开这道题的外衣,并尽可能详细地把 优先队列 这一知识点解释通透。


1. 题目初探

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

下面是 Leetcode 官方的示例:

输入: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 叠已经从小到大理好的扑克牌。现在你的任务是,把它们合并成一叠全新且有序的扑克牌,你会怎么做?

2. 破局思路

一开始我完全没有思路,但我突然想到,我刚刚不是才写过 合并 2 个链表 的代码吗?这两道题颇有相似之处。

只能合并两叠牌的代码我已经写过了,那合并 K 叠牌的暴力解法不就有了吗?

  • 我先把第 1 叠和第 2 叠放进机器,融合成一大叠。
  • 我拿着这新的一大叠,再和第 3 叠放进机器……
  • 一直循环 K-1 次,成功。

我直接复用第 21 题的代码,把它封装成一个 mergeTwoLists 函数,然后用一个 for 循环搞定。

提交代码之后也是成功通过了,但就是耗时长的吓人,有 100 多毫秒。

但冷静下来一算时间复杂度,越到后面,合并后的链表就越长,每次都要从头遍历全部的节点,如果在真实的项目中早就卡死了。

在看了评论区的大神的解法之后,我才意识到,C++ 远没有我想象的这么简单,真正的大杀器才刚刚登场。


3. 优先队列的降维打击

有没有一种方法,不需要我们辛辛苦苦地两条链表互相比较?

有的,C++ 的 STL 标准模板库中有一个和这道题简直是天生一对的工具——优先队列

3.1 什么是优先队列?

从它的名字我们就能看出来它不是普通的队列,当然,这好像是一句废话。

普通队列就是将就一个先来后到。

而优先队列就像是医院的急诊室,谁的病重谁先进去。

不管你扔进去多少个数据,只要你调用出队指令,它总是自动把最极端的那个数据(最大或最小)弹出来。

3.2 新的解题思路

有了这个工具,我们的合并逻辑变得极其方便:

  • 把 K 叠牌的最上面那一张,也就是 K 个链表的头节点,全部放进优先队列里面。
  • 让优先队列弹出最上面的那一个头节点,根据题目我们要让最上面的头节点的val最小。
  • 然后看看刚刚拿走的那张牌属于哪一叠?从那一叠里再摸下一张牌,放进优先队列里补充。
  • 一直重复拿牌和补充,直到优先队列空了,所有链表就完美合并了。

因为盒子里最多只有 K 个节点,利用优先队列内部的二叉树结构,每次放进去或拿出来的时间复杂度只有 O(log⁡K)。总时间复杂度瞬间降维到 O(Nlog⁡K),运行速度相当的快。


4. 优先队列详解

思路现在已经很完美了,但是这里还有些坑。

在 C++ 中引入 #include <queue> 后,如果只存整数,写 priority_queue<int> pq 就行了。但现在我们要存的是链表节点指针 ListNode*,如果依旧按照常规的方法来写,他就会去比较内存地址的大小,这不是乱套了吗?

这就引出了 C++ 优先队列的 完整体模板结构,包含三个参数:

priority_queue< 数据类型, 底层容器类型, 比较规则 >

在 C++ 中,我们通常用 仿函数 来定义比较规则。这看起来有点吓人,但其实就是个重写了 () 的结构体:

//比较规则
struct cmp {
    bool operator()(ListNode* a, ListNode* b) {
        //优先队列默认是大顶堆。
        //我们要从小到大排,也就是小顶堆,所以要反着来,用大于号
        //如果 a 的值大于 b,就把 a 沉到下面去。
        return a->val > b->val; 
    }
};

当优先队列需要比较两个元素时:

  1. 调用 cmp()(a, b)
  2. 如果返回 true:a 的优先级低于 b,a 会被放在堆的下面。
  3. 如果返回 false:a 的优先级高于 b,a 会被放在堆的上面。

我们来梳理一下它的工作原理,最后返回的是a->val > b->val,也就是说当avalbval小时,返回false,这时 a 的优先级高于 ba 会被放在堆的上面,正是我们要的结果。

定义好规则后,我们的优先队列就彻底成型了:

priority_queue<ListNode*, vector<ListNode*>, cmp> pq;

5. 完整代码

class Solution {
public:
    struct cmp{
        bool operator() (ListNode* a,ListNode* b){
            return a->val > b->val;//小顶端
        }
    };
    
    ListNode* mergeKLists(vector<ListNode*>& lists) {
        priority_queue<ListNode*,vector<ListNode*>,cmp> pq;
        ListNode* dummy = new ListNode(0);
        ListNode* curr = dummy;
        for(int i=0;i<lists.size();i++)
        {
            if(lists[i] != nullptr)//必须判断是否为空,如果为空还放到优先队列会导致后面minNode->next非法解引用
            {
                pq.push(lists[i]);//将所有链表头节点塞入优先队列
            }
        }
​
        while(!pq.empty())//优先队列不空
        {
            ListNode* minNode = pq.top();//把最小的拿出来,用于后面找它所在的那一叠的下一个节点
            pq.pop();//弹出最小的
​
            curr->next = minNode;//将最小的节点接到我们的新链表上
            curr = curr->next;//让curr指针向后移动一位
​
            if(minNode->next != nullptr)//找到刚才弹出的最小节点所在的链表,只要下一个节点不为空,就塞到优先队列
            {
                pq.push(minNode->next);
            }
        }
        ListNode* result = dummy->next;
        delete dummy;
        return result;
    }
};

写在最后:

回首这几天从 C 语言向 C++ 跃迁的历程,我有了些深刻的感悟,关乎我们面对生活和挑战的真实态度。

如果你也是一个刚刚踏上编程之路,或者正在被某个 Hard 标签的题目按在地上摩擦的初学者,我想对你说:不要慌张,也不要畏惧。 底层的功夫慢慢的扎,高级的工具勇敢去学。只要逻辑的指针不断向前curr != nullptr,即使再长的链表,我们也终将抵达理想的彼岸。


本文结束。