上一篇介绍了几大排序算法,从基本原理解释到Python代码实现,平时有空的话还需要经常翻出来复习复习。今天就主要来看看另外一大类算法:经典查找算法。
参考资料: 《大话数据结构》、《算法第4版》(配套视频:Algorithms, Part I,Algorithms, Part II)
本篇相关python代码已上传至Github:使劲儿点!
1.基本概念
查找就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素。
查找表:由同一类型的数据元素构成的集合
关键字:数据元素中某个数据项的值,又称为键值
主键:可唯一的标识某个数据元素或记录的关键字
查找表按照操作方式可分为:
1.静态查找表(Static Search Table):只做查找操作的查找表。它的主要操作是:
- 查询某个“特定的”数据元素是否在表中
- 检索某个“特定的”数据元素和各种属性
2.动态查找表(Dynamic Search Table):在查找中同时进行插入或删除等操作:
- 查找时插入数据
- 查找时删除数据
2.无序表查找
也就是数据不排序的线性查找,遍历数据元素。
算法分析:最好情况是在第一个位置就找到了,此为O(1);最坏情况在最后一个位置才找到,此为O(n);所以平均查找次数为(n+1)/2。最终时间复杂度为O(n)
下面是基于遍历元素的无序表查找代码实现
def unorder_search(lis, key):
length = len(lis)
for i in range(length):
if lis[i] == key:
return i
else:
return False
3.有序表查找
有序表查找也就是表中数据必须按某个主键的某种排序方式进行。
3.1 二分查找(Binary Search)
算法核心:在查找表中不断取中间元素与查找值进行比较,以二分之一的倍率进行表范围的缩小。
其时间复杂度为O(log(n))
def binary_search(lists, key):
low = 0
high = len(lists) - 1
time = 0
while low <= high:
time += 1
mid = int((low + high) / 2)
if key < lists[mid]:
high = mid - 1
elif key > lists[mid]:
low = mid + 1
else:
# 打印折半的次数
print("times: %s" % time)
return mid
print("times: %s" % time)
return False
3.2 插值查找
二分查找法虽然已经很不错了,但还有可以优化的地方。
有的时候,对半过滤还不够狠,要是每次都排除十分之九的数据岂不是更好?选择这个值就是关键问题,插值的意义就是:以更快的速度进行缩减。
插值的核心就是使用公式:
value = (key - list[low])/(list[high] - list[low])
用这个value来代替二分查找中的1/2。上面二分查找的代码可以直接使用,只需要改一句。
其时间复杂度仍为O(log(n))
def interpolation(lists, key):
low = 0
high = len(lists) - 1
time = 0
while low <= high:
time += 1
# 计算mid值是插值算法的核心代码
mid = low + int((high - low) * (key - lists[low])/(lists[high] - lists[low]))
print("mid=%s, low=%s, high=%s" % (mid, low, high))
if key < lists[mid]:
high = mid - 1
elif key > lists[mid]:
low = mid + 1
else:
# 打印查找的次数
print("times: %s" % time)
return mid
print("times: %s" % time)
return False
插值算法的总体时间复杂度仍然属于O(log(n))级别的。其优点是,对于表内数据量较大,且关键字分布比较均匀的查找表,使用插值算法的平均性能比二分查找要好得多。反之,对于分布极端不均匀的数据,则不适合使用插值算法。
3.3 斐波那契查找
由插值算法带来的启发,发明了斐波那契算法。其核心也是如何优化那个缩减速率,使得查找次数尽量降低。
使用这种算法,前提是已经有一个包含斐波那契数据的列表
F = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144,...]
其时间复杂度为O(log(n))
def fibonacci_search(lists, key):
# 需要一个现成的斐波那契列表。其最大元素的值必须超过查找表中元素个数的数值。
F = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144,
233, 377, 610, 987, 1597, 2584, 4181, 6765,
10946, 17711, 28657, 46368]
low = 0
high = len(lists) - 1
# 为了使得查找表满足斐波那契特性,在表的最后添加几个同样的值
# 这个值是原查找表的最后那个元素的值
# 添加的个数由F[k]-1-high决定
k = 0
while high > F[k]-1:
k += 1
print(k)
i = high
while F[k]-1 > i:
lis.append(lists[high])
i += 1
print(lists)
# 算法主逻辑。time用于展示循环的次数。
time = 0
while low <= high:
time += 1
# 为了防止F列表下标溢出,设置if和else
if k < 2:
mid = low
else:
mid = low + F[k-1]-1
print("low=%s, mid=%s, high=%s" % (low, mid, high))
if key < lists[mid]:
high = mid - 1
k -= 1
elif key > lists[mid]:
low = mid + 1
k -= 2
else:
if mid <= high:
# 打印查找的次数
print("times: %s" % time)
return mid
else:
print("times: %s" % time)
return high
print("times: %s" % time)
return False
算法分析:斐波那契查找的整体时间复杂度也为O(log(n))。但就平均性能,要优于二分查找。但是在最坏情况下,比如这里如果key为1,则始终处于左侧半区查找,此时其效率要低于二分查找。
总结:二分查找的mid运算是加法与除法,插值查找则是复杂的四则运算,而斐波那契查找只是最简单的加减运算。在海量数据的查找中,这种细微的差别可能会影响最终的查找效率。因此,三种有序表的查找方法本质上是分割点的选择不同,各有优劣,应根据实际情况进行选择。
4.线性索引查找
对于海量的无序数据,为了提高查找速度,一般会为其构造索引表。
索引就是把一个关键字与它相对应的记录进行关联的过程。
一个索引由若干个索引项构成,每个索引项至少包含关键字和其对应的记录在存储器中的位置等信息。
索引按照结构可以分为:线性索引、树形索引和多级索引。
线性索引:将索引项的集合通过线性结构来组织,也叫索引表。
线性索引可分为:稠密索引、分块索引和倒排索引
4.1 稠密索引
稠密索引指的是在线性索引中,为数据集合中的每个记录都建立一个索引项。
这其实就相当于给无序的集合,建立了一张有序的线性表。其索引项一定是按照关键码进行有序的排列。
这也相当于把查找过程中需要的排序工作给提前做了。
4.2 分块索引
给大量的无序数据集合进行分块处理,使得块内无序,块与块之间有序。
这其实是有序查找和无序查找的一种中间状态或者说妥协状态。因为数据量过大,建立完整的稠密索引耗时耗力,占用资源过多;但如果不做任何排序或者索引,那么遍历的查找也无法接受,只能折中,做一定程度的排序或索引。
分块索引的效率比遍历查找的O(n)要高一些,但与二分查找的O(logn)还是要差不少。
4.3 倒排索引
不是由记录来确定属性值,而是由属性值来确定记录的位置,这种被称为倒排索引。其中记录号表存储具有相同次关键字的所有记录的地址或引用(可以是指向记录的指针或该记录的主关键字)。
倒排索引是最基础的搜索引擎索引技术。
5.二叉查找树(BST)和平衡二叉树(AVL)
这两种查找算法原理及代码实现已经在前面数据结构中具体分析过,这里就不再赘述
6. 多路查找树(B树)
多路查找树(muitl-way search tree):其每一个节点的孩子可以多于两个,且每一个结点处可以存储多个元素。对于多路查找树,每个节点可以存储多少个元素,以及它的孩子数的多少是关键,常用的有这4种形式:2-3树、2-3-4树、B树和B+树。
6.1 2-3树
2-3树:每个结点都具有2个孩子,或者3个孩子,或者没有孩子。
一个2结点包含一个元素和两个孩子(或者没有孩子,不能只有一个孩子)。与二叉排序树类似,其左子树包含的元素都小于该元素,右子树包含的元素都大于该元素。
一个3结点包含两个元素和三个孩子(或者没有孩子,不能只有一个或两个孩子)。
2-3树中所有的叶子都必须在同一层次上。
6.1.1 查找
要判断一个键是否在树中,先将它和根结点中的键比较。如果它和其中任意一个相等,查找命中;否则我们就根据比较的结果找到指向相应区间的链接,并在其指向的子树中递归地继续查找。如果这是个空链接,则查找未命中。
6.1.2 插入
插入一共有四种不同的情况:
①向2-结点中插入新键:只要把这个2-结点换为一个3-结点,将要插入的键保存在其中即可。
②向只含一个3-结点的树中插入新键:先临时将新键存入该结点中形成一个4-结点。然后将其转换为一颗由3个2-结点组成的2-3树,其中一个结点(根)含有中键,一个结点含有三个键中的最小者(和根结点左链接相连),一个结点含有三个键中的最大者(右相连)。如下所示:
③向一个父节点为2-结点的3-结点中插入新键:首先将3-结点替换为包换新键的4-结点,其次将2-结点替换为含有中键的新3-结点,最后将4-结点分解为两个2-结点并将中键移动至父节点中,如下所示:
④向一个父结点为3-结点的3-结点中插入新键:处理方式与③类似,一直向上不断分解,直至遇到一个2-结点。
6.1.3 删除
6.2 B树
B树是一种平衡的多路查找树。节点最大的孩子数目称为B树的阶(order)。2-3树是3阶B树,2-3-4是4阶B树。
B树的数据结构主要用在内存和外部存储器的数据交互中。
B+树
为了解决B树的所有元素遍历等基本问题,在原有的结构基础上,加入新的元素组织方式后,形成了B+树。
B+树是应文件系统所需而出现的一种B树的变形树,严格意义上将,它已经不是最基本的树了。
B+树中,出现在分支节点中的元素会被当做他们在该分支节点位置的中序后继者(叶子节点)中再次列出。另外,每一个叶子节点都会保存一个指向后一叶子节点的指针。
所有的叶子节点包含全部的关键字的信息,及相关指针,叶子节点本身依关键字的大小自小到大顺序链接
B+树的结构特别适合带有范围的查找。比如查找年龄在20~30岁之间的人。
7. 散列表(哈希表)
散列表:所有的元素之间没有任何关系。元素的存储位置,是利用元素的关键字通过某个函数直接计算出来的。这个一一对应的关系函数称为散列函数或Hash函数。
采用散列技术将记录存储在一块连续的存储空间中,称为散列表或哈希表(Hash Table)。关键字对应的存储位置,称为散列地址。
散列表是一种面向查找的存储结构。它最适合求解的问题是查找与给定值相等的记录。但是对于某个关键字能对应很多记录的情况就不适用,比如查找所有的“男”性。也不适合范围查找,比如查找年龄20~30之间的人。排序、最大、最小等也不合适。
因此,散列表通常用于关键字不重复的数据结构。比如python的字典数据类型。
设计出一个简单、均匀、存储利用率高的散列函数是散列技术中最关键的问题。
但是,一般散列函数都面临着冲突的问题。
冲突:两个不同的关键字,通过散列函数计算后结果却相同的现象。collision。
使用散列的查找算法分为两步:第一步是用散列函数将被查找的键转化为数组的一个索引。第二步就是一个处理碰撞冲突的过程
7.1 散列函数的构造
一个好的散列函数:计算简单、散列地址分布均匀
1. 直接定址法
例如取关键字的某个线性函数为散列函数:
f(key) = a*key + b (a,b为常数)
2. 数字分析法
抽取关键字里的数字,根据数字的特点进行地址分配
3. 平方取中法
将关键字的数字求平方,再截取部分
4. 折叠法
将关键字的数字分割后分别计算,再合并计算,一种玩弄数字的手段。
5. 除留余数法
最为常见的方法之一。
对于表长为m的数据集合,散列公式为:
f(key) = key mod p (p<=m)
mod:取模(求余数)
该方法最关键的是p的选择,而且数据量较大的时候,冲突是必然的。一般会选择接近m的质数。
6. 随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址。
f(key) = random(key)
总结,实际情况下根据不同的数据特性采用不同的散列方法,考虑下面一些主要问题:
- 计算散列地址所需的时间
- 关键字的长度
- 散列表的大小
- 关键字的分布情况
- 记录查找的频率
7.2 处理散列冲突
1. 开放定址法
就是一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。公式如下:
这种简单的冲突解决办法被称为线性探测,无非就是自家的坑被占了,就逐个拜访后面的坑,有空的就进,也不管这个坑是不是后面有人预定了的。
线性探测带来的最大问题就是冲突的堆积,你把别人预定的坑占了,别人也就要像你一样去找坑。
2. 再散列函数法
发生冲突时就换一个散列函数计算,总会有一个可以把冲突解决掉,它能够使得关键字不产生聚集,但相应地增加了计算的时间。
3. 链接地址法
碰到冲突时,不更换地址,而是将所有关键字为同义词的记录存储在一个链表里,在散列表中只存储同义词子表的头指针,如下图:
这样的好处是,不怕冲突多;缺点是降低了散列结构的随机存储性能。本质是用单链表结构辅助散列结构的不足。
4. 公共溢出区法
其实就是为所有的冲突,额外开辟一块存储空间。如果相对基本表而言,冲突的数据很少的时候,使用这种方法比较合适。
class HashTable:
def __init__(self, size):
self.elem = [None for i in range(size)] # 使用list数据结构作为哈希表元素保存方法
self.count = size # 最大表长
def hash(self, key):
return key % self.count # 散列函数采用除留余数法
def insert_hash(self, key):
"""插入关键字到哈希表内"""
address = self.hash(key) # 求散列地址
while self.elem[address]: # 当前位置已经有数据了,发生冲突。
address = (address+1) % self.count # 线性探测下一地址是否可用
self.elem[address] = key # 没有冲突则直接保存。
def search_hash(self, key):
"""查找关键字,返回布尔值"""
star = address = self.hash(key)
while self.elem[address] != key:
address = (address + 1) % self.count
if not self.elem[address] or address == star: # 说明没找到或者循环到了开始的位置
return False
return True
if __name__ == '__main__':
list_a = [12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34]
hash_table = HashTable(12)
for i in list_a:
hash_table.insert_hash(i)
for i in hash_table.elem:
if i:
print((i, hash_table.elem.index(i)), end=" ")
print("\n")
print(hash_table.search_hash(15))
print(hash_table.search_hash(33))
如果没发生冲突,则其查找时间复杂度为O(1),属于最极端的好了。
但是,现实中冲突可不可避免的,下面三个方面对查找性能影响较大:
- 散列函数是否均匀
- 处理冲突的办法
- 散列表的装填因子(表内数据装满的程度)
小结
对以上几种经典的查找算法进行对比
以上~
2018.05.02