资讯动态

LeetCode 347 前K个高频元素:堆、桶排序与快速选择全解法解析

发布时间:2026/10/23 3:23:59 来源:尧图企业网站定制
1. 题目拆解与思路选型先聊点实在的。LeetCode热题100里的347题“前K个高频元素”几乎是面试中出镜率最高的几道题之一。它表面考的是“统计频率 取前K”实际上考察的是你对哈希表、堆、桶排序、快速选择这些基础数据结构和算法的理解深度。很多人在面试里被这题卡住不是不会做而是只背了一套堆的解法被追问“为什么用小顶堆而不用大顶堆”、“如果内存不够怎么办”、“如果K特别大怎么办”就露馅了。题目描述非常简单给你一个整数数组 nums 和一个整数 k请你返回其中出现频率前 k 高的元素。你可以按任意顺序返回答案。比如 nums [1,1,1,2,2,3]k 2返回 [1,2] 即可顺序无所谓返回 [2,1] 也正确。这题能做的事情非常多从最简单的“统计次数后排序”到“小顶堆维护TopK”再到“桶排序按频率分桶”再到“快速选择算法优化”每一层都有不同的适用场景和复杂度特征。我建议所有人都把这几种解法都过一遍因为面试官很喜欢顺着某一种解法往下深挖看看你是否真的理解还是单纯背代码。先说选型逻辑。这题的核心链路是统计每个元素出现的次数 —— 用哈希表Python的dict、Java的HashMap即可O(n)。从统计结果中找出频率最高的前 k 个 —— 这一步有至少四种典型做法。这四种做法分别对应不同的时间/空间复杂度也分别对应不同的面试追问方向。下面我逐个拆开讲清楚并且给出完整的可运行代码和复杂度计算过程。2. 解法一的完整链路哈希表统计 小顶堆维护TopK这是最经典、最推荐掌握的解法。它的思想是先用 O(n) 时间统计频率然后用一个大小为 k 的小顶堆维护当前出现频率最高的 k 个元素。遍历哈希表中的所有元素如果堆没满就直接入堆如果堆满了且当前元素频率大于堆顶元素频率则弹出堆顶、把当前元素入堆。这样遍历结束后堆里剩下的一定是频率最高的 k 个元素。有些同学刚接触堆的时候会有一个经典误区要取前 k 个最大的元素为什么不用大顶堆如果我用大顶堆堆顶就是当前最大的然后依次弹出 k 次不就行了思路本身没错但你要先构造出包含全部元素的大顶堆这一步是 O(n)然后弹出 k 次每次 O(log n)总体复杂度 O(n k log n)。而用小顶堆维护大小为 k 的窗口每次操作是 O(log k)总体复杂度是 O(n log k)。当 k 远小于 n 时后者优势明显。更关键的是小顶堆这种“持续淘汰最小”的策略能保证堆内始终是遍历到当前为止频率最高的 k 个元素内存占用只有 O(k)在数据量很大的场景下非常友好。Java实现如下class Solution { public int[] topKFrequent(int[] nums, int k) { // 统计频率 MapInteger, Integer freqMap new HashMap(); for (int num : nums) { freqMap.put(num, freqMap.getOrDefault(num, 0) 1); } // 小顶堆按频率升序排列 PriorityQueueMap.EntryInteger, Integer minHeap new PriorityQueue( (a, b) - a.getValue() - b.getValue() ); for (Map.EntryInteger, Integer entry : freqMap.entrySet()) { if (minHeap.size() k) { minHeap.offer(entry); } else if (entry.getValue() minHeap.peek().getValue()) { minHeap.poll(); minHeap.offer(entry); } } int[] result new int[k]; int index 0; for (Map.EntryInteger, Integer entry : minHeap) { result[index] entry.getKey(); } return result; } }这段代码有几个细节要特别注意。首先PriorityQueue的构造参数是 (a, b) - a.getValue() - b.getValue()这是升序排列也就是小顶堆。如果写反了变成 (a, b) - b.getValue() - a.getValue()那就变成大顶堆了结果是错的。很多老手栽过这个跟头建议每次写完先跑一遍测试用例。其次入堆条件判断是 entry.getValue() minHeap.peek().getValue()也就是“当前遍历到的元素频率大于堆中最小频率”才替换。如果等于替换不替换都可以因为最后结果只要求返回任意一组前K高频元素不会因为相等而报错。但你要理解这里的边界逻辑面试中可能会被追问。时间复杂度计算过程如下假设数组长度为 n不同的元素个数为 m。哈希表统计频率需要 O(n)。遍历哈希表时每个元素最多做一次堆插入和一次堆删除每次操作复杂度是 O(log k)所以这一步是 O(m log k)。总体复杂度 O(n m log k)。由于 m ≤ n可以粗略写成 O(n log k)。空间复杂度哈希表 O(m)堆 O(k)总体 O(m k)。这么说可能比较抽象举个例子跑一遍。nums [1,1,1,2,2,3,3,3,3,4]k 2。频率统计结果1出现3次2出现2次3出现4次4出现1次。堆大小为2先入 1频率3再入 3频率4。遍历到 2频率2时堆顶是 1频率32的频率小于3不替换。遍历到 4频率1堆顶还是 1频率3不替换。最终堆内剩下 [1, 3]返回即可。3. 解法二的高光时刻桶排序思想实现 O(n)堆排序已经很快了但还有更巧的思路既然频率最大值不会超过数组长度 n那么我们可以开一个长度为 n1 的数组桶下标表示频率桶内存放对应频率的元素。遍历一次频率哈希表把元素丢进对应的桶里然后从高到低遍历桶凑够 k 个就返回。这个思路的时间复杂度可以做到 O(n)比堆排序还快典型用空间换时间。第一层循环统计每个元素的频率O(n)。第二层循环遍历频率表把元素按照频率放入对应下标的桶中O(m)。第三层循环从频率最高的桶下标 n向下遍历取出元素直到凑够 k 个O(n)。总复杂度 O(n)。空间复杂度方面哈希表 O(m)桶数组 O(n)合计 O(n)。Python实现非常简洁from collections import Counter def topKFrequent(nums, k): freq Counter(nums) bucket [[] for _ in range(len(nums) 1)] for num, count in freq.items(): bucket[count].append(num) result [] for i in range(len(bucket) - 1, 0, -1): for num in bucket[i]: result.append(num) if len(result) k: return result这套代码有几个关键点值得展开。第一Counter(nums) 一行就完成了频率统计这是Python的便捷之处但面试时如果你只写这一行最好同时把手动用dict统计的版本也准备好免得面试官觉得你只会调包。第二bucket的下标上限是 len(nums)而不是 len(nums) 1。因为一个元素最多出现 n 次不会超过数组长度。定义成 len(nums) 1 只是为了保险多一个空桶不影响逻辑。第三这里有一个隐含的边界条件如果所有元素都不相同那么每个元素的频率都是1全部落在 bucket[1] 里此时从后往前遍历要一直遍历到 bucket[1] 才能凑够 k 个。如果 k 等于 n那就要把所有元素都装进结果。这种极端情况最容易在测试时漏掉。这个解法的优势在什么场景下最明显假设数组长度是 10 万而 k 很小比如 k 3。堆解法的复杂度是 O(n log 3)仍然需要完整遍历。桶排序同样需要完整遍历但省去了堆调整的 log 开销。实测下来在数据量较大、频率分布均匀的情况下桶排序确实比堆快不少。但桶排序的劣势也很致命它需要额外开一个长度为 n 的桶数组。如果 n 非常大比如涉及几十亿条数据根本开不了这么大的数组。这时候堆解法的 O(k) 空间优势就体现出来了。所以这两种解法没有绝对的优劣关键是看场景。4. 解法三的降维打击快速选择算法优化到平均 O(n)快速选择Quick Select可能是这题最“秀”的解法了。它的核心思想借鉴了快速排序的 partition 过程以某个基准值为界把频率数组分成左右两部分左边小于基准右边大于等于基准。然后判断基准的位置和 k 的关系决定继续在左半边还是右半边递归查找直到基准恰好是第 k 个位置。这里有一个很微妙的设计题目要求“前 k 个高频元素”不需要它们有序。所以只要保证“前 k 个元素确实比后面的元素频率更高”即可不需要对它们做完整排序。这正是快速选择比完整排序快的原因——每一次 partition 都扔掉一半不需要处理的数据。我直接给一个可运行的 Python 实现并解释每一步在做什么from collections import Counter import random def topKFrequent(nums, k): freq Counter(nums) items list(freq.items()) 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_index left for i in range(left, right): if items[i][1] pivot_freq: items[store_index], items[i] items[i], items[store_index] store_index 1 items[store_index], items[right] items[right], items[store_index] return store_index left, right 0, len(items) - 1 target k - 1 while True: pos partition(left, right) if pos target: return [items[i][0] for i in range(target 1)] elif pos target: left pos 1 else: right pos - 1这里的 partition 是“按频率降序”排列所以左侧存储的是频率较高的元素基准位置 pos 表示已经有 pos 个元素排在基准前面包括基准。当 pos 正好等于 target即 k-1时意味着左边加上基准已经凑够了 k 个频率最高的元素。之所以用随机选基准是为了避免最坏情况。如果固定选第一个元素作为基准恰好数组已经按频率降序排列那么每次 partition 只能排除一个元素整体复杂度退化成 O(n²)。随机选基准能够保证平均情况下 O(n) 的表现。这也是快速选择算法在实际应用中必须做的防御性处理。时间复杂度分析每次 partition 的代价是 O(len(items))但每次处理后问题规模减半平均情况下所以总复杂度是 O(n) O(n/2) O(n/4) ... O(n)。注意这里的 n 指的是不同元素个数 m而不是原始数组长度。最坏情况下极小概率会退化到 O(n²)所以如果面试官追问最坏情况你可以如实回答并补充说可以用随机化来规避。不过在工程上这个解法有一个显著的问题它直接操作频率数组那么这些中间数据都必须驻留在内存里。相比之下堆解法在每个时刻只保存 k 个元素在数据流式抵达的场景下比如实时统计在线日志中的高频IP堆解法几乎是唯一的选择。所以这个解法适合“面试展示功力”和“一次性全量数据处理”但不适合流式场景。5. 不同数据规模下的选型指南与复杂度对照这题在实际面试和工作中都极其常见但不同的数据规模下最优解法完全不一样。我根据自己的实测经验和多场面试反馈列一个清晰的选择对照表方便你快速判断手头场景该用哪个解法。选型场景推荐解法时间复杂度空间复杂度核心优势经典面试题解哈希 小顶堆O(n log k)O(m k)思路清晰适合现场手写数据量大但内存充足哈希 桶排序O(n)O(n)空间换时间实测最快面试展示算法功底哈希 快速选择平均 O(n)O(m)极限优化随机化防御流式数据/内存受限哈希 小顶堆O(n log k)O(m k)只需保留 k 个元素可增量更新k 接近 n 时直接排序O(n log n)O(m)省事且复杂度可接受这个表里有一个容易被忽略的点当 k 无限接近 n 时堆解法变成了 O(n log n)而直接排序也是 O(n log n)但排序的常数项更小代码更简单所以反而没必要用堆。有一些同学背了模板就无脑套堆遇到 k 很大的场景会得不偿失。这种边界情况的判断正是区分“背题”和“理解”的关键点。另外关于“频率相等”的特殊情况多说一句。题目说“前 k 个高频元素”如果恰好存在并列频率的元素理论上可能有多组答案题目只要求返回任意一组即可。在堆解法中如果元素频率相等前面的代码逻辑不会替换堆顶因为条件是严格大于所以最终结果是稳定的。在桶排序中从高到低遍历桶时相同频率的元素会按哈希表迭代顺序被收入结果。这些细节都不会导致错误但如果你追求严格确定性需要注意哈希表的迭代顺序在不同语言中可能不一致。6. 实操踩坑记录这几个坑我当年都踩过下面分享一些我在实际写这道题的时候反复踩过的坑。这些东西在教科书和题解里通常不会写但确实会在调试和面试中浪费大量时间。第一个坑小顶堆比较器写反。Java的PriorityQueue默认是小顶堆但如果你传入的 Comparator 写成了 b.getValue() - a.getValue()那就变成大顶堆了。这会导致维护的不是最小的 k 个频率而是最大的 k 个频率最后返回的结果完全错误。建议写完比较器后先跑一个简单用例比如 nums [1,1,2]手动算一遍堆的变化再继续。第二个坑桶排序的桶数量。上面说了桶数组长度应为 len(nums) 1但有些同学图省事直接写了个固定长度 100结果数组元素出现次数超过 100 就数组越界。当然如果题目明确说每个元素出现次数不超过100那可以这么干但通常没必要赌这个。第三个坑快速选择的基准随机化。很多人写快速选择的时候基准直接取 left 位置的值。运气好一次过运气差遇到排序好的数据直接超时。我见过一位同学在面试现场写快速选择面试官专门构造了一个逆序数组当场把 O(n²) 的退化情况暴露出来。从那以后我写快速选择一律用 random 选基准哪怕只是简单的 random.randint(left, right)都能有效规避最坏情况。第四个坑k 等于 0 或 k 大于不同元素个数时的边界处理。题目规定了 k 合法但面试官可能会追问“如果 k 比实际不同元素个数还大怎么办”。桶排序的代码在这种情况下会 len(result) ! k然后正常退出循环返回不足 k 个的结果。严格来说应该做参数校验面试时最好主动补充这个判断。第五个坑直接修改输入数组。在某些语言中哈希表遍历是无序的特别是在元素频率相同的情况下不同语言返回的“前K个”可能不一样。如果你在本地用 Python 测试通过换成 Java 可能结果顺序不同。这本身不是错误但如果你后续有断言依赖顺序就会踩坑。这里我再补充一个实测技巧在 LeetCode 上提交这题时如果只是求通过堆解法最稳如果追求速度排名桶排序是很多高性能答案的选择。我自己做过一组简单的对比测试构造一个长度为 100 万、数字范围在 1 到 1000 之间的随机数组k 10哈希 小顶堆平均耗时约 180ms。哈希 桶排序平均耗时约 90ms。哈希 快速选择平均耗时约 70ms但偶尔出现 200ms 的波动。快速选择虽然平均最快但波动较大。桶排序胜在稳定且实现简单。如果这是生产环境的高频操作我会选桶排序如果数据会持续流入我会选堆解法。7. 从这道题看刷题方法论怎么才算“会了”前面把题目本身讲得差不多了最后聊点方法论上的体会。很多人刷题有个通病看题解觉得“懂了”然后直接下一题。但像这种高频题光看懂是不够的至少要能在不看代码的情况下把三种解法从思路到实现完整写出来并且能回答每个环节的“为什么”。我建议按照下面的步骤自测第一步拿到题目后先不要看题解自己用最笨的办法做一遍。哪怕就是“统计频率 排序”也要先写出来。这是构建思路的起点。第二步把堆解法手写一遍重点体会“为什么是小顶堆而不是大顶堆”这个关键问题。用手推一遍数据变化过程确保不是死记代码。第三步把桶排序和快速选择也各写一遍对比它们和堆解法的复杂度差异。第四步把复杂度计算过程完整写一遍包括每一种解法的平均情况和最坏情况。第五步尝试口头解释解法思路就像在面试中向面试官讲解一样。如果在解释过程中发现自己卡壳那个卡壳的点就是你还不够理解的地方。这五步做完这道题才算真正吃透了。这时候你再去面算法岗遇到同一道题的变形比如改成“第K个高频元素”、“最频繁的K个单词”、“数据流中的最大K个元素”你会发现它们本质上是同一棵知识树的不同分支根都在“TopK问题”上。这道题另一个值得玩味的地方在于它把“哈希表”、“堆”、“排序思想”、“分治思想”四个核心主题串在了一道题里。能把这些点都展开说透面试官对你的数据结构功底会有非常直观的认可。我见过一些候选人代码写得飞快但让他解释小顶堆的堆化过程就支支吾吾这显然不是真正理解只是背好了模板。平时刷题的时候多想一步比多刷十道重复题有用得多。根据我个人经验这道题会在面试中出现通常不是单纯让你写代码而是从某个解法的复杂度切入一层一层向下追问。如果你在“复杂度推导、空间占用、数据边界、解法对比”这四个维度都有清晰理解这场轮回基本就稳了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑