Java版LeetCode热题100之只出现一次的数字:从暴力破解到异或优化,深入理解位运算在算法中的妙用
引言
在算法面试中,“只出现一次的数字”是一道经典且高频的题目,它不仅考察了候选人对基本数据结构的理解,更深入地测试了其对位运算这一底层编程技巧的掌握程度。本题出自 LeetCode 热题 100,被众多大厂(如 Google、Amazon、字节跳动等)反复使用,是面试准备中不可绕过的一环。
本文将围绕这道题展开全方位、多维度、深层次的剖析。我们将从最朴素的解法出发,逐步过渡到最优解,并在此过程中穿插数据结构回顾、时间/空间复杂度分析、面试官可能提出的问题、实际开发中的应用场景等内容,力求帮助读者不仅“会做这道题”,更能“理解这类问题”,并具备举一反三的能力。
全文结构如下:
- 原题回顾:明确问题定义与约束条件
- 原题分析:拆解题干关键信息
- 答案构思:从暴力到优化的思维演进
- 完整答案:提供可运行的 Java 代码
- 代码分析:逐行解读核心逻辑
- 复杂度分析:时间与空间效率评估
- 问题解答:常见疑问与误区澄清
- 优化思路:为何异或是最优解?
- 基础知识点回顾:哈希表、集合、位运算详解
- 面试官提问环节:模拟真实面试场景
- 实际开发应用:这道题真的只是“玩具”吗?
- 相关题目推荐:拓展训练清单
- 总结与延伸:升华认知,构建知识体系
一、原题回顾
题目:给你一个非空整数数组
nums,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。要求:
- 设计并实现线性时间复杂度的算法;
- 该算法只使用常量额外空间(即 O(1) 空间)。
示例
示例 1: 输入:nums = [2,2,1] 输出:1 示例 2: 输入:nums = [4,1,2,1,2] 输出:4 示例 3: 输入:nums = [1] 输出:1约束条件
1 <= nums.length <= 3 * 10^4-3 * 10^4 <= nums[i] <= 3 * 10^4- 除一个元素外,其余元素均出现两次
二、原题分析
这道题的关键在于理解题目的隐含条件和硬性约束:
- 唯一性:只有一个数字出现一次,其余都出现恰好两次。这意味着不存在“出现三次”或“多个唯一值”的情况。
- 线性时间:O(n) 时间复杂度,排除了排序(O(n log n))等方案。
- 常量空间:O(1) 额外空间,意味着不能使用哈希表、集合等需要 O(n) 空间的结构。
- 整数范围:包含负数,说明不能仅依赖正整数特性。
这些限制条件直接排除了大多数直观解法,迫使我们思考更“聪明”的方法——位运算。
三、答案构思:从暴力到最优的思维演进
面对一个问题,优秀的工程师不会直接跳到最优解,而是先尝试可行方案,再逐步优化。下面我们按思维路径递进:
思路一:暴力双重循环(不推荐)
遍历每个元素,再内层遍历统计其出现次数。
- 时间复杂度:O(n²)
- 空间复杂度:O(1)
- 缺点:效率极低,无法通过大测试用例。
思路二:使用 HashSet(集合)
- 遍历数组,若元素不在集合中,则加入;若已在,则移除。
- 最终集合中只剩一个元素,即为答案。
Set<Integer>set=newHashSet<>();for(intnum:nums){if(set.contains(num)){set.remove(num);}else{set.add(num);}}returnset.iterator().next();- 时间复杂度:O(n)
- 空间复杂度:O(n)
- 优点:逻辑清晰
- 缺点:违反“常量空间”要求
思路三:使用 HashMap(哈希表)
统计每个数字的出现频率,最后遍历 map 找出 value 为 1 的 key。
Map<Integer,Integer>map=newHashMap<>();for(intnum:nums){map.put(num,map.getOrDefault(num,0)+1);}for(Map.Entry<Integer,Integer>entry:map.entrySet()){if(entry.getValue()==1)returnentry.getKey();}- 时间复杂度:O(n)
- 空间复杂度:O(n)
- 缺点:同样不满足空间限制
思路四:数学法(求和差值)
- 利用集合去重:
sum(set) * 2 - sum(nums) = 唯一元素 - 例如:
[4,1,2,1,2]→ set = {1,2,4} → sum(set)=7 → 7*2=14 → sum(nums)=10 → 14-10=4
Set<Integer>set=newHashSet<>();inttotal=0,uniqueSum=0;for(intnum:nums){total+=num;set.add(num);}for(intnum:set)uniqueSum+=num;returnuniqueSum*2-total;- 时间复杂度:O(n)
- 空间复杂度:O(n)
- 缺点:仍需额外空间,且存在整数溢出风险(虽然本题范围小)
思路五:位运算 —— 异或(XOR)【最优解】
这是本题的标准答案,也是面试官期待看到的解法。
核心思想:利用异或运算的三大性质:
a ^ 0 = aa ^ a = 0- 异或满足交换律和结合律
由于数组中除一个数外,其余都成对出现,那么所有成对的数异或后为 0,最终结果就是那个唯一的数。
例如:[4,1,2,1,2]
计算过程:4 ^ 1 ^ 2 ^ 1 ^ 2 = 4 ^ (1^1) ^ (2^2) = 4 ^ 0 ^ 0 = 4
四、完整答案(Java 实现)
classSolution{publicintsingleNumber(int[]nums){intresult=0;for(intnum:nums){result^=num;}returnresult;}}✅ 该代码满足:
- 时间复杂度 O(n)
- 空间复杂度 O(1)
- 代码简洁、高效、无副作用
五、代码分析
让我们逐行解析这段看似简单的代码:
intresult=0;- 初始化结果为 0。根据异或性质
a ^ 0 = a,0 是异或运算的“单位元”。
for(intnum:nums){result^=num;}- 遍历数组中的每一个元素,将其与
result进行异或。 - 由于异或满足交换律和结合律,顺序无关紧要。
- 成对出现的数字会相互抵消(
a ^ a = 0),最终只剩下唯一出现一次的数字。
returnresult;- 返回最终结果。
关键洞察:整个过程不需要记录任何中间状态,仅用一个变量即可完成计算,完美契合 O(1) 空间要求。
六、时间复杂度与空间复杂度分析
时间复杂度:O(n)
- 只需遍历数组一次,执行 n 次异或操作。
- 异或操作是 CPU 原生指令,时间复杂度为 O(1)。
- 总体:O(n)
空间复杂度:O(1)
- 仅使用了一个额外的整型变量
result。 - 不依赖输入规模,空间恒定。
- 总体:O(1)
💡 对比其他方法:
方法 时间复杂度 空间复杂度 是否满足要求 双重循环 O(n²) O(1) ❌ 时间超限 HashSet O(n) O(n) ❌ 空间超限 HashMap O(n) O(n) ❌ 空间超限 数学求和 O(n) O(n) ❌ 空间超限 异或 O(n) O(1) ✅ 完全满足
七、问题解答:常见疑问与误区
Q1:为什么异或能解决这个问题?
答:因为题目保证“其余元素均出现两次”,而a ^ a = 0,所以所有成对元素异或后消失,只剩唯一元素。
Q2:如果数组中有负数,异或还有效吗?
答:完全有效!异或运算是按位操作,与数值正负无关。Java 中int是 32 位有符号整数,异或直接作用于二进制表示。
Q3:能否用加减法代替异或?比如+a -a = 0?
答:理论上可以,但存在两大问题:
- 溢出风险:大数相加可能溢出(虽然本题范围小,但不通用)
- 无法区分顺序:加减不具备“自反抵消”的确定性(如
a + b - a = b,但需知道何时加何时减)
而异或天然具备“自反性”和“无状态性”。
Q4:如果唯一元素出现奇数次(如3次),其余出现偶数次,还能用异或吗?
答:不能直接使用。此时异或结果为a ^ a ^ a = a,看似可行,但若其他元素出现4次、6次等,仍可抵消。但题目明确限定“其余出现两次”,所以无需考虑。
⚠️ 注意:若题目变为“其余出现三次”,则需用更复杂的位计数方法(如模3计数),不属于本题范畴。
八、优化思路:为何异或是最优解?
从信息论角度看:
- 我们需要从 n 个数中提取 1 个“异常”信息。
- 异或操作本质上是在进行无损压缩:将成对信息压缩为 0,保留差异信息。
- 它利用了题目给出的强约束条件(其余元素恰好出现两次),实现了“零存储”的状态跟踪。
从工程实践看:
- 异或操作是 CPU 最快的位运算之一,通常只需 1 个时钟周期。
- 无内存分配,无 GC 压力,适合高并发、低延迟场景。
因此,异或不仅是理论最优,也是实践最优。
九、数据结构与算法基础知识点回顾
1. 哈希表(HashMap)
- 原理:基于哈希函数将 key 映射到桶(bucket),支持 O(1) 平均查找。
- 适用场景:快速查找、去重、计数。
- 本题局限:需要 O(n) 空间。
2. 集合(HashSet)
- 本质:基于 HashMap 实现,只存储 key。
- 特点:元素唯一、无序。
- 本题用途:去重或配对消除。
3. 位运算(Bitwise Operations)
异或(XOR,^)
- 相同为 0,不同为 1
- 性质:
a ^ 0 = aa ^ a = 0- 交换律、结合律
- 典型应用:
- 交换两个数(无需临时变量)
- 找唯一数
- 加密(一次性密码本)
其他位运算
- 与(
&):掩码操作 - 或(
|):设置位 - 非(
~):取反 - 左移(
<<)、右移(>>):快速乘除 2
📌建议:熟练掌握位运算是进阶算法工程师的必备技能。
十、面试官提问环节(模拟真实面试)
Q1:你能解释一下为什么异或能满足常量空间吗?
期望回答:因为异或操作具有“自反性”和“结合律”,我们不需要存储任何中间状态,只需一个累加变量即可完成全部计算,空间不随输入规模增长。
Q2:如果题目改为“有两个数字只出现一次,其余出现两次”,如何解决?
期望回答:
- 先对所有数异或,得到
a ^ b(设两个唯一数为 a, b)- 找到
a ^ b中任意一个为 1 的位(说明 a 和 b 在该位不同)- 按该位将数组分为两组,每组分别异或,即可得到 a 和 b
- 时间 O(n),空间 O(1)
Q3:异或操作在底层是如何实现的?
加分回答:在 CPU 中,异或由 ALU(算术逻辑单元)直接支持,通常通过 XOR 门电路实现,速度极快,常用于校验、加密、图形处理等领域。
Q4:这个解法能扩展到浮点数吗?
回答:不能。浮点数的二进制表示包含符号位、指数位、尾数位,直接异或无意义。且浮点数比较应避免直接用
==。本题限定为整数。
十一、这道算法题在实际开发中的应用
很多人认为 LeetCode 题是“玩具”,但其实位运算在工业界有广泛应用:
1. 数据校验(Checksum)
- 网络传输中,常用异或校验检测数据是否被篡改。
- 例如:发送方计算数据块的异或值作为校验码,接收方重新计算并比对。
2. 加密与安全
- 一次性密码本(One-time pad)使用异或进行加密/解密。
- 简单但理论上不可破解(前提是密钥真随机且不重复使用)。
3. 内存优化
- 在嵌入式系统或高频交易系统中,常利用位运算节省内存。
- 例如:用一个 int 的 32 位表示 32 个布尔状态。
4. 图形处理
- 图像翻转、颜色混合等操作常使用位运算加速。
5. 数据库去重
- 虽然数据库不用异或去重,但“成对抵消”的思想可用于日志分析、事务回滚等场景。
✅结论:这道题不仅是面试题,更是位运算思维的启蒙课。
十二、相关题目推荐(LeetCode 拓展训练)
掌握本题后,可挑战以下变种:
| 题号 | 题目 | 难度 | 关键点 |
|---|---|---|---|
| 137. 只出现一次的数字 II | 中等 | 其余出现三次 | 位计数(模3) |
| 260. 只出现一次的数字 III | 中等 | 两个唯一数 | 分组异或 |
| 268. 丢失的数字 | 简单 | 0~n 缺一个 | 异或或求和 |
| 389. 找不同 | 简单 | 字符串中多一个字符 | 异或字符 |
| 477. 汉明距离总和 | 中等 | 所有数对的异或位数和 | 位统计 |
🔥 建议:先独立思考,再参考题解,形成自己的解题模板。
十三、总结与延伸
核心收获
- 异或的三大性质是解决“成对抵消”类问题的利器。
- 约束条件驱动算法选择:线性时间 + 常量空间 → 位运算。
- 从暴力到优化的思维路径是解决算法问题的标准流程。
- 位运算不仅是技巧,更是思维方式,值得深入学习。
延伸思考
- 如果数组中有一个数出现一次,其余出现k 次(k 为奇数),如何找?
- 答:仍可用异或(因 k 为奇数,
a ^ a ^ ... ^ a = a)
- 答:仍可用异或(因 k 为奇数,
- 如果 k 为偶数?
- 答:异或结果为 0,无法区分,需其他方法(如求和、位计数)
学习建议
- 动手实现:将本文所有思路都编码一遍,对比性能。
- 画图理解:用二进制位图展示异或过程。
- 阅读源码:查看 Java
Integer.bitCount()等位操作方法。 - 扩展阅读:《算法导论》位运算章节、《Hacker’s Delight》
结语
“只出现一次的数字”看似简单,却蕴含着算法设计的精髓:在约束条件下寻找最优解。它教会我们:
不是所有问题都需要复杂的数据结构,有时一个巧妙的位运算就能四两拨千斤。
希望本文能帮助你在算法之路上更进一步。如果你觉得有收获,欢迎点赞、收藏、转发!也欢迎在评论区留下你的思考或疑问。
Stay curious, keep coding!
附:完整可运行测试代码
publicclassSingleNumber{publicstaticintsingleNumber(int[]nums){intresult=0;for(intnum:nums){result^=num;}returnresult;}publicstaticvoidmain(String[]args){System.out.println(singleNumber(newint[]{2,2,1}));// 1System.out.println(singleNumber(newint[]{4,1,2,1,2}));// 4System.out.println(singleNumber(newint[]{1}));// 1}}