ARTICLE DETAIL

资讯详情

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

数组元素按出现次数筛选并升序排序:哈希计数与多语言实现

数组元素按出现次数筛选并升序排序:哈希计数与多语言实现 统计数组中出现指定次数的元素并且升序输出——这个需求光看标题像是初学者练习题但真落到代码里牵扯出来的东西其实不少怎么确定指定次数这个阈值、重复元素要不要保留、排序时数字和字母混在一起怎么办、空数组输入怎么处理更别说不同语言里实现方式天差地别。我这些年写 JavaScript、Python、VBA 都遇到过类似需求统计词频、筛选重复点击的用户 ID、找埋点日志里异常上报的接口名、整理问卷多选题选项全是这个套路先数次数再按次数筛最后排个序。今天把这块掰开揉碎讲清楚JS、Python、Java、C、VBA 各给一套能直接抄的写法再聊聊排序和统计里的坑。不管你是刚学数组的新手还是写了几年业务代码的老兵这篇都能给你省点时间。1. 核心思路拆解为什么先统计、再筛选、最后排序是唯一正确顺序做这个需求第一反应可能是双重循环拿数组每个元素去跟全数组比一遍数出有几个一样的。数组短还好长度一上 1000这就是 100 万次比较页面直接卡死。正确姿势是引入一个中间容器——哈希表、字典、Map、对象都行——用一趟遍历完成计数时间复杂度 O(n)。1.1 哈希计数背后的空间换时间逻辑新手最容易忽略所谓哈希计数就是建立一个元素值 → 出现次数的映射关系。拿 JavaScript 举例用普通对象{}做容器遍历数组每遇到一个元素就在对象里找对应的键有就加 1没有就初始化为 1。const arr [3, 1, 2, 3, 5, 1, 3]; const freq {}; for (const item of arr) { freq[item] (freq[item] || 0) 1; } // freq { 1: 2, 2: 1, 3: 3, 5: 1 }每次循环只做一次字典查找 一次加法几十万条数据也就几十万次操作秒级完成。代价是多了一块和数组元素数量成正比的内存。这属于典型的空间换时间在绝大多数业务场景下都划算。这里有个新手极容易踩的坑JS 普通对象的键会被强制转成字符串所以数字1和字符串1会被当成同一个键。真要区分类型得换Map来存。同样的问题在 Python 里也有字典的键必须是可哈希的列表就不能当键。1.2 筛选出现指定次数的边界条件先想清楚再写循环出现指定次数这句话其实有多个理解层次。拿题目来说我倾向理解为找出所有出现次数恰好等于某个数 N 的元素。但也有人要的是大于等于 N这直接决定了过滤条件怎么写。我一般第一件事就是把需求翻译成逻辑表达式恰好等于 N 次freq[item] N至少出现 N 次freq[item] N找出所有不重复元素freq[item] 1找出重复元素freq[item] 1还有一个更阴间的细节如果数组里同一个值出现了 N 次但它其实是一个重复项最终输出时是输出一个元素还是 N 个相同元素正常业务逻辑都是每个唯一值输出一次但你要是拿双重循环硬数很容易把重复元素重复塞进结果数组。所以淘汰双重循环还有一个原因它天然会让输出结果里带重复项你还得二次去重。1.3 升序排序放最后是因为它根本不关心你的计数逻辑为什么排序不放到前面因为排序是一个纯排列操作它不改变元素集合只改变顺序。你先排序再统计确实能通过相邻元素比较来计数但排序本身比哈希计数慢最快 O(n log n)而计数是 O(n)。而且先排序会破坏数组原始顺序万一后面还有别的需求依赖原始顺序那就麻烦了。正确的流水线是原始数组 → 哈希计数 → 按条件筛选 → 得到结果数组 → 对结果数组做升序排序 → 输出。每步只管一件事清晰、好调试、可复用。这个思路不只在编程题里成立在 Excel 的数据透视、SQL 的 GROUP BY 和 ORDER BY 里也是完全一样的逻辑。2. 各语言实现盘点JS、Python、Java、C、VBA 一次给全同一个需求不同语言因为内置容器和 API 的差异写出来的代码风格完全不同。下面这五种我都实测过直接复制就能用。2.1 JavaScript对象 filter sort三行搞定但要注意类型陷阱function filterAndSort(arr, targetCount) { const freq {}; for (const item of arr) { freq[item] (freq[item] || 0) 1; } return Object.keys(freq) .filter((key) freq[key] targetCount) .map(Number) .sort((a, b) a - b); } const data [5, 3, 8, 3, 1, 5, 3, 8, 9]; console.log(filterAndSort(data, 2)); // [5, 8]这段代码里Object.keys(freq)拿到的键一律是字符串所以.map(Number)这步不能省——除非你想让10排在9前面。排序回调(a, b) a - b是升序的固定写法千万别写成sort()不带参数那样是按 Unicode 字典序排的数字10会排在2前面这是 JS 新手最常踩的排序坑。如果数组里可能存在对象、NaN、undefined 这类复杂元素freq[item] (freq[item] || 0) 1这套以普通对象做字典的方案就会出问题。稳妥做法换成Mapfunction filterAndSortSafe(arr, targetCount) { const freq new Map(); for (const item of arr) { freq.set(item, (freq.get(item) || 0) 1); } const result []; for (const [item, count] of freq) { if (count targetCount) result.push(item); } return result.sort((a, b) a - b); }Map的键可以是任何类型for (const [item, count] of freq)这种解构遍历也清晰明白。日常业务代码里只要有统计出现次数需求我建议一律用Map而不是{}省得后面数据形态变了还要回头改。2.2 PythonCounter 计数器 列表推导式目前最优雅的方案Python 的collections.Counter就是为这种需求量身定做的。它本身是一个字典子类直接统计好所有元素的出现次数然后一行过滤、一行排序from collections import Counter def filter_and_sort(arr, target_count): freq Counter(arr) return sorted([item for item, count in freq.items() if count target_count]) data [5, 3, 8, 3, 1, 5, 3, 8, 9] print(filter_and_sort(data, 2)) # [5, 8]Counter(arr)内部用 C 语言优化过的哈希表实现性能比手写for循环快不少。统计 100 万元素大概只要零点几秒。sorted()默认就是升序对纯数字序列直接生效。如果你不想引入collections自己写也不难本质跟 JS 一样用 dict 做哈希def filter_and_sort_manual(arr, target_count): freq {} for item in arr: freq[item] freq.get(item, 0) 1 result [item for item, count in freq.items() if count target_count] result.sort() return result注意一个细节Python 的元组、字符串、数字都是可哈希的可以放进 dict 做键但列表不行。如果你统计的对象是列表得先转成元组再计数。2.3 JavaHashMap Stream 流处理适合工程项目的写法import java.util.*; import java.util.stream.Collectors; public class FrequencySort { public static ListInteger filterAndSort(int[] arr, int targetCount) { MapInteger, Integer freq new HashMap(); for (int num : arr) { freq.put(num, freq.getOrDefault(num, 0) 1); } return freq.entrySet().stream() .filter(e - e.getValue() targetCount) .map(Map.Entry::getKey) .sorted() .collect(Collectors.toList()); } public static void main(String[] args) { int[] data {5, 3, 8, 3, 1, 5, 3, 8, 9}; System.out.println(filterAndSort(data, 2)); // [5, 8] } }Java 的HashMap跟 Python 的 dict、JS 的 Map 一个原理。getOrDefault是处理键不存在的惯用法。Stream写法在工程代码里很常见链式调用清晰但如果你是写 Android 或者老项目也可以用传统循环 ArrayList效果一样。传统写法长这样容易读懂适合面试时手写public static ListInteger filterAndSortTraditional(int[] arr, int targetCount) { MapInteger, Integer freq new HashMap(); for (int num : arr) { Integer count freq.get(num); freq.put(num, count null ? 1 : count 1); } ListInteger result new ArrayList(); for (Map.EntryInteger, Integer e : freq.entrySet()) { if (e.getValue() targetCount) { result.add(e.getKey()); } } Collections.sort(result); return result; }2.4 Cunordered_map vector 排序性能敏感场景的标杆C 里能用std::unordered_map做到 O(1) 平均复杂度哈希查找注意它不是有序的所以最后必须sort一次。这个方案适合大数组、性能敏感的场景。#include iostream #include vector #include unordered_map #include algorithm std::vectorint filterAndSort(const std::vectorint arr, int targetCount) { std::unordered_mapint, int freq; for (int num : arr) { freq[num]; } std::vectorint result; for (const auto pair : freq) { if (pair.second targetCount) { result.push_back(pair.first); } } std::sort(result.begin(), result.end()); return result; } int main() { std::vectorint data {5, 3, 8, 3, 1, 5, 3, 8, 9}; auto result filterAndSort(data, 2); for (int num : result) { std::cout num ; // 输出 5 8 } return 0; }freq[num]这里有个小陷阱如果 key 不存在operator[]会先默认初始化一个 0然后自增成 1。这在统计场景下反而是我们想要的行为所以可以直接写。如果数组本身是有序的你还可以省掉 unordered_map直接扫描相邻元素计数这样空间复杂度降到 O(1)。但请记住有序是前置条件不是所有场景都满足。2.5 VBA字典对象 集合排序Excel 场景的另类解法VBA 处理数组排序比较反直觉——它没有内置的数组排序函数常规做法是把数据丢到 Excel 工作表里用 Range.Sort或者借助ArrayList这类 .NET 对象。真正统计次数用Scripting.Dictionary准没错。Function FilterAndSort(arr, targetCount As Long) As Variant Dim dict As Object Set dict CreateObject(Scripting.Dictionary) Dim i As Long For i LBound(arr) To UBound(arr) If dict.Exists(arr(i)) Then dict(arr(i)) dict(arr(i)) 1 Else dict.Add arr(i), 1 End If Next i 筛选出现次数等于 targetCount 的键 Dim tempArr() As String Dim count As Long count 0 For Each key In dict.Keys If dict(key) targetCount Then ReDim Preserve tempArr(count) tempArr(count) key count count 1 End If Next key VBA 没有原生数组排序扔到工作表排序 If count 0 Then FilterAndSort Array() Exit Function End If Dim ws As Worksheet Set ws ThisWorkbook.Sheets(排序辅助) ws.Cells.Clear ws.Range(A1).Resize(count, 1).Value Application.Transpose(tempArr) ws.Range(A1).Resize(count, 1).Sort ws.Range(A1), xlAscending FilterAndSort Application.Transpose(ws.Range(A1).Resize(count, 1).Value) End Function这个方案里我用了辅助工作表存中间结果属于脏但能用的典型 VBA 风格。更彻底的办法是引入System.Collections.ArrayList代码更简洁但要开启对应引用。VBA 里用字典要记住CreateObject(Scripting.Dictionary)这个创建方式直接New Dictionary在多数环境里是不行的。2.6 五种语言方案横向对比选择困难的直接看表语言核心容器计数复杂度排序方式适合场景JavaScriptObject / MapO(n)sort((a,b)a-b)Web 前端、Node 后端Pythoncollections.CounterO(n)sorted()数据分析、脚本、算法题JavaHashMapO(n)Stream.sorted()工程后端、AndroidCunordered_mapO(n)std::sort高性能计算、算法竞赛VBAScripting.DictionaryO(n)工作表排序Excel 自动化处理3. 排序细节与边界情况升序输出不是 sort 一下那么简单这个题目的后半段升序输出看着简单但不同类型、不同数据形态下都有隐藏问题。3.1 数字排序的字典序陷阱JavaScript 用户最常见JS 的sort()默认行为是对元素做字符串转换后按 Unicode 码点排序。也就是说[10, 9, 100].sort()得到的是[10, 100, 9]。对这个题目结果数组如果是数字必须显式传比较器result.sort((a, b) a - b);Python 的sorted()对纯数字列表没有这个问题它直接比较数值大小。但如果你的列表里混了字符串和数字比如[1, 2, 3]会直接抛TypeError。所以实际操作中第一件事是确认元素的类型一致性。Java 的Collections.sort()要求元素实现Comparable接口Integer 原生支持但混合类型也无法编译。C 的std::sort默认用运算符数字没问题但如果是自定义结构体要自己重载operator或传 lambda。3.2 空数组、全重复数组、边界值数组三个极端用例必须测我写这类函数时有个习惯先把极端输入测一遍再跑正常用例。这个题目至少有三种边界空数组输入freq是空字典filter结果也是空排序空数组不报错直接返回[]。这个流程对 JS、Python、Java、C 都自然成立。目标次数为 0 或负数正常逻辑里出现 0 次意味着元素根本不在数组里不应该出现在结果中。所以严格来说targetCount应该在函数入口做校验小于 1 就直接返回空数组避免后续无意义计算。数组长度恰等于目标次数比如[7, 7]里找出现 2 次的元素结果是[7]找出现 3 次的元素结果是[]。这类用例最能验证筛选条件写没写对。3.3 稳定性与逆序输入的考量以及出现次数相同元素的二次排序如果需求变成先按出现次数排序次数相同再按值升序代码就要在排序键上做文章。Python 里可以# 按出现次数降序次数相同按元素升序 sorted_items sorted(freq.items(), keylambda x: (-x[1], x[0]))JS 里对应写法const sortedKeys Object.keys(freq).sort((a, b) { if (freq[b] ! freq[a]) return freq[b] - freq[a]; // 次数降序 return Number(a) - Number(b); // 值升序 });排序稳定性在现代引擎里都是稳定的但你要是手写了一个不稳定的快排比如某些教科书简化版相同次数的元素顺序可能随机变化。我建议不要过分依赖稳定性显式把二级排序键写清楚最稳妥。3.4 使用排序后相邻比较统计的替代方案什么时候它更优前面我主推哈希统计但存在一个哈希不擅长的场景元素本身不可哈希。比如数组里装的是坐标点[x, y]或嵌套对象。这时候哈希容器无能为力替代方案是先排序再扫描def count_sorted(arr): arr sorted(arr) # 先排序让相同元素相邻 result [] i 0 n len(arr) while i n: j i while j n and arr[j] arr[i]: j 1 if j - i target_count: # 此处 target_count 在外层定义 result.append(arr[i]) i j return result这个方案的时间复杂度是 O(n log n)比哈希慢一点但好处是空间 O(1)而且对元素类型要求低只要能比较大小就行。大数组内存紧张或者是复杂对象数组时值得考虑。4. 常见问题与排查技巧实录每次写这种统计排序的需求我都会在生产环境里遇到一些教科书不会讲的坑集中整理如下。4.1 统计词频结果不对多半是类型或隐式转换问题场景用户上传 Excel 里的工号要求统计每个人出现的次数然后筛选出现 3 次的工号。结果发现有的工号明明出现了 3 次却不在结果里。排查后发现 Excel 读取的数字和字符串混在一起——00123和123数值相同但哈希容器把它们当成两个不同的键。解决办法统计前统一做类型归一化要么全部转字符串要么全部转数字并且注意前导零的保留。这个坑在 VBA 和 JS 里尤其常见。4.2 排序结果顺序诡异检查到底是数字比较还是字典序比较经典案例数组[1, 2, 10, 20]期望升序[1, 2, 10, 20]实际输出[1, 10, 2, 20]。十有八九用了默认字符串排序。排查方法很简单在排序前打印每个元素的typeof或type()确认类型再看排序回调有没有写。JS 里sort()不带参数就是这个结果加(a, b) a - b立刻正常。4.3 VBA 数组明明有值字典统计却是空的多半是数组维度问题VBA 数组有两种定长数组Dim arr(1 To 10)和动态数组Dim arr()配合ReDim。LBound和UBound的使用是基础中的基础但很多人不知道Application.Transpose有长度限制——超过 65537 个元素会报错。应对大数据别用Transpose往工作表搬改成分段写入或者直接用ArrayList处理。4.4 大数据量性能优化的三个方向百万级数组的场景我建议关注这三个方向按性价比排序哈希容器预分配容量Java 的HashMap构造时传入预估大小new HashMap(arr.length / 2)能减少扩容次数。Python 的 Counter 没法预分配但可以用defaultdict(int)略快一点。避免不必要的复制JS 里filter会生成新数组再sort又生成一次。如果内存敏感可以先把结果 push 进数组再原位 sort。并行化如果数组分布在多个分片可以先对每个分片做统计再把各分片的统计结果合并。这正好是 MapReduce 思想的雏形Map 阶段各算各的Reduce 阶段汇总频次。4.5 常见错误速查表建议贴显示器旁边症状可能原因解决方案统计次数少算普通对象键类型转换导致数字和字符串串键改用 Map / 提前归一化类型统计次数多算数组本身包含重复项双重循环未去重改用哈希容器做唯一键统计排序结果乱字符串字典序排序JS 传比较器其他语言用对应数值排序 API结果里有重复元素筛选时遍历原始数组而非唯一键集合遍历freq.keys()而不是遍历arr大数据 VBA 报错Array 维度或 Transpose 长度限制用 ArrayList 或循环写入单元格负数排序错位未考虑负数绝对值排序规则升序直接用数值比较器即可4.6 面试现场如何拆解这道题5 分钟讲出高分答案这题经常伪装成统计数组中出现次数最多的元素找出出现次数超过一半的数字数组中出现次数不低于两次的元素等面目出现出现在社招和校招面试里。我要是面试官我会这么考察候选人第一步问思路。候选人如果能说出先哈希计数再筛选最后排序这个三步流水线基本分拿到。第二步问复杂度。要能答出时间 O(n)、空间 O(n) 以及为什么不能更优——因为至少要看一遍所有元素O(n) 是下限。第三步追边界。空数组、全相同数组、目标次数大于数组长度、数组元素类型混杂这些都是加分项。第四步发散的隐藏考点如果要统计次数并保留原数组顺序输出怎么办答案是不排序遍历原数组用freq[item] N !seen.has(item)去重输出。这个变体考察的是对排序是否必要的判断力。5. 实操扩展从统计出现次数到真实业务场景的迁移学会了这道题的解法你会发现它像一块积木能拼进各种真实系统里。5.1 日志分析场景统计每个接口的调用频次并升序输出 Top N假设后端收到一批访问日志每行一个接口名要找出调用次数最多的 5 个接口。核心代码就是哈希计数 排序取前 Nfrom collections import Counter logs [/api/login, /api/order, /api/login, /api/user, /api/order, /api/login] counter Counter(logs) top5 counter.most_common(5) print(top5)most_common内部就是先计数后按次数排序取出前 N底子还是Counter 排序。业务侧唯一的额外考虑是日志量级——一天几亿条时单机哈希可能扛不住得往消息队列 流计算框架上迁移。流计算的词频统计本质上是分片统计 汇总合并跟前面的并行优化思路一脉相承。5.2 Excel 场景数据清洗后按出现次数筛选工号并升序输出VBA 那段代码可以直接封装成一个宏选中一个包含工号的列运行宏筛选出出现次数等于 N 的工号按升序输出到另一列。 这比用 Excel 自带的数据透视表更适合重复执行。 数据透视表步骤多而且每次数据更新都要手动刷新。 宏一次写好按钮一点就完事。5.3 数据可视化前奏先统计再排序直接喂给图表做柱状图、词云之前通常要把原始数据转成标签 频次的表结构。import matplotlib.pyplot as plt word_freq Counter(text.split()).most_common(10) labels [w for w, _ in word_freq] values [c for _, c in word_freq] plt.bar(labels, values) plt.show()这里的most_common(10)已经帮你按频次排序了要是想升序输出就sorted(word_freq.items(), keylambda x: x[1])。同样一个统计逻辑换个排序方向就能服务完全不同的可视化需求。5.4 从数组统计到其他数据结构的迁移思路这个解法稍微变形还能处理统计字符串中每个字符出现次数Counter(hello world)统计二维数组中满足条件的行数逐行套用哈希计数两个数组的交集/差集先各自计数再比较频次找出数组中出现次数超过一半即大于 n/2的元素排序后取中位数验证5.5 用树状数组解决连续区间频次统计的进阶题目如果题目升级为统计一个动态数组中各个区间段内元素的出现次数并支持单点修改哈希表就不够用了需要树状数组Fenwick Tree或线段树这种支持区间求和与单点修改的数据结构。我见过的最经典例子是维护一个长度为 n 的序列支持两个操作——查询前缀和sum(11)、单点修改add(3, x)。树状数组在这类场景下能做到 O(log n) 的查询与更新比每次重新统计全数组快得多。这个进阶方向不展开讲了但想提醒你基础题目的解法是一块跳板能把哈希计数和树状数组这两套思路放在一起理解以后遇到数据流场景就不会抓瞎。6. 我的实操心得与避坑清单这个需求我写了不下二十次闭着眼都能背出解法。最后分享几个只有踩过坑才知道的细节。第一永远先用测试用例验证筛选条件。写循环前先把预期结果写出来。数组[1, 1, 2]查出现 2 次的元素预期就是[1]。如果代码输出[]别急着改排序先测计数对不对。第二哈希容器选错排查半天也找不出原因。JS 普通对象会把数字键转字符串、__proto__这类特殊键还会出幺蛾子。直接上Map省心省力。Map还有一个好处插入顺序就是遍历顺序这在某些场景下能做按首次出现顺序输出。第三排序永远要显式声明按什么排。你以为的升序在不同语言、不同 API 里可能是字典序、可能是内存地址序、可能是随机序。显式写清楚数字比较器既防止自己脑子短路也方便同事 review 时一眼看懂意图。第四业务数据里元素类型比你以为的复杂。Excel 读出来的单元格有字符串、数字、日期、布尔值日志里的接口名偶尔带空格或换行。统计前先做一层清洗把空白 trim 掉把类型统一掉能省下大量排查时间。这步脏活不做后面再巧妙的数据结构都救不了你。最后分享一个真正好用的小技巧如果你只需要出现次数达标的元素不需要关心具体次数值可以把次数直接当成淘汰条件来遍历——先给每个元素标记出现过一次第二次遇到直接从候选集合里移除专门给找出只出现一次的元素这类题目用空间占用还能再矮一截。这算是哈希统计的一个变种写出来跟常规方案完全不同面试时提一嘴通常会有意外收获。希望这篇把统计、筛选、排序的全链路讲透了。记住核心那七个字先计数再筛选后排序。剩下的都是各语言 API 的细节查文档翻翻就能解决。
返回列表