🔥原地哈希法:如何用“负数暗号”秒杀一道经典算法题?
今天我们要聊的这道题,堪称算法面试界的“钉子户”——
给你一个无序整数数组,请找出第一个缺失的正整数。
听起来平平无奇?但加上这个条件后,瞬间变脸:
⚠️ 要求时间复杂度 O(n),空间复杂度 O(1)
这时候,大多数人的第一反应是:“哈希表不香吗?”
——当然香,但你得有地方放啊!题目说了:不准开额外数组、不准用 Set、不准 new Map!
于是,我们被迫走上一条“走火入魔”的道路:在原数组上搞事情,靠‘负号’传情报。
这就是传说中的——原地哈希法(In-place Hashing)。
🧩 问题长什么样?
举几个例子感受一下:
| 输入 | 输出 | 解释 |
|---|---|---|
[1,2,0] | 3 | 1和2都有了,最小没出现的是3 |
[3,4,-1,1] | 2 | 1有,2没有,所以是2 |
[7,8,9,11,12] | 1 | 连1都没有,直接出局 |
乍一看好像很简单?暴力遍历1到∞不就行了?
可问题是,你怎么知道要查到多大?而且时间复杂度直接爆炸。
但这里有个关键洞察,也是解题突破口:
✅ 第一个缺失的正整数,一定在 [1, n+1] 范围内!
为什么?
- 数组长度为 n,最多只能装下 n 个不同的正整数;
- 如果 1~n 全都在,那答案就是
n+1; - 只要缺了一个,那它肯定 ≤ n。
所以,我们的战场被压缩成了一个小盒子:只用关心 1 到 n+1 就够了!
💡 原地哈希:把数组当“地下联络站”
既然不能开哈希表,那能不能借用原数组的空间来记录信息?
灵机一动:索引 i 表示数字 i+1 是否存在,元素的正负表示“有没有见过”!
比如:
nums[0] < 0→ 说明数字1出现过nums[1] > 0→ 说明数字2没出现
这样一来,数组本身就成了一个“带符号的哈希表”,我们称之为:原地哈希(In-place Hashing)
🛠 实现三步走战略:
第一步:清理垃圾数据
先把所有非正整数(≤0 的数)换成 n+1,因为它们没用还碍事。
const n = nums.length;
for (let i = 0; i < n; i++) {
if (nums[i] <= 0) {
nums[i] = n + 1;
}
}
比如 [3,4,-1,1] → [3,4,5,1](n=4)
第二步:打标记 —— “见谁就给谁的脸涂黑”
遍历每个数,如果是 1~n 范围内的,就把对应位置的数变成负的。
注意:要用 Math.abs() 防止已经被标记成负数了。
for (let i = 0; i < n; i++) {
const num = Math.abs(nums[i]);
if (num <= n) {
nums[num - 1] = -Math.abs(nums[num - 1]); // 确保是负的
}
}
继续上面的例子:
- 看到 3 → 把 index=2 涂黑 →
nums[2] = -5 - 看到 4 → index=3 涂黑 →
nums[3] = -1 - 看到 5 → 大于4,跳过
- 看到 1 → index=0 涂黑 →
nums[0] = -3
最终数组变成:[-3, 4, -5, -1]
第三步:找谁还是“白脸”
再扫一遍,第一个正数所在的位置,就是缺失的正整数!
for (let i = 0; i < n; i++) {
if (nums[i] > 0) {
return i + 1;
}
}
return n + 1;
在这个例子里,nums[1] = 4 > 0 → 缺失的是 2,完美命中!
🌟 为什么说这是“神操作”?
- 时间复杂度:三次遍历,O(n)
- 空间复杂度:没开新变量(除了几个指针),O(1)
- 思路清奇:用符号做布尔标记,把数组当成哈希表使
- 面试杀手锏:讲出来能让面试官瞳孔地震
🔄 还有别的思路吗?当然!
虽然原地哈希是最优解,但我们也可以看看“普通人”的做法:
方法一:排序暴力法(适合新手)
先排序,然后从头找第一个“不该出现”的正整数。
nums.sort((a,b) => a - b);
let expect = 1;
for (const x of nums) {
if (x === expect) expect++;
}
return expect;
✅ 好理解
❌ 时间 O(n log n),不符合要求
📌 优点是代码短、不易出错,适合快速 AC
方法二:Set 记录法(现实世界的首选)
const set = new Set(nums);
for (let i = 1; ; i++) {
if (!set.has(i)) return i;
}
✅ 极其简洁,逻辑清晰
❌ 空间 O(n),不适合本题约束
💡 但在实际开发中,这才是你应该写的代码!
方法三:置换法(另一种 O(1) 空间思路)
思想类似“循环排序”:让数字 x 回到 nums[x-1] 的位置。
const n = nums.length;
for (let i = 0; i < n; i++) {
while (nums[i] >= 1 && nums[i] <= n && nums[nums[i]-1] !== nums[i]) {
[nums[nums[i]-1], nums[i]] = [nums[i], nums[nums[i]-1]];
}
}
for (let i = 0; i < n; i++) {
if (nums[i] !== i + 1) return i + 1;
}
return n + 1;
✅ 也是 O(n) 时间 + O(1) 空间
⚠️ 交换逻辑容易写错,边界头疼
🎯 和原地哈希并称“双雄”,各有拥趸
🏆 总结:三种方法对比
| 方法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 原地哈希(推荐) | O(n) | O(1) | 面试秀操作 |
| 置换法 | O(n) | O(1) | 喜欢交换的同学 |
| Set 法 | O(n) | O(n) | 实际项目首选 |
| 排序法 | O(n log n) | O(1) or O(log n) | 快速验证思路 |
📣 写给掘金小伙伴的话
这道题的意义,不只是“找一个缺失的数”。
它教会我们一件事:
当你被限制了资源,创造力才会真正爆发。
就像原地哈希——没有空间?那就借数组的身体传消息;
没有额外容器?那就用正负号当摩斯密码。
这才是算法的魅力:在规则的缝隙里跳舞,在不可能中创造可能。
下次遇到“必须原地操作”的题,别慌。
想想今天的“负数暗号”,也许你就离最优解只差一个灵感。