数据结构之查找

321 阅读17分钟

本文内容参考了《数据结构 c语言版 第2版》由严蔚敏 李冬梅 吴伟民编著

1、查找的基本概念

  • 查找表:由同一类型的数据元素构成的集合。由于集合中的数据元素之间是完全松散的关系,因此查找表是一种比较灵便的数据结构,可以用线性表、树表、散列表来实现
  • 关键字:数据元素或者记录当中某个数据项的值,用它可以标识一个数据元素。
  • 主关键字:可以唯一标识一条记录的关键字
  • 次关键字:可以识别若干记录的关键子
  • 查找:根据给定的某个值,在查找表当中确定一个其关键字等于给定值的记录或数据元素
    • 如果存在这么一条记录,则查找成功,此时查找的结果可给出整个记录的信息,或指示该记录在查找表当中的位置
    • 如果表中不存在关键字等于给定值的记录,则称查找不成功,此时查找的结果可以给出一个空记录或空指针
  • 动态查找表:在查找的同时对表做修改操作(插入和删除),则相应的表称之为动态查找表
    • 若表中存在其关键字等于给定值的记录,则查找成功返回
    • 不存在,则插入关键子等于给定值的记录
  • 静态查找表:查找过程中不会修改表
  • 平均查找长度:为确定记录在查找表当中的位置,需要和给定值进行比较的关键字个数的期望值,称为查找算法在查找成功时的平均查找长度

2、线性表的查找

2.1 顺序查找

顺序查找:从表的一端开始,依次将记录的关键字和给定值进行比较,若某个记录的关键字和给定值相等,则查找成功;反之,扫描整个表都没有找到和给定值相等的关键字的记录,则查找失败 顺序查找方法适用于线性表的顺序存储结构,又适用于链式存储结构

设置监视哨的顺序查找

  • 原先每次for循环结束条件是当前的下标是否到头,循环的内部判断关键字是否相同

      int search_seq(SSTable ST,KeyType key){
          for(int i = ST.length; i >= 1; i--){
              if(ST.R[i].key == key) return i;
          }
          return 0;
      }
    
  • 设置监视哨,就比如顺序表的第一个元素是不存放的,专门用来做一个哨兵,for循环的终止条件变成找到关键字为key的记录,要么到头,要么真的找到了。这个改进可以让顺序查找在ST.length >= 1000时,进行一次查找所需要的平均时间减少一半

      int search_seq(SSTable ST,KeyType key){
          ST.R[0].key = key;
          int i;
          for(i = ST.length; ST.R[i].key == key ; i--){
          }
          return i;
      }
      
    
  • 时间复杂度:O(n)

  • 顺序查找优点

    • 算法简单
    • 对于表的结构没有要求,适用于顺序结构,也适用于链式结构
    • 关键字是否有序都是可用的
  • 顺序查找缺点

    • 查找长度大时查找效率低

2.2 折半查找

折半查找:也叫二分查找,是一种效率较高的查找方法。要求线性表必须采用顺序存储结构,表中的元素按照关键字有序排列。

如果从小到大排列,查找过程是,从表的中间记录开始:

  • 如果给定值和中间记录的关键字相等,则查找成功
  • 如果给定值大于或小于中间记录的关键字的关键字,则在表中大于或小于中间记录的那一半中查找,这样重复操作,直到查找成功

折半查找每次查找比较都会让查找范围缩小一半,与顺序查找比较,会提高查找效率

  • 时间复杂度:O(logn)
  • 折半查找优点
    • 比较次数小,查找效率高
  • 折半查找缺点
    • 对表结构要求高,只能用于顺序存储的有序表
    • 考虑到进行插入和删除时平均都要移动一半的元素,不太适用于需要频繁变动数据元素的线性表

2.3 分块查找

分块查找,又称为索引顺序查找,性能介于顺序表和折半查找之间的一种查找方式,除了表本身,还需要建立一个索引表。

通过索引表,将整个线性表分为多个子表,索引表内为每个子表存储了一个索引项,每个索引项内包含了关键字项和指针项,关键字为子表的最大关键字,指针指向子表开始的位置。

索引表按照关键字有序排列表项,而存储数据的表是分块有序,比如A索引关键字大于B索引,则A内的所有数据的关键字都大于B内所有最大关键字

  • 分块查找查找过程
    • 确定所在的块,顺序查找或者折半查找索引,找到关键字满足 A<key<=B,则这条记录在B块当中
    • 在块内进行顺序查找,如果找到则返回记录,如果遍历完子表都没有找到则说明没有这个关键字对应记录
  • 平均查找长度:Lb + Lw ,Lb表示确定所在块的平均查找长度,Lw为在块中查找元素的平均查找长度
  • 分块查找的优点
    • 在表中插入和删除元素时,只需要找到元素对应的块,就可以在该块内进行插入和删除操作,块内无序,故插入和删除比较容易
    • 如果线性表既要快速查找,又要动态变化,适合用分块查找
  • 分块查找的缺点
    • 依赖于索引表,需要增加索引表这个新的数据结构
    • 索引表需要排序

3、树表的查找

顺序表的查找更适合于用静态查找的方式,因为插入或删除元素都需要移动多条记录。如果需要动态查找,适合采用几种特殊的二叉树作为查找表的组织形式。

3.1 二叉排序树

二叉排序树也叫二叉查找树

  • 二叉排序树的定义

    • 二叉排序树要么是一棵空树,要么是一棵具有如下性质的完全二叉树
    • 若它的左子树不为空,则左子树上的所有结点的值均小于它的根节点的值
    • 若它的右子树不为空,则右子树上的所有结点的值均大于它的根节点的值
    • 左右子树分别为二叉排序树
  • 中序遍历一棵二叉排序树可以得到一个结点值递增的有序序列

3.1.1 二叉排序树的查找

  • 查找方式

    • 若二叉排序树为空,则查找失败,返回空指针
    • 若二叉排序树非空,则比较根关键字和所查找的关键字
      • 如果根结点关键字等于所查找关键字,则查找成功,返回该结点
      • 如果根结点关键字大于所查找关键字,递归查找左子树
      • 如果根结点关键字大于所查找关键字,递归查找右子树
  • 平均查找长度

    • 最坏情况,这棵树是一个单支树,n个结点组成,平均查找需要(n + 1)/2 次

    • 最好情况,这棵树是一棵满二叉树,n个结点组成,平均查找长度为logn的相同数量级

3.1.2 二叉排序树的插入

  • 插入方式
    • 若二叉排序树为空,则将待插入的结点作为根结点
    • 若二叉排序树不为空,则将待插入的结点关键字与当前根节点关键字比较
      • 如果当前根结点的关键字 < 待插入结点关键字,将结点插入右子树
      • 如果当前根结点的关键字 > 待插入结点关键字,将结点插入左子树

3.1.3 二叉排序树的删除

  • 被删除的结点是叶子结点,这个时候只需要修改双亲的孩子指针为空

  • 被删除的结点只有左子树或者右子树,这个时候如果被删除的结点是双亲结点的左子树,则删除的结点的子树作为双亲结点的左子树,右结点则反之

  • 被删除的结点左右子树都不为空,可以从二叉排序树的中序遍历是从小到大的序列入手,...PL P PR ... , 此时存在两种处理方式

    • 将PL链接到P的父节点上,PR作为右子树链接到原本PL的最大关键字的结点上,这样会让树的深度增加
    • 用PL的最大关键字的结点代替P,PR不需要动,如果最大关键字的结点原来存在左子树,把左子树移动到该结点的父节点的右子树位置
  • 时间复杂度:O(logn)

3.2 平衡二叉树

二叉排序树查找的性能取决于树的结构,如果数据是有序排列的,查找的时间复杂度为O(n),如果树的结构合理,查找的时间复杂度为O(logn),树的高度越小,那么查找需要的次数越小。

  • 平衡二叉树的概念
    • 平衡二叉树或是空树,或是具有如下特征的二叉排序树
    • 左子树和右子树深度相差不超过1
    • 左子树和右子树也是平衡二叉树
  • 平衡因子:该结点的左子树和右子树的深度之差,如果该树是平衡二叉树,那么每个结点的平衡因子都只能是 -1,1,0

3.2.1 平衡二叉树的调整方法

找到距离插入结点最近的平衡因子的绝对值超过1的祖先结点,以该结点为根的子树称为最小不平衡子树,重新调整的范围限定在这棵最小不平衡子树

一般情况下,假设最小不平衡子树的根结点为A,则失去平衡后进行调整的的规律可分为如下四种情况

  • LL型
    • 在A左子树根结点B的左子树上插入结点,让A的平衡因子由1增加到2,需要进行一次向右顺时针旋转
      • A的左子树根节点B变为整个树的根结点,A变为B的右子树
      • 如果B原来存在右子树,则把B的右子树变为A的左子树

LL.png

  • RR型
    • 在A的右子树根节点B的右子树上插入结点,A的平衡因子由-1增加到-2,需要进行一次向左逆时针旋转
      • A的右子树根结点B变为整个树的根结点,A变为B的左子树
      • 如果B原来存在左子树,那么需要把B的左子树变为A的右子树

RR.png

  • LR型
    • 在A的左子树根结点B的右子树C上插入结点,A的平衡因子由1增加到2,需要进行两次旋转
      • 将C和B逆时针旋转,让B变为C的左子树,如果C原来存在左子树,那么C的左子树变为B的右子树
      • 此时情况变为了LL的情况,让C和A顺时针旋转,A变为C的右子树,如果C原来存在右子树,那么C的右子树变为A的左子树

LR.png

  • RL型
    • 在A的右子树根结点B的左子树C上插入结点,A的平衡因子由-1变为-2,需要进行两次旋转
    • 将C和B旋转,让B变为C的右子树,如果C原来有右子树,那么让其成为B的左子树
    • 将C和A旋转,让A变为C的左子树,如果C原来有左子树,那么让其成为A的右子树

3.3 B树

前面介绍的查找方式使用于存储在计算机内存中较小的文件,统称为内查找法。如果文件很大且存放于外存进行查找时,这些方法就不适用了。内部查找都是以结点为单位进行查找,这样需要反复进行内、外存的交换,是很费时的。

B树,也叫B-树,是适用于外部查找的平衡多叉树,磁盘管理系统中的目录管理,以及数据库系统中的索引组织大多都采用B树这种数据结构。

3.3.1 B树的定义

  • 一棵m阶的B-树,或为空树,或为满足下列特性的m叉树
    • 树中的每个结点至多有m棵子树
    • 若根结点不是叶子结点,则至少有两棵子树
    • 除了根之外的所有非终端结点至少有m/2向上取整 棵子树
    • 所有的叶子节点都出现在同一层次,并且不带信息,通常称为失败结点,失败结点并不存在,指向这些节点的指针为空,引入失败结点是为了便于分析B树的查找能力
    • 所有非终端结点最多有m-1个关键字,每个结点包括了当前结点的关键字个数,关键字,以及关键字个数+1个指针,为了记录双亲结点,也会添加双亲结点的指针域
      • 多个关键字按照大小顺序排列

      • 关键字左指针指向的子树所有关键字都小于该关键字,关键字右指针指向的子树所有关键字都大于该关键字

  • B树体现的特点
    • 平衡:每个叶子结点均在同一层次
    • 有序: 树中每个结点当中的关键字都是有序的,且关键字K1左子树中的关键字均小于K1,而右子树中的关键字都大于K1
    • 多路:有的结点一个关键字,有两棵子树,有的结点2个关键字,有3棵子树

3.3.2 B树查找

在结点内顺序查找关键字,确定位置后到下一个结点去顺序查找关键字,这样逐层遍历单个结点,最终找到关键字即找到,找到叶子结点就表示没有查找到

3.3.3 B树插入

B树是平衡多叉树,如果是m叉树,那么其除了根结点以外的其他结点,都至少有 m/2 向上取整的子树,即出了根节点外,每个结点内都至少需要有 m/2 - 1向上取整 - 1 个关键字,至多有m - 1个关键字。比如3叉树,则除了根结点外,其他结点内至少需要包含1个关键字,至多包含2个关键字

  • 插入一个结点是在最低层某个非终端结点添加一个关键字
    • 如果关键字数量不超过 m - 1,那么插入完成
    • 如果关键字数量超过 m - 1,需要分裂,将此结点在同一层分为两个结点,以中间关键字为界限,把结点一分为二,把中间关键字插入到双亲结点上,如果双亲结点满了,以同样的方式分裂双亲结点,最坏情况一直分裂到根结点,树的高度加一

3.3.4 B树删除

删除操作是指删除某个关键字以及其临近的指针,删除之后该树依然要满足B树的定义 * 如果删除关键字后结点的关键字小于 m/2向上取整 - 1个,那么需要进行合并 * 删除指针 * 如果删除的指针指向为空,那么直接删除 * 如果删除的指针指向一棵子树,需要...

3.4 B+树

B+树是B树的变形树,更适合用于文件索引系统

  • B+树和B树的区别

    • 有n棵子树的结点包含n个关键字
    • 所有的叶子结点包含了全部关键字的信息,以及指向包含这些关键字记录的指针,叶子结点本身依靠关键字的大小自小而大顺序连接
    • 所有非终端结点都可以看作是索引部分,结点中仅包含子树根结点中最大或最小的关键字
    • B+树有两个头指针,一个指向根结点,一个指向最小关键字所在结点,所以查找也有两种方式,一种从最小关键字起顺序查找,另一种从根结点开始进行随机查找
  • B+树的查找:若非终端结点上的关键字等于给定值,并不终止,继续向下查找直到找到了叶子结点

  • B+树的插入:仅在叶子节点上进行插入,插入后关键字大于m个需要进行分裂,分裂后双亲结点添加两个结点的最大关键字,如果双亲结点关键字个数超过m,双亲结点分裂

  • B+树的删除:仅在叶子结点当中进行删除,叶子结点中的最大关键字被删除后,需要在非终端结点中选中一个代替的关键字,如果删除关键字让结点中关键字的个数小于m/2向上取整个,需要进行合并

4 散列表的查找

前面的关于线性表的查找,树表的查找都是基于关键字的比较,查找时间和表的长度有关,当表中的记录很多的时候,查找需要大量和关键字进行比较,查找速度会很慢。如果能在元素的存储位置和其关键字中建立某种直接关系,做查找的时候就无需做比较,或者做很少次数的比较。

由关键字和对应关系找到记录的方法称为数列查找法(Hash Search),它通过对元素的关键字进行某种运算,直接求出元素的地址,不要反复比较,因此,散列查找法又叫做杂凑法或数列法。

  • 散列函数和散列地址: 在记录的存储位置p和关键字key之间建立一个确定的对应关系H,使p=H(key),称这个对应关系H为散列函数,p为散列地址
  • 散列表:一个有限连续的地址空间,用以存储按散列函数计算得到的相应散列地址的数据纪律,通常是一个一维数组,散列地址是数组的下标
  • 冲突:对于不同的关键字可能可到同一散列地址。这两个关键字互为同义词

4.1 散列函数构造方法

  • 构造散列函数的方法很多,一般具体问题具体分析,通常需要考虑以下因素
    • 散列表的长度
    • 关键字的长度
    • 关键字的分布情况
    • 计算散列函数需要的时间
    • 记录的查找频率
  • 好的散列函数具备以下特征
    • 函数计算要简单,每一关键字都只能有一个散列地址与之对应
    • 函数的值域需要在表长的范围内,计算得到的散列地址应当分布均匀,尽可能减少冲突
  • 构造方法
    • 数字分析法:事先知道关键字集合,每个关键字的位数比散列表地址码位数多,每个关键字有n位数组成,如k1k2...kn,则可以从关键字中提取数字分布比较均匀的若干位作为散列地址
    • 平方取中法:选定散列函数的时候不一定知道关键字的全部情况,取其中的几位作为散列地址也不一定合适。一个数的平方后的中间几位数和数的每一位都相关,可以取其中间几位数或者组合作为散列地址,也比较随机
    • 折叠法:将关键字分为位数相同的几个数,取这几个数的叠加和舍去进位作为散列地址
      • 移位叠加:正序读取每一个分割的数
      • 边界叠加:S型读取每一个分割的数
    • 除留余数法:设散列表长为m,选择一个不大于m的数p,用p去除关键字,除后所得余数为散列地址,即H(key) = key % p。需要选取适当的p,一般p为小于表长的最大质数。这种方法是最常用的构造散列函数的方法。

4.2 处理冲突的方法

在实际应用中,很难完全避免发送冲突,所以选择一个有效的处理方法是散列法的一个关键问题。

处理冲突的方法和散列表本身的组织形式有关。按照组织形式的不同,通常分为两大类:开放地址法和链地址法

4.2.1 开放地址法

把记录都存放到散列表数组当中,当某一记录关键字key的初始散列地址H0 = H(key)发生冲突时,以H0为基础,采用合适的方法计算得到另一个地址H1,如果H1仍然发生冲突,以H1为基础求H2,直到找到不发生冲突的位置。

由于寻找下一个空的散列地址时,数组空间对于所有元素都是开放的,所以称为开放地址法,用公式表示是,Hi = (H(key) + di) % m,i = 1,2,...,k(k <= m - 1)

  • 线性探测法: 发生冲突时从冲突地址的下一个单元顺序查找,如果到最后一个位置也没有找到,则回到表头查找
    • di = 1,2,3,4,..,m - 1
    • 只要线性表没有被填满,就可以找到位置
    • 会发生二次聚集,两个第一个散列地址不同的数争夺同一个后继散列地址
  • 二次探测法
    • di = 1^2,-1^2,2^2,-2^2,...,+k^2,-k^2 (k <= m/2)
    • 可以避免二次聚集,但不一定能找到
  • 伪随机探测法:
    • di = 伪随机数序列
    • 可以避免二次聚集,但不一定能找到

4.2.2 链地址法

把具有相同散列地址的记录放在一个单链表中,称为同义词链表,有m个散列地址就有m个单链表,单链表的头指针用一个一维数组存储,散列地址相同的记录都以结点的形式插入到对应的单链表单中,使用前插法效率更高