查找
【知识框架】
1.查找概论
查找的基本概念:
查找(Searching):就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素( 或记录)。
查找表(Search Table):是由同一类型的数据元素(或记录)构成的集合。
关键字(Key):数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。例如,在由一个学生元素构成的数据集合中,学生元素中“学号”这一数据项的值唯一地标识一名学生。
静态查找表(Static Search Table):只作查找操作的查找表。
主要操作:
- 查询某个“特定的”数据元素是否在查找表中。
- 检索某个“特定的”数据元素和各种属性。
动态查找表(Dynamic Search Table): 在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。
主要操作:
- 查找时插入不存在的数据元素。
- 查找时删除已存在的数据元素。
平均查找长度:在查找过程中,一次查找的长度是指需要比较的关键字次数,而平均查找长度,则是所有查找过程中进行关键字的比较次数的平均值。
2.顺序表查找
一、定义:
顺序查找(Sequential Search) 又叫线性查找,是最基本的查找技术,作为一种最直观的查找方法,其基本思想是从线性表的一端开始,逐个检查关键字是否满足给定的条件。若查找到某个元素的关键字满足给定条件,则查找成功,返回该元素在线性表中的位置;若已经查找到表的另一端,但还没有查找到符合给定条件的元素,则返回查找失败的信息。
二、算法:
python实现算法:
import time
import numpy as np
def Sequential_Search(arr, n, key):
"""
:param arr:待查元素所在数组
:param n:在arr的前n位里查找
:param key:待查元素
:return:返回待查元素位置或者提示信息
"""
if 0 <= n <= len(arr):
for i in range(n):
if arr[i] == key:
return i
return "没有查到此记录"
return "对不起,n值不合法"
if __name__ == '__main__':
arr = np.arange(100000)
begin = time.time()
res = Sequential_Search(arr, len(arr), 88888)
end = time.time()
print(res)
print(f"耗时:{end - begin}")
运行结果:
88888
耗时:0.015154361724853516
时间复杂度分析:顺序表查找时间复杂度是O(n)
3.有序表查找
(1)折半查找
折半查找(Binary Search)技术,又称为二分查找。它的前提是线性表中的记录必须是关键码有序(通常从小到大有序),线性表必须采用顺序存储。折半查找的基本思想是:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止。
python代码实现:
import time
def Binary_Search(arr, n, key):
"""
:param arr:待查元素所在数组
:param n:在arr的前n位里查找
:param key:待查元素
:return:返回待查元素位置或者提示信息
"""
if 0 <= n <= len(arr): # 判断查找范围是否越界
low = 0 # 定义最低下标为记录首位
high = n - 1 # 定义最高下标为记录末位
while low <= high:
mid = (low + high) // 2 # 折半
if arr[mid] > key: # 若查找值比中值小
high = mid - 1 # 最高下标调整到中位下标小一位
elif arr[mid] < key: # 若查找值比中值大
low = mid + 1 # 最低下标调整到中位下标大一位
else:
return mid # 若相等则说明mid即为查找到的位置
return "没有此元素"
return "n值不合法"
if __name__ == '__main__':
arr = range(1, 100000000000, 3)
res = None # 用来存储返结果
begin = time.time() # 查找函数开始时的时间戳
for i in range(100000): # 查找多次以便求平均查找时间,如果只查早一次的话会因为耗时很短,结果为耗时0
res = Binary_Search(arr, len(arr), 4)
end = time.time() # 查找函数结束时的时间戳
print(res)
print(f"耗时:{(end - begin) / 100000}") # 打印平均耗时
运行结果:
1
耗时:7.2002077102661135e-06
时间复杂度分析:折半查找时间复杂度是O(logn)
(2)插值查找
现在我们的新问题是,为什么一定要折半,而不是折四分之一或者折更多呢?
比如要在取值范围0 ~ 10000之间100个元素从小到大均匀分布的数组中查找5,我们自然会考虑从数组下标较小的开始查找。
所以,折半查找还是有改善空间的。
上述折半查找代码的第4行,等式变换后可得到:
mid = (low + high) // 2 = l o w + ( h i g h − l o w ) // 2
也就是mid等于最低下标low加上最高下标high与low的差的一半。接下来改进为下面的计算方案:
mid = low + ((key - arr[low]) // (arr[high] - arr[low])) * (high - low)
就得到了另一种有序表查找算法,插值查找法。插值查找(Interpolation Search) 是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值的计算公式。应该说,从时间复杂度来看,它也是O(logn),但对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好得多。反之,数组中如果分布类似{0,1,2,2000,2001,......,999998, 99999}这种极端不均匀的数据,用插值查找未必是很合适的选择。
python代码实现:
import time
def Interpolation_Search(arr, n, key):
"""
:param arr:待查元素所在数组
:param n:在arr的前n位里查找
:param key:待查元素
:return:返回待查元素位置或者提示信息
"""
if 0 <= n <= len(arr): # 判断查找范围是否越界
low = 0 # 定义最低下标为记录首位
high = n - 1 # 定义最高下标为记录末位
while low <= high:
mid = low + ((key - arr[low]) // (arr[high] - arr[low])) * (high - low) # 插值
if arr[mid] > key: # 若查找值比中值小
high = mid - 1 # 最高下标调整到中位下标小一位
elif arr[mid] < key: # 若查找值比中值大
low = mid + 1 # 最低下标调整到中位下标大一位
else:
return mid # 若相等则说明mid即为查找到的位置
return "没有此元素"
return "n值不合法"
if __name__ == '__main__':
arr = range(1, 100000000000, 3)
res = None # 用来存储返结果
begin = time.time() # 查找函数开始时的时间戳
for i in range(100000): # 查找多次以便求平均查找时间,如果只查早一次的话会因为耗时很短,结果为耗时0
res = Interpolation_Search(arr, len(arr), 4)
end = time.time() # 查找函数结束时的时间戳
print(res)
print(f"耗时:{(end - begin) / 100000}") # 打印平均耗时
运行结果:
1
耗时:1.3898181915283203e-06
从结果来看,二分查找的平均查找时间是插值查找的好几倍。
(3)斐波那契查找
斐波那契查找(Fibonacci Search),它是利用了黄金分割原理来实现的。
我们先定义一个斐波那契数组F:
python代码实现:
import time
def Fibonacci_Search(arr, n, key, F):
"""
:param arr:待查元素所在数组
:param n:在arr的前n位里查找
:param key:待查元素
:return:返回待查元素位置或者提示信息
"""
if 0 <= n <= len(arr): # 判断查找范围是否越界
low = 0 # 定义最低下标为记录首位
high = n - 1 # 定义最高下标为记录末位
k = 0
while n > F[k] - 1:
k += 1
for i in range(n, F[k] - 1):
arr.append(None)
arr[i] = arr[n - 1]
while low <= high:
mid = low + F[k - 1] - 1
if key < arr[mid]:
high = mid + 1
k -= 1
elif key > arr[mid]:
low = mid + 1
k -= 2
else:
if mid <= n:
return mid
else:
return n
return "没有查到此元素"
return "n值不合法"
if __name__ == '__main__':
arr = [0, 1, 16, 24, 35, 47, 59, 62, 73, 88, 99]
res = None # 用来存储返结果
F = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55] # 部分斐波那契数列
begin = time.time() # 查找函数开始时的时间戳
for i in range(100000): # 查找多次以便求平均查找时间,如果只查早一次的话会因为耗时很短,结果为耗时0
res = Fibonacci_Search(arr, len(arr), 59, F)
end = time.time() # 查找函数结束时的时间戳
print(f"斐波那契查找结果为:{res}\n耗时:{(end - begin) / 100000}") # 打印平均耗时
运行结果:
斐波那契查找结果为:6
耗时:1.3396072387695312e-06
时间复杂度分析:O(logn)
斐波那契查找算法的核心在于:
- 当key=a[mid]时,查找就成功;
- 当key<a[mid]时,新范围是第low个到第mid-1个,此时范围个数为F[k-1]-1个;
- 当key>a[mid]时,新范围是第m+1个到第high个,此时范围个数为F[k-2]-1个。
(4)线性索引查找
现实生活中计算机要处理的数据量是极其庞大的,而数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度而设计的一种数据结构。索引就是把一个关键字与它对应的记录相关联的过程, 一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。
索引按照结构可以分为线性索引、树形索引和多级索引。
这里主要介绍线性索引,所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。我们重点介绍三种线性索引:稠密索引、分块索引和倒排索引。
一、稠密索引
稠密索引是很简单直白的一种索引结构。
稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项,而索引项一定是按照关键码有序的排列。如下图所示:
索引项有序也就意味着,我们要查找关键字时,可以用到折半、插值、斐波那契等有序查找算法,提高了效率。这是稠密索引优点,但是如果数据集非常大,比如上亿,那也就意味着索引也得同样的数据集长度规模,对于内存有限的计算机来说,可能就需要反复去访问磁盘,查找性能反而大大下降了。
二、分块索引
稠密索引因为索引项与数据集的记录个数相同,所以空间代价很大。为了减少索引项的个数,我们可以对数据集进行分块,使其分块有序,然后再对每一块建立一个索引项,从而减少索引项的个数。
分块有序,是把数据集的记录分成了若千块,并且这些块需要满足两个条件:
块内无序:即每一块内的记录不要求有序。
块间有序:例如,要求第二块所有记录的关键字均要大于第一块中所有记录的关键字,第三块的所有记录的关键字均要大于第二块的所有记录关键字…因为只有块间有序,才有可能在查找时带来效率。
对于分块有序的数据集,将每块对应一个索引项, 这种索引方法叫做分块索引。如下图所示,我们定义的分块索引的索引项结构分三个数据项:
最大关键码:它存储每一块中的最大关键字,这样的好处就是可以使得在它之后的下一块中的最小关键字也能比这一块最大的关键字要大;
块长:存储了块中的记录个数,以便于循环时使用;
块首指针:用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历。
在分块索引表中查找,就是分两步进行:
- 在分块索引表中查找要查关键字所在的块。由于分块索引表是块间有序的,因此很容易利用折半、插值等算法得到结果。例如,在上图的数据集中查找62 6262,我们可以很快可以从左上角的索引表中由57 < 62 < 96得到62在第三个块中。
- 根据块首指针找到相应的块,并在块中顺序查找关键码。
三、倒排索引
搜索引擎中涉及很多搜索技术,这里介绍一种最简单,也是最基础的搜索技术:倒排索引。
我们举个简单的例子:
假如下面两篇极短的文章。
- Books and friends should be few but good.
- A good book is a good friend.
忽略字母大小写,我们统计出每个单词出现在哪篇文章之中:文章1、文章2、文章(1,2),得到下面这个表,并对单词做了排序:
有了这样一张单词表,我们要搜索文章,就非常方便了。如果你在搜索框中填写“book"关键字。系统就先在这张单词表中有序查找“book" ,找到后将它对应的文章编号1和2的文章地址返回。
在这里这张单词表就是索引表,索引项的通用结构是:
- 次关键码,例如上面的“英文单词”;
- 记录号表,例如上面的“文章编号"。
其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的主关键字)。这样的索引方法就是倒排索引(inverted index) 。
这名字为什么要叫做“倒排索引”呢?
顾名思义,倒排索引源于实际应用中需要根据属性(或字段、次关键码)的值来查找记录(或主关键编码)。这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。
4.动态查找表
(1)二叉排序树
二叉排序树(也称二叉查找树)或者是一棵空树,或者是具有下列特性的二叉树:
若左子树非空,则左子树上所有结点的值均小于根结点的值。
若右子树非空,则右子树上所有结点的值均大于根结点的值。
左、右子树也分别是一棵二叉排序树。
根据二叉排序树的定义,左子树结点值<根结点值<右子树结点值,所以对二叉排序树进行中序遍历,可以得到一个递增的有序序列。例如,下图所示二叉排序树的中序遍历序列为123468。
python代码实现:
class Node:
"""节点类"""
def __init__(self, data):
self.data = data
self.lchild = None
self.rchild = None
class BST:
"""二叉查找树"""
def __init__(self, node_list):
self.root = Node(node_list[0]) # 设置树的根为列表第一位
for data in node_list[1:]: # 依次把列表中除了第一位的其他所有元素插入到树中
self.insert(data)
# 搜索
def search(self, T, p, key):
"""
:param T: 要搜索的树的根节点
:param p: 当前节点的双亲节点
:param key: 要收索的键或元素
:return: (布尔值:代表是否查到,T:当前查到的节点,p:查到的节点的双亲,用于确定所查元素的位置)
"""
if not T: # 查找不成功
return False, T, p
if T.data == key: # 查找成功
return True, T, p
if T.data > key:
return self.search(T.lchild, T, key) # 在左子树中继续查找
else:
return self.search(T.rchild, T, key) # 在右子树中继续查找
# 插入
def insert(self, key):
"""
:param key: 要插入的元素或键"
:return: True:插入成功 False:插入失败
"""""
flag, n, p = self.search(self.root, None, key)
if not flag: # 当前要插入的键不存在
new_node = Node(key)
if key > p.data:
p.rchild = new_node # 插入到p的右孩子节点
return True
else:
p.lchild = new_node # 插入到p的左孩子节点
return True
else:
return False
# 删除
def delete(self, root, key):
"""
:param root: 要删除的键所在的树的根
:param data: 要删除的键或节点
:return:
"""
flag, n, p = self.search(root, root, key)
if not flag: # 要删除的节点不存在
print("无该关键字,删除失败")
else:
if n.lchild is None: # 如果要删除的节点只有右孩子
if n == p.lchild:
p.lchild = n.rchild
else:
p.rchild = n.rchild
elif n.rchild is None: # 如果要删除的节点只有左孩子
if n == p.lchild:
p.lchild = n.lchild
else:
p.rchild = n.lchild
else: # 左右子树均不为空
pre = n.rchild # 这里找要删除节点的后继,所以要在右子树中找,所以让pre指向待删节点的右子树
if pre.lchild is None: # 如果为None说明pre就是待删节点的后继
n.data = pre.data # 令把后继的数据赋值给待删节点
n.rchild = pre.rchild # 待删节点的后继赋值为待删节点的后继的右孩子
else:
next = pre.lchild
while next.lchild: # 一直查左孩子,最后查到的即为待删节点的中序遍历的后继
pre = next
next = next.lchild
n.data = next.data
pre.lchild = next.rchild
# 中序遍历
def inOrderTraverse(self, node):
if node:
self.inOrderTraverse(node.lchild)
print(node.data, end=" ")
self.inOrderTraverse(node.rchild)
if __name__ == '__main__':
a = [49, 38, 65, 97, 60, 76, 13, 27, 5, 1]
bst = BST(a) # 创建二叉查找树
bst.inOrderTraverse(bst.root) # 中序遍历
print()
# 插入 88 66 0
bst.insert(88)
bst.insert(66)
bst.insert(0)
bst.inOrderTraverse(bst.root) # 中序遍历
print()
bst.delete(bst.root, 49) # 删除49
bst.inOrderTraverse(bst.root) # 中序遍历
isFind, T, p = bst.search(bst.root, None, 88, )
print(f"\n查找结果为{isFind},它的双亲节点为{p.data}")
程序运行结果:
1 5 13 27 38 49 60 65 76 97
0 1 5 13 27 38 49 60 65 66 76 88 97
0 1 5 13 27 38 60 65 66 76 88 97
查找结果为True,它的双亲节点为76
性能分析:
二叉排序树的优点明显,插入删除的时间性能比较好。而对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。极端情况,最少为1次,即根结点就是要找的结点,最多也不会超过树的深度。也就是说,二叉排序树的查找性能取决于二叉排序树的形状。可问题就在于,二叉排序树的形状是不确定的。
例如{ 62 , 88 , 58 , 47 , 35 , 73 , 51 , 99 , 37 , 93 }这样的数组,我们可以构建如下左图的二叉排序树。但如果数组元素的次序是从小到大有序,如{35,37,47,51,58,62,73,88,93,99},则二叉排序树就成了极端的右斜树,如下面右图的二叉排序树:
也就是说,我们希望二叉排序树是比较平衡的,即其深度与完全二叉树相同,那么查找的时间复杂也就是O(logn),近似于折半查找。
不平衡的最坏情况就是像上面右图的斜树,查找时间复杂度为O ( n ),这等同于顺序查找。
因此,如果我们希望对一个集合按二叉排序树查找,最好是把它构建成一棵平衡的二叉排序树。
(2)平衡二叉树
平衡二叉树(Self-Balancing Binary Search Tree 或 Height-Balanced Binary Search Tree)是一种二叉排序树,其中每一个节点的左子树和右子树的高度差至多等于1。
它是一种高度平衡的二叉排序树。它要么是一棵空树, 要么它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF (Balance Factor) , 那么平衡二叉树上所有结点的平衡因子只可能是-1、0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。
平衡二叉树的插入过程的前半部分与二叉排序树相同,但在新结点插入后,若造成查找路径上的某个结点不再平衡,则需要做出相应的调整。可将调整的规律归纳为下列4种情况:
- LL平衡旋转(右单旋转)。由于在结点A的左孩子(L)的左子树(L)上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。如下图所示,结点旁的数值代表结点的平衡因子,而用方块表示相应结点的子树,下方数值代表该子树的高度。
- RR平衡旋转(左单旋转)。由于在结点A的右孩子®的右子树®上插入了 新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树。
- LR平衡旋转(先左后右双旋转)。由于在A的左孩子(L)的右子树®上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置(即进行一次RR平衡旋转(左单旋转)),然后再把该C结点向右上旋转提升到A结点的位置(即进行一次LL平衡旋转(右单旋转))。
- RL平衡旋转(先右后左双旋转)。由于在A的右孩子®的左子树(L)上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置(即进行一次LL平衡旋转(右单旋转)),然后再把该C结点向左上旋转提升到A结点的位置(即进行一次RR平衡旋转(左单旋转))。
注意: LR和RL旋转时,新结点究竟是插入C的左子树还是插入C的右子树不影响旋转过程,而上图中是以插入C的左子树中为例。
举个例子:
假设关键字序列为15 , 3 , 7 , 10 , 9 , 8 {15,3, 7, 10, 9, 8}15,3,7,10,9,8,通过该序列生成平衡二叉树的过程如下图所示。
c语言代码实现:
#include "stdio.h"
#include "stdlib.h"
#include "math.h"
#include "time.h"
#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0
#define MAXSIZE 100 /* 存储空间初始分配量 */
typedef int Status; /* Status是函数的类型,其值是函数结果状态代码,如OK等 */
/* 二叉树的二叉链表结点结构定义 */
typedef struct BiTNode /* 结点结构 */
{
int data; /* 结点数据 */
int bf; /* 结点的平衡因子 */
struct BiTNode *lchild, *rchild; /* 左右孩子指针 */
} BiTNode, *BiTree;
/* 对以p为根的二叉排序树作右旋处理, */
/* 处理之后p指向新的树根结点,即旋转处理之前的左子树的根结点 */
void R_Rotate(BiTree *P)
{
BiTree L;
L=(*P)->lchild; /* L指向P的左子树根结点 */
(*P)->lchild=L->rchild; /* L的右子树挂接为P的左子树 */
L->rchild=(*P);
*P=L; /* P指向新的根结点 */
}
/* 对以P为根的二叉排序树作左旋处理, */
/* 处理之后P指向新的树根结点,即旋转处理之前的右子树的根结点0 */
void L_Rotate(BiTree *P)
{
BiTree R;
R=(*P)->rchild; /* R指向P的右子树根结点 */
(*P)->rchild=R->lchild; /* R的左子树挂接为P的右子树 */
R->lchild=(*P);
*P=R; /* P指向新的根结点 */
}
#define LH +1 /* 左高 */
#define EH 0 /* 等高 */
#define RH -1 /* 右高 */
/* 对以指针T所指结点为根的二叉树作左平衡旋转处理 */
/* 本算法结束时,指针T指向新的根结点 */
void LeftBalance(BiTree *T)
{
BiTree L,Lr;
L=(*T)->lchild; /* L指向T的左子树根结点 */
switch(L->bf)
{ /* 检查T的左子树的平衡度,并作相应平衡处理 */
case LH: /* 新结点插入在T的左孩子的左子树上,要作单右旋处理 */
(*T)->bf=L->bf=EH;
R_Rotate(T);
break;
case RH: /* 新结点插入在T的左孩子的右子树上,要作双旋处理 */
Lr=L->rchild; /* Lr指向T的左孩子的右子树根 */
switch(Lr->bf)
{ /* 修改T及其左孩子的平衡因子 */
case LH: (*T)->bf=RH;
L->bf=EH;
break;
case EH: (*T)->bf=L->bf=EH;
break;
case RH: (*T)->bf=EH;
L->bf=LH;
break;
}
Lr->bf=EH;
L_Rotate(&(*T)->lchild); /* 对T的左子树作左旋平衡处理 */
R_Rotate(T); /* 对T作右旋平衡处理 */
}
}
/* 对以指针T所指结点为根的二叉树作右平衡旋转处理, */
/* 本算法结束时,指针T指向新的根结点 */
void RightBalance(BiTree *T)
{
BiTree R,Rl;
R=(*T)->rchild; /* R指向T的右子树根结点 */
switch(R->bf)
{ /* 检查T的右子树的平衡度,并作相应平衡处理 */
case RH: /* 新结点插入在T的右孩子的右子树上,要作单左旋处理 */
(*T)->bf=R->bf=EH;
L_Rotate(T);
break;
case LH: /* 新结点插入在T的右孩子的左子树上,要作双旋处理 */
Rl=R->lchild; /* Rl指向T的右孩子的左子树根 */
switch(Rl->bf)
{ /* 修改T及其右孩子的平衡因子 */
case RH: (*T)->bf=LH;
R->bf=EH;
break;
case EH: (*T)->bf=R->bf=EH;
break;
case LH: (*T)->bf=EH;
R->bf=RH;
break;
}
Rl->bf=EH;
R_Rotate(&(*T)->rchild); /* 对T的右子树作右旋平衡处理 */
L_Rotate(T); /* 对T作左旋平衡处理 */
}
}
/* 若在平衡的二叉排序树T中不存在和e有相同关键字的结点,则插入一个 */
/* 数据元素为e的新结点,并返回1,否则返回0。若因插入而使二叉排序树 */
/* 失去平衡,则作平衡旋转处理,布尔变量taller反映T长高与否。 */
Status InsertAVL(BiTree *T,int e,Status *taller)
{
if(!*T)
{ /* 插入新结点,树“长高”,置taller为TRUE */
*T=(BiTree)malloc(sizeof(BiTNode));
(*T)->data=e; (*T)->lchild=(*T)->rchild=NULL; (*T)->bf=EH;
*taller=TRUE;
}
else
{
if (e==(*T)->data)
{ /* 树中已存在和e有相同关键字的结点则不再插入 */
*taller=FALSE; return FALSE;
}
if (e<(*T)->data)
{ /* 应继续在T的左子树中进行搜索 */
if(!InsertAVL(&(*T)->lchild,e,taller)) /* 未插入 */
return FALSE;
if(*taller) /* 已插入到T的左子树中且左子树“长高” */
switch((*T)->bf) /* 检查T的平衡度 */
{
case LH: /* 原本左子树比右子树高,需要作左平衡处理 */
LeftBalance(T); *taller=FALSE; break;
case EH: /* 原本左、右子树等高,现因左子树增高而使树增高 */
(*T)->bf=LH; *taller=TRUE; break;
case RH: /* 原本右子树比左子树高,现左、右子树等高 */
(*T)->bf=EH; *taller=FALSE; break;
}
}
else
{ /* 应继续在T的右子树中进行搜索 */
if(!InsertAVL(&(*T)->rchild,e,taller)) /* 未插入 */
return FALSE;
if(*taller) /* 已插入到T的右子树且右子树“长高” */
switch((*T)->bf) /* 检查T的平衡度 */
{
case LH: /* 原本左子树比右子树高,现左、右子树等高 */
(*T)->bf=EH; *taller=FALSE; break;
case EH: /* 原本左、右子树等高,现因右子树增高而使树增高 */
(*T)->bf=RH; *taller=TRUE; break;
case RH: /* 原本右子树比左子树高,需要作右平衡处理 */
RightBalance(T); *taller=FALSE; break;
}
}
}
return TRUE;
}
int main(void)
{
int i;
int a[10]={3,2,1,4,5,6,7,10,9,8};
BiTree T=NULL;
Status taller;
for(i=0;i<10;i++)
{
InsertAVL(&T,a[i],&taller);
}
printf("本样例建议断点跟踪查看平衡二叉树结构");
return 0;
}
(3)多路查找树
多路查找树(muitl-way search tree), 其每一个结点的孩子数可以多于两个,且每一个结点处可以存储多个元素。由于它是查找树,所有元素之间存在某种特定的排序关系。
在这里,每一个结点可以存储多少个元素,以及它的孩子数的多少是非常关键的。常见的有4种特殊形式:2-3树、2-3-4树、B树和B+树。这里主要介绍B树和B+树,因为2-3树、2-3-4树都是B树的特例。
如下图所示是一颗2-3树:
一、B树
B树,又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m 表示。一棵m 阶B树或为空树,或为满足如下特性的m叉树:
- 树中每个结点至多有m mm棵子树,即至多含有m − 1 m-1m−1个关键字。
- 若根结点不是终端结点,则至少有两棵子树。
- 除根结点外的所有非叶结点至少有⌈ m / 2 ⌉ 棵子树,即至少含有⌈ m / 2 ⌉ − 1 个关键字。
- 所有非叶结点的结构如下:
5.所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。
B树是所有结点的平衡因子均等于0的多路平衡查找树。
下图所示的B树中所有结点的最大孩子数m = 5 ,因此它是一棵5阶B树,在m 阶B树中结点最多可以有m 个孩子。可以借助该实例来分析上述性质:
- 结点的孩子个数等于该结点中关键字个数加1(每一个空隙都存在一个分支)。
- 如果根结点没有关键字就没有子树,此时B树为空;如果根结点有关键字,则其子树必然大于等于两棵,因为子树个数等于关键字个数加1。
- 除根结点外的所有非终端结点至少有⌈ m / 2 ⌉ = ⌈ 5 / 2 ⌉ = 3 棵子树(即至少有⌈ m / 2 ⌉ − 1 = ⌈ 5 / 2 ⌉ − 1 = 2 个关键字),至多有5棵子树(即至多有4个关键字)。
- 结点中关键字从左到右递增有序,关键字两侧均有指向子树的指针,左边指针所指子树的所有关键字均小于该关键字,右边指针所指子树的所有关键字均大于该关键字。或者看成下层结点关键字总是落在由上层结点关键字所划分的区间内,如第二层最左结点的关键字划分成了3个区间:( − ∞ , 5 ) , ( 5 , 11 ) , ( 11 , + ∞ ) ,该结点3个指针所指子树的关键字均落在这3个区间内。
- 所有叶结点均在第4层,代表查找失败的位置。
B树与磁盘存取:
B树中的大部分操作所需的磁盘存取次数与B树的高度成正比。
我们的外存,比如硬盘, 是将所有的信息分割成相等大小的页面,每次硬盘读写的都是一个或多个完整的页面,对于一个硬盘来说,一页的长度可能是211到214个字节。
在一个典型的B树应用中,要处理的硬盘数据量很大,因此无法一次全部装入内存。因此我们会对B树进行调整,使得B树的阶数(或结点的元素)与硬盘存储的页面大小相匹配。比如说一棵B树的阶为1001 (即1个结点包含1000个关键字),高度为2,它可以储存超过10亿个关键字,我们只要让根结点持久地保留在内存中,那么在这棵树上,寻找某一个关键字至多需要两次硬盘的读取即可。
通过这种方式,在有限内存的情况下,每一次磁盘的访问我们都可以获得最大数量的数据。由于B树每结点可以具有比二叉树多得多的元素,所以与二叉树的操作不同,它们减少了必须访问结点和数据块的数量,从而提高了性能。可以说,B树的数据结构就是为内外存的数据交互准备的。
B树的查找:
在B树上进行查找与二叉查找树很相似,只是每个结点都是多个关键字的有序表,在每个结点上所做的不是两路分支决定,而是根据该结点的子树所做的多路分支决定。
B树的查找包含两个基本操作:①在B树中找结点;②在结点内找关键字。由于B树常存储在磁盘上,因此前一个查找操作是在磁盘上进行的,而后一个查找操作是在内存中进行的,即在找到目标结点后,先将结点信息读入内存,然后在结点内采用顺序查找法或折半查找法。
在B树上查找到某个结点后,先在有序表中进行查找,若找到则查找成功,否则按照对应的指针信息到所指的子树中去查找。例如,在上图中查找关键字42 ,首先从根结点开始,根结点只有一个关键字,且42 > 22 ,若存在,必在关键字22 的右边子树上,右孩子结点有两个关键字,而36 < 42 < 45 ,则若存在,必在36 和45 中间的子树上,在该子结点中查到关键字42 ,查找成功。若查找到叶结点时(对应指针为空指针),则说明树中没有对应的关键字,查找失败。
B树的插入:
与二叉查找树的插入操作相比,B树的插入操作要复杂得多。在二叉査找树中,仅需査找到需插入的终端结点的位置。但是,在B树中找到插入的位置后,并不能简单地将其添加到终端结点中,因为此时可能会导致整棵树不再满足B树定义中的要求。将关键字key插入B树的过程如下:
- 定位。利用前述的B树査找算法,找出插入该关键字的最低层中的某个非叶结点(在B树中查找key时,会找到表示查找失败的叶结点,这样就确定了最底层非叶结点的插入位置。注意:插入位置一定是最低层中的某个非叶结点)。
- 插入。在B树中,每个非失败结点的关键字个数都在区间[⌈ m / 2 ⌉ − 1,m-1] 内。插入后的结点关键字个数小于m ,可以直接插入;插入后检查被插入结点内关键字的个数,当插入后的结点关键字个数大于m − 1时,必须对结点进行分裂。
分裂的方法是:取一个新结点,在插入key后的原结点,从中间位置⌈ m / 2 ⌉ 将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置⌈ m / 2 ⌉ 的结点插入原结点的父结点。若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度增1。对于m = 3的B树,所有结点中最多有m − 1 = 2 个关键字,若某结点中已有两个关键字,则结点已满,如下图a所示。插入一个关键字60 后,结点内的关键字个数超过了m − 1 ,如图b所示,此时必须进行结点分裂,分裂的结果如图c所示。
B树的删除:
B树中的删除操作与插入操作类似,但要更复杂一些,即要使得删除后的结点中的关键字个数≥ ⌈ m / 2 ⌉ − 1 ,因此将涉及结点的“合并”问题。
-
当被删关键字k kk不在终端结点(最低层非叶结点)中时,可以用k 的前驱(或后继) k1来替替代k ,然后在相应的结点中删除k1,关键字k1必定落在某个终端结点中,则转换成了被删关键字在终端结点中的情形。在下图的B树中,删除关键字80,用其前驱78替代,然后在终端结点中删除78。因此只需讨论删除终端结点中关键字的情形。
2.当被删关键字在终端结点(最低层非叶结点)中时,有下列三种情况:
① 直接删除关键字。若被删除关键字所在结点的关键字个数≥ ⌈ m / 2 ⌉,表明删除该关键字后仍满足B树的定义,则直接删去该关键字。
② 兄弟够借。若被删除关键字所在结点删除前的关键字个数= ⌈ m / 2 ⌉ − 1,且与此结点相邻的右(或左)兄弟结点的关键字个数≥ ⌈ m / 2 ⌉ ,则需要调整该结点、右(或左)兄弟结点及其双亲结点(父子换位法),以达到新的平衡。在图(a)中删除B树的关键字65 ,右兄弟关键字个数≥ ⌈ m / 2 ⌉ = 2 ,将71取代原65的位置,将74调整到71的位置。
③ 兄弟不够借。若被删除关键字所在结点删除前的关键字个数= ⌈ m / 2 ⌉ − 1 ,且此时与该结点相邻的左、右兄弟结点的关键字个数均= ⌈ m / 2 ⌉ − 1 ,则将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并。在图(b)中删除B树的关键字5 ,它及其右兄弟结点的关键字个数= ⌈ m / 2 ⌉ − 1 = 1 ,故在5删除后将60合并到65结点中。
在合并过程中,双亲结点中的关键字个数会减1。若其双亲结点是根结点且关键字个数减少至0(根结点关键字个数为1时,有2棵子树),则直接将根结点删除,合并后的新结点成为根;若双亲结点不是根结点,且关键字个数减少到⌈ m / 2 ⌉ − 2 ,则又要与它自己的兄弟结点进行调整或合并操作,并重复上述步骤,直至符合B树的要求为止。
其实通俗点讲,B树的删除,删除结点无非就是多留少补的情况,多留不必多说;少补复杂点:当兄弟够借时,就向左旋转一次(即往左挪一个位置,重构根节点关键字的前驱和后继);当兄弟不够借时就拆根节点,合并到兄弟结点,合并拆分要始终保证B树平衡,理清了就很容易理解。
二、B+树
尽管B树的诸多好处,但其实它还是有缺陷的。对于树结构来说,我们都可以通过中序遍历来顺序查找树中的元素,这一切都是在内存中进行。可是在B树结构中,我们往返于每个结点之间也就意味着,我们必须得在硬盘的页面之间进行多次访问。
如上图所示。我们希望遍历这棵B树,假设每个结点都属于硬盘的不同页面,我们中序遍历所有的元素:
页 面 2 → 页 面 1 → 页 面 3 → 页 面 1 → 页 面 4 → 页 面 1 → 页 面 5
而且我们每次经过结点遍历时,都会对结点中的元素进行一次遍历,这就非常糟糕。有没有可能让遍历时每个元素只访问一次呢?
B+树来了。
定义:
B+树是应文件系统(比如数据库)所需而出现的一种B树的变形树。
m阶的B+树与m阶的B树的主要差异如下:
- 有n棵子树的结点中包含有n 个关键字;
- 所有的叶子结点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接;
- 所有分支结点可以看成是索引,结点中仅含有其子树中的最大(或最小)关键字。
- 在B+树中,每个结点(非根内部结点)的关键字个数n的范围是⌈ m / 2 ⌉ ≤ n ≤ m (根结点:1 ≤ n ≤ m );在B树中,每个结点(非根内部结点)的关键字个数n范围是⌈ m / 2 ⌉ − 1 ≤ n ≤ m − 1 (根结点: 1 ≤ n ≤ m − 1 )。
下图所示为一棵4阶B+树:
B+树的结构特别适合带有范围的查找。比如查找我们学校18~22岁的学生人数,我们可以通过从根结点出发找到第一个18岁的学生,然后再在叶子结点按顺序查找到符合范围的所有记录。
B+树的插入、删除过程也都与B树类似,只不过插入和删除的元素都是在叶子结点上进行而已。
5.散列表查找(Hash Table)
(1) 散列表查找的基本概念
散列表是根据关键字而直接进行访问的数据结构。也就是说,散列表建立了关键字和存储地址之间的一种直接映射关系。我们只需要通过某个函数f,使得: 存 储 位 置 = f ( 关 键 字 ) 。那样我们可以通过查找关键字不需要比较就可获得需要的记录的存储位置。
散列技术既是一种存储方法, 也是一种查找方法,散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f ( k e y ) 。查找时,根据这个确定的对应关系找到置上。
这里我们把这种对应关系f称为散列函数,又称为哈希(Hash) 函数。按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。那么关键字对应的记录存储位置我们称为散列地址。散列函数可能会把两个或两个以上的不同关键字映射到同一地址,称这种情况为冲突,这些发生碰撞的不同关键字称为同义词。一方面,设计得好的散列函数应尽量减少这样的冲突;另一方面,由于这样的冲突总是不可避免的,所以还要设计好处理冲突的方法。
理想情况下,对散列表进行查找的时间复杂度为O(1),即与表中元素的个数无关。
(2) 散列函数的构造方法
在构造散列函数时,必须注意以下几点:
散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围。
散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,从而减少冲突的发生。
散列函数应尽量简单,能够在较短的时间内计算出任一关键字对应的散列地址。
下面介绍常用的散列函数:
1、直接定址法
直接取关键字的某个线性函数值为散列地址,散列函数为: H ( k e y ) = k e y 或 H ( k e y ) = a ∗ k e y + b ,式中,a和b是常数。这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。
举例:0~100岁的人口数字统计表,可以把年龄数值直接当做散列地址。2、数字分析法
例如当手机号码为关键字时,其11位数字是有规则的,此时是无需把11位数值全部当做散列地址,这时我们给关键词抽取, 抽取方法是使用关键字的一部分来计算散列存储位置的方法,这在散列函数中是常常用到的手段。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。3、平方取中法
这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用做散列地址。再比如关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671,也可以是710,用做散列地址。平方取中法比较适合于不知道关键字的分布,而位数又不是很大的情况。4、除留余数法
这是一种最简单、最常用的方法,假定散列表表长为m,取一个不大于m但最接近或等于m的质数p,利用以下公式把关键字转换成散列地址。散列函数为:
H ( k e y ) = k e y % p ( p < = m )
事实上,这方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模。
除留余数法的关键是选好p pp,使得每个关键字通过该函数转换后等概率地映射到散列空间上的任一地址,从而尽可能减少冲突的可能性。5、随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址。也就是
H ( k e y ) = r a n d o m ( k e y )
这里random是随机函数。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。6、折叠法
把关键词分割成位数相同的几个部分,然后叠加
(3) 处理散列冲突
任何设计出来的散列函数都不可能绝对地避免冲突。为此,必须考虑在发生冲突时应该如何处理,即为产生冲突的关键字寻找下一个“空”的Hash地址。
用Hi表示处理冲突中第i次探测得到的散列地址,假设得到的另一个散列地址H1仍然发生冲突,只得继续求下一个地址H2以此类推,直到Hk不发生冲突为止,则Hk为关键字在表中的地址。
1、开放定址法
所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
它的公式是:
Hi( k e y ) = ( f ( k e y ) + di) % m ( di = 1 , 2 , 3 , . . . , m − 1 )式中,Hi( k e y )为散列函数;i = 0 , 1 , 2 , . . . , k ( k < = m − 1 );m表示散列表表长;di为增量序列。
取定某一增量序列后,对应的处理方法就是确定的。通常有以下4种取法:
线性探测法。当di = 0 , 1 , 2 , . . . , m − 1时,称为线性探测法。这种方法的特点是:冲突发生时,顺序查看表中下一个单元(探测到表尾地址m − 1时,下一个探测地址是表首地址0,直到找出一个空闲单元(当表未填满时一定能找到一个空闲单元)或查遍全表。线性探测法可能使第i个散列地址的同义词存入第i + 1个散列地址,这样本应存入第i + 1个散列地址的元素就争夺第i + 2个散列地址的元素的地址,从而造成大量元素在相邻的散列地址上堆积,大大降低了查找效率。
平方探测法。当di= 02, 12 , − 12, 22 , − 22 , . . , k2 , − k2 时,称为平方探测法,其中k < m / 2,散列表长度m必须是一个可以表示成4 k + 3的素数,又称二次探测法。
平方探测法是一种较好的处理冲突的方法,可以避免出现“堆积”问题,它的缺点是不能探测到散列表上的所有单元,但至少能探测到一半单元。再散列法。当di= Hash2 (key)时,称为再散列法,又称双散列法。需要使用两个散列函数,当通过第一个散列函数H ( k e y )得到的地址发生冲突时,则利用第二个散列函数Hash2(key) 计算该关键字的地址增量。它的具体散列函数形式如下:
Hi= ( H(key) + i ∗ Hash2(key)) % m
初始探测位置Hi= H(key)。i是冲突的次数,初始为0。在再散列法中,最多经过m − 1次探测就会遍历表中所有位置,回到H0位置。伪随机序列法。当di=伪随机数序列时,称为伪随机序列法。
注意:在开放定址的情形下,不能随便物理删除表中的已有元素,因为若删除元素,则会截断其他具有相同散列地址的元素的查找地址。因此,要删除一个元素时,可给它做一个删除标记,进行逻辑删除。但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用,因此需要定期维护散列表,要把删除标记的元素物理删除。
2、链地址法(拉链法)
不换地方。转换一下思路,为什么非得有冲突就要换地方呢,如果不换地方该怎么处理?于是我们就有了链地址法。
将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。
例如,关键字序列为{ 12 , 67 , 56 , 16 , 25 , 37 , 22 , 29 , 15 , 47 , 48 , 34 } ,我们用除留余数法构造散列函数H( k e y ) = k e y % 12 ,用拉链法处理冲突,建立的表如下图所示。
3、公共溢出区法
这个方法其实就更加好理解,就是把凡是冲突的家伙额外找个公共场所待着。我们为所有冲突的关键字建立了一个公共的溢出区来存放。
就前面的例子而言,我们共有三个关键字37 , 48 , 34与之前的关键字位置有冲突,那么就将它们存储到溢出表中,如下图所示。
如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。
(4) 算法实现
class HashTable():
def __init__(self, hashsize):
"""
初始化哈希表
:param hashsize:哈希表的规模
"""
self.m = hashsize # 哈希表长
self.count = self.m # 当前数据元素个数
self.elem = [None for _ in range(self.m)] # 数据元素存储基址,动态分配数组
print("哈希表初始化已完成!")
def Hash(self, key):
"""散列函数"""
return key % self.m # 除留余数法
def InsertHash(self, key):
"""哈希表的插入操作"""
addr = self.Hash(key) # 求哈希地址
while self.elem[addr]: # 说明冲突了
addr = (addr + 1) % self.m # 开放定址法的线性探测
self.elem[addr] = key # 直到有空位后插入关键字
def SearchHash(self, key):
"""搜索关键字"""
addr = self.Hash(key) # 求哈希地址
while self.elem[addr] != key: # 说明冲突了
addr = (addr + 1) % self.m # 开放定址法的线性探测
if not self.elem[addr] or addr == self.Hash(key): # 如果循环到原点,则说明关键字不存在
return "UNSUCCESSS"
return "您搜索的关键字地址为:{}".format(addr)
if __name__ == '__main__':
li = [12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34]
H = HashTable(len(li))
for key in li:
H.InsertHash(key)
print(H.elem)
res = H.SearchHash(12)
print(res)
结果:
哈希表初始化已完成!
[12, 25, 37, 15, 16, 29, 48, 67, 56, 34, 22, 47]
您搜索的关键字地址为:0
从散列表的查找过程可见:
- 虽然散列表在关键字与记录的存储位置之间建立了直接映像,但由于“冲突”的产生,使得散列表的查找过程仍然是一个给定值和关键字进行比较的过程。因此,仍需要以平均查找长度作为衡量散列表的查找效率的度量。
- 散列表的查找效率取决于三个因素:散列函数、处理冲突的方法和装填因子。
- 若用ci表示每一个关键字查找的次数,则平均查找次数可表示为:ASL = $$ \sum_{i=0}^{m} ci $$ / m
装填因子。散列表的装填因子一般记为α,定义为一个表的装满程度,即α = n ( 表 中 记 录 数 ) / m ( 散 列 表 长 度 )
散列表的平均查找长度依赖于散列表的装填因子α ,而不直接依赖于n或m。直观地看,α 越大,表示装填的记录越“满”,发生冲突的可能性越大,反之发生冲突的可能性越小。
不管记录个数n有多大,我们总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时我们散列查找的时间复杂度就真的是O(1)了。 为了做到这一点,通常我们都是将散列表的空间设置得比查找集合大,此时虽然是浪费了一定的空间,但换来的是查找效率的大大提升,总的来说,还是非常值得的。
本文部分内容来自:https://blog.csdn.net/Real_Fool_/article/details/114359564
标签:结点,索引,关键字,算法,查找,key,数据结构,散列 From: https://www.cnblogs.com/minqiliang/p/16859762.html