第七章 查找技术
查找是数据处理领域中,使用最频繁的一种基本操作。
查找以集合为数据结构,以查找为核心操作,同时也可能包括插入和删除等其他操作。
7.1 概述
7.1.1 查找的基本概念
在查找问题中,通常将数据元素称为记录。
●关键码:可以标识一个记录的某个数据项。
●键值:关键码的值。
●主关键码:可以唯一地标识一个记录的关键码。
●次关键码:不能唯一地标识一个记录的关键码。
★查找 :在具有相同类型的记录构成的集合中找出满足给定条件的记录。给定的查找条件可能是多种多样的,为便于讨论,把查找条件限制为“匹配”,即查找关键码等于给定值的记录。
★查找的结果 :若在查找集合中找到了与给定值相匹配的记录,则称查找成功;否则,称查找失败。
★静态查找 :不涉及插入和删除操作的查找 。
静态查找适用于:查找集合一经生成,便只对其进行查找,而不进行插入和删除操作,或经过一段时间的查找之后,集中地进行插入和删除等修改操作;
★动态查找 :涉及插入和删除操作的查找。
动态查找适用于:查找与插入和删除操作在同一个阶段进行,例如当查找成功时,要删除查找到的记录,当查找不成功时,要插入被查找的记录。
★查找结构 :面向查找操作的数据结构,即查找基于的数据结构。
★本章讨论的查找结构有:
※线性表:适用于静态查找,主要采用顺序查找技术和折半查找技术。
※树表:适用于动态查找,主要采用二叉排序树的查找技术。
※散列表:静态查找和动态查找均适用,主要采用散列技术。
7.1.2 查找算法的性能
查找算法时间性能通过关键码的比较次数来度量。
平均查找长度:查找算法进行的关键码的比较次数的数学期望值。
7.2 线性表的查找技术
在线性表中进行的查找属于静态查找,这种查找算法简单,主要适用于对小型查找集合的查找。线性表一般有两种存储结构:顺序存储和链接存储,此时,可采用顺序查找技术;对顺序存储结构,若记录已按关键码有序,可采用更高效的查找技术——折半查找技术。
7.2.1 顺序查找(线性查找)
▲基本思想:从线性表的一端向另一端逐个将关键码与给定值进行比较,若相等,则查找成功,给出该记录在表中的位置;若整个表检测完仍未找到与给定值相等的关键码,则查找失败,给出失败信息。
▲顺序表的顺序查找算法SeqSearch1
int SeqSearch1(int r[ ], int n, int k)
{
r[0] = k;
i = n;
while (r[i] != k)
i--;
return i;
}
▲单链表的顺序查找算法SeqSearch2
int SeqSearch2(Node<int>*first, int k)
{
p=first->next;count=1;
While(p!=NULL&&p->data!=k)
{
p=p->next;
j++;
}
if(p->data==k) return j;
else return 0;
}
◆顺序查找的缺点:
平均查找长度较大,特别是当待查找集合中元素较多时,查找效率较低。
◆顺序查找的优点:
算法简单而且使用面广。对表中记录的存储没有任何要求,顺序存储和链接存储均可;对表中记录的有序性也没有要求,无论记录是否按关键码有序均可。
7.2.2 折半查找
★使用条件:
线性表中的记录必须按关键码有序;
必须采用顺序存储。
★基本思想:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键码相等,则查找成功;若给定值小于中间记录的关键码,则在中间记录的左半区继续查找;若给定值大于中间记录的关键码,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所查找的区域无记录,查找失败。
★折半查找非递归算法BinSearch1
int BinSearch1(int r[ ], int n, int k)
{ //数组r[1] ~ r[n]存放查找集合
low = 1; high = n;
while (low <= high)
{
mid = (low + high) / 2;
if (k < r[mid]) high = mid - 1;
else if (k > r[mid]) low = mid + 1;
else return mid;
}
return 0;
}
★折半查找递归算法BinSearch2
int BinSearch2(int r[ ], int low, int high, int k)
{
if (low > high) return 0;
else {
mid = (low + high) / 2;
if (k < r[mid])
return BinSearch2(r, low, mid-1, k);
else if (k > r[mid])
return BinSearch2(r, mid+1, high, k);
else return mid;
}
}
▲判定树:折半查找的过程可以用二叉树来描述,树中的每个结点对应有序表中的一个记录,结点的值为该记录在表中的位置。通常称这个描述折半查找过程的二叉树为折半查找判定树,简称判定树。
▲判定树的构造方法:
⑴ 当n=0时,折半查找判定树为空;
⑵ 当n>0时,折半查找判定树的根结点是有序表中序号为mid=(n+1)/2的记录,根结点的左子树是与有序表r[1] ~ r[mid-1]相对应的折半查找判定树,根结点的右子树是与r[mid+1] ~ r[n]相对应的折半查找判定树。
●折半查找性能分析:
具有n个结点的折半查找判定树的深度为 log2n向下取整+1.
※查找成功:在表中查找任一记录的过程,即是折半查找判定树中从根结点到该记录结点的路径,和给定值的比较次数等于该记录结点在树中的层数。
※查找不成功:查找失败的过程就是走了一条从根结点到外部结点的路径,和给定值进行的关键码的比较次数等于该路径上内部结点的个数。
7.3 树表的查找技术
7.3.1 二叉排序树
★二叉排序树(也称二叉查找树):或者是一棵空的二叉树,或者是具有下列性质的二叉树:
⑴ 若它的左子树不空,则左子树上所有结点的值均小于根结点的值;
⑵ 若它的右子树不空,则右子树上所有结点的值均大于根结点的值;
⑶ 它的左右子树也都是二叉排序树。
二叉排序树的定义采用的是递归方法。
中序遍历二叉排序树可以得到一个按关键码有序的序列
二叉排序树的存储结构
★以二叉链表形式存储,类声明如下:
class BiSortTree
{
public:
BiSortTree(int a[ ], int n);
~ BiSortTree( );
void InsertBST(BiNode<int> *root , BiNode<int> *s);
void DeleteBST(BiNode<int> *p, BiNode<int> *f );
BiNode<int> *SearchBST(BiNode<int> *root, int k);
private:
BiNode<int> *root;
};
★二叉排序树的插入
分析:若二叉排序树为空树,则新插入的结点为新的根结点;否则,新插入的结点必为一个新的叶子结点,其插入位置由查找过程得到。
void BiSortTree::InsertBST(BiNode<int> *root, BiNode<int> *s)
{
if (root == NULL)
root = s;
else if (s->data < root->data)
InsertBST(root->lchild, s);
else InsertBST(root->rchild, s);
}
★二叉排序树的构造
BiSortTree::BiSortTree(int r[ ], int n)
{
for (i = 0; i < n; i++)
{
s = new BiNode<int>;
s->data = r[i];
s->lchild = s->rchild = NULL;
InsertBST(root, s);
}
}
¤ 小 结:
一个无序序列可以通过构造一棵二叉排序树而变成一个有序序列;
每次插入的新结点都是二叉排序树上新的叶子结点;
找到插入位置后,不必移动其它结点,仅需修改某个结点的指针;
在左子树/右子树的查找过程与在整棵树上查找过程相同;
新插入的结点没有破坏原有结点之间的关系。
★二叉排序树的删除
在二叉排序树上删除某个结点之后,仍然保持二叉排序树的特性。
■分三种情况讨论:
被删除的结点是叶子;
被删除的结点只有左子树或者只有右子树;
被删除的结点既有左子树,也有右子树。
▲情况1——被删除的结点是叶子结点
操作:将双亲结点中相应指针域的值改为空。
▲情况2——被删除的结点只有左子树或者只有右子树
操作:将双亲结点的相应指针域的值指向被删除结点的左子树(或右子树)。
▲情况3——被删除的结点既有左子树也有右子树
操作:以其左子树中的最大值结点(或右子树中的最小值结点)替代之,然后再删除该结点。
●二叉排序树的删除算法——伪代码
1. 若结点p是叶子,则直接删除结点p;
2. 若结点p只有左子树,则只需重接p的左子树;
若结点p只有右子树,则只需重接p的右子树;
3. 若结点p的左右子树均不空,则
3.1 查找结点p的右子树上的最左下结点s及其双亲结点par;
3.2 将结点s数据域替换到被删结点p的数据域;
3.3 若结点p的右孩子无左子树,
则将s的右子树接到par的右子树上;
否则,将s的右子树接到结点par的左子树上;
3.4 删除结点s;
●二叉排序树的删除算法:
void BiSortTree::DeleteBST(BiNode<int>*p,BiNode<int>*f)
{
if((p->lchild==NULL)&&(p->rchild==NULL))
{
f->lchild=NULL;
delete p;
}
else if(p->rchild==NULL)
{
f->lchild=p->lchild;
delete p;
}
else if(p->lchild==NULL)
{
f->lchild=p->lchild;
delete p;
}
else
{
par=p;
s=p->rchild;
while(s->lchild!=NULL)
{
par=s;
s=s->lchild;
}
p->data=s->data;
if(par==p) par->rchild=s->rchild;
else par->lchild=s->rchild;
delete s;
}
}
★二叉排序树的查找
在二叉排序树中查找给定值k的过程是:
⑴ 若root是空树,则查找失败;
⑵ 若k=root->data,则查找成功;否则
⑶ 若k<root->data,则在root的左子树上查找;否则
⑷ 在root的右子树上查找。
上述过程一直持续到k被找到或者待查找的子树为空,如果待查找的子树为空,则查找失败。
二叉排序树的查找效率在于只需查找二个子树之一。
BiNode *BiSortTree::SearchBST(BiNode<int> *root, int k)
{
if (root == NULL)
return NULL;
else if (root->data == k)
return root;
else if (k < root->data)
return SearchBST(root->lchild, k);
else return SearchBST(root->rchild, k);
}
7.3.2 平衡二叉树
●平衡二叉树:或者是一棵空的二叉排序树,或者是具有下列性质的二叉排序树:
⑴ 根结点的左子树和右子树的深度最多相差1;
⑵ 根结点的左子树和右子树也都是平衡二叉树。
●平衡因子:结点的平衡因子是该结点的左子树的深度与右子树的深度之差。 在平衡树中,结点的平衡因子可以是1,0,-1。
●最小不平衡子树:在平衡二叉树的构造过程中,以距离插入结点最近的、且平衡因子的绝对值大于1的结点为根的子树。
◆构造平衡二叉树的基本思想:每插入一个结点,
(1)从插入结点开始向上计算各结点的平衡因子,如果某结点平衡因子的绝对值超过1,则说明插入操作破坏了二叉排序树的平衡性,需要进行平衡调整;否则继续执行插入操作。
(2)如果二叉排序树不平衡,则找出最小不平衡子树的根结点,根据新插入结点与最小不平衡子树根结点之间的关系判断调整类型。
(3)根据调整类型进行相应的调整,使之成为新的平衡子树。
◆设结点A为最小不平衡子树的根结点,对该子树进行平衡调整归纳起来有以下四种情况:
1. LL型
2. RR型
3. LR型
4. RL型
7.4 散列表的查找技术
7.4.1 概述
●散列的基本思想:在记录的存储地址和它的关键码之间建立一个确定的对应关系。这样,不经过比较,一次读取就能得到所查元素的查找方法。
●散列表:采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为散列表。
●散列函数:将关键码映射为散列表中适当存储位置的函数。
●散列地址:由散列函数所得的存储地址 。
散列既是一种查找技术,也是一种存储技术。
散列只是通过记录的关键码定位该记录,没有完整地表达记录之间的逻辑关系,所以,散列主要是面向查找的存储结构。
散列技术一般不适用于允许多个记录有同样关键码的情况。散列方法也不适用于范围查找,换言之,在散列表中,我们不可能找到最大或最小关键码的记录,也不可能找到在某一范围内的记录。
散列技术最适合回答的问题是:如果有的话,哪个记录的关键码等于待查值。
●冲突:对于两个不同关键码ki≠kj,有H(ki)=H(kj),即两个不同的记录需要存放在同一个存储位置,ki和kj相对于H称做同义词。
●散列技术的关键问题:
⑴ 散列函数的设计。如何设计一个简单、均匀、存储利用率高的散列函数。
⑵ 冲突的处理。如何采取合适的处理冲突方法来解决冲突。
7.4.2 散列函数的设计
★设计散列函数一般应遵循以下原则:
⑴ 计算简单。散列函数不应该有很大的计算量,否则会降低查找效率。
⑵ 函数值即散列地址分布均匀。函数值要尽量均匀散布在地址空间,这样才能保证存储空间的有效利用并减少冲突。
★以下是几种常见的散列函数:
(1)散列函数——除留余数法
一般情况下,选p为小于或等于表长(最好接近表长)的最小素数或不包含小于20质因子的合数。
除留余数法是一种最简单、也是最常用的构造散列函数的方法,并且不要求事先知道关键码的分布。
(2)散列函数——数字分析法
根据关键码在各个位上的分布情况,选取分布比较均匀的若干位组成散列地址。
能预先估计出全部关键码的每一位上各种数字出现的频度,不同的关键码集合需要重新分析。
(3)散列函数——平方取中法
对关键码平方后,按散列表大小,取中间的若干位作为散列地址(平方后截取)。
事先不知道关键码的分布且关键码的位数不是很大。
(4)散列函数——折叠法
将关键码从左到右分割成位数相等的几部分,将这几部分叠加求和,取后几位作为散列地址。
关键码位数很多,事先不知道关键码的分布。
7.4.3 处理冲突的方法
以下介绍几种常用的处理冲突的方法:
<1>处理冲突的方法——开放定址法
由关键码得到的散列地址一旦产生了冲突,就去寻找下一个空的散列地址,并将记录存入。
(1)线性探测法
(2)二次探测法
(3)随机探测法
◆线性探测法
当发生冲突时,从冲突位置的下一个位置起,依次寻找空的散列地址。
●对于键值key,设H(key)=d,闭散列表的长度为m,则发生冲突时,寻找下一个散列地址的公式为:
Hi=(H(key)+di) % m (di=1,2,…,m-1)
●用开放定址法处理冲突得到的散列表叫闭散列表。
●堆积:在处理冲突的过程中出现的非同义词之间对同一个散列地址争夺的现象。
●在线性探测法构造的散列表中查找算法——伪代码
1. 计算散列地址j;
2. 若ht[j]等于k,则查找成功,返回记录在散列表中的下标;
否则执行第3步;
3. 若ht[j]为空或整个散列表探测一遍,则查找失败,转4;
否则,j指向下一单元,转2;
4. 若整个散列表探测一遍,则表满,抛出溢出异常;
否则,将待查值插入;
●C++描述
int HashSearch1(int ht[ ], int m, int k)
{
j = H(k); //计算散列地址
if (ht[j] == k) return j; //没有发生冲突,比较一次查找成功
else if (ht[j] == Empty) {ht[j] = k; return 0; } //查找不成功,插入
i = (j + 1) % m; //设置探测的起始下标
while (ht[i] != Empty && i != j)
{
if (ht[i] == k) return i; //发生冲突,比较若干次查找成功
else i = (i + 1) % m; //向后探测一个位置
}
if (i == j) throw "溢出";
else {ht[i] = k; return 0; } //查找不成功,插入
}
◆二次探测法
当发生冲突时,寻找下一个散列地址的公式为:
Hi=(H(key)+di)% m
(di=12,-12,22,-22,…,q2,-q2且q≤m/2)
◆随机探测法
当发生冲突时,下一个散列地址的位移量是一个随机数列,即寻找下一个散列地址的公式为:
Hi=(H(key)+di)% m
(di是一个随机数列,i=1,2,……,m-1)
<2>处理冲突的方法——拉链法(链地址法)
★基本思想:将所有散列地址相同的记录,即所有同义词记录存储在一个单链表中(称为同义词子表),在散列表中存储的是所有同义词子表的头指针。
■用拉链法处理冲突构造的散列表叫做开散列表。
■开散列表不会出现堆积现象。
设n个记录存储在长度为m的散列表中,则同义词子表的平均长度为n / m。
■在拉链法构造的散列表查找算法——伪代码
1. 计算散列地址j;
2. 在第j个同义词子表中顺序查找;
3. 若查找成功,则返回结点的地址;
否则,将待查记录插在第j个同义词子表的表头。
■C++描述
Node<int> *HashSearch2(Node<int> *ht[ ], int m, int k)
{
j = H(k);
p = ht[j];
while (p != NULL && p->data != k)
p = p->next;
if (p->data == k) return p;
else {
q = new Node<int>; q->data = k;
q->next = ht[j];
ht[j] = q;
}
}
7.4.4 散列查找的性能分析
▲由于冲突的存在,产生冲突后的查找仍然是给定值与关键码进行比较的过程。
▲在查找过程中,关键码的比较次数取决于产生冲突的概率。影响冲突产生的因素有:
(1)散列函数是否均匀
(2)处理冲突的方法
(3)散列表的装载因子:
α=填入表中的记录个数/散列表的长度
▲几种处理冲突方法的平均查找长度
散列表的平均查找长度是装填因子α的函数,而不是查找集合中记录个数n的函数。在很多情况下,散列表的空间都比查找集合大,此时虽然浪费了一定的空间,但换来的是查找效率。