136.只出现一次的数字(异或运算及其扩展)

给你一个 非空 整数数组 nums ,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

你必须设计并实现线性时间复杂度的算法来解决此问题,且该算法只使用常量额外空间。

示例 1 :

输入: nums = [2,2,1]

输出: 1

示例 2 :

输入: nums = [4,1,2,1,2]

输出: 4

示例 3 :

输入: nums = [1]

输出: 1

提示:

  • 1 <= nums.length <= 3 * 104
  • -3 * 104 <= nums[i] <= 3 * 104
  • 除了某个元素只出现一次以外,其余每个元素均出现两次。

核心观察(关键思路)

使用 按位异或(XOR) 的性质:

  • a ^ a = 0(相同数异或为 0)
  • a ^ 0 = a
  • 异或运算满足交换律和结合律(顺序无关)

因此,把数组中所有元素全部异或在一起,成对出现的元素两两抵消(因为 x ^ x = 0),最终只剩下那个只出现一次的元素。这个过程只需维护一个变量来累积异或结果,空间 O(1),遍历一次,时间 O(n)。


举例演示(逐步运行)

例子 nums = [4,1,2,1,2](二进制用 3 位表示):

  • 初始 ans = 0 (000)
  • ans ^ 4 = 000 ^ 100 = 100 (4)
  • ans ^ 1 = 100 ^ 001 = 101 (5)
  • ans ^ 2 = 101 ^ 010 = 111 (7)
  • ans ^ 1 = 111 ^ 001 = 110 (6)
  • ans ^ 2 = 110 ^ 010 = 100 (4) ------ 最终为 4

再看简短例子 nums = [2,2,1]

  • 0 ^ 2 = 2
  • 2 ^ 2 = 0
  • 0 ^ 1 = 1 ------ 结果 1

注意:负数也没问题,因为异或是按位在二补表示上进行的,依然成立。


证明(简单形式)

设数组中每个成对出现的元素是 a1,a1,a2,a2,...,ak,ak,唯一元素是 x

总异或结果为:a1 ^ a1 ^ a2 ^ a2 ^ ... ^ ak ^ ak ^ x

根据 a ^ a = 0,所有成对的均化为 0,剩下 0 ^ x = x,故结果就是 x


算法复杂度

  • 时间:遍历一次数组,O(n)。
  • 空间:只使用常数个整型变量,O(1)。

推荐实现(Java,带详细注释)

arduino 复制代码
// Java 实现:线性时间,常数额外空间
class Solution {
    public int singleNumber(int[] nums) {
        int ans = 0;                 // 累积异或结果,初始为0
        for (int num : nums) {
            ans ^= num;             // 将当前元素异或到累积值上
            // 由于相同数字两次异或会抵消(x ^ x = 0),
            // 最终ans 会是只出现一次的那个数
        }
        return ans;
    }
}

Python 版本(同理):

matlab 复制代码
def singleNumber(nums):
    ans = 0
    for num in nums:
        ans ^= num
    return ans

其它解法与比较

  1. 哈希表计数 :用 Map/HashMap 统计频次,找到频次为 1 的元素。

    • 时间 O(n),但空间 O(n)。不满足题目"常量额外空间"的限制。
  2. 排序后扫描:先排序再线性扫描找单独元素。

    • 时间 O(n log n),不满足线性时间要求。

因此,XOR 方法是最简洁且完全符合「线性时间 + 常数空间」要求的解。


拓展(若不是"出现两次",比如"出现三次"的情形)

若"除了某个元素出现一次外,其他元素都出现 三次 ",可以用 按位计数 的方法:对 32 位的每一位统计数组中该位 1 的个数,对 3 取模(count[i] % 3)得到该位在唯一元素中的值,最后把各位重建成整数。时间仍为 O(32·n)=O(n),额外空间为常数(32 个计数器)。这是常见的推广技巧。

思路总览(题目变体:其它数字都出现 3 次 ,只有一个数字出现 1 次

要在 线性时间 O(n)常数额外空间 O(1) 下找出只出现 1 次的那个数,常见且可靠的两种做法:

  1. 按位计数法(直观) ------ 对整数的每一位(32 位)统计所有数中该位的 1 的个数,% 3 后得到唯一数该位的值。时间 O(32·n)=O(n),额外空间 O(1)(常量大小的数组)。
  2. 位状态机法(位运算技巧,最优) ------ 用两个整数 onestwos 跟踪每个位出现 1 次或 2 次的状态,通过位运算在遍历中完成"模 3 计数"。时间 O(n),额外空间 O(1)(只用常数个变量)。这是面试中常见且优秀的解法。

下面我先详细讲解 按位计数法 (好理解,易证明、稳健),再给出 位状态机法(更精巧,常用)的 Java 实现并带详注与示例。


方法一:按位计数(逐位统计 1 的个数 % 3)

原理

将所有数的每一位(0..31)上的 1 的个数加起来,记为 count[i]

因为除了唯一数之外其他每个数都出现 3 次,所以对于任一位 i,来自成组三个相同数的贡献是 03 的倍数,取模 3 后为 0。把 count[i] % 3 就得到唯一数在第 i 位上的真实比特(0 或 1)。把这些位重建回去就是答案。

注意负数:Java 使用二补表示(32 位),按位统计包含符号位(第 31 位),同样适用。构造结果时把第 31 位也设置上,得到的 int 会自动是负数(若最高位为 1)------这正是我们想要的结果。

代码(Java,详细注释)

arduino 复制代码
public class Solution {
    /**
     * 按位计数法:对 32 位每一位统计所有 nums 中该位为 1 的次数,然后对 3 取模重建唯一元素。
     *
     * 时间复杂度:O(32 * n) = O(n)
     * 空间复杂度:O(1)(counts 长度固定为 32)
     */
    public int singleNumber(int[] nums) {
        // counts[i] 表示所有 nums 中第 i 位(从 0 LSB 到 31 MSB)1 的总次数
        int[] counts = new int[32];

        // 对每个数的每一位累加
        for (int num : nums) {
            // 使用无符号右移 >>> ,避免符号扩展带来理解上的混淆
            for (int i = 0; i < 32; i++) {
                counts[i] += (num >>> i) & 1; // 取出 num 的第 i 位(0 或 1)
            }
        }

        // 现在每一位的计数对 3 取模,重建结果
        int result = 0;
        for (int i = 0; i < 32; i++) {
            int bit = counts[i] % 3;     // bit ∈ {0,1} ------ 唯一元素第 i 位的值
            // 将该位放回 result 中
            result |= (bit << i);
            // 当 i = 31 且 bit = 1 时,(1 << 31) 会把符号位置为 1,
            // result 最终会成为负数 ------ 这是正确并且符合二补表示的。
        }

        return result;
    }
}

举例演示

nums = [2,2,3,2]

  • 2(二进制 10),3(二进制 11)
  • 对每个位统计:LSB(位0) 的 1 的个数是 2(来自三个 2 的 LSB 都是 0,只有 3 的 LSB 是 1,统计得到 1 ? wait,具体步演可跟代码跑)------最终 count % 3 得到唯一数 3 的每位。
    (实现可用小例子逐位打印,代码逻辑保证正确。)

方法二:位状态机(ones & twos,常见且高效)

原理(直观版)

对每一位独立来看,数字出现次数会在 0 -> 1 -> 2 -> 0(模 3)循环。我们用两个位掩码 onestwos 来记录当前遍历到的位置,每一位的状态用这两个掩码的组合表示:

  • 对于某一位 b 的三个状态(出现次数 mod 3):

    • 00 表示出现 0 次
    • 01 表示出现 1 次 (存储在 ones
    • 10 表示出现 2 次 (存储在 twos
    • 当出现第 3 次时,要从 01/10 都清零(回到 00

通过巧妙的位运算更新 onestwos,可以在一次遍历中完成每位的模 3 计数。常用更新公式(顺序执行):

ini 复制代码
ones = (ones ^ num) & ~twos;
twos = (twos ^ num) & ~ones;
  • ones ^ num:把当前 num 的位加入到 ones(异或实现"出现/取消")
  • & ~twos:如果某个位已经在 twos 中(表示该位已经出现了两次),则不要把它放到 ones 中(保持状态一致)
  • 第二行利用更新后的 ones 继续计算 twos(并排除已转入 ones 的位)

最终 ones 保存出现 1 次的位 ------ 就是我们要找的那个唯一数的位模式。

代码(Java,详细注释)

arduino 复制代码
public class Solution {
    /**
     * 位状态机法:只用两个整型变量 ones 和 twos 跟踪每个位出现 1 次或 2 次的状态。
     *
     * 时间复杂度:O(n)
     * 空间复杂度:O(1)
     */
    public int singleNumber(int[] nums) {
        int ones = 0; // 存放当前位出现过 1 次(模 3 后)的位集合
        int twos = 0; // 存放当前位出现过 2 次(模 3 后)的位集合

        for (int num : nums) {
            // 先更新 ones:把 num 的位异或进 ones,但排除那些已经在 twos 中的位
            // (ones ^ num) 会把 num 的位翻转到 ones(出现奇数次保留)
            // & ~twos 确保如果某位已经出现两次(twos=1),则不再把它放回 ones
            ones = (ones ^ num) & ~twos;

            // 再更新 twos:把 num 的位异或进 twos,但排除那些已在 ones 中的位
            // 注意这里用到的是更新后的 ones(保证状态推进正确)
            twos = (twos ^ num) & ~ones;
        }

        // 遍历结束后,ones 存放的就是只出现一次的那个数的各个位
        return ones;
    }
}

为什么有效(简要说明)

对于每一位,ones/twos 的更新实现了以下有限状态转换(按出现次数):

  • 初始 00(ones=0, twos=0)
  • 第一次遇到该位为 1 → 01(ones 置 1)
  • 第二次 → 10(从 ones 移到 twos)
  • 第三次 → 00(从 twos 清零)
  • 如此循环,最终只出现一次的数会导致那一位停留在 01(即 ones)上。

此方法不需要 32 个计数器,空间更省、位运算速度也更快。


两种方法的对比与选择建议

  • 按位计数法

    • 易于理解、实现,稳健。
    • 时间常数因子稍大(32 次位循环),但仍然是线性时间。
    • 代码直观,便于调试/打印每位计数。
  • 位状态机法(ones/twos)

    • 更优雅、真正常数空间且常数因子更小。
    • 面试常见答案,但逻辑稍微难以直观理解,建议能讲清楚状态机转换再使用。
    • 推荐用于追求最优实现或面试展示。

测试例子(建议在 IDE 中跑)

  • 示例 1:[2,2,3,2] → 输出 3
  • 示例 2(含负数):[-2,-2,-3,-2] → 输出 -3
  • 示例 3:[0,0,0,7] → 输出 7
相关推荐
普通网友3 小时前
C++构建缓存加速
开发语言·c++·算法
杨筱毅4 小时前
【算法】430.扁平化多级双向链表--通俗讲解
算法·链表·深度优先
yongui478344 小时前
INTLAB区间工具箱在区间分析算法中的应用与实现
数据结构·算法
今天也好累6 小时前
贪心算法之会议安排问题
c++·笔记·学习·算法·贪心算法
KWTXX9 小时前
【国二】C语言-部分典型真题
java·c语言·算法
lucialeia10 小时前
leetcode (4)
算法·leetcode
永远有多远.11 小时前
【CCF-CSP】第39次CSP认证前三题
算法
墨染点香11 小时前
LeetCode 刷题【90. 子集 II】
算法·leetcode·职场和发展
Code_LT13 小时前
【算法】多榜单排序->综合排序问题
人工智能·算法