如何统计不同电话号码的个数?—位图法

已知某个文件内包含100亿个电话号码,每个号码为8位数字,如何统计不同号码的个数?内存限制100M

有人说遍历,使用HashSet或者int数组来存储,这里先不谈算法效率的问题,这100亿数据如何在能否在内存中放下也是一个问题。

如果用int类型来存储这100亿个电话号码,那么就需要 100亿 * 4字节 = 37GB ≈ 40GB。所以这些方法行不通的根本原因实际上是内存不够。

思路分析

这类题目其实是求解数据重复的问题。对于这类问题,可以使用位图法处理

实际上只需要判断数据是否存在,可以使用0表示存在,1表示不存在。那么在程序中,就可以使用最小的单位bit来进行表示,1个bit就有两种取值:0和1。所以位图其实就是一种直接定址法的哈希,只不过位图只能表示这个值在或者不在。

8位电话号码可以表示的范围为00000000~99999999。如果用1 bit表示一个号码,那么总共需要1亿个bit,总共需要大约10MB的内存。

申请一个位图并初始化为0,然后遍历所有电话号码,把遍历到的电话号码对应的位图中的bit设置为1。当遍历完成后,如果bit值为1,则表示这个电话号码在文件中存在,否则这个bit对应的电话号码在文件中不存在。

最后这个位图中bit值为1的数量就是不同电话号码的个数了。

那么如何确定电话号码对应的是位图中的哪一位呢?

可以使用下面的方法来做电话号码和位图的映射

java 复制代码
00000000 对应位图最后一位:0×0000...000001。
00000001 对应位图倒数第二位:0×0000...0000010(1 向左移 1 位)。
00000002 对应位图倒数第三位:0×0000...0000100(1 向左移 2 位)。
......
00000012 对应位图的倒数第十三位:0×0000...0001 0000 0000 0000(1 向左移 12 位)。

也就是说,电话号码就是1这个数字左移的次数。

具体实现

首先位图可以使用一个int数组 来实现(在Java中int占用4byte)。

假设电话号码为 P,而通过电话号码获取位图中对应位置的方法为:

第一步 ,因为int整数占用4*8=32bit,通过 P/32 就可以计算出该电话号码在 bitmap 数组中的下标,从而可以确定它对应的 bit 在数组中的位置。

第二步 ,通过 P%32 就可以计算出这个电话号码在这个int数字中具体的bit的位置。只要把1向左移 P%32 位,然后把得到的值与这个数组中的值做或运算,就可以把这个电话号码在位图中对应的位设置为1。

以00000100号码为例。

  1. 首先计算数组下标,100 / 32 = 3,得到数组下标位3。
  2. 然后计算电话号码在这个int数字中具体的bit的位置,100 % 32 = 4。取余为0左移1位,故取余为4左移5位,得到000...000010000
  3. 将位图中对应的位设置为 1,即arr[2] = arr[2] | 000..00010000。
  4. 这就将电话号码映射到了位图的某一位了。

最后,统计位图中bit值为1的数量,便能得到不同电话号码的个数了。

位图的缺点

  • 位图存储的元素大小受限于存储空间的大小。比如,1K 字节内存,能存储 8K 个值 且大小上限为 8K 的元素
    • 比如,要存储 值为 65535 的数,就需要8Kb的内存。这就有可能导致大量的内存浪费。也就是说,在数据比较稠密的情况下,位图算法能够节约存储空间,但是如果数据稀疏且值较大,存储空间同样会存在一定程度的浪费。基于此,就提出了位图的改进版 - 稀疏位图
  • 不能存储真实数据值,即只能判断数据存在不存在,且只适用于整数型数据。如果判断字符串以及其他类型数据存在与否,此时就可以使用布隆过滤器

稀疏位图

较为经典的位图压缩算法RoaringBitmap

RoaringBitmap的核心思想就是,将32位无符号整数按照高16位分桶,即最多可能有 2^16=65536 个桶,高16位的值作为其桶的索引,每个桶对应一个容器container。存储数据时,按照数据的高16位找到container(找不到就会新建一个),再将低16位放入container中。也就是说,Roaring将Bitmap从一层的连续存储,转换为一个二级的存储结构

图中示例(这只是例子):

  • 高16位为0000H的container,存储有前1000个62的倍数。
  • 高16位为0001H的container,存储有[2^16, 2^16+100)区间内的100个数。
  • 高16位为0002H的container,存储有[2×2^16, 3×2^16)区间内的所有偶数,共215个。

实际上,container容器的结构有三种类型:有序数组、未压缩位图、和行程长度编码。

  • 区间内数据较多,且分布零散,则选择(未压缩)位图。当低16位中,元素个数大于4096时,则使用(未压缩)位图
  • 区间内数据较少,且分布零散,则选择使用有序数组。当低16位中,元素个数小于4096时,采用有序数组的结构进行存储。在查找元素时,使用二分查找方法。
  • 区间内数据连续分布,则选择用Run Length Encoding编码。行程长度编码是一种无损数据压缩技术,其原理是,将连续出现的数据存储为起始值和计算两部分。比如,数据列表[1,2,3,4,5,6]存储为[1,5],表示以1开始,后面连续递增5个数值。

在进行插入和删除操作之后,需要根据元素个数进行容器转换。插入元素时,若元素个数达到4096,则需要转换为未压缩位图进行存储。删除元素时,若元素个数小于4096时,则需要转换为有序数组存储。

那么这里为什么阈值选择的是4096呢?

论文中提到,由于container中只需要存储低16位的数,那么数组存储的时候是使用2字节即16 bit的short类型存储的,那么数组中存一个数字就是2字节,存4096个数字就是 8KB

而当需要存储的数字大于4096个数后,可以使用2^16 bit 的位图来存储,2^16 bit 的位图的存储空间恒等于8KB,有某一个数字,就把那个位置为1,没有就置为0

如下图所示,显然当元素个数小于4096时,占用空间就是 元素个数 * 2B < 8KB;当元素个数大于4096时,占用空间就是8KB

Roaring 提供O(logn)的查找性能:

  1. 首先二分查找key值的高16位是否在分片(chunk)中
  2. 如果分片存在,则查找分片对应的Container是否存在
    • 如果Bitmap Container,查找性能是O(1)
    • 其它两种Container,需要进行二分查找

因此RoaringBitMap尽量节省空间,但也同时影响了效率,相当于时间换空间。

相关推荐
有你的冬天1982 小时前
LinkedList与链表
数据结构·链表
@Aurora.2 小时前
数据结构手撕--【栈和队列】
数据结构
猎猎长风3 小时前
【数据结构和算法】6. 哈希表
数据结构·算法·哈希表
callJJ4 小时前
阻塞队列的介绍和简单实现——多线程编程简单案例[多线程编程篇(4)]
java·开发语言·数据结构·java-ee·多线程编程·定时器·阻塞队列
泽02024 小时前
二叉树OJ题目
数据结构
Kay_Liang4 小时前
Java集合框架中的List、Map、Set详解
java·开发语言·数据结构·蓝桥杯·list
清羽_ls4 小时前
leetcode-哈希表
前端·数据结构·算法·leetcode·哈希表
2401_872945094 小时前
【补题】The 1st Universal Cup. Stage 15: Hangzhou D. Master of Both III
数据结构·算法
拾忆-eleven4 小时前
C++ 算法(12):数组参数传递详解,值传递、指针传递与引用传递
数据结构·c++·算法