上周我在刷题群里看到有人把 LeetCode 895 直接丢给 Kimi,Kimi 很快给了一版看起来特别标准的“双哈希表 + 频率桶”解法。第一眼确实漂亮,但那个人照着敲完,把测试用例一拉长,发现 pop 的结果开始对不上了。问题出在哪?出在它对最大频率降低时的处理太想当然,甚至没想过同一个值可能在多个频率桶里同时留下记录。
所以我决定把这题从头到尾掰开揉碎讲一遍,尤其讲清楚那些 AI 不会主动告诉你的边界条件。LeetCode 895 的全名叫 Maximum Frequency Stack,中文社区一般叫“最大频率栈”。它要求你设计一个 FreqStack 类,实现两个方法:
- push(int val):往结构里压入一个元素。
- pop():弹出并返回当前出现频率最高的元素;如果最高频有多个,则返回距离栈顶最近的那个,也就是最近一次 push 进去的那个。
这是一道典型的数据结构设计题,难度是 Hard,但它不需要任何高级算法。真正卡人的地方是:你能不能想到用“两个维度”去管理元素,以及能不能把同频元素之间的先后关系处理干净。
1. 我最初对题目的误读:最大频率栈到底在弹谁
刚看到“栈”这个字的时候,我下意识以为能用一个普通数组模拟。毕竟栈的特性就是后进先出,pop 直接弹尾部就行。但仔细读题,这个栈的 pop 规则其实分成了两步:
- 先找到当前出现次数最多的元素。
- 如果出现次数最多的元素不止一个,再在这些元素里找最近 push 的那个。
也就是说,它要比较两个关键字:第一关键字是频率,第二关键字是“时间新鲜度”。普通栈只比时间新鲜度,不比频率,所以普通栈根本处理不了这个需求。
这里我放一个反例,你可以体会一下普通栈方案的崩溃过程:
按顺序 push 1、push 2、push 1、push 2、push 3。此时元素频率是:
- 1 出现了 2 次
- 2 出现了 2 次
- 3 出现了 1 次
如果按最大频率栈的规则,第一次 pop 应该返回谁?1 和 2 频率并列最高,都是 2 次,要比“最近 push”。2 的最后一次 push 发生在第 4 步,比 1 的最后一次 push(第 3 步)晚,所以答案应该是 2。
但普通栈的栈顶是 3,你只能先把 3 弹出来,再弹 2,然后考虑怎么把 3 放回去。放回去还能保持原来的相对顺序吗?普通栈做不到,因为你已经改变了栈顶。更麻烦的是,后续如果还要按频率规则继续 pop,所有被临时弹出的元素都需要一个临时空间暂存,复杂度直接失控。
这个例子说明了一件事:你不能把所有元素堆在一个主栈里,因为 pop 的目标不一定在主栈的栈顶。你必须在压入元素的同时,就把“频率”和“同频率下的时间顺序”这两部分信息提前组织好。
还有一个很容易被忽略的点:pop 之后,元素的频率会动态变化。比如刚才第一次 pop 返回的是 2,弹完之后 2 的频率降到 1。这时候下一个 pop 又该返回谁?不是凭感觉,而是要看更新后的频率分布。这也是为什么题目本质上是设计一个“实时维护动态频率序列”的结构,而不是一个静态排序问题。
很多新手会掉进另一个坑:以为频率最高的元素就永远是那几个“老面孔”。不是的。元素频率会随着每次 push 和 pop 实时变化,任何缓存了“全局最高频元素”而不关心频次变化的方案,都会在多次操作后失效。
所以我在动手写代码之前,先把题目里的排序规则拆成了两条清晰约束:
- 频率高者优先;
- 频率相同,最后到达者优先。
这两条约束一旦明确,数据结构的选择就很自然了:直接用哈希表记录元素频率,再用一组“频率桶”来维护同一频率下的元素顺序。这就是我们常说的双哈希表方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双哈希表的分工原理:一张频率账本,一组历史栈
先直接给结论,这个方案维护三个核心变量:
- 哈希表
cnt:记录每个元素当前出现了几次。 - 哈希表
groups:key 是某个出现次数,value 是一个栈。groups[f]
