LeetCode 347详解:哈希表+堆/桶排序/快速选择求前K个高频元素 LeetCode 347这道题我面试里遇到过不止一次身边朋友被问到的概率也极高。它表面只要求“统计频率 取前K个”但真正考察的是你对哈希表、堆、桶排序、快速选择这些基础数据结构的理解深度以及面试时能不能把复杂度讲明白。这篇文章我按自己的刷题经验把四种主流解法从思路到代码再到坑点完整拆一遍。1. 题目拆解与核心思路1.1 题目到底在问什么给你一个整数数组nums和一个整数k要求返回出现频率前k高的元素。比如输入nums [1,1,1,2,2,3], k 2输出就是[1,2]因为1出现3次、2出现2次、3只出现1次。答案不要求按频率排序所以[2,1]也算正确。这题有个隐藏前提题目保证答案唯一不用处理频率并列的复杂情况。实际面试中最好主动确认一句“如果频率相同返回哪个都可以吗”这个小动作能让面试官觉得你考虑问题周全。1.2 为什么这道题是面试高频题因为它在“中等难度”里卡得恰到好处。暴力解法一眼就能想到但面试官会一步步追问“能不能优化”“还有没有别的方法”从排序到堆、从桶排序到快速选择每层优化都在考察不同的知识模块。一道题能串起哈希表、优先队列、分治思想、复杂度分析四大块性价比极高。而且这题在很多真实业务场景中都有投影比如从日志里统计 Top 10 错误码、从搜索记录里提取热门关键词、从用户行为中挖掘高频访问页面。算法题背后就是这些实实在在的需求面试官问它一点都不奇怪。2. 暴力解法与基础优化2.1 哈希表 全排序的思路最直觉的做法是两步走先用哈希表统计每个数字的出现次数再把所有键值对按次数从大到小排序取前k个。def topKFrequent(nums, k): count {} for num in nums: count[num] count.get(num, 0) 1 sorted_pairs sorted(count.items(), keylambda x: x[1], reverseTrue) return [pair[0] for pair in sorted_pairs[:k]]这段代码能过题目测试但时间复杂度是O(n log n)空间复杂度O(n)。问题在于我们只需要前k个最大值却把整个数组排了序n log n的排序成本里很大一部分是浪费的。从业务角度理解假如要统计一亿条日志里的 Top 10 错误码为这 10 个结果把一亿条数据全部排序代价明显不合理。2.2 复杂度瓶颈在哪里瓶颈不在哈希统计这一步O(n)是必须花的时间瓶颈在排序。只要涉及全量排序复杂度下限就是O(n log n)。面试官听到你给出这个答案后下一句几乎一定是“能不能做到比O(n log n)更好”标准的追问路径。此时思路要转一个弯维护一个大小为k的容器让容器里始终只保留当前的前k高频元素用“局部有序”替代“全局有序”复杂度就有机会降到O(n log k)甚至O(n)。3. 最优解法一哈希表 小顶堆3.1 为什么是小顶堆而不是大顶堆这是面试中最常推的解法也是最推荐的写法。核心思想是维护一个大小为k的小顶堆遍历哈希表时如果堆里元素不足k个就直接放入如果堆已满当前元素的频率比堆顶大就弹出堆顶、把当前元素放进去。为什么用小顶堆因为我们要留住“最大的 k 个”小顶堆的堆顶是堆里最小的那个。新元素只要比“前 k 名里最弱的那一个”强就有资格进堆把最弱的挤出去。反过来如果用大顶堆堆顶是最大的你没法判断新元素该不该进堆因为你要挤掉的是最小的而大顶堆看不到最小元素。生活类比小顶堆就像一个只招 k 个人的排行榜排在末位的人是“守门员”新选手只有打败守门员才能上位。3.2 完整代码实现import heapq def topKFrequent(nums, k): count {} for num in nums: count[num] count.get(num, 0) 1 heap [] for num, freq in count.items(): if len(heap) k: heapq.heappush(heap, (freq, num)) elif freq heap[0][0]: heapq.heapreplace(heap, (freq, num)) return [pair[1] for pair in heap]这段代码有几个细节值得展开第一堆里存的是(freq, num)二元组。Python 的heapq在比较元组时优先比较第一个元素所以必须把频率放在前面。如果写成(num, freq)堆排序会按哈希表的键排序结果完全错误。第二heapreplace等价于先heappop再heappush但效率更高因为它只需要一次上下调整而pop push需要两次调整。虽然常数因子差异不大但面试时能说出这种细节会显得你写过不少真实代码。第三如果freq heap[0][0]即频率跟堆顶一样大我选择不替换。因为题目保证答案唯一并列情况不需要处理保持堆内元素不变即可。这个边界判断很重要很多人写代码时漏掉“等于”的情况导致堆中出现重复元素。3.3 时间复杂度的准确计算这段代码的时间复杂度是O(n log k)而不是O(n log n)关键在于堆的大小被限制在k。每次堆操作的成本是O(log k)最多处理n个不同的数字实际是哈希表里不重复元素的数量所以总成本O(n log k)。当k远小于n时这个优势非常明显。空间复杂度是O(n)主要由哈希表贡献堆本身只占O(k)额外空间。面试中如果要抠得更细可以补充一句如果只看堆的部分空间是O(k)但整体算法必须统计所有元素频率所以哈希表的O(n)是不可避免的。3.4 堆解法适合什么场景堆解法适合k比较小、数据流式的场景。真实系统中经常用“固定大小的堆”来处理流式数据数据源源不断进来堆始终只保留 Top k不需要把所有历史数据存下来。如果面试官追问“如果数据无限流入怎么办”堆解法就是标准答案的雏形。另外heapq是 Python 内置模块不需要额外依赖刷题和工程化场景都能直接用。这也是我优先推荐堆解法的原因之一。4. 最优解法二桶排序思路4.1 桶排序怎么解决这个问题如果频率分布相对集中也就是数组中不同元素数量不多可以用桶排序把时间复杂度优化到O(n)。思路是创建n 1个桶下标i的桶里放“出现次数正好为 i 的所有元素”。最后从后往前遍历桶依次取出元素直到取满k个。def topKFrequent(nums, k): count {} for num in nums: count[num] count.get(num, 0) 1 buckets [[] for _ in range(len(nums) 1)] for num, freq in count.items(): buckets[freq].append(num) result [] for freq in range(len(buckets) - 1, 0, -1): for num in buckets[freq]: result.append(num) if len(result) k: return result这段代码的时间复杂度统计O(n)建桶O(n)遍历桶最坏情况下O(n)合计O(n)。空间复杂度O(n)。4.2 桶排序的适用边界与代价桶排序的代价是要分配一个长度为n 1的数组不管实际上有多少个不同频率都会占用这么多空间。如果n 10这个开销无所谓如果n 1000万仅桶数组就是 1000 万个列表对象内存消耗可能比堆解法大一个数量级。所以桶排序适用于“频率分布集中”的场景。极端情况下比如数组里只有一两个不同元素桶会非常稀疏此时内存浪费严重。但如果面试中你主动提到“这个解法有额外内存开销适合普通数据量”同时对比堆解法的优劣会显得你对工程细节有把控力。4.3 桶下标的细节对照桶排序的陷阱在于索引对齐频率最低是 1最高是n所以需要n 1个桶。如果某个数字出现n次正好放进下标为n的桶不会越界。很多人在面试时写buckets [[] for _ in range(n)]频率为n的元素会导致IndexError这属于一次性 bug写了就能看出来但现场紧张时容易漏。逆向遍历时要注意频率高的元素优先被收集返回结果天然就是“按频率从高到低排列”的。虽然题目不要求排序但拿到一个有序结果总归是加分项。我刷题时习惯了从后往前遍历写避免遗漏。5. 最优解法三快速选择算法5.1 快速选择的核心思想快速选择是基于快速排序的分区思想每轮选定一个基准把数组分成“基准左边 基准”和“基准右边 基准”两部分。如果基准恰好落在n - k位置说明基准及它右边的元素就是前k大的直接返回即可如果落在左边说明前k大还在右边继续递归右半部分反之递归左半部分。这题的“元素列表”是哈希表里的所有键值对比较依据是频率值。因此不能用原数组的数值直接比较而要先做一次频率统计再在(num, freq)列表上做分区。import random def topKFrequent(nums, k): count {} for num in nums: count[num] count.get(num, 0) 1 items list(count.items()) # [(num, freq), ...] n len(items) target n - k def partition(left, right): pivot_index random.randint(left, right) pivot_freq items[pivot_index][1] items[pivot_index], items[right] items[right], items[pivot_index] store left for i in range(left, right): if items[i][1] pivot_freq: items[store], items[i] items[i], items[store] store 1 items[right], items[store] items[store], items[right] return store left, right 0, n - 1 while True: pos partition(left, right) if pos target: return [item[0] for item in items[pos:]] elif pos target: left pos 1 else: right pos - 15.2 随机化的重要性划重点快速选择一定要加随机化。如果不加随机每次选基准都用固定位置比如最右边当数据接近有序时分区极度不平衡复杂度退化成O(n²)。加了随机化之后期望时间复杂度是O(n)这是一个数学期望值不是最坏情况保证。为什么期望是O(n)因为每次分区期望把问题规模缩小一半左右代价从n降到n/2再降到n/4……加起来是n n/2 n/4 ... 2n所以期望是O(n)。听起来很划算但要注意这是“期望”如果运气不好连续选到最差基准还是会退化。实际编码时用random.randint即可。5.3 快速选择的优缺点对比最大优点是平均线性时间面试官听到你能说出“平均O(n)”通常会眼前一亮。缺点是常数因子偏大随机数生成、多次交换而且代码比堆解法复杂不少现场写容易在 partition 边界上翻车。我的建议是如果面试中时间充裕、思路清晰可以展示快速选择来体现功力如果想稳扎稳打堆解法是更安全的选择。两种都写熟练面试时根据题目难度和现场状态灵活选用。6. 面试沟通与常见坑点总结6.1 面试官最想听到的思维链路最理想的答题流程是先给出暴力解法明确说出它的复杂度然后自己指出“全量排序做了无用功”引出堆解法写完堆解法后主动补充“如果数据量小、内存充足还有桶排序的线性解法”最后可以提一句快速选择作为理论最优解。每一步都有清晰的时间复杂度演进O(n log n) → O(n log k) → O(n)这种递进式回答在面试中非常加分。我见过很多候选人一上来就写堆虽然是对的但面试官无法判断你是真的理解还是背了模板。从暴力解法开始讲反而是展示思考能力的信号。6.2 Python heap 的常见陷阱heapq默认为小顶堆没有直接的大顶堆参数。如果题目想取频率最低的 k 个一个常见技巧是存入(-freq, num)利用取负把小顶堆变成大顶堆。347 题不需要这个技巧但相邻题型 692前 K 个高频单词有类似逻辑值得一并掌握。还要注意heapq只保证堆顶是最小值不保证整个列表有序。如果直接打印 heap顺序会让人疑惑这是正常现象。刷 LeetCode 不需要输出有序结果所以没问题。另一个坑在堆元素不足时没检查堆长度直接比较freq heap[0][0]会报IndexError。这是最简单也最容易犯的错误每次写堆类题目我都要提醒自己先判断堆长度。6.3 实际刷题中的测试用例参考我调试这道题时固定用这几个用例建议你也跑一遍测试输入预期输出说明nums [1], k 1[1]单元素极端情况nums [1,1,1,2,2,3], k 2[1,2]标准情况nums [4,4,4,4], k 1[4]所有元素相同nums [-1,-1,0,0,0,2], k 2[0,-1]含负数验证哈希键值处理正确nums [1,2,3,4], k 4[1,2,3,4]k 等于不重复元素总数堆解法要能装下全部这几个用例覆盖了最大频率、单元素、负数、k 等于全量等边界条件跑通了基本不会有逻辑遗漏。6.4 这个题目怎么横向扩展复习347 题可以串起好几道经典题目215 题“数组中的第K个最大元素”是纯快速选择/堆选择421 题“数组中两个数的最大异或值”也用到前缀树贪心思想692 题“前K个高频单词”则在排序规则上加了字典序要求。我建议集中刷一遍体会不同题目如何复用“统计频率 堆/分区”这个模板。个人体会是347 题最核心的价值不是那道题本身而是让你把“求前K个”这类问题彻底吃透。面试中“求Top K”的变体非常多底层思路通通指向堆或快速选择把这两个工具的复杂度特征和代码模板练熟远比记住单一题目的输出更重要。工具本身不会告诉你用哪个真正决定方案的是数据规模和场景特点这也是为什么我在这篇文章里反复强调复杂度分析的原因。