9.算法之顺序、二分、hash查找
一.查找/搜索
- 我们现在把注意力转向计算中经常出现的一些问题,即搜索或查找的问题。搜索是在元素集合中查找特定元素的算法过程。搜索通常对于元素是否存在返回 True 或 False。有时它可能返回元素被找到的地方。我们在这里将仅关注成员是否存在这个问题。
- 在 Python 中,有一个非常简单的方法来询问一个元素是否在一个元素列表中。我们使用 in 运算符。
>>> 15 in [3,5,2,4,1] False >>> 3 in [3,5,2,4,1] True >>>
- 这很容易写,一个底层的操作替我们完成这个工作。事实证明,有很多不同的方法来搜索。我们在这里感兴趣的是这些算法如何工作以及它们如何相互比较。
二.顺序查找
- 当数据存储在诸如列表的集合中时,我们说这些数据具有线性或顺序关系。 每个数据元素都存储在相对于其他数据元素的位置。 在 Python 列表中,这些相对位置是单个元素的索引值。由于这些索引值是有序的,我们可以按顺序访问它们。 这个过程产实现的搜索即为顺序查找
。
- 顺序查找原理剖析:从列表中的第一个元素开始,我们按照基本的顺序排序,简单地从一个元素移动到另一个元素,直到找到我们正在寻找的元素或遍历完整个列表。如果我们遍历完整个列表,则说明正在搜索的元素不存在。
- 代码实现:该函数需要一个列表和我们正在寻找的元素作为参数,并返回一个是否存在的布尔值。found
布尔变量初始化为 False,如果我们发现列表中的元素,则赋值为 True。
def sequentialSearch(alist, item): pos = 0 found = False while pos < len(alist) and not found: if alist[pos] == item: found = True else: pos = pos+1 return found testlist = [1, 2, 32, 8, 17, 19, 42, 13, 0] print(sequentialSearch(testlist, 3)) print(sequentialSearch(testlist, 13))
- 顺序查找分析:为了分析搜索算法,我们可以分析一下上述案例中搜索算法的时间复杂度,即统计为了找到搜索目标耗费的运算步骤。实际上有三种不同的情况可能发生。在最好的情况下,我们在列表的开头找到所需的项,只需要一个比较。在最坏的情况下,我们直到最后的比较才找到项,第 n 个比较。平均情况怎么样?平均来说,我们会在列表的一半找到该项; 也就是说,我们将比较 n/2 项。然而,回想一下,当 n 变大时,系数,无论它们是什么,在我们的近似中变得不重要,因此顺序查找的复杂度是 O(n)
- 有序列表:之前我们列表中的元素是随机放置的,因此在元素之间没有相对顺序。如果元素以某种方式排序,顺序查找会发生什么?我们能够在搜索技术中取得更好的效率吗?
- 设计:假设元素的列表按升序排列。如果我们正在寻找的元素存在此列表中,则目标元素在列表的 n 个位置中存在的概率是相同。我们仍然会有相同数量的比较来找到该元素。然而,如果该元素不存在,则有一些优点。下图展示了这个过程,在列表中寻找元素 50。注意,元素仍然按顺序进行比较直到 54,因为列表是有序的。在这种情况下,算法不必继续查看所有项。它可以立即停止。
def orderedSequentialSearch(alist, item): pos = 0 found = False stop = False while pos < len(alist) and not found and not stop: if alist[pos] == item: found = True else: if alist[pos] > item: stop = True else: pos = pos+1 return found testlist = [0, 1, 2, 8, 13, 17, 19, 32, 42,] print(orderedSequentialSearch(testlist, 3)) print(orderedSequentialSearch(testlist, 13))
该排序模式在最好的情况下,我们通过只查看一项会发现该项不在列表中。 平均来说,我们将只了解 n/2 项就知道。然而,这种复杂度仍然是 O(n)。 但是在我们没有找到目标元素的情况下,才通过对列表排序来改进顺序查找。
三. 二分查找:
- 有序列表对于我们的实现搜索是很有用的。在顺序查找中,当我们与第一个元素进行比较时,如果第一个元素不是我们要查找的,则最多还有 n-1
个元素需要进行比较。 二分查找则是从中间元素开始,而不是按顺序查找列表。 如果该元素是我们正在寻找的元素,我们就完成了查找。 如果它不是,我们可以使用列表的有序性质来消除剩余元素的一半。如果我们正在查找的元素大于中间元素,就可以消除中间元素以及比中间元素小的一半元素。如果该元素在列表中,肯定在大的那半部分。然后我们可以用大的半部分重复该过程,继续从中间元素开始,将其与我们正在寻找的内容进行比较。下图展示了该算法如何快速找到值 54 。
def binarySearch(alist, item): first = 0 last = len(alist)-1 found = False while first<=last and not found: midpoint = (first + last)//2 if alist[midpoint] == item: found = True else: if item < alist[midpoint]: last = midpoint-1 else: first = midpoint+1 return found testlist = [0, 1, 2, 8, 13, 17, 19, 32, 42,] print(binarySearch(testlist, 3)) print(binarySearch(testlist, 13))
- 二分查找分析:为了分析二分查找算法,我们需要记住,每个比较消除了大约一半的剩余元素。该算法检查整个列表的最大比较数是多少?如果我们从 n 项开始,大约 n/2 项将在第一次比较后留下。第二次比较后,会有约 n/4。 然后 n/8,n/16,等等。 我们可以拆分列表多少次?
当我们切分列表足够多次时,我们最终得到只有一个元素的列表。 要么是我们正在寻找的元素,要么不是。达到这一点所需的比较数是 i,当 n / i^2=1时。 求解 i 得出 i = logn。 最大比较数相对于列表中的项是对数的。 因此,二分查找是 O(log n)。
Hash 查找
。None
。下图展示了大小 m = 11 的哈希表。换句话说,在表中有 m 个槽,命名为 0 到 10。54,26,93,17,77
和31
的集合,hash 函数将接收集合中的任何元素,并在槽名范围内(0和 m-1之间)返回一个整数。λ=项数/表大小
, 在这个例子中,λ = 6/11
。- 结论:当我们要搜索一个元素时,我们只需使用哈希函数来计算该元素的槽名称,然后检查哈希表以查看它是否存在。该搜索操作是 O(1)。
- 注意:只有每个元素映射到哈希表中的位置是唯一的,这种技术才会起作用。 例如,元素77是我们集合中的某一个元素,则它的哈希值为
0(77%11 == 0)
。 那么如果集合中还有一个元素是44,则44的hash值也是 0,我们会有一个问题。根据hash函数,两个或更多元素将需要在同一槽中。这种现象被称为碰撞(它也可以被称为“冲突”)。显然,冲突使散列技术产生了问题。我们将在后面详细讨论。- 其他计算hash值的方法:
- 分组求和法:如果我们的元素是电话号码
436-555-4601
,我们将取出数字,并将它们分成2位数(43,65,55,46,01)
。43 + 65 + 55 + 46 + 01
,我们得到 210。我们假设哈希表有 11 个槽,那么我们需要除以 11 。在这种情况下,210%11
为 1,因此电话号码436-555-4601
放置到槽 1 。- 平方取中法:我们首先对该元素进行平方,然后提取一部分数字结果。例如,如果元素是 44,我们将首先计算
44^2 = 1,936
。通过提取中间两个数字93
,我们得到93%11=5,因此元素44放置到槽5.
- 注意:还可以思考一些其他方法来计算集合中元素的哈希值。重要的是要记住,哈希函数必须是高效的,以便它不会成为存储和搜索过程的主要部分。如果哈希函数太复杂,则计算槽名称的程序要比之前所述的简单地进行基本的顺序或二分搜索更耗时。 这将打破哈希的目的。
- 冲突解决:如果有两个元素通过调用hash函数返回两个同样的槽名,我们就必须有一种方法可以使得这两个元素可以散落在hash表的不同槽中!
- 解决方案:解决冲突的一种方法是查找哈希表,尝试查找到另一个空槽以保存导致冲突的元素。一个简单的方法是从原始哈希值位置开始,然后以顺序方式移动槽,直到遇到第一个空槽。这种冲突解决过程被称为开放寻址,因为它试图在哈希表中找到下一个空槽或地址。通过系统的依次访问每个槽。当我们尝试将
44
放入槽 0 时,发生冲突。在线性探测下,我们逐个顺序观察,直到找到位置。在这种情况下,我们找到槽 1。再次,55
应该在槽 0 中,但是必须放置在槽 2 中,因为它是下一个开放位置。值 20 散列到槽 9 。由于槽 9 已满,我们进行线性探测。我们访问槽10,0,1
和2
,最后在位置 3 找到一个空槽。原图
现图
一旦我们使用开放寻址建立了哈希表,我们就必须使用相同的方法来搜索项。假设我们想查找项
93
。当我们计算哈希值时,我们得到5
。查看槽 5 得到93
,返回 True。如果我们正在寻找20
, 现在哈希值为9
,而槽9
当前项为31
。我们不能简单地返回 False,因为我们知道可能存在冲突。我们现在被迫做一个顺序搜索,从位置10
开始寻找,直到我们找到项20
或我们找到一个空槽。- 代码实现
- 实现 map 抽象数据类型:最有用的 Python 集合之一是字典。回想一下,字典是一种关联数据类型,你可以在其中存储键-值对。该键用于查找关联的值。我们经常将这个想法称为
map
。map 抽象数据类型定义如下:del map[key]
形式的语句从 map 中删除键值对。key in map
语句,如果给定的键在 map 中,否则为False。- 我们使用两个列表来创建一个实现 Map 抽象数据类型的HashTable 类。一个名为
slots
的列表将保存键项,一个称data
的并行列表将保存数据值。当我们查找一个键时,data
列表中的相应位置将保存相关的数据值。我们将使用前面提出的想法将键列表视为哈希表。注意,哈希表的初始大小已经被选择为 11。尽管这是任意的,但是重要的是,大小是质数,使得冲突解决算法可以尽可能高效。- hash 函数实现简单的余数方法。冲突解决技术是
加1
rehash 函数的线性探测。 put 函数假定最终将有一个空槽,除非 key 已经存在于self.slots
中。 它计算原始哈希值,如果该槽不为空,则迭代 rehash 函数,直到出现空槽。如果非空槽已经包含 key,则旧数据值将替换为新数据值。- 同样,get 函数从计算初始哈希值开始。如果值不在初始槽中,则 rehash 用于定位下一个可能的位置。注意,第 15 行保证搜索将通过检查以确保我们没有返回到初始槽来终止。如果发生这种情况,我们已用尽所有可能的槽,并且项不存在。HashTable 类提供了附加的字典功能。我们重载
__getitem__
和__setitem__
方法以允许使用[]
访问。 这意味着一旦创建了HashTable,索引操作符将可用。- 下面展示了 HashTable 类的操作。首先,我们将创建一个哈希表并存储一些带有整数键和字符串数据值的项。
- 接下来,我们将访问和修改哈希表中的一些项。注意,正替换键 20 的值。