Hash算法只是一个定义,并没有规定具体的实现
简述
把任意长度的输入,通过Hash算法变换成固定长度的输出,这个输出就是Hash值。哈希值的空间远小于输入的空间,所以可能会发生“哈希碰撞”,即两个不同的输入,产生了同一个输出。Hash算法常用于消息摘要的场景 MD5、SHA都属于Hash算法的实现。
简单使用
凡是涉及到分布式的系统,就会有负载均衡和数据分布的问题。为了让连接(或者数据)能够分布得更均匀,很多时候会使用到Hash算法
Hash取模 (hash(request) % n)
假设我们现在有3个服务器,想要做负载均衡,就可以对请求的ip地址或者用户的id等使用Hash函数,然后将计算得出的Hash值对3取模,余数为几,就把请求分配到相应的服务器上
缺点:如果新增服务器的时候,绝大多数请求基本上都需要重新映射到另一个节点。这种变动有时候是不能接受的。比如在Web负载均衡的场景下,session会保存在每个节点里。当然,如果你是“无状态”的服务,那不会存在这个问题。因为如果增加或者删除了一个节点,就会导致几乎所有的数据都需要重新迁移。
一致性Hash
优点:解决因为横向伸缩导致的大规模数据变动
上面说到用节点的数量作为除数去求余。而一致性Hash的除数是232。从0到232 - 1,首尾相连构成了一个环。我们先对服务器节点的IP进行Hash,然后除以2^32
得到服务器节点在这个Hash环中的位置。如果有请求进来了,同样进行Hash然后处于2^32求余。如果落在Hash环上,然后顺时针找到第一个节点,这个节点就负责处理这个请求。一致性Hash算法在节点数量较少的时候,会出现分布不均匀的问题
。解决这个问题的方案就是在Hash环上增加虚拟节点
非哈希表:关键字在表中的位置和它本身不存在一个确定的关系,查找的过程为给定值一次和各个关键字进行比较,查找的效率取决于和给定值进行比较的次数。
哈希表:关键字在表中位置和它之间存在一种确定的关系。
哈希函数:一般情况下,需要在关键字与它在表中的存储位置之间建立一个函数关系,以f(key)作为关键字为key的记录在表中的位置,通常称这个函数f(key)为哈希函数。
hash:翻译为“散列”,就是把任意长度的输入,通过散列算法,变成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到莫伊固定长度的消息摘要的函数。
hash冲突:就是根据key即经过一个函数f(key)得到的结果的作为地址去存放当前的key value键值对(这个是hashmap的存值方式),但是却发现算出来的地址上已经被其他key占用。
开放定址法又叫做封闭散列(closed hashing)
哈希表的长度为m,增量为di。i为正整数,表示散列的次数,当H0 = H(key)MOD m发生Hash冲突(H0位置已有Key存在)时,按照散列地址序列:Hi = (H(Key)+ di) MOD m (其中1 <= s <= m-1 , 1 <= i <= s)
散列方式 | di | 备注 | |
表达式 | 示例 | ||
线性探测再散列 | i | 1 , 2 , 3 , … , m-1 | |
平方(二次)探测再散列 | (i为奇数就是+,i为偶数就是 -)(i/2取整)^2 | 1^2 , -1^2 , 2^2, -2^2 , 3^2 , -3^2 , … , k^2 , -k^2 | 取相应数的平方 |
伪随机探测再散列 | 一组伪随机数列Ri,给定一个随机数做起点(如:Ri=(Ri+p) % m) | 2,5,9,…….. |
示例代码:
线性探测散列:
Integer[] strings = {19, 1, 23, 14, 55, 68, 11, 82, 36};
List stringList = new ArrayList<>();
for (int i = 0; i < 11; i++) {
stringList.add(null);
}
int initialInteger;
for (int i : strings) {
initialInteger = i;
while (true){
if (stringList.get(i % 11) == null) {
stringList.set(i % 11, initialInteger);
break;
} else {
i += 1;
}
}
}
System.out.println(stringList);
平方探测散列:
Integer[] integers = {19, 1, 23, 14, 55, 68, 11, 82, 36};
List stringList = new ArrayList<>();
for (int i = 0; i < 11; i++) {
stringList.add(null);
}
int initialInteger;
for (int i : integers) {
initialInteger = i;
int j = 0;
int k = 0;
while (true){
if (stringList.get(i % 11) == null) {
stringList.set(i % 11, initialInteger);
break;
} else {
k += 1;
i = ((k % 2 == 1) ? (initialInteger + (++j)*j) : (initialInteger - j*j));
}
}
}
System.out.println(stringList);
优点:
1. 记录更容易进行序列化(serialize)操作 ;
2. 如果记录总数可以预知,可以创建完美哈希函数,此时处理数据的效率是非常高的。
缺点:
1. 存储记录的数目不能超过桶数组的长度,如果超过就需要扩容,而扩容会导致某次操作的时间成本飙升,这在实时或者交互式应用中可能会是一个严重的缺陷。
2. 使用探测序列,有可能其计算的时间成本过高,导致哈希表的处理性能降低。
3. 由于记录是存放在桶数组中的,而桶数组必然存在空槽,所以当记录本身尺寸(size)很大并且记录总数规模很大时,空槽占用的空间会导致明显的内存浪费。
4. 删除记录时,比较麻烦。比如需要删除记录a,记录b是在a之后插入桶数组的,但是和记录a有冲突,是通过探测序列再次跳转找到的地址,所以如果直接删除a,a的位置变为空槽,而空槽是查询记录失败的终止条件,这样会导致记录b在a的位置重新插入数据前不可见,所以不能直接删除a,而是设置删除标记。这就需要额外的空间和操作。
链地址法又叫做开放散列(open hashing)/ 拉链法(针对桶链结构)
假设Hash函数(算法)是:H(key) = key % 7,算出来对应的hash值,这个hash值暂时就决定,当前的这个值,存放在数组的位置。
Java的HashMap就是用的这个链表产生机制,在put()方法里面,最后部分有个如下的调用:
addEntry(hash, key, value, i);
其中hash是根据key算出来的一个值,即Hash函数得出的结果,源码:
int hash = hash(key);
key和value就是往HashMap中正在存放的键值对,i是这个键值对存放在底层数组的索引下标,源码中是int i = indexFor(hash, table.length);
Key先通过Hash函数得到自身的Hash值,对此Hash值取模得到这个Key即将要存放的地址 i,i就是数组中的下标。
虽然在put的时候,可能会出现扩容的问题,这里暂不讨论
这样在后面的Key存放的时候,可能会出现Hash值与之前的Hash值相同的情况,这样就可能(HashMap没有发生扩容)造成得出的数组下标i相同,这时就会生成链表,以及链表上的键值对的顺序,数组中的某一位置发生Hash冲突时会创建一个节点到数组上:
createEntry(hash, key, value, bucketIndex);
在JDK1.8之后,达到一定的条件还会触发数组中的链表会变成红黑树的情况,是对之前链表过程会影响性能的优化。
未完待续。。。
优点:
1. 对于记录总数频繁可变的情况,处理的比较好(也就是避免了动态调整的开销)。
2. 由于记录存储在结点中,而结点是动态分配,不会造成内存的浪费,所以尤其适合那种记录本身尺寸(size)很大的情况,因为此时指针的开销可以忽略不计了。
3.删除记录时,比较方便,直接通过指针操作即可。
缺点:
1. 存储的记录是随机分布在内存中的,这样在查询记录时,相比结构紧凑的数据类型(比如数组),哈希表的跳转访问会带来额外的时间开销。
2. 如果所有的 key-value 对是可以提前预知,并之后不会发生变化时(即不允许插入和删除),可以人为创建一个不会产生冲突的完美哈希函数(perfect hash function),此时封闭散列的性能将远高于开放散列。
3. 由于使用指针,记录不容易进行序列化(serialize)操作。
产生冲突时计算另一个哈希函数地址,直到不再产生冲突为止:
Hi = RHi(Key) i =1,2,3...k
将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。