美文网首页
巧用位运算(三)—— 奇技淫巧 Bitmap

巧用位运算(三)—— 奇技淫巧 Bitmap

作者: liurenhao | 来源:发表于2021-04-30 17:30 被阅读0次

Bitmap

考虑下面一个问题:

如何从40亿个正整数中,判断某个数字是否存在?

第一反应肯定是数组遍历,如果我们考虑用Int类型数组来存放,那么这40亿的数据会占用约12G内存,这显然不太现实。

这里肯定有朋友要杠了:我为啥非要加载到内存中呢,就从文件读取遍历不行吗?
当然可以。但是如果这个需求是频繁出现的呢,每次都去走I/O操作进行文件内容遍历吗?

回归正题,要怎样才能让我们把40亿数据都加载进入内存呢?这个时候我们就可以考虑一种叫做Bitmap的数据结构了。

什么是Bitmap呢,非常简单,我们看下面图1表示一个数组,这个数组长度是10,该数组的初始值均为0,并且数组的值只能是01.

图1

现在我们存放三个数字124,如图2,只需要把数组索引为124的值置为1

图2

以此类推,我们这个数组可以存放0~99个数字。反应快的朋友已经发现了,我们这个数组实际上就是一个“bit类型”(bit只能表示为二进制的0和1)的数组。也就是我们通常所说的Bitmap
刚才我们说到,JAVA里面我们通常用Int存放一个数字,Int占用4个字节,每个字节占用8位bit,即一个Int占用32位bit。也就是说,我们用一个Int类型的变量就可以存放0-31这样最多32个数字。

那我们现在来看看,用Bitmap存放40亿个数字,只需要约40亿/8/1024/1024=476M的内存空间。

解决了内存的问题,我们再来看看查询的问题。

回顾开篇我们提到的问题,如何判断一个数字是否存在。如果用数组存放,我们需要遍历,时间复杂度是O(N),加上排序和二分查找,可以把时间复杂度降到O(logN)。而Bitmap则可以把查询的时间复杂度降低到O(1),因为只需要进行一次简单的位运算操作,具体内容后面参照代码再做解释。

Bitmap的用处

Bitmap的最大的好处当然是内存压缩,但它的数据结构也给我们带来了很多额外的好处。

位运算替代SQL

设想以下场景:

一个网站如何统计出所有连续在线一周的用户

当然,在关系型数据库下,我们只需要一条SQL就能轻松搞定这个问题。但如果这个网站有20亿用户呢?你要怎么来设计这条SQL以保证我们查询的性能呢?

无疑Bitmap为我们提供了更好的解决思路。 首先,我们保证用户ID为一串纯数字,以便我们能够把20亿的用户ID存放在Bitmap中。通过上面的学习我们已经了解到Bitmap的本质就是按位存储,这样我们只需要把7天的Bitmap数据进行一个&位运算,最终得出的新的Bitmap就是连续一周在线的用户ID的集合了。

同理,如果我们要找出一周内上过线的所有用户,也只是一个|位运算就能搞定的事情。

布隆过滤器

大名鼎鼎的布隆过滤器实际上底层的数据结构(比特向量)就使用的Bitmap。将数据哈希映射在Bitmap上,然后通过位运算能够快速地判定数据是否已经存在,从而有效地防止缓存穿透问题。

索引

Oracle等关系型数据库提供了位图索引,其本质就是Bitmap

海量数据的快速排序、查找、去重

Bitmap的实现

下面我以Go语言为例实现简单的Bitmap数据结构。

package bitmap

const AddressBitsPerWord = 6
const InitCap = 1024
const Bits = 64

type Bitmap struct {
    words     []uint64
    wordInUse uint16
    size      uint16
}

func CreateBitmap() *Bitmap {
    bitmap := &Bitmap{}
    bitmap.words = make([]uint64, InitCap)
    bitmap.wordInUse = 1
    return bitmap
}

func (bm *Bitmap) Set(bitIndex uint16) bool {
    wordIndex := wordIndex(bitIndex)
    bm.expandTo(wordIndex)
    word := bm.words[wordIndex]
    bi := bitIndex & (Bits - 1)
    // 判断是否已经存在
    if (word & (1 << bi)) == (1 << bi) {
        return false
    }
    bm.words[wordIndex] |= 1 << bi
    bm.size += 1
    return true
}

func (bm *Bitmap) Get(bitIndex uint16) bool {
    wordIndex := wordIndex(bitIndex)
    word := bm.words[wordIndex]
    bi := bitIndex & (Bits - 1)
    // 判断是否已经存在
    if (word & (1 << bi)) == (1 << bi) {
        return true
    }
    return false
}

func (bm *Bitmap) List() []uint16 {
    cap := bm.wordInUse << AddressBitsPerWord
    arr := make([]uint16, bm.size)
    index := 0
    for i := uint16(0); i < cap; i++ {
        if bm.Get(i) {
            arr[index] = i
            index++
        }
    }
    return arr
}

func wordIndex(bitIndex uint16) uint16 {
    return bitIndex >> AddressBitsPerWord
}

func (bm *Bitmap) expandTo(index uint16) {
    wordRequired := index + 1
    if bm.wordInUse < wordRequired {
        bm.ensureCapacity(wordRequired)
        bm.wordInUse = wordRequired
    }
}

func (bm *Bitmap) ensureCapacity(wordRequired uint16) {
    wl := uint16(len(bm.words))
    if wl < wordRequired {
        expand := make([]uint64, wordRequired-wl)
        bm.words = append(bm.words, expand...)
    }
}

func (bm *Bitmap) Size() uint16 {
    return bm.size
}

从上面的Get()方法中我们可以看出,查询数据是否存在只是做了一个取模和位运算。

Bitmap的进化

大家很容易想到Bitmap在应对稀疏数据时不是一个好的选择,例如我们要存以下数据

1,11111,11111111,111111111111

如果使用bitmap进行存储,仅仅4个数字花费的存储空间是不可想象的。这种情况下,我们不得不对Bitmap进行改进。

EWAHCompressedBitmap

谷歌所实现的EWAHCompressedBitmap完全使用了行程压缩算法对Bitmap进行压缩。此处不对EWAHCompressedBitmap做详细介绍,重点介绍一下下面的RoaringBitMap算法。

RoaringBitMap(RBM)

RBM是2016年由S. Chambi、D. Lemire、O. Kaser等人在论文《Better bitmap performance with Roaring bitmaps》《Consistently faster and smaller compressed bitmaps with Roaring》中提出。

RBM的设计思路是:将32位的无符号整型按高16位进行分组,也就分出了2^16=65536Container

低16位作为值,放入到对应的Container中, 如下图

image.png

对于Container的实现一般有两种:

  • Array
    Container中的基数不大于4096时,使用ArrayContainer来存放,ArrayContainer本质为uint16的数组,初始长度4,数组会自动扩容。
  • Bitmap
    Container中的基数大于4096时,改用BitmapContainer来存放会更节约空间,BitmapContainer本质为我们上文说到的bitmap。

当然极限情况下也可以考虑用我在前文 《巧用位运算(二)—— 变长整型的实现》提到的变长整形数组来实现。

这里我们可以代入hashmap的设计思路来理解,相当于把uint32分为两部分,高16位作为key,低16位作为value。即有一个最大长度为65535的哈希槽(本质为数组,存放keys)加上最多65535个链表(存放values)。只不过这里的链表实现换成了数组或bitmap的实现(因为只需要存放无符号整数,链表实现浪费空间)。

我们可以体会到,在大数据并且数据稀疏的情况下,使用RoaringBitMap会非常地节省内存空间。大名鼎鼎的ElasticSearch的倒排表索引就用到了RoaringBitMap(实际为Lucene实现)。

下面贴一下我自己基于Go语言实现的RMB

package rbm

import (
    "reflect"
    "roaringbitmap/bitmap"
    "roaringbitmap/container"
    "roaringbitmap/lookup"
    "roaringbitmap/util"
)

type RoaringBitmap struct {
    // 高16位作为key
    keys []uint16
    // 低16位作为value
    values map[uint16]container.Container
    size   uint
}

func (rbm *RoaringBitmap) Values() map[uint16]container.Container {
    return rbm.values
}

func (rbm *RoaringBitmap) Keys() []uint16 {
    return rbm.keys
}

func Init() (rbm *RoaringBitmap) {
    rbm = &RoaringBitmap{}
    rbm.keys = make([]uint16, 0)
    rbm.values = make(map[uint16]container.Container)
    return
}

// 添加整数
func (rbm *RoaringBitmap) Add(val uint32) bool {
    high := uint16(val >> 16)
    low := uint16(val)
    // 添加key
    rbm.addKey(high)
    return rbm.addValue(high, low)
}

func (rbm *RoaringBitmap) addKey(key uint16) {
    // 判断key是否已经存在   使用二分查找
    i, b := lookup.Lookup(rbm.keys, key)
    if b {
        return
    }
    rbm.keys = util.InsertArray(rbm.keys, key, i)
}

func (rbm *RoaringBitmap) addValue(key uint16, value uint16) bool {
    if rbm.values[key] == nil {
        rbm.values[key] = &container.ArrayContainer{
            Values: make([]uint16, 0),
        }
    }
    con := rbm.values[key]
    if (reflect.TypeOf(con).String() == "*container.ArrayContainer") && con.Size() >= 4096 {
        con = rbm.resetContainer(key)
    }
    if !con.Add(value) {
        return false
    }
    rbm.size = rbm.size + 1
    return true
}

func (rbm *RoaringBitmap) List() []uint32 {
    arr := make([]uint32, 0)
    for _, v := range rbm.keys {
        high := v
        container := rbm.values[v]
        lows := container.List()
        for _, low := range lows {
            val := (uint32(high) << 16) | uint32(low)
            arr = append(arr, val)
        }
    }
    return arr
}

func (rbm *RoaringBitmap) Size() uint {
    return rbm.size
}

func (rbm *RoaringBitmap) resetContainer(key uint16) container.Container {
    oldContainer := rbm.values[key]
    newContainer := &container.BitmapContainer{
        Values: bitmap.CreateBitmap(),
    }
    for _, v := range oldContainer.List() {
        newContainer.Add(v)
    }
    rbm.values[key] = newContainer
    return newContainer
}

相关文章

  • 巧用位运算(三)—— 奇技淫巧 Bitmap

    Bitmap 考虑下面一个问题: 如何从40亿个正整数中,判断某个数字是否存在? 第一反应肯定是数组遍历,如果我们...

  • 1.1 位运算基础

    Chapter1: 位运算的奇技淫巧 1. 位运算基础 1. 基本概念与基本运算 1.1 原码、反码与补码 在计算...

  • Javascript 位运算及运用

    Javascript 位运算 参考:巧用JS位运算 ECMAScript 整数有两种类型,即有符号整数(允许用正数...

  • 位运算之巧用

    之前接触到位运算的时候,总是似懂非懂,一脸萌比。最近花点时间,细细研究,其实发现也相当简单。下面来举两个相当实用的...

  • 面试知识汇总-2019.7.16

    手撕代码题: 其他数据结构与算法中有那些奇技淫巧位运算装逼指南 ---- 带你领略位运算的魅力 单项列表实现加法运...

  • 1.4 将整数的二进制奇偶位互换

    Chapter1: 位运算的奇技淫巧 4. 将整数的二进制奇偶位互换 问题 将整数的二进制奇偶位互换 算法 算法1...

  • 1.0 推荐书籍与学习方法

    Chapter1: 位运算的奇技淫巧 1.0 推荐书籍与学习方法 推荐书籍 《程序员面试金典》 《挑战程序设计竞赛...

  • 巧用位运算(一)—— ThreadPoolExecutor源码解

    鸡汤 三月七日,沙湖道中遇雨。雨具先去,同行皆狼狈,余独不觉。已而遂晴,故作此词。莫听穿林打叶声,何妨吟啸且徐行。...

  • 1.5 二进制表示浮点数

    Chapter1: 位运算的奇技淫巧 5. 二进制表示浮点数 问题 给定一个介于0和1之间的实数(如0.625),...

  • 1.2 题解:如何找数组中唯一成对的那个数

    Chapter1: 位运算的奇技淫巧 2. 题解:如何找数组中唯一成对的那个数 题目 1-1000这1000个数放...

网友评论

      本文标题:巧用位运算(三)—— 奇技淫巧 Bitmap

      本文链接:https://www.haomeiwen.com/subject/timshctx.html