给你一个 非空 整数数组 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
其它解法与比较
-
哈希表计数 :用
Map
/HashMap
统计频次,找到频次为 1 的元素。- 时间 O(n),但空间 O(n)。不满足题目"常量额外空间"的限制。
-
排序后扫描:先排序再线性扫描找单独元素。
- 时间 O(n log n),不满足线性时间要求。
因此,XOR 方法是最简洁且完全符合「线性时间 + 常数空间」要求的解。
拓展(若不是"出现两次",比如"出现三次"的情形)
若"除了某个元素出现一次外,其他元素都出现 三次 ",可以用 按位计数 的方法:对 32 位的每一位统计数组中该位 1 的个数,对 3 取模(count[i] % 3
)得到该位在唯一元素中的值,最后把各位重建成整数。时间仍为 O(32·n)=O(n),额外空间为常数(32 个计数器)。这是常见的推广技巧。
思路总览(题目变体:其它数字都出现 3 次 ,只有一个数字出现 1 次)
要在 线性时间 O(n) 且 常数额外空间 O(1) 下找出只出现 1 次的那个数,常见且可靠的两种做法:
- 按位计数法(直观) ------ 对整数的每一位(32 位)统计所有数中该位的 1 的个数,
% 3
后得到唯一数该位的值。时间 O(32·n)=O(n),额外空间 O(1)(常量大小的数组)。 - 位状态机法(位运算技巧,最优) ------ 用两个整数
ones
和twos
跟踪每个位出现 1 次或 2 次的状态,通过位运算在遍历中完成"模 3 计数"。时间 O(n),额外空间 O(1)(只用常数个变量)。这是面试中常见且优秀的解法。
下面我先详细讲解 按位计数法 (好理解,易证明、稳健),再给出 位状态机法(更精巧,常用)的 Java 实现并带详注与示例。
方法一:按位计数(逐位统计 1 的个数 % 3)
原理
将所有数的每一位(0..31)上的 1 的个数加起来,记为 count[i]
。
因为除了唯一数之外其他每个数都出现 3 次,所以对于任一位 i
,来自成组三个相同数的贡献是 0
或 3
的倍数,取模 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)循环。我们用两个位掩码 ones
和 twos
来记录当前遍历到的位置,每一位的状态用这两个掩码的组合表示:
-
对于某一位 b 的三个状态(出现次数 mod 3):
00
表示出现 0 次01
表示出现 1 次 (存储在ones
)10
表示出现 2 次 (存储在twos
)- 当出现第 3 次时,要从
01
/10
都清零(回到00
)
通过巧妙的位运算更新 ones
和 twos
,可以在一次遍历中完成每位的模 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