TL;DR

直接看代码

🎯《编程珠玑》中有这样的一个问题:

在一个有 40 亿个随机排列的 32 位整数的顺序文件中,找出一个不在文件中的 32 位整数,在具有足够内存的情况下应该如何解决?如果可以借助几个临时文件,但是可用内存很小的情况,又该如何解决?

在有足够内存的情况下,可以先将 40 亿个整数读取出来后按大小排序,然后遍历这个列表找出不存在的数字。

但是,40 亿 个整数占用的内约为 4e10 * 32 / 8 / 1024 / 1024 / 1024 ≈ 149 GB,这样的实现方式我们日常是根本不可能用的。而在内存很小的那种极端情况其实很少会遇到。

所以,作者提出了使用 bitmap 的解决方案

✨ Bitmap Intro

在 bitmap 中,每个 bit 位都是一个标识位,可以表示某种意义。

👆 上面的问题,我们简化一下,假设有 200 个 8 位的正整数(0~255),我们可以声明一个 [32]uint8 的数组作为 bitmap ,在这个数组中,一共有 32 * 8 = 256 个 bit 位,刚好可以覆盖这 200 个整数。 第 0 个元素的第 0 个 bit 位表示数字 0,第 1 个 bit 表示数字 1,第 1 个元素的第 0 个 bit 位表示数字 8,第 1 个元素的第 1 个 bit 位表示数字 9,以此类推(都是从 0 开始数)

0 0 0 0 0 0 0 0 | 0 0 0 0 0 0 0 0 | ... | 0 0 0 0 0 0 0 0
0 1 2 3 4 5 6 7   8 9 ...

我们遍历这 200 个数字,分别将 bitmap 中对应下标位置的 bit 位的值修改为 1

假如前 4 个数字为 3, 5, 1, 2

那么,bitmap 应该会变成这样

0 1 1 1 0 1 0 0 | 0 0 0 0 0 0 0 0 | ... | 0 0 0 0 0 0 0 0
0 1 2 3 4 5 6 7   8 9 ...

当我们把 200 个元素遍历完成后,bit 位的值为 0 的下标就是不存在的数字了

🌈 Bitmap 的优点

常规方式我们把 200 个数字读取到一个 slice 中排序,这些数字占用的内存为 200 * 8 / 8 = 200 bytes

而是用 bitmap 的话,只需要 32 * 8 / 8 = 32 bytes

而且数据量越大,bitmap 的优势越明显

💻 Bitmap 的实现

ok,讲重点,理解了 bitmap 后,其实实现并不难。首先我们需要先了解一下 位运算

🧬 结构定义

我们首先来定义一下接口和结构体这些骨架

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

import (
	"errors"
)

// BitMap go 目前泛型还没有上线,我们定义 bitmap 只能固定一种类型,
// 这里用 uint64 就只是展示。添加了泛型后会更加灵活
type BitMap interface {
	// Add 添加一个元素到 bitmap 中
	Add(uint64) error
	// Del 从 bitmap 中删除一个元素
	Del(uint64) error
	// Contain bitmap 中是否包含这个数字
	Contain(uint64) bool
}

type bitmap struct {
	store []uint64
}

// Add 实现了 BitMap 接口的方法
func (b *bitmap) Add(u uint64) error {
	panic("implement me")
}

// Del 实现了 BitMap 接口的方法
func (b *bitmap) Del(u uint64) error {
	panic("implement me")
}

// Contain 实现了 BitMap 接口的方法
func (b *bitmap) Contain(u uint64) bool {
	panic("implement me")
}

// ErrZeroCapacity 表示要创建的 bitmap 的容量小于等于 0,这样是没有意义的
var ErrZeroCapacity = errors.New("capacity should greater than 0")

// NewBitmap 创建一个 Bitmap,cap 表示 bitmap 的容量
func NewBitmap(cap int) (*bitmap, error) {
	if cap <= 0 {
		return nil, ErrZeroCapacity
	}
	return &bitmap{
		store: make([]uint64, cap/64+1), // +1 防止数字太小整除后结果为 0
	}, nil
}

// 类型检查
var _ BitMap = (*bitmap)(nil)

🧪 测试用例

养成好习惯,写代码之前先写测试用例

tips: goland 和 vscode 都支持自动生成测试用例,文件中点击右键后选择 generate 就可以生成测试用例了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package main

import (
	"reflect"
	"testing"
)

func TestNewBitmap(t *testing.T) {
	type args struct {
		cap int
	}
	tests := []struct {
		name    string
		args    args
		want    *bitmap
		wantErr bool
	}{
		{
			name: "negative capacity",
			args: args{
				cap: -1,
			},
			want:    nil,
			wantErr: true,
		},
		{
			name: "ok",
			args: args{
				cap: 1,
			},
			want: &bitmap{
				store: make([]uint64, 1),
			},
			wantErr: false,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := NewBitmap(tt.args.cap)
			if (err != nil) != tt.wantErr {
				t.Errorf("NewBitmap() error = %v, wantErr %v", err, tt.wantErr)
				return
			}
			if !reflect.DeepEqual(got, tt.want) {
				t.Errorf("NewBitmap() got = %v, want %v", got, tt.want)
			}
		})
	}
}

func Test_BitMap(t *testing.T) {
	bm, _ := NewBitmap(10)
	if err := bm.Add(10); err != nil {
		t.Errorf("unexpected error: %v", err)
	}
	if !bm.Contain(10) {
		t.Errorf("bitmap should contain number 10")
	}

	if err := bm.Del(10); err != nil {
		t.Errorf("unexpect error: %s", err)
	}
	if bm.Contain(10) {
		t.Errorf("bitmap should not contain number 10")
	}
}

⚓ 计算坐标

我们如何快速计算出一个数字应该存储在 bitmap 中的哪个位置?

我们还是以 uint8 的 bitmap 来计算…因为方便😂

0 0 0 0 0 0 0 1 | 0 1 0 0 0 0 0 0 | ... | 0 0 0 0 0 0 0 0
0 1 2 3 4 5 6 7   8 9

数字 7 存储在第 0 个元素的第 7 个 bit 位上(从 0 开始数)

数字 9 存储在第 1 个元素的第 1 个 bit 位上

计算公式如下

7 / 8 = 0

1 « (7 % 8) = 0b10000000

9 / 8 = 1

1 « (9 & 8) = 0b00000001

得到了这个规律,我们就可以编写代码了,因为每个方法都需要计算坐标,所以我定义了一个 coordinate 的方法

1
2
3
4
// 计算 u 在 bitmap 中的坐标
func (b *bitmap) coordinate(u uint64) (int, uint64) {
	return int(u / bitSize), 1 << (u % bitSize)
}

📥 添加元素

知道了如何计算坐标以后,剩下的就简单了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ErrOutOfRange 表示存储的元素超出了 bitmap 的最大容量
var ErrOutOfRange = errors.New("out of range")

// Add 实现了 BitMap 接口的方法
func (b *bitmap) Add(u uint64) error {
	x, y := b.coordinate(u)
	if x > len(b.store) {
		return ErrOutOfRange
	}
	b.store[x] |= y
	return nil
}

📤 删除元素

1
2
3
4
5
6
7
8
9
// Del 实现了 BitMap 接口的方法
func (b *bitmap) Del(u uint64) error {
	x, y := b.coordinate(u)
	if x > len(b.store) {
		return ErrOutOfRange
	}
	b.store[x] &= ^y
	return nil
}

⁉️ 是否包含元素

1
2
3
4
5
6
7
8
// Contain 实现了 BitMap 接口的方法
func (b *bitmap) Contain(u uint64) bool {
	x, y := b.coordinate(u)
	if x > len(b.store) {
		return false
	}
	return b.store[x]&y == y
}

测试代码

一个简易的 bitmap 就实现了,现在就可以用测试用例来验证一下我们的逻辑是否正确

1
2
~/src/bitmap » go test .
ok      github.com/ningzio/bitmap       0.563s

✅ 测试通过~