文章

位运算实用详解教程

介绍位运算的基础知识与实用技巧

位运算实用详解教程

本教程部分参考了 Matrix67 的《位运算简介及实用技巧》,运算符采用 C 语言系的表示方法,常用的许多高级语言都使用相同的运算符表示,如 Python 等。

位运算基础

位运算简介

位运算(Bitwise operation)是对整数的二进制位直接进行的运算。现代广泛使用的计算机中,数据、代码都是以二进制的形式储存的,二进制运算在计算机硬件上是 first class 的,其花费的指令周期比我们在高级语言中写下的加减乘除要少得多,且使用计算资源少。当然,现代的编译器已经能做到将加减乘除优化成高效的位运算指令,所以学位运算的目的并不在于将你所写的所有表达式都改成位运算,这样不仅可能妨碍编译器优化,还破坏了代码的可读性。学习位运算的目的是在需要直接使用位运算的情景下,能够自如地看懂和应用位运算直接操纵二进制位,比如单片机程序中直接写寄存器、特定场合下的特殊数据结构如编解码、某些使用精妙的位运算达成高效运算的场合,亦或者是在逆向工程中查看汇编代码,等等。在由二进制建构起来的计算机体系上,位运算是不可跳过的内容。

位运算使用

位运算概览如下表所示:

描述常用符号运算规则
逐位与 ANDa & b两个位同时为1时结果为1,否则为0
逐位或 ORa | b两个位同时为0时结果为0,否则为1
逐位取反 NOT~a0变成1,1变成0
逐位异或 XORa ^ b两个位相同结果为0,相异为1
逐位左移a « ba向左移动b位,左边舍弃,右边补0
逐位右移a » ba向右移动b位,右边舍弃,左边补0

逐位与(AND)

对应逻辑上的与,逐位与对操作数的每一位进行与操作,其真值表如下:

ABA & B
000
010
100
111

可以看到,只有两位都为 1,结果才为 1。这里我们可以得到一个结论,如果我们用 1 去与一个未知数,那么结果取决于未知数;如果我们用 0 去与一个未知数,那么结果肯定为 0。

举个实际应用的例子,110101 & 000111 == 000101。我们可以这样理解,用 b 将 a 的第 4~6 位(从低位开始数)的比特位清零,这是逐位与操作的第一个主要用途,即用掩码(mask)将某些位置 0。或者我们也可以这样理解,b 将 a 的第 1 到第 3 位取出,这就是逐位与操作的第二个主要用途,即用掩码将特定位取出用于判断

经典用法就是用来判断奇偶性,a & 1 为真则为奇数,否则为偶数。当然再次强调没有必要在这种操作上使用位运算,换成 if (a % 2) 或者 if (isOdd(a)) 可读性会好很多,编译器会优化成位运算的。

逐位或(OR)

对应逻辑或,逐位或对操作数的每一位进行或操作,其真值表如下:

ABA | B
000
011
101
111

可以看到,只要有一位为 1,结果就为 1。我们同样可以得到一个结论,如果用 1 去或一个未知数,那么结果肯定为 1;如果用 0 去或一个未知数,那么结果就取决于未知数。

举个例子,100101 | 000111 == 100111。可以这样理解,用 b 将 a 的第 1~3 位的比特位置 1,而其它位保持不变。当然,也可以理解成 a 将 b 的第 1、3、6 位置 1。这就是逐位或的一个主要用途,用掩码将某些位置 1

一般来说,将某一位置 0 是使用逐位与,但需要将最低位置 0 时也可以用逐位或,即 (a | 1) - 1,这样无论最低位是 0 还是 1 结果都是 0,从而不用构造出 111110 的掩码。

逐位取反(NOT)

逐位取反操作是很好理解的,其真值表为:

A~A
01
10

值得注意的是,到目前为止我们讨论的都是无符号整数,即二进制 101101 直接转成十进制 45 。如果是有符号整数,取反时依然是逐位取反,但是转化成十进制时则需要使用补码规则解读。

逐位异或(XOR)

逐位异或(Exclusive OR)是一个比较特别的位运算,其真值表为:

ABA ^ B
000
011
101
110

当a和b不同时结果为 1,相同为 0。对于异或,我们可以有多种理解方式,异或的定义是一种,另一种是看成未知数异或 0 则不变,异或 1 则取反。这些理解方式都有具体的应用,比如用掩码将特定位取反,或者一个数异或自己等于 0。

两次异或同一个数结果不变,也就是说异或运算的逆运算是它本身。这可以用于简单的加密,比如密钥 b 是 100110 ,用 b 加密数据 a 110011 结果为 110011 ^ 100110 == 010101 ,解密就是再次用密钥 b 异或密文 010101 ^ 100110 == 110011

逐位异或的另一个经典用法就是不引入第三个变量交换两个变量:

1
2
3
4
5
{
    x = x ^ y;
    y = x ^ y;
    x = x ^ y;
}

如果不是很理解的话,可以先看看下面的这段同样不引入第三个变量的交换代码:

1
2
3
4
5
{
    x = x + y;
    y = x - y;
    x = x - y;
}

实际上,对于两个互为逆运算的运算符都可以写成以上形式,执行第一行后 x 变成了两数的和,于是第二行将两数之和减去 y 得到 x 原本的值赋给了 y,第三行将两数之和减去 x 原本的值(放在变量 y 里)得到 y 原本的值放在变量 x 里,就完成了两变量的交换。

再次提醒,这种写法只当学习用,在现在的机器上与普通的三变量写法是一样快的,甚至GCC有一个专门的处理函数将这些奇技淫巧变成三变量的写法。第一没有速度上的区别,第二降低可读性,第三这种方法只适用于整数。

逐位左移&逐位右移

逐位左移逐位右移是很好理解的,000111 << 2 的结果就是 011100000111 >> 2 的结果是 000001,很明显地,由于是对二进制位的位移,那么左移n位相当于乘以 2^n,右移n位相当于除以 2^n,溢出、小数的部分丢弃。

位移还有逻辑位移算术位移之分,上面所说的就是逻辑位移。对于左移,逻辑左移和算数左移是相同的,都是在后面补 0。对于算数右移,有符号整数在左边补的是符号位,也就是说,正数的算数右移在左边补 0,负数的算数右移在左边补 1,这样位移的符号是不变的。

有些编程语言会专门区分出逻辑和算数右移,比如 Java 中 >> 表示有符号右移(算数),>>> 表示无符号右移(逻辑)。在 C/C++ 中的位移行为是实现定义的(C++20有了良好定义),但是大部分编译器的实现都是算术位移,如果想要逻辑右移,那么先转型成无符号整数就可以了。

位移可以用来产生掩码,比如我想要前 8 位的掩码 0000000011111111,那么就写 (1 << 8) - 1

位运算的常用应用

注:运算符优先级参考此处

功能示例位运算
去掉最后一位110011->11001x » 1
末尾加一个0110011->1100110x « 1
末尾加一个1110011->1100111(x « 1) + 1
末位置0110011->110010(x | 1) - 1
末位置1110010->110011x | 1
末位取反101101->101100x ^ 1
末尾起第k位置0101101->101001,k=3x & ~(1 « k - 1)
末尾起第k位置1101001->101101,k=3x | (1 « k - 1)
末尾起第k位取反101001->101101,k=3x ^ (1 « k - 1)
取出末尾三位1100101->101x & 7
取出末尾n位1101101->01101,n=5x & ((1 « k) - 1)
取出末尾起第k位1101101->1,k=4(x » k - 1) & 1
末尾起n位全置1101001->101111,n=4x | ((1 « k) - 1)
末尾起n位取反101001->100110,n=4x ^ ((1 « k) - 1)
把末尾起始连续的1置0100101111->100100000x & x + 1
把末尾起始连续的0置111011000->11011111x | x - 1
把末尾起第一个0置1100101111->100111111x | x + 1
取末尾起所有连续的1100101111->1111x & ~(x + 1)或(x ^ x + 1) » 1
去掉末尾起第一个1的左边100101000->1000x & (x ^ x - 1)

位运算进阶

位运算操作的集大成者,推荐阅读《Hacker’s Delight》。

计算二进制中1的奇偶数

计算一个32位整数的二进制中1有奇数个还是偶数个,若偶数个1则输出0,若奇数个则输出1。例如11010011有5个1,则输出1。

简单实现使用循环计数:

1
2
3
4
5
6
7
8
9
10
int count1parity(uint32_t x)
{
    int count = 0;
    for (int i = 0; i < 32; i++)
    {
        count += x & 1;
        x >>= 1;
    }
    return count & 1;
}

复杂度为O(n),即进行32次计算。

但实际上可以这样:

1
2
3
4
5
6
7
8
9
int count1parity(uint32_t x)
{
    x = x ^ (x >> 1);
    x = x ^ (x >> 2);
    x = x ^ (x >> 4);
    x = x ^ (x >> 8);
    x = x ^ (x >> 16);
    return x & 1;
}

这是什么原理呢?我们以11010011为例,第一次异或的结果表示第i位与第i+1位上1的奇偶数,如图所示,灰色的数字是我们不关心的,因为如此操作将整个数字切成4份,我们只关心1、3、5、7位上的结果。

count1parity_1

对第一次异或结果再次进行移位异或,这次右移2位,由于先前的运算结果中的1、3、5、7位保存了本位与前一位的奇偶数,因此第二次运算相当于把结果合并一半。

count1parity_2

同理,第三次运算完成了8位数字奇偶性计算的结果合并。

count1parity_3

从计算的过程可以看出,这其实是分而治之的思想,复杂度为O(logn),在32位下只需计算5次。

计算二进制中1的个数

这次我们不止想知道一个32位整数中的1的奇偶数,还想知道1的具体数量,我们同样可以运用分而治之的思想。

对于2位二进制,1的个数与结果的对应关系是:

1
2
3
4
00->00
01->01
10->01
11->10

其实就相当于第1位和第2位的数字相加,因此我们可以用:

1
(x & 1) + (x >> 1 & 1)

那么拓展到32位整数,第一次计算拓展为:

1
x = (x & 0x55555555) + (x >> 1 & 0x55555555);

同理,完整实现如下:

1
2
3
4
5
6
7
8
9
int bitcount(uint32_t x)
{
    x = (x & 0x55555555) + (x >> 1 & 0x55555555);
    x = (x & 0x33333333) + (x >> 2 & 0x33333333);
    x = (x & 0x0F0F0F0F) + (x >> 4 & 0x0F0F0F0F);
    x = (x & 0x00FF00FF) + (x >> 8 & 0x00FF00FF);
    x = (x & 0x0000FFFF) + (x >> 16 & 0x0000FFFF);
    return x;
}

注意到,第4步数据已经压缩到了1个完整的字节,而第5步压缩到了2个字节,因此从第4步开始可以考虑将4个字节直接相加:

1
x = (x & 0xFF) + (x >> 8 & 0xFF) + (x >> 16 & 0xFF) + (x >> 24 & 0xFF);

可以通过先左移再右移的方式等效0xFF的截断操作:

1
x = ((x << 24) >> 24) + ((x << 16) >> 24) + ((x << 8) >> 24) + ((x << 0) >> 24);

等效于:

1
2
x = (x << 24) + (x << 16) + (x << 8) + (x << 0);
x >>= 24;

等效于:

1
2
// x = (x * 2^24) + (x * 2^16) + (x * 2^8) + (x * 2^0);
x = (x * 0x01010101) >> 24;

最终实现如下:

1
2
3
4
5
6
7
8
int bitcount(uint32_t x)
{
    x = (x & 0x55555555) + (x >> 1 & 0x55555555);
    x = (x & 0x33333333) + (x >> 2 & 0x33333333);
    x = (x & 0x0F0F0F0F) + (x >> 4 & 0x0F0F0F0F);
    x = (x * 0x01010101) >> 24;
    return x;
}

这其实就是著名的SWAR算法,除此之外这个算法还有各种改进版,这个只是最基础版本。另外在某些CPU上是支持popcnt指令的,只需一条指令即可得到结果。

计算二进制中前导0个数

同样可以使用分而治之的方法:

1
2
3
4
5
6
7
8
9
10
11
int count_leading_zero(uint32_t x)
{
    if (x == 0) return 32;
    int n = 0;
    if ((x >> 16) == 0) { n += 16; x <<= 16; }
    if ((x >> 24) == 0) { n += 8; x <<= 8; }
    if ((x >> 28) == 0) { n += 4; x <<= 4; }
    if ((x >> 30) == 0) { n += 2; x <<= 2; }
    if ((x >> 31) == 0) { n += 1; }
    return n;
}

最后一步可以简化为:

1
2
3
4
5
6
7
8
9
10
11
int count_leading_zero(uint32_t x)
{
    if (x == 0) return 32;
    int n = 1; // 先假设第2位为0
    if ((x >> 16) == 0) { n += 16; x <<= 16; }
    if ((x >> 24) == 0) { n += 8; x <<= 8; }
    if ((x >> 28) == 0) { n += 4; x <<= 4; }
    if ((x >> 30) == 0) { n += 2; x <<= 2; }
    n -= x >> 31;
    return n;
}

计算二进制逆序

此操作可以用来转换大小端。方法依旧分而治之,首先交换相邻两个bit,然后以交换过的数作为整体,继续交换相邻的数,直到完成所有交换。

1
2
3
4
5
6
7
8
9
int reverse_bit(uint32_t x)
{
    x = (x & 0x55555555) << 1 | (x & 0xAAAAAAAA) >> 1;
    x = (x & 0x33333333) << 2 | (x & 0xCCCCCCCC) >> 2;
    x = (x & 0x0F0F0F0F) << 4 | (x & 0xF0F0F0F0) >> 4;
    x = (x & 0x00FF00FF) << 8 | (x & 0xFF00FF00) >> 8;
    x = (x & 0x0000FFFF) << 16 | (x & 0xFFFF0000) >> 16;
    return x;
}
本文由作者按照 CC BY-NC-SA 4.0 进行授权