data structure-查找
数据结构之查找算法
一、简介
在日常生活中,几乎每天都要进行一些查找的工作,在电话簿中查阅某个人的电话号码;在电脑的文件夹中查找某个具体的文件等等。本节主要介绍用于查找操作的数据结构——查找表。
查找表是由同一类型的数据元素构成的集合。例如电话号码簿和字典都可以看作是一张查找表。
一般对于查找表有以下几种操作:
- 在查找表中查找某个具体的数据元素;
- 在查找表中插入数据元素;
- 从查找表中删除数据元素;
1.1 静态查找表和动态查找表
静态查找表:在查找表中只做查找操作,而不改动表中数据元素
动态查找表:在查找表中做查找操作的同时进行插入数据或者删除数据的操作
1.2 关键字
在查找表查找某个特定元素时,前提是需要知道这个元素的一些属性。例如,每个人上学的时候都会有自己唯一的学号,因为你的姓名、年龄都有可能和其他人是重复的,唯独学号不会重复。而学生具有的这些属性(学号、姓名、年龄等)都可以称为关键字。
关键字又细分为主关键字和次关键字。若某个关键字可以唯一地识别一个数据元素时,称这个关键字为主关键字,例如学生的学号就具有唯一性;反之,像学生姓名、年龄这类的关键字,由于不具有唯一性,称为次关键字。
1.3 如何进行查找?
不同的查找表,其使用的查找方法是不同的。例如每个人都有属于自己的朋友圈,都有自己的电话簿,电话簿中数据的排序方式是多种多样的,有的是按照姓名的首字母进行排序,这种情况在查找时,就可以根据被查找元素的首字母进行顺序查找;有的是按照类别(亲朋好友)进行排序。在查找时,就需要根据被查找元素本身的类别关键字进行排序。
具体的查找方法需要根据实际应用中具体情况而定。
本章从静态查找表、动态查找表和哈希表的角度具体分析针对不同的查找表可供选择的查找算法。
二、静态查找表
2.1 顺序查找算法
通过前面对静态查找表的介绍,静态查找表即为只做查找操作的查找表。
静态查找表既可以使用顺序表表示,也可以使用链表结构表示。虽然一个是数组、一个链表,但两者在做查找操作时,基本上大同小异。
本节以静态查找表的顺序存储结构为例做详细的介绍。
顺序查找的实现
静态查找表用顺序存储结构表示时,顺序查找的查找过程为:
从表中的最后一个数据元素开始,逐个同记录的关键字做比较
如果匹配成功,则查找成功
反之,如果直到表中第一个关键字查找完也没有成功匹配,则查找失败。
可运行代码中设置了一个固定长度为 6 的顺序表,例如在查找表为{1,2,3,4,5,6}
找到关键字为 1 的数据元素的位置,则运行效果为:
输入表中的数据元素:
1 2 3 4 5 6
请输入查找数据的关键字:
2
数据在查找表中的位置为:2
同时,在程序中初始化创建查找表时,由于是顺序存储,所以将所有的数据元素存储在数组中,但是把第一个位置留给了用户用于查找的关键字。例如,在顺序表{1,2,3,4,5,6}
中查找数据元素值为 7 的元素,则添加后的顺序表为:
顺序查找的性能分析
查找操作的性能分析主要考虑其时间复杂度,而整个查找过程其实大部分时间花费在关键字和查找表中的数据进行比较上。
所以查找算法衡量好坏的依据:从开始查找到查找成功为止,查找的关键字和查找表中的数据元素中进行过比较的次数的平均值,称为 平均查找长度(Average Search Length,用 ASL 表示)。
例如,对于具有 n 个数据元素的查找表,查找成功的平均查找长度的计算公式为:
Pi 为第 i 个数据元素被查找的概率,所有元素被查找的概率的和为 1;
Ci 表示在查找到第 i 个数据元素之前已进行过比较的次数。
若表中有 n 个数据元素,查找第一个元素时需要比较 n 次;查找最后一个元素时需要比较 1 次,所以有
Ci = n – i + 1
。
一般情况,表中各数据元素被查找的概率是未知的。假设含有 n 个数据元素的查找表中,各数据被查找的概率是相同的,则:
可得:
如果对于查找表中各个数据元素有可能被查找的概率提前已知,就应该根据其查找概率的大小对查找表中的数据元素进行适当的调整:
- 被查找概率越大,离查找出发点 i 越近;反之,越远。这样可以适当的减少查找操作中的比较次数。
上边的平均查找长度是在假设查找算法每次都成功的前提下得出的。
而对于查找算法来说,查找成功和查找失败的概率是相同的。
所以,查找算法的平均查找长度应该为查找成功时的平均查找长度加上查找失败时的平均查找长度。
对于含有 n 个数据的表来说,每次查找失败,比较的次数都是 n+1。所以查找算法的平均查找长度的计算公式为:
在使用算法A进行搜索的前提下,在一个含有n个元素的数据结构D中查找某个集合X中的元素x
这个元素x的查找长度\(SL_x\)(Search Length of x):
X中所有元素搜索长度的均值即为算法A在数据结构D下搜索集合X中元素的平均搜索长度\(ASL_{AD}\)(Average Search Length)
- Pi 表示X与D的交集的元素\(x_i\)被查找的概率
- P表示集合X-D中的元素被选中的概率
- xi 表示第i个属于D元素x的搜索长度\(SL_i\)
2.2 折半查找【也称二分查找】
折半查找,也称二分查找,在某些情况下相比于顺序查找,使用折半查找算法的效率更高。但是该算法的使用的前提是静态查找表中的数据必须是有序的。
例如,在{5,21,13,19,37,75,56,64,88 ,80,92}
这个查找表使用折半查找算法查找数据之前,需要首先对该表中的数据按照所查的关键字进行排序:{5,13,19,21,37,56,64,75,80,88,92}
。
在折半查找之前对查找表按照所查的关键字进行排序的意思是:若查找表中存储的数据元素含有多个关键字时,使用哪种关键字做折半查找,就需要提前以该关键字对所有数据进行排序。
注意折半查找的判定树的画法
2.3 分块查找
索引表折半查找,查找表分块查找
2.4 静态树表查找算法
最优查找树,次优查找树
次优查找树的构建方法
首先取出查找表中每个关键字及其对应的权值,采用如下公式计算出每个关键字对应的一个值:
其中 wj 表示每个关键字的权值(被查找到的概率),h 表示关键字的个数。
表中有多少关键字,就会有多少个$\Delta p_i $,取其中最小的做为次优查找树的根结点,然后将表中关键字从第 i 个关键字的位置分成两部分,分别作为该根结点的左子树和右子树。同理,左子树和右子树也这么处理,直到最后构成次优查找树完成。
三、动态查找表
前几节介绍的都是有关静态查找表的相关知识,从本节开始介绍另外一种查找表——动态查找表。
动态查找表中做查找操作时,若查找成功可以对其进行删除;如果查找失败,即表中无该关键字,可以将该关键字插入到表中。
动态查找表的表示方式有多种,本节介绍一种使用树结构表示动态查找表的实现方法——二叉排序树(又称为“二叉查找树”)。
2.5 二叉排序树(二叉查找树)
二叉排序树的定义
二叉排序树要么是空二叉树,要么具有如下特点:
- 二叉排序树中,如果其根结点有左子树,那么左子树上所有结点的值都小于根结点的值;
- 二叉排序树中,如果其根结点有右子树,那么右子树上所有结点的值都大小根结点的值;
- 二叉排序树的左右子树也要求都是二叉排序树;
例如,图 1 就是一个二叉排序树:
图1:
二叉排序树的查找
二叉排序树中查找某关键字时,查找过程类似于次优二叉树,在二叉排序树不为空树的前提下,首先将被查找值同树的根结点进行比较,会有 3 种不同的结果:
- 如果相等,查找成功;
- 如果比较结果为根结点的关键字值较大,则说明该关键字可能存在其左子树中;
- 如果比较结果为根结点的关键字值较小,则说明该关键字可能存在其右子树中;
实现函数为:(运用递归的方法)
BiTree SearchBST(BiTree T,KeyType key){
//如果递归过程中 T 为空,则查找结果,返回NULL;或者查找成功,返回指向该关键字的指针
if (!T || key==T->data) {
return T;
}else if(key<T->data){
//递归遍历其左孩子
return SearchBST(T->lchild, key);
}else{
//递归遍历其右孩子
return SearchBST(T->rchild, key);
}
}
二叉排序树的插入
二叉排序树本身是动态查找表的一种表示形式,有时会在查找过程中插入或者删除表中元素,当因为查找失败而需要插入数据元素时,该数据元素的插入位置一定位于二叉排序树的叶子结点,并且一定是查找失败时访问的最后一个结点的左孩子或者右孩子。
图1:
例如,在图 1 的二叉排序树中做查找关键字 1 的操作,当查找到关键字 3 所在的叶子结点时,判断出表中没有该关键字,此时关键字 1 的插入位置为关键字 3 的左孩子。
所以,二叉排序树表示动态查找表做插入操作,只需要稍微更改一下上面的代码就可以实现,具体实现代码为:
BOOL SearchBST(BiTree T,KeyType key,BiTree f,BiTree *p){
//如果 T 指针为空,说明查找失败,令 p 指针指向查找过程中最后一个叶子结点,并返回查找失败的信息
if (!T){
*p=f;
return false;
}
//如果相等,令 p 指针指向该关键字,并返回查找成功信息
else if(key==T->data){
*p=T;
return true;
}
//如果 key 值比 T 根结点的值小,则查找其左子树;反之,查找其右子树
else if(key<T->data){
return SearchBST(T->lchild,key,T,p);
}else{
return SearchBST(T->rchild,key,T,p);
}
}
//插入函数
BOOL InsertBST(BiTree T,ElemType e){
BiTree p=NULL;
//如果查找不成功,需做插入操作
if (!SearchBST(T, e,NULL,&p)) {
//初始化插入结点
BiTree s=(BiTree)malloc(sizeof(BiTree));
s->data=e;
s->lchild=s->rchild=NULL;
//如果 p 为NULL,说明该二叉排序树为空树,此时插入的结点为整棵树的根结点
if (!p) {
T=s;
}
//如果 p 不为 NULL,则 p 指向的为查找失败的最后一个叶子结点,只需要通过比较 p 和 e 的值确定 s 到底是 p 的左孩子还是右孩子
else if(e<p->data){
p->lchild=s;
}else{
p->rchild=s;
}
return true;
}
//如果查找成功,不需要做插入操作,插入失败
return false;
}
通过使用二叉排序树对动态查找表做查找和插入的操作,同时在中序遍历二叉排序树时,可以得到有关所有关键字的一个有序的序列。
二叉排序树的删除
2.6 平衡二叉树(AVL树)
2.7 hash table
(1) 概述
哈希表
是一种使用哈希函数
组织数据,以支持快速插入和搜索的数据结构。
有两种不同类型的哈希表:哈希集合和哈希映射。
哈希集合
是集合
数据结构的实现之一,用于存储非重复值
。哈希映射
是映射
数据结构的实现之一,用于存储(key, value)
键值对。
在标准模板库
的帮助下,哈希表是易于使用的
。大多数常见语言(如Java,C ++ 和 Python)都支持哈希集合和哈希映射。
通过选择合适的哈希函数,哈希表可以在插入和搜索方面实现出色的性能
。
在本小节中,将回答以下问题:
- 哈希表的
原理
是什么? - 如何
设计
哈希表? - 如何使用
哈希集
来解决与重复相关的问题? - 如何使用
哈希映射
按键聚合信息? - 如何在使用哈希表时
设计正确的键
?
(2) 哈希表的原理
正如我们在介绍中提到的,哈希表
是一种数据结构,它使用哈希函数组织数据,以支持快速插入和搜索
。在本文中,我们将简要说明哈希表的原理。
哈希表的关键思想是使用哈希函数将键映射到存储桶
。更确切地说:
Q1
- 当我们插入一个新的键时,哈希函数将决定该键应该分配到哪个桶中,并将该键存储在相应的桶中;
- 当我们想要搜索一个键时,哈希表将使用相同的哈希函数来查找对应的桶,并只在特定的桶中进行搜索。
1. 示例
在示例中,我们使用 y = x % 5
作为哈希函数。让我们使用这个例子来完成插入和搜索策略:
- 插入:我们通过哈希函数解析键,将它们映射到相应的桶中。
- 例如,1987 分配给桶 2,而 24 分配给桶 4。
- 搜索:我们通过相同的哈希函数解析键,并仅在特定存储桶中搜索。
- 如果我们搜索 1987,我们将使用相同的哈希函数将1987 映射到 2。因此我们在桶 2 中搜索,我们在那个桶中成功找到了 1987。
- 例如,如果我们搜索 23,将映射 23 到 3,并在桶 3 中搜索。我们发现 23 不在桶 3 中,这意味着 23 不在哈希表中。
(3) 设计哈希表的关键
在设计哈希表时,你应该注意两个基本因素。
1. 哈希函数
哈希函数是哈希表中最重要的组件,该哈希表用于将键映射到特定的桶。在上一篇文章中的示例中,我们使用 y = x % 5
作为散列函数,其中 x
是键值,y
是分配的桶的索引。
散列函数拥有键值的范围
和桶的数量
这两个属性。
下面是一些哈希函数的示例:
Q2
哈希函数的设计是一个开放的问题。其思想是尽可能将键分配到桶中,理想情况下,完美的哈希函数将是键和桶之间的一对一映射。然而,在大多数情况下,哈希函数并不完美,它需要在桶的数量和桶的容量之间进行权衡。
2. 冲突解决
理想情况下,如果我们的哈希函数是完美的一对一映射,我们将不需要处理冲突。不幸的是,在大多数情况下,冲突几乎是不可避免的。例如,在我们之前的哈希函数(y = x % 5)中,1987 和 2 都分配给了桶 2,这是一个冲突
。
冲突解决算法应该解决以下几个问题:
- 如何组织在同一个桶中的值?
- 如果为同一个桶分配了太多的值,该怎么办?
- 如何在特定的桶中搜索目标值?
根据我们的哈希函数,这些问题与桶的容量
和可能映射到同一个桶
的键的数目
有关。
让我们假设存储最大键数的桶有 N
个键。
通常,如果 N 是常数且很小,我们可以简单地使用一个数组将键存储在同一个桶中。如果 N 是可变的或很大,我们可能需要使用高度平衡的二叉树
来代替.。
(4) 训练
到目前为止,您应该能够实现基本的哈希表。我们为您提供了实现哈希集和哈希映射的练习。阅读需求
,确定哈希函数
并在需要时解决冲突
。
如果你不熟悉哈希集或是哈希映射的概念,可以返回介绍部分找出答案。.
插入
和搜索
是哈希表中的两个基本操作。
此外,还有基于这两个操作的操作。例如,当我们删除元素
时,我们将首先搜索元素,然后在元素存在的情况下从相应位置移除元素。
1. 设计哈希集合
不使用任何内建的哈希表库设计一个哈希集合(HashSet)。
实现 MyHashSet
类:
void add(key)
向哈希集合中插入值key
。bool contains(key)
返回哈希集合中是否存在这个值key
。void remove(key)
将给定值key
从哈希集合中删除。如果哈希集合中没有这个值,什么也不做。
示例:
输入:
["MyHashSet", "add", "add", "contains", "contains", "add", "contains", "remove", "contains"]
[[], [1], [2], [1], [3], [2], [2], [2], [2]]
输出:
[null, null, null, true, false, null, true, null, false]
解释:
MyHashSet myHashSet = new MyHashSet();
myHashSet.add(1); // set = [1]
myHashSet.add(2); // set = [1, 2]
myHashSet.contains(1); // 返回 True
myHashSet.contains(3); // 返回 False ,(未找到)
myHashSet.add(2); // set = [1, 2]
myHashSet.contains(2); // 返回 True
myHashSet.remove(2); // set = [1]
myHashSet.contains(2); // 返回 False ,(已移除)
方法一:链地址法
设哈希表的大小为base
,则可以设计一个简单的哈希函数:hash(x) = x mod base
我们开辟一个大小为 base
的数组,数组的每个位置是一个链表。当计算出哈希值之后,就插入到对应位置的链表当中。
由于我们使用整数除法作为哈希函数,为了尽可能避免冲突,应当将base
取为一个质数。在这里,我们取 base = 769
。