阅读目录
1 查找概论
只要你打开电脑,就会涉及到査找技术。如炒股软件中查股票信息、硬盘文件中找照片等,都要涉及到查找。所有这些需要被査的数据所在的集合,我们给它一个统称叫查找表。
查找表(Search Table):是由同一类型的数据元素(或记录)组成的集合。
关键字(Key):是数据元素中某个数据项的值,又称为键值。它可以标识一个数据元素,也可以标识一个记录的某个数据项(字段),我们称为关键码。
若此关键字可以唯一地标识一个记录,则称此关键字为主关键字(Primary Key)。这也就意味着,对不同的记录,其主关键字均不相同。主关键字所在的数据项称为主关键码。
对于那些可以识别多个数据元素(或记录)的关键字,我们称为次关键字(Secondary Key),次关键字也可以理解为是不以唯一标识一个数据元素(或记录)的关键字,它对应的数据项就是次关键码。
查找表按照操作方式来分有两大种:静态查找表和动态查找表。
静态查找表(Static Search Table):只作查找操作的查找表。它的主要操作有:
- 查询某个“特定的”数据元素是否在查找表中;
- 检索某个“特定的”数据元素和各种属性。
动态查找表(Dynamic Search Table):在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。显然动态查找表的操作就是两个:
- 查找时插入数据元素;
- 查找时删除数据元素。
为了提高查找的效率,我们需要专门为查找操作设置数据结构,这种面向查找操作的数据结构称为查找结构。
从逻辑上来说,查找所基于的数据结构是集合,集合中的记录之间没有本质关系。可是要想获得较高的查找性能,我们就不能不改变数据元素之间的关系,在存储时可以将查找集合组织成表、树等结构。
对于静态查找表来说,我们不妨应用线性表结构来组织数据,这样可以使用顺序查找算法,如果再对主关键字排序,则可以应用折半查找等技术进行高效的查找。
如果是需要动态查找,则会复杂一些,可以考虑二叉排序树的查找技术。
2 顺序表查找
顺序查找(Sequential Search)又叫线性查找,是最基本的查找技术,它的查找过程是:从表中第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直到最后一个(或第一个)记录,其关键字和给定值比较都不等时,则表中没有所查的记录,查找不成功。
2.1 顺序表查找算法
顺序查找的算法实现如下:
// 顺序查找,a为数组,n为要查找的数组长度,key为要查找的关键字
int Sewuential_Search(int *a,int n,int key){
int i;
for(i=1;i<=n;i++){
if(a[i]==key)
return i;
}
return 0;
}
当你需要查找复杂表结构的记录时,只需要把数组a与关键字key定义 成你需要的表结构和数据类型即可。
2.2 顺序表查找优化
这个算法并不完美,因为每次循环时都需要对i是否越界,即是否小于等于n作判断。因此,我们可以设置一个哨兵,不需要每次让i与n作比较。
下面是改进后的顺序查找算法代码。
// 有哨兵顺序查找
int Sequential_Search2(int *a, int n, int key)
{
int i;
// 设置a[0]为关键字值,我们称之为“哨兵”
a[0]=key;
// 循环从数组尾部开始
i=n;
while(a[i]!=key)
{
i--;
}
// 返回0则说明查找失败
return i;
}
此时代码是从尾部开始查找,由于a[0]=key,也就是说,如果在a[i]中有key则返回i值,査找成功。否则一定在最终的a[0]处等于key,此时返回的是0,即说明 a[i]~a[n]中没有关键字key,查找失败。
这种在查找方向的尽头放置哨兵,免去了在查找过程中每一次比较后都要判断查找位置是否越界的小技巧,看似与原先差别不大,但在总数据较多时,效率提高很大,是非常好的编码技巧。
对于这种顺序查找算法来说,查找成功最好的情况就是在第一个位置就找到了,算法时间复杂度为O(1),最坏的情况是在最后一位置才找到,需要n次比较,时间复杂度为O(n),当查找不成功时,需要n+1次比较,时间复杂度为O(n)。我们之前推导过,关键字在任何一位置的概率是相同的,所以平均查找次数为(n+1)/2,所以最终时间复杂度还是O(n)。
很显然,顺序查找技术是有很大缺点的,n很大时,查找效率极为低下,不过优点也是有的,这个算法非常简单,对静态查找表的记录没有任何要求,在一些小型数据的查找时,是可以适用的。另外,也正由于查找概率的不同,我们完全可以将容易查找到的记录放在前面,而不常用的记录放置在后面,效率就可以有大幅提高。
3 有序表查找
我们如果仅仅是把书整理在书架上,要找到一本书还是比较困难的,也就是刚才讲的需要逐个顺序查找。但如果我们在整理书架时,将图书按照书名的拼音排序放置,那么要找到某一本书就相对容易了。说白了,就是对图书做了有序排列,一个线性表有序时,对于查找总是很有帮助的。
3.1 折半查找
又称为二分查找。它的前提是线性表中的记录必须是关键码有序,线性表必须采用顺序存储。
基本思想是:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止。
折半查找的代码如下。
// 折半查找
int Binary_Search(int *a,int n,int key)
{
int low,high,mid;
// 定义最低下标为记录首位
low=1;
// 定义最高下标为记录末位
high=n;
while(low<=high){
// 折半
mid=(low+high)/2;
// 若查找值比中值小
if(keya[mid])
// 最低下标调整到中位下标大一位
low=mid+1;
else
// 若相等则说明mid即为查找到的位置
return mid;
}
return 0;
}
最终我们折半算法的时间复杂度为O(logn)。
不过由于折半查找的前提条件是需要有序表顺序存储,对于静态查找表,一次排序后不再变化,这样的算法已经比较好了。但对于需要频繁执行插入或删除操作的数据集来说,维护有序的排序会带来不小的工作量,那就不建议使用。
3.2 插值查找
对于折半查找的思考是为什么一定要折半,而不是折四分之一或者更多呢?比如在字典查apple单词或zoo单词,肯定不会从中间开始查起,而是有一定目的的往前翻或往后翻。同样,若在取值范围0~1000
之间的100
个元素从小到大均匀分布的数组中查找5
,自然会考虑从数组下标较小的开始查找。
在折半查找中,
也就是mid等于最低下标low加上最高下标high与low的差的一半。算法科学家们考虑的就是将这个1/2进行改进,改进为下面的计算方案:
将参数1/2改为low+(high-low)*(key-a[low])/(a[high]-a[low])
, 可以让middle值更快的靠近关键字所在的位置,也就减少了比较次数。代码改为:
mid =low+(high-low)*(key-a[low])/(a[high]-a[low]); // 插值
就得到了另一种有序表查找算法,插值查找法。插值查找(Interpolation Search)是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值的计算公式(key-a[low])/(a[high]-a[low])
。应该说,从时间复杂度来看,它也是O(logn),但对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好得多。反之,数组中如果分布类似{0,1,2,2000,2001,......,999998,999999}
这种极端不均匀的数据,用插值查找未必是很合适的选择。
3.3 斐波那契查找
还有没有其他办法?我们折半查找是从中间分,也就是说,每一次查找总是一分为二,无论数据偏大还是偏小,很多时候这都未必就是最合理的做法。除了插值查找,我们再介绍一种有序查找,斐波那契査找(Fibonacci Search),它是利用了黄金分割原理来实现的。
斐波那契数列,又称黄金分割数列、因数学家列昂纳多·斐波那契以兔子繁殖为例子而引入,故又称为“兔子数列”,这个数列从第3项开始,每一项都等于前两项之和。
为了能够介绍清楚这个查找算法,我们先需要有一个斐波那契数列的数组,如下面两图所示。
斐波那契查找原理与前两种相似,仅仅改变了中间结点(mid)的位置,mid不再是中间或插值得到,而是位于黄金分割点附近,即mid=low+F(k-1)-1(F代表斐波那契数列)。
对F(k-1)-1的理解:
由斐波那契数列 F[k]=F[k-1]+F[k-2] 的性质,可以得到 (F[k]-1)=(F[k-1]-1)+(F[k-2]-1)+1 。该式说明:只要顺序表的长度为F[k]-1,则可以将该表分成长度为F[k-1]-1和F[k-2]-1的两段,即如上图所示。从而中间位置为mid=low+F(k-1)-1。类似的,每一子段也可以用相同的方式分割,从而方便编程。
但顺序表长度n不一定刚好等于F[k]-1,所以需要将原来的顺序表长度n增加至F[k]-1。这里的k值只要能使得F[k]-1恰好大于或等于n即可,由以下代码得到:
while(n>F[k]-1)
k++;
顺序表长度增加后,新增的位置(从n+1到F[k]-1位置),都赋为n位置的值即可。
斐波那契查找的代码如下。
// 斐波那契查找
int Fibonacci_Search(int *a, int n, int key)
{
int low,high,mid,i,k;
// 定义最低下标为记录首位
low=1;
// 定义最高下标为记录末位
high=n;
k=0;
// 计算n位于斐波那契数列的位置
while(n>F[k]-1)
k++;
// 将不满的数值补全
for(i=n;ia[mid])
{
// 最低下标调整到分隔下标mid+1处
low=mid+1;
// 斐波那契数列下标减两位
k=k-2;
}
else
{
if(midn说明是补全数值,返回n
return n;
}
}
return 0;
}
数组F是事先计算好的全局变置数组,它是斐波那契数列,F={0,1,1,2,3,5,8,13,21,……}
。
算法核心:
1)当key=a[mid]
时,查找就成功;
2)当key时,新范围是第
low
个到第mid-1
个,此时范围个数为F[k-1]-1
个;
3)当key>a[mid]
时,新范围是第m+1
个到第high
个,此时范围个数为F[k-2]-1
个。
复杂度: 若要查找的记录始终出现在右侧,时间复杂度为O(log(n)),但优于折半查找。若始终出现在左侧,则效率低于折半查找。
应该说, 三种有序表的查找本质上是分隔点的选择不同,各有优劣,实际开发时可根据数据的特点综合考虑再做出选择。
4 线性索引查找
很多数据集可能增长非常快,要保证记录全部是按照当中的某个关键字有序,其时间代价是非常高昂的,所以这种数据通常都是按先后顺序存储。
对于这样的查找表,为了能够快速查找到需要的数据,就需要用到索引。
数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度而设计的一种数据结构。索引就是把一个关键字与它对应的记录相关联的过程,一个索引由若干个索引项构成,每个索引项至少应包含关键码和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。
索引按照结构可以分为线性索引、树形索引和多级索引。我们这里就只介绍线性索引技术。所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。
4.1 稠密索引
稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项,如下图所示。
稠密索引要应对的可能是成千上万的数据,因此对于稠密索引这个索引表来说,索引项一定是按照关键码有序的排列。
索引项有序也就意味着,我们要查找关键字时,可以用到折半、插值、斐波那契等有序查找算法,大大提高了效率。比如上图中,我要查找关键字是18的记录,如果直接从右侧的数据表中查找,那只能顺序查找,需要查找6次才可以查到结果。而如果是从左侧的索引表中查找,只需两次折半查找就可以得到18对应的指针,最终查找到结果。
但是如果数据集非常大,比如上亿,那也就意味着索引也得同样的数据集长度规模,对于内存有限的计算机来说,可能就需要反复去访问磁盘,查找性能反而大大下降了。
4.2 分块索引
稠密索引因为索引项与数据集的记录个数相同,所以空间代价很大。为了减少索引项的个数,我们可以对数据集进行分块,使其分块有序,然后再对每一块建立一个索引项,从而减少索引项的个数。
分块有序,是把数据集的记录分成了若干块,并且这些块需要满足两个条件:
块内无序,即每一块内的记录不要求有序。当然,你如果能够让块内有序对查找来说更理想,不过这就要付出大量时间和空间的代价,因此通常我们不要求块内有序。
块间有序,例如,要求第二块所有记录的关键码均要大于第一块中所有记录的关键码,第三块的所有记录的关键码均要大于第二块的所有记录关键码……因为只有块间有序,才有可能在查找时带来效率。
对于分块有序的数据集,将每块对应一个索引项,这种索引方法叫做分块索引。如下图所示,我们定义的分块索引的索引项结构分三个数据项 :
- 最大关键码:它存储每一块中的最大关键字,这样的好处就是可以使得在它之后的下一块中的最小关键字也能比这一块最大的关键字要大;存储了块中的记录个数,以便于循环时使用;
- 块长:存储了块中的记录个数,以便于循环时使用;
- 块首指针:用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历。
在分块索引表中查找,就是分两步进行:
- 在分块索引表中查找要查关键字所在的块。由于分块索引表是块间有序的,因此很容易利用折半、插值等算法得到结果。例如,在上图的数据集中查找62,我们可以很快可以从左上角的索引表中由57<62<96得到62在第三个块中。
- 根据块首指针找到相应的块,并在块中顺序查找关键码。因为块中可以是无序的,因此只能顺序查找。
分块索引的效率比之顺序查找的O(n)是高了不少,不过显然它与折半查找的O(logn)相比还有不小的差距。因此在确定所在块的过程中,由于块间有序,所以可以应用折半、插值等手段来提高效率。
总的来说,分块索引在兼顾了对细分块不需要有序的情况下,大大增加了整体查找的速度,所以普遍被用于数据库表查找等技术的应用当中。
4.3 倒排索引
现在有两篇极短的英文“文章”—其实只能算是句子,我们暂认为它是文章,编号分别是1和2。
- Books and friends should be few but good.(读书如交友,应求少而精。)
- A good book is a good friend.(好书如挚友。)
假设我们忽略掉如“books”、“friends”中的复数“s”以及如“A”这样的大小写差异。我们可以整理出这样一张单词表,如下表所示,并将单词做了排序,也就是表格显示了每个不同的单词分别出现在哪篇文章中,比如“good”它在两篇文章中都有出现,而“is”只是在文章2中才有。
在这里这张单词表就是索引表,索引项的通用结构是:
- 次关键码,例如上面的“英文单词”;
- 记录号表,例如上面的“文章编号”。
其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的主关键字)。这样的索引方法就是倒排索引(in-verted index)。倒排索引源于实际应用中需要根据属性(或字段、次关键码)的值来查找记录。这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。
倒排索引的优点显然就是查找记录非常快,基本等于生成索引表后,查找时都不用去读取记录,就可以得到结果。但它的缺点是这个记录号不定长,比如上例有7个单词的文章编号只有一个,而“book”、“friend”、“good”有两个文章编号,若是对多篇文章所有单词建立倒排索引,那每个单词都将对应相当多的文章编号,维护比较困难,插入和删除操作都需要作相应的处理。
5 二叉排序树
对于普通的顺序存储来说,插入、删除操作很简便,效率高;而这样的表由于无序造成查找的效率很低。
对于有序线性表来说(顺序存储的),查找可用折半、插值、斐波那契等查找算法实现,效率高;而因为要保持有序,在插入和删除时不得不耗费大量的时间。
那么,如何既使得插入和删除效率不错,又可以比较高效率地实现查找的算法呢?
我们在之前把需要在查找时插入或删除的查找表称为动态查找表,那么什么样的结构可以实现动态查找表的高效率呢?
先看一个例子:
现在我们的目标是插入和查找同样高效。假设我们的数据集开始只有一个数{62},然后现在需要将88插入数据集,于是数据集成了{62,88},还保持着从小到大有序。再查找有没有58,没有则插入,可此时要想在线性表的顺序存储中有序,就得移动62和88的位置,如下左图,可不可以不移动呢?嗯,当然是可以,那就是二叉树结构。当我们用二叉树的方式时,首先我们将第一个数62定为根结点,88因为比62大,因此让它做62的右子树,58因比62小,所以成为它的左子树。此时58的插入并没有影响到62与88的关系,如下右图所示。
也就是说,若我们现在需要对集合{62,88,58,47,35,73,51,99,37,93}做查找,在我们打算创建此集合时就考虑用二叉树结构,而且是排好序的二叉树来创建。如下图所示,62、88、58创建好后,下一个数47因比58小,是它的左子树(见③),35是47的左子树(见④),73比62大,但却比88小,是88的左子树(见⑤),51比62小、比58小、比47大,是47的右子树(见⑥),99比62、88都大,是88的右子树(见⑦),37比62、58、47都小,但却比35大,是35的右子树(见⑧),93则因比62、88大是99的左子树(见⑨)。
这样我们就得到了一棵二叉树,并且当我们对它进行中序遍历时,就可以得到一个有序的序列{35,37,47,51,58,62,73,88,93,99},所以我们通常称它为二叉排序树。
二叉排序树(Binary Sort Tree),又称为二叉查找树。它或者是一棵空树,或者是具有下列性质的二叉树:
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 它的左、右子树也分别为二叉排序树。
从二叉排序树的定义也可以知道,它前提是二叉树,然后它采用了递归的定义方法。
构造一棵二叉排序树的目的,其实并不是为了排序,而是为了提高查找和插入删除关键字的速度。不管怎么说,在一个有序数据集上的查找,速度总是要快于无序的数据集的,而二叉排序树这种非线性的结构,也有利于插入和删除的实现。
5.1 二叉排序树查找操作
首先提供一个二叉树的结构:
// 二叉树的二叉链表结点结构定义
// 结点结构
typedef struct BiTNode{
// 结点数据
int data;
// 左右孩子指针
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
思路:查找值与结点数据对比,根据大小确定往左子树还是右子树进行下一步比较。然后是二叉排序树的查找的实现(采用递归):
// 递归查找二叉排序树T中是否存在key
// 指针f指向T的双亲,其初始调用值为NULL
// 若查找成功,则指针p指向该数据元素结点,并返回TRUE
// 否则指针p指向查找路径上访问的最后一个结点并返回FALSE
Status SearchBST(BiTree T,int key,BiTree f,BiTree *p){
// 查找不成功 (是否进行到叶子结点)
if(!T){
*p=f;
return FALSE;
}
// 查找成功
else if(key==T->data){
*p=T;
return TRUE;
}
else if(keydata)
// 在左子树继续查找
return SearchBST(T->lchild,key,T,p);
else
// 在右子树继续查找
return SearchBST(T->rchild,key,T,p);
}
5.2 二叉排序树插入操作
思路:与查找类似,但需要一个父节点来进行赋值。代码如下:
// 当二叉排序树T中不存在关键字等于key的数据元素时
// 插入key并返回TRUE,否则返回FALSE
Status InsertBST(BiTree *T, int key){
BiTree p,s;
// 查找不成功
if(!SearchBST(*T,key,NULL,&p)){
s=(BiTree)malloc(sizeof(BiTBode));
s->data=key;
a->lchild=s->rchild=NULL;
if(!p)
// 插入s为新的根结点
*T=s;
else if (keydata)
// 插入s为左孩子
p->lchild=s;
else
// 插入s为右孩子
p->rchild=s;
return TRUE;
}
else
// 树中已有关键字相同的结点,不再插入
return FALSE;
}
5.3 二叉排序树删除操作
对于二叉排序树的删除要注意,我们不能因为删除了结点,而让这棵树变得不满足二叉排序树的特性,所以删除需要考虑多种情况。
(1)删除叶子结点
直接删除,不影响原树,如下图所示。
(2)删除仅有左或右子树的结点
节点删除后,将它的左子树或右子树整个移动到删除节点的位置就可以,子承父业,如下图所示。
(3)删除左右子树都有的结点
我们仔细观察一下,47的两个子树中能否找出一个结点可以代替47呢?果然 有,37或者48都可以代替47,此时在删除47后,整个二叉排序树并没有发生什么本质的改变。
为什么是37和48?对的,它们正好是二叉排序树中比它小或比它大的最接近47 的两个数。也就是说,如果我们对这棵二叉排序树进行中序遍历,得到的序列 {29,35,36,37,47,48,49,50,51, 56,58,62,73,88,93,99}。
因此,比较好的办法就是,找到删除结点p的直接前驱(或直接后驱)s,用s来替换结点p,然后删除结点s,如下图所示。
根据我们对删除结点三种情况的分析:
- 叶子结点;
- 仅有左或右子树的结点;
- 左右子树都有的结点。
我们来看代码,下面这个算法是递归方式对二叉排序树T查找key,查找到时删除。
// 若二叉排序树T中存在关键字等于key的数据元素时,
// 则删除该数据元素结点,
// 并返回TRUE;否则返回FALSE
Status DeleteBST(BiTree *T,int key)
{
// 不存在关键字等于key的数据元素
if(!*T)
return FALSE;
else
{
// 找到关键字等于key的数据元素
if(key==(*T)->data)
return Delete(T);
else if (key<(*T)->data)
return DeleteBST(&(*T)->lchild,key);
else
return DeleteBST(&(*T)->rchild,key);
}
}
可以看出,这段代码和前面的二叉排序树查找几乎完全相同,唯一区别在于当找到对应key值的结点时,执行的是删除操作。
下面是Delete的代码:
Status Delete(BiTree *p)
{
BiTree q,s;
// 右子树空则只需重接它的左子树
if((*p)->rchild ==NULL)
{
q=*p;
*p=(*p)->lchild;
free(q);
}
// 只需重接它的右子树
else if((*p)->lchild==NULL)
{
q=*p;
*p=(*p)->rchild;
free(q);
}
// 左右子树均不空
else
{
q=*p;
s=(*p)->lchild;
// 转左,然后向右到尽头(找待删结点的前驱)
while(s->rchild){
q=s;s=s->rchild;
}
// s指向被删结点的直接前驱
(*p)->data=s->data;
if(q!=*p)
// 重接q的右子树
q->rchild=s->lchild;
else
// 重接q的左子树
q->lchild=s->lchild;
free(s);
}
returnn TRUE;
}
5.4 二叉排序树总结
总之,二叉排序树是以链接的方式存储,保持了链接存储结构在执行插入或删除操作时不用移动元素的优点,只要找到合适的插入和删除位置后,仅需修改链接指针即可。
而对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。技术情况,最少为1次,即根结点就是要找的结点;最多也不会超过树的深度。也就是说,二叉排序树的查找性能取决于二叉排序树的形状。可问题是,二叉排序树的形状是不确定的。
例如{62,88,58,47,35,73,51,99,37,93}这样的数组,我们可以构建如下左图的二叉排序树。但如果数组元素的次序是从小到大有序,如{35,37,47,51,58,62,73,88,93,99},则二叉排序树就成了极端的右斜树,注意它依然是一棵二叉排序树,如下右图。此时,同样是查找结点99,左图只需要两次比较,而右图就需要10次比较才可以得到结果,二者差异很大。
也就是说,我们希望二叉排序树是比较平衡的,即其深度与完全二叉树相同,那么查找的时间复杂也就为O(logn),近似于折半查找。而像上图右边这种情况,查找时间复杂度为O(n),这等同于顺序查找。
因此,如果我们希望对一个集合按二叉排序树查找,最好是把它构建成一棵平衡的二叉排序树。
6 平衡二叉树(AVL树)
平衡二叉树是一种二叉排序树,其中每一个节点的左子树和右子树的高度差至多等于1。
由于解决平衡二叉树的算法是由两位俄罗斯数学家G.M. Adelson-Velskii和E.M. Landis在1962年共同发明的,所以平衡二叉树也叫AVL树。
我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF(Balance Factor),那么平衡二叉树上所有结点的平衡因子只可能是-1、0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。
距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树,我们称为最小不平衡子树。
6.1 平衡二叉树实现原理
平衡二叉树构建的基本思想就是在构建二叉排序树的过程中,每当插入一个结点时,先检查是否因插入而破坏了树的平衡性,若是,则找出最小不平衡子树。在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,使之成为新的平衡子树。
在插入过程中,当最小不平衡子树根结点的平衡因子BF大于1时,就右旋;小于-1时就左旋。插入结点后,最小不平衡子树的BF与它的子树的BF符号相反时,就需要对结点先进行一次旋转以使得符号相同后,再反向旋转一次才能够完成平衡操作。
6.2 平衡二叉树实现算法
二叉排序树的结点结构:(增加一个bf,用来存储平衡因子)
// 二叉树的二叉链表结点结构定义
// 结点结构
typedef struct BiTNode
{
// 结点数据
int data;
// 结点的平衡因子
int bf;
// 左右孩子指针
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
对于右旋操作,代码如下:
// 对以p为根的二叉排序树作右旋处理
// 处理之后p指向新的树根结点,即旋转处理之前的左子树的根结点
void R_Rotate(BiTree *P)
{
BiTree L;
// L指向P的左子树根结点
L=(*P)->lchild;
// L的右子树挂接为P的左子树
(*P)->lchild=L->rchild;
L->rchild=(*P);
// P指向新的根结点
*P=L;
}
左旋操作代码如下:
// 对以P为根的二叉排序树作左旋处理
// 处理之后P指向新的树根结点,即旋转处理之前的右子树的根结点0
void L_Rotate(BiTree *P)
{
BiTree R;
// R指向P的右子树根结点
R=(*P)->rchild;
// R的左子树挂接为P的右子树
(*P)->rchild=R->lchild;
R->lchild=(*P);
// P指向新的根结点
(*P)=R;
}
总结:右旋,则原左子树的根结点变新树根结点,它的右子树根结点是原来的树根结点,那么它原来的右子树根结点呢?变成原来的树根结点(即现在的右子树根结点)的左子树根结点,因为此结点的左子树变成新的树根结点了,左子树也就空缺了。左旋类似。
如果我们需要查找的集合本身没有顺序,在频繁查找的同时也需要经常的插入和删除操作,显然我们需要构建一棵二叉排序树,但是不平衡的二叉排序树,查找效率是非常低的,因此我们需要在构建时,就让这棵二叉排序树是平衡二叉树,此时我们的查找时间复杂度就为O(logn),而插入和删除也为O(logn)。这显然是比较理想的一种动态查找表算法。
7 多路查找树(B树)
一个结点只能存储一个元素,在元素非常多的时候,就使得要么树的度非常大(结点拥有子树的个数的最大值),要么树的高度非常大,甚至两者都必须足够大才行。这就使得内存存取外存次数非常多,这显然成了时间效率上的瓶颈,这迫使我们要打破每一个结点只存储一个元素的限制,为此引入了多路查找树的概念。
多路查找树(muitl-way search tree),其每一个结点的孩子树可以多于两个,且每一个结点处可以存储多个元素。由于它是查找树,所有元素之间存在某种特定的排序关系。
7.1 2-3树
2-3树是这样的一棵多路查找树:其中的每一个结点都具有两个孩子(我们称它为2结点)或三个孩子(我们称它为3结点)。
一个2结点包含一个元素和两个孩子(或没有孩子),左子树包含的元素小于该元素,右子树包含的元素大于该元素。一个2结点要么没有孩子,要有就有两个,不能有一个孩子。(与二叉排序树的不同)。
一个3结点包含一小一大两个元素和三个孩子(或没有孩子),一个3结点要么没有孩子,要么就有3个孩子。如果某个3结点有孩子的话,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素。
并且2-3子树中所有的叶子都在同一层次上。
2-3树复杂的地方就在于新结点的插入和已有结点的删除。毕竟每个结点可能是2结点也可能是3结点,要保证所有叶子都在同一层次,是需要进行一番复杂操作的。
其它更多内容由于篇幅原因,不再做介绍。
8 散列表查找(哈希表)
我们发现,为了查找到结果,之前的方法“比较”都是不可避免的,但这是否真的有必要?能否直接通过关键字key得到要查找的记录内存存储位置呢?
我们只需要通过某个函数f,使得存储位置=f(关键字),那样我们可以通过查找关键字不需要比较就可获得需要的记录的存储位置。这就是一种新的存储技术—散列技术。
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。
这里我们把这种对应关系f称为散列函数,又称为哈希(Hash)函数。采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为散列表或哈希表(Hash table)。关键字对应的记录存储位置我们称为散列地址。
整个散列过程其实就是两步:
- 在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录;
- 当查找记录时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。
散列技术既是一种存储方法,也是一种查找方法。它与线性表、树、图等结构不同的是,前面几种结构,数据元素之间都存在某种逻辑关系,可以用连线图示表示出来,而散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联。因此,散列主要是面向查找的存储结构。
对于那种同样的关键字,它能对应很多记录的情况,不适合用散列技术;同样散列表也不适合范围查找。总之设计一个简单、均匀、存储利用率高的散列函数是散列技术中最关键的问题。
我们时常会碰到两个关键字key1≠key2,但是却有f(key1)=f(key2),这种现象我们称为冲突(collision),并把key1和key2称为这个散列函数的同义词(synonym)。
9 散列函数的构造方法
什么才是好的散列函数呢?有两个原则可以参考:
- 计算简单。你说设计一个算法可以保证所有的关键字都不会产生冲突,但是这个算法需要很复杂的计算,会耗费很多时间,这对于需要频繁地查找来说,就会大大降低查找的效率了。因此,散列函数的计算时间不应该超过其他查找技术与关键字比较的时间。
- 散列地址分布均匀。尽量让散列地址均匀地分布在存储空间中,这样可以保证存储空间的有效利用,并减少为处理冲突而耗费的时间。
9.1 直接定址法
取关键字的某个线性函数值为散列地址,即:
f(key) = a*key + b(a、b为常数)
这样的散列函数优点就是简单、均匀,也不会产生冲突。但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的连续的情况。因此,在现实应用中,此方法虽然简单,但却并不常用。
9.2 数字分析法
抽取方法是使用关键字的一部分来计算散列存储位置的方法,通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。
比如我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的。那么我们选择后面的四位成为散列地址就是不错的选择。如果这样的抽取工作还是容易出现冲突问题,还可以对抽取出来的数字再进行反转(如1234改成4321)、右环位移(如1234改成4123)等方法。总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各位置。
9.3 平方取中法
将关键字平方后再抽取中间几位。比较适合于不知道关键字的分布,而位数又不是很大的情况。
比如关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671,也可以是710,用做散列地址。
9.4 折叠法
将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
比如我们的关键字是9876543210,散列表表长为三位,我们将它分为四组,987|654|321|0,然后将它们叠加求和987+654+321+0=1962,再求后3位得到散列地址为962。
有时可能这还不能够保证分布均匀,不妨从一端向另一端来回折叠后对齐相加。比如我们将987和321反转,再与654和0相加,变成789+654+123+0=1566,此时散列地址为566。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。
9.5 除留余数法
对于散列表长为m的散列函数公式为:
f(key)=key mod p(p≤m)
该方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模。
本方法的关键就在于选择合适的p,p如果选得不好,就可能会容易产生同义词。
根据经验,若散列表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
9.6 随机数法
选择一个随机数,取关键字的随机函数值为它的散列地址。也就是f(key)=random(key)。这里random是随机函数。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。
那如果关键字是字符串如何处理?其实无论是英文字符,还是中文字符,也包括各种各样的符号,它们都可以转化为某种数字来对待,比如ASCII码或者Unicode码等,因此也就可以使用上面的这些方法。
总结:
总之,现实中,应该视不同的情况采用不同的散列函数。我们只能给出一些考虑的因素来提供参考:1.计算散列地址所需的时间。 2.关键字的长度。 3.散列表的大小。 4.关键字的分布情况。 5.记录查找的频率。综合这些因素,才能决策选择哪种散列函数更合适。
10 处理散列冲突的方法
10.1 开放定址法
所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
它的公式是:
fi(key)=(f(key)+di)MOD m(di=1,2,3,......,m-1)
比如说,我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12。我们用散列函数f(key)=key mod 12。
10.2 再散列函数法
对于我们的散列表来说,我们可以事先准备多个散列函数。
fi(key)=RHi(key)(i=1,2,...,k)
这里RHi就是不同的散列函数,每当发生散列地址冲突时,就换一个散列函数计算,相信总会有一个可以把冲突解决掉。这种方法能够使得关键字不产生聚集,当然,相应地也增加了计算的时间。
10.3 链地址法
将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。
对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},我们用前面同样的12为除数,进行除留余数法,可得到如下图结构,此时,已经不存在什么冲突换址问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。
链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗。
10.4 公共溢出区法
为所有冲突的关键字建立了一个公共的溢出区来存放。
在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表去进行顺序查找。如果对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。
11 散列表查找实现
11.1 散列表查找算法实现
首先是需要定义一个散列表的结构以及一些相关的常数。其中HashTable就是散列表结构。结构当中的elem为一个动态数组。
#define SUCCESS 1
#define UNSUCCESS 0
// 定义散列表长为数组的长度
#define HASHSIZE 12
#define NULLKEY -32768
typedef struct{
// 数据元素存储基址,动态分配数组
int *elem;
// 当前数据元素个数
int count;
} HashTable;
// 散列表表长,全局变量
int m=0;
有了结构的定义,我们可以对散列表进行初始化:
// 初始化散列表
Status InitHashTable(HashTable *H){
int i;
m=HASHSIZE;
H->count=m;
H->elem=(int*)malloc(m *sizeof(int));
for(i=0;ielem[i]=NULLKEY;
return OK;
}
为了插入时计算地址,我们需要定义散列函数,散列函数可以根据不同情况更改算法。
// 散列函数
int Hash(int key){
// 除留余数法
return key%m;
}
初始化完成后,我们可以对散列表进行插入操作。假设我们插入的关键字集合就是前面的{12,67,56,16,25,37,22,29,15,47,48,34}。
// 插入关键字进散列表
void InsertHash(HashTable *H, int key){
// 求散列地址
int addr=Hash(key);
// 如果不为空,则冲突
while(H->elem[addr]!=NULLKEY)
// 开放定址法的线性探测
addr=(addr+1)%m;
// 直到有空位后插入关键字
H->elem[addr]=key;
}
散列表存在后,我们在需要时就可以通过散列表查找要的记录。
// 散列表查找关键字
Status SearchHash(HashTable H, int key, int *addr){
// 求散列地址
*addr=Hash(key);
// 如果不为空,则冲突
while(H.elem[*addr]!=key){
// 开放定址法的线性探测
*addr=(*addr+1)%m;
if(H.elem[*addr]==NULLKEY||*addr==Hash(key)){
// 如果循环回到原点
// 则说明是关键字不存在
return UNSUCCESS;
}
}
return SUCCESS
}
可以看出,查找的代码与插入的代码非常类似,只需做一个不存在关键字的判断而已。
11.2 散列表查找性能分析
如果没有冲突,散列查找是我们本章介绍的所有查找中效率最高的,因为它的时间复杂度为O(1)。可惜,冲突是无法避免的。散列查找的平均长度取决于以下因素:
1、散列表是否均匀
散列函数的好坏直接影响着出现冲突的频繁程度,不过,由于不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的,因此我们可以不考虑它对平均查找长度的影响。
2、处理冲突的方法
相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。比如线性探测处理冲突可能会产生堆积,显然就没有二次探测法好,而链地址法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能。
3、散列表的装填因子
装填因子α=填入表中的记录个数/散列表长度。α标志着散列表的装满的程度。当填入表中的记录越多,α就越大,产生冲突的可能性就越大。故散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数。
不管记录个数n有多大,我们总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时我们散列查找的时间复杂度就真的是O(1)了。通常我们都将散列表的空间设置得比查找集合大,此时虽然是浪费了一定的空间,但换来的是查找效率的大大提升。
12 总结回顾
我们这一章全都是围绕一个主题“查找“来作文章的。
首先我们要弄清楚査找表、记录、关键字、主关键字、静态查找表、动态查找表等这些概念。
然后,对于顺序表査找来说,尽管很土(简单),但它却是后面很多査找的基础, 注意设置“哨兵”的技巧,可以使得本已经很难提升的简单算法里还是提高了性能。
有序査找,我们着重讲了折半查找的思想,它在性能上比原来的顺序査找有了质的飞跃,由〇(n)变成了 O(logn)。之后我们又讲解了另外两种优秀的有序查找:插值查找和斐波那契査找,三者各有优缺点,望大家要仔细体会。
线性索引查找,我们讲解了稠密索引、分块索引和倒排索引。索引技术被广泛的用于文件检索、数据库和搜索引擎等技术领域,是进一步学习这些技术的基础。
二叉排序树是动态查找最重要的数据结构,它可以在兼顾査找性能的基础上,让插入和删除也变得效率较高。不过为了达到最优的状态,二叉排序树最好是构造成平衡的二叉树才最佳。因此我们就需要再学习关于平衡二叉树(AVL树)的数据结构, 了解AVL树是如何处理平衡性的问题。这部分是本章重点,需要认真学习掌握。
B树这种数据结构是针对内存与外存之间的存取而专门设计的。由于内外存的查找性能更多取决于读取的次数,因此在设计中要考虑B树的平衡和层次。
散列表是一种非常髙效的查找数据结构,在原理上也与前面的査找不尽相同,它回避了关键字之间反复比较的烦琐,而是直接一步到位査找结果。当然,这也就带来了记录之间没有任何关联的弊端。应该说,散列表对于那种查找性能要求高,记录之 间关系无要求的数据有非常好的适用性。在学习中要注意的是散列函数的选择和处理冲突的方法。