C++ 位运算 高频面试考点 力扣 268. 丢失的数字 题解 每日一题

文章目录


题目解析

题目链接:力扣 268. 丢失的数字

题目描述:

示例 1:

输入:nums = [3,0,1]

输出:2

解释:n = 3,因为有 3 个数字,所以所有的数字都在范围 [0,3] 内。2 是丢失的数字,因为它没有出现在 nums 中。
示例 2:

输入:nums = [0,1]

输出:2

解释:n = 3,因为有 3 个数字,所以所有的数字都在范围 [0,3] 内。2 是丢失的数字,因为它没有出现在 nums 中。
示例 3:

输入:nums = [9,6,4,2,3,5,7,0,1]

输出:8

解释:n = 9,因为有 9 个数字,所以所有的数字都在范围 [0,9] 内。8 是丢失的数字,因为它没有出现在 nums 中
限制:

  1. n == nums.length
  2. 1 <= n <= 10⁴
  3. 0 <= nums[i] <= n
  4. nums 中的所有数字都 独一无二
    进阶:你能否实现线性时间复杂度、仅使用额外常数空间的算法解决此问题?

为什么这道题值得你花几分钟的时间弄懂?

这道题是算法面试中的"经典基础题",看似简单却暗藏多种优化思路。它的价值不在于"做出答案",而在于展现从"暴力"到"极致优化"的思维递进,尤其能体现对"位运算"这一面试高频技巧的掌握程度。

面试官考察这道题时,核心关注三点:

  1. 能否想到多种不同解法,体现思维广度,解决问题的灵活程度;
  2. 能否精准分析每种解法的时间/空间复杂度,暴露基础功底;
  3. 能否理解并写出"异或解法",并解释清楚核心原理(尤其是初始化细节),彰显对算法本质的理解。

如果对位运算的细节有些生疏,建议先结合我的这篇总结复习:位运算 常见方法总结 算法练习。这篇博客详细的将位运算常见的几种技巧进行了说明总结,在后文中我只会单对这道题所用的异或进行详细分析

这道题的所有解法

这道简单题的解法有很多,各有利与弊这里通过表格来进行一个横纵对比

解法 时间 空间 面试推荐度 一句话记忆
暴力 O(n²) O(1) 只能用来讲思路
哈希 O(n) O(n) ⭐⭐ 空间换时间,稳但不够秀
排序 O(n log n) O(log n) ⭐⭐ 不改原数组就别用
高斯 O(n) O(1) ⭐⭐⭐ 记得 long long
异或 O(n) O(1) ⭐⭐⭐⭐⭐ 无溢出、常数级、面试加分
解法 核心逻辑 时间复杂度 空间复杂度 适用场景 优势与不足
暴力遍历 对0到n的每个数进行枚举,检查是否存在于数组中,未找到则返回 O(n²) O(1) 数组长度极小(n<100) 无额外空间,但时间效率极低
哈希表 遍历数组存入哈希表(数组模拟),再遍历0到n,检查哈希表中是否存在,不存在则返回 O(n) O(n) 追求时间效率,允许额外空间 时间最优,但空间开销大
排序+线性查找 先排序数组,再遍历判断:若当前元素≠索引,则返回索引;否则返回n O(n logn) O(logn) 对空间要求较低,可接受排序开销 空间比哈希表优,但依赖排序时间复杂度高
高斯求和(数学法) 计算0到n的和,减去数组元素总和,差值即为缺失的数 O(n) O(1) 数据范围不大,无溢出风险 代码简洁,时空效率优,但有溢出可能
异或运算(最优) 利用异或"自反性",通过累计异或找到缺失的数,无额外空间且无溢出风险 O(n) O(1) 面试加分项,追求极致优化 时空效率双优,无溢出风险,逻辑巧妙

下面我们将重点提探讨异或运算这种在时间复杂度最优的情况下保证空间效率最小的最优解法,同时补充其他基础解法作为对比,我们着重关注异或解法的原理与细节。

🌟🌟🌟异或运算(O(n) 时间,O(1) 空间)

这是这道题在面试中的"最优",完美规避了溢出问题,且完全不依赖额外数据结构。

1. 异或运算的核心特性

要理解解法,必须先掌握异或(^)的3个关键性质,这是整个逻辑的基石:

  1. 自反性a ^ a = 0(任何数与自身异或,结果为0);
  2. 恒等性a ^ 0 = a(任何数与0异或,结果仍为自身);
  3. 交换律与结合律a ^ b ^ c = a ^ (b ^ c) = (a ^ b) ^ c,顺序不影响结果。

2. 如何用异或找缺失值?

假设完整序列为 [0, 1, 2, ..., n],数组 nums 是完整序列去掉某个数 x 后的结果。我们将"完整序列的所有元素"与"数组的所有元素"做异或运算,结果就是 x,原因如下:

  • 完整序列和数组的交集(即数组中存在的元素),每个数都会出现2次(完整序列一次,数组一次),根据"自反性",它们的异或结果为0;
  • 唯一只出现1次 的数是缺失的 x,根据"恒等性",0 ^ x = x,最终结果即为缺失值。

简单点说我们就可以这样记忆,异或可以当作消消乐🌟:相同的数异或就没了(变为0),不同的数相遇则 "暂时保留",最后剩下的就是 "独一份" 的那个数(有个细节这里没有说我们放到后面说)。


3. 如何高效合并两次异或?

直接先异或完整序列、再异或数组元素,需要两次遍历。但我们可以一次遍历同时完成两种异或 ,核心是利用"索引"替代完整序列的前n个元素(完整序列是0~n,索引是0~n-1,差一个n)。

具体步骤:

  1. 初始化异或结果 ret = n(先补上完整序列中缺失的"n",因为索引只到n-1);
  2. 遍历数组,对每个索引 i 和对应的元素 nums[i],执行 ret ^= i ^ nums[i]
  3. 遍历结束后,ret 即为缺失的数字。

🌟🌟🌟 4. 初始化ret = nums.size()

在2. 如何用异或找缺失值?那里我说个我遗留了一个细节问题在这里详细说明,这一初始化的核心目的是补全"完整序列"与"索引序列"的差值nums.size() 本质就是 n(题目明确n == nums.length):

  • 完整序列是 [0, 1, 2, ..., n](共n+1个元素);
  • 索引序列是 [0, 1, 2, ..., n-1](共n个元素);
  • 两者的差异是"完整序列多了一个n",所以初始化ret = n,相当于先把这个差异元素加入异或运算。

后续遍历中,ret ^= i ^ nums[i] 等价于:
ret = n ^ 0 ^ nums[0] ^ 1 ^ nums[1] ^ ... ^ (n-1) ^ nums[n-1]

整理后即为:
ret = (0 ^ 0) ^ (1 ^ 1) ^ ... ^ (x ^ 缺失) ^ ... ^ (n ^ n)(其中x是数组中存在的元素,缺失的x只出现一次)

代码实现

cpp 复制代码
class Solution {
public:
    int missingNumber(vector<int>& nums) {
        int ret = nums.size(); // 关键初始化:nums.size() = n,补全完整序列的最后一个元素
        for(int i = 0; i < nums.size(); i++)
        {
            // 同时异或索引i(替代完整序列的0~n-1)和数组元素nums[i]
            ret ^= i ^ nums[i];
        }
        return ret;
    }
};

代码示例推演(以nums = [3,0,1]为例)

  1. 初始化:ret = 3
  2. i=0,nums[0]=3:ret = 3 ^ 0 ^ 3 = (3^3) ^ 0 = 0 ^ 0 = 0
  3. i=1,nums[1]=0:ret = 0 ^ 1 ^ 0 = (0^0) ^ 1 = 0 ^ 1 = 1
  4. i=2,nums[2]=1:ret = 1 ^ 2 ^ 1 = (1^1) ^ 2 = 0 ^ 2 = 2
  5. 返回ret=2,与预期结果一致。

异或解法的优劣势分析

优点 缺点
✅ 无溢出风险:异或运算不涉及数值累加,完全规避溢出问题 ⚠️ 逻辑较抽象:相比高斯求和等解法,需要理解异或的"自反性"等特性才能掌握原理
✅ 空间极致优化:仅用一个整数存储结果,空间复杂度严格O(1)
✅ 时间效率最优:一次遍历即可完成计算,时间复杂度O(n)
✅ 无额外数据结构:不依赖哈希表、数组等辅助空间,实现更轻量

这种解法特别适合对空间敏感、数据规模较大的场景,是面试中展现算法功底的"加分项"解法。

其他方法(对比参考)

高斯求和(数学优化)

🌟 核心思路:利用等差数列求和公式直接计算完整序列的总和,再减去数组元素的实际总和,差值就是缺失的数字。这种方法完全不需要额外数据结构,是空间效率极高的解法。

原理拆解

  1. 完整序列的特性[0, 1, 2, ..., n] 是一个标准的等差数列

    • 首项 = 0,末项 = n,项数 = n+1

    • 总和可通过 等差数列求和公式 计算(注:这里因为这道题一定从0开始所以对我们熟悉的公式做了优化,方便写程序):

      复制代码
      总和 = (首项 + 末项) × 项数 ÷ 2 = (0 + n) × (n+1) ÷ 2 = n × (n+1) / 2
  2. 找缺失值的逻辑

    完整序列的总和 比 数组元素的总和 正好多出"缺失的那个数",因此:

    复制代码
    缺失的数 = 完整序列总和 - 数组元素总和

代码实现(O(n) 时间,O(1) 空间)

cpp 复制代码
class Solution {
public:
    int missingNumber(vector<int>& nums) {
        int n = nums.size();
        // 计算0到n的完整序列总和(用long long避免溢出)
        long long total = (long long)n * (n + 1) / 2;
        
        // 计算数组中所有元素的实际总和
        int sum_nums = 0;
        for (int num : nums) {
            sum_nums += num;
        }
        
        // 差值即为缺失的数字
        return total - sum_nums;
    }
};

⚠️ 关键注意点:整数溢出问题

  • n 较大时(如 n=10⁵),n×(n+1)/2 的结果会超过 32 位 int 的最大值(2147483647),导致计算错误。
  • 解决方案 :必须用 long long 类型存储总和(如代码中所示),确保数值不会溢出。

优劣对比

优点 缺点
✅ 代码极简,逻辑直观 ⚠️ 存在溢出风险(需显式处理)
✅ 时间复杂度 O(n),仅需一次遍历
✅ 空间复杂度 O(1),无额外数据结构

这种方法特别适合对代码简洁度要求高的场景,但务必注意数值范围,避免溢出陷阱!

暴力遍历(O(n²))

核心定位:最直观的"朴素解法",完全贴合问题描述的逻辑,但因效率过低,仅适合理解问题本质,实际开发中几乎不采用。

核心思路

通过双层嵌套循环实现"逐个校验":

  1. 外层循环:遍历 [0, n] 中的每个数 target(枚举所有可能的缺失值);
  2. 内层循环:对每个 target,遍历数组 nums 检查是否存在;
  3. 终止条件:若内层循环未找到 target,则 target 即为缺失的数,直接返回。

代码实现(O(n²) 时间,O(1) 空间)

cpp 复制代码
class Solution {
public:
    int missingNumber(vector<int>& nums) {
        int n = nums.size();
        // 外层:枚举0到n的每个可能值
        for (int target = 0; target <= n; ++target) {
            bool exists = false;
            // 内层:检查当前target是否在数组中
            for (int num : nums) {
                if (num == target) {
                    exists = true;
                    break; // 找到则跳出内层循环,无需继续检查
                }
            }
            if (!exists) {
                return target; // 未找到,即为缺失值
            }
        }
        return -1; // 理论上不会执行(必存在缺失值)
    }
};

⚠️ 关键注意点:效率瓶颈

  • 时间复杂度的核心是双层循环的乘积效应 :外层 n+1 次循环,内层平均 n 次循环,总操作次数约为 ,当 n=10⁴ 时需执行 10⁸ 次操作,远超时间限制;
  • 仅适用于 n<100 的极小数据量,一旦数据规模扩大,性能会急剧下降。

优劣对比

优点 缺点
✅ 逻辑零门槛:完全按"找缺失值"的字面意思实现,无需额外算法知识 ⚠️ 时间效率极差:O(n²) 复杂度,数据量大时不可用
✅ 空间极致节省:无需任何辅助数据结构,空间复杂度严格O(1) ⚠️ 冗余计算多:大量重复的元素比对,无优化空间

哈希表(O(n) 时间,O(n) 空间)

📌 核心定位:"空间换时间"的典型解法,通过额外存储将内层循环的"查找操作"从 O(n) 优化到 O(1),大幅提升效率。

核心思路

利用哈希表的"快速查找"特性,将流程拆分为"存储"和"校验"两步:

  1. 存储阶段:遍历数组 nums,将所有元素存入哈希表(如 unordered_set),构建快速查找的"字典";
  2. 校验阶段:遍历 [0, n] 中的每个数,通过哈希表检查是否存在,不存在的即为缺失值。

代码实现(O(n) 时间,O(n) 空间)

cpp 复制代码
#include <unordered_set>
using namespace std;

class Solution {
public:
    int missingNumber(vector<int>& nums) {
        int n = nums.size();
        unordered_set<int> num_set; // 用哈希表存储数组元素
        
        // 第一步:将数组元素存入哈希表
        for (int num : nums) {
            num_set.insert(num);
        }
        
        // 第二步:检查0到n的每个数是否在哈希表中
        for (int target = 0; target <= n; ++target) {
            // 哈希表查找复杂度为O(1)
            if (num_set.find(target) == num_set.end()) {
                return target;
            }
        }
        return -1; // 理论上不会执行
    }
};

⚠️ 关键注意点:哈希表的选择

  • 优先使用 unordered_set 而非 setunordered_set 基于哈希表实现,平均查找复杂度为 O(1);set 基于红黑树实现,查找复杂度为 O(logn),效率更低;
  • 空间开销的本质:哈希表需存储 n 个元素,空间复杂度随输入规模线性增长(O(n)),适合空间资源宽松的场景。

优劣对比

优点 缺点
✅ 时间效率高:查找操作优化为O(1),总复杂度降至O(n) ⚠️ 空间开销大:需额外存储n个元素,空间复杂度O(n)
✅ 实现简洁:逻辑清晰,无需复杂算法设计 ⚠️ 依赖数据结构:需掌握哈希表的基本用法
✅ 无溢出风险:仅涉及查找操作,不涉及数值累加

排序+线性查找(O(n logn) 时间,O(logn) 空间)

📌 核心定位:"空间与时间的折中解法",无需哈希表的额外空间,但需承担排序的时间开销,适合对空间敏感但可接受一定时间成本的场景。

核心思路

利用"排序后元素与索引匹配"的特性,将"无序查找"转化为"有序校验":

  1. 排序阶段:对数组 nums 进行升序排序(默认快速排序,复杂度 O(n logn));
  2. 校验阶段:
  • 遍历排序后的数组,若 nums[i] != i,说明 i 是缺失值(正常情况下排序后 nums[i] 应等于索引 i);
  • 若遍历结束所有元素均与索引匹配,说明缺失的是最后一个数 n(如 nums=[0,1],缺失 2)。

代码实现(O(n logn) 时间,O(logn) 空间)

cpp 复制代码
#include <algorithm>
using namespace std;

class Solution {
public:
    int missingNumber(vector<int>& nums) {
        int n = nums.size();
        // 对数组进行升序排序(快速排序的递归栈空间为O(logn))
        sort(nums.begin(), nums.end());
        
        // 遍历检查元素与索引是否匹配
        for (int i = 0; i < n; ++i) {
            if (nums[i] != i) {
                return i; // 元素与索引不匹配,索引即为缺失值
            }
        }
        
        // 所有元素均匹配索引,缺失的是n
        return n;
    }
};

⚠️ 关键注意点:空间复杂度的细节

  • 排序的隐性空间:代码中未显式声明辅助数组,但 sort 函数的底层实现(如快速排序)会使用递归栈,空间复杂度为 O(logn);若使用堆排序,可将空间复杂度优化至 O(1),但实现更复杂;
  • 边界处理:必须考虑"缺失值为n"的情况,否则会遗漏(如示例2中 nums=[0,1],排序后元素与索引匹配,需返回 2)。

优劣对比

优点 缺点
✅ 空间更优:仅需排序的隐性空间O(logn),远低于哈希表的O(n) ⚠️ 时间成本高:排序阶段占主导,总复杂度O(n logn)
✅ 逻辑直观:排序后"元素=索引"的特性易于理解 ⚠️ 可能修改原数组:排序会改变输入数组的元素顺序(若需保留原数组,需额外复制,空间变为O(n))
✅ 无溢出风险:仅涉及比较操作,不依赖数值计算

面试避坑指南

  1. 高斯求和的溢出问题 :若用int存储总和,当n≥46341时(46341*46342/2=1073767311,接近int上限),会发生溢出。面试中需主动提及并给出long long的解决方案;
  2. 异或解法的初始化 :必须解释清楚ret = nums.size()的原因------补全完整序列中"n"这个元素,否则会被认为"只背代码不懂原理";
  3. 排序解法的边界处理:不要遗漏"遍历结束后返回n"的情况(如nums=[0,1],排序后元素与索引匹配,缺失的是2);
  4. 哈希表的选择 :优先用unordered_set(平均O(1)查找)而非set(O(logn)查找),避免不必要的性能损耗。

思考题

如果数组中存在重复元素(题目当前限制"所有数字独一无二"),异或解法还能生效吗?若不能,哪种解法依然适用?

提示:如果同一数字出现偶数次,异或会把它消成 0;奇数次会剩下它本身。

这样数组长度和值域的关系被打破,无法保证"缺失值"只出现一次。

总结

这道题的本质是"寻找完整序列与子集的差异",解法的优化路径清晰展现了"数学思维 "和"位运算思维"对算法效率的巨大提升:

  1. 若追求"最简单理解":选高斯求和
  2. 若追求"面试加分+无隐患":必选异或运算
  3. 若仅需"能做出即可":选哈希表排序+查找

核心考点回顾:

  • 异或特性:自反性、恒等性是解决"缺失/重复"问题的关键;
  • 边界处理:ret = nums.size() 是异或解法的灵魂,体现对问题细节的拆解能力;
  • 时空权衡:不同解法的复杂度对比,是算法基础功底的直接体现。

下题预告

下一篇将讲解力扣 371. 两整数之和 ,带你跳出 "用 + 号做加法" 的思维惯性,解锁计算机底层 "无进位加法 + 进位处理" 的核心逻辑。

如果这篇内容对你有帮助,别忘了 点赞👍 + 收藏⭐ + 关注👀 哦!

相关推荐
NQBJT2 分钟前
双轮足导盲机器人:多传感融合与全局-局部分层导航系统设计
c++·esp32·openmv·避障·导盲·轮足
lzh200409193 分钟前
Linux信号(Signal)
linux·c++
生成论实验室5 分钟前
《源·觉·知·行·事·物:生成论视域下的统一认知语法》导论:在破碎的世界寻找统一语法
人工智能·科技·算法·架构·创业创新
承渊政道5 分钟前
【动态规划算法】(两个数组的DP问题深度剖析与求解方法)
数据结构·c++·学习·算法·leetcode·动态规划·哈希算法
杨连江10 分钟前
原子级平面限域协同晶核诱导定向生长单层鳞片石墨的研究
算法
MATLAB代码顾问15 分钟前
混合粒子群-模拟退火算法(HPSO-SA)求解作业车间调度问题——附MATLAB代码
算法·matlab·模拟退火算法
Felven20 分钟前
C. Prefix Min and Suffix Max
算法
加农炮手Jinx20 分钟前
LeetCode 26. Remove Duplicates from Sorted Array 题解
算法·leetcode·力扣
加农炮手Jinx21 分钟前
LeetCode 88. Merge Sorted Array 题解
算法·leetcode·力扣
格林威21 分钟前
线阵工业相机:如何计算线阵相机的行频(Line Rate)?公式+实例
开发语言·人工智能·数码相机·算法·计算机视觉·工业相机·线阵相机