【大话数据结构】第八章-查找(1)

八、查找(1)

1.查找概论

查找表(Search Table)是由同一类型的数据元素(或记录)构成的集合,如下图就是一个查找表。

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

若此关键字可以唯一地标识一个记录,则称此关键字为主关键字(Primary Key)。这就意味着,对不同的记录,其主关键字均不相同。主关键字所在的数据项称为主关键码,如下图③④。

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

在这里插入图片描述

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

若表中存在这样一个记录,则称查找是成功的,此时查找的结果给出整个记录的信息,或指示该记录在查找表中的位置。

若表中不存在关键字等于给定值的记录,则称查找不成功,此时查找的结果可给出一个“空”记录或“空”指针。

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

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

(1)查找某个“特定的”数据元素是否在查找表中。

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

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

(1)查找时插入数据元素。

(2)查找时删除数据元素。

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

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

2.顺序表查找

试想一下,要在散落的一大堆书中找到你需要的那本有多么麻烦。碰到这种情况的人大都会考虑做一件事,就是把这些书排列整齐,根据书名找书。散落的图书可以理解为一个集合,而将它们排列整齐,就如同是将此集合构造成一个线性表。我们要针对这一线性表进行查找操作,因此它就是静态查找表

此时图书尽管已经排列整齐,但还没有分类,因此我们要找书只能从头到尾或从尾到头一本一本查看,直到找到或全部查找完为止,这就是我们现在要讲的顺序查找。

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

2.1 顺序表查找算法

顺序查找的算法如下:

/* 顺序查找,a为数组,n为要查找的数组长度,key为要查找的关键字 */
int Sequential_Search(int *a, int n, int key) {
    
    
    int i;
    for (i=1; i<=n; i++) {
    
    
        if (a[i] == key)
            return i;
    }
    return 0;
}

2.2 顺序表查找优化

上面算法并非足够完美,因为每次循环时都需要对i是否越界,即是否小于等于n做判断。事实上,还可以有更好一点的办法,设置一个哨兵,可以不要每次让i与n作比较。改进后的代码如下:

/* 有哨兵的顺序查找 */
int Sequential_Search2(int *a, int n, int key) {
    
    
    int i;
    a[0] = key;
    i=n;
    while (a[i] != key) {
    
    
        i--;
    }
    return i;     // 返回0则说明查找失败
}

该算法的时间复杂度为O(n),有很大的缺点,当n很大时,查找效率极为低下,不过也有优点,这个算法非常简单,对静态查找表的记录没有任何要求,在一些小型数据的查找时,是可以适用的。

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

3.有序表查找

我们如果仅仅是把书整理在书架上,要找到一本书还是比较困难的,也就是刚才讲的需要逐个顺序查找。但如果我们在整理书架时,将图书按照书名的拼音排序放置,那么要找到某一本书就相对容易了。

下面我们来介绍三种有序表的查找方法。

3.1 折半查找

折半查找(Binary Search)技术,又称为二分查找。它的前提是线性表中的记录必须是关键码有序(通常从小到大有序),线性表必须采用顺序存储。折半查找的基本思想:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或所有查找区域无记录,查找失败为止

假如我们现在有这样一个有序表数组{0,1,16,24,35,47,59,62,73,88,99},除0下标外共10个数字。折半算法如下:

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
            return mid;
    }
    return 0;
}

该算法的时间复杂度为O(logn),显然好于顺序查找的O(n)时间复杂度。

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

3.2 插值查找

举个例子,如果要在取值范围0~10000之间100个元素从小到大均匀分布的数组中查找5,我们自然会考虑从数组下标较小的开始查找。

由此看来,折半查找还有改进的空间。

上面代码的第6句,我们略微变换后得到:

在这里插入图片描述

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

在这里插入图片描述

此法可以大大提高查找的效率。

换句话说,我们只需要在折半查找算法的代码中更改第6行为:

mid=low+(high-low)*(key-a[low])/(a[hgih]-a[low]);  // 插值

就得到了另一种有序表查找算法,插值查找法。插值查找(Interpolation Search)是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值的计算公式。应该说,从时间复杂度来看,它也是O(logn),但对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好得多。反之,数组中如果分布类似{0,1,2,2000,2001,······,99998,999999}这种极端不均匀的数据,用插值查找未必是很适合的选择。

3.3 斐波那契查找

斐波拉契查找(Fibonaci Search)是利用了黄金分割原理来实现的。

/* 斐波那契查找 */
int Fibonaci_Search(int *a, int n, int key) {
    
    
    int low,high,mid,i,k;
    low = 1;
    high = n;
    k = 0;
    while (n > F[k]-1)     // 计算n位于斐波那契数列的位置
        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]) {
    
    
            high = mid-1;
            k = k-1;
        } else if (key > a[mid]) {
    
    
            low = mid+1;
            k = k-2;
        } else {
    
    
            if (mid <= n)
                return mid;
            else
                return n;       // 若mid>n说明是补全数值,返回n
        }
    }
    return 0;
}

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

1)当key=a[mid]时,查找就成功;

2)当key<a[mid]时,新范围是第low个到第mid-1个,此时范围个数为F[k-1]-1个;

3)当key>a[mid]时,新范围是第mid+1个到第high个,此时范围个数为F[k-2]-1个。

在这里插入图片描述

尽管斐波那契查找的时间复杂度也为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)

在海量数据的查找过程中,这种细微的差别可能会影响最终的查找效率。

应该说,三种有序表的查找本质上是分隔点的选择不同,各有优劣,实际开发时可根据数据的特点综合考虑再做出选择。

4.线性索引查找

我们前面讲的几种比较高效的查找方法都是基于有序的基础之上的,但事实上,很多数据集可能增长非常快,例如,某些微博网站或大型论坛的帖子和回复总数每天都是成百上千万条,或者一些服务器的日志信息记录也可能是海量数据,要保证记录全部是按照当中的某个关键字有序,其时间代价是非常高昂的,所以这种数据通常都是按先后顺序存储的。

对于这样的查找表,我们如何能够快速查找到需要的数据呢?办法就是——索引。

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

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

4.1 稠密索引

稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项,如下图。

在这里插入图片描述

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

索引项有序也就意味着,我们要查找关键字时,可以用到折半、插值、斐波那契等有序查找算法,大大提高了效率。这显然是稠密索引的优点,但是如果数据集非常大,比如上亿,那就意味着索引也得有同样的数据集长度规模,对于内存有限的计算机来说,可能就需要反复去访问磁盘,查找性能反而大大下降了。

4.2 分块索引

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

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

  • 块内无序:即每一块内的记录不要求有序。
  • 块间有序:例如,要求第二块所有记录的关键字均要大于第一块所有记录的关键字。

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

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

在这里插入图片描述

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

  1. 在分块索引表中查找要查关键字所在的块。由于分块索引表是块间有序的,因此很容易利用折半、插值等算法得到结果。
  2. 根据块首指针找到相应的块,并在块中顺序查找关键字。因为块中可以是无序的,因此只能顺序查找。

我们再来分析一下分块索引的平均查找长度。设n个记录的数据集被平均分成m块,每个块中有t条记录,显然n=m×t,那么平均查找长度为:

ASLw = (m+1)/2+(t+1)/2 = 1/2(n/t+t)+1

最佳情况就是分的块数m与块中的记录数t相同,此时ASLw = n \sqrt{n} n +1

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

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

4.3 倒排索引

大家有没有对搜索引擎好奇过,无论你查找什么样的信息,它都能在极短的时间内给出一个结果。是什么算法技术达到这样的高效查找呢?

这里我们介绍最简单的,也是最基础的搜索技术——倒排索引。

我们来看样例,现在有两篇极短的英文文章,编号分别为1和2。

  1. Books and friends should be few but good.
  2. A good book is a good friend.

假如我们忽略掉如“books”、“friends”中的复数“s”,以及大小写差异,可以整理出这样一张单词表,如下图,并将单词做了排序,也就是表格显示了每个不同的单词分别出现在哪篇文章中。

在这里插入图片描述

有了这张单词表,我们要搜索文章就非常方便了。如果你在搜索框中填写“book”关键字,系统就会先在这张单词表中有序查找“book”,找到后将它对应的文章编号1和2的文章地址(通常在搜索引擎中就是网页的标题和链接)返回,并告诉你,查找到两条记录,用时0.0001秒。由于单词表是有序的,查找效率很高,返回的又只是文章的编号,所以整体速度都很快。

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

  • 次关键码,例如上面的“英文单词”;
  • 记录号表,例如上面的“文章编号”。

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

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

5.二叉排序树

假设查找的数据集是普通的顺序存储,那么插入操作就是将记录放在表的末端,给表记录数加一即可,删除操作可以是删除后,后面的记录向前移,也可以是要删除的元素与最后一个元素互换,表记录数减一,反正整个数据集也没有什么顺序,这样的效率也不错。应该说,插入和删除对于顺序存储结构来说,效率是可以接受的,但这样的表由于无序造成查找的效率很低,前面已有讲解,这里不再赘述。

假如查找的数据集是有序线性表,并且是顺序存储的,查找可以用折半、插值、斐波那契等查找算法来实现,可惜,因为有序,在插入和删除操作上,就需要耗费大量的时间。

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

当然有,这里我们介绍二叉排序树,我们在之前讲过,把这种需要在查找时插入或删除的查找表称为动态查找表。

假设我们的数据集开始只有一个数{62},然后我们要将88插入数据集,我们以二叉树的方式,首先将62定为根结点,88因为比62大,因此做62的右子树,接着要插入58,因为比62小,所以成为左子树。以此类推,最终我们得到下图这样的二叉排序树,并且我们可以对它进行中序遍历,就得到一个有序序列。
在这里插入图片描述

二叉排序树(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 (key < T->data)
        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(BiTree));
        s->data = key;
        s->lchild = s->rchild = NULL;
        if (!p)
            *T = s;                  // 插入s为新的根结点
        else if (key < p->data)
            p->lchild = s;           // 插入s为左孩子
        else
            p->rchild = s;           // 插入s为右孩子
        return TRUE;
    }
    return FALSE;                    // 树中已有关键字相同的结点,不再插入
}

有了二叉排序树的插入代码,我们要实现二叉排序树的构建就非常容易了。下面的代码就可以创建一棵树。

int i;
int a[10] = {
    
    62,88,58,47,35,73,51,99,37,93};
BiTree T = NULL;
for (i=0; i<10; i++) {
    
    
    InsertBST(&T, a[i]);
}

5.3 二叉排序树删除操作

删除结点的三种情况:

  • 叶子结点
  • 仅有左或右子树的结点
  • 左右子树都有结点

对于第一种情况,叶子结点,直接删除即可,对整棵树并没有什么影响;

对于第二种情况,仅有左或右子树的结点,结点删除后,将它的左子树或右子树整个移动到删除结点的位置即可;

对于第三种情况,左右子树都有结点,比较好的办法是,找到需要删除的结点p的直接前驱(或直接后继)s,用s来替换结点p,然后再删除此结点s。

下面这个算法是递归方式对二叉排序树T查找key,查找到时删除。

Status DeleteBST(BiTree *T, int key) {
    
    
    if (!*T)      // 不存在关键字等于key的数据元素
        return FALSE;
    else {
    
    
        if (key == (*T)->data)
            return Delete(T);
        else if (key < (*T)->data)
            return DeleteBST(&(*T)->lchild, key);
        else
            return DeleteBST(&(*T)->rchild, key);
    }
}

上面代码第6行执行了Delete方法,对当前结点进行删除操作。代码如下:

/* 从二叉排序树中删除结点p,并重接它的左或右子树 */
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;
        }
        (*p)->data = s->data;           // s指向被删结点的直接前驱
        if (q != *p)
            q->rchild = s->lchild;      // 重接q的右子树
        else
            q->lchild = s->lchild;      // 重接q的左子树
        free(s);
    }
    return TRUE;
}

5.4 二叉排序树总结

总之,二叉排序树是以链接的方式存储,保持了链接存储结构在执行插入或删除操作时不用移动元素的优点,只要找到合适的插入和删除位置后,仅需修改链接指针即可,插入删除的时间性能比较好。而对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。极端情况,最少为1次,即根结点就是要找的结点,最多也不会超过树的深度。也就是说,二叉排序树的查找性能取决于二叉排序树的形状。可问题在于,二叉排序树的形状是不确定的,如下图。

在这里插入图片描述

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

因此,如果我们希望对一个集合按二叉排序树查找,最好是把它构建成一棵平衡的二叉排序树。这样我们就引申出另一个问题,如何让二叉排序树平衡的问题。

6.平衡二叉树(AVL树)

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

有两位俄罗斯数学家G.M.Adelson-Velskii和E.M.Landis在1962年共同发明一种解决平衡二叉树的算法,所以平衡二叉树又称AVL树

平衡二叉树是一种高度平衡的二叉排序树,也就是说,它要么是一棵空树,要么它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF(Balance Factor),那么平衡二叉树上所有结点的平衡因子只可能是-1,0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。

如下图:

图1是平衡二叉树;

图2不是平衡二叉树,因为平衡二叉树的前提是二叉排序树,图2中59比58大却是58的左子树,不是二叉排序树;

图3不是平衡二叉树,因为结点58的左子树高度为2,而右子树为空,差绝对值大于1;

图4是平衡二叉树。

在这里插入图片描述

距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树,我们称之为最小不平衡子树。如下图,当新插入结点37时,距离它最近的平衡因子绝对值超过1的结点是58(即它的左子树高度2减去右子树高度0),所以从58开始以下的子树为最小不平衡子树。

在这里插入图片描述

6.1 平衡二叉树实现原理

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

为了能在讲解算法时轻松一些,我们先讲一个平衡二叉树构建过程的例子。假设我们现在有一个数组a[10]={3,2,1,4,5,6,7,10,9,8}需要构建二叉排序树。在没有学习平衡二叉树之前,根据二叉排序树的特性,我们通常会将他构建成如下图1所示的样子。虽然它完全符合二叉排序树的定义,但是对这样高度达到8的二叉树来说,查找是非常不利的。我们更期望构建成如下图2的样子,高度为4的二叉排序树才可以提供高效的查找效率。那么现在我们就来研究如何将一个数组构建成图2的树结构。

在这里插入图片描述

对于数组a[10]={3,2,1,4,5,6,7,10,9,8}的前两位3和2,我们正常构建,到了第3个数“1”时,发现此时根结点“3”的平衡因子变成了2,此时整棵树都成了最小不平衡子树,因此需要调整,如下图1(结点左上角数字为平衡因子BF值)。因为BF值为正,因此我们将整个树进行右旋(顺时针旋转),此时结点2成了根结点,3成了2的右孩子,这样三个结点的BF值均为0,非常的平衡,如下图2所示。

在这里插入图片描述

在这里插入图片描述

然后我们再增加结点4,平衡因子没发生改变,如上图3。增加结点5时,结点3的BF值为-2,说明要旋转了。由于BF是负值,所以我们对这棵最小平衡子树进行左旋(逆时针旋转),如上图4,此时我们整个树又达到了平衡。

继续,增加结点6时,发现根结点2的BF值变成了-2,如下图6。所以我们对根结点进行左旋,注意此时本来结点3是4的左孩子,由于旋转后需要满足二叉排序树特性,因此它成了结点2的右孩子,如图7。增加结点7,同样的左旋,使得整棵树达到平衡,如图8和9所示。

在这里插入图片描述

当增加结点10时,结构无变化,如图10。再增加结点9,此时结点7的BF变成了-2,理论上我们只需要旋转最小不平衡子树7、9、10即可,但如果左旋后,结点9就成了10的右孩子,这是不符合二叉排序树的特性的,此时不能简单的左旋,如图11所示。

在这里插入图片描述

仔细观察图11,发现根本原因在于结点7的BF是-2,而结点10的BF是1,也就是说,它们俩一正一负,符号并不统一,而前面的几次旋转,无论左还是右旋,最小不平衡子树的根结点与它的子结点符号都是相同的。这就是不能直接旋转的关键。

在这里插入图片描述

所以我们需要把它们符号统一,于是我们先对结点9和结点10进行右旋,使得结点10成了9的右子树,结点9的BF为-1,此时就与结点7的BF值符号统一了,如图12所示。

这样我们再以结点7为最小不平衡子树进行左旋,得到图13。接着插入8,情况与刚才类似,结点6的BF是-2,而它的右孩子9的BF是1,如图14,因此首先以9为根结点,进行右旋,得到图15,此时结点6和结点7的符号都是负,再以6为根结点左旋,最终得到最后的平衡二叉树,如图16所示。

在这里插入图片描述

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)->lchild;          // L指向p的左子树根结点
    (*p)->lchild = L->rchild;  // L的右子树挂接为p的左子树
    L->rchild = (*p);
    *p = L;                    // p指向新的根结点
}

左旋操作代码如下:

/* 对以p为根的二叉排序树作左旋处理 */
/* 处理之后p指向新的树根结点,即旋转处理之前的右子树的根结点 */
void L_Rotate(BiTree *p) {
    
    
    BiTree R;
    R = (*p)->rchild;         // R指向p的右子树根结点
    (*p)->rchild = R->lchild; // R的左子树挂接为p的右子树
    R->lchild = (*p);
    *p = R;                   // p指向新的根结点
}

下面我们来看左平衡旋转处理的函数代码:

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

同样的,右平衡旋转处理的函数代码非常类似,不做讲解。

下面就是主函数,代码如下:

/* 若在平衡的二叉排序树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;
            if (taller) {
    
      // 已插入到T的左子树中且左子树“长高”
                switch((*T)->bf) {
    
      // 检查T的平衡度
                    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;
            if (*taller) {
    
      // 已插入到T的右子树且右子树“长高”
                switch((*T)->bf) {
    
      // 检查T的平衡度
                    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;
}

对于这段代码来说,我们只需要在需要构建平衡二叉树的时候执行如下代码即可在内存中生成一棵如下图的平衡二叉树。

int i;
int a[10] = {
    
    3,2,1,4,5,6,7,10,9,8};
BiTree T = NULL;
Status taller;
for (i=0; i<10; i++) {
    
    
    InsertAVL(&T, a[i], &taller);
}

在这里插入图片描述

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

猜你喜欢

转载自blog.csdn.net/m0_50833438/article/details/114479421