620 likes | 818 Views
第七章 查找 第一节 基本概念与术语 第二节 静态查找表 第三节 动态查找表 第四节 哈希表查找 本 章 小 结 实 训 思 考 与 习 题. 第七章 查找. 学习要求: 通过本章的教学,读者应熟悉各种查找算法的思路、算法及性能分析,以灵活应用于各种实际问题中。 主要内容: 本章将要学习有关查找的基本概念、几种常用的查找方法,并对其进行性能分析。
E N D
第七章 查找 第一节 基本概念与术语 第二节 静态查找表 第三节 动态查找表 第四节 哈希表查找 本 章 小 结 实 训 思 考 与 习 题
第七章 查找 学习要求: 通过本章的教学,读者应熟悉各种查找算法的思路、算法及性能分析,以灵活应用于各种实际问题中。 主要内容: 本章将要学习有关查找的基本概念、几种常用的查找方法,并对其进行性能分析。 查找是计算机应用中最常用的操作。据统计,商业计算机应用系统花费在这方面的计算时间超过25%。因此,查找算法的优劣对系统的运行效率影响极大。本章讨论有关查找的若干算法,并通过对它们的分析来比较各种查找方法的优劣。
第一节 基本概念与术语 查找(search)又称检索,就是确定一个已给的数据是否出现在某个数据表中。例如,学生花名册中存放着全体学生记录,每个记录包括学生的学号、姓名、性别、出生年月、住址、联系电话等数据信息,要求按学号或姓名查询学生的有关信息;又如,在电话簿中查寻某单位或某人的电话号码,就是数据查找问题,亦称查找表。 查询通常是在文件中进行的,文件是指由相同类型的记录组成的集合。一般的,每个记录(record)都由若干个数据项组成,通常把表中能唯一标识一个记录的最小数据项组合称为关键字(key),例如在学生花名册中学号是没有重复的,能唯一确定一个学生记录,学号这个数据项在表中就是关键字。然而,有时为了检索具有某种性质的记录,需要按指定的数据项(组)值进行检索,该数据项(组)值并不能唯一确定一个记录,为区分起见,我们称这样的数据项(组)为次关键字或属性域。例如职工花名册中的职工号、职工姓名、年龄、性别、籍贯、住址等都是数据项域。其中,职工号为主关键字,职工姓名也可作为主关键字,但必须假设没有同名、
同姓的职工。而年龄就不会是主关键字,因为相同年龄的职工记录会有很多。性别、籍贯也是如此。当需要查找全体女职工记录时,就是把性别作为一个次关键字进行检索,但一般在查表中统称检索项为关键字。同姓的职工。而年龄就不会是主关键字,因为相同年龄的职工记录会有很多。性别、籍贯也是如此。当需要查找全体女职工记录时,就是把性别作为一个次关键字进行检索,但一般在查表中统称检索项为关键字。 数据查找问题的一般提法是:设文件或表有几个具有相同类型的记录r1,r2,…,rn,每个记录由一个称为关键字的数据项和若干相关数据项的值组成,对给定值k,要求查找这几个记录中是否存在关键字值等于k的某个记录。查找的结果有两种:一种是查到该记录,则查找成功;另一种是查找不到,给出查找不成功的信息,或者将给定值作为关键字,再将此记录插入到文件中。 在计算机中,查找主要分静态查找、动态查找和哈希查找。静态查找主要是指静态查询某个“特定的”数据元素是否在查找表中,或检索某个“特定的”数据元素的各种属性;动态查找是指在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已存在的某个数据元素;哈希查找则利用哈希函数,通过计算求取待查元素的存储地址。
讨论各种查找算法时,自然要以算法的效率和存储的开销来衡量算法的优劣。然而,效率和存储开销常常是相互制约的,两者很难兼顾。效率也只是相对的。对于查找算法,常把对关键字的最多比较次数和平均比较次数作为两个基本的技术指标。前者叫做最大查找长度(maximum search length),简记MSL。后者叫做平均查找长度(average search length),简记ASL。 对于n个记录进行查找,查找成功时的平均查找长度ASL可用下列式子表示: 其中,ci为查找第i个记录所需的比较次数,pi为查找第i个记录的查找概率。如果每个记录的查找机会均等,则每个pi均等于1/n。 在实际应用中,大批的数据记录常以文件形式存储在像磁盘、磁带这样的外存储器中,在进行查找时,必须成组地将这些数据记录调入内存,按一定的数据结构(如数组、链表等)组织存储,然后选择合适的查找技术进行查找。
第二节 静态查找表 在对查找表操作过程中,只做查找操作的查找表为静态查找表。静态查找表一般以顺序表或线性表表示,在不同的表示方法中,实现查找的方法也不同。 一、静态查找表结构 静态查找表是数据元素的线性表,可以是基于数组的顺序存储或以线性链表存储。一般采用顺序存储结构表示静态查找表。 静态查找表的顺序存储结构可定义如下: typedef struct { ElemType *elem;/* 查找表存储空间的基地址 */ intlength;/* 表长度 */ } Sstable; 静态查找表的链式存储结构其结点结构的类型定义如下: typedef struct NODE { ElemType elem;/* 结点的值域 */ struct NODE *next;/* 下一个结点指针域 */ } NodeType;
二、顺序表的查找 顺序表是日常生活中常见的数据结构。对它的查找也是常见的操作。记录的逻辑顺序与其在计算机存储器中的顺序一致的表称为顺序表。顺序表的查找又称为线性查找,它是一种最简单、最基本的查找方法。 查找的基本思想是:从表的一端开始,顺序扫描线性表,依次将扫描到的记录关键字和给定值k相比较。若当前扫描到的记录关键字与k相等,则查找成功,返回记录在表中的位置序号;若扫描结束后,仍未找到关键字等于k的记录,则查找失败,返回0。 1具体算法 【算法7.1】 顺序查找算法。 SeqSearch(Sstable ST[ ],ElemType k) { //在顺序表ST [1..n]中顺序查找关键字为k的记录 //成功时返回找到的记录位置,失败时返回0 int i; ST.elem[0].key=k; //监视哨 for (i=ST.length;ST.elem[i].keyk;i--); //从表后往前找 return i; //若i为0,表示查找失败,否则ST [i]即是要找的记录 } //SeqSearch
2算法分析 (1)算法中监视哨ST.elem[0].key的作用 为了在for循环中省去判定防止下标越界的条件i≥1,从而节省进行比较的时间。 (2)成功时顺序查找的平均查找长度 在等概率情况下,pi=1/n(1≤i≤n),故成功的平均查找长度为 (n+…+2+1)/n=(n+1)/2 即查找成功时的平均比较次数约为表长的一半。 若k值不在表中,则需进行n+1次比较之后才能确定查找失败。 从分析中可以推出,顺序表查找的最大查找长度和平均查找长度的数量级(即算法的时间复杂度)均为O(n)。 (3)顺序查找的优点 算法简单,且对表的结构无任何要求,无论是用向量还是用链表来存放结点,也无论结点之间是否按关键字有序,它都同样适用。
(4)顺序查找的缺点 查找效率低,因此,当n较大时不宜采用顺序查找。 三、有序表的查找 以有序表表示静态查找表时,可用折半查找(binary search)(又称二分查找)来实现。有序表就是指各个记录的次序是按其关键字值的大小顺序排列的。 算法思路:折半查找不像顺序查找那样从第1个记录开始逐个顺序搜索,而是每次把要找的给定值k与有序表中的中间位置的记录关键字值进行比较。中间位置记录的序号为m=(n+1)/2,相应记录的关键字值为rm.key。将给定值k与rm.key进行比较,若k=rm.key,则查找成功,返回该元素下标m,结束查找;若k<rm.key,由于各记录的关键字值是由小到大排列的,因此要找的记录存在的话,必定在左半部分,接着只要在左半部分继续使用折半查找即可;若k>rm.key,类似k<rm.key情形,要找的记录如果存在的话,必定在右半部分,只要继续对右半部分使用折半查找即可。经过一次关键字比较,查找空间就缩小一半,重复下去,查找空间不断缩小,当最后只剩下一个记录,而且此记录不是要找的记录时,则宣告查找失败。
【例7.1】 假定有一组记录的关键字值为:{18,20,30, 35, 52, 65, 80,90},若用整型变量low,m,high分 别表示被查区间的第1个、中间一个和最后一个记录的下标。假设要查找k=65的记录,则折半查找过程如下: 开始时有low=1,high=8,m=(1+8)/2=4,第一个、中间一个和最后一个记录的关键字值分别为r1.key,r4.key和r8.key。即: 18 20 30 35 52 65 80 90 ↑ ↑ ↑ low m high 因k=65>35,下一步继续到右半部分查找,即: 18 20 30 35 52 65 80 90 ↑ ↑ ↑ low m high 此时,low=m+1=5,high=8,m=(5+8)/2=6。由于k=65=rm.key,查找成功,所找到的记录序号为6。 假设现在查找k=25的记录,则折半查找过程如下:
18 20 30 35 52 65 80 90 ↑ ↑ ↑ low m high 因low=1,high=8,m=(1+8)/2=4,k=25<35,下一步到左半部分去找,即: 18 20 30 35 52 65 80 90 ↑ ↑ ↑ low m high 因low=1,high=3,m=(1+3)/2=2,k=25>20,下一步转到右半部分去找。此时,low,m,high均指向30,即: 18 20 30 35 52 65 80 90 ↑ low m high 因k≠30,则查找失败。 折半查找算法如下: 【算法7.2】 折半查找算法。
int Bin_Search(Sstable r, int n, ElemType k) { /*在有序表r中折半查找关键字等于k的元素*/ low=1; high=n; while (low<=high) { mid=(low+high)/2; if (k==r[mid].key) return mid; else if (k<r[mid],key) high=mid-1; else low=mid+1; } return -1; } /* Bin_search */ 算法分析: 折半查找过程可用一棵二叉树来描述,树中的每个根结点对应当前查找区间的中点记录,它的左子树和右子树分别对应该区间的左子表和右子表,树中每个结点表示一个记录,结点中的值为对应记录的“位置”序号。附加的带箭头虚线表示查找一个记录的路径。通常把此二叉树称为折半查找的判定树。图7.1给出了例7.1的折半查找的二叉树及查找关键字为65的记录的路径。
折半查找的过程实际上是从判定树的根结点开始到该记录结点的查找过程,对应着二叉树中从根结点到待查结点的一条路径,而同关键字进行比较的次数就等于待查结点所处的层数。我们借助二叉判定树,可以求得折半查找的平均查找长度。假设表长为n,树深为h=log2(n+1),所以平均查找长度为:折半查找的过程实际上是从判定树的根结点开始到该记录结点的查找过程,对应着二叉树中从根结点到待查结点的一条路径,而同关键字进行比较的次数就等于待查结点所处的层数。我们借助二叉判定树,可以求得折半查找的平均查找长度。假设表长为n,树深为h=log2(n+1),所以平均查找长度为: 图7.1 折半查找的判定树
ASL=(n+1)/nlog2(n+1)-1(按层深统计比较次数) 展开有 nASL=1+2×2+3×22+…+h×2h-1 ① 2nASL=2+2×22+3×23+…+h×2h ② ②式-①式,得到: nASL=h·2h-(1+2+22+…+2h-1)=2h·(h-1)+1 在满二叉树时,n=2h-1,h=log2(n+1) 最后得出: ASL=(n+1)/nlog2(n+1)-1 n较大时(n>50)则可简化为:ASL=log2(n+1)-1,所以折半查找算法的时间复杂度为O(log2n)。可见折半查找的效率比顺序查找高得多。例如,当n=1000个记录,顺序查找的ASL=500,而折半查找的ASL=9。但折半查找只适用于有序表,且限于顺序存储结构。
四、索引顺序表的查找 索引顺序表查找(indexed sequential search)又称分块查找。这是顺序表查找的一种改进方法,它是以索引顺序表表示的静态查找表。用此方法查找,在查找表上需建立一个索引表,图7.2是一个索引顺序表的示例。 图7.2 索引顺序表结构示意图 顺序表中的19个记录可分成三个子表(r1,r2,…,r6), (r7, r8,…,r12),(r13,r14,…,r19),每个子表为一块。索引表中由若干个表项组成,每个表项包括两部分内容:关键字项和指针项。关键字项中存放对应块中的最大关键字,指针项中存放对应块中第一个记录在查找表中的位置序号。查找表可以是有序表,也可以是分块有序。分块有序是指第
二个子表块中所有记录的关键字均大于第一子表块中的最大关键字,第三子表块中的所有记录的关键字均大于第二子表块中的最大关键字,依此类推。所以索引表一定是按关键字项递增有序排列的。二个子表块中所有记录的关键字均大于第一子表块中的最大关键字,第三子表块中的所有记录的关键字均大于第二子表块中的最大关键字,依此类推。所以索引表一定是按关键字项递增有序排列的。 分块查找的基本思想是:首先用给定值k在索引表中查找,因为索引表是按关键字项递增有序排列的,可采用折半查找或顺序查找以确定待查记录在哪一块中,然后在已确定的块中进行顺序查找。当查找表是有序表时,在块中也可用折半查找。对应图7.2,如果给定值k=38,先将k和索引表各关键字进行比较,因为22<k<48,则关键字为38的记录如果存在,必定在第二个子表中,再从第二个子表的第一个记录的位置序号7开始,按记录顺序查找,直到确定第10个记录就是要找的记录。又如当k=29时,则仍在第二子表中查找,自第7个记录起按记录顺序查找至第12个记录,每个记录的关键字和k比较都不相等,则查找不成功。 说明:由于索引表是有序的,所以在索引表上既可以采用顺序查找,也可以采用折半查找。而每个块中的记录排列是无序的,所以在块内只能采用顺序查找。
如果在索引表中确定块和在块中查找记录都采用顺序查找,则分块查找成功的平均查找长度由两部分组成:如果在索引表中确定块和在块中查找记录都采用顺序查找,则分块查找成功的平均查找长度由两部分组成: ASLbs=Lb+Lw 其中,Lb是在索引表中确定块的平均查找长度,Lw是在对应块中找到记录的平均查找长度。一般情况下,假设有n个记录的查找表可均匀地分成b块,则每块含有s个记录,s=n/b,b就是索引表中表项的数目。又假定表中每个记录的查找概率相等,则等概率下分块查找成功的平均查找长度是: ASLbs=(b+1)/2+(s+1)/2=1/2(n/s+s)+1 可见,平均查找长度和表中记录的个数有关,而且和每一块中的记录个数s也有关。对于表长n确定的情况下,s取n时,ASLbs=n+1达到最小值。它是一种效率介于顺序查找和折半查找之间的查找方法。
第三节 动态查找表 静态查找表一旦生成之后,所含记录在查找过程中一般是固定不变的。而动态查找表则不然,会对表中记录经常进行插入和删除操作,所以动态查找表是一直在变化的。动态查找表的这种特性要求采用灵活的存储方法来组织查找表中的记录,以便高效率地实现动态查找表的查找、插入、删除等操作。动态查找表的特点是:表结构本身是在查找中动态生成的,即对于给定值k,若表中存在其关键字等于k的记录,则查找成功,否则插入关键字等于k的记录。动态查找表往往采用树形存储结构。 一、二叉排序树 (一) 二叉排序树的定义 二叉排序树(binary sort tree)又称二叉查找(搜索)树(binary search tree)。其定义为:二叉排序树或者是空树,或者是满足如下性质的二叉树:
1若它的左子树非空,则左子树上所有结点的值均小于根结点的值。 2若它的右子树非空,则右子树上所有结点的值均大于根结点的值。 3左、右子树本身又各是一棵二叉排序树。 上述性质简称二叉排序树性质(BST性质),故二叉排序树实际上是满足BST性质的二叉树。 可以将二叉排序树作为表的一种组织方式,即按二叉树的结构来组织各个记录的关键字值,使树中任意非叶子结点的关键字值大于其左子树所有结点的关键字值(假如左子树存在),而小于其右子树所有结点的关键字值(假如右子树存在)。例如图7.3所示的就是两棵二叉排序树。
图7.3 二叉排序树示例 (二) 二叉排序树的特点 由BST性质可得二叉排序树的特点是: 1二叉排序树中任一结点x,其左(右)子树中任一结点y(若存在)的关键字必小(大)于x的关键字。 2二叉排序树中,各结点关键字是唯一的。 3对二叉排序树进行中序遍历,可得到一个由小到大的有序序列。 因此,用二叉排序树组织数据既可以维持数据的有序性,便于查询,又可以方便地进行插入或删除操作。
(三) 二叉排序树查找算法 二叉排序树的查找十分方便,它明显优于一般二叉树的查找。对于一般的二叉树,按给定关键字值查找树中结点时,一般从根结点开始,按先序遍历或中序遍历或后序遍历的方法查找树中结点,直到找到该结点为止。当树中不存在该结点时,必须遍历完树中的所有结点,才能得出查找失败的结论。对于二叉排序树来讲,按给定关键字值查找树中结点时,首先将给定值与根结点比较,若相等,则查找成功,否则将依据给定值和根结点的关键字之间的大小关系,分别在左子树或右子树上继续进行查找。也就是说,每次只需查找左子树或右子树中的一枝便可。其效率明显提高。 二叉排序树的查找过程可描述为: 将给定值和二叉排序树的根结点的关键字进行比较: 1给定值等于根结点的关键字,则根结点就是要查找的结点。
2给定值大于根结点的关键字,则继续在根结点的右子树中查找。2给定值大于根结点的关键字,则继续在根结点的右子树中查找。 3给定值小于根结点的关键字,则继续在根结点的左子树中查找。 4在子树中的查找过程和前面的步骤(1),(2),(3)相同。 二叉排序树存储结构可描述如下: typedef struct BTnode { keytype key; /*关键字域类型*/ struct BTnode*lchild, *rchild; /*左、右子树指针域*/ } BTNode; /*二叉排序树结点类型*/ 假定二叉排序树的根结点指针为t,给定的关键字值为k,则查找算法如下: 【算法7.3】 二叉排序树的查找算法。 #define NODELEN sizeof(BTNode) BTNode *f; BTNode *BST_Search(BTNode *t, keytype k) { /*在根结点为t的二叉排序树中查找关键字等于k的结点*/
BTNode *p; p=t; f=NULL; /*指向p的双亲*/ while (pNULL) { if (k=p→key) return p; / *查找结束*/ else if (k<p→key){ f=p;p=p→lchild; } /*在左子树中继续查找*/ else { f=p; p=p→rchild;} /*在右子树中继续查找*/ } return NULL; /*查找失败*/ } /*BST_Search*/ 上述算法执行后,若查找成功,则返回所找到结点的指针值;若查找失败,则返回空指针。 算法分析:不难看出,在二叉排序树上查找关键字值等于给定值的结点的过程,恰是走了一条从根结点到该结点的路径。因此,与折半查找类似,和关键字比较次数不超过该二叉树的深度。深度为i的结点,查找成功时所需比较次数为i。因此,对于深度为d的二叉排序树,若设第i层有ni个结点(1<i<d),则在同等查找概率的情况下,其平均查找长度为: 其中,n=n1+n2+…+nd为二叉树的结点数。
显然,在二叉排序树上进行查找时,平均查找长度和二叉排序树的形态有关。这也是与折半查找不同的地方。折半查找长度为n的表的判定树是唯一的,而含有n个结点的二叉排序树却不是唯一的。在最坏的情况下,二叉排序树是通过把一个有序表的n个结点依次插入而生成的,此时所得到的二叉排序树蜕化成一棵深度为n的单支树,即每层仅有一个结点,这时ASL值达到最大,即:显然,在二叉排序树上进行查找时,平均查找长度和二叉排序树的形态有关。这也是与折半查找不同的地方。折半查找长度为n的表的判定树是唯一的,而含有n个结点的二叉排序树却不是唯一的。在最坏的情况下,二叉排序树是通过把一个有序表的n个结点依次插入而生成的,此时所得到的二叉排序树蜕化成一棵深度为n的单支树,即每层仅有一个结点,这时ASL值达到最大,即: 最好的情况是,二叉排序树在生成的过程中,生成的树的形状比较均匀,与折半查找的判定树相似,即除去最低层的叶子结点外,每个结点均有两个儿子,这时ASL值达到最小,即: ASL=1/n(1+2×2+3×22+…+(d-1)×2d-2+d×L) 其中,L为最低层的叶子结点个数,1≤L≤2d-1,n= 2d-1-1+L。 例如图7.4(a)和(b)中所示的二叉排序树中结点的值都相同,但两棵树的深度不一样,分别用ASL(a)和ASL(b)来表示其二叉排序树的ASL。即:
图7.4 不同形态的二叉排序树 ASL(a)=(6+1)/2=3.5 ASL(b)=(1+2*2+3*2+4*1)/6=2.5 不难证明,二叉排序树的平均查找长度为O(log2n),与折半查找一样,因此两者在查找速度上相差不大。但在二叉排序树上做插入和删除结点却十分方便,因此对于需要经常做插入、删除和查找运算的表,以采用二叉排序树为宜。
(四)二叉排序树插入操作和构造一棵二叉排序树 先讨论向二叉排序树中插入一个结点的过程:设待插入结点的关键字值为k,为将其插入,先要在二叉排序树中查找要插入的位置,找到位置将其插入。若查找成功,按二叉排序树定义,待插入结点已存在,不用插入;查找不成功时,则插入之。因此,新插入结点一定是作为叶子结点添加上去的,并且是查找路径上访问的最后一个结点的左孩子或右孩子结点,使之仍然构成一棵二叉排序树。对应的算法如下: 【算法7.4】 二叉排序树的插入算法。 BTNode *BST_Insert(BTNode *t, keytype k) { /*在二叉排序树t中查找关键字等于k的结点,查找失败时将k结点插入。全局变 量f 指向待插结点的双亲*/ BTNode *p, *s; p=BST_Search(t,k); if (!p) { s=(BTNode*) malloc (NODELEN); s→key=k;
s→lchild=NULL; s→rchild=MULL; if (t==NULL) t=s; /*生成根结点*/ else if (k<f→key) f→lchild=s; else f→rchild=s; } return; } /*BST_Insert*/ 若二叉排序树为空树时,经过一系列的查找、插入操作,可构造生成一棵二叉排序树,所以以上算法同时含有建立和插入两项功能。 【例7.2】 设有一组结点的关键字输入次序为(20,5, 25, 10,15,16,13,23,3,7,27),按上述算法生成二叉排序树的过程如图7.5(a)所示。
图7.5(a) 序列为(20,5,25,10,15,16,13,23,3,7,27)的二叉排序树生成过程
插入是从根结点开始逐层向下查找插入位置,最终总是将待插结点作为叶子结点插入二叉排序树。当建立二叉排序树时,若结点插入的先后次序不同,则所构成的二叉排序树的形态及深度也不同。如果上面例子中这组结点的关键字的输入次序改变为(13,23,16,20,5,10,25,7,27,3,15),则按算法生成的二叉排序树如图7.5(b)所示。插入是从根结点开始逐层向下查找插入位置,最终总是将待插结点作为叶子结点插入二叉排序树。当建立二叉排序树时,若结点插入的先后次序不同,则所构成的二叉排序树的形态及深度也不同。如果上面例子中这组结点的关键字的输入次序改变为(13,23,16,20,5,10,25,7,27,3,15),则按算法生成的二叉排序树如图7.5(b)所示。 图7.5(b) 序列为(13,23,16,20,5,10,25,7,27,3,15)对应生成的二叉排序树 所以含有n个结点的二叉排序树的形态并不唯一,但它们按中序方式遍历访问二叉排序树时,其中序遍历的结果是唯一的,都是关键字值递增的有序序列。
(五)二叉排序树删除操作 从二叉排序树中删除一个结点之后,要保证删除后所得二叉树仍是一棵二叉排序树。 删除操作首先是进行查找,确定被删除结点是否在二叉排序树中。假设被删结点为p指针所指结点,其双亲结点为f指针所指结点,被删结点的左子树和右子树分别用PL和PR表示。 下面分几种情况讨论如何删除该结点: 1若被删除结点是叶子结点,即PL和PR均为空子树,则只需修改被删除结点的双亲结点的指针即可。如图7.6(a)和7.6(b)所示。 图7.6 删除二叉排序树中的叶子结点
2若被删除结点只有左子树PL或只有右子树PR,则此时只要令PL或PR直接成为其双亲结点的左子树或右子树即可。如图7.7(a),(b),(c),(d)所示。 图7.7 二叉排序树中被删除结点只有左子树或只有右子树的删除过程
3若被删除结点的左子树和右子树均不空时,在删除该结点前为了保持其余结点之间的序列位置相对不变,首先要用被删除结点在该树中序遍历序列中的直接前驱(或直接后继)结点的值取代被删除结点的值,然后再从二叉排序树中删除那个直接前驱(或直接后继)结点。过程描述如下: (1)被删除结点在中序遍历序列中的直接前驱是从该结点的左孩子的右孩子方向一直找下去,找到没有右孩子的结点为止。被删除结点的中序直接前驱结点肯定是没有右子树的。 (2)将直接前驱结点取代被删除结点。 (3)删除直接前驱结点。注意:该直接前驱结点一定是无右子树的结点。如图7.8所示。对于第3种情况,还可以用其他方法来实现删除,这里不再一一说明。
二、平衡二叉树 从上节的讨论可知,二叉排序树的查找效率与二叉排序树的结点插入次序有关。但是,结点插入的先后次序是不确定的。这就要求我们找到一种动态平衡的方法,对于任意给定的关键字序列都能构造一棵形态均匀的二叉排序树。 图7.8 二叉排序树中被删除结点既有左子树又有右子树时的删除过程
(一)平衡二叉树的定义 1结点的平衡因子BF(balanced factor) 该结点的平衡因子等于左子树的深度减去右子树的深度。 2平衡二叉树 平衡二叉树或者是一棵空树,或者是每个结点的平衡因子绝对值不大于1的树。显然,平衡二叉树上所有结点的平衡因子只可能是-1,0和1, 如图7.9所示。 (a)平衡二叉树 (b)非平衡二叉树 图7.9 平衡与非平衡二叉树
(二)平衡二叉树的构造 平衡二叉树构造的其基本思路是:在构造二叉排序树的过程中,每当插入一个结点时,先检查是否因插入该结点而破坏了树的平衡性。如果是,则找出其中最小不平衡子树,在保持排序树特性的前提下,调整最小不平衡树中各结点之间的连结关系,以达到新的平衡。其中最小不平衡子树是指离插入结点最近且平衡因子绝对值大于1的结点作根结点的子树。 设在插入结点的过程中,使二叉树失去平衡的最小不平衡子树的根结点为A,则失去平衡后,进行调整的规律可归纳为下列4种情况: 1 LL型平衡旋转 由于在结点A的左孩子B的左子树上插入新结点,使A的平衡因子由1增至2而失去平衡,所以此时应以B为轴心作顺时针旋转,使得结点A作为结点B的右孩子,而将B的右孩子作为A的左孩子,如图7.10。
2RR型平衡旋转 由于在结点A的右孩子B的右子树上插入新结点,使A的平衡因子由-1变为-2而失去平衡,所以此时应以B为轴心作逆时针旋转,使得结点A作为结点B的左孩子,而将B的左孩子作为A的右孩子,如图7.11。 图7.10 LL型平衡调整法
3LR型平衡旋转 由于在结点A的左孩子B的右子树C上插入新结点,使A的平衡因子由2增至3而失去平衡,所以此时首先应以B的右子树根C为轴心作逆时针旋转,使得结点B变为结点C的左孩子,而将C的左孩子作为B的右孩子;然后,再以结点C为轴心作顺时针旋转,使得结点A变为C的右孩子,而将C的右孩子作为A的左孩子,如图7.12。 图7.11 RR型平衡调整法
4RL型平衡旋转 由于在结点A的右孩子B的左子树C中插入新结点,使A的平衡因子由-2变为-3而失去平衡,所以此时应首先以B的左子树的根C为轴心作顺时针旋转,使得结点B变为结点C的右孩子,而将C的右孩子作为B的左孩子;然后,再以结点C为轴心作逆时针旋转,使得结点A变为结点C的左孩子,而将C的左孩子作为A的右孩子,如图7.13。 图7.12 LR型平衡调整法
【例7.3】 设一组记录的关键字按以下次序进行插入:(8,5,4,9,10,7,2,3),其生成及调整成平衡二叉排序树的过程如图7.14所示。 图7.13 RL型平衡调整法
LR 7 图7.14 平衡二叉树生成过程
在图7.14中,当插入关键字为7的结点后,由于离结点7最近的平衡因子为-2的祖先是根结点5,所以第一次旋转应以结点8为轴心,将结点9从结点8的右上方转到右下侧,从而结点8的右孩子是结点9,结点8成为结点5的右孩子;然后再以结点8为轴心,按RR类型进行转换。这种插入与调整平衡方法的算法和程序,这里就不作介绍了。在图7.14中,当插入关键字为7的结点后,由于离结点7最近的平衡因子为-2的祖先是根结点5,所以第一次旋转应以结点8为轴心,将结点9从结点8的右上方转到右下侧,从而结点8的右孩子是结点9,结点8成为结点5的右孩子;然后再以结点8为轴心,按RR类型进行转换。这种插入与调整平衡方法的算法和程序,这里就不作介绍了。
第四节 哈希表查找 在前面几节所讨论的各种查找算法中,由于记录在表中的相对存储位置(地址)是随机的,与记录的关键字之间没有直接联系,因而必须通过对所给关键字值进行一系列比较,才能确定被找记录在表中的位置,其查找时间与表的长度有关,查找效率依赖于查找过程中所进行的比较次数。本节要介绍的哈希表查找技术,是基于建立从关键字到记录存储地址之间的函数(映射)关系来进行查找,因而是另一类不同的查找方法。 一、哈希表的定义 哈希查找因使用哈希(Hash)函数而得名,哈希函数又叫散列函数。散列同顺序、链表和索引一样,是存储线性表的又一种方法。哈希函数是一种能把关键字映射成记录存储地址的函数:假定数组HT[0…m-1]为存储记录的地址空间,m为表长。哈希函数H以记录的关键字k为自变量,计算出对应的函数值H(k),并以它作为关键字k所标识的记录在表HT中的(相对)地址或索引号,这样产生的记录表HT叫做对应于哈希函
数H的哈希表。简言之,在哈希表中,关键字为k的记录,存储在HT[H(k)]位置。习惯上,把哈希函数值H(k)称为k的哈希地址或散列地址。数H的哈希表。简言之,在哈希表中,关键字为k的记录,存储在HT[H(k)]位置。习惯上,把哈希函数值H(k)称为k的哈希地址或散列地址。 【例7.4】 一张BASIC语言语句符号表(语句定义符),按哈希方法组织记录存储,先要设定一个长度为m的哈希表HT,然后构造哈希函数H,按照关键字值k计算出各个记录的哈希地址H(k),并将这些记录存储到HT[H(k)]中去。 假设取关键字的首字母在英文字母中的序号作为哈希地址,即: H(k)=Ord(ch)-Ord('A')+1 其中ch是关键字值k的首字母。构造过程如图7.15所示。 根据计算得到的哈希地址,可将各个记录存储到哈希表中相应位置。以后,若要访问某个记录,只要重新计算H(k),得到哈希地址后,便可直接到该位置去存储即可。然而,问题并非总是如此简单,假定在上面的记录中再增加记录IF,结果可以发现:H(IF)=9。因为HT[9]中已有INPUT,因此记录IF不能再存到哈希表中去了,否则会将原来存储的记录冲掉。这种现象称为冲突或碰撞,即不同的关键字值,具有相同的哈希地址。冲突是很难避免的,问题在于一旦发生了冲突应如何处理。关于冲突处理我们将在后面的7.4.3节中详细讨论。
二、哈希函数的构造方法 构造哈希函数的方法很多,这里只介绍一些常用的计算简便的方法。 图7.15 哈希函数及哈希表的构造示意图
(一)直接定址法 直接定址法是以关键字k本身或关键字加上某个常量c作为哈希地址的方法。其对应的哈希函数H(k)为: H(k)=k+c 若c为0,则哈希地址就是关键字本身。 这种方法简单明了,也没有冲突发生,但它只适应于关键字的分布基本连续的情况。若关键字分布不连续,即空号较多,将造成存储空间的浪费。 (二)平方取中法 平方取中法是取关键字平方的中间几位作为哈希地址的方法,即算出关键字值的平方,再取其中若干位作为哈希函数值(哈希地址)。 【例7.5】 假定表中各关键字是由字母组成的,用二位数字的整数01~26表示对应的26个英文字母在计算机中的内部编码,则使用平方取中法计算DEYA,KBYC,AKEY,FDXZ的哈希地址可得:
(三)除留余数法 除留余数法是用模(%)运算得到的方法。设给定的关键字值为k,存储区单元数为m,则用一个小于m的质数p去除k,得到的余数为R,即: R=k%p 如果R落在存储区地址范围内,则R就取为哈希函数值(哈希地址);否则,就要再用一个线性数求出哈希函数值。 【例7.6】 有一组关键字从000001到859999,指定的存储区地址为:1000000到1005999,即m=6000,可选p=599,若要转换关键字k=172148,则有: R=172148%599=4176 因R不在指定的地址范围内,所以取哈希函数为: H(k)=1000000+R 故有:H(k)=H(172148)=1004176。 这样就把关键字k直接转换成存储地址了。
这种方法的关键是选好p,使得每个关键字通过该函数转换后映射到哈希空间上任一地址的概率相等,从而尽可能减少发生冲突的可能性。根据理论分析和试验结果,p应取小于存储区容量的素数。例如,若n=100,则p最好取23,79,97等数。又若n=1000,则p最好取123, 127,997等素数。 【例7.7】 对关键字KEYA,KEYB,AKEY和BKEY,若表的存储区为000~999,p应取小于1000的函数。如取p=997,则可得到以下结果: 关键字 k H(k)=k%997 KEYA 11052501 756 KEYB 11052502 757 AKEY 01110525 864 BKEY 02110525 873 这些结果是比较好的。所以除留余数法是经常使用的。
(四)数字分析法 数字分析法是对各个关键字的各个码位进行分析,取关键字中某些取值较分散的数字位作为哈希地址的方法。它适合于所有关键字已知的情况,便于对关键字中每一位的取值分布作出分析。 【例7.8】 给定一组关键字:k1:542482241,k2:542813678,k3:532228171,k4:542389671,k5:542541577,k6:542985376,k7:542193552。 通过分析可知,每个关键字从左到右的第1,2,3,5,8,9位取值较集中不宜作散列地址,剩余的第4,6,7位可作为哈希函数值(散列地址)。假定存储区地址为000~999,则哈希地址分别为422,836,281,396,515,953和135。由于数字分析法需预先知道各位上字符的分布情况,这就大大限制了它的实用性。 构造哈希函数除上面几种常用的方法外,还有截段法,即截取关键字中的某一段数码作为哈希函数;分段叠加法,即把关键字的机内代码分成几段再进行叠加(可以是算术加,也可以是按位加)而得到哈希函数值。
对于各种构造哈希函数的方法,很难一概而论地评价其优劣。对任何一种哈希函数都只有用实际数据去测试它的均匀性,才能做出正确的判断和结论。三、处理冲突的方法尽管我们不希望发生冲突,但实际上发生冲突是不可避免的。这里主要发生的是地址冲突和溢出。下面介绍解决这两个问题的方法。对于各种构造哈希函数的方法,很难一概而论地评价其优劣。对任何一种哈希函数都只有用实际数据去测试它的均匀性,才能做出正确的判断和结论。三、处理冲突的方法尽管我们不希望发生冲突,但实际上发生冲突是不可避免的。这里主要发生的是地址冲突和溢出。下面介绍解决这两个问题的方法。 (一)开放地址法 为了便于发现冲突和溢出,首先要对哈希表HT[0…m-1]进行初始化,置表中每个位置为NULL。NULL表示“什么也没有”。就关键字而言,它表示不属于关键字值域的一种特定的特殊符号。 前面已经指出,假定记录Ri和Rj的关键字分别为ki和kj,而有H[ki]=H[kj]=t时,则发生了冲突。如果Ri已装入HT[t]中,那么Rj就不能再装入HT[t]中,但只要HT中还有空位,总可以把Rj存入HT[t]的“下一个”空位上。寻找“下一个”空位的过程称为探测。下面介绍三种探测方法。