顺序搜索
什么是搜索?
搜索是指从元素集合中找到某个特定元素的算法过程,搜索过程通常返回true或false来表示元素是否存在。Python提供了运算符in,通过它可以方便地检查元素是否在列表中。
>>> 15 in [3, 4, 5, 6, 7]
False
>>> 15 in [3, 4, 5, 6, 15]
True
什么是顺序搜索?
数据之间存在线性或顺序的关系,能够顺序访问,由此可以进行顺序搜索。
下图展示了顺序搜索的原理,它是通过从列表的第一个元素开始,沿着默认的顺序逐个查看,直到找到目标元素或查完列表,如果查完列表仍然没有找到该元素,证明该元素不在此列表中。
用Python实现无序列表的顺序搜索
无序列表的顺序搜索需要比较n次才能得出结论,实际上会出现3种情况,最好情况是目标元素位于第一个位置,最坏情况是目标元素位于最后一个位置,普通情况我们会在列表中间找到目标元素,也就是需要比较二分之n次,所以顺序搜索算法的时间复杂度是O(n)。
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
用Python实现有序列表的顺序搜索
假设列表中的元素按照升序排序,如果存在目标元素,那么他出现在n个位置中的任意一个位置的可能性一样大,因此比较次数与无序列表相同。不过,如果不存在目标元素,那么搜索效率就会提高。下图展示了搜索50的过程:
顺序搜索算法将一路比较列表中的元素,直到遇到54,而54并不是目标元素,那么54后面的也都不是目标元素,所以到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
下表展示了有序列表中顺序搜索的比较次数,最好情况下,只需要一次就知道目标元素在不在列表中,最坏情况下为n,普通情况下是二分之n,算法的时间复杂度依然是O(n)。所以,只有目标元素不在当前列表中时,有序列表的搜索排序才会提高效率。
二分搜索
什么是二分搜索?
二分搜索是从中间的元素着手,如果这个元素是目标元素就立刻停止搜索,如果不是,可以利用列表有序的特性,直接排除一半的元素,下图展示了查找54的二分搜索:
用Python实现有序列表的二分搜索
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
用递归实现二分搜索
def binarySearch(alist, item):
if len(alist) == 0:
return False
else:
midpoint = len(alist) // 2
if alist[midpoint] == item:
return True
else:
if item < alist[midpoint]:
return binarySearch(alist[midpoint], item)
else:
rerurn binarySearch(alist[midpoint + 1:], item)
二分搜索法是在每一次搜索的时候将要考虑的元素减半,那么要检查完整个列表,最多要比较多少次呢?假设列表有n个元素,第一次比较厚剩下n/2个元素,第二次n/4,第三次n/8,...。
如下表所示,最终二分搜索算法的时间复杂度是O(logn):
散列
散列表是元素集合,其中的元素以一种便于查找的方式存储,散列表中的每个位置通常被称为槽,其中可以存储一个元素。初始情况下,散列表中没有元素,每一个槽都是空的。下图展示了有m个槽,编号从0到10的散列表。
散列函数
散列函数将散列表中的元素与其所属位置对应起来,对散列表中的任一元素,散列表返回一个介于0和m-1之间的整数。
假设现在有一个由整数元素54、26、93、17、77和31构成的集合。我们通过一个叫做“取余函数”的散列函数来计算散列值。即用一个元素除以表的大小,并将得到的余数作为散列值。
计算出散列值后,就可以将元素插入到相应的位置,注意,在11个槽中,有6个被占用了,占用率被称作载荷因子。
但是“取余函数”只有在每个元素的散列值不同时才有用,如果此时有元素44和77,他们两个的散列值都是0(44%11=0,77%11=0),这样的话两个元素就会被放进同一个槽中,这种情况叫做冲突。
处理冲突
当两个元素被分到同一个槽中时,必须通过一种系统化方法在散列表中安置第二个元素,这种方法被称为处理冲突。如果散列函数是完美的,那么就永远不会存在冲突。
开放定址法
这种方法是在散列表中找到另一个空槽,用于防止引起冲突的元素。简单的做法是顺序遍历散列表,直到找到一个空槽,这其中需要注意的一点是,可能需要往回检查第一个槽,这个过程被称为开放定址法。它在散列表中寻找下一个空槽或地址,由于是逐个访问槽,因此这个做法被称为线性探测。
举个🌰: 我们现在扩充一下上面的整数元素(54、26、93、17、77、31、44、55、20),根据图5-5可以看到,当我们尝试将44放进0号槽时,就会产生冲突,我们使用线性探测,探测到了1号槽,44就会被放进1号槽。以此类推,就会出现以下情况:
但线性探测有个缺点,那就是会使散列表中的元素出现聚集现象。也就是说,如果一个槽发生太多冲突,线性探测就会填满其附近的槽,相应就会影响到后续插入的元素,比如我们在插入20时,就需要越过所有散列值为0的元素才能找到一个空槽。
再散列
为了避免元素聚集,我们可以采用跳过一些槽的方法,这样可以使近期冲突的元素被分布的更加均匀。再散列泛指在发生冲突后寻找另一个槽的过程。下图展示的是,采用“加3“的探测策略处理冲突后的元素分布情况:
需要注意的是,我们定义的这个”加3“的大小要保证表中所有的槽最终都能被访问到,否则就会浪费槽资源。
平方探测
平方探测是线性探测的一个变体,他不采用固定的跨步大小,而是通过递增的方式。例如第一个散列值如果是h,那么后续就是h+1、h+4、H+9、h+16....。也就是跨步大小是一个完全平方数。下面是采用平方探测处理后的结果:
链接法
它允许散列表中的同一个位置上存在多个元素,发生冲突时,元素仍然会被插入散列值对应的槽中。但是由于同一位置上的元素越来越多,搜索就会变得越来越困难。下图展示了使用链接法解决冲突后的结果:
在搜索目标元素时,我们使用散列函数算出它对应的编号,由于每一个槽中都有一个元素集合,所以需要再搜索一次才能确定目标元素是否存在。
完美散列函数
给定一个元素集合,能将每一个元素映射到不同的槽,这种散列函数称作完美散列函数。
构建完美散列函数有三种方法:
1)增大散列表,使之能容纳每一个元素。
这种方法如果元素太多就会浪费极大的内存空间,所以当元素量巨大时,此方法不可用。
2)折叠法
先将元素切成等长的分数,最后一部分的长度可能会不同,然后把这些部分相加,得到一个散列值。
例如:我们现在有一个电话号码436-555-4601,我们以2位为一组进行区分就得到了43、65、66、46、01。将这些数字相加之后得到了210,假设现在我们有11个槽,接着用210除以11得到余数1,所以这个电话号码将被映射到散列表中的1号槽。
3)平方取中法
先将元素取平方,然后提取中间的几位数。比如元素为44,先计算44的平方等于1936,然后取中间两位93,进行取余的步骤,就得到了5。
分析散列搜索算法
在最好的情况下,散列搜索算法的时间复杂度是O(1),如果发生冲突,那么就会比较复杂。
在分析散列表的使用情况时,最重要的信息就是载荷因子(以下称为
)。
越小,那么发生冲突的概率就越小,元素也就很有可能是各就各位的。
越大,证明散列表越拥挤,发生冲突的概率就越大。因此,发生的冲突越多,找到空槽需要的次数就越多。
而如果采用链接法,冲突越多,每条链上的元素也就越多。
采用线性探测策略的开放定址法,搜索成功的平均比较次数如下:
搜索失败的平均比较次数如下:
采用链接法,则搜索成功的平均比较次数如下:
搜索失败时,平均比较次数就是
。