目录

题目描述

448. 找到所有数组中消失的数字

题意分析

给一个长度为 n 的数组 nums,其中每个元素都落在 $[1, n]$ 这个闭区间内。把 1nn 个数当成一份完整名单,数组里出现过的算「到场」,要求把所有没在数组里出现过的数字全部找出来并返回。

题面里有两个数量关系必须先咬死:值域是 $[1, n]$,数组长度也是 n值的个数和坑位的个数完全一样,这是整道题的题眼。它意味着「有数字重复出现」和「有数字一个都没出现」是同一件事的两面——重复了几个,就必然缺了几个。反过来说,如果不允许重复,答案必然是空。

值域与下标一一对应还带来第二层信息:数字 v 可以唯一地映射到下标 v - 1,这个映射是双射。也就是说,我们完全不需要一张额外的哈希表来记录「谁出现过」,因为数组自己的下标空间就是一张现成的、大小刚好合适的表。题目进阶要求 $O(n)$ 时间且不使用额外空间,正是在逼你利用这层对应关系。

但下标空间被「占用」了:nums 里已经存着输入数据,不能直接拿某一格当布尔标记,否则原数据丢了后面还没遍历到的元素就读不出来。于是问题变成:如何在同一个 int 里同时保存「原值」和「一个布尔位」。约束里的 nums[i] >= 1 恰好留下了符号位这块空地——所有值天然为正,负号从未被使用过,可以拿它当标记位。

边界方面:n 最小是 1,此时数组只能是 [1],没有缺失,返回空列表;数组可能全是同一个值(如 [2, 2]),此时同一个下标会被重复标记,代码必须能容忍「标记已经打过」这种情况而不把它再翻回正数;结果为空时要返回空列表而不是 null

解法:利用下标原地标记出现过的数字

核心思路

最直接的写法是开一个长度 n + 1boolean 数组(或者一个 HashSet),扫一遍 nums 把出现过的数字打上标记,再从 1 到 n 检查哪些没被标记。这个做法完全正确,时间也是 $O(n)$,瓶颈只有一个:它额外用了 $O(n)$ 的空间,而进阶要求把这块空间省掉。

关键观察是那张 boolean 表和输入数组长度一模一样、下标一一对应。既然形状相同,就没必要真的开一块新内存,只要能在 nums 的每一格上额外挂一个布尔位就行。而 $1 \le nums[i] \le n$ 保证了所有元素恒为正,符号位是完全空闲的:nums[idx] 取反,就等价于在这块假想的 boolean 表的第 idx 位写下 true,而元素的绝对值仍然完好地保留着原始数据

于是第一趟遍历的循环不变量是:处理完前 i 个位置后,对任意下标 jnums[j] < 0 当且仅当数字 j + 1nums 的前 i 个元素中出现过;并且对任意 j|nums[j]| 恒等于该位置的原始输入值。第一条说明标记是准确的,第二条说明数据没被破坏——正是第二条要求我们每次读取元素时都必须先取绝对值。

第一趟结束后,不变量对整个数组成立:nums[j] < 0 表示数字 j + 1 出现过,nums[j] > 0 表示它一次都没出现。第二趟只需把所有仍为正数的下标 j 转成 j + 1 收集起来,就是答案。

重复出现的元素会让同一个下标被标记两次。这就是代码里要判 nums[idx] > 0 才取反的原因——如果无脑写 nums[idx] = -nums[idx],第二次标记会把负数翻回正数,等于把「出现过」的记录擦掉了。

解题步骤

  • 第一趟遍历,对每个元素先取绝对值int value = Math.abs(num)。因为遍历到位置 i 时,nums[i] 可能早已被前面的某次标记改成了负数,直接拿它算下标会得到负值并越界。取绝对值恢复的正是不变量第二条所保证的原始值。
  • 算出映射下标 idx = value - 1:值域是 $[1, n]$、下标是 $[0, n-1]$,减一是唯一正确的对齐方式。这里不需要任何范围校验,因为题目已经保证了值域。
  • 只在 nums[idx] > 0 时执行 nums[idx] = -nums[idx]:这个判断是幂等性保护。数字重复出现时第二次会命中已经是负数的格子,跳过即可;漏掉这个判断会把标记翻回正号,导致一个真正出现过的数字被误报成缺失。
  • 第二趟遍历收集答案:对每个下标 i,若 nums[i] > 0 就把 i + 1 加入结果。加一是第二步减一的逆运算;直接加 i 会让整个答案偏小 1。
  • 返回结果列表:没有缺失时自然返回空列表,不需要特判。

nums = [4, 3, 2, 7, 8, 2, 3, 1] 走一遍(n = 8,期望 [5, 6])。第一趟按下标推进,每轮列出「读到的原值 → 标记的下标 → 数组状态」。

i = 0:读到 4,取绝对值仍是 4,idx = 3nums[3] = 7 > 0,取反。数组变为 [4, 3, 2, -7, 8, 2, 3, 1]

i = 1:读到 3,idx = 2nums[2] = 2 > 0,取反。数组变为 [4, 3, -2, -7, 8, 2, 3, 1]

i = 2:读到 -2这里就是取绝对值的用武之地——它的原值是 2,idx = 1nums[1] = 3 > 0,取反。数组变为 [4, -3, -2, -7, 8, 2, 3, 1]

i = 3:读到 -7,原值 7,idx = 6nums[6] = 3 > 0,取反。数组变为 [4, -3, -2, -7, 8, 2, -3, 1]

i = 4:读到 8,idx = 7nums[7] = 1 > 0,取反。数组变为 [4, -3, -2, -7, 8, 2, -3, -1]

i = 5:读到 2,idx = 1nums[1] = -3 已经是负数,跳过。这正是 2 第二次出现的情形,若强行取反会把 nums[1] 变回 3,最终把数字 2 误判为缺失。

i = 6:读到 -3,原值 3,idx = 2nums[2] = -2 已为负,跳过。

i = 7:读到 -1,原值 1,idx = 0nums[0] = 4 > 0,取反。数组变为 [-4, -3, -2, -7, 8, 2, -3, -1]

第二趟扫描:下标 0 到 3 全是负数,跳过;i = 4nums[4] = 8 > 0,收集 5i = 5nums[5] = 2 > 0,收集 6;下标 6、7 为负,跳过。返回 [5, 6],与期望一致。注意最终留在正号位置上的 8 和 2 是原始输入值本身,它们的数值和答案毫无关系,判断依据只有符号,不是数值

代码实现

class Solution {
    // 可以把原数组当成访问标记表:看到值 value 时,将 nums[value - 1] 置为负数。
    public List<Integer> findDisappearedNumbers(int[] nums) {
        for (int num : nums) {
            int idx = Math.abs(num) - 1;
            if (nums[idx] > 0) {
                nums[idx] = -nums[idx];
            }
        }

        List<Integer> res = new ArrayList<>();
        for (int i = 0; i < nums.length; i++) {
            if (nums[i] > 0) {
                res.add(i + 1);
            }
        }

        return res;
    }
}
func findDisappearedNumbers(nums []int) []int {
    // 可以把原数组当成访问标记表:看到值 value 时,将 nums[value - 1] 置为负数。
    for i := 0; i < len(nums); i++ {
        idx := nums[i]
        if idx < 0 {
            idx = -idx
        }
        idx--
        if nums[idx] > 0 {
            nums[idx] = -nums[idx]
        }
    }

    res := make([]int, 0)
    for i, val := range nums {
        if val > 0 {
            res = append(res, i+1)
        }
    }

    return res
}

复杂度分析

  • 时间复杂度:$O(n)$。凭什么:两趟独立的线性遍历,没有嵌套循环也没有回退,每轮只做一次取绝对值、一次比较和至多一次赋值,全是常数操作,合计 $2n$ 次基本操作。
  • 空间复杂度:$O(1)$(不计返回值)。凭什么:标记信息全部寄生在输入数组的符号位上,没有开辟任何哈希表或布尔数组,只用了 idxi 这几个标量。结果列表长度取决于缺失数字个数,它是题目要求返回的输出本身,按惯例不计入额外空间。

关键点总结

  • 当值域和下标空间大小相同且能一一对应时,输入数组本身就是一张现成的哈希表,「值 v ↔ 下标 v - 1」是这类题的统一入口,看到「长度为 n、元素在 1 到 n」就该立刻往这个方向想。
  • 想在不额外开空间的前提下附加信息,就要找数据里没被使用的表示位:本题元素恒为正,符号位空闲;若元素可能为负,则可以改用「加 n + 1 后取模」或交换归位的方式携带标记。
  • 原地修改数组时必须区分「数据的当前存储形态」和「数据的原始语义」,取绝对值就是从前者还原到后者的翻译层,任何一次读取都要过这道翻译。
  • 标记操作必须是幂等的,if (nums[idx] > 0) 的守卫比无条件取反更本质——只要输入允许重复,取反类标记就一定要加这层保护。
  • 「重复的个数等于缺失的个数」这个抽屉原理式的观察,是本题与 442、645、287 共享的底层结构,答对本题后往往会被追问其中之一。
  • 面试时值得主动说的是「为什么可以借符号位」以及「如果元素允许为 0 或负数该怎么改」,这两句能证明你理解的是原地标记这个手法,而不是背了一段模板。

易错点总结

  • 读元素时忘记取绝对值[4, 3, 2, 7, 8, 2, 3, 1] 走到 i = 2 时读到的是已被标记成 -2 的值,idx = -3 直接抛数组越界异常。
  • 无条件执行 nums[idx] = -nums[idx],不判断是否已为负[2, 2] 中数字 2 被标记两次,nums[1] 先变负再翻回正,最终结果算出缺失为 [2],而正确答案是 [1]
  • 映射下标写成 idx = value[1, 1] 中读到 1 会去标记 nums[1]n = 2 时值 2 又会去访问 nums[2] 造成越界;即便不越界,整张标记表也整体错位一格。
  • 收集答案时加入 i 而不是 i + 1[4, 3, 2, 7, 8, 2, 3, 1] 会返回 [4, 5],整体比正确答案 [5, 6] 小 1。
  • 第二趟判断写成 nums[i] < 0 收集:条件取反,[1, 1] 会返回出现过的数字 [1],而不是缺失的 [2]
  • 在同一趟循环里边标记边收集[2, 1] 里遍历到 i = 0 时数字 1 还没被标记,nums[0] 仍为正会被误收成缺失,结果多出一个不存在的答案。必须等第一趟全部标记完再统计。
  • 第一趟遍历时把 nums[i] 缓存在循环外或用旧值判断:读的是快照而不是当前存储值,[3, 3, 1] 这类含重复的用例里守卫失效,等价于漏掉幂等保护。
  • 返回前忘记把数组恢复成正数:单看本题判题不影响,但如果面试官追问「这个函数会不会有副作用」,答不上来就暴露了没意识到它破坏了入参;工程场景中调用方拿到的是一个满是负数的数组。
  • 用排序后比对下标的做法[4, 3, 2, 7, 8, 2, 3, 1] 结果虽对,但时间退化到 $O(n \log n)$,达不到进阶要求,面试中会被要求重写。
  • HashSet 装完再从 1 到 n 查询:正确但额外占用 $O(n)$ 空间,本题的考点恰恰是把这块空间省掉,只写这版基本等于没答到点上。

相似题目

题目 难度 考察点
442. 数组中重复的数据 中等 同一套符号标记,但收集的是「标记时发现已为负」的那些数,是本题的镜像面
645. 错误的集合 简单 要同时输出重复数和缺失数,等于把 442 与本题的两种收集逻辑合并进一趟遍历
268. 丢失的数字 简单 值域是 0 到 n 且只缺一个,可直接用求和差或全体异或,不必原地标记
41. 缺失的第一个正数 困难 元素可以为负或超出 n,符号位不再空闲,必须改用交换归位把 v 换到下标 v - 1
287. 寻找重复数 中等 明确禁止修改数组,符号标记不可用,要转成链表环入口用快慢指针求解
面试题 17.04. 消失的数字 简单 与 268 同题,值域含 0 且只缺一个,异或解法最短
剑指 Offer 03. 数组中重复的数字 简单 值域是 0 到 n-1,只要找出任意一个重复值即可,原地交换归位一次命中