目录
1. 散列表的概念
2. 散列函数的构造方法
2.1 直接定址法
2.2 除留余数法
2.3 数字分析法
2.4 平方取中法
3. 处理冲突的方法
3.1 开放定址法
3.1.1 线性探测法
3.1.2 平方探测法
3.1.3 双散列法
3.1.4 伪随机序列法
3.2 拉链法(链接法)
4. 散列查找及性能分析
5. 哈希的应用
5.1 位图
5.1.1 位图的概念
5.1.2 位图的实现
5.1.3 位图的应用
5.2 布隆过滤器
5.2.1 布隆过滤器的提出
5.2.2 布隆过滤器的概念
5.2.3 布隆过滤器的实现
5.2.4 布隆过滤器的应用
1. 散列表的概念
在线性表和树表的查找中,记录在表中的位置与记录的关键字之间不存在确定关系,因此,在这些表中查找记录时需进行一系列的关键字比较。这类查找方法建立在“比较”的基础上,查找的效率取决于比较的次数。
散列函数:一个把查找表中的关键字映射成该关键字对应的地址的函数,记为Hash(key) = Addr(这里的地址可以是数组下标、索引或内存地址等)。
散列函数可能会把两个或两个以上的不同关键字映射到同一地址,称这种情况为冲突,这些发生碰撞的不同关键字称为同义词。一方面,设计得好的散列函数应尽量减少这样的冲突;另一方面,由于这样的冲突总是不可避免的,所以还要设计好处理冲突的方法。
散列表:根据关键字而直接进行访问的数据结构。也就是说,散列表建立了关键字和存储地址之间的一种直接映射关系。
理想情况下,对散列表进行查找的时间复杂度为O(1),即与表中元素的个数无关。下面分别介绍常用的散列函数和处理冲突的方法。
2. 散列函数的构造方法
在构造散列函数时,必须注意以下几点:
- 散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围。
- 散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,从而减少冲突的发生。
- 散列函数应尽量简单,能够在较短的时间内计算出任意一个关键字对应的散列地址。下面介绍常用的散列函数。
2.1 直接定址法
直接取关键字的某个线性函数值为散列地址,散列函数为
H(key) = key 或 H(key) = a * key + b
式中,a和b是常数。这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。
2.2 除留余数法
这是一种最简单、最常用的方法,假定散列表表长为m,取一个不大于m但最接近或等于m的质数p,利用以下公式把关键字转换成散列地址。散列函数为
H(key) = key % p
除留余数法的关键是选好p,使得每个关键字通过该函数转换后等概率地映射到散列空间上的任意一个地址,从而尽可能减少冲突的可能性。
2.3 数字分析法
设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等:而在某些位上分布不均匀,只有某几种数码经常出现,此时应选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。
2.4 平方取中法
顾名思义,这种方法取关键字的平方值的中间几位作为散列地址。具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。
在不同的情况下,不同的散列函数具有不同的性能,因此不能笼统地说哪种散列函数最好。在实际选择中,采用何种构造散列函数的方法取决于关键字集合的情况,但目标是尽量降低产生冲突的可能性。
3. 处理冲突的方法
应该注意到,任何设计出来的散列函数都不可能绝对地避免冲突。为此,必须考虑在发生冲突时应该如何处理,即为产生冲突的关键字寻找下一个“空”的Hash地址。用Hi表示处理冲突中第i次探测得到的散列地址,假设得到的另一个散列地址H1仍然发生冲突,只得继续求下一个地址H2,以此类推,直到Hk不发生冲突为止,则Hk为关键字在表中的地址。
3.1 开放定址法
所谓开放定址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为
Hi = (H(key) + di) % m
式中,H(key)为散列函数;i = 0,1,2,…,k(k≤m-1);m表示散列表表长;di为增量序列。
取定某一增量序列后,对应的处理方法就是确定的。通常有以下4种取法:
3.1.1 线性探测法
当di = 0,1,2,…,m-1时,称为线性探测法。这种方法的特点是:冲突发生时,顺序查看表中下一个单元(探测到表尾地址m-1时,下一个探测地址是表首地址0),直到找出一个空闲单元(当表未填满时一定能找到一个空闲单元)或查遍全表。线性探测法可能使第i个散列地址的同义词存入第i+1个散列地址,这样本应存入第i+1个散列地址的元素就争夺第i+2个散列地址的元素的地址……从而造成大量元素在相邻的散列地址上“聚集”(或堆积)起来,大大降低了查找效率。
3.1.2 平方探测法
当di = ,,,,,…,,时,称为平方探测法,其中k≤m/2,散列表长度m必须是一个可以表示成4k+3的素数,又称二次探测法。
平方探测法是一种处理冲突的较好方法,可以避免出现“堆积”问题,它的缺点是不能探测到散列表上的所有单元,但至少能探测到一半单元。
3.1.3 双散列法
当di = 时,称为双散列法。需要使用两个散列函数,当通过第一个散列函数H(key)得到的地址发生冲突时,则利用第二个散列函数计算该关键字的地址增量。它的具体散列函数形式如下:
Hi = (H(key) + i*) % m
初始探测位置H0 = H(key) % m。i是冲突的次数,初始为0。在双散列法中,最多经过m-1次探测就会遍历表中所有位置,回到H位置。
3.1.4 伪随机序列法
当di = 伪随机数序列时,称为伪随机序列法。
注意:在开放定址的情形下,不能随便物理删除表中的已有元素,因为若删除元素,则会截断其他具有相同散列地址的元素的查找地址。因此,要删除一个元素时,可给它做一个删除标记,进行逻辑删除。但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用,因此需要定期维护散列表,要把删除标记的元素物理删除。
3.2 拉链法(链接法)
显然,对于不同的关键字可能会通过散列函数映射到同一地址,为了避免非同义词发生冲突,可以把所有的同义词存储在一个线性链表中,这个线性链表由其散列地址唯一标识。假设散列地址为i的同义词链表的头指针存放在散列表的第i个单元中,因而查找、插入和删除操作主要在同义词链中进行。拉链法适用于经常进行插入和删除的情况。
例如,关键字序列为{19,14,23,01,68,20,84,27,55,11,10,79},散列函数H(key)=key % 13,用拉链法处理冲突,建立的表如图所示。
4. 散列查找及性能分析
散列表的查找过程与构造散列表的过程基本一致。对于一个给定的关键字key,根据散列函数可以计算出其散列地址,执行步骤如下:
初始化:Addr = Hash(key);
- 检测查找表中地址为Addr的位置上是否有记录,若无记录,返回查找失败;若有记录,比较它与key的值,若相等,则返回查找成功标志,否则执行步骤2。
- 用给定的处理冲突方法计算“下一个散列地址”,并把Addr置为此地址。转入步骤①。
例如,关键字序列{19,14,23,01,68,20,84,27,55,11,10,79}按散列函数H(key) = key % 13和线性探测处理冲突构造所得的散列表L如图所示。
给定值84的查找过程为:首先求得散列地址H(84)=6,因L[6]不空且L[6]≠84,则找第一次冲突处理后的地址H1=(6+1)%16=7,而L[7]不空且L[7]≠84,则找第二次冲突处理后的地址H2=(6+2)%16=8,L[8]不空且L[8]=84,查找成功,返回记录在表中的序号8。
给定值38的查找过程为:先求散列地址H(38)=12,L[12]不空且L[12]≠ 38,则找下一地址H1=(12+1)%16=13,由于L[13]是空记录,故表中不存在关键字为38的记录。
查找各关键字的比较次数如图所示。
平均查找长度ASL为
ASL = (1*6 + 2 + 3*3 + 4 + 9) / 12 = 2.5
对同一组关键字,设定相同的散列函数,则不同的处理冲突的方法得到的散列表不同,它们的平均查找长度也不同,本例与上节采用拉链法的平均查找长度不同。
从散列表的查找过程可见:
- 虽然散列表在关键字与记录的存储位置之间建立了直接映像,但由于“冲突”的产生,使得散列表的查找过程仍然是一个给定值和关键字进行比较的过程。因此,仍需要以平均查找长度作为衡量散列表的查找效率的度量。
- 散列表的查找效率取决于三个因素:散列函数、处理冲突的方法和装填因子。
装填因子。散列表的装填因子一般记为α,定义为一个表的装满程度,即
n—表中记录数,m—散列表长度。
散列表的平均查找长度依赖于散列表的装填因子α,而不直接依赖于n或m。直观地看,α越大,表示装填的记录越“满”,发生冲突的可能性越大,反之发生冲突的可能性越小。
5. 哈希的应用
5.1 位图
5.1.1 位图的概念
给40亿个不重复的无符号整数,没排过序。给一个无符号整数,如何快速判断一个数是否在这40亿个数中。
解决方法:
- 遍历:O(N)
- 排序:O(NlogN) + 二分查找:O(logN)
- 位图
1KB = 1024Byte
1MB = 1024KB
1GB = 1024MB = 1024*1024*1024Byte = 10 7374 1824Byte ≈ 10亿Byte
40亿个整数占用40亿*4 = 160亿Byte,即16GB,将它们全部存储是不可能的,前两种方法不可行。
数据是否存在是两种状态,可以用一个比特位表示这种状态,1表示存在,0表示不存在。
位图就是哈希表直接定址法的变形。用位图表示{ 1,3,7,4,12,16,19,13,22,18 }中的元素是否存在:
上述数组中最大的元素是22,所以我们要表示0~22之间的元素是否存在,理论上只需要开辟23个bit即可,但是内存无法按bit开辟(除了位段),所以我们以Byte为单位开辟空间。
如何知道数据映射到哪里呢,以19为例:
- 先num/8,算出num映射到哪个char里。19/8=2,19在_bits[2]这个char中。
- 再num%8,算出num在这个char中的哪一位(从0开始,从后往前数)。19%8=3,19在_bits[2]的第3位。
5.1.2 位图的实现
template<size_t N>
class bitset
{
public:
//构造函数
bitset()
{
_bits.resize(N / 8 + 1, 0);//N/8只保留整数部分,为防止空间不够,再+1
}
//将x映射的bit置1
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] |= (1 << j);
}
//将x映射的bit置0
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);
}
//检测x映射的bit是否为1
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
private:
vector<char> _bits;
};
5.1.3 位图的应用
5.1.3.1
给定100亿个整数,设计算法找到只出现一次的整数?
解决方法:
- 2位位图:00表示没有出现;01表示出现1次;10表示出现2次及以上。1Byte能表示4个数字的出现次数,但是需要修改class bitset的代码。
- 2个位图:也是用两个bit表示数字的出现次数,但是是把两个位图拼起来,可以直接复用class bitset的代码。
template<size_t N>
class bitset
{
public:
//构造函数
bitset()
{
_bits.resize(N / 8 + 1, 0);//N/8只保留整数部分,为防止空间不够,再+1
}
//将x映射的bit置1
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] |= (1 << j);
}
//将x映射的bit置0
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);
}
//检测x映射的bit是否为1
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
private:
vector<char> _bits;
};
template<size_t N>
class twobitset
{
public:
//修改x的出现次数的状态
void set(size_t x)
{
//00 -> 01
if (_bs1.test(x) == false && _bs2.test(x) == false)
{
_bs2.set(x);
}
//01 -> 10
else if (_bs1.test(x) == false && _bs2.test(x) == true)
{
_bs1.set(x);
_bs2.reset(x);
}
//10不变
}
//打印只出现一次(01)的整数
void Print()
{
for (size_t i = 0; i < N; ++i)
{
if (_bs2.test(i))//因为没有11的状态,所以只要第二个bit是1,那就是01的状态
{
cout << i << endl;
}
}
}
public:
bitset<N> _bs1;
bitset<N> _bs2;
};
5.1.3.2
给两个文件,分别有100亿个整数,我们只有1G内存,如何找到两个文件交集?
解决方法:
- 将一个文件的数字读取到内存的一个位图中,再读取另一个文件,判断其中的数字在不在位图中。这样得到的交集可能会有重复的数字,需要去重;或者,每次找到交集值,都将上面位图对应的值设置为0可以解决找到交集有重复值的问题
- 读取文件1的数据映射到位图1,读取文件2的数据映射到位图2。2个位图的对应的位之间进行按位与运算,等于1,则该位对应的数字属于交集。
5.1.3.3
1个文件有100亿个int,1G内存,设计算法找到出现次数不超过2次的所有整数。
解决方法类似5.3.1:00表示没有出现;01表示出现1次;10表示出现2次;11表示出现3次及以上。不超过2次——01 10。
5.2 布隆过滤器
5.2.1 布隆过滤器的提出
我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉
那些已经看过的内容。问题来了,新闻客户端推荐系统如何实现推送去重的? 用服务器记录了用
户看过的所有历史记录,当推荐系统推荐新闻时会从每个用户的历史记录里进行筛选,过滤掉那
些已经存在的记录。 如何快速查找呢?
- 用哈希表存储用户记录,缺点:浪费空间。
- 用位图存储用户记录,缺点:位图一般只能处理整型,如果内容编号是字符串,就无法处理了。
- 将哈希与位图结合,即布隆过滤器。
5.2.2 布隆过滤器的概念
布隆过滤器是由布隆(Burton Howard Bloom)在1970年提出的 一种紧凑型的、比较巧妙的概
率型数据结构,特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存
在”,它是用多个哈希函数,将一个数据映射到位图结构中。此种方式不仅可以提升查询效率,也
可以节省大量的内存空间。
假设有3个哈希函数。
数据"string"对应的哈希值分别为2、3、7,把这3个位置的bit置1:
数据"vector"对应的哈希值分别为1、3、5,把这3个位置的bit置1:
查找数据"list"是否存在:数据"list"对应的哈希值分别为1、4、5,而4这个bit位为0,说明没有任何一个值映射到这个bit位上,因此"list"一定不存在。
查找数据"deque"是否存在:数据"deque"对应的哈希值分别为2、3、5,这3个bit位都为1,但是只能说明"deque"可能存在。因为这3个bit位可能都被其他数据置1了,即使"deque"不存在,"deque"对应的哈希值的bit位也可能全为1。
布隆过滤器不能直接支持删除工作,因为在删除一个元素时,可能会影响其他元素。比如:删除上图中"vector",如果直接将该元素所对应的bit位置0,"string"也被删除了,因为这两个元素在多个哈希函数计算出的bit位上刚好有重叠。
一种支持删除的方法:将布隆过滤器中的每个比特位扩展成一个小的计数器,插入元素时给k个计数器(k个哈希函数计算出的哈希地址)加一,删除元素时,给k个计数器减一,通过多占用几倍存储空间的代价来增加删除操作。
缺陷:
- 无法确认元素是否真正在布隆过滤器中
- 存在计数回绕
来源:详解布隆过滤器的原理,使用场景和注意事项 - 知乎
5.2.3 布隆过滤器的实现
template<size_t N>
class bitset
{
public:
//构造函数
bitset()
{
_bits.resize(N / 8 + 1, 0);//N/8只保留整数部分,为防止空间不够,再+1
}
//将x映射的bit置1
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] |= (1 << j);
}
//将x映射的bit置0
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);
}
//检测x映射的bit是否为1
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
private:
vector<char> _bits;
};
struct BKDRHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (long i = 0; i < s.size(); i++)
{
size_t ch = s[i];
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ ch ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ ch ^ (hash >> 5)));
}
}
return hash;
}
};
struct DJBHash
{
size_t operator()(const string& s)
{
size_t hash = 5381;
for (auto ch : s)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
//N:最多会插入key数据的个数
template<size_t N,
class K = string,
class Hash1 = BKDRHash,
class Hash2 = APHash,
class Hash3 = DJBHash>
class BloomFilter
{
public:
void set(const K& key)
{
size_t len = N * _X;
size_t hash1 = Hash1()(key) % len;
_bs.set(hash1);
size_t hash2 = Hash2()(key) % len;
_bs.set(hash2);
size_t hash3 = Hash3()(key) % len;
_bs.set(hash3);
//cout << hash1 << " " << hash2 << " " << hash3 << " " << endl << endl;
}
bool test(const K& key)
{
size_t len = N * _X;
size_t hash1 = Hash1()(key) % len;
if (!_bs.test(hash1))
{
return false;
}
size_t hash2 = Hash2()(key) % len;
if (!_bs.test(hash2))
{
return false;
}
size_t hash3 = Hash3()(key) % len;
if (!_bs.test(hash3))
{
return false;
}
// 在 不准确的,存在误判
// 不在 准确的
return true;
}
private:
static const size_t _X = 4;
bitset<N * _X> _bs;
};
5.2.4 布隆过滤器的应用
给两个文件,分别有100亿个query,我们只有1G内存,如何找到两个文件交集?分别给出精确算法和近似算法。
假设平均每个query是50Byte, 100亿个query是5000亿Byte,即500GB。
近似算法:布隆过滤器(有误判)
精确算法:哈希切分