写在前面:本文仅供个人学习使用。《大话数据结构》通俗易懂,适合整体做笔记输出,构建体系。并且文中很多图片来源于该书,如有侵权,请联系删除。

文章目录

  • 8.1 开场白
  • 8.2 查找概论
  • 8.3 顺序表查找
    • 8.3.1 顺序表查找算法
    • 8.3.2 顺序表查找优化
  • 8.4 有序表查找
    • 8.4.1 折半查找
    • 8.4.2 插值查找
    • 8.4.3 斐波那契查找
  • 8.5 线性索引查找
    • 8.5.1稠密索引
    • 8.5.2 分块索引
    • 8.5.3倒排索引
  • 8.6 二叉排序树
    • 8.6.1 二叉排序树查找操作
    • 8.6.2 二叉排序树插入操作
    • 8.6.3 二叉排序树删除操作
    • 8.6.4 二叉排序树总结
  • 8.7 平衡二叉树(AVL树)
    • 8.7.1 平衡二叉树实现原理
    • 8.7.2 平衡二叉树实现算法
  • 8.8 多路查找树(B树)
    • 8.8.1 2-3树
      • 2-3树的插入实现
      • 2-3树的删除实现
    • 8.8.2 2-3-4树
    • 8.8.3 B树
    • 8.8.4 B+树
  • 8.9 散列表(哈希表)查找概述
    • 8.9.1 散列表查找定义
    • 8.9.2 散列表查找步骤
  • 8.10 散列函数的构造方法
    • 8.10.1 直接定址法
    • 8.10.2 数字分析法
    • 8.10.3 平方取中法
    • 8.10.4 折叠法
    • 8.10.5 除留余数法
    • 8.10.6 随机数法
  • 8.11 处理散列冲突的方法
    • 8.11.1 开放定址法
    • 8.11.2 再散列函数法
    • 8.11.3 链地址法
    • 8.11.4 公共溢出区法
  • 8.12 散列表查找实现
    • 8.12.1 散列表查找算法实现
    • 8.12.2 散列表查找性能分析

8.1 开场白

相信在座的同学都用过搜索引擎。那么,你知道它的大概工作原理吗?

当你精心制作了一个网页、或写了一篇博客、或者上传了一组照片到互联网上,来自世界各地的无数“蜘蛛”便会蜂拥而至。所谓蜘蛛就是搜索引擎公司服务器上的软件,它如同蜘蛛一样把互联网当成了蜘蛛网,没日没夜地访问互联网上的各种信息。

它抓取并复制你的网页,且通过你网页上的链接爬上更多的页面,将所有信息纳入到搜索引擎网站的索引数据库。服务器拆解你网页上的文字内容、标记关键词的位置、字体、颜色,以及相关图片、音频、视频的位置等信息,并生成庞大的索引记录,如图8-1-1所示。


当你在搜索引擎上输入一个单词,点击搜索时,它会在不到1秒内,带着单词奔向索引数据库的每个神经末梢,检索到所有包含搜索词的网页,依据它们的浏览次数与关联性等一系列算法确定网页级别,排列出顺序,最终按你期望的格式呈现在网页上。

这就是一个“关键词”的云端之旅。过去的十多年,成就了本世纪最早期的创新明星Google,还有Yandex、Navar和百度等搜索引擎,搜索引擎已经称为人们最依赖的互联网工具。

作为学习编程的人,面对查找或者叫做搜索这种最为频繁的操作,理解它的原理并学习应用它是非常必要的事情,让我们对“Search”的探索之旅开始吧。

8.2 查找概论

只要你打开电脑,就会涉及到查找技术。如炒股软件中查股票信息、硬盘文件中找照片等,所有这些需要被查的数据所在的集合,我们给它们一个统称叫查找表。

查找表(search table)是由同一类型的数据元素(或记录)构成的集合。例如图8-2-1就是一个查找表。

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

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

那么对于那些可以标识多个数据元素(或记录)的关键字,我们 称为次关键字(Secondary key),如图⑤所示。次关键字也可以理解为是不用以唯一识别一个数据元素(或记录)的关键字。

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

若表中存在这样的一个记录,则称查找是成功的,此时查找的结果给出整个记录的信息,或指示该记录在查找表中的位置。比如图8-2-1所示,如果我们查找主关键码“代码”的主关键字为“sh601398”的记录时,就可以得到第2条唯一记录。 如果我们查找次关键码“涨跌幅”为“-0.11”的记录时,就可以得到两条记录。

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

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

静态查找表(Static Search Table):只作查找操作的查找表。
它的主要操作有: (1) 查找某个“特定的”数据元素是否在查找表中。(2)检索某个“特定的”数据元素和各种属性。

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

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

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

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

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

另外,还可以利用散列表结构来解决一些查找问题,这些技术都会在接下来一一介绍。

8.3 顺序表查找

设想一下,要在散落的一大堆书中找到你需要的那本有多麻烦。碰到这种情况大多人都会考虑去做这样一件事,那就是把这些书排列整齐,比如竖起来排到书架上,这样根据书名,就很容易找到需要的图书,如图8-3-1所示。

散落的图书可以理解为一个集合,而将它们排列整齐,就如同是将此集合构造成一个线性表,我们要针对这一线性表进行查找操作,因此它就是静态查找表。

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

8.3.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;
}

这段代码非常简单,就是在数组a(注意元素值从下标1开始)中查看有没有关键字,当你需要查找复杂表结构的记录时,只需要把数组a与关键字key定义成你需要的表结构和数据类型即可。

8.3.2 顺序表查找优化

到这里并非完美,因为每次循环时都需要对i是否越界,即是否小于等于n做判断。事实上,还可以有更好一点的办法,设置一个哨兵,可以解决不需要每次让i和n作比较。看下面的改进后的顺序查找算法代码

/* 有哨兵顺序查找 */
int Sequential_Search2(int *a,int n,int key)
{int i;a[0]=key;// 设置a[0]为关键字值,我们称之为”哨兵“i=n;循环从数组尾部开始while(a[i]!=key){i--;}return i;//返回0则表示查找失败
}

此时代码时从尾部开始查找,由于a[0]=key,也就是说,如果在a[i] 中有key 则返回i值,查找成功;否则一定时最终a[0]处等于key值,此时返回的是0,即说明查找失败。

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

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

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

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

8.4 有序表查找

一个线性表有序时,对于查找总是有帮助的。

8.4.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{return mid;        /* 若相等则说明mid即为查找到的位置 */}}return 0;
}

具有n个结点的完全二叉树的深度为⌊log2n⌋+1\lfloor log_2n \rfloor +1⌊log2​n⌋+1,在这里尽管折半查找判定二叉树并不是完全二叉树,但同样的推导可以得出,最坏情况是查找到关键字或查找失败的次数是⌊log2n⌋+1\lfloor log_2n \rfloor +1⌊log2​n⌋+1
最好情况呢?当然是1次了。

因此最终我们折半查找的时间复杂度为O(logn)O(logn)O(logn),它显然远远好于顺序查找的O(n)复杂度了。

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

8.4.2 插值查找

现在我们的问题是,为什么一定要折半,而不是折四分之一或者折更多呢?

打个比方,在英文词典中查找apple这个单词,你下意识翻开词典的前面还是后面?如果再让你查zoo这个单词呢? 很显然,这里你绝对不会是从中间开始查起,而是有一定目的的往前或往后翻。

同样的,比如要在取值=~10000之间 共有100个元素从小到大均匀分布的数组中查找5,我们自然会考虑从数组下标较小的开始查找。

看来,我们的折半查找,还是有改进空间的。

折半查找mid= (low+high)/2 = low+(high-low)/2

算法科学家们考虑的就是将这个1/2进行改进,改进为下面的计算方案:

mid=low+key−a[low]a[high]−a[low](high−low)mid=low+\frac{key-a[low]}{a[high]-a[low]}(high-low)mid=low+a[high]−a[low]key−a[low]​(high−low)

将1/2改成key−a[low]a[high]−a[low]\frac{key-a[low]}{a[high]-a[low]}a[high]−a[low]key−a[low]​有什么道理吗?

假设 a[11]={0,1,16,24,35,47,59,62,73,88,99},low=1,high=10,则 a[low]=1,a[high]=99,如果我们要查找的是key=16时,按照原来折半的做法,我们需要4次(如图8-4-6)才可以得到结果。

但如果用新方法,key+a[low]a[high]−a[low]=(16−1)/(99−1)=0.153\frac{key+a[low]}{a[high]-a[low]}=(16-1)/(99-1)=0.153a[high]−a[low]key+a[low]​=(16−1)/(99−1)=0.153,即mid= 1+0.153*(10-1)=2.377,取整得到mid=2,我们只需要两次就查找到结果了,显然大大提高了查找的效率。

换句话说,我们只需要在折半查找算法的代码中更改一行代码便可以得到插值查找的代码

/* 插值查找 */
int Interpolation_Search(int *a,int n,int key)
{int low,high,mid;low=1;   /* 定义最低下标为记录首位 */high=n;   /* 定义最高下标为记录末位 */while(low<=high){mid=low+ (high-low)*(key-a[low])/(a[high]-a[low]); /* 插值 */if (key<a[mid])       /* 若查找值比插值小 */high=mid-1;      /* 最高下标调整到插值下标小一位 */else if (key>a[mid])/* 若查找值比插值大 */low=mid+1;       /* 最低下标调整到插值下标大一位 */elsereturn mid;     /* 若相等则说明mid即为查找到的位置 */}return 0;
}

插值查找(Interpolation Search)是根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心在于插值的计算公式key−a[low]a[high]−a[low]\frac{key-a[low]}{a[high]-a[low]}a[high]−a[low]key−a[low]​。 应该说,从时间复杂度来看,它就是O(logn),但对于表长较大,而关键字分布又比较均匀的查找表来说,差值查找算法的平均性能比折半查找要好得多。反之,数组中如果分布类似{0,1,2,2000,2001,…,99999919,99999999} 这种极端不平均的数据,用插值查找未必是合适的算法。

8.4.3 斐波那契查找

还有没有其他办法? 我们折半查找是从中间分,也就是说,每一次查找总是一分为二,无论数据是偏大还是偏小,很多时候这都未必是最合理的做法。除了插值查找外,我们再介绍一种有序查找,斐波那契查找(Fibonacci Search),它是利用黄金分割的原理来实现的。

为了能够介绍清楚这个查找算法,我们先需要有一个斐波那契数列的数组,如图8-4-8所示。

下面我们根据代码来看程序是如何运行的

/* 斐波那契查找 */
int Fibonacci_Search(int *a,int n,int key)
{int low,high,mid,i,k=0;low=1;    /* 定义最低下标为记录首位 */high=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]){high=mid-1;     k=k-1;}else if (key>a[mid]){low=mid+1;        k=k-2;}else{if (mid<=n)return mid;     /* 若相等则说明mid即为查找到的位置 */else return n;}}return 0;
}

1 程序开始运行,参数 a[11]={0,1,16,24,35,47,59,62,73,88,99}, n=10,要查找的关键字key=59。注意此时我们已经有了事先计算好的全局变量数组F 的具体数据,它是斐波那契数列, F={0,1,1,2,3,5,8,13,21,…}

2 第6-8行是计算当前的n处于斐波那契数列的位置。现在是n=10,F[6]<10<F[7],,所以计算出来k=7。

3 第9~10行,由于k=7,计算时是以F[7]=13为基础,而a中最大的仅是a[10],后面的a[11],a[12]均未赋值, 这不能构成有序序列,因此将它们都赋值为最大的数组值,所以此时a[11]=a[12]=a[10]=99(此段代码后面有解释)。

4第11~31行查找正式开始。

5 第13行,mid=1+F[7-1]-1=8,也就是说,我们第一个要对比的数值是从下标为8开始的

6 由于此时key=59而a[8]=73,因此执行第16~17行 得到high=7,k=6.

7再次循环,mid=low+F[k-1]-1 =1+F[6-1]-1=5, 此时a[5]=47<key,因此执行19~23行代码,得到low=mid+1=6,k=k-2=4 ,注意此时k下调2个单位。

8 再次循环,mid=low+F[k-1]-1 =6+F[4-1]-1=7, 此时a[7]=62>key,因此执行14~18行代码,得到high=mid-1=6,k=k-1=3 .

9再次循环, mid=low+F[k-1]-1 =6+F[3-1]-1=6, 此时a[6]=59=key,因此执行代码第26~27行,返回值为6. 程序运行结束。

如果key=99,此时查找循环第一次时,mid=8与上例是相同的,第二次循环时,mid=11,如果a[11]没有值(a中最大的仅是a[10],后面的a[11],a[12]均未赋值, 这不能构成有序序列)就会使得与key的比较失败,为了避免这样的情况出现,第9~10行的代码就起到了这样的作用。

斐波那契查找算法的核心在于:
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个。

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

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

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

8.5 线性索引查找

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


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

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

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

8.5.1稠密索引

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

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

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

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

8.5.2 分块索引

回想一下图书馆是如何藏书的。显然它不会是顺序摆放的,给我们一个稠密索引表去查,然后再找到书给你。图书馆的图书分类摆放是有完整的学科体系的,而它最重要的一个特点就是分块。

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

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

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

对于分块有序的数据集,将每块对应一个索引项,这种索引方法称为分块索引。如图8-5-4所示,我们定义的分块索引的索引项结构分三个数据项:

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

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

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

应该说,分块索引的思想是很容易理解的,我们通常在整理书架时,都会考虑不同的层板放置不同类别的图书。例如,我家里就是最上层方不太常看的小说,中间层放计算机相关的专业书,这就是分块的概念,并且让它们块间有序了。 只至于上层是《红楼梦》在《三国演义》的左边还是有边,并不是很重要。 毕竟要找小说《红楼梦》,只需要对这一层的书用眼睛扫一遍即可。

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

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

注意这个式子的推导是为了让整个分块索引查找长度依赖于n和t两个变量。从这里我们也就得到,平均长度不仅仅取决于数据集的总记录数n,还和每一个块的记录个数t相关。最佳的情况就是分的块数m与块中的记录数t相同,此时意味着 n=m∗t=t2n =m*t =t^2n=m∗t=t2,即

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

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

8.5.3倒排索引

我不知道大家有没有对搜索引擎好奇过,无论你查找什么样的信息,它都可以在极短的时间内给你一些结果,如图8-5-5所示。是什么算法技术得到这样的高效查找呢?

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

我们来看样例,现在有两篇极短的英文文章—其实只能算是英文句子,我们暂时认为它是文章,编号分别是1和2.

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

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

如果没有这张单词表,为了能证实所有的文章中有没有没有关键字book,则需要对每一篇文章每一个单词顺序查找。在文章数是海量的情况下,这样的做法只存在理论上的可行性,现实中没有人愿意使用它。

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

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

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

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

当然,现实中的搜索技术非常复杂,这里只介绍了最简单的思想。

8.6 二叉排序树

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

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

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

我们在8.2 节把这种需要在查找时插入或删除的查找表称为动态查找表。我们现在就来看看什么样的结构可以实现动态查找表的高效率。

如果在复杂的问题面前,我们束手无策的话,不妨先从最简单的情况入手。现在我们的目标是插入和查找同样高效。假设我们的数据集开始只有一个数{62},然后仙子啊需要将88插入数据集,于是数据集成了{62,88},还保持着从小到大有序。再查找有没有58,没有则插入,可此时要想在线性表的顺序存储中有序,就得移动62和88的位置,如图8-6-2的左图,可不可以不移动呢?


嗯,当然是可以,那就是二叉树结构。当我们用二叉树的方式时,首先我们将第一个数62定为根结点,88因为比62大,因此让它作为62的右子树,58因比62小,所以成为它的左子树。此时58的插入并没有影响到62和88的关系,如上图右图所示。

也就是说,若我们现在需要对集合{62,88,58,47,35,73,51,99,37,93}做查找,在我们打算创建此集合时就考虑用二叉树结构,而且是排好序的二叉树来创建。 如图8-6-3所示,62,58,88创建好后,下一个数47因为比58小,是它的左子树(见③),35是47的左子树(见④),73比62大,但却比88小,是88的左子树(见⑤),51比62小,比58小,比47大,是47的右子树(见⑥),99比62大,比88大,是88的右子树(见⑦),37比62、58、、47都小,但是比35大,所以37是35的右子树(见⑧),93则比62、88大,比99小,所以98是99的左子树(见⑨)。

这样我们就得到了一棵二叉树,并且当我们对它进行中序遍历时,就可以得到一个有序的序列{35,37,47,51,58,62,73,88,93,99},所以我们通常称它为二叉排序树。

二叉排序树(Binary Sort Tree) ,又称二叉查找树,它或者是一棵空树,或者是具有下列性质的二叉树:

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
  • 若它的右子树不空,则柚子树上所有结点的值均大于它的根结点的值;
  • 它的左右子树也分别为二叉排序树。

从二叉排序树的定义也可以知道,它前提是二叉树,然后它采用了递归的定义方式,再者,它的结点间满足一定的次序关系,左子树结点一定比其双亲结点小,右子树结点一定比其双亲结点大。

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

8.6.1 二叉排序树查找操作

首先我们提供一个二叉树的结构

//二叉树的二叉链表结点结构定义tydedef struct BiTNode{  //结点结构int data;   // 结点数据struct BiTNode *lchild, *rchild;// 左右孩子指针} BiTNode, *BiTree;

然后我们来看看二叉排序树的查找是如何实现的

//递归查找二叉排序树T中是否存在key
//指针f指向T的双亲,其初始调用值为NULL
//若查找成功,则指针p指向该数据元素结点,并返回TRUE
//否则指针p指向查找路径上访问的最后一个结点并返回FALSEStatus 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);//在右子树继续查找}

1 SearchBST 函数是一个可递归运行的函数,函数调用时的语句为 SearchBST(T,93,NULL,p);参数T是一个二叉链表,其中数据如图8-6-3所示,key代表要查找的关键字,目前我们打算查找93,二叉树f指向T的双亲,当T指向根结点时,f的初值就为NULL,它在递归时有用,最后的参数p时为了查找成功后可以得到查找到的结点位置。

下面复习一下二叉链表

二叉树每个结点最多有两个孩子,所以为它设计一个数据域和两个指针域是比较自然的想法,我们称这样的链表为二叉链表。结点结构图如下所示

其中data为数据域,lchild和rchild都是指针域,分别存放指向左孩子和右孩子的指针。

以下是我们的二叉链表的结点结构定义代码。

typedef struct BiTNode{TElemType data; //结点数据struct BiTNode *lchild ,*rchild;//左右孩子指针
}BiTNode, *BiTree;

结构示意图如下图所示

下面继续二叉排序树的查找

2 第3~7行,是用来判断当前二叉树是否到叶子结点,显然图8-6-3告诉我们当前T指向根结点62的位置,T不为空,第5 ~ 6行不执行。

3 第8~12行是查找到相匹配的关键字时执行语句,显然93≠62,第10 ~11行不执行。

4 第13~14行是当要查找的关键字小于当前结点值时执行,由于93>62,第14行不执行。

5 第15~16行时当要查找的关键字大于当前结点值时执行,由于93>62,所以递归调用SearchBST(T->rchild,key,T,p); 此时T指向了62的右孩子88,如图8-6-4所示。

6 此时第二层SearchBST,因93比88大,所以执行第16行,再次递归调用SearchBST(T->rchild,key,T,p); 此时T指向了88的右孩子99,如图8-6-5所示。

7 第三层的SearchBST,因93小于99,所以执行第14行,递归调用SearchBST(T->lchild,key,T,p); 此时T指向了99的左孩子93,如图8-6-6所示。

8 第四层 SearchBST,因为key==T->data,所以执行10~11行,此时指针p指向93所在的结点,并返回TRUE到第三层、第二层、第一层,最终函数返回TRUE.

8.6.2 二叉排序树插入操作

看了二叉排序树的查找函数,那么所谓的二叉排序树的插入,其实也就是将关键字放到树中合适的位置而已,来看代码。

//当二叉排序树T中不存在关键字等于key的数据元素时,
//插入key并返回TRUE,否则返回FALSEStatus InsertBST(BiTree *T, int key){BiTree p,s;if( !SearchBST( *T, key ,NULL, &p)){ //查找不成功:p返回不成功最后访问的结点位置s=(BiTree) malloc(sizeof(BiTNode));s->data=key; //值key赋值s结点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;}else return FALSE;//  树中已有关键字相同的结点,不再插入}

这段代码非常简单。如果你调用函数 InsertBST(T,93) 那么结果就是FALSE ,如果是InsertBST(T,95),那么一定就是在93结点增加一个右孩子95,并且返回TRUE。如图8-6-7所示。

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

 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]);}

8.6.3 二叉排序树删除操作

俗话说“请神容易送神难”,我们已经介绍了二叉排序树的查找和插入算法,但是对于二叉排序树的删除,就不是那么容易,我们不能因为删除了结点,而让这棵树变得不满足二叉排序树的特性,所以删除需要考虑多种情况。

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

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

但是对于要删除的结点既有左子树又有右子树的情况怎么办呢? 比如图8-6-10中的结点47 若要删除了,它的两个儿子以及子孙们怎么办呢?(这里增加了结点47下的子孙结点数量)

起初的想法,我们当结点47只有一个左子树,那么做法和一个左子树的操作一样,让35以及它之下的结点成为58的左子树,然后再对47的右子树所有结点进行插入操作,如图8-6-11所示。这是比较简单的做法,可是47的右子树有子孙共5个结点,这么做效率不高不说,还会导致整个二叉排序树结构发生很大的变化,有可能会增加树的高度。增加高度可不是个好事,这我们待会再说,总之这个想法不太好。

我们仔细观察一下,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} ,它们正好是47的前驱和后继。

因此,比较好的做法就是,找到需要删除的结点p的直接前驱(或直接后继)s,用s来代替结点p,然后再删除此结点s,如图8-6-12所示。


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

  • 叶子结点;
  • 仅有左子树或右子树的结点;
  • 左右子树都有的结点。
    我们来看代码,下面这个算法是递归方式对二叉排序树T查找key,查找到时删除。
//若二叉排序树T中存在关键字等于key的数据元素时,则删除该结点
//并返回TRUE,否则返回FALSEStatus DeleteBST( BiTree *T, int key){if( ! * T)  //不存在关键字等于key的数据元素return FALSE;else{if(key==(*T)->data) return Delete(T);//  找到关键字等于key的数据元素else if( key<(*T)->data)return DeleteBST(&(*T)->lchild,key);else return DeleteBST(&(*T)->rchild,key);}}

这段代码和前面的二叉排序树查找几乎完全相同,唯一的区别在于第8行,此时执行第是Delete方法,对当前结点进行删除操作。我们来看Delete的代码

//从二叉排序树中删除结点p,并重接它的左或右子树Status Delete(BiTree *p){BiTree  q,s;if((*p)->rchild ==NULL) {  //右子树为空,只需要重挂接它的左子树q=*p;   (*p)=(*p)->lchild;    free(q);  //先把它赋值给结点q,然后把它左儿子付给它,然后释放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的右子树elseq->lchild=s->lchild; //重接q的左子树free(s);}return TRUE;
}

1 程序开始执行, 代码第4~7行目的是为了删除没有右子树只有左子树的结点。此时只需要将此结点的左孩子替换它自己,然后释放此结点的内存,就等于删除了。

2 代码第8~11行 是同样的道理 ,处理只有右子树而没有左子树的结点。

3第12~25行,处理复杂的左右子树都存在的问题。

4 在第14行,将要删除的结点p赋值给临时变量q,再将p的左孩子 p->lchild 赋值给临时变量s。此时 q指向47结点, s指向35结点,如图8-6-13所示。

5 第15~18行,循环找到左子树的右结点,直到右侧尽头。就当前的例子来所,就是让q指向35,而s指向了37这个再没有右子树的结点,如下图所示。

6 第19行,此时让待删除的结点p的位置的数据被赋值为s->data ,即让 p->data = 37,如下图所示。【用直接前驱覆盖】

7 第20~23行,如果q和p的指向不同,则将s->lchild 赋值给q->rchild,否则就是将s->lchild 赋值给q->lchild,显然这个例子p不等于q,将s->lchild 指向的36 赋值给 q->rchild ,也就是让q->rchild 指向36结点,如下图。

8 第24行,free(s),就非常好理解了,将结点37删除,如下图。

从这段代码可以看出,我们其实是在找删除结点的前驱结点替换,对于用后继结点替换的思路,方法上是一样的。

8.6.4 二叉排序树总结

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

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

也就是说,我们希望二叉排序树是比较平衡的,即其深度与完全二叉树相同,均为⌊log2n⌋+1\lfloor log_2n \rfloor +1⌊log2​n⌋+1,那么查找的时间复杂度就是O(logn),近似于二分查找,事实上,上图中的左图也不够平衡,明显的左重右轻。

不平衡的最坏情况就是像上图右图的斜树,查找时间复杂度为O(n),这等同于顺序查找。

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

最后,记住二叉排序树解决的问题是:

既可以使得插入和删除效果不错,又可以比较高效率地实现查找\color{red}{既可以使得插入和删除效果不错,又可以比较高效率地实现查找 }既可以使得插入和删除效果不错,又可以比较高效率地实现查找

8.7 平衡二叉树(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,则该二叉树就是不平衡的。

看图8-7-2,为什么图1是平衡二叉树,而图2而不是呢?这里要考察的是我们对平衡二叉树定义的理解,它的前提首先是一棵二叉排序树,右上图的59比58大,却是58的左子树,这是不符合二叉排序树的定义的。图3不是平衡二叉树在于结点58的左子树高度为2,而没有右子树,两者之差大于1,因此它是不平衡的。而经过适当调整的ttu4,它就符合平衡二叉树的定义,因此它是平衡二叉树。

距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树,我们 称为最小不平衡子树。 图8-7-3,当新插入结点37时,距离它最近的平衡因子绝对值超过1的结点为58,所以从58开始以下的子树为最小不平衡二叉树。

8.7.1 平衡二叉树实现原理

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

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

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

然后我们再增加结点4,平衡因子没有超过2,如图3.

增加结点5,结点的3的BF值变成了-2,说明要旋转了。由于BF是负值,所以我们对这棵最小不平衡二叉树左旋(逆时针旋转),如图4,此时我们整个树又达到了平衡。

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

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

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

不统一,不统一就把它们先转到符号统一再说,于是我们先对结点9和结点10右旋,使得10变成结点9的右子树,结点8的BF变成-1,此时就和结点7的BF值符号统一了,如图8-7-7的图12所示。

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

相信大家有点明白,所谓的平衡二叉树,其实就是在二叉排序树创建过程中保证它的平衡性,一旦发现有不平衡的情况,马上处理,这样就不会造成不可收拾的情况出现。通过刚才这个例子,你会发现,当最小不平衡子树根结点的平衡因子BF是大于1时,就右旋,小于-1时就左旋。插入结点后,最小不平衡子树的BF与它的子树的BF符号相反时,就需要对结点先进行一次旋转以使得符号相同,之后再反向旋转一次才能够完成平衡操作。

8.7.2 平衡二叉树实现算法

好了,有了这么多的准备工作,我们可以来讲解代码了。首先是需要改进二叉排序树的结点结构,增加一个bf,用来存储平衡因子。

//二叉树的二叉链表结点结构定义typedef struct BiTNode{int data;// 结点数据int bf;// 结点的平衡因子struct BiTNode *lchild, *rchild;//左右孩子指针
}BiTNode, *BiTNode;

然后,对于右旋操作,我们的代码如下。

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

此段代码的意思是说,当传入一个二叉排序树P,将的它的左孩子结点定义为L,将L的右子树变成P的左子树,再将P改成L的右子树,最后将L替换P成为根结点。 这样就完成了一次右旋操作,如图8-7-9所示。图中三角形代笔子树,N代表新增结点。 上面例子中的新增结点N(对应下图中的图1和图2),就是右旋操作。

左旋操作代码如下

 //对以p为根的二叉排序树做左旋处理//处理之后p指向新的树根结点,即旋转处理之前的右子树的根结点R
void L_Rotate(BiTree *p){BiTree R;R=( *p)->rchild;(*p)->rchild= R->rchild;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){case LH: // 新结点插入在T的左孩子的左子树上,要做单右旋处理(*T)->bf=L->bf=EH;R_Rotate(T);break;case RH: //新结点插入在T的左孩子的右子树上,要做双旋处理Lr=L->rchild;// Lr指向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;L_Rotate(*(*T)->lchild); //对T的左子树作左旋平衡处理R_Rotate(T);//对T作右旋平衡处理}}

首先,我们定义了三个常数变量,分别是1、0和-1.

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

2 第4行,我们将T的左孩子赋值给L.

3 第5~27行是分支判断

4 当L的平衡因子为LH,即为1时,表明它与根结点的BF值符号相同,因此,第8行,将 它们的BF值都改成0,并且第8行,进行右旋操作。操作方式如图8-7-9所示。

5 当L的平衡因子为RH,即为-1时,表明它与根节点的BF值符号相反,此时需要作双旋处理。第13~22行代码,针对L的右孩子Lr的BF作判断,修改根结点T的L的BF值。第24行将当前Lr的BF改为0.

6 第25行,对根结点的左子树进行左旋,如图8-7-10第二图所示。

7 第26行,对根结点进行右旋,如图8-7-10第三图所示,完成平衡操作。


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

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

我们前面例子中的新增结点9和8就是典型的右平衡旋转,并且双旋完成平衡的例子(如图9-7-7的图11,12,图8-7-8的图14、15、16)

有了这些准备,我们的主函数才正式登场

//若平衡的二叉排序树T中不存在和e有相同关键字的结点,则插入一个数据元素为额的新结点并返回1,否则返回0.
//若因插入而使二叉排序树失去平衡,则作平衡旋转处理,布尔变量taller反映T长高与否。Status InsertAVL(BiTree *T, int e, Status *taller){if(! *T){//插入新结点,树“长高”,置taller  为TRUE*T=(BiTree) malloc(sizeof(BiNode));(*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;
}

1 程序开始执行时,第3~10行是指当前T为空时,则申请内存新增一个结点。

2 第13~17行表示当存在相同结点,则不需要插入

3 第18~40行,当新结点小于T的跟结点值时,则在T的左子树中查找

4 第20~21行,递归调用本函数,直到找到则返回false,否则说明插入结点成功,继续执行下面的语句。

5 第22~39行,当taller为true时,说明插入了结点(在左子树中),此时需要判断T的平衡因子,如果时1,说明左子树高于右子树,需要调用LeftBalance函数进行左平衡旋转处理。如果为0或者-1,则说明新插入结点没有让整棵二叉排序树失去平衡性,只需要修改相关的BF值即可。

6 第41~63行,说明新结点e大于T的根结点的值,在T的有子树中查找。代码与上面类似。不再赘述。

对于这段代码,我们只需要在需要构建平衡二叉树的时候执行如下列代码即可在内存中生成一棵与图8-7-4的图2相同的平衡的二叉树。

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++){InserAVL(&T,a[i],&taller);
}

终于讲完了,本算法代码较长,是有些复杂,编程中容易在很多细节上出错,要想真正掌握它,需要自己多练习。不过其思想还是不难理解的,总之就是把不平衡消灭在最早时刻。

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

8.8 多路查找树(B树)

内存一般都是由硅制的存储芯片组成,这种技术的每个存储单元单位代价都是比磁盘存储技术昂贵两个数量级,因此基于磁盘技术的外存,容量比内存的容量至少大两个数量级。这也就是目前PC通常内存几个G而已,而硬盘去可以成百上千G容量的原因。

我们前面讨论的数据结构,处理数据都是在内存中,因此考虑的都是内存中的运算时间复杂度。

但如果我们要操作的数据集非常大,达到内存已经没有办法处理了怎么办呢? 如数据库中上千万条记录的数据表、硬盘中上万个文件等。在这种情况下,对数据的处理需要不断从硬盘等存储设备中调入或调出内存页面。

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

试想一下,为了要在一个拥有几十万个文件的磁盘中查找一个文本文件,你设计的算法需要读取磁盘上万次还是读取几十次,这是有本质差别的。此时,为了降低对外存设备的访问次数,我们就需要新的数据结构来处理这样的问题。

我们之前谈到的树,都是一个结点可以有多个孩子,但是它自身只存储一个元素。二叉树限制更多,结点最多只能有两个孩子。

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

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

在这里,每一个结点可以存储多少个元素,以及它的孩子数的多少是非常关键的。为此,我们讲解它的四种特殊形式:2-3树,2-3-4树,B树和B+树。

8.8.1 2-3树

2和3是基本的阿拉伯数字,用它们来命名一种树结构,显然是说明这种结构与数字2和3有密切关系。

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

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

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

并且2-3树中所有叶子结点都在同一层次上。如图8-8-2所示,图中就是一棵有效的2-3树。

2-3树的插入实现

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

2-3树插入可分为三种情况:

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

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

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

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

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

再来看个例子,如图8-8-6所示,需要在左图中插入元素2.

经过遍历可得到元素2比4小,比1大,因此需要插入在拥有1和3元素的3结点位置。与上例一样,你会发现1,3结点,4,6结点,甚至是8,12都是3结点,那就意味着,当前我们的树结构是3层已经不能满足结点增加的要求了。于是将1,3拆分,4,6拆分,甚至根结点8,12也拆分,形成右图所示的样子。

通过这个例子,也让我们发现,如果2-3树插入的传播效应导致了根结点的拆分,则树的高度就会增加。

2-3树的删除实现

对于2-3树的删除来说,如果对前面插入的理解到位的话,应该不是难事儿。2-3树的删除也分为三种情况。与插入相反,我们从3结点说起。

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

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


对于删除叶子是2结点的情况,我们需要分为四种情况来处理。

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

情形二,此结点的双亲是2结点,它的右孩子也是2结点。

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

情形三,此结点的双亲是一个3结点。

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

情形四,如果当前树是一个满二叉树的情况,此时删除任何一个结点都使得整棵树不能满足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来补位即可。

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

当然,如果对2-3树的插入和删除等所有情况讲解,既占篇幅,又没必要,总的来说它是有规律 的,需要你们在上面的这些例子中多去体会后掌握。

8.8.2 2-3-4树

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

由于2-3-4树和2-3树类似,我么你这里就简单介绍一下,如果我们构建一个数组为{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.

8.8.3 B树

我们本节名称叫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个孩子,其中 ⌈m/2⌉≤k≤m(这里是向上取整)\lceil m/2\rceil ≤k≤m(这里是向上取整)⌈m/2⌉≤k≤m(这里是向上取整)
  • 所有叶子结点都位于同一层次。
  • 所有分支结点包含下列信息数据n,A0,K1,A1,K2,A2,...,Kn,Ann,A_0,K_1,A_1,K_2,A_2,...,K_n,A_nn,A0​,K1​,A1​,K2​,A2​,...,Kn​,An​ ,其中 KiK_iKi​为关键字,且Ki<Ki+1,K_i<K_{i+1},Ki​<Ki+1​,Ai为指向子树根结点的指针,且指针Ai−1A_{i-1}Ai−1​所指子树中所有结点的关键字均小于 KiK_iKi​,AnA_{n}An​所指子树中所有结点的关键字均大于KnK_nKn​,n (⌈m/2⌉−1≤n≤m−1(这里是向上取整)\lceil m/2\rceil-1 ≤n≤m-1(这里是向上取整)⌈m/2⌉−1≤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树结构怎么就可以做到减少次数呢?

我们的外存,比如硬盘,是将所有的信息分割成同样大小的页面,每次硬盘读写的都是一个或多个完整的页面,对于一个硬盘来说,一页的长度可能是211到214个字节。

在一个典型的B树应用中,要处理的硬盘数据量非常大,因此无法一次全部装入内存。因此我们会对B树进行调整,使得B树的阶数(或结点的元素)与硬盘存储的页面大小相匹配。比如说一棵B树的阶为1001(即1个结点包含1000个关键字),高度为2,它存储超过10亿个关键字,我们只要让根结点持久地保留在内存中,那么在这棵树上,寻找某一关键字至多需要两次硬盘读取即可。这就好比我们普通人数钱都是一张一张地数,而银行职员数钱则是五张、十张,甚至几十张一数,速度当然是比常人快了不少。

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

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

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

k≤log⌈m2⌉(n+12)+1k≤ log_{\lceil \frac{m}{2}\rceil}(\frac{n+1}{2})+1k≤log⌈2m​⌉​(2n+1​)+1

也就是说,在含有n个关键字的B树上查找时,从根结点到关键字结点路径上涉及的结点数不超过log⌈m2⌉(n+12)+1log_{\lceil \frac{m}{2}\rceil}(\frac{n+1}{2})+1log⌈2m​⌉​(2n+1​)+1。

8.8.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树类似,只不过插入和删除的元素都是在叶子结点上进行而已。

8.9 散列表(哈希表)查找概述

能够直接通过关键字key得到要查找的记录的内存存储的位置呢?

8.9.1 散列表查找定义

我们需要某个函数 f,使得

存储位置= f ( 关键字 )

那样我们就可以通过查找关键字不需要比较就能获得需要的记录的存储位置。这是一种新的存储技术—散列技术。

散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key) 。 查找时,根据这个确定的对应关系找到给定值key 的映射f(key),若查找集合中存在这个记录,则必定在f(key) 的位置上。

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

8.9.2 散列表查找步骤

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

1)在存储时,通过散列函数计算记录的散列地址,并按照此散列地址存储该记录。不管是什么记录,我们需要用同一个散列函数计算出地址再存储。

2)当查找记录时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。说起来很简单,在哪儿存的,就到哪儿找,由于存取用的是同一个散列函数,因此结果当然是相同的。

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

散列技术最适合的求解问题是查找与给定值相等的记录。 对于查找来说,简化了比较过程,效率就会大大提高。但万事有利就有弊,散列技术不具备很多常规数据结构的能力。

比如那种同样的关键字,它能对应很多记录的情况,却不适合用散列技术。一个班级几十个人,他们的性别有男有女,你用关键字男去查找,对应的有许多学生的记录,这显示是不合适的。只有用如学号或者身份证号来散列存储,此时一个号码唯一对应一个学生。

同样散列表也不适合范围查找,比如查找一个班级18~22岁的同学,在散列表中无法进行。想获得表中记录的排序也不可能,像最大值、最小值等结果也都无法从散列表中计算出来。

我们说了这么多,散列函数应该如何设计? 这个我们需要重点来讲解,总之设计一个简单、均匀、存储利用率高的散列函数是散列技术中最关键的问题。

另一个问题是冲突。在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是一个理想。我们时常会碰到两个关键字 key1≠ key2,但是f(key1) = f(key2) ,这种现象我们称为冲突(collision),并把key1和key2称为这个散列函数的同义词(synonym)。出现了冲突当然非常糟糕,那就造成数据查找错误。经过我们可以通过精心设计的散列函数让冲突尽可能少,但是不能完全避免。于是如何处理冲突就成了一个重要的课题,后面会有详细讲解。

8.10 散列函数的构造方法

不管做什么事要达到最优都不容易,既要付出尽可能的少,又要得到最大化的多。那么什么才是好的散列函数呢? 这里我们有两个原则可以参考。
1 计算简单

散列函数的计算时间不应该超过其他查找技术与关键字比较的时间。

2散列地址分布均匀

我们刚才也提到冲突带来的问题,最好的办法就是尽量让散列均匀地分布在存储空间中,这样可以保证存储空间的有效利用,并减少为处理冲突而耗费的时间。

接下来我们介绍几种常用的散列函数构造方法。

8.10.1 直接定址法

我们现在要对0~100岁的人口数字统计,如表8-10-1所示,那么我们对年龄这个关键字就可以直接用年龄的数字作为地址。此时f(key)=key.

如果我们现在要统计的是80后出生年份的人口数,如下表。那么我们对出生年份这个关键字可以用年份减去1980来作为地址。此时f(key)=key-1980.

也就是说,我们可以取关键字的某个线性函数值作为散列地址,即
f(key)=a×key+b,(a,b为常数)f(key)= a \times key +b ,(a,b为常数)f(key)=a×key+b,(a,b为常数)

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

8.10.2 数字分析法

如果我们的关键字是位数较多的数字,比如我们的11为手机号,其中前三位是接入号,一般对应不同运营商公司的子品牌,比如130是联通如意通,136是移动神州行,153是电信等。中间四位是HLR识别号,表示用户号的归属地;后四位才是真正的用户号,如下表。

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

这里我们提到了一个关键词—抽取。抽取方法是使用关键字的一部分来计算散列存储位置的方法,这在散列函数中是经常用到的手段。

数字分析法适合处理关键字位数比较多的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。

8.10.3 平方取中法

这个方法计算很简单,假设关键字是1234,它的平方是1522756,再抽取中间的3位就是227,用作散列地址。 再比如关键字4321,那么它的平方就是18671041,抽取中间的3位就可以是671或者是710,用作散列地址。平方取中法比较适用于不知道关键字的分布,而位数又不是很大的情况。

8.10.4 折叠法

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

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

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

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

8.10.5 除留余数法

此方法为最常用的构造散列函数的方法。对于散列表长为m的散列函数公式为

f(key)=keymodp(p≤m)f ( key )= key \mod \quad p \quad (p≤ m)f(key)=keymodp(p≤m)

mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,还可以在折叠、平方取中后再取模。

很显然,该方法的关键在于选择合适的p,p如果选得不好,就可能会容易产生同义词。

例如表8-10-4,我们对于有12个记录的关键字构造散列表,就用了f(key)= key mod 12 的方法,比如 29 mod 12 =5 ,所以它存储在下标为5的位置。


不过这也会存在冲突, 如下表,p=12,此时下标全部是0,这就极为糟糕。

我们不选 p=12来做除留余数法,而选用p=11,如下表

此时只有12和144有冲突,相对来说,就要好很多。

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

8.10.6 随机数法

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

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

总之,现实中,应该视不同的情况采用不同的散列函数。我们只能给出一些考虑因素来提供参考:

  1. 计算散列地址所需要的时间
  2. 关键字的长度
  3. 散列表的大小
  4. 关键字的分布情况
  5. 记录查找的频率。

综合这些因素,才能决策选择哪种散列函数更合适。

8.11 处理散列冲突的方法

冲突其实是不能避免的,要考虑怎么来处理。

8.11.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}时,都是没有冲突的散列地址,直接存入,如下表。

计算key=37时,发现f(37)=1,此时就和25的位置冲突。于是我们应用上面的公式 f(37)=(f(37)+1) mod 12 =2 ,于是将37存入下标为2的位置。 如下表

接下来22,29,15,47都没有冲突,正常的存入,如下表。

到了 key=48, 我们计算得到f(48)=0,冲突,不要紧 , f(48)=(f(48)+1) mod 12 =1 ,还是冲突。 再来, f(48)=(f(48)+2) mod 12 =2,冲突…,一直到 f(48)=(f(48)+6) mod 12 =6 时 ,才有空位,机不可失,赶快存入,如下表。

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

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

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

还有一种方法是,在冲突时,对于位移量 di采用随机函数得到,我们称之为随机探测法。

此时一定有人会问,既然是随机,那么查找的时候不也随机生成di吗? 如何可以获得相同的地址? 这是一个问题。这里的随机其实是伪随机数。 伪随机数是说, 如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的di当然可以得到相同的散列地址。

总之,开放定址法只要在散列表未满时,总是能找到不发生冲突的地址,是我们常用的解决冲突的方法。

8.11.2 再散列函数法

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

这里RHi就是不同的散列函数,你可以把我们前面说的什么除留余数、折叠、平方取中全部用上。每当发生散列地址冲突时,就换一个散列函数计算,相信总会有一个可以把冲突解决掉。这种方法能够使得关键字不产生聚集,当然,相应地增加了计算的时间。

8.11.3 链地址法

思路还是可以再换一换,为什么有冲突就要换地方呢,我们直接就在原地想办法不行吗? 于是我们就有了链地址法。

所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有 同义词子表的头指针。 对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},我们用前面同样的12为除数,进行除留余数法,可以得到下图的结构,此时,已经不存在什么冲突换址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点。


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

8.11.4 公共溢出区法

这个方法其实更好理解,你不是冲突吗?好吧,凡是冲突的都跟我走,我给你们这些冲突找个地儿待着。这就如同孤儿院收留很多无家可归的孩子一样,我们为所有冲突的关键字建立一个公共的溢出区来存放。

就前面的例子而言,我们共有三个关键字{37,48,34}以之前的关键字位置有冲突,那么就将它们存储在溢出表,如下图。

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

8.12 散列表查找实现

说了这么多散列表查找的思想,我们就来看看查找的实现代码

8.12.1 散列表查找算法实现

首先需要定义一个散列表的结构以及一些相关的常数,其中HashTable就是散列表结构。结构当中的elem为一个动态数组。

#define  SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12
#define NULLKEY  -32768typedef 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 SUNCESS;}

8.12.2 散列表查找性能分析

最后,我们对散列表查找的性能做一个简单的分析。如果没有冲突,散列查找是我们本章介绍的所有查找里面效率最高的,散列查找时间复杂度为O(1),这是在没有冲突的情况下。但是在实际情况下,冲突是不可避免的。那么散列查找的平均查找长度取决于哪些因素呢?

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

2 处理冲突的方法

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

3 散列表的装填因子

所谓的装填因子 α =填入表中的记录个数散列表长度\frac{填入表中的记录个数}{散列表长度}散列表长度填入表中的记录个数​。 α标志着散列表的装满程度。 当填入表中的记录越多,α就越大,产生冲突的可能性就越大。比如我们前面的例子,如图8-11-5,如果你的散列表长度是12,而填入表中的记录个数为11,那么此时的装填因子α =11/12=0.9167,再填入最后一个关键字产生冲突的可能性就非常之大。 也就是说,散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数

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

散列函数测试代码

#include "stdio.h"
#include "stdlib.h"
#include "io.h"
#include "math.h"
#include "time.h"#define OK 1
#define ERROR 0
#define TRUE 1
#define FALSE 0#define MAXSIZE 100 /* 存储空间初始分配量 */#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 /* 定义散列表长为数组的长度 */
#define NULLKEY -32768 typedef int Status;  /* Status是函数的类型,其值是函数结果状态代码,如OK等 */ 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; /* 除留余数法 */
}/* 插入关键字进散列表 */
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;
}int main()
{int arr[HASHSIZE]={12,67,56,16,25,37,22,29,15,47,48,34};int i,p,key,result;HashTable H;key=39;InitHashTable(&H);for(i=0;i<m;i++)InsertHash(&H,arr[i]);result=SearchHash(H,key,&p);if (result)printf("查找 %d 的地址为:%d \n",key,p);elseprintf("查找 %d 失败。\n",key);for(i=0;i<m;i++){key=arr[i];SearchHash(H,key,&p);printf("查找 %d 的地址为:%d \n",key,p);}return 0;
}

测试结果

查找 39 失败。
查找 12 的地址为:0
查找 67 的地址为:7
查找 56 的地址为:8
查找 16 的地址为:4
查找 25 的地址为:1
查找 37 的地址为:2
查找 22 的地址为:10
查找 29 的地址为:5
查找 15 的地址为:3
查找 47 的地址为:11
查找 48 的地址为:6
查找 34 的地址为:9

《大话数据结构》读书笔记-查找相关推荐

  1. 大话数据结构读书笔记艾提拉总结 查找算法 和排序算法比较好 第1章数据结构绪论 1 第2章算法 17 第3章线性表 41 第4章栈与队列 87 第5章串 123 第6章树 149 第7章图 21

    大话数据结构读书笔记艾提拉总结 查找算法 和排序算法比较好 第1章数据结构绪论 1 第2章算法 17 第3章线性表 41 第4章栈与队列 87 第5章串 123 第6章树 149 第7章图 211 第 ...

  2. 大话数据结构读书笔记系列(三)线性表

    2019独角兽企业重金招聘Python工程师标准>>> 3.1 开场白 各位同学,大家好.今天我们要开始学习数据结构中最常用和最简单的一种结构,在介绍它之前先讲个例子. 我经常下午去 ...

  3. 大话数据结构读书笔记系列(七)图

    2019独角兽企业重金招聘Python工程师标准>>> 7.1 开场白 旅游几乎是每个年轻人的爱好,但没有钱或没时间也是困惑年轻人不能圆梦的直接原因.如果可以用最少的资金和最少的时间 ...

  4. 大话数据结构读书笔记系列(五)串

    2019独角兽企业重金招聘Python工程师标准>>> 5.1 开场白 同学们,大家好!我们开始上新的一课. 我们古人没有电影电视,没有游戏网络,所以文人们就会想出一些文字游戏来娱乐 ...

  5. [大话数据结构-读书笔记] 栈

    栈 1 栈的定义 1.1 栈的定义 在我们软件应用中,栈这种后进先出数据结构的应用是非常普遍的.比如Word. Photoshop 等文档或图像编辑软件中, 都有撤销 (undo)的操作,也是用栈这种 ...

  6. 大话设计模式读书笔记

    主题 概要 设计模式 大话设计模式读书笔记 编辑 时间 新建 20170423 序号 参考资料 1 大话设计模式 重新看了一遍设计模式,除了一些已经特别熟悉的模式,都自己敲了一遍代码,有些豁然开朗的感 ...

  7. 《大话数据结构》笔记——第8章 查找(二)

    文章目录 8.6 二叉排序树 8.6.1 二叉排序树查找操作 8.6.2 二叉排序树插入操作 8.6.3 二叉排序树删除操作 8.6.4 二叉排序树总结 8.7 平衡二叉树( AVL树 ) 8.7.1 ...

  8. 《大话数据结构》笔记——第8章 查找(一)

    文章目录 8.1 开场白 8.2 查找概述 8.3 顺序查找 8.3.1 顺序表查找算法 8.3.2 顺序表查找优化 8.4 有序表查找 8.4.1 折半查找 8.4.2 插值查找 8.4.3 斐波那 ...

  9. 大话数据结构之图-查找算法(C++)

    大话数据结构 Unit7 查找 查找算法举例 代码 #include<iostream> using namespace std;//顺序查找 //a为数组,n为数组长度,key为关键字 ...

最新文章

  1. DOT:视觉SLAM的动态目标物跟踪
  2. SAP WM LT42创建TO单据,报错-No entry in Table 329S (NM1 L)-
  3. Material Design 设计规范总结(2)
  4. nvidia 程序安装失败
  5. WPF 4 日历控件(Calendar)
  6. IBM发明世界首个人造神经元,人工智能的底层硬件基石已完成!
  7. python遍历目录压缩文件夹_Python实现多级目录压缩与解压文件的方法
  8. Java split拆分使用竖线为分隔符的字符串方法
  9. 启动Eclipse时,启不起来JVM terminated. Exit code=-1
  10. .net Kafka.Client多个Consumer Group对Topic消费不能完全覆盖研究总结(一)
  11. element table 组件内容换行方案
  12. 前端学习(2429):上午回顾
  13. 什么是计算机系统计算机硬件和计算机软件,什么是计算机系统、计算机硬件和计算机软件?硬件和软件哪个更重要?...
  14. easyPR源码解析之chars_segment.h
  15. 关于 shell 脚本编程的10 个最佳实践
  16. 星巴克又出事 被强制执行1087万!网友:欠租了吗?
  17. 【Linux】tmpfs简介及增加方式
  18. Winform 中tabcontrol 美化
  19. 哨兵卫星影像数据Sentinel-1基本参数
  20. 系统分析师考试大纲(2009版)

热门文章

  1. python2.7下同步华为云照片的爬虫程序实现
  2. JS将日期转换为yyyy-MM-dd HH:mm:ss
  3. ora-14400插入的分区关键字未映射到任何分区---oracle数据库表过期问题
  4. POJ 1057 File Mapping 最详细的解题报告
  5. zookeeper C API
  6. 上证所Level-2在信息内容和传送方式方面的比较优势[逐笔数据与分笔数据的根本区别]...
  7. 计算氦原子的基态能级
  8. 机器人焊枪动作与编程实验_机器人编程实验报告.pdf
  9. 4.6 什么是神经风格迁移-深度学习第四课《卷积神经网络》-Stanford吴恩达教授
  10. STM32 电机教程 11 - BLDC 6 步方波开环速度控制