大家好,我是渔夫子。
今天我们通过开源包bitset来分析位集合的设计和实现。
bitset包是一个将非负整数映射到布尔值的位的集合。比如我们有一个64位的二进制序列,要将第N位设置成true,对应的就是将第N位置成1。如下:
image.png
该包因为使用的是位操作,所以比使用map[uint]bool来实现非负整数到布尔值的映射会更高效。
该包不仅提供了setting、clearing、flipping和testing的方法。还提供了集合的交集、并集、差集等方法。
**项目地址: **https://github.com/bits-and-blooms/bitset星标:1.1k 贡献者人数:33 人
image.png
在了解了bitset的基本功能之后,我们来分析bitset的设计和实现。
在bitset包中,核心的数据结构是BitSet
。其定义如下:
// A BitSet is a set of bits. The zero value of a BitSet is an empty set of length 0.
type BitSet struct {
length uint
set []uint64
}
首先来看为什么使用uint64的数据类型。bitset不是按位存储的集合吗,怎么set的数据类型是uint64呢?
这里就涉及到计算机的一个基础知识点:
“计算机存储和处理的信息都是以二值信号表示的。所谓的二值信号就是0和1,也就是我们常说的二进制。
所以,整数的底层也是二进制位。uint64在go语言中就代表的是用64个二进制位表示的整数值。
在bitset中,我们先假设set字段只有一个uint64的整数。那么,如果我们想将第7位设置成1,那么就如下:
但是,一个uint64的整数最多也就只有64个二进制位。那如果我们想设置第100位为true,那又该怎么表示呢?这也就是set字段的类型为什么是一个切片的原因了。既然一个uint64最多只能表示64个二进制位,那么我就用多个uint64不就能表示更多的二进制位了吗。
所以,set中第一个uint64表示前64个二进制位,第二个uint64表示65到128的二进制位,以此类推。这样就理论上就可以表示任意位数的二进制位了。
length字段表示在初始化一个BitSet对象时,该BitSet对象总共能容纳多少位,根据这个总位数来分配set字段的切片长度。如下:
// New creates a new BitSet with a hint that length bits will be required
func New(length uint) (bset *BitSet) {
defer func() {
if r := recover(); r != nil {
bset = &BitSet{
0,
make([]uint64, 0),
}
}
}()
bset = &BitSet{
length,
make([]uint64, wordsNeeded(length)),
}
return bset
}
看代码的第12到15行。在第14行中,需要计算的是要表示length个二进制位需要几个uint64的非负整数来表示。这里通过wordsNeeded函数来计算的,如下:
// wordsNeeded calculates the number of words needed for i bits
func wordsNeeded(i uint) int {
if i > (Cap() - wordSize + 1) {
return int(Cap() >> log2WordSize)
}
return int((i + (wordSize - 1)) >> log2WordSize)
}
这里主要看第6行的int((i + (wordSize - 1)) >> log2WordSize)
。这里有几个常量,如下:
所以,wordsNeeded函数表示的就是要存储i个二进制位需要用几个uint64的整数。
为了简便,我们用uint8来说明。uint8代表的是一个8位的非负整数。例如,要把uint8的第2位设置成1。用二进制表示就是:00000100
。这个怎么得到呢?我们知道1的二进制表示是00000001
,那么让这个1左移2位就能得到结果00000100
。即 1<<2
。
如果再把该uint8的第3位也设置成1,怎么办呢?首先让1左移3位得到00001000
。因为原有uint8的第二位也是1,这里就要用uint8原有的值和00001000
进行做或操作,就能保持住uint8原有的位的值不变了。如下:
原有的uint8(第二位是1):00000100
第三位设置成1:00001000
-----------------------------
或的结果: 00001100
以上就是在整数中进行的位操作。
在上面的BitSet的数据结构中,我们知道set字段是一个uint64的切片类型,相当于把每64位分成一组。那么,当设置第N位为1的时候,首先要做的是计算第N位应该落在哪个分组上。这个是怎么计算呢?就是第N位是63(因为位数是从0开始的)的多少倍,比如要设置第66位为1,那么66位是63的1倍(余数省略),所以在切片的第1个分组上(索引是从0开始,实际是切片的第二个分组)。
还是以uint8(8位)一组为例来说。如果要设置第10位,则落在第二个uint8的分组上。如下:
按位操作来计算除法就是右移操作。这里让N右移3位,因为移动3位,代表的2的3次方,即8。也是用10除以8的商是1,即在set切片的第1个索引上,也就是第二个uint8上。
其次,要计算第N位是在第2个分组的第几位上。简单点就是取余操作。用10%8,就是第2位上(因为从0开始,所以是第3位)。同样,这里还有一种按位移操作的方法:10&7
。我们解释下这个与操作。我们看下8的二进制表示:1000
。要想让10除以8,就是将第3位的1抹掉,并保持其他位不变。要想保持原有位保持不变,就和1进行与操作。所以,让二进制的1000
变成0111
,再和10的二进制进行与操作,就相当于除以8取余数了。如下:
你看,这样就把最高位的1给消除了,结果余数是2的1次方,即2。最后,因为一个uint8的整数的最高位是第7位(从0位开始),所以第10位应该是第二个uint8的第3位上。最后让1再左移上述结果的2位即可。
如下是bitset的实现:
// log2WordSize is lg(wordSize)
const log2WordSize = uint(6)
func (b *BitSet) Set(i uint) *BitSet {
if i >= b.length { // if we need more bits, make 'em
b.extendSet(i)
}
// 说明第0位从右边往左边数的
b.set[i>>log2WordSize] |= 1 << wordsIndex(i)
return b
}
// the wordSize of a bit set
const wordSize = uint(64)
// wordsIndex calculates the index of words in a `uint64`
func wordsIndex(i uint) uint {
return i & (wordSize - 1)
}
以上就是针对BitSet最基本的数据结构以及如何设置一个位为1的实现,其他的方法基本都是类似的思想来实现的,有兴趣大家可以继续研读该包的源代码。
bitset基于uint64的整数实现了位的操作。该包的代码实现中涉及到大量的位操作。阅读本包的源代码,可以帮助大家理解位操作的概念以及应用场景。