《剑指 Offer》专项突破版 - 面试题 59、60 和 61 : 详解堆的应用(C++ 实现)

目录

前言

[面试题 59 : 数据流的第 k 大数字](#面试题 59 : 数据流的第 k 大数字)

[面试题 60 : 出现频率最高的 k 个数字](#面试题 60 : 出现频率最高的 k 个数字)

[面试题 61 : 和最小的 k 个数对](#面试题 61 : 和最小的 k 个数对)



前言

堆最大的特点是最大值或最小值位于堆的顶部,只需要 O(1) 的时间就可以求出一个数据集合中的最大值或最小值,同时在堆中添加或删除元素的时间复杂度都是 O(logn),因此综合来看堆是一个比较高效的数据结构 。如果面试题需要求出一个动态数据集合中的最大值或最小值,那么可以考虑使用堆来解决问题。

堆经常用来求取一个数据集合中值最大或最小的 k 个元素。通常,最小堆用来求取数据集合中 k 个值最大的元素,最大堆用来求取数据集合中 k 个值最小的元素

接下来使用最小堆或最大堆解决几道典型的算法面试题。


面试题 59 : 数据流的第 k 大数字

题目

请设计一个类型 KthLargest,它每次从一个数据流中读取一个数字,并得出数据流已经读取的数字中第 k(k >= 1)大的数字。该类型的构造函数有两个参数:一个是整数 k,另一个是包含数据流中最开始数字的整数数组 nums。该类型还有一个函数 add,用来添加数据流中的新数字并返回数据流中已经读取的数字的第 k 大数字。

例如,当 k = 3 且 nums 为数组 [4, 5, 8, 2] 时,调用构造函数创建类型 KthLargest 的实例之后,第 1 次调用 add 函数添加数字 3,此时已经从数据流中读取了数字 4、5、8、2 和 3,第 3 大的数字是 4;第 2 次调用 add 函数添加数字 5 时,则返回第 3 大的数字 5。

分析

与数据流相关的题目的特点是输入的数据是动态添加的,也就是,可以不断地从数据流中读取新的数据,数据流的数据量是无限的。在这个题目中,类型 KthLargest 的函数 add 用来添加从数据流中读取的新数据。

解决这个题目的关键在于选择合适的数据结构。如果数据存储在排序的数组中,那么只需要 O(1) 的时间就能找出第 k 大的数字。但这个直观的方法有两个缺点。首先,需要把从数据流中读取的所有数据都存到排序数组中,如果从数据流中读取 n 个数字,那么动态数组的大小为 O(n)。随着不断地从数据流中读取新的数据,O(n) 的空间复杂度可能会耗尽所有的内存。其次,在排序数组中添加新的数字的时间复杂度也是 O(n)

下面换一个角度看待第 k 大的数字。如果能够找出 k 个最大的数字,那么第 k 大的数字就是这 k 个最大数字中最小的一个。例如,从数据流中已经读出了 4、5、8、2、3 这 5 个数字,其中最大的 3 个数字是 4、5、8。这 3 个数字的最小值 4 就是 4、5、8、2、3 这 5 个数字中第 3 大的数字。

由于每次都需要找出 k 个数字中的最小值,因此可以把这 k 个数字保存到最小堆中。每当从数据流中读出一个数字,就先判断这个新的数字是不是有必要添加到最小堆中

  1. 如果最小堆中元素的数目还小于 k,那么直接将它添加到最小堆中

  2. 如果最小堆中已经有 k 个元素,那么将其和位于堆顶的最小值进行比较。如果新读出的数字小于或等于堆中的最小值,那么堆中的 k 个数字都比它大,因此它不可能是 k 个最大的数字中的一个。由于只需要保存最大的 k 个数字,因此新读出的数字可以忽略。如果新的数字大于堆顶的数字,那么堆顶的数字就是第 k + 1 大的数字,可以将它从堆中删除,并将新的数字添加到堆中,这样堆中保存的仍然是到目前为止从数据流中读出的最大的 k 个数字,此时第 k 大的数字正好位于最小堆的堆顶

代码实现

cpp 复制代码
class KthLargest {
public:
    KthLargest(int k, vector<int>& nums) : capacity(k) {
        for (int num : nums)
        {
            add(num);
        }
    }
    
    int add(int val) {
        if (minHeap.size() < capacity)
        {
            minHeap.push(val);
        }
        else if (val > minHeap.top())
        {
            minHeap.pop();
            minHeap.push(val);
        }
        return minHeap.top();
    }
private:
    priority_queue<int, vector<int>, greater<int>> minHeap;
    size_t capacity;
};

假设数据流中总共有 n 个数字。这种解法特别适合 n 远大于 k 的场景。当 n 非常大时,内存可能不能容纳数据流中的所有数字。但使用最小堆之后,内存中只需要保存 k 个数字,空间效率非常高


面试题 60 : 出现频率最高的 k 个数字

题目

请找出数组中出现频率最高的 k 个数字。例如,当 k 等于 2 时,输入数组 [1, 2, 2, 1, 3, 1],由于数字 1 出现了 3 次,数字 2 出现了 2 次,数字 3 出现了 1 次,因此出现频率最高的 2 个数字是 1 和 2。

分析

如果在面试过程中遇到这个题目,首先要想到的是解决这个题目需要用到哈希表。这个题目的输入是一个数组,哈希表可以用来统计数组中数字出现的频率,哈希表的键是数组中出现的数字,而值是数字出现的频率

接下来找出出现频率最高的 k 个数字。可以用一个最小堆存储出现频率最高的 k 个数字,堆中的每个元素是数组中的数字及其在数组中出现的次数(即哈希表中数字到频率的映射)。由于比较的是数字的频率,因此设置最小堆比较元素的规则,以便让频率最低的数字位于堆的顶部

代码实现

cpp 复制代码
struct GreaterCmpByCnt
{
    bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) const
    {
        return lhs.second > rhs.second;
    }
};
​
class Solution {
public:
    vector<int> topKFrequent(vector<int>& nums, int k) {
        unordered_map<int, int> numToCount;
        for (int num : nums)
        {
            ++numToCount[num];
        }
​
        priority_queue<pair<int, int>, vector<pair<int, int>>, GreaterCmpByCnt> minHeap;
        for (const pair<int, int>& kv : numToCount)
        {
            if (minHeap.size() < k)
            {
                minHeap.push(kv);
            }
            else if (minHeap.top().second < kv.second)
            {
                minHeap.pop();
                minHeap.push(kv);
            }
        }
​
        vector<int> result(k);
        for (int i = 0; i < k; ++i)
        {
            result[i] = minHeap.top().first;
            minHeap.pop();
        }
        return result;
    }
};

面试题 61 : 和最小的 k 个数对

题目

给定两个递增排序的整数数组,从两个数组中各取一个数字 u 和 v 组成一个数对 (u, v),请找出和最小的 k 个数对。例如,输入两个数组 [1, 5, 13, 21] 和 [2, 4, 9, 15],和最小的 3 个数对为 (1, 2)、(1, 4) 和 (2, 5)。

分析

假设第 1 个数组 nums1 的长度为 m,第 2 个数组 nums2 的长度为 n,那么从两个数组中各取一个数字能组成 m x n 个数对。

这个题目要求找出和最小的 k 个数对。可以用最大堆来存储这个 k 个和最小的数对。逐一将 m x n 个数对添加到最大堆中

题目给出的条件是输入的两个数组都是递增排序的 ,这个特性我们还没有用到。如果从第 1 个数组中选出第 k + 1 个数字和第 2 个数组中的某个数字组成数对 p,那么该数对之和一定不是和最小的 k 个数对中的一个,这是因为第 1 个数组中的前 k 个数字和第 2 个数组中的同一个数字组成的 k 个数对之和都要小于数对 p 之和。因此,不管输入的数组 nums1 有多长,最多只考虑前 k 个数字。同理,不管输入的数组 nums2 有多长,最多也只考虑前 k 个数字。

代码实现

cpp 复制代码
struct LessCmpBySum {
    bool operator()(const vector<int>& lhs, const vector<int>& rhs) 
    {
        return lhs[0] + lhs[1] < rhs[0] + rhs[1];
    }
};
​
class Solution {
public:
    vector<vector<int>> kSmallestPairs(vector<int>& nums1, vector<int>& nums2, int k) {
        priority_queue<vector<int>, vector<vector<int>>, LessCmpBySum> maxHeap;
        int m = min((int)nums1.size(), k), n = min((int)nums2.size(), k);
        for (int i = 0; i < m; ++i)
        {
            for (int j = 0; j < n; ++j)
            {
                if (maxHeap.size() < k)
                {
                    maxHeap.push({ nums1[i], nums2[j] });
                }
                else if (nums1[i] + nums2[j] < maxHeap.top()[0] + maxHeap.top()[1])
                {
                    maxHeap.pop();
                    maxHeap.push({ nums1[i], nums2[j] });
                }
            }
        }
​
        vector<vector<int>> result;
        while (!maxHeap.empty())
        {
            result.push_back(maxHeap.top());
            maxHeap.pop();
        }
        return result;
    }
};

上述代码有两个相互嵌套的 for 循环,每个循环最多执行 k 次(假设数组 num1 和 num2 的长度都大于或等于 k)。在循环体内可能在最大堆中进行添加或删除操作,由于最大堆中最多包含 k 个元素,因此添加、删除操作的时间复杂度都是 O(logk)。这两个 for 循环的时间复杂度是 O(k^2 * logk)。另外,上述代码还有一个 while 循环,它逐一从最大堆中删除元素并将对应的数对添加到 result 数组中,这个 while 循环的时间复杂度是 O(klogk)。因此,上述代码总的时间复杂度是 O(k^2 * logk)

相关推荐
吱吱鼠叔3 分钟前
MATLAB计算与建模常见函数:5.曲线拟合
算法·机器学习·matlab
嵌入式AI的盲1 小时前
数组指针和指针数组
数据结构·算法
一律清风1 小时前
QT-文件创建时间修改器
c++·qt
不知所云,1 小时前
qt cmake自定义资源目录,手动加载资源(图片, qss文件)
开发语言·qt
风清扬_jd2 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome
安冬的码畜日常2 小时前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine
阑梦清川2 小时前
Java继承、final/protected说明、super/this辨析
java·开发语言
GISer_Jing2 小时前
【React】增量传输与渲染
前端·javascript·面试
PythonFun2 小时前
Python批量下载PPT模块并实现自动解压
开发语言·python·powerpoint
Death2002 小时前
Qt 6 相比 Qt 5 的主要提升与更新
开发语言·c++·qt·交互·数据可视化