引言:为什么是“位”?
举个例子:你需要记录 1000 万个用户的在线状态(在线为1,离线为0)。如果用我们熟悉的 boolean 数组(在Java中,每个 boolean 约占1字节),那么需要的内存大约是:
10,000,000 * 1 byte ≈ 10 MB
但如果我们换一种思路,用一个比特(bit)来存储一个状态呢?
10,000,000 * 1 bit ≈ 1.25 MB
看到了吧?仅仅是换了一种存储方式,就大大节省了内存空间!这就是位数据结构的魅力。通过直接操作二进制位,将空间效率提升到了极致。
在高性能计算、海量数据处理、数据库索引等场景下,这种效率提升往往是至关重要的。而要掌握位数据结构,我们必须先熟悉它们的基础——位运算。
一、 位运算
就是直接对整数在内存中的二进制位进行操作。它通常由CPU直接提供指令支持,因此速度极快,是许多底层优化的不二选择。
我们先来学习一下六种基本位运算符(以 Java/C 语法为例):
| 运算符 | 名称 | 描述 | 示例 (二进制) |
|---|---|---|---|
& |
与 | 两位都为1时,结果才为1 | 1010 & 1100 = 1000 |
| |
或 | 两位有一位为1时,结果就为1 | 1010 | 1100 = 1110 |
^ |
异或 | 两位相同为0,相异为1 | 1010 ^ 1100 = 0110 |
~ |
取反 | 对每一位取反,0变1,1变0 | ~1010 = 0101 (视位宽而定) |
<< |
左移 | 各二进位全部左移若干位,高位丢弃,低位补0 | 1010 << 2 = 101000 |
>> |
右移 | 各二进位全部右移若干位,对无符号数,高位补0;对有符号数,各编译器处理方法不一样 | 1010 >> 2 = 0010 (逻辑右移) |
注意:在Java中,有明确的
>>>运算符表示无符号右移(高位补0),而>>是带符号右移(高位补符号位)。
光知道定义可不行,下面这些“骚操作”才是位运算的精髓所在。
1. 判断奇偶性
技巧:使用 & 1
- 偶数的最低位是0,奇数的最低位是1。
x & 1的结果如果为0,则x为偶数;如果为1,则x为奇数。
public static boolean isEven(int x) {
// 与 1 进行与运算,只看最后一位
return (x & 1) == 0;
}
// 测试
System.out.println(isEven(4)); // true
System.out.println(isEven(7)); // false
这比 x % 2 == 0 在底层效率更高。
2. 交换两个数
技巧:使用 ^ 异或运算
异或运算有一个神奇的性质:a ^ b ^ b = a。我们可以利用这个性质,不需要临时变量就能交换两个数。
int a = 5; // 二进制 0101
int b = 3; // 二进制 0011
a = a ^ b; // a 现在为 0101 ^ 0011 = 0110 (6)
b = a ^ b; // b 现在为 0110 ^ 0011 = 0101 (5) -> 原来的a
a = a ^ b; // a 现在为 0110 ^ 0101 = 0011 (3) -> 原来的b
System.out.println("a=" + a + ", b=" + b); // a=3, b=5
原理流程图:
3. 判断是否为2的幂次方
技巧:使用 & (x - 1)
- 一个数如果是2的幂次方,它的二进制表示中一定有且仅有一个1。例如:
1 (1),2 (10),4 (100),8 (1000)。 x - 1会将最低位的1变成0,后面的所有0变成1。例如8 (1000) - 1 = 7 (0111)。- 那么
x & (x - 1)的结果就会是0。如果结果是0,则x是2的幂次方(x > 0)。
public static boolean isPowerOfTwo(int x) {
// 注意要排除x=0的情况,因为0 & -1 = 0,但0不是2的幂
return x > 0 && (x & (x - 1)) == 0;
}
// 测试
System.out.println(isPowerOfTwo(8)); // true
System.out.println(isPowerOfTwo(10)); // false
System.out.println(isPowerOfTwo(0)); // false
4. 取最低位的1
技巧:使用 x & -x
-x在计算机中是以补码形式存储的,它等于~x + 1。- 这个操作会将
x最低位的1保留下来,其他位全部置0。这个技巧是很多复杂位操作的基础。
int x = 12; // 二进制 1100
int lowBit = x & -x; // 结果是 4 (二进制 0100)
System.out.println(lowBit); // 4
5. 移除最低位的1
技巧:使用 x & (x - 1)
- 这个操作我们在判断2的幂时用过,它可以直接将数字二进制表示中最低位的1变成0。
int x = 12; // 二进制 1100
x = x & (x - 1); // x 现在为 8 (二进制 1000),最低位的1被移除了
System.out.println(x); // 8
这个操作常用于统计一个数的二进制表示中有多少个1。
小结一下:
位运算虽然看起来简单,但组合起来能解决许多看似复杂的问题。熟练掌握它们是理解后续位数据结构的关键。
二、 位图(Bitmap)
掌握了位运算,我们就可以开始构建第一个位数据结构——位图(Bitmap),也叫位集合(Bitset)。
2.1 什么是位图?
核心思想:用一个比特位(bit)来标记某个元素对应的值或者状态。由于每个比特位只有0和1两种状态,所以它非常适合用来表示“存在”或“不存在”这种二值信息。
我们可以把位图想象成一个超长的、只有0和1的数组。比如,我们要表示数字 {2, 5, 9},就可以用一个长度为10的位图,在第2、5、9位设置为1,其他位为0:
索引: 0 1 2 3 4 5 6 7 8 9
值: 0 0 1 0 0 1 0 0 0 1
2.2 如何实现一个位图?
在编程语言中,我们通常不能直接申请一个bit数组,最小单位是字节(byte)。所以,我们需要用一个基本类型的数组(如 int[] 或 long[])来“模拟”一个bit数组。
实现思路:
- 确定容量:假设我们要存储
n个元素的状态。 - 选择底层数组:一个
int是32位。那么存储n个bit,需要(n + 31) / 32个int。 - 定位:给定一个数字
x,我们需要找到它在数组中的哪个int的哪一位上。- 数组下标:
x / 32(在代码中我们用位运算x >> 5,因为32 = 2^5) - 位偏移:
x % 32(在代码中我们用x & 31,因为31的二进制是11111)
- 数组下标:
核心代码实现:
public class MyBitSet {
// 使用 int 数组存储位信息
private int[] words;
/**
* 构造一个BitSet,至少能表示 [0, nBits-1] 的范围
* @param nBits 位的数量
*/
public MyBitSet(int nBits) {
// (nBits + 31) / 32 是为了向上取整,确保位数足够
this.words = new int[(nBits + 31) >> 5];
}
/**
* 将第 bitIndex 位设置为1
* @param bitIndex 位的索引
*/
public void set(int bitIndex) {
// 定位到 words 数组的哪个下标,以及在该下标的哪个位
int wordIndex = bitIndex >> 5; // 相当于 bitIndex / 32
int bitOffset = bitIndex & 31; // 相当于 bitIndex % 32
// 使用位或操作 | 将指定位置1,其他位不变
// 1 << bitOffset 创建了一个只有目标位是1的掩码
words[wordIndex] |= (1 << bitOffset)

最低0.47元/天 解锁文章

被折叠的 条评论
为什么被折叠?



