用途
举个例子,我手上有n个整数(可能有一大堆,比如1000个),想知道这些数里有没有10086和2333,我要怎么做?
办法当然有很多:
如果我们简单地将这些数字存在一个数组里,那我们大概只能把整个数组都找一遍,查询1000次,看看有没有我们想要的数字,这显然并不是什么好主意。
我们都知道,计算机通过下标索引一个数组消耗的时间是很短的。利用这一点,如果限定上述问题中出现的数字取值范围,我们就能在特定条件下很快的得出上面问题的答案:
如果这1万个整数取值在区间[0,10万],我们可以利用他们本身作为下标建立一个出现次数表,来记录这个数字是否出现。
例如,我们申请一个数组 int a[1000001]
,先将所有a数组的元素初始化为0,当我们发现这些数里出现了一次666,那么将a[666] = a[666] + 1
,这样a[666]
就表示666
这个数在这些整数中出现了几次。
只要通过这样的方法维护这个整数的集合,由于数组查询时间是O(1),我们可以仅用一次数组查询就知道这些数里存不存在10086了(存在的话,a[10086]将大于0)。
上面的方法其实是用空间上的消耗来优化时间。由于现在计算机的空间(内存)相对廉价,因此这种思路某种程度上是可行的。但仔细想想就会发现,我们维护的这个数组a的大小是可能出现元素的最大上限。如果我们维护的单个数值很大(例如几百亿),这样的算法在空间上的消耗将变得无法承受。
原理
Hash表其实可以看作时上述方法的优化算法。为了解决数组下标过大或过小的情况,我们需要通过某种方法,将这些数组下标映射到合理的区间范围之内。
假设我们有这样一个函数 fun(k)=v
(k可以为任意值,v为一个我们可以用作数组下标的数,比如范围[0,1312]内的某个数)。
我们通过a[fun(k)]
来维护k,其意义与之前的a[666]
一样,表示k在所有的数中出现了一次。这样,我们在最后查询a[fun(10086)]
,就可以判断10086有没有出现过。
这里的fun()
就可以被称为hash函数,fun(a)
的取值则被称作hash值而。
对整数求hash值
那么上面的fun()应该如何实现呢?
对于整数的key,要将它转换成hash值,容易想到的办法是利用取余数的性值。
key mod m
的值域将会在[0, m-1]
之间。因此我们可以将hash函数表示为fun(k) = k mod m
,对于任意的k,都可以转换为一个[0,m-1]
的数字,
并存储在一个大小为m的数组a[m]
中,m此时也是hash表内空间的容量。
你也许会想到,要在hash表内存储n个数,最理想的情况也至少需要n个储存空间。要将可能区间较大的数据压缩到我们希望的区间当中,
有可能会出现两个不同的key拥有相同hash值的情况。
比如m == 10
的时候,11,21,31…对于p的余数都是1。他们都应该被存储在a[1]的
位置,可是a[1]
的位置只有一个,怎么办?
上面这种情况被称作哈希冲突或“碰撞”。
解决碰撞
常见的方法是链地址法(俗称拉链法,嗯,挺通俗的)
简单说就是每个哈希表的地址存的其实是个链表,链表的节点里会保存完整的key。当出现两个hash值相同的时候,就新增一个链表节点。
查询的时候,通过key的hash值查找到这个链表头。然后遍历链表,比对一下完整的key值来判断到底哪个是我们想要的节点。
一个好的hash函数应该在特定的区间内,尽可能少的减少碰撞发生的几率。
例如hash表的容量p应该比要存储的数据总量n大些,否则碰撞必然发生。
而为了最大限度的利用hash空间,我们希望任意的key都可以再hash之后产生散步均匀的hash值。
因此,对使用fun(k) = k mod m
这个函数的hash表而言,m的取值有一定规则。
- m不应该是2的幂
- m最好选择一个合适的素数
首先要明确一点,如果我们能够保证需要处理的key值是完全随机的。那么我们就不需要讨论m到底取什么值了。
对于随机的key,我们取任意的m结果都是一样的 key mod m 会平均分配到[0,m]中
但是在实际的应用中,我们需要进行hash的数值往往存在一定的规律性。作为通用的数据结构,我们应该保证在某些极端的情况下,结构依然拥有良好的性能。
m不应该是2的幂,即 m ≠ 2p 2 p
这个其实不难理解
考虑
m=2p
m
=
2
p
时,m在二进制上可以表达为1<<p
(1在二进制上左移p位),如 2,4,8,16(二级制分别为:10,100,1000,10000)
若m取8,对于一个任意的key,比如1011010110,有:
---------------
k:1011010110 (726)
% m: 1000 (8)
---------------
= 110 (6)
---------------
可以发现,k的低3位 110 就是m无法除尽的余数,而高于第三位的所有位数都能够被整除。因此,k mod 2^p会使得任意被除数k只有低三位对结果产生影响。
这无疑对hash值的平均散布产生不利影响。当我们的key值集合全部都是低3位为110的数,那么我们的hash表就退化成链表了。
为什么m最好选择一个素数
如果选择的m为非素数,那么必定存在因子,即 m=a m = a × b b <script id="MathJax-Element-4" type="math/tex">b</script>。若key与m存在公因子,那么对于所有可能的key,key mod m的值将会在m的因子中产生循环。
例如 m = 9, 那么对于所有和m有公因子3的key(这里只考虑大于m的情况),
------------------------------------------------
12 15 18 21 24 27 30 ...
与m的余数为 3 6 0 3 6 0 3 ...
------------------------------------------------
又如 m = 12, 所有和m有公因子6的key,
------------------------------------------------
18 24 30 36 42 48 54 ...
与m的余数为 6 0 6 0 6 0 6 ...
------------------------------------------------
同样的道理,如果我们key值是完全随机的,那么取不取素数其实没有影响。但是如果我们的key值集合符合上面样例的情况。那么非素数m产生的坏影响就显现了出来。为了我们的hash能够较好地处理这些极端情况,因此我们最好选择与其他数字都不存在公因子的素数来作为m。
对字符串hash
在大多数环境中,我们拿到的需要hash存储的数据其实都不是一个单纯的整数。而这些元素往往都有一个字符串作为键值key。换个角度想,即使作为key的是一个结构体对象,我们也完全可以将它当作一个byte数组,这样当作字符串来处理。
那么如何对一个字符串求hash值呢?
原则上为了保证key被很好地hash,我们希望key里的全部信息都被有效利用。然后利用这些信息生产一个唯一性较强的整数。
这些整数当然也应该满足在区间上尽可能均匀分布这一特性。
在获得这个整数之后,我们只要按照之前对整数hash的流程处理就可以了。
常见的字符串hash算法有很多。这里列举一种BKDRHash。
unsigned int BKDRHash(char *str)
{
unsigned int seed = 131;//31,131,1313,13131,131313 etc.
unsigned int hash = 0;
while(*str)
{
hash = hash*seed + (*str++);
}
return hash%0x7FFFFFFF;
}
此处算法中间过程会发生整形溢出,不过由于用的是unsigned int 而且最后会取余,因此不产生影响
map与hashmap
熟悉c++stl的朋友一定对map这个结构不会陌生。C++的map是一颗红黑树,和hash表类似,也常常被用来维护一个键值对的集合。由于红黑树是一颗二叉排序树,因此设定排序规则后,map里的元素可以是有序的。
但是更多的特性往往带来更高的消耗。二叉树的查询和插入删除的时间复杂度都是O(logn)。这点上,map的效率不如一个碰撞率正常的hash表。
事实上,我们用到map的时候大部分都是用到其键值对维护集合的特性,而用到有序特性的时候相对比较少。因此,丢弃有序而追求效率的hashmap就诞生了。在go语言中map的实现就是用hashmap取代了高冷的红黑树,当然这里并不是说用hashmap就比红黑树更好,这两种选择其实都是对不同需求的不同妥协而已(C++中hashmap和红黑树map都是存在的。而go语言中要用红黑树,如果不求助于第三方包的话,我们只能手写一个)。
在hashmap中,hash表的元素不再是一个节点。而是一个包含8个键值对的数组(结构中被称为桶 bucket)。当新的元素要被插入hashmap时,key值先被hash,然后将key和value都放入相应桶里的空位中。
而桶上除了保存这8个键值对之外,还会保存这些key值得前八位hash值用作查询加速。前8为hash值不同就没有必要在查询时比较key值了。
同时hashmap里会有一个overflow指针组,当一个桶的8个空位都被占满时,通过overflow指针链接一个新的桶来存储放不下的数据。
要作为一个容器,固定容量得hash表是满足不了需求得。因此,hashmap需要按照一定得规则生长来保存更多的数据。go语言中得map会在存储的数据量 > bucket数组大小 * 6.5
的时候增长。
每次增长为新申请一张原表大小两倍的hash表。并在之后逐渐将旧表的元素转移到新表中。这个拷贝并不是在新建表之后立即完成的,而是在接下来每次发生数据插入的时候拷贝一部分。