多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

异或运算从原理到实战:位运算中的对称加减法

异或运算从原理到实战:位运算中的对称加减法 最近在网上翻到一道很经典的题一组整数里每个数字都出现了偶数次只有唯一一个数字出现了奇数次要求把它找出来。很多人第一反应是哈希表数一遍再遍历。但如果再加一条限制空间复杂度必须做到 O(1)局面就立刻不一样了。标准答案是一行代码把所有数字全部异或一遍留下来的那个就是答案。这个解法流传很广可你要是追问一句“为什么异或一遍就能找到落单的数”不少人是答不上来的只能说“本来就是这样的”。异或确实是被误解最多、也被低估最多的运算符。我这些年做协议解析、状态机、图形处理和性能优化几乎每个领域都能看到它的影子。这篇文章不想停留在背真值表的层面而是想从运算本质讲到工程习惯把异或真正讲透。内容包括它的三种理解方式、几个关键数学性质、常见实战场景以及我在代码里踩过的坑。为了让不同基础的读者都能跟上我会把每个结论都拆到可直接验证的地步。1. 异或到底在算什么三种理解方式帮我建立直觉1.1 真值表之后的第一个误区别只记结论异或的真值表很简单任意一位上相同为0、不同为1xyx ^ y000011101110这张表几乎人人都见过。但问题在于只记住这张表遇到实际问题时根本不知道往哪个方向想。我后来发现真正有用的不是表本身而是从三个角度去理解它二进制加法、按位比较、逻辑门实现。三种理解对应三类不同的应用场景比死记结论有用得多。1.2 理解一不带进位的二进制加法异或最直观的数学含义就是“模2加法”你可以把它看成二进制加法把进位直接丢掉。举个例子5 3 用二进制算是 101 011 1000也就是8。而 5 ^ 3 是 101 ^ 011 110也就是6。为什么差在这因为最低位 1 1 产生了进位异或不管这个进位直接把这一位清零。这个理解方式在做校验和、加密、数据恢复时特别重要。很多工程场景里我们关心的不是“数值上的和”而是“每一位上是否相同”异或恰好就是这样一个逐位运算。记住这句话异或是对齐二进制位后逐位做加法、但不往前进位。一旦建立起这个画面很多公式就不会觉得抽象了。1.3 理解二按位比较相同为0、不同为1第二种理解更简单把两个数展开成二进制从右往左逐位比较一样的位置记0不一样的位置记1。这就像小时候玩的“找不同”游戏两张图片并排看哪里有差异哪里就标出来。这套直觉在算法题里非常管用因为很多问题的本质就是“两组数据的差异在哪里”。比如要判断两个二进制串有多少位不同直接异或一下结果里有多少个1就有多少处差异。这在汉明距离、简单纠错、布隆过滤器的位运算里都是基础操作。我见过不少写权限系统的同学看到位掩码就头晕其实只要抓住“异或就是找位差异”这个角度一切都能顺下来。1.4 理解三从逻辑门看异或的本质构造如果从数字电路的角度看异或可以拆成两个基本逻辑的组合x ^ y (x ~y) | (~x y)这个式子翻译成人话就是要么 x 为1且 y 为0要么 x 为0且 y 为1两种情况都能让输出为1。你会看到异或天然表达了一种“二选一但是只能选一个”的逻辑。这也是为什么它在条件切换、分支判断、状态机设计里频繁出现。很多讲位运算的资料都略过这一层直接跳到应用但我觉得理解逻辑门构成能帮你一眼看穿它的性质异或的输出和输入之间是对称的、可逆的。因为式子本身对 x 和 y 完全对称交换两个输入结果不变。这为后面的“交换律”和“自反性”打下了直觉基础。2. 三条数学性质为什么异或能把数据“变回来”2.1 异或的自反性不是魔术是代数推导异或之所以能解决那么多问题核心在于三条性质交换律a ^ b b ^ a结合律(a ^ b) ^ c a ^ (b ^ c)自反性a ^ a 0a ^ 0 a其中交换律和结合律意味着一堆数做异或顺序完全无所谓可以随便重新排列分组。自反性则是最神奇的一条一个数与自身异或得0与0异或保持自身。这两条合起来就推出了一个关键推论a ^ b ^ a (a ^ a) ^ b 0 ^ b b也就是一个数异或两次同一个数会变回原来的自己。这不是魔术只是代数推导。你把它想成“按了两下同一个开关”第一下开灯第二下关灯最终状态不变。这个“两次操作互相抵消”的特性是整个异或应用的基石。2.2 和其它运算对比为什么异或的逆运算就是它自己加法有对应的减法乘法有对应的除法而异或的“逆运算”就是异或本身。这意味着什么如果你想撤销一次操作不需要额外保存什么状态只要把同样的值再异或一次即可。这在很多场景里能省掉一个变量、一个步骤甚至一条指令。我举个生活中的类比假如你在一张纸上写了一个数字想擦掉重来通常需要把纸翻面或重新写。而异或更像一个“可逆的开关账本”你记一笔再记同一笔两项就互相抵消。所以加密解密、画了又擦的图形操作、校验纠错全都建立在它的自反性上。2.3 单位元与零元以及它们对算法复杂度的意义0 在异或运算里扮演着“单位元”的角色任何数与0异或都等于它自己。所以在做累积异或时我们通常把初始值设为0然后逐个往里面异或所有元素。因为0不会污染结果最后得到的数字就能保留所有有效信息。顺带一提在布尔代数和代数结构里异或构成一个阿贝尔群。这个说法听起来吓人实际含义却很朴素参与运算的元素可以任意交换位置、任意加括号而且每个元素都有自己的逆元它自己运算结果永远在一个封闭集合里。正因为结构这么好异或才成为哈希混淆、检错码、密码算法中的常客。理解到这一层你再去看那些“一行搞定”的位运算题就不会觉得是灵光一现而是有明确的数学依据。3. 实战场景一交换变量、简单加密与数据恢复3.1 不使用临时变量的交换看起来很酷但要注意陷阱异或最出名的应用之一是不用临时变量交换两个整数a a ^ b; b a ^ b; a a ^ b;我来逐步推导一下。设初始 a 5b 3。第一步后a 变成 5 ^ 3 6。第二步b 6 ^ 3 5此时 b 已经等于原来的 a。第三步a 6 ^ 5 3a 变成了原来的 b。三行代码完成交换。这个技巧在网上很流行但我要提醒你它有一个非常隐蔽的坑——如果 a 和 b 指向同一个内存地址比如 C 语言里用数组下标操作 a[i] 和 a[j]并且 i j那么 a 先变成0b 也跟着变成0最后数据就丢了。我在 C 语言里吃过这个亏后来每次用这套写法必然先判断两个下标是否相同。更关键的问题是这个写法真的值得用吗在大数交换上现代编译器对临时变量做优化之后性能和异或写法相差无几而且可读性更好。异或交换真正有优势的是寄存器极度受限的嵌入式场景或教学演示。日常业务代码里我会毫不犹豫选择临时变量。这不是说异或没用而是说工具的选用要看上下文炫技式写法不是工程智慧。3.2 简单异或加密加密和解密用的是同一把钥匙异或的自反性让加密和解密变得极其简单。比如要对一个字节流做简单加密可以选一个密钥 key对每个字节做 plain ^ key得到密文。解密时只需把密文再异或同一个 keyplain bhello key 0x1F cipher bytes([p ^ key for p in plain]) # 加密 decoded bytes([c ^ key for c in cipher]) # 解密 print(cipher) # 输出 bwzsso 之类 print(decoded) # 输出 bhello原理就是前面那条性质plain ^ key ^ key plain。如果 key 是个固定单字节那么每个字节都是同样的变换这在现实世界里非常脆弱。只要收集足够多的已知明文就能猜出 key 的规律。所以不要把这种异或加密用于真正的敏感数据保护它更适合做简单混淆、协议中的防误读、或者仅供学习理解密码学的台阶。3.3 RAID中的数据恢复思路多块硬盘如何互相备份这是异或在工程领域最让我惊叹的应用之一。简单说在某种廉价磁盘冗余阵列方案里数据不是单纯地复制一份放在第二块盘上而是把多块盘的数据做异或生成一份校验数据。假设有 D1、D2、D3 三份数据块校验块 P D1 ^ D2 ^ D3。如果 D2 那块盘坏了可以从 D1、D3 和 P 把 D2 恢复出来D2 D1 ^ D3 ^ P用刚才的例子验证一下D1 0xA5D2 0x5AD3 0xC3那么 P 0xA5 ^ 0x5A ^ 0xC3 0x3C。如果 D2 丢了算一下 D1 ^ D3 ^ P 0xA5 ^ 0xC3 ^ 0x3C逐位运算后确实得回 0x5A。这种做法的本质是校验数据不是简单地复制原数据而是所有原数据的“叠加签名”任何一份失踪都能由其他人合力还原。你可以这样理解三兄弟各自记了一本账同时还把三本账的差异汇总成一张公共卡。任何一本账丢了另外两本加上公共卡就能重写出来。明白这个思想再去看分布式存储里的纠删码、容灾系统的恢复策略都会有熟悉感因为底层逻辑一脉相承。4. 实战场景二算法题里的常客以及背后的统一模式4.1 找唯一出现奇数次的数字一行代码的解释回到开头的那个问题。假设数组是 [4, 1, 2, 1, 2]我们要找4。代码可以写成def single_number(nums): result 0 for num in nums: result ^ num return result把它展开来看result 最后等于 4 ^ 1 ^ 2 ^ 1 ^ 2。因为异或满足交换律和结合律可以重新排成 (1 ^ 1) ^ (2 ^ 2) ^ 4。1 ^ 1 得02 ^ 2 得00 ^ 0 ^ 4 得4。所有出现偶数次的数字都两两抵消归零而那个只出现一次的数字因为只被异或一次就留了下来。这道题背后藏着一个很通用的思想成对出现的数据可以互相抵消。当你需要在一堆数据里找“不对称的那一个”异或往往是最省空间的解法时间 O(n)空间 O(1)。相比之下哈希表虽然思路直接却要开辟额外空间。当然哈希表能处理更一般的“统计频次”问题异或只能处理“成对抵消”模型二者不是替代关系。4.2 找缺失的数字异或与数学方法的殊途同归有一个经典问题0 到 n 这 n1 个数字里少了一个找出它。常见的数学解法是把 0 到 n 的和减去数组总和得出缺失值。但用异或也有简洁的思路def missing_number(nums, n): x 0 for i in range(n 1): x ^ i for v in nums: x ^ v return x第一轮让 x 等于 0 到 n 全部值的异或第二轮再把数组里现存的数字也异或进去。于是所有现存数字等价于被异或了两次全部抵消唯独缺失的那个数只出现了一次最终留存在 x 里。数学求和法需要小心大数溢出而异或逐位运算则没有这个顾虑在嵌入式等场景里更稳。4.3 进阶两个数只出现一次怎么把它们分别找出来这是我认为最能体现异或思维深度的一道题。数组里有两个数只出现一次其余都出现两次要求找出这两个数。先全体异或得到 diff这实际上是那两个数的异或结果。因为两个数不同diff 至少有一位是1。接着找到 diff 最低的为1的那一位用它作为分界把数组分成两组该位为0的一组、该位为1的一组。由于那两个数在这一位上不同必然被分到不同组而其余成对的数在同一组内依然成对继续各自异或就能分别得到这两个数。def single_numbers(nums): diff 0 for num in nums: diff ^ num lowbit diff -diff a 0 b 0 for num in nums: if num lowbit: a ^ num else: b ^ num return a, b以 [2, 3, 2, 4, 3, 6] 为例4 和 6 只出现一次。全体异或 diff 4 ^ 6 2。lowbit 是 2也就是二进制第二位。把第二位为1的数分到一组为0的分到另一组4 和 6 恰好分开各自组内抵消后得到它们。这道题的关键在于异或不仅能找到“异常的那个”还能通过某一位的差异把异常者拆开。很多复杂位运算题的套路都是这样先用异或找到线索再利用线索分组最后分别求解。4.4 这类题型的统一口诀两两抵消落单自现把上面三道题放在一起你会发现它们共享同一个思维模型数据中有大量成对出现的信息这些信息在异或面前会互相湮灭真正需要关注的永远是“没被抵消”的那部分。遇到算法题先问自己三个问题哪些数据是成对出现的成对的数据是否可以通过异或抵消抵消之后剩下的信息代表了什么这不是死记题型而是一种识别模式的能力。当你看到“寻找唯一出现一次”“寻找缺失数字”“成对抵消”等关键词时异或应该自动进入你的候选方案列表。不是说每次都要用它而是至少先过一遍这个思路再决定是否换更复杂的方法。5. 工程实战位掩码切换、集合对称差与图形学中的异或5.1 用异或切换状态位比判断后赋值更干净实际项目里异或最朴素的工程用途是状态切换。假设你用位掩码管理权限或开关一个 flag 本身是 0b1010想翻转其中某一位可以直接flag flag ^ mask # mask 里哪一位是1哪一位就被翻转比如 flag 0b1010mask 0b0010一次异或后变 0b1000再一次异或又变回 0b1010。传统写法要判断当前位是0还是1再决定置位还是清零异或写法直接翻转不需要分支判断代码更短在分支预测失效的场景下还更省时间。我做过一个简单的状态开关模块原本要用四五个 if 判断换成异或后逻辑少了一小半可读性反而提高了因为看代码的人一眼就知道这是“翻转”。5.2 位图表示下的集合对称差权限与标签筛选的利器如果你用位图表示一个集合那么两个集合的对称差属于A但不属于B或属于B但不属于A正好对应位图的异或。这个性质在标签系统、权限系统里非常实用。假设用户 A 有权限位 0b1100用户 B 有权限位 0b1010那么两者权限的差异就是 0b0110哪些权限不一致一目了然。我做标签筛选时也常用这个思路两个集合做对称差能得到“两边标签分布不一致”的对象列表。当然如果你的需求是求交集或并集那要分别用与和或只有当你关心“差异”时异或才是那个最自然的运算符。区分清楚需求就不会用错。5.3 图形学里的橡皮筋效果画了又擦全靠自反性这可能是异或最优雅的工程应用之一。在图形界面里拖动鼠标画临时矩形、调整选区时希望图形能随鼠标移动动态更新同时不破坏底下的原有内容。做法是把绘图模式设为 XOR第一次用掩码画图形像素被异或叠加第二次在原位置再画一遍所有像素恢复原样。这样就不需要保存和重绘整个背景只需要记住上次的图形位置。这背后用的正是“异或两次恢复原状”。我记得早期很多图形工具拖动光标、橡皮筋框选都采用这个策略。现在 GPU 渲染管线和窗口系统的处理方式更复杂但在某些轻量级图形库、低端嵌入式显示、老式终端模拟器里这种技巧依然有用。它让我意识到理解一个运算的数学性质是真的能在意想不到的地方派上用场。5.4 协议帧的异或校验和轻量但要知道它的边界写网络协议或串口通信时经常需要加一个校验字节确保数据在传输过程中没被改坏。最简单可靠的校验和之一就是把所有字节逐个异或frame bytes([0xAA, 0x01, 0x02, 0x03]) checksum 0 for b in frame: checksum ^ b # 0xAA ^ 0x01 0xAB # 0xAB ^ 0x02 0xA9 # 0xA9 ^ 0x03 0xAA发送端把 checksum 追加在帧尾接收端对整帧重新异或一遍。如果结果是0说明所有字节在传输过程中异常的概率很低。它能检测单比特翻转因为任何一位变了最终异或结果就不是0。但它有一个著名弱点如果某位在传输中被翻转两次翻转会互相抵消校验就发现不了。所以要求更高的场景会改用 CRC 这类更复杂的校验算法。不是异或校验不能用而是你要清楚它的能力边界在安全关键场景里选择对应强度的方案。6. 边界条件与常见误区别在最简单的地方翻车6.1 异或不是加法区分“无进位和”与“数值和”我见过不少初学者以为异或就是“直接相加但不进位”然后理所当然地把所有加法换成异或。这通常在只需要判断奇偶位或识别差异时有效但一旦你真正需要数值结果就会出错。5 ^ 3 等于6不是8因为异或丢掉了全部进位而 5 3 等于8。做算术逻辑、统计总数时绝对不能混用。一个判断技巧是如果你关心的是“总共有多少”用加法如果你关心的是“哪些位不一样”用异或。6.2 负数在补码体系下的异或结果可能让你意外不同编程语言里异或都是按二进制的补码表示逐位运算的。拿 -1 ^ 1 来说结果不是0而是 -2。因为在补码里-1 的所有位都是1与1异或后最低位变成0其余位保持1对应补码 -2。很多语言新手在这里第一次踩坑会觉得异或结果“不符合直觉”。如果要在跨语言项目里用位运算处理负数务必先搞清楚目标语言里整数的位宽和补码规则。C/C 里溢出是未定义行为Java 和 Python 的处理方式又各有差异。我的习惯是预处理里先把负数转成无符号形式或明确记录符号位避免在负数位模式下推导半天。6.3 非整数数据不能直接按字节异或序列化是前置步骤有些同学会把字符串或浮点数直接拿去异或结果发现语言报错或行为诡异。原因很简单异或运算定义在整数位模式上字符串、浮点数需要先序列化成字节序列再逐字节处理。比如在 Python 里我对一个字符串做异或加密会先 encode 成 bytes再对每个字节做运算最后再 decode 回来。浮点数更麻烦它的字节表示和数值本身完全不是一回事直接异或字节得到的结果没有数学意义。如果确实需要做校验应该选择把浮点转成二进制表示或字符串之后再校验。搞清楚对象的底层表示才能安全使用异或。6.4 性能与可读性的平衡什么时候该用什么时候别用异或指令在 CPU 层面非常快一条指令就能完成比分支判断通常更高效。但这不代表你要把代码里所有逻辑都改写成异或。现代编译器已经会帮你优化掉很多多余分支而写满异或的代码一旦缺少注释后来维护的人很可能要花很长时间才能读懂。我的判断标准很简单如果异或能让代码意图更清晰比如表示“翻转状态”“校验数据”“提取差异”就用如果只是为了显得自己很懂位运算把一个简单的累加逻辑改成异或那是给自己和同事挖坑。可读性是工程代码的第一属性位运算只是达成目的的工具。7. 踩过几次坑之后我总结的异或使用清单说了这么多最后整理一份我实际工作中沉淀下来的检查清单。遇到位运算场景时我会先过一遍这几个问题数据里是否存在“成对出现、期望抵消”的结构如果是优先考虑异或。我的目标是“求差异”“翻转状态”“快速校验”还是“算术求和”只有前三者适合异或。参与运算的数据是否都是整数或可序列化为字节负数、浮点、字符串要先做处理。代码里用了异或旁边有没有注释如果没有至少保证变量名和函数名能让人猜出意图。在需要数据恢复的场景有没有想清楚异或校验的边界它无法检测所有错误组合。有一次我调试一个通信模块数据偶尔出现丢失却半天找不到原因。后来用十六进制逐字节打印才意识到是校验位的算法写反了把数据帧本身也异或进了校验值。这类问题如果不借助二进制层面的观察很容易在逻辑层面打转。后来我每次做异或相关调试都会先用二进制把输入和输出打印出来逐位对比通常很快就能定位。理解异或的最佳方式其实不是背那些公式和结论而是花一个下午亲手在纸上把几个数字展开成二进制一步步做按位运算再在代码里打断点观察每一步的结果。我当年就是这样把一个看似简单的运算符彻底弄明白的。之后再看那些精巧的位运算解法就不再觉得是奇技淫巧而是能找到清晰的推导路径。希望这篇内容也能帮你建立属于自己的异或直觉。
返回列表