LeetCode 1207. 独一无二的出现次数
题目描述
题意分析
给定整数数组
arr,把每个值的出现次数都算出来,问这一组次数是否互不相同:只要存在两个不同的值出现次数相同就返回false,否则返回true。最容易读错的地方在这里:题目问的是次数之间是否互不相同,而不是元素之间是否互不相同。这两件事互相推不出来。
[1,2,2,1,1,3]的元素明显有重复,但三个次数 3、2、1 互不相同,答案是true;[1,2]的元素完全互不相同,可两个次数都是 1,答案反而是false。所以直接把「判重」的思路套过来,在这两类用例上会各错一次。约束里数组长度至少为 1,不必考虑空数组。单元素数组只有一个值、一个次数,天然不存在两个次数相等的情况,答案必为
true。次数本身一定是正整数,不会出现 0 这种「没出现过」的干扰值,次数的上界就是数组长度。另一个必须注意的信号是元素取值可以为负。这意味着不能直接把元素值当下标去开一个定长的计数容器,负下标会当场越界;除非先把整个取值区间平移到非负。因此更稳妥的是选一种对键的取值范围不作假设的计数方式。
解法:哈希计数加次数判重
核心思路
这道题天然分成两个互不干扰的阶段:先算出「每个值出现了几次」,再判断算出来的这组次数里有没有重复。把两件事分开想,比试图在一次遍历里同时完成要清晰得多。
第一阶段按值计数,键是元素值、值是出现次数。选哈希表而不是定长数组,正是因为元素可能为负、取值也可能稀疏,而哈希表对键的取值范围没有任何假设,负数和零都能直接当键,不需要做偏移。一次遍历之后,每个条目就对应一个「值 → 次数」,条目数等于不同元素的个数。
第二阶段要回答的是「一组数是否互不相同」,这件事的标准手法是把它们逐个放进一个集合。集合天生去重,于是有两种等价写法:一种是全部放完后比较集合大小与这组数的个数,相等说明没有任何一个被吞掉,即互不相同;另一种是插入时就检查,一旦插入的次数已经存在,说明撞上重复,可以立刻返回失败。后者更省事,能在发现问题的瞬间结束,不必把剩下的次数也走完。
正确性来自两点。计数阶段每个元素恰好被访问一次,所以每个值的次数都是准确的。判重阶段把「存在两个不同的值出现次数相同」等价地翻译成「存在一个次数被插入了两次」,这两个说法是同一件事——因为计数表的键本来就互不相同,每个条目只贡献一个次数,次数撞车必然来自两个不同的值。
解题步骤
- 建一个空的计数哈希表,键为元素值、值为出现次数。这里用哈希表是为了让负数元素也能直接做键,不必考虑下标偏移。
- 遍历
arr,把每个元素对应的计数加一。取旧值时必须给不存在的键约定默认值 0,否则第一次遇到某个元素会读到空值。- 再建一个空集合,用来收集已经见过的次数。集合的意义是把「这个次数出现过吗」的判断压到常数级。
- 遍历计数表的所有值,也就是次数,逐个尝试插入集合。遍历值而不是键,因为要比较的对象是次数;键本来就互不相同,比较它们没有任何信息量。
- 某个次数插入失败(集合里已经有它),说明有两个不同的元素出现次数相同,立即返回
false。- 所有次数都插入成功,说明这组次数互不相同,返回
true。以
[-3,0,1,-3,1,1,1,-3,10,0]走一遍:计数阶段结束后,计数表的内容是-3 → 3、0 → 2、1 → 4、10 → 1,一共 4 个条目。接着遍历这些次数,遍历顺序不影响结论,这里按-3、0、1、10的次序说明:插入 3,集合变成{3};插入 2,集合变成{3, 2};插入 4,集合变成{3, 2, 4};插入 1,集合变成{3, 2, 4, 1}。四次插入全部成功,集合大小 4 恰好等于条目数 4,返回true。注意这个数组里负数-3直接当了键,完全没有额外处理。再看一个
false的用例[1,2]:计数表是1 → 1、2 → 1,两个条目。插入第一个次数 1,集合变成{1};插入第二个次数 1 时,集合里已经有 1,插入失败,立即返回false。这个数组的元素是互不相同的而答案是false,正好印证前面那句——两个「互不相同」问的根本不是一回事。
代码实现
class Solution {
public boolean uniqueOccurrences(int[] arr) {
Map<Integer, Integer> count = new HashMap<>();
for (int num : arr) {
// 第一阶段:按值计数,负数也能直接当键,不需要下标偏移。
count.put(num, count.getOrDefault(num, 0) + 1);
}
Set<Integer> times = new HashSet<>();
for (int c : count.values()) {
// 遍历的是值(次数)而不是键;add 返回 false 说明这个次数已出现过。
if (!times.add(c)) {
return false;
}
}
return true;
}
}
func uniqueOccurrences(arr []int) bool {
count := make(map[int]int)
for _, num := range arr {
// 第一阶段:map 的零值就是 0,可以直接自增。
count[num]++
}
times := make(map[int]bool)
for _, c := range count {
// 先查再写:已经见过这个次数说明有两个值的出现次数相同。
if times[c] {
return false
}
times[c] = true
}
return true
}
复杂度分析
- 时间复杂度:$O(n)$,$n$ 为
arr的长度。计数阶段遍历数组一次,判重阶段遍历的条目数不超过 $n$,每次哈希查询与插入平均为 $O(1)$。- 空间复杂度:$O(n)$,最坏情况下所有元素互不相同,计数表要存 $n$ 个条目,次数集合再存至多 $n$ 个次数。
关键点总结
- 判断一组数是否互不相同,标准做法是全部放进集合后比较集合大小与元素个数;若想提前退出,就在插入时检查是否已存在,用插入操作本身的返回值当判据。
- 「元素互不相同」和「元素的出现次数互不相同」是两个独立性质,任何一个都推不出另一个,读题时必须落实到底问的是哪一层。
- 按值计数时,键的取值范围决定容器选择:范围小且非负可以用定长数组,含负数或范围未知就用哈希表,或者对下标做统一偏移。
- 计数类问题往往能拆成「先统计、再对统计结果做判断」两个独立阶段,各自遍历一次,比挤在一次遍历里同时完成更不容易出错。
- 从计数结构里取数据前先想清楚要的是键还是值:问元素本身的性质取键,问频次的性质取值。
- 累加计数前必须为缺失的键约定默认值,这是所有「读—改—写」计数循环的共同前提。
易错点总结
- 把题意做成判断元素是否互不相同:喂
[1,2,2,1,1,3]→ 因为元素有重复而返回false,正确答案是true;同一份代码喂[1,2]→ 因为元素互不相同而返回true,正确答案是false。两类用例各错一次,是本题最常见的失分点。- 用固定长度数组当计数桶却不处理负数下标:喂
[-3,0,1,-3,1,1,1,-3,10,0]→ Java 的cnt[-3]++抛ArrayIndexOutOfBoundsException: Index -3 out of bounds for length 1001,Go 里 panicruntime error: index out of range [-3]。不是答案错,是运行时直接崩掉;要用定长数组就必须先把下标平移到非负区间。- 拿次数集合的大小去和数组长度比较:喂
[1,2,2,1,1,3]→ 次数集合是{1,2,3},大小 3,与数组长度 6 不等而返回false,正确答案是true。该比的是集合大小与计数表的条目数,不是原数组长度。- 累加计数时直接读取缺失的键:Java 写成
count.put(num, count.get(num) + 1),喂[1,2,2,1,1,3]→ 第一次遇到元素 1 时get返回null,拆箱抛NullPointerException,一个用例都过不去。改用getOrDefault或merge。- 先插入集合再检查是否存在:喂任意用例,例如
[1,2,2,1,1,3]→ 刚插进去的次数当然查得到,判断恒成立,函数在第一个次数上就返回false,等于所有输入都答false。顺序必须是先查后插,或者直接用插入操作的返回值。- 把计数表的键而不是值放进集合:喂
[1,2]→ 键{1,2}本来就互不相同,集合大小 2 等于条目数 2,返回true,正确答案是false。这种写法对任何输入都只会返回true。- Go 里只判断次数是否见过却忘了写回集合:喂
[1,2]→times始终是空的,判断永远不成立,返回true,正确答案是false。同理,恒返回true。- Java 里用
==比较两个次数:构造两个元素各出现 200 次的数组(长度 400)→ 两个Integer的值都是 200,超出Integer的缓存范围,==比的是引用而得到false,函数误判成「次数互不相同」返回true,正确答案是false;而次数都是 2 的[1,1,2,2]因为落在缓存内反倒侥幸答对,属于随数据规模时对时错的隐蔽错误。比较装箱整数要用equals或先取出int。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 217. 存在重复元素 | 简单 | 只问元素本身是否重复,完全不统计次数,正是本题最容易被误解成的那道题 |
| 242. 有效的字母异位词 | 简单 | 同样先做频次统计,但比较的是两个计数表是否逐键相等,而非次数之间是否互不相同 |
| 347. 前 K 个高频元素 | 中等 | 统计完次数后还要按次数大小排序取前 K,关心次数的排名而不是次数是否撞车 |
| 387. 字符串中的第一个唯一字符 | 简单 | 统计完次数后要回到原序列定位次数恰为 1 的首个位置,关心单个次数的具体取值 |
| 1657. 确定两个字符串是否接近 | 中等 | 要比较两个字符串的次数多重集合是否相同,是在本题的次数视角上再进一步 |
| 2215. 找出两数组的不同 | 简单 | 用集合做存在性差集,只关心元素在不在,不涉及出现次数 |