前置知识
前置: 位运算和二进制的知识
周末摸鱼时刻…
位图引入
怎么说呢?从哈希表开始引入吧。
我们知道哈希表支持快速查询操作。
有这样一个问题,比如 我要查询[0,1e8]
这个区间某个数是否出现。
显然哈希表可以解决这个问题(没超出内存容量), 但内存开销巨大。
Java中, 1个int占32字节。遍历这个区间上的所有出现过的数, 最坏情况就是(1+1e8) * 32的数量级, 换算成GB。 好的内存也就在16GB的大小范围, 显然哈希表的做法无疑是对内存的沉重打击。
有没有一种专门的数据结构可以减少内存空间也能做到快速查询, 那就是位图(也称位集
)
总结:
-
哈希表
-
优点:
哈希表可以快速存储和查询数据。连续区间上也可以查询某个数是否存在 查询时间是 O ( 1 ) O(1) O(1)。 -
问题:
- 内存开销大:
上述举的例子内存还勉强够用。如果是存储较多的区间或者在更大的范围(如[0, 1e10]
)内查询,直接出错误了。Exception in thread "main" java.lang.OutOfMemoryError: Java heap space...
哈希表利用内存的效率低。存储稀疏数据
- 内存开销大:
位图原理
位图是专门解决连续区间且整数数字是否出现过的数据结构。 它是由一个bit位表示状态, 0表示不存在,1表示存在。它利用位运算实现极高的空间使用率。
本质也是一种映射思想:
比如1个int就可以充当32长度的bit数组,
00000000 00000000 00000000 00000000
好的, 一共32位。
那么这个就适用于[0,31]范围整数的查询。
上面的位序列,从右往左看, 建立数字与bit数组的下标关系
0->bit数组的第一个下标0处
1->bit数组的第二个下标1处
2->bit数组的第三个下标2处。
…
提供的api呢?
比如我要查询15是否出现
boolean contains(int num)
, 那么调用这个接口函数,通过<<移位运算符取得对应位的状态, 若是1返回true, 否则返回false.
实现的接口和大致思想
Bitset(int n)
:初始化位图大小, 它可以支持[0,n-1]
区间上数字操作
void add(int num)
: 把数字num添加进位图中。将其在位图中映射处的位置位标记为1.
void remove(int num)
: 把num从位图中删除。将其在位图中映射处的位置位标记为0.
void reverse(int num)
:反转操作, 若位图存在这个数字那么删除它, 否则就添加进去。
boolean contains(int num)
: 查询num是否在位图中。
BitSet好比Java中set,它支持查询整数数字是否存在, 而不是一种键值对关系。
编程语言中没有bit类型的数组, 因为字节是最小可寻址单位。 但好在我们可以通过其他类型的数组,比如int数组来模拟实现出一个bit数组。
Java中提供了BitSet的集合类, 就是Java内置的位集。
实现及其对数测试技巧
实现一个BitSet类
通过一个int数组实现
每一个int是32个位, 我们要对每个数字进行分区。
比如一个int[] set
,100这个数字应该首先定位在数组的那个下标上, 比如100 / 32 == 3
, 说明100在set数组的3下标区域内, 然后通过移位操作找到它确定的位。
如何通过int数组找到数组位的位置, 然后操作位(添加删除或者)
初始化的细节
比如给一个[0,100]区间的数组区间, 应该开一个
⌈
100
/
32
⌉
\lceil{100/32}\rceil
⌈100/32⌉大小的数组。
意思上set数组大小是n/32向上取整的结果。
可以通过向上取整公式(a+b-1)/b
add
:
add操作是修改num在set对应的位为1.
set[num/32]
是定位对应int区域, 然后通过(1 << (num % 32))移动具体位数
.
若原先是1则保持不变, 若为0则修改为1 。 可以通过|
实现
remove
:
set[num/32] &= ~(1 << (num % 32));
还是先定位, 然后& 0
来将位中的1修改为0。
reverse
:
反转对应位置的位, 1->0 , 0->1 。
根据异或是无进位加法的特点, ^ 1即可。1+1=0, 0+1=1
二进制加法。
return ((set[num/32] >> (num % 32)) & 1) == 1;
contains
:
((set[num/32] >> (num % 32)) & 1) == 1;
判断当前num数字是否存在, 就是找到其在bitset对应的位。
set[num/32]->找到对应区域, 对这个值进行右移num % 32, 然后通过& 1把位取出来, 判断是否为1。
leetcode有一道线上测试OJ,但给的条件太苛刻了, 比较有难度。
所以可以对照哈希表的解法,写一下对数器测试。
下面的类里有测试的main函数, 是根据哈希表解法和位图的解法对照的结果。
public class BitSet {
public int[] set ;
public BitSet(int n){
set = new int[(n+31)/32];
}
public void add(int num){
set[num/32] |= 1 << (num % 32);
}
public void remove(int num){
set[num/32] &= ~(1 << (num % 32));
}
public void reverse(int num){
set[num/32] ^= 1 << (num % 32);
}
public boolean contains(int num){
return ((set[num/32] >> (num % 32)) & 1) == 1;
}
//哈希表写法与位图写法进行对数验证
public static void main(String[] args) {
//生成多大的右边界范围
//范围是[0,n)
int n = 1000;
int testTimes = 10000;//测试10000次
System.out.println("Testing begins...");
// Initialize BitSet and HashSet
BitSet bitset = new BitSet(n);
var hashset = new HashSet<Integer>(n);
System.out.println("等概率随机调用3种方法");
for(int i=0;i<testTimes;i++){
double decide = Math.random();
int num = (int)(Math.random() * n);
if(decide < 1.0/3){
bitset.add(num);
hashset.add(num);
}else if(decide < 2.0/3){
bitset.remove(num);
hashset.remove(num);
}else{
bitset.reverse(num);
if(hashset.contains(num)){
hashset.remove(num);
}else{
hashset.add(num);
}
}
}
System.out.println("验证开始");
for(int i=0;i<n;i++){
if(bitset.contains(i)!=hashset.contains(i)){
System.out.printf("第%d次: 出错了!\n", i);
}
}
System.out.println("结束");
}
}
leetcode上在线OJ测试
设计位集
思路:使用 reverse
标志模拟反转,而非直接操作数组中的位,避免了耗时的遍历操作。
并且维护两个字段exists表示存在, noexists表示不存在。它们并不表示实际1或者0的个数。
确切说, 当reverse是false, exists它确实表示1的个数。 revrese是true,它表示0的个数(实际是模拟反转后1的个数)
all, one,count
, 由于维护了相关字段实现非常简单。
flip
函数,除了reverse反转状态后。 exists和noexists的大小应该交换。
class Bitset {
public int[] set;
public final int n;//位集总的个数
public int exists;//不存在个数
public int noexists;//存在个数
public boolean reverse;//反转状态
public Bitset(int size) {
n = size;
set = new int[(n+31)/32];
noexists = n;
exists = 0;
reverse = false;
}
public void fix(int idx) {
int index = idx / 32;
int bit = idx % 32;
//不存在->存在
if(!reverse){
//0表示不存在
//1表示存在
if((set[index] & (1 << bit)) == 0){
exists++;
noexists--;
//0->1
set[index] |= (1<<bit);
}
}else{
//1表示不存在
//0表示存在
if((set[index] & (1 << bit)) != 0){
exists++;
noexists--;
//1->0 , ^ 解决
set[index] ^= (1 << bit);
}
}
}
public void unfix(int idx) {
int index = idx / 32;
int bit = idx % 32;
//存在->不存在
if(!reverse){
//0表示不存在
//1表示存在
if((set[index] & (1 << bit)) != 0){
exists--;
noexists++;
//1->0
set[index] ^= (1<<bit);
}
}else{
//1表示不存在
//0表示存在
if((set[index] & (1 << bit)) == 0){
exists--;
noexists++;
//0->1
set[index] |= (1 << bit);
}
}
}
public void flip() {
reverse = !reverse;
int tmp = exists;
exists = noexists;
noexists = tmp;
}
public boolean all() {
return exists == n;
}
public boolean one() {
return exists != 0;
}
public int count() {
return exists;
}
public String toString() {
char[] result = new char[n];
for (int i = 0, k = 0, number, status; i < n; k++) {
number = set[k];
for (int j = 0; j < 32 && i < n; j++, i++) {
status = (number >> j) & 1; // 获取当前位
status ^= reverse ? 1 : 0; // 考虑反转逻辑
result[i] = (char) ('0' + status); // 转换为字符
}
}
return String.valueOf(result);
}
}