刷力扣刷到一定阶段,很多人会有一种错觉:Easy题不值得花时间。但你真把一道Easy题放到面试里,能一次写对并说清楚原理的人,其实不多。力扣1207《独一无二的出现次数》就是这样一道题——它表面上只是“统计次数 + 判断重复”,难度标的是Easy,但它恰好把哈希表最核心的两个能力“计数”和“判重”都考了一遍。这篇我就用Java把这道题从题面到代码再到边界情况完整拆一遍,尤其是HashMap和HashSet配合使用的思路,以及那些看题解时容易被忽略的细节。
如果你正准备面试、或者刚开始刷题想建立哈希法的直觉,这篇文章应该能帮你省不少时间。我会先讲题面到底在问什么,再讲为什么用哈希法,然后给出可提交代码、踩坑记录和几个延伸变形,保证每一步都能直接落到你的代码里。
1. 题面拆解:读懂“独一无二的出现次数”
1.1 题目到底在问什么
原题是给你一个整数数组 arr,要求判断数组中每个数字的出现次数是否互不相同。
举两个例子就清楚了。arr = [1, 2, 2, 1, 1, 3],这个数组里:
- 数字 1 出现了 3 次
- 数字 2 出现了 2 次
- 数字 3 出现了 1 次
出现次数分别是 3、2、1,三个数互不相同,所以返回 true。
再看 arr = [1, 2]:
- 数字 1 出现 1 次
- 数字 2 出现 1 次
两个数字的出现次数都是 1,存在重复的次数,所以返回 false。
题目看起来很简单,但很多人的第一个坑就踩在审题上:他们以为要判断的是“数组里有没有重复元素”。实际上这题完全不在乎元素本身重复不重复,它只关心“每个元素出现的次数”这一层是否有重复。换句话说,它判断的是“次数的次数”。
1.2 一个直观的模拟过程
如果不用任何数据结构,纯靠人脑模拟,这个过程分两步:
第一步,给数组做一次人口普查,搞清楚每个数字出现了几次。第二步,把所有统计出来的“次数”集中放到一起,看有没有哪两个数字的“次数”是一样的。
你可以把数组想象成一个班级的花名册,数字就是学生姓名,出现次数就是每个学生的出勤天数。题目问的不是“有没有同名同姓的人”,而是“有没有两个人的出勤天数一样”。
这个“出勤天数”的集合,就是我们要检查的核心对象。如果你能把这个类比搞清楚,后面哈希法的思路就顺理成章了。
1.3 为什么这个条件容易读错
我见过不少人在白板上写这道题,写着写着就变成了“判断数组元素是否唯一”,写出一个 HashSet 把整个数组塞进去,然后比较 set.size() 和 arr.length。这样做对 [1, 2] 会误判为 true,但实际上应该是 false。
为什么会这样?因为人的直觉天然倾向于处理“眼前看得见的元素”,而不会主动去抽象“元素背后的次数”。这是这类题最容易出错的地方。所以读题时看到一个关键短语“出现次数”,你的大脑就要立刻切换到统计模式:要建一个“数字 -> 次数”的映射,而不是简单判断元素是否重复。
理解了题面,接下来就是选数据结构的问题。这题最自然的解法,就是哈希法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么这题是哈希法教科书级别的用例
2.1 哈希表解决的是哪两类问题
你去看力扣上所有打着“哈希”标签的题目,会发现它们本质上只解决两件事:计数和查找/判重。
- 计数:给你一堆数据,问某个东西出现了多少次。典型代表是“两数之和”里用 Map 记录每个数出现的位置,“多数元素”里统计每个数的频率。
- 查找/判重:给你一个目标,问这个东西是否已经存在过。典型代表是“环形链表”里用 Set 记录走过的节点,“最长连续序列”里用 Set 判断某个数的下一个数是否存在。
而 1207 这题非常巧妙,它把这两件事串在了一起:先用哈希表计数,再用哈希集合判重。
2.2 第一轮:用HashMap做频率统计
统计“数字 -> 出现次数”,第一反应就是 HashMap<Integer, Integer>。
为什么不用数组?因为数组下标只能是 int,你得把“数字”映射成“数组下标”,这就限制了数字的取值范围。如果题目告诉你数字范围是 [0, 100],那数组没问题;但力扣的题通常只给一个很宽泛的范围,比如 -1000 到 1000,甚至可能更大,这时候直接用数组做“数字到下标的映射”就得做偏移,而且值域一旦很大,数组的空间就浪费了。HashMap 的优势在于 key 可以是任意对象,Integer、String、甚至自定义类都可以。
遍历数组,对每个元素执行:
java复制map.put(num, map.getOrDefault(num, 0) + 1);
这里 getOrDefault 是一个非常实用的 API。它的含义是:如果 key 不存在,就返回默认值 0;如果存在,就返回当前次数。加 1 之后再放回去。
有人可能会写:
java复制if (map.containsKey(num)) {
map.put(num, map.get(num) + 1);
} else {
map.put(num, 1);
}
这样写没错,但啰嗦。用 getOrDefault 一行就能替代,而且可读性更高。我面试的时候如果看到候选人写 getOrDefault,会默认他对 Java 集合 API 是熟悉的。
2.3 第二轮:用HashSet完成唯一性校验
统计做完之后,map 的 value 集合就是所有数字的出现次数。接下来要判断“这些次数是否互不相同”。
判断一批元素是否互不相同,最简单的做法就是用 HashSet:把每个值往 Set 里塞,如果某个值塞不进去(说明已经存在),那就说明有重复。
两种写法:
写法一:先塞进去,再比较 size
java复制Set<Integer> set = new HashSet<>();
for (int count : map.values()) {
set.add(count);
}
return set.size() == map.size();
写法二:边塞边判断
java复制Set<Integer> set = new HashSet<>();
for (int count : map.values()) {
if (!set.add(count)) {
return false;
}
}
return true;
第二种写法更高效一些——一旦发现重复就立刻返回,不需要遍历完整个集合。而且 HashSet.add() 的返回值本身就是“是否新增成功”的布尔值,用这个特性可以少写一个 contains 判断。
这里要注意的是 map.values() 这个视图。Java 的 Map.values() 返回的是所有 value 的集合视图,遍历它的时间复杂度是 O(n),和遍历 keySet 一样。我们只需要 value,不需要 key,所以直接用 values() 是最省事的。
3. Java实现:从思路到可提交代码
3.1 前置准备:处理输入与边界
写代码之前先想清楚几个边界:
- 如果数组为空,按题意
1 <= arr.length <= 1000,其实不会出现空数组。但如果你自己写单元测试,空数组应当返回true还是false?严格来说,空数组没有元素,也就不存在“出现次数”,逻辑上可以认为true。但力扣的测试用例不会给你空数组,所以这点不需要过度纠缠。 - 如果数组只有一个元素,如
[7],那么数字 7 出现 1 次,次数集合是{1},没有重复,返回true。 - 如果数组所有元素都相同,如
[5, 5, 5],那么只有一种次数3,也返回true。
这些边界都不需要特判,用通用逻辑就能正确处理。真正要小心的是负数、大数等下标问题,这在后面第 4 节单独展开。
3.2 完整实现代码
直接上可以提交的完整代码:
java复制class Solution {
public boolean uniqueOccurrences(int[] arr) {
// 第一轮:统计每个数字出现的次数
Map<Integer, Integer> countMap = new HashMap<>();
for (int num : arr) {
countMap.put(num, countMap.getOrDefault(num, 0) + 1);
}
// 第二轮:判断所有次数是否互不相同
Set<Integer> timeSet = new HashSet<>();
for (int count : countMap.values()) {
if (!timeSet.add(count)) {
return false;
}
}
return true;
}
}
这段代码在力扣上是能直接通过全部测试用例的。逻辑清爽,没有多余操作。
如果你用的是 Java 8+,Map 和 Set 的泛型推断已经够用了,写 HashMap<>() 和 HashSet<>() 就行。
3.3 一个容易被忽略的优化:利用 add 的返回值
上面代码里最值得品的是这一行:
java复制if (!timeSet.add(count)) {
return false;
}
很多人第一次会写成:
java复制timeSet.add(count);
if (timeSet.contains(count)) { ... }
这样写是错的,因为 add 之后 contains 必然为 true,根本判断不了重复。更常见的“保守写法”是这样:
java复制if (timeSet.contains(count)) {
return false;
}
timeSet.add(count);
这个也能用,但多了一次哈希查找。而直接利用 add 的返回值,一次操作同时完成了“判断”和“插入”,省了一次查询。在数据量小的时候差别不大,但代码更简洁,也更能体现你对集合 API 的理解。
同样的技巧在很多题里都能用。比如“判断字符串中字符是否唯一”,就可以用一个 HashSet<Character> 边遍历边 add,根据返回值判断是否提前返回。
3.4 性能实测与复杂度分析
时间复杂度:两次遍历都是 O(n),其中 n 是 arr.length。HashMap 和 HashSet 的插入、查找平均都是 O(1),所以总时间复杂度是 O(n)。
空间复杂度:最坏情况下每个元素都不同,HashMap 需要存 n 个 key,HashSet 需要存 n 个 value,所以空间复杂度是 O(n)。
这里有一个细节值得说一下:HashSet 在存 value 时,存的这些 value 其实就是数字出现的次数。次数最大不可能超过数组长度 n,所以理论上可以用一个布尔数组来替代 HashSet,空间上会省一点。但那样做会让代码失去通用性,我不推荐在力扣这种场景为了微小的常数优化牺牲可读性。
4. 边界情况、易错点与性能取舍
4.1 边界用例梳理
我用几组典型用例验证一下这段代码:
| 输入 | 统计结果 | 次数集合 | 预期输出 |
|---|---|---|---|
[1, 2, 2, 1, 1, 3] |
1->3, 2->2, 3->1 | true | |
[1, 2] |
1->1, 2->1 | false | |
[3, 3, 3, 3] |
3->4 | true | |
[-1, 0, 1] |
-1->1, 0->1, 1->1 | false | |
[1000, 1000, -1000] |
1000->2, -1000->1 | true |
可以看到,负数和大数都能通过 HashMap 正确处理,这也是哈希法在这题上最稳的原因。
4.2 一种更极端的解法:用定长数组取代HashMap
如果你仔细看这道题的约束条件,会发现一个“作弊”的机会:
1 <= arr.length <= 1000-1000 <= arr[i] <= 1000
数值范围只有 2001 个可能值。所以完全可以用一个长度为 2001 的 int[] 来统计频率,再用一个长度为 1001 的 boolean[](或 HashSet)来判重。
代码可以写成这样:
java复制class Solution {
public boolean uniqueOccurrences(int[] arr) {
int[] freq = new int[2001];
for (int num : arr) {
freq[num + 1000]++;
}
Set<Integer> timeSet = new HashSet<>();
for (int count : freq) {
if (count != 0 && !timeSet.add(count)) {
return false;
}
}
return true;
}
}
这里要特别说明 num + 1000 这个偏移量。因为下标不能为负数,所以把最小的 -1000 映射到下标 0,最大的 1000 映射到下标 2000。
这种做法的优势是:数组访问是精确的 O(1),没有哈希计算和哈希冲突的成本,常数更小。在力扣的测试数据下,两种解法耗时差距并不大,但如果你在面试中提出来,并且能解释清楚为什么这里可以用数组代替 HashMap,面试官通常会给你加分——因为这说明你读题时注意到了数据范围,并且知道什么时候该选数组,什么时候该选 HashMap。
但它的局限也很明显:如果把数值范围改成 [-10^9, 10^9],数组方案直接爆炸。所以“用数组代替 HashMap”只适用于值域已知且较小的情况,它是一个优化技巧,不是通用解法。
4.3 踩坑记录:偏移量与索引越界
我最初写这个数组版本的时候,栽过一个跟头:忘记做偏移,直接写成 freq[num]++。结果 num = -1000 的时候直接 ArrayIndexOutOfBoundsException。
这个错误对新手来说特别容易犯,因为你脑子里想的是“用数值当下标”,但 Java 数组下标是从 0 开始的,不接受负数。做偏移时还有一个容易错的地方:偏移量到底是 1000 还是 1001。如果数组长度设成 2001,那最大下标是 2000,对应原始值 1000,所以偏移量必须是 1000。你要是把长度设成 2001、偏移量却写成 1001,最后 num = 1000 时下标变成 2001,照样越界。
我的建议是:只要记一句口诀,“先看值域跨度,再加偏移量”。值域是 [-1000, 1000],跨度是 2001,最小值是 -1000,所以偏移量就是 -1000 的绝对值 1000。换句话说,freq[num + 1000] 里的加数就是 最小值取绝对值。
5. 从这一题延伸:哈希法在力扣面试题中的两个变形方向
5.1 变形一:从“统计次数”到“判断唯一”
1207 的思路可以套到很多“看起来完全不一样”的题上。
比如“字符串中每个字符出现的次数是否唯一”——把 int[] arr 换成 String s,把 Map<Integer, Integer> 换成 Map<Character, Integer>,其余逻辑一模一样。
再比如“判断两个字符串是否互为字符重排”,本质上也是统计字符频率,然后比较两个 Map 是否相等。这类题的核心模式是:碰到“出现次数”“频率”“是否唯一”这些关键词,就立刻想到“先建频次表,再对频次做进一步处理”。
我复盘力扣热题的时候,发现哈希表的题其实就那么几个套路。1207 是最好的入门题,因为它把“计数”和“判重”这两个动作拆得清清楚楚。你把这个最小模型吃透了,再去看“两数之和”(需要查找历史元素)、“最长连续序列”(需要判断元素是否存在)、“快乐数”(需要判断循环)时,会发现底层都是同一套哈希思维。
5.2 变形二:开放地址法与哈希冲突
如果你背过“Java面试八股文”,一定见过“HashMap 的底层原理”“哈希冲突怎么办”这类问题。1207 本身没有让你手写哈希表,但它能帮你把哈希冲突这个抽象概念具象化。
Java 的 HashMap 在发生哈希冲突时,默认用的是链地址法:多个 key 映射到同一个桶,就在这个桶后面挂一个链表;当链表长度超过阈值(默认 8),且数组长度超过 64 时,会转成红黑树。而开放地址法是另一种解决冲突的策略:一旦发现目标位置被占了,就按某种探测序列继续往后找空位。
很多人在概念上区分不开这两种方法。我的建议是:刷完 1207 之后,可以顺手写一个小的开放地址法哈希表,比如把 freq 数组里的“空位”当作“探测的终点”,模拟线性探测的插入过程。这个练习能帮你把“哈希”从力扣题里的 API 调用,变成你真正理解的数据结构。
当然,面试时考开放地址法的手写实现概率不大,但如果你能在聊到 1207 时主动提一句“哈希冲突还有链地址法和开放地址法两种解决思路”,会比单纯讲完代码显得扎实很多。
5.3 面试官喜欢追问的几个点
基于这道题,面试官通常会往下追问:
- 为什么用 HashMap 而不是数组?——答:数值范围不确定,HashMap 更通用。
- 如果数值范围是 [-1000, 1000],你怎么优化?——答:用长度 2001 的数组做频次统计。
- 如果内存非常紧张,不能用额外空间怎么办?——答:先排序数组,然后遍历统计相邻相同元素的个数,再判断这些个数是否重复。时间复杂度会从 O(n) 升到 O(n log n),但空间复杂度降到 O(1)。注意排序会改变原数组,要不要先复制取决于需求。
- 如果数组元素不是 int,而是 String,代码需要改哪里?——答:只需要把
Map<Integer, Integer>换成Map<String, Integer>,遍历逻辑完全不变。
第四个问题的答案能体现你对泛型的理解。很多人死记硬背“HashMap 的 key 必须是引用类型”,但真正写的时候忘了 int 会自动装箱成 Integer。Java 的自动装箱机制让代码看起来像“Map 里可以直接放 int”,实际上存的是 Integer 对象,这一点在内存占用和 null 判断上会有微妙的影响。
我个人在刷题复盘时有个习惯:一道题提交通过之后,再花十分钟想一想“如果数据范围改一下,我的解法还成不成立”。1207 的数组版本就是这个问题的最优解,而 HashMap 版本是通用解。两者都掌握了,才是真的把这道题吃透。
最后再分享一个小技巧:刷题时遇到“统计次数”“是否唯一”“是否存在”这三个关键词,第一反应就应该是哈希表。“数字 -> 次数”用 HashMap,“次数是否重复”用 HashSet,这个条件反射建立起来之后,哈希类的 Easy 和 Medium 题你会做得特别顺。1207 作为这个套路的入门题,值得你多写几遍,直到闭着眼睛都能把 getOrDefault 和 add 的返回值用法写对。
