目录

题目描述

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 → 30 → 21 → 410 → 1,一共 4 个条目。接着遍历这些次数,遍历顺序不影响结论,这里按 -30110 的次序说明:插入 3,集合变成 {3};插入 2,集合变成 {3, 2};插入 4,集合变成 {3, 2, 4};插入 1,集合变成 {3, 2, 4, 1}。四次插入全部成功,集合大小 4 恰好等于条目数 4,返回 true。注意这个数组里负数 -3 直接当了键,完全没有额外处理。

再看一个 false 的用例 [1,2]:计数表是 1 → 12 → 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 里 panic runtime 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,一个用例都过不去。改用 getOrDefaultmerge
  • 先插入集合再检查是否存在:喂任意用例,例如 [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. 找出两数组的不同 简单 用集合做存在性差集,只关心元素在不在,不涉及出现次数