C++ 位运算 高频面试考点 力扣137. 只出现一次的数字 II 题解 每日一题

文章目录


题目解析

题目链接:力扣137. 只出现一次的数字 II

题目描述:

示例 1:

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

输出:3

解析:数组中 2 出现 3 次,3 出现 1 次,符合"除一个元素外其余均出现三次"的条件,因此返回 3。
示例 2:

输入:nums = [0,1,0,1,0,1,99]

输出:99

解析:0 和 1 分别出现 3 次,99 出现 1 次,满足题目约束,故返回 99。
提示:

1.1 <= nums.length <= 3 * 10⁴:数组长度在合理范围内,需保证算法在大规模数据下仍高效运行;

2.-2³¹ <= nums[i] <= 2³¹ - 1:覆盖整数的全部范围,需注意处理负数的二进制表示(尤其是补码);

3.nums 中,除某个元素仅出现一次外,其余每个元素都恰出现三次。

为什么这道题值得你花几分钟的时间弄懂?

如果说位运算题型里有"必学经典",那这道题绝对能排进前三。它就像一把钥匙,不仅能帮你打开"从二进制视角拆解问题"的新大门,更能让你亲身体会算法设计中"时间与空间如何权衡"的底层逻辑,无论对思维提升还是面试应试,都大有裨益。

我们先说说它的核心价值在哪:

  • 考点踩得准,面试直接用:题目明确定死了"线性时间(O(n))"和"常数空间(O(1))"的双重要求,而这正是大厂面试里"算法优化能力"的高频考点。很多时候,面试官看的不只是"能不能做出来",更是"能不能用最优的方式做出来"------而这道题刚好能帮你练会"用二进制操作抠空间、提效率"的关键能力。
  • 方法对比超直观,理解更透彻:解题时你会遇到两种典型思路:一种是哈希表,直观好懂但要耗额外空间;另一种是位运算,代码稍抽象却能做到空间最优。把这两种方法放一起对比,你能清晰看到"时间-空间权衡"不是一句空话------不同场景下该选哪种方案,看完这道题会有更具体的体感。
  • 举一反三能力直接拉满:它是"只出现一次的数字"系列的第二题(第一题是"其余元素出现两次"),但学会它的位运算逻辑后,你会发现这套思路能直接迁移到"其余元素出现k次"的通用场景。比如下次遇到"找只出现一次、其余出现四次"的题,你不用重新想解法,改个小细节就能用,这种"一通百通"的感觉,才是刷题的核心意义。

再从应试角度聊聊,面试官考察这道题时,其实在悄悄观察你三个层面的能力:

  1. 基础层:会不会用常规思路解题? 比如能不能想到用哈希表统计次数------这能看出你对"哈希表映射特性"这类基础数据结构的掌握程度,是入门门槛;
  2. 进阶层:能不能突破空间限制? 也就是能不能想到位运算解法------这能暴露你对"二进制位统计规律"的理解深度,是区分普通选手和优秀选手的关键;
  3. 迁移层:能不能把解法通用化? 比如问你"如果其余元素出现五次,该怎么改?"------这能看出你的算法思维是否灵活,会不会把一道题的解法,变成一类题的解题模板。

不过,要是有朋友对位运算的基础操作(比如异或、与、左移)有点忘了也不用慌。先花两分钟回顾下核心规则:异或( ^ )能模拟"无进位相加"(比如1 ^ 1=0,0 ^ 1=1,刚好对应不考虑进位的加法),而与(&)加左移(<<1)能模拟"进位计算"(比如1&1=1,左移一位后就是进位值)。这两个是解决本题的"核心工具",要是想更系统地复习,也可以看看这篇博客:位运算 常见方法总结 算法练习 C++,帮你快速捡回知识点~

位运算(最优解法)

算法原理

其实这道题的核心思路特别朴素------既然数组里"除了一个数出现1次,其他都出现3次",那我们不妨把目光聚焦到二进制的每一位上。毕竟不管数字多大,最终都得拆成0和1的组合,而"出现3次"这个规律,在二进制位上会体现得格外明显。

你可以这么理解:把数组里所有数字都转成二进制,然后像"列竖式"一样纵向看每一位(比如从最低位的第0位,到最高位的第31位)。对于那些出现3次的数字,它们在某一位上如果是1,那这个1肯定会"凑够3个";而那个只出现1次的目标数字,要是它在某一位上是1,就会让这一位的1的总数多出来1个。

基于这个特点,我们只需要做两步,就能推出目标数字每一位的值:

  1. 统计每一位的1的总数:比如看第0位(最右边那一位),把所有数字在这一位上的1都数一遍;再看第1位,重复同样的操作,直到把32位都统计完。
  2. 用"取余3"判断目标位的值 :因为除了目标数字,其他数字的1都会凑成3的倍数,所以对每一位的1的总数除以3取余数,结果只有两种可能:
    • 余数是0:说明这一位的1刚好是3的倍数,目标数字在这一位肯定是0(没多出来的1);
    • 余数是1:说明这一位的1比3的倍数多1个,这个多出来的1只能来自目标数字,所以目标数字在这一位是1。

结合这张二进制位统计图,你能更直观地看到这个规律:

图里每一位的统计结果,要么是"3的倍数(3n)",要么是"3的倍数加1(3n+1)",取余3之后的结果,和目标数字对应位的0/1完全一样------这就是位运算解法的"核心密码"。

可以自己拿起笔和纸结合下面这个例子自己来验证一下:

比如数组[2,2,3,2]

  • 2的二进制是10,3的二进制是11
  • 看第0位(最低位):3个2的第0位都是0,1个3的第0位是1 → 1的总数是1 → 取余3得1 → 目标数字第0位是1;
  • 看第1位:3个2的第1位都是1(3个1),1个3的第1位是1 → 1的总数是4 → 取余3得1 → 目标数字第1位是1;
  • 更高的位上所有数字都是0 → 取余3得0 → 目标数字这些位都是0;
  • 最后把这些位组合起来,就是11(也就是3),正好是我们要找的答案。

代码实现

明白了原理,代码就很好理解了。我们要做的就是"遍历每一位→统计1的个数→取余判断→拼出结果",一步一步把刚才的思路落地:

注:代码中出现的两个位运算公式的详细推导可以看 位运算 常见方法总结 算法练习 C++

cpp 复制代码
class Solution {
public:
    int singleNumber(vector<int>& nums) {
        int ret = 0; // 用来存最终结果,一开始是0(二进制全0)
        // 遍历int的32位:从最低位(第0位)到最高位(第31位),一个都不能漏
        for (int i = 0; i < 32; i++) {
            int sum = 0; // 临时变量,统计当前第i位上所有数字中1的总数
            // 逐个检查数组里的每个数字,看它们的第i位是不是1
            for (auto e : nums) {
                // if括号中的位运算公式是判断e的二进制表示中的第i位是否为1,即注释中说明的第一个公式
                if (((e >> i) & 1) == 1) {
                    sum++; // 是1的话,就把统计数加1
                }
            }
            // 取余3,判断目标数字第i位是0还是1
            sum %= 3;
            // 如果余数是1,说明目标数字第i位是1,得把这个1"拼"到结果里
            
            if (sum == 1) {
                ret |= (1 << i);// 这里的位运算公式是将ret的二进制表示的第i位修改成1,即注释中说明的第二个公式
            }
        }
        return ret; // 把32位拼好的结果返回,就是那个只出现一次的数字
    }
};

关键补充:负数补码的兼容处理

题目中nums包含负数(范围到-2³¹),但上述代码无需额外修改即可兼容,核心原因是:

C++中int类型以补码 存储,负数的符号位(第31位)为1,正数为0。统计第31位时,"sum%3"的逻辑与其他位完全一致------若sum%3=1,说明目标数字是负数(符号位为1),通过ret |= (1 << 31)会自然将ret转为补码形式,最终返回的就是正确的负数值。

时间复杂度和空间复杂度:为什么它是"最优解"?

  • 时间复杂度:O(n)

    这里有两层循环,但外层循环固定只跑32次(因为int类型就32位,不管数组多长都不会变,属于"常数级操作");内层循环要遍历整个数组,跑n次。所以总操作次数其实是"32×n"------严肃的来讲时间复杂度应该是O(nlogC),其中 n 是数组的长度,C 是元素的数据范围,在本题中 logC = log 2³² = 32),根据大O记号的规则常数(32)可以忽略,最终时间复杂度就是O(n),完全符合题目"线性时间"的要求。

  • 空间复杂度:O(1)

    整个过程只用到了3个变量:ret存结果、sum统计当前位的1、i控制遍历的位数。不管数组长度是10还是10万,这些变量的数量都不会变,没有额外占用和数组长度相关的空间,所以是"常数级空间",完美满足题目最严格的约束。

哈希表(直观解法)

算法原理

核心思路:利用哈希表的"键-值"映射特性,统计每个元素的出现次数,最后筛选出次数为1的元素

具体步骤:

1.初始化哈希表(C++中可用unordered_map<int, int>),键为数组元素,值为该元素的出现次数;

2.遍历数组,对每个元素:若已在哈希表中,次数加1;若未在哈希表中,添加到哈希表并设次数为1;

3.遍历哈希表,找到值为1的键,该键即为目标元素。

该方法的优势是逻辑直观,无需深入理解二进制操作,适合刚接触这类题目时快速建立解题思路;但缺点是空间复杂度较高,不满足题目"常数级空间"的最优要求,可作为"过渡解法"理解。

代码实现

cpp 复制代码
#include <unordered_map>

class Solution {
public:
    int singleNumber(std::vector<int>& nums) {
        std::unordered_map<int, int> countMap; // 键:数组元素,值:出现次数
        // 第一步:统计每个元素的出现次数
        for(int num : nums)
        {
            countMap[num]++; // 若num已存在,次数+1;否则添加到哈希表并设为1
        }
        // 第二步:遍历哈希表,找到次数为1的元素
        for(auto& pair : countMap)
        {
            if(pair.second == 1)
                return pair.first; // 找到目标元素,直接返回
        }
        return 0; // 理论上不会执行(题目保证有唯一解)
    }
};

时间复杂度和空间复杂度的分析

  • 时间复杂度:O(n)(平均)

    遍历数组统计次数(O(n)),遍历哈希表筛选结果(哈希表元素个数最多为(n-1)/3 +1,即O(n))。std::unordered_map的插入与查询平均时间复杂度为O(1),但最坏情况下(哈希冲突严重)会退化为O(n),此时总时间复杂度接近O(n²)------不过LeetCode题目用例中哈希冲突概率极低,实际可认为满足"线性时间"要求。

  • 空间复杂度:O(n)

    哈希表存储的元素个数取决于数组中不同元素的数量,最坏情况下(如目标元素外的其他元素均不重复,但题目中其他元素均出现三次,实际不同元素个数为(n-1)/3 +1),空间复杂度仍为O(n),不满足"常数级空间"约束。

总结

  1. 方法对比与选择
对比维度 位运算解法 哈希表解法
时间复杂度 O(n)(32×n,常数可忽略) O(n)(平均),O(n²)(最坏)
空间复杂度 O(1)(常数级,最优) O(n)(依赖不同元素数量)
适用场景 需满足"常数空间"约束 追求逻辑直观,无空间限制
理解门槛 较高(需位运算知识铺垫) 较低(依赖哈希表基础)
  1. 核心知识点回顾

    位运算:通过右移(>>)、与(&)、或(|)、左移(<<)实现二进制位的操作与统计;

    哈希表:利用"键-值"映射快速统计元素出现次数,平均时间复杂度为O(1)的插入与查询。

  2. 解题思维迁移

    本题的位运算思路可直接推广到"其余元素出现k次,找只出现1次的元素"的场景,核心修改仅一处:将"sum%3"改为"sum%k"。

示例 :若题目要求"其余元素出现4次,找只出现1次的元素",只需将代码中sum %= 3改为sum %= 4。以数组[5,5,5,5,7]为例:

  • 5的二进制为101,4次出现后,每一位的1总数均为4的倍数(sum%4=0);
  • 7的二进制为111,每一位的1会让sum多1,sum%4=1,最终拼接结果为111(即7),与预期一致。

下题预告

接下来,我们将聚焦「数组中缺失元素」的经典延伸问题------力扣 面试题 17.19. 消失的两个数字

这道题的核心场景极具代表性:给定一个包含 n-2 个不同整数的数组,所有元素均在 [1, n] 范围内(n ≥ 2),要求找出 [1, n] 中缺失的那两个数字,且需尽可能优化时间与空间复杂度。

前置思考:不妨先试着想两个问题,为下一题的学习铺垫思路:

  1. 若先计算 [1, n] 的总和与数组总和的差值,能得到"两个缺失数字的和",但如何进一步把这两个数分开?
  2. 借鉴"异或分组"的思路:若对 [1, n] 所有数和数组所有数做异或,最终结果是什么?如何利用这个结果将两个缺失数字分到不同组中?

带着这些疑问,我们下一题将逐步拆解最优解法,我们一起掌握"找两个缺失数字"的核心逻辑!

相关推荐
天特肿瘤电场研究所3 小时前
专业的肿瘤电场疗法厂家
算法
爱编程的鱼3 小时前
Python 与 C++、C 语言的区别及选择指南
c语言·开发语言·c++
奔跑吧邓邓子3 小时前
【C++实战(78)】解锁C++ 大数据处理:从并行到分布式实战
c++·分布式·实战·并发·大数据处理
DASXSDW3 小时前
NET性能优化-使用RecyclableBuffer取代RecyclableMemoryStream
java·算法·性能优化
浔川python社3 小时前
《C++ 实际应用系列》第二部分:内存管理与性能优化实战
c++
kfepiza3 小时前
CAS (Compare and Swap) 笔记251007
java·算法
liulilittle3 小时前
OPENPPP2 静态隧道链路迁移平滑(UDP/IP)
开发语言·网络·c++·网络协议·tcp/ip·udp·通信
UrbanJazzerati3 小时前
一句话秒懂什么是状语从句
面试
墨染点香4 小时前
LeetCode 刷题【103. 二叉树的锯齿形层序遍历、104. 二叉树的最大深度、105. 从前序与中序遍历序列构造二叉树】
算法·leetcode·职场和发展