前 K 个高频元素 图解题解
找出现最多的前 K 个数,堆排序够用,但桶排序能让时间复杂度直接打到 O(n)。
想象统计一篇文章里每个词出现了几次,词最多能出现 n 次(n 是文章总词数)。先开 n+1 个桶,编号 0 到 n,每个词按出现次数直接扔进对应编号的桶。桶建好后从高编号往回数,数够 k 个词就停——既没排序,也没额外开销。桶的数量跟数组长度走,不是固定的,是 n+1。
这道题到底在问什么
- 输入
- nums = [1,1,1,2,2,3], k = 2
- 输出
- [1,2]
最优解:为什么这么做
一句话答案:LeetCode 347 前 K 个高频元素的经典解是哈希表数频次加大小为 k 的最小堆:把(频次,值)依次入堆,堆一超过 k 个就弹掉堆顶那个频次最低的,扫完堆里剩下的恰好是频次前 k 高的元素,时间 O(n log k)、空间 O(n+k);追求线性时间可以换桶排序做到 O(n)。
这道题真正在问什么
给一个整数数组和数字 k,返回出现频率前 k 高的那些元素,顺序不限。它天然拆成两步:先数清每个数出现几次,再从这些频次里挑出最大的 k 个。第一步用哈希表数频次毫无悬念;这道题真正考的是第二步——「选前 k 大」怎么做得比全排序更聪明。
为什么不该把频次全排序
把所有(值,频次)按频次排序再取前 k 当然正确,但要 O(n log n)。浪费在哪?排序给出了全体元素的完整次序,而我们只关心「谁能进前 k」,前 k 之外的元素彼此谁大谁小根本无所谓。关键观察:只需要动态维护一个「目前最强的 k 个」的小圈子,新元素来了就和圈里最弱的比一比,更强就把最弱的踢出去。这个「随时找到圈内最弱者」的需求,正是堆的主场。
为什么用最小堆而不是最大堆
直觉常会反着走:求前 k 大不是该用最大堆吗?用最大堆得把全部元素建堆再连弹 k 次,堆的规模是 n。换成只装 k 个元素的最小堆,堆顶永远是圈子里频次最低的那个——恰好是随时要淘汰的对象:新元素入堆,一旦超过 k 个就弹掉堆顶,弱者出局、强者留下,堆始终只有 k 的规模。
两个容易翻车的点:一是堆的比较键必须是频次而不是数值本身,所以入堆的是(频次,值)这样的二元组;二是必须先用哈希表把频次统计完再入堆,直接把原数组元素怼进堆会把同一个值拆成多份,频次全错。
凭什么留在堆里的就是前 k 高
循环全程保持一个不变量:堆里装的始终是「到目前为止频次最高的至多 k 个元素」。每次被弹出的都是当前堆中频次最小的,它已经输给了堆里所有同伴;又因为频次在入堆之前就统计完毕、不会再变化,被淘汰者永远没有翻盘机会。所以全部元素处理完后,堆里剩下的 k 个就是全局频次前 k 高,正确性不依赖任何输入顺序。
复杂度多少,能做到 O(n) 吗
数组长度为 n 时,哈希表里的不同元素至多 n 个,每个各做一次入堆、至多一次弹出,每次 O(log k),总时间 O(n log k);空间 O(n+k),哈希表存频次、堆只存 k 个。k 远小于 n 时比全排序省得多。
想彻底去掉 log 可以换桶排序:频次的取值范围只能是 1 到 n,开 n+1 个桶,把每个元素放进「下标等于它频次」的桶里,再从高频桶往低频桶倒着收集,凑满 k 个就停,整体 O(n)。面试先给堆解、再补一句桶排序的优化,是这道题的完整答案。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3为什么用「最小堆」而不是最大堆?因为我们要不停淘汰「频次最低」的,让频次最低的永远浮在堆顶,超员就弹它,省去对全部元素排序。
- 4数频次。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 5数频次。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 6数频次。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 7频次数完。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 8准备进堆。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 9入堆 1(×3)。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 101 入堆完成。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 11入堆 2(×2)。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 12比较 2(×2) 与父 1(×3)。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 13上浮:2 换到上面。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 142 入堆完成。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 15入堆 3(×1)。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 16比较 3(×1) 与父 2(×2)。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 17上浮:3 换到上面。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 183 入堆完成。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 19弹出堆顶 3(×1)。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 20末尾 2 补到堆顶。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 21堆已恢复最小堆。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 22淘汰 3(×1),堆恢复大小 2。节点显示「值(×频次)」,堆按频次做最小堆——堆顶永远是当前频次最小的,超过 k 个就弹掉堆顶。
- 23堆里最终留下 1(×3) 和 2(×2)(绿色)= 前 2 个高频元素。3(×1) 因频次最低被弹出。按频次排好 = 答案 [1, 2]。
⚠️ 容易写错的地方
✗ 错:用「最大堆」装全部元素再取前 k
✓ 对:用大小 k 的「最小堆」边入边淘汰
最大堆要建 n 个,O(n log n);小根堆只留 k 个,O(n log k)
✗ 错:堆按「值」比较
✓ 对:堆必须按「频次」比较
我们要淘汰的是频次最低的,比较键是频次不是数值本身
✗ 错:忘了先去重/数频次,直接把原数组怼进堆
✓ 对:先用哈希表数频次,再把 (值,频次) 入堆
同一个值要合并计数,否则频次算错
完整代码(Python / C++ / Java)
Python
import heapq
from collections import Counter
def topKFrequent(nums, k):
cnt = Counter(nums) # 1) 数频次
heap = [] # 最小堆:(频次, 值)
for val, fr in cnt.items():
heapq.heappush(heap, (fr, val))
if len(heap) > k: # 超员
heapq.heappop(heap) # 弹掉频次最小的
return [val for fr, val in heap]C++
vector<int> topKFrequent(vector<int>& nums, int k){
unordered_map<int,int> cnt;
for(int x : nums) cnt[x]++; // 1) 数频次
// 最小堆:pair<频次, 值>
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
for(auto& [val, fr] : cnt){
pq.push({fr, val});
if((int)pq.size() > k) pq.pop(); // 弹掉频次最小的
}
vector<int> ans;
while(!pq.empty()){ ans.push_back(pq.top().second); pq.pop(); }
return ans;
}Java
public int[] topKFrequent(int[] nums, int k){
Map<Integer,Integer> cnt = new HashMap<>();
for(int x : nums) cnt.merge(x, 1, Integer::sum); // 1) 数频次
// 最小堆:按频次升序,堆顶=频次最小
PriorityQueue<int[]> pq =
new PriorityQueue<>((a, b) -> a[1] - b[1]);
for(Map.Entry<Integer,Integer> e : cnt.entrySet()){
pq.offer(new int[]{e.getKey(), e.getValue()});
if(pq.size() > k) pq.poll(); // 弹掉频次最小的
}
int[] ans = new int[k];
for(int i = 0; i < k; i++) ans[i] = pq.poll()[0];
return ans;
}复杂度
时间
O(n log k)
n 个不同元素各做一次 O(log k) 的堆操作
空间
O(n + k)
哈希表 O(n) 存频次,堆 O(k)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 前 K 个高频元素 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
能不能做到 O(n)?+
能,用桶排序:开 n+1 个桶,把元素按频次放进对应下标的桶(频次范围是 1..n),再从高频桶往低频桶倒着取,凑满 k 个即止。建桶 O(n)、收集 O(n),整体 O(n),省掉堆的 log。
如果数据是流式、不能一次性数完频次怎么办?+
频次随流更新,堆里维护「当前频次前 k」会失效(旧频次过期)。实务上用 Count-Min Sketch 近似频次 + Top-K 结构(如 heavy hitters / Space-Saving 算法)做近似前 k。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 前 K 个高频元素 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。