【C++】只出现一次的数字 II:位运算的三种解法深度解析

一、题目分析

问题描述

给定一个整数数组 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. 算法步骤
  1. 遍历每一位(0-31)

  2. 统计该位为1的数字个数

  3. 如果该位为1的个数模3余1,则结果在该位为1

  4. 将所有为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位统计
}

四、解法二:有限状态自动机(最优解法)

核心思想

使用两个变量 onestwos 来表示三种状态:

  • 状态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;
}

七、性能对比与分析

时间复杂度分析

  1. 位统计法:O(32n) → O(n)

    • 外层循环32次

    • 内层循环n次

    • 实际执行32n次操作

  2. 状态机法:O(n)

    • 一次遍历

    • 每个元素进行常数次位运算

空间复杂度分析

  1. 位统计法:O(1)

    • 使用固定数量的变量
  2. 状态机法: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;

十一、总结

关键点回顾

  1. 位统计法:直观通用,易于理解和扩展

  2. 状态机法:巧妙高效,体现了位运算的精髓

  3. 数学方法:思路简单,但受限于空间和溢出问题

选择建议

  • 面试中:建议先讲解位统计法,再优化到状态机法

  • 竞赛中:直接使用状态机法

  • 工程中:根据可读性和性能需求选择

学习建议

  1. 深入理解二进制和位运算

  2. 掌握状态机的设计和分析

  3. 练习类似的位运算题目

  4. 理解不同解法的时间空间复杂度

位运算不仅是算法竞赛的重要工具,在实际工程中也广泛应用。掌握这些技巧,能够帮助我们写出更高效、更优雅的代码,解决看似复杂的问题。

相关推荐
Takoony4 小时前
GPU 推理并发的本质:从第一性原理到工程实践
算法·gru
qq_454245034 小时前
通用引用管理框架
数据结构·架构·c#
小贾要学习4 小时前
【Linux】TCP网络通信编程
linux·服务器·网络·c++·网络协议·tcp/ip
哎嗨人生公众号5 小时前
手写求导公式,让轨迹优化性能飞升,150ms变成9ms
开发语言·c++·算法·机器人·自动驾驶
foundbug9995 小时前
STM32 内部温度传感器测量程序(标准库函数版)
stm32·单片机·嵌入式硬件·算法
Hello.Reader5 小时前
为什么学线性代数(一)
线性代数·算法·机器学习
code_whiter5 小时前
C++6(模板)
开发语言·c++
_深海凉_5 小时前
LeetCode热题100-找到字符串中所有字母异位词
算法·leetcode·职场和发展
lcj25115 小时前
【C语言】数据在内存中的存储
c语言·数据结构