原地哈希法:如何用“负数暗号”秒杀一道经典算法题?

81 阅读5分钟

🔥原地哈希法:如何用“负数暗号”秒杀一道经典算法题?

今天我们要聊的这道题,堪称算法面试界的“钉子户”——

给你一个无序整数数组,请找出第一个缺失的正整数。

听起来平平无奇?但加上这个条件后,瞬间变脸:

⚠️ 要求时间复杂度 O(n),空间复杂度 O(1)

这时候,大多数人的第一反应是:“哈希表不香吗?”
——当然香,但你得有地方放啊!题目说了:不准开额外数组、不准用 Set、不准 new Map!

于是,我们被迫走上一条“走火入魔”的道路:在原数组上搞事情,靠‘负号’传情报
这就是传说中的——原地哈希法(In-place Hashing)


🧩 问题长什么样?

image.png

举几个例子感受一下:

输入输出解释
[1,2,0]31和2都有了,最小没出现的是3
[3,4,-1,1]21有,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)快速验证思路

📣 写给掘金小伙伴的话

这道题的意义,不只是“找一个缺失的数”。
它教会我们一件事:

当你被限制了资源,创造力才会真正爆发。

就像原地哈希——没有空间?那就借数组的身体传消息;
没有额外容器?那就用正负号当摩斯密码。

这才是算法的魅力:在规则的缝隙里跳舞,在不可能中创造可能。

下次遇到“必须原地操作”的题,别慌。
想想今天的“负数暗号”,也许你就离最优解只差一个灵感。