您的位置:首页 > 理论基础 > 数据结构算法

小白学数据结构——五、查找(哈希表&布隆过滤器)

2017-11-10 21:23 246 查看

哈希表

使用哈希表可以进行非常快速的查找操作,查找时间为常数,同时不需要元素排列有序

python的内建数据类型:字典,就是用哈希表实现的

哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

定义

存放数据的集合(不是线性表,不在乎顺序,不能重复)

操作:根据(Key, Value)进行

插入,查找,删除(可以没有)

空间复杂度:O(m)

单次操作时间复杂度:O(1)

本质:Key的索引

一个输入通过散列算法得到一个固定长宽的输出,这个散列值是有可能冲突的,也就是说输入值不同,而通过计算后得到的散列值有可能相同了,这样就产生了冲突,拉链法就能解决这样的冲突。拉链法把散列值所对应的数据又放在了一个链表里,放在链表的位置就是以散列值作为索引值,这样就能解决散列冲突的问题,要以下边的示意图来展示:



实现(python)

最简单的方式——用线性表的方式实现

class LinearMap(object):
""" 线性表结构 """
def __init__(self):
self.items = []

def add(self, k, v):    # 往表中添加元素
self.items.append((k,v))

def get(self, k):       # 线性方式查找元素
for key, val in self.items:
if key==k:      # 键存在,返回值,否则抛出异常
return val
raise KeyError


我们可以在使用add添加元素时让items列表保持有序,而在使用get时采取二分查找方式,时间复杂度为O(log n)。 然而往列表中插入一个新元素实际上是一个线性操作,所以这种方法并非最好的方法。同时,我们仍然没有达到常数查找时间的要求。

我们可以做以下改进将总查询表分割为若干段较小的列表,比如100个子段。通过hash函数求出某个键的哈希值,再通过计算,得到往哪个子段中添加或查找。相对于从头开始搜索列表,时间会极大的缩短。尽管get操作的增长依然是线性,但BetterMap类使得我们离哈希表更近一步:

class BetterMap(object):
""" 利用LinearMap对象作为子表,建立更快的查询表 """
def __init__(self,n=100):
self.maps = []          # 总表格
for i in range(n):      # 根据n的大小建立n个空的子表
self.maps.append(LinearMap())

def find_map(self,k):       # 通过hash函数计算索引值
index = hash(k) % len(self.maps)
return self.maps[index] # 返回索引子表的引用

# 寻找合适的子表(linearMap对象),进行添加和查找
def add(self, k, v):
m = self.find_map(k)
m.add(k,v)

def get(self, k):
m = self.find_map(k)
return m.get(k)


测试代码:

if __name__=="__main__":
table = BetterMap()
pricedata = [("Hohner257",257),
("SW1664",280),
("SCX64",1090),
("SCX48",830),
("Super64",2238),
("CX12",1130),
("Hohner270",620),
("F64C",9720),
("S48",1988)]

for item, price in pricedata:
table.add(k=item, v=price)

print table.get("CX12")


由于每个键的hash值必然不同,所以对hash值取余的值基本也是不同的。

当n=100时, BetterMap的查找速度大约是LinearMap的100倍。

明显,BetterMap的查找速度受到参数n的限制,同时其中每个LinearMap的长度不固定,使得子段中的元素依然是线性查找。如果,我们能够限制每个子段的最大长度,这样在单个子段中查找的时间负责度就有一个固定上限,则LinearMap.get方法的时间复杂度就成为了一个常数。由此,我们仅仅需要跟踪元素的数量,每当某个LinearMap中的元素数量超过阈值时, 对整个hashtable进行重排,同时增加更多的LinearMap,这样子就可以保证查找操作为一个常数啦。

以下是hashtable的实现

class HashMap(object):
def __init__(self):
# 初始化总表为,容量为2的表格(含两个子表)
self.maps = BetterMap(2)
self.num = 0        # 表中数据个数

def get(self,k):
return self.maps.get(k)

def add(self, k, v):
# 若当前元素数量达到临界值(子表总数)时,进行重排操作
# 对总表进行扩张,增加子表的个数为当前元素个数的两倍!
if self.num == len(self.maps.maps):
self.resize()

# 往重排过后的 self.map 添加新的元素
self.maps.add(k, v)
self.num += 1

def resize(self):
""" 重排操作,添加新表, 注意重排需要线性的时间 """
# 先建立一个新的表,子表数 = 2 * 元素个数
new_maps = BetterMap(self.num * 2)

for m in self.maps.maps:  # 检索每个旧的子表
for k,v in m.items:   # 将子表的元素复制到新子表
new_maps.add(k, v)

self.maps = new_maps      # 令当前的表为新表


重点关注 add 部分,该函数检查元素个数与BetterMap的大小,如果相等,则“平均每个LinearMap中的元素个数为1”,然后调用resize方法。

resize创建一个新表,大小为原来的两倍,然后对旧表中的元素“rehashes 再哈希”一 遍,放到新表中。

resize过程是线性的,听起来好像很不怎么好,因为我们要求的hashtable具有常数时间。但是,要知道我们并不需要经常进行重排操作,所以add操作在绝大部分时间中都是常数的,偶然出现线性。由于对n个元素进行add操作的总时间与n成比例,所以每次add的平均时间就是一个常数!

另一种实现方式

实现的方法:

Map()方法,创建一个空Hash表

put(key, value)方法,接收一个key和value,没有返回值

get(key)方法,接收key,返回key对应的value值,如果没有此值默认返回None

remove(key)方法,接收key,删除key对应的value

定义一个元素类

class Node:
def __init__(self, key, value):
self.key = key
self.value = value


Node类接收两个参数,一个是key,另一个Key对应的value。

Map的实现

class Map:
def __init__(self, init_size, hash=hash):
self.__slot = [[] for _ in range(init_size)]
self.__size = init_size
self.hash = hash


如果计算散列值的函数不使用pyton的内建的hash函数,可以自己传入一个函数,这里默认采用hash函数。代码中的列表解析self._slot = [[] for in range(init_size)]与下边的代码块等价,都是生成一个包含init_size个[]元素的列表。

self.__slot = []
for _ in range(init_size):
self.__slot.append([])


put方法

def put(self, key, value):
node = Node(key, value)
address = self.hash(node.key) % self.__size
self.__slot[address].append(node)


这里的address = self.hash(node.key) % self.__size是把散列值与Hash表尺寸做取模运算,这样address就一定能落到如上图中的数组元素中,接着再把node对象append到链表中。

get方法实现

def get(self, key, default=None):
_key = self.hash(key)
address = _key % self.__size
for node in self.__slot[address]:
if node.key == key:
return node.value
return default


要得到一个key的value值,首先得采用相同的hash函数计算出散列地址,再在这个地址上的链表中遍历key,有则返回其value,否则返回一个默认值。

remove方法实现

def remove(self, key):
address = self.hash(key) % self.__size
for idx, node in enumerate(self.__slot[address].copy()):
if node.key == key:
self.__slot[address].pop(idx)


此方法同样需要得到key的散列地址,再遍历链表,只是这里会删除key所对应的value。在使用python的可迭代对象时有一条定律,那就是永远都不要对迭代对象进行数据的修改,所以这里把链表进行了copy(),生成一个副本,对这个副本进行遍历,如果链表中有key,那就在原链表里弹出此key。

完整代码:

class Node:
def __init__(self, key, value):
self.key = key
self.value = value

class Map:
def __init__(self, init_size, hash=hash):
self.__slot = [[] for _ in range(init_size)]
#self.__slot = []
#for _ in range(init_size):
# self.__slot.append([])
self.__size = init_size
self.hash = hash

def put(self, key, value): node = Node(key, value) address = self.hash(node.key) % self.__size self.__slot[address].append(node)
def get(self, key, default=None): _key = self.hash(key) address = _key % self.__size for node in self.__slot[address]: if node.key == key: return node.value return default

def remove(self, key): address = self.hash(key) % self.__size for idx, node in enumerate(self.__slot[address].copy()): if node.key == key: self.__slot[address].pop(idx)

if __name__ == '__main__':
map = Map(16)
for i in range(5):
map.put(i, i)

map.remove(3)

for i in range(5):
print(map.get(i, 'not set'))


参考文献

http://zhaochj.github.io/2016/05/16/2016-05-16-%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84-hash/

布隆过滤器

在日常生活中,包括在设计计算机软件时,我们经常要判断一个元素是否在一个集合中。比如在字处理软件中,需要检查一个英语单词是否拼写正确(也就是要判断它是否在已知的字典中);在 FBI,一个嫌疑人的名字是否已经在嫌疑名单上;在网络爬虫里,一个网址是否被访问过等等。最直接的方法就是将集合中全部的元素存在计算机中,遇到一个新元素时,将它和集合中的元素直接比较即可。一般来讲,计算机中的集合是用哈希表(hash table)来存储的。它的好处是快速准确,缺点是费存储空间。当集合比较小时,这个问题不显著,但是当集合巨大时,哈希表存储效率低的问题就显现出来了。比如说,一个象 Yahoo,Hotmail 和 Gmai 那样的公众电子邮件(email)提供商,总是需要过滤来自发送垃圾邮件的人(spamer)的垃圾邮件。一个办法就是记录下那些发垃圾邮件的 email 地址。由于那些发送者不停地在注册新的地址,全世界少说也有几十亿个发垃圾邮件的地址,将他们都存起来则需要大量的网络服务器。如果用哈希表,每存储一亿个 email 地址, 就需要 1.6GB 的内存(用哈希表实现的具体办法是将每一个 email 地址对应成一个八字节的信息指纹 ,然后将这些信息指纹存入哈希表,由于哈希表的存储效率一般只有 50%,因此一个 email 地址需要占用十六个字节。一亿个地址大约要 1.6GB, 即十六亿字节的内存)。因此存贮几十亿个邮件地址可能需要上百 GB 的内存。除非是超级计算机,一般服务器是无法存储的。

今天,我们介绍一种称作布隆过滤器的数学工具,它只需要哈希表 1/8 到 1/4 的大小就能解决同样的问题。

布隆过滤器是由巴顿.布隆于一九七零年提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。我们通过上面的例子来说明起工作原理。

假定我们存储一亿个电子邮件地址,我们先建立一个十六亿二进制(比特),即两亿字节的向量,然后将这十六亿个二进制全部设置为零。对于每一个电子邮件地址 X,我们用八个不同的随机数产生器(F1,F2, …,F8) 产生八个信息指纹(f1, f2, …, f8)。再用一个随机数产生器 G 把这八个信息指纹映射到 1 到十六亿中的八个自然数 g1, g2, …,g8。现在我们把这八个位置的二进制全部设置为一。当我们对这一亿个 email 地址都进行这样的处理后。一个针对这些 email 地址的布隆过滤器就建成了。(见下图)



现在,让我们看看如何用布隆过滤器来检测一个可疑的电子邮件地址 Y 是否在黑名单中。我们用相同的八个随机数产生器(F1, F2, …, F8)对这个地址产生八个信息指纹 s1,s2,…,s8,然后将这八个指纹对应到布隆过滤器的八个二进制位,分别是 t1,t2,…,t8。如果 Y 在黑名单中,显然,t1,t2,..,t8 对应的八个二进制一定是一。这样在遇到任何在黑名单中的电子邮件地址,我们都能准确地发现。

布隆过滤器决不会漏掉任何一个在黑名单中的可疑地址。但是,它有一条不足之处。也就是它有极小的可能将一个不在黑名单中的电子邮件地址判定为在黑名单中,因为有可能某个好的邮件地址正巧对应个八个都被设置成一的二进制位。好在这种可能性很小。我们把它称为误识概率。在上面的例子中,误识概率在万分之一以下。

布隆过滤器的好处在于快速,省空间。但是有一定的误识别率。常见的补救办法是在建立一个小的白名单,存储那些可能别误判的邮件地址。



参考文献

https://china.googleblog.com/2007/07/bloom-filter_7469.html
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息