数据结构 - 哈希

原文连接: blog.wangriyu.wang/2018/06-Has…html

索引

在 MySQL 中,主要有四种类型的索引,分别为:B-Tree 索引,Hash 索引,Fulltext 索引和 R-Tree 索引。前一节已经讲了 B 类树的结构特色,此次讲哈希索引,至于后面的全文索引R 树索引感兴趣本身看吧。python

以前讲 B 树时提到过哈希索引能够支持动态长度,并且因为 Hash 索引结构的特殊性,其检索效率很是高,最好的状况能够一次定位,查询效率远大于 B 树,可是实际运用中主要仍是用 B 树作索引,由于哈希索引有如下局限性:算法

(1)Hash 索引适合定值查询,不能作范围查询数据库

因为 Hash 索引比较的是进行 Hash 运算以后的 Hash 值,因此它只能用于等值的过滤,不能用于基于范围的过滤,由于通过相应的 Hash 算法处理以后的 Hash 值的大小关系,并不能保证和 Hash 运算前彻底同样。数组

(2)Hash 索引没法用于排序操做缓存

因为 Hash 索引中存放的是通过 Hash 计算以后的 Hash 值,并且 Hash 值的大小关系并不必定和 Hash 运算前的键值彻底同样,因此数据库没法利用索引的数据来进行任何排序运算;安全

(3)Hash 索引不能利用部分索引键查询服务器

好比 (a,b,c) 形式的组合索引,查询中只用到了 a 和 b 也是能够查询的,若是使用 hash 表,组合索引会将几个字段合并 hash,没办法支持部分索引网络

(4)Hash 索引遇到大量 Hash 值相等的状况后性能并不必定就会比 B-Tree 索引高数据结构

数据库支持非惟一的 Hash 索引,若是遇到非惟一值,存储引擎会将他们连接到同一个 hash 键值下以一个链表的形式存在,而后在取得实际键值的时候再过滤不符合的键

Hash 索引在 MySQL 中使用的并非不少,目前主要是 Memory 和 NDB 引擎会使用;而经常使用的 Innodb 存储引擎和 MyISAM 引擎使用的索引仍是 B+ 树为主

哈希表

哈希表 (Hash Table),也叫散列表,是根据键(Key)转换而直接访问在存储地址的数据结构。

定义

好比咱们有关键字 k,则其值存放在 f(k) 的存储位置上,所以不须要比较就能够找到记录。这个映射关系 f(x) 称为哈希函数(散列函数),以此创建的表就是哈希表

哈希表中存着的是包含存储单元的数组,这些存储单元咱们称做槽 (slot) 或者桶 (bucket) ,每一个存储单元能够容纳一个或多个关键字信息。理想状况下,完美的散列函数能为关键字找到惟一的独占的桶,但大多数状况下用到的散列函数都是不完美的,会存在冲突;

对不一样的关键字可能获得同一散列地址,即 k1 != k2,而 f(k1) == f(k2),这种现象称为冲突 (Collision)。具备相同函数值的关键字对该散列函数来讲称作同义词

若对于关键字集合中的任一个关键字,经散列函数映象到地址集合中任何一个地址的几率是相等的,则称此类散列函数为均匀散列函数(Uniform Hash function),这就是使关键字通过散列函数获得一个“随机的地址”,从而减小冲突

原理

image

输入一个关键字 "lies",通过散列获得值 9,对应哈希表获得咱们要的地址 20

从关键字到索引值的转换过程使用的就是散列函数,散列函数有不少种,好比一种简单的散列方式以下:

image

取关键字每一个字符的 ASCII 码,相加,即 lies -> 108+105+101+115 -> 429

可是咱们的表只有 30 项,那么能够再把上面的值模 30: 429 mod 30 = 9

一个散列就完成了

可是继续输入发现:

image

关键字 "foes" 散列后也对应表下标 9,这就是冲突,"lies" 和 "foes" 是同义词

每种哈希表实现都要有处理冲突的方法,处理冲突也有不少方式,好比这里能够用简单的链表来处理:

image

以前哈希表中索引对应的就是具体的地址,而如今改为一个指向链表的指针,全部同义词都存在相应链表中

可是咱们怎么直到链表哪一个是 "lies" 哪一个是 "foes" 呢,能够在链表每一个节点中添加关键字信息:

image

这样冲突就算处理了,可是这种哈希表结构的最坏状况是 O(n),不一样于以前 O(1)。由于假如大部分关键字都是冲突的,那么这些关键字就变为链表查询了。

所以一个均匀的散列函数和一个高效的处理冲突的方法对哈希表来讲相当重要

下面会介绍一些常见的哈希函数和处理冲突的方法

哈希函数

  1. 直接定址法:取关键字或关键字的某个线性函数值为散列地址。即 hash(k) = k 或 hash(k) = a * k + b,其中 a, b 为常数(这种散列函数叫作自身函数)
  2. 数字分析法:假设关键字是以 r 为基的数,而且哈希表中可能出现的关键字都是事先知道的,则可取关键字的若干数位组成哈希地址
  3. 平方取中法:取关键字平方后的中间几位为哈希地址。一般在选定哈希函数时不必定能知道关键字的所有状况,取其中的哪几位也不必定合适,而一个数平方后的中间几位数和数的每一位都相关,由此使随机分布的关键字获得的哈希地址也是随机的。取的位数由表长决定。好比 {421,423,436},平方以后的结果为{177241,178929,190096},那么能够取中间的两位数{72,89,00}做为 Hash 地址
  4. 折叠法:将关键字分割成位数相同的几部分(最后一部分的位数能够不一样),而后取这几部分的叠加和(舍去进位)做为哈希地址。好比图书的 ISBN 号为 8903-241-23,能够将 address(key)=89+03+24+12+3 做为 Hash 地址。
  5. 随机数法
  6. 除留余数法:取关键字被某个不大于散列表表长 m 的数 p 除后所得的余数为散列地址。即 hash(k) = k mod p, p <= m。不只能够对关键字直接取模,也可在折叠法、平方取中法等运算以后取模。对 p 的选择很重要,通常取素数或 m,若 p 选择很差,也容易产生冲突

冲突

影响因素

影响产生冲突多少有如下三个因素:

  • 散列函数是否均匀
  • 处理冲突的方法
  • 散列表的负载因子

散列表的负载因子定义为:α = 填入表中的元素个数 / 散列表的长度。α 是散列表装满程度的标志因子。 因为表长是定值,α 与“填入表中的元素个数”成正比,因此 α 越大,代表填入表中的元素越多,产生冲突的可能性就越大。

处理方法

一、开放寻址 (open addressing/closed hashing): 一旦发生了冲突,经过探测寻找其余空的位置,而后插入。

hash_i = (hash(k) + d_i) mod m, i = 1, 2, 3...m-1, 其中 hash(k) 为散列函数,d_i 是增量序列,m 为哈希表长,i 为已发生冲突的次数。

hash(k) 是初始的探测位置,以后的探测位置 hash _1, hash_2, hash_3...hash _{m-1} 造成一个探测序列。

若是 d_i = i = 1, 2, 3...m-1,则为线性探测,至关于逐一往下找表空闲位置

若是 d_i = \pm1^2, \pm2^2, \pm3^2...\pm k^2,则为二次探测(或者叫平方探测),至关于探测间隔为 \pm i^2

若是 d_i = i * rehash(k),rehash 是另外一个哈希函数,则为双散列探测

若是 d_i = 伪随机数序列,则为伪随机探测

对开放寻址散列表性能的关键影响是载荷因子。随着载荷系数增长到 100%,可能须要查找或插入给定关键字的探针数量急剧增长。一旦表格变满,探测算法甚至可能没法终止。即便具备良好的散列函数,也应严格限制载荷因子在 0.7-0.8 如下,好比 Java 的系统库限制了荷载因子为 0.75,超过此值将动态调整散列表大小。

二、单独链表法。即上述原理用到的方法,也叫拉链法,链表的使用方式能够分不少种,能够见 WikiPedia 的事例,另外算法导论里提到一种彻底散列的方式,至关于二维哈希表,每一个槽对应的是另外一个哈希列表,也值得借鉴

三、再散列: hash_i = rehash_i(k), i = 1, 2, 3...k, 在发生冲突时,使用其余散列函数计算哈希值,直到冲突再也不发生。这种方法不易产生“汇集”,但增长了计算时间。

四、创建一个公共溢出区

动态哈希

前面的问题都是以哈希表定长为基础的,可是当关键字较多时,哈希表出现汇集时,性能会急剧降低。

即散列表的载荷因子较大时须要考虑扩充哈希表大小,若是是静态 hash,则须要新建一张更大的表,而后将全部关键字从新散列到新的表中,以后删除旧表

而动态散列能够在哈希表元素增加的同时,动态的调整 hash 桶的数目,动态 hash 不须要对原有元素进行从新插入(重组),而是在原基础上,进行动态的桶扩展。

有如下三种方法能够实现动态 hash:

  • 多 hash 表
  • 可扩展的动态散列
  • 线性散列

多 hash 表

多 hash 表就是采用多张哈希表来扩充原哈希表。

hash table

如图,哈希函数为 hash(k) = k % 5,每一个桶最多含 4 个关键字

当咱们要插入关键字 5 时,hash 值为 0,应该插入 Bucket 1 中,并且 Bucket 1 有空闲位置,直接插入便可;

当咱们要插入关键字 3 时,hash 值为 3,应该插入 Bucket 3 中,可是 Bucket 3 已经满了,此时新建一张 hash 表来完成插入

hash table

对于此种方式,执行插入、查找、删除操做时,均需先求得 hash 值 x。

  • 插入时,获得当前的 hash 表的个数,并分别取得各个 hash 表的 x 位置上的索引项,若其中某个项指向的桶存在空闲位置,则插入之。同时,在插入时,可保持多个 hash 表在某个索引项上桶中元素的个数近似相等。若不存在空闲位置,则简单的进行表扩充,即新建一个 hash 表,如上所示
  • 查找时,因为某个记录值可能存在当前 hash 结构的多个表中,所以需同时在多个 hash 表的同一位置上进行查找操做,等待全部的查找结束后,方可断定该元素是否存在。因为该种结构需进行屡次查找,当表元素很是多时,为提升效率,在多处理器上可采用多线程,并发执行查找操做
  • 删除操做,与上述过程基本相似。须要注意的是,若删除操做致使某个 hash 表元素为空,这时可将该表从结构中剔除

这种方式的优势是设计和实现比较简单的。缺点是占用空间大,并且关键字较密集时空间利用率低。

可扩展的动态散列

引入一个仅存储桶指针的索引数组,用翻倍的索引项数来取代翻倍的桶的数目,且每次只分裂有溢出的桶,从而减少翻倍的代价。

Extendible hashing

如图所示哈希表有一个标识表示全局位深度 (Global Depth),全局位深度表示取哈希值的低几位做为索引,好比 hash(k4) = 0100,全局位深度为 2,则取低两位 00 归属到 00 索引的桶中,并且哈希表索引项数始终等于 2 ^ Global Depth;桶中有一个本地位深度 (Local Depth),本地位深度表示当前桶中元素的低几位是同样的;图中给出的桶中元素表示哈希后的值,好比桶 1 中 4 表示哈希值为 0100 的关键字 k4

  • 插入时,计算出哈希值再根据全局位深度匹配索引项,若是找到的桶未满,则直接插入便可;若是桶已满,则判断当前桶的本地位深度 L 与全局位深度 G 的大小关系: 若是 L = G,此时只有一个指针指向当前桶,则扩展索引,本地位深度和全局位深度均加一,索引项翻倍,重组当前桶的元素;若是 L < G,此时不止一个指针指向当前桶,故不须要翻倍索引项,只需分裂出一个桶,将本地位深度加一而后重组当前桶元素便可
  • 查找时,对于须要查找的关键字 x,hash(x) = y,根据当前 hash 表的全局位深度,决定对 y 取其后 G 位,位数不够用 0 填充,找到对应的索引项,从而找到对应的桶,在桶中逐一进行比较
  • 删除时,和查找操做相似,先定位元素,删除之。若删除时发现桶为空,则能够考虑将该桶与其兄弟桶进行合并,并使局部位深度减一

插入实例:

当咱们插入某个关键字 k13,hash(k13) = 1101,对应索引 01 的桶 Bucket 2,桶未满直接插入便可;

再插入某个关键字 k20,hash(k20) = 10100,对应索引 00 的桶 Bucket 1,桶已满并且本地位深度等于全局位深度,须要扩展索引,全局位深度和本地位深度均加一,并重组当前桶的元素,变为下图:

Extendible hashing

继续插入某个关键字 k25,hash(k25) = 11001,对应索引 001 的桶 Bucket 2,桶已满并且本地位深度小于全局位深度,当前桶存在两个指针,只须要分裂出一个桶便可并将桶的本地位深度加一,如图:

Extendible hashing

这种方式的优势是能够可动态进行桶的增加,且增加的同时,用索引项的翻倍代替桶数翻倍的传统作法,可用性更好。缺点是当散列的数据分布不均或偏斜较大时,会使得索引项的数目很大,数据桶的利用率很低;还有索引的增加速度,是指数级增加,扩展较快

线性散列

线性散列能随数据的插入和删除,适当的对 hash 桶数进行调整,一次只分裂一个桶,线性散列不须要存放数据桶指针的专门索引项。

定义:

标识符 描述
h_0,h_1,h_2... 一系列哈希函数,后者的范围老是前者的两倍,i 为下标,h_i(key) = hash(key) mod (N*2^i)
N 桶的初始个数,必须是 2 的幂次方
d_i 多少比特位用于表示 N,N = 2^{d_i}
Level 当前轮数,每轮的初始桶数等于 N*2^{Level}
Next 一个指针指向须要分裂的桶,每次发生分裂的桶老是由 Next 决定,与当前值被插入的桶已满或溢出无关
Load Factor 负载因子,当桶中记录数达到该值时进行分裂;也可选择当桶满时才进行分裂

当某个桶发生溢出时,能够将溢出元素以链表的形式链在桶后; 能够监控整张哈希表或者桶的负载因子,视状况选择是否分裂,好比全表的负载达到 0.75 能够对当前桶进行分裂,也能够选择只有桶满了才分裂;

实例:

初始状态 N = 4, Level = 0, Next 指向第一个桶

Linear-hashing

插入某个关键字 k37,hash(k37) = 37 = 100101,h_0(k37) = 01(取低两位),对应编号 1 的桶,此时桶未满直接插入便可:

Linear-hashing

插入某个关键字 k43,hash(k43) = 43 = 101011,h_0(k43) = 11(取低两位),对应编号 3 的桶,此时桶已满须要分裂: 首先把 43 链在桶 3 后面,而后分裂 Next 指向的桶 0,产生一个新桶,新桶编号 = N + Next = 4 + 0 = 100,Next 指向下一个桶;最后把桶 0 的元素按 h1 散列重组

Linear-hashing

继续插入某个关键字 k29,hash(k29) = 29 = 11101,h_0(k29) = 01,对应编号 1 的桶,此时桶已满须要分裂: 也是按刚才的步骤处理,不过此次 Next 指向的桶就是当前桶

Linear-hashing

向桶 1 中连续插入 17,33,41,引发分裂:

Linear-hashing

继续向桶 1 插入 57,引发分裂,须要注意的是这时 Next 指针已经遍历过初始的四个桶,第一轮已结束,Level 加一,Next 指向第一个桶,第二轮初始为八个桶(即 N = 8):

Linear-hashing

插入就是如上形式进行,接下来看怎么查找:

假如要查找某个关键字 key 对应的位置,若是 Next \leq h _{Level}(key) \leq N-1,则查询 h _{Level} 列的与低 d_i 位对应的桶,不然查询 h _{Level+1} 列的与低 d_i +1 位对应的桶。

Linear-hashing

在此图中 N = 4,Level = 0,Next = 2,d_0 = 2

好比查询 k44,h_0(k44) = 00,00(= 0) 不在 2 和 3 之间,则查询 h1 列与 k44 低三位 (100) 对应的桶,找到编号为 4 的桶,再从桶找到对应位置;

假如查询的是 k7,h_0(k7) = 11,11(= 3) 在 2 和 3 之间,则查询 h0 列与 k7 低两位 (11) 对应的桶,找到编号为 3 的桶,再从桶中找到对应位置

Linear-hashing

在此图中 N = 8,Level = 1,Next = 0,d_1 = 3

假如查询 k44,h_1(k44) = 100,100(= 4) 在 0 和 7 之间,因此查询 h1 列与 100 对应的桶,即编号为 4 的桶;

其余关键字的 h1 值都是在范围 [Next, N] 内的,因此都是找 h1 列的

线性散列的删除操做是插入操做的逆操做,若溢出块为空,则可释放。若删除致使某个桶元素变空,则 Next 指向上一个桶。当 Next 减小到 0,且最后一个桶也是空时,则 Next 指向 N/2 - 1 的位置,同时 Level 值减 1。

线性散列比可扩展动态散列更灵活,且不须要存放数据桶指针的专门索引项,节省了空间;但若是数据散列后分布不均匀,致使的问题可能会比可扩展散列还严重

分布式哈希

分布式哈希表 - DHT增长或移除节点只改变邻近节点所拥有的关键值集合,而其余节点的则不会被改变。传统的散列表,若增长或移除一个位置,则整个关键值空间必须从新散列。

其中一致性哈希算法很是适用于分布式哈希表,一致性哈希容许任意顺序插入或删除存储桶,不一样于线性散列那样须要顺序处理。

一致性哈希

应用场景

有 N 台服务器提供缓存服务,须要对服务器进行负载均衡,将请求平均分发到每台服务器上,每台机器负责 1/N 的服务。

经常使用的算法是对 hash 结果模 N 取余,好比 N = 5,hash = 103,余数为 3 则分发到编号为 3 的服务器上。可是这样的算法存在严重问题,若是有某台机器宕机或者新添加服务器,都会形成大量缓存请求失效或者须要转移重组数据。

而使用一致性哈希后,一样增长或者删除服务器节点只会对少许数据产生影响,不须要大规模迁移数据。好比分布式缓存系统节点数多,并且容易变更,很是适合使用一致性哈希

原理

假设一致性哈希算法的结果是 32 位的 Key 值,将这 2^32 个数值映射在一个环形空间上,则对应有 2^32 个缓存区。好比下图中五个 Key 值分布在环形空间上

Consistent-hashing

每个缓存节点(Shard)也遵循一样的 Hash 算法,好比利用 IP 作 Hash,映射到环形空间当中

Consistent-hashing

而后按顺时针将 Key 归属到最近的节点上,好比 K五、K1 归属为节点 N1 上

Consistent-hashing

增长节点 N4 时,咱们只须要改变 K5 的归属便可,不会对其余节点和数据形成影响

Consistent-hashing

删除节点 N1 时,咱们只须要将 K1 归属到 N2 上就行

Consistent-hashing

可是仍会有分布不均的状况,特别是服务器节点较少时,好比下图大部分键值都流向某个单一节点

Consistent-hashing

为了应对这种状况,引入了虚拟节点的概念,将原来的一个物理节点映射出多个子节点,让子节点代替物理节点放置在环形空间中

Consistent-hashing

好比本来的物理节点 N1 使用的关键字是本身的 IP 192.168.1.109,其位置是 hash("192.168.1.109")。如今为其建立两个虚拟节点 N1_1 和 N1_2,两个子节点可使用 IP + 编号 的方式计算哈希,好比 hash("192.168.1.109#1") 和 hash("192.168.1.109#2"),同理为 N2 也建立了两个虚拟节点,

建立虚拟节点的好处是分布更均匀

其余常见散列函数

MD5

Message-Digest Algorithm 5 - 消息摘要算法,一种被普遍使用的密码散列函数,能够产生出一个 128 位的散列值

能够应用于文件校验,例如,服务器预先提供一个 MD5 校验和,用户下载完文件之后,用 MD5 算法计算下载文件的 MD5 校验和,而后经过检查这两个校验和是否一致,就能判断下载的文件是否出错

MD5 是输入不定长度信息,输出固定长度 128 位的算法。通过程序流程,生成四个 32 位数据,最后联合起来成为一个 128 位散列。基本方式为,求余、取余、调整长度、与连接变量进行循环运算,得出结果。

可是 MD5 能够被破解,不适合高度安全性的场景,好比不能用于密钥认证和数字签名

SHA

Secure Hash Algorithm - 安全散列算法是一个密码散列函数家族,也能计算出一个数字消息所对应到的,长度固定的字符串(又称消息摘要)的算法。安全性高于 MD5,好比数字签名经常使用的就是 SHA-256。

SHA 分为 SHA-0、SHA-一、SHA-二、SHA-3 四个大版本,其中 SHA-0 和 SHA-1 输出的散列值为 160 位;SHA-2 细分为多种,好比 SHA-256 输出的是 256 位散列值,另外还有 SHA-22四、SHA-38四、SHA-512 等等;SHA-3 是 2015 年正式发布的,SHA-3 并非要取代 SHA-2,由于 SHA-2 目前并无出现明显的弱点,因为对 MD5 出现成功的破解,以及对 SHA-0 和 SHA-1 出现理论上破解的方法,NIST 感受须要一个与以前算法不一样的,可替换的加密散列算法,也就是如今的 SHA-3

CRC

Cyclic redundancy check - 循环冗余校验是一种根据网络数据包或计算机文件等数据产生简短固定位数校验码的一种散列函数,主要用来检测或校验数据传输或者保存后可能出现的错误。通常来讲,循环冗余校验的值都是 32 位的整数。

CRC 的计算过程网络课程便讲过,不算复杂,这里不深刻。WikiPedia 上提供了一些 CRC 变体的描述,感兴趣能够了解一下。

尽管在错误检测中很是有用,CRC 并不能可靠地校验数据完整性,由于 CRC 多项式是线性结构,能够很是容易地故意改变量据而维持 CRC 不变。通常使用Message authentication code校验数据完整性

Reference

相关文章
相关标签/搜索