[从零开始面试算法] (04/100) LeetCode 136. 只出现一次的数字:哈希表与位运算的巅峰对决

引言

欢迎来到本系列的第四篇!在前面的文章中,我们已经见识了哈希表在“查找”问题上的威力。今天,我们将面对一个看似简单,却能引出两种截然不同且都极为重要的解题思路的经典题目——LeetCode 136. 只出现一次的数字。

这道题是面试中的高频题,因为它像一个十字路口,既可以通往我们熟悉的数据结构之路(哈希表),又可以引领我们进入一个全新的、充满数学之美的领域——位运算

本文将带你一起:

  1. 用我们熟悉的哈希表方法,轻松解决这个问题。

  2. 深入探讨时间与空间复杂度的真正含义,澄清一些常见的误解。

  3. 揭开“位运算”的神秘面纱,领略其 O(1) 空间复杂度的极致魅力。


一、题目呈现

  • 题目编号:136. 只出现一次的数字

  • 题目难度:简单

  • 题目链接136. 只出现一次的数字 - 力扣 (LeetCode)

  • 题目描述

    给你一个 非空 整数数组 nums ,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

    你必须设计并实现线性时间复杂度的算法来解决此问题,且该算法只使用常量额外空间。

  • 核心要求

    • 时间复杂度:线性,即 O(n)

    • 空间复杂度:常量,即 O(1)

  • 示例

    • 输入: nums = [4,1,2,1,2]

    • 输出: 4

二、我的思考:哈希表虽好,但空间超标

看到题目要求“找出唯一的元素”,我的第一反应依然是求助于我们的老朋友——哈希表。我们可以用哈希表来统计每个数字出现的次数,最后再找出那个出现次数为 1 的数字。

哈希表解法

这个思路非常直观,而且我们在之前的文章中已经熟练掌握了

#include <vector>
#include <unordered_map>

class Solution_HashMap {
public:
    int singleNumber(std::vector<int>& nums) {
        // 使用哈希表统计频率
        std::unordered_map<int, int> counts;
        for (int num : nums) {
            counts[num]++;
        }

        // 遍历哈希表,找出频率为 1 的数字
        for (auto const& [num, count] : counts) {
            if (count == 1) {
                return num;
            }
        }
        return -1; // 理论上不会执行到
    }
};

复杂度分析:

  • 时间复杂度:我们遍历了一次 nums 来构建哈希表(O(n)),又遍历了一次哈希表来找结果(最坏情况下 O(n))。所以总时间复杂度是 O(n) + O(n) = O(n)符合要求

  • 空间复杂度:哈希表需要存储 n/2 + 1 个不同的数字。随着输入数组 n 的增大,哈希表的大小也会线性增大。因此,空间复杂度是 O(n)不符合 O(1) 的要求

虽然哈希表解法在时间上达标了,但在空间上却“超标”了。这迫使我们必须寻找一种全新的、不依赖额外存储空间的解题路径。

三、豁然开朗:位运算的魔法——异或 (XOR)

要实现 O(1) 的空间复杂度,意味着我们不能使用随 n 增长的额外数据结构。我们只能使用有限的几个变量。这听起来似乎不可能,但位运算让它变成了现实。

核心知识点:异或运算 ^

你可能还记得离散数学中的异或,编程中的位运算正是它的延伸。它有三个神奇的性质,是解决此题的关键:

  1. 任何数与 0 异或,结果是它本身: a ^ 0 = a

  2. 任何数与它自己异或,结果是 0: a ^ a = 0

  3. 异或运算满足交换律和结合律:a ^ b ^ a = (a ^ a) ^ b = 0 ^ b = b

魔法发生了!
第三条性质告诉我们:将一堆数字全部异或在一起,那些成对出现的数字会因为 a ^ a = 0 而相互抵消,最终只剩下那个落单的数字!

一个具体的例子:[4, 1, 2, 1, 2]

让我们把数组中所有数字全部异或起来:
result = 4 ^ 1 ^ 2 ^ 1 ^ 2

根据交换律,我们可以重新排列:
result = 4 ^ (1 ^ 1) ^ (2 ^ 2)

根据性质2,1 ^ 1 = 0,2 ^ 2 = 0:
result = 4 ^ 0 ^ 0

根据性质1,4 ^ 0 = 4,4 ^ 0 = 4:
result = 4

我们不费吹灰之力就找到了答案!


四、C++ 最优代码与详解

有了位运算这个强大的武器,代码实现变得异常简洁。

#include <vector>
#include <numeric> // 在某些写法中可能用到,但这里不需要

class Solution {
public:
    int singleNumber(std::vector<int>& nums) {
        // 1.
        int accumulator = 0;

        // 2.
        for (int num : nums) {
            // 3.
            accumulator ^= num;
        }

        // 4.
        return accumulator;
    }
};

代码逐点解释
  1. int accumulator = 0;
    我们初始化一个“累加器”变量,值为 0。选择 0 是因为它是异或运算的单位元,不会影响第一次异或的结果(0 ^ 第一个元素 = 第一个元素)。

  2. for (int num : nums)
    我们使用范围 for 循环遍历 nums 数组中的每一个数字。

  3. accumulator ^= num;
    这是算法的核心。在每一次循环中,我们将累加器的当前值与数组中的当前元素 num 进行异或运算,并将结果存回累加器。a ^= b 是 a = a ^ b 的简写形式。

  4. return accumulator;
    当循环结束时,所有成对出现的数字都已相互抵消(变成了 0),累加器中剩下的就是那个唯一的、只出现了一次的数字。我们将其返回。


五、深度思考与答疑

问:如何理解“线性时间”和“常量空间”?

:这是你笔记中提出的一个非常深刻的问题!

  • 线性时间复杂度 O(n)

    • “线性”可以类比一次函数 y = kx。算法的执行时间(或步骤数)与输入数据的规模 n 成一个正比关系

    • 我们的代码只有一个 for 循环,它会遍历 n 个元素。如果数组长度翻倍,循环次数也大致翻倍。这就是典型的 O(n)。

    • 你对 n-a 的思考非常有趣:在大 O 表示法中,我们只关心增长的趋势。O(n),O(n-1),O(2n+5) 都被简化为 O(n),因为当 n 趋向于无穷大时,常数项和系数的影响都可以忽略不计。

  • 常量额外空间 O(1)

    • “常量”意味着算法使用的额外内存空间不随输入规模 n 的变化而变化

    • 在位运算解法中,我们只使用了一个额外的 int 变量 accumulator。无论输入数组 nums 有 10 个元素还是 1000 万个元素,我们都只需要这一个额外的变量。它的内存占用是恒定的。

    • 而哈希表解法中,如果数组有 1000 万个元素,哈希表也需要存储约 500 万个元素,内存占用随 n 线性增长,所以是 O(n) 空间。


六、总结与收获

  • 复杂度分析:位运算解法完美地满足了题目的苛刻要求。时间复杂度为 O(n),额外空间复杂度为 O(1)

  • 核心思想

    • 哈希表是解决频率统计问题的通用武器,但以空间换时间。

    • 位运算(异或) 在处理“成对出现”的问题时,提供了一种极致空间优化的“捷径”。

  • 关键技巧:熟练掌握异或运算的三个核心性质,是解锁许多位运算难题的关键。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值