ARTICLE DETAIL

资讯详情

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

位运算入门到实战:与、或、异或的底层逻辑与陷阱解析

位运算入门到实战:与、或、异或的底层逻辑与陷阱解析 如果你写了几年代码大概率在代码里见过这三个运算符^、|、。很多入门教程都会给一张真值表然后告诉你按位与、按位或、按位异或分别是什么规则最后留两道练习题就算讲完了。我当年也是这样学的背下了口诀但真正遇到问题的时候完全想不起来它们能干什么。直到后来在算法题、嵌入式开发、网络协议解析里反复撞上才慢慢把这些操作符从考试知识点变成下意识就用的工具。这篇东西不是教科书式的科普我想从一个真正会拿位运算干活的人的角度把三个操作符的底层逻辑、实战场景和容易踩的坑一次说透。重点放在异或上——因为它是三个里面最反直觉、但也是最有用的一个。适合刚学完基础语法想进阶的初学者也适合写了好几年业务代码、对位运算始终一知半解的同学。你不需要有汇编基础只要会十进制转二进制就能跟上。1. 三个符号的本质从背真值表到看得见二进制位1.1 与串联思维与清零利器按位与的规则用一句话概括两个位都是1结果才是1。其余情况全部是0。这就像家里两个开关串联的灯——只有两个开关同时闭合灯才会亮。任何一个断开电路就断了。这里的核心关键词是按位。它不是说两个整数做一次与运算而是说把两个整数的每一位分别拿出来对着做一次与运算。比如10110010 01101101 ---------- 00100000从右往左逐位看第5位从0开始编号上两个数都是1所以结果是1其他位要么有一个0要么两个都是0所以都是0。这个运算最实用的价值有两个。第一判断某一位是不是1num (1 k)如果结果非零说明第k位是1否则是0。第二清零指定位置num ~(1 k)用取反后的掩码把第k位强制变成0其他位不受影响。本质上与运算做的事情是保留我想保留的位屏蔽我不想看到的位。理解这个逻辑之后你再看网络编程里的子网掩码、权限系统里的权限位校验本质上全部是同一件事拿一个数字和掩码做按位与看结果是否符合预期。所以我说按位与是筛选器它的性格是保守、收敛、只做减法。1.2 或|并联思维与置位开关按位或的规则也一句话两个位只要有一个是1结果就是1。只有两个都是0的时候结果才是0。这对应的是并联开关——任何一个开关闭合灯就能亮。还是用刚才的两个字节10110010 | 01101101 ---------- 11111111逐位看任何一位上只要出现1结果就是1。在这个例子里两个数恰好互补结果变成了全1。或运算最常用的场景是置位把某一位强制变成1其他位保持不变。num | (1 k)就是经典操作。比如你想在权限数字上追加读的权限不用关心当前是否已经拥有直接或上去就行。和与运算相反或运算的性格是开放、叠加、只做加法。它不会去动你原来有的位只会把你指定的位点亮。正因为这个特性它特别适合管理一组独立的开关每个开关占一个bit开和关互不干扰通过|追加、通过 ~清除、通过查询。这种一组bit当作多个布尔值容器的思路是位运算最经典的玩法后面我会详细展开。1.3 异或^无进位加法与数据翻转异或的规则比前两个绕一点两个位相同结果是0两个位不同结果是1。所以它叫异或——相异才为真。把异或看成不考虑进位的加法你就抓住它的灵魂了10110010 ^ 01101101 ---------- 11011111你看101011000这些和普通加法一样唯一的区别是11的时候普通加法得到0并进位异或这里得到0但不进位。所以它也叫模2加。这个性质带来了一系列非常漂亮的特点x ^ 0 x任何数和0异或等于自己。x ^ x 0任何数和自身异或等于0。x ^ y ^ y x异或两次同一个数会还原成原来的数。这叫自反性也是异或最迷人的地方。第三个性质直接催生了两个经典应用。一个是数据加密明文和密钥异或得到密文密文再和同一个密钥异或就还原成明文。另一个是变量交换a ^ b; b ^ a; a ^ b;三步完成交换不需要临时变量。异或还有一个隐藏身份翻转指定位。x ^ mask会把mask中为1的位全部取反为0的位保持不变。开关翻转、图像反色、状态切换这类需求用异或一行就搞定。1.4 顺带说下同或异或的镜像既然热搜词里有人提同或这里多写一句。同或XNOR的规则是两个位相同为1不同为0恰好和异或相反。它其实就是异或的取反逻辑上等价于~(a ^ b)。同或在数字电路里很常见用来判断两个信号是否一致。但在日常编码里用得比较少因为大多数语言没有独立的同或运算符你需要用!或~配合异或来实现。理解它只是为了让你看到逻辑关系的完整性与、或、非、异或、同或这五个构成了布尔代数的完整家族它们之间可以互相转换并不是彼此孤立的。2. 实战场景这三个操作符在真实代码里到底怎么用理论说完了进入正题。可能你还在想这些二进制规则跟我写业务代码有什么关系。我挑几个最典型的场景代码都不长但每一个都是真实项目里能直接用上的。2.1 判断奇偶和检查特定位读状态判断一个整数是奇数还是偶数最朴素的写法是n % 2 0。但用位运算可以写成(n 1) 0。原理很简单二进制的最低位是1就是奇数是0就是偶数。n 1只保留最低位其余全部归零。结果不是0就是1一眼可辨。用位运算判断奇偶不只是炫技。在做高频循环、图像处理、大量数据过滤时%运算符的底层开销比大不少。虽然现代编译器在优化开启时会自动把n % 2优化成n 1但如果你自己写位运算版本哪怕不开优化也能保证性能还能让代码意图更明确。类似的读状态操作是检查某一位// 检查第k位是否是1 int isKthBitSet(int num, int k) { return (num (1 k)) ! 0; }你可能会问这能干嘛举个例子假设一个int的每一位都代表一个用户的今日打卡状态第0位代表是否登录第1位代表是否签到第2位代表是否完成问卷。那么一次操作就能判断任意一个状态不需要定义三个bool变量更不需要三个字段存数据库。等你想查用户当天完成了哪些任务一条SQL或者一次内存读取拿到一个数字用三个就全查完了。2.2 权限叠加与开关管理写状态这是|最经典的舞台。我用一个文件权限的例子// 定义权限位 #define PERM_READ (1 0) // 1 #define PERM_WRITE (1 1) // 2 #define PERM_EXEC (1 2) // 4 // 给用户分配读 写权限 int user_perm 0; user_perm | PERM_READ; user_perm | PERM_WRITE; // 判断是否有写权限 if (user_perm PERM_WRITE) { // 允许写入 } // 撤销写权限 user_perm ~PERM_WRITE; // 切换执行权限有就删没有就加 user_perm ^ PERM_EXEC;这套玩法你可能在很多框架源码里见过。Linux文件权限用0755这种八进制数字本质就是对三组权限位的编码很多ORM框架的with/without参数也是用位掩码拼出来的。位操作在这里的核心价值是用最少的存储表达最多的独立状态并且增删改查都是O(1)常数级操作。我自己在写配置系统的时候特别喜欢这种模式。十几个开关选项一个uint32_t就装完了接收端拿一个整数就能判断所有选项不用搞十几个字段、十几层if判断。等状态多了你会发现位掩码这招是状态管理的万能解。2.3 变量交换、数据翻转与临时标记异或三连交换变量是每个学位运算的人都会背的经典a a ^ b; b a ^ b; // 实际上等于原来的a a a ^ b; // 实际上等于原来的b逐步推一下第一步a a ^ b第二步b (a ^ b) ^ b a ^ (b ^ b) a ^ 0 a第三步a (a ^ b) ^ a b ^ (a ^ a) b ^ 0 b三次异或交换完成。看起来很精妙但我必须提醒在实际业务代码里我不推荐用它替换临时变量交换法。为什么第一现代编译器对临时变量交换已经优化到极致异或交换并不会更快第二异或交换在a和b是同一个变量或者指向同一块内存的引用时会直接归零这是一个非常隐蔽的bug。它更大的价值在于让你理解异或的自反性以及在纯函数式语言或极端寄存器受限的环境中多一种思路。异或更实际的用途是翻转位。比如你有一个状态位表示用户是否喜欢某篇文章用户点了喜欢/取消喜欢的切换按钮status ^ FLAG_LIKED; // 喜欢变不喜欢不喜欢变喜欢一条异或搞定toggle不需要先读再判断再写。RGB颜色反色、精灵图翻转、UI主题切换这类取反需求用异或都直截了当。记住一个规律异或是和0保持不变和1翻转。你想翻转哪些位就把掩码的对应位设为1。3. 最容易翻车的三类错误优先级、负数与逻辑运算符混淆位运算看着简单实际写起来翻车率极高。这些坑我基本都踩过而且很多是网上搜不到的教训。3.1 运算符优先级被坑得最多的一个点很多人第一次写if (a b 0)的时候心里想的是如果a和b按位与的结果等于0但编译器和你想的完全不是一回事。先看常见的优先级表从高到低优先级运算符说明高~按位取反一元运算较高移位中等按位与较低^按位异或更低|按位或低!比较更低||逻辑与或注意这个关键结论的优先级高于^^高于|但、^、|都低于。所以a b 0实际被解析成a (b 0)而不是(a b) 0。这个问题在C/C/Java/C#里都存在各语言细节略有差异搜一下位运算符优先级陷阱你会发现中招的人遍地都是。最离谱的一次是我在一个项目里看到有人写if (x 0xFF 0)本意是判断低8位是否全为0实际执行的是x (0xFF 0)也就是x 0整个判断永远为假。安全策略就一条涉及位运算和比较运算混用时别省括号。(x 0xFF) 0和(a ^ b) 0写清楚括号既防编译器误解也防后来维护代码的同事看半天。3.2 负数与补码位运算不是数学运算第二个高频坑是按位运算符在负数上的表现。很多教程举例时只用正整数潜移默化让人以为位运算是数学运算。实际上位运算操作的是补码twos complement不是数学上那个数的绝对值。拿C语言举例int x -3; printf(%d\n, x 0xFF); // 结果是253不是1-3在32位补码里是11111111 11111111 11111111 11111101和0xFF也就是255做按位与最低8位是11111101即253。如果你以为按位与会保留数学上的负号就会困惑很久。再比如int x -1; printf(%d\n, x ^ 0xFF);-1的补码全是111111111 11111111 11111111 11111111异或0xFF之后变成11111111 11111111 11111100也就是-256。很多人第一次算这种题都愣住。所以处理负数位运算之前先问自己这个数为什么会是负数我要不要用无符号类型在嵌入式、协议解析、图像处理里我一般直接用uint8_t、uint32_t这类无符号类型彻底绕开符号位的坑。只有在明确知道补码计算规则的情况下才允许自己用signed类型做位运算。3.3 位运算符和逻辑运算符不是一回事第三个坑是混淆和、|和||。两者虽然形式相似但完全是两个物种。和||是逻辑运算操作数是布尔值结果也是布尔值而且有短路求值false anything直接返回false后面的表达式根本不会执行。这在写保护性判断时是故意的if (ptr ! NULL ptr-value 0) { ... }如果ptr是NULL右侧ptr-value根本不会执行避免了空指针崩溃。这是最宝贵的特性。和|是按位运算操作数可以是任意整数结果也是整数没有短路行为两侧表达式都会完整求值。如果你把ptr ! NULL ptr-value 0这么写当ptr是NULL时右侧仍然会执行并崩溃。另一个重要区别a b的结果不是按位与之后的结果而是逻辑上的都为真。在C语言中4 2结果为1真而4 2结果为0因为二进制的4100和2010没有共同位。很多初学者在这里懵掉就是因为没有分清逻辑层面和二进制层面。写代码时的经验是状态判断用和||位操作才用和|。如果你发现自己正在混合使用这两种运算符做同一件事停下来想想是不是设计出了问题。4. 算法题与竞赛视角异或为什么是出题人最爱从热搜词能看到csp-j 2025 异或和异或问题这类竞赛词汇。如果你刷过LeetCode或者参加过信息学竞赛应该已经发现异或是算法题里的常客出题频率远超与和或。原因很简单——异或的自反性让很多看起来不可能的问题有了极简解法。4.1 唯一出现奇数次的数字与自反性应用最经典的题目是一个数组里只有一个数出现奇数次其他数都出现偶数次怎么高效找到它解法出乎意料地简单把所有数从头到尾异或一遍最终结果就是那个出现奇数次的数。原理多推几步就清楚了。异或满足交换律和结合律所以你可以把相同的数凑在一起x ^ x变成0。偶数次出现的数全变成0最后剩下的就是唯一出现奇数次的数。int findOddOne(int* arr, int n) { int ans 0; for (int i 0; i n; i) { ans ^ arr[i]; } return ans; }这段代码只有几行时间复杂度O(n)空间复杂度O(1)。如果不用位运算常规思路是哈希表计数空间复杂度O(n)。异或让空间降到了常数级这就是它出现在面试题里的核心原因。进阶版本是数组里有两个数出现奇数次其他都出现偶数次找出这两个数。思路分三步全部异或得到diff x ^ y。因为两个数不同diff非0。找到diff最右侧为1的那一位说明x和y在这一位不同。按这一位把数组分成两组各自异或就得到x和y。void findTwoOdds(int* arr, int n, int* x, int* y) { int diff 0; for (int i 0; i n; i) diff ^ arr[i]; // 取最右侧的1 int lowestBit diff (-diff); *x 0; *y 0; for (int i 0; i n; i) { if (arr[i] lowestBit) { *x ^ arr[i]; } else { *y ^ arr[i]; } } }这里diff (-diff)又是一个经典的位运算技巧提取一个数最右侧的1。负数是对应正数的补码x -x得到的正好是x最右边的1所在位的权值。这个技巧在树状数组Fenwick Tree里也是核心操作记住了能用在很多地方。4.2 前缀异或和区间查询的通用套路异或和出现在竞赛题里最常见的形式是区间异或查询。典型题目长这样给定数组a[1..n]有多次查询每次问[l, r]区间所有数的异或和是多少。如果每次都暴力遍历区间一次查询O(n)m次查询就是O(n*m)数据一大直接超时。套路是预处理前缀异或数组pre[i] pre[i - 1] ^ a[i];前缀异或数组的含义是pre[i]表示从第1个元素到第i个元素的异或和。查询[l, r]就变成了ans pre[r] ^ pre[l - 1];为什么能这样简单因为异或的逆运算还是异或。加法区间和需要减法异或区间和需要的减法还是异或。int rangeXor(int* pre, int l, int r) { return pre[r] ^ pre[l - 1]; }这个技巧和前缀和几乎一模一样但很多新手因为不熟悉异或的逆运算是自己死活想不起来。一旦想通以后遇到区间异或和的题目第一反应就是前缀异或数组。这属于数据结构的固定套路竞赛里非常高频。学了前缀异或之后你还能顺手解决有多少个子数组的异或和等于k这类问题。思路是利用前缀异或数组哈希表对于每个pre[r]找前面有多少个pre[l-1]满足pre[l-1] ^ k pre[r]也就是找前面有多少个pre[l-1] pre[r] ^ k。又是异或自反性在发挥作用。4.3 状态压缩用二进制位表示集合最后一个大杀器是状态压缩。它在竞赛和算法设计里太常用了本质上就是|和的集体操作。假设你有一个集合元素数量不超过20个你想枚举它的所有子集。用布尔数组表示每个选不选当然可以但更优雅的方式是用第i个bit表示第i个元素是否被选中。一个int就表示了一个子集。空集0全集(1 n) - 1给第i个元素打上标记mask | (1 i)检查第i个元素是否在集合里mask (1 i)删掉第i个元素mask ~(1 i)枚举所有子集只需一个循环for (int mask 0; mask (1 n); mask) { // 处理每个子集 }这种写法在动态规划里非常常见比如旅行商问题、背包问题变体、集合划分等。状态压缩的威力在于把一个集合的整体当作一个整数传入函数参数、作为状态转移的key甚至作为数组下标。它能写进DP数组dp[mask]让状态存储和状态转移都极其自然。我自己刷题的时候遇到n比较小的组合优化问题第一反应就是看能不能用状态压缩。有了这个思维很多看起来复杂的搜索问题马上变成枚举mask预计算转移代价的矩阵代码结构和可调试性都大幅提升。5. 实践建议练好位运算的三件小事5.1 先学会手推二进制位运算能力本质上是对二进制的直觉。如果你看到0x3C要在脑子里转两秒才能想到00111100那做题和写代码都会慢半拍。我建议花一个下午做一件事拿一张白纸把0到31的十进制、二进制、十六进制三栏写一遍。然后随机挑两个数手算它们的、|、^的结果再用代码验证。这个训练看起来笨但特别有效。练完之后你看到0x0F会自动反应低4位全是1看到(1 3)会想到数值8看到mask (mask - 1)会想到去掉最右侧的1。有了这种条件反射题目和代码的难度会瞬间下降一个档次。mask (mask - 1)这个技巧值得单独记一下它是把二进制里最右边的1变成0的操作。判断一个数是不是2的幂用n 0 (n (n - 1)) 0数一个数里有多少个1就循环执行这个操作直到变成0。这些看起来高深的东西都是手推几次就彻底明白的。5.2 调试时打印二进制而不是十进制我在调试位运算代码时有一条铁律打开调试器或写打印语句的时候输出二进制不要输出十进制。十进制的253很难让你联想到11111101但如果你打印的是0b11111101一眼就能看出哪些位是1哪些位是0结果是否符合预期。在C语言里可以手写一个二进制打印函数在C里有std::bitsetPython里可以用bin()Java里有Integer.toBinaryString()。工具都很方便关键是养成习惯。我自己调试权限系统和协议解析时几乎每次都要把中间变量转成二进制看一眼很多bug其实在看到二进制的瞬间就一目了然了。5.3 写位运算代码的防护习惯最后总结几个我从踩坑经验里沉淀下来的代码习惯不敢说百分百避免问题但至少能挡住绝大多数低级错误能用无符号就用无符号。在涉及位运算的场景uint32_t、unsigned char比int安全得多避免符号扩展和负数补码的意外。位运算和比较运算之间要加括号。不要依赖优先级编译器可能和你想的不一样。位掩码用宏或常量定义不要散落魔法数字。给每个bit取名字代码可读性会大幅提升。异或交换变量不要用在同一个对象上。如果确实要交换临时变量法更稳妥。区分、|和、||涉及条件判断优先用逻辑运算符短路特性会保护你。多用移位代替乘法除法x 1代替x * 2x 1代替x / 2但注意不要对负数右移产生错误预期。我个人做完位运算相关功能一定会做一件事把十进制预期值和二进制手推结果对比一遍。比如算一个掩码flags 0xF0我会先自己心算低4位被清零、高4位被保留再打印一份二进制确认。大多数时候心算是准的偶尔也会发现自己对某个边界值判断失误这个时候手推就救了我。位运算是一个看着简单、错起来很隐蔽的领域多一次校验少一次线上事故。回到开头那句话位运算符不是只存在于教科书里的古董知识它们是理解计算机底层逻辑的一把钥匙。你用筛选数据、用|拼装状态、用^翻转和还原本质上都是在以一个更接近硬件的视角思考问题。这种视角不仅对算法竞赛有直接帮助在日常debug、性能优化、阅读高质量源码时也会让你比别人多看到一层东西。
返回列表