位数组(Bit Array),这种数组如何在Redis中应用?
统计每个月学习专栏的用户活跃度
在开始之前,先来考虑一个关于用户行为分析的问题,假设要统计《数据结构精讲:从原理到实战》这个专栏每个月的用户活跃度。在每个月中只要有用户登录并且学习这个专栏,都会将这个用户的ID写入一张MySql表中,如果想知道2019年11和12这两个月内都在学习这个专栏的用户有多少,应该怎么做呢?
很直观的做法是执行类型下面的sql语句
1 SELECT COUNT(*) FROM nov_stats 2 INNER JOIN dec_stats 3 ON nov_stats.user_id = dec_stats.user_id 4 WHERE nov_stats.logged_in_month = "2019-11" 5 AND dec_stats.logged_in_month = "2019-12";
不过这种做法需要到数据库中去读取数据并做内连接,效率不是那么高,是不是有更简单高效的做法呢?请看接下来的内容:
比特与字节
我们经常听到一些人打趣的说:“在程序员的眼中,永远只有0和1”。确实,计算机处理信息的最小单位就是一个二进制单位,即比特(Bit),而8个二进制比特位都可以组成另一个单位,及字节(Byte),字节是内存空间中的一个基本单位。
因位比特只能表达“0”或者“1”两种状态,它非常适合用来表示布尔型的状态。例如,我们可以用比特表示用户是否有订阅《数据结构精讲:从原理到实战》这个专栏,“0”的状态表示没有订阅,“1”的状态表示已经订阅。
如果我们声明一个以比特为基本单位的数组,应该怎么做呢?我们都知道,在高级语言里面,是无法声明一个以比特为单位的基本类型的,而比特只有“0”或者“1”两种状态,那最简单的方法是可以声明一个以int为单位的数组,这个数组的值只能为“0”或者“1”,用来表示比特位的“0”和“1”。
下面以java为例,假设我们要声明一个大小为2的“比特数组”,其代码如下所示:
int[] d = new int[2];
根据上面的声明,我们可以利用这个数组表示两种不同的状态。但是这种方法有一个明显的缺点,那就是消耗过多的存储空间。无论是在32位还是64位的机器上,int这种基本类型在java中的大小都是真4个字节空间的,及总共有4*8=32个比特位。从理论上来说,我们只需要其中一个比特位来记录状态,所以这里整个数组浪费掉了31/32= 96.875%的内存空间。
位数组
那有没有更好的方法呢?当然有,既然一个int类型是有32个比特位的,我们其实可以把数组中的一个int类型元素当作可以表达布尔状态的32个比特位元素。这种将每个元素中每一个比特位都作为状态信息存储的数组称之为位数组(Bit Array)或者位图(Bit Map)。
那我们来看看上面声明的拥有两个元素的int数组的内存模型是怎样的?
这个位数组总共可以表达64个状态位,通过上图,我们可以得知,位数组在内存中的结构以及这个位数组索引的分部。
当我们要操作位数组在位置为“i”这个比特位的时候,应该如何定位它呢?很简单,可以按照下面的公式来定位。
所在数组中的元素为: i / data_size
比特位在元素中的位置为: i % data_size
那我们以定义索引为35这个比特位为例子来说明一下,套用上面的公式,可以得出:
所在数组中的元素为: 35 / 32 = 1
比特位在元素中的位置为: 35 % 32 = 3
所以这个比特位是位于d[1]这个元素上索引为3的位置。
一般来说因为位数组的元素只保存“0”或者“1”两种状态,所以对于位数组的操作有以下几种:
- 获取某个位置状态的比特位
- 设置某个位置的比特位,也就是将那个位置的比特位设置为“1”;
- 清除某个位置的比特位,也就是将那个位置的比特位设置为“0”。
位数组的实现
下面以java为例,自己动手来实现这三个操作的核心部分。
(1)GetBit
我们可以声明GetBit的方法签名为:
1 boolean GitBit(int[] array,long index);
这个方法用于获取在array位数组中index位上的比特位是什么状态,如果为“1”,则返回true,如果为“0”,则返回false。
根据上面的介绍,获取比特位所在的元素以及比特位在元素中的位置公式核心的算法如下:
1 boolean GitBit(int[] array,long index){ 2 ... 3 int elementIndex = index / 32; 4 int position = index % 32; 5 long flag = 1; 6 flag = flag << position; 7 if((array[elementIndex] & flag) != 0){ 8 return true; 9 } else { 10 return false; 11 } 12 }
我们用以下这个位数组来验证一下,假设这个位数组的值如下图所示:
如果调用了GetBit(d,35)这条语句,将得到elementIndex为1、position为3、flag为0x08,将d[1]和0x08进行位操作的与运算,最后可以得出一个非0的结果,所以这个函数返回true。
而如果调用了GitBit(d,32)这条语句,将得到elementIndex为1、position为0、flag为0x1,将d[1]和0x1进行位操作的与运算,最后可以得出一个0的结果,所以这个函数返回false。
SetBit
我们可以声明SetBit方法的签名为:
1 void SetBit(int[] array,long index);
这个方法用于将array数组中index位上比特位设置为1。
根据前面的介绍,获取比特位所在的元素及比特位所在元素位置中的公式,核心算法如下:
1 void SetBit(int[] array,long index){ 2 ... 3 int elementIndex = index / 32; 4 int position = index % 32; 5 long flag = 1; 6 flag = flag << position; 7 array[elementIndex] | false; 8 }
我们用下面这个数组来验证一下,假设这个数组的值如下图所示:
如果调用了SetBit(d,35)这条语句,我们将得到elementIndex为1、position为3、flag为0x08,将d[1]与0x08进行位操作的或运算,设置完成后位数组的状态如下图所示:
ClearBit
我们可以声明CleatBit方法签名为:
void ClearBit(int[] array,long index);
这个方法用户将array位数组中index位上比特位设置为0。
根据前面的介绍,获取比特位所在的元素以及比特位在元素中的位置公式,核心算法如下:
1 void ClearBit(int[] array,long index){ 2 ... 3 int elementIndex = index / 32; 4 int position = index % 32; 5 long flag = 1; 6 flag = ~(flag << position); 7 array[elementIndex] & flag; 8 }
我们用下面这个数组来验证一下,假设这个数组的值如下入所示:
如果调用了ClearBit(d,32)这条语句,我们将得到elementIndex为1,position为1,flag为0xFFFFFFFE,将d[1]和0xFFFFFFFE进行位操作的与运算,设置完之后位数组的状态如下入所示:
上面所介绍的三个位数组操作时间复杂度都是O(1)。
Redis中的BitMap数据结构
数组在Redis中的应用。Redis是开源的并且使用内存来作为存储空间的高效数据库。
下面介绍一下Redis里面的一个数据结构——BitMap。BitMap在这里其实就是我们刚刚讲解的位数组。
BitMap的本质其实就是在Redis里面通过一个Strings类型来表示的,在Redis中Strings类型的最大长度是512MB。
也就是说根据上面的计算,BitMap可以用来表示大概42亿多个状态,这对于大多数的应用已经足够了。
在Redis里面对BitMap的操作命令有以下几种:BITCONUT、BITFIELD、BITOP、GETBIT、SETBIT。其中,GETBIT和SETBIT命令和前面我们自己所实现的GetBIt和SetBit操作原理是一样的。
回到最开始的那个问题:如果想知道2019年11和12月份学习专栏的用户数是多少,该怎么做呢?
我们可以用Redis里的BITCOUNT、SETBIT和BITOP来完成。BITCOUNT这个命令其实可以计算一个位数组里面有多少比特位是为“1”的,而BITOP可以针对位数组进行“与”、“或”、“非”、“异或”这样的操作。
首先针对11月学习的用户和12月学习的用户,我们可以为它们创建单独的位数组,例如,logins:2019:11 logins:2019:12,在11月每当用户学习时,程序会自动调用“logins:2019:11 user_id 1”,同理,对于12月学习的用户,我们也可以调用“logins:2019:12 user_id 1”。SETBIT命令可以将user_id在位数组中相应的位设为“1”。
当要获得两个月内同时都学习了这个专栏的用户数时,可以调用“BITOP AND logins:2019:11-12 logins:2019:11 logins:2019:12”。将logins:2019:11和logins:2019:12这两个数组做位运算中的与操作,将结果放在“logins:2019:11-12”这个位数组中,最后调用“BITCOUNT logins:11-12”来得到结果。
Redis的BitMap操作之所以强大,是因为所有操作都是位运算以及发生在内存中,所以速度极快。
心得:
写到这,这一节课的笔记就算抄写完了,一开始,写一句话思考一句话的时候觉得都还能懂,但是众观全局看下来,又是似懂非懂,尤其是写到最后一节,“Redis中BitMap数据结构”,有些疑虑,茫然,可能是没有接触过Redis的原因,所以只是表面上懂了,略懂皮毛。2020年加油,向20W努力!!!
原文地址:https://www.cnblogs.com/gxkeven/p/12096528.html