尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

前K个高频元素全解法:堆、桶排序、快速选择与面试要点

前K个高频元素全解法:堆、桶排序、快速选择与面试要点 1. 这道题在面试中的真实分量为什么“前K个高频元素”值得死磕先聊点实际的。力扣hot100里的“前K个高频元素”题号347是很多人在准备面试时绕不过去的一道题。你可能觉得它就是一道“哈希表计数排序”的简单题但如果你真这么想那这道题的隐藏价值就被你浪费了。这道题表面上问的是“给你一个整数数组返回出现频率前k高的元素”但它真正想考察的东西其实有三层第一层你会不会用哈希表统计频率。这算是基础中的基础但很多人统计的时候不注意细节比如用getOrDefault还是先判空再赋值在数据量大的时候性能差异其实不小。第二层你知不知道“Top-K问题”有专门的解法套路。所谓Top-K就是在一堆数据里找出最大/最小/频率最高/最相关的前K个这类问题在大数据处理、搜索引擎、推荐系统里到处都是。而Top-K问题的核心解法就那么几类排序、堆、快速选择、桶排序。这道题恰好把这几种思路全串起来了。第三层你在做到“最优解”的时候能不能说清楚为什么那个解是最优的。很多人背下了堆的写法但被问到“为什么用堆不用排序”“堆的时间复杂度为什么是O(nlogk)”就卡住了。面试官问这种问题的目的不是考察你的记忆力而是想确认你是真的理解了还是只会抄模板。我见过不少候选人一上来就说“用大顶堆”但仔细一问连堆的大小应该设成k还是n都说不清。这种人往往栽在下一轮的系统设计或者项目深挖上因为这些看似基础的选择题反映的是你平时写代码时有没有养成“先分析再动手”的习惯。这篇文章就把这道题从暴力解到最优解完整拆一遍顺便把我在实际刷题和面试复盘过程中踩过的坑、总结出的判断逻辑都写出来。无论你是刚刷到hot100的新手还是准备冲刺大厂面试的老手这篇都值得你花二十分钟慢慢看。2. 先把所有解法摆在桌面上复杂度与适用场景的横向对比在动手写代码之前我建议你先建立一个全局认知。这道题最常用的解法有四条路线每一条都有自己的脾气暴力解法双重循环统计频率 对频率排序取前k个桶排序思路用“频率”做下标空间换时间堆解法维护一个大小为k的小顶堆是面试中最推荐的方案快速选择QuickSelect基于快排的partition思想理论最优先给一张表把这四种方案的选型逻辑说清楚后面再逐个拆解原理和代码解法时间复杂度空间复杂度适用场景面试推荐度排序法O(nlogn)O(n)数据量小、不追求极致不推荐桶排序O(n)O(n)频率范围可控如整数可作为加分项堆解法O(nlogk)O(n)海量数据、需要在线处理强烈推荐快速选择O(n)平均O(n)无需保持有序结果时进阶加分项这里有个常见误区我得先点一下很多人以为桶排序的O(n)是“最快的”所以面试一定要用桶排序。但问题在于桶排序的O(n)建立在“频率种类有限”的前提下如果频率范围特别大桶数组会浪费大量空间而且最后从桶里收集结果时也要额外遍历。更关键的是桶排序是“原地替换”不了的它在有些场景下比如流式数据根本没法用。而堆解法的O(nlogk)虽然表面上看多了一个log但在k远小于n的时候比如从100万个数字里找前10个logk很小实际运行速度并不会输给桶排序。所以我的建议是面试首选堆解法桶排序作为补充答案展示你思路的开阔度快速选择放到你时间充裕时再研究——它的代码细节最容易翻车不熟练时别在面试中冒险。3. 排序解法最容易想到但一定要知道它慢在哪3.1 排序解法的完整流程先写最朴素的版本。这个版本不需要任何特别的算法知识逻辑很直白public int[] topKFrequent(int[] nums, int k) { // 1. 统计每个数字出现的次数 MapInteger, Integer countMap new HashMap(); for (int num : nums) { countMap.put(num, countMap.getOrDefault(num, 0) 1); } // 2. 把所有key取出来按照频率降序排序 ListInteger keys new ArrayList(countMap.keySet()); keys.sort((a, b) - countMap.get(b) - countMap.get(a)); // 3. 取前k个 int[] result new int[k]; for (int i 0; i k; i) { result[i] keys.get(i); } return result; }这个代码能通过力扣的用例但你要清楚一个问题第2步的排序时间复杂度是O(nlogn)这里的n是“数组中不同元素的个数”。如果数组里所有数字都不同n就是原数组长度排序100万个元素就是实实在在的O(nlogn)不管k是1还是100万排序的代价都是一样的。3.2 排序解法浪费在哪一个很直观的例子假设数组是[1,1,1,1,1,1,1,2,3,4]一共有10个元素但不同元素只有4个。排序只需要对这4个元素排一下代价不大。但如果数组是[1,2,3,4,5,...,1000000]每个数字出现一次k1你要找出出现频率最高的那个。其实随便返回哪个都行因为频率都是1但排序依然要把100万个元素全排一遍这就是纯粹的浪费。再举一个更贴近生活的例子你想知道全班50个人里谁的身高最高正常人的做法是遍历一遍记录最大值O(n)就搞定了。但如果你把全班人按身高从高到低排个序再取第一个人虽然结果一样但你多干了一堆根本不需要干的活。这就是排序法在这道题里的问题它把“求前k个”变成了“求全部的顺序”而后面的排序结果中我们根本用不到第k名之后的任何信息。这不是说排序法一无是处。在数据量小、代码追求简单直接的场景比如写脚本快速验证想法排序法就是最稳的选择。但如果你要在面试中展示数据结构和算法的功底它只能作为“我首先想到的笨办法”抛砖引玉用。4. 哈希表统计频率这一步才是后续所有解法的基础不管最终选哪种高级算法第一步永远是统计频率。这一步看似无脑但有几个细节值得抠一抠。4.1 用getOrDefault还是containsKey性能差异比你想象的大很多人在统计频率的时候习惯这么写MapInteger, Integer countMap new HashMap(); for (int num : nums) { if (countMap.containsKey(num)) { countMap.put(num, countMap.get(num) 1); } else { countMap.put(num, 1); } }每次循环都要先containsKey再getHashMap会执行两次哈希查找。改成getOrDefault就只需要一次MapInteger, Integer countMap new HashMap(); for (int num : nums) { countMap.put(num, countMap.getOrDefault(num, 0) 1); }在LeetCode的测试用例规模下两种写法都能过但getOrDefault更简洁也是JDK 8之后的主流写法平时写代码的时候就应该养成这个习惯。4.2 用HashMap还是Arrays.sort后的相邻计数分情况有一种“投机取巧”的频率统计方式先对数组排序然后遍历排序后的数组统计相邻相同元素的个数。这种做法的时间复杂度是O(nlogn)——排序主导频率统计本身是O(n)。什么时候可以用当你的解法本来就是排序法的时候先排序再统计能省掉HashMap的空间开销。但如果你后面要用堆或快速选择那就别排序了老老实实用HashMap因为排序带来的O(nlogn)时间代价会直接把堆解法的优势抹掉。4.3 从频率统计到“反向索引”这是桶排序的伏笔统计完频率后手里有一份数字 - 频率的映射表。桶排序的思路是把它翻过来以频率为下标把出现次数相同的数字放进同一个桶里。举个例子数组[1,1,2,2,2,3]统计结果是1出现2次2出现3次3出现1次。桶排序会创建一个长度为数组长度1的桶数组因为最高频率不可能超过数组长度然后桶下标1放[3]桶下标2放[1]桶下标3放[2]最后从桶数组末尾往前遍历依次取出数字直到取满k个为止。理解了这个结构后面看桶排序代码就一目了然了。5. 桶排序解法O(n)时间的“空间换时间”方案5.1 桶排序的完整实现public int[] topKFrequent(int[] nums, int k) { MapInteger, Integer countMap new HashMap(); for (int num : nums) { countMap.put(num, countMap.getOrDefault(num, 0) 1); } // 桶数组下标表示频率每个桶是一个列表存放该频率下的数字 ListInteger[] buckets new List[nums.length 1]; for (Map.EntryInteger, Integer entry : countMap.entrySet()) { int frequency entry.getValue(); if (buckets[frequency] null) { buckets[frequency] new ArrayList(); } buckets[frequency].add(entry.getKey()); } // 从高频往低频遍历桶收集前k个 ListInteger result new ArrayList(); for (int i buckets.length - 1; i 0 result.size() k; i--) { if (buckets[i] ! null) { result.addAll(buckets[i]); } } // 转换为int数组 int[] ans new int[k]; for (int i 0; i k; i) { ans[i] result.get(i); } return ans; }5.2 为什么桶排序能到O(n)用一个类比讲明白想象你有一堆试卷老师让你把所有试卷按分数分堆90分以上的放一摞80-89分放一摞以此类推。分完之后你想找到分数最高的10份只需要从最高分那一摞开始拿就行不用把每一摞内部再排序。因为“分数”这个属性天然有范围0-100你可以直接用分数当“抽屉”的标签。这道题的“分数”就是“出现频率”它的范围不会超过数组长度n。所以我们可以开一个长度为n1的数组下标就是频率每个下标对应一个列表。遍历一遍把数字扔进对应的桶再从后往前扫整个过程是O(n)的。5.3 桶排序的局限不是所有Top-K问题都能用桶排序能O(n)核心前提是频率的取值范围是已知且有界的。如果题目改成“给定一个字符串数组返回出现频率前k高的字符串”频率依然不会超过数组长度所以依然能用。但如果问题变成流式数据数据源源不断进来随时需要返回当前的前k个桶就没法用了——因为新数据到来时频率变了你得把元素从一个桶挪到另一个桶这个挪动的代价不可控。另一个隐藏问题是内存。假设数组长度是100万但只有两个不同元素一个出现999999次一个出现1次我们依然要开一个长度100万的桶数组其中99.99%的空间是空的。这就是典型的空间换时间要知道自己在用什么换什么。5.4 力扣测试中桶排序的真实表现实测下来桶排序在LeetCode上确实能跑出非常漂亮的耗时比堆解法快一些原因在于它的时间复杂度是O(n)而堆是O(nlogk)。如果你提交的时候看到桶排序的runtime是5ms堆解法是10ms不用惊讶这是正常的。但我也要提醒一句面试的时候除非面试官问“还有没有更快的解法”否则不建议主动在第一时间抛出桶排序。原因在于桶排序的O(n)看起来惊艳但它依赖“频率范围数组长度”这个条件面试官只要加一句“如果是海量数据且内存紧张呢”你立马就陷入被动。而堆解法的O(nlogk)虽然理论复杂度高一点但它对内存的占用是受控的这在大数据场景下反而是更扎实的答案。6. 堆解法面试官最想看到的“标准答案”6.1 为什么用“小顶堆”而不是“大顶堆”这是最多人栽的坑先说结论求前k个高频元素用的是大小为k的小顶堆。很多初学者一听“前k个高频”直觉反应是“用大顶堆把频率最高的顶上来”。但你想一下大顶堆的堆顶是整个堆里最大的元素如果堆的大小是k那么堆顶就是这k个元素里最大的——每当有新的、更大的元素进来它会把堆顶挤掉但你维持的始终是“当前最大的那k个”吗不是的大顶堆在“淘汰”的时候淘汰的是堆顶最大的这正好反了。正确的逻辑是维护一个大小为k的小顶堆堆顶是这k个元素里最小的。每当扫描到一个新元素如果它的频率比堆顶大就替换掉堆顶并调整堆结构。这样一来堆里始终保存着“到目前为止频率最高的k个”。最后堆顶就是这k个里最小的那个也就是“前k个高频元素”的门槛。这里用Java的PriorityQueue来实现默认就是小顶堆。有一个关键点必须注意PriorityQueue的排序规则要基于频率而不是基于元素本身的值。所以我们在构造PriorityQueue时需要传入一个比较器比较的是countMap.get(a)和countMap.get(b)。6.2 堆解法的完整代码与逐行解读public int[] topKFrequent(int[] nums, int k) { // 第一步统计频率 MapInteger, Integer countMap new HashMap(); for (int num : nums) { countMap.put(num, countMap.getOrDefault(num, 0) 1); } // 第二步构建小顶堆堆里的元素是数字比较规则是它们在countMap中的频率 PriorityQueueInteger heap new PriorityQueue((a, b) - countMap.get(a) - countMap.get(b)); // 第三步遍历所有数字维持堆的大小为k for (int num : countMap.keySet()) { heap.offer(num); if (heap.size() k) { heap.poll(); // 弹出堆顶当前堆中最小的保证堆里是频率最高的k个 } } // 第四步取出堆中的元素放入结果数组 int[] result new int[k]; for (int i 0; i k; i) { result[i] heap.poll(); } return result; }逐行解释一下关键逻辑heap.offer(num)把当前数字放入堆中。如果堆的大小超过了k立刻poll()掉堆顶。因为堆顶是当前堆里频率最小的那个如果堆已经有k1个元素了那这个最小的肯定不属于“前k个高频”该走就走。最后堆里留下的k个元素就是全局出现频率最高的k个数字。这段代码有一个细节值得说为什么遍历的是countMap.keySet()而不是原数组nums因为原数组里同一个数字会出现很多次如果直接遍历原数组同一个数字会被反复offer进堆逻辑就乱了。我们只需要对“每种数字”判断一次keySet正好满足需求。6.3 复杂度分析O(nlogk)到底意味着什么堆解法的时间复杂度由两部分组成统计频率遍历一次数组O(n)遍历keySet并维护大小为k的堆keySet的大小最多是n每次入堆/出堆的调整代价是O(logk)所以总代价是O(nlogk)空间复杂度方面哈希表存了最多n个键值对O(n)堆存了k个元素O(k)所以总空间复杂度是O(n)。你可以对比一下排序法的O(nlogn)当k远小于n时logk和logn的差距就体现出来了。比如n100万k10logk≈3.32logn≈19.93堆解法的耗时差不多只有排序法的六分之一。如果k更小差距更夸张。这就是在“海量数据里找极少数Top”场景下堆解法成为工业界首选的根本原因。6.4 堆解法的工程价值从力扣走向真实系统力扣上写堆解法是为了通过测试而实际工程项目里用堆是为了解决“数据量大到无法全排序”的问题。举个真实的例子假设你在做一个日志分析系统每天产生上亿条日志需要实时统计出现次数最多的前10个错误码。你不可能把所有日志读进内存排个序再取前10内存根本扛不住。但你可以维护一个大小为10的小顶堆一条日志进来更新计数然后跟堆顶比较决定要不要入堆。全程只需要常数的内存而且每个日志的处理是O(log10)≈O(1)这在流式处理的场景下是极其关键的。这也是为什么面试官特别愿意考察堆解法——它不仅是一道算法题更是分布式系统、大数据处理中的基础组件。7. 快速选择QuickSelect理论最快的进阶方案但代码细节处处是坑7.1 快速选择的核心思想快排的“偏科”版本如果你已经熟练掌握了堆解法可以考虑再进一步研究快速选择。快速选择的思路来自快速排序快排每轮会选一个pivot把数组分成小于pivot和大于pivot两部分。如果分完之后pivot的最终位置恰好是第k个位置那左边那部分就是前k个不需要再管右边。基于这个思想我们只需要“单边递归”平均时间复杂度能降到O(n)但最坏情况每次pivot都选到极值会退化到O(n^2)。在这道题里我们不是对原数组做快速选择而是对“数字列表”按频率做快速选择。也就是说先把频率统计进HashMap然后取出所有key放到一个List里对这个List按照频率做划分找到前k个高频数字。7.2 一个可用的快速选择实现public int[] topKFrequent(int[] nums, int k) { MapInteger, Integer countMap new HashMap(); for (int num : nums) { countMap.put(num, countMap.getOrDefault(num, 0) 1); } // 取出所有不重复的数字 int[] unique new int[countMap.size()]; int index 0; for (int num : countMap.keySet()) { unique[index] num; } // 对unique数组按频率做快速选择目标是让高频的前k个数字都位于数组左侧 quickSelect(unique, 0, unique.length - 1, k, countMap); // 取前k个 int[] result new int[k]; for (int i 0; i k; i) { result[i] unique[i]; } return result; } private void quickSelect(int[] nums, int left, int right, int k, MapInteger, Integer countMap) { if (left right) return; // 随机选择pivot规避最坏情况 int pivotIndex left (int)(Math.random() * (right - left 1)); int pivotFreq countMap.get(nums[pivotIndex]); // 交换到最右边 swap(nums, pivotIndex, right); int storeIndex left; for (int i left; i right; i) { if (countMap.get(nums[i]) pivotFreq) { swap(nums, storeIndex, i); storeIndex; } } swap(nums, storeIndex, right); // 此时storeIndex位置左边的元素频率都大于pivotFreq int leftCount storeIndex - left 1; if (leftCount k) { return; } else if (leftCount k) { quickSelect(nums, left, storeIndex - 1, k, countMap); } else { quickSelect(nums, storeIndex 1, right, k - leftCount, countMap); } } private void swap(int[] nums, int i, int j) { int temp nums[i]; nums[i] nums[j]; nums[j] temp; }7.3 快速选择的三个易错点我真踩过第一个易错点pivot的选择。如果你每次都选最左边或最右边的元素作为pivot在已经有序的情况下会退化到O(n^2)。最稳妥的做法是随机选择一个位置作为pivot这也是上面代码里Math.random()存在的意义。面试中如果不方便写随机你至少可以选“中间位置”或者“左中右三数取中”都能明显降低退化概率。第二个易错点边界条件里leftCount的计算。storeIndex是第一个小于等于pivot频率的位置leftCount storeIndex - left 1代表的是“包括pivot在内的左边区域的元素个数”。如果你把1漏了递归的k值就会错位结果自然不对。第三个易错点递归方向的判断。判断完当前位置后要跟k比较如果左边元素个数等于k说明前k个高频已经齐了如果大于k说明第k个还在左边区域递归左边如果小于k说明左边不够要从右边再找剩下的一部分这时候k要减去左边的个数。这个“减去”的操作很容易被忽略。7.4 快速选择对比堆解法时间、空间、稳定性从时间复杂度来看快速选择平均O(n)比堆的O(nlogk)好一点。但在实际运行中快速选择的常数比较大partition过程要多次交换在小数据量下并不一定比堆快。而且快速选择是“一次性”的它要求所有数据都在内存里不像堆那样可以流式处理。空间上快速选择需要把keySet复制到一个数组里额外空间是O(m)m为不同元素个数堆只需要O(k)的额外空间。在m远大于k的场景下堆对内存更友好。稳定性上堆是绝对可靠的时间复杂度不会因为输入数据的分布而变化快速选择则看运气虽然随机pivot能大概率避免最坏情况但理论上的最坏情况依然存在。所以我的建议是面试优先堆快速选择作为“了解的进阶方案”提及即可除非你对它的实现细节有十足的把握。8. 题目变体与边界情况从“能过”到“稳过”的关键8.1 返回顺序算不算错力扣的判定规则要搞清楚力扣这道题的判题逻辑是“集合相等”不要求返回的顺序必须是从高到低排列。也就是说你返回[1,2]或者[2,1]只要集合相同都算通过。但我建议你在代码里还是尽量保持稳定顺序比如从堆顶弹出的顺序或者bucket从后往前遍历的顺序。原因很简单虽然判题不要求但如果你在本地调试时输出顺序混乱很容易让自己误判结果。而且在实际工作中Top-K的结果通常需要按重要程度排序展示保持有序输出是更好的习惯。8.2 几个必须想清楚的边界case我把常见的边界场景列出来大家可以对照自查场景输入示例你的代码能否正确处理数组只有一个元素[1], k1能返回[1]所有元素都相同[1,1,1,1], k1能返回[1]所有元素频率相同[1,2,3,4], k2能但结果可能是任意两个k等于不同元素个数[1,1,2], k2能返回全部空数组[], k0能返回空数组超大kk超过不同元素个数不能会越界关于最后一行我想多说两句。力扣的测试用例保证了k是合法的k的范围是1到不同元素个数但你在面试中最好主动问一句“k会不会超过不同元素的数量”这不只是在确认边界条件更是让面试官看到你思考问题的严谨性。同样如果数组为空有人会直接返回空数组也有人觉得题目默认不出现这种情况。我建议宁可多写一个防御性判断也不要冒险。8.3 频率相同的元素怎么取舍这个话题值得跟面试官聊聊当出现频率相同的元素时比如[1,1,2,2,3]k2正确答案可以是[1,2]、[2,1]甚至是[1,2]或[2,1]以外的组合吗不可以因为频率最高的两个元素就是1和2第三个元素3的频率只有1进不了前二。但如果数组是[1,1,2,2,3,3]k2三个元素频率都是2这时候正确答案可以是任意两个元素。这个细节我在面试中被追问过“如果有大量元素频率相同你的堆会怎么选”当频率相同而堆容量超了堆会随机淘汰一个取决于PriorityQueue内部比较器的返回值如果返回0则不会替换。力扣的测试用例通常不会对这种场景做严格校验但你自己心里要有数当比较器返回0时新元素是否替换旧元素决定了结果的稳定性。如果你希望结果在频率相同时依然稳定可以在比较器里加一个“平局决胜”规则比如数字小的优先PriorityQueueInteger heap new PriorityQueue((a, b) - { int freqCompare countMap.get(a) - countMap.get(b); return freqCompare ! 0 ? freqCompare : a - b; });当然这样就等于额外维护了内部顺序代价是在比较时多执行了一次减法但对结果可预期的好处是实打实的。8.4 Java的Integer比较陷阱为什么不能随便减在堆的比较器里countMap.get(a) - countMap.get(b)是有隐患的。如果两个频率分别是Integer.MAX_VALUE和Integer.MIN_VALUE相减会溢出变成负数等于比较器给出了完全相反的顺序。大部分刷题场景不会触发这个bug但在面试中面试官可能会拿这种极端情况试探你。稳妥的写法是用Integer.compare(countMap.get(a), countMap.get(b))它内部做了防止溢出的处理PriorityQueueInteger heap new PriorityQueue( (a, b) - Integer.compare(countMap.get(a), countMap.get(b)) );这个习惯不只在力扣有用日常写业务代码时凡是涉及大整数的比较都应该想到溢出问题。你说它是“理论上的坑”也行但我觉得能写出防御性代码本身就是工程经验的一部分。9. 从这道题延伸出去的“Top-K”问题全家桶9.1 同类题目与套路识别“前K个高频元素”是Top-K问题的一个切片。同一个套路还可以套在不少题目上“数组中的第K个最大元素”力扣215完全相同的思路堆解法维护大小为k的小顶堆快排思路用快速选择。“前K个高频单词”力扣692把整数换成字符串比较器里多一个字典序的平局规则。“数据流中的第K大元素”力扣703典型的流式数据场景只能用堆。海量数据中的“最热门搜索词”工程题本质上就是Top-K的分布式版本。你会发现识别这类题目的关键信号有两个一个是有“前K个”或“第K个”这种字眼另一个是要求输出“最大”“最小”“最频繁”之类的最值。一旦识别出来你就先在脑海里过一遍能不能用堆能不能桶数据量有多大内存够不够放是否需要流式处理这样一套“组合拳”打下来代码还没写解决方案的雏形已经有了。9.2 一题多解的价值不是炫技而是建立“算法嗅觉”有人会觉得我掌握堆解法就够了桶排序、快速选择了解个名字就行。但我的实际体会是一题多解的价值在于帮你建立“算法嗅觉”——当你看到一道新题能快速判断用哪种数据结构和算法靠的不是背模板而是脑子里确实积累过多种解法的适用边界。比如你看一道题发现数据范围特别大但取值范围又很有限你自然会想到桶排序比如你发现数据是源源不断进来的你自然会排除快速选择和排序直奔堆比如你发现内存特别吃紧你自然会考虑快速选择的O(1)原地划分。这些判断能力都是从“同一道题反复换解法”中练出来的。9.3 刷题节奏建议这道题应该花多长时间以我的经验这道题值得你分三步走第一步用排序法通过目的是熟悉题目和哈希表统计频率的基本功大概15分钟。 第二步用堆解法通过重点理解“为什么是小顶堆”和“为什么复杂度是O(nlogk)”大概30分钟。 第三步如果还有余力用桶排序和快速选择各实现一次重点感受它们的代码细节和边界情况大概60分钟。这三步走完你对这道题的理解会比只抄一遍堆解法的人深得多。下次面试再遇到你能不仅写出代码还能主动跟面试官分析几种方案的取舍。10. 我在刷这道题时踩过的坑一段真实的排错回忆最后分享一个我自己当年的真实经历可能是这篇文章里最有“人味儿”的部分。我第一次做这道题用的就是堆解法代码写完一跑LeetCode直接给了一个Wrong Answer。我当时很困惑逻辑明明没错啊。后来加了打印才发现问题出在PriorityQueue的初始化上。我当时写的代码是PriorityQueueInteger heap new PriorityQueue();这是默认的小顶堆但它比较的是元素本身的值。打个比方如果数组是[5,5,5,1,1,2]统计完频率后5的频率是31的频率是22的频率是1。默认的小顶堆会把1和2这些数字本身大小当作排序依据而不是按频率排序。结果堆顶弹出的不是我想要的“频率最小的元素”而是“数值最小的元素”。整个逻辑全乱了。这个错误特别典型不是算法思路的问题而是API使用不当。PriorityQueue默认按元素的自然顺序也就是Comparable接口排序如果元素是整数就直接比大小如果我们想按Map里的frequency排序就必须显式传入比较器。很多新手栽在这就是因为没意识到“堆里存的是数字但排序依据是频率”这两者之间的偏差。另一个让我印象更深的坑是我在遍历countMap.keySet()的时候顺手在循环里修改了countMap。我当时的想法是“处理完一个数字就把它从Map里删掉避免重复处理”结果直接抛出了ConcurrentModificationException。这是一个非常经典的Java集合错误——迭代时修改集合结构不是线程并发才会触发单线程也会触发。正确做法是循环结束后集中处理或者用Iterator的remove()方法而不是在for-each里直接map.remove()。这两个坑都不算难但如果你自己踩一遍会比看十遍文档记得更牢。这也是我建议大家刷题时不要只追求“Accepted”要多关注异常场景和边界情况的原因——你踩过的每个坑将来都可能成为面试里“讲出独特经验”的素材。回过头来看“前K个高频元素”这道题代码量最多的版本也不到50行但它牵涉到的知识点横跨哈希表、堆、排序、快速选择、Java集合框架、复杂度分析甚至还有溢出风险和并发修改异常。这也是它能进hot100的原因一道题浓缩了太多东西值得反复咀嚼。
返回列表