题目描述

✅ 137. 只出现一次的数字 II

image-20260928220258382

题意分析

数组中恰好有一个数出现一次,其他每个不同的数都出现三次,返回那个只出现一次的数值。数组允许零和负数,数值范围覆盖完整的 32 位有符号整数。

要求线性时间和常数额外空间,因此不能靠保存所有频次来求解。这里的重复次数是三次,直接把所有数异或并不能消去重复项。

解法一:逐位计数取模

核心思路

[!blue]

把问题拆成 32 个独立的二进制位。对某一位,出现三次的同一个数要么贡献三个一,要么贡献三个零;所有重复数合起来,该位的一的总数必然是三的倍数。

另外只出现一次的目标数,在这一位只能贡献零或一。因此将该位总计数对三取模,余数就恰好是目标数的这一位。对所有位重复统计,再把余数为一的位置设为一,就能重建整个答案。

负数无需单独取绝对值,因为同一个负数的补码也会完全重复三次,同样满足逐位消去的条件。必须处理第 31 位符号位,否则只能恢复低 31 位,负数会失去符号。

Java 用无符号右移后与一取与,提取指定位置,再直接拼回 int 的补码。Go 先转换为 uint32,固定只处理低 32 位,重建后转为 int32 恢复符号,最后再转为返回类型 int。

解题步骤

  1. 初始化答案为零,依次枚举第 0 位到第 31 位。
  2. 对当前位扫描整个数组,取出每个数在该位的零或一并累加。
  3. 计数对三取模非零时,把答案对应位置设为一。
  4. 处理完全部 32 位后,按有符号整数返回重建结果。

代码实现

class Solution {
    public int singleNumber(int[] nums) {
        int result = 0;

        for (int bit = 0; bit < 32; bit++) {
            int count = 0;

            for (int num : nums) {
                count += (num >>> bit) & 1;
            }

            // 三次重复的位贡献被模三消去,剩下的是单次数在本位的值。
            if (count % 3 != 0) {
                result |= 1 << bit;
            }
        }

        return result;
    }
}
func singleNumber(nums []int) int {
    var result uint32
    for bit := 0; bit < 32; bit++ {
        count := 0
        for _, num := range nums {
            count += int((uint32(num) >> bit) & 1)
        }
        // 三次重复的位贡献被模三消去,剩下的是单次数在本位的值。
        if count%3 != 0 {
            result |= uint32(1) << bit
        }
    }
    // 先恢复三十二位符号,再转换到返回类型。
    return int(int32(result))
}

复杂度分析

  • 时间复杂度:$O(32n)$,固定扫描 32 个位,每位遍历全部元素。
  • 空间复杂度:$O(1)$,只记录当前位计数与结果。

关键点总结

[!green]

  • 三次重复按位贡献均为三的倍数,取模后只剩单次数的位。
  • 必须包含第三十一位,负数同样通过补码还原。
  • Go 的 uint32 → int32 → int 转换负责把符号位还原为负值。

解法二:双变量位状态机

核心思路

[!blue]

第一种方法本质上只需要知道每个位的一出现次数对三的余数。可以用两个整数的对应位一起编码这个余数,从而一次位运算就同时更新全部位,无需分别扫描 32 遍。

对任意一位,(twos, ones) 的 00 表示余数零,01 表示余数一,10 表示余数二。读到零时状态不变,读到一时按 00 → 01 → 10 → 00 循环;11 不应出现。

先算 ones = (ones ^ num) & ~twos。当旧 twos 为零时,异或让第一次出现的一进入 ones、第二次出现的一离开;当旧 twos 为一时,说明已经累计两次,掩码强制新的 ones 仍为零,第三次不会重新进入一次状态。

再算 twos = (twos ^ num) & ~ones,这里必须读取刚更新的 ones。第一次读到一时,新 ones 为一,会屏蔽 twos;第二次时新 ones 为零,允许 twos 置一;第三次时异或把原来的 twos 清零。两式恰好完成所需的三状态循环。

两个变量的每一位都独立执行这套更新。所有三次重复的贡献最终归零,目标数为一的位停在 ones 中,因此直接返回 ones;符号位也遵循同样规则。Go 的 &^ 表示清除右侧为一的位,对应 Java 的 & ~。

解题步骤

  1. 初始化 ones = 0、twos = 0,每个位的计数余数都为零。
  2. 依次读取 num,先用旧 ones、旧 twos 更新 ones。
  3. 用旧 twos 和已经更新的 ones 更新 twos,不能将两条语句改为同时使用旧值的赋值。
  4. 扫描结束后返回 ones,其中保存了单次出现数的全部二进制位。

代码实现

class Solution {
    public int singleNumber(int[] nums) {
        int ones = 0;
        int twos = 0;

        for (int num : nums) {
            // 各位并行维护模三状态,第二行必须读取更新后的 ones。
            ones = (ones ^ num) & ~twos;
            twos = (twos ^ num) & ~ones;
        }

        return ones;
    }
}
func singleNumber(nums []int) int {
    ones, twos := 0, 0
    for _, num := range nums {
        // 各位并行维护模三状态,第二行必须读取更新后的 ones。
        ones = (ones ^ num) &^ twos
        twos = (twos ^ num) &^ ones
    }
    return ones
}

复杂度分析

  • 时间复杂度:$O(n)$,每个元素只触发常数次位运算。
  • 空间复杂度:$O(1)$,只使用两个状态变量。

关键点总结

[!green]

  • 状态机是“逐位计数后模 3”的并行写法;ones、twos 分别编码余数 1、2。
  • 更新顺序属于公式的一部分:先算 ones,再让 twos 排除新的 ones。
  • 位运算直接作用于补码,符号位与普通位没有区别,因此正负数统一处理。

易错点总结

[!yellow]

  • 同时用旧值计算 ones 和 twos:第一次读入某个为 1 的位后,两者都会置 1,产生不存在的 11 状态。
  • 漏掉清除掩码,只写 ones ^= num、twos ^= num:状态不会在第三次出现时归零。
  • 套用 136 题的单纯异或:一个数字异或三次仍等于自身,无法消掉出现三次的元素。
  • 位计数不能漏掉符号位或先取绝对值;Go 直接把无符号结果转成 64 位 int 会把负数变成正数,必须先转 int32。

相似题目

题目 难度 关联与区别
136. 只出现一次的数字 简单 原题其他值出现两次,可直接异或;本题出现三次,要按位统计模3或用状态机。
260. 只出现一次的数字 III 中等 同样寻找少数例外值,原题有两个单次值,可按异或差异位分组。
191. 位1的个数 简单 按位统计重复模式并重建答案;本题各位计数对三取模,该题统计一个整数的置位数。
338. 比特位计数 简单 按位统计重复模式并重建答案;本题各位计数对三取模,该题利用去掉最低置位或右移结果递推计数。
补充题 115. 其余元素出现 k 次的唯一数 简单 都逐位统计 1 的出现次数;本题按 3 取模,补充题改为按 k 取模。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/65530861
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!