引言
欢迎来到本系列的第三篇!在前两篇文章中,我们分别探讨了如何用哈希表实现 O(1) 的快速查找,以及如何用双指针实现 O(1) 空间复杂度的原地序列修改。今天,我们将把这两者的优点结合起来,学习一种堪称“黑魔法”的算法思想——原地哈希。
你是否想过,当题目要求空间复杂度为 O(1) 时,我们能否在不使用额外哈希表的情况下,享受到哈希思想带来的便利?原地哈希正是解决这类问题的钥匙。
我们将以 LeetCode 448. 找到所有数组中消失的数字 为例,深入探讨:
-
如何用常规的哈希表(O(n) 空间)解决此问题。
-
原地哈希的精髓:如何巧妙地将数组本身用作哈希表。
-
通过一个生动的比喻,让你彻底掌握这种极致的空间优化技巧。
一、题目呈现
-
题目编号:448. 找到所有数组中消失的数字
-
题目难度:简单
-
题目描述:
给你一个含 n 个整数的数组 nums ,其中 nums[i] 在区间 [1, n] 内。请你找出所有在 [1, n] 范围内但没有出现在 nums 中的数字,并以数组的形式返回结果。
进阶:你能在不使用额外空间且时间复杂度为 O(n) 的情况下解决这个问题吗? 你可以假定返回的数组不算在额外空间内。
-
示例:
-
输入: nums = [4,3,2,7,8,2,3,1]
-
输出: [5,6]
-
二、我的思考:从 O(n) 空间到 O(1) 空间的进化
看到这个问题,我的第一反应是“判断数字是否存在”。根据我们第一篇文章的经验,处理“是否存在”这类问题,哈希表(在C++中是 std::unordered_set 或 std::unordered_map)是首选武器。
常规哈希解法 (O(n) 空间)
我们可以用一个哈希集合(unordered_set)来存储 nums 中出现过的所有数字。然后,我们再从 1 到 n 遍历一遍,如果哪个数字不在哈希集合中,那它就是消失的数字。
// O(n) 空间复杂度的常规解法
class Solution_HashSet {
public:
std::vector<int> findDisappearedNumbers(std::vector<int>& nums) {
std::unordered_set<int> seen_numbers;
// 1. 将所有出现过的数字存入哈希集合
for (int num : nums) {
seen_numbers.insert(num);
}
std::vector<int> disappeared;
// 2. 从 1 到 n 检查哪个数字没出现过
for (int i = 1; i <= nums.size(); ++i) {
if (seen_numbers.find(i) == seen_numbers.end()) {
disappeared.push_back(i);
}
}
return disappeared;
}
};
这个解法的时间复杂度是 O(n),空间复杂度也是 O(n)。它能完美地解决问题,但没有满足“不使用额外空间”的进阶要求。
那么,如何在不使用额外哈希表的情况下,记录下“某个数字是否出现过”这个信息呢? 答案就藏在数组 nums 自己身上。
三、豁然开朗:原地哈希的“寝室点名”模型
这正是你笔记中提到的核心思想:利用数组天然的索引和值的对应关系,将数组本身变成一张哈希表。
让我们用一个“寝室点名”的比喻来彻底理解它:
假设有一栋寝室楼(数组 nums),里面有 n 个房间,房间号从 0 到 n-1。
现在要入住 n 个学生,他们的学号(数组中的值)范围恰好是 1 到 n。
我们定一个规则(哈希函数):学号为 k 的学生,应该住在 k-1 号房间。
我们的任务是:找出哪些学号的学生根本没来报到。
原地哈希的做法:
-
第一步:点名与标记
-
我们遍历所有已到场的学生(即遍历 nums 数组)。
-
每遇到一个学号为 k 的学生,我们就跑到他应该住的 k-1 号房间门口,做一个标记,表示“这个房间对应的学生,人到了!”
-
如何做标记,同时不破坏房间里原来的信息(可能还住着别人)?
-
最聪明的技巧是:将房间门口的门牌号(nums[k-1])变成负数! 因为题目保证了所有学号都是正数,所以正负号这个“信息位”是我们可以免费利用的。
-
-
-
第二步:检查空房
-
点完名后,我们再从 0 号房间开始,一个一个地检查。
-
如果发现哪个房间的门牌号还是正数,就说明在第一步中,没有任何一个学生被分配到这个房间来做标记。
-
这就意味着,这个房间号对应的学号的学生,压根就没来!
-
让我们用 nums = [4,3,2,7,8,2,3,1] 来演练一遍:
遍历 nums | value = abs(nums[i]) | index_to_mark = value - 1 | 操作 (Action) | 数组状态 (nums) |
初始 | [4,3,2,7,8,2,3,1] | |||
i=0, nums[0]=4 | 4 | 3 | 将 nums[3] 变为负数 | [4,3,2,-7,8,2,3,1] |
i=1, nums[1]=3 | 3 | 2 | 将 nums[2] 变为负数 | [4,3,-2,-7,8,2,3,1] |
i=2, nums[2]=-2 | 2 | 1 | 将 nums[1] 变为负数 | [4,-3,-2,-7,8,2,3,1] |
i=3, nums[3]=-7 | 7 | 6 | 将 nums[6] 变为负数 | [4,-3,-2,-7,8,2,-3,1] |
i=4, nums[4]=8 | 8 | 7 | 将 nums[7] 变为负数 | [4,-3,-2,-7,8,2,-3,-1] |
i=5, nums[5]=2 | 2 | 1 | nums[1] 已经是负数,不变 | [4,-3,-2,-7,8,2,-3,-1] |
i=6, nums[6]=-3 | 3 | 2 | nums[2] 已经是负数,不变 | [4,-3,-2,-7,8,2,-3,-1] |
i=7, nums[7]=-1 | 1 | 0 | 将 nums[0] 变为负数 | [-4,-3,-2,-7,8,2,-3,-1] |
最终标记结果: [-4, -3, -2, -7, 8, 2, -3, -1]
第二步,检查哪些门牌号还是正数:
-
nums[0]...nums[3] 都是负数。
-
nums[4] = 8 是正数! -> 意味着 4 号房间没人标记。4 号房间对应的学号是 4+1=5。所以 5 是消失的数字。
-
nums[5] = 2 是正数! -> 意味着 5 号房间没人标记。5 号房间对应的学号是 5+1=6。所以 6 是消失的数字。
-
...
四、C++ 最优代码与详解
#include <vector>
#include <cmath> // 为了使用 std::abs
class Solution {
public:
std::vector<int> findDisappearedNumbers(std::vector<int>& nums) {
// 阶段一:原地哈希 - 点名与标记
// 1.
for (int i = 0; i < nums.size(); ++i) {
// 2.
int value = std::abs(nums[i]);
// 3.
int index_to_mark = value - 1;
// 4.
if (nums[index_to_mark] > 0) {
nums[index_to_mark] *= -1;
}
}
// 阶段二:找出结果 - 检查空房
std::vector<int> result;
// 5.
for (int i = 0; i < nums.size(); ++i) {
// 6.
if (nums[i] > 0) {
// 7.
result.push_back(i + 1);
}
}
return result;
}
};
代码逐点解释
-
for (int i = 0; i < nums.size(); ++i)
这是算法的第一轮遍历。我们的目标是读取数组中的每一个原始数值,并根据这些数值在数组中留下“标记”。 -
int value = std::abs(nums[i]);
我们从 nums 数组中取出当前元素 nums[i]。关键在于 std::abs()。因为在遍历过程中,数组中的某些元素可能已经被之前的步骤标记为了负数。为了获得它原始的、代表“学号”的值,我们必须取其绝对值。例如,如果 nums[i] 是 -3,我们知道它原始的值是 3。 -
int index_to_mark = value - 1;
这是核心的哈希函数。它将我们刚刚获得的数值 value 映射到一个数组索引。因为题目保证了数值的范围是 [1, n],而数组的索引范围是 [0, n-1],所以 value - 1 正好可以将每一个数值完美地、唯一地映射到一个合法的索引上。 -
if (nums[index_to_mark] > 0) { ... }
这是执行标记的步骤。我们检查目标索引 index_to_mark 上的元素是否还是正数。如果是,就通过 *= -1 将其变为负数,留下“这个位置对应的数字已经出现过”的标记。if 判断是必要的,因为一个数字可能在输入中出现多次(例如 [2, 2, 1]),我们只需要标记一次即可,重复乘以 -1 会导致负数变回正数,从而丢失标记。 -
for (int i = 0; i < nums.size(); ++i)
这是算法的第二轮遍历。在第一轮遍历结束后,nums 数组已经变成了一个“标记板”。现在,我们需要检查这个“标记板”,找出哪些位置没有被标记过。 -
if (nums[i] > 0)
在第二轮遍历中,我们检查当前索引 i 上的元素 nums[i] 是否仍然为正数。如果它还是正数,就意味着在第一轮的整个标记过程中,没有任何一个 value 能够被映射到 i 这个索引上。 -
result.push_back(i + 1);
一旦我们发现 nums[i] 是正数,我们就找到了一个“消失的数字”。根据我们之前定义的哈希函数 index = value - 1,反推回来,索引 i 对应的原始数值就是 i + 1。我们使用 push_back() 将这个找到的消失数字添加到我们的结果数组 result 中。
五、深度思考与答疑
问:这个问题需要排序吗?
答:不需要。排序(例如 std::sort)的时间复杂度是 O(n log n),而原地哈希只需要 O(n),效率更高。排序后虽然可以更容易地发现不连续的数字,但它不是解决这个问题的最优思路。
问:如何使用动态数组(std::vector)来收集结果?
答:在 C++ 中,我们创建一个空的 std::vector<int> result。在找到一个消失的数字后,使用 result.push_back() 方法将其添加到结果数组的末尾。切记,对于一个空的 vector,不能使用 [] 来添加新元素,必须用 push_back()。
六、总结与收获
-
复杂度分析:我们通过两次独立的遍历完成了任务,时间复杂度为 O(n) + O(n) = O(n)。我们没有使用任何额外的哈希表或数组(返回的结果数组除外),空间复杂度为 O(1)。
-
核心思想:原地哈希是一种利用数组值与索引之间的映射关系,将数组自身作为哈希表的空间优化思想。
-
关键技巧:利用正负号来作为“是否存在”的标记,是一种在不丢失原始数值信息(可通过 abs() 恢复)的情况下进行原地标记的绝妙技巧。
-
适用场景:当数组元素的值在某个特定且与数组大小相关的范围(如 [1, n])内,并且允许修改输入数组时,原地哈希是解决查找、重复、缺失等问题的强大武器。