数据结构与算法查找(Search)详解

Posted 逆流°只是风景-bjhxcc

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构与算法查找(Search)详解相关的知识,希望对你有一定的参考价值。

文章目录

查找

【知识框架】

查找概论

一、查找的基本概念

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

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

关键字(Key):数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。例如,在由一个学生元素构成的数据集合中,学生元素中“学号”这一数据项的值唯一地标识一名学生。

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

  • 主要操作
    1. 查询某个“特定的”数据元素是否在查找表中。
    2. 检索某个“特定的”数据元素和各种属性。

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

  • 主要操作
    1. 查找时插入不存在的数据元素。
    2. 查找时删除已存在的数据元素。

平均查找长度:在查找过程中,一次查找的长度是指需要比较的关键字次数,而平均查找长度,则是所有查找过程中进行关键字的比较次数的平均值,其数学定义为

式中,n是查找表的长度;Pi是查找第i个数据元素的概率,一般认为每个数据元素的查找概率相等,即Pi = 1 / ;Ci 是找到第i ii个数据元素所需进行的比较次数。平均查找长度是衡量查找算法效率的最主要的指标。

顺序表查找

一、定义

顺序查找(Sequential Search) 又叫线性查找,是最基本的查找技术,作为一种最直观的查找方法,其基本思想是从线性表的一端开始,逐个检查关键字是否满足给定的条件。若查找到某个元素的关键字满足给定条件,则查找成功,返回该元素在线性表中的位置;若已经查找到表的另一端,但还没有查找到符合给定条件的元素,则返回查找失败的信息。

二、算法

下面给出其算法:

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

这种在查找方向的尽头放置“哨兵”免去了在查找过程中每一次比较后都要判断查找位置是否越界的小技巧,看似与原先差别不大,但在总数据较多时,效率提高很大,是非常好的编码技巧。
上述顺序表查找时间复杂度是O(n)。

有序表查找

一、折半查找

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

算法如下:

int Binary_Search(SeqList L, ElemType key)
	int low = 0, high = L.length - 1, mid;
	while(low <= high)
		mid = (low + hight)/2;	//取中间位置
		if(L.elem[mid] == key)
			return mid;	//查找成功返回所在位置
		else if(L.elem[mid] > key)
			high = mid - 1;	//从前半部分继续查找
		else
			low = mid + 1;	//从后半部分继续查找
		
	
	return -1;	//查找失败,返回-1

折半查找的过程可用二叉树来描述,称为判定树。
我们知道,具有n个结点的二叉树的深度为⌈ log2(n+1)⌉,所以,折半查找的时间复杂度为O(log2n),平均情况下比顺序查找的效率高。
因为折半查找需要方便地定位查找区域,所以它要求线性表必须具有随机存取的特性。因此,该查找法仅适合于顺序存储结构,不适合于链式存储结构,且要求元素按关键字有序排列。

二、插值查找

现在我们的新问题是,为什么一定要折半,而不是折四分之一或者折更多呢?
比如要在取值范围0 ~ 10000之间100个元素从小到大均匀分布的数组中查找5,我们自然会考虑从数组下标较小的开始查找。
所以,折半查找还是有改善空间的。
上述折半查找代码的第4行,等式变换后可得到:
mid=(low+high)/2 = low+(high-low)/2
也就是m i d midmid等于最低下标low加上最高下标high与low 的差的一半。大佬们考虑的就是将这个1/2进行改进,改进为下面的计算方案:
mid=low+(high−low)(key−a[low])/(a[high]−a[low])
也就是说,我们把上述折半查找代码的第4行的代码改为:

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

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

三、斐波那契查找

斐波那契查找(Fibonacci Search),它是利用了黄金分割原理来实现的。

关于斐波那契数列,不了解的可点击这里做一个大致的了解

我们先定义一个斐波那契数组F:

算法实现如下:

/*斐波那契查找*/
int Fibonacci_Search(int *a, int n, int key)
	int low, high, mid, i, k;
	low = 0;	//定义最低下标为记录首位
	high = n;	//定义最高下标为记录末尾
	k = 0;
	while(n > F[k] - 1)
		//计算n位于斐波那契数列的位置
		k++;
	
	for(i=n; i<F[k]; i++)
		//在尾部补上F[k]-n-1个数,大小等于尾部最大值,否则会存在数组溢出
		a[i]=a[n];
	
	while(low <= hight)
		mid = low + F[k-1]-1;	//计算当前分隔的下标
		if(key < a[mid])
			//若查找记录小于当前分隔记录
			hight = mid - 1;	//最高下标调整到分隔下标mid-1处
			k = k - 1;	//斐波那契数列下标减一位
		else if(key > a[mid])
			//若查找记录大于当前分隔记录
			low = mid + 1;	//最低下标调整到分隔下标mid+1处
			k = k - 2;	//斐波那契数列下标减两位
		else
			if(mid <= n)
				return mid;	//若相等则说明mid即为查找的位置
			else
				return n;	//若mid>n说明是补全数值,返回n
			
		
	
	return -1;

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

  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 ( l o g n ) O(logn)O(logn),但就平均性能来说,斐波那契查找要优于折半查找。不过如果是最坏情况,比如这里key=1,那么始终都处于左侧长半区在查找,则查找效率要低于折半查找。

线性索引查找

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

这里主要介绍线性索引,所谓线性索引就是将索引项集合组织为线性结构,也称为索引表。我们重点介绍三种线性索引:稠密索引、分块索引和倒排索引。

一、稠密索引

稠密索引是很简单直白的一种索引结构。
稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项,而索引项一定是按照关键码有序的排列。如下图所示:

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

二、分块索引

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

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

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

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

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

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

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

我们来分析一下它的平均查找长度:
分块查找的平均查找长度为索引查找和块内查找的平均长度之和。设索引查找和块内查找的
平均查找长度分别为LI , LS,则分块查找的平均查找长度为:
ALS=LI+LS
我们假设,将长度为n nn的查找表均匀地分为b bb块,每块有s ss个记录,即b = n / s b=n/sb=n/s,在等概率情况下,若在块内和索引表中均采用顺序查找,则平均查找长度为:
ASL=LI+LS=(b+1)/2 + (s+1)/2 = (s^2+2s+n)/2s此时,若s = √n ,则平均查找长度取最小值√n + 1 ;若对索引表采用折半查找时,则平均查找长度为:
ASL=LI+LS=⌈log2(b+1)⌉ + (s+1)/2

三、倒排索引

搜索引擎中涉及很多搜索技术,这里介绍一种最简单,也是最基础的搜索技术:倒排索引。

我们举个简单的例子:
假如下面两篇极短的文章。

  1. Books and friends should be few but good.
  2. A good book is a good friend.
    忽略字母大小写,我们统计出每个单词出现在哪篇文章之中:文章1、文章2、文章(1,2),得到下面这个表,并对单词做了排序:

有了这样一张单词表,我们要搜索文章,就非常方便了。如果你在搜索框中填写“book"关键字。系统就先在这张单词表中有序查找“book" ,找到后将它对应的文章编号1和2的文章地址返回。

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

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

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

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

当然,现实中的搜索技术是非常复杂的,要考虑诸多因素用到诸多技术,由于本文的侧重点并非搜索引擎,所以于此不再赘述。

二叉树排序与平衡二叉树

一、二叉排序树

1、定义

二叉排序树(也称二叉查找树)或者是一棵空树,或者是具有下列特性的二叉树:

  1. 若左子树非空,则左子树上所有结点的值均小于根结点的值。
  2. 若右子树非空,则右子树上所有结点的值均大于根结点的值。
  3. 左、右子树也分别是一棵二叉排序树。

根据二叉排序树的定义,左子树结点值<根结点值<右子树结点值,所以对二叉排序树进行中序遍历,可以得到一个递增的有序序列。例如,下图所示二叉排序树的中序遍历序列为123468。

2、二叉排序树的常见操作

构造一个二叉树的结构:

/*二叉树的二叉链表结点结构定义*/
typedef struct BiTNode

	int data;	//结点数据
	struct BiTNode *lchild, *rchild;	//左右孩子指针
 BiTNode, *Bitree;

(1)查找操作

/*
递归查找二叉排序树T中是否存在key
指针f指向T的双亲,其初始调用值为NULL
若查找成功,则指针p指向该数据元素结点,并返回TRUE
否则指针p指向查找路径上访问的最后一个结点并返回FALSE
*/
bool SearchBST(BiTree T, int key, BiTree f, BiTree *p)
	if(!T)
		*p = f;
		return FALSE;
	else if(key == T->data)
		//查找成功
		*p = T;
		return TRUE;
	else if(key < T->data)
		return SearchBST(T->lchild, key, T, p);	//在左子树继续查找
	else
		return SearchBST(T->rchild, key, T, p);	//在右子树继续查找
	

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

/*
当二叉排序树T中不存在关键字等于key的数据元素时
插入key并返回TRUE,否则返回FALSE
*/
bool 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;	//树种已有关键字相同的结点,不再插入
		

有了二叉排序树的插入代码,我们要实现二叉排序树的构建就非常容易了,几个例子:

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

上面的代码就可以创建一棵下图这样的树。

(3)删除操作
二叉排序树的查找和插入都很简单,但是删除操作就要复杂一些,此时要删除的结点有三种情况:

  1. 叶子结点;
  2. 仅有左或右子树的结点;
  3. 左右子树都有的结点;

前两种情况都很简单,第一种只需删除该结点不需要做其他操作;第二种删除后需让被删除结点的直接后继接替它的位置;复杂就复杂在第三种,此时我们需要遍历得到被删除结点的直接前驱或者直接后继来接替它的位置,然后再删除。
第三种情况如下图所示:

代码如下:

/*
若二叉排序树T中存在关键字等于key的数据元素时,则删除该数据元素结点,
并返回TRUE;否则返回FALSE
*/
bool DeleteBST(BiTree *T, int key)
	if(!T)
		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);
		
	

下面是Delete()方法:

/*从二叉排序树中删除结点p,并重接它的左或右子树。*/
bool Delete(BiTree *p)
	BiTree q, s;
	if(p->rchild == NULL)
		//右子树为空则只需重接它的左子树
		q = p;
		p = p->lchild;
		free(q);
	else if(p->lchild == NULL)
		//左子树为空则只需重接它的右子树
		q = p;
		p = p->rchild;
		free(q);
	else
		//左右子树均不空
		q = p;
		s = p->lchild;	//先转左
		while(s->rchild)//然后向右到尽头,找待删结点的前驱
			q = s;
			s = s->rchild;
		
		//此时s指向被删结点的直接前驱,p指向s的父母节点
		p->data = s->data;	//被删除结点的值替换成它的直接前驱的值
		if(q != p)
			q->rchild = s->lchild;	//重接q的右子树
		else
			q->lchild = s->lchild;	//重接q的左子树
		
		pree(s);
	
	return TRUE;

3、性能分析

二叉排序树的优点明显,插入删除的时间性能比较好。而对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。极端情况,最少为1次,即根结点就是要找的结点,最多也不会超过树的深度。也就是说,二叉排序树的查找性能取决于二叉排序树的形状。可问题就在于,二叉排序树的形状是不确定的。
例如 62 , 88 , 58 , 47 , 35 , 73 , 51 , 99 , 37 , 93 62,88,58,47,35,73,51,99,37,9362,88,58,47,35,73,51,99,37,93这样的数组,我们可以构建如下左图的二叉排序树。但如果数组元素的次序是从小到大有序,如35,37,47,51,58,62,73,88,93,99,则二叉排序树就成了极端的右斜树,如下面右图的二叉排序树:

也就是说,我们希望二叉排序树是比较平衡的,即其深度与完全二叉树相同,那么查找的时间复杂也就为O(logn),近似于折半查找。
不平衡的最坏情况就是像上面右图的斜树,查找时间复杂度为O(n),这等同于顺序查找。
因此,如果我们希望对一个集合按二叉排序树查找,最好是把它构建成一棵平衡的二叉排序树。

二、平衡二叉树

1、定义

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

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

2、平衡二叉树的查找

在平衡二叉树上进行查找的过程与二叉排序树的相同。因此,在查找过程中,与给定值进行比较的关键字个数不超过树的深度。假设以nh表示深度为h的平衡树中含有的最少结点数。显然,有 n0=0,n1=1,n2=2n ,并且有nh=nh-1+nh-2+1。可以证明,含有n nn个结点的平衡二叉树的最大深度为O ( l o g 2 n ) O(log2n)O(log2n),因此平衡二叉树的平均查找长度为O(log2n) 如下图所示。

3、平衡二叉树的插入

二叉排序树保证平衡的基本思想如下:每当在二叉排序树中插入(或删除)一个结点时,首先检查其插入路径上的结点是否因为此次操作而导致了不平衡。若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于1的结点A,再对以A为根的子树,在保持二叉排序树特性的前提下,调整各结点的位置关系,使之重新达到平衡。

注意:每次调整的对象都是最小不平衡子树,即以插入路径上离插入结点最近的平衡因子的绝对值大于1的结点作为根的子树。下图中的虚线框内为最小不平衡子树。

平衡二叉树的插入过程的前半部分与二叉排序树相同,但在新结点插入后,若造成查找路径上的某个结点不再平衡,则需要做出相应的调整。可将调整的规律归纳为下列4种情况:

  1. LL平衡旋转(右单旋转)。由于在结点A的左孩子(L)的左子树(L)上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。
    如下图所示,结点旁的数值代表结点的平衡因子,而用方块表示相应结点的子树,下方数值代表该子树的高度。

  2. RR平衡旋转(左单旋转)。由于在结点A的右孩子®的右子树®上插入了 新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树。

  3. LR平衡旋转(先左后右双旋转)。由于在A的左孩子(L)的右子树®上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置(即进行一次RR平衡旋转(左单旋转)),然后再把该C结点向右上旋转提升到A结点的位置(即进行一次LL平衡旋转(右单旋转))。

  4. RL平衡旋转(先右后左双旋转)。由于在A的右孩子®的左子树(L)上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置(即进行一次LL平衡旋转(右单旋转)),然后再把该C结点向左上旋转提升到A结点的位置(即进行一次RR平衡旋转(左单旋转))。

    注意: LR和RL旋转时,新结点究竟是插入C的左子树还是插入C的右子树不影响旋转过程,而上图中是以插入C的左子树中为例。
    举个例子:
    假设关键字序列为15 , 3 , 7 , 10 , 9 , 8 15,3, 7, 10, 9, 815,3,7,10,9,8,通过该序列生成平衡二叉树的过程如下图所示。

多路查找树

多路查找树(muitl-way search tree), 其每一个结点的孩子数可以多于两个,且每一个结点处可以存储多个元素。由于它是查找树,所有元素之间存在某种特定的排序关系。
在这里,每一个结点可以存储多少个元素,以及它的孩子数的多少是非常关键的。常见的有4种特殊形式:2-3树、2-3-4树、B树和B+树。这里主要介绍B树和B+树,因为2-3树、2-3-4树都是B树的特例。
如下图所示是一颗2-3树:

一、B树

1、定义

B树,又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。一棵m m阶B树或为空树,或为满足如下特性的m叉树:

  1. 树中每个结点至多有m mm棵子树,即至多含有m−1个关键字。
  2. 若根结点不是终端结点,则至少有两棵子树。
  3. 除根结点外的所有非叶结点至少有⌈m/2⌉棵子树,即至少含有⌈m/2⌉−1个关键字。
  4. 所有非叶结点的结构如下:

其中,Ki(i=1,2,…,n)为结点的关键字,且满足K1<K2<…<Kn;Pi (i=0,1,…,n)为指向子树根结点的指针,且指针Pi1 所指子树中所有结点的关键字均小于Ki 所指子树中所有结点的关键字均大于Ki(即符合二叉排序树的左小右大),n(⌈m/2⌉−1≤n≤m−1)为结点中关键字的个数。

  1. 所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。

B树是所有结点的平衡因子均等于0的多路平衡查找树。
下图所示的B树中所有结点的最大孩子数m = 5 m=5m=5,因此它是一棵5阶B树,在m mm阶B树中结点最多可以有m mm个孩子。可以借助该实例来分析上述性质:

  1. 结点的孩子个数等于该结点中关键字个数加1(每一个空隙都存在一个分支)。
  2. 如果根结点没有关键字就没有子树,此时B树为空;如果根结点有关键字,则其子树必然大于等于两棵,因为子树个数等于关键字个数加1。
  3. 除根结点外的所有非终端结点至少有⌈ m / 2 ⌉ = ⌈ 5 / 2 ⌉ = 3 ⌈m/2⌉=⌈5/2⌉=3⌈m/2⌉=⌈5/2⌉=3棵子树(即至少有⌈ m / 2 ⌉ − 1 = ⌈ 5 / 2 ⌉ − 1 = 2 ⌈m/2⌉- 1=⌈5/2⌉-1=2⌈m/2⌉−1=⌈5/2⌉−1=2个关键字),至多有5棵子树(即至多有4个关键字)。
  4. 结点中关键字从左到右递增有序,关键字两侧均有指向子树的指针,左边指针所指子树的所有关键字均小于该关键字,右边指针所指子树的所有关键字均大于该关键字。或者看成下层结点关键字总是落在由上层结点关键字所划分的区间内,如第二层最左结点的关键字划分成了3个区间:( − ∞ , 5 ) , ( 5 , 11 ) , ( 11 , + ∞ ) (-∞, 5),(5, 11),(11, +∞)(−∞,5),(5,11),(11,+∞),该结点3个指针所指子树的关键字均落在这3个区间内。
  5. 所有叶结点均在第4层,代表查找失败的位置。

2、B树与磁盘存取

B树中的大部分操作所需的磁盘存取次数与B树的高度成正比。

我们的外存,比如硬盘, 是将所有的信息分割成相等大小的页面,每次硬盘读写的都是一个或多个完整的页面,对于一个硬盘来说,一页的长度可能是211到214个字节。
在一个典型的B树应用中,要处理的硬盘数据量很大,因此无法一次全部装入内存。因此我们会对B树进行调整,使得B树的阶数(或结点的元素)与硬盘存储的页面大小相匹配。比如说一棵B树的阶为1001 (即1个结点包含1000个关键字),高度为2,它可以储存超过10亿个关键字,我们只要让根结点持久地保留在内存中,那么在这棵树上,寻找某一个关键字至多需要两次硬盘的读取即可。
通过这种方式,在有限内存的情况下,每一次磁盘的访问我们都可以获得最大数量的数据。由于B树每结点可以具有比二叉树多得多的元素,所以与二叉树的操作不同,它们减少了必须访问结点和数据块的数量,从而提高了性能。可以说,B树的数据结构就是为内外存的数据交互准备的。

3、B树的查找

在B树上进行查找与二叉查找树很相似,只是每个结点都是多个关键字的

二分查找及其变种大全详解

1.经典二分查找

经典的二分查找,是对一个已排序的数组,通过查找数组中间位置元素值的大小与要查找的目标值大小进行对比,从而缩小数组的搜素范围,直到最后找到目标值出现的位置,或者将数组查找完毕一直没有找到目标值直至算法结束。

其基本的算法框架为(c++版本)

int binaray_search(int[] nums, int n, int target) 
	int left = ..., right = ..., mid;
	while(...) 
		int mid = (left+right)/2;
		if (nums[mid] == target) 
			...
		 else if (nums[mid] > target) 
			right = ...
		 else if (nums[mid] < target) 
			left = ...
		
	

二分查找的基本版本以及其他各种变种,就是针对上面的框架进行相应修改。

2.经典版本二分查找

首先我们实现经典的二分查找。

int classical_version(int nums[], int n, int target) 
    int left=0, right=n-1, mid;
    while(left<=right) 
        mid = (left+right) / 2;
        if (nums[mid] > target) right=mid-1;
        else if (nums[mid] < target) left=mid+1;
        else return mid;
    
    return -1;

其中,n是数组nums的长度,target是需要查找的目标数值。
注意循环退出的条件是left<=right,等号不能丢。因为如果退出条件为left<right,那么当left=right时,循环就退出了,这样将少查一个元素。如果该元素刚好为target,程序将会返回错误的结果。

3.查找target出现的第一个位置

上面的经典算法,适合数组中没有重复元素的情况。如果数组中有重复元素,而且需要查找的target刚好是重复元素,那么找到的只是其中一个元素。如果要查找指定的第一个或者最后一个出现位置,上面的写法是搞不定的。
下面我们来实现查找target出现的第一个位置。

int find_first_target(int nums[], int n, int target) 
    int left=0, right=n-1, mid;
    while(left<=right) 
        mid = (left+right)/2;
        if (nums[mid] > target) right=mid-1;
        else if (nums[mid] < target) left=mid+1;
        else 
            if (mid == 0 || nums[mid-1] != target) return mid;
            else right=mid-1;
        
    
    return -1;

注意与经典版本不一样的地方在于,经典版本在nums[mid] == target时就支持返回退出了。而如果数组中有重复元素,需要进一步进行判断。因为是要找到出现的第一个位置,所以搜素范围需要向左移动。我们将mid-1个元素与target进行对比,如果不相等,直接返回。如果相等,则将right向左移动一个位置。

4.查找target出现的最后一个位置

很显然思路与上面类似,区别就是查找第一个位置,当nums[mid]==target搜索范围向左移动,而查找最后一个位置则是向右移动。

int find_last_target(int nums[], int n, int target) 
    int left=0, right=n-1, mid;
    while(left<=right) 
        mid = (left+right)/2;
        if (nums[mid] > target) right=mid-1;
        else if (nums[mid] < target) left=mid+1;
        else 
            if (mid==right || nums[mid+1] != target) return mid;
            else left = mid+1;
        
    
    return -1;

5.查找第一个大于等于target的元素

有时候我们还需要查找第一个大于等于target的元素。先上代码,然后再针对代码分析。

int find_first_euqal_big_element(int nums[], int n, int target) 
    int left=0, right=n-1, mid;
    while(left <= right) 
        mid = (left+right) / 2;
        if(nums[mid] < target) left=mid+1;
        else 
            if (mid == 0 || nums[mid-1] < target) return mid;
            else right=mid-1;
        
    
    return -1;

上面算法几个要点:
1.因为需要找的是大于等于的第一个元素,这个元素必然出现在right一侧,left一侧无需做特殊处理,只需要跟原始版本一样即可。
2.如果mid==0且nums[mid]>=target,直接返回mid。
3.如果nums[mid]>=target,且nums[mid-1]<target,说明mid则是第一个大于等于target值的位置。否则,right侧向左移动。

6. 查找最后一个小于等于target的元素

int find_last_equal_small_element(int nums[], int n, int target) 
    int left=0, right=n-1, mid;
    while(left <= right) 
        mid = (left+right)/2;
        if(nums[mid] > target) right=mid-1;
        else 
            if (mid==n-1 || nums[mid+1] > target) return mid;
            else left=mid+1;
        
    
    return -1;


与第五部分类似的是:
1.因为查找的是小于等于target的元素,所以必然是出现在left侧,right一侧无需特殊处理。
2.如果mid==n-1且nums[mid]<=target,直接返回mid。
3.如果nums[mid]<=target,且nums[mid+1]>target,说明mid则是第一个小于等于target值的位置。否则,left侧向右移动。

以上是关于数据结构与算法查找(Search)详解的主要内容,如果未能解决你的问题,请参考以下文章

算法与数据结构的介绍

数据结构与算法(Java)之查找

数据结构与算法问题 二叉搜索树

数据结构与算法哈希算法的原理和应用详解!

7种查找算法详解(转)

python数据结构与算法第十四天二分查找