一、题目分析
问题描述
给定一个整数数组 nums,其中恰好有两个元素只出现一次,其余所有元素均出现两次。找出只出现一次的那两个元素。
示例
text
输入: [1,2,1,3,2,5]
输出: [3,5]
限制条件
-
时间复杂度 O(n)
-
空间复杂度 O(1)(不考虑返回结果占用的空间)
-
元素的顺序任意
二、位运算基础知识
1. 异或运算 (XOR) 的特性
cpp
a ^ a = 0 // 相同数字异或为0
a ^ 0 = a // 任何数与0异或为本身
a ^ b = b ^ a // 交换律
a ^ b ^ c = a ^ (b ^ c) = (a ^ b) ^ c // 结合律
2. 补码和取反操作
cpp
// 对于任意整数x:
-x = ~x + 1 // 补码表示
x & (-x) // 获取x最右边的1
3. 位运算常用技巧
cpp
// 1. 判断奇偶
bool isOdd = (x & 1) == 1;
// 2. 清零最低位的1
x = x & (x - 1);
// 3. 获取最低位的1
int lowbit = x & (-x);
// 4. 判断某一位是否为1
bool bitSet = (x >> k) & 1;
// 5. 设置某一位为1
x |= (1 << k);
// 6. 设置某一位为0
x &= ~(1 << k);
三、解题思路详解
问题转换
如果数组中只有一个数字出现一次,我们可以用异或轻松解决:
cpp
int singleNumber(vector<int>& nums) {
int result = 0;
for (int num : nums) {
result ^= num;
}
return result;
}
但本题有两个只出现一次的数字(设为a和b),直接异或得到的是 a ^ b。
核心思路
-
获取两个数字的差异位
diff = a ^ b的结果中,为1的位表示a和b在该位不同
-
分离这两个数字
-
找到diff中任意一个为1的位(通常用最右边的1)
-
根据这个位将数组分成两组
-
在每组中,相同的数字会被分到同一组,而a和b会被分到不同组
-
四、代码逐步解析
cpp
class Solution {
public:
vector<int> singleNumber(vector<int>& nums) {
// 1. 计算两个只出现一次数字的异或值
long long diff = 0;
for (int num : nums) {
diff ^= num;
}
// 此时 diff = a ^ b
// 2. 找到最右边为1的位(两个数字在这一位不同)
diff &= -diff;
// 例如:diff = 6(110),-diff = -6(010),相与得010
// 3. 根据这一位将数组分成两组,分别异或
vector<int> result(2, 0);
for (int num : nums) {
if ((num & diff) == 0) {
result[0] ^= num; // 这一位为0的组
} else {
result[1] ^= num; // 这一位为1的组
}
}
return result;
}
};
详细分析
第一步:获取异或值
cpp
long long diff = 0;
for (int num : nums) {
diff ^= num;
}
-
出现两次的数字相互抵消(
x ^ x = 0) -
最后
diff = a ^ b,其中a和b是两个只出现一次的数字
第二步:分离关键位
cpp
diff &= -diff;
这个操作是算法的核心,让我们详细分析:
假设 a = 3(011), b = 5(101),则:
text
a ^ b = 110 (二进制,6的十进制)
计算 diff & -diff:
text
diff = 110
-diff = 010 (二进制补码)
相与得: 010
为什么这样做?
-
-diff是diff的二进制补码(取反加1) -
这个操作保留了
diff中最右边的1,其他位都变为0 -
这个1的位置就是a和b在二进制表示中第一个不同的位
第三步:分组异或
cpp
vector<int> result(2, 0);
for (int num : nums) {
if ((num & diff) == 0) {
result[0] ^= num;
} else {
result[1] ^= num;
}
}
以示例 [1,2,1,3,2,5],diff = 2(010):
-
分组1(第1位为0):
[1(001), 1(001), 5(101)]1 ^ 1 ^ 5 = 5
-
分组2(第1位为1):
[2(010), 3(011), 2(010)]2 ^ 3 ^ 2 = 3
五、算法正确性证明
为什么能正确分组?
设两个目标数为a和b,diff = a ^ b:
-
diff中至少有一个1(否则a=b,与题意矛盾) -
设
diff的最右边1在第k位 -
那么a和b在第k位一定不同(一个为0,一个为1)
-
所有其他数字出现两次,相同的数字在第k位相同,会被分到同一组
为什么分组后每组只有一个目标数?
-
a和b在第k位不同,所以会被分到不同组
-
每组中其他数字都成对出现,异或后为0
-
每组最终只剩下一个目标数
六、复杂度分析
时间复杂度
-
O(n):遍历数组两次
-
第一次:计算异或值
-
第二次:分组异或
-
-
每个元素只处理一次,位运算操作是O(1)
空间复杂度
-
O(1):只使用了固定大小的额外空间
-
diff:存储异或结果
-
result:存储返回结果(不计入空间复杂度)
-
七、边界情况处理
cpp
// 测试用例示例
vector<int> testCases[] = {
{1, 2, 1, 3, 2, 5}, // [3, 5]
{-1, 0}, // [-1, 0]
{0, 1, 0, 2}, // [1, 2]
{1, 1, 0, 2147483647} // [0, 2147483647]
};
// 需要注意的特殊情况
// 1. 负数处理:使用long long避免溢出
// 2. 大数处理:异或运算对大数同样适用
// 3. 最小负数:-2147483648的特殊性
八、算法优化与变体
1. 使用int代替long long
cpp
int diff = 0;
for (int num : nums) {
diff ^= num;
}
diff &= -diff; // 对于int,当diff为INT_MIN时,-diff会溢出
解决方法:
cpp
// 方法1:转换为unsigned int
unsigned int udiff = static_cast<unsigned int>(diff);
udiff &= -udiff;
diff = static_cast<int>(udiff);
// 方法2:使用其他方法找到最右边的1
int mask = 1;
while ((diff & mask) == 0) {
mask <<= 1;
}
2. 不使用额外数组
cpp
vector<int> singleNumber(vector<int>& nums) {
int diff = 0;
for (int num : nums) {
diff ^= num;
}
// 找到最右边的1
int rightmost = diff & (~diff + 1);
int a = 0, b = 0;
for (int num : nums) {
if (num & rightmost) {
a ^= num;
} else {
b ^= num;
}
}
return {a, b};
}
九、实际应用场景
1. 错误检测与纠正
-
奇偶校验
-
CRC校验
-
RAID存储系统
2. 加密算法
-
简单异或加密
-
流密码基础
3. 数据处理
-
快速查找数组中缺失的数字
-
统计不同元素的个数
4. 硬件设计
-
数字电路中的比较器
-
校验和计算
十、相关题目
-
只出现一次的数字 I:所有数字出现两次,只有一个出现一次
-
只出现一次的数字 II:所有数字出现三次,只有一个出现一次
-
找出重复和缺失的数字
-
交换两个变量的值(不使用临时变量)
cpp
// 不使用临时变量交换两个数
void swap(int &a, int &b) {
a = a ^ b;
b = a ^ b;
b = a ^ b;
}
十一、总结
本题展示了位运算在算法设计中的强大威力。通过巧妙的异或操作和位掩码技术,我们能够在O(n)时间复杂度和O(1)空间复杂度内解决问题。
关键点回顾:
-
异或运算的消去特性:相同数字异或为0
-
补码的性质 :
x & (-x)获取最右边的1 -
分组思想:根据某个特征将问题分解
学习建议:
-
理解位运算的基本原理和性质
-
掌握常用的位运算技巧
-
练习相关的位运算题目,培养位运算思维
-
注意边界条件和特殊情况处理
位运算不仅是解决算法问题的利器,也是理解计算机底层原理的重要窗口。掌握位运算,能够帮助我们写出更高效、更优雅的代码。