每次有人让我推荐值得反复琢磨的算法题,我几乎都会提到"只出现一次的数字"这道题。题目本身非常短:给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次,找出那个只出现一次的元素,并且要求线性时间复杂度、不使用额外空间。
乍一看,这就是一道"统计频率"的题,似乎用哈希表就能解决。但"不使用额外空间"这个限制条件,把所有舒适区的解法全部堵死,逼着你重新思考数据在二进制层面的表现。等你真正理解异或(XOR)运算之后,你会发现这道题其实不是考"会不会用哈希表",而是考"能不能跳出常规思维,用数学性质直接解题"。
这篇文章我会从题目本身出发,讲清楚异或解法背后的原理、多语言实现、边界条件,再延伸到几个常见变体(比如出现两次的数字变成两个怎么办、出现三次怎么办),最后聊聊这类位运算技巧在工程里的实际意义。
1. 一道"限制条件比题目本身更值得琢磨"的题
1.1 从数据清洗场景里看这题的原始需求
先别急着把它当面试题,这道题的应用场景其实很常见。举个最朴素的例子:某公司在做日志对账时,每条请求都会生成一个唯一的请求 ID,正常情况下一次请求会产生两条记录——一条是写入记录,一条是确认记录。但由于某些链路问题,偶尔会漏掉其中一条,导致某个 ID 只出现一次。这时候就需要从几十万条记录里,快速找出这个"落单"的 ID。
如果用 Excel 或者写脚本,最常见的做法是排序后两两比较,或者用哈希表计数。但数据量一旦大起来,排序的时间复杂度是 O(n log n),哈希表虽然快但占用 O(n) 的内存。如果这是一台内存紧张的分析机,又希望在线性时间内跑完,哈希表就不是最优解了。
这正是这道题想训练的核心能力:当常规的数据结构方案被空间限制卡住时,能不能从"数据本身的性质"找到出路。题目里"其余元素均出现两次"这个条件,不是随便写的,它在暗示解题者:成对出现的东西,一定有某种抵消机制可以利用。你只要找到了抵消机制,问题就变成了一个公式推导。
1.2 哈希表方案为什么被题目直接"ban"掉
哈希表方案大概是这样的:遍历数组,把每个数字作为 key 存入哈希表,遇到重复就在计数上加一;最后再遍历一次,找到计数为 1 的那个数字。思路非常直白,也是正常人第一反应就能想到的解法。
但题目明确要求"只使用常量级别的额外空间",这个约束直接把哈希表否掉了。为什么?因为哈希表占用的空间随数组规模线性增长。数组有 1 万个元素,哈希表就要存几千个键值对;数组有 100 万个元素,哈希表就要存更多的键值对。空间复杂度是 O(n),根本谈不上"常量额外空间"。
有些人可能会说,那我可以用一个变量存当前可能的答案,遍历过程中不断替换,这样不就能做到 O(1) 空间了吗?问题在于,你无法知道"当前这个数字是不是只出现一次",除非你事先知道它后面还会不会再次出现。在只遍历一遍的限制下,单变量缓存方案没办法保证正确性。这也是为什么哈希表几乎是"直观解法"的唯一代表,而题目恰恰要让你放弃这种最直观的思路,去寻找更底层的数学规律。
2. 异或运算:看起来像魔法,其实是数学性质
2.1 异或的三种性质,以及一个手推过程
异或运算(XOR)在编程里通常写作^,它的规则非常朴素:两个二进制位相同则为 0,不同则为 1。也就是说:
0 ^ 0 = 01 ^ 1 = 00 ^ 1 = 11 ^ 0 = 1
从这个真值表可以推出三个直接可用的性质:
- 归零律:
a ^ a = 0,一个数和自己异或,结果为 0。 - 恒等律:
a ^ 0 = a,一个数和 0 异或,结果还是它自己。 - 交换律与结合律:
a ^ b = b ^ a,(a ^ b) ^ c = a ^ (b ^ c)。
正是这三条性质,构成了整个解法的地基。拿一个具体数组来推演:[2, 3, 2, 4, 4]。正常思维是数频率,但异或的做法是把所有元素从头到尾异或一遍:
2 ^ 3 ^ 2 ^ 4 ^ 4根据交换律,先把相同的 2 和 2 放在一起,4 和 4 放在一起:
= (2 ^ 2) ^ (4 ^ 4) ^ 3 = 0 ^ 0 ^ 3 = 3最后剩下的3就是只出现一次的数字。这个推演过程你可以随便换数组试试,只要其他数字都恰好出现两次,它们就会两两抵消归零,最后剩下的必然是那个落单的数字。
为什么会这样?因为异或本质上是在做"二进制位的奇偶校验":成对出现的数字在某一位上贡献的 1 的个数是偶数,异或会把它清零;只有那个只出现一次的数字,会在各个位上留下自己独有的 1。这个过程不需要额外空间,只需要一个变量存累积结果,遍历一遍就完成,时间复杂度 O(n),空间复杂度 O(1),完美契合题目限制。
2.2 为什么加减法的"浪漫"解法有隐患
还有一类解法也会被新手提出来:既然成对出现,那我把所有数字加起来,然后减去成对数字的总和,不就行了吗?思路是:先遍历一遍得到所有数字的和,再想办法得到"如果每个数字都成对"时的总和,两者相减,差值就是只出现一次的数字。
听起来可行,但实现起来有个绕不开的问题:你并不知道成对数字的"配对基准"是什么。比如数组[2, 3, 2, 3, 4],你算出来总和是 14,但你不知道基准和是多少,除非你额外用一个集合去记录"出现过的数字有哪些",这就又回到了需要额外空间。哪怕你用数学技巧硬算,在极端情况下还会遇到整数溢出的问题——当数组元素很大、数量很多时,累加和可能超过语言里整型能表示的范围。异或按位运算,每一位只关心 1 的奇偶性,天然规避了溢出问题,而且在语意上也更加优雅。
所以,异或不是"炫技",而是这道题在数学上的本质解。理解了这一点,后面所有变体都能顺着同样的思路推出来。
3. 多语言落地:代码讲清楚了,边界也没那么可怕
3.1 核心循环怎么写得直观且不易出错
原理理解了,代码其实短到离谱。Python 版本:
def single_number(nums): result = 0 for num in nums: result ^= num return resultJavaScript 版本:
const singleNumber = (nums) => { let result = 0; for (const num of nums) { result ^= num; } return result; };C++ 版本:
int singleNumber(vector<int>& nums) { int result = 0; for (int num : nums) { result ^= num; } return result; }三个版本的逻辑完全一致:result初始化为 0,遍历数组时不断与当前数字异或。为什么初值要设成 0?因为0 ^ x = x,第一个数字进来后结果就是它自己,不会影响后续的异或累积。
这里有一个写代码时需要留意的点:不要为了让代码短而把异或塞进reduce之类的函数里堆一行,除非你对这类函数非常熟悉。我见过不少人在面试手写时,明明原理讲得很清楚,结果写reduce时因为回调参数顺序搞错或者初始化值漏掉,导致整个答案从头翻车。老老实实用循环,反而显得稳健、可读性强。
3.2 负数、大整数与"非空数组"这些边界
有人会担心,如果数组里有负数怎么办?异或还成立吗?结论是:完全成立,因为负数在计算机里以补码形式存储,异或运算直接作用在补码的二进制位上,不会出现"符号位特殊处理"的问题。比如[-1, -1, 2],-1 的补码和自身异或后同样是 0,最后剩下 2,结果依旧正确。
如果有非常大的整数呢?在 Java、C++ 等固定位数整型语言里,异或不会产生溢出,因为它只是位级别的运算,不涉及进位。在 Python 这种整数无限精度的语言里,异或同样安全。这是异或相比加减法方案的一个隐形优势。
还有一个边界是"数组非空"这个前提。题目明确说了输入是非空数组,所以result = 0的初始值不会导致问题。如果题目换成"数组可能为空,空数组返回 0",那代码依然成立,因为循环不执行,result保持 0,可以当作一个合理默认值。这算是这道题里少有的"边界条件恰好不会坑人"的情况。
另外,如果你做的是 C 系语言,循环里可以用for (int num : nums)这种范围遍历,也可以用下标遍历。性能差异可以忽略,但可读性上范围遍历更直观。这里值得提一个实际经验:数组里元素类型如果是自定义结构体或者大对象,异或就不再适用了,因为异或操作通常只对基本整型有意义。这道题只讨论整数数组,遇到更复杂的对象数组时得回到哈希表的思路上,不要盲目套用位运算。
4. 变体升级:两个"单身数字"和"出现三次"的场景
4.1 出现两个只出现一次的数字:异或结果拆分组
学完基础版之后,很多资料会接着讲一道变体:给定一个整数数组,其中恰好有两个元素只出现一次,其余所有元素均出现两次。找出那两个只出现一次的元素。
先按基础版的思想,把整个数组异或一遍。成对的数字全部抵消,最后得到的结果是x ^ y,其中x和y就是那两个落单的数字。问题来了:x ^ y是一个合并后的值,怎么把x和y拆开?
关键观察是:x和y不相等,所以x ^ y必然不等于 0,也就是结果中至少有一个二进制位是 1。这个位上的 1 表示:x和y在这一位上的值不同——一个是 0,一个是 1。接下来,用这个位作为分组依据,把原数组分成两组:该位为 1 的一组,该位为 0 的一组。因为成对出现的数字在某一位上的值一定相同,它们会被分到同一组,组内依然两两抵消;而x和y被这个位严格切分到不同组里。最后,分别对两组做一遍异或,就能各自得到x和y。
具体实现时,找"某个为 1 的位"有一个经典技巧:mask = x_xor_y & (-x_xor_y),可以取出最低位的 1。在整数以补码表示的语言里,-x_xor_y等于~x_xor_y + 1,按位与之后得到的结果一定只包含一个 1。如果你用的语言对负数位运算的处理不太熟悉,也可以老老实实循环找:从第 0 位开始,逐个检查(x_xor_y >> i) & 1,找到第一个为 1 的位。两种方法殊途同归,前者代码短,后者更好读。
这道变体非常有意思的地方在于,它把基础版的"一个变量累积异或"升级成了"异或 + 位掩码分组",本质上还是在利用异或的"相同抵消,不同保留"性质。你一旦理解了这个套路,以后遇到"找两个异常元素"的场景(比如找出数组里两个出现次数为奇数的数),立刻就能建立起联想。
4.2 出现三次的情况:逐位统计取模
如果说上面那道变体是"异或的延伸",那么"其余元素均出现三次,只有一个元素出现一次"这道题,就需要换一个角度了。因为三次不是偶数次,异或的成对抵消逻辑不再适用。
这时候可以考虑更底层的视角:统计整个数组在每一个二进制位上 1 出现的次数。如果一个数字出现三次,它在任意一位上贡献的 1 的个数一定是 3 的倍数;只有那个只出现一次的数字,会让某些位上的计数比 3 的倍数多 1。所以,只需要用一个长度为 32(或 64,取决于整数位数)的数组统计每一位上 1 的总数,最后对 3 取模,模 1 说明这一位属于那个落单的数字。
举个例子:数组[2, 2, 2, 3]。2的二进制是10,3的二进制是11。统计每一位:第 0 位上2贡献了 0,3贡献了 1,总数是 1;第 1 位上2贡献了 3,3贡献了 1,总数是 4。对 3 取模后,第 0 位是 1,第 1 位是 1,拼起来就是11,正好是 3。成对的重复数字在取模后全部归零,落单数字的每一位独立显现,最后拼回完整整数。
实现上可以写两层循环:外层遍历数组,内层遍历 32 个位,把每一位的计数累加。时间复杂度是 O(32n),常数项虽然比 O(n) 大了不少,但 32 对绝大多数场景来说是个固定的、可以接受的小范围,依然属于线性复杂度。空间上只需要一个固定长度的数组,也算 O(1)。这种"按位统计取模"的思路比异或更加通用,它不依赖"偶数次抵消"这个特性,只要你知道重复次数是几,把模数换成几就行。
4.3 让它更通用:重复 m 次也能解
顺着上一节的思路继续推进:如果数组里只有一个元素出现一次,其余元素都恰好出现 m 次,那么最朴素可靠的解法就是按位统计,最后对 m 取模。因为出现 m 次的元素在每一位上贡献的 1 的个数都是 m 的倍数,取模后归零,落单数字的位保留下来。
这个通用方案在工程上有一定价值,因为它把一道看起来需要"灵光一闪"的题,降维成了一类可复盘的模板:遇到"找出现频率异常元素"的需求,先统计每一位的计数,再对频率取模。代价是位数是常数,所以整个算法依然是线性时间、常量空间。
不过要提醒一句:当 m 是偶数时,异或的简洁方案依然有效;当 m 是奇数时,就必须回到按位统计。做面试题或者日常排查问题时,先判断重复次数是奇是偶,再选择套路,能省不少时间。还有一种更进阶的优化是用"有限状态自动机"来模拟每个位上计数对 3 取模的过程,只需两个变量就能完成,但这属于锦上添花的技巧,实际写代码时反而容易混淆,感兴趣可以单独练习,不建议第一篇就啃这种版本。
5. 从这道题带出的工程思维
5.1 位运算在真实工程里的常见投影
很多人学完这道题,觉得异或只是一个"面试专用技巧",平时写业务代码根本用不上。这个看法其实不完全对。位运算在真实工程里的影子比你想象的多,只是它们往往藏在框架和工具链的内部。
一个非常经典的场景是奇偶校验位。在数据传输中,发送方会把所有数据位的 1 的个数做异或,生成一个校验位;接收方收到数据后再做一次同样的异或,如果不为 0,说明传输过程中有奇数个比特位发生了翻转。这和"只出现一次的数字"的思路完全一致——成对的正确信息互相抵消,异常位暴露出来。
另一个场景是状态切换。比如某个配置项要在一个布尔值之间反复翻转,flag ^= true就是一种极为简洁的写法。把它展开来看,本质就是"每次出现一次,就在状态里累计一次",和异或累积结果异曲同工。还有不少一致性哈希、负载均衡方案里会用到"同值异或两次恢复原值"的特性来做临时加解密或者 token 校验,因为异或的逆运算就是它自己,密钥相同的情况下(data ^ key) ^ key就能还原出原始数据。
所以当你真正理解了异或,再回头看这道题,会发现它不是一道孤立的算法题,而是帮助你建立"用数学性质简化编程问题"这一思维方式的最小训练单元。
5.2 时间空间权衡,面试和代码评审里怎么表达
这道题还带出一个更通用的话题:算法设计中的时间与空间权衡。哈希表方案时间 O(n)、空间 O(n),已经足够快,但空间不达标;异或方案时间 O(n)、空间 O(1),在时间复杂度相同的情况下,省下了成倍的内存。
有人可能会问:现代机器内存动辄几个 G,省这么点空间有意义吗?有,但要分场景看。在嵌入式设备、实时数据处理管线或者超大规模日志分析里,内存可能非常紧张;更关键的是,O(n) 空间不只是"占内存",它还意味着需要额外的内存分配与回收、缓存命中率下降,甚至可能触发 GC 压力。很多时候,一个"看起来一样快"的 O(n) 空间算法在生产环境里的真实吞吐量,反而比 O(1) 空间算法差不少。
在代码评审时,我一般会这样表达:先说明最直观的哈希表方案,讲清楚它是怎么做到 O(n) 时间的;然后指出空间短板,再引出异或方案,重点讲"为什么异或能在不引入额外存储的情况下达到同样时间复杂度"。这种表达方式不是为了显得自己会很多解法,而是让听的人明白,你是基于约束条件在做取舍,不是在背答案。
6. 踩坑复盘:我给这道题的解法的几条心得
最后聊几个我自己学习和带人时踩过的坑,希望对你有帮助。
第一个坑是"只会背异或,不理解为什么"。有些朋友看完答案,代码写得飞起,但换一个变体就傻眼。比如我把题改成"找出出现次数为奇数次的唯一数字",他虽然知道先异或,但解释不清为什么异或能把奇数次出现的数字挑出来。实际上,异或统计的是每一位上 1 的个数的奇偶性,出现偶数次的比特位必然抵消,出现奇数次的比特位必然保留。理解到这个层面,才能举一反三。
第二个坑是"过早优化"。我刚接触这类题时,总想一步到位写状态机版本,结果被ones、twos两个变量的循环更新绕得晕头转向。后来发现,先写出按位统计取模的版本,跑通所有测试用例,再去研究状态机优化,心理压力会小很多。先正确,再优化,这个顺序在算法学习里比任何技巧都重要。
第三个坑是"只在脑子里推,不手写验证"。异或的抵消过程在脑子里想是挺清楚的,但真到面试或者写生产代码时,手一抖就容易把result ^= num写成result += num,或者把循环顺序写错。我的习惯是:写完之后,拿一个最短的例子比如[1, 2, 2]在心里快速走一遍,确认结果是 1 而不是别的值再提交。这个习惯帮我避免过不少低级错误。
再分享一个面试小技巧:如果被问到这道题,不要一上来就写异或的答案,哪怕你已经非常熟悉。先讲哈希表方案,表示你理解常规解法;再讲题目限制了空间,引导自己思考位运算;然后引出异或,并花二十秒解释三个性质。这一套流程走下来,面试官看到的不是一个"背过答案的人",而是一个"会从约束条件推导解法的人"。这两者之间的差距,往往就是拿到 offer 和只是"通过"之间的差距。
最后想说的是,这道题虽然简单,但它是一个很好的思维转折点。它让你意识到,算法的力量很多时候不来自复杂的数据结构,而来自对数据本身性质的洞察。希望你读完这篇文章,不只是记住了result ^= num这一行代码,而是真正理解了异或背后的奇偶校验思想,然后把这种思想带到你遇到的下一个"找异常数据"的问题里去。