数据结构——哈希表

前言

使用哈希表能够进行很是快速的查找操做。可是,哈希表到底是什么玩意儿?不少人避而不谈,虽然知道常常用到,不少语言的内置数据结构像python中的字典,java中的HashMap,都是基于哈希表实现。但哈希表到底是啥?java

哈希是什么?

散列(hashing)是电脑科学中一种对资料的处理方法,经过某种特定的函数/算法(称为散列函数/算法)将要检索的项与用来检索的索引(称为散列,或者散列值)关联起来,生成一种便于搜索的数据结构(称为散列表)。也译为散列。旧译哈希(误觉得是人名而采用了音译)。它也经常使用做一种资讯安全的实做方法,由一串资料中通过散列算法(Hashing algorithms)计算出来的资料指纹(data fingerprint),常常用来识别档案与资料是否有被窜改,以保证档案与资料确实是由原创者所提供。 ----Wikipediapython

哈希函数

全部的哈希函数都具备以下一个基本特性:若是两个散列值是不相同的(根据同一函数),那么这两个散列值的原始输入也是不相同的。这个特性是散列函数具备肯定性的结果,具备这种性质的散列函数称为单向散列函数。算法

哈希表

  • 若关键字为k,则其值存放在f(k)的存储位置上。由此,不需比较即可直接取得所查记录。称这个对应关系f为散列函数,按这个思想创建的表为散列表。数组

  • 对不一样的关键字可能获得同一散列地址,即k1≠k2,而f(k1)=f(k2),这种现象称为冲突。具备相同函数值的关键字对该散列函数来讲称作同义词。综上所述,根据散列函数f(k)和处理冲突的方法将一组关键字映射到一个有限的连续的地址集(区间)上,并以关键字在地址集中的“像”做为记录在表中的存储位置,这种表便称为散列表,这一映射过程称为散列造表或散列,所得的存储位置称散列地址。安全

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

创建哈希表

总的来讲,哈希表就是一个具有映射关系的表,你能够经过映射关系由键找到值。有没有现成的例子?固然有,不过你直接用就没意思了。app

反正就是要实现f(k),即实现key-value的映射关系。咱们试着本身实现一下:函数

class Map:
    def __init__(self):
        self.items=[]

    
    def put(self,k,v):
        self.items.append((k,v))
    

    def get(self,k):
        for key,value in self.items:
            if(k==key):
                return value
复制代码

这样实现的Map,查找的时间复杂度为O(n)。 “这太简单了,看上去与key没什么关系啊,这不是顺序查找么,逗我呢?” 这只是一个热身,好吧,下面咱们根据定义,来搞一个有映射函数的:优化

class Map:
    def __init__(self):
        self.items=[None]*100
    
    def hash(self,a):
        return a*1+0
    
    def put(self,k,v):
        self.items[hash(k)]=v

    def get(self,k):
        hashcode=hash(k)
        return self.items[hashcode]
复制代码

“这hash函数有点简单啊” 是的,它是简单,但简单不妨碍它成为一个哈希函数,事实上,它叫直接定址法,是一个线性函数: hash(k)= a*k+bspa

“为啥初始化就指定了100容量?” 必需要指出的是,这个是必须的。你想经过下标存储并访问,对于数组来讲,这不可避免。在JDK源码里,你也能够看到,JavaHashMap的初始容量设成了16。你可能说,你这hash函数,我只要key设为100以上,这程序就废了。是啊,它并不完美。这涉及到扩容的事情,稍后再讲。

直接定址法的优势很明显,就是它不会产生重复的hash值。但因为它与键值自己有关系,因此当键值分布很散的时候,会浪费大量的存储空间。因此通常是不会用到直接定址法的。

处理冲突

假如某个hash函数产生了一堆哈希值,而这些哈希值产生了冲突怎么办(实际生产环境中常常发生)?在各类哈希表的实现里,处理冲突是必需的一步。 好比你定义了一个hash函数: hash(k)=k mod 10 假设key序列为:[15,1,24,32,55,64,42,93,82,76]

0 1 2 3 4 5 6 7 8 9
1 32 93 24 15 76
42 64 55
82

一趟下来,冲突的元素有四个,下面有几个办法。

开放定址法

开放定址法就是产生冲突以后去寻找下一个空闲的空间。函数定义为:

其中,hash(key)是哈希函数,di是增量序列,i为已冲突的次数。

  • 线性探测法

di=i,或者其它线性函数。至关于逐个探测存放地址的表,直到查找到一个空单元,而后放置在该单元。

[15,1,24,32,55,64,42,93,82,76]

能够看到,在55以前都还没冲突:

0 1 2 3 4 5 6 7 8 9
1 32 24 15

此时插入55,与15冲突,应用线性探测,此时i=1,能够获得:

0 1 2 3 4 5 6 7 8 9
1 32 24 15 55

再插入64,冲突很多,要取到i=3

0 1 2 3 4 5 6 7 8 9
1 32 24 15 55 64

插入42i=1

0 1 2 3 4 5 6 7 8 9
1 32 42 24 15 55 64

插入93i=5

0 1 2 3 4 5 6 7 8 9
1 32 42 24 15 55 64 93

插入82i=7

0 1 2 3 4 5 6 7 8 9
1 32 42 24 15 55 64 93 82

插入76i=4

0 1 2 3 4 5 6 7 8 9
76 1 32 42 24 15 55 64 93 82

发现越到后面,冲突的愈来愈离谱。因此,表的大小选择也很重要,此例中选择了10做为表的大小,因此容易产生冲突。通常来说,越是质数,mod取余就越可能分布的均匀

  • 平方探测

这称做平方探测法,一个道理,也是查找到一个空单元而后放进去。这里就不一步一步说明了=。=

  • 伪随机探测 di是一个随机数序列。 “随机数?那get的时候咋办?也是随机数啊,怎么确保一致?” 因此说了,是伪随机数。其实咱们在计算机里接触的几乎都是伪随机数,只要是由肯定算法生成的,都是伪随机。只要种子肯定,生成的序列都是同样的。序列都同样,那不就能够了么=。=

链表法

这是另一种类型解决冲突的办法,散列到同一位置的元素,不是继续往下探测,而是在这个位置是一个链表,这些元素则都放到这一个链表上。javaHashMap就采用的是这个。

再散列

若是一次不够,就再来一次,直到冲突再也不发生。

创建公共溢出区

将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一概填入溢出表(注意:在这个方法里面是把元素分开两个表来存储)。

说了这么一堆,举个例子,用开放地址法(线性探测):

class Map:
    def __init__(self):
        self.hash_table=[[None,None]for i in range(11)]
    
    def hash(self,k,i):
        h_value=(k+i)%11
        if self.hash_table[h_value][0]==k:
            return h_value
        if self.hash_table[h_value][0]!=None:
            i+=1
            h_value=self.hash(k,i)
        return h_value
 
    def put(self,k,v):
        hash_v=self.hash(k,0)
        self.hash_table[hash_v][0]=k
        self.hash_table[hash_v][1]=v

    def get(self,k):
        hash_v=self.hash(k,0)
        return self.hash_table[hash_v][1]
复制代码

“能不能不要定死长度?11个彻底不够用啊”

这是刚才的问题,因此有了另一个概念,叫作载荷因子(load factor)。载荷因子的定义为: α= 已有的元素个数/表的长度

因为表长是定值, α与“填入表中的元素个数”成正比,因此, α越大,代表填入表中的元素越多,产生冲突的可能性就越大;反之,α越小,代表填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子 α的函数,只是不一样处理冲突的方法有不一样的函数。

因此当到达必定程度,表的长度是要变的,即resize=。=像javaHashMap,载荷因子被设计为0.75;超过0.8cpucache missing会急剧上升。能够看下这篇讨论: www.zhihu.com/question/22…

具体扩容多少,通常选择扩到已插入元素数量的两倍,java也是这么作的。

接着上面,再升级一下咱们的map

class Map:
    def __init__(self):
        self.capacity=11
        self.hash_table=[[None,None]for i in range(self.capacity)]
        self.num=0
        self.load_factor=0.75
    
    def hash(self,k,i):
        h_value=(k+i)%self.capacity
        if self.hash_table[h_value][0]==k:
            return h_value
        if self.hash_table[h_value][0]!=None:
            i+=1
            h_value=self.hash(k,i)
        return h_value

    def resize(self):
        self.capacity=self.num*2 #扩容到原有元素数量的两倍
        temp=self.hash_table[:]
        self.hash_table=[[None,None]for i in range(self.capacity)] 
        for i in temp:
            if(i[0]!=None):  #把原来已有的元素存入
                hash_v=self.hash(i[0],0)
                self.hash_table[hash_v][0]=i[0]
                self.hash_table[hash_v][1]=i[1]
 
    def put(self,k,v):
        hash_v=self.hash(k,0)
        self.hash_table[hash_v][0]=k
        self.hash_table[hash_v][1]=v
        self.num+=1                 #暂不考虑key重复的状况,具体本身能够优化
        if(self.num/len(self.hash_table)>self.load_factor):# 若是比例大于载荷因子
            self.resize()

    def get(self,k):
        hash_v=self.hash(k,0)
        return self.hash_table[hash_v][1]
复制代码

看上面的函数,能够看到resize是一个比较耗时的操做,由于只是原理教学,因此并无什么奇淫技巧在里面。能够去看一下JavaHashMaphash方法和resize方法,还有处理冲突时的设计(jdk8及以后的HashMap用到了红黑树),其中的思路要精妙的多。

关于哈希表,原理的东西都基本差很少了。能够看到,它本质要解决的是查找时间的问题。若是顺序查找的话,时间复杂度为O(n);而哈希表,时间复杂度则为O(1)!直接甩了一个次元,这也就是为何在大量数据存储查找的时候,哈希表获得大量应用的缘由。

相关文章
相关标签/搜索