散列(hash)
散列(hash)是一种能够以常数平均时间执行插入、删除和查找的技术。但是散列无法做到元素间的有序。
基本想法#
散列的基本想法就是将元素按照一定的规则处理后,映射到散列表中的某个位置,比如是0到tablesize - 1的某个数,这样就可以保证能够快速的找到元素位置。这种映射就叫做散列函数(哈希函数)。但是要保证任意两个不同的元素也能够被映射到不同的位置是几乎不可能实现的,因为在数学逻辑中,等于是将一个大的集合映射到一个小的集合中,两个不同大小的集合要实现一对一的映射关系是不可能的。
那么剩下的问题就是如果出现了冲突要怎么解决,以及如果来确定散列表的大小。
散列函数#
最容易想到的散列函数莫过于直接使用元素值来和tablesize取模,但如果元素具有某些不理想的性质,就不是那么好了。比如tablesize取10,而元素恰好很多个位上是0,那么直接取模就是一个坏的选择。相对好的方法是控制表的大小是一个素数,例如tablesize取11,那对于上述的元素也可以很好的平均分配位置。
另外,如果元素是不可以直接运算的,比如是字符串类型,那么散列函数的设计就需要考虑更多的因素,比如可以取字符的ASCII码,或者用字符串的字符数量等等,但这些都不是很好的选择。写出一个散列函数并不难,但是写出一个好的散列函数其实是非常困难的。好的散列函数能做到的是尽可能快而均匀的将元素分配到散列表中。一个散列函数能被称为好的散列函数,一定是在某种使用场景下,结合了场景的特性设计出来的。
在c++11之后,散列函数可以使用模板对象来表示。
template<typename Key>
class hash {
public:
size_t operator()(const Key& x) const;
};
这个模板提供了如int
和string
这种标准类型的默认实现,也可以通过重载来实现自定义的散列函数,例如:
template<>
class hash<std::string> {
public:
size_t operator()(const std::string& key) {
size_t hashVal = 0;
for(char ch : key) {
hashVal = 37 * hashVal + ch;
}
return hashVal;
}
};
分离链接法#
分离散列表是一种解决冲突的做法,其做法是将冲突的元素以链表的形式串起来,标准库中的unordered_map
就是采用了这种方式。这种方式对空间的要求比较大,因为这些表双向链接且浪费空间,如果空间比较吃紧,那么最好还是不要使用这种形式。
为了执行一次搜索,先使用散列函数确定是在哪一个链表中,然后再在这个链表中执行一次遍历查找。执行插入时,也是先通过散列函数找到要插入的链表,并且检查是否已经有了该元素,如果没有那么就将这个新元素插入到开头位置。
为什么是开头位置?这是基于这样一种事实:往往新插入的元素最有可能在不久就被访问到。
template<typename Type>
class HashTable {
public:
explicit HashTable(int size = 101);
bool contains(const Type& x) const;
void makeEmpty();
bool insert(const Type& x);
bool insert(Type&& x);
bool remove(const Type& x);
private:
bool rehash();
size_t myHash(const Type& x) const;
private:
std::vector<std::list<Type>> m_table;
int m_size;
};
类型size_t
是系统定义的无符号整型,用于表示对象的大小。因此,可用于存储数组的下标。通过调用散列函数对象生成一个size_t
类型的值,然后换算成散列表适当的数组下标,正如myHash
的实现所要做的。
template<typename Type>
inline size_t HashTable<Type>::myHash(const Type& x) const {
static std::hash<Type> hf;
return hf(x) % m_table.size();
}
下面要实现散列表的插入和移除等操作,首要的前提是元素必须支持相等运算符操作(operator==),因为散列表的插入和删除操作都需要判断当前的元素是否存在。
template<typename Type>
inline bool HashTable<Type>::contains(const Type& x) const {
auto& which_list = m_table[myHash(x)];
return std::find(which_list.begin(), which_list.end(), x) != std::end(which_list);
}
template<typename Type>
inline void HashTable<Type>::makeEmpty() {
for(auto list : m_table) {
list.clear();
}
}
template<typename Type>
inline bool HashTable<Type>::insert(const Type& x) {
auto& which_list = m_table[myHash(x)];
if(std::find(which_list.begin(), which_list.end(), x) == std::end(which_list)) {
return false;
}
which_list.push_back(x);
if(++m_size > m_table.size()) {
rehash();
}
return true;
}
template<typename Type>
inline bool HashTable<Type>::remove(const Type& x) {
auto& which_list = m_table[myHash(x)];
auto iter = std::find(which_list.begin(), which_list.end(), x);
if(iter == std::end(which_list)) {
return false;
}
which_list.erase(iter);
--m_size;
return true;
}
除了使用链表外,也可以使用其他的合理的方案来解决冲突,比如一棵树甚至是另一个散列表都可。不过我们期望的是,如果散列表足够大,而散列函数又是好的,那么每个链表都是比较短的,也就没有必要再去尝试复杂的手段了。
散列表中定义了一个概念叫做负载因子(load factor),表示散列表中的元素个数和该表大小的比值。如果我们将其记作是λ,那么每条链表的平均长度为λ,执行一次查找的代价就是计算散列函数值的常数时间加上遍历对应链表的时间,一次失败的查找平均需要遍历的就是λ个节点,一次成功的查找则平均需要遍历1+(λ/2)个节点。于是能够得到这样的结论,散列表的大小其实并不重要,重要的是负载因子。在分离链接法的散列表中的一般做法都是将表的大小大致和元素的个数差不多(即让λ≈1),当负载因子超过了1时,就执行一次rehash
的过程,扩大散列表的大小。
不使用链表的散列表#
分离链接法的缺点在于使用到了一些链表,而链表在给新元素分配地址时是需要相对较长时间的,这就拖慢了散列表的速度。另外一种不使用链表解决冲突的方式是尝试表中的另外的一些单元,直到找到一个空的单元为止,例如对于表中单元h0(x),h1(x),h2(x) ...,相继被尝试,其中hi(x) = (hash(x) + f(i)) mod tablesize, f(i)
是解决冲突的方法。这种表称作探测散列表,一般来说,这种表的所需大小要比分离链接散列表大,其负载因子λ应该低于0.5。
线性探测法#
顾名思义,线性探测法就是解决冲突的方法f
是i的线性函数,最典型的就是f(i)=i
,相当于在出现冲突是,依次尝试下一个单元以找出一个空的单元。但是这样做并不是一个好办法,首先是依次遍历的耗时较多,更糟糕的是,采用这种方法容易导致即使表比较空,仍会使数据相对集中到一个区域,称为一次聚集
。
平方探测法#
平方探测法是为了消除线性探测法中一次聚集问题的冲突解决方法,即冲突解决函数f(i)
是以平方的形式进行探测。
空表 | 插入89后的表 | 插入18后的表 | 插入49后的表 | 插入58后的表 | 插入69后的表 | |
---|---|---|---|---|---|---|
0 | 49 | 49 | 49 | |||
1 | ||||||
2 | 58 | 58 | ||||
3 | 69 | |||||
4 | ||||||
5 | ||||||
6 | ||||||
7 | ||||||
8 | 18 | 18 | 18 | 18 | ||
9 | 89 | 89 | 89 | 89 | 89 |
当插入49时,发现已经有了89,那么第一次探测f(i=1)=1
,在9的位置上加1,回到0行的位置,此时0位置是空的,那么49就可以放到这个位置。再插入58时,已经有了18,i=1时发现这个位置上也有了89,再试探f(i=2)=4
,在原有位置上加上4是2行,是个空位置,同样的,可以探测到69放在了3行的位置。
在使用线性探测时,务必要保证负载因子在0.5以下,且表的大小应该是个素数,否则一旦表被填满了一半且表的大小还不是素数,使用平方探测法就无法保证一定能找到空位置了。
另外,在探测散列表中不能使用常规的删除操作,因为在删除时可能会对其他的元素产生影响,其他元素可能是绕过当前元素到达的位置,一旦直接删除这个位置成了空位置,那么依赖这个位置的元素都将无法查找到。所以在探测散列表中一般使用懒惰删除。
虽然平方探测法解决了一次聚集的问题,但是hash到同一个位置的元素都不可避免地会探测相同的备选位置,这称作二次聚集,这是理论上的小遗憾。
双散列#
双散列的意思就是在第一次hash得到的位置如果冲突的话,会在此基础上再次进行hash,这可以解决上述二次聚集的问题。常用的双散列方式是f(i)=ihash2(x)
,也就是将第二个散列函数应用到x,并在距离hash2(x)、2hash2(x)、...等位置进行探测。
自然,第二个散列函数是非常重要的,如果选择的不好将会是毁灭性的。例如,将99这个数插入到前面的散列表中,如果hash2(x) = x mode 9
,那么二次的散列将毫无作用,也就是说,第二次的散列函数不应该能取得0值,如hash2(x) = R - (x mod R)
这类会取得比较好的效果,其中R
是一个小于散列表大小的素数。
还有我们一再强调的表的大小要是素数,还是上面这个表的例子,这张散列表只有10个位置,如果我们用hash2(x) = R - (x mod R), R = 7
来进行二次散列,在第一次插入23时会与58冲突,于是hash2(23) = 7 - (23 mode 7) = 5,还是原来的位置,也就是说对于这张散列表永远无法再插入23这类值。
再散列(rehash)#
我们一再强调负载因子的重要性,随着元素的不断插入,负载因子只会不断变大,终会超出我们的理想值,这时候就需要进行再散列,也就是将散列表扩大。一种常用的方法是建立一个约两倍大小的散列表,重新计算各个元素的值再填入新表中。例如,对于上述10个位置的表,可以扩大到23个位置大小的表(这是原表两倍大小的最接近的素数),然后将之前的元素重新经过hash计算放入新表中,最后销毁原表。
无疑这个过程是非常昂贵的(运行时间在O(N)),不过好在这个过程并不会时常地发生,实际效果并没有想象的那么差。
常数时间访问的散列表#
迄今为止,我们考察的散列表都会有这样的性质:在合力的负载因子和适当的散列表下,插入、删除和查找操作的平均花费开销都是在O(1)的。
我们总期望能在最坏的情况下也获得较高的性能,例如对于分离链接法,如果负载因子是1,要查找一个元素就是经典的球箱问题(N个球被随机地放入了N个箱子中,求放球最多的箱子中球的个数是多少),答案是Θ(logN/log logN)不知道怎么算的,是log级别的时间消耗。
如果想要获得一个最坏期望也是O(1)的开销,就需要使用到下述的解决方案。
完美散列#
假设有多少个元素是已知的,共N个,那么如果分离链接法能保证每个链表的项数都最多有常数个,那么就可以得到一个最坏期望也是O(1)的开销。但如果要达成这种目标,有两个基本问题需要解决:
- 链表的个数可能过大。
- 即使链表足够多,仍有概率发生冲突。
第二个问题原则上可以通过以下方法解决:假设此时的散列表足够大,能够保证至少有1\2的概率不发生冲突,也就是说表中有至少一半是空的。那么如果此时发生冲突了,我们就直接清空该表,选择一个无关的散列函数重新进行散列,如果还冲突,就再选一个不同的无关散列函数,...,直至不存在冲突。那么尝试的期望次数就可以保证最多为2(成功率是1\2)。如何得到这些额外的散列函数,在通用散列中再描述。这里就假定我们已经解决了第二个问题。
还剩下第一个问题,需要多大的散列表呢?结论是Ω(N²)。显然这个大小是不切实际的。
退而求其次,如果不能保证在主散列表中有N²个位置的话,我们可以选择再用一个散列表来代替分离链接法中的链表。
在2位置和6位置有两个元素,那么2位置和6位置的二级散列表就有4(2²)个位置,同理,9位置有三个元素,那二级列表就有9(3²)个位置。每个二级散列表都会用不同的散列函数来构造直到不再发生冲突,如果冲突的次数超过了要求,则主散列表也可以再进行重新构造。
杜鹃散列#
在球箱问题中,如果将N个球随机放入N个箱子中,那么含球的最多的箱子中的期望球数是Θ(logN/log logN)。这个问题被数学家们透彻地研究过,并在20实际90年代中期证明了下面的结论:
如果在每次投掷中随机选取两个箱子且将元素放入到较空的箱子中,则放了最多个球的箱子的期望球数只有Θ(log logN),这是一个比经典结论小的数。许多算法和结构从这种“双选威力”中得到了灵感。
杜鹃散列就是其中一种,在杜鹃散列中始终保持有两个多半为空的散列表,并且我们可以通过两个独立的散列函数,将元素分配到每个表中的一个位置。也就是说,杜鹃散列保持了这一不变性:一个元素总会被存储在通过两个散列函数计算出的位置之一。
现在有以下元素依次插入,用(x,y)表示其计算出的两个散列位置。
元素 | 位置 |
---|---|
A | (0,2) |
B | (0,0) |
C | (1,4) |
D | (1,0) |
E | (3,2) |
F | (3,4) |
在插入B时,发现第一个表中的位置已经被A占了,有两种选择,一是直接放到第二个表中的位置,二是移动A,标准杜鹃散列选择的是第二种方法,将A移动到第二张表中的位置,然后将B放入第一张表的位置;在插入D和F时也是同样的操作,不过插入F要复杂一点,也就是需要多次的递归操作:将E拿出来再把F插入到3位置,发现E的第二张表中的位置被A占了,那就把A再拿出来再把E放进去,查找A的第一张表中的位置,发现被B占了,于是将B拿出来,把A放进去,再把B放入到第二张表中的位置,此时才完成了全部的插入。
但是有一个问题,如果我们现在又要插入一个元素G(1,2)
,将会使整张表陷入无限的循环。
好在如果负载因子在0.5以下,发生循环的概率是很低的,并且如果出了一定的次数仍没有成功插入的话,我们可以选择使用新的散列函数来重建散列表。
杜鹃散列还有许多的推广,比如一次使用更多的表,譬如3个或4个;或是允许在一个位置存储多个关键词,这可以增加空间的利用率并使得插入更容易进行。
此外,杜鹃散列常常是作为拥有两个(或更多)的散列函数的一个大表来实现,这些散列函数探测整个大表,如果存在一个可用位置,一些变化的做法是尝试将一项立即放入到二级散列表中,而不是执行一系列的位置替换。
跳房子散列#
跳房子散列的思路是通过一个预先确定的、在计算机结构体系的基础上优化的常数,来为探测序列的最大长度确定一个界限范围。
如果我们要插入一个新元素时,且这个元素的位置离它的散列位置超出了设定的边界,那么会将边界内的可能潜在项逐出,再把这些逐出的项放到离它们原有位置不太远的地方。
我们记MAX_DIST
为最大的探测序列边界,这意味着元素x必须在hash(x)、hash(x)+1、hash(x)+2、...、hash(x)+(MAX_DIST-1)的某个位置上被找到。为了有效地处理逐出,需要记录这样的信息,即对任意位置上的元素,其备选位置上是否已被其他散列到相同位置的元素占据。
假设有以下元素依次插入,取MAX_DIST=4,
element | hash |
---|---|
A | 7 |
B | 9 |
C | 6 |
D | 7 |
E | 8 |
F | 12 |
G | 11 |
Hop[i]记录了散列到当前位置的元素在边界内的备选位置的占用情况。在MAX_DIST=4的情况下,C被散列到了6行,对应的Hop[6]的第一位记作1说明这个位置被占了;A被散列到了7行,因为D也散列到了7行,所以Hop[7]的前两位是1,表示从7行开始的两个位置被散列到7行的A和D占了。
现在我们要再插入一个散列到了9的元素H,但是9位置已经被B占了,按照线性探测的话,H放到13的位置上超出了设定的MAX_DIST值,于是我们尝试是否可以将其他的元素移动到13位置上,能够移动到13位置上的只能是散列到10、11、12、13位置的值,依次查看这些位置上的Hop值,Hop[10]显示没有散列到10位置的元素、Hop[11]显示有一个元素G,那么我们就可以将G移动到13位置,更新Hop[11],再将H放到11位置上,更新Hop[9]。
跳房子散列可以在负载因子较高的场景下依然有不错的表现。
通用散列函数#
散列函数的两个基本性质:
- 散列函数必须是常数时间内可计算的(即与散列表中的项数无关)。
- 散列函数必须在数组所包含的位置之间均匀地分布元素。
假设将非常大的整数映射到从0到M-1的较小整数,令p是一个比最大输入关键字还要大的素数,则通用散列函数可以是下列的形式:
H={Ha,b(x)=((ax+b) mod p) mod M, 其中1≤a≤p-1, 0≤b≤p-1}
H是一个函数簇,可以看出存在p(p-1)种可能的散列函数,例如:
H3,7(x)=((3x+7) mod p) mod M
H4,1(x)=((4x+1) mod p) mod M
选择一个有利于计算的素数是有意思的,231-1就是这样的一个数,这种形式的素数称为梅森素数(Mersene prime),还有其他如25-1、261-1、289-1,这些素数可以通过一次移位和一次减法实现。涉及梅森素数的模运算也可以通过一次移位和一次减法实现,设r = y mod p
,若用(p + 1)去除y,则y=q'(p+1)+r'
,其中q'和r'分别为商和余数,因此,r = q'(p + 1) + r' mod p
,而(p+1)=1 mod p,于是r=q' + r' mod p
,称为Carter-Wegman技巧。
constexpr int DIGS = 31;
constexpr int MERSEN = (1 << DIGS) - 1;
int universalHash(int x, int a, int b, int M) {
long long hash_val = static_cast<long long>(a) * x + b;
hash_val = (hash_val >> DIGS) + (hash_val & METSEN);
if(hash_val >= MERSEN) {
hash_val - MERSEN;
}
return static_cast<int>(hash_val) % M;
}
由于p+1一定是个2的幂次方,右移操作可以(p+1)除y的商,与操作则计算出了余数。由于计算出的余数可能和p一样大,所以最后再进行一次比较,如果比p大,就再减去p。
字符串的通用散列函数也是存在的。首先选择一个大于M的任意素数p(且大于最大字符码),然后通过字符串散列函数,在1到p-1之间随机选择乘数,并返回0到p-1之间的中间散列值,最后应用一个通用散列函数来生成在0到M-1之间的最终的散列值。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!