一、题目分析
问题描述
给定一个整数数组 nums,其中除了一个元素只出现一次外,其余每个元素均出现三次。找出那个只出现一次的元素。
示例
text
输入: [2,2,3,2]
输出: 3
输入: [0,1,0,1,0,1,99]
输出: 99
限制条件
-
时间复杂度 O(n)
-
空间复杂度 O(1)
-
不使用额外空间(不考虑函数调用栈)
二、位运算的三种解法对比
方法对比表
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 | 核心思想 |
|---|---|---|---|---|
| 位统计法 | O(32n) → O(n) | O(1) | 通用,易于理解 | 统计每位1的个数,模3得到结果 |
| 有限状态自动机 | O(n) | O(1) | 最优解,巧妙 | 用两个变量模拟三种状态 |
| 哈希表法 | O(n) | O(n) | 简单但不符合要求 | 统计每个数字出现次数 |
三、解法一:位统计法(通用解法)
核心思想
统计每个二进制位上1出现的次数,因为除了目标数外,其他数字都出现3次,所以每位上1的个数模3的结果就是目标数在该位的值。
cpp
class Solution {
public:
int singleNumber(vector<int>& nums) {
int result = 0;
// 对每一位进行统计
for (int i = 0; i < 32; i++) { // int类型32位
int sum = 0;
// 统计第i位为1的数字个数
for (int num : nums) {
if ((num >> i) & 1) {
sum++;
}
}
// 如果sum % 3 == 1,说明只出现一次的数字在该位为1
if (sum % 3 == 1) {
result |= (1 << i);
}
}
return result;
}
};
详细分析
1. 为什么是32位?
-
在大多数系统中,
int类型是32位 -
需要考虑负数的情况(使用补码表示)
-
我们逐位分析每个数字的二进制表示
2. 算法步骤
-
遍历每一位(0-31)
-
统计该位为1的数字个数
-
如果该位为1的个数模3余1,则结果在该位为1
-
将所有为1的位组合起来得到最终结果
3. 示例演示
text
输入: [2, 2, 3, 2]
二进制:
2 = 010
2 = 010
2 = 010
3 = 011
统计每位1的个数:
第0位: 0+0+0+1 = 1 → 1%3=1 → 结果第0位=1
第1位: 1+1+1+1 = 4 → 4%3=1 → 结果第1位=1
第2位: 0+0+0+0 = 0 → 0%3=0 → 结果第2位=0
结果: 011 = 3
4. 处理负数
cpp
// 对于负数,需要特殊处理吗?
// 不需要!因为负数的补码表示也遵循同样的位运算规则
// 例如:-1的补码是全1,统计时也按位处理
// 但如果考虑大整数或溢出,可以使用long long
long long result = 0;
for (int i = 0; i < 64; i++) {
// ... 64位统计
}
四、解法二:有限状态自动机(最优解法)
核心思想
使用两个变量 ones 和 twos 来表示三种状态:
-
状态0:出现0次
-
状态1:出现1次
-
状态2:出现2次
当出现第3次时,状态归零
cpp
class Solution {
public:
int singleNumber(vector<int>& nums) {
int ones = 0, twos = 0;
for (int num : nums) {
// 第一次出现:记录到ones中
// 第二次出现:从ones中清除,记录到twos中
// 第三次出现:从twos中清除
ones = (ones ^ num) & ~twos;
twos = (twos ^ num) & ~ones;
}
return ones; // 最后ones中存储的就是只出现一次的数字
}
};
状态转移分析
状态定义
text
状态 ones twos 描述
0次 0 0 数字未出现
1次 1 0 数字出现一次
2次 0 1 数字出现两次
3次 0 0 数字出现三次(回到初始状态)
状态转移表
| 当前状态 (ones, twos) | 输入num | 新状态 (ones, twos) |
|---|---|---|
| (0,0) | 1 | (1,0) |
| (1,0) | 1 | (0,1) |
| (0,1) | 1 | (0,0) |
| (0,0) | 0 | (0,0) |
| (1,0) | 0 | (1,0) |
| (0,1) | 0 | (0,1) |
状态转移公式推导
对于每个位,我们需要实现以下逻辑:
-
当遇到1时,状态按 00 → 01 → 10 → 00 循环
-
当遇到0时,状态保持不变
用真值表推导:
text
ones twos num 新ones 新twos
0 0 0 0 0
0 0 1 1 0
0 1 0 0 1
0 1 1 0 0
1 0 0 1 0
1 0 1 0 1
推导出状态转移方程:
text
新ones = (~ones & ~twos & num) | (ones & ~twos & ~num)
新twos = (~ones & twos & ~num) | (~ones & ~twos & num & ones_old)
简化后得到代码中的形式:
cpp
ones = (ones ^ num) & ~twos;
twos = (twos ^ num) & ~ones;
逐步执行示例
text
输入: [2, 2, 3, 2]
初始化: ones=0, twos=0
处理2:
ones = (0 ^ 2) & ~0 = 2 & 1 = 2
twos = (0 ^ 2) & ~2 = 2 & -3 = 0
处理2:
ones = (2 ^ 2) & ~0 = 0 & 1 = 0
twos = (0 ^ 2) & ~0 = 2 & 1 = 2
处理3:
ones = (0 ^ 3) & ~2 = 3 & -3 = 1
twos = (2 ^ 3) & ~1 = 1 & -2 = 0
处理2:
ones = (1 ^ 2) & ~0 = 3 & 1 = 3
twos = (0 ^ 2) & ~3 = 2 & -4 = 0
结果: ones = 3
五、解法三:数学方法
核心思想
text
3 × (a + b + c) - (a + a + a + b + b + b + c) = 2c
c = [3 × (a + b + c) - (数组总和)] / 2
cpp
class Solution {
public:
int singleNumber(vector<int>& nums) {
// 使用set去重
unordered_set<int> s;
long long sum = 0, uniqueSum = 0;
for (int num : nums) {
sum += num;
if (s.find(num) == s.end()) {
s.insert(num);
uniqueSum += num;
}
}
return (3 * uniqueSum - sum) / 2;
}
};
缺点
-
需要额外空间存储set
-
对于大数可能溢出
-
不符合O(1)空间要求
六、通用解法:扩展到出现k次,一个数字出现m次
通用位统计法
cpp
// 适用于:数组中所有数字出现k次,只有一个数字出现m次(m不能被k整除)
int singleNumber(vector<int>& nums, int k, int m) {
int result = 0;
// 对每一位进行统计
for (int i = 0; i < 32; i++) {
int sum = 0;
// 统计第i位为1的数字个数
for (int num : nums) {
if ((num >> i) & 1) {
sum++;
}
}
// 如果sum % k == m,说明只出现一次的数字在该位为1
if (sum % k == m) {
result |= (1 << i);
}
}
return result;
}
通用状态机法
对于k次出现的情况,需要 ⌈log₂k⌉ 个状态变量:
cpp
// k=3时,需要2个变量(ones, twos)
// k=4时,需要2个变量(可以表示4种状态)
// k=5时,需要3个变量(2^3=8>5)
// 通用状态机实现(以k=3为例)
int singleNumberK(vector<int>& nums, int k) {
vector<int> count(32, 0);
for (int num : nums) {
for (int i = 0; i < 32; i++) {
count[i] += (num >> i) & 1;
count[i] %= k;
}
}
int result = 0;
for (int i = 0; i < 32; i++) {
result |= (count[i] << i);
}
return result;
}
七、性能对比与分析
时间复杂度分析
-
位统计法:O(32n) → O(n)
-
外层循环32次
-
内层循环n次
-
实际执行32n次操作
-
-
状态机法:O(n)
-
一次遍历
-
每个元素进行常数次位运算
-
空间复杂度分析
-
位统计法:O(1)
- 使用固定数量的变量
-
状态机法:O(1)
- 只使用两个int变量
实际性能测试
cpp
// 测试代码框架
void testPerformance() {
vector<int> nums(1000000);
// 填充测试数据...
auto start = chrono::high_resolution_clock::now();
// 调用函数
auto end = chrono::high_resolution_clock::now();
auto duration = chrono::duration_cast<chrono::microseconds>(end - start);
cout << "Time: " << duration.count() << " microseconds" << endl;
}
八、边界情况与注意事项
1. 负数处理
cpp
// 方法一:使用无符号整数
unsigned int u_num = static_cast<unsigned int>(num);
// 方法二:直接处理,C++右移负数会进行符号扩展
// 对于负数,右移操作会填充1,需要注意
// 推荐:使用位统计法时,循环32次,直接处理补码
2. 大数溢出
cpp
// 使用long long避免溢出
long long sum = 0;
for (int num : nums) {
sum += static_cast<long long>(num);
}
3. 特殊测试用例
cpp
// 测试用例
vector<vector<int>> testCases = {
{2, 2, 3, 2}, // 正数
{-2, -2, -3, -2}, // 负数
{0, 1, 0, 1, 0, 1, 99}, // 包含0
{INT_MAX, INT_MAX, INT_MAX, 1}, // 边界值
{1}, // 单个元素
};
九、实际应用场景
1. 数据纠错
-
RAID存储系统:检测和恢复数据
-
网络传输:校验数据完整性
2. 信号处理
-
数字滤波器设计
-
错误检测与纠正编码
3. 加密算法
-
伪随机数生成
-
流密码中的状态机
4. 硬件设计
-
有限状态机电路
-
计数器设计
十、扩展思考
1. 如果其他数字出现5次,一个数字出现3次?
cpp
// 修改模数为5
if (sum % 5 == 3) {
result |= (1 << i);
}
2. 如果其他数字出现2次,两个数字各出现1次?
cpp
// 使用异或,然后分离两个数字
int diff = 0;
for (int num : nums) {
diff ^= num;
}
int mask = diff & -diff; // 找到不同的位
// 分组异或...
3. 如果要求不使用位运算?
cpp
// 使用哈希表
unordered_map<int, int> count;
for (int num : nums) {
count[num]++;
}
for (auto& p : count) {
if (p.second == 1) return p.first;
}
return -1;
十一、总结
关键点回顾
-
位统计法:直观通用,易于理解和扩展
-
状态机法:巧妙高效,体现了位运算的精髓
-
数学方法:思路简单,但受限于空间和溢出问题
选择建议
-
面试中:建议先讲解位统计法,再优化到状态机法
-
竞赛中:直接使用状态机法
-
工程中:根据可读性和性能需求选择
学习建议
-
深入理解二进制和位运算
-
掌握状态机的设计和分析
-
练习类似的位运算题目
-
理解不同解法的时间空间复杂度
位运算不仅是算法竞赛的重要工具,在实际工程中也广泛应用。掌握这些技巧,能够帮助我们写出更高效、更优雅的代码,解决看似复杂的问题。