哈希表的原理与实现
一列键值对数据,存储在一个table中,如何经过数据的关键字快速查找相应值呢?不要告诉我一个个拿出来比较key啊,呵呵。 算法
你们都知道,在全部的线性数据结构中,数组的定位速度最快,由于它可经过数组下标直接定位到相应的数组空间,就不须要一个个查找。而哈希表就是利用数组这个可以快速定位数据的结构解决以上的问题的。 数组
具体如何作呢?你们是否有注意到前面说的话:“数组能够经过下标直接定位到相应的空间”,对就是这句,哈希表的作法其实很简单,就是把Key经过一个固定的算法函数,既所谓的哈希函数转换成一个整型数字,而后就将该数字对数组长度进行取余,取余结果就看成数组的下标,将value存储在以该数字为下标的数组空间里,而当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就能够充分利用到数组的定位性能进行数据定位。服务器
不知道说到这里,一些不了解的朋友是否大概了解了哈希表的原理,其实就是经过空间换取时间的作法。到这里,可能有的朋友就会问,哈希函数对key进行转换,取余的值必定是惟一的吗?这个固然不能保证,主要是因为hashcode会对数组长度进行取余,所以其结果因为数组长度的限制必然会出现重复,因此就会有“冲突”这一问题,至于解决冲突的办法其实有不少种,好比重复散列的方式,大概就是定位的空间已经存在value且key不一样的话就从新进行哈希加一并求模数组元素个数,既 (h(k)+i) mod S , i=1,2,3…… ,直到找到空间为止。还有其余的方式你们若是有兴趣的话能够本身找找资料看看。 数据结构
Hash表这种数据结构在java中是原生的一个集合对象,在实际中用途极广,主要有这么几个特色:dom
- 访问速度快
- 大小不受限制
- 按键进行索引,没有重复对象
- 用字符串(id:string)检索对象(object)
今天整理之前写的一些算法,翻出来一个hash表的实现,就贴出来,本身也温习温习。先看看头文件,也就是数据结构的定义,至关于java中的接口的概念:分布式
03 |
03 #define HASHSIZE 256 |
07 |
07 struct nlist *next; |
13 |
13 unsigned hash( char *s); |
14 |
14 struct nlist *lookup( char *s); |
15 |
15 struct nlist *install( char *name, char *defn); |
而后是具体实现:函数
01 |
01 #include <string.h> |
04 |
04 static struct nlist *hashtab[HASHSIZE]; |
06 |
06 unsigned hash( char *s) |
10 |
10 for (hashval = 0; *s != '\0' ;s++) |
11 |
11 hashval = *s + 31 * hashval; |
12 |
12 return hashval % HASHSIZE; |
15 |
15 struct nlist *lookup( char *s) |
19 |
19 for (np = hashtab[hash(s)]; np != NULL; np = np->next) |
20 |
20 if ( strcmp (s,np->name) == 0) |
25 |
25 struct nlist *install( char *name, char *defn) |
30 |
30 if ((np = lookup(name)) == NULL){ |
31 |
31 np = ( struct nlist *) malloc ( sizeof ( struct nlist)); |
32 |
32 if (np == NULL || (np->name = strdup(name)) == NULL) |
34 |
34 hashval = hash(name); |
35 |
35 np->next= hashtab[hashval]; |
36 |
36 hashtab[hashval] = np; |
38 |
38 free (( void *)np->defn); |
39 |
39 if ((np->defn = strdup(defn)) == NULL) |
很简单,只有两个外部接口,性能
- install(key, value),用来插入一个新的节点
- lookup(key),根据一个键来进行搜索,并返回节点
代码很简单,主要用到的hash算法跟java中的String的hashcode()方法中用到的算法同样,使用:
1 |
1 unsigned hash( char *s) |
5 |
5 for (hashval = 0; *s != '\0' ;s++) |
6 |
6 hashval = *s + 31 * hashval; |
7 |
7 return hashval % HASHSIZE; |
这里的31并不是随意,乃是一个经验值,选取它的目的在于减小冲突,固然,hash冲突这个问题是不能根本避免的。这里只是一我的们在测试中发现的能够相对减小hash冲突的一个数字,可能之后会发现更好的数值来。
一致性 hash 算法
consistent hashing 一致性 hash 算法早在 1997 年就在论文 Consistent hashing and random trees 中被提出,目前在 cache 系统中应用愈来愈普遍。
基本场景
好比你有 N 个 cache 服务器(后面简称 cache ),那么如何将一个对象 object 映射到 N 个 cache 上呢,你极可能会采用相似下面的通用方法计算 object 的 hash 值,而后均匀的映射到到 N 个 cache:
hash(object)%N
一切都运行正常,再考虑以下的两种状况:
- 一个 cache 服务器 m down 掉了(在实际应用中必需要考虑这种状况),这样全部映射到 cache m 的对象都会失效,怎么办,须要把 cache m 从 cache 中移除,这时候 cache 是 N-1 台,映射公式变成了 hash(object)%(N-1) ;
- 因为访问加剧,须要添加 cache ,这时候 cache 是 N+1 台,映射公式变成了 hash(object)%(N+1) ;
1 和 2 意味着什么?这意味着忽然之间几乎全部的 cache 都失效了。对于服务器而言,这是一场灾难,洪水般的访问都会直接冲向后台服务器;
再来考虑第三个问题,因为硬件能力愈来愈强,你可能想让后面添加的节点多作点活,显然上面的 hash 算法也作不到。有什么方法能够改变这个情况呢,这就是 consistent hashing 一致性 hash 算法...
hash 算法和单调性
Hash 算法的一个衡量指标是单调性( Monotonicity ),定义以下:
单调性是指若是已经有一些内容经过哈希分派到了相应的缓冲中,又有新的缓冲加入到系统中。哈希的结果应可以保证原有已分配的内容能够被映射到新的缓冲中去,而不会被映射到旧的缓冲集合中的其余缓冲区。
容易看到,上面的简单 hash 算法 hash(object)%N 难以知足单调性要求。
consistent hashing 算法的原理
consistent hashing 是一种 hash 算法,简单的说,在移除 / 添加一个 cache 时,它可以尽量小的改变已存在 key 映射关系,尽量的知足单调性的要求。
下面就来按照 5 个步骤简单讲讲 consistent hashing 算法的基本原理。
1. 环形hash 空间
考虑一般的 hash 算法都是将 value 映射到一个 32 为的 key 值,也便是 0~2^32-1 次方的数值空间;咱们能够将这个空间想象成一个首( 0 )尾( 2^32-1 )相接的圆环,以下图所示的那样。
2. 把对象映射到hash 空间
接下来考虑 4 个对象 object1~object4 ,经过 hash 函数计算出的 hash 值 key 在环上的分布以下图所示。
1 |
1 hash(object1) = key1; |
3 |
3 hash(object4) = key4; |
4 个对象的 key 值分布
3. 把cache 映射到hash 空间
Consistent hashing 的基本思想就是将对象和 cache 都映射到同一个 hash 数值空间中,而且使用相同的 hash 算法。假设当前有 A,B 和 C 共 3 台 cache ,那么其映射结果将如图 3 所示,他们在 hash 空间中,以对应的 hash 值排列。
1 |
1 hash(cache A) = key A; |
3 |
3 hash(cache C) = key C; |
cache 和对象的 key 值分布
说到这里,顺便提一下 cache 的 hash 计算,通常的方法可使用 cache 机器的 IP 地址或者机器名做为 hash 输入。
4. 把对象映射到cache
如今 cache 和对象都已经经过同一个 hash 算法映射到 hash 数值空间中了,接下来要考虑的就是如何将对象映射到 cache 上面了。
在这个环形空间中,若是沿着顺时针方向从对象的 key 值出发,直到碰见一个 cache ,那么就将该对象存储在这个 cache 上,由于对象和 cache 的 hash 值是固定的,所以这个 cache 必然是惟一和肯定的。这样不就找到了对象和 cache 的映射方法了吗?!
依然继续上面的例子(上图),那么根据上面的方法:
- 对象 object1 将被存储到 cache A 上;
- object2和 object3 对应到 cache C ;
- object4 对应到 cache B。
5. 考察cache 的变更
前面讲过,经过 hash 而后求余的方法带来的最大问题就在于不能知足单调性,当 cache 有所变更时, cache 会失效,进而对后台服务器形成巨大的冲击,如今就来分析分析 consistent hashing 算法。
考虑假设 cache B 挂掉了,根据上面讲到的映射方法,这时受影响的将仅是那些沿 cache B 逆时针遍历直到下一个 cache ( cache C )之间的对象,也便是原本映射到 cache B 上的那些对象。
所以这里仅须要变更对象 object4 ,将其从新映射到 cache C 上便可:
Cache B 被移除后的 cache 映射
再考虑添加一台新的 cache D 的状况,假设在这个环形 hash 空间中, cache D 被映射在对象 object2 和 object3 之间。这时受影响的将仅是那些沿 cache D 逆时针遍历直到下一个 cache ( cache B )之间的对象(它们是也原本映射到 cache C 上对象的一部分),将这些对象从新映射到 cache D 上便可。
所以这里仅须要变更对象 object2 ,将其从新映射到 cache D 上:
添加 cache D 后的映射关系
虚拟节点
考量 Hash 算法的另外一个指标是平衡性 (Balance) ,定义以下:
平衡性是指哈希的结果可以尽量分布到全部的缓冲中去,这样可使得全部的缓冲空间都获得利用。
hash 算法并非保证绝对的平衡,若是 cache 较少的话,对象并不能被均匀的映射到 cache 上,好比在上面的例子中,仅部署 cache A 和 cache C 的状况下,在 4 个对象中, cache A 仅存储了 object1 ,而 cache C 则存储了 object2 、 object3 和 object4 ;分布是很不均衡的。
为了解决这种状况, consistent hashing 引入了“虚拟节点”的概念,它能够以下定义:
“虚拟节点”( virtual node )是实际节点在 hash 空间的复制品( replica ),一实际个节点对应了若干个“虚拟节点”,这个对应个数也成为“复制个数”,“虚拟节点”在 hash 空间中以 hash 值排列。
仍以仅部署 cache A 和 cache C 的状况为例,在前面 中咱们已经看到, cache 分布并不均匀。如今咱们引入虚拟节点,并设置“复制个数”为 2 ,这就意味着一共会存在 4 个“虚拟节点”, cache A1, cache A2 表明了 cache A ; cache C1, cache C2 表明了 cache C ;假设一种比较理想的状况,参见下图 。
引入“虚拟节点”后的映射关系
此时,对象到“虚拟节点”的映射关系为:
所以对象 object1 和 object2 都被映射到了 cache A 上,而 object3 和 object4 映射到了 cache C 上;平衡性有了很大提升。引入“虚拟节点”后,映射关系就从 { 对象 -> 节点 } 转换到了 { 对象 -> 虚拟节点 } 。查询物体所在 cache 时的映射关系如图 7 所示。
查询对象所在 cache
“虚拟节点”的 hash 计算能够采用对应节点的 IP 地址加数字后缀的方式。例如假设 cache A 的 IP 地址为 202.168.14.241 。
引入“虚拟节点”前,计算 cache A 的 hash 值:Hash("202.168.14.241");
引入“虚拟节点”后,计算“虚拟节”点 cache A1 和 cache A2 的 hash 值:
1 |
1 Hash( "202.168.14.241#1" ); |
2 |
2 Hash( "202.168.14.241#2" ); |
小结
Consistent hashing 的基本原理就是这些,具体的分布性等理论分析应该是很复杂的,不过通常也用不到。
分布式哈希算法
咱们从浅入深一步一步介绍什么是分布式哈希表。
哈希函数
哈希函数是一种计算方法,它能够把一个值A映射到一个特定的范围[begin, end]以内。对于一个值的集合{k1, k2, … , kN},哈希函数把他们均匀的映射到某个范围之中。这样,经过这些值就能够很快的找到与之对应的映射地址{index1, index2, … , indexN}。对于同一个值,哈希函数要能保证对这个值的运算结果老是相同的。
哈希函数须要通过精心设计才可以达到比较好的效果,可是老是没法达到理想的效果。多个值也许会映射到一样的地址上。这样就会产生冲突,如图中的红线所示。在设计哈希函数时要尽可能减小冲突的产生。
最简单的哈希函数就是一个求余运算: hash(A) = A % N。这样就把A这个值映射到了[0~N-1]这样一个范围之中。
哈希表
哈希表的核心就是哈希函数hash()。
哈希表是一中数据结构,它把KEY 和 VALUE用某种方式对应起来。使用hash()函数把一个KEY值映射到一个index上,即hash(KEY) = index。这样就能够把一个KEY值同某个index对应起来。而后把与这个KEY值对应的VALUE存储到index所标记的存储空间中。这样,每次想要查找KEY所对应的VALUE值时,只须要作一次hash()运算就能够找到了。
举个例子:图书馆中的书会被某人借走,这样“书名”和“人名”之间就造成了KEY与VALUE的关系。假设如今有三个记录:
这就是“书名”和“人名”的对应关系,它表示某人借了某本书。如今咱们把这种对应关系用哈希表存储起来,它们的hash()值分别为:
hash(简明现代魔法) = 2 |
hash(最后一天) = 0 |
hash(变形记) = 1 |
而后咱们就能够在一个表中存储“人名”了:
这三我的名分别存储在0、1和2号存储空间中。当咱们想要查找《简明现代魔法》这本书是被谁借走的时候,只要hash()一下这个书名,就能够找到它所对应的index,为2。而后在这个表中就能够找到对应的人名了。在这里,KEY为“书名”, VALUE为“人名”。
当有大量的KEY VALUE对应关系的数据须要存储时,这种方法就很是有效。
分布式哈希表
哈希表把全部的东西都存储在一台机器上,当这台机器坏掉了以后,所存储的东西就所有消失了。分布式哈希表能够把一整张哈希表分红若干个不一样的部分,分别存储在不一样的机器上,这样就下降了数据所有被损坏的风险。
分布式哈希表一般采用一致性哈希函数来对机器和数据进行统一运算。这里先不用深究一致性哈希到底是什么,只须要知道它是对机器(一般是其IP地址)和数据(一般是其KEY值)进行统一的运算,把他们全都映射到一个地址空间中。假设有一个一致性哈希函数能够把一个值映射到32bit的地址空间中,从0一直到2^32 – 1。咱们用一个圆环来表示这个地址空间。
假设有N台机器,那么hash()就会把这N台机器映射到这个环的N个地方。而后咱们把整个地址空间进行一下划分,使每台机器控制一个范围的地址空间。这样,当咱们向这个系统中添加数据的时候,首先使用hash()函数计算一下这个数据的index,而后找出它所对应的地址在环中属于哪一个地址范围,咱们就能够把这个数据放到相应的机器上。这样,就把一个哈希表分布到了不一样的机器上。以下图所示:
这里蓝色的圆点表示机器,红色的圆点表示某个数据通过hash()计算后所得出的地址。
在这个图中,按照逆时针方向,每一个机器占据的地址范围为从本机器开始一直到下一个机器为止。用顺时针方向来看,每一个机器所占据的地址范围为这台机器以前的这一段地址空间。图中的虚线表示数据会存储在哪台机器上。
哈希表的工做原理与经常使用操做
哈希表(Hash Table)的应用近两年才在NOI中出现,做为一种高效的数据结构,它正在竞赛中发挥着愈来愈重要的做用。
哈希表最大的优势,就是把数据的存储和查找消耗的时间大大下降,几乎能够当作是常数时间;而代价仅仅是消耗比较多的内存。然而在当前可利用内存愈来愈多的状况下,用空间换时间的作法是值得的。另外,编码比较容易也是它的特色之一。
哈希表又叫作散列表,分为“开散列” 和“闭散列”。考虑到竞赛时多数人一般避免使用动态存储结构,本文中的“哈希表”仅指“闭散列”,关于其余方面读者可参阅其余书籍。
基础操做
咱们使用一个下标范围比较大的数组来存储元素。能够设计一个函数(哈希函数, 也叫作散列函数),使得每一个元素的关键字都与一个函数值(即数组下标)相对应,因而用这个数组单元来存储这个元素。也能够简单的理解为,按照关键字为每一 个元素“分类”,而后将这个元素存储在相应“类”所对应的地方。
可是,不可以保证每一个元素的关键字与函数值是一一对应的,所以极有可能出现对于不一样的元素,却计算出了相同的函数值,这样就产生了“冲突”,换句话说,就是把不一样的元素分在了相同的“类”之中。后面咱们将看到一种解决“冲突”的简便作法。
总的来讲,“直接定址”与“解决冲突”是哈希表的两大特色。
函数构造:构造函数的经常使用方法(下面为了叙述简洁,设 h(k) 表示关键字为 k 的元素所对应的函数值):
- 除余法: 选择一个适当的正整数 p ,令 h(k ) = k mod p ,这里, p 若是选取的是比较大的素数,效果比较好。并且此法很是容易实现,所以是最经常使用的方法。
- 数字选择法: 若是关键字的位数比较多,超过长整型范围而没法直接运算,能够选择其中数字分布比较均匀的若干位,所组成的新的值做为关键字或者直接做为函数值。
冲突处理:线性从新散列技术易于实现且能够较好的达到目的。令数组元素个数为 S ,则当 h(k) 已经存储了元素的时候,依次探查 (h(k)+i) mod S , i=1,2,3…… ,直到找到空的存储单元为止(或者从头至尾扫描一圈仍未发现空单元,这就是哈希表已经满了,发生了错误。固然这是能够经过扩大数组范围避免的)。
支持运算:哈希表支持的运算主要有:初始化(makenull)、哈希函数值的运算(h(x))、插入元素(insert)、查找元素(member)。 设插入的元素的关键字为 x ,A 为存储的数组。 初始化比较容易,例如 :
1 |
1 const empty=maxlongint; |
哈希函数值的运算根据函数的不一样而变化,例如除余法的一个例子:
1 |
1 function h(x:longint):Integer; |
咱们注意到,插入和查找首先都须要对这个元素定位,即若是这个元素若存在,它应该存储在什么位置,所以加入一个定位的函数 locate。
01 |
01 function locate(x:longint):integer; |
02 |
02 var orig,i:integer; |
06 |
06 while (i < S)and(A[(orig+i)mod S]<>x)and(A[(orig+i)mod S]<>empty) do |
10 |
10 locate:=(orig+i) mod S; |
插入元素:
1 |
1 procedure insert(x:longint); |
5 |
5 if A[posi]=empty then A[posi]:=x |
查找元素是否已经在表中:
1 |
1 procedure member(x:longint):boolean; |
5 |
5 if A[posi]=x then member:= true |
这些就是创建在哈希表上的经常使用基本运算。
当数据规模接近哈希表上界或者下界的时候,哈希表彻底不可以体现高效的特色,甚至还不如通常算法。可是若是规模在中央,它高效的特色能够充分体现。试验代表当元素充满哈希表的 90% 的时候,效率就已经开始明显降低。这就给了咱们提示:若是肯定使用哈希表,应该尽可能使数组开大,但对最太大的数组进行操做也比较费时间,须要找到一个平衡点。一般使它的容量至少是题目最大需求的 120% ,效果比较好(这个仅仅是经验,没有严格证实)。
应用举例
何时适合应用哈希表呢?若是发现解决这个问题时常常要询问:“某个元素是否在已知集合中?”,也就是须要高效的数据存储和查找,则使用哈希表是最好不过的了!那么,在应用哈希表的过程当中,值得注意的是什么呢?
哈希函数的设计很重要。一个很差的哈希函数,就是指形成不少冲突的状况,从前面的例子已经能够看出来,解决冲突会浪费掉大量时间,所以咱们的目标就是尽力避免冲突。前面提到,在使用“除余法”的时候,h(k)=k mod p ,p 最好是一个大素数。这就是为了尽力避免冲突。为何呢?假设 p=1000 ,则哈希函数分类的标准实际上就变成了按照末三位数分类,这样最多1000类,冲突会不少。通常地说,若是 p 的约数越多,那么冲突的概率就越大。
简单的证实:假设 p 是一个有较多约数的数,同时在数据中存在 q 知足 gcd(p,q)=d >1 ,即有 p=a*d , q=b*d, 则有 q mod p= q – p* [q div p] =q – p*[b div a] . ① 其中 [b div a ] 的取值范围是不会超过 [0,b] 的正整数。也就是说, [b div a] 的值只有 b+1 种可能,而 p 是一个预先肯定的数。所以 ① 式的值就只有 b+1 种可能了。这样,虽然mod 运算以后的余数仍然在 [0,p-1] 内,可是它的取值仅限于 ① 可能取到的那些值。也就是说余数的分布变得不均匀了。容易看出, p 的约数越多,发生这种余数分布不均匀的状况就越频繁,冲突的概率越高。而素数的约数是最少的,所以咱们选用大素数。记住“素数是咱们的得力助手”。
另外一方面,一味的追求低冲突率也很差。理论上,是能够设计出一个几乎完美,几乎没有冲突的函数的。然而,这样作显然不值得,由于这样的函数设计 很浪费时间并且编码必定很复杂,与其花费这么大的精力去设计函数,还不如用一个虽然冲突多一些可是编码简单的函数。所以,函数还须要易于编码,即易于实现。
综上所述,设计一个好的哈希函数是很关键的。而“好”的标准,就是较低的冲突率和易于实现。
另外,使用哈希表并非记住了前面的基本操做就能以不变应万变的。有的时候,须要按照题目的要求对哈希表的结构做一些改进。每每一些简单的改进就能够带来巨大的方便。
这些只是通常原则,真正遇到试题的时候实际状况变幻无穷,须要具体问题具体分析才行。