使用哈希表能够进行很是快速的查找操做。可是,哈希表到底是什么玩意儿?不少人避而不谈,虽然知道常常用到,不少语言的内置数据结构像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
源码里,你也能够看到,Java
的HashMap
的初始容量设成了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 |
插入42
,i=1
:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 32 | 42 | 24 | 15 | 55 | 64 |
插入93
,i=5
:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 32 | 42 | 24 | 15 | 55 | 64 | 93 |
插入82
,i=7
:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | 32 | 42 | 24 | 15 | 55 | 64 | 93 | 82 |
插入76
,i=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的时候咋办?也是随机数啊,怎么确保一致?” 因此说了,是伪随机数。其实咱们在计算机里接触的几乎都是伪随机数,只要是由肯定算法生成的,都是伪随机。只要种子肯定,生成的序列都是同样的。序列都同样,那不就能够了么=。=这是另一种类型解决冲突的办法,散列到同一位置的元素,不是继续往下探测,而是在这个位置是一个链表,这些元素则都放到这一个链表上。java
的HashMap
就采用的是这个。
若是一次不够,就再来一次,直到冲突再也不发生。
将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一概填入溢出表(注意:在这个方法里面是把元素分开两个表来存储)。
说了这么一堆,举个例子,用开放地址法(线性探测):
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
=。=像java
的HashMap
,载荷因子被设计为0.75
;超过0.8
,cpu
的cache 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
是一个比较耗时的操做,由于只是原理教学,因此并无什么奇淫技巧在里面。能够去看一下Java
的HashMap
的hash
方法和resize
方法,还有处理冲突时的设计(jdk8
及以后的HashMap
用到了红黑树),其中的思路要精妙的多。
关于哈希表,原理的东西都基本差很少了。能够看到,它本质要解决的是查找时间的问题。若是顺序查找的话,时间复杂度为O(n)
;而哈希表,时间复杂度则为O(1)
!直接甩了一个次元,这也就是为何在大量数据存储查找的时候,哈希表获得大量应用的缘由。