📖 第104课:只出现一次的数字

3 阅读15分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第104课:只出现一次的数字

模块:高级技巧 | 难度:Easy ⭐⭐⭐ LeetCode 链接:leetcode.cn/problems/si… 前置知识:无 预计学习时间:15分钟


🎯 题目描述

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

要求:必须设计并实现线性时间复杂度的算法,且只使用常量级别的额外空间。

示例:

输入:nums = [2,2,1]
输出:1

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

输入:nums = [1]
输出:1

约束条件:

  • 1 <= nums.length <= 30000
  • -30000 <= nums[i] <= 30000
  • 除了某个元素只出现一次外,其余每个元素均出现两次

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
最小输入nums=[1]1单元素数组
顺序排列nums=[1,1,2,2,3]3有序情况
乱序排列nums=[5,3,5,1,1]3无序情况
负数nums=[-1,-1,0]0负数和零
大规模n=30000性能边界

💡 思路引导

生活化比喻

想象你在整理一堆配对的袜子,所有袜子都是成双成对的,只有一只袜子落单了。

🐌 笨办法:把所有袜子一只只拿出来,用笔记本记录每只袜子出现几次,最后找出只出现一次的。这需要一个很大的笔记本(O(n)空间),而且要翻来翻去查找(慢)。

🚀 聪明办法:利用"相同的袜子互相抵消"的魔法!把袜子两两配对时,相同的就"消失",最后剩下的必然是落单的那只。这个"抵消魔法"就是异或运算——相同为0,不同为1

关键洞察

异或运算的三大性质:

  1. 任何数和0异或等于它本身:a ⊕ 0 = a
  2. 任何数和自己异或等于0:a ⊕ a = 0
  3. 异或运算满足交换律和结合律:a ⊕ b ⊕ a = b

利用性质2和3,所有成对的数字会自动抵消为0,最后只剩下落单的数字!


🧠 解题思维链

Step 1:理解题目 → 锁定输入输出

  • 输入:整数数组 nums,除一个元素外其余均出现两次
  • 输出:只出现一次的那个元素(整数)
  • 限制:必须 O(n) 时间 + O(1) 空间

Step 2:先想笨办法(暴力法)

用哈希表统计每个数字的出现次数,最后找出次数为1的:

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)
  • 瓶颈在哪:需要额外的哈希表存储计数,违反O(1)空间要求

Step 3:瓶颈分析 → 优化方向

核心问题:如何在不使用额外空间的情况下找到答案?

关键观察:

  • 所有成对的数字是"冗余信息",需要被"消除"
  • 只有落单的数字是"有效信息",需要被"保留"

能否找到一种操作,让成对的数字互相抵消,而不影响落单的数字?

Step 4:选择武器

  • 选用:位运算 — 异或(XOR)
  • 理由:异或运算天然具备"相同抵消,不同保留"的特性,且是原地操作(O(1)空间)

🔑 模式识别提示:当题目出现"成对出现,找单独"或"奇偶次数问题",优先考虑"异或运算"


🔑 解法一:哈希表统计(直觉法)

思路

用字典统计每个数字的出现次数,最后遍历字典找出次数为1的元素。

图解过程

nums = [4,1,2,1,2]

Step 1: 构建计数字典
  遍历数组,统计频率
  {4:1, 1:2, 2:2}

Step 2: 找出频率为1的元素
  遍历字典,返回值为1的键
  → 4

Python代码

from typing import List


def singleNumber_hashmap(nums: List[int]) -> int:
    """
    解法一:哈希表统计
    思路:统计每个数字出现次数,找出次数为1的
    """
    # 步骤1:构建计数字典
    count = {}
    for num in nums:
        count[num] = count.get(num, 0) + 1

    # 步骤2:找出频率为1的元素
    for num, freq in count.items():
        if freq == 1:
            return num


# ✅ 测试
print(singleNumber_hashmap([2, 2, 1]))        # 期望输出:1
print(singleNumber_hashmap([4, 1, 2, 1, 2]))  # 期望输出:4
print(singleNumber_hashmap([1]))              # 期望输出:1

复杂度分析

  • 时间复杂度:O(n) — 遍历两次数组,第一次建表,第二次查找
    • 具体地说:如果输入规模 n=10000,大约需要 20000 次操作
  • 空间复杂度:O(n) — 需要哈希表存储 n 个元素的计数

优缺点

  • ✅ 思路清晰,代码易懂
  • ✅ 适用于更复杂的变体(比如找出现奇数次的所有元素)
  • 违反题目要求的 O(1) 空间限制

🏆 解法二:异或运算(最优解)

优化思路

利用异或运算的"自反性"(a ⊕ a = 0)和"交换律",把所有数字异或起来,成对的数字会自动抵消为0,最后只剩下单独的数字。

💡 关键想法:把数组中所有数字看作一个"异或链",相同的数字会两两抵消,顺序无关!

图解过程

nums = [4,1,2,1,2]

把所有数字异或起来:
  4 ⊕ 1 ⊕ 2 ⊕ 1 ⊕ 2

利用交换律重新排列(相同的放一起):
  4 ⊕ (1 ⊕ 1) ⊕ (2 ⊕ 2)

利用自反性(a ⊕ a = 0):
  4 ⊕ 0 ⊕ 0

利用性质(a ⊕ 0 = a):
  4  ← 答案!

位运算过程(二进制视角):
  4:  100
  1:  001
  ⊕   101
  2:  010
  ⊕   111
  1:  001
  ⊕   110
  2:  010
  ⊕   100  → 4

Python代码

def singleNumber(nums: List[int]) -> int:
    """
    解法二:异或运算(最优解)
    思路:所有数字异或,成对的抵消为0,剩下单独的数字
    """
    result = 0
    for num in nums:
        result ^= num  # 持续异或每个数字
    return result


# ✅ 测试
print(singleNumber([2, 2, 1]))        # 期望输出:1
print(singleNumber([4, 1, 2, 1, 2]))  # 期望输出:4
print(singleNumber([1]))              # 期望输出:1
print(singleNumber([-1, -1, 0]))      # 期望输出:0

复杂度分析

  • 时间复杂度:O(n) — 只需遍历一次数组,每次异或是 O(1) 操作
    • 具体地说:如果输入规模 n=10000,大约需要 10000 次异或操作
  • 空间复杂度:O(1) — 只需要一个整数变量 result,与输入规模无关

为什么是最优解:

  • 时间复杂度 O(n) 已经是理论最优(必须至少看一遍所有元素)
  • 空间复杂度 O(1) 满足题目的严格要求
  • 一次遍历,无需排序或额外数据结构

🐍 Pythonic 写法

利用 functools.reduce 的函数式写法:

from functools import reduce
from operator import xor

def singleNumber_pythonic(nums: List[int]) -> int:
    """
    Pythonic 写法:用 reduce 和 xor 操作符
    """
    return reduce(xor, nums)


# 测试
print(singleNumber_pythonic([4, 1, 2, 1, 2]))  # 输出:4

解释:

  • reduce(xor, nums) 相当于 nums[0] ^ nums[1] ^ nums[2] ^ ...
  • operator.xor 是 Python 内置的异或函数
  • 一行代码完成任务,展示函数式编程功底

⚠️ 面试建议:先写清晰的循环版本展示思路,再提 Pythonic 写法展示语言功底。面试官更看重你的思考过程,而非代码行数。


📊 解法对比

维度解法一:哈希表🏆 解法二:异或运算(最优)
时间复杂度O(n)O(n) ← 时间最优
空间复杂度O(n)O(1) ← 空间最优
代码难度简单简单
面试推荐⭐⭐⭐ ← 首选
适用场景通用统计问题本题最优解,满足所有约束

为什么异或是最优解:

  • 同时达到时间 O(n) 和空间 O(1) 的最优标准
  • 代码极简(3行核心代码),易于实现和记忆
  • 展示了位运算的巧妙应用,是面试高频考点

面试建议:

  1. 先用30秒口述哈希表思路(O(n)空间),表明你能想到基本解法
  2. 立即指出题目要求 O(1) 空间,需要优化
  3. 重点讲解🏆最优解(异或运算),阐述三大性质和推导过程
  4. 强调为什么这是最优:时间空间双重最优,无法进一步优化
  5. 手动模拟一个示例,展示异或的"抵消"过程

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道题。

:(审题30秒)好的,这道题要求在数组中找出只出现一次的元素,其他元素都出现两次。我的第一个想法是用哈希表统计每个数字的频率,最后找出频率为1的,时间复杂度是 O(n),但空间复杂度也是 O(n)。

不过我注意到题目明确要求空间复杂度必须是 O(1),所以需要优化。我可以利用异或运算的性质来做到:相同的数字异或为0,而 0 异或任何数等于那个数本身。这样把所有数字异或起来,成对的会自动抵消,最后剩下的就是答案。时间 O(n),空间 O(1)。

面试官:很好,请写一下代码。

:(边写边说)我用一个 result 变量初始化为0,然后遍历数组,把每个数字异或到 result 上。因为异或满足交换律和结合律,所以顺序无关,成对的数字会自动抵消为0。

def singleNumber(nums):
    result = 0
    for num in nums:
        result ^= num
    return result

面试官:测试一下?

:用示例 [4,1,2,1,2] 走一遍:

  • 初始 result = 0
  • 0 ^ 4 = 4
  • 4 ^ 1 = 5
  • 5 ^ 2 = 7
  • 7 ^ 1 = 6 (第二个1和第一个1抵消效果)
  • 6 ^ 2 = 4 (第二个2和第一个2抵消效果)
  • 最终结果:4 ✓

再测一个边界情况 [1],只有一个元素:

  • 0 ^ 1 = 1 ✓

高频追问

追问应答策略
"如果有三个数字,其中两个出现三次,一个出现一次,怎么办?""异或不适用了,因为 a⊕a⊕a = a 不为0。需要改用位统计:统计每个位上1出现的次数,如果次数不是3的倍数,说明单独的数字在该位为1。时间 O(n),空间 O(1)。"
"为什么异或运算 a⊕a=0?""从二进制角度看,异或是不进位相加:相同位为0,不同位为1。所以两个相同数字每一位都相同,异或后全为0。"
"还有其他位运算的经典应用吗?""有!比如判断奇偶(n&1),乘以2(n<<1),除以2(n>>1),交换两数(a^=b, b^=a, a^=b),判断2的幂(n&(n-1)==0)。"
"能否不用异或,用数学方法?""可以!用 2×(集合元素和) - (数组元素和) = 答案。但需要转换为集合(O(n)空间),且可能溢出,不如异或简洁。"

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:异或运算符 ^ — 位级异或
a = 5   # 二进制 101
b = 3   # 二进制 011
c = a ^ b  # 二进制 110,十进制 6

# 技巧2:快速交换两数(无需中间变量)
a, b = 5, 3
a ^= b  # a = 5^3
b ^= a  # b = 3^(5^3) = 5
a ^= b  # a = (5^3)^5 = 3
# 现在 a=3, b=5

# 技巧3:检查两数是否相等(位级)
def are_equal(a, b):
    return (a ^ b) == 0  # 相同则异或为0

# 技巧4:找出不同的位
def diff_bits(a, b):
    return bin(a ^ b).count('1')  # 统计1的个数

💡 底层原理(选读)

异或运算为什么这么神奇?

从逻辑电路角度看,异或门(XOR gate)是数字电路的基本门电路之一:

  • 输入相同 → 输出0
  • 输入不同 → 输出1

数学性质:

  • 交换律:a ⊕ b = b ⊕ a
  • 结合律:(a ⊕ b) ⊕ c = a ⊕ (b ⊕ c)
  • 恒等律:a ⊕ 0 = a
  • 归零律:a ⊕ a = 0
  • 自反律:a ⊕ b ⊕ b = a

应用场景:

  • 加密算法(简单的异或加密)
  • 奇偶校验(检测数据传输错误)
  • 位图操作(图像处理)
  • 内存交换(无需临时变量)
  • 找出数组中的特殊元素(本题)

Python 中的位运算符:

  • & 按位与(AND):都为1才为1
  • | 按位或(OR):有1就为1
  • ^ 按位异或(XOR):不同为1
  • ~ 按位取反(NOT):0变1,1变0
  • << 左移:相当于乘以2
  • >> 右移:相当于除以2

算法模式卡片 📐

  • 模式名称:异或性质 — 成对抵消
  • 适用条件:数组中元素成对出现,需找出单独的元素或奇偶次数问题
  • 识别关键词:"只出现一次"、"成对出现"、"奇数次/偶数次"、"O(1)空间"
  • 模板代码:
def find_single_element(nums):
    """异或模板:找出只出现一次的元素"""
    result = 0
    for num in nums:
        result ^= num  # 所有数字异或,成对抵消
    return result

易错点 ⚠️

  1. 误用其他位运算

    • 错误:用按位与(&)或按位或(|)
    • 原因:只有异或(^)具有"自反性"(a^a=0)
    • 正确:始终使用异或运算符 ^
  2. 忘记初始化为0

    • 错误:result 不初始化或初始化为其他值
    • 原因:异或的恒等元是0(a^0=a),初始化为0才能保证正确性
    • 正确:result = 0
  3. 边界情况漏测

    • 错误:只测试正数,忘记测试负数和零
    • 原因:负数在计算机中用补码表示,异或同样适用
    • 正确:测试用例应包含 [-1,-1,0]
  4. 复杂度分析错误

    • 错误:认为异或运算是 O(log n) 或 O(32)
    • 原因:整数异或是固定时间的 O(1) 操作,与数值大小无关
    • 正确:整体时间复杂度就是循环的 O(n)

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:数据完整性校验

    • RAID 5 磁盘阵列使用异或运算生成校验盘,任一磁盘损坏可通过异或恢复数据
    • 示例:如果有磁盘 A、B、C,校验盘 P = A ⊕ B ⊕ C,若 B 损坏,可通过 B = A ⊕ C ⊕ P 恢复
  • 场景2:简单加密

    • 异或加密是最简单的对称加密:密文 = 明文 ⊕ 密钥,解密时再异或一次密钥即可
    • 示例:消息 M 用密钥 K 加密为 C=M⊕K,解密时 M=C⊕K
  • 场景3:网络传输奇偶校验

    • 在串口通信中,用所有字节异或结果作为校验和,检测传输错误
    • 示例:发送 [0x12, 0x34, 0x56],校验和 = 0x12 ⊕ 0x34 ⊕ 0x56
  • 场景4:Git 内部哈希

    • Git 使用类似的位运算技巧快速比较文件差异

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 137. 只出现一次的数字 IIMedium位运算其他数字出现3次,用位统计
LeetCode 260. 只出现一次的数字 IIIMedium异或 + 分组有两个单独的数字,先异或找差异位分组
LeetCode 268. 丢失的数字Easy异或/数学0到n中缺失的数字,异或所有索引和值
LeetCode 389. 找不同Easy异或两字符串多了一个字符,异或所有字符
LeetCode 645. 错误的集合Easy异或/哈希一个数字重复,一个缺失

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:给定一个整数数组,其中恰好有两个元素只出现一次,其他元素都出现两次。找出这两个只出现一次的元素。要求 O(n) 时间和 O(1) 空间。

例如:nums = [1,2,1,3,2,5] 返回 [3,5]

💡 提示(实在想不出来再点开)

先把所有数字异或,得到的是那两个单独数字的异或结果(a^b)。然后找到这个结果中任意一个为1的位,说明a和b在这一位不同,以此为依据把数组分成两组,每组分别异或就能得到a和b!

✅ 参考答案
def singleNumber_iii(nums: List[int]) -> List[int]:
    """
    LC 260. 只出现一次的数字 III
    思路:先全部异或得到 a^b,再用差异位分组
    """
    # 步骤1:全部异或,得到 a^b
    xor_all = 0
    for num in nums:
        xor_all ^= num

    # 步骤2:找到 a^b 中任意一个为1的位(说明a和b在该位不同)
    # 用 xor_all & (-xor_all) 可以快速得到最右边的1
    diff_bit = xor_all & (-xor_all)

    # 步骤3:根据该位是否为1,把数组分成两组
    a, b = 0, 0
    for num in nums:
        if num & diff_bit:
            a ^= num  # 该位为1的一组
        else:
            b ^= num  # 该位为0的一组

    return [a, b]


# 测试
print(singleNumber_iii([1, 2, 1, 3, 2, 5]))  # 输出:[3, 5]

核心思路:

  1. 全部异或得到 a^b(两个单独数字的异或)
  2. 找到 a^b 中为1的任意一位,这一位上a和b必然不同
  3. 以这一位为标准把数组分成两组,每组分别异或就能分别得到a和b

时间复杂度:O(n),空间复杂度:O(1)


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。