十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

位运算巧解“找不同”:异或与按位计数在算法题中的应用

位运算巧解“找不同”:异或与按位计数在算法题中的应用 1. 从一个“找不同”的视角切入算法题最近在刷力扣LeetCode时遇到一类很有意思的题目我习惯称之为“找特有姿态”问题。这类问题的核心不是让你去实现一个复杂的算法结构而是要求你在一堆看似相似的数据中精准地找出那个“与众不同”的家伙。它有点像我们小时候玩的“找不同”游戏但规则更严谨逻辑更缜密。我注意到灵茶山艾府大佬在相关的题解中通常会用极其精炼的代码和深刻的洞见直击问题本质让人拍案叫绝。但对于很多刚开始接触这类问题的朋友来说可能更需要一些“慢镜头”式的拆解来理解这精妙代码背后的思考路径。就像看一位书法大师挥毫我们不仅要知道最终的字多么漂亮更想看清每一笔的起承转合。因此我想从一个图解和过程推导的角度对这类“找特有姿态”问题的通用思考模式做一个补充希望能帮助大家把“看懂了”变成“下次我能自己推出来”。这类问题最经典的代表就是“只出现一次的数字”系列例如力扣136、137、260题。它们通常有一个共同的前提在一组数据中除了某个特定的元素其余所有元素都出现了偶数次比如两次或者特定的、有规律的次数。我们的任务就是把这个“单身”的元素找出来。解决它们的关键钥匙往往就是位运算尤其是异或XOR操作。今天我们就以这个为线索把“找特有姿态”的整个过程掰开揉碎讲清楚。2. 异或运算为什么它是“找不同”的神器在深入问题之前我们必须先彻底搞懂我们手中的核心工具——异或运算。很多教程只告诉你怎么用但我想先花点时间说说“为什么非得是它”。异或运算的规则很简单对于两个二进制位相同为0不同为1。0 XOR 0 00 XOR 1 11 XOR 0 11 XOR 1 0从这四条规则我们可以推导出异或运算几个至关重要的性质这些性质正是它能解决“找不同”问题的根本原因性质一归零律。任何数和自己异或结果为零。a ^ a 0。因为每一位都和自己相同按规则全变成0。性质二恒等律。任何数和0异或结果等于其本身。a ^ 0 a。因为0的每一位都是0与a的位不同则为1即a的位是1时相同则为0即a的位是0时结果就是a本身。性质三交换律和结合律。异或运算满足交换律和结合律即a ^ b b ^ a(a ^ b) ^ c a ^ (b ^ c)。这意味着操作顺序不影响最终结果。现在让我们把这些性质放到一个具体场景里。假设我们有一个数组[a, b, c, a, b]。除了c出现一次其他都出现了两次。如果我们把数组中所有数字依次异或起来result a ^ b ^ c ^ a ^ b根据交换律和结合律我们可以调整顺序把相同的数字凑到一起result (a ^ a) ^ (b ^ b) ^ c根据归零律a ^ a 0b ^ b 0result 0 ^ 0 ^ c根据恒等律0 ^ 0 0 然后0 ^ c cresult c看那个只出现一次的c就这样被我们“揪”出来了。这个过程就像一场数字间的“消消乐”所有成对出现的元素都因为归零律而相互抵消最终只剩下那个落单的元素。这就是解决力扣第136题《只出现一次的数字》的核心思想代码简洁到令人发指def singleNumber(nums): res 0 for num in nums: res ^ num return res但我想提醒一个初学者极易忽略的细节初始化res 0是至关重要的。因为0是异或运算的“单位元”它保证在遍历开始时不会对第一个数字造成任何干扰。如果你错误地初始化为res nums[0]然后从第二个元素开始遍历虽然在这个特定问题上结果可能也对但它破坏了逻辑的纯粹性和通用性在更复杂的问题里可能会埋下隐患。3. 当“姿态”升级所有数字出现三次找一个出现一次的理解了基础模型我们来看一个进阶问题力扣第137题《只出现一次的数字 II》。题目变成给你一个数组除了某个元素只出现一次其余每个元素都恰好出现三次。请你找出那个只出现一次的元素。这时简单的“消消乐”模型失效了。因为a ^ a ^ a并不等于0而是等于a本身a ^ a 0, 0 ^ a a。成三的数字无法通过一次异或完全抵消。我们需要新的思路。灵茶山艾府的题解通常会引入“状态机”或“位计数”的思想。这里我用一种更直观的“按位累加”方式来图解。核心思想是既然整体异或不行我们就观察每一个二进制位bit。考虑数组中所有数字的某一个特定二进制位比如最低位。由于除了目标数x其他数都出现了三次那么在这个位上如果x的这个位是0那么数组中所有数字在这个位上的“1”的总个数一定是3的倍数因为其他每个数出现三次它们在这个位上的贡献要么是0要么是3。如果x的这个位是1那么“1”的总个数除以3的余数就是1。注意这是一个非常关键的转换。我们把“在整个数字中找唯一数”的问题转化成了32个独立的“在每一个二进制位上判断余数”的子问题假设是32位整数。让我们用一个极简的例子来演示假设数字只有1个二进制位0或1数组是[1, 1, 1, 0]找只出现一次的数。统计位1出现的次数1出现了3次所以“1”这个位总共出现了3次。3除以3余数为0。根据上面的规律余数为0意味着唯一数x在这个位上是0。所以x 0。对于32位整数我们只要从第0位到第31位重复这个过程即可。下面是具体的算法步骤图解初始化答案ans 0。循环遍历每一个位i从0到31 a.统计遍历数组中所有数字num计算(num i) 1的值并求和。这个操作就是取出num的第i位是0还是1。 b.判断将求和得到的值对3取余数得到remainder。这个余数只可能是0或1因为任何数对3取余结果在0到2之间但这里统计的是“1”的个数且其他数出现三次所以余数只能是0或1对应唯一数在该位的值。 c.设置答案位如果remainder 1说明唯一数x的第i位是1。那么我们就需要把答案ans的第i位设置为1。操作是ans | (1 i)。循环结束32个位都处理完后ans就是我们要找的唯一数。这个方法的代码实现如下def singleNumber(nums): ans 0 for i in range(32): # 遍历32个位 total 0 for num in nums: # 统计第i位为1的个数 total (num i) 1 # 如果该位的1的个数不是3的倍数则答案的该位为1 if total % 3: # 对于Python需要处理负数的情况。这里先按找到正整数理解。 # 如果是第31位符号位且有余数需要特殊处理详见后文。 if i 31: ans - (1 31) # 处理负数边界 else: ans | (1 i) # 将答案的对应位设为1 return ans实操心得与避坑点为什么是(num i) 1num i是右移i位把第i位移到最低位。 1是“按位与”操作可以屏蔽掉其他所有位只保留最低位从而得到该位是0还是1。这是提取特定位值的标准操作。Python中的负数陷阱这是本题最大的一个坑上面的逻辑在C、Java中直接使用int32位是没问题的。但在Python中整数没有固定的位数是无限精度的而且右移操作对于负数是算术右移高位补符号位。当我们处理到最高位第31位符号位时如果唯一数是负数我们的算法依然会认为它的符号位是1并在ans中设置该位。但在Python里一个很大的正数设置了第31位它依然是个正数。为了得到正确的负数结果我们需要一个判断如果第31位有余数即唯一数是负数我们不能简单地用ans | (1 31)因为这会得到一个很大的正数约21亿。正确的做法是将这个数减去2^32即1 32或者更直接地ans - (1 31)再- (1 31)实际上更清晰的做法是if i 31 and total % 3: ans - (1 31)。这利用了补码的知识将最高位的权重从2^31转换为-2^31。很多人在此栽跟头务必理解。4. 最复杂的姿态两个“单身”数字的查找现在我们挑战终极形态力扣第260题《只出现一次的数字 III》。题目描述给定一个整数数组nums其中恰好有两个元素只出现一次其余所有元素均出现两次。找出只出现一次的那两个元素。这下情况更复杂了。如果我们直接全部异或设两个唯一数为a和b那么最终结果xor_all a ^ b。成对的数字都抵消了剩下的是两个目标数的异或值而不是它们本身。我们得到了一个混合体。解题的关键一步在于利用a和b不相等这一事实。既然a ! b那么xor_all a ^ b的结果必然不等于0。也就是说在xor_all的二进制表示中至少有一位是1。这个为1的位意味着在a和b的对应位上一个是0一个是1。这是整个算法的灵魂所在。我们通过这个“差异位”成功地将原数组划分成了两组。对于这个差异位为0的所有数字包含a和b中的一个以及对于这个差异位为1的所有数字包含另一个。而且重要的是那些成对出现的数字因为两两相同它们在这个差异位上的值也必然相同所以一定会被分到同一组里。这样问题就神奇地降维了我们在每一组内部都变成了“所有数字出现两次只有一个数字出现一次”的简单问题第136题。分别对两组进行全员异或就能分别得到a和b。让我们用图解和步骤来拆解步骤一全员异或得到diffdiff 0 for num in nums: diff ^ num # 此时 diff a ^ b步骤二找出diff中任意一个为1的位这个位是区分a和b的分组依据。找一个为1的位有很多方法最常用且高效的是利用补码的特性diff -diff。这个操作可以提取出diff二进制表示中最低位的那个1而其他位都置为0。# 获取diff最右侧的1其他位设为0 # 例如 diff 6 (二进制 0110) -diff在计算机中以补码表示是其取反加1 # diff -diff 结果就是 2 (二进制 0010) lowbit diff -diff为什么是diff -diff在计算机中负数以补码存储-diff等于~diff 1按位取反再加一。diff与~diff 1进行与操作恰好会保留最低位的1因为取反加一的操作会使最低位1之前的所有位与diff相反之后的所有位包括最低位1在加一后会产生进位和保留效应。记住这个技巧它是位运算中的常客。步骤三根据lowbit进行分组并异或我们不需要真正创建两个新数组。可以遍历原数组用num lowbit的结果来判断num应该属于哪一组。如果结果等于0说明num在lowbit这个位上是0如果不等于0说明是1。a, b 0, 0 for num in nums: if num lowbit: # 该位为1的组 a ^ num else: # 该位为0的组 b ^ num # 循环结束后a和b就是我们要找的两个数 return [a, b]一个完整的例子nums [1, 2, 1, 3, 2, 5] 唯一数是3 (011)和5 (101)。diff 1^2^1^3^2^5 3^5 6 (二进制 110)。lowbit 6 -6。6的二进制是110-6的补码是...11111010假设8位110 010 010即lowbit 2 (二进制 010)。我们取的是最低位的1也就是第二位从右向左第0位是1第1位是1我们取第1位。分组组1 (num 2 ! 0): 数字2(010), 3(011), 2(010)。组内异或2 ^ 3 ^ 2 3。组0 (num 2 0): 数字1(001), 1(001), 5(101)。组内异或1 ^ 1 ^ 5 5。结果[3, 5]。经验技巧diff -diff是获取最低位1的标准操作务必掌握。你也可以通过diff (diff - 1)来消去最低位的1这在计算一个数二进制中1的个数时很有用。分组条件num lowbit可以写成num lowbit lowbit或直接num lowbit因为在布尔语境下非零即真。选择一种你觉得最清晰的方式。这个算法的空间复杂度是O(1)只用了几个变量时间复杂度是O(n)需要遍历数组两遍第一遍求diff第二遍分组异或。这是最优解。5. 举一反三从“找数字”到“找状态”的思维迁移通过上面三个由浅入深的例子我们掌握了“找特有姿态”问题的核心套路利用数学规律或位运算性质让重复出现的元素以某种形式“抵消”或“归零”从而让独特的元素凸显出来。这个思维模式可以迁移到许多其他场景并不局限于数字和异或。关键在于识别出问题中“重复”与“唯一”所满足的数学关系。我分享两个我遇到过的变体变体一寻找缺失的数字问题一个长度为n的数组包含了从0到n的所有整数但缺少了一个。请找出缺失的那个。所有数字不重复。 经典解法是利用高斯求和公式total n*(n1)/2减去数组总和sum(nums)差值就是缺失的数。这其实就是另一种“抵消”完整的集合总和与残缺的集合总和之差就是缺失的元素。如果把0到n全部异或起来再把数组所有数异或起来两者再异或也能得到缺失的数因为出现两次的数都抵消了只剩下缺失的那个数出现一次。变体二状态编码与奇偶校验这更像是一种设计思路。假设你有一个监控系统很多台服务器会周期性发送心跳信号用1表示。某个时间段内每台服务器应该发送偶数次心跳。如何快速检测出哪台服务器发送了奇数次心跳可能意味着故障 我们可以给每台服务器分配一个唯一的ID比如一个二进制位向量每次收到心跳就将一个全局状态变量与这台服务器的ID进行异或。根据异或的归零律如果所有服务器都发送了偶数次心跳最终状态变量应为0。如果某台服务器发送了奇数次那么最终状态变量就等于那台服务器的ID。这本质上就是“所有数字出现偶数次找一个出现奇数次的”问题。避坑总结与心得警惕语言特性正如在Python中处理负数位运算遇到的坑不同编程语言对整数类型、位运算、移位操作的定义可能有细微差别。在实现算法时特别是处理边界情况如负数、溢出时一定要查阅语言规范。理解优于记忆不要死记硬背diff -diff这样的技巧。要理解它的由来我们需要一个掩码来区分两个不同的数而它们异或结果中的任何一个“1”位都可以作为区分依据。选取最低位的1只是一种方便、高效的选择。从暴力法开始思考面对新问题如果一时没有头绪可以先从最直接的暴力法或哈希表法想起。然后问自己空间复杂度能优化吗题目给出的特殊条件如“出现两次”、“出现三次”有没有什么数学性质可以利用这种“从通用解到最优解”的思考路径往往能帮你发现规律。画图是利器对于位运算问题在纸上画出数字的二进制表示手动模拟异或、与、或、移位的过程是理解算法最直观的方式。比如在解第260题时亲手画一下diff、-diff的二进制再画一下操作的过程你会对lowbit的提取有刻骨铭心的理解。“找特有姿态”这类问题训练的是我们对数据规律的敏感度和抽象能力。它告诉我们有时候最强大的工具就藏在那些最基础的运算如异或之中。通过位运算这把手术刀我们可以将复杂问题分解到每一个比特位上去观察和解决这种“分而治之”的思想无疑是算法思维中一颗璀璨的明珠。下次当你遇到“除了一个或两个元素其余都出现N次”这样的描述时希望你能会心一笑知道该从哪里入手去捕捉那个独特的“姿态”。
返回列表