You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

13 KiB

29位图如何用更少空间对大量数据进行去重和排序

你好,我是微扰君。

今天我们从一道非常经典的面试题开始说起看看你能否用之前学过的知识回答出来题目是这样的QQ相信你肯定用过假设QQ号也就是用户的ID是一个10位以内的数字用一个长整型是可以存储得下的。

现在有一个文件里存储了很多个QQ号但可能会有一定的重复如果让你遍历一边文件把其中重复的QQ号都过滤掉然后把结果输出到一个新的文件中。你会怎么做如果QQ号多达40亿个但是你的内存又比较有限比如1GB又会怎么做呢

你可以先暂停,思考一下这个问题,如果有了初步思路,我们一起进入今天的学习。

直接基于内存进行去重

先来说说常规的思路。假设我们的数据可以被内存装下,这个问题其实就有很多种方式可以解决。

比如对于去重直接采用基于散列思想的hashset或者基于树状结构的set就可以了前者可以在O(1)的时间复杂度内判断某个元素是否存在于集合中后者虽然需要O(logN)的时间复杂度但是在十亿的数量级下其实也就是比较30次左右代价也并不高然后我们遍历一遍整个文件存入set中再输出到另一个文件。总的时间复杂度前者是O(N)后者是O(N*logN)。

当然还有一种思路我们先用数组把所有QQ号存储下来进行排序然后顺次遍历跳过所有和前个QQ号相同的QQ号就能实现去重采用快排同样可以达到O(N*logN)的时间复杂度。

所以总的来说,基于哈希算法的时间复杂度,理论上已经是最优的了,耗时也是可接受的,毕竟无论如何,想要去重,每个元素至少要遍历一遍,不可能存在更优的时间复杂度了。我们唯一还能优化的点就是在像QQ号这种以数字为key的特殊情况下直接利用数组来充当hashmap避免hash的开销

但是这个题真正的问题是从空间上来说我们真的能开着这么大的一个数组吗因为存储的是10位数的QQ号这意味着我们的数组至少要有10位数以上的index假设最高位以1、2、3、4开头也就是说数组至少要存放40亿级别的数据。

假设数组类型是bool表示对应index的QQ号是否存在那我们所需要的内存空间大约是4GB。这对目前的硬件来说不是一个特别高的内存要求许多个人电脑所采用的内存条都足以支撑但是如果存11位的QQ号或者其他更大的数据量显然就不够用了。而且这道面试题的原始版本要求我们用1GB的内存实现去重。

总之,如果有办法用更低的内存,我们应该想办法去发掘。

对文件进行分割

现在内排序、直接使用hashmap或者数组计数去重的方式肯定是不行了。

不知道你有没有想到之前学过的外排本质上就是对文件的分割和逐步排序。在我们的QQ号场景下不需要真的做排序只是需要去重所以完全可以逐行读入大文件根据QQ号的范围切分成多个可以一次性加载进内存的小文件。

不过因为不同范围内QQ号重复的数量可能不同分割范围可能不是特别好把握保守一点的话我们可以把QQ号按照1000W的大小进行分段这样大约需要分为400个文件。这样基本上算上重复的QQ号也不太可能超过1G内存的容量每个文件再用hashmap之类的手段去重最后合并就可以了。

外排序是一个可行的解决办法,而且理论上来说,利用类似的思路,我们还可以实现更大数量级的去重任务,但是代价是要进行更多次的IO。性能比较差

事实上40亿级的数据范围在1GB的内存下我们是有办法直接在内存中处理去重的这就是我们今天要学习的Bitmap它非常有用在计算机的世界里无处不在从文件系统、数据库还有Redis中都有广泛应用。我们来看设计思路。

Bitmap

40亿的数据直接放在内存里是不行的但去重必须获得所有的信息如果我们想要只利用内存进行去重也仍然需要把每个数字是否出现在文件中的信息通过某种方式记录下来。

前面通过数组存bool值的方式显然不是最经济的一种存储方式因为每个bool类型在数组中占据了一个字节的数据也就是8个bit。

但是存储一个数字是否出现其实我们只需要用一个bit来记录。Bitmap的核心就是用数字二进制的每一位去标记某个二值的状态比如是否存在用0表示不存在用1表示存在所以可以在非常高的空间利用率下保存大量二值的状态。

一个基本的Bitmap的图示相信你一看就能明白

图片

比如可以用char类型来存储8个不同元素是否出现的情况char的范围在各大主流语言中一般为0255一共包含8位二进制我们可以用下标的每一位来表示一个元素是否出现。在这张图中Bitmap的值为254表示下标17的元素都有出现而下标0的元素没有出现。

这样仅仅用了一个字节就表示了8个元素是否出现的情况而如果用map或者数组至少需要8个bool值也就是8个字节大小的空间这样我们就节约了8倍的空间。

同样如果我们用别的类型来存储Bitmap比如unsigned int类型每一个数字就可以表示32个元素的存在与否采用多个unsigned int类型数据级联就可以标识更多的元素是否存在。

在QQ号的场景下要表示40亿的元素采用Bitmap最少只需要40亿个bits所占据的空间大约是500M左右这样我们就大大压缩了内存的使用空间在1GB之内就可以完成去重的工作。

这里插句题外话不知道你有没有想过为什么bool类型在大部分语言中都需要一个byte去存储呢bool本身语意上就只是二值我们不应该用一个bit来实现吗这样不是效率高得多

这个问题需要我们有比较好的计算机组成原理相关的知识了。本质原因是在大部分的计算机架构中最小的内存操作单元就是一个byte直接采用一个byte作为bool类型的存储在一次读内存的操作内即可完成如果存储为一个bit我们还需要像Bitmap那样从若干位中通过位运算进行一次提取操作反而更慢。

当然Bitmap的本质实质上就是更好地利用空间来做二值的标记相比于一般的hash算法它能获得更好的空间成本从计算上来说其实也是更高效的在去重和排序中有比较良好的应用。

具体实现

具体的代码实现我们需要开辟一个unsigned char的数组记作flags。数组中的每个元素都记录了8个QQ号是否存在可以简单地从0开始往后计数虽然位数很低的QQ号其实并不存在。

flags[index] 代表着第 index*8 ~ index*8+7 这8个QQ号是否存在的情况最低位表示 index*8 是否存在,而最高位就代表 index*8+7 这个QQ号是否存在。

建立好Bitmap数组之后遍历文件中的QQ号进行对应Bitmap标记的更新根据QQ号计算出对应的flags的下标和二进制的哪一位进行和1的或运算即可。

遍历完成后我们只需要顺次遍历Bitmap的每一位如果为1说明QQ号存在输出到新文件为0的位直接跳过即可。整个过程完成后,其实我们不止做到了去重,也做到了排序

整个过程可以很简单地用C++进行实现。首先要实现一个基本的Bitmap对外提供初始化、设置标记、获取标记3个功能。

class BitMap{
private:
    char *flags;
    int size;
public:
    BitMap(){
        flags = NULL;
        size = 0;
    }

    BitMap(int size){
        // 声明bitmap数组
        flags = NULL;
        flags = new char[size];
        memset(flags, 0x0, size * sizeof(char));
        this->size = size;

    }
  
    // 根据index设置元素是否出现过
    int bitmapSet(int index){
        int addr = index/8;
        int offset = index%8;
        unsigned char b = 0x1 << offset;
        if (addr > (size+1)) {
            return 0;
        }else{
            flags[addr] |= b;
            return 1;
        }
    }
    
    // 根据index查看元素是否出现过
    int bitmapGet(int index){
        int addr = index/8;
        int offset = index%8;
        unsigned char temp = 0x1 << offset;
        if (addr > (size + 1)) {
            return 0;
        }else{
            return (flags[addr] & temp) > 0 ? 1 : 0;
        }
    }
};

有了这样的基本能力我们需要做的就是遍历文件的部分由于文件IO和解析的部分不是今天的重点我们直接处理一个在内存中的QQ号数组。

int remove_dup(vector<unsigned int> qqs) {
  BitMap b = new BitMap(4000000000);
  for (int i = 0; i < qqs.size(); i++) {
    b.bitmapSet(qqs[i]);
  }
  for (int i = 0; i < 4000000000; i++) {
    if (b.bitmapGet(i)) cout << i << endl;
  }
  return 0;
}

如果封装好了基本的Bitmap逻辑之后使用过程和hashmap看起来没有什么太大区别你可以认为Bitmap就是一种对数字的散列方式和数组用于去重的场景相似适用于下标比较密集的情况否则仍然会浪费大量的空间在QQ号去重的场景下就非常好用。

数据库中的Bitmap索引

Bitmap既然可以适用于排序当然也可以用来做索引。在数据库中就有一类索引被称为Bitmap索引位图索引比较适用于某个字段只有部分可选值的情况,比如性别的男、女,或者所在城市之类。

采用位图索引,不止可以降低空间成本,在多条件查询中,我们也可以基于位运算提高索引的利用率。看个具体例子:

图片

现在我们有一张北京市某所学校的学生信息表,其中有两列,分别记录了性别和所在区,这两列显然都属于有固定枚举值的情况。这里为了讲解方便,我们简单假设所包含的区只有海淀、朝阳、东城、西城这四个。

如果采用传统的B+树建立索引,在性别这一栏上区分度其实很低,因为很大的概率我们任意一个筛选条件都要筛选出接近半数的元素所以基本上先利用性别的红黑树检索就没有什么查询优势了事实上在大部分的数据库中也会直接选择全局遍历。同样的在区这一维度看由于我们假设只有4个可选项采用B+树也没有什么特别大的好处。

而位图索引在这样固定枚举值的场景下非常合适,具体做法就是我们会为性别男、性别女,海淀区、朝阳区、东城区、西城区这样几个独立的选项,都建立一个位图向量,作为索引。比如:

性别_= 10010101
性别_= 01101010

_朝阳= 00000100

就意味着id=7\4\3\0的学生性别为男id=1\2\5\6 的学生为女。

当然由于数据库存储的数据量要大的多我们会采用更长的Bitmap来存储所有学生在某个key为不同取值的情况。

使用位图索引最大的好处就在于多查询条件的时候我们可以直接通过对Bitmap的位运算来获得结果集的向量。比如想获得朝阳区的女生,只需要对区_朝阳性别_女 这两个Bitmap做与运算就可以得到同时符合两个条件的结果集向量比如在这个例子里两者与得结果为0说明不存在这样的学生。相比于B+树,位图索引效率就会高很多。

总结

Bitmap位图本质上就是一种通过二进制位来记录状态的数据结构。比基于硬件特性设计、用一个字节来存储bool类型的方式提高了8倍的存储效率可以用更少的空间来表示状态。

Bitmap在大量数据去重和排序的场景下很有用比如大量QQ号的去重问题在内存资源敏感、需要标记状态的场景下也很常见比如文件系统中存储某个block是否被占用的状态用到的就是Bitmap。

在数据库中,我们也可以在枚举类型的属性上建立位图索引,为属性的每个取值建立一个位图,从而可以大大提高多条件过滤查询的效率。事实上,之后会讲到的布隆过滤器,底层也是基于位图的思想,如果你的工作中有去重的需要,也不妨考虑一下采用位图实现的方式,说不定就能大大提高系统的性能。

课后练习

今天主要讲的就是QQ号面试题课后你可以尝试自己动手实现一下另外有时我们的Bitmap也会需要把设置为1的状态清除目前我们没有提供这样的接口欢迎把你的代码贴在留言区一起讨论。

如果觉得这篇文章对你有帮助的话,也欢迎转发给你的朋友一起学习。我们下节课见。