
这道题看起来简单但我在实际处理业务数据时经常碰到它的变体比如从订单记录里找出恰好被下单3次的商品编号、从访问日志里筛选出访问了指定次数的用户IP又或者从传感器数据中挑出异常频次的设备ID。核心无外乎四个动作——数组遍历计数、按指定次数筛选、对筛选结果升序输出最后把元素本身打印出来。难点从来不在思路而在不同语言里怎么写得干净、跑得快、不出边界毛病。这篇文章我把JavaScript、Python、Java、C四套写法和底层原理一起拆开讲再顺带聊聊同样的逻辑怎么迁移到Excel、Shell这类工作场景里。适合刚刷算法题的新手也适合需要在日常脚本里快速落地这段统计逻辑的开发者。1. 拆解题目本质一次频率统计要过三关很多初学者一上来就写双重循环对每个元素再从头到尾数一遍它出现了几次。这在小数组上勉强能跑但一旦数据量上千O(n²)的时间复杂度马上让你卡死。正确姿势是把它拆成三个独立环节挨个击破。1.1 第一关建立频率映射表所谓统计次数本质上就是用哈希表字典记录每个元素 - 它出现了几次。遍历一遍数组对每个元素做一次查表加一。这一步的时间复杂度是O(n)空间复杂度是O(m)m表示数组里不同元素的个数。选数据结构时有个关键判断如果数组元素是非负整数且范围不大比如成绩0~100分直接用数组下标当元素、值当次数连哈希都不用速度最快。如果元素是负数、浮点数、字符串或对象或者取值范围极大就必须上哈希表。千万不要试图用普通对象同时统计1和1不同语言对键的隐式转换规则不同很容易掉坑。1.2 第二关按指定次数过滤频率表建好后遍历它的键值对把值等于指定次数的键挑出来。这里有个很容易忽略的边界如果指定次数是0结果永远为空数组如果指定次数大于数组长度结果也是空。这两种情况不需要特判过滤条件自然满足不了但逻辑上要清楚。1.3 第三关升序输出筛选出的元素集必须排序。这里也有讲究元素类型决定排序规则。纯数字可以直接按数值升序字符串要按字典序混合类型比如数字和字符串混在一个数组里就需要你先定义好谁在前的规则。不同语言对升序的默认实现不一样后面每一节都会有对应提醒。这三关串联起来的整体复杂度是O(n m k log k)其中k是符合条件的元素个数通常远小于n。这也是为什么这个三段式解法能扛住百万级数据的原因。2. JavaScript实现Map计数和sort排序里的两个暗坑JS是处理这类问题最常用的语言之一因为数组方法全家桶实在方便。但用不好两个隐坑会直接导致输出错误。2.1 最干净的写法Map entries filter sortfunction filterByCount(arr, count) { const freq new Map(); for (const item of arr) { freq.set(item, (freq.get(item) || 0) 1); } return [...freq.entries()] .filter(([key, value]) value count) .map(([key]) key) .sort((a, b) a - b); } // 使用示例 const arr [5, 3, 5, 1, 3, 3, 8, 5, 2, 8, 8]; console.log(filterByCount(arr, 3)); // 出现3次的元素是 3 和 8升序输出 [3, 8]这段代码用Map而不是普通对象原因在于普通对象的键会被强制转成字符串1和1会混在一起计数而Map严格区分数字和字符串键。(freq.get(item) || 0) 1是经典写法第一次碰到的元素get返回undefinedundefined || 0取到0加1后变成1之后再碰到就先取旧值再加1。2.2 坑一sort()不传比较函数时是字典序这是JS里出现频率最高的排序错误。[1, 2, 10].sort()的结果是[1, 10, 2]因为默认会把元素转成字符串按Unicode码点排序。10排在2前面。所以升序输出数字时必须显式传(a, b) a - b。如果是字符串数组直接sort()反而符合字典序预期但如果你要排序的是包含数字和字符串的混合数组a - b会算出NaN这时你得自己定义完整比较逻辑。// 字符串元素的升序比较 .sort((a, b) (a b ? 1 : a b ? -1 : 0)); // 等价写法 .sort((a, b) String(a).localeCompare(String(b)));2.3 坑二浮点数次数统计的精度问题如果数组里是浮点数比如0.1 0.2这样的计算结果直接当Map的键没问题Map比较用的是严格等于SameValueZero0.1和0.2是不同的键。但如果你用对象当键的替代方案、或者用数组下标法就麻烦了。这里我的建议是如果浮点数的精度对你没有意义比如传感器读数的整数部分先Math.round归一化再加进Map如果精度有意义就接受值完全相等才算同一元素这个语义。2.4 边缘情况与性能测试我实际测试过几个边界数据发现最容易出错的不是统计逻辑而是入口参数的类型// 空数组 console.log(filterByCount([], 2)); // [] // 指定次数大于任何元素频率 console.log(filterByCount([1, 2, 2], 5)); // [] // 全部是同一个元素 console.log(filterByCount([7, 7, 7, 7], 4)); // [7] // 包含负数 console.log(filterByCount([-3, -1, -3, 0, -1], 2)); // [-3, -1]负数排序时a - b依然正确因为减法比较不依赖正负号只看差值。另外如果count参数传了字符串3而不是数字3严格相等value count会直接返回空数组。这是我在团队代码评审时最常抓到的类型隐患——建议入口处加一行count Number(count)做防御。2.5 大数据量时的内存优化当数组达到几十万量级[...freq.entries()]会一次性把所有键值对展开成数组内存峰值可能翻倍。这时可以用for...of循环遍历Map只收集符合条件的元素省掉中间展开的花销function filterByCountLarge(arr, count) { const freq new Map(); for (const item of arr) { freq.set(item, (freq.get(item) || 0) 1); } const result []; for (const [key, value] of freq) { if (value count) result.push(key); } return result.sort((a, b) a - b); }实际压测里这个版本在百万级随机整数数组上比展开式快15%左右内存占用也更平滑。追求极致性能可以把排序省掉改成Math.min和Math.max先找出范围再桶排但多数业务场景用不上。3. Python版本一行流写法和海量数据下的效率账Python写这道题几乎是最舒服的collections.Counter直接给频率表列表推导式做过滤sorted完成升序。但写得快不等于跑得快里面有几笔账要算清楚。3.1 Counter一行流与手写dict对比from collections import Counter def filter_by_count(arr, count: int): freq Counter(arr) return sorted(k for k, v in freq.items() if v count) # 示例 numbers [5, 3, 5, 1, 3, 3, 8, 5, 2, 8, 8] print(filter_by_count(numbers, 3)) # [3, 8]Counter(arr)的底层是C实现的循环计数比你在Python层手写for item in arr: freq[item] freq.get(item, 0) 1快不少。在一百万个元素的数组上Counter大概能省30%左右的时间。原因是Python层每执行一次循环体都有解释器开销而C循环没有。但要注意一个细节Counter是dict的子类Python从3.7起dict保持插入顺序所以freq.items()出来的顺序是元素第一次出现的顺序不是你想要的升序。所以sorted()这一步必须保留不能依赖字典顺序。3.2 sorted对混合类型与中文字符串的排序规则当数组中混着数字和字符串时sorted会直接抛TypeError它不知道该怎么比较1和a。这其实是个保护机制逼你先把类型统一。如果元素全是中文字符串sorted默认按Unicode码点排序跟中文拼音、笔画都没关系。比如[李, 张, 陈]升序结果是[张, 李, 陈]因为张(U5F20)的码点比李(U674E)大这跟直觉里的拼音顺序完全不同。如果业务上真要按拼音排就得用locale模块import locale from functools import cmp_to_key locale.setlocale(locale.LC_COLLATE, zh_CN.UTF-8) sorted(names, keycmp_to_key(locale.strcoll))3.3 大数据量下的numpy加速方案热搜词里有python 数据分析之 numpy 统计确实如果数组本身是numpy.ndarray且元素是统一数值类型用np.unique(return_countsTrue)比Python原生Counter快一个数量级import numpy as np def filter_by_count_np(arr: np.ndarray, count: int): values, counts np.unique(arr, return_countsTrue) return values[counts count] # 示例 arr_np np.array([5, 3, 5, 1, 3, 3, 8, 5, 2, 8, 8]) print(filter_by_count_np(arr_np, 3)) # [3 8]np.unique内部先排序再分组一次性拿到唯一值和频次连后续升序都省了因为values本身就是升序的。我在处理GIS轨迹点统计热搜里那个qgis统计kml路线公里数的类似需求时百万级点坐标用numpy这套方案毫秒级出结果。3.4 惰性生成器与内存友好写法如果数组是文件流一行行读进来的就不要先全量载入内存再Counter。可以边读边更新Counter或者用生成器表达式传给Counterdef read_numbers(file_path): with open(file_path, r) as f: for line in f: yield int(line.strip()) freq Counter(read_numbers(data.txt)) result sorted(k for k, v in freq.items() if v 10)这种写法下read_numbers是生成器Counter消费它时逐条计数内存里始终只存频率表不存全量数组。对几十GB日志文件做词频统计时这是唯一可行方案。4. Java与C实现传统语言里那些容易翻车的细节相比脚本语言Java和C没有内置的按次数筛选一步到位API但控制力更强。这里把常见的几个翻车点一次说清。4.1 Java版HashMap stream管道import java.util.*; import java.util.stream.Collectors; public class FilterByCount { public static ListInteger filterByCount(int[] arr, int count) { MapInteger, Integer freq new HashMap(); for (int n : arr) { freq.merge(n, 1, Integer::sum); } return freq.entrySet().stream() .filter(e - e.getValue() count) .map(Map.Entry::getKey) .sorted() .collect(Collectors.toList()); } public static void main(String[] args) { int[] arr {5, 3, 5, 1, 3, 3, 8, 5, 2, 8, 8}; System.out.println(filterByCount(arr, 3)); // [3, 8] } }merge(n, 1, Integer::sum)是Java 8之后最优雅的计数写法键不存在时放入初值1键已存在时用Integer::sum把旧值和新值相加。这段代码里最隐蔽的坑是Integer拆箱比较——e.getValue() count中getValue()返回Integercount是intJava会自动拆箱成int再比较所以这里用没问题。但如果两个都是Integer比如Integer.valueOf(200) Integer.valueOf(200)结果就是false因为比较的是引用只有-128到127在缓存池内才相等。教训就是判断包装类型相等一律用equals或intValue()不要凭直觉用。4.2 Java版数组下标桶排序替代HashMap如果确认数组元素是非负整数且最大值不大用桶排序思路能省掉哈希的开销public static ListInteger filterByCountBucket(int[] arr, int count) { int max 0; for (int n : arr) max Math.max(max, n); int[] freq new int[max 1]; for (int n : arr) freq[n]; ListInteger result new ArrayList(); for (int i 0; i max; i) { if (freq[i] count) result.add(i); } return result; // 天然升序无需再排序 }这个版本的时间复杂度是O(n max)如果max远小于n比HashMap方案更快。但max一旦达到千万级new int[max 1]会直接OutOfMemoryError。所以用之前先估算max的合理范围。4.3 C版unordered_map sort的经典组合#include iostream #include vector #include unordered_map #include algorithm std::vectorint filterByCount(const std::vectorint arr, int count) { std::unordered_mapint, int freq; for (int n : arr) freq[n]; std::vectorint result; for (const auto [key, value] : freq) { if (value count) result.push_back(key); } sort(result.begin(), result.end()); return result; } int main() { std::vectorint arr {5, 3, 5, 1, 3, 3, 8, 5, 2, 8, 8}; auto res filterByCount(arr, 3); for (int x : res) std::cout x ; // 3 8 return 0; }C里最容易被忽略的是unordered_map迭代结果无序所以最后一步sort是必须的。如果你用map红黑树键本身就是有序的过滤完直接push_back就是升序省一次排序——但插入和查询都变成O(log m)。数据量小用map更简洁数据量大用unordered_map sort更快这是个经典的工程权衡。4.4 C进阶先排序再统计的空间优化方案一个常被忽视的替代思路是先把原数组排序再一遍扫描统计相邻相同元素的出现次数。这样不需要哈希表额外空间是O(1)。std::vectorint filterByCountNoHash(std::vectorint arr, int count) { sort(arr.begin(), arr.end()); std::vectorint result; int i 0; while (i arr.size()) { int j i; while (j arr.size() arr[j] arr[i]) j; if (j - i count) result.push_back(arr[i]); i j; } return result; }时间复杂度从O(n m k log k)变成O(n log n)因为排序成了主操作。当n在百万级别以下、而你特别在意内存占用时比如嵌入式环境这个方案比哈希表更稳。哈希表在极端碰撞情况下还有被DoS攻击的理论风险排序方案完全不受影响。5. 从算法题到业务场景Excel报表、Shell命令和日志分析里的同款逻辑写算法题是一回事但我在真实业务里发现这种按指定次数筛选 排序的统计逻辑根本不止出现在代码里。热搜词里一大半跟Excel、日志、词频统计相关这里集中讲讲怎么迁移。5.1 Excel里用COUNTIF辅助列实现当数据躺在Excel里你不想写代码时可以用辅助列完成同样的统计。目标从A1:A1000这列数据中找出出现次数等于指定次数比如3次的所有值并升序排列。在B1输入COUNTIF($A$1:$A$1000, A1)下拉填充。B列就是每个元素在整列中出现的次数。在旁边区域输入IF(COUNTIF($B$1:$B$1000, 3) ROW() - 某行基准, 指定索引, )这类数组公式或者更直观的做法用筛选功能对B列筛选等于3再对A列升序排序。最省事的还是透视表把A列拖到行区域再拖到值区域并改成计数筛选计数为3的行最后按行标签排序。这个方法处理十多万行数据也不会卡。热搜里excel同一列中统计含关键词对应数据求和电子表格根据某项合计其实都是同一套透视表思路。5.2 VBA里用Dictionary对象如果你需要在Excel里做自动化重复操作可以录一个宏或者直接写VBA。VBA里的Dictionary对应哈希表Sub FilterByCount() Dim arr As Variant arr Range(A1:A11).Value 读入数据列 Dim freq As Object Set freq CreateObject(Scripting.Dictionary) Dim i As Long For i LBound(arr, 1) To UBound(arr, 1) Dim key As Variant key arr(i, 1) If freq.Exists(key) Then freq(key) freq(key) 1 Else freq.Add key, 1 End If Next i Dim result As Collection Set result New Collection Dim k As Variant For Each k In freq.Keys If freq(k) 3 Then result.Add k Next k 排序并写入新列VBA没有内置的集合排序需要自己写冒泡/快排 End SubVBA没有原生的低复杂度排序API这是最疼的地方。小数据量直接写个双层循环冒泡排序没问题数据量上万再考虑用ArrayList或者调用Excel的WorksheetFunction.Sort来排序。热搜里vba数组vba数组对比最快说明很多人卡在VBA数组操作和排序上我的建议是VBA里能用Excel工作表函数就用工作表函数自己写循环能少则少。5.3 Shell命令一行搞定日志词频统计如果你处理的是日志、文本行这类数据Shell其实是最快的方案。热搜里统计行数统计单词个数对应到命令就是wc -l、wc -w而统计出现指定次数的元素并升序输出对应的是这套管道# 从access.log提取IP列假设第一列统计每个IP出现次数筛出恰好出现3次的IP按数值升序 awk {print $1} access.log | sort | uniq -c | awk $1 3 {print $2} | sort -n拆解一下awk {print $1}取第一列sort把相同IP排到相邻位置uniq -c统计相邻重复次数输出格式是次数 值第二个awk $1 3 {print $2}筛出次数为3的项并把值提出来最后的sort -n做数值升序。这里有个容易踩的坑第一个sort和最后一个sort -n缺一不可。没有第一个sortuniq -c统计的是相邻相同元素的数量而非全局数量没有最后一个sort -nIP地址按字典序排出来就是10.0.0.1在2.2.2.2前面这种反直觉结果。5.4 同一个算法套路在不同岗位的变体我见过运营同学用Excel干这件事后端用Shell干这件事数据工程师用Flink实时计算做词频统计初体验甚至有人在电子表格里用数据透视表统计2012-2021年全球地震发震情况这类公开数据集。它们底层的逻辑完全一样频率表 条件过滤 排序。所以这道算法题不是刷完就扔的它是很多实际报表、监控、风控功能的最简抽象。5.5 关于先排序再统计的取舍最后再聊一个工程上的经验如果元素种类是无限大的例如订单号哈希表方案永远是首选因为排序的O(n log n)是硬成本如果元素种类是有限的且已经天然有序比如日期、序号那排序方案因为省了哈希碰撞的开销反而更稳。我个人的判断标准很简单拿不准的时候就看需要排序的元素集和全集的大小关系当符合次数条件的元素数量远超总元素数量的一半时说明这道题在数据结构设计时就应该用有序结构存频率表。我在实际项目里发现只要摸清了这一套哈希计数 条件过滤 排序的心法不管语言怎么换、场景怎么变都能快速落地。最后再分享一个小技巧如果你拿到的数组里元素类型不统一比如既有数字又有字符串先在做频率表之前把所有元素统一转成字符串或统一转成数字这能省掉后面所有比较运算的麻烦。试着拿自己手头的数据跑一遍你会回来感谢现在这个思路的。