数据结构—查找(第八章)

目录

1. 查找概论

2. 顺序表查找

2.1 顺序表查找算法

2.2 顺序表查找优化

3. 有序表查找

3.1 折半查找

3.2 插值查找

3.3 斐波那契查找

4. 线性索引查找

4.1 稠密索引

4.2 分块索引

4.3 倒排索引

5. 二叉排序树

5.1 二叉排序树查找操作

5.2 二叉排序树插入操作

5.3 二叉排序树删除操作

5.4 二叉排序树总结

6. 平衡二叉树( AVL树 )

6.1 平衡二叉树实现原理

6.2 平衡二叉树实现算法

7. 多路查找树( B树 )

7.1 2-3树

7.2  2-3-4 树

7.3  B 树

7.4 B+树

8. 散列表查找( 哈希表 )概述

8.1 散列表查找定义

8.2 散列表查找步骤

9. 散列函数的构造方法

9.1 直接定制法

9.2 数字分析法

9.3 平方取中法

9.4 折叠法

9.5 除留余数法

9.6 随机数法

10. 处理散列冲突的方法

10.1 开放地址法

10.2 再散列函数法

10.3 链地址法

10.4 公共溢出区法

11. 散列表查找实现

11.1 散列表查找算法实现

11.2 散列表查找性能分析

12. 总结回顾


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

 相信能看到这篇博客的人都用过搜索引擎。那么,你知道它的大概工作原理吗?

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

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

数据结构—查找(第八章)_第1张图片

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

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

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

1. 查找概论

只要你打开电脑,就会涉及到查找技术。如炒股软件中查股票信息、硬盘文件中找照片、在光盘中搜 DVD ,甚至玩游戏时在内存中查找攻击力、魅力值等数据修改用来作弊等,都要涉及到查找。当然,在互联网上査找信息就更加是家常便饭。所有这些需要被查的数据所在的集合,我们给它一个统称叫查找表

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

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

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

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

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

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

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

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

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

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

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

按照我们大多数人的理解,查找,当然是在已经有的数据中找到我们需要的。静态查找就是在干这样的事情,不过,现实中还有存在这样的应用:查找的目的不仅仅只是查找。

比如网络时代的新名词,如反应年轻人生活的 “蜗居” 、 “蚁族” 、 “孩奴” 、 “啃老” 等,以及 “X 客” 系列如博客、播客、闪客、黑客、威客等,如果需要将它们收录到汉语词典中,显然收录时就需要查找它们是否存在,以及找到如果不存在时应该收录的位置。再比如,如果你需要对某网站上亿的注册用户进行清理工作,注销一些非法用户,你就需查找到它们后进行删除,删除后其实整个查找表也会发生变化。对于这样的应用,我们就引入了动态査找表

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

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

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

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

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

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

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

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

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

2.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 ,即说明 a[1]〜a[n] 中没有关键字 key ,查找失败。

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

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

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

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

3. 有序表查找

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

3.1 折半查找

曾经提到过一个小游戏,我在纸上已经写好了一个 100 以内的正整数数字请你猜,问几次可以猜出来,当时已经介绍了如何最快猜出这个数字。我们把这种每次取中间记录查找的方法叫做折半查找,如下图所示:

数据结构—查找(第八章)_第3张图片

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

假设我们现在有这样一个有序表数组 { 0, 1, 16, 24, 35, 47, 59, 62, 73, 88, 99 } ,除 0 下标外共 10 个数字。对它进行查找是否存在 62 这个数。我们来看折半查找的算法是如何工作的。

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

1. 程序开始运行,参数 a={ 0, 1, 16, 24, 35, 47, 59, 62, 73, 88, 99 } n=10 key=62 ,第 4〜6 行,此时low=1 high=10 ,如下图所示:

2. 7〜22 行循环,进行查找。

3.9 行,mid 计算得 5 ,由于 a[5]=47 ,所以执行了第 17 行, low=5+1=6 ,如下图所示:

数据结构—查找(第八章)_第4张图片

4. 再次循环,mid=(6+10)/2=8 ,此时 a[8]=73>key ,所以执行第 12 行, high=8-1=7 ,如下图所示:

数据结构—查找(第八章)_第5张图片

5. 再次循环,mid=(6+7)/2=6 ,此时 a[6]=59 ,所以执行 17 行, low=6+1=7 ,如下图所示:

数据结构—查找(第八章)_第6张图片

6. 再次循环,mid=(7+7)/2=7 ,此时 a[7]=62=key ,査找成功,返回 7

该算法还是比较容易理解的,同时我们也能感觉到它的效率非常高。但到底高多少?关键在于此算法的时间复杂度分析。

首先,我们将这个数组的查找过程绘制成一棵二叉树,如下图所示,从图上就可以理解,如果查找的关键字不是中间记录 47 的话,折半查找等于是把静态有序查找表分成了两棵子树即查找结果只需要找其中的一半数据记录即可,等于工作量少了一半,然后继续折半查找,效率当然是非常高了。

数据结构—查找(第八章)_第7张图片

对于一个二叉树的性质:“ 具有 n 个结点的完全二叉树的深度为 [log_{2}n]+1 ” (注:[x] 表示不大于 x 的最大整数)。在这里尽管折半查找判定二叉树并不是完全二叉树,但同样相同的推导可以得出,最坏情况是查找到关键字或查找失败的次数为 [log_{2}n]+1 。最好的情况当然是 1 次了。

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

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

3.2 插值查找

现在出现了一个新问题:为什么一定要折半,而不是折四分之一或者折更多呢?

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

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

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

折半查找代码的第 9 句,我们略微等式变换后得到:

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

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

1/2 改成了 有什么道理呢?假设 a[11]={ 0, 1, 16, 24, 35, 47, 59, 62, 73, 88, 99 }low=1 high = 10 ,则 a[low]=1a[high]=99 ,如果我们要找的是 key=16 时,按原来折半的做法,我们需要四次(下图所示)才可以得到结果:

但如果用新办法, ,即 取整得到 mid=2 ,我们只需要二次就查找到结果了,显然大大提高了查找的效率。

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

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

就得到了另一种有序表查找算法,插值查找法

插值査找( Interpolation Search )是根据要查找的关键字 key 与查找表中最大最小记录的关键字比较后的查找方法其核心就在于插值的计算公式

应该说,从时间复杂度来看,它也是 O(logn) 但对于表长较大,而关键字分布又比较均匀的查找表来说,插值查找算法的平均性能比折半查找要好得多。反之,数组中如果分布类似 {0,1,2,2000,2001,……, 999998, 999999 } 这种极端不均匀的数据,用插值查找未必是很合适的选择

3.3 斐波那契查找

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

斐波那契数列在另一篇博客中能详细的介绍了(博客链接,点我!)。为了能够介绍清楚这个查找算法,我们先需要有一个斐波那契数列的数组,如下图所示:

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

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

1. 程序开始运行,参数 a={ 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, …… }

数据结构—查找(第八章)_第8张图片

2. 7〜11 行是计算当前的 n 处于斐波那契数列的位置。现在 n=10F[6] ,所以计算得出 k=7

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

4. 16〜40 行査找正式开始。

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

6. 由于此时 key=59 a[8]=73 ,因此执行第 21〜22 行,得到 high=7 k=6

数据结构—查找(第八章)_第9张图片

7. 再次循环, mid=1 + F[6-1]-1=5 。此时 a[5]=47 ,因此执行第 26〜27 行,得到 low=6 , k=6-2=4 。注意此时 k 下调 2 个单位。

8. 再次循环, mid=6 + F[4-1]-1=7 。此时 a[7]=62>key ,因此执行第 21〜22 行,得到 high=6 , k=4-1=3

数据结构—查找(第八章)_第10张图片

9. 再次循环, mid=6 + F[3-1]-1=6 。此时 a[6]=59=key ,因此执行第 31〜34 行,得到返回值为 6 。程序运行结束。

如果 key=99 ,此时查找循环第一次时, mid=8 与上例是相同的,第二次循环时, mid=11 ,如果 a[11] 没有值就会使得与 key 的比较失败,为了避免这样的情况出现, 第 12〜15 行的代码就起到这样的作用。

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

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

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

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

数据结构—查找(第八章)_第11张图片

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

还有比较关键的一点,折半查找是进行加法与除法运算 ,插值查找进行复杂的四则运算 ,而斐波那契查找只是最简单加减法运算 ,在海量数据的查找过程中,这种细微的差别可能会影响最终的查找效率。

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

4. 线性索引查找

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

数据结构—查找(第八章)_第12张图片

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

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

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

4.1 稠密索引

老年人年纪大了,记忆力不好,经常在家里找不到东西,于是一些老年人想到了一个办法。她用一小本子记录了家里所有小东西放置的位置,比如户口本放在右手床头柜下面抽屉中,针线放在电视柜中间的抽屉中,钞票放在衣柜…… 。总之,老人家把这些小物品的放置位置都记录在了小本子上,并且每隔一段时间还按照本子整理一遍家中的物品,用完都放回原处,这样她就几乎再没有找不到东西。

假如她的孩子申请职称时,单位一定要她孩子的大学毕业证,她孩子在家里找了很长时间未果,急得要死。和她老妈一说,她的神奇小本子马上发挥作用,一下子就找到了,原来被她整理后放到了衣橱里的抽屉里。

从这件事就可以看出,家中的物品尽管是无序的,但是如果有一个小本子记录,寻找起来也是非常容易,而这小本子就是索引

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

数据结构—查找(第八章)_第13张图片

刚才的小例子和稠密索引还是略有不同,家里的东西毕竟少,小本子再多也就几十页,全部翻看完就几分钟时间,而稠密索引要应对的可能是成千上万的数据,因此对于稠密索引这个索引表来说,索引项一定是按照关键码有序的排列

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

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

4.2 分块索引

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

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

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

■   块内无序,即每一块内的记录不要求有序。当然,你如果能够让块内有序对查找来说更理想,不过这就要付出大量时间和空间的代价,因此通常我们不要求块内有序。

■   块间有序,例如,要求第二块所有记录的关键字均要大于第一块中所有记录的关键字,第三块的所有记录的关键字均要大于第二块的所有记录关键字…… 因为只有块间有序,才有可能在查找时带来效率。

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

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

   最大关键码,它存储每一块中的最大关键字,这样的好处就是可以使得在它之后的下一块中的最小关键字也能比这一块最大的关键字要大;

■   存储了块中的记录个数,以便于循环时使用

■   用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历

数据结构—查找(第八章)_第14张图片

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

1. 在分块索引表中查找要查关键字所在的块。由于分块索引表是块间有序的, 因此很容易利用折半、插值等算法得到结果。例如,在上图的数据集中查找 62 ,我们可以很快可以从左上角的索引表中由 57<62<96 得到 62 在第三个块中。

2. 根据块首指针找到相应的块,并在块中顺序查找关键码。因为块中可以是无序的,因此只能顺序查找。

应该说,分块索引的思想是很容易理解的,我们通常在整理书架时,都会考虑不同的层板放置不同类别的图书。例如,有些人家里就是最上层放不太常翻阅的小说书,中间层放经常用到的如菜谱、字典等生活和工具用书,最下层放大开本比较重的计算机书。这就是分块的概念,并且让它们块间有序了。至于上层中《红楼梦》是应该放在 《三国演义》的左边还是右边,并不是很重要。毕竟要找小说《三国演义》,只需要对这一层的图书用眼睛扫过一遍就能很容易查找到。

我们再来分析一下分块索引的平均查找长度。

n 个记录的数据集被平均分成 m 块,每个块中有 t 条记录,显然 ,或者说 。再假设 L_{b} 为查找索引表的平均查找长度,因最好与最差的等概率原则,所以 L_{b} 的平均长度为 。L_{w} 为块中查找记录的平均查找长度,同理可知它的平均查找长度为 。

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

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

可见,分块索引的效率比之顺序查找的 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” 以及如 “A” 这样的大小写差异。我们可以整理出这样一张单词表,如下表所示,并将单词做了排序,也就是表格显示了每个不同的单词分别出现在哪篇文章中,比如 “good” 它在两篇文章中都有出现,而 “is” 只是在文章 2 中才有。

数据结构—查找(第八章)_第15张图片

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

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

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

■    次关键码,例如上面的 “英文单词” ;

■    记录号表,例如上面的 “文章编号” 。    .

其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的主关键字)。这样的索引方法就是倒排索引( inverted index )。

倒排索引源于实际应用中需要根据属性(或字段、次关键码)的值来查找记录。这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址。由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排索引。

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

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

如果文章是中文就更加复杂。比如文章中出现 “中国人” ,它本身是关键字,那么“中国”、“国人”也都可能是要查找的关键字——啊,太复杂了,你还是自己去找相关资料吧。如果想彻底明白,努力进入 google 或者百度公司做搜索引擎的软件工程师,我想他们会满足你对技术知识的渴求。

5. 二叉排序树

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

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

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

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

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

数据结构—查找(第八章)_第16张图片

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

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

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

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

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

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

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

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

5.1 二叉排序树查找操作

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

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

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

#define TRUE 1
#define FALSE 0
typedef int Status; //创建子函数返回类型 
/* 递归查找二叉排序树 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); /*在右子树继续查找 */
	}
}

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

2. 9〜13 行,是用来判断当前二叉树是否到叶子结点,显然上图告诉我们当前 T 指向根结点 62 的位置,T 不为空,第 11〜12 行不执行。

3. 14〜18 行是查找到相匹配的关键字时执行语句,显然 93≠62 ,第 16〜17 行不执行。

4. 19〜22 行是当要查找关键字小于当前结点值时执行语句,由于 93>62 ,第 21 行不执行。

5. 23〜26 行是当要查找关键字大于当前结点值时执行语句,由于 93>62 ,所以递归调用 SearchBST(T->rchild, key, T, p) 。此时 T 指向了 62 的右孩子 88 ,如下图所示:

数据结构—查找(第八章)_第17张图片

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

数据结构—查找(第八章)_第18张图片

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

数据结构—查找(第八章)_第19张图片

8. 第四层 SearchBST ,因 key 等于 T->data ,所以执行第 16〜17 行,此时指针 p 指向 93 所在的结点,并返回 True 到第三层、第二层、第一层,最终函数返回 True

5.2 二叉排序树插入操作

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

#define TRUE 1
#define FALSE 0
typedef int Status; //创建子函数返回类型 
/* 当二叉排序树T中不存在关键字等于 key 的数据元素时,*/
/* 插入 key 并返回 TRUE ,否則返回 FALSE */
Status InsertBST(BiTree *T,int key)
{
	BiTree p, s;
	if (!SearchBST(*T, key, NULL, &p))	/* 查找不成功 */
	{
		s = (BiTree)malloc(sizeof (BiTNode));
		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;
	}
	else
		return FALSE;	/* 树中已有关键字相同的结点,不再插入 */
}

这段代码非常简单。如果你调用函数是 “InsertBST(T, 93);” ,那么结果就是 FALSE ,如果是 “InsertBST (T,95);” ,那么一定就是在 93 的结点增加一个右孩子 95 ,并且返回 True 。如下图所示:

数据结构—查找(第八章)_第20张图片

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

在你的大脑里,是否已经有一幅随着循环语句的运行逐步生成这棵二叉排序树的动画图案呢?如果不能,那只能说明你还没真理解它的原理哦。

5.3 二叉排序树删除操作

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

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

数据结构—查找(第八章)_第21张图片

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

但是对于要删除的结点既有左子树又有右子树的情况怎么办呢?比如下图中的 47 结点若要删除了,它的两儿子以及子孙们怎么办呢?

数据结构—查找(第八章)_第22张图片

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

数据结构—查找(第八章)_第23张图片

我们仔细观察一下,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 ,如下图所示:

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

■    叶子结点;

■    仅有左或右子树的结点;

■    左右子树都有的结;

我们来看代码,下面这个算法是递归方式对二叉排序树 T 査找 key ,查找到时删除。

typedef int Status; //创建子函数返回类型 
/* 若二叉排序树T中存在关键字等于 key 的数据元素时,则删除该数据元素结点,*/
/* 并返回 TRUE ;否則返回 FALSE */
Status DeleteBST(BiTree *T, int key)
{
	if(!*T) /* 不存在关键字等于 key 的数据元素 */
	{
		return FALSE;
	}
	else
	{
		if(key == (*T)->data) /* 找到关键字等于 key 的数据元素 */
		{
			return Delete(T);
		}
		else if(key < (*T)->data)
		{
			return DeleteBST(&(*T)->lchild, key);
		}
		else 
		{
			return DeleteBST(&(*T)->rchild, key);
		}
	}
}

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

typedef int Status; //创建子函数返回类型 
/*从二叉排序树中删除结点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->lchilds = s->lchild;	/* 重接 q 的左子树 */
		}
		free(s);
	}
	return TRUE;
}

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

2. 代码第 12〜17 行是同样的道理处理只有右子树没有左子树的结点删除问题。

3. 18〜37 行处理复杂的左右子树均存在的问题。

4. 20~21 行,将要删除的结点 p 赋值给临时的变量 q ,再将 p 的左孩子 p->lchild 赋值给临时的变量 s 。此时 q 指向 47 结点, s 指向 35 结点,如下图所示:

数据结构—查找(第八章)_第24张图片

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

6. 27 行,此时让要删除的结点 p 的位置的数据被赋值为 s->data ,即让 p->data=37 ,如下图所示:

数据结构—查找(第八章)_第25张图片

7. 28〜35 行,如果 pq 指向不同,则将 s->lchild 赋值给 q->rchild ,否则就是将 s->lchild 赋值给 q->lchild 。显然这个例子 p 不等于 q ,将 s->lchild 指向的 36 赋值给 q->rchild ,也就是让 q->rchild 指向 36 结点,如下图所示:

8. 36 行,free(s) ,就非常好理解了,将 37 结点删除,如下图所示:

数据结构—查找(第八章)_第26张图片

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

5.4 二叉排序树总结

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

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

数据结构—查找(第八章)_第27张图片

也就是说,我们希望二叉排序树是比较平衡的,即其深度与完全二叉树相同,均为 [log_{2}n]+1 ,那么查找的时间复杂也就为 O(logn) 近似于折半查找,事实上,上图的左图也不够平衡,明显的左重右轻。

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

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

6. 平衡二叉树( AVL树 )

我在网络上,看到过一部德国人制作的叫《平衡》(英文名:Balance)的短片, 它在 1989 年获得奥斯卡最佳短片奖。说的是在空中,悬浮着一个四方的平板,上面站立着 5 个人,同样的相貌,同样的装束,同样的面无表情。平板的中心是个看不见的支点,为了平衡, 5 个人必须寻找合适的位置。原本,简单的站在中心就可以了,可是,如同我们一样,他们也好奇于这个世界,想知道下面是什么样子。而随着一个箱子的来临,这种平衡被打破了,箱子带来了音乐,带来了兴奋,也带来了不平衡,带来了分歧和斗争。

平板就是一个世界,当诱惑降临,当人心中的平衡被打破,世界就会混乱,最后留下的只有孤独寂寞失败。这种单调的机械化社会,禁不住诱惑的侵蚀,很容易崩溃。最容易被侵蚀的,恰恰是最空虚的心灵

这里我们主要是讲与平衡这个词相关的数据结构——平衡二叉树

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

有两位俄罗斯数学家 G.M.Adelson-VelskiiE.M.Landis1962 年共同发明一种解决平衡二叉树的算法,所以有不少资料中也称这样的平衡二叉树为 AVL 树

从平衡二叉树的英文名字,你也可以体会到,它是一种髙度平衡的二叉排序树。 那什么叫做高度平衡呢?意思是说,要么它是一棵空树,要么它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子 BF ( Balance Factor ),那么平衡二叉树上所有结点的平衡因子只可能是 -1 01 。只要二叉树上有一个结点的平衡因子的绝对值大于 1 ,则该二叉树就是不平衡的。

看下图,为什么 图1 是平衡二叉树,而 图2 却不是呢?这里就是考查我们对平衡二叉树的定义的理解,它的前提首先是一棵二叉排序树,右上图的 5958 大, 却是 58 的左子树,这是不符合二叉排序树的定义的。图3 不是平衡二叉树的原因就在于,结点 58 的左子树高度为 2 ,而右子树为空,二者差大于了绝对值 1 ,因此它也不是平衡的。而经过适当的调整后的 图4 ,它就符合了定义,因此它是平衡二叉树。

数据结构—查找(第八章)_第28张图片

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

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

数据结构—查找(第八章)_第29张图片

6.1 平衡二叉树实现原理

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

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

数据结构—查找(第八章)_第30张图片

对于数组 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 ,此时结点 7BF 变成了 -2 ,理论上我们只需要旋转最小不平衡子树 7910 即可,但是如果左旋转后,结点 9 就成了 10 的右孩子,这是不符合二叉排序树的特性的,此时不能简单的左旋,如 图11 所示。

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

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

数据结构—查找(第八章)_第31张图片

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

西方有一句民谣是这样说的:“丢失一个钉子,坏了一只蹄铁;坏了一只蹄铁,折了一匹战马;折了一匹战马,伤了一位骑士;伤了一位骑士,输了一场战斗;输了一场战斗,亡了一个帝国。”相信大家应该有点明白,所谓的平衡二叉树,其实就是在二叉排序树创建过程中保证它的平衡性,一旦发现有不平衡的情况,马上处理,这样就不会造成不可收拾的情况出现。通过刚才这个例子,你会发现,当最小不平衡子树根结点的平衡因子 BF 是大于 1 时,就右旋,小于 -1 时就左旋,如上例中结点 1567 的插入等。插入结点后,最小不平衡子树的 BF 与它的子树的 BF 符号相反时,就需要对结点先进行一次旋转以使得符号相同后,再反向旋转一次才能够完成平衡操作,如上例中结点 98 的插入时。

6.2 平衡二叉树实现算法

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

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

1. 右旋操作

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

/* 对以 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 ,将它的左孩子结点定义为 L , 将 L 的右子树变成 P 的左子树,再将 P 改成 L 的右子树,最后将 L 替换 P 成为根结点。这样就完成了一次右旋操作,如下图所示。图中三角形代表子树,N 代表新增结点。

上面例子中的新增加结点 N ( 如下图的 图1 和 图2 ),就是右旋操作。

2. 左旋操作

左旋操作代码如下:

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

这段代码与右旋代码是对称的,在此不做解释了。上面例子中的新增结点 567( 如下图的 图4、图5、图6、图7、图8、图9 ),都是左旋操作。

数据结构—查找(第八章)_第32张图片

3. 左平衡旋转处理

现在我们来看左平衡旋转处理的函数代码。

#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 作右旋平衡处理 */
	}
}

首先,我们定义了三个常数变量,分别代表 10-1

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

2.9 行,我们将 T 的左孩子赋值给 L

3.10〜36 行是分支判断。

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

5. L 的平衡因子为 RH ,即为 -1 时,表明它与根结点的 BF 值符号相反,此时需要做双旋处理。第 19〜32 行,针对 L 的右孩子 L_{r}BF 作判断,修改根结点 TL BF 值。第 33 行将当前 L_{r}BF 改为 0

6. 34 行,对根结点的左子树进行左旋,如下图第二图所示。

7. 35 行,对根结点进行右旋,如上图的第三图所示,完成平衡操作。

4. 右旋平衡处理

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

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

数据结构—查找(第八章)_第33张图片

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

#define LH +1	/* 左高 */
#define EH 0	/* 等高 */
#define RH -1	/* 右高 */
#define TRUE 1
#define FALSE 0
typedef int Status; //创建子函数返回类型 
/* 若在平衡的二叉排序树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;
}

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

2. 23〜28 行表示当存在相同结点,则不需要插入。

3. 29〜54 行,当新结点 e 小于 T 的根结点值时,则在 T 的左子树査找。

4. 32〜35 行,递归调用本函数,直到找到则返回 fake ,否则说明插入结点成功,执行下面语句。

5. 36〜53 行,当 tallerTRUE 时,说明插入了结点,此时需要判断 T 的平衡因子,如果是 1 ,说明左子树高于右子树,需要调用 LeftBalance 函数进行左平衡旋转处理。如果为 0-1 ,则说明新插入结点没有让整棵二叉排序树失去平衡性,只需要修改相关的 BF 值即可。

6. 55〜80 行,说明新结点 e 大于 T 的根结点的值,在 T 的右子树查找。代码上述类似,不再详述。

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

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

数据结构—查找(第八章)_第34张图片

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

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

7. 多路查找树( B树 )

某作家在书中曾经有这样的文字:“ 要观察一个公司是否严谨,看他们如何开会就知道了。如果开会时每一个人都只是带一张嘴,即兴发言,这肯定是一家不严谨的公司,因为肯定每一个人都只是用直觉与反射神经在互相应对, 不可能有深度的思考与规划……,语言是沟通的工具,文字是记录存证的工具,而文字化的过程,又可以让思考彻底沉淀,善于使用文字的人,通常是深沉而严谨的。” 显然,这是一个很好理解的观点,但许多人都难以做到它。

要是我们把开会比作内存中的数据处理的话,那么写下来和时常阅读它就是内存数据对外存磁盘上的存取操作了。

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

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

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

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

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

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

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

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

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

7.1 2-3树

说到二三,我就会想起儿时的童谣,“一去二三里,烟村四五家。亭台六七座,八九十支花。”  2 3 是最基本的阿拉伯数字,用它们来命名一种树结构,显然是说明这种结构与数字 2 3 有密切关系。

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

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

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

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

数据结构—查找(第八章)_第35张图片

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

1.  2-3树的插入实现

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

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

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

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

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

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

数据结构—查找(第八章)_第36张图片

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

数据结构—查找(第八章)_第37张图片

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

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

2.  2-3 树的删除实现

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

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

数据结构—查找(第八章)_第38张图片

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

数据结构—查找(第八章)_第39张图片

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

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

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

情形三,此结点的双亲是一个 3 结点。如下图所示,此时删除结点 10 ,意味着双亲 1214 这个结点不能成为 3 结点了,于是将此结点拆分,并将 1213 合并成为左孩子。

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

数据结构—查找(第八章)_第40张图片

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

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

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

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

7.2  2-3-4 树

有了 2-3 树的讲解,2-3-4 树就很好理解了。

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 树的过程,如下图所示。图1 是在分别插入 712 时的结果图,因为 3 个元素满足 2-3-4 树的单个 4 结点定义,因此此时不需要拆分,接着插入元素 5 ,因为已经超过了 4 结点的定义,因此拆分为 图2 的形状。 之后的图其实就是在元素不断插入时最后形成了 图7 2-3-4 树。

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

数据结构—查找(第八章)_第41张图片

7.3  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 个孩子,其中  ( 表示不小于 的最小整数 )。21 每一个叶子结点 n 都有 k-1 个元素,其中 。

■     所有叶子结点都位于同一层次。

■     所有分支结点包含下列信息数据 ,其中: 为关键字,且  ; 为指向子树根结点的指针,且指针 A_{i-1} 所指子树中所有结点的关键字均小于 ,A_{n} 所指子树中所有结点的关键字均大于 K_{n} , 为关键字的个数( 或 n+1 为子树的个数 )。

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

数据结构—查找(第八章)_第42张图片

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

比方说,我们要查找数字 7 ,首先从外存(比如硬盘中)读取得到根结点 3、5、8 三个元素,发现 7 不在当中,但在 5 8 之间,因此就通过 A_{2} 再读取外存的 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 个结点,由于除根结点外每个分支结点至少有 棵子树,则第三层至少有 个结点,……,这样第 k+1 层至少有 个结点,而实际上,k+1 层的结点就是叶子结点。若 m B 树有 n 个关键字,那么当你找到了叶子结点,其实也就等于查找不成功的结点为 n+1 ,因此 ,即:

也就是说,在含有 n 个关键字的 B 树上查找时,从根结点到关键字结点的路径上涉及的结点数不超过

7.4 B+树

尽管前面我们已经讲了 B 树的诸多好处,但其实它还是有缺陷的。对于树结构来说,我们都可以通过中序遍历来顺序查找树中的元素,这一切都是在内存中进行。

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

数据结构—查找(第八章)_第43张图片

为了说明这个解决的办法,我举个例子。一个优秀的企业尽管可能有非常成熟的树状组织结构,但是这并不意味着员工也很满意,恰恰相反,由于企业管理更多考虑的是企业的利益,这就容易忽略员工的各种诉求,造成了管理者与员工之间的矛盾。正因为此,工会就产生了,工会原意是指基于共同利益而自发组织的社会团体。这个共同利益团体诸如为同一雇主工作的员工,在某一产业领域的个人。工会组织成立的主要作用,可以与雇主谈判工资薪水、工作时限和工作条件等。这样,其实在整个企业的运转过程中,除了正规的层级管理外,还有一个代表员工的团队在发挥另外的作用。

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

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

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

数据结构—查找(第八章)_第44张图片

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

■    有 n 棵子树的结点中包含有n个关键字;

■    所有的叶子结点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接;

■    所有分支结点可以看成是索引,结点中仅含有其子树中的最大(或最小) 关键字。

这样的数据结构最大的好处就在于,如果是要随机査找,我们就从根结点出发, 与 B 树的查找方式相同,只不过即使在分支结点找到了待查找的关键字,它也只是用来索引的,不能提供实际记录的访问,还是需要到达包含此关键字的终端结点。

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

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

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

8. 散列表查找( 哈希表 )概述

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

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

8.1 散列表查找定义

试想这样的场景,你很想学太极拳,听说学校有个叫张三丰的人打得特别好,于 是你到学校学生处找人,学生处的工作人员可能会拿出学生名单,一个一个的查找, 最终告诉你,学校没这个人,并说张三丰几百年前就已经在武当山作古了。可如果你 找对了人,比如在操场上找那些爱运动的同学,人家会告诉你,“哦,你找张三丰呀, 有有有,我带你去。”于是他把你带到了体育馆内,并告诉你,那个教大家打太极的小 伙子就是“张三丰”,原来“张三丰”是因为他太极拳打得好而得到的外号。

学生处的老师找张三丰,那就是顺序表查找,依赖的是姓名关键字的比较。而通过爱好运动的同学询问时,没有遍历,没有比较,就凭他们 “欲找太极‘张三丰’,必在体育馆当中” 的经验,直接告诉你位置。

也就是说,我们只需要通过某个函数 f ,使得

存储位置= f (关键字)

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

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

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

8.2 散列表查找步骤

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

(1) 在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。就像张三丰我们就让他在体育馆,那如果是 ‘爱因斯坦’ 我们让他在图书馆,如果是 ‘居里夫人' 那就让她在化学实验室,如果是 ‘巴顿将军’ ,这个打仗的将军——我们可以让他到网吧。总之,不管什么记录,我们都需要用同一个散列函数计算出地址再存储

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

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

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

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

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

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

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

9. 散列函数的构造方法

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

1. 计算简单

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

2. 散列地址分布均匀

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

接下来我们就要介绍几种常用的散列函数构造方法。估计设计这些方法的前辈们当年可能是从事间谍工作,因为这些方法都是将原来数字按某种规律变成另一个数字而已。

9.1 直接定制法

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

数据结构—查找(第八章)_第45张图片

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

也就是说,我们可以取关键字的某个线性函数值为散列地址,即  ( a、b 为常数)

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

9.2 数字分析法

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

数据结构—查找(第八章)_第46张图片

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

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

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

9.3 平方取中法

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

9.4 折叠法

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

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

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

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

9.5 除留余数法

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

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

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

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

不过这也是存在冲突的可能的,因为 12=2 X 6=3 X 4 。如果关键字中有像 18 (3 X 6)、30 (5 X 6)、42 (7 X 6) 等数字,它们的余数都为 6 ,这就和 78 所对应的下标位置冲突了。

甚至极端一些,对于下表的关键字,如果我们让 p12 的话,就可能出现下面的情况,所有的关键字都得到了 0 这个地址数,这未免也太糟糕了点。

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

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

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

9.6 随机数法

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

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

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

1.    计算散列地址所需的时间。

2.    关键字的长度。

3.    散列表的大小。

4.    关键字的分布情况。

5.    记录查找的频率。综合这些因素,才能决策选择哪种散列函数更合适。

10. 处理散列冲突的方法

我们每个人都希望身体健康,虽然疾病能够预防,但是不可避免,没有任何成年人生下来到现在没有生过一次病。

从刚才除留余数法的例子也可以看出,我们设计得再好的散列函数也不可能完全避免冲突,这就像我们再健康也只能尽量预防疾病,但却无法保证永远不得病一样, 既然冲突不能避免,就要考虑如何处理它。

那么当我们在使用散列函数后发现两个关键字 ,但是却有 ,即有冲突时,怎么办呢?我们可以从生活中找寻思路。

试想一下,当你观望很久很久,终于看上一套房打算要买了,正准备下订金,人家告诉你,这房子已经被人买走了,你怎么办?

对呀,再找别的房子呗!这其实就是一种处理冲突的方法——开放定址法

10.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 ,与 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 时,才有空位,机不可失,赶快存入,如下表所示。

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

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

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

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

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

( 是一个随机数列 )

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

10.2 再散列函数法

我们继续用买房子来举例,如果你看房时的选择标准总是以市中心、交通便利、价格适中为指标,这样的房子凤毛麟角,基本上当你看到时,都已经被人买去了。

我们不妨换一种思维,选择市郊的房子,交通尽管要差一些,但价格便宜很多,也许房子还可以买得大一些、质量好一些,并且由于更换了选房的想法,很快就找到了你需要的房子了。

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

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

10.3 链地址法

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

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

数据结构—查找(第八章)_第47张图片

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

10.4 公共溢出区法

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

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

数据结构—查找(第八章)_第48张图片

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

11. 散列表查找实现

11.1 散列表查找算法实现

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

#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12	/* 定义散列表长为数组的长度 */
#define NULLKEY -32768 
typedef struct 
{
	int *elem;	/* 数据元素存储基址,动态分配数组 */
	int count; /* 当前数据元素个数 */
}HashTable;
int m = 0;	/* 散列表表长,全局变量 */

有了结构的定义,我们可以对散列表进行初始化。

#define OK 1
#define HASHSIZE 12	/* 定义散列表长为数组的长度 */
#define NULLKEY - 32768
typedef int Status; 
/* 初始化散列表 */
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;
}

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

11.2 散列表查找性能分析

最后,我们对散列表查找的性能作一个简单分析。如果没有冲突,散列査找是本博客介绍的所有查找中效率最高的,因为它的时间复杂度为 O(1) 。可惜,我说的只是“如果”,没有冲突的散列只是一种理想,在实际的应用中,冲突是不可避免的。那么散列查找的平均查找长度取决于哪些因素呢?

1.  散列函数是否均匀

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

2.  处理冲突的方法

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

3.  散列表的装填因子

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

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

12. 总结回顾

我们这篇博客全都是围绕一个主题 “查找” 来作文章的。

首先我们要弄清楚查找表记录关键字主关键字静态查找表动态查找表等这些概念。

然后,对于顺序表查找来说,尽管很土(简单),但它却是后面很多查找的基础, 注意设置 “哨兵” 的技巧,可以使得本已经很难提升的简单算法里还是提高了性能。

有序查找,我们着重讲了折半查找的思想,它在性能上比原来的顺序查找有了质 的飞跃,由 O(n) 变成了 O(logn) 。之后我们又讲解了另外两种优秀的有序查找:插值查找斐波那契查找,三者各有优缺点,望大家要仔细体会。

线性索引查找,我们讲解了稠密索引分块索引倒排索引。索引技术被广泛的用于文件检索、数据库和搜索引擎等技术领域,是进一步学习这些技术的基础。

二叉排序树是动态查找最重要的数据结构,它可以在兼顾査找性能的基础上,让插入和删除也变得效率较高。不过为了达到最优的状态,二叉排序树最好是构造成平衡的二叉树才最佳。因此我们就需要再学习关于平衡二叉树( AVL 树)的数据结构,了解 AVL 树是如何处理平衡性的问题。这部分是重点,需要认真学习掌握。

 B 树这种数据结构是针对内存与外存之间的存取而专门设计的。由于内外存的查找性能更多取决于读取的次数,因此在设计中要考虑 B 树的平衡和层次。我们讲解时是先通过最最简单的 B 树( 2-3 树)来理解如何构建、插入、删除元素的操作,再通过 2-3-4 树的深化,最终来理解 B 树的原理。之后,我们还介绍了 B+ 树的设计思想。

散列表是一种非常高效的查找数据结构,在原理上也与前面的查找不尽相同,它回避了关键字之间反复比较的烦琐,而是直接一步到位查找结果。当然,这也就带来了记录之间没有任何关联的弊端。应该说,散列表对于那种查找性能要求高,记录之间关系无要求的数据有非常好的适用性。在学习中要注意的是散列函数的选择和处理冲突的方法。

 

注:本博客是本人在学习《大话数据结构》后整理的笔记,用于自己以后的复习与回顾,博客中的照片是本人从《大话数据结构》中截取的。

你可能感兴趣的:(数据结构,算法,查找,数据结构,算法-查找)