大话数据结构 -- 查找

 

查找概论

查找,就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。

查找表(Search Table)是由同一类型的数据元素(或记录)组成的集合

关键字(Key)是数据元素中某个数据项的值,又称为键值。它可以标识一个数据元素,也可以标识一个记录的某个数据项(字段),我们称为关键码

若此关键字可以唯一地标识一个记录,则称此关键字为主关键字(Primary Key)

这也就意味着,对不同的记录,其主关键字均不相同。主关键字所在的数据项称为主关键码

对于那些可以识别多个数据元素(或记录)的关键字,我们称为次关键字(SecondaryKey),次关键字也可以理解为是不以唯一标识一个数据元素(或记录)的关键字,它对应的数据项就是次关键码

查找表按照操作方式来分有两大种:静态查找表和动态查找表。

静态查找表(Static Search Table):只作查找操作的查找表。它的主要操作有:

1、查询某个“特定的”数据元素是否在查找表中;

2、检索某个“特定的”数据元素和各种属性。

动态查找表(Dynamic Search Table):在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。显然动态查找表的操作就是两个:

1、查找时插入数据元素;

2、查找时删除数据元素。

为了提高查找的效率,我们需要专门为查找操作设置数据结构,这种面向查找操作的数据结构称为查找结构

从逻辑上来说,查找所基于的数据结构是集合,集合中的记录之间没有本质关系。可是要想获得较高的查找性能,我们就不能不改变数据元素之间的关系,在存储时可以将查找集合组织成表、树等结构

对于静态查找表来说,我们不妨应用线性表结构来组织数据,这样可以使用顺序查找算法,如果再对主关键字排序,则可以应用折半查找等技术进行高效的查找。

如果是需要动态查找,则会复杂一些,可以考虑二叉排序树的查找技术。

顺序表查找

顺序查找(Sequential Search)又叫线性查找,是最基本的查找技术,它的查找过程是:从表中第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直到最后一个(或第一个)记录,其关键字和给定值比较都不等时,则表中没有所查的记录,查找不成功

顺序查找的算法实现如下:

// 顺序查找,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;
}

这个算法并不完美,因为每次循环时都需要对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;
}

在查找方向的尽头放置“哨兵”免去了在查找过程中每一次比较后都要判断查找位置是否越界的小技巧,看似与原先差别不大,但在总数据较多时,效率提高很大,是非常好的编码技巧。当然,“哨兵”也不一定就一定要在数组开始,也可以在末端。

对于这种顺序查找算法来说,查找成功最好的情况就是在第一个位置就找到了,算法时间复杂度为O(1),最坏的情况是在最后一位置才找到,需要n次比较,时间复杂度为O(n),当查找不成功时,需要n+1次比较,时间复杂度为O(n)。我们之前推导过,关键字在任何一位置的概率是相同的,所以平均查找次数为(n+1)/2,所以最终时间复杂度还是O(n)

很显然,顺序查找技术是有很大缺点的,n很大时,查找效率极为低下,不过优点也是有的,这个算法非常简单,对静态查找表的记录没有任何要求,在一些小型数据的查找时,是可以适用的

另外,也正由于查找概率的不同,我们完全可以将容易查找到的记录放在前面,而不常用的记录放置在后面,效率就可以有大幅提高。

有序表查找

1、折半查找(Binary Search)

又称为二分查找。它的前提是线性表中的记录必须是关键码有序,线性表必须采用顺序存储

基本思想是:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止。

// 折半查找
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(key<a[mid])
            // 最高下标调整到中位下标小一位
            high=mid-1;
        // 若查找值比中值大
        else if(key>a[mid])
            // 最低下标调整到中位下标大一位
            low=mid+1;
        else 
            // 若相等则说明mid即为查找到的位置
            return mid;
    }    
    return 0;
}    

最终我们折半算法的时间复杂度为O(logn)

不过由于折半查找的前提条件是需要有序表顺序存储,对于静态查找表,一次排序后不再变化,这样的算法已经比较好了。但对于需要频繁执行插入或删除操作的数据集来说,维护有序的排序会带来不小的工作量,那就不建议使用。

2、插值查找

在折半查找中,

也就是mid等于最低下标low加上最高下标high与low的差的一半。算法科学家们考虑的就是将这个1/2进行改进,改进为下面的计算方案:

也就是代码中改为:

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、斐波那契查找

利用黄金分割原理实现。

// 斐波那契查找
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;i<F[k]-1;i++)
        a[i]=a[n];
    while(low<=high){
        // 计算当前分隔的下标
        mid=low+F[k-1]-1;
        // 若查找记录小于当前分隔记录
        if(key<a[mid]){
            // 最高下标调整到分隔下标mid-1处
            high=mid-1;
            // 斐波那契数列下标减一位
            k=k-1;
        }
        // 若查找记录大于当前分隔记录
        else if(key>a[mid]){
            // 最低下标调整到分隔下标mid+1处
            low=mid+1;
            // 斐波那契数列下标减两位
            k=k-2;
        }
        else{
            if(mid<n)
                // 若相等则说明mid即为查找到的位置
                return mid;
            else
                // 若mid>n说明是补全数值,返回n
                return n;
        }
    }
    return 0;
}

斐波那契查找算法的核心在于:

1)当key=a[mid]时,查找就成功;
2)当key<a[mid]时,新范围是第low个到第mid-1个,此时范围个数为F[k-1]-1个;
3)当key>a[mid]时,新范围是第m+1个到第high个,此时范围个数为F[k-2]-1个。

注意,这里利用了斐波拉契数列中的F[k-1]+F[k-2]=F[k]

也就是说,如果要查找的记录在右侧,则左侧的数据都不用再判断了,不断反复进行下去,对处于当中的大部分数据,其工作效率要高一些。所以尽管斐波那契查找的时间复杂度也为O(logn),但就平均性能来说,斐波那契查找要优于折半查找。可惜如果是最坏情况,比如这里key=1,那么始终都处于左侧长半区在查找,则查找效率要低于折半查找。

还有比较关键的一点,折半查找是进行加法与除法运算(mid=(low+high)/2),插值查找进行复杂的四则运算(mid=low+(high-low)*(key-a[low])/(a[high]-a[low])),而斐波那契查找只是最简单的加减法运算(mid=low+F[k-1]-1),在海量数据的查找过程中,这种细微的差别可能会影响最终的查找效率。

注意:如果序列长度n不等于斐波那契数列中的某个值,填充序列直到其长度为斐波那契数列中的某个值就可以了,即代码中的:

// 将不满的数值补全
    for(i=n;i<F[k]-1;i++)
        a[i]=a[n];

 

线性索引查找

很多数据集可能增长非常快,要保证记录全部是按照当中的某个关键字有序,其时间代价是非常高昂的,所以这种数据通常都是按先后顺序存储

对于这样的查找表,为了能够快速查找到需要的数据,就需要用到索引

数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度而设计的一种数据结构。索引就是把一个关键字与它对应的记录相关联的过程,一个索引由若干个索引项构成,每个索引项至少应包含关键字和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术。

索引按照结构可以分为线性索引、树形索引和多级索引。我们这里就只介绍线性索引技术。所谓线性索引就是将索引项集合组织为线性结构,也称为索引表

1、稠密索引

稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项,如图8-5-2所示。

稠密索引要应对的可能是成千上万的数据,因此对于稠密索引这个索引表来说,索引项一定是按照关键码有序的排列

索引项有序也就意味着,我们要查找关键字时,可以用到折半、插值、斐波那契等有序查找算法,大大提高了效率。比如图8-5-2中,我要查找关键字是18的记录,如果直接从右侧的数据表中查找,那只能顺序查找,需要查找6次才可以查到结果。而如果是从左侧的索引表中查找,只需两次折半查找就可以得到18对应的指针,最终查找到结果。

但是如果数据集非常大,比如上亿,那也就意味着索引也得同样的数据集长度规模,对于内存有限的计算机来说,可能就需要反复去访问磁盘,查找性能反而大大下降了。

2、分块索引

稠密索引因为索引项与数据集的记录个数相同,所以空间代价很大。为了减少索引项的个数,我们可以对数据集进行分块,使其分块有序,然后再对每一块建立一个索引项,从而减少索引项的个数

分块有序,是把数据集的记录分成了若干块,并且这些块需要满足两个条件:

1、块内无序,即每一块内的记录不要求有序。当然,你如果能够让块内有序对查找来说更理想,不过这就要付出大量时间和空间的代价,因此通常我们不要求块内有序。
2、块间有序,例如,要求第二块所有记录的关键字均要大于第一块中所有记录的关键字,第三块的所有记录的关键字均要大于第二块的所有记录关键字……因为只有块间有序,才有可能在查找时带来效率


对于分块有序的数据集,将每块对应一个索引项,这种索引方法叫做分块索引

如下图所示,我们定义的分块索引的索引项结构分三个数据项:

最大关键码,它存储每一块中的最大关键字,这样的好处就是可以使得在它之后的下一块中的最小关键字也能比这一块最大的关键字要大
存储了块中的记录个数,以便于循环时使用;
用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历

在分块索引表中查找,就是分两步进行:

1.在分块索引表中查找要查关键字所在的块。由于分块索引表是块间有序的,因此很容易利用折半、插值等算法得到结果。例如,在图8-5-4的数据集中查找62,我们可以很快可以从左上角的索引表中由57<62<96得到62在第三个块中。
2.根据块首指针找到相应的块,并在块中顺序查找关键码。因为块中可以是无序的,因此只能顺序查找。

设n个记录的数据集被平均分成m块,每个块中有t条记录,显然n=m×t,或者说m=n/t。再假设Lb为查找索引表的平均查找长度,因最好与最差的等概率原则,所以Lb的平均长度为(m+1)/2。Lw为块中查找记录的平均查找长度,同理可知它的平均查找长度为(t+1)/2。

这样分块索引查找的平均查找长度为:

这个式子的推导是为了让整个分块索引查找长度依赖n和t两个变量

从这里我们也就得到,平均长度不仅仅取决于数据集的总记录数n,还和每一个块的记录个数t相关。最佳的情况就是分的块数m与块中的记录数t相同,此时意味着n=mxt=t^2,即ASLw=1/2·(n/t+t)+1=t+1=sqrt(n)+1

可见,分块索引的效率比之顺序查找的O(n)是高了不少,不过显然它与折半查找的O(logn)相比还有不小的差距。因此在确定所在块的过程中,由于块间有序,所以可以应用折半、插值等手段来提高效率

总的来说,分块索引在兼顾了对细分块不需要有序的情况下,大大增加了整体查找的速度,所以普遍被用于数据库表查找等技术的应用当中。

3、倒排索引

现在有两篇极短的英文“文章”——其实只能算是句子,我们暂认为它是文章,编号分别是1和2。

1.Books and friends should be few but good.(读书如交友,应求少而精。)
2.A good book is a good friend.(好书如挚友。)

假设我们忽略掉如“books”、“friends”中的复数“s”以及如“A”这样的大小写差异。我们可以整理出这样一张单词表,如表8-5-1所示,并将单词做了排序,也就是表格显示了每个不同的单词分别出现在哪篇文章中,比如“good”它在两篇文章中都有出现,而“is”只是在文章2中才有。

表8-5-1
英文单词 文章编号
      a            2
      and        1
      be          1
      book     1,2
      but         1
      few         1
      friend    1,2
      good     1,2
      is            2
      should    1

在这里这张单词表就是索引表,索引项的通用结构是:

1、次关键码,例如上面的“英文单词”;
2、记录号表,例如上面的“文章编号”。
其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的主关键字)。这样的索引方法就是倒排索引(in-verted index)。倒排索引源于实际应用中需要根据属性(或字段、次关键码)的值来查找记录。这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。

倒排索引的优点显然就是查找记录非常快,基本等于生成索引表后,查找时都不用去读取记录,就可以得到结果。但它的缺点是这个记录号不定长,比如上例有7个单词的文章编号只有一个,而“book”、“friend”、“good”有两个文章编号,若是对多篇文章所有单词建立倒排索引,那每个单词都将对应相当多的文章编号,维护比较困难,插入和删除操作都需要作相应的处理。

当然,现实中的搜索技术非常复杂,比如我们不仅要知道某篇文章有要搜索的关键字,还想知道这个关键字在文章中的哪些地方出现,这就需要我们对记录号表做一些改良。再比如,文章编号上亿,如果都用长数字也没必要,可以进行压缩,比如三篇文章的编号是“112,115,119”,我们可以记录成“112,+3,+4”,即只记录差值,这样每个关键字就只占用一两个字节。甚至关键字也可以压缩,比如前一条记录的关键字是“and”而后一条是“an-droid”,那么后面这个可以改成“<3,roid>”,这样也可以起到压缩数据的作用。再比如搜索时,尽管告诉你有几千几万条查找到的记录,但其实真正显示给你看的,就只是当中的前10或者20条左右数据,只有在点击下一页时才会获得后面的部分索引记录,这也可以大大提高了整体搜索的效率。

二叉排序树

对于普通的顺序存储来说,插入、删除操作很简便,效率高;而这样的表由于无序造成查找的效率很低

对于有序线性表来说(顺序存储的),查找可用折半、插值、斐波那契等查找算法实现,效率高;而因为要保持有序,在插入和删除时不得不耗费大量的时间

那么,如何既使得插入和删除效率不错,又可以比较高效率地实现查找的算法呢?

我们在之前把需要在查找时插入或删除的查找表称为动态查找表,那么什么样的结构可以实现动态查找表的高效率呢?

先看一个例子:

现在我们的目标是插入和查找同样高效。假设我们的数据集开始只有一个数{62},然后现在需要将88插入数据集,于是数据集成了{62,88},还保持着从小到大有序。再查找有没有58,没有则插入,可此时要想在线性表的顺序存储中有序,就得移动62和88的位置,如图8-6-2左图,可不可以不移动呢?嗯,当然是可以,那就是二叉树结构。当我们用二叉树的方式时,首先我们将第一个数62定为根结点,88因为比62大,因此让它做62的右子树,58因比62小,所以成为它的左子树。此时58的插入并没有影响到62与88的关系,如图8-6-2右图所示。

也就是说,若我们现在需要对集合{62,88,58,47,35,73,51,99,37,93}做查找,在我们打算创建此集合时就考虑用二叉树结构,而且是排好序的二叉树来创建。如图8-6-3所示,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),又称为二叉查找树。它或者是一棵空树,或者是具有下列性质的二叉树:

1、若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值

2、若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值

3、它的左、右子树也分别为二叉排序树

从二叉排序树的定义也可以知道,它前提是二叉树,然后它采用了递归的定义方法。

构造一棵二叉排序树的目的,其实并不是为了排序,而是为了提高查找和插入删除关键字的速度。不管怎么说,在一个有序数据集上的查找,速度总是要快于无序的数据集的,而二叉排序树这种非线性的结构,也有利于插入和删除的实现。

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(key<T->data)
        // 在左子树继续查找
        return SearchBST(T->lchild,key,T,p);
    else
        // 在右子树继续查找
        return SearchBST(T->rchild,key,T,p);
}

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 (key<p->data)
            // 插入s为左孩子
            p->lchild=s;
        else
            // 插入s为右孩子
            p->rchild=s;
        return TRUE;
    }   
    else
        // 树中已有关键字相同的结点,不再插入
        return FALSE; 
}

3、二叉排序树删除操作

对于二叉排序树的删除要注意,我们不能因为删除了结点,而让这棵树变得不满足二叉排序树的特性,所以删除需要考虑多种情况。

如果需要查找并删除如37、51、73、93这些在二叉排序树中是叶子的结点,那是很容易的,毕竟删除它们对整棵树来说,其他结点的结构并未受到影响,如图8-6-8所示。

对于要删除的结点只有左子树或只有右子树的情况,相对也比较好解决。那就是结点删除后,将它的左子树或右子树整个移动到删除结点的位置即可,可以理解为独子继承父业。比如下图,就是先删除35和99结点,再删除58结点的变化图,最终,整个结构还是一个二叉排序树。

但是对于要删除的结点既有左子树又有右子树的情况怎么办呢?

比较好的方法是,找到需要删除的结点p的直接前驱(或直接后继)s(中序遍历二叉树得到),用s来替换结点p,然后再删除此结点s,如下图所示。

根据我们对删除结点三种情况的分析:

1、叶子结点;
2、仅有左或右子树的结点;
3、左右子树都有的结点,我们来看代码,下面这个算法是递归方式对二叉排序树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;
}

q始终指向s的双亲结点。

注意,其中的q=*p说明待删结点的前驱是它的左子树,且待删结点的前驱已经没有了右子树,有可能有左子树,要把此左子树接到其双亲结点(即待删结点)的左子树上;

q!=*p说明待删结点的前驱不是它的左子树,且待删结点的前驱已经没有了右子树,有可能有左子树,要把此左子树接到其双亲结点的右子树上(即原来待删结点的前驱的位置)。

总之,二叉排序树是以链接的方式存储,保持了链接存储结构在执行插入或删除操作时不用移动元素的优点,只要找到合适的插入和删除位置后,仅需修改链接指针即可。

而对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。技术情况,最少为1次,即根结点就是要找的结点;最多也不会超过树的深度。也就是说,二叉排序树的查找性能取决于二叉排序树的形状。可问题是,二叉排序树的形状是不确定的

例如{62,88,58,47,35,73,51,99,37,93}这样的数组,我们可以构建如图8-6-18左图的二叉排序树。但如果数组元素的次序是从小到大有序,如{35,37,47,51,58,62,73,88,93,99},则二叉排序树就成了极端的右斜树,注意它依然是一棵二叉排序树,如图8-6-18的右图。此时,同样是查找结点99,左图只需要两次比较,而右图就需要10次比较才可以得到结果,二者差异很大。

也就是说,我们希望二叉排序树是比较平衡的,即其深度与完全二叉树相,那么查找的时间复杂也就为O(logn)近似于折半查找。而像上图右边这种情况,查找时间复杂度为O(n),这等同于顺序查找。

因此,如果我们希望对一个集合按二叉排序树查找,最好是把它构建成一棵平衡的二叉排序树

平衡二叉树(AVL树)

平衡二叉树(Self-Balancing Binary Search Tree 或Height-Balanced Binary Search Tree)是一种二叉排序树,其中每一个节点的左子树和右子树的高度差至多等于1。

由于解决平衡二叉树的算法是由两位俄罗斯数学家G.M. Adelson-Velskii和E.M. Landis在1962年共同发明的,所以平衡二叉树也叫AVL树。

我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF(Balance Factor),那么平衡二叉树上所有结点的平衡因子只可能是-1、0和1只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的

注意:平衡二叉树首先是一棵二叉树,它需满足二叉树的定义

距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树,我们称为最小不平衡子树

如图8-7-3,当新插入结点37时,距离它最近的平衡因子绝对值超过1的结点是58(即它的左子树高度3减去右子树高度1),所以从58开始以下的子树为最小不平衡子树。

平衡二叉树构建的基本思想就是在构建二叉排序树的过程中,每当插入一个结点时,先检查是否因插入而破坏了树的平衡性,若是,则找出最小不平衡子树。在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,使之成为新的平衡子树。

在插入过程中,当最小不平衡子树根结点的平衡因子BF大于1时,就右旋小于-1时就左旋。插入结点后,最小不平衡子树的BF与它的子树的BF符号相反时,就需要对结点先进行一次旋转以使得符号相同后,再反向旋转一次才能够完成平衡操作。

注意:所有旋转的方向都针对最小不平衡子树的根结点下的边。

二叉排序树的结点结构:(增加一个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;
}

总结:右旋,则原左子树的根结点变新树根结点,它的右子树根结点是原来的树根结点,那么它原来的右子树根结点呢?变成原来的树根结点(即现在的右子树根结点)的左子树根结点,因为此结点的左子树变成新的树根结点了,左子树也就空缺了。左旋类似。

左平衡旋转处理的函数代码:

#define LH 1 // 左高
#define EH 0 // 等高
#define RH -1 // 右高
// 对以指针T所指结点为根的二叉树作左平衡旋转处理
// 本算法结束时,指针T指向新的根结点
void LeftBalance(BiTree *T){
    BiTree L,Lr;
    // L指向T的左子树根结点
    L=(*T)->lchild;
    switch(L->bf){
        // 检查T的左子树的平衡度,并作相应平衡处理
        // 新结点插入在T的左孩子的左子树上,要作单右旋处理
        case LH:
            (*T)->bf=L->bf=EH;
            R_Rotate(T);
            break;
        // 新结点插入在T的左孩子的右子树上,要作双旋处理        
        case RH:
            // Lr指向T的左孩子的右子树根
            Lr=L->rchild;
            // 修改T及其左孩子的平衡因子
            switch (Lr->bf){
                case LH:
                    (*T)->bf=RH;
                    L->bf=EH;
                    break;
                case EH:
                    (*T)->bf=L->bf=EH;
                    break;
                case RH:
                    (*T)->bf=EH;
                    L->bf=LH;
                    break;        
            }
            Lr->bf=EH;
            // 对T的左子树作左旋平衡处理
            L_Rotate(&(*T)->lchild);
            // 对T作右旋平衡处理
            R_Rotate(T);
    }
}

注意,当LeftBalance函数被调用时,其实已经确认当前子树是不平衡状态,且左子树的高度大于右子树的高度。换句话说,此时T的根结点应该是平衡因子BF的值大于1的数。

注意,会发现只定义了 LH 1  左高      EH 0 // 等高     RH -1 // 右高   三种情况,这是因为平衡二叉树每次插入时都会检查平衡情况!所以至大出现2或-2的情况(即发生了不平衡),绝对值不可能再大!

会发现,左平衡旋转是为了让原来不平衡的二叉树变平衡,而原来不平衡的结点一定是根结点!那么无论原根结点的左子树的右子树根结点的BF符号状态如何,左平衡旋转后,根结点的BF一定变为0(注意,此时的根结点已经变成原来根结点的左子树的右子树根!即Lr。)!

再来看区别。如果原来根结点的左子树根的右子树根的BF为1,可考虑该右子树根有左子树无右子树,那么首先针对原根结点的左子树根进行左旋(旋转的根结点的右子树根变为根结点)时,由于其右子树根变为根结点,且该右子树根原来有左子树而无右子树,那么它会变为原旋转根结点(原树根结点的左子树根),而它原来的左子树会变成原旋转根结点(原树根结点的左子树)的右子树,要注意原来该右子树根有左子树,说明左边至少三层,而到三层才出现不平衡,说明原树根结点有一个右孩子。那么右旋后它仍有一个右孩子,且旋转根结点(原树根结点的左子树的右子树根)无右子树,也即旋转后原树根结点的左子树无变化,那么它的BF变为-1,而原树根结点的左子树的BF为0。

同理,如果原来其根结点的左子树根的右子树根的BF为-1,可考虑该右子树根有右子树无左子树,那么原树根结点最后有了一个左孩子,它的BF变为0,而原根结点的左子树根没了这个右孩子,它的BF变为1。

而如果原来其根结点的左子树根的右子树根的BF为0,可考虑它既无左子树也无右子树,就是三个结点简单的左旋然后右旋,最后三个结点的BF都是0。 

其实就是先把原树根结点的左子树的右子树根变成原树根结点的左子树根(通过左旋),再把它变成新树根结点(通过右旋)。

主要难以理解的点其实在前两种情况,可以这么考虑:BF为1,有左子树无右子树,左旋时左子树可变为原树根结点的左子树根的右子树,使之平衡,而右旋时则无法使原树根结点平衡(没有右子树加到它的左子树上);反之,BF为-1,有右子树无左子树,左旋时没有左子树加到原树根结点的左子树根的右子树,无法使之平衡,而右旋时右子树变为原树根结点的左子树,使之平衡。

讲的有点绕,因为很难准确表述。。画一下图可能更好理解:

右平衡旋转处理类似,不再赘述。

--------------------------------------------------------

这里引用https://blog.csdn.net/lemon_tree12138/article/details/50393548的一段图文表述,帮助理解:

(1)在一个节点的左子树的左子树上插入一个新节点。即LL。在这种情况下,我们可以通过将节点右旋使其平衡。如图-2所示;

图-2 LL单右旋操作

(2)在一个节点的右子树的右子树上插入一个新节点。即RR。在这种情况下,我们可以通过将节点左旋使其平衡。如图-3所示;

图-3 RR单左旋操作

(3)在一个节点的左子树的右子树上插入一个新节点。即LR。在这种情况下,我们不能直接通过将节点左旋或右来使其平衡了。这里需要两步来完成,先让树中高度较低的进行一次左旋,这个时候就变成了LL了。再进行一次单右旋操作即可。如图-4所示;

图-4 LR先左旋再右旋操作

(4)在一个节点的右子树的左子树上插入一个新节点。即RL。在这种情况下,我们不能直接通过将节点左旋或右来使其平衡了。这里需要两步来完成,先让树中高度较低的进行一次右旋,这个时候就变成了RR了。再进行一次单左旋操作即可。如图-5所示;

图-5 RL先右旋再左旋操作

--------------------------------------------------------

有了这些分析理解,我们再来看主函数:

// 若在平衡的二叉排序树T中不存在和e有相同关键字的结点,则插入一个数据元素为
//e的新结点并返回1,否则返回0。
// 若因插入而使二叉排序树失去平衡,则作平衡旋转处理,布尔变量taller反应T长高与否。
Status insertAVL(BiTree *T,int e,Status *taller){
    if(!*T){
        // 插入新结点,树“长高”,置taller为TRUE
        *T=(BiTree)malloc(sizeof(BiTNode));
        (*T)->data=e;
        (*T)->lchild=(*T)->rchild=NULL;
        (*T)->bf=EH;
        *taller=TRUE;
    }
    else{
        if(e==(*T)->data){
            // 树中已存在和e有相同关键字的结点则不再插入
            *taller=FALSE;
            return FALSE;
        }
        if(e<(*T)->data){
            // 应继续在T的左子树中进行搜索
            // 未插入
            if(!InsertAVL(&(*T)->lchild,e,taller))
                return FALSE;
            // 已插入到T的左子树中且左子树“长高”
            if(*taller){
                // 检查平衡度
                switch((*T)->bf){
                    // 原本左子树比右子树高,需要作左平衡处理
                    case LH:
                        LeftBalance(T);
                        *taller=FALSE;
                        break;
                    // 原本左右子树等高,现因左子树增高而树增高
                    case EH:
                        (*T)->bf=LH;
                        *taller=TRUE;
                        break;
                    // 原本右子树比左子树高,现在左右子树等高
                    case RH:
                        (*T)->bf=EH;
                        *taller=FALSE:
                        break;    
                }
            }
        }
        else{
            // 应继续在T的右子树中进行搜索
             // 未插入
            if(!InsertAVL(&(*T)->rchild,e,taller))
                return FALSE;
            // 已插入到T的右子树中且右子树“长高”
            if(*taller){
                // 检查平衡度
                switch((*T)->bf){
                    // 原本左子树比右子树高,现在左右子树等高
                    case LH:
                        (*T)->bf=EH;
                        *taller=FALSE:
                        break; 
                    // 原本左右子树等高,现因右子树增高而树增高
                    case EH:
                        (*T)->bf=RH;
                        *taller=TRUE;
                        break;
                    // 原本右子树比左子树高,需要作右平衡处理
                    case RH:
                        RightBalance(T);
                        *taller=FALSE;
                        break;   
                }
            }
        }
    }
    return TRUE;
}

如果我们需要查找的集合本身没有顺序,在频繁查找的同时也需要经常的插入和删除操作,显然我们需要构建一棵二叉排序树,但是不平衡的二叉排序树,查找效率是非常低的,因此我们需要在构建时,就让这棵二叉排序树是平衡二叉树,此时我们的查找时间复杂度就为O(logn),而插入和删除也为O(logn)。这显然是比较理想的一种动态查找表算法。

多路查找树(B树)

内存一般都是有制的存储芯片组成,这种技术的每一个存储单位代价都要比磁存储技术昂贵两个数量级

一旦涉及到外部存储设备,关于时间复杂度的计算就会发生变化,访问该集合元素的时间已经不仅仅是寻找该元素所需比较次数的函数,我们必须考虑对硬盘等外部存储设备的访问时间以及将会对该设备做出多少次单独访问

一个结点只能存储一个元素,在元素非常多的时候,就使得要么树的度非常大(结点拥有子树的个数的最大值),要么树的高度非常大,甚至两者都必须足够大才行。这就使得内存存取外存次数非常多,这显然成了时间效率上的瓶颈,这迫使我们要打破每一个结点只存储一个元素的限制,为此引入了多路查找树的概念。

多路查找树(muitl-way search tree),其每一个结点的孩子树可以多于两个,且每一个结点处可以存储多个元素。由于它是查找树,所有元素之间存在某种特定的排序关系

1、2-3树

2-3树是这样的一棵多路查找树:其中的每一个结点都具有两个孩子(我们称它为2结点)或三个孩子(我们称它为3结点)。

一个2结点包含一个元素和两个孩子(或没有孩子)左子树包含的元素小于该元素,右子树包含的元素大于该元素一个2结点要么没有孩子,要有就有两个,不能有一个孩子。(与二叉排序树的不同)。

一个3结点包含一小一大两个元素和三个孩子(或没有孩子)一个3结点要么没有孩子,要么就有3个孩子。如果某个3结点有孩子的话,左子树包含小于较小元素的元素,右子树包含大于较大元素的元素,中间子树包含介于两元素之间的元素

并且2-3子树中所有的叶子都在同一层次上

2-3树复杂的地方就在于新结点的插入和已有结点的删除。毕竟每个结点可能是2结点也可能是3结点,要保证所有叶子都在同一层次,是需要进行一番复杂操作的。

2-3树的插入实现

对于2-3树的插入来说,与二叉排序树相同,插入操作一定是发生在叶子结点上。可与二叉排序树不同的是,2-3树插入一个元素的过程有可能会对该树的其余结构产生连锁反应

1、对于空树,插入一个2结点即可,这很容易理解。

2、插入结点到一个2结点的叶子上。应该说,由于其本身就只有一个元素,所以只需要将其升级为3结点即可。如图8-8-3所示。我们希望从左图的2-3树中插入元素3,根据遍历可知,3比8小、比4小,于是就只能考虑插入到叶子结点1所在的位置,因此很自然的想法就是将此结点变成一个3结点,即右图这样完成插入操作。当然,要视插入的元素与当前叶子结点的元素比较大小后,决定谁在左谁在右。例如,若插入的是0,则此结点就是“0”在左“1”在右了。

3、要往3结点中插入一个新元素。因为3结点本身已经是2-3树的结点最大容量(已经有两个元素),因此就需要将其拆分,且将树中两元素或插入元素的三者中选择其一向上移动一层。复杂的情况也正在于此。

1)见图8-8-4,需要向左图中插入元素5。经过遍历可得到元素5比8小比4大,因此它应该是需要插入在拥有6、7元素的3结点位置。问题就在于,6和7结点已经是3结点,不能再加。此时发现它的双亲结点4是个2结点,因此考虑让它升级为3结点,这样它就得有三个孩子,于是就想到,将6、7结点拆分,让6与4结成3结点,将5成为它的中间孩子,将7成为它的右孩子,如图8-8-4的右图所示。

2)如图8-8-5所示,需要向左图中插入元素11。经过遍历可得到元素11比12、14小比9、10大,因此它应该是需要插入在拥有9、10元素的3结点位置。同样道理,9和10结点不能再增加结点。此时发现它的双亲结点12、14也是一个3结点,也不能再插入元素了。再往上看,12、14结点的双亲,结点8是个2结点。于是就想到,将9、10拆分,12、14也拆分,让根结点8升级为3结点,最终形成如图8-8-5的右图样子。

3)如图8-8-6所示,需要在左图中插入元素2。经过遍历可得到元素2比4小、6比1大,因此它应该是需要插入在拥有1、3元素的3结点位置。与上例一样,你会发现,1、3结点,4、6结点都是3结点,都不能再插入元素了,再往上看,8、12结点还是一个3结点,那就意味着,当前我们的树结构是三层已经不能满足当前结点增加的需要了。于是将1、3拆分,4、6拆分,连根结点8、12也拆分,最终形成如图8-8-6的右图样子。

由此我们也发现,如果2-3树插入的传播效应导致了根结点的拆分,则树的高度就会增加。

2-3树的删除实现

1、所删除元素位于一个3结点的叶子结点上, 这非常简单,只需要在该结点处删除该元素即可,不会影响到整棵树的其他结点结构。如图8-8-7所示,删除元素9,只需要将此结点改成只有元素10的2结点即可。

2、所删除的元素位于一个2结点上,即要删除的是一个只有一个元素的结点。如果按照以前树的理解,删除即可,可现在的2-3树的定义告诉我们这样做是不可以的。比如图8-8-8所示,如果我们删除了结点1,那么结点4本来是一个2结点(它拥有两个孩子),此时它就不满足定义了。

分四种情况:

1)此结点的双亲也是2结点,且拥有一个3结点的右孩子。如图8-8-9所示,删除结点1,那么只需要左旋,即6成为双亲,4成为6的左孩子,7是6的右孩子。

2)此结点的双亲是2结点,它的右孩子也是2结点。如图8-8-10所示,此时删除结点4,如果直接左旋会造成没有右孩子,因此需要对整棵树变形,办法就是,我们目标是让结点7变成3结点,那就得让比7稍大的元素8下来,随即就得让比元素8稍大的元素9补充结点8的位置,于是就有了图8-8-10的中间图,于是再用左旋的方式,变成右图结果。

3)此结点的双亲是一个3结点。如图8-8-11所示,此时删除结点10,意味着双亲12、14这个结点不能成为3结点了,于是将此结点拆分,并将12与13合并成为左孩子。

4)如果当前树是一个满二叉树的情况,此时删除任何一个叶子都会使得整棵树不能满足2-3树的定义。如图8-8-12所示,删除叶子结点8时(其实删除任何一个结点都一样),就不得不考虑要将2-3的层数减少,办法是将8的双亲和其左子树6合并为一3个结点,再将14与9合并为3结点,最后成为右图的样子。

3、所删除的元素位于非叶子的分支结点。此时我们通常是将树按中序遍历后得到此元素的前驱或后继元素,考虑让它们来补位即可。

如果我们要删除的分支结点是2结点。如图8-8-13所示我们要删除4结点,分析后得到它的前驱是1后继是6,显然,由于6、7是3结点,只需要用6来补位即可,如图8-8-13右图所示。

如果我们要删除的分支结点是3结点的某一元素,如图8-8-14所示我们要删除12、14结点的12,此时,经过分析,显然应该是将是3结点的左孩子的10上升到删除位置合适。

2、2-3-4树

其实就是2-3树的概念扩展,包括了4结点的使用。一个4结点包含小中大三个元素和四个孩子(或没有孩子),一个4结点要么没有孩子,要么具有4个孩子。如果某个4结点有孩子的话,左子树包含小于最小元素的元素;第二子树包含大于最小元素、小于第二元素的元素;第三子树包含大于第二元素、小于最大元素的元素;右子树包含大于最大元素的元素。

如果我们构建一个数组为{7,1,2,5,6,9,8,4,3}的2-3-4树的过程,如图8-8-15所示。图1是在分别插入7、1、2时的结果图,因为3个元素满足2-3-4树的单个4结点定义,因此此时不需要拆分,接着插入元素5,因为已经超过了4结点的定义,因此拆分为图2的形状。之后的图其实就是在元素不断插入时最后形成了图7的2-3-4树。

图8-8-16是对一个2-3-4树的删除结点的演变过程,删除顺序是1、6、3、4、5、2、9。

3、B树(B-树)

B树(B-tree)是一种平衡的多路查找树2-3树和2-3-4树都是B树的特例结点最大的孩子数目称为B树的阶(order),因此,2-3树是3阶B树,2-3-4树是4阶B树。

一个m阶的B树具有如下属性:

●  如果根结点不是叶结点。则其至少有两颗子树;

●  每一个非根的分支结点都有k-1个元素和k个孩子,每一个叶子结点n都有k-1个元素;

●  所有叶子结点都位于同一层次;

●  所有分支结点包含下列信息数据:(n,A0,K1,A1,K2,A2,...,Kn,An),其中:Ki(i=1,2,...,n)为关键字,且Ki<Ki+1(i=1,2,...,n-1);Ai(i=0,2,...,n)为指向子树根结点的指针,且指针Ai-1所指子树中所有结点的关键字均小于Ki(i=1,2,...,n),An所指子树中所有结点的关键字均大于Kn,n(≤n≤m-1)为关键字的个数(或n+1为子树的个数)。

例如,在讲2-3-4树时插入9个数后的图转成B树示意就如图8-8-17的右图所示。左侧灰色方块表示当前结点的元素个数。

在B树上查找的过程是一个顺指针查找结点和在结点中查找关键字的交叉过程

比方说,我们要查找数字7,首先从外存(比如硬盘中)读取得到根结点3、5、8三个元素,发现7不在当中,但在5和8之间,因此就通过A2再读取外存的6、7结点,查找到所要的元素。

至于B树的插入和删除,方式是与2-3树和2-3-4树相类似的,只不过阶数可能会很大而已。

在一个典型的B树应用中,要处理的硬盘数据量很大,因此无法一次全部装入内存。因此我们会对B树进行调整,使得B树的阶数(或结点的元素)与硬盘存储的页面大小相匹配。比如说一棵B树的阶为1001(即1个结点包含1000个关键字),高度为2,它可以储存超过10亿个关键字,我们只要让根结点持久地保留在内存中,那么在这棵树上,寻找某一个关键字至多需要两次硬盘的读取即可。

通过这种方式,在有限内存的情况下,每一次磁盘的访问我们都可以获得最大数量的数据。由于B树每结点可以具有比二叉树多得多的元素,所以与二叉树的操作不同,他们减少了必须访问结点和数据块的数量,从而提高了性能。可以说,B树的数据结构就是为内外存的数据交互准备的。

那么对于n个关键字的m阶B树,最坏情况是要查找几次呢?我们来作一分析。

第一层至少有1个结点,第二层至少有2个结点,由于除根结点外每个分支结点至少有|m/2|棵子树,则第三层至少有2×|m/2|个结点,……,这样第k+1层至少有2×(|m/2|)^k-1个结点,而实际上,k+1层的结点就是叶子结点。若m阶B树有n个关键字,那么当你找到了叶子结点,其实也就等于查找不成功的结点为n+1,因此n+1≥2×(|m/2|)k-1,即:

也就是说,在含有n个关键字的B树上查找时,从根结点到关键字结点的路径上涉及的结点数不超过log|m/2|((n+1)/2)+1

4、B+树

B树是有缺陷的。对于树结构来说,我们都可以通过中序遍历来顺序查找树中的元素,这一切都是在内存中进行。可是在B树结构中,我们往返于每个结点之间也就意味着我们必须得在硬盘的页面之间多次访问,如图8-8-18所示,我们希望遍历这棵B树,假设每个结点都属于硬盘的不同页面,我们为了中序遍历所有的元素,页面2→页面1→页面3→页面1→页面4→页面1→页面5。而且我们每次经过结点遍历时,都会对结点中的元素进行一次遍历,这就非常糟糕。有没有可能让遍历时每个元素只访问一次呢?

为了能够解决所有元素遍历等基本问题,我们在原有的B树结构基础上,加上了新的元素组织方式,这就是B+树

B+树是应文件系统所需而出的一种B树的变形树,注意严格意义上讲,它其实已经不是常规定义的树了。在B树中,每一个元素在该树中只出现一次,有可能在叶子结点上,也有可能在分支结点上。而在B+树中,出现在分支结点中的元素会被当作它们在该分支结点位置的中序后继者(叶子结点)中再次列出。另外,每一个叶子结点都会保存一个指向后一叶子结点的指针

例如图8-8-19所示,就是一棵B+树的示意,灰色关键字即是根结点中的关键字在叶子结点再次列出,并且所有叶子结点都链接在一起。

一棵m阶的B+树和m阶的B树的差异在于:

●  有n棵子树的结点中包含有n个关键字;
●  所有的叶子结点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接;
●  所有分支结点可以看成是索引,结点中仅含有其子树中的最大(或最小)关键字。
这样的数据结构最大的好处就在于,如果是要随机查找,我们就从根结点出发,与B树的查找方式相同,只不过即使在分支结点找到了待查找的关键字,它也只是用来索引的,不能提供实际记录的访问,还是需要到达包含此关键字的终端结点

如果我们是需要从最小关键字进行从小到大的顺序查找,我们就可以从最左侧的叶子结点出发,不经过分支结点,而是延着指向下一叶子的指针就可遍历所有的关键字

B+树的结构特别适合带有范围的查找。比如查找我们学校18~22岁的学生人数,我们可以通过从根结点出发找到第一个18岁的学生,然后再在叶子结点按顺序查找到符合范围的所有记录

B+树的插入、删除过程也都与B树类似,只不过插入和删除的元素都是在叶子结点上进行而已。

散列表查找(哈希表)

如果你要查找某个关键字的记录,就是从表头开始,挨个的比较记录a[i]与key的值是“=”还是“≠”,直到有相等才算是查找成功,返回i。到了有序表查找时,我们可以利用a[i]与key的“<”或“>”来折半查找,直到相等时查找成功返回i。最终我们的目的都是为了找到那个i,其实也就是相对的下标,再通过顺序存储的存储位置计算方法,LOC(ai)=LOC(a1)+(i-1)×c,也就是通过第一个元素内存存储位置加上i-1个单元位置,得到最后的内存地址。

我们发现,为了查找到结果,之前的方法“比较”都是不可避免的,但这是否真的有必要?能否直接通过关键字key得到要查找的记录内存存储位置呢?

我们只需要通过某个函数f,使得存储位置=f(关键字),那样我们可以通过查找关键字不需要比较就可获得需要的记录的存储位置。这就是一种新的存储技术——散列技术

散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。

这里我们把这种对应关系f称为散列函数,又称为哈希(Hash)函数。采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为散列表或哈希表(Hash table)。关键字对应的记录存储位置我们称为散列地址

整个散列过程其实就是两步:

1、在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录;

2、当查找记录时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。

散列技术既是一种存储方法,也是一种查找方法。它与线性表、树、图等结构不同的是,前面几种结构,数据元素之间都存在某种逻辑关系,可以用连线图示表示出来,而散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联。因此,散列主要是面向查找的存储结构

对于那种同样的关键字,它能对应很多记录的情况,不适合用散列技术;同样散列表也不适合范围查找

总之设计一个简单、均匀、存储利用率高的散列函数是散列技术中最关键的问题。

我们时常会碰到两个关键字key1≠key2,但是却有f(key1)=f(key2),这种现象我们称为冲突(collision),并把key1和key2称为这个散列函数的同义词(synonym)

散列函数的构造方法

什么才是好的散列函数呢?有两个原则可以参考:

1、计算简单。你说设计一个算法可以保证所有的关键字都不会产生冲突,但是这个算法需要很复杂的计算,会耗费很多时间,这对于需要频繁地查找来说,就会大大降低查找的效率了。因此,散列函数的计算时间不应该超过其他查找技术与关键字比较的时间

2、散列地址分布均匀。尽量让散列地址均匀地分布在存储空间中,这样可以保证存储空间的有效利用,并减少为处理冲突而耗费的时间

1、直接定址法

取关键字的某个线性函数值为散列地址,即:

f(key)=a×key+b(a、b为常数)

这样的散列函数优点就是简单、均匀,也不会产生冲突。但问题是这需要事先知道关键字的分布情况适合查找表较小且连续的连续的情况。因此,在现实应用中,此方法虽然简单,但却并不常用

2、数字分析法

抽取方法是使用关键字的一部分来计算散列存储位置的方法,通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。

比如我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的。那么我们选择后面的四位成为散列地址就是不错的选择。如果这样的抽取工作还是容易出现冲突问题,还可以对抽取出来的数字再进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环位移、甚至前两数与后两数叠加(如1234改成12+34=46)等方法。总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各位置。

3、平方取中法

关键字平方后再抽取中间几位。比较适合于不知道关键字的分布,而位数又不是很大的情况。

比如关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671,也可以是710,用做散列地址。

4、折叠法

将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址

比如我们的关键字是9876543210,散列表表长为三位,我们将它分为四组,987|654|321|0,然后将它们叠加求和987+654+321+0=1962,再求后3位得到散列地址为962。

有时可能这还不能够保证分布均匀,不妨从一端向另一端来回折叠后对齐相加。比如我们将987和321反转,再与654和0相加,变成789+654+123+0=1566,此时散列地址为566。

折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况

5、除留余数法

对于散列表长为m的散列函数公式为:

f(key)=key mod p(p≤m)

该方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模

本方法的关键就在于选择合适的pp如果选得不好,就可能会容易产生同义词

根据经验,若散列表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数

6、随机数法

选择一个随机数,取关键字的随机函数值为它的散列地址。也就是f(key)=random(key)。这里random是随机函数。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的

那如果关键字是字符串如何处理?其实无论是英文字符,还是中文字符,也包括各种各样的符号,它们都可以转化为某种数字来对待,比如ASCII码或者Unicode码等,因此也就可以使用上面的这些方法。

总之,现实中,应该视不同的情况采用不同的散列函数。我们只能给出一些考虑的因素来提供参考: 1.计算散列地址所需的时间。 2.关键字的长度。 3.散列表的大小。 4.关键字的分布情况。 5.记录查找的频率。综合这些因素,才能决策选择哪种散列函数更合适。

处理散列冲突的方法

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。

当计算前5个数{12,67,56,16,25}时,都是没有冲突的散列地址,直接存入,如表8-11-1所示。

计算key=37时,发现f(37)=1,此时就与25所在的位置冲突。于是我们应用上面的公式f(37)=(f(37)+1)mod 12=2。于是将37存入下标为2的位置。这其实就是房子被人买了于是买下一间的作法,如表8-11-2所示。

接下来22,29,15,47都没有冲突,正常的存入,如表8-11-3所示。

到了key=48,我们计算得到f(48)=0,与12所在的0位置冲突了,不要紧,我们f(48)=(f(48)+1)mod 12=1,此时又与25所在的位置冲突。于是f(48)=(f(48)+2)mod 12=2,还是冲突……一直到f(48)=(f(48)+6)mod 12=6时,才有空位,机不可失,赶快存入,如表8-11-4所示。

我们把这种解决冲突的开放定址法称为线性探测法

从这个例子我们也看到,我们在解决冲突的时候,还会碰到如48和37这种本来都不是同义词却需要争夺一个地址的情况,我们称这种现象为堆积。很显然,堆积的出现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低

考虑深一步,如果发生这样的情况,当最后一个key=34,f(key)=10,与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置,尽管可以不断地求余数后得到结果,但效率很差。因此我们可以改进di=1^2,-1^2,2^2,-2^2,......,q^2,-q^2,(q≤m/2),这样就等于是可以双向寻找到可能的空位置。对于34来说,我们取di=-1即可找到空位置了。另外增加平方运算的目的是为了不让关键字都聚集在某一块区域。我们称这种方法为二次探测法
fi(key)=(f(key)+di)MOD m(di=1^2,-1^2,2^2,-2^2,...,q^2,-q^2,q≤m/2)
还有一种方法是,在冲突时,对于位移量di采用随机函数计算得到,我们称之为随机探测法

注意,这里的随机其实是伪随机数。伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的di当然可以得到相同的散列地址

fi(key)=(f(key)+di)MOD m(di是一个随机数列)

2、再散列函数法

对于我们的散列表来说,我们可以事先准备多个散列函数。

fi(key)=RHi(key)(i=1,2,...,k)

这里RHi就是不同的散列函数,每当发生散列地址冲突时,就换一个散列函数计算,相信总会有一个可以把冲突解决掉。这种方法能够使得关键字不产生聚集,当然,相应地也增加了计算的时间。

3、链地址法

将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。

对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},我们用前面同样的12为除数,进行除留余数法,可得到如下图结构,此时,已经不存在什么冲突换址问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题

链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗

4、公共溢出区法

为所有冲突的关键字建立了一个公共的溢出区来存放。

就前面的例子而言,我们共有三个关键字{37,48,34}与之前的关键字位置有冲突,那么就将它们存储到溢出表中,如图8-11-2所示。

在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表去进行顺序查找。如果对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。

散列表查找实现

首先是需要定义一个散列表的结构以及一些相关的常数。其中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;i<m;i++)
        H->elem[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
}

可以看出,查找的代码与插入的代码非常类似,只需做一个不存在关键字的判断而已。

如果没有 冲突,散列查找是我们本章介绍的所有查找中效率最高的,因为它的时间复杂度为O(1)。可惜,冲突是无法避免的。散列查找的平均长度取决于以下因素:

1、散列表是否均匀

散列函数的好坏直接影响着出现冲突的频繁程度,不过,由于不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的,因此我们可以不考虑它对平均查找长度的影响

2、处理冲突的方法

相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。比如线性探测处理冲突可能会产生堆积,显然就没有二次探测法好,而链地址法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能。

3、散列表的装填因子

装填因子α=填入表中的记录个数/散列表长度。α标志着散列表的装满的程度。当填入表中的记录越多,α就越大,产生冲突的可能性就越大。故散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数

不管记录个数n有多大,我们总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内此时我们散列查找的时间复杂度就真的是O(1)了。通常我们都将散列表的空间设置得比查找集合大,此时虽然是浪费了一定的空间,但换来的是查找效率的大大提升。

猜你喜欢

转载自blog.csdn.net/qq_36770641/article/details/82463229