文章目录
- 前言
- 位图
- 什么是位图
- 简单实现一个自己的位图
- 位图的应用场景
- 布隆过滤器
- 位图的缺陷及布隆过滤器的提出
- 布隆过滤器的概念
- 简单实现一个自己的布隆过滤器
- 布隆过滤器的优缺点
- 布隆过滤器的应用场景
- 海量数据处理
前言
哈希思想的在实际中的应用除了哈希表这个数据结构之外还有其他的一些数据结构,比如说位图、布隆过滤器、基数树等,在了解和学习哈希表之后,个人又去了解了位图和布隆过滤器,这里对这两个数据结构的概念、实现、应用场景、优缺点做一个简单的总结,这是第一部分;第二部分就是总结一下,当遇到海量数据处理问题时该怎么用哈希思想来进行解决(这里说的海量数据不是指大数据那个级别,而是解决问题时数据量过大,无法在内存中直接处理的场景)。
这里附上之前总结哈希表时写的文章《由浅入深一步步了解什么是哈希(概念向)》。
位图
什么是位图
位图是一种基于哈希思想实现的数据结构,数据结构这个东西是为了解决实际问题创造出来,因此结合实际问题问题来理解什么是位图比单纯讲概念更有价值和更有效率。
这里是一道腾讯的面试题:给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中?
首先来分析一下题目:
- 在内存中存储一个无符号整数需要 4 Byte,有40亿个就需要消耗 160亿 Byte。
- 在计算机中,1 GB = 1024 MB,1 MB = 1024 KB,1 KB = 1024 Byte,经过换算之后,可以发现要想在内存中完全存的下这40亿个数字,至少得 14.9 GB 的空间。
- 这个级别的空间消耗可以推断出这份数据大概率不会在内存中而是存储在外设上,所以在解决问题的时候就需要将数据读取到内存中,同时考虑到读取数据过程中内存会满的问题,就需要分段式读取。
【方法一】
分析完题目之后,首先想到的第一个解决问题的方法就是遍历,将目标值和读取到内存中的数据依次比较,相等就说明存在,反之则不存在,这个方法的时间复杂度是
O
(
N
)
O(N)
O(N),空间复杂度取决与内存的承受能力。
如果说只进行一次判断的话,这个方法其实还好,如果要进行多次判断这个问题就有巨大的缺陷了,因为每次判断都要将40亿个数字遍历一遍,遍历就要读取数据,读取数据的本质其实就是内存在向外设拿数据,数据读取的速度受限于内存和外设之间的数据传输速率,同时随着内存空间的减少得承担计算机性能下降的风险,总的来说是不推荐的。
【方法二】
说到快速判断,这里可以考虑二分查找算法,它的时间复杂度是
O
(
log
N
)
O(\log N)
O(logN),但是二分查找有个前提,数据得是有序的,题目中说过数据没有排序,这里就需要对数据进行排序,选用的排序算法是外排序的归并排序,时间复杂度是
O
(
N
log
N
)
O(N \log N)
O(NlogN);虽然这个方法在二次及以上判断时同样面临着数据读取的消耗问题,但是由于二分查找的时间复杂度足够小,查找的速度还是很快的。
这个方法虽然克服了方法一的缺点,但是二分查找算法要求数据是有序的这个前提本身就是个缺陷,假设对有序数据插入新数据之后不一定保证插入之后数据仍然是有序的,这时候就得重新排序了,因此这个方法也不是最优解。
【方法三】
第三个方法也是较为不错的解决方法——位图。
在这个问题中,我们可以将无符号整数的数据范围中的从 0 0 0 到 2 32 − 1 2^{32}-1 232−1(约等于42亿9千万多)的这 2 32 2^{32} 232 个数字看作全集,题目中给出的这40亿个不重复的无符号整数是这个全集的子集。
判断一个子集中的数字在或不在这个全集中,只有两种状态,这时候我们就可以用一个二进制位(比特位)来表示这个数字的状态,“1” 就是 “在”,“0” 就是 “不在”,换言之,只需要 2 32 2^{32} 232 个比特位就能够表示题目中给出的这40亿个不重复的无符号整数存储状态
这时候我们可以先遍历一遍子集中的所有数字来获取它们的存储状态,而有了这么一组二进制位之后,往后我们需要判断某个数字在或不在子集中时就从文件中查找转变成到这组比特位中判断这个数字对应的比特位是 0 还是 1,从而快速地得到结果,这个操作的时间复杂度是 O ( 1 ) O(1) O(1),而且 2 32 2^{32} 232 bit 经过换算也才 512 MB,对于内存来说,这完全是小意思;
此外,新增数据也不是问题,我们可以先将新增数据对应的比特位设置位 1,再写入到文件中,这样判断存不存在的效率依旧是 O ( 1 ) O(1) O(1)。
像方法三中这样的,和数据建立映射关系,用于表示数据状态的一组比特位集合,我们通常将这种结构称之为 “ 位图 ”。
简单实现一个自己的位图
像C++,Java这样的主流语言其实已经提供有位图这样的工具了,比如说,在C++中位图可以使用标准库中的 std::bitset
容器实现;在Java中位图可以使用 java.util.BitSet
类实现。我们只需要去了解怎么使用就好了,为什么还要去手搓轮子呢?
- 深入理解: 通过手动实现位图,可以更深入地理解其原理和实现细节,有助于提高对数据结构和算法的理解和掌握。
- 学习编程技能: 手动实现位图可以锻炼编程能力和技巧,提高代码设计和实现的能力。
- 定制需求: 有时候现有的位图实现可能无法满足特定的需求,手动实现可以根据具体情况进行定制和优化。
- 面试准备: 在面试中,展示手动实现位图的能力可能会给面试官留下深刻印象,展示出对基本数据结构的扎实掌握。
自己的实现位图当然不需要像标准库中提供的那样复杂,只需要实现主要的功能就可以了,因为个人主修C++,所以这里的实现主要是模拟C++的STL中的 bitset
容器来实现,大致框架如下:
#include <cassert>
namespace MyBitSet
{
// 每个位图的大小由非类型模板参数 N 指定
template<size_t N>
class bitset
{
public:
bitset() {} // 构造函数
void set(size_t x) {} // 将x映射的比特位设置为1
void reset(size_t x) {} // 将x映射的比特位设置为0
void test(size_t x) {} // 返回x映射的比特位的状态
protected:
vector<char> _bits; // 用于存储位图数据的数组
};
}
第一步要实现的是【构造】
首先,需要考虑如何表示位图的每个元素。在C++中,最小的存储单位是字节(byte),无法直接以比特位为单位存储数据。因此,需要以字节为单位来存储每个比特位的状态,每个字节可以表示8个比特位的状态。这时候,问题就变成了从如何开辟 N 个比特位大小的位图转换成了如何根据位图的大小 N 计算需要多少个字节来存储位图数据。
计算字节数也不难,但是要注意向上取整的问题,比如说,我要开辟一个拥有9个比特位的位图,但是一个字节才8个比特位,这时候我们就需要两个字节的空间。
由此我们可以知道,位图中实际的比特位个数 ≥ 需求的 N 个比特位的,为了防止越界访问,这就要求在后续操作中要进行越界检查。
bitset() // 构造函数
{
// 向上取整,有时候会多开辟一些比特位
_bits.resize(N / 8 + 1, 0);
}
第二步要实现的是【set】和【reset】
要将某个数字映射的比特位设置为 1 或 0,首先就得通过这个数字找到这个比特位。
假设以数字 27 为例,要在下面这个位图中找到数字 27 映射的比特位。
步骤1:先找到这个比特位在 vector 的哪个元素上,假设是第 i
个,由于比特位是8个为一组,因此,i = 27 / 8 = 3
,即在 vector[3]
上。
步骤2:再找到这个比特位在 vector[3]
上的哪个位置,假设是第 j
个,由于比特位是8个为一组,因此,j = 27 % 8 = 3
,即在第 3 个比特位。但是这时候新的问题产生了,数字 27 映射的比特位是 vector[3]
上从左往右数的第三个比特位还是从右往左数的第三个比特位呢?
如果你有这个疑问说明,说明你对计算机的大小端存储模式有一定了解,在小端模式下,低位字节存储在低地址,高位字节存储在高地址;在大端模式下,高位字节存储在低地址,低位字节存储在高地址。因此,在不同的存储模式下,字节的排列顺序是不同的。
但是实际上,我们不需要去在乎这个差异问题,因为一般来说语言是有可移植性,它能够屏蔽底层差异,从而一份代码既能在大端存储模式的机器上运行,也能在小端存储模式下运行,比如说C/C++的<<
(左移)运算符就是默认往高地址位移动,>>
(右移)运算符就是往低地址移动,j == 3
,就表示是从低往高数的3个比特位(vector[3] << 3
),至于底层实际怎样移动,我们不需要关心。
能够找到这个比特位了,下面就是位运算的问题了,【set】在如何不影响位图中其他的比特位的情况下将某个比特位设置位 1,【reset】在如何不影响位图中其他的比特位的情况下将某个比特位设置位 0。
// 将x映射的比特位设置为1
void set(size_t x)
{
// 越界检测
assert(x <= N);
int i = x / 8;
int j = x % 8;
_bits[i] |= (1 << j);
}
// 将x映射的比特位设置为0
void reset(size_t x)
{
// 越界检测
assert(x <= N);
int i = x / 8;
int j = x % 8;
_bits[i] &= ~(1 << j);
}
第三步要实现的是【test】
和前面的类似,都是先找到比特位,但是有一点不同的是,【test】仅仅只是查看,而前面两个是修改,因此这里用的是 &
而不是 &=
。
// 返回x映射的比特位的状态
void test(size_t x)
{
// 越界检测
assert(x <= N);
int i = x / 8;
int j = x % 8;
return _bits[i] & (1 << j);
}
到这里,关于位图最核心的三个接口已经实现好了,如果有需求的话可以在这个基础之上扩展。
位图的应用场景
-
快速查找某个数据是否在一个集合中
例子: 上面提到的那道腾讯面试题就是一个典型的例子。 -
排序 + 去重
例子: 对一个包含大量重复元素的数组进行排序并去重。使用位图记录数组中出现过的元素,然后进行排序和去重操作。通过遍历位图,可以得到排序后的唯一元素集合。 -
求两个集合的交集、并集等
例子: 求两个用户的好友列表中共同的好友。将每个用户的好友列表表示为位图,然后进行位运算操作,如与操作得到共同好友,或者或操作得到两个用户的所有好友。 -
操作系统中磁盘块标记
例子: 在文件系统中标记磁盘块的分配情况。
布隆过滤器
位图的缺陷及布隆过滤器的提出
位图的性能和效率确实很优秀,但位图也有很强的局限性,位图的底层实现是基于比特位的,每个比特位只能表示0或1,因此位图比较适合处理整数等固定长度的数据类型。但对于字符串等变长数据类型,位图难以处理。
为什么这么说呢?
位图将数据与比特位建立映射关系的方法类似与哈希方法中的直接地址法,即采用绝对编址的方式给一定范围内的所有数据分配一个比特位来表示数据状态,这种建立映射关系的方法对于整数来说是不存在哈希冲突的。
字符串不同于整数,字符串不能不能直接建立映射关系,必须得先经过字符串哈希函数转换成一个整数才可以建立映射关系。
问题有两点,一是经函数转换出来的整数的范围是不确定的,有可能会超出位图的范围,但是可以通过取余操作来加以限制;二是字符串的范围在不加以限制的情况下可以说是无限的,不同字符串经过字符串哈希函数有极大的概率转换成的相同的整数,换言之,会出现不同数据映射到同一个比特位的情况出现,这个就是哈希冲突,而位图无法处理哈希冲突。
于是,就有人针对位图的这个缺陷提出了一种新的结构,叫做布隆过滤器。
布隆过滤器的概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉你 “ 某样东西一定不存在或者可能存在 ”,换言之,布隆过滤器存在误判的情况。
布隆过滤器看似很高级的样子,但本质上就是位图的一个变形和延申,增加空间消耗和处理手段来尝试解决位图无法处理哈希冲突的问题。
相比起位图,数据是怎么映射到布隆过滤器中的?
布隆过滤器的底层还是一个位图,当一个数据映射到位图中时,布隆过滤器会用多个哈希函数将其映射到多个比特位,当判断一个数据是否在位图当中时,需要分别根据这些哈希函数计算出对应的比特位,如果这些被映射的比特位都被设置为1则判定为该数据存在,否则判定为该数据不存在。
同样以字符串 “hello” 和 “你好” 举个例子,假设布隆过滤器使用的哈希函数有3个。
从图中可以看到,字符串 “hello” 经过哈希函数3 和字符串 “你好” 经过哈希函数1映射到的是同一个比特位,虽然这一个比特位发生了冲突,但是这并不影响对于这两个字符串存在或不存在的判断。
那为什么说布隆过滤器存在误判的情况呢?
假设现在有一个没有建立映射关系的字符串 “张三”,可是在布隆过滤器中查询之后神奇地发现,它会返回字符串 “张三” 已经存在了的这样一个错误结果,这就是为什么说布隆过滤器是一个概率型数据结构。
通过上面的分析可以发现,布隆过滤器并没有真正的解决位图发生哈希冲突的问题,而是通过应用多个哈希函数将数据映射到多个比特位的方法来降低哈希冲突发生的概率来近似解决问题。
既然布隆过滤器存在误判,那么该如何控制误判率呢?
误判率和两个因素有关:
-
布隆过滤器的长度
布隆过滤器的长度即位图的大小,它直接影响了误判率。过小的布隆过滤器容易发生碰撞,导致误判率升高。因此,在不增加哈希函数的情况下可以通过增加位图的大小来降低误判率。通常情况下,布隆过滤器的长度越长,误判率越低。 -
哈希函数的长度
当布隆过滤器的长度一定时,哈希函数的个数的个数越多,位图中比特位被设置为1的速度就越快,同时也会增加误判率,因此,需要权衡哈希函数的个数,选择一个适当的值来保证误判率在可接受的范围内。
至于长度和个数到底该怎么设置,个人建议可以参考下面这张表。
图片来自文章《布隆过滤器设置合适指标》,表中的 k k k 指的是哈希函数的个数, p p p 指的是误判率, r r r 指的是当插入的数据量位 n n n 时,需要将布隆过滤器的长度设置为 r × n r \times n r×n。
简单实现一个自己的布隆过滤器
在了解和学习布隆过滤器过程中,个人发现应用到布隆过滤器的场景还是非常多的,只是了解概念终归是之上谈兵,纸上得来终觉浅,绝知此事要躬行,所以接下来就要手搓一个自己的布隆过滤器,搓完之后再对它的性能作一番测试。
这里实现的布隆过滤器的考量大致有以下几点:
- 这里实现的布隆过滤器用到的哈希函数个数为 3 3 3。
- 这里实现的布隆过滤器默认处理的数据的类型为字符串(
string
)。 - 假设布隆过滤器处理的数据量为 N N N,布隆过滤器的长度为 M M M,那么参考上面 的表格, M = 5 N M = 5N M=5N。
- 布隆过滤器用的位图是STL提供的
std::bitset
。 - 布隆过滤器使用的哈希函数参考自文章《各种字符串Hash函数》。
- 布隆过滤器实现的接口主要有【Set】和【Test】,至于为什么没有【Reset】后面细讲。
【代码如下】
struct BKDRHash
{
size_t operator()(const string& str)
{
register size_t hash = 0;
for (auto& ch : str)
{
hash = hash * 131 + ch;
}
return hash;
}
};
struct DJBHash
{
/// @brief DJB Hash Function
/// @detail 由Daniel J. Bernstein教授发明的一种hash算法。
size_t operator()(const string& str)
{
if (str == "")
return 0;
register size_t hash = 5381;
for (auto& ch : str)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
struct APHash
{
/// @brief AP Hash Function
/// @detail 由Arash Partow发明的一种hash算法。
size_t operator()(const string& str)
{
register size_t hash = 0;
for (size_t i = 0; i < str.size(); i++)
{
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ str[i] ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ str[i] ^ (hash >> 5)));
}
}
return hash;
}
};
template<size_t N, // 处理数据个数
class T = string, // 布隆过滤器处理的数据类型
class Hash1 = BKDRHash, // 第一个哈希函数
class Hash2 = DJBHash, // 第二个哈希函数
class Hash3 = APHash> // 第三个哈希函数
class BloomFilter
{
public:
void Set(const T& key)
{
_bs->set(Hash1()(key) % M);
_bs->set(Hash2()(key) % M);
_bs->set(Hash3()(key) % M);
}
bool Test(const T& key)
{
if (_bs->test(Hash1()(key) % M) == false)
return false;
if (_bs->test(Hash2()(key) % M) == false)
return false;
if (_bs->test(Hash3()(key) % M) == false)
return false;
// 存在误判(有可能3个位都是跟别人冲突的,所以误判)
return true;
}
protected:
static const size_t M = 5 * N; // 位图的长度
std::bitset<M>* _bs = new std::bitset<M>; // 在堆上申请位图的空间
};
布隆过滤器不能直接支持删除工作是因为在删除一个元素时可能会影响其他元素。
现有一个布隆过滤器,其中映射(插入)了三个字符串,分别是"hello"
、"你好"
、"张三"
。
现在要删除字符串"张三"
,但是发现连带地将字符串"你好"
也删除了,这就是为什么不建议加上【Reset】接口原因。
布隆过滤器的优缺点
【优点】
- 增加和查询元素的时间复杂度为:O(K), (K为哈希函数的个数,一般比较小),与数据量大小无关。
- 哈希函数相互之间没有关系,方便硬件并行运算。
- 布隆过滤器不需要存储元素本身,在某些对保密要求比较严格的场合有很大优势。
- 在能够承受一定的误判时,布隆过滤器比其他数据结构有这很大的空间优势。
- 数据量很大时,布隆过滤器可以表示全集,其他数据结构不能。
- 使用同一组散列函数的布隆过滤器可以进行交、并、差运算
【缺点】
- 有误判率,即存在假阳性(False Position),即不能准确判断元素是否在集合中(补救方法:再建立一个白名单,存储可能会误判的数据)。
- 不能获取元素本身。
- 一般情况下不能从布隆过滤器中删除元素
- 如果采用计数方式删除,可能会存在计数回绕问题
布隆过滤器的应用场景
我们在某网站或者某游戏注册账号时都要填写用户信息,其中一般有 “用户名” 或者 “昵称” 这一项,当我们填写完之后,网站或者游戏会快速反馈一个 “该昵称未被使用” 或者 “该昵称已被使用” 的信息,这里网站或者游戏快速检测昵称是否被使用就可以应用布隆过滤器。
一般网站或者游戏都有自己的后端数据库存储和管理账号信息,这时候就可以将数据库中所有的账号的昵称或者名字映射到布隆过滤器中,这样新用户注册新账号时填写昵称就可以快速获得反馈。
由于布隆过滤器存在误判,返回未被使用的昵称一定是未被使用的,但是返回已被使用的昵称却不一定被使用了,这时候就可以直接到数据库中查询该昵称是否真的已经被使用,来解决误判问题。
至于为什么不直接到数据库中查询,而是要再加上布隆过滤器这一层主要是为了查询效率,随着网站或者游戏的体量越大,其数据库中的数据量越多,查询所花费的时间就越多,这么做反馈到用户上就是优化差、延迟高的差评,从而导致用户流失,这是难以承受的。
而结合布隆过滤器和数据库查询,可以在保证查询速度的同时,有效地减少数据库查询的压力,从而提高系统的响应速度和性能,避免用户因查询耗时而产生不满。
海量数据处理
第一道:给定一个拥有100亿个整数的数据集,设计算法如何找到其中只出现一次的整数?
题目中与统计次数相关,首先考虑使用map
容器或者哈希表,但是经过分析发现超过内存容量,无法直接处理,分析如下:
map
容器底层为红黑树,一个结点要存储三个指针、一个颜色位、一个键值对,在32位机器下一个结点消耗 24 Byte内存,处理数据集至少要
24
×
5e9
≈
1200
亿
≈
120
GB
24 \times \text{5e9} \approx 1200 \text{ 亿} \approx 120 \text{ GB}
24×5e9≈1200 亿≈120 GB 的内存空间,在64位机器下一个结点消耗 36 Byte内存,处理数据集至少需要
36
×
5e9
≈
1800
亿
≈
180
GB
36 \times \text{5e9} \approx 1800 \text{ 亿} \approx 180 \text{ GB}
36×5e9≈1800 亿≈180 GB 的内存空间。
哈希表假设底层是哈希桶,挂桶的表的大小在32位下约为 4 × 5e9 ≈ 200 亿 ≈ 20 GB 4 \times \text{5e9} \approx 200 \text{ 亿} \approx 20 \text{ GB} 4×5e9≈200 亿≈20 GB,在62位下约为 8 × 5e9 ≈ 400 亿 ≈ 40 GB 8 \times \text{5e9} \approx 400 \text{ 亿} \approx 40 \text{ GB} 8×5e9≈400 亿≈40 GB,每个结点存储一个键值对和一个指针,在32位机器下一个结点消耗 12 Byte内存,所有结点至少要 12 × 5e9 ≈ 600 亿 ≈ 60 GB 12 \times \text{5e9} \approx 600 \text{ 亿} \approx 60 \text{ GB} 12×5e9≈600 亿≈60 GB 的内存空间,在64位机器下一个结点消耗 16 Byte内存,处理所有结点至少需要 16 × 5e9 ≈ 900 亿 ≈ 90 GB 16 \times \text{5e9} \approx 900 \text{ 亿} \approx 90 \text{ GB} 16×5e9≈900 亿≈90 GB 的内存空间。
5 e 9 5e9 5e9 指的是数据集中只有一个数字出现一次,其余数据出现次数至少两次,假设是两次,就要为50亿个不重复的整数统计次数。
但是,题目只要求找到出现次数为 1 的数字,这样我们就可以数据集中的数字分成出现次数为 1 的数字和出现次数为其他的数字,然后通过两个位图组合的方式来进行处理,而两个100亿bit的位图大小总共也才2.5 GB而已,这是空间复杂度。
操作上,首先得遍历一遍数据集统计次数(如果出现次数超过 3 次就不管了),再遍历一遍位图,时间复杂度是
O
(
N
)
O(N)
O(N)。
第二道:给定A、B两个文件,其中分别存储了100亿个整数,最大可用内存只有1 GB,如何找到两个文件交集?
第二道题也是用位图处理,将文件A的数字映射到位图A中,将文件B中的数据映射到文件B中,然后同时遍历这两个位图,如果对应位置的比特位的值都为 1,那说明这个位置对应的数字就是交集的数字。
问题就在于第二道题对内存容量做出了限制,限定最大可利用内存容量只有 1 GB,两个100亿bit的位图大小至少要 2.5 GB 内存容量,这时候就可以分段处理(假设最大值是100亿),将数据分成[0, 25亿),[25亿,50亿),[50亿,75亿),[75亿,100亿]四个区间,找到4个区间的交集之后再组合就是A、B两个文件的交集了。
第三道:现有两个分别有100亿个 query 的文件,最大可用内存只有1 GB,如何找到两个文件交集?分别给出精确算法和近似算法。
题目分析
问题中的 query 指的是查询的意思,一个 query 可以理解为一个字符串,假设一个 query 大小为 50 Byte,一个文件总共有 100 亿个 query,一个文件的大小约为 500 GB,A、B两个文件总计约占 1 T的存储空间。
近似算法:布隆过滤器
- 将文件A中的 query 映射到布隆过滤器中。
- 遍历文件B中的 query,并在布隆过滤器中检查它们的存在性。
- 如果文件B中的一个 query 被布隆过滤器检测为存在,那么它就是交集之一。
- 通过这种方法找到的交集是不完全正确的,交集中的一部分是因为布隆过滤器的误判而放进来的。
精确算法:哈希切分 + 去重 + 求交集
在C++中,想要找到一个精确的交集,就需要std::set
(集合)或者std::unordered_set
(哈希表)这样的容器来处理,可是由于A、B文件都属于大型文件,哪怕去重之后都有可能超过内存容量,因此可以考虑将源文件切分成能直接被内存处理的小文件。
文件切分有着平均切分和哈希切分两种做法。
第一种做法,平均切分,又称均匀切分,指的是将源文件均匀地分割成多个子文件,每个子文件的大小大致相同。 例如,如果有两个大小约 500 GB 的文件,可以将每个文件切分成500个大小约为1 GB的子文件。这个做法的优点是切分容易实现,每个子文件的大小相近,可以保证每个子文件中的数据分布比较均匀;缺点是在查找交集时存在一个严重的问题,即会导致时间复杂度为 O ( N 2 ) O(N^2) O(N2)。
这是因为,在找到A、B两个文件的交集时,需要分别比较A文件中的每个query是否在B文件中,而由于A、B文件被切分成了多个子文件,因此需要对每个A文件中的query都与B文件中的所有query进行比较,这样的比较次数将会非常多,导致时间复杂度非常高。因此,平均切分不适用于大型文件的交集查找问题。
因此选择第二种做法,哈希切分,指的是在切分过程中,通过对数据进行哈希计算,将相同哈希值的数据划分到同一个子文件中。 例如,建立1000个小文件(尽可能让每个小文件的大小小于1 GB)同时给它们取一个编号(比如说, A 1 A_1 A1, A 2 A_2 A2, A 3 A_3 A3,……, A 999 A_{999} A999),然后遍历源文件使用哈希函数对query进行哈希计算,然后根据哈希值的范围将query划分到不同的子文件中(经过取余操作)。
就这样,经过哈希切分之后,就将A、B两个大型文件找交集的文件变成了1000组的
A
i
A_i
Ai、
B
i
B_i
Bi子文件找交集再组合的问题。
这样做的优点是文件A、B中相同的query一定会进入编号相同的子文件 A i A_i Ai和 B i B_i Bi中,找到A、B两个文件的交集时不用花费过多的力气;缺点是这样切分出来的子文件大小分布不均匀,有可能某个文件特别大(比如说,5 GB),有个文件特别小(比如说,50 MB)。
导致某个文件特别大的原因可能有两个,一是子文件中重复的query太多(重复指的是哈希值相同且query相同);二是子文件中冲突太多(冲突指的是哈希值相同但query不相同)。
当然这也是有处理方法的,这里可以不管子文件大小是否超过内存容量,统一将
A
i
A_i
Ai子文件用std::set<string> setA
容器来存储,
B
i
B_i
Bi子文件用std::set<string> setB
容器来存储,假设遇到情况一,std::set
容器会自动去重,这个完全不用担心;如果遇到情况二,也是先进行存储,如果存储过程中出现内存不足的异常,就说明文件过大,这时候就可以对这个子文件进行二次哈希切分再处理。
这样通过哈希切分源文件 + std::set
容器去重 + 在子交集组合的方式以
O
(
N
)
O(N)
O(N)的时间复杂度,在最大可用内存容量只有1 GB的情况下,完成两个大型文件找交集的问题。
【总结】
从上面这三道题来看,处理这些大规模数据,
- 考虑
map
、set
、哈希表、位图、布隆过滤器这样的数据结构能不能直接处理。 - 第一步失败,考虑使用哈希切分出子文件,然后通过数据结构来处理,最后将子问题答案进行总结就是最终结果。