本文记录位运算的常见应用。
在介绍位运算的应用之前,应当首先对二进制表示有一定的了解,这方面知识属于计算机原理的范畴。我们知道,按位与、按位或、按位异或、按位取反就是对整数的二进制位逐个进行逻辑与、逻辑或、逻辑异或、逻辑取反来得到的。所以我们首先要考虑的是,整数是怎么通过二进制表示的?
我们知道常用的二进制表示整数的方法有原码、反码、补码三种,这三种方法在正整数上的表示是相同的,就是按数学上的二进制数位来直接表示,区别只在于零和负整数上。
在英文中,原码称为sign/magnitude,而反码称为ones' complement,有意思的是,ones' complement 的中文直译是“一的补码”,而国内所说的补码用英文称为two' complement,中文直译是“二的补码”。
三种方法的区别在于,已知某正整数的二进制表示,得到其相反数的二进制表示的方法是不同的:
- 原码:仅取反符号位(最高位)
- 反码:取反所有位
- 补码:取反所有位再加一
一个16位表示5和-5的例子如下:
number | two's complement | ones' complement | sign/magnitude |
5 | 0000 0000 0000 0101 | 0000 0000 0000 0101 | 0000 0000 0000 0101 |
-5 | 1111 1111 1111 1011 | 1111 1111 1111 1010 | 1000 0000 0000 0101 |
既然负数有这三种表示法,那么对于一个负数进行位操作的结果就与平台有关了。在C语言标准里,规定了必须使用上面三种方法之一,但并没有强制要求使用哪一种方法,幸运的是,现在绝大多数机器所使用的都是补码(two' complement)。JAVA在各类数据类型下的二进制表示则遵循下面的规则:
byte | 8-bit | two's complement |
short | 16-bit | two's complement |
int | 32-bit | two's complement |
long | 64-bit | two's complement |
float | 32-bit | IEEE 754 |
double | 64-bit | IEEE 754 |
boolean | 由VM决定 | 由VM决定 |
char | 16-bit | Unicode character |
还要注意到:整数的二进制表示与整数在内存中储存的实际数据在顺序上并不总是一致,这主要是由于不同机器下字节序的不同。主要有两种方案:大端方案(Big-Endian)和小端方案(Little-Endian)。
所谓大端方案,即高位字节储存在内存低位地址上,低位字节储存在内存高位地址上,直观地说,即内存中储存的字节序与二进制表示的顺序一致。如 0x12345678采用大端方案,在内存中按地址从低到高储存为[12 34 56 78]。而小端方案则正好相反,若采用小端方案,在内存中则储存为[78 56 34 12]。
注意,无论是大端方案还是小端方案,它们规定的只是字节顺序,而不是位顺序。假设使用补码,整数-5在两种方案下的储存如下:
two's complement | 1111 1111 1111 1011 |
Big-Endian | 1111 1111 1111 1011 |
Little-Endian | 1111 1011 1111 1111 |
大小端方案的选用是由机器决定的,目前绝大多数的个人电脑都使用小端方案。在进行位运算时,由于运算结果也是用同一方案储存的,所以无论是哪种方案,都不会影响位运算的结果,程序员依旧只需从二进制表示(补码)的角度去理解,无需关注内存中的实际细节。这个的前提是程序代码不涉及内存上字节层次的操作,例如在汇编语言中,可能需要取两个字节的某些位进行拼接,这个时候字节序就无法忽略了。
至此,在对整数在内存中的实际情况有一定了解后,我们便可以应用位运算了,并知晓这些位运算在负数上操作时所可能产生的结果。下面介绍几个常用的操作:
- 特定位变成1
- 利用位或,当A位或B时,B上为1的位将导致A上相同的位变成1
- 例:a | 0x08 将 a 上第4位变成1,其余位不变
- 例:a | (1 << n) 将 a 上第n+1位变成1
- 特定位变成0
- 利用位与,当A位与B时,B上为0的位将导致A上相同的位变成0
- 例:a & 0x08 将 a 上除第4位外均变成0
- 例:a & 0x00 可清除 a 上所有的位
- 例:a & ~0x08 将清除 a 上第4位
- 例:a & ~(1 << n) 将 a 上第n+1位变成0
- 特定位反转
- 利用异或,当A异或B时,B上为1的位将导致A上相同的位反转
- 例:a ^ 0x08 将 a 上第4位反转
- 例:a ^ (1 << n) 将 a 上第n+1位反转
- 特定位检测
- 利用位与,当A位与B时,若B上所有为1的位在A上相同位置下均为0,那么位与的结果是0。否则,B上存在为1的位在A上相同位置下也为1,此时位与的结果为非0值,且结果在这些位上为1,其余位为0。
- 例:a & 0x08 == 0x08 为真当且仅当 a 上第4位为1
- 例:a & (0x08 | 0x04) == (0x08 | 0x04) 为真当且仅当 a 上第3位和第4位均为1
- 例:a & (0x08 | 0x04) != 0 为真当且仅当 a 上第3位和第4位至少有一个为1
使用上面这些操作,便可以自由对某个字节上的任意位进行操作了,这引出了位运算的一个最常见的应用:位开关变量:即使用一个字节上的8个位来表示8个只有两种状态的变量,通过上述位运算来对其中每个位进行检测和修改,节省了空间。
接下来介绍一个有趣的应用,利用异或找出出现奇数次的数。
给定一个整数数组,其中有唯一元素出现了奇数次,其余元素都出现了偶数次,试找出那个出现了奇数次的整数。由于一个数与自身的异或为0,且异或具有交换律,所以对该数组中所有元素进行异或,重复偶数次的元素都会被消除(变为0),异或的结果即是出现奇数次的那个元素。时间复杂度为O(n),空间复杂度为O(1)。这个例子说明异或可以用于去重。