在这篇文章中,我们将深入探讨一个经典且在面试中高频出现的算法问题:如何在一个无序数组中快速找出出现频率最高的前 K 个元素。这不仅仅是一个关于计数的问题,更是一次关于如何在哈希表、排序算法和优先队列(堆)之间做出权衡的深度思维训练。我们将从最直观的思路出发,逐步优化算法性能,并一起探讨在实际工程应用中如何应对海量数据的挑战。
问题重述与核心挑战
首先,让我们明确一下问题的具体定义。给定一个整数数组 INLINECODE1da2185d 和一个正整数 INLINECODE1c820f71,我们的任务是找出数组中出现频率最高的 k 个元素。
这里有一个关键的细节需要注意: 如果两个不同的数字出现的频率相同,我们需要将数值较大的元素排在前面。这个“平局规则”虽然听起来简单,但在编写代码时往往容易被忽略,导致排序逻辑出错。
#### 举例说明
让我们通过两个具体的例子来直观理解题目要求:
示例 1:
> 输入: INLINECODEce434c3a, INLINECODE79a373d0
> 输出: [4, 1]
> 解释: 在这个数组中,4 出现了 2 次,1 也出现了 2 次。其他数字(3, 5, 2, 6)只出现了 1 次。因为 4 和 1 的频率并列第一,且 4 的数值大于 1,所以结果就是 [4, 1]。
示例 2:
> 输入: INLINECODEa4667077, INLINECODEfd9e7c79
> 输出: [5, 11, 7, 10]
> 解释:
> – 5 出现了 3 次(频率最高)。
> – 11 和 7 都出现了 2 次(频率并列第二)。因为 11 > 7,所以 11 排在 7 前面。
> – 剩下的数字(10, 2, 8, 9)都只出现了 1 次。因为我们需要取第 4 个元素,而在频率为 1 的所有数字中,10 是最大的,所以 10 入选。
理解了这些规则后,让我们开始探索解决方案。
方案一:直观的哈希映射与排序法
这是我们最容易想到的“朴素解法”。当我们面对“统计频率”这类问题时,脑海中应该第一时间浮现出哈希表这种数据结构。哈希表允许我们在 O(1) 的平均时间复杂度下完成查找和插入操作。
#### 算法思路
- 构建频率映射: 首先,我们需要遍历整个数组。对于数组中的每一个元素,我们以“元素值”为键,以“出现次数”为值存入哈希表中。这一步的时间复杂度是 O(n),其中 n 是数组的长度。
- 转换与排序: 接下来,我们将哈希表中的键值对转换为一个列表或数组,以便进行排序。排序的依据是我们之前定义的规则:首先按频率降序排列,如果频率相同,则按数值降序排列。这一步的时间复杂度是 O(m log m),其中 m 是数组中不同元素的个数。在最坏情况下(所有元素都不同),m 等于 n,所以这一步是 O(n log n)。
- 提取结果: 最后,我们只需要取出排序后列表的前 k 个元素即可。
#### 代码实现 (C++)
让我们看看如何用 C++ 优雅地实现这个逻辑。这里的关键在于自定义排序的比较函数。
#include
#include
#include
#include
using namespace std;
// 自定义比较函数:决定排序的优先级
// 传入的是一对对的 {数值, 频率}
bool compare(const pair &a, const pair &b) {
// 1. 首要条件:频率高的排在前面
if (a.second != b.second) {
return a.second > b.second;
}
// 2. 次要条件:如果频率相同,数值大的排在前面
return a.first > b.first;
}
vector topKFreq(vector& arr, int k) {
// 步骤 1: 统计频率
unordered_map freqMap;
for (int val : arr) {
freqMap[val]++;
}
// 步骤 2: 将 Map 转换为 Vector 以便排序
vector<pair> freqVec(freqMap.begin(), freqMap.end());
// 步骤 3: 执行排序
sort(freqVec.begin(), freqVec.end(), compare);
// 步骤 4: 提取前 K 个结果
vector result;
for (int i = 0; i < k; ++i) {
result.push_back(freqVec[i].first);
}
return result;
}
int main() {
vector arr = {7, 10, 11, 5, 2, 5, 5, 7, 11, 8, 9};
int k = 4;
vector res = topKFreq(arr, k);
cout << "输出: [";
for (size_t i = 0; i < res.size(); ++i) {
cout << res[i] << (i < res.size() - 1 ? ", " : "");
}
cout << "]" << endl;
// 输出应为: [5, 11, 7, 10]
return 0;
}
#### 复杂度分析
- 时间复杂度: O(n) + O(m log m)。构建哈希表需要 O(n),排序需要 O(m log m)。由于 m <= n,总体主要由排序决定。
- 空间复杂度: O(m)。我们需要额外的空间来存储哈希表和排序用的向量。
这种方法的优点是代码逻辑清晰,易于实现和调试。在数据量不是特别大(例如 n 小于几百万)的情况下,这种方法的性能完全足够,且在实际工程中非常可靠。
方案二:使用最小堆优化(当 K 远小于 N 时)
如果我们的数组非常大(比如有 1 亿个数据),但我们只需要找出频率最高的前 10 个元素(即 k 很小),上述的排序方法就显得有些“杀鸡用牛刀”了。我们不需要对所有 m 个不同的元素都进行排序,只需要维护一个大小为 k 的“当前最优”集合即可。这时,堆(Priority Queue) 就派上用场了。
#### 算法思路
在这里,我们选择使用最小堆。为什么是最小堆而不是最大堆?因为我们要保留频率最高的元素,最小堆可以帮助我们快速地“踢出”频率最低的那个元素。
- 遍历哈希表中的元素。
- 将元素对(频率, 数值)插入堆中。注意:为了让堆在频率相同时能正确处理数值,我们需要调整比较逻辑,使得当频率相同时,数值较小的被视为“优先级较低”(因为我们想要保留数值大的)。或者更简单的方法是,当堆大小超过 k 时,检查堆顶元素是否比当前元素“更差”。
n3. 一旦堆的大小超过了 k,我们就弹出堆顶元素。堆顶永远是当前堆里频率最低(或在频率相同时数值最小)的元素,移除它是符合逻辑的。
- 最终,堆中剩下的就是我们想要的前 k 个高频元素。最后我们需要把它们取出来并按降序排列(因为堆输出的顺序通常是无序或升序的)。
#### 代码实现 (Java)
Java 的 PriorityQueue 默认是最小堆,非常适合这个场景。
import java.util.*;
class Solution {
public int[] topKFreq(int[] arr, int k) {
// 步骤 1: 统计频率
Map freqMap = new HashMap();
for (int num : arr) {
freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
}
// 步骤 2: 维护一个大小为 K 的最小堆
// 堆中的元素是 int[]{数值, 频率}
PriorityQueue minHeap = new PriorityQueue((a, b) -> {
if (a[1] != b[1]) {
// 频率低的排在前面(容易被弹出)
return a[1] - b[1];
} else {
// 频率相同时,数值小的排在前面(容易被弹出)
// 注意:这保证了当我们要移除元素时,优先移除频率低或数值小的
return a[0] - b[0];
}
});
// 遍历 Map 中的条目
for (Map.Entry entry : freqMap.entrySet()) {
int num = entry.getKey();
int freq = entry.getValue();
minHeap.offer(new int[]{num, freq});
// 如果堆大小超过 K,弹出堆顶(最不符合条件的元素)
if (minHeap.size() > k) {
minHeap.poll();
}
}
// 步骤 3: 从堆中取出结果
// 因为堆顶是最小的,取出时是倒序的,我们需要反转并调整
int[] result = new int[k];
int index = k - 1;
while (!minHeap.isEmpty()) {
result[index--] = minHeap.poll()[0];
}
return result;
}
public static void main(String[] args) {
Solution sol = new Solution();
int[] arr = {7, 10, 11, 5, 2, 5, 5, 7, 11, 8, 9};
int k = 4;
int[] res = sol.topKFreq(arr, k);
System.out.println("输出: " + Arrays.toString(res));
}
}
#### 复杂度分析
- 时间复杂度: O(n) + O(m log k)。遍历数组是 O(n)。堆操作涉及 m 个元素,每次插入和删除操作是 O(log k)。如果 k 远小于 n(比如 k=10, n=100万),这比 O(n log n) 要快得多。
- 空间复杂度: O(m) 用于哈希表,O(k) 用于堆。
方案三:桶排序法—— 当频率范围有限时
如果我们追求极致的速度,且知道频率的最大值不会特别大(或者不在意空间开销),我们可以利用“以空间换时间”的策略,即桶排序的变体。
#### 算法思路
- 首先依然建立哈希表统计频率。
- 创建一个列表的列表(桶),索引代表频率。比如
buckets[2]存放所有出现了 2 次的数字。因为一个数字出现的频率最高也就是 n 次,所以我们需要 n+1 个桶。 - 遍历哈希表,将数字放入对应的桶中。
- 为了处理“频率相同取数值大”的规则,每个桶内部可以先进行降序排序。
- 最后,从频率最大的桶(即数组末尾)开始向前遍历,依次取出数字,直到凑满 k 个为止。
这种方法之所以快,是因为它避免了基于比较的排序(Sort),而是直接将数据分发到了固定的“桶”里。
#### 代码实现
from collections import Counter
def topKFreqBucketSort(arr, k):
# 步骤 1: 统计频率
count = Counter(arr)
max_freq = max(count.values())
# 步骤 2: 创建桶
# buckets[i] 存储出现频率为 i 的所有数字
buckets = [[] for _ in range(max_freq + 1)]
for num, freq in count.items():
buckets[freq].append(num)
# 步骤 3: 对每个桶内部进行降序排序,以满足题目要求
for bucket in buckets:
# 降序排列,这样我们在取的时候直接按顺序取即可
bucket.sort(reverse=True)
# 步骤 4: 从后往前取结果
result = []
# 从最大频率开始遍历
for i in range(max_freq, 0, -1):
for num in buckets[i]:
result.append(num)
if len(result) == k:
return result
return result
# 测试
arr = [7, 10, 11, 5, 2, 5, 5, 7, 11, 8, 9]
print("输出:", topKFreqBucketSort(arr, 4))
# 输出应为: [5, 11, 7, 10]
#### 复杂度分析
- 时间复杂度: 虽然听起来是 O(n),但别忘了我们在桶内部进行了排序。如果桶内的元素分布不均匀,最坏情况下复杂度可能退化。不过在实际场景中,尤其是整数频率分布均匀时,速度非常快。
- 空间复杂度: O(n)。
实际应用场景与最佳实践
在现实世界的软件开发中,我们什么时候会遇到这个问题呢?
- 热门搜索词/关键词统计: 比如搜索引擎要统计一天内搜索最频繁的前 10 个关键词。这是典型的 Top K 问题,数据量极大,但 K 很小。此时,堆 是最佳选择,甚至需要使用“布隆过滤器”配合堆来处理内存不足的情况。
- 电商推荐系统: 找出某个用户购买最频繁的前 5 类商品。
- 系统日志分析: 在错误日志中找出出现频率最高的 Top 10 报错代码。
#### 常见错误与坑
在编写这类代码时,新手容易犯以下错误:
- 忽略 k 大于唯一元素总数的情况: 比如 INLINECODE2ee26e2e,但数组只有 3 个不同的数字。这时应该返回所有 3 个数字,而不是报错。上面的代码如果写成 INLINECODE7e73ee82 且没有做边界检查,可能会在 INLINECODE49a36231 中越界。解决方案:在循环前取 INLINECODE8692bc95。
- 自定义比较器写反: 这是一个非常头疼的逻辑错误。在 C++ 的 INLINECODE13d015dd 中,INLINECODEa963f0a5 是升序还是降序?如果在堆中,逻辑又反过来了。建议:写完比较器后,马上写一个简单的单元测试用例(比如两个相同频率、不同数值的元素)验证一下。
- Integer 溢出: 虽然在统计频率时不太容易溢出,但在计算哈希值或累加频率时,如果数据量达到几十亿,普通的 INLINECODE06c00c90 可能会不够用。使用 INLINECODEc5b46058 或 Python 的
int会更安全。
总结
在本文中,我们从最基础的哈希映射+排序开始,解决了 Top K Frequent Elements 问题。这种方法代码简洁,适合中小规模数据。
接着,我们针对 K 较小的场景,引入了最小堆优化方案,将时间复杂度优化到了 O(n log k),这是面试中非常加分的考点。
最后,我们简单提到了桶排序的思路,展示了算法优化的多样性。
给读者的建议:
当你下次遇到类似的统计问题时,先问自己三个问题:
- 数据规模有多大?
- K 值相对于数据总量大还是小?
- 对时间复杂度还是空间复杂度更敏感?
弄清楚这些,你就能在哈希表、堆和排序之间做出最专业的选择了。希望这篇文章能帮助你在编程之路上更进一步!