散列是一种算法(通过散列函数),将大型可变长度数据集(称为键,不一定是整数)映射为固定长度的较小整数数据集。
哈希表是一种数据结构,它使用哈希函数有效地将键映射到值(表或地图ADT),以便进行高效的搜索/检索,插入和/或删除。
散列表广泛应用于多种计算机软件中,特别是关联数组,数据库索引,缓存和集合。
在这个电子讲座中,我们将深入讨论表ADT,哈希表的基本概念,讨论哈希函数,然后再讨论哈希表数据结构本身的细节。
表ADT必须至少支持以下三种操作,并且尽可能高效:
哈希表是这个表ADT的一个可能的好实现(另一个是这个)。
PS1:对于Table ADT的两个较弱的实现,您可以单击相应的链接:未排序数组或排序数组来阅读详细讨论。
PS2:在现场课程中,您可能想要比较Table ADT和List ADT的要求。
当整数键的范围很小时,例如 [0..M-1],我们可以使用大小为M的初始空(Boolean)数组A,并直接实现以下表ADT操作:
就是这样,我们使用小整数键本身来确定数组A中的地址,因此称为直接寻址。 很明显,所有三种主要的ADT操作都是O(1)。
题外话:这个想法在其他地方也有使用,例如在计数排序中。
在新加坡(截至2021年9月),公交路线编号都在[2,991]区间。
当前并不是所有的[2..991]之间的整数,例如, 没有公交线路989 - 搜索(989)应该返回错误。 我们可以引入新的公共汽车路线 x,即插入(x)或现有的公共汽车路线 y 可以不再继续,即移除(y)。
公交线路编号的取值范围很小,要记录一个编号是否存在,我们可以用一个1000个布尔值的数组。
讨论:在现实的课堂里,我们可能会讨论为什么要用1000而不是991(或者992)。
由于可能的公交路线的范围很小,为了记录数据是否存在公交线路号码,我们可以使用具有1 000大小的布尔数组(Boolean array)的DAT。
讨论:在现实生活中,我们可以讨论为什么我们使用1 000而不是990(或991)。
请注意,我们总是可以添加辅助数据,而不是只使用boolean数组来记录键的存在。
例如,我们可以使用一个关联字符串数组A来映射一个公交路线号码到它的运营商名称,例如,
A[2] = "Go-Ahead Singapore",
A[10] = "SBS Transit",
A[183] = "Tower Transit Singapore",
A[188] = "SMRT Buses", etc.
讨论:你能想到其他一些现实生活中的DAT示例吗?
使用哈希,我们可以:
如果我们有映射到卫星数据的键,并且我们也想记录原始键,我们可以使用如下一对(整数,卫星数据类型)数组实现哈希表:
但是,现在你应该注意到有些地方是不完整的......
散列函数可能且很可能将不同的键(整数或不是)映射到同一个整数槽中,即多对一映射而不是一对一映射。
例如,早些时候三张幻灯片中的h(6675 2378)= 237,如果我们想插入另一个电话号码6675 4372,我们也会遇到问题,因为h(6675 4372)= 237。
这种情况称为碰撞,即两个(或更多)键具有相同的散列值。
假设Q(n)是房间中n个人不同生日的概率。
Q(n)= 365/365×364/365×363/365×...×(365-n + 1)/ 365,
即第一人的生日可以是365天中的任何一天,第二人的生日可以是除第一人的生日之外的任何365天,等等。
设P(n)为房间中 n 个人的相同生日(碰撞)的概率。P(n)= 1-Q(n)
我们计算P(23) = 0.507> 0.5(50%)。
因此,我们只需要在有365人的座位(单元格)的房间(哈希表)中有23人(少量键),发生碰撞事件(该房间中两个不同人的生日 是365天/插槽之一)就会超过50%。
问题1:我们已经看到了一个简单的散列函数,如电话号码示例中使用的h(v)= v%997,它将大范围的整数键映射到整数键的较小范围内,但非整数键的情况如何? 如何有效地做这样的散列?
问题2:我们已经看到,通过将大范围散列或映射到更小范围,很可能会发生碰撞。 如何处理它们?
在讨论现实之前,让我们讨论理想的情况:完美的散列函数。
完美的散列函数是键和散列值之间的一对一映射,即根本不存在冲突。 如果事先知道所有的键是可能的。 例如,编译器/解释器搜索保留关键字。 但是,这种情况很少见。
当表格大小与提供的关键字数量相同时,实现最小的完美散列函数。 这种情况更为罕见。
如果你有兴趣,你可以探索GNU gperf,这是一个用C++编写的免费可用的完美哈希函数生成器,可以从用户提供的关键字列表中自动构建完美的函数(C++程序)。
int hash_function(string v){//假设1:v仅使用['A'..'Z']
int sum = 0; //假设2:v是一个短字符串
for(auto&c:v)//对于v中的每个字符c
sum =((sum * 26)%M +(c - 'A' + 1))%M; // M是表格大小
return sum;
}
分离链接法(SC)冲突解决技术很简单。 我们使用 M 个副本数据结构副本,通常是双向链接列表。 如果两个键 a 和 b 都具有相同的散列值 i,则两个键都将被附加到双链表 i 的(前/后)(在该可视化中,我们借助尾指针,用O(1)的时间将它附加到后面)。 就是这样,键(keys)将被插入的地方完全依赖于散列函数本身,因此我们也称分离链接法为封闭寻址冲突解决技术。
如果我们使用分离链接法,负载因子α= N / M描述了M个列表的平均长度,它将决定搜索的性能(v),因为我们可能需要平均探索α个元素。 由于删除(v) - 也需要搜索(v),其性能与搜索(v)相似。 插入(v)显然是O(1)。
如果我们可以将α限制为一个小常量 (如果我们知道在我们的哈希表应用中预期的最大N,那么我们可以相应地设置M,则为真),则使用分离链接法的所有搜索(v),插入(v)和删除(v)操作都将为O(1)。
对于分离连接法(SC)冲突解决方法,第一行包含M个双向链表的M“H”(头)指针。
然后,每个双向链表我包含按任意顺序散列到 i 中的所有密钥。 在数学上,所有可以表示为i(mod M)的键都被散列到DLL i 中。 再次,我们不在此可视化中存储任何卫星数据。
Now click
— three individual insertions in one command.Recap (to be shown after you click the button above).
Formally, we describe Linear Probing index i as i = (base+step*1) % M where base is the (primary) hash value of key v, i.e., h(v) and step is the Linear Probing step starting from 1.
Tips: To do a quick mental calculation of a (small) Integer V modulo M, we simply subtract V with the largest multiple of M ≤ V, e.g., 18%7 = 18-14 = 4, as 14 is the largest multiple of 7 that is ≤ 18.
Although we can resolve collision with Linear Probing, it is not the most effective way.
We define a cluster to be a collection of consecutive occupied slots. A cluster that covers the base address of a key is called the primary cluster of the key.
Now notice that Linear Probing can create large primary clusters that will increase the running time of Search(v)/Insert(v)/Remove(v) operations beyond the advertised O(1).
See an example above with M = 31 and we have inserted 15 keys [1..15] so that they occupy cells [1..15]. Now see how 'slow'
(the 16th key) is.The probe sequence of Linear Probing can be formally described as follows:
h(v) // base address
(h(v) + 1*1) % M // 1st probing step if there is a collision
(h(v) + 2*1) % M // 2nd probing step if there is still a collision
(h(v) + 3*1) % M // 3rd probing step if there is still a collision
...
(h(v) + k*1) % M // k-th probing step, etc...
During Insert(v), if there is a collision but there is an empty (or DEL) slot remains in the Hash Table, we are sure to find it after at most M Linear Probing steps, i.e., in O(M). And when we do, the collision will be resolved, but the primary cluster of the key v is expanded as a result and future Hash Table operations will get slower too. Try the slow
on the same Hash Table as in the previous slide but with many DEL markers (suppose {4, 5, 8, 9, 10, 12, 14} have just been deleted).In the previous slide (Primary Clustering, Part 1), we break the assumption that the hash function should uniformly distribute keys around [0..M-1]. In the next example, we will show that the problem of primary clustering can still happen even if the hash function distribute the keys into several relatively short primary clusters around [0..M-1].
On screen, you see M = 31 from the previous slide, but only cells [1..6], [8..13], and [15..20] are filled. If we then insert these next 3 keys {37, 44, 63}, these first two keys will initially collide with the cells that already contain {6, 13}, have "short" one-step probes, then by doing so "plug" the empty cells and accidentally annex (or combine) those neighboring (but previously disjointed) clusters into a (very) long primary cluster. So the next insertion of a key {63} that lands at (the beginning of) this long primary cluster will end up performing almost O(M) probing steps just to find an empty cell. Try
.In Quadratic Probing, clusters are formed along the path of probing, instead of around the base address like in Linear Probing. These clusters are called Secondary Clusters and it is 'less visible' compared to the Primary Clusters that plagued the Linear Probing.
Secondary clusters are formed as a result of using the same pattern in probing by colliding keys, i.e., if two distinct keys have the same base address, their Quadratic Probing sequences are going to be the same.
To illustrate this, see the screen with M = 31. We have populated this Hash Table with only 10 keys (so load factor α = 10/31 ≤ 0.5) and the Hash Table looks 'sparse enough' (no visibly big primary cluster). However, if we then insert
, despite the fact that there are many (31-10 = 21) empty cells and 62 != 93 (different keys that ends up hashed into index 0), we end up doing 10 probing steps along this 'less visible' secondary cluster (notice that both {62, 93} follow similar Quadratic Probing sequences).Secondary clustering in Quadratic Probing is not as bad as primary clustering in Linear Probing as a good hash function should theoretically disperse the keys into different base addresses ∈ [0..M-1] in the first place.
In summary, a good Open Addressing collision resolution technique needs to:
Now, let's see the same test case that plagues Quadratic Probing earlier. Now try again. Although h(62) = h(93) = 0 and their collide with 31 that already occupy index 0, their probing steps are not the same: h2(62) = 29-62%29 = 25 is not the same as h2(93) = 29-93%29 = 23.
Discussion: Double Hashing seems to fit the bill. But... Is Double Hashing strategy flexible enough to be used as the default library implementation of a Hash Table? Let's see...
Try
to see how Insert(v) operation works if we use Separate Chaining as collision resolution technique. On such random insertions, the performance is good and each insertion is clearly O(1).However if we try
, notice that all Integers {68,90} are 2 (modulo 11) so all of them will be appended into the (back of) Doubly Linked List 2. We will have a long chain in that list. Note that due to the screen limitation, we limit the length of each Doubly Linked List to be at maximum 6.Try
to see that Search(v) can be made to run in O(1+α).Try
to see that Remove(v) can be made to run in O(1+α) too.If α is large, Separate Chaining performance is not really O(1). However, if we roughly know the potential maximum number of keys N that our application will ever use, then we can set table size M accordingly such that α = N/M is a very low positive (floating-point) number, thereby making Separate Chaining performances to be expected O(1).
您已经完成了这个哈希表数据结构的基本工作,我们鼓励您在探索模式中进一步探索。
但是,我们仍然会为您提供一些本节中概述的更有趣的哈希表的挑战。
但是,如果您需要使用C ++, Python或Java实现哈希表,并且您的键(Keys)是整数或字符串,则可以使用内置的C ++ STL,Python标准库或Java API。 他们已经有了整数或字符串默认散列函数的内置实现。
请参阅C ++ STL unordered_map,unordered_set, Python dict, set, 或 Java HashMap,HashSet。
请注意,multimap / multiset实现也存在(允许重复键)。
对于OCaml, 我们可以用 Hashtbl。
这里是我们分离链接法的实现: HashTableDemo.cpp | py | java.
如果(整数或字符串)键只需要映射到卫星数据,则散列表是实现Table ADT的非常好的数据结构,对于Search(v),Insert(v)和Remove( v)如果哈希表设置正确,则执行时间复杂度为O(1)。
但是,如果我们需要更多地使用 键,我们可能需要使用另一种数据结构。
关于这个数据结构的一些更有趣的问题,请在哈希表培训模块上练习(不需要登录,但是只需短暂且中等难度设置)。
但是,对于注册用户,您应该登录,然后转到主要培训页面以正式清除此模块,这些成果将记录在您的用户帐户中。
尝试解决一些基本的编程问题,就很可能需要使用哈希表(特别是如果输入大小很大的时候):