☰
从LC268看位运算:异或如何优雅找出缺失数字
2026/10/8 20:03:30 网站建设 项目流程

1. 从LC268开始认识“位运算”这个冷兵器

LeetCode 268题“丢失的数字”大概是所有刷题人都会遇到的一道“入门友好但上限很高”的题。题目描述极为简单:给定一个包含[0, n]中 n 个数的数组,找出缺失的那个数字。比如nums = [3,0,1],n=3,那么0到3应该是0、1、2、3四个数,数组里只有3、0、1,缺的是2。

我第一次做这道题时,脑子里第一反应就是用哈希表把所有出现过的数字记下来,然后从0到n逐个查。这当然能解,而且时间复杂度和空间复杂度都很清晰。但后来在面试复盘和刷题总结中,我发现这道题真正的“分水岭”不在“能不能做出来”,而在“能不能想到用位运算一步搞定”。位运算是C/C++、Java、Kotlin、Python等语言里都自带的基础能力,但很多人在日常业务开发中几乎不用它,导致面试时一提到“位运算”就发怵。

这篇文章我打算从一个实际刷题者的视角,把LC268和位运算彻底讲透。不仅讲怎么用异或解这道题,还会把异或运算的底层逻辑、运算优先级、C++中的按位运算顺序、以及若干常见坑全部铺开。读完之后你再看“位运算”,就不只是背一个技巧,而是真正理解它为什么能在这里做到“零额外空间、单次遍历”。

适合谁来读?如果你是刚开始刷LeetCode的初学者,这篇文章能让你建立对位运算的直觉;如果你已经会做哈希表解法,但想知道“为什么别人能想到异或”,那这里面的推导过程对你更有价值;如果你想在面试中把一道简单题讲出层次感,这篇文章同样能给你一个清晰的讲述框架。

2. 常规解法拆解:为什么哈希表不是最优答案

2.1 题目的本质是“一个萝卜一个坑”的配对问题

先别急着上位运算,我们把题目本身解剖清楚。数组长度是 n,但原始数据范围是 [0, n],也就是说完整的序列应该有 n+1 个数字。现在数组里只有 n 个数字,相当于从完整序列里抠掉了一个。

这就像你有100个编号从0到99的储物柜,但现在手里只有99把钥匙,每把钥匙上刻着一个编号。你要快速找出缺失的那把钥匙编号。最直观的做法是把已有钥匙编号记下来,然后逐一核对0到99哪个编号没出现。这就是哈希表思路。

另一个直观做法是排序。排完序后,数组下标和元素值应当一一对应,扫描一遍就能找出第一个“对不上”的位置。但排序本身的时间复杂度是 O(n log n),而且某些语言的排序还要额外空间,显然不是最优。

还有一个很经典的做法是数学求和。完整序列 0 到 n 的和是n * (n + 1) / 2,用这个完整和减去数组所有元素的和,差值就是缺失的数字。这个方法时间复杂度 O(n),额外空间 O(1),看起来已经非常好了。那为什么还要有第四种做法——位运算?答案在于“减法”本质上仍然依赖一个前提:你知道完整的和是多少。虽然这个前提在数学上成立,但实际工程中,如果 n 足够大,n * (n + 1) / 2有可能提前溢出整数范围。面试官听你说完求和法,往往就会追问一句:“如果 n 是 2^31 - 1 呢?求和会不会有问题?”

这时候,位运算的价值就体现出来了。异或解法它不关心总和有多大,每一步的中间结果都保持在相对较小的范围内(实际上中间结果也在整数范围内,但不会像求和那样累计出一个巨大的值后再做减法),而且它的核心逻辑本身就是“配对消除”。

2.2 哈希表、排序、求和三种方案的复杂度对比

我把这三种常规解法整理成一个表格,方便你在脑海里建立整体印象:

方案时间复杂度空间复杂度核心思路缺点
哈希表O(n)O(n)记录出现过的数字,再逐一查缺额外空间开销大,面试官容易追问能否O(1)
排序O(n log n)O(1)或O(n)排序后比对下标与值时间偏慢,且排序本身不是这道题想考的
数学求和O(n)O(1)完整和减去实际和n很大时可能溢出;思路偏“算术”而非“位级”
异或位运算O(n)O(1)同一数字两次异或归零需要理解异或的性质,有思维门槛

看到这里你应该能明白,哈希表和求和法都不是“错”,只是这道题作为位运算的经典入门题,天然有一个更优雅的解法等着你去发现。LeetCode上这道题的好评区里,大量高赞回答都在强调一个点:当你发现题目在“找缺失”、“找重复”、“找只出现一次的数字”时,优先想想异或。

3. 位运算解题原理:异或是怎么“凭空”找出缺失数字的

3.1 异或运算的三条基本性质,必须刻进脑子

异或的符号是^,在C++、Java、Kotlin、C#里都是这个符号,Python里也是。它的运算规则是:两个二进制位相同则为0,不同则为1。也就是说1 ^ 1 = 0,0 ^ 0 = 0,1 ^ 0 = 1,0 ^ 1 = 1。

从这条规则可以推导出三条极其重要的性质:

  1. 归零律:x ^ x = 0,任何数与自身异或,结果为0。
  2. 恒等律:x ^ 0 = x,任何数与0异或,结果还是它自己。
  3. 交换律与结合律:a ^ b = b ^ a,(a ^ b) ^ c = a ^ (b ^ c),这意味着你可以按照任意顺序对一组数做异或,结果不变。

这三条性质合起来能干什么?我们可以把“异或”理解为一种“配对联消”的操作。举个例子:1 ^ 2 ^ 3 ^ 2 ^ 1,根据交换律先整理成(1 ^ 1) ^ (2 ^ 2) ^ 3,然后1 ^ 1 = 0,2 ^ 2 = 0,最后0 ^ 3 = 3。你看,成对出现的数字全部抵消了,只剩落单的那个。

这个特性和LC268简直是天作之合。为什么?因为完整序列[0, n]中每个数字“应该”只出现一次,数组里每个数字也“应该”只出现一次,如果我们把数组中的所有数字和[0, n]的所有数字全部放在一起做异或,那么除了缺失的那个数字只出现一次外,其余每个数字都恰好出现两次,全部抵消。最后留下的就是缺失数字。

3.2 代码实现:从0到n的完整异或链

具体怎么把“数组中的数字”和“[0, n]完整序列”合在一起?常见的做法是声明一个变量ans初始为0,然后先对数组所有元素逐一异或,再对0到n的每个整数逐一异或,最终结果就是缺失值。

C++的写法如下:

int missingNumber(vector<int>& nums) { int n = nums.size(); int ans = 0; for (int num : nums) { ans ^= num; } for (int i = 0; i <= n; i++) { ans ^= i; } return ans; }

这段代码的核心逻辑就是两条循环。第一条循环把数组里的每个数字逐个异或进ans,第二条循环把完整序列里的每个数字逐个异或进ans。最终,所有出现两次的数字全部抵消,唯一出现一次的数字就是缺失值。

实际上还可以再精简。因为数组下标天生就是从0到n-1,刚好可以和数组元素做一轮配对。把ans初始化为n,然后遍历数组,每次执行ans ^= i ^ nums[i],这样一轮循环就完成了所有异或。这样做的好处是代码更短,也更容易体现出“索引与值配对”的直觉:

int missingNumber(vector<int>& nums) { int n = nums.size(); int ans = n; for (int i = 0; i < n; i++) { ans ^= i ^ nums[i]; } return ans; }

为什么ans初始化为n而不是0?因为完整序列是0到n,下标i只能覆盖0到n-1,n这个数字本身在数组里是不会作为下标出现的,但它作为“完整序列”的一员必须参与异或。所以直接把它作为初始值,既节省了一次循环,又把缺口补上了。

用[3, 0, 1]来手动推演一遍:

  • ans = 3
  • i=0:ans = 3 ^ 0 ^ 3 = 0
  • i=1:ans = 0 ^ 1 ^ 0 = 1
  • i=2:ans = 1 ^ 2 ^ 1 = 2

最终结果2,正确。你再观察这个过程:每轮操作其实是在做“当前下标应当匹配的值”和“实际数组里的值”之间的对消。如果所有数字都齐全,全部操作结束后ans一定是0,但现在有缺失,最后就剩下了缺失值。这个推演过程很有助于理解为什么这一步是成立的。

3.3 多语言实现:别把思路限制在单一语言里

面试时你可能会用不同语言写这道题,位运算的基础语法在主流语言里几乎一致。我写几个常用版本的伪代码风格示例,方便你在不同的环境里快速切换。

Java版本:

public int missingNumber(int[] nums) { int n = nums.length; int ans = n; for (int i = 0; i < n; i++) { ans ^= i ^ nums[i]; } return ans; }

Kotlin版本:

fun missingNumber(nums: IntArray): Int { var ans = nums.size nums.forEachIndexed { index, value -> ans = ans xor index xor value } return ans }

JavaScript版本:

var missingNumber = function(nums) { let ans = nums.length; for (let i = 0; i < nums.length; i++) { ans ^= i ^ nums[i]; } return ans; };

Python版本:

def missingNumber(nums): ans = len(nums) for i, num in enumerate(nums): ans ^= i ^ num return ans

注意Kotlin里的异或是一种中缀函数,写作xor;而在C++、Java、JavaScript、Python里异或都是符号^。另外C++里^的优先级低于+、-、比较运算符等,如果你写出ans ^= i + nums[i]这种表达式就有歧义了,i + nums[i]会先执行,再与 ans 异或。所以做位运算时建议多用括号。这个问题我会在后面单独用一个小节展开,因为“C++中按位运算顺序”这句话在LeetCode讨论区里被反复提起,很多初学者栽过跟头。

4. 位运算优先级与C++中的常见陷阱

4.1 按位运算在C++里的优先级表,面试爱问

很多人以为位运算就是一个简单的符号,写起来不会出错。但实际上,C++的运算符优先级里,位运算的位置相当靠后,非常容易踩坑。我整理了一张简化优先级表,从高到低排列:

优先级运算符说明
1()、[]、->、.括号、下标、成员访问
2!、~、++、--、单目-逻辑非、按位取反、自增自减
3*、/、%乘除取模
4+、-加减
5<<、>>位移
6<、<=、>、>=关系比较
7==、!=相等比较
8&按位与
9^按位异或
10|按位或
11&&逻辑与
12||逻辑或
13? :三目条件
14=、+=、^=等赋值与复合赋值

这张表的关键信息有两点。第一,按位异或^的优先级低于相等比较==,所以在写if ((a ^ b) == 0)时,括号是必须的;如果不加括号,编译器会先执行a ^ (b == 0),语义就完全错了。第二,^=这种复合赋值运算符的优先级非常低,只比逗号运算符高一点,所以ans ^= i ^ nums[i]这个表达式里的i ^ nums[i]会先被完整计算,然后再做异或赋值,这在大多数情况下是符合预期的,但如果你在右边写了一个复杂的表达式,最好还是用括号明确边界。

举个实际的坑:有人想写if (x & 1 == 0)来判断偶数,但在C++里==优先级高于&,实际执行的是x & (1 == 0),也就是x & 0,结果恒为0,条件永远为假。正确写法必须是if ((x & 1) == 0)。这种错误在笔试里经常出现,我自己第一次写的时候也被坑过。

4.2 按位或赋值与按位异或赋值的区别

热搜词里的“按位或赋值运算”也值得展开说一下。C/C++里有|=、&=、^=、<<=、>>=等复合赋值运算符。它们的基本语义是a op= b等价于a = a op b,但有一个隐藏细节:复合赋值只对左值生效,而且整个表达式只求值一次左侧。比如a ^= b就是a = a ^ b。

在实际开发中,|=常用于“置位”(把某个二进制位设置为1),&=常用于“清位”(把某个二进制位设置为0),^=常用于“翻转位”(某个二进制位取反)。LC268里我们用的是^=来累积异或结果。理解^=和|=的区别很重要:|=是只要两个对应位中有一个为1,结果就是1,它不会把已有的1变回0;而^=是相同为0、不同为1,一个位如果被异或了奇数次,最终结果是1,被异或偶数次则是0。异或天然适合“出现次数奇偶判断”,而按位或适合“标记某一位是否出现过”。

这道题如果用|=来做,那就变成了一个“二进制掩码”方案:用一个足够大的整数变量,每一位代表一个数字是否出现,遍历数组时把对应位置1,最后再查哪一位是0。这个思路也能解,但需要的变量位数随n增长,本质上更像一个压缩后的哈希表,比异或要复杂得多。所以LC268的标准位运算答案,永远指向异或,而不是与、或、非。

4.3 位运算之外的“奇偶校验”直觉

我再帮你建立一个更直觉的理解。异或在二进制层面的行为,很像“奇偶校验”。你把所有参与异或的数的二进制位逐位相加,如果某一位上1的个数是偶数,结果就是0;是奇数,结果就是1。这就是为什么异或能用来做数据校验、RAID磁盘阵列冗余校验等底层工作。

把这个直觉映射到LC268上:数组里的数字和0到n的完整序列,除了缺失的那个数只出现一次,其余每个数都出现两次。所谓“出现两次”,等价于在每一个二进制位上贡献了偶数次1,全部抵消。所以最后剩下的那个数,它的每一个二进制位正好反映了缺失数字的二进制信息。这就是异或解法的底层逻辑。

5. 边界情况、延伸题型与实战心得

5.1 边界情况要逐一过一遍

LeetCode的测试用例经常会藏一些边界条件,写代码时不能只盯着主流程,要把特殊情况都在脑子里跑一遍。

情况一:n = 0。数组为空,长度为0,完整序列只有[0]。此时缺失的数字就是0。用精简版代码,ans初始为0,循环不执行,直接返回0,没问题。

情况二:缺失的是0。比如nums = [1, 2, 3],n=3。此时数组中不包含0。用异或解法,ans初始为3,循环执行0^1、1^2、2^3,最后结果0。逻辑依然成立。

情况三:缺失的是n。比如nums = [0, 1, 2],n=3。数组里少的是3。ans初始为3,循环依次异或0^0、1^1、2^2,前面的结果全是0对消,最后ans保持3,返回值就是3。逻辑同样成立。

情况四:n很大的边界。比如n接近INT_MAX,数组长度接近32位整数的上限。异或解法完全不需要累加一个巨大的总和,每一步都是在做位运算,不会产生溢出问题。这正是异或相比于求和法的核心优势之一。

5.2 一道题延伸出一个位运算系列

LC268只是位运算系列里的一道开胃菜。我建议你刷完这道题后,顺势把下面几道题一起拿下,因为它们的内核几乎一模一样:

题目核心异或思路
LC136 只出现一次的数字数组中只有一个数出现1次,其余出现2次全部异或,成对抵消,剩下的就是答案
LC137 只出现一次的数字 II数组中只有一个数出现1次,其余出现3次需要统计每一位上1出现的次数,用位计数或状态机
LC260 只出现一次的数字 III有两个数各出现1次,其余出现2次先全部异或得到两个目标数的异或值,再按某一位分组
LC191 位1的个数统计二进制中1的个数用n & (n - 1)消除最低位的1
LC461 汉明距离两个整数二进制位不同的个数x ^ y后统计1的个数

你会发现“异或”在这些题里的角色高度一致:它是一个“配对消消乐”工具。凡是让你在“一堆东西里找落单者”的题,都可以优先考虑异或。

5.3 实操心得:我怎么在面试里把这道题讲出层次

说一个我个人的真实体验。有一次模拟面试,面试官问LC268,我第一遍给的答案是求和法。他点了点头,问:“空间复杂度?”我说O(1)。他继续问:“如果数组非常大,求和会溢出,你怎么解决?”我立刻想到异或,然后当着他的面把代码改成异或版本,并把这套“配对消除”的思路讲了一遍。他明显更满意。

面试官为什么喜欢这道题?因为它足够简单,但又能考察一个人的“知识迁移能力”。哈希表、排序、求和、位运算,每一种解法都对应一个数据结构或算法思想,你能给出几种解法,就代表你对这个基础问题理解到哪个层次。所以我的建议是:不要只背一种解法,要把四种解法都写一遍,然后理解它们之间的递进关系——哈希表解决“记不记得住”,排序解决“排不排得齐”,求和解决“算不算得清”,异或解决“能不能更优雅”。

5.4 一个容易被忽略的调试技巧:先打印中间结果

如果你在力扣上提交异或解法,可能会遇到一种情况:代码看起来完全正确,但本地测试时有几个用例跑不出来。这通常不是算法的问题,而是语言本身的优先级问题或者类型问题。

我调试位运算代码时,习惯在循环里先打印每一次异或后的ans值,观察中间结果是否符合直觉。比如对于[0, 1, 3],期望答案2,你会看到ans的轨迹是3、2、3、2,最终停在2。这个中间轨迹本身就是很好的推导验证。如果中间结果出现非常异常的值(比如负数、超过n很多),那大概率是代码里混进了其他运算符的影响,优先检查表达式里有没有忘记加括号。

另外,C++里如果数组下标是size_t类型,把它直接和int类型的值做异或时会发生隐式类型提升,结果可能变成无符号整数,在某些编译器下打印出来的值会出乎意料。稳妥做法是统一用int类型操作,或者显式做强制转换。

6. 写在最后:位运算不是炫技,而是一种思维方式

我在实际使用中最大的感受是,位运算这个东西,平时不用觉得它鸡肋,一旦用顺了就回不去了。它特别适合那些“状态压缩”和“配对消除”的场景,比如权限系统里用一个整数表示多个开关位、网络协议里用掩码提取字段、算法题里用异或找缺失和重复。

LC268作为一道“简单题”,其实承载了比表面难度更深的东西。它教会我的是:在动手写解法之前,先思考题目背后是否有更本质的规律。这个数组缺失数字的规律就是“配对”,而位运算里的异或恰好就是最纯粹的配对工具。这种“工具与场景的契合感”,比单纯背下代码有价值得多。

如果你刚接触位运算,也不要指望看一遍就完全掌握。我建议你把这篇文章里的代码手动敲一遍,然后分别用求和法和异或法提交同一个题目,对比两者的时间、空间表现。在多轮练习中,你会逐渐形成一种条件反射:看到数字配对、状态翻转、缺失查找,先想到异或。到了那个时候,你就真正吃透这道题了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询