数据结构(C++)笔记:07.查找

7.1 概 述

7.1.1 查找的基本概念

在查找问题中,通常将数据元素称为记录。
关键码:可以标识一个记录的某个数据项称为关键码,关键码的值称为键值。
查找:查找是在具有相同类型的记录构成的集合中找出满足给定条件的记录。
查找的结果: 若在查找集合中找到了与给定值相匹配的记录,则称查找成功;否则,称查找不成功(或查找失败)。
静态查找、动态查找:不涉及插入和删除操作的查找称静态查找;涉及插入和删除操作的查找称动态查找。
查找结构:专门为查找操作设置数据结构,这种面向查找操作的数据结构称为查找结构。

查找结构适用范围
线性表适用于静态查找,主要采用顺序查找技术、折半查找技术。
适用于动态查找,主要采用二叉排序树、平衡二叉树、B树等查找技术
散列表静态查找和动态查找均适用采用散列查找技术

7.1.2 查找算法的性能

查找算法的时间复杂度是问题规模n和待查关键码在查找集合中的位置 k k k的函数,记为 T ( n , k ) T(n,k) T(nk)
将查找算法进行的关键码的比较次数的数学期望值定义为平均查找长度(Average Search Length)。
查找成功时,其计算公式为:
A S L = ∑ i = 1 n p i c i ASL=\sum_{i=1}^np_ic_i ASL=i=1npici
其中,
n:问题规模,查找集合中的记录个数;
p i p_i pi: 为查找第i个记录的概率
c i c_i ci:查找第i个记录所需的比较次数
c i c_i ci与算法密切相关,决定于算法; p i p_i pi与算法无关,决定于具体应用。如果 p i p_i pi是已知的,则平均查找长度ASL只是问题规模n的函数。
对于查找不成功的情况,平均查找长度即为查找失败对应的关键码的比较次数。查找算法总的平均查找长度应为查找成功与查找失败两种情况下的查找长度的平均。

7.2 线性表的查找技术

7.2.1 顺序查找

顺序查找的基本思想为:
顺序查找(Sequential Search)又叫线性查找,是最基本的查找技术,它的查找过程是:从表中第一个(或最后一个)记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直到最后一个(或第一个)记录,其关键字和给定值比较都不等时,则表中没有所查的记录,查找不成功。
1. 顺序表的顺序查找
顺序查找的一种改进算法:设置“哨兵”。哨兵就是待查值,将它放在查找方向的“尽头”,免去了在查找过程中每一次比较后都要判断查找位置是否越界,从而提高查找速度。
在这里插入图片描述
PS:一切为简化边界条件而引入的附加结点(或元素)均可称为哨兵,例如单链表中的头结点。
时间性能分析:
·查找成功的情况:
对于这种顺序查找算法来说,查找成功最好的情况就是在第一个位置就找到了,算法时间复杂度为O(1),最坏的情况是在最后一位置才找到,需要n次比较,时间复杂度为O(n),当查找不成功时,需要n+1次比较,时间复杂度为O(n)。我们之前推导过,关键字在任何一位置的概率是相同的,所以平均查找次数为(n+1)/2,所以最终时间复杂度还是O(n)。
·查找不成功的情况:
平均查找长度即为查找失败的比较次数。
很显然,顺序查找技术是有很大缺点的,n很大时,查找效率极为低下,不过优点也是有的,这个算法非常简单,对静态查找表的记录没有任何要求,存储结构貌似也没有要求,顺序表和链表都可以,在一些小型数据的查找时,是可以适用的。
另外,也正由于查找概率的不同,我们完全可以将容易查找到的记录放在前面,而不常用的记录放置在后面,效率就可以有大幅提高。类似Oracle的 data buffer机制。

7.2.2 折半查找

折半查找要求线性表中的记录必须按关键码有序,并且必须采用顺序存储(链式存储计算中间记录很麻烦)。(两个前提)
折半查找(Binary Search)技术,又称为二分查找。其基本思想是:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,则查找成功;若给定值小于中间记录的关键字,则在中间记录的左半区继续查找;若给定值大于中间记录的关键字,则在中间记录的右半区继续查找。不断重复上述过程,直到查找成功,或者查找区域无记录,查找失败。

非递归算法

待查值为k,折半查找非递归算法的成员函数定义如下:

/***************************
    折半查找的非递归算法 
****************************/ 
int LineSearch :: BinSearch1(int k)  
{
    int mid, low = 1, high = length;             //初始查找区间是[1, n]
	while (low <= high)                      //当区间存在时
	{ 
		mid = (low + high) / 2;            
		if (k < data[mid]) high = mid - 1;
		else if (k > data[mid]) low = mid + 1; 
		else return mid;                   //查找成功,返回元素序号
	}
	return 0;                               //查找失败,返回0
}

递归算法

设查找区间为[low,high],待查值为k,折半查找递归算法的成员函数定义如下:

/*************************
    折半查找的递归算法 
**************************/ 
int LineSearch :: BinSearch2(int low, int high, int k)
{
	if (low > high) return 0;                        //递归的边界条件
	else {
		int mid = (low + high) / 2;
	  	if (k < data[mid]) return BinSearch2(low, mid-1, k);
	  	else if (k > data[mid]) return BinSearch2(mid+1, high, k); 
	    else return mid;                         //查找成功,返回序号
	}
}

性能分析

用折半查找判定树描述查找过程。下图是一个具有11 个结点的判定树。
在这里插入图片描述
·内部结点:判定树中所有节点的空指针域,圆形节点。与给定值进行比较的次数等于该路径上内部结点的个数。例如:4号节点的比较次数是3
·外部结点:判定树中所有节点的非空指针域,方形节点。查找不成功时就是走了一条从根节点到外部结点的路径。外部结点是不存在结点,因此不能参加比较。例如,-1这个外部节点比较次数是3
因此:
查找成功的平均比较次数 = (1×1+2×2+3×4+4×4)/11 = 3
查找不成功的平均比较次数 = (3×4+4×8)/12 = 11/3

以深度为k的满二叉树( n = 2 k − 1 ) n=2^{k-1}) n=2k1)为例,假设表中每个记录的查找概率相等,即 p i = 1 / n ( 1 ≤ i ≤ n ) p_i=1/n(1≤i≤n) pi=1/n(1in),而树的第i层上有 2 i − 1 2^{i-1} 2i1个结点,因此,折半查找的平均查找长度为:
A S L = ∑ i = 1 n p i c i = 1 n ∑ j = 1 k j × 2 j − 1 = 1 n ( 1 × 2 0 + 2 × 2 1 + . . . + k × 2 k − 1 ) ≈ l o g 2 ( n + 1 ) − 1 ASL=\sum_{i=1}^np_ic_i=\frac{1}{n}\sum_{j=1}^kj×2^{j-1}=\frac{1}{n}(1×2^0+2×2^1+...+k×2^{k-1})≈log_2(n+1)-1 ASL=i=1npici=n1j=1kj×2j1=n1(1×20+2×21+...+k×2k1)log2(n+1)1
所以,折半查找的平均时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)
我们之前讲的二叉树的性质,有过对“具有n个结点的完全二叉树的深度为 ⌊ l o g 2 n ⌋ + 1 \left \lfloor log_2n\right \rfloor+1 log2n+1”性质的推导过程。在这里尽管折半查找判定二叉树并不是完全二叉树,但同样相同的推导可以得出,最坏情况是查找到关键字或查找失败的次数为 ⌊ l o g 2 n ⌋ + 1 \left \lfloor log_2n\right \rfloor+1 log2n+1
有人还在问最好的情况?那还用说吗,当然是1次了。
因此最终我们折半算法的时间复杂度为O(logn),它显然远远好于顺序查找的O(n)的时间复杂度了。
折半查找适用于静态查找,为什么?
不过由于折半查找的前提条件是需要有序表顺序存储,对于静态查找表,一次排序后不再变化,这样的算法已经比较好了。但对于需要频繁执行插入或删除操作的数据集来说,维护有序的排序会带来不小的工作量,那就不建议使用。

7.2.3插值查找

现在我们的新问题是,为什么一定要折半,而不是折四分之一或者折更多呢?打个比方,在英文词典里查“apple”,你下意识里翻开词典是翻前面的书页还是后面的书页呢?如果再让你查“zoo”,你又怎么查?很显然,这里你绝对不会是从中间开始查起,而是有一定目的的往前或往后翻。同样的,比如要在取值范围0~10000之间100个元素从小到大均匀分布的数组中查找5,我们自然会考虑从数组下标较小的开始查找。
类似的查找还有:斐波那契查找,具体可以度娘。

7.3树表的查找技术

7.3.1 二叉排序树

二叉排序树又称二叉查找树,它或者是一棵空的二叉树,或者是具有下列性质的二叉树:
(1)若它的左子树不空,则则左子树上所有结点的值均小于它的根结构的值;
(2)若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
(3)它的左右子树也都是二叉排序树。
从二叉排序树的定义也可以知道,它前提是二叉树,然后它采用了递归的定义方法,再者,它的结点间满足一定的次序关系,左子树结点一定比其双亲结点小,右子树结点一定比其双亲结点大。
{62,88,58,47,35,73,51,99,37,93}
在这里插入图片描述

 class BiSortTree    //本章假定记录中只有一个整型数据项
{
    public:
		BiSortTree(int a[ ], int n);     //建立查找集合a[n]的二叉排序树
		~ BiSortTree( );      //析构函数,释放二叉排序树中所有结点,同二叉链表的析构函数
		void InsertBST(BiNode<int> *root , BiNode<int> *s); //在二叉排序树中插入一个结点s
		void DeleteBST(BiNode<int> *p, BiNode<int> *f );  //删除结点f的左孩子结点p
		BiNode<int> *SearchBST(BiNode<int> *root, int k);  //查找值为k的结点
	 private:
	   BiNode<int> *root;   //二叉排序树(即二叉链表)的根指针
};

二叉排序树的插入

根据二叉排序树的定义,向二叉排序树中插入一个结点s的过程用伪代码描述为:

  1. 若root是空树,则将结点s作为根结点插入;否则
  2. 若s->data<root->data,则把结点s插入到root的左子树中;否则
  3. 把结点s插入到root的右子树中。

二叉排序树的构造

构造二叉排序树的过程是从空的二叉排序树开始,依次插入一个个结点。

二叉排序树的删除

二叉排序树的删除操作要求:首先,从二叉排序树中删除一个结点之后,要仍能保持二叉排序树的特性;其次,由于被删除的可能是叶子结点,也可能是分支结点,当删除分支结点时就破坏了二叉排序树中原有结点之间的链接关系,需要重新修改指针,使得删除结点后仍为一棵二叉排序树。
·在二叉排序树中删除值最小的结点。
首先找到这个结点,记作s。删除s只需要简单地把s的父结点中原来指向s的指针改为指向s的右孩子。这样修改指针,删除了s结点,且二叉排序树的特性保持不变。
在这里插入图片描述
二叉排序树的删除。
不失一般性,设待删除结点为p,其双亲结点为f,且p是f的左孩子,则被删除结点有以下三种情况:
⑴ p结点为叶子,p既没有左子树也没有右子树。
在这里插入图片描述
⑵ p只有左子树pL或只有右子树pR。
对于要删除的结点只有左子树或只有右子树的情况,相对也比较好解决。那就是结点删除后,将它的左子树或右子树整个移动到删除结点的位置即可,可以理解为独子继承父业
在这里插入图片描述
⑶ p既有左子树pL又有右子树pR
我们仔细观察一下,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,如下图所示。
在这里插入图片描述
在这里插入图片描述
综上,在二叉排序树中删除一个结点f的左孩子结点p的算法用伪代码描述为:

  1. 若结点p是叶子,则直接删除结点p;
  2. 若结点p只有左子树,则只需重接p的左子树;
    若结点p只有右子树,则只需重接p的右子树;
  3. 若结点p的左右子树均不空,则
    3.1 查找结点p的右子树上的最左下结点s以及结点s的双亲结点par;;
    3.2 将结点s数据域替换到被删结点p的数据域;
    3.3 若结点p的右孩子无左子树,则将s的右子树接到par的右子树上;
    否则,将s的右子树接到结点par的左子树上;
    3.4 删除结点s;

二叉排序树的查找及性能分析

由二叉排序树的定义,在二叉排序树中查找给定值k的过程是:
⑴ 若root是空树,则查找失败;
⑵ 若k=root->data,则查找成功;否则
⑶ 若k<root->data,则在root的左子树上查找;否则
⑷ 在root的右子树上查找。
上述过程一直持续到k被找到或者待查找的子树为空,如果待查找的子树为空,则查找失败。二叉排序树的查找效率就在于只需要查找二个子树之一。
总之,二叉排序树是以链接的方式存储,保持了链接存储结构在执行插入或删除操作时不用移动元素的优点,只要找到合适的插入和删除位置后,仅需修改链接指针即可。插入删除的时间性能比较好。而对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。极端情况,最少为1次,即根结点就是要找的结点,最多也不会超过树的深度。也就是说,二叉排序树的查找性能取决于二叉排序树的形状。可问题就在于,二叉排序树的形状是不确定的。
例如{62,88,58,47,35,73,51,99,37,93}这样的数组,我们可以构建如下图左图的二叉排序树。但如果数组元素的次序是从小到大有序,如{35,37,47,51,58,62,73,88,93,99},则二叉排序树就成了极端的右斜树,注意它依然是一棵二叉排序树,如图8-6-18的右图。此时,同样是查找结点99,左图只需要两次比较,而右图就需要10次比较才可以得到结果,二者差异很大。
在这里插入图片描述
也就是说,我们希望二叉排序树是比较平衡的,即其深度与完全二叉树相同,均为 ⌊ l o g 2 n ⌋ + 1 \left \lfloor log_2n\right \rfloor+1 log2n+1,那么查找的时间复杂也就为O(logn),近似于折半查找,事实上,上图的左图也不够平衡,明显的左重右轻。
不平衡的最坏情况就是像上图右图的斜树,查找时间复杂度为O(n),这等同于顺序查找。
因此,如果我们希望对一个集合按二叉排序树查找,最好是把它构建成一棵平衡的二叉排序树。这样我们就引申出另一个问题,如何让二叉排序树平衡的问题。

7.3.2 平衡二叉树AVL

AVL 树得名于它的发明者 G. M. Adelson-Velsky 和 Evgenii Landis,他们在1962年的论文《An algorithm for the organization of information》中公开了这一数据结构。Balanced Binary Tree才是它的英文名。
几个基本概念:
平衡二叉树:平衡二叉树或者是一棵空的二叉排序树,或者是具有下列性质的二叉排序树:
⑴ 根结点的左子树和右子树的深度最多相差1。
⑵ 根结点的左子树和右子树也都是平衡二叉树,
平衡因子
结点的平衡因子是该结点的左子树的深度与右子树的深度之差。
在这里插入图片描述
从平衡二叉树的名字,你也可以体会到,它是一种高度平衡的二叉排序树。
那什么叫做高度平衡呢?意思是说,要么它是一棵空树,要么它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1。我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF(Balance Factor),那么平衡二叉树上所有结点的平衡因子只可能是一1、0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。
最小不平衡子树:最小不平衡子树是指以距离插入结点最近的、且平衡因子的绝对值大于1的结点为根的子树。如下图,当新插入结点37时,距离它最近的平衡因子绝对值超过1的结点是58(即它的左子树高度2减去右子树高度0),所以从58开始以下的子树为最小不平衡子树。
在这里插入图片描述

平衡二叉树实现原理

平衡二叉树构建的基本思想就是在构建二叉排序树的过程中,每当插入一个结点时,先检查是否因插入而破坏了树的平衡性,若是,则找出最小不平衡子树。在保持二叉排序树特性的前提下,调整最小不平衡子树中各结点之间的链接关系,进行相应的旋转,使之成为新的平衡子树。
为了能在讲解算法时轻松一些,我们先讲一个平衡二叉树构建过程的例子。假设我们现在有一个数组a[10]={3,2,1,4,5,6,7,10,9,8}需要构建二叉排序树。在没有学习平衡二叉树之前,根据二叉排序树的特性,我们通常会将它构建成如下图的左边所示的样子。虽然它完全符合二叉排序树的定义,但是对这样高度达到8的二叉树来说,查找是非常不利的。我们更期望能构建成如下图右边的样子,高度为4的二叉排序树才可以提供高效的查找效率。那么现在我们就来研究如何将一个数组构建出下图右边的树结构。
在这里插入图片描述
对于数组 a [ 10 ] = { 3 , 2 , 1 , 4 , 5 , 6 , 7 , 10 , 9 , 8 } a[10]=\{3,2,1,4,5,6,7,10,9,8\} a[10]={32145671098}的前两位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所示。
在这里插入图片描述
在这里插入图片描述
通过刚才这个例子,你会发现,当最小不平衡子树根结点的平衡因子BF是大于1时,就右旋,小于-1时就左旋,如上例中结点1、5、6、7的插入等。插入结点后,最小不平衡子树的BF与它的子树的BF符号相反时,就需要对结点先进行一次旋转以使得符号相同后,再反向旋转一次才能够完成平衡操作,如上例中结点9、8的插入时。

平衡树实现再说明

有四种种情况可能导致二叉查找树不平衡,分别为:

(1)LL:插入一个新节点到根节点的左子树(Left)的左子树(Left),导致根节点的平衡因子由1变为2

(2)RR:插入一个新节点到根节点的右子树(Right)的右子树(Right),导致根节点的平衡因子由-1变为-2

(3)LR:插入一个新节点到根节点的左子树(Left)的右子树(Right),导致根节点的平衡因子由1变为2

(4)RL:插入一个新节点到根节点的右子树(Right)的左子树(Left),导致根节点的平衡因子由-1变为-2

针对四种种情况可能导致的不平衡,可以通过旋转使之变平衡。有两种基本的旋转:

(1)左旋转:将根节点旋转到(根节点的)右孩子的左孩子位置

(2)右旋转:将根节点旋转到(根节点的)左孩子的右孩子位置

  1. AVL树的旋转操作

AVL树的基本操作是旋转,有四种旋转方式,分别为:左旋转,右旋转,左右旋转(先左后右),右左旋转(先右后左),实际上,这四种旋转操作两两对称,因而也可以说成两类旋转操作。
设结点A为最小不平衡子树的根结点,对该子树进行平衡化调整归纳起来有以下四种情况:
⑴ LL型
在这里插入图片描述
⑵ RR型
在这里插入图片描述
⑶ LR型调整
在这里插入图片描述
⑷ RL型调整
在这里插入图片描述

7.4 散列表的查找技术

7.4.1 概述

散列的基本思想:散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。查找时,根据这个确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则必定在f(key)的位置上。
这里我们把这种对应关系f称为散列函数,又称为哈希(Hash)函数。按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。那么关键字对应的记录存储位置我们称为散列地址。
散列过程为:
⑴ 存储记录时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。
⑵ 查找记录时,通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。址说起来很简单,在哪存的,上哪去找,由于存取用的是同一个散列函数,因此结果当然也是相同的。
说明:
1.散列既是一种存储方法,也是一种查找方法。然而它与线性表、树、图等结构不同的是,前面几种结构,数据元素之间都存在某种逻辑关系,可以用连线图示表示出来,而散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联。因此,散列主要是面向查找的存储结构。
2.散列技术的查找速度要比基于关键码比较的查找技术的查找速度高。散列技术最适合的求解问题是查找与给定值相等的记录。对于查找来说,简化了比较过程,效率就会大大提高。但万事有利就有弊,散列技术不具备很多常规数据结构的能力。
比如那种同样的关键字,它能对应很多记录的情况,却不适合用散列技术。一个班级几十个学生,他们的性别有男有女,你用关键字“男”去查找,对应的有许多学生的记录,这显然是不合适的。只有如用班级学生的学号或者身份证号来散列存储,此时一个号码唯一对应一个学生才比较合适。
同样散列表也不适合范围查找,比如查找一个班级18~22岁的同学,在散列表中没法进行。想获得表中记录的排序也不可能,像最大值、最小值等结果也都无法从散列表中计算出来。
另一个问题是冲突。在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是一个理想。我们时常会碰到两个关键字key≠key2,但是却有f(key1)=f(key2),这种现象我们称为冲突(collision),并把key1和key2称为这个散列函数的同义词(synonym)。出现了冲突当然非常糟糕,那将造成数据查找错误。尽管我们可以通过精心设计的散列函数让冲突尽可能的少,但是不能完全避免。于是如何处理冲突就成了一个很重要的课题,这在我们后面也需要详细讲解。

7.4.2 散列函数的设计

设计散列函数一般应遵循以下原则:
⑴ 计算简单。散列函数不应该有很大的计算量,否则会降低查找效率。
你说设计一个算法可以保证所有的关键字都不会产生冲突,但是这个算法需要很复杂的计算,会耗费很多时间,这对于需要频繁地查找来说,就会大大降低查找的效率了。因此散列函数的计算时间不应该超过其他查找技术与关键字比较的时间。
⑵ 函数值即散列地址分布均匀。函数值要尽量均匀散布在地址空间,这样才能保证存储空间的有效利用,并减少为处理冲突而耗费的时间。
几种常见的散列函数。

1. 直接定址法

直接定址法的散列函数是关键码的线性函数,即:
H ( k e y ) = a × k e y + b ( a 、 b 为 常 数 ) H(key)=a×key+b (a、b为常数) H(key)=a×keyb(ab)
如果我们现在要对0~100岁的人口数字统计表,如下表所示,那么我们对年龄这个关键字就可以直接用年龄的数字作为地址。此时f(key)=key。
在这里插入图片描述
.如果我们现在要统计的是80后出生年份的人口数,如下表所示,那么我们对出生年份这个关键字可以用年份减去1980来作为地址。此时f(key)=key-1980。
在这里插入图片描述
这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的情况。由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用。

2. 除留余数法

除留余数法的基本思想是:选择某个适当的正整数p,以关键码除以p的余数作为散列地址,即:
H ( k e y ) = k e y   m o d   p H(key)=key \space mod \space p H(key)=key mod p
mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模。
很显然,本方法的关键就在于选择合适的p,p如果选得不好,就可能会容易产生同义词。
例如下表,我们对于有12个记录的关键字构造散列表时,就用了f(key)=key.mod12的方法。比如29mod12=5,所以它存储在下标为5的位置。
在这里插入图片描述
不过这也是存在冲突的可能的,因为12=2×6=3×4。如果关键字中有像18(3×6)、30(5×6)、42(7×6)等数字,它们的余数都为6,这就和78所对应的下标位置冲突了。
甚至极端一些,对于下表的关键字,如果我们让p为12的话,就可能出现下面的情况,所有的关键字都得到了0这个地址数,这未免也太糟糕了点。
在这里插入图片描述
我们不选用p=12来做除留余数法,而选用p=11,如下表所示。
在这里插入图片描述
此就只有12和144有冲突,相对来说,就要好很多。
因此根据前辈们的经验,若散列表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。
注意:这个方法不需要事先知道关键码分布。

3. 数字分析法

数字分析法根据关键码在各个位上的分布情况,选取分布比较均匀的若干位组成散列地址。
如果我们的关键字是位数较多的数字,比如我们的11位手机号“130xxxx1234”,其中前三位是接入号,一般对应不同运营商公司的子品牌,如130是联通如意通、136是移动神州行、153是电信等;中间四位是HLR识别号,表示用户号的归属地;后四位才是真正的用户号,如下表所示。
在这里插入图片描述
若我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的。那么我们选择后面的四位成为散列地址就是不错的选择。如果这样的抽取工作还是容易出现冲突问题,还可以对抽取出来的数字再进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环位移、甚至前两数与后两数叠加(如1234改成12+34=46)等方法。总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各位置。
这里我们提到了一个关键词——抽取。抽取方法是使用关键字的一部分来计算散列存储位置的方法,这在散列函数中是常常用到的手段。
数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。

4. 平方取中法

 平方取中法是对关键码平方后,按散列表大小,取中间的若干位作为散列地址(平方后截取)。

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

5. 折叠法

折叠法是将关键码从左到右分割成位数相等的几部分,最后一部分位数可以短些,然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。通常有两种叠加方法:
⑴ 移位叠加:将各部分的最后一位对齐相加;
⑵ 间界叠加:从一端向另一端沿各部分分界来回折叠后,最后一位对齐相加。
比如我们的关键字是9876543210,散列表表长为三位,我们将它分为四组,
987—654—321—0,然后将它们叠加求和987+654+321+0=1962,再求后3位得到散列地址为962。
有时可能这还不能够保证分布均匀,不妨从一端向另一端来回折叠后对齐相加。
比如我们将987和321反转,再与654和0相加,变成789+654+123+0=1566,此时散列地址为566。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。

6.随机数法

选择一个随机数,取关键字的随机函数值为它的散列地址。也就是f(key)=random(key)。这里random是随机函数。当关键字的长度不等时,采用这个方法构造散列函数是比较合适的。
有同学问,那如果关键字是字符串如何处理?其实无论是英文字符,还是中文字符,也包括各种各样的符号,它们都可以转化为某种数字来对待,比如ASCIl码或者Unicode码等,因此也就可以使用上面的这些方法。
总之,现实中,应该视不同的情况采用不同的散列函数。我们只能给出一些考虑的因素来提供参考:
1.计算散列地址所需的时间。
2.关键字的长度。
3.散列表的大小。
4.关键字的分布情况。
5.记录查找的频率。综合这些因素,才能决策选择哪种散列函数更合适。

7.4.3 处理冲突的方法

1.开放定址法

那么当我们在使用散列函数后发现两个关键字key1=key2,但是却有f(key1)=f(key2),即有冲突时,怎么办呢?我们可以从生活中找寻思路。
试想一下,当你观望很久很久,终于看上一套房打算要买了,正准备下订金,人家告诉你,这房子已经被人买走了,你怎么办?
对呀,再找别的房子呗!这其实就是一种处理冲突的方法—开放定址法。
用开放定址法处理冲突得到的散列表叫做闭散列表。
所谓开放定址法,就是由关键码得到的散列地址一旦产生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

(1)线性探测法

当发生冲突时,线性探测法从冲突位置的下一个位置起,依次寻找空的散列地址,即:
H i = ( H ( k e y ) + d i ) % m   ( d i = 1 , 2 , … , m − 1 ) H_i=(H(key)+d_i) \% m \space (d_i=1,2,…,m-1) Hi=(H(key)di)%m (di=12m1)
比如说,我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12。我们用散列函数f(key)=key mod12。
当计算前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)mod12=2,还是冲突……一直到f(48)=(f(48)+6)mod
12=6时,才有空位,机不可失,赶快存入,如下表所示。
在这里插入图片描述
我们把这种解决冲突的开放定址法称为线性探测法。
从这个例子我们也看到,我们在解决冲突的时候,还会碰到如48和37这种本来都不是同义词却需要争夺一个地址的情况,我们称这种现象为堆积。很显然,堆积的出现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低。

(2)二次探测法

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

(3)随机探测法

此时一定有人问,既然是随机,那么查找的时候不也随机生成 d i = d_i= di=吗?如何可以获得相同的地址呢?这是个问题。这里的随机其实是伪随机数。伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的 d i = d_i= di=当然可以得到相同的散列地址。
H i = ( H ( k e y ) + d i ) % m   ( d i 是 一 个 随 机 数 列 , i = 1 , 2 , … … , m − 1 ) H_i=(H(key)+d_i)\% m \space (d_i是一个随机数列,i=1,2,……,m-1) Hi=(H(key)+di)%m (dii=12m1)

2.拉链法(链地址法)

思路还可以再换一换,为什么有冲突就要换地方呢,我们直接就在原地想办法不可以吗?于是我们就有了链地址法。
将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,3},我们用前面同样的12为除数,进行除留余数法,可得到如下图的结构,此时,已经不存在什么冲突换址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。
在这里插入图片描述
链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗。
用拉链法处理冲突构造的散列表叫做开散列表。

3.公共溢出区

这个方法其实就更加好理解,你不是冲突吗?好吧,凡是冲突的都跟我走,我给你们这些冲突找个地儿待着。这就如同孤儿院收留所有无家可归的孩子一样,我们为所有冲突的关键字建立了一个公共的溢出区来存放。
就前面的例子而言,我们共有三个关键字{37,48,34)与之前的关键字位置有冲突,那么就将它们存储到溢出表中,如下图所示。
在这里插入图片描述
在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表去进行顺序查找。如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。

7.4.4散列查找的性能分析。

最后,我们对散列表查找的性能作一个简单分析。如果没有冲突,散列查找是我们本章介绍的所有查找中效率最高的,因为它的时间复杂度为O(1)。可惜,我说的只是“如果”,没有冲突的散列只是一种理想,在实际的应用中,冲突是不可避免的。那么散列查找的平均查找长度取决于哪些因素呢?
影响冲突产生的概率有以下三个因素:
(1)散列函数是否均匀。
散列函数的好坏直接影响着出现冲突的频繁程度,不过,由于不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的,因此我们可以不考虑它对平均查找长度的影响。
(2)处理冲突的方法。
相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。比如线性探测处理冲突可能会产生堆积,显然就没有二次探测法好,而链地址法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能。
(3)散列表的装填因子。
所谓的装填因子 α = 填 入 表 中 的 记 录 个 数 散 列 表 长 度 α=\frac{填入表中的记录个数}{散列表长度} α=。α标志着散列表的装满的程度。当填入表中的记录越多,α就越大,产生冲突的可能性就越大。比如你的散列表长度是12,而填入表中的记录个数为11,那么此时的装填因子α=11/12=0.9167,再填入最后一个关键字产生冲突的可能性就非常之大。也就是说,散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数。
不管记录个数n有多大,我们总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时我们散列查找的时间复杂度就真的是O(1)了。为了做到这一点,通常我们都是将散列表的空间设置得比查找集合大,此时虽然是浪费了一定的空间,但换来的是查找效率的大大提升,总的来说,还是非常值得的。

参考书目:

大话数据结构
数据结构——从概念到C++实现(第2版)
数据结构(C++版)教师用书

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

oldmao_2000

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值