最近在LeetCode上刷到一道很经典的题,题号268,英文名叫 Missing Number,中文一般叫“丢失的数字”。题目描述特别简单:给定一个包含 [0, n] 中 n 个数的数组,找出该范围中没有出现在数组中的那个数。但就是这么一道“简单题”,我见过不少人用哈希表、求和公式做完就扔了,很少有人去深挖题目背后的东西。直到我看到有人用位运算写出了一行核心逻辑的解法,才意识到这道题其实是一把非常好的钥匙——它能串起位运算的几个核心规则,还能让你在面试中展示出“我不是只会暴力解”的思维深度。
这篇文章我想完整记录一下我对这道题的理解过程:从最朴素的思路出发,到为什么位运算是这条路里最优雅的方案,再到异或解法的底层原理、代码实现、边界情况,以及它和哈希表、求和公式之间的复杂度博弈。最后我还会延伸几个位运算的常见变体,帮你把这一套思路从“会做一道题”变成“会做一类题”。如果你正准备面试,或者刚接触 C++ 算法题,这篇文章应该能给你一些不一样的启发。
1. 先拆题:题目给了你哪些隐藏信息
这道题表面看是个“查找缺失元素”的问题,但如果只盯着“查找”两个字,很容易一上来就想到哈希表或者排序,然后问题就变成了“怎么把缺失的那个找出来”。但我想说的是,先别急着写代码,把题目给你的信息全部列出来,你会发现有一些线索是被低估了的。
先看题目描述:给定一个包含 [0, n] 中 n 个数的数组 nums,找出缺失的那个数。举个例子,如果 n = 3,那么完整的序列是 [0, 1, 2, 3],而数组可能只给了 [3, 0, 1],那缺失的就是 2。
注意这里有几个关键信息:
- 数组的长度是
n,但数的范围是[0, n],也就是说一共有n + 1个数,但只给了n个位置,所以一定恰好有一个数缺席。 - 范围是从
0开始连续到n,不是从 1 开始的,这意味着0的缺失是一种合法的边界情况。 - 数组中的元素是“无序”的,题目没规定必须有序。
- 数组中的元素不重复,因为没有哪个数会出现两次,除非
n个位置装不下n+1个不重复的数。
这几个信息组合起来,其实已经暗示了一条很重要的路:这个数组的长度和数值范围存在一个天然的“差值关系”。数组有 n 个元素,完整序列有 n + 1 个元素,缺失的那个就是两者之间的桥梁。顺着“差”这个思路想,很多人会想到求和公式:先把 0 到 n 全部加起来,再减去数组里所有数的和,差值天然就是缺失的那个数。
求和公式确实能解,而且写起来非常短。但它有一个隐患,后面我会详细讲。这里想强调的是,题目描述里其实还藏了一个容易被忽略的点:“只缺失一个数”意味着你可以把所有元素和所有下标建立映射关系。这个“下标”可能就是解题的突破点。如果我们把数组的每个位置也当成一个“数”,跟数组里的值放到一起看,你会发现什么?下标 0 到 n-1,值是 [0, n] 中的 n 个数,除了缺失的那个,其他每个数都会出现两次:一次作为值,一次作为下标(或者反过来)。这时候,位运算里的异或就变得非常有用了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 异或解法核心原理:为什么“自反性”在这里如此契合
在我讲异或解法之前,先带大家快速过一遍异或(XOR)运算的四个基本规则。这几个规则不是八股文,而是理解后面所有代码的关键。
- 归零律:
a ^ a = 0。任何数和自己异或,结果是 0。 - 恒等律:
a ^ 0 = a。任何数和 0 异或,结果还是它自己。 - 交换律:
a ^ b = b ^ a。 - 结合律:
(a ^ b) ^ c = a ^ (b ^ c)。
前两条是最核心的。如果把一个数和自己异或,它会消掉;如果和 0 异或,它会保留。这种特性天然适合用来找“只出现一次”或“出现奇数次的数”。
那放到“丢失的数字”这个场景里怎么用?思路是这样的:我们先把所有下标异或一遍,再跟数组中所有的值异或一遍。在数组里,除了那个缺失的数之外,其他每个数都会出现两次——一次是作为这个数本身,一次是对应的下标。异或运算遇到两个相同的数会互相抵消变成 0,所以那些成对出现的数全部会消掉。最后剩下的,就是既没有在下标集合里找到对应,也没有在原数组里出现两次的那个数,也就是缺失的数字。
这个“下标”和“值”配对抵消的过程,是整套解法的灵魂。很多人第一次看代码觉得“看不懂为什么这么短”,其实就是没理解这个配对逻辑。
直接看代码,C++ 实现如下:
cpp复制class Solution {
public:
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,然后把每个下标和对应的数组元素拿来跟 ans 异或。最终 ans 就是缺失的值。
我手动跑一个例子:假设 nums = [3, 0, 1],n = 3,缺失的是 2。
- 初始化:
ans = 3 i = 0:ans = 3 ^ 0 ^ 3 = 0i = 1:ans = 0 ^ 1 ^ 0 = 1i = 2:ans = 1 ^ 2 ^ 1 = 2
最后返回 2,正确。
这里面的底层逻辑是:0 在数组值里出现了,同时在下标 1 那里被异或到了一次(因为 nums[1] = 0),所以 0 总共参与了两次异或,抵消了。1 作为下标 1 出现一次,作为值 nums[2] = 1 又出现一次,也抵消了。3 作为初始值出现一次,作为 nums[0] = 3 又出现一次,也抵消了。唯独 2,它既不在数组里,也不是初始值,从头到尾只参与了“下标 2”这一次异或,所以孤独地留在了 ans 里。
这种做法的精妙之处在于:它不需要额外开辟空间,不需要排序,也不需要单独处理 0 缺失的情况——因为 0 缺失时初始值 n 自然就会留下来。
3. 四种解法横向对比:不只是“能不能做”,而是“哪个才是最优”
丢数字这道题解法很多,网上随便搜都能看到哈希表、排序、求和、位运算四种主流方案。但我想从几个维度把它们放在一起对比一下,你会发现“最优解”是有条件的,不同场景下选择可以完全不同。
3.1 哈希表法:最直观但不是最短
哈希表的思路是先把所有数组元素存入哈希表,然后遍历 0 到 n,哪个不在表里就返回哪个。
cpp复制class Solution {
public:
int missingNumber(vector<int>& nums) {
unordered_set<int> s(nums.begin(), nums.end());
for (int i = 0; i <= nums.size(); ++i) {
if (!s.count(i)) return i;
}
return -1;
}
};
时间复杂度 O(n),空间复杂度 O(n)。这种方案的好处是思路直接,不容易出错,但明显多了 O(n) 的空间。如果题目额外要求“只能使用 O(1) 额外空间”,哈希表就跪了。
3.2 排序法:最慢的“正人君子”
先把数组排序,然后遍历检查 nums[i] == i 是否成立,第一个不成立的位置就是缺失的值。如果全部相等,缺失的值就是 n。
cpp复制class Solution {
public:
int missingNumber(vector<int>& nums) {
sort(nums.begin(), nums.end());
for (int i = 0; i < nums.size(); ++i) {
if (nums[i] != i) return i;
}
return nums.size();
}
};
时间 O(n log n),空间取决于排序算法,一般 O(1) 或 O(log n)。这种方案适合当“保底方案”,但在需要追求性能的场景下不建议选。
3.3 求和公式法:代码最短,但有溢出隐患
数学法是我最喜欢的比较对象,因为它和位运算是两种完全不同的思维路径,但代码量都很短。
cpp复制class Solution {
public:
int missingNumber(vector<int>& nums) {
int n = nums.size();
int sum = n * (n + 1) / 2;
for (int num : nums) sum -= num;
return sum;
}
};
理论上这是 O(n) 时间和 O(1) 空间,看起来和位运算一样优秀。但它有一个隐患:n * (n + 1) 可能会溢出。当 n 比较大(比如 n = 2^31 - 1 时,n * (n + 1) 会超出 32 位 int 的范围),计算结果会变成负数或者溢出后的错误值。虽然你可以改用 long long 来规避,但这相当于在“为语言特性打补丁”。而位运算不会,因为异或操作的中间值永远不会超出参与运算的数本身的范围。
3.4 位运算法:每一步都保持“小而美”
cpp复制class Solution {
public:
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;
}
};
时间 O(n),空间 O(1),无溢出风险。代码短,而且逻辑一旦理解了就很难忘记。
为了更直观地对比,我整理了一个表格,各位可以直接收藏:
| 解法 | 时间复杂度 | 空间复杂度 | 溢出风险 | 代码长度 | 适用场景 |
|---|---|---|---|---|---|
| 哈希表 | O(n) | O(n) | 无 | 中等 | 需要快速验证、不在乎空间的场景 |
| 排序 | O(n log n) | O(1) | 无 | 短 | 数据量小、逻辑优先的场景 |
| 求和公式 | O(n) | O(1) | 有 | 最短 | 面试时可作补充思路,但需注意类型 |
| 位运算 | O(n) | O(1) | 无 | 短 | 追求极致性能与代码优雅的场景 |
可以看到,位运算几乎没有短板。它在时间和空间上都是最优的,而且在面试中能展示出“我对计算机底层位运算有理解”这一点。面试官通常会喜欢这种解法——不仅因为代码短,更因为你能解释清楚“为什么初始值是 n”以及“为什么下标和值能配对抵消”。
4. 从这道题延伸到位运算的“实战武器库”
说实话,“丢失的数字”这道题本身不算难,但它的价值在于帮你建立了一个很重要的思维模型:当你需要在一堆数里寻找“落单的数”时,异或是你的第一反应。有了这个思维模型,下面这几类题目你会瞬间觉得眼熟。
4.1 只出现一次的数字
LeetCode 136 题:给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
这道题是“丢失的数字”的最简版。因为除了目标元素,其他数都出现两次,所以你把所有数全部异或一遍,剩下的那个就是只出现一次的元素。
cpp复制int singleNumber(vector<int>& nums) {
int ans = 0;
for (int num : nums) ans ^= num;
return ans;
}
对比“丢失的数字”,这道题甚至不需要下标参与配对,因为它没有“下标”这个额外的信息源,天然就是“重复的自动抵消”。
4.2 寻找缺失的两个数
如果把“丢失的数字”扩展一下,变成“长度为 n-2 的数组,包含 [0, n] 中的 n-1 个数,缺失了两个数”。这时候你只靠一次异或就不能直接得出结果了,因为异或的结果是 a ^ b,你没法从单一的异或值里分离出两个数。
常见的做法是:先算出 a ^ b,然后找到 a ^ b 中任意一个为 1 的二进制位,按这个位把原数组分成两组,分别异或,就能分离出 a 和 b。这个思路背后的依据是:如果 a 和 b 在某一位上不同,那么这一位为 1 的二进制位可以把所有数分成两类,a 和 b 必然落在不同类里,然后两类里各自做一次“找只出现一次的数”的异或即可。
这种分治思想是位运算进阶的一个典型套路,也是前面那道“丢失的数字”没有直接展开的部分。
4.3 判断一个数是不是 2 的幂
一个数 x 如果是 2 的幂,那么它的二进制表示中只有一个 1。比如 8 是 1000,16 是 10000。判断方式可以这样:
cpp复制bool isPowerOfTwo(int n) {
return n > 0 && (n & (n - 1)) == 0;
}
n & (n - 1) 的作用是把 n 二进制中最右边的那个 1 变成 0。如果一个数只有一个 1,那这个操作之后就会变成 0。这个技巧在“丢失的数字”里没有直接用到,但在位运算题里非常高频。
4.4 交换两个数,不用临时变量
cpp复制a ^= b;
b ^= a;
a ^= b;
这个技巧很经典,本质上是异或的逆运算特性:a ^ b 之后再异或 a 能得到 b,异或 b 能得到 a。虽然现在实际开发中没人会为了省一个变量这么写(可读性太差),但理解它对你掌握异或运算的交换、结合性质非常有帮助。
用一张逻辑链总结一下:异或的“自反性”让它天生适合做“配对消消乐”。凡是“在一个集合里找落单元素”的题目,你都可以先想想异或。这不是套模板,而是异或运算的数学性质和这类题目的需求正好高度契合。
5. 代码落地与工程习惯:真实项目中位运算的几个注意点
算法题里你可以只写一个函数,但在真实工程里,位运算的使用要考虑的细节比 LeetCode 上要多不少。我从自己的实践出发,讲几个经常被忽略的地方。
5.1 无符号数与有符号数的差异
在 C++ 中,int 是有符号数,最高位是符号位。如果你对一个负数做异或或者 &、|、<< 等位操作,结果会根据编译器和平台的不同有些微妙差异。虽然 C++ 标准现在规定负数的内部表示必须使用二进制补码,但在实际项目里,如果你做的是一个纯位运算的逻辑,建议使用无符号类型 uint32_t 或 uint64_t。原因很简单:无符号数的位运算结果完全确定,不存在“符号位会被算术右移填充还是逻辑右移填充”这类不确定性问题。
5.2 运算符优先级是位运算重灾区
下面这行代码是最经典的坑:
cpp复制if (a & b == 0) { ... }
很多人以为这行是 “a & b 的结果是否为 0”,实际上因为 == 的优先级高于 &,这行代码的真实含义是 a & (b == 0)。一旦 b == 0 为 true(即 1),这行表达式就变成了 a & 1。这种 bug 不会导致编译错误,运行时也可能偶尔正常,属于最难查的那类问题。
我自己的习惯是:凡是位运算和逻辑运算混用的地方,一律加括号。别觉得自己记性好,过几个月回头看代码,括号能救你一命。
5.3 可读性优先,适当封装
在真实项目里,直接写一长串异或逻辑往往让人摸不着头脑。我更推荐给位运算起一个有意义的名字,或者封装成函数。比如 “丢失的数字” 里那行 ans ^= i ^ nums[i],在 LeetCode 里没问题,但在工程代码里,建议写成:
cpp复制int missing = nums.size();
for (int i = 0; i < nums.size(); ++i) {
missing ^= i;
missing ^= nums[i];
}
这样每一步的意图都非常清晰:先把下标抵消掉,再把值抵消掉。性能上几乎没差别,但可读性好了很多。
5.4 位运算是真的快吗?
很多人以为位运算“一定比加减乘除快”,这个说法在现代 CPU 上其实已经不那么绝对了。现代编译器会把 x * 2 优化成 x << 1,会把 x / 2 优化成 x >> 1(无符号),所以单纯从指令数量上看,编译器已经帮你做了优化。但位运算的真正优势不在于“快几纳秒”,而在于它的确定性和无分支特性。
举个例子,x & 1 判断奇偶性,不会像 x % 2 那样可能被编译器优化成分支跳转;x & (x - 1) 清零最低位,可以减少分支判断。在算法题或者对性能极致敏感的场景里,这种“去分支化”往往比省一条指令更有价值。
另外,位运算还特别适合和状态压缩、掩码操作配合。比如你要表示 32 个布尔状态,一个 uint32_t 就搞定了,这就是位图思想。很多高性能代码(比如网络协议中的标志位解析)都在大量使用位运算,这类场景里位运算不是“优化技巧”,而是核心基础。
6. 从刷题思维到“举一反三”:这题还能怎么变着考
没有哪道算法题是孤立的。面试官出一道“丢失的数字”,往往会顺着你的解法继续加约束条件,让你现场扩展。我把自己见到的几种变体列一下,大家心里有个底。
6.1 变体一:缺失值不止一个
题目改成数组长度是 n - k,数值范围是 [0, n],缺失了 k 个数。此时异或解法需要升级为分组异或,或者改用位图。如果你知道 k 不大,可以用一个 uint64_t 位图来标记所有出现过的数,然后遍历位图找 0 位。这种场景下,位运算依然是核心工具。
6.2 变体二:数组有序时你会怎么解
如果题目额外说明数组已经排好序,那最优解会变成二分查找。因为缺失数把数组分成了两部分:前半部分 nums[i] == i,后半部分 nums[i] == i + 1(因为缺失了一个数,后面所有元素都往后错了一个位置)。二分出第一个 nums[i] != i 的位置,这个位置就是缺失值。这个变体考察的是你有没有“根据输入特点调整算法”的意识。
6.3 变体三:如果给的是 0 到 n-1,缺的是 n
如果数值范围是 [0, n-1],数组长度也是 n-1,那么缺失的可能值是 n。这时异或解法依然有效,因为你在外面补了一个初始值 n,正好覆盖了这个边界。这也是我为什么强调“初始值设为 n”的原因——它不仅仅是为了让循环里的异或有一个起始点,更是为了把 n 这个可能缺失的候选值提前纳入异或池中。
6.4 变体四:扩展到二维或者更复杂结构
理论上,如果你有一个二维矩阵,缺失的是某个特定位置的“配对值”,你也可以用异或思想,把行索引、列索引和值一起异或。我在实际刷题中见过类似的题目(比如给一个 n 行 n 列矩阵,每行每列都有规律,缺一个元素),思路完全相通:把所有已知维度信息都丢进异或池,最后剩下的就是缺失的信息。
这种思路的本质是:异或池里的每个元素都应该出现偶数次,缺失的那个元素会出现奇数次,所以它会留在结果里。只要你能把题目转换成“找出现奇数次的元素”,异或就是最优解。
7. 我在实战中总结的几个小习惯
最后聊几个和代码无关但和刷题体验、面试表现强相关的小习惯。
第一,不要背模板,要背推导过程。很多人看到“丢失的数字”就背了 ans = n; ans ^= i ^ nums[i] 这个模板,一旦题目稍微变形就蒙了。我自己做过一个实验,把数组改成从 1 到 n+1,很多人就不知道该把初始值设成什么了。如果你能从“为什么初始值是 n”“为什么要用下标和值配对”这些底层逻辑开始理解,变体题根本难不住你。
第二,手算一遍小例子。我每次刷位运算题,都会拿一个长度为 3 或 4 的数组手动走一遍异或过程。这个习惯帮我发现了不止一次逻辑错误,尤其是当涉及多组异或配对时,手算能直观地看到哪些数被抵消了,哪些数留了下来。
第三,用编译器验证位运算优先级。很多人写位运算表达式时不加括号,觉得反正 & 和 ^ 优先级差不多。真不是。C++ 的运算符优先级表里,== 和 != 高于 &,& 高于 ^,^ 高于 |。所以 a & b == 0 这种写法是错的。我建议把所有混合位运算表达式的每个部分都用括号包起来,或者分开写成多行。这不会影响性能,却能省下大量排查时间。
第四,面试时先讲思路,再写代码。这套题最打动面试官的往往不是最后的代码,而是你分析过程:从哈希表想到空间优化,从求和想到溢出风险,最后滑到异或,一条线推下来,逻辑非常顺。哪怕你最后写出来的代码有 bug,面试官也更愿意继续对话。
第五,关注编译器优化和平台差异。在本地跑 LeetCode 和部署到真实服务器不是一回事。如果你要在项目里用位运算,建议明确使用固定宽度的类型(比如 uint32_t、int64_t),避免 int 在不同平台上的字节数不一致带来的隐患。我做网络协议解析时,所有标志位都会用固定宽度类型,就是为了避免跨平台编译时行为不一致。
回到最初的题目。一个简单的 missingNumber 函数,看似只有几行代码,背后却牵扯出异或运算的数学性质、时间复杂度的权衡、溢出风险的规避、工程代码的可读性,以及面试中的思维表达。这也是为什么我一直觉得,刷题不光是刷“会做”,更重要的是刷“理解”。等你真正把一道题的底层原理吃透,再做任何变体题,都只是在原来的骨架上添加新的血肉而已。
