第一道题是:在一个班级中,每位同学都拿到了一张卡片,上面有一个整数。有趣的是,除了一个数字之外,所有的数字都恰好出现了两次。现在需要你帮助班长小C快速找到那个拿了独特数字卡片的同学手上的数字是什么。
这是一个经典的"找出数组中只出现一次的数字"问题,这道题的难点是题目要求:
- 设计一个算法,使其时间复杂度为 O(n),其中 n 是班级的人数。
- 尽量减少额外空间的使用,以体现你的算法优化能力。
那这意味着现实生活中的方法是无法解决这道题的,比如我们通常会这么思考:
- 排队法
- 让同学们按照卡片上的数字排成一列
- 相同数字的同学会自然站在一起
- 最后看谁旁边没有同伴,那个同学的数字就是独特的
- 不满足❌:需要移动所有同学,比较耗时
- 登记表法
- 班长准备一张表,让每位同学轮流来登记自己的数字
- 在表上画正字符号,第一次出现画一笔,第二次出现画第二笔
- 最后看哪个数字只有一笔,那就是独特的数字
- 不满足❌:需要额外准备登记表
那以 Python 为例,我们很快就能敲出下面代码
def solution(nums):
count_dict = {}
for num in nums:
count_dict[num] = count_dict.get(num, 0) + 1
for num, count in count_dict.items():
if count == 1:
return num
def solution(nums):
nums.sort() # 只是演示目的
i = 0
while i < len(nums)-1:
if nums[i] != nums[i+1]:
return nums[i]
i += 2
return nums[-1] # 处理最后一个数是单独的情况
可以看到上面的两种算法虽然可以跑过 Test case,但是没有办法满足题目的要求。 那我们就要学习新的知识:异或 XOR
什么是 XOR
异或是一个位运算的运算符,基本规则是:
- 相同为0
- 不同为1
用二进制举例:
-
比较 5 和 5: 5 的二进制是: 101 5 的二进制是: 101 异或结果是: 000 (因为每一位都相同,所以都是0)
-
比较 5 和 3: 5 的二进制是: 101 3 的二进制是: 011 异或结果是: 110 (不同的位得1,相同的位得0)
为什么异或能找到单独的数字?
-
当两个相同的数异或时,结果为0
比如:5 xor 5 = 0 -
任何数和0异或,结果是它自己
比如:5 xor 0 = 5 -
所以,如果有一组数:[5, 3, 5]
第一步:5 xor 3 = 6 第二步:6 xor 5 = 3- 两个5相互抵消(因为5 xor 5 = 0)
- 最后剩下3(因为0 xor 3 = 3)
通过这几个例子我们发现了一个基本规律:
- 相同的数字配对后会消除(变成0)
- 任何数字和0配对还是它自己
- 配对的顺序不重要
为什么配对的顺序不重要
让我用一个具体的例子来说明为什么顺序不影响。
假设我们有数组 [5, 3, 5],我们可以用不同的顺序来异或:
顺序1:从左到右
第一步:5 xor 3 = 6
第二步:6 xor 5 = 3
结果是3
顺序2:先处理两个5
第一步:5 xor 5 = 0 (相同的数异或得0)
第二步:0 xor 3 = 3 (0异或任何数得到这个数)
结果还是3
这是因为异或运算有三个重要特性:
-
交换律:a xor b = b xor a
- 就像 5 xor 3 = 3 xor 5
-
结合律:(a xor b) xor c = a xor (b xor c)
- 就像 (5 xor 3) xor 5 = 5 xor (3 xor 5)
-
自反性:a xor a = 0
- 相同的数异或得0
所以无论我们用什么顺序:
- [5, 3, 5] 可以看作 (5 xor 3) xor 5
- 也可以看作 5 xor (3 xor 5)
- 也可以看作 (5 xor 5) xor 3
利用这个特性,我们就可以直接遍历数组,那个最终留下的数就是没有配对的数
def solution(nums):
result = 0
for num in nums:
result ^= num
return result
反思
这道题特意设计 "除了一个数字外都出现两次"这个条件,来贴合异或运算的特性。但是题目里又没有明说,所以平日不怎么用的到的 xor 会让很多同学翻车。
但这道题真的只是为了考而考吗? 其实也不是, 异或运算在实际的使用场景中还是很普遍的因为其优势:
- 空间效率高, 只需要用一个变量存储结果
- 计算速度快, 比加减乘除移位步骤少很多
举一个内存检测的例子,如果我们想在内存出厂时检测内存是否损坏:
-
初始状态:将一块内存区域写入一组配对的数据
-
比如写入:[0x55, 0xAA, 0x55, 0xAA](0x55和0xAA是常用的测试模式)
-
运行一段时间后检测:
如果内存完好:异或结果 = 0 如果某个位置损坏:异或结果 ≠ 0 -
这样只需要一个变量就能检测出内存是否损坏,而不需要存储完整的参考数据
再比在数据传输时可以作为校验的手段,比如嵌入式设备在数据传输时
-
发送方:
- 数据包:[1, 2, 3, 4]
- 校验值:将所有数据异或得到校验码 C
C = 1 xor 2 xor 3 xor 4 = 4- 发送:[1, 2, 3, 4, 4](数据包 + 校验码)
-
接收方:
- 正常接收:[1, 2, 3, 4, 4]
- 对所有收到的数据(包括校验码)进行异或
- 如果传输正确,结果应该是0
-
错误情况:
- 如果收到:[1, 2, 3, 4, 4, 3](多了一个3)
- 异或结果就不是0,说明数据有问题
- 如果收到:[1, 2, 4, 4](丢了3)
- 异或结果也不是0,说明数据有问题