ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

数组元素频次统计与升序输出:三种语言实现详解

数组元素频次统计与升序输出:三种语言实现详解 这个题目我太熟悉了几乎每家做数据业务的公司都会遇到类似需求笔试面试里也经常拿它当“热身题”来筛人。名字直白得很“统计数组中出现指定次数的元素并且升序输出”看着简单真写起来却能看出一个人对数据结构、排序原理和边界处理的基本功扎不扎实。这篇文章先把这个题目拆成三个阶段讲清楚每步为什么这么做再把 JavaScript、Python、C 三种主流语言的完整代码摆出来最后把边界情况、性能分析和扩展场景拉通讲一遍。新手可以照着代码一步步跑有经验的朋友可以重点看第 5 节的问题排查和第 6 节的应用扩展那里有不少我在实际项目里踩过的坑。1. 需求拆解与整体思路1.1 题目到底在问什么先把题目拆开。“统计数组中出现指定次数的元素并且升序输出”核心动作有三个统计、过滤、排序。第一统计。遍历整个数组算出每个元素分别出现了几次。比如数组是[3, 1, 4, 1, 5]统计结果就是 1 出现 2 次、3 出现 1 次、4 出现 1 次、5 出现 1 次。第二过滤。设一个指定次数 target比如 target 等于 2那就只保留出现次数恰好等于 2 的元素。上面的例子过滤完就只剩 1。第三排序。把过滤出来的元素按元素本身的大小升序排列然后输出。这里有两个特别容易理解偏的地方先说破。第一个误区是把“升序输出”理解成“按出现次数升序”。题目要的是元素值升序不是计数值升序。比如元素是 8、2、10升序结果就是 2、8、10。如果你把次数 1、2、3 拉出去排序那方向就完全错了。第二个误区是“出现指定次数”的理解。绝大多数情况它指“恰好出现指定次数”也就是说出现次数必须等于 target。如果题目写的是“至少出现 k 次”那是另外一个过滤器判断条件从改成就行。后面第 6 节我会专门讲这个变体。1.2 为什么“统计、过滤、排序”的顺序最舒服有人会问我能不能先排序再统计确实能。排序之后相同的元素都会聚在一起这时只要扫一遍就能数出每段连续元素的长短从而实现统计。这种做法的代码写出来也不复杂Python 里用双指针就能搞掂。但先排序再统计有一个明显代价排序本身是 O(n log n)。假设数组长度是 10 万排序要花的时间比一次单纯遍历高一个量级。如果你做这道题只是为了统计频率先排序相当于多交了额外的时间税。而“统计、过滤、排序”每一步只负责一件事。先用哈希表把频率统计出来这一步只遍历原数组一遍时间复杂度 O(n)。然后从哈希表里筛选符合条件的键最后只对筛选出来的少量元素排序。每一步都清晰可控写完哪天要改需求也好下手。比如过滤条件从“恰好等于 target”改成“小于等于 target”只需要动一行。生活里的类比也很好理解你想知道班里哪些同学恰好借了两本书肯定先各人数一遍再去找借了两本的人最后按学号排。谁也不会为了做这个先把全班按学号排一遍再一本一本记录。1.3 动手前先看清数据规模题目描述里通常会写数组长度范围这是选方案最重要的一把尺子。有些输入只有几百个元素怎么写都行有些输入能到几十万甚至百万级这时候代码的复杂度直接决定能不能跑完。最典型的一个坏写法是每遍历到一个元素就用 indexOf 或 includes 去查它之前有没有出现过以此实现“统计 去重”。这种写法在数组只有几十个元素时感受不到问题到了几万以上时间复杂度是 O(n²)跑起来会慢到让人怀疑程序卡死了。我个人的习惯是拿到这类题目先把数据规模划出来普通量级用哈希表中等量级也还够只有范围很窄的整数才考虑用数组当计数器。后面第 2 节会对这几种方案展开讲。2. 统计出现次数的三种主流方案2.1 哈希表计数最通用也最稳统计出现次数绝大多数场景都该用哈希表。它的核心思路是拿一个字典结构键是数组元素值是出现次数遍历数组一遍每次遇到元素就把值加一。JavaScript 里我强烈建议用 Map而不是普通对象当计数器const counter new Map(); for (const item of arr) { counter.set(item, (counter.get(item) || 0) 1); }这里不建议counter[item]这种写法。普通对象有原型链如果数组里恰好有toString、__proto__这种特殊字符串访问counter[item]时很可能命中原型上的属性计数就乱了。Map 没有这类问题键可以是任意类型数字、字符串、对象都行而且 Map 会保留插入顺序这在某些场景下还能帮你省点事。统计完成后counter中每个 key 对应原数组中的元素value 就是出现次数。因为同一个 key 只存在一份这一步天然完成了去重后面从 key 取元素时不会重复。如果你用 Python可以直接用collections.Counterfrom collections import Counter counter Counter(arr)Counter 本质还是哈希表但把累加逻辑封装好了代码更简洁。C 里则用std::unordered_mapstd::unordered_mapint, int counter; for (int num : arr) { counter[num]; }总之哈希表统计是这条链路里最核心的一环后续过滤和排序都建立在它之上。2.2 排序后统计空间敏感场景的替代方案先排序再统计的思路是另一种完全可行但通常不是最优的方案。数组排序后相同元素一定相邻。这时候用一个指针从左往右扫扫到相同的一整段就数长度数完再跳到下一段。arr.sort() result [] i 0 n len(arr) while i n: j i while j n and arr[j] arr[i]: j 1 count j - i if count target: result.append(arr[i]) i j这段代码里关键的是内层 while。它不断把 j 往前挪直到遇到不同的元素这样一段相同元素只需要处理一次。如果你不小心把内层循环写成每次都从当前位置往后遍历时间又会退化到 O(n²)。这种方案的优点是不需要额外哈希表空间占用很低。缺点是必须先接受排序的成本。它比较适合“原数组可以被修改”的场景。如果原数组不能动需要先复制一份那空间优势也没有了。所以只有在内存极其敏感、输入规模又不是特别大的时候我才会优先考虑它。2.3 桶计数范围受限时的极速方案桶计数的思路很朴素如果元素是整数而且取值范围不算宽就直接开一个数组下标就是元素值数组存的值就是计数。// 假设元素范围是 0 到 maxVal const counts new Array(maxVal 1).fill(0); for (const num of arr) { counts[num]; }完成统计后只要遍历 counts 数组看哪些下标对应的计数等于 target这些下标就是符合条件的元素。因为下标天然有序这一步连最后排序都省了一举两得。桶计时空前是因为它的额外空间取决于取值范围跨度。假如最大值是 1 亿就得开一个长度 1 亿的数组内存是个大问题。所以桶计数只适合成绩统计、状态码统计、年龄段统计这类范围明确且可控的场景。遇到浮点数、字符串、对象桶计数就不适用了。2.4 三种方案怎么选一张表看明白方案时间复杂度额外空间适用场景明显缺点哈希表计数O(n)O(m)元素类型任意适用范围广需要额外内存存哈希表排序后统计O(n log n)O(1) 或 O(n)数组可原地修改内存敏感必须先付出排序成本桶计数O(n)O(k)整数范围已知且有限取值范围过大时内存爆炸这里的 m 是数组中不同元素的个数k 是取值范围的跨度。日常工程里哈希表方案最稳妥值得默认选择。桶计数虽然最快限制条件太苛刻。排序后统计一般只有当原数组允许被修改并且你不想开额外哈希表时才考虑。3. 过滤与升序排序的细节3.1 按指定次数过滤边界值别漏统计表拿到手过滤就非常简单了。用 Map 遍历const target 3; const result []; for (const [key, count] of counter) { if (count target) { result.push(key); } }但 target 的不同取值会造成几个容易漏掉的边界情况。第一target 等于 1。这时所有只出现一次的元素都会进结果。如果数组里每个元素都唯一结果就是整个排序后的数组。第二target 等于 0。没有任何元素会“恰好出现 0 次”所以结果必然是空数组。有些判题用例专门用 target 为 0 来测试你有没有写空数组的兜底。第三target 大于数组长度。同样不可能有元素出现这么多次结果为空。如果需求是“至少出现 target 次”把 target改成 target其余代码不动即可。别小看这一行很多面试题的变体就是把“恰好”和“至少”互换用来考察你读题是否仔细。3.2 升序排序两个高频坑必须说透排序列进坑的人实在太多我单独拎出来讲。第一个坑是 JavaScript 的默认排序机制。Array.prototype.sort()默认会把每个元素转成字符串再按字符串的编码顺序比较。所以[10, 9, 2].sort()得到的是[10, 2, 9]因为字符串 “10” 排在 “2” 前面。这不叫 bug是规范就是这么定的。数字升序必须显式传比较函数result.sort((a, b) a - b);比较函数返回负数表示 a 排前面正数表示 b 排前面0 表示相等。数值减法能得到正确结果但要注意如果数组里混进了非数字减法可能算成 NaN排序结果就乱了。第二个坑是中文和其他语言字符串的排序。JavaScript 默认按 Unicode 码点排序中文的码点顺序跟拼音完全不是一回事。想按拼音排就得启用本地化比较result.sort((a, b) a.localeCompare(b, zh-Hans-CN));Python 里则要留意直接对中文字符串列表调用sort()也是按 Unicode 码点排。如果题目要求按拼音需要引入第三方库或者自己构建拼音映射难度完全是另一个级别。所以在动手前先确认题目的排序规则到底按什么。3.3 结果天然去重特殊情况要小心因为结果是从计数表的 key 里取出来的key 天然唯一所以理论上不需要额外做去重。这是这个题目比“数组去重”类题目简单的地方。但有一个特殊场景容易翻车数组元素是对象。如果你直接把对象塞进 Map每个对象引用都是独立的 key就算两个对象外观完全一样也不会被合并。比如const arr [{ id: 1 }, { id: 1 }]; const counter new Map(); for (const item of arr) { counter.set(item, (counter.get(item) || 0) 1); }这里统计出来两个对象各出现 1 次没法合并成“id 为 1 的对象出现 2 次”。想按业务逻辑统计必须手动取字段作为 key比如item.id。完整示例放在第 6 节这里先记住思路对象数组统计前先确认“相同”的标准是什么。4. 主流语言完整实现与示例验证4.1 JavaScript 完整代码与逐步验证把三阶段拼起来就是完整的函数function filterByFrequency(arr, target) { // 第一步统计出现次数 const counter new Map(); for (const item of arr) { counter.set(item, (counter.get(item) || 0) 1); } // 第二步按指定次数过滤 const result []; for (const [item, count] of counter) { if (count target) { result.push(item); } } // 第三步升序排序 if (result.length 0 typeof result[0] number) { result.sort((a, b) a - b); } else { result.sort(); } return result; } const arr [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]; console.log(filterByFrequency(arr, 2));现在手动验算一下数组里 1 出现 2 次2 出现 1 次3 出现 2 次4 出现 1 次5 出现 3 次6 出现 1 次9 出现 1 次。target 是 2符合条件的元素只有 1 和 3。升序排序后结果就是[1, 3]。需要说明的是排序前我判断了结果数组中第一个元素的类型。如果元素是数字走数值比较函数如果是字符串走默认字典序。这种处理能让函数稍微通用一点。如果题目明确只发数字数组那直接写result.sort((a, b) a - b)就行。4.2 Python 完整代码Python 里用 Counter 可以让代码极其简洁from collections import Counter def filter_by_frequency(arr, target): counter Counter(arr) result [key for key, count in counter.items() if count target] result.sort() return result print(filter_by_frequency([3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5], 2))Counter 内部就是一个字典items()返回键值对。Python 3.7 以后字典会保留插入顺序但插入顺序跟元素大小无关所以result.sort()一步不能省。如果不想用 Counter也可以手写字典统计counter {} for item in arr: counter[item] counter.get(item, 0) 1两种写法本质一样Counter 只是省了get(item, 0)这几下。建议初学者先手写一遍知道底层逻辑再用 Counter 偷懒。4.3 C 完整代码C 的写法清晰直接#include unordered_map #include vector #include algorithm std::vectorint filterByFrequency(const std::vectorint arr, int target) { std::unordered_mapint, int counter; for (int num : arr) { counter[num]; } std::vectorint result; for (const auto entry : counter) { if (entry.second target) { result.push_back(entry.first); } } std::sort(result.begin(), result.end()); return result; }C 里有两点要重点提醒。第一unordered_map内部用哈希表存储顺序是随机的所以过滤完必须手动std::sort否则每次运行结果顺序可能都不一样。第二std::sort默认升序正好符合题目要求。如果哪天要做降序可以传第三个参数std::greaterint()。如果元素类型是字符串unordered_mapstring, int也能正常工作。如果元素是自定义结构体就需要给unordered_map提供哈希函数代码会复杂一截一般面试不会考到这个深度。4.4 数组初始化与输入输出的工程细节题目来自在线判题系统时输入通常是一行数组加一个 target。我见过不少人算法完全对却栽在输入解析上。比如 JS 里拿到的是字符串3,1,4,1,5要先split(,)再map(Number)转成数字数组。如果数组初始化错了后面统计自然全错。数组初始化这一点不同语言风格差异很大// JavaScript 数组初始化为 0 const counts new Array(maxVal 1).fill(0);# Python 列表初始化为 0 counts [0] * (max_val 1)// C 数组初始化为 0 vectorint counts(maxVal 1, 0);这是最基础的初始化但如果范围很大的整数数组直接用桶计数会占掉很大内存。所以先判断数据类型和范围再决定初始化的方式这也是一种重要的编程素养。输出环节也别忘了看题目要求是返回数组还是每行打印一个元素还是空格分隔。逻辑上没区别只是最后一层输出的处理不同。我的习惯是先用一个最简单的样例完整跑通主流程再根据题目改输出格式。5. 边界情况、性能分析与问题排查5.1 边界情况速查表边界测试是最容易暴露问题的地方。我整理了一张常用速查表可以直接当自测清单输入数组target期望输出说明[]3[]空数组直接返回空[1, 2, 2]3[]没有元素出现 3 次[1, 2, 2]2[2]常规目标[1, 2, 3]1[1, 2, 3]所有元素唯一[5, 5, 5, 5]4[5]单个元素重复多次[a, b, a]1[b]字符串类型同样适用前两行覆盖空数组和完全无匹配的情况第三行验证普通匹配最后两行测试不同类型和高重复场景。把这些用例压到本地跑一遍基本能覆盖掉八成隐藏错误。5.2 时间与空间复杂度拆解假设数组总长度是 n不同元素个数是 m。哈希表统计要遍历全部 n 个元素时间复杂度 O(n)。过滤阶段遍历 m 个键时间复杂度 O(m)。排序阶段最多对 m 个元素排序时间复杂度 O(m log m)。总的复杂度是 O(n m log m)。因为 m 一定小于等于 n最坏情况下也就是 O(n log n)。额外空间主要来自哈希表占用 O(m)。结果数组最长也不超过 m所以整体额外空间 O(m)。对绝大多数场景来说这个消耗完全可接受。复杂度不是用来背的概念它直接决定代码在边界数据下的表现。我实际优化过一个日志统计脚本数据量从几千涨到几十万之后原来 O(n²) 的写法要跑 20 多秒换成哈希表加排序后降到毫秒级。这种体感变化远比公式更有说服力。5.3 大数组下的优化细节面对百万级数组几个编码习惯能带来明显差异用for循环而不是forEach减少函数调用开销。优先用 Map 而不是普通对象避免原型链干扰也保证键类型语义正确。过滤结果时先判断再push不必要的中间变量不要创建。如果 target 是 0直接返回空数组连统计遍历都可以省掉。这些优化单独看都不大但叠加在一起效果在大数据量下非常明显。还有一个通用思路如果数组本身非常稀疏考虑只在出现过的元素上做统计不要让桶计数把空位也占满。5.4 常见 Bug 与排查思路实录按我处理过的实际问题最常见的 Bug 集中在四类。第一排序结果不对。先确认元素类型再确认比较函数。数字数组没有(a, b) a - b就会被字符串排序坑字符串数组要确认题目到底按字典序还是拼音序。第二过滤结果为空。先检查 target 的类型是不是数字。如果你从输入字符串里拿到的是3统计结果是数字 33 3永远为 false。解决方式是统一在解析输入时做Number()转换或者比较时两边都转类型。第三统计次数不对。优先查是不是用了普通对象当哈希表数组里是否有__proto__、constructor这种特殊键名。换成 Map 后这类问题通常直接消失。第四对象数组没有合并。检查是否用了业务主键比如item.id而不是整个对象作为 key。这个错误很隐蔽因为代码逻辑没报错但结果完全不符合预期。排查顺序我建议先看类型再看数据结构最后才看算法逻辑。因为类型问题最隐蔽也最高频。6. 实际场景与扩展应用6.1 从“恰好 k 次”到“至少 k 次”只需要改一行判断就能把题目扩展到更贴近业务的场景if (count target) { result.push(item); }现实需求里“至少出现 k 次”反而更常见。比如找出访问次数超过 5 次的用户、找出被引用至少 3 次的数据记录。如果你只记得写遇到这类描述就会卡壳。我建议大家把函数设计成支持两种模式一种精确匹配一种至少匹配。参数稍微复杂点但复用价值高很多。6.2 高频 Top K题目的自然延伸统计完频率后如果不再按元素升序而是按计数降序取前 K 个就是经典的 Top K 高频元素问题function topKFrequent(arr, k) { const counter new Map(); for (const item of arr) { counter.set(item, (counter.get(item) || 0) 1); } return [...counter.entries()] .sort((a, b) b[1] - a[1]) .slice(0, k) .map(([key]) key); }这里先用展开运算符把 Map 转成二维数组然后按第二个元素降序排序最后截取前 k 个 key。数据量特别大时可以用大小为 k 的小顶堆来优化很多业务场景里直接排序就足够。这道题和本文主题是同一个统计体系。你掌握了“统计、过滤、排序”这个骨架就能根据需求自由排列组合。6.3 我在实际业务中的使用场景我不止一次在真实项目里碰到这个题目的身影。一个是日志分析统计每个接口在一天内的调用次数找出调用次数恰好等于 5 的小时段再按时间升序输出用来排查异常波动。本质就是数组元素由“接口名 时段”组成按组合统计频率过滤出达标项再按时间排序。另一个是订单数据清洗一批订单里找出重复提交次数等于 3 的订单号按订单号升序展示给运营团队复核。逻辑一模一样。这种高频复用的东西很值得沉淀成通用函数比如filterByFrequency(arr, target)就是那种写完一次、多处调用的工具函数。放在自己的工具库或测试框架里面以后处理类似问题会快很多。6.4 对象数组按字段统计与去重对象数组的统计核心在于先确定业务主键。假设有一条用户操作记录我们要统计每个用户操作了多少次再筛出操作次数等于 2 的用户const records [ { userId: 1001, action: click }, { userId: 1002, action: view }, { userId: 1001, action: buy }, { userId: 1003, action: share }, ]; const counter new Map(); for (const record of records) { const id record.userId; counter.set(id, (counter.get(id) || 0) 1); } const target 2; const userIds []; for (const [id, count] of counter) { if (count target) { userIds.push(id); } } userIds.sort((a, b) a - b);输出结果是[1001]。如果你还要把完整的对象带出来可以在统计时把“第一次出现的对象”或“最后一次出现的对象”存起来过滤后再通过 id 映射回去。这一步在很多报表查询里很有用。7. 一点个人经验这类数组统计题我最深的体会就是别想复杂。很多人一上来就想着用堆、用各种高级数据结构结果基础的三步流程反而没写利索。我的做法是先按最简单的哈希表方案写一个能跑通所有样例的版本再根据数据规模决定要不要优化。绝大多数情况下O(n m log m) 已经足够优秀。测试用例也一定要覆盖空数组、target 为 0、元素类型不统一这三类。它们不在题目示例里出现却往往是判题系统用来卡分的隐藏用例。最后留个升级练手题把数组换成一串英文文本先按空格切分成单词数组再统计每个单词出现次数找出出现次数等于指定值的单词按字母序输出。本质和这个题目完全一样只是多了一步文本预处理。把它写顺了你就会发现“统计数组中出现指定次数的元素并且升序输出”这套思路能迁移到很多数据加工场景里远不止一个算法题那么简单。
返回列表