图 1:本文封面,点明题目与复杂度约束。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。题面:1 到 n+2 这些整数本该全在一个数组里,现在缺了两个,数组乱序。要求 O(n) 时间、O(1) 额外空间,把缺的两个数找出来。
面试现场,这道题先淘汰的是「排序后扫一遍」——O(n log n) 时间超标;再淘汰「开个哈希表登记出现过的数」——O(n) 空间超标。面试官想等的是:不开额外存储,怎么用「整体特征」把两个未知数挤出来。
核心结论:两条路都通,一条用数学求和,一条用位运算异或。
解法一:求和加平方和。
缺的两个数记为 a 和 b。一趟遍历算出数组的实际和,再用 1 到 n+2 的理论和减去它,得到 a + b。一个方程解不出两个未知数,那就再造一个:同样方法算平方和之差,得到 a² + b²。两个方程联立:由 (a−b)² = 2(a²+b²) − (a+b)² 求出 |a−b|,再和 a+b 一配,a、b 就都出来了。一趟遍历、两个累加器,时间与空间都达标。
这个解法唯一要警惕的是溢出。n 上到十万,平方和就是 10¹⁵ 量级,超出 32 位整型的射程;用 JavaScript 写演示时 Number 也不保险,得换 BigInt。面试里主动提溢出,是加分项。
解法二:异或分组。
异或有一个美妙的性质:相同的数两两抵消。把数组所有元素和 1 到 n+2 全部异或在一起,成对的数全消掉,最后剩下 a⊕b。a⊕b 不为零,说明 a 和 b 至少在某一位上不同。取 a⊕b 任意一个为 1 的位(惯用最低位),按「这一位是 0 还是 1」把数组元素和 1 到 n+2 全体分成两组。a 和 b 在该位不同,必然各落一组;每组内部除了一个缺失数之外全部成对。两组各自再异或一遍,各剩一个数,就是 a 和 b。
异或法比求和法多几步,但完全不担心数值范围——位运算没有溢出这回事。
写出可运行的代码。
下面这份实现同时给出两种解法,用 1..8 缺 3 和 6 的示例走查,再做两千组随机对拍(暴力集合做基准),用 Node.js 22.15.0 直接运行(node find-missing-two.js)即可:
'use strict';
// 解法一:求和 + 平方和,BigInt 防溢出;m = n+2,完整范围为 1..m
function findTwoBySum(arr, m) {
let sum = 0n, sq = 0n;
for (const v of arr) { sum += BigInt(v); sq += BigInt(v) * BigInt(v); }
let fullSum = 0n, fullSq = 0n;
for (let i = 1; i <= m; i++) { fullSum += BigInt(i); fullSq += BigInt(i) * BigInt(i); }
const s = fullSum - sum; // a + b
const q = fullSq - sq; // a² + b²
const d = isqrt(2n * q - s * s); // |a - b| = sqrt(2(a²+b²) - (a+b)²)
return [Number((s + d) / 2n), Number((s - d) / 2n)];
}
// 整数平方根:二分查找(BigInt)
function isqrt(x) {
if (x < 2n) return x;
let lo = 1n, hi = x;
while (lo <= hi) {
const mid = (lo + hi) / 2n;
if (mid * mid <= x) lo = mid + 1n; else hi = mid - 1n;
}
return hi;
}
// 解法二:异或分组
function findTwoByXor(arr, m) {
let x = 0;
for (const v of arr) x ^= v;
for (let i = 1; i <= m; i++) x ^= i; // 成对抵消后 x = a ^ b
const bit = x & -x; // 取最低为 1 的位:a、b 在此位不同
let g0 = 0, g1 = 0;
const feed = (v) => { if (v & bit) g1 ^= v; else g0 ^= v; };
for (const v of arr) feed(v);
for (let i = 1; i <= m; i++) feed(i);
return [g0, g1];
}
// 暴力基准:用 Set 找出缺失的两个数
function brute(arr, m) {
const seen = new Set(arr);
const missing = [];
for (let i = 1; i <= m; i++) if (!seen.has(i)) missing.push(i);
return missing;
}
const demo = [1, 2, 4, 5, 7, 8]; // 1..8 缺 3 和 6
console.log('示例:求和法', findTwoBySum(demo, 8), '异或法', findTwoByXor(demo, 8), '基准', brute(demo, 8));
let bad = 0;
for (let t = 0; t < 2000; t++) {
const m = 2 + Math.floor(Math.random() * 1000);
const a = 1 + Math.floor(Math.random() * m);
let b = 1 + Math.floor(Math.random() * m);
while (b === a) b = 1 + Math.floor(Math.random() * m);
const arr = [];
for (let i = 1; i <= m; i++) if (i !== a && i !== b) arr.push(i);
const want = [Math.min(a, b), Math.max(a, b)].join(',');
const ok = (r) => r.slice().sort((p, q) => p - q).join(',') === want;
if (!ok(findTwoBySum(arr, m)) || !ok(findTwoByXor(arr, m))) bad++;
}
console.log('2000 组随机对拍,不一致组数:', bad);
实测输出:
示例:求和法 [ 6, 3 ] 异或法 [ 6, 3 ] 基准 [ 3, 6 ]
2000 组随机对拍,不一致组数: 0
两千组随机用例两种解法全部与暴力基准一致。
图 2:求和方程法与异或分组法的完整推导。
可能的追问。
「只缺一个数呢?」更简单,求和相减或全体异或,一行出答案。「缺三个呢?」数学派再加一个立方和方程,但方程组越来越脆;异或派难以直接分组,通常改用位图或分治。「数组里有重复数呢?」题设就变了,这两种方法的前提(每个数至多出现一次)被破坏,得换「找重复数」的套路。
易错点清单。
一,平方和用 32 位整型累加,大 n 直接溢出而不自知。二,异或分组时只对数组元素分组,忘了 1 到 n+2 的全量也要按同一位分组,结果两组都混进了成对的数。三,取标志位写成 x & (−x) 时没想清楚负数补码,换个语言实现就翻车。四,答完求和法不提溢出,被追问「n 是一亿呢」当场卡住。
回到面试现场,建议的答题顺序:先给求和加平方和(方程思想最直观),主动指出溢出风险,再补异或分组作为「免疫溢出」的替代。两种方法看似无关,内核是同一个:不逐个追踪元素,而是用可逆的整体聚合特征(和、平方和、异或和)做集合差异。这个思想在样本缺失检测、特征对齐里反复出现。
你在面试里被推导出「第二个方程」吗?当时想到的是平方和还是别的?欢迎分享。