查找算法
基本概念
- 生活中处处有查找,例如:搜索引擎、大数据问题等等。
- 我们学习查找想要解决的问题:对于超大数据量,如何提高查找的效率?
- 基本概念:
- 关键码:用以标识一个记录的某个数据项。如果该关键码可以唯一的标识一条记录,则称为主关键码,反之为次关键码。
- 查找:在具有相同类型的记录集中找出满足给定条件的记录。
- 查找结果:在查找集中找到匹配的记录,称为查找成功;否则查找失败。一般情况下,查找需要返回记录的位置。
P.S.
- 散列技术其实有很多,例如HASH哈希(题外话:在python中,字典就是一种哈希映射~欲知详情,请查看我的另一篇笔记
),散列查找的效率是相当高的,在最近十几年才崛起。 - 二叉排序树与平衡二叉树的区别?
因为对于一组无序数据,通过二叉排序树排序以后得到的树有很大可能是不平衡的(左右子树大小相差太多),而平衡二叉树称得上是二叉排序树的升级版,可以解决左右子树不平衡的问题。
思考: 查找结构与存储结构有什么区别?
线性表查找
顺序查找
问题: 对于乱序数据,如何快速查找出关键字Key是否在乱序中?若是,如何返回位置?
方法一:简单粗暴的直接查找
int search(int a[],int n,int key)
{
for(int i=0;i<n;i++)//①
if(a[i] == key)//②
return i+1;//找到key,返回位置
return 0;//没有找到,返回0
}
反思: 上述代码的时间复杂度?是O(n^2),因为有二次比较。
思考: 如何进一步提高效率?
方法二:哨兵法–用空间换时间
思想: 对于长度为n的乱序表,另建一个长度为n+1的表,其中a[0]做哨兵,其值赋为key。哨兵的意义–使函数无论如何都会返回一个值,而且只需要比较一次。
int search(int a[],int n,int key)
{
a[0] = key; //哨兵
for(int i=n;a[i]!=key;i--); //从后向前查找
return i; //如果找到key,就返回位置i,没有找到,就返回0
}
计算ASL:
- 查找不成功 ASL = n+1
- 查找成功
折半查找(敲黑板:必考题)
思考: 折半法的前提是什么?待查找序列为有序表。
基本思想: 先确定待查记录所在的范围,再用二分法逐步缩小范围直到找到或找不到且查完整个表。
再思考: 对存放在数组中的有序表,如何快速找到Key?
注意:
- (以上题为例)low的第一次移动要移动到 mid+1,因为mid原来坐在的位置,数据已经比较过一次了,可以直接跳到它的下一个。(同理:high=mid-1)
- 如何判断没有找到key?
low > high 时就说明没有找到。换句话说,循环条件就是 low<=high
int Search_Bin(int a[],int n,int key)
{
int low = 1;
int high = n;
while(low<=high)
{
mid = (low+high)/2;
if(key == a[mid])
return mid;
else if (key<a[mid])
high = mid-1;
else
low = mid +1;
}
return 0;
}
折半查找的性能分析
折半查找的判定树:
- 一般情况下,表长为n的折半查找的判定树的深度和含有n个结点的完全二叉树的深度相同。
所以:
索引查找(分块查找)
- 分块查找的性能介于顺序查找和折半查找。只用于分段有序的信息表。
- 分段查找的核心思想:在建立顺序表的同时,建立一个索引表。
- 可以看出索引表可以用折半查找,而基本表不可以。
- 基本思想:首先根据索引表确定待查记录的区间,然后再确定的主表区间采用顺序查找。(这其实就是哈希映射的思想,在当前大数据处理方面应用广泛。)
- 性能分析:
缺点:需要有辅助数组,且初始表要经过分块排序。
三种查找方式的比较
查找方式 | 性能 | 适用条件 |
---|---|---|
顺序查找 | ASL= (n+1)/2 或n+1,性能最差 | 乱序表 |
折半查找 | ASL=(2log)n 或 (2log)n+!,性能最好 | 有序表 |
分块查找 | 性能位于前两者中间 | 分块有序表 |
然而这三者都只适用于静态查找。如果我们想在查找的同时,对一些记录进行添加、删除操作,就要使用下面的树表。
树表查找
树表查找是典型的动态查找,适用于乱序表的查找,同时也可以对记录进行操作。
二叉排序树
基本思想: 将乱序化有序,然后根据折半查找的思想进行查找。
定义
二叉排序树:
- 空树
- 具有如下性质的树(注意体会递归的思想):
- 若它的左子树不空,则左子树上所有结点的值均小于根节点的值
- 若它的右子树不空,则右子树上所有结点的值均大于根结点的值
- 它的左右子树也分别都是二叉排序树
例如:
显然,当我们对二叉排序树进行中序递归时,就可以得到有序数表。
通常,可取二叉链表作为二叉排序树的结点存储结构。
template<class T>
class BiNode
{
public:
T data;
BiNode<T> *lch;
BiNode<T> *rch;
BiNode():lch(NULL),rch(NULL){}; //构造函数
}
建立
基本思路:
- 若当前节点=NULL,直接插入
- 否则将给定值与当前结点进行比较
2.1. 若key<当前节点,与其左孩子继续比较
2.2 .否则与其右孩子进行比较
反复执行,直到插入key
插入元素
举个栗子(所有元素均成功插入):
有兴趣的朋友可以看一下这个网站:数据可视化工具better
里面有各种动态的二叉树建立过程,也有很多其他逻辑结构的相关算法。
代码实现
二叉排序树的存储结构
template<class T>
calss BST
{
private:
BiNode<T> *Root; //根结点
public:
BST(T r[],int n); //构造函数,创建二叉排序树
BiNode<T>*Search(BiNode<T>*R,T key); //查找关键字key
void InsertBST(BiNode<T> *&R,BiNode<T>*s); //插入结点
void Delete(BiNode<T> *&R); //删除结点
bool DeteteBST(BiNode<T>*&R,T key); //根据关键字key删除指定结点
~BST(); //析构函数
}
插入元素
template<class T>
void BST<T>::InsertBST(BiNode<T>*&R,BiNode *s)
//R为二叉排序树的根节点,s为待插入的新结点
{
if(R == NULL) R = s; //插入R的位置
else if(s->data < R->data)
InsertBST(R->lch,s); //在左子树中插入
else
InsertBST(R->rch,s); //在右子树中插入
}
注意:
InsertBST算法的第一个参数类型为 *& 即指针的引用,其目的有两个:一,作为输入时,即把指针的值传递到了函数内部,又可以将指针的关系传递到函数内部;二,作为输出时,由于算法修改了指针R的值,可以将R的新值传递到函数外部。
一般情况下,若函数内部修改了指针本身的值(不是指针指向的地址的内容),则需要将该指针的参数设置为指针的引用 *& 。
二叉排序树的建立过程,就是把序列元素依次插入的过程
template<calss T>BST<T>::BST(T r[],int n)
{
Root = NULL;
for(int i=0;i<n;i++)
{
BiNode<T>*s = new BiNode<T>; //创建新结点
s->data = r[i];
s->lch = s->rch = NULL;
InsertBST(Root,s); //插入
}
}
删除
和插入相反,删除在查找成功以后进行,并且要求在删除二叉排序树上的某个结点后,仍然保持二叉排序树的特性。
删除结点的三种情况:
被删除的结点是叶结点(最简单)
方法:delete指向该叶结点的指针;父结点对应的指针置空
被删除的结点只有左子树或只有右子树
方法:被删除结点的双亲指向被删除结点的孩子,随后delete即可
被删除的结点既有右子树也有左子树(最复杂)
为了解决这一问题,我们又遇到了化繁为简的思想,只需要将这种情况转化为前两种情况即可。
算法分析:
- 中序遍历得到悲删除结点p的前驱结点q(q是p的左子树最右下结点),则q必为单分支结点或叶结点(总之,q的右指针必为空)
- 将q的值赋给p的值域(不必更改q的值域)
- 将删除p的操作转换为删除q的操作
代码实现
删除算法就两步:已知key,查找对应结点,判断类型;调用Delete()函数
第一步,递归查找
template<class T>
bool BST<T>::DeleteBST(BiNode<T> *&R, T key)
//R是二叉排序树的根结点,key是关键字
{
if(R == NULL) return false; //查找失败
else
{
if(key == R->data)
{
Delete(R); //找到域key匹配的结点,删除
return true;
}
else if(key < R->data)
return DeleteBST(R->lch,key); //在左子树查找
else
return DeleteBST(R->rch,key); //在右子树查找
}
}
第二步,删除已知结点R
template<class T>
void BST<T>::Delete(BiNode<T> *&R)
{
BiNode<T> *q,*s;
if(R->lch == NULL) //只有右子树,删除叶子结点包含在这种情况中
{
q = R;
R = R->rch;
delete q;
}
else if(R->rch ++ NULL) //只有左子树
{
q = R;
R = R->lch;
delete q;
}
else //左右子树都有
{
q = R;
s = R->rch;
while(s->rch != NULL)
{//使s指向R的前驱
q = s;
s = s->rch;
}
R -> data = s->data; //替换数值
if(q != R)
q->rch = s->rch; //s是q的右孩子
else
R->rch = s->rch; //q=R 表示s是R的左孩子
delete s;
}
}
Delete()函数采用 *& 类型传递指针,大大简化了删除算法。这是由于调用Delete函数时,传递参数R,不仅将R的值传给了Delete()函数,而且将指针R与它的左右孩子的对应关系传递给了Delete()函数,因此“R = R->rch” 就相当于直接给R的右孩子赋值。
查找
算法性能分析:
对于每一棵特定的二叉排序树,均可按照平均查找长度的定义来求它的ASL值,显然,由值相同的n个关键字,构造所得的不同形态的每个二叉排序树的ASL是不同的,甚至可能差别相当大。
为什么?
因为二叉排序树的结构不一定是平衡的,例如:值相同的一个左斜树和一个结构相当平衡的二叉树,显然,后者ASL更小。当二叉排序树结构比较稳定、结点数又比较多时,它的查找性能就接近于折半查找了。
template<class T>
BiNode<T>*BST<T>::Search(BiNode *R,T key)
{
if(R == NULL) return NULL; //查找失败
if(key == R->data) return R;
else if(key < R->data) return Search(R->lch,key);
else return Search(R->rch,key);
}
平衡二叉树(AVL)
为了进一步优化查找效率,使二叉排序树的结构更加平衡,平衡二叉树应运而生。
定义
平衡二叉树:
- 空树
- 具有如下性质的树:
- 左右子树都是平衡二叉树
- 左右子树高度值差的绝对值小于等于1
如果在建立二叉排序树时,保证其为平衡二叉树,则可避免查找的时间复杂度从O(2logn)退化成O(n)
(对于平衡二叉树,此处不再详细讲解,有兴趣的朋友可以看看这篇文章:平衡二叉树(AVL)图解与实现)
散列查找
散列查找的效率非常高!举个栗子,索引查找。
散列技术
什么是查找?
确定关键码=给定值的记录在集合中的存储位置。由于存储位置与关键码之间不存在确定的对应关系,因此,查找时必须通过一系列与关键码的比较。
理想情况:
在记录的存储位置与其关键码之间建立一个确定的对应关系H,使得每个关键码key和唯一的一个存储位置H(key)对应。
这就是散列技术,采用散列技术将记录存储在一块连续的存储空间中,就是散列表。
散列过程:
- 存储记录,通过H(key)计算记录的散列地址,并按此地址存储记录
- 查找记录,通过同样的H(key)计算记录的散列地址,按此地址访问该纪录。
P.S.散列不能表达记录之间的逻辑关系,所以是不完整的存储结构,是主要面向查找的存储结构。
散列函数设计
如何确定所需的哈希函数呢?这就是我们下面要讨论的散列函数的设计问题。
直接定址法
哈希函数: H(key)=a*key+b
特点: 计算简单,没有冲突,适合关键码分布比较连续的情况,否则会浪费大量空间。实际意义不大。
举个栗子:
除留余数法
哈希函数: H(key)=key%p (p<m) m为散列表长度,p最好为素数或不包含小于20的质因数的合数。
特点: 计算机简单,使用范围广。
举个栗子:
反思: 一定能找到不会引起冲突的p吗?
答案是不一定,这就是为什么要求p最好是是质数了。
冲突处理
但实际情况中,我们可能无法找到符合条件的完美哈希函数,会有 key2 != key2 但是 H(key1) == H(key2) 这样的冲突产生。
冲突处理的实际含义: 为产生冲突的地址寻找下一个哈希地址。
下面介绍三种方法
开放定址法
未产生冲突的地址H(key)按照某种规则产生另一个地址。
有三种方法:
线性探测法
Hi = (H(key) + di) MOD m
di = c*i
最简单的情况:c = 1(冲突+1再取模)
产生冲突的部分需要按照规则多查找两三次
平方探测法
Hi = (H(key) + di) MOD m
di = 1^2, -1^2, 2^2, -1^2, ……
随机探测法
Hi = (H(key) + di) MOD m
di是一组伪随机数,或者 di = i*H2(key)【又称双散列函数探测】
比如:3、1、9、2
链地址法(拉链法)
基本思想: 将所有散列地址相同的记录都存储在一个单链表中–同义词子表,三裂变存储所有同义词的头指针。
这种方法思路十分简单,对于数据量不是很大的情况,使用起来也非常方便.只是要注意建立链表时的方法(头插法或尾插法)会影响遍历顺序。
建立公共溢出区
基本思想: 散列表包含基本表和溢出表两个部分,将发生冲突的记录存储在溢出表中。
查找方法: 通过H(key)函数计算散列地址,先与基本表中记录进行比较,若相等,则查找成功,否则,到溢出表顺序查找。
这种方法跟第二种一比就比较麻烦了。
散列查找的性能分析
性能分析: 散列技术中,处理冲突的方法不同,得到的散列表不同,散列表的查找性能也不同。
决定性能的因素: 比较次数取决于发生冲突的概率,产生的冲突越多,查找效率就越低。
举个栗子:
影响冲突的因素:
- 散列函数是否均匀
- 处理冲突的方法
- 散列函数的填装因子a
a越大,代表填入表中的记录越多,产生冲突的可能性就越大。
后面会出有关查找算法实例的新文章(尤其是二叉排序树)
如果对上述内容有疑问,欢迎大家评论或私聊。
一起学习,一起进步~