算法整理1--Hash表

用途


举个例子,我手上有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表。并在之后逐渐将旧表的元素转移到新表中。这个拷贝并不是在新建表之后立即完成的,而是在接下来每次发生数据插入的时候拷贝一部分。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值