搜索
搜索是在项集合中查找特定项的算法过程。搜索通常对于项是否存在返回 True 或 False。有时它可能返回项被找到的地方。我们在这里将仅关注成员是否存在这个问题。
在 Python 中,有一个非常简单的方法来询问一个项是否在一个项列表中。我们使用 in 运算符。
>>> 15 in [3,5,2,4,1] False >>> 3 in [3,5,2,4,1] True >>>
顺序查找
当数据存储在类似列表这样的集合中时,我们说它具有线性和顺序关系。
每个数据项都存储在相对于其他数据项的位置。
在 Python 列表中,这些相对位置是单个项的索引值。
由于这些索引值是有序的,我们可以按顺序访问它们。
这个过程产生我们的第一种搜索技术 顺序查找
。
从列表中的第一个项目开始,我们按照基本的顺序排序,简单地从一个项移动到另一个项,直到找到我们正在寻找的项或遍历完整个列表。如果我们遍历完整个列表,则说明正在搜索的项不存在。
1 ''' 2 该算法需要一个列表和我们要查找的项作为参数; 3 返回一个是否存在的布尔值; 4 如果发现列表中的项,返回True 5 ''' 6 def search(ls,item): 7 position = 0 8 found = False 9 while position < len(ls) and not found: 10 if ls[position] == item: 11 found = True 12 else: 13 position += 1 14 return found
如果项不在列表中,知道它的唯一方法是将其与存在的每个项进行比较。
如果有n 个项,则顺序查找需要 n 个比较来发现项不存在。在项在列表中的情况下,分析不是那么简单。
实际上有三种不同的情况可能发生。在最好的情况下,我们在列表的开头找到所需的项,只需要一个比较。在最坏的情况下,我们直到最后的比较才找到项,第 n 个比较。
平均情况怎么样?平均来说,我们会在列表的一半找到该项; 也就是说,我们将比较 n/2 项。
然而,回想一下,当 n 变大时,系数,无论它们是什么,在我们的近似中变得不重要,因此顺序查找的复杂度是 O(n)。
二分查找
有序列表对于我们的比较是很有用的。
二分查找从中间项开始。 如果该项是我们正在寻找的项,我们就完成了查找。 如果它不是,我们可以使用列表的有序性质来消除剩余项的一半。
如果我们正在查找的项大于中间项,就可以消除中间项以及比中间项小的一半元素。如果该项在列表中,肯定在大的那半部分。
1 def binarySearch(alist, item): 2 first = 0 3 last = len(alist) - 1 4 found = False 5 6 while first <= last and not found: 7 midpoint = (first + last) // 2 8 if alist[midpoint] == item: 9 found = True 10 else: 11 if item < alist[midpoint]: 12 last = midpoint - 1 13 else: 14 first = midpoint + 1 15 16 return found 17 18 19 testlist = [0, 1, 2, 8, 13, 17, 19, 32, 42, ] 20 print(binarySearch(testlist, 3)) 21 print(binarySearch(testlist, 13)) 22 23 #使用迭代方法 24 def erfensearch(ls,item): 25 if len(ls) == 0: 26 return False 27 else: 28 midpoint = len(ls)//2 29 if ls[midpoint] == item: 30 return True 31 elif item > ls[midpoint]: 32 return erfensearch(ls[midpoint+1:],item) 33 else: 34 return erfensearch(ls[:midpoint],item) 35 testlist = [0, 1, 2, 8, 13, 17, 19, 32, 42,] 36 print(erfensearch(testlist, 3)) 37 print(erfensearch(testlist, 13))
当我们切分列表足够多次时,我们最终得到只有一个项的列表。 要么是我们正在寻找的项,要么不是。达到这一点所需的比较数是 i
当我们切分列表足够多次时,我们最终得到只有一个项的列表。 要么是我们正在寻找的项,要么不是。达到这一点所需的比较数是 i,当 $$\frac{n}{2^i} = 1$$ 时。 求解 i 得出 i = logn 。 最大比较数相对于列表中的项是对数的。 因此,二分查找是 O(log n)。
但需要注意的是:
binarySearch(alist[:midpoint],item)
使用切片运算符创建列表的左半部分,然后传递到下一个调用(同样对于右半部分)。
我们上面做的分析假设切片操作符是恒定时间的。然而,我们知道 Python中的 slice 运算符实际上是 O(k)。
这意味着使用 slice 的二分查找将不会在严格的对数时间执行。
幸运的是,这可以通过传递列表连同开始和结束索引来纠正。可以采用第一个代码的方法。
即使二分查找通常比顺序查找更好,但重要的是要注意,对于小的 n 值,排序的额外成本可能不值得。
事实上,我们应该经常考虑采取额外的分类工作是否使搜索获得好处。如果我们可以排序一次,然后查找多次,排序的成本就不那么重要。然而,对于大型列表,一次排序可能是非常昂贵,从一开始就执行顺序查找可能是最好的选择。
Hash查找
在本节中,我们将尝试进一步建立一个可以在 O(1) 时间内搜索的数据结构。这个概念被称为 Hash 查找
。
当我们在集合中查找项时,我们需要更多地了解项可能在哪里。如果每个项都在应该在的地方,那么搜索可以使用单个比较就能发现项的存在。然而,我们看到,通常不是这样的。
哈希表
是以一种容易找到它们的方式存储的项的集合。哈希表的每个位置,通常称为一个槽,可以容纳一个项,并且由从 0 开始的整数值命名。
例如,我们有一个名为 0 的槽,名为 1 的槽,名为 2 的槽,以上。最初,哈希表不包含项,因此每个槽都为空。我们可以通过使用列表来实现一个哈希表,每个元素初始化为None
。Figure 4 展示了大小 m = 11 的哈希表。换句话说,在表中有 m 个槽,命名为 0 到 10。
项 和 这个项在散列表中所属的槽 之间的映射被称为 hash 函数
。
hash 函数将接收集合中的任何项,并在槽名范围内(0和 m-1之间)返回一个整数。
假设我们有整数项 54,26,93,17,77
和 31
的集合。
我们的第一个 hash 函数,有时被称为 余数法
,只需要一个项并将其除以表大小,返回剩余部分作为其散列值(h(item) = item%11)
。
下图给出了我们的示例项的所有哈希值。注意,这种余数方法(模运算)通常以某种形式存在于所有散列函数中,因为结果必须在槽名的范围内。
一旦计算了哈希值,我们可以将每个项插入到指定位置的哈希表中,下图所示。
注意,11 个插槽中的 6 个现在已被占用。这被称为负载因子,通常表示为 λ=项数/表大小
, 在这个例子中,λ = 6/11
。
当搜索一个项时,只需使用哈希函数(散列函数)来计算项的槽的名称,然后检查哈希表来查看它是否存在。这个搜索操作室O(1),因为只需要恒定的时间来计算散列值(就是槽的名称),然后在在位置索引散列表。如果一切都正确的话,我们已经找到了一个恒定时间搜索算法。
注意:只有每个项映射到哈希表中的唯一位置,这种技术才起作用。如果两个或者更多项在同一个槽中,这种现象称为碰撞(冲突)。显然,冲突使散列技术产生了问题。
hash函数
给定项的集合,将每个项映射到唯一槽的散列函数被称为完美散列函数。如果我们知道项和集合将永远不会改变,那么可以构造一个完美的散列函数。
不幸的是,给定任意的项集合,没有系统的方法来构建完美的散列函数。幸运的是,我们不需要散列函数是完美的,仍然可以提高性能。
总具有完美散列函数的一种方式是增加散列表的大小,使得可以容纳项范围中的每个可能值。这保证每个项将具有唯一的槽。虽然这对于小数目的项是实用的,但是当可能项的数目大时是不可行的。例如,如果项是九位数的社保号码,则此方法将需要大约十亿个槽。如果我们只想存储 25 名学生的数据,我们将浪费大量的内存。
我们的目标是创建一个散列函数,最大限度地减少冲突数,易于计算,并均匀分布在哈希表中的项。有很多常用的方法来扩展简单余数法。
分组求和法
将项划分为相等大小的块(最后一块可能不是相等大小)。然后将这些块加在一起以求出散列值。例如,如果我们的项是电话号码 436-555-4601
,我们将取出数字,并将它们分成2位数(43,65,55,46,01)
。43 + 65 + 55 + 46 + 01
,我们得到 210。我们假设哈希表有 11 个槽,那么我们需要除以 11 。在这种情况下,210%11
为 1,因此电话号码 436-555-4601
散列到槽 1 。一些分组求和法会在求和之前每隔一个反转。对于上述示例,我们得到 43 + 56 + 55 + 64 + 01 = 219
,其给出 219%11 = 10
。
平方取中法
。我们首先对该项平方,然后提取一部分数字结果。例如,如果项是 44,我们将首先计算 44^2 = 1,936
。通过提取中间两个数字 93
,我们得到 5(93%11)
。
我们还可以为基于字符的项(如字符串)创建哈希函数。 词 cat
可以被认为是 ascii 值的序列。
1 >>> ord('c') 2 99 3 >>> ord('a') 4 97 5 >>> ord('t') 6 116
然后,我们可以获取这三个 ascii 值,将它们相加,并使用余数方法获取散列值。
下图展示了一个名为 hash 的函数,它接收字符串和表大小 作为参数,并返回从 0
到 tablesize-1
的范围内的散列值。
1 def hash(astring, tablesize): 2 sum = 0 3 for pos in range(len(astring)): 4 sum = sum + ord(astring[pos]) 5 6 return sum%tablesize
当使用此散列函数时,字符串总是返回相同的散列值。
为了弥补这一点,我们可以使用字符的位置作为权重。下面展示了使用位置值作为加权因子的一种可能的方式。
注意:哈希函数必须是高效的,以便它不会成为存储和搜索过程的主要部分。如果哈希函数太复杂,则计算槽名称的程序要比之前所述的简单地进行基本的顺序或二分搜索更耗时。 这将打破散列的目的。
冲突解决
解决冲突的一种方法是查找散列表,尝试查找到另一个空槽以保存导致冲突的项。
一个简单的方法是从原始哈希值位置开始,然后以顺序方式移动槽,直到遇到第一个空槽。注意,我们可能需要回到第一个槽(循环)以查找整个散列表。这种冲突解决过程被称为开放寻址,因为它试图在散列表中找到下一个空槽或地址。通过系统地一次访问每个槽,我们执行称为线性探测的开放寻址技术。
例子说明
下图是原始项的哈希值。
简单余数法散列函数(54,26,93,17,77,31,44,55,20)
下的整数项的扩展集合,下图是原始内容。
当将44放入到槽0时,发生冲突。在线性探测下,我们逐个顺序观察,直到找到位置。在这种情况下,我们找到槽 1。也就是44放到槽1。
再次,55应该放到槽0的,但是必须放置在槽 2 中,因为它是下一个开放位置。
20散列到槽 9 ,由于槽 9 已满,我们进行线性探测。我们访问槽10,0,1
和 2
,最后在位置 3 找到一个空槽。也就是20放入到槽3.
一旦我们使用开放寻址和线性探测建立了哈希表,我们就必须使用相同的方法来搜索项。
比如说查找项93,计算哈希值时,得到5。查看5槽得到的是93,返回True。
如果找20,哈希值是9,但是槽9的当前项是31,。不能简单的返回False,因为我们知道可能存在冲突,需要被迫做一个顺序搜索,从位置10开始寻找,知道找到项21或我们找到一个空槽。
线性探测的缺点是聚集的趋势。项在表中聚集。这意味着如果在相同的散列值处发生很多冲突,则将通过线性探测来填充多个周边槽。这将影响正在插入的其他项,正如我们尝试添加上面的项 20
时看到的。必须跳过一组值为 0
的值,最终找到开放位置。该聚集如 Figure 9 所示。
假设我们想查找项 93
。当我们计算哈希值时,我们得到 5
。查看槽 5 得到 93
,返回 True。如果我们正在寻找 20
, 现在哈希值为 9
,而槽 9
当前项为 31
。我们不能简单地返回 False,因为我们知道可能存在冲突。我们现在被迫做一个顺序搜索,从位置 10
开始寻找,直到我们找到项 20
或我们找到一个空槽。
线性探测的缺点是聚集的趋势,项在表中聚集。这意味着如果在相同的散列值处发生很多冲突,则将通过线性探测来填充多个周边槽。这将影响正在插入的其他项,正如我们尝试添加上面的项 20
时看到的。必须跳过一组值为 0
的值,最终找到开放位置。
处理聚集的一种方式是扩展线性探测技术,使得不是顺序地查找下一个开放槽,而是跳过槽,从而更均匀地分布已经引起冲突的项。这将潜在地减少发生的聚集。 下图展示了使用 加3
探头进行碰撞识别时的项。 这意味着一旦发生碰撞,我们将查看第三个槽,直到找到一个空。
在冲突后寻找另一个槽的过程叫 重新散列
。
使用简单的线性探测,rehash 函数是
newhashvalue = rehash(oldhashvalue)
其中rehash(pos)=(pos + 1)%sizeoftable
加3
rehash 可以定义为rehash(pos)=(pos + 3)%sizeoftable
一般来说,rehash(pos)=(pos + skip)%sizeoftable
。
注意:“跳过”的大小必须使得表中的所有槽最终都被访问。否则,表的一部分将不被使用。为了确保这一点,通常建议表大小是素数。这是我们在示例中使用 11 的原因。
线性探测思想的一个变种称为二次探测。
代替使用常量 “跳过” 值,我们使用rehash 函数,将散列值递增 1,3,5,7,9,
依此类推。这意味着如果第一哈希值是 h
,则连续值是h + 1,h + 4,h + 9,h + 16
,等等。换句话说,二次探测使用由连续完全正方形组成的跳跃。Figure 11 展示了使用此技术放置之后的示例值。
用于处理冲突问题的替代方法是允许每个槽保持对项的集合(或链)的引用。
链接允许许多项存在于哈希表中的相同位置。当发生冲突时,项仍然放在散列表的正确槽中。
随着越来越多的项哈希到相同的位置,搜索集合中的项的难度增加。 下图展示了添加到使用链接解决冲突的散列表时的项。
当我们要搜索一个项时,我们使用散列函数来生成它应该在的槽。
由于每个槽都有一个集合,我们使用一种搜索技术来查找该项是否存在。
优点是,平均来说,每个槽中可能有更少的项,因此搜索可能更有效。我们将在本节结尾处查看散列的分析。
使用map抽象数据类型
最有用的 Python 集合之一是字典。字典是一种关联数据类型,你可以在其中存储键-值对。该键用于查找关联的值。我们经常将这个想法称为map。
map 抽象数据类型定义如下。该结构是键与值之间的关联的无序集合。map 中的键都是唯一的,因此键和值之间存在一对一的关系。操作如下:
- Map() 创建一个新的 map 。它返回一个空的 map 集合。
- put(key,val) 向 map 中添加一个新的键值对。如果键已经在 map 中,那么用新值替换旧值。
- get(key) 给定一个键,返回存储在 map 中的值或 None。
- del 使用
del map[key]
形式的语句从 map 中删除键值对。- len() 返回存储在 map 中的键值对的数量。
- in 返回 True 对于
key in map
语句,如果给定的键在 map 中,否则为False。
字典一个很大的好处是,给定一个键,我们可以非常快速地查找相关的值。为了提供这种快速查找能力,我们需要一个支持高效搜索的实现。我们可以使用具有顺序或二分查找的列表,但是使用如上所述的哈希表将更好,因为查找哈希表中的项可以接近 O(1)性能。
class hashtable: def __init__(self): # size 哈希表的长度为11 # 注意哈希表长度是质数,使得冲突解决算法可以尽可能高效 self.size = 11 # 键列表看成哈希表 # keys列表保存键项,就是position self.slots = [None] * self.size # data列表保存相关的数据,就是项的值 self.data = [None] * self.size # 余数法 def hashfunction(self,key,size): return key%size # 解决冲突的技术是 加1 rehash函数的线性检测。 def rehash(self,oldhash,size): return (oldhash + 1)%size def put(self,key,data): # 计算得到键对应的哈希值 hashvalue = self.hashfunction(key,len(self.slots)) # 如果键哈希表中,哈希值对应的项的值是None的话 if self.slots[hashvalue] == None: # 就把这个key值放到这个hashvalue槽中 self.slots[hashvalue] = key # 就把key对应的数据放入到data哈希表的hashvalue槽中 self.data[hashvalue] = data # 如果键哈希表中,哈希值对应的项的值不是None,也就是这个槽中已经有了项 else: # 键哈希表中,哈希值(索引)对应的项值是key自己, # 那么就将新的key对应的data数据更新一下就行了 if self.slots[hashvalue] == key: self.data[hashvalue] = data # 键哈希表中,哈希值(索引)对应的项被其他key占据了 else: # 使用新的哈希函数,得到key的新的哈希值(索引) nextslot = self.rehash(hashvalue,len(self.solts)) # 这个时候,如果下一个槽也被其他的占据了,那么就要再使用一个新的哈希函数 while self.slots[nextslot] != None and self.slots[nextslot] != key: nextslot = self.rehash(nextslot,len(self.slots)) # 这个时候,可能找到了新的空的槽,或者找到了就是key自己 # 如果是空的槽,就放进去 if self.slots[nextslot] == None: self.slots[nextslot] = key self.data[nextslot] = data # 如果是key自己,那就更新data else: self.data[nextslot] = data #同样,get函数计算初始哈希值开始。如果值不在初始槽中,则rehash用于定位下一个可能的位置。 def get(self, key): # 最初哈希值 startslot = self.hashfunction(key, len(self.slots)) data = None stop = False found = False position = startslot # 当最初的哈希值对应的项非空,且没有找到,也没有停止 while self.slots[position] != None and not found and not stop: # 先判断是不是要找的key,是的话就找到了,找到之后将对应的data哈希表中对应的数据读出来就行了 if self.slots[position] == key: found = True data = self.data[position] # 如果不是,说明被其他的占据了,或者是这个key是通过新的哈希函数得到的哈希值占据了 else: # 开始计算新的哈希值,判断这个key有没有通过新的哈希函数计算的 position = self.rehash(position, len(self.slots)) # 将新的哈希值与哈希表中的哈希值比较,如果相等,说明是通过新的哈希函数计算得到的,那么停止循环 # 否则一直这样循环下去,知道便利玩所有槽 if position == startslot: stop = True return data # 我们重载 __getitem__ 和__setitem__ 方法以允许使用 [] 访问。 # 这意味着一旦创建了HashTable,索引操作符将可用。 def __getitem__(self, key): return self.get(key) def __setitem__(self, key, data): self.put(key, data)
>>> H=HashTable() >>> H[54]="cat" >>> H[26]="dog" >>> H[93]="lion" >>> H[17]="tiger" >>> H[77]="bird" >>> H[31]="cow" >>> H[44]="goat" >>> H[55]="pig" >>> H[20]="chicken" >>> H.slots [77, 44, 55, 20, 26, 93, 17, None, None, 31, 54] >>> H.data ['bird', 'goat', 'pig', 'chicken', 'dog', 'lion', 'tiger', None, None, 'cow', 'cat'] >>> H[20] 'chicken' >>> H[17] 'tiger' >>> H[20]='duck' >>> H[20] 'duck' >>> H.data ['bird', 'goat', 'pig', 'duck', 'dog', 'lion', 'tiger', None, None, 'cow', 'cat'] >> print(H[99]) None
hash法分析
在最好的情况下,散列将提供 $$O(1)$$,恒定时间搜索。然而,由于冲突,比较的数量通常不是那么简单。
我们需要分析散列表的使用的最重要的信息是负载因子 λ。
如果 λ 小,则碰撞的机会较低,这意味着项更可能在它们所属的槽中。
如果 λ 大,意味着表正在填满,则存在越来越多的冲突。这意味着冲突解决更困难,需要更多的比较来找到一个空槽。使用链接,增加的碰撞意味着每个链上的项数量增加。
和以前一样,我们将有一个成功的搜索结果和不成功的。对于使用具有线性探测的开放寻址的成功搜索,平均比较数大约为1/2(1 + 1/(1-λ))
,不成功的搜索为 1/2(1+(1/1-λ)^2 ) 如果我们使用链接,则对于成功的情况,平均比较数目是 1+λ/2,如果搜索不成功,则简单地是 λ 比较次数。