LeetCode 1224 的 Maximum Equal Frequency(最大相等频率)是我刷题时印象很深的一道困难题。它名字很直白:给一个正整数数组,找出最长的一个前缀,使得我们删除前缀中的一个元素后,剩下的每个不同数字出现次数完全相同。我第一次做的时候,被“删除一个元素”这个动作带偏了,真的去枚举每个位置删除,结果不仅超时,还漏了好几种边界。后来才发现,关键不在于删除哪一个,而在于把“删除后频率相等”这个状态,翻译成删除前的频率分布形态。这篇文章我会从题目语义、形态分类、线性维护、代码实现到边界坑点完整过一遍,适合被这道题卡过的读者,也适合准备周赛时想学一种通用套路的人。
1. 题目到底在问什么
1.1 返回的不是整个数组,而是最长前缀
先明确最基础的输入输出:给定一个正整数数组 nums,长度可以到 10^5,要求返回一个最大的前缀长度 k,使得前 k 个数组成的子数组满足条件:删除其中一个数后,剩余数组中每个不同数字的出现次数都相同。前缀必须从下标 0 开始连续取,不能跳过中间元素,更不能取任意子数组。很多人第一次读题会默认答案是“最长的满足条件的子段”,但 LeetCode 1224 限定了必须是前缀,这反而简化了很多,因为我们只需要从头往后扫描,不需要滑动窗口回退。
LeetCode 官方给的经典例子是 nums = [2,2,1,1,5,3,3,5]。整个数组长度是 8,四个数字 2、1、5、3 每个都出现了 2 次,看起来非常整齐。但删除一个元素后,四个数字的出现次数会变成 1、2、2、2 这种不均匀的状态,所以 8 不合法。再看前 7 个元素 [2,2,1,1,5,3,3],出现次数是 2:2次、1:2次、5:1次、3:2次。删掉唯一的 5 之后,剩下的三个数字都出现 2 次,满足条件,所以返回 7。这个例子同时说明两个点:第一,前缀不是结果子序列,答案必须从数组左边连续取;第二,即便整个数组已经做到了每个数字频率相同,删除一个元素后可能反而不满足,因为删除会打破原本的均衡。
1.2 “每个元素出现次数相同”里的每个元素指什么
这句话最容易产生歧义。这里比较的对象是不同数字之间的出现次数,不是每个数字的每个个体。比如剩余数组 [1,1,2,2],数字 1 出现 2 次,数字 2 出现 2 次,所以频数序列是 [2,2],满足。剩余数组 [1,2,2] 中,数字 1 出现 1 次,数字 2 出现 2 次,频数序列是 [1,2],不满足。再看一个例子 [2,2,1,1,5,3,3],删除数字 5 后剩余 [2,2,1,1,3,3],频数序列是 [2,2,2],满足。
还有个小细节容易被忽略:如果某个数字在删除后完全不出现了,它就不在剩余数组中,不要给它补一个 0 次。0 次不应该参与比较,因为条件里说的是“每个不同数字在剩余数组中出现的次数”,不存在于数组里的数字自然不在讨论范围内。这个细节对理解后面形态二特别关键:删除唯一一个只出现一次的数字时,这个数字会直接消失,剩下的其他数字只要频率相等就合法,不需要考虑那个已经消失的数字。
1.3 为什么暴力法在这里会挂
如果老老实实地对每个前缀做统计,再尝试删除每个位置验证,复杂度大约在 O(n^3) 到 O(n^2 log n) 之间,n 到 10^5 时完全不可行。就算优化成“每个前缀只统计一次,然后遍历所有数字,模拟删除其中一个数字后的频率变化”,最坏情况下每个前缀也要 O(distinct),整体还是 O(n^2),依然无法通过大数据。
所以这道题真正的门槛不是题意,而是如何用增量更新的方式,在一次遍历里同时维护状态并判断合法性。这要求我们先把“合法状态”压缩成几个简单条件,而不是每次真的去删元素。LeetCode 1224 的困难标签多半也是来源于此:代码量很少,但抽象过程很绕。
2. 核心思路:把“删除一个数字”翻译成频率形态
2.1 用“频率的频率”看问题
设 cnt[x] 表示数字 x 在当前前缀里出现几次。再设 freq[k] 表示“当前有多少个不同的数字,它们的出现次数恰好是 k”。比如前缀 [2,2,1,1,5,3,3],cnt 是 {2:2, 1:2, 5:1, 3:2},freq 就是 {1:1, 2:3},含义是:有 1 个数字出现了 1 次,有 3 个数字出现了 2 次。这种“频率的频率”是这道题最关键的一层抽象。
有了 freq,我们不再关心具体是哪几个数字,只关心出现次数的分布形态。删除一个元素,只会让被删数字 x 的 cnt[x] 减 1,也就是让 freq[old] 减少 1,freq[old-1] 增加 1,其他数字的 cnt 和 freq 完全不变。这样一来,“删除一个元素后频数序列全相等”这个问题,就等价于“频数分布经过一次从 old 到 old-1 的迁移后,freq 里只剩一种非零 key”。从数字维度一下子压缩到了频率维度,后面所有推导都基于这个视角。
2.2 合法前缀只有三种形态
设当前前缀长度是 n,最大出现次数是 maxFreq。如果我们删掉一个元素后,所有剩余数字的出现次数都相同,记为 t,那么删除前只可能有三种形态:
- 形态一:所有数字都出现 1 次,即 maxFreq == 1。此时删除任意一个数字,它消失,剩余数字仍然都出现 1 次。形如 [1,2,3,4,5]。
- 形态二:有一个数字只出现 1 次,其他所有数字都出现 maxFreq 次。删除那个只出现 1 次的数字,它消失,剩余数字全部出现 maxFreq 次。形如 [1,1,2,2,3]:1 和 2 出现 2 次,3 出现 1 次,删除 3。
- 形态三:有一个数字出现 maxFreq 次,其他所有数字都出现 maxFreq - 1 次。删除那个高频数字的一个元素后,它也变成 maxFreq - 1 次,于是所有数字频率相等。形如 [1,1,2,2,3,3,3]:1 和 2 出现 2 次,3 出现 3 次,删除一个 3 后剩下 2、2、3。
为什么没有第四种形态?因为一次删除只会影响一个数字的频率。如果删除前频数分布超过两种,删除一个数字最多只能消掉一种来源,不可能让所有剩余数字频率相等。严谨一点说,删除后所有数字频率同为 t,那么删除前被删数字的频率只能是 t+1(删一个变成 t),或者是 1(删除后这个数字消失,相当于 t 从 0 变成不存在)。如果被删数字频率是 t+1,其他数字频率就必须是 t,这就是形态三;如果被删数字频率是 1,其他数字频率必须是 t,而被删数字消失,这就是形态一或形态二。分类是完备的。
2.3 三个形态的数学判定式
用 maxFreq 和 freq 表来写表达式,比用文字描述要严谨得多:
- 形态一:maxFreq == 1。
- 形态二:maxFreq * freq[maxFreq] == n - 1。
- 形态三:freq[maxFreq] == 1 且 (maxFreq - 1) * (freq[maxFreq - 1] + 1) == n - 1。
形态二的推导:所有达到最大频率的数字,它们总的元素个数是 maxFreq * freq[maxFreq]。如果这个值正好占满了 n-1 个位置,那么剩下的 1 个位置一定是某个只出现一次的数字。删除它后,剩余元素全部来自 freq[maxFreq] 个数字,每个数字出现 maxFreq 次,于是相等。这里被删除的数字不是高频数字,而是那个唯一的低频数字。
形态三的推导:只有一个数字 x 达到 maxFreq,删除 x 的一个元素后,x 贡献 maxFreq - 1 次。其他数字如果都要等于 maxFreq - 1,那么它们总的元素数应该等于 (maxFreq - 1) * freq[maxFreq - 1]。删除后总长度是 n - 1,所以要求 (maxFreq - 1) + (maxFreq - 1) * freq[maxFreq - 1] = n - 1,也就是 (maxFreq - 1) * (freq[maxFreq - 1] + 1) = n - 1。反过来,这个等式成立时,也能反推出不存在其他频率更小的数字,因为其他数字的总出现次数刚好被 freq[maxFreq - 1] 个数字填满,没有余量给更低频率。
2.4 把常见合法前缀套进判定式
我习惯用几个具体例子验证判定式,确认没有漏条件。
[1,1,1]:maxFreq = 3,freq[3] = 1。形态三里 (3-1)(freq[2]+1) = 2(0+1) = 2,n-1 = 2,成立。这对应删除一个 1 后,剩余两个 1 都出现 2 次,合法。
[1,1,2]:maxFreq = 2,freq[2] = 1。形态二里 2*1 = 2,n-1 = 2,成立。这对应删除唯一的低频数字 2?等等,[1,1,2] 中 2 出现 1 次,删除 2 后剩余 [1,1],数字 1 出现 2 次,合法。
[1,2,3,4,5]:maxFreq = 1,形态一成立。注意形态二和形态三在这里都不成立,所以形态一必须单独判断。
[2,2,1,1,3,3,3]:maxFreq = 3,freq[3] = 1,freq[2] = 2。形态三里 2*(2+1) = 6,n-1 = 6,成立。这对应删除一个 3 后,三个数字都出现 2 次,合法。
[1,1,2,2,3,3,3,4,4,4]:n = 10,maxFreq = 3,freq[3] = 2,形态二 3*2 = 6,不等于 9;形态三因为 freq[3] = 2,不满足。也确实无法通过删除一个元素让 1、2 变成 3 次,或者让 3、4 中的一个变成 2 次后全局相等,判定式正确地排除了它。
3. 线性维护两个频率表
3.1 增量更新 cnt 和 freq
遍历数组时,每遇到一个新元素 x,先记下它旧的出现次数 old = cnt[x]。如果 old > 0,说明之前已经有 x,那么它原来贡献了一个“出现次数为 old”的数字,现在这个数字要从 old 档位挪到 old+1 档位,所以 freq[old] 要减 1。然后更新 cnt[x] = old + 1,freq[old + 1] 加 1。如果 freq[old] 减到 0,我习惯把键删掉,因为后面的判定要用 freq[maxFreq - 1] 这类查询,删掉后直接用 get 也能拿到 0,不会出现残留脏数据。对于第一次出现的数字,old = 0,不需要动 freq[0],直接让 freq[1] 加 1。
这里有个容易写错的点:老手也会把顺序搞反,先加了 cnt 再去减 freq[old],结果把新频率减掉了。正确顺序是先减旧档位,再更新 cnt,再加新档位,最后更新 maxFreq。每次更新时,freq[old] 一定存在,因为 old 对应的至少有一个数字 x;freq[old + 1] 可能不存在,用 get 默认 0 再加 1。整套操作在 Python 里用字典实现非常顺手,注意不要写成freq[old] -= 1而不做防御性判断,那样遇到 old=0 会把 freq[0] 减成负数。
3.2 为什么 maxFreq 只增不减
很多刚接触这个解法的读者会疑惑:如果某个数字的出现次数一直是最大值,但后来没有新增,它的 freq 可能变成 0,maxFreq 是不是要回退?其实完全不需要。因为我们每次只把一个数字的出现次数加 1,并不会减少任何已有数字的出现次数,所以整个前缀的“最大出现次数”只可能维持不变,或者变大。举例:前缀 [1,1,2] 的 maxFreq 是 2,加入 3 后变成 [1,1,2,3],maxFreq 仍然是 2;加入 1 后变成 [1,1,1,2,3],maxFreq 变成 3。它永远不会从 3 掉回 2。因此每轮更新 maxFreq = max(maxFreq, newFreq) 就足够了,不需要额外维护次大值或者回退逻辑。这个性质是这道题能线性维护的重要前提。
如果你在别的地方见过需要维护“当前出现次数前二大”的题目,比如删除一个数后要保证剩余最大频率不超过某个阈值,那种场景 maxFreq 才会回退。但 1224 的删除动作是在判断条件里模拟的,而不是真的从数组里移除元素,所以当前前缀的 maxFreq 只会随数组增长而单调非降。
3.3 每一轮按顺序判定三个条件
我的代码里,每次加入一个元素后立即得到当前前缀长度 n,然后依次检查三个条件。检查顺序建议是:先检查 maxFreq == 1,再检查形态二,最后检查形态三。形态一必须单独放在最前面,因为当所有数字都只出现一次时,freq[maxFreq] = freq[1] = n,形态二的等式是 1*n == n-1,除非 n=1,否则不成立,不能靠后两个条件覆盖。形态二和形态三在绝大多数情况下不会同时成立,即使同时成立,结果都是满足条件,不影响答案。
有些人喜欢用 if 和 elif 串起来,有些人用独立的布尔变量。我习惯先设 ok = False,然后逐条判断,只要满足就 ok = True,最后如果 ok 就更新答案。这样调试时能看到每个条件是否命中,不至于把多个分支搅在一起。
3.4 时间复杂度与空间复杂度
这个解法只需要一次遍历,每个元素更新 cnt 和 freq 都是 O(1),整体时间复杂度 O(n),n 最大 10^5,非常轻松。空间上,cnt 需要存不同数字的计数,最坏情况下每个元素都不同,cnt 大小 O(n);freq 的 key 是出现次数,最大值不会超过 n,但用哈希表时也是 O(n)。
如果题目明确 nums[i] 范围很小,比如 1 到 10^5,可以开定长数组替代哈希表,速度更快。但通用解法用哈希表最稳,因为题目没有保证数字范围一定小。我实测 Python 里用 dict 和 defaultdict 差距不大,反而要小心 defaultdict 在查询不存在的 key 时会自动创建键,干扰后续逻辑。所以这里我更推荐普通 dict 加 get。
4. 代码实现与测试用例
4.1 Python 完整实现
下面是用 Python 写的完整版本。我特意不用 Counter 的 most_common 之类的技巧,把每一步拆开写,方便你对着上面的推导检查。代码里除了解释行之外,核心逻辑只有二十多行。
from typing import List class Solution: def maxEqualFreq(self, nums: List[int]) -> int: cnt = {} # 数字 -> 出现次数 freq = {} # 出现次数 -> 有多少个数字恰好是这个次数 max_freq = 0 ans = 0 for i, x in enumerate(nums): n = i + 1 old = cnt.get(x, 0) if old > 0: freq[old] -= 1 if freq[old] == 0: del freq[old] new = old + 1 cnt[x] = new freq[new] = freq.get(new, 0) + 1 max_freq = max(max_freq, new) ok = False if max_freq == 1: ok = True elif max_freq * freq[max_freq] == n - 1: ok = True elif freq[max_freq] == 1 and (max_freq - 1) * (freq.get(max_freq - 1, 0) + 1) == n - 1: ok = True if ok: ans = n return ans这里有几个实现细节需要强调。第一,freq[old] 减到 0 时删除键,之后freq.get(max_freq - 1, 0)就足够安全。第二,freq[max_freq] 一定有键,因为 max_freq 至少来自当前新增的那个数字,它已经让对应频率的计数加了一,所以可以直接用中括号。第三,形态二和形态三的 n - 1 都写的是当前前缀长度减一,千万别写成 i,i 是从 0 开始的下标,当前长度是 i + 1。
4.2 测试用例逐条解释
我建议刷题时准备一组覆盖不同情况的数据,提交前在本地跑一遍。这里列几个典型的。
| 输入 | 输出 | 原因 |
|---|---|---|
| [1] | 1 | 删除唯一元素后为空,只有一种数字,可以认为满足 |
| [1,1] | 2 | 删除任意一个 1,剩余一个 1,频率相同 |
| [1,1,1] | 3 | 删除一个 1,剩余两个 1,仍只有一个数字 |
| [1,2] | 2 | 删除任意一个,剩余单个数字,频率相同 |
| [1,2,3,4,5] | 5 | 所有数字出现频率都是 1,删除任意一个即可 |
| [2,2,1,1,5,3,3,5] | 7 | 前缀 8 不满足,前缀 7 删除唯一的 5 后剩余频率都是 2 |
| [1,1,1,2,2,2,3,3,3,4,4,4,5] | 13 | 删除唯一出现 1 次的 5,其余数字频率均为 3 |
| [1,1,2,2,3,3,3] | 7 | 删除一个 3 后,1、2、3 出现次数均变为 2 |
| [1,1,1,2,2,3] | 不返回 6 | 删除 3 后频率为 3、2;删除一个 1 后频率为 2、2、1,均不均衡 |
最后一行我写“不返回 6”是为了提醒:不是所有看起来接近均衡的数组都满足。测试时把这种反例放进去,能有效检验条件是否过拟合。你在本地可以用 true 或 false 判断某个前缀是否满足,而 LeetCode 要求的是最长前缀长度,所以要用一个变量记录最近一次满足的 n。
4.3 其他语言的实现要点
C++ 和 Java 的实现思路完全一样,只是数据结构写法不同。C++ 可以用 unordered_map 或者 vector。如果 nums[i] 的范围小,用 vector 更快:cnt 开成数字最大值 + 1,freq 开成 n + 1,max_freq 是整数。但题目并没有保证值域,所以最稳还是 unordered_map。
C++ 里更新 freq 时要注意,freq[old]在 old=0 时不能直接减,要判断 old > 0。另外查询 max_freq - 1 时,如果直接用freq[max_freq - 1],[]运算符会自动插入一个默认 0 的键,会影响循环次数,但不会影响结果判断。如果在意,用freq.find(max_freq - 1) != freq.end()或者 C++17 的if (auto it = freq.find(...); it != freq.end())。
Java 使用 HashMap<Integer, Integer>,更新时需要用getOrDefault。删除键为 0 的项可以用remove(key, 0)这个方法,它会仅当当前映射值等于 0 时删除,非常方便。如果你想把 freq 数组化,因为出现次数最大值不会超过数组长度 n,可以直接int[] freq = new int[n + 2];,然后freq[old]--和freq[new]++,省去很多空指针判断。
4.4 答案初始值设定为 0 还是 1
我的代码里答案初始值是 0,但数组长度至少为 1,遍历第一轮时 maxFreq == 1,就会把 ans 更新成 1。所以从 0 开始是安全的。也有题解习惯把 ans 初始化成 1,因为只有一个元素时无论如何都满足。两者都可以,但如果你把 ans 初始化成 1,要注意一些极端情况:比如测试用例只有一个元素,那么循环里即使我漏掉了判断,答案也正确,反而会掩盖 bug。所以我更推荐答案初始 0,让第一轮判断真实执行,一旦漏条件就能暴露出来。
还有一个容易误解的点:长度为 1 的前缀删除一个元素后是空数组,“每个元素出现次数相同”这个命题在空集上通常被认为是真,LeetCode 的数据和标准题解也接受了这个情况。所以答案至少会是 1。如果实在不放心,在代码开头加一句if len(nums) <= 1: return len(nums)也没问题,只是对最终逻辑没影响。
5. 常见问题与排查技巧
5.1 为什么我的答案总是少 1
最常见的原因是漏掉了 maxFreq == 1 这个条件。比如 [1,2,3,4,5],maxFreq = 1,freq[1] = 5。形态二的等式是 15 = 5,不等于 4;形态三的等式左边是 0(freq[0]+1) = 0,也不等于 4。如果只写了形态二和形态三,这种全不相同的数组永远判不合法,答案就会停留在之前某个较短的位置。另一个常见原因是把 n 写成了 i。比如当前前缀长度是 7,但 i 是 6,判断式里用 i-1 自然少 2,答案也跟着偏小。editing 这种下标问题在 Python 里尤其容易发生,因为 enumerate 给的是从 0 开始的下标。
5.2 更新 freq 顺序写错的坑
错误版本可能是这样:先cnt[x] += 1,再freq[old] -= 1,最后freq[new] += 1。表面看影响不大,但实际会出问题。假设 x 之前出现 1 次,freq[1] 是 1。先更新 cnt[x] = 2,此时 freq[1] 还没动,再执行 freq[old] -= 1,实际上是在把“出现 1 次的数字数量”减一。这个动作本身是对的,但如果之后又执行 freq[new] += 1,而 new 也是 2,逻辑也能对上。真正的坑在于,如果 old 对应的键已经减到 0,你没有删除它,而后面的判断又依赖 freq[maxFreq - 1] 这样的值,残留的 0 不会造成错误,但在调试打印时很难看。
更严重的问题出现在 old = 0 且你写了freq[old] -= 1。这时会把 freq[0] 减成 -1,虽然 0 档位不参与判断,但对后续 key 的清理造成干扰。所以我建议严格区分:old == 0 时跳过旧档位更新,old > 0 时再减旧档位。这类顺序问题靠背诵容易忘,最好的办法是每次更新后打印 freq 和 cnt,跑一组短数据,肉眼看档位变化是否符合“从 old 挪到 old+1”。
5.3 条件判断要不要用 get
Python 中freq[maxFreq]一定有 key,因为 maxFreq 至少由当前新更新的数字产生,freq 里必然会存在它。但freq[maxFreq - 1]不一定存在,需要用freq.get(maxFreq - 1, 0)。如果直接freq[maxFreq - 1],当这个键不存在时,会在判断时插入一个新键,值为 0。虽然不会直接让结果出错,但插入了新键会导致后续循环里 freq 的键越来越多,影响性能,也容易在调试时造成困惑。
同理,freq[old]在 old > 0 时理论上是存在的,但如果前面某轮更新时没有删除归零的键,它也可能存在且值为 0。用freq[old] -= 1会把 0 减成 -1,产生脏数据。所以我建议每次减完旧档位后,如果值等于 0 就删除键。这样后续所有访问都更安全。还有一个隐藏问题:如果你用defaultdict(int)去存 freq,那么查询一个不存在的键会自动创建为 0,然后判断表达式里出现freq.get(max_freq - 1, 0)还好,但如果写成freq[max_freq - 1],会在判断时把键插入,所以我在这里偏好普通 dict。
5.4 本地对拍:先写暴力验证
推荐一个非常实用的做法:写一个 O(n^2) 的暴力验证函数,随机生成 nums,和线性解法跑同样的样例,确保条件不漏。暴力的判断可以这样写:对每个前缀统计 Counter,枚举要删除的数字,也就是遍历 cnt 的 key,模拟 cnt[x] - 1,然后看剩余非零频数是否全部相等。这种暴力虽然慢,但用来生成正确答案非常可靠。跑一两百组随机数据,很容易发现公式漏洞。
我自己做这道题时,就是因为写了暴力对拍才找到形态二少写条件的 bug。如果只靠 LeetCode 的十几个测试用例,很可能带着错误思路赛后才发现。对拍脚本不需要提交,只放在本地编辑器里,数据规模设小一点,比如 n 不超过 10,随机生成 1000 组,线性解法和暴力结果一致后再提交,心里会踏实很多。
6. 从这道题学到的通用套路
6.1 “删除一个元素使所有频率相等”的同类题
LeetCode 2423 是“删除字符使频率相同”,思路几乎一样,只不过针对字符串,且返回布尔值。掌握 1224 的形态分类后,2423 就是它的固定长度版本。另外 LeetCode 周赛里出现过很多“操作一个位置后让数组或字符串满足某性质”的题,通常先把目标状态分类成少数几种,再用哈希表或计数数组维护。1224 的困难点不是代码量,而是你能不能想到用“频率的频率”来表示状态。想通了,代码不到 30 行,甚至可以迁移到 2423 的题解里。
还有一类更常见的题,比如“最少删除几个字符使频率唯一”,需要贪心调整 freq 表。这类题的核心也是先统计 cnt 再统计 freq,只不过操作从“删一个元素”变成了“删除多个字符”,判定逻辑变成了“频率不能重复”。刷题时把这几道放在一起对比,你会发现 1224 建立的状态抽象能力是后面很多中高难度题的基石。
6.2 为什么会想不到“频率的频率”这个状态
刷题时遇到“删除”“翻转”这类操作,先不要急着模拟操作,而是问自己:操作前后的状态空间能否压缩?删除一个元素,影响的不是一个元素的“内容”,而是它的出现次数,进而影响的是出现次数的直方图。用一个 freq 表去记录直方图,是最自然的状态压缩。这个思维模型在字符串、数字数组、树形结构里都适用。
很多人一开始会盯着 cnt 表,试图判断“有没有哪个数字出现次数不同”,这样也能推,但很容易漏。换成 freq 表之后,所有判断都变成对少数几个整数的比较,逻辑瞬间清晰。这就是为什么我反复强调“频率的频率”:它把原本散落在不同数字上的状态,聚合成了几个档位的数量。当你下次遇到“操作一个元素后全局性质”的题目时,先想想能不能构造一个直方图来承载这个性质。
6.3 我踩过的一个坑
第一次写的时候,我把形态二写成了freq[maxFreq] == 1 && maxFreq * freq[maxFreq] == n - 1,多加了一个“最大频率数字唯一”的条件。结果遇到 [2,2,1,1,5,3,3] 这种多个数字同时出现 maxFreq=2 的情况,形态二其实合法,但我误判为不合法,答案少了一段。后来才明白,形态二删除的是低频数字,不是高频数字,所以不需要高频唯一。类似的,形态三才要求高频唯一。这种“高频唯一”和“低频唯一”的差异非常容易搞混,建议把两个条件写在注释里:删除哪个、删除后变几档、剩余几档。
再看形态二,它其实允许高频数字有多个,因为删除的对象是低频的那个数字。比如 [1,1,2,2,3],1 和 2 都出现 2 次,删除低频 3 后,剩下的 1 和 2 还是 2 次,相等。所以形态二里 freq[maxFreq] 完全可以是大于 1 的。而形态三删除的是高频数字,如果高频数字有多个,删掉一个后,被删的那个数字变成 maxFreq-1,另一个高频数字仍然 maxFreq,两者就不相等了,因此形态三必须要求 freq[maxFreq] == 1。
6.4 做题时的极端用例清单
写完代码以后,我习惯手推几个极端用例:全相同,比如 [5,5,5,5];全部不同,比如 [1,2,3,4,5];只有两个数字,比如 [1,1,2,2,3];最大频率对应多个数字,比如 [1,1,2,2,3,3];低频数字有多个,比如 [1,1,2,2,3,4]。每个用例都要确认答案是否符合直觉。
全相同的情况最容易让人误判,比如 [5,5,5,5],删除一个 5 后剩下三个 5,只有一个数字,按定义所有剩余数字的频率都是 4?等等,这里要小心。删除一个 5 后,剩余数组里数字 5 出现 3 次,由于只有一种数字,频数序列是 [3],所以是相等的。因此 [5,5,5,5] 答案是 4。而 [1,1,2,2,3,3] 这种,每个数字都出现 2 次,删除任意一个后,三个数字的频率变成 1、2、2,不相等,所以不满足。把这些极端情况跑完再提交,基本不会翻车。
我个人在实际操作中还有一个习惯:如果时间允许,先把三个判定式写在纸面上,对着某个随机数组逐轮推导,而不是只依赖代码看懂。因为 LeetCode 这类困难题的难点往往不在编码,而在分类讨论是否完备。把形态一、形态二、形态三的逻辑彻底理解清楚,比背下代码重要得多。