第七章 查找技术
【学习重点】
1 折半查找的过程及性能分析;
2 二叉排序树的插入,删除和查找操作;
3 平衡二叉树的调整方法;
4 散列表的构造和查找方法;
5 各种查找技术的时间性能及对比。
【学习难点】
1 二叉排序树的删除操作;
2 平衡二叉树的调整方法;
3 闲散列表的删除算法。
7.1 概述
7.1.1 查找的基本概念
在查找问题中,通常将数据元素称为记录。
关键码 可以标识一个记录的某个数据项称为关键码,关键码的值称为键值。
查找 广义地讲,查找是在具有相同类型的记录构成的集合中找出满足给定条件的记录。
查找的结果 若在查找集合中找到了与给定值相匹配的记录,则称查找成功。
静态查找 动态查找 不涉及删除操作的查找称为静态查找,涉及删除操作的查找称为动态查找。
查找结构
本章讨论的查找结构有:
(1)线性表
(2)树表
(3)散列表
7.1.2 查找算法的技能
对于查找算法,以个别关键码的查找来衡量时间性能是不完全的。一般来讲,我们关心的是它的整体性能。将查找算法进行的关键码比较次数的数学期望值定义为平均查找长度。
7.2 线性表的查找技术
7.2.1 顺序查找
顺序查找又称线性查找,是最基本的查找技术之一。
1. 顺序表的顺序查找
在长度为n的顺序表中查找给定值为k的记录,将哨兵设在数组的低端,顺序查找算法用伪代码描述如下:
1. 设置哨兵;
2. 初始化查找的起始下标i=n;
3. 若r[i]与k相等,则返回当前i的值,继续比较前一个记录;
顺序查找算法的C++描述如下:
顺序表的顺序查找算法 SeqSearch1
int SeqSearch1(int r[],int n,int k)
{
r[0]=k;
i=n;
while(r[i]!=k)
i--;
return i;
}
2.单链表的顺序查找
假定带头结点的单链表的头指针为first,在单链表中的顺序查找算法如下:
单链表的顺序查找算法 SeqSearch2
int SeqSearch2(Node
{
p=first->next;cout=1;
while (p!=NULL&&p->data!=k)
{
p=p->next;
j++;
}
if(p->data==k) return j;
else return 0;
}
7.2.2 折半查找
相对于顺序查找技术来说,折半查找技术的要求比较高,它要求线性表中的记录必须按关键码有序,并且必须采用顺序存储。折半查找技术一般只能应用于静态查找。
1. 执行过程
折半查找利用了记录按关键码有序的特点。
折半查找算法用伪代码描述为:
1. 设置初始查找区间:low=1;high=n;
2. 测试查找区间[low,high]是否存在,若不存在,则查找失败;否则
3. 取中间位置mid=(low+high)/2;比较k与r[mid]有以下三种情况:
3.1若 k
3.2若k>r[mid],则low=mid+1; 查找在右半区进行,转第二步;
3.3若k=r[mid], 则查找成功,返回记录在表中位置mid;
2. 非递归算法
设有序表的长度为n,待查值为k,折半查找非递归算法的C++描述如下:
折半查找非递归算法 BinSearch1
int BinSearch(int r[],int n,int k)
{
low=1;high=n;
while (low<=high)
{
mid=(low+high)/2;
if(k else if(k>r[mid])low=mid+1; else return mid; } return 0; } 3.递归算法 递归算法可以直接依照折半查找的定义给出。 折半查找递归算法 BinSearch2 int BinSearch2(int r[],int low,int high,int k) { if(low>high)return 0; else{ mid=(low+high)/2; if(k else if(k>r[mid])return BinSearch2(r,mid+1,high,k); else return mid; } } 4.性能分析 从折半查找的过程看,以有序表的中间记录作为比较对象,并以中间记录将表分割为两个子表,对子表继续这种操作。所以,对表中每个记录的查找过程,可用二叉树来描述,树中的每个节点对应有序表中的一个记录,结点的值为该记录在表中的位置。通常称这个描述折半查找过程中的二叉树为折半查找判定树,简称判定树。 折半查找判定树具有如下性质: (1) 任意两棵折半查找判定树,若它们的结点个数相同,则它们的结构完全相同; (2) 具有n个节点的折半查找判定树的深度为[log2n]+1; (3) 任意两个叶子所处的层数最多相差1。 7.3树表的查找技术 7.3.1 二叉排序表 二叉排序树又称二叉查找树,它或者是一颗空的二叉树,或者是具有下列性质的二叉树: ⑴若它的左子树不空,则左子树上的所有结点的值均小于根结点的值; ⑵若它的右子树不空,则右子树上的所有结点的值均大于根结点的值; ⑶它的左右子树也都是二叉排序树。 从上述定义可以看出,二叉排序树是记录之间满足一定次序关系的二叉树,中序遍历二叉排序树可以得到一个关键码有序的序列,这也是二叉排序树的名称的由来。 二叉排序树通常采用二叉链表进行存储,其结点的结构可复用二叉链表的结点结构,如下列算法: class BiSortTree { public: BiSortTree(int a[ ],int n); ~BiSortTree(); void InsertBSF(BiNode void DeleteBSF(BiNode BiNode private: BiNode }; ⒈二叉排序树的插入 根据二叉排序树的定义,想二叉排序树中插入一个结点s的过程用伪代码描述为: ⒈若root为空树,则将结点s作为根节点的插入; ⒉否则,若s->data小于root->data,则把结点s插入到root的左子树中; ⒊否则把结点s插入到root右子树中; 二叉排序树插入算法InsertBST void BiSortTree::InsertBST(BiNode { if(root==NULL)root=s; else if (s->data else InsertBSF(root->rchild,s); } ⒉二叉排序树的构造 构造二叉排序树的过程是从空的二叉排序树开始,以此插入一个个结点。 构造一棵二叉排序树的算法通过不断的调用插入结点的算法二进行。设查找集合中的记录存放在数组r[n]中,二叉排序树的构造算法用伪代码表示为: ⒈依次取每一个记录r[i],执行下述操作: 1.1申请一个数据域为r[i]的结点s,令结点s的左右指针域为空; 1.2调用算法InsertBSF,将结点s插入到二叉排序树中; 二叉排序树构造函数算法BiSortTree BiSortTree::BiSortTree(int r[ ], int n) { for (i=0; i { s=new BiNode; s->data=r[i]; s->lchild=s->rchild=NULL; InsertBSF(root, s); } } 在找到插入位置后,向二叉排序树中插入结点的操作只是修改相应的指针,而寻找插入位置的比较次数不超过树的深度,所有二叉排序树具有较高的插入效率。 ⒊二叉排序树的删除 二叉排序树的删除操作比插入操作要复杂一些。首先,从二叉排序树中删除一个结点之后,要仍然保持二叉排序树的特性;其次,由于被插入的结点都是作为叶子结点链接到二叉排序树上。因而不会破坏结点之间的链接关系。而删除结点则不同,被删除的可能是叶子结点,也可能是分支结点,当删除分支结点时就破坏了二叉排序树中原有的结点之间的链接关系,需要重新修改指针,使得删除结点后仍为一棵二叉排序树。 为了删除二叉排序树中值最小的结点,首先应该找到这个结点。这只需要沿左子树下移,知道最左下结点,这样就找到了值最小的结点,记作s。删除s只需要简单地把s的父结点中原来指向s的指针改为指向s的右孩子(s肯定没有左孩子,否则它就不是值最小的结点)。这样修改指针,删除了s结点,且二叉排序树的特性保持不变, 二叉排序树中删除一个结点f的左孩子结点p的算法用伪代码表示: ⒈若结点p是叶子,则直接删除结点p; ⒉若结点p只有左子树,则只需重接p的左子树; 若结点p只有右子树,则只需重接p的右子树; ⒊若结点p的左右子树均不空,则 3.1查找结点p的右子树上的最左下结点s以及结点s的双亲结点par; 3.2将结点s的数据域替换到被删结点p的数据域; 3.3若结点p的右孩子无左子树,则将s的右子树接到par的右子树上; 否则,将s的右子树接到结点par的左子树上; 3.4 删除结点s; 二叉排序树的删除算法DeleteBSF void BiSortTree::DeleteBSF(BiNode { 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->rchild; 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的左子树上查找; ⑷若k大于root->data,则在root的右子树上查找; 上述过程一直持续到k被找到或者待查到的子树为空。如果待查找的子树为空,则查找失败。当查找失败时,恰好找到了以k为键值的新结点在二叉排序树中的插入位置。二叉排序树的查找效率就在于只需要查找两个子树之一。 二叉排序树查找算法SearchBSF BiNode { if (root==NULL) return NULL; else if(root->data==k) return root; else if (root->data else return SearchBSF(root->rchild,k); } 在二叉排序树上查找关键码等于给定值的结点的过程,恰好走了一条从根节点到该结点的路径,和给定值的比较次数等于给定值结点在二叉排序树中的层数,比较次数最少为1次(即整个二叉排序树的根节点就是待查结点),最多不差过树的深度。 7.3.2平衡二叉树 平衡二叉树 平衡二叉树或者是一棵空的二叉排序树,或者是具有以下列性质的二叉排序树: ⑴根节点的左子树和右子树的深度最多相差1. ⑵根节点的左子树和右子树也就是平衡二叉树。 平衡因子 结点的平衡因子就是该结点的左子树的深度与右子树的深度之差。 最小不平衡子树 最小不平衡子树是指在平衡二叉树的构造过程中,以距离插入结点最近的、且平衡因子的绝对值大于1的结点为根的字数。 构造平衡二叉树的基本思想:在构造二叉排序树的过程中,每当插入一个结点时,首先检查是否插入而破坏了树的平衡性。若是,则找出最小不平衡的子树。在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,使之成为新的平衡子树。 一般情况下,设结点A为最小不平衡子树的根节点,对该子树进行平衡化调整归纳起来有以下四种情况: ⑴LL型 ⑵RR型 ⑶LR型调整 ⑷RL型调整 7.4 散列表的查找技术 7.4.1 概述 所谓查找实际上就是要确定关键码等于给定值的记录在查找结构中的存储位置。 采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为散列表,将关键码映射为散列表中适当存储位置的函数称为散列函数,所得的存储位置称为散列地址。 具体地,散列过程为: (1) 存储记录时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录; (2) 查找记录时,通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。 在大多数情况下,往往会出现这样的情况:对于两个不同的关键码k1≠k2,有H(k1) =H(k2),即两个不同的记录需要存放在同一个存储位置中,这种现象称为冲突,此时,k1和k2相对于H称作同义词。 采用散列技术需要考虑的两个主要问题是: (1) 散列函数的设计。 (2) 冲突的处理。 7.4.2 散列函数的设计 设计散列函数一般应遵循以下基本原则: (1) 计算简单。 (2) 函数值即散列地址分布均匀。 在设计散列函数时,要根据具体情况,选择一个比较合理的方案。下面介绍几种常见的散列函数。 1. 直接地址法 直接地址法的散列函数是关键码的线性函数,即: H(key)=a×key+b (a,b为常数) 直接地址法的特点是单调,均匀,不会产生冲突,但实际中能使用这种散列函数的情况很少。它适用于事先知道关键码的分布,且关键码集合不是很大而连续性较好的情况下。 2. 除留余数法 除留余数法的基本思想是:选择某个适当的正整数P,以关键码除以p的余数作为散列地址,即: H(key)=key mod p 除留余数法是一种最简单,最常用的构造散列函数的方法,并且这种方法不要求事先知道关键码的分布。 3. 数字分析法 数字分析法根据关键码在各个位上的分布情况,选取分布比较均匀的若干位组成散列地址。 数字分析法适合于事先知道关键码的分布且关键码中有若干位分布较均匀的情况。 4. 平方取中法 平方取中法是对关键码平方后,按散列表大小,取中间的若干位作为散列地址(平方后截取)。 平方取中法通常用在事先不知道关键码的分布且关键码的位数不是很大的情况下,比如有些编译器对标识符的管理采用的就是这种方法。 5. 折叠法 折叠法是将关键码从左到右分割成位数相等的几部分,最后一部分位数可以短些,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。通常有两种叠加方法: (1) 移位叠加:将各部分的最后一位对齐相加。 (2) 间界相加:从一端向另一端各部分分界来回折叠后,最后一位对齐相加。 折叠法适合于关键码的位数很多,且关键码的每一位分布都不均匀的情况。折叠法事先不需要知道关键码的分布。 7.4.3 处理冲突的方法 采用不同的处理冲突方法可以得到不同的散列表。下面介绍几种常用的处理冲突方法。 1. 开放定址法 用开放定址法处理冲突得到的散列表叫做闭散列表。 所谓开放定址法,就是由关键码得到的散列地址一旦产生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。 找下一个空散列地址的方法很多,下面介绍三中: (1) 线性探测法 当发生冲突时,线性探测法从冲突位置的下一个位置起,依次寻找空的散列地址,即对于键值key,设H(key)=d,闭散列表的长度为m,则发生冲突时,寻找下一个散列地址的公式为: Hi=(H(key)+di)%m (di=1,2,3,…..,m-1)。 设散列表表长为m,在线性探测法构造的闭散列表ht[m]上进行动态查找的算法用伪代码描述如下: 1. 计算散列地址j; 2. 若ht[j]等于k,则查找成功,返回记录在散列表中的下标;否则 3. 若ht[j]为空或整个散列表探测一遍,则查找失败,转4; 否则,j指向下一个单元,转2; 4. 若整个散列表探测一遍,则表满,抛出溢出异常;否则,将待查值插入; 下面给出闭散列表查找算法的C++描述。 闭散列表查找算法 HashSearch1 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;} } 当从闭散列表中删除一个记录时,有两点需要考虑: (1) 删除一个记录一定不能影响以后的查找; (2) 删除记录后的存储单元应该能够为将来的插入使用。 (2)二次探测法 当发生冲突时,二次探测法寻找下一个散列地址的公式为: Hi=(H(key)+di)%m (di=12,-12,22,-22,….,q2,-q2且q≤根号m) (3)随机探测法 当发生冲突时,随机探测法探测下一个散列地址的位移量是一个随机数列,即寻找下一个散列地址的公式为: Hi=(H(key)+di)%m (di是一个随机数列,i=1,2, ….,m-1) 2. 拉链法(链地址法) 用拉链法处理冲突构造的散列表叫做开散列表。 在开散列表中进行动态查找的算法用伪代码描述如下: 1.计算散列地址j; 2.在第j个同义词子表中顺序查找; 3.若查找成功,则返回结点的地址; 否则,将待查记录插在第j个同义词子表的表头。 开散列表的查找算法 HashSearch2 Node { 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;q->data=k; q->next=ht[j];ht[j]=q; } } 在用拉链法处理冲突的散列表中删除一个记录,只需在相应单链表中删除一个结点。 7.4.4 散列查找的性能分析 在查找过程中,关键码的比较次数取决于产生冲突的概率。产生的冲突越多,查找效率就越低。影响冲突产生的概率有以下三个因素。 (1)散列函数是否均匀。 (2)处理冲突的方法。 (3)散列表的装填因子。 在很多情况下,散列表的空间都比查找集合打,此时虽然浪费了一定的空间,但换来的是查找效率。 7.4.5 开散列表与闭散列表的比较 开散列表与闭散列表的比较类似于单链表与顺序表的比较。 由于开散列表中各同义词子表的表长是动态变化的,无须事先确定表的容量(开散列表由此得名);而闭散列表却必须事先估计容量。因此,开散列表更适合于事先难以估计容量的场合。 思想火花——把注意力集中于主要因素,不要纠缠于噪声