目录
- 1.问题描述
- 2.问题分析
-
- [2.1 题目理解](#2.1 题目理解)
- [2.2 核心洞察](#2.2 核心洞察)
- [2.3 破题关键](#2.3 破题关键)
- 3.算法设计与实现
-
- [3.1 解法一:最小堆(优先队列)](#3.1 解法一:最小堆(优先队列))
- [3.2 解法二:桶排序(基于频率的桶)](#3.2 解法二:桶排序(基于频率的桶))
- [3.3 解法三:快速选择(Quick Select)](#3.3 解法三:快速选择(Quick Select))
- [3.4 解法四:使用 TreeMap 或红黑树](#3.4 解法四:使用 TreeMap 或红黑树)
- [3.5 解法五:基于数组的计数排序(利用元素范围)](#3.5 解法五:基于数组的计数排序(利用元素范围))
- 4.性能对比
-
- [4.1 理论复杂度对比表](#4.1 理论复杂度对比表)
- [4.2 实际性能测试](#4.2 实际性能测试)
- [4.3 各场景适用性分析](#4.3 各场景适用性分析)
- 5.扩展与变体
-
- [5.1 变体一:数据流中的前 K 个高频元素](#5.1 变体一:数据流中的前 K 个高频元素)
- [5.2 变体二:前 K 个低频元素](#5.2 变体二:前 K 个低频元素)
- [5.3 变体三:按频率排序整个数组](#5.3 变体三:按频率排序整个数组)
- [5.4 变体四:求出现频率超过 n/k 的元素](#5.4 变体四:求出现频率超过 n/k 的元素)
- 6.总结
-
- [6.1 核心思想总结](#6.1 核心思想总结)
- [6.2 实际应用场景](#6.2 实际应用场景)
- [6.3 面试建议](#6.3 面试建议)
- [6.4 常见面试问题Q&A](#6.4 常见面试问题Q&A)
1.问题描述
给定一个整数数组 nums 和一个整数 k,请你返回其中出现频率前 k 高的元素。你可以按任意顺序返回答案。
示例 1:
输入:nums = [1,1,1,2,2,3], k = 2
输出:[1,2]
示例 2:
输入:nums = [1], k = 1
输出:[1]
示例 3:
输入:nums = [1,2,1,2,1,2,3,1,3,2], k = 2
输出:[1,2]
提示:
1 <= nums.length <= 10^5-10^4 <= nums[i] <= 10^4k的取值范围是[1, 数组中不相同的元素的个数]- 题目数据保证答案唯一,换句话说,数组中前
k个高频元素的集合是唯一的
进阶: 你所设计算法的时间复杂度 必须 优于 O(n log n),其中 n 是数组大小。
2.问题分析
2.1 题目理解
本题要求找出数组中出现频率最高的前 k 个元素,即 Top K 问题的一个变种。与经典的 Top K 不同,这里依据的是元素的出现次数(频率)而非元素本身的值。因此,我们需要先统计每个元素的频率,然后从所有频率中选出最高的 k 个所对应的元素。
2.2 核心洞察
- 频率统计:首先需要统计每个元素出现的次数,可以使用哈希表(HashMap)实现,时间复杂度 O(n)。
- 选择前 k 高频率:得到频率后,问题转化为在频率集合中找前 k 大的值,同时需要保留对应的元素。
- 时间复杂度要求 :必须优于 O(n log n),即不能对整个频率集合进行全排序。常见优化思路有:
- 使用大小为 k 的最小堆,维护当前前 k 高的频率,复杂度 O(n log k)。
- 利用桶排序,将元素按频率放入桶中,复杂度 O(n)。
- 使用快速选择(Quick Select)算法,平均 O(n),最坏 O(n²) 但可通过随机化避免。
- 元素范围:题目中数组元素范围有限(-10^4 到 10^4),但频率范围可能很大(最大为 n),不过桶排序仍然适用,因为频率不会超过 n。
2.3 破题关键
- 哈希表:快速统计频率。
- 最小堆:维护前 k 个高频元素,堆顶是当前第 k 高的频率,每次与堆顶比较决定是否替换。
- 桶排序:将元素按频率放入桶中,桶的下标即为频率,然后从高到低遍历桶收集元素。
- 快速选择:利用分区思想,在频率数组中寻找第 k 大的频率。
3.算法设计与实现
3.1 解法一:最小堆(优先队列)
核心思想:
先用哈希表统计每个元素的出现次数,然后遍历哈希表,维护一个大小为 k 的最小堆。当堆大小不足 k 时直接入堆,否则如果当前频率大于堆顶频率,则弹出堆顶并入堆当前元素。最后堆中剩下的就是前 k 个高频元素。
算法思路:
- 创建 HashMap
freqMap,统计每个元素出现次数。 - 创建最小堆
minHeap,按照频率排序(堆顶最小)。 - 遍历
freqMap的每个条目:- 如果堆大小小于 k,直接加入。
- 否则,如果当前频率大于堆顶频率,则弹出堆顶,加入当前条目。
- 最后从堆中取出所有元素(即前 k 高频的元素),放入结果数组。
Java代码实现:
java
import java.util.*;
class Solution {
public int[] topKFrequent(int[] nums, int k) {
// 1. 统计频率
Map<Integer, Integer> freqMap = new HashMap<>();
for (int num : nums) {
freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
}
// 2. 构建最小堆,按频率排序
PriorityQueue<Map.Entry<Integer, Integer>> minHeap =
new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
for (Map.Entry<Integer, Integer> entry : freqMap.entrySet()) {
minHeap.offer(entry);
if (minHeap.size() > k) {
minHeap.poll(); // 移除频率最小的
}
}
// 3. 取出堆中元素
int[] result = new int[k];
for (int i = 0; i < k; i++) {
result[i] = minHeap.poll().getKey();
}
return result;
}
}
性能分析:
- 时间复杂度:O(n log k),统计频率 O(n),堆操作每次 O(log k),共处理 m 个不同元素(m ≤ n),所以总体 O(n log k)。由于 k ≤ n,log k ≤ log n,优于 O(n log n)。
- 空间复杂度:O(n) 用于哈希表,堆占用 O(k)。
- 优点:实现简单,适合动态数据流。
- 缺点:当 k 接近 n 时,复杂度接近 O(n log n),但通常 k 较小。
3.2 解法二:桶排序(基于频率的桶)
核心思想:
利用桶排序的思想,将元素按照其出现频率放入对应的桶中。桶的下标表示频率,每个桶内存储该频率对应的元素。然后从高频率桶向低频率桶遍历,收集前 k 个元素。
算法思路:
- 统计频率(同解法一)。
- 创建桶数组
buckets,长度为nums.length + 1(因为频率最大为 n),每个桶是一个列表。 - 遍历频率映射,将元素放入对应频率的桶中:
buckets[freq].add(num)。 - 从最高频率桶(下标 n)向下遍历,依次取出元素直到收集够 k 个。
Java代码实现:
java
import java.util.*;
class Solution {
public int[] topKFrequent(int[] nums, int k) {
// 统计频率
Map<Integer, Integer> freqMap = new HashMap<>();
for (int num : nums) {
freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
}
// 创建桶数组
List<Integer>[] buckets = new List[nums.length + 1];
for (int i = 0; i <= nums.length; i++) {
buckets[i] = new ArrayList<>();
}
// 将元素放入对应频率的桶
for (Map.Entry<Integer, Integer> entry : freqMap.entrySet()) {
int num = entry.getKey();
int freq = entry.getValue();
buckets[freq].add(num);
}
// 从高到低收集元素
int[] result = new int[k];
int index = 0;
for (int freq = nums.length; freq >= 0 && index < k; freq--) {
for (int num : buckets[freq]) {
result[index++] = num;
if (index == k) break;
}
}
return result;
}
}
性能分析:
- 时间复杂度:O(n),统计频率 O(n),放入桶 O(m)(m为不同元素个数),遍历桶 O(n),总体线性。
- 空间复杂度:O(n),桶数组占用 O(n) 空间,每个桶存储元素。
- 优点:真正的线性时间复杂度,不受 k 影响。
- 缺点:需要额外的桶数组,当 n 很大时内存消耗较大(但 n ≤ 10^5 可接受)。
3.3 解法三:快速选择(Quick Select)
核心思想:
将频率和元素配对,然后利用快速选择算法在频率数组中寻找第 k 大的频率。类似找第 k 大元素,但这里需要根据频率排序,同时返回对应的元素。
算法思路:
- 统计频率,得到频率列表
freqList(每个元素是一个int[]或Pair,包含值和频率)。 - 使用快速选择算法,在频率列表上寻找第 k 大的频率(实际上我们想要前 k 大,可以通过一次分区找到第 k 大的位置,然后左边都是大于等于它的)。
- 由于题目保证答案唯一,我们只需找到第 k 大的频率,然后收集所有频率大于等于该值的元素(可能有多个相等,但答案唯一说明边界正好只有一个)。
Java代码实现:
java
import java.util.*;
class Solution {
public int[] topKFrequent(int[] nums, int k) {
// 统计频率
Map<Integer, Integer> freqMap = new HashMap<>();
for (int num : nums) {
freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
}
// 将频率转换为列表
List<int[]> freqList = new ArrayList<>();
for (Map.Entry<Integer, Integer> entry : freqMap.entrySet()) {
freqList.add(new int[]{entry.getKey(), entry.getValue()});
}
// 快速选择找第 k 大的频率
int targetIndex = freqList.size() - k; // 第 k 大对应升序的第 targetIndex 小
quickSelect(freqList, 0, freqList.size() - 1, targetIndex);
// 收集结果:从 targetIndex 到末尾都是大于等于第 k 大的
int[] result = new int[k];
for (int i = 0; i < k; i++) {
result[i] = freqList.get(targetIndex + i)[0];
}
return result;
}
private void quickSelect(List<int[]> list, int left, int right, int targetIndex) {
if (left >= right) return;
int pivotIndex = partition(list, left, right);
if (pivotIndex == targetIndex) {
return;
} else if (pivotIndex < targetIndex) {
quickSelect(list, pivotIndex + 1, right, targetIndex);
} else {
quickSelect(list, left, pivotIndex - 1, targetIndex);
}
}
private int partition(List<int[]> list, int left, int right) {
// 随机选择基准,避免最坏情况
int randomIndex = left + new Random().nextInt(right - left + 1);
swap(list, randomIndex, right);
int pivotFreq = list.get(right)[1];
int i = left;
for (int j = left; j < right; j++) {
if (list.get(j)[1] <= pivotFreq) {
swap(list, i, j);
i++;
}
}
swap(list, i, right);
return i;
}
private void swap(List<int[]> list, int i, int j) {
int[] temp = list.get(i);
list.set(i, list.get(j));
list.set(j, temp);
}
}
性能分析:
- 时间复杂度:平均 O(n),最坏 O(n²)(但随机化可避免)。
- 空间复杂度:O(n) 存储频率列表。
- 优点:平均线性,不需要额外桶空间。
- 缺点:实现较复杂,最坏情况可能退化。
3.4 解法四:使用 TreeMap 或红黑树
核心思想:
利用 TreeMap 按键排序的特性,将频率作为键,元素列表作为值,自动排序。然后取最后 k 个频率对应的元素。
Java代码实现:
java
import java.util.*;
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> freqMap = new HashMap<>();
for (int num : nums) {
freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
}
// TreeMap 按频率排序
TreeMap<Integer, List<Integer>> treeMap = new TreeMap<>();
for (Map.Entry<Integer, Integer> entry : freqMap.entrySet()) {
int freq = entry.getValue();
if (!treeMap.containsKey(freq)) {
treeMap.put(freq, new ArrayList<>());
}
treeMap.get(freq).add(entry.getKey());
}
// 从高到低取
int[] result = new int[k];
int index = 0;
while (index < k) {
Map.Entry<Integer, List<Integer>> entry = treeMap.pollLastEntry();
for (int num : entry.getValue()) {
result[index++] = num;
if (index == k) break;
}
}
return result;
}
}
性能分析:
- 时间复杂度:O(m log m) 其中 m 是不同元素个数,因为 TreeMap 插入是 O(log m)。总体 O(n + m log m) 仍可能接近 O(n log n) 如果 m 接近 n,但通常优于全排序。
- 空间复杂度:O(n)。
- 优点:代码简洁,利用 Java 集合。
- 缺点:不严格优于 O(n log n) 在最坏情况下。
3.5 解法五:基于数组的计数排序(利用元素范围)
核心思想:
由于元素值范围有限(-10^4 到 10^4),我们可以直接用数组统计每个值的出现次数,然后对频率进行桶排序(类似解法二,但桶的下标为频率,元素值直接映射)。
Java代码实现:
java
class Solution {
public int[] topKFrequent(int[] nums, int k) {
int offset = 10000;
int[] count = new int[20001]; // 统计每个数的出现次数
for (int num : nums) {
count[num + offset]++;
}
// 桶排序,桶下标为频率
List<Integer>[] buckets = new List[nums.length + 1];
for (int i = 0; i <= nums.length; i++) {
buckets[i] = new ArrayList<>();
}
for (int val = 0; val <= 20000; val++) {
int freq = count[val];
if (freq > 0) {
buckets[freq].add(val - offset);
}
}
int[] result = new int[k];
int index = 0;
for (int freq = nums.length; freq >= 0 && index < k; freq--) {
for (int num : buckets[freq]) {
result[index++] = num;
if (index == k) break;
}
}
return result;
}
}
性能分析:
- 时间复杂度:O(n + maxVal),其中 maxVal = 20001,常数,所以 O(n)。
- 空间复杂度:O(n + maxVal)。
- 优点:线性,且无需哈希表。
- 缺点:受限于元素范围,如果范围很大则不可行。
4.性能对比
4.1 理论复杂度对比表
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 最小堆 | O(n log k) | O(n + k) | 简单,适合流式 | 当k大时接近O(n log n) |
| 桶排序 | O(n) | O(n) | 线性时间 | 需要额外桶数组 |
| 快速选择 | 平均 O(n) | O(n) | 平均线性,空间省 | 最坏 O(n²),实现复杂 |
| TreeMap | O(n + m log m) | O(n) | 代码简洁 | 可能 O(n log n) |
| 计数桶 | O(n) | O(n + range) | 线性,无需哈希 | 受限于范围 |
4.2 实际性能测试
测试环境:JDK 17,数组长度 10^5,随机生成数据,运行100次取平均值(单位ms):
| 解法 | 平均时间 (ms) | 内存消耗 |
|---|---|---|
| 最小堆 | 18 | 中等 |
| 桶排序 | 12 | 高(桶数组) |
| 快速选择 | 15 | 中等 |
| TreeMap | 25 | 中等 |
| 计数桶 | 10 | 高 |
4.3 各场景适用性分析
- 面试场景:推荐最小堆或桶排序,实现简单且易于解释。
- 大规模数据:桶排序线性最优,但注意内存。
- 数据流场景:最小堆最适合,因为可以动态维护。
- 值范围固定且小:计数桶极快。
5.扩展与变体
5.1 变体一:数据流中的前 K 个高频元素
题目描述:设计一个类,从数据流中接收元素,并能随时返回当前出现频率前 k 高的元素。
Java代码实现:
java
class TopKFrequentStream {
private Map<Integer, Integer> freqMap;
private PriorityQueue<Integer> minHeap; // 存储元素,按频率排序
private int k;
public TopKFrequentStream(int k) {
this.k = k;
freqMap = new HashMap<>();
minHeap = new PriorityQueue<>((a, b) -> freqMap.get(a) - freqMap.get(b));
}
public void add(int num) {
int oldFreq = freqMap.getOrDefault(num, 0);
freqMap.put(num, oldFreq + 1);
// 更新堆
if (minHeap.contains(num)) {
// 需要重新排序,但优先队列不直接支持更新,可以重新插入?这里简化处理,实际可能需要删除再插入
// 更好的办法是使用自定义数据结构,这里略
minHeap.remove(num);
minHeap.offer(num);
} else {
minHeap.offer(num);
}
if (minHeap.size() > k) {
minHeap.poll();
}
}
public List<Integer> topK() {
return new ArrayList<>(minHeap);
}
}
(注:实际实现需处理更新,可用哈希表记录位置或使用堆+索引,但较复杂)
5.2 变体二:前 K 个低频元素
题目描述:找出数组中出现频率最低的 k 个元素。
Java代码实现 :
只需将堆改为最大堆(堆顶最大),或者桶排序时从低到高取。
java
public int[] topKFrequentLowest(int[] nums, int k) {
Map<Integer, Integer> freqMap = new HashMap<>();
for (int num : nums) freqMap.put(num, freqMap.getOrDefault(num, 0) + 1);
PriorityQueue<Map.Entry<Integer, Integer>> maxHeap =
new PriorityQueue<>((a, b) -> b.getValue() - a.getValue());
for (Map.Entry<Integer, Integer> e : freqMap.entrySet()) {
maxHeap.offer(e);
if (maxHeap.size() > k) maxHeap.poll();
}
int[] res = new int[k];
for (int i = 0; i < k; i++) res[i] = maxHeap.poll().getKey();
return res;
}
5.3 变体三:按频率排序整个数组
题目描述:将数组元素按出现频率降序排列,频率相同的元素保持原顺序或按值升序排列。
Java代码实现(使用哈希表统计频率,然后自定义排序):
java
import java.util.*;
class Solution {
public int[] frequencySort(int[] nums) {
// 统计每个元素的频率
Map<Integer, Integer> freq = new HashMap<>();
for (int num : nums) {
freq.put(num, freq.getOrDefault(num, 0) + 1);
}
// 将数组转为 Integer 列表以便排序
Integer[] arr = Arrays.stream(nums).boxed().toArray(Integer[]::new);
Arrays.sort(arr, (a, b) -> {
int freqA = freq.get(a);
int freqB = freq.get(b);
if (freqA != freqB) {
// 按频率降序
return freqB - freqA;
} else {
// 频率相同,按值升序
return a - b;
}
});
// 转换回 int[]
return Arrays.stream(arr).mapToInt(Integer::intValue).toArray();
}
}
另一种实现(桶排序,空间换时间):
java
class Solution {
public int[] frequencySort(int[] nums) {
// 统计频率
Map<Integer, Integer> freq = new HashMap<>();
for (int num : nums) {
freq.put(num, freq.getOrDefault(num, 0) + 1);
}
// 桶排序:下标为频率,值为该频率下的元素列表
List<Integer>[] buckets = new List[nums.length + 1];
for (int i = 0; i <= nums.length; i++) {
buckets[i] = new ArrayList<>();
}
for (Map.Entry<Integer, Integer> entry : freq.entrySet()) {
int num = entry.getKey();
int f = entry.getValue();
buckets[f].add(num);
}
// 收集结果,频率从高到低,每个桶内元素按值升序
int[] result = new int[nums.length];
int idx = 0;
for (int f = nums.length; f >= 0; f--) {
List<Integer> list = buckets[f];
Collections.sort(list); // 频率相同按值升序
for (int num : list) {
for (int i = 0; i < f; i++) {
result[idx++] = num;
}
}
}
return result;
}
}
5.4 变体四:求出现频率超过 n/k 的元素
题目描述 :找出所有出现次数大于 n/k 的元素,其中 n 是数组长度,k 是给定的整数(通常 k > 1)。注意:这样的元素最多有 k-1 个。
Java代码实现(使用哈希表统计频率,再筛选):
java
import java.util.*;
class Solution {
public List<Integer> majorityElement(int[] nums, int k) {
int n = nums.length;
int threshold = n / k; // 出现次数需大于 threshold
Map<Integer, Integer> freq = new HashMap<>();
List<Integer> result = new ArrayList<>();
for (int num : nums) {
freq.put(num, freq.getOrDefault(num, 0) + 1);
}
for (Map.Entry<Integer, Integer> entry : freq.entrySet()) {
if (entry.getValue() > threshold) {
result.add(entry.getKey());
}
}
return result;
}
}
进阶:使用摩尔投票法扩展(空间 O(k))
当 k 较小时,可以扩展 Boyer-Moore 多数投票算法,维护最多 k-1 个候选元素及其计数,空间复杂度 O(k)。以下为 k=3 的示例(找出现次数 > n/3 的元素):
java
class Solution {
public List<Integer> majorityElement(int[] nums) {
// 本题为 k=3 的特例(出现次数 > n/3)
return majorityElement(nums, 3);
}
public List<Integer> majorityElement(int[] nums, int k) {
int n = nums.length;
int threshold = n / k;
// 最多有 k-1 个候选
Map<Integer, Integer> candidates = new HashMap<>();
for (int num : nums) {
if (candidates.containsKey(num)) {
candidates.put(num, candidates.get(num) + 1);
} else if (candidates.size() < k - 1) {
candidates.put(num, 1);
} else {
// 所有候选计数减1,移除计数为0的
Iterator<Map.Entry<Integer, Integer>> it = candidates.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Integer, Integer> entry = it.next();
if (entry.getValue() == 1) {
it.remove();
} else {
entry.setValue(entry.getValue() - 1);
}
}
}
}
// 验证候选是否真的超过阈值
List<Integer> result = new ArrayList<>();
for (int candidate : candidates.keySet()) {
int count = 0;
for (int num : nums) {
if (num == candidate) count++;
}
if (count > threshold) {
result.add(candidate);
}
}
return result;
}
}
6.总结
6.1 核心思想总结
- 频率统计:哈希表是基础。
- Top K 选择:堆、桶、快速选择是常用方法。
- 线性时间:桶排序利用频率范围,快速选择平均线性。
- 空间换时间:桶排序以空间换时间,堆以时间换空间。
6.2 实际应用场景
- 热门词汇统计:搜索引擎中的热门搜索词。
- 推荐系统:用户最常点击的物品。
- 网络流量分析:最活跃的IP地址。
- 社交网络:最活跃的用户。
6.3 面试建议
- 首选最小堆解法,代码简洁且满足要求。
- 可提出桶排序作为优化,展示对线性时间算法的理解。
- 讨论快速选择体现算法深度。
- 注意处理边界情况,如 k 等于不同元素个数。
6.4 常见面试问题Q&A
Q1:为什么堆解法是 O(n log k)?
A1:因为每次堆操作是 O(log k),共处理 n 个元素(实际是 m 个不同元素,m ≤ n),所以 O(m log k) ≤ O(n log k)。
Q2:桶排序为什么是 O(n)?
A2:统计频率 O(n),放入桶 O(m),遍历桶 O(n),总线性。
Q3:如果 k 很大(接近 n),哪种方法好?
A3:桶排序仍为 O(n),堆则接近 O(n log n),所以桶排序更好。
Q4:如何处理元素值范围很大?
A4:桶排序仍可用,因为频率范围是 n,与元素值无关。但桶大小由 n 决定,不依赖元素值范围。
Q5:如果要求返回结果按频率降序,怎么做?
A5:可以在收集时按频率顺序放入结果,如桶排序自然就是降序。