ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

位运算核心指南:按位与、或、异或的原理与工程实践

位运算核心指南:按位与、或、异或的原理与工程实践 1. 从拆位说起为什么位运算值得单独拎出来讲很多人第一次接触位运算是在一道很朴素的题目里给一个两位整数拆出它的十位和个位。比如73十位是7个位是3。最直觉的写法是73 / 10和73 % 10这没问题。但如果有人告诉你某些场景下用位运算能做得更快、更省、更优雅你可能会愣一下——毕竟在十进制世界里待久了二进制那套东西总显得有点底层。可一旦你开始碰嵌入式、图像处理、权限系统、哈希算法、压缩编码、状态机、网络协议解析甚至只是想让一段循环跑得更快一点位运算就会像影子一样跟上来。它不是什么炫技的玩具而是计算机最原始、最直接的操作方式。CPU 里的算术逻辑单元ALU天生就会做与、或、异或、取反、移位这些操作往往一个时钟周期就能完成比乘除法便宜得多。这篇内容就是围绕**按位与、按位或|、异或^**这三个核心运算展开的。我会先把它们的运算规则和二进制基础讲透再往下挖它们在实际工程里的典型用法最后聊几个容易踩的坑。不管你是刚学编程、正在准备算法题还是已经写了几年代码但位运算总是看得懂、写不出这篇都值得从头到尾过一遍。关键词里出现了二进制除法十六位二进制对照表lrc校验码二进制表示十进制小数这些词说明大家关心的不只是三个符号本身而是它们在真实问题里的落地。所以我会尽量把每个知识点都落到具体场景上而不是停留在真值表。2. 二进制的地基不理解这一层位运算永远是死记硬背2.1 二进制到底在表达什么十进制的本质是逢十进一每一位的权重是 10 的幂。二进制同理只是逢二进一每一位的权重是 2 的幂。一个字节有 8 位从右往左权重分别是 2⁰ 到 2⁷也就是 1、2、4、8、16、32、64、128。拿217举例它转成二进制的过程是不断除以 2 取余数从下往上读217 ÷ 2 108 ... 1 108 ÷ 2 54 ... 0 54 ÷ 2 27 ... 0 27 ÷ 2 13 ... 1 13 ÷ 2 6 ... 1 6 ÷ 2 3 ... 0 3 ÷ 2 1 ... 1 1 ÷ 2 0 ... 1从下往上读余数得到11011001。验证一下128 64 16 8 1 217对上了。这个除二取余、逆序排列的方法就是最通用的十进制转二进制手段手算和写代码都适用。反过来的二进制转十进制更简单把每一位是 1 的位置对应的权重加起来就行。11011001里第 7、6、4、3、0 位是 1对应 128、64、16、8、1求和得 217。2.2 十六位二进制对照表为什么有用人眼读一长串 0 和 1 非常痛苦所以工程上常用十六进制做压缩表示。每 4 位二进制正好对应 1 位十六进制因为 2⁴ 16。这就是为什么你会看到十六位二进制对照表这种需求——它其实是把 4 位一组做映射。二进制十六进制十进制0000000001110010220011330100440101550110660111771000881001991010A101011B111100C121101D131110E141111F15有了这张表11011001就可以拆成1101和1001分别对应D和9所以是0xD9。写代码时用十六进制表示位掩码比写一长串二进制清爽得多也不容易数错位。2.3 小数和负数两个容易被忽略的角落二进制表示十进制小数是个高频疑问。整数部分用除二取余小数部分用乘二取整。比如0.60.6 × 2 1.2 → 取整 1剩 0.2 0.2 × 2 0.4 → 取整 0剩 0.4 0.4 × 2 0.8 → 取整 0剩 0.8 0.8 × 2 1.6 → 取整 1剩 0.6 0.6 × 2 1.2 → 又回到起点你会发现它开始循环了0.6的二进制是0.100110011001...永远除不尽。这就是为什么浮点数在计算机里往往只是近似值0.1 0.2 ! 0.3的经典问题根源就在这里。做金额计算时千万别用浮点要用整数或定点数这是血泪教训。负数则涉及补码。简单说一个数的相反数等于按位取反再加一。比如 8 位下1是00000001取反得11111110加一得11111111这就是-1。补码的好处是让减法可以用加法电路实现硬件设计大大简化。理解补码对后面理解位运算的符号问题至关重要。3. 按位与、按位或、异或三个符号背后的运算逻辑3.1 按位与只有全 1 才得 1按位与的规则非常干脆两个对应位都是 1结果位才是 1否则是 0。1101 (13) 1011 (11) -------- 1001 (9)它的核心用途是提取和清零。想保留某几位、把其他位抹掉就用一个掩码去与。比如想取一个字节的低 4 位就和0x0F即00001111做与uint8_t value 0xD9; // 11011001 uint8_t low value 0x0F; // 00001001 9判断奇偶也是经典用法n 1结果为 1 是奇数为 0 是偶数。因为二进制最低位就是 2⁰ 位它决定了这个数能不能被 2 整除。这比n % 2更直接编译器通常也能优化成同样的指令但语义上更贴近硬件。3.2 按位或|只要有一个 1 就是 1按位或的规则是两个对应位只要有一个是 1结果就是 1。1101 (13) | 1011 (11) -------- 1111 (15)它的核心用途是置位。想把某些位强制设成 1就用一个掩码去或。比如要给一个寄存器的第 3 位置 1reg reg | (1 3); // 等价于 reg | (1 3)1 3得到00001000也就是只有第 3 位是 1 的掩码。或上去之后无论原来第 3 位是什么结果都是 1其他位不受影响。这种读-改-写模式在驱动开发里天天见。3.3 异或^相同为 0不同为 1异或的规则是两个对应位相同则结果为 0不同则结果为 1。1101 (13) ^ 1011 (11) -------- 0110 (6)异或有几个非常漂亮的数学性质值得单独记住自反性a ^ a 0任何数和自己异或都归零。恒等性a ^ 0 a和 0 异或保持不变。交换律和结合律a ^ b b ^ a(a ^ b) ^ c a ^ (b ^ c)。可逆性a ^ b ^ b a异或两次同一个数就还原了。这些性质直接催生了两个经典应用不用临时变量交换两个数以及找出数组中唯一出现一次的数字。// 交换 a 和 b a a ^ b; b a ^ b; // 此时 b (a^b)^b a a a ^ b; // 此时 a (a^b)^a b// 数组中只有一个数出现奇数次其余都出现偶数次 int result 0; for (int i 0; i n; i) { result ^ arr[i]; } // result 就是那个落单的数第二个例子是很多算法题的标配解法时间复杂度 O(n)空间 O(1)比用哈希表还省。3.4 三个运算的真值表对照把三个运算放在一起看规律会更清晰aba ba | ba ^ b00000010111001111110记住一句话与是乘法全 1 才 1或是加法有 1 就 1异或是找不同不同才 1。这个类比虽然不严谨但对记忆非常有效。4. 位运算在真实工程里的六种典型用法4.1 权限系统一个整数装下一组开关Linux 文件权限rwx就是位运算的教科书案例。读、写、执行分别对应 4、2、1三个位组合起来能表示 0 到 7 的所有权限。chmod 755里的 7 就是4|2|15 就是4|1。自己写系统时也可以照搬这个思路。假设一个用户有查看、编辑、删除、分享四种权限用 4 个位表示#define PERM_VIEW (1 0) // 0001 #define PERM_EDIT (1 1) // 0010 #define PERM_DELETE (1 2) // 0100 #define PERM_SHARE (1 3) // 1000 int perm PERM_VIEW | PERM_EDIT; // 授予查看和编辑 // 判断是否有删除权限 if (perm PERM_DELETE) { ... } // 追加分享权限 perm | PERM_SHARE; // 撤销编辑权限 perm ~PERM_EDIT;一个int就能装 32 种权限数据库里只存一个字段查询和更新都极快。比建一张权限关联表轻量得多适合权限种类固定、数量不多的场景。4.2 状态压缩把集合塞进一个整数动态规划里有个技巧叫状态压缩 DP本质就是用整数的每一位表示一个元素选没选。比如有 5 个物品10110表示选了第 1、2、4 个从 0 开始数。这样遍历所有子集只需要从 0 循环到(15)-1。for (int mask 0; mask (1 n); mask) { for (int i 0; i n; i) { if (mask (1 i)) { // 第 i 个元素在当前集合中 } } }这个模式在旅行商问题、棋盘覆盖、子集和问题里反复出现。它的价值在于把集合这种抽象结构映射成了整数让状态可以用数组下标直接索引省掉了哈希或树结构的开销。4.3 图像与像素处理通道分离与混合一张 RGB 图片每个像素用 32 位整数存成0xAARRGGBB。想单独取出红色通道就是右移 16 位再与0xFFuint32_t pixel 0xFF3A6B2C; uint8_t red (pixel 16) 0xFF; // 0x6B uint8_t green (pixel 8) 0xFF; // 0x2C uint8_t blue pixel 0xFF; // 0x2C反过来合成一个像素就是移位加或uint32_t p (0xFF 24) | (r 16) | (g 8) | b;这种操作在滤镜、透明度混合、颜色量化里无处不在。用位运算比调用一堆函数快得多因为它是纯算术没有分支预测失败的开销。4.4 哈希与校验异或的天然舞台LRC纵向冗余校验就是异或的经典应用。它把一串数据逐字节异或得到一个校验字节。接收方重新算一遍如果结果和发送方一致就认为数据没出错。uint8_t lrc 0; for (int i 0; i len; i) { lrc ^ data[i]; }异或之所以适合做校验是因为它满足异或两次还原的性质而且计算极快不需要乘除。CRC 校验虽然更复杂但底层也是移位和异或的组合。理解异或是理解一大类校验算法的前提。4.5 位图与布隆过滤器用位代替字节如果要记录 1 亿个用户是否在线用bool数组要 100MB用位图bitmap只要 12.5MB。每个用户对应一个位置 1 表示在线置 0 表示离线。#define SET_BIT(map, i) ((map)[(i) 3] | (1 ((i) 7))) #define CLEAR_BIT(map, i) ((map)[(i) 3] ~(1 ((i) 7))) #define GET_BIT(map, i) (((map)[(i) 3] ((i) 7)) 1)i 3算出在第几个字节i 7算出在字节内的第几位。布隆过滤器就是在这个基础上加了多个哈希函数用极小的空间判断某元素一定不存在或可能存在。这类结构在缓存穿透防护、去重、爬虫 URL 判重里非常常见。4.6 性能优化用移位代替乘除左移一位等于乘 2右移一位等于整除 2对无符号数而言。n 3就是n * 8n 1就是n / 2。在早期编译器不智能的年代这是手动优化的标配。不过要提醒一句现代编译器基本都会自动做这个优化你写n * 8它照样生成移位指令。所以现在用移位更多是为了语义清晰比如处理位掩码时而不是为了性能。如果为了显得快而把n * 8硬写成n 3反而可能降低可读性。这个取舍要心里有数。5. 那些年踩过的坑位运算的边界与陷阱5.1 运算符优先级比低这是新手最容易翻车的地方。if (a 1 1)不会按你想象的方式执行因为的优先级高于它实际等价于a (1 1)也就是a 1结果恰好对但纯属巧合。如果写成if (a 2 2)就变成a (2 2)a 1逻辑完全错了。记住一条铁律位运算和比较运算混用时永远给位运算加括号。if ((a 2) 2)才是正确写法。5.2 有符号数的右移算术移位 vs 逻辑移位对无符号数右移左边补 0这叫逻辑移位。对有符号负数右移不同语言和平台行为可能不同有的补 0有的补符号位算术移位。C 语言里这是实现定义的行为不可移植。int a -8; int b a 1; // 可能是 -4也可能是很大的正数所以处理位运算时尽量用无符号类型unsigned int、uint8_t等。如果确实要处理有符号数先转成无符号再操作最后转回来避免依赖未定义行为。5.3 移位数超过类型宽度1 32在 32 位int上是未定义行为因为移位量必须小于类型宽度。想要第 32 位得用1ULL 3264 位无符号。这个坑在写位图、处理大掩码时特别容易踩而且编译器不一定报错运行时结果可能莫名其妙。uint64_t mask 1ULL 40; // 正确 uint32_t bad 1 40; // 错误移位量超宽5.4 异或交换的隐藏代价前面那个不用临时变量交换两个数的技巧很酷但实际工程里不建议用。原因有三一是可读性差二是当a和b指向同一块内存时比如swap(arr[i], arr[i])异或会把值清零三是现代编译器对普通swap的优化已经足够好异或版本未必更快。void swap(int *a, int *b) { *a ^ *b; *b ^ *a; *a ^ *b; } // 如果 a b调用后 *a 变成 0这个坑在面试里常被拿来考但真实代码里请老老实实用临时变量。5.5 位运算与浮点数别混用位运算只对整数类型有意义。对float或double做、|、^会直接编译报错。如果确实想操作浮点数的二进制表示得先用memcpy或联合体union把它转成整数类型操作完再转回来。这种需求出现在快速开平方、浮点比较技巧等底层优化里日常开发基本用不到。6. 从看得懂到写得出我的练习建议位运算这东西看别人写觉得简单自己上手就卡壳根本原因是练得太少。我的建议是分三步走。第一步把本文里的真值表和几个基础例子亲手在纸上推一遍不要只用眼睛看。尤其是0xD9拆成二进制、再拆成高低 4 位这个过程手推三次就形成肌肉记忆了。第二步找几道经典题练手判断奇偶、统计二进制中 1 的个数Brian Kernighan 算法n n - 1、找出落单的数、判断是否是 2 的幂n 0 (n (n-1)) 0。这几道题覆盖了与、异或、移位的核心用法。第三步在自己的项目里主动找场景用。比如配置项用位掩码存、状态用位图管、简单校验用异或。用一次比看十遍记得牢。最后分享一个我自己的习惯写位运算代码时在注释里把二进制形式标出来。比如perm 0x0F // 取低4位。因为位运算的可读性天然就差多写一行注释三个月后的你会感谢现在的自己。二进制除法、十六进制对照、小数表示这些基础平时多翻翻、多算算慢慢就内化成直觉了。位运算不是玄学它只是计算机最朴素的语言你越熟悉它就越能听懂机器在说什么。
返回列表