位运算进阶:用异或和lowbit找出数组中两个只出现一次的数字
这个标题看起来很学术但拆开来看其实就是我们在刷题时遇到的一道经典题目一个整数数组里恰好有两个数字只出现了一次其余所有数字都出现了两次要求把这两个“落单”的数字找出来。我第一次看到这个题的时候第一反应是哈希表计数能解但总觉得有点“重”。后来认真把位运算的方案吃透才发现这个题的精髓根本不是“怎么去重”而是“怎么用异或把一个集合干净地切分成两个互不干扰的子集”。这篇文章我就把手推的过程、C代码的实现细节、以及我在实际刷题中踩过的坑一起讲清楚。1. 从哈希表到位运算这道题为什么值得深挖1.1 题目的真实需求找两个“落单”的数字先把题目说清楚给定一个非空整数数组除了某两个元素只出现一次以外其余每个元素均恰好出现两次。你要找出这两个只出现一次的元素。注意题目有个隐含前提——这两个目标数字是不相等的否则它们会合并成一个出现两次的数字。常规解法很直接开一个哈希表遍历数组统计每个数字出现的次数最后再把次数为1的两个数字捞出来。这样做的正确性毫无问题但空间复杂度是O(n)也就是需要额外存储n个键值对。如果数组里有一百万个数就要开一个不小的哈希表。在嵌入式环境、内存受限的场景或者面试官明确要求“只能用常数级额外空间”的时候这条路就走不通了。1.2 为什么位运算能成为最优解位运算的优势在哪里三点空间O(1)、时间O(n)、实现干净。它不需要任何容器只靠几个整数变量就能完成全部计算。这背后的底气来自异或运算(^)的三大性质交换律、结合律a ^ b b ^ a(a ^ b) ^ c a ^ (b ^ c)所以数字的先后顺序不影响最终结果。相同为0x ^ x 0两个相同的数异或会互相抵消。与0异或保持不变0 ^ x x。你把这三点合起来想数组里的数那些成对出现的数字一旦全部丢进异或运算里就会自动两两抵消最终剩下的只能是那两个孤零零的只出现一次的数字的异或结果。这一句话就是整个解法的地基。我印象很深的是第一次推到这个结论时总觉得少了一步——知道A和B异或的结果怎么把A、B各自还原出来这也是这道题真正的分水岭多数人能想到第一层异或但卡在第二层的“分组”上。2. 异或的“成对抵消”逻辑第一遍全体异或到底得到了什么2.1 用一个具体数组手推一遍全体异或别空想直接上手算。假设数组是[2, 4, 2, 6, 3, 4, 6, 5]这里2出现了两次4出现了两次6出现了两次只有3和5出现了一次所以目标答案是{3, 5}。现在按顺序做全体异或初始化xorSum 00 ^ 2 22 ^ 4 66 ^ 2 4你看第二个2把第一个2抵消了4 ^ 6 22 ^ 3 11 ^ 4 55 ^ 6 33 ^ 5 6最终xorSum 6。而3 ^ 5 6验证了我们的推断全体异或的结果就等于两个目标数字的异或值。3的二进制是0115的二进制是101011 ^ 101 110也就是十进制的6。每一个二进制位上只要两个数不同这一位就是1相同则为0。2.2 为什么已知异或值还不够很多人卡在这既然xorSum target1 ^ target2那能不能直接从这个值反推出两个数答案是单靠它不够。反推是一个多对一的过程——3 ^ 5 6但4 ^ 2 67 ^ 1 6无数对组合都能得到同一个异或结果。你手里只有一把锁却对应着无数把钥匙。所以必须引入“分组”这个概念把数组拆成两个子集每个子集里包含一个目标数字并且其余成对的数字仍然完整地待在同一个子集里。这样分别在两个子集内做全体异或就能各得出一个目标数字。问题就转化为怎么找到一个分组规则既能保证两个目标数字被分开又不拆散任何一对相同的数字异或结果的二进制表示恰好给了我们灵感——异或结果中某一位是1说明在这一位上两个目标数字的二进制值必然是一个0、一个1异或为1表示不相同。那就以这一位作为“分界线”把数组分成“该位为0”和“该位为1”两组。成对数字在该位上的值完全一致所以绝不会被拆散两个目标数字在该位上必然不同所以必然被分到两组。分组完成之后组内分别异或即可。3. 分组的关键如何从异或结果中挑出那一位“1”3.1 lowbit操作最快拿到最低位的那个1怎么从xorSum里挑一个“为1的位”最优雅的办法是取它的最低位1也就是常说的lowbit操作int lowbit xorSum -xorSum;这行的原理很多人第一次看会懵其实核心在于补码的表示方式。负数的二进制是正数的补码即“按位取反再加1”。比如xorSum 6二进制110。-xorSum即-6补码是010注意有限位宽下前面补充的1不影响后续位运算。把110和010按位与110 010 ----- 010结果是010也就是十进制的2恰好是6的最低位的那个1。再看一个例子xorSum 12二进制1100-12补码为0100按位与得0100正是从右往左数第三个位的1。这里要特别提醒在C里对有符号整数做x -x当x为INT_MIN时会出现溢出问题严格做法是先转成unsigned int或者直接使用unsigned int容器类型避免未定义行为。不过就这道题来说发生溢出需要xorSum恰好等于INT_MIN概率低但依然应该在代码里做防备。3.2 依据lowbit分组后的正确性验证拿到了lowbit就拿到了一个只含单个1的掩码。用它判断数组中每个数字的对应位是否为1为1进一组为0进另一组。回到刚才数组[2, 4, 2, 6, 3, 4, 6, 5]xorSum 6lowbit 2也就是二进制010。按第二位从右往左第2位分组数字二进制第二位分组20101B组41000A组20101B组61101B组30111B组41000A组61101B组51010A组A组{4, 4, 5}组内异或结果为5。 B组{2, 2, 6, 6, 3}组内异或结果为3。两个目标数字3和5被分开了而所有成对的2和4都完整地留在同一组符合前面的推导。3.3 一个常被忽略的事实选哪一位都可以很多题解强调取最低位1但并不是说只能取最低位。事实上只要这一位在xorSum中是1取它做分组位都是合法的xorSum中每个为1的位都是目标数字之间的差异位。选最低位的好处是算起来简单、不易出错只用一行lowbit就能拿到。选其他位也可以只需要用(xorSum k) 1来逐位检查并构造掩码。理解了这一点你就不会死记硬背代码而是真正掌握了分组的内在逻辑。4. C实现与代码精读从思路到可运行的解法4.1 完整代码与逐行解释直接看最终版本我把关键类型设计为unsigned int既避免符号位问题也方便后面用 -lowbit取位掩码#include vector #include cstdint std::vectorunsigned int singleNumberIII(const std::vectorunsigned int nums) { // 第一遍全体异或得到两个目标数字的异或值 unsigned int xorSum 0; for (unsigned int num : nums) { xorSum ^ num; } // 取出 xorSum 中最低位的那个 1 // 这里 xorSum 保证非零因为两个目标数字不同所以 -xorSum 不会溢出 unsigned int lowbit xorSum -xorSum; // 第二遍按 lowbit 分组分别在组内异或 unsigned int group0 0; unsigned int group1 0; for (unsigned int num : nums) { if ((num lowbit) 0) { group0 ^ num; } else { group1 ^ num; } } return {group0, group1}; }逐行拆解一下思路第一遍循环结束后xorSum里藏着两个目标数字的全部差异信息。第二遍循环开始前先用lowbit xorSum -xorSum锁定一个分组依据。为什么取负号不取反号因为补码取反加一的效果正好让“最低位的1保留、其余位全部清0”这是C层面最直接的表达。第二遍循环里对每个num检查(num lowbit) 0。这一行等价于判断“该数字在lowbit对应的那一位上是否为0”。注意不能写成(num lowbit) lowbit之外的形式因为lowbit只有一个1位如果这一位为1按位与结果就等于lowbit如果为0就是0。逻辑上是等价的但写! 0更直白也方便阅读。4.2 测试用例与手写推演跑几个边界用例验证代码行为输入[1, 2, 1, 3, 2, 5]原题示例全体异或1 ^ 2 ^ 1 ^ 3 ^ 2 ^ 5 6lowbit 2。按第二位分组最终返回{3, 5}顺序不保证。输入[0, 1, 0, 3, 1, 2]全体异或因为0出现两次抵消剩下3 ^ 2 1lowbit 1。按最低位分组奇数一组、偶数一组得到{3, 2}。这组数据能顺带检验负数不存在时的正常状况。输入[INT_MAX, INT_MAX, 1, 2]全体异或两个INT_MAX抵消剩下1 ^ 2 3lowbit 1分组后得到{1, 2}。极端大数同样适用。输入[1, 1, 2, 2]这其实不符合题目“恰好两个数字出现一次”的约束但代码会得到全体异或为0lowbit为0此时分组逻辑退化为全部进入group0最终返回{0, 0}。所以如果要在工程里用最好加一个前置判断if (xorSum 0) return {};避免误导。结合这些用例我特别提醒尽量保证代码在异常输入下也有确定行为。虽然算法题通常不需要防御但把防御写在注释里能体现工程素养。4.3 复杂度分析与实际运行表现时间复杂度数组只遍历两遍每遍O(n)总时间O(n)。空间复杂度只使用了若干unsigned int局部变量O(1)。这是这道题能被选为经典面试题的原因——在最优复杂度下解法依旧简洁。我在本机实测一千万个整数随机生成确保恰好两个落单跑完全程大约在几十毫秒量级而用哈希表版本内存占用会多出数十MB。在一般笔试题里二者差异不大但在内存受限的模拟环境里位运算版本可以做到几乎不额外占用堆内存。这也是它值得掌握的实战价值。5. 边界情况与STL容器方案的对比5.1 输入规模与特殊输入的防御策略刷题时可以直接忽略异常输入但真正写工程代码至少要考虑三种情况数组长度小于4不可能有恰好两个出现一次的数字应返回空容器。全体异或结果为0说明数组里没有符合题意的“两个落单数字”同样应返回空容器。数字范围极大或存在unsigned int的最大值异或本身不受影响但注意不要在lowbit计算时对0取负否则会导致未定义行为。我一般会在入口加一个轻型防御if (nums.size() 4) return {};这样能挡住最明显的非法输入又不影响正常路径的性能。其余的防御可以在注释里说明不必过度设计。5.2 哈希表、排序法与位运算的综合对比把三种常见解法放在同一张表里看各自的取舍方案时间复杂度空间复杂度编码复杂度适用场景哈希表计数O(n)O(n)最低普通业务代码不要求额外空间排序后扫描O(n log n)O(1)或O(n)中等数据量中等允许原地排序位运算分组异或O(n)O(1)中等内存受限、性能敏感场景排序法看起来空间更优但时间上升一个量级哈希表最直观但内存是瓶颈位运算在时间和空间上双优代价是你必须对位运算足够熟练能推演出分组逻辑。实际工程中我更倾向于先用可读性最强的哈希表完成任务再在性能分析确认为瓶颈后改造成位运算版本。刷题和工程是两码事这个要分清楚。5.3 一个容易翻车的小细节结果顺序不确定很多刚接触这道题的人会忽略一点group0和group1的顺序取决于lowbit的选择以及数组内元素的分布。return {group0, group1}返回的是“按分组位为0的那个目标数字”和“按分组位为1的那个目标数字”并不是“小的在前或大的在前”。如果题目要求按升序返回需要额外排个序auto result singleNumberIII(nums); if (result[0] result[1]) { std::swap(result[0], result[1]); } return result;这个问题在笔试里顶多算边界用例但却是不少人在手写白板时丢分的地方。6. 从“只出现一次的数字”系列看位运算的进阶用法6.1 三兄弟题目的递进关系只出现一次的数字系列几乎是把位运算考法给练全了第一题只有一个数字出现一次其余都出现两次。解法最简单——全体异或最后剩下的就是目标值。第三题两个数字出现一次。解法就是本文的分组异或。第二题只有一个数字出现一次其余都出现三次。异或在这里不够用了因为三倍出现的数字异或后不会抵消。常用方案是逐位统计各二进制位上1的个数对3取模。它本质上是把“出现次数”当作模运算来处理和异或的“成对抵消”逻辑一脉相承只是模数从2变成了3。理解了这一递进关系你会发现位运算不是死记硬背的魔法而是有规律可循的同一个成对出现的数字在二进制上的特征是对称的运算规则随之设计。6.2 类似思路在真实场景中的迁移分组异或的思路并不只在刷题时有用。我实际在工作中碰到过类似场景两个数据流各有一个异常节点其余节点成对出现需要快速定位异常。把节点ID做异或交错的两个流也能用同样的分组逻辑分离出来。另一个常见场景是状态压缩当一组开关状态需要用少量整数表示、又要快速判断哪些开关状态被翻转时异或的翻转特性和lowbit的取位特性就是天然的“状态诊断工具”。更基础的应用是判断奇偶性(x 1)比x % 2更快这在嵌入式循环里能省下不少周期。再比如用x (x - 1)清除最低位的1可以O(1)判断一个数是不是2的幂。这些技巧单个看都很小组合起来就是位运算体系的威力。6.3 我的实战心得怎么把位运算变成“条件反射”最后分享一点个人的训练方法。我刷这类题时不会只看题解就过而是坚持在纸上手推至少三组数据逼自己把异或、按位与、按位取反这些操作的二进制变化画出来。真正弄懂lowbit的原理后再看类似题目脑子里会自动浮现“异或结果中为1的位就是分割点”这个画面代码几乎可以一次写对。另一个心得是注意C的类型细节。很多人在普通整数上推导没问题但一到负数就翻车。记住一条黄金法则涉及位运算的中间结果尽量用无符号整数保存尤其是 -这种涉及负号的操作无符号类型能帮你避开溢出和符号扩展这些隐性坑。把小细节抠到位笔试时就能专注在算法本身而不是花时间调试类型带来的诡异bug。到这里这道题从原理到实现已经拆得很透。以后碰到类似的“找落单数字”问题你可以先想想能不能用异或把成对的数字抵消如果能异或结果里挑哪一位作为分界分组之后各自异或会得到什么沿着这三步走位运算的方案基本就浮出水面了。