位图思想详解:用一个小小的比特征服整个世界
一、什么是位图?
位图是一种超级节省空间的数据结构,他利用二进制位(0/1)来表示某个元素是否存在或某种状态是否为真。想象一下,用一个小小的比特位就能记录一个信息,这简直是数据存储界的"极简主义"!
二、位图的形象理解
想象你是一个小学班主任,班上有40个学生。周一早上,你需要记录谁到校了,谁没到。
- 传统方法:创建一个布尔数组
boolean[] attended = new boolean[40]
,这要占用40个布尔值的内存。 - 位图方法:只需要申请一个64位的
long
类型变量,每一个对应一个学生的出勤率。这就像是一个微型的"打卡机",每个学生占用一个小格子,来了就打个勾(1),没来就空着(0)。
三、位图的 Java 实现
public class Bitmap {
private long[] bits;
// 假设我们要表示n个元素
public Bitmap(int n) {
// 每个long有64位,所以需要 (n+63)/64 个long
this.bits = new long[(n + 63) / 64];
}
// 设置第i位为1(表示存在)
public void set(int i) {
bits[i / 64] |= 1L << (i % 64);
}
// 设置第i位为0(表示不存在)
public void clear(int i) {
bits[i / 64] &= ~(1L << (i % 64));
}
// 检查第i位是否为1
public boolean get(int i) {
return (bits[i / 64] & (1L << (i % 64))) != 0;
}
}
🏗️ 构造函数:为派对准备场地
public Bitmap(int n) {
// 每个long有64位,所以需要 (n+63)/64 个long
this.bits = new long[(n + 63) / 64];
}
想象你要举办一个有n位客人的派对,但每张桌子只能坐64人。你需要多少张桌子?当然是 ⌈n÷64⌉
(向上取整)。这就是 (n + 63) / 64
的由来!
这里用了一个小技巧:整数除法会自动向下取整,所以 (n + 63) / 64
等价于 ⌈n÷64⌉
。就像买桌子,9人需要2张桌子,不能买1.5张对吧?
n = 150 的情况
需要的long数组大小:(150 + 63) / 64 = 213 / 64 = 3.328125 ≈ 3
创建数组:bits = new long[3], 64 * 3 = 192
bits[0]:表示第0-63位
bits[1]:表示第64-127位
bits[2]:表示第128-191位
🔨 set() 方法:点亮你的专属小灯泡
public void set(int i) {
bits[i / 64] |= 1L << (i % 64);
}
这个方法就像一个漆黑的体育馆里,你要点亮 i 座位的灯。问题:这个座位在哪个区域?在哪个位置?
i / 64
:确定在哪个long
中(哪个区域)i % 64
:确定哪个long
中哪个bit
位上(哪个位置)1L << (i % 64)
:创建一个只有那个位置是1的掩码(准备一个只照亮那个座位的聚光灯)|=
:使用OR操作将那个位置修改成1(打开闪光灯)
假设i = 73,我们要点亮第73个灯
i / 64 = 73 / 64 = 1(在第1个long里)
i % 64 = 73 % 64 = 9(在这个long的第9位)
1L << 9 = 000...000100000000(二进制,只有第9位是1)
bits[1] |= 000...000100000000
🧹 clear() 方法:熄灭特定的灯
public void clear(int i) {
bits[i / 64] &= ~(1L << (i % 64));
}
这个方法就像是派对结束后,你要关掉特定的一盏灯。操作流程是:
i / 64
:确定在哪个long
(哪个区域)i % 64
:确定哪个long
中哪个bit
位上(区域内的灯号)1L << (i % 64)
:创建一个只有那个位置是1的掩码~(1L << (i % 64))
:反转掩码,现在除了那个位置都是1(准备一个"除了那盏灯以外都亮着"的状态)&=
:使用AND操作将那一位设置为0,其他位保持不变(只关那一盏灯)
假设i = 73,我们要熄灭第73个灯
i / 64 = 73 / 64 = 1(在第1个long里)
i % 64 = 73 % 64 = 9(在这个long的第9位)
1L << 9 = 000...000100000000(二进制,只有第9位是1)
~(1L << 9) = 111...111011111111(二进制,只有第9位是0)
bits[1] &= 111...111011111111
🔍 get() 方法:查看灯是否亮着
public boolean get(int i) {
return (bits[i / 64] & (1L << (i % 64))) != 0;
}
这个方法就像是派对组织者在检查:第i号座位有人坐着吗?
i / 64
:确定在哪个long
(哪个区域)i % 64
:确定在这个long
的哪一位(区域内的座位号)1L << (i % 64)
:创建一个只有那个位置是1的掩码(准备一个只照亮那个座位的手电筒)bits[i / 64] & (1L << (i % 64))
:使用AND操作检查那一位是否为1(用手电筒照,看看有没有人)!= 0
:如果结果不是0,说明那一位是1(座位上有人)
假设我想要检查第73个灯是否被设置
i / 64 = 73 / 64 = 1(在第1个long里)
i % 64 = 73 % 64 = 9(在这个long的第9位)
1L << 9 = 000...000100000000(二进制,只有第9位是1)
假设 bits[1] = 000...000100010000(第9位和第4位是1)
bits[1] & (1L << 9) = 000...000100010000 & 000...000100000000 = 000...000100000000 ≠ 0
所以返回 true
四、位图的算法原理剖析
位图的核心原理其实也很简单:利用二进制位的0和1来记录信息
- 索引映射:每一个需要记录的元素映射到位图中的一个特定的位置
- 状态表示:使用0表示不存在/否,1表示存在/是
- 位操作:通过位运算可以高效的设置和获取状态
位图的数学原理可以表示为
- 元素 i 映射到数组索引
i / 64
(假设用long数组) - 在该数组元素中的位偏移量为
i % 64
- 设置该位:
array[i / 64] |= (1L << (i % 64))
- 清除该位:
array[i / 64] &= ~(1L << (i % 64))
- 检查该位:
(array[i / 64] & (1L << (i % 64))) != 0
五、实际应用案例:网站用户活跃度统计
假设你在追踪一个拥有1000万用户的网站,需要记录每个用户在过去30天是否登录过。
- 传统方法:使用布尔数组,需要约300MB内存。
- 位图方法:只需约36MB!
public class UserActivityTracker {
private Bitmap[] dailyActivity; // 30天的活跃记录
// 创建3个位图,每个表示一天的用户活跃度
public UserActivityTracker() {
dailyActivity = new Bitmap[30];
for (int i = 0; i < 30; i++) {
// 每一天的用户容量都得达到 1千万
dailyActivity[i] = new Bitmap(1_000_000);
}
}
// 记录用户在某天登录
public void recordLogin(int userId, int dayOffset) {
dailyActivity[dayOffset].set(userId);
}
// 检查用户在30天内是否活跃(至少有一天是在线的)
public boolean isActiveInLast30Days(int userId) {
for (int i = 0; i < 30; i++) {
if (dailyActivity[i].get(userId)) {
return true;
}
}
return false;
}
// 计算某天的用户活跃度
public int countActiveUsersOnDay(int dayOffset) {
int count = 0;
for (int i = 0; i < 1_000_000; i++) {
if (dailyActivity[dayOffset].get(i)) {
count++;
}
}
return count;
}
}
五、真实的应用场景:布隆过滤器的基础
这个位图实现是布隆过滤器(Bloom Filter)的基础,布隆过滤器是一种高效判断元素是否在集合中的概率数据结构。
// 使用位图实现的简单布隆过滤器
public class SimpleBloomFilter {
private Bitmap bitmap;
private int size;
public SimpleBloomFilter(int size) {
this.size = size;
this.bitmap = new Bitmap(size);
}
public void add(String item) {
int hash1 = Math.abs(item.hashCode() % size);
int hash2 = Math.abs((item.hashCode() * 31) % size);
int hash3 = Math.abs((item.hashCode() * 37) % size);
bitmap.set(hash1);
bitmap.set(hash2);
bitmap.set(hash3);
}
public boolean mightContain(String item) {
int hash1 = Math.abs(item.hashCode() % size);
int hash2 = Math.abs((item.hashCode() * 31) % size);
int hash3 = Math.abs((item.hashCode() * 37) % size);
return bitmap.get(hash1) && bitmap.get(hash2) && bitmap.get(hash3);
}
}
这就像是一个高效的俱乐部入场系统:
- 当有人加入俱乐部时,在多个特定位置留下标记
- 当有人要进入时,检查这些位置是否都有标记
- 可能会有误判(误认为某人是会员),但绝不会错过真正的会员
想要了解更多关于布隆过滤器的内容,请点击🔍该链接
六、算法题:判断字符是否唯一(easy)
请点击🔍:该题目链接
代码示例:
class Solution {
public boolean isUnique(String astr) {
// 利用鸽巢原理 来做优化
if (astr.length() > 26) {
return false;
}
int bitMap = 0; // 位图原理:比特位 有 1 表示已经存在 0 表示还未存在
for (int i = 0; i < astr.length(); i++) {
// 判断 bit 位是否已经标志上 1 了
int x = astr.charAt(i) - 'a';
if (((bitMap >> x) & 1) == 1) {
// 表示 bitMap >> x == 0 此时 未标志上
return false;
}
// 把当前字符加⼊到位图中
bitMap |= (1 << x);
}
return true;
}
}
代码解析:
鸽巢原理:派对人数超过座位数,直接拒之门外
首先,小写字母只有26个,如果字符串长度超过26,那肯定有人得站着(重复)。所以代码开头就用了这个优化:
if (astr.length() > 26) {
return false;
}
这就像保安大哥看了一眼派对名单,发现人数超过26,直接说:“别来了,里面没位置了!”
位图:每个字符对应一个比特位,0是空位,1是有人
接下来的重头戏是位图。我们用整数bitMap
的每一位代表一个字母是否出现过。比如:
a
→ 第0位b
→ 第1位- …
z
→ 第25位
假设bitMap
是二进制数000...101
,表示a
和c
已经到场(第0位和第2位是1)。
操作步骤:查座位、占座位
-
查座位:
当字符c
(x=2)到来时,我们想知道第2位是否已被占用。
把bitMap
右移2位,让第2位变成最低位,然后和1做&
运算:(bitMap >> x) & 1 // 结果为1表示座位被占
-
占座位:
如果座位是空的,就用bitMap |= (1 << x)
把对应位设为1。
这相当于给字符c
发了一张VIP卡,标记这个座位已有人。
举个栗子 🌰
假设字符串是"abc"
:
-
处理a(x=0):
bitMap
初始为0
(二进制全0)。- 检查
(0 >> 0) & 1 → 0
,座位空。 - 更新
bitMap = 1
(二进制000...001
)。
-
处理b(x=1):
- 检查
(1 >> 1) & 1 → 0
(1右移1位是0)。 - 更新
bitMap = 1 | (1<<1) = 3
(二进制000...011
)。
- 检查
-
处理c(x=2):
- 检查
(3 >> 2) & 1 → 0
(3是11
,右移2位是0)。 - 更新
bitMap = 3 | (1<<2) = 7
(二进制000...0111
)。
- 检查
派对圆满结束,所有字符都有独立座位,返回true
!
冲突时刻:当字符试图抢占已占座位
如果字符串是"abca"
,处理最后一个a
时:
- x=0,此时
bitMap
是7(二进制000...0111
)。 (7 >> 0) & 1 → 1
,座位已被占!- 保安立刻将其赶走,返回
false
。