目录
[面试题 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 个数字保存到最小堆中。每当从数据流中读出一个数字,就先判断这个新的数字是不是有必要添加到最小堆中。
-
如果最小堆中元素的数目还小于 k,那么直接将它添加到最小堆中。
-
如果最小堆中已经有 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)。