[从零开始面试算法] (03/100) LeetCode 448. 消失的数字:原地哈希的极致空间优化

引言

欢迎来到本系列的第三篇!在前两篇文章中,我们分别探讨了如何用哈希表实现 O(1) 的快速查找,以及如何用双指针实现 O(1) 空间复杂度的原地序列修改。今天,我们将把这两者的优点结合起来,学习一种堪称“黑魔法”的算法思想——原地哈希

你是否想过,当题目要求空间复杂度为 O(1) 时,我们能否在不使用额外哈希表的情况下,享受到哈希思想带来的便利?原地哈希正是解决这类问题的钥匙。

我们将以 LeetCode 448. 找到所有数组中消失的数字 为例,深入探讨:

  1. 如何用常规的哈希表(O(n) 空间)解决此问题。

  2. 原地哈希的精髓:如何巧妙地将数组本身用作哈希表。

  3. 通过一个生动的比喻,让你彻底掌握这种极致的空间优化技巧。

一、题目呈现

  • 题目编号:448. 找到所有数组中消失的数字

  • 题目难度:简单

  • 题目链接448. 找到所有数组中消失的数字 - 力扣 (LeetCode)

  • 题目描述

    给你一个含 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 号房间。

我们的任务是:找出哪些学号的学生根本没来报到。

原地哈希的做法:

  1. 第一步:点名与标记

    • 我们遍历所有已到场的学生(即遍历 nums 数组)。

    • 每遇到一个学号为 k 的学生,我们就跑到他应该住的 k-1 号房间门口,做一个标记,表示“这个房间对应的学生,人到了!”

    • 如何做标记,同时不破坏房间里原来的信息(可能还住着别人)?

      • 最聪明的技巧是:将房间门口的门牌号(nums[k-1])变成负数! 因为题目保证了所有学号都是正数,所以正负号这个“信息位”是我们可以免费利用的。

  2. 第二步:检查空房

    • 点完名后,我们再从 0 号房间开始,一个一个地检查。

    • 如果发现哪个房间的门牌号还是正数,就说明在第一步中,没有任何一个学生被分配到这个房间来做标记。

    • 这就意味着,这个房间号对应的学号的学生,压根就没来!

让我们用 nums = [4,3,2,7,8,2,3,1] 来演练一遍:

遍历 numsvalue = abs(nums[i])index_to_mark = value - 1操作 (Action)数组状态 (nums)
初始[4,3,2,7,8,2,3,1]
i=0, nums[0]=443将 nums[3] 变为负数[4,3,2,-7,8,2,3,1]
i=1, nums[1]=332将 nums[2] 变为负数[4,3,-2,-7,8,2,3,1]
i=2, nums[2]=-221将 nums[1] 变为负数[4,-3,-2,-7,8,2,3,1]
i=3, nums[3]=-776将 nums[6] 变为负数[4,-3,-2,-7,8,2,-3,1]
i=4, nums[4]=887将 nums[7] 变为负数[4,-3,-2,-7,8,2,-3,-1]
i=5, nums[5]=221nums[1] 已经是负数,不变[4,-3,-2,-7,8,2,-3,-1]
i=6, nums[6]=-332nums[2] 已经是负数,不变[4,-3,-2,-7,8,2,-3,-1]
i=7, nums[7]=-110将 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;
    }
};

代码逐点解释
  1. for (int i = 0; i < nums.size(); ++i)
    这是算法的第一轮遍历。我们的目标是读取数组中的每一个原始数值,并根据这些数值在数组中留下“标记”。

  2. int value = std::abs(nums[i]);
    我们从 nums 数组中取出当前元素 nums[i]。关键在于 std::abs()。因为在遍历过程中,数组中的某些元素可能已经被之前的步骤标记为了负数。为了获得它原始的、代表“学号”的值,我们必须取其绝对值。例如,如果 nums[i] 是 -3,我们知道它原始的值是 3。

  3. int index_to_mark = value - 1;
    这是核心的哈希函数。它将我们刚刚获得的数值 value 映射到一个数组索引。因为题目保证了数值的范围是 [1, n],而数组的索引范围是 [0, n-1],所以 value - 1 正好可以将每一个数值完美地、唯一地映射到一个合法的索引上。

  4. if (nums[index_to_mark] > 0) { ... }
    这是执行标记的步骤。我们检查目标索引 index_to_mark 上的元素是否还是正数。如果是,就通过 *= -1 将其变为负数,留下“这个位置对应的数字已经出现过”的标记。if 判断是必要的,因为一个数字可能在输入中出现多次(例如 [2, 2, 1]),我们只需要标记一次即可,重复乘以 -1 会导致负数变回正数,从而丢失标记。

  5. for (int i = 0; i < nums.size(); ++i)
    这是算法的第二轮遍历。在第一轮遍历结束后,nums 数组已经变成了一个“标记板”。现在,我们需要检查这个“标记板”,找出哪些位置没有被标记过。

  6. if (nums[i] > 0)
    在第二轮遍历中,我们检查当前索引 i 上的元素 nums[i] 是否仍然为正数。如果它还是正数,就意味着在第一轮的整个标记过程中,没有任何一个 value 能够被映射到 i 这个索引上。

  7. 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])内,并且允许修改输入数组时,原地哈希是解决查找、重复、缺失等问题的强大武器。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值