大话数据结构_查找

1、查找概论

所有这些需要被查的数据所在的集合,统称为查找表。
查找表(Search Table)是由同一类型的数据元素(或记录)构成的集合。例如8-2-1就是一个查找表。
在这里插入图片描述
关键字(Key)是数据元素中某个数据项的值,又称为键值,用它可以标识一个数据元素。也可以标识一个记录的某个数据项(字段),称为关键码。
若此关键字可以唯一标识一个记录,则称此关键字为主关键字(Primary Key)。这也就意味着,对不同的记录,其主关键字均不相同。主关键字所在的数据项称为主关键码。
对于那些可以标识多个数据元素(或记录)的关键字,称为次关键字(Secondary Key)。次关键字也可以理解为不以唯一标识一个数据元素(或记录)的关键字,其对应的数据项称为次关键码。

查找(Searching)就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。
若表中存在这样的一个记录,则称查找是成功的,此时查找的结果给出整个记录的信息,或指示该记录在查找表中的位置;若表中不存在关键字等于给定值的记录,则称查找不成功,此时查找的结果可以给出一个"空"记录或“空”指针。

查找表按照某种方式来分有两大种:静态查找表动态查找表
静态查找表(Static Search Table):只作查找操作的查找表。它的主要操作有:
1)查询某个"特定的"数据元素是否在查找表中;
2)检索某个"特定的"数据元素和各种属性。
动态查找表(Dynamic Search Table):在查找过程中同时需要插入查找时i表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。动态查找时的操作有两个:
1)查找时插入数据元素;
2)查找时删除数据元素。

为了提高查找效率,需要专门为查找操作设置数据结构,这种面向查找操作的数据结构称为查找结构。
从逻辑上来说,查找所基于的数据结构是集合,集合中的记录之间没有本质关系。可是想要获得较高的查找性能,就不能不改变数据元素之间的关系,在存储时可以将查找集合组织成表、树的结构。
例如,对于静态查找表来说,不妨应用线性表结构来组织数据,这样可以使用顺序查找,如果再对主关键字排序,则可以应用折半查找等技术进行高效查找。
如果是需要动态查找,则会复杂一些,可以考虑二叉树的查找技术。
另外,还可以应用散列表结构来解决一些查找问题。

2、顺序查找

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

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

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

(2)顺序查找优化

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

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

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

在这里插入图片描述

很显然,顺序查找技术是有很大缺陷的,n很大时,查找效率极为低下,其优点是算法极为简单,对静态查找表的记录没有任何要求,在一些小型数据的查找时,是很适用的。
另外,也正由于查找概率的不同,可以将容易查找到的记录放在前面,而不常用的记录放在后面,这样查找效率会大幅提高。

3、有序表查找

(1)折半查找

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

/*折半查找,数据元素按从小到大排序*/
int Binary_Search(int* a,int n,int key)
{
    
    
 int low,mid,height;
 low = 1;/*定义最低下标作为记录首位*/
 height = n;/*定义最高下标为记录末位*/
 while (low<=height)
 {
    
    
  mid = (low + height) / 2; /*折半*/
  if (a[mid] < key)         /*若查找值比中间值小*/
   height = mid - 1;     /*最高下标调整到中位下标小一位*/
  else if (a[mid] > key)    /*若查找值比中间值大*/
   low = mid + 1;        /*最低下标调整到中位下标大一位*/
  else
   return mid;          /*若相等则说明mid即为查找到的位置*/
 }
 return 0;
}

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(2)插值查找

在这里插入图片描述

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

就得到了另一种查找算法,插值查找(Interpolation Search)。插值查找是要根据要查找的关键字key与查找表中最大最小记录的关键字比较之后的查找方法,其核心
在这里插入图片描述

(3)斐波那契查找

斐波那契查找(Fibonacci Search)利用了黄金分割原理来实现。
在这里插入图片描述

/*斐波那契查找*/
int Fibonacci_Search(int* a,int n,int key)
{
    
    
 int low, height, mid, i, k;
 low = 1;
 height = n;
 k = 0;
 while (n > F[k] - 1)/*计算n位子斐波那契数列的位置*/
  k++;
 for(int i=n;i<F[k]-1;i++)/*将不满的数值补全*/
 {
    
    
    a[i]=a[n];
 }
 while (low <= height)
 {
    
    
  mid = low + F[k - 1] - 1;/*计算当前分割的下标*/
  if(key<a[mid])/*若查找记录小于当前分割记录*/
  {
    
    
   height = mid - 1;/*最高下标调整到分隔下标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;/*若mid>0则说明是补全数值,返回n*/
  }
 }
 return 0;
}

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
这三种有序表的查找本质上是分割点的选择不同,各有优势,实际开发时可根据数据的特点综合考虑再做出选择。

4、线性索引查找

在这里插入图片描述
在这里插入图片描述
对于这样的查找表,索引能够快速查找到需要的数据。
数据结构的最终目的是提高数据的处理速度,索引是为了加快查找速度而设计的一种数据结构
索引就是把一个关键字与其对应的记录相关联的过程,一个索引由若干个索引项构成,每个索引项至少包含关键字和其对应的记录在存储器中的位置等信息。索引技术是组织大型数据库以及磁盘文件的一种重要技术
索引按照结构可以分为线性索引、树形索引和多级索引。这里只介绍线性索引。
线性索引就是将索引项集合组织成线性结构线性结构,也称索引表。
这里重点介绍三种线性索引:稠密索引、分块索引和倒排索引。

(1)稠密索引

在这里插入图片描述
稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项,如下图:
在这里插入图片描述
对于稠密索引这个索引表来说,索引项一定是按照关键码的有序排列
上图中的指针指向关键码对应的记录在存储器中的位置。
索引项有序也就意味着,我们要查找关键字时,可以用到折半、插值、斐波那契等有序查找算法,大大提高了效率。如上图中,我们要查找关键字18的记录,如果直接从右侧的数据表中查找,那只能顺序查找,需要查找6次才能找到结果。而如果从左侧的索引表中查找,只需两次折半查找就可以找到18对应的指针,最终查找到结果。
这是稠密查找的优点,但如果数据集非常大,比如上亿,那就意味着索引也得同样的数据集长度规模,对于内存有限的计算机来说,可能
就需要反复去访问磁盘(注:因为内存不够,所以需要把原本存在内存中的数据放入磁盘中,需要时再从磁盘读取数据,但访问磁盘比访问内存的速度要慢得多),查找性能反而大大下降了。

(2)分块索引

在这里插入图片描述
稠密索引因为索引项与数据集的记录个数相同,所以空间代价很大。为了减少索引项的个数,可以对数据集分块,使其分块有序,然后再每一个块建立一个索引项,从而减少索引项的个数。
分块有序,是把数据集分成了若干块,并且这些块需要满足两个条件:
1)块内无序:即每一块内的记录不要求有序。当然,如果能让块内有序,对查找来说更理想,只不过这要付出大量的时间和空间代价,因此通常不要求块内有序。
2)块间有序:例如,要求第二块所有记录的关键字均要大于第一块所有记录的关键字,第三块所有记录的关键字均要大于第二块所有记录的关键字,……因为只有块间有序,才有可能在查找时带来效率。
对于分块有序的数据集,将每一块对应一个索引项,这种索引方法叫作分块索引,如下图,我们定义的分块索引的索引项结构分为三个数据项:
1)最大关键码:它存储每一块中的最大关键字,这样的好处是可以使得在它之后的下一块中的最小关键字也能比这一块最大的关键字大;
2)存储了块中的记录个数,以便于循环时使用;
3)用于指向块首数据元素的指针,便于开始对这一块中记录进行遍历。
在这里插入图片描述
在分块索引表中查找,就是要分两步进行:
1)在分块索引表中查找要查关键字所在的块。由于分块索引表是块间有序的,所以我们容易利用折半、插值等算法等到结果。
在这里插入图片描述
2)根据块首指针找到相应的块,并在块中顺序查找关键码。因为块中可以是无序的,所以只能是顺序查找。
在这里插入图片描述
在这里插入图片描述
总的来说,分块索引在兼顾了对细分块不需要有序的情况下,大大提高了整体查找的速度,所以普遍用于数据库表查找等技术的应用中

(3)倒序索引

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里这张单词表就是索引表,索引项的通用结构是:
次关键码:记录上面的"英文单词";
记录号表:例如上面的"文章编号"。
其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向该记录的指针或者是该记录的主关键字)。这样的索引方法就是倒排索引(Inverted index)。倒排索引源于实际应用中需要根据属性(或字段、或次关键码)的值来查找记录。这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址由于不是由记录来确定属性值,而是由属性值来确定记录,因而称为倒排索引
倒排索引的优点是查找记录非常快,基本等于生成索引表后,查找时都不用去读取记录,就可以得到结果。但它的缺点是这个记录号不确定,如一个单词可以在多篇文章中出现。
在这里插入图片描述

6、二叉排序树

在这里插入图片描述
因为插入或删除数据后,会影响原有数据集的排序,所以有可能需要重新排序。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
二叉排序树(Binary Sort Tree),又称为二叉查找树。它或者是一棵空树,或者是具有下列性质的二叉树:
1)若它的左子树不空,则它的左子树上所有结点的值均小于它的根结点的值;
2)若它的右子树不为空,则它的右子树上所有结点的值均大于它的根结点的值;
3)它的左、右子树也分别为二叉排序树。
从二叉树的定义可以知道,它前提是二叉树,然后它采用了递归的定义方法,再者,它的结点间满足一定的次序关系,左子树结点一定比其双亲结点小,右子树结点一定比其双亲结点大。
构造一棵二叉树的目的,其实不是为了排序,而是为了提高查找和插入删除关键字的速度。毕竟,在一个有序数据集上的查找,速度总是快于无序数据集的,而二叉树这种非线性的结构,也有利于插入和删除的实现

(1)二叉排序树查找操作

/*二叉树的二叉链表结点结构定义代码*/
typedef struct BiTNode/*结点结构*/
{
    
    
 int data;/*结点数据*/
 struct BiTNode* lchild, *rchild;/*左右孩子指针*/
}BiTNode, *BiTree;
/*递归查找二叉树中是否存在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);
}

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(2)二叉树插入操作

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

/*当二叉树T中不存在关键字等于key的数据元素时,*/
/*插入Key并返回TRUE,否则返回FALSE*/
Status InsertBST(BiTree* T,int key)
{
    
    
 BiTree p, s;
 if (!SearchBST(*T, key, NULL, &p))/*查找不成功*/
 {
    
    
  s = (BiTree)malloc(sizeof(BiTNode));
  s->data = key;
  s->lchild = s->rchild = NULL;
  if (!T)/*T为空树*/
   *T = s;/*插入s为根结点*/
  else if (key < p->data)
   p->lchild = s;/*插入s为p的左孩子*/
  else
   p->rchild = s;/*插入s为p的右孩子*/
  return TRUE;
 }
 else/*树中已有关键字相同的结点,不再插入*/
  return FALSE;
}

在这里插入图片描述
因为若查找操作执行失败,则p执行无左右孩子的结点,故不存在满二叉树时无法插入的情况。
创建一棵8-6-3这样的二叉树

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

(3)二叉树删除操作

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
如果删除的是有左右孩子的结点,那么按照中序遍历方法,它的直接前驱或后继不会同时有左孩子和右孩子。
故删除的结点有如下三种情况:
叶子结点;
仅有左子树或右子树的结点;
左右子树都有的结点,

/*若二叉树T中存在关键字等于key的数据元素时,则删除该数据元素结点,*/
/*并返回TRUE,否则返回FALSE*/
Status DeleteBST(BiTree* T, int key)
{
    
    
 if (!*T)/*查找不成功*/
 {
    
    
  return FALSE;
 }
 else
 {
    
    
  if (key == (*T)->data)/*找到关键字等于key的数据元素*/
   return Delete(T);
  else if (key < (*T)->data)
  {
    
    
   DeleteBST(&(*T)->lchild,key);
  }
  else
   DeleteBST(&(*T)->rchild, key);
 }
}
/*从二叉树中删除结点p,并重接它的左或右子树*/
Status Delete(BiTree* p)
{
    
    
 BiTree q, s;
 if (NULL == (*p)->lchild)/*左子树为空*/
 {
    
    
  q = *p;
  (*p) = (*p)->rchild;
  free(q);
 }
 else if (NULL == (*p)->rchild)/*右子树为空*/
 {
    
    
  q = *p;/*获得要删除的结点*/
  (*p) = (*p)->lchild;
  free(q);/*删除结点*/
 }
 else/*左右子树均不为空*/
 {
    
    
  q = *p;
  s = (*p)->lchild;
  while (s->rchild)/*转左,然后向右到尽头(找待测结点的直接前驱s)*/
  {
    
    
   q = s;
   s = s->rchild;
  }
  (*p)->data = s->data;/*s有可能是只有左孩子的结点或者是作为右孩子的叶结点*/
  if (q != (*p))
   q->rchild = s->lchild;/*重接q的右子树*/
  else/*说明没有进入while循环,即p的左孩子s没有右孩子*/
   q->lchild = s->lchild;/*重接q的左子树,s->lchild可以为NULL*/
  free(s);
 }
 return TRUE;
}

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(4)二叉排序树总结

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

6、平衡二叉树(AVL树)

平衡二叉树(Self-Balancing Binary Search Tree或Height-Balanced Binary Search Tree)是一种二叉排序树,其中每一个结点的左子树和右子树的高度差至多等于1
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(1)平衡二叉树实现原理

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

(2)平衡二叉树实现算法

结点结构定义:

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

右旋操作代码如下:

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

在这里插入图片描述
左旋操作代码如下:

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

在这里插入图片描述
上图中R,即6的左结点为空。
左平衡旋转处理的函数代码:

#define LH +1/*左高*/
#define EH 0/*等高*/
#define RH -1/*右高*/
/*对指针T所指结点为根的二叉树作左平衡旋转处理*/
/*本算法结束,指针T指向新的根结点*/
void LeftBalance(BiTree* T)
{
    
    
 BiTree L, Lr;
 L = (*T)->lchild;
 switch (L->bf)
 {
    
    /*检查T的左子树的平衡度,并作相应平衡处理*/
 case LH:/*新结点插入在T的左孩子的左子树上,要作右旋处理*/
  (*T)->bf = L->bf = EH;/*作平衡处理后,平横度为0*/
  R_Rotate(T);
  break;
 case RH:
  /*新结点插入在T的左孩子的右子树上,要作双旋处理*/
  Lr = L->rchild;/*Lr指向T的左孩子的右子树*/
  switch (Lr->bf)
  {
    
    
  case LH:
   (*T)->bf = RH;/*平衡后T和L互换,故这里的T在平衡后即为L,L即为T*/
   L->bf = EH;
   break;
  case EH:
   (*T)->bf = L->bf = EH;
   break;
  case RH:
   (*T)->bf = EH;
   L->bf = LH;
  }
  Lr->bf = EH;
  L_Rotate(&(*T)->lchild);/*T的左子树作左旋平衡处理*/
  R_Rotate(T);/*对T作右旋平衡处理*/
  break;
 }
}

在这里插入图片描述
在这里插入图片描述
右旋平衡旋转处理的函数代码:

/*对指针T所指结点为根的二叉树作右平衡旋转处理*/
/*本算法结束,指针T指向新的根结点*/
void RightBalance(BiTree* T)
{
    
    
 BiTree R, Rl;
 R = (*T)->rchild;
 switch (R->bf)
 {
    
    /*检查T的左子树的平衡度,并作相应平衡处理*/
 case RH:/*新结点插入在T的左孩子的左子树上,要作右旋处理*/
  (*T)->bf = R->bf = EH;/*作平衡处理后,平横度为0*/
  L_Rotate(T);
  break;
 case LH:
  /*新结点插入在T的左孩子的右子树上,要作双旋处理*/
  Rl = R->lchild;/*Lr指向T的左孩子的右子树*/
  switch (Rl->bf)
  {
    
    
  case RH:
   (*T)->bf = LH;/*平衡后T和R互换,故这里的T在平衡后即为R,R即为T*/
   R->bf = EH;
   break;
  case EH:
   (*T)->bf = R->bf = EH;
   break;
  case LH:
   (*T)->bf = EH;
   R->bf = RH;
  }
  Rl->bf = EH;
  R_Rotate(&(*T)->rchild);/*T的右子树作右旋平衡处理*/
  L_Rotate(T);/*对T作左旋平衡处理*/
  break;
 }
}

以上结点bf只能时1、-1和0,故在创建二叉树时,根据一个结点是否存在左子树和(或)右子树即可得出该结点的bf。
主函数:

/*若在平衡树的二叉排序树T中不存在和有相同关键字的结点,则插入*/
/*一个新的数据元素e为新的结点并返回1,否则返回0。若因插入而使*/
/*二叉排序树失去平衡,则作平衡旋转处理,布尔变量taller反映T长高与否。*/
Status InsertAVL(BiTree* T,int e,Status* taller)
{
    
    
 if (!T)
 {
    
    /*插入新结点,树“长高”,taller为TURE*/ 
  *T = (BiTree)malloc(sizeof(BiTNode));
  (*taller)= TRUE;
  (*T)->data = e;
  (*T)->lchild = (*T)->rchild = NULL;
  (*T)->bf = EH;
 }
 else
 {
    
    
  if (e == (*T)->data)
  {
    
    /*树中已存在和e有相同关键字的结点,不再插入*/
   (*taller) = FALSE;
   return FALSE;
  }
  else if(e<(*T)->data)
  {
    
    /*应继续在T的左子树中进行搜索*/
   if (!InsertAVL(&(*T)->lchild, e, taller))
    return FALSE;
   if ((*taller))
   {
    
    /*已插入到T的左子树中且左子树“长高”*/
    switch ((*T)->bf)/*检查T的平衡度(这里的T是新分配结点的双亲)*/
    {
    
    
    case LH:/*原本左子树比右子树高,需要作左平衡处理*/
     LeftBalance(T);
     (*taller) = FALSE;
     break;
    case EH:/*原本左右子树等高,现因左子树增高而树增高*/
     (*T)->bf = LH;
     (*taller) = FALSE;
     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的平衡度(这里的T是新分配结点的双亲)*/
    {
    
    
    case LH:/*原本左子树比右子树高,需要左右子树等高*/
     (*T)->bf = EH;
     (*taller) = FALSE;
     break;
    case EH:/*原本左右子树等高,现因右子树增高而树增高*/
     (*T)->bf = RH;
     (*taller) = FALSE;
     break;
    case RH:/*原本右子树比左子树高,需要作右平衡处理*/
     RightBalance(T);
     (*taller) = FALSE;
     break;
    }
   }
  }
 }
 return TRUE;
}

在这里插入图片描述

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

在这里插入图片描述
在这里插入图片描述

7、多路查找树(B树)

内存一般都是由硅制存储芯片组成,这种技术的每一个存储单位代价都要比磁盘存储技术的昂贵两个数量级,因此基于磁盘技术的外存,容量比内存容量至少大两个数量级。
前面讨论过的数据结构,处理数据都是在内存中,因此考虑的都是内存中的运算时间复杂度。
但如若操作的数据集非常大,大到内存已经没有办法处理时,如数据库中上千记录的数据表、硬盘中的上千万文件等,在这种情况下,对数据的处理需要不断从硬盘等存储设备中调入或调出内存页面。
一旦涉及到这样的外部存储设备,关于时间复杂度的计算就会发生变化,访问该集合元素的时间已经不仅仅是寻找该元素所需比较次数的函数,必须考虑对硬盘等外部存储设备的访问时间以及将会对该设备做出多少次单独访问。
试想一下,为了要在一个拥有几十万文件的磁盘中查找一个文本文件,你设计的算法需要读取磁盘上万次还是几十次,是有本质差异的。此时,为了降低外存设备的访问次数,就需要新的数据结构来处理这样的问题。
之前提到的树,一个结点只能存储一个元素,在元素非常多时,就使得要么树的度(结点拥有的子树的个数的最大值)非常大,要么树的高度非常大,甚至两者都必须足够大才行。这就使得内存存取外存次数非常多,这显然成了时间效率上的瓶颈,这迫使我们要打破一个结点只存储一个元素的限制,为此引入多路查找树的概念。
多路查找树(muti-way search tree)其每一个结点的孩子可以多于两个,且每一个结点处可以存储多个元素。由于它是查找树,所有元素之间存在某种特定排序关系。
多路查找树有四种特殊形式:2-3树、2-3-4树、B树、B+树。

(1) 2-3树

2-3树是这样一棵多路查找树:其中的每一个结点都具有两个孩子(我们称它为2结点)或三个孩子(我们称它为3结点)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(2) 2-3-4树

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(3)B树

B树(B-tree)是一种平衡的多路查找树2-3树和2-3-4树都是B树的特例。结点最大的孩子数目称为B树的阶(order)。因此2-3树是3阶B树,2-3-4树是4阶B树。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(4)B+树

前面提及了B树的诸多好处,但它还是有缺陷的。对于树结构来说,我们都可以通过中序遍历来顺序查找树中的元素,这一切都是在内存中进行。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
这样的数据结构最大的好处就在于,如果是要随机查找,就从根结点出发,与B树的查找方式相同,只不过即使在分支结点找到了待查找的关键字,它也只是用来索引的,不能提供实际的访问,还是需要到达包含此关键字的终端结点。
如果需要从最小关键字进行从小到大顺序查找,就可以从最左侧的叶子结点出发,不经过分支结点,而是延着下一叶子指针就可以遍历所有关键字。
B+树的结构特别适合带有范围的查找。比如查找学校中18-22岁的学生人数,可以通过从根结点出发找到第一个18岁的学生,然后再在叶子结点按顺序查找到符合范围的所有记录。
B+树的插入、删除过程也与B树类似,只不过插入和删除元素都是在叶子结点上进行而已

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

在这里插入图片描述
在这里插入图片描述

(1)散列表的定义

只需要通过某个函数f,使得存储位置=f(关键字),即只需要查找关键字而不需要比较就可以获得需要的记录的存储位置。这就是一种新的存储技术——散列技术。
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个关键字f(key)。查找时,根据这个确定的对应关系找到给定值key的映射f(key),若查找集合中存在这个记录,则必定在f(key)的位置上。
这里把这种对应关系f称为散列函数,又称为哈希(Hash)函数。按照这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash Table)。那么关键字对应的记录存储位置称为散列地址。

(2)散列表查找步骤

在这里插入图片描述
因此,散列技术既是一种存储方法,也是一种查找方法。然而它与线性表、树、图这种结构不同的是,前面几种结构,数据元素之间都存在某种逻辑关系,可以用连线图表示出来,而散列技术的记录之家不存在什么逻辑关系,它只与关键字有关联。因此,散列主要是面向查找的存储结构。
散列技术最适合求解的问题是查找与给定值相等的记录。对于查找来说,简化了比较过程,效率就会大大提高。但同时也意味着散列技术不具备很多常规数据结构的能力。
比如那种同样的关键字,它能对应很多记录的情况,却不适合用散列技术。一个jin’xing
在这里插入图片描述
同样散列表也不适合范围查找,比如查找一个班级18-22岁的同学,在散列表中没法进行。想获得表中记录的排序也不可能,像最大值、最小值等结果也无法从散列表中计算出来。
总之,设计一个简单、均匀、存储效率高的散列函数是散列技术中最关键的问题。
另一个问题是冲突。在理想情况下,每一个关键字通过散列函数计算出来的地址都是不一样的,可实际上,这只是一个理想。我们时常碰到两个关键字key1≠key2,但是却有f(key1)=f(key2),这种现象我们称为冲突(collision),并把key1和key2称为这个散列函数的同义词(synonym)。出现了冲突当然非常糟糕,那将造成查找错误。尽管可以通过精心设计散列函数让冲突尽可能的少,但是不能完全避免。

9、散列函数的构造方法

好的散列函数有如下两个参考原则:

(1)计算简单

在这里插入图片描述

(2)散列地址分布均匀

在这里插入图片描述

散列函数的构造方法:

以下是几种常用的散列函数的构造方法,这些方法实质上是将原来数字按某种规律变成另一种数字。

(1)直接定址法

在这里插入图片描述
也就是说,可以取关键字的某个线性函数值作为散列地址,即
f(key)=a✖key+b (a、b为常数)。
这样的散列函数优点就是简单、均匀,也不会产生冲突,但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的情况。由于这样的限制,在实际应用中,此方法虽然简单,但不常用。

(2)数字分析法

在这里插入图片描述
这里提到了一个关键词——抽取。抽取方法是使用关键字的一部分来计算散列存储位置的方法,这在散列函数中是常用到的手段。
数字分析法适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。

(3)平方取中法

在这里插入图片描述

(4)折叠法

折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。
在这里插入图片描述

(5)除留余数法

在这里插入图片描述
在这里插入图片描述
根据前辈的经验,若散列表表长为m,通常p为小于或等于表长的最小质数或不包含小于20质因子(如6的质因子为2和3,因为6=2✖3)的合数。

(6)随机数法

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

如果关键字是字符串,如英文字符、中文字符和各种各样的字符,可以依据ASCII码或者Unicode码等,将其换转成数字,也就可以使用上面介绍的这些办法。

总之,现实生活中,应该视不同的情况采用不同的散列函数,下面提供一些考虑的因素提供参考:
1)计算散列地址所需的时间;
2)关键字的长度;
3)散列表的大小;
4)关键字的分布情况;
5)记录查找的概率。
综合这些因素,才能决策选择哪种散列函数更合适。

10、处理散列冲突的方法

(1)开放定址法

所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
把这种解决冲突的开放定址法称为线性探测法
在这里插入图片描述
在这里插入图片描述
随机种子(Random Seed)是计算机专业术语,一种以随机数作为对象的以真随机数(种子)为初始条件的随机数。一般计算机的随机数都是伪随机数,以一个真随机数(种子)作为初始条件,然后用一定的算法不停迭代产生随机数。

(2)再散列函数法

在这里插入图片描述

(3)链地址法

在这里插入图片描述
在这里插入图片描述

(4)公共溢出区法

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

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;/*散列表表长,全局变量*/
/*初始化散列表*/
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 (NULLKEY != H->elem[addr])/*如果不为空,则冲突*/
  addr = (addr + 1) % m;/*开放定址法的线性探测解决冲突*/
 H->elem[addr] = key;/*直到有空位后插入数据*/
}

代码中插入关键字时,首先算出散列地址,如果当前地址不为空关键字,则说明有冲突。此时应用开放定址法的线性探测进行重新寻址,此处也可以更改为链地址法等其他解决冲突的办法。
散列表存在后,需要时就可以通过散列表查找要的记录。

/*散列表查找关键字,用addr返回关键字的存储地址*/
Status SearchHash(HashTable H, int key,int* addr)
{
    
    
 *addr = Hash(key);/*求散列地址*/
 while (key != H.elem[*addr])/*地址addr存储的关键字与key不相等,*/
        /*说明key的存储地址是经过解决冲突之后的*/
 {
    
    
  *addr = (*addr+1)%m;/*开放定址法的线性探测*/
  if (NULLKEY == H.elem[*addr] || *addr == Hash(key))
  {
    
    /*如果循环回到原点*/
   return UNSUCCESS;/*则说明关键字不存在*/
  }
 }
 return SUCCESS;
}

注:上面的提到的存储地址相当于数组下标。

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

12、总结回顾

在这里插入图片描述
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/fazstyle/article/details/106120810