1、查找的基本概念
查找表是由同一类型的数据元素或者记录构成的集合。由于集合中的数据元素之间存在着松散的关系,因此查找表是一种应用灵便的结构。
查找: 根据给定的值,在查找表中确定一个其关键字等于给定值的数据元素或者记录。
关键字: 用来标识一个数据元素或者记录的某个数据项的值
主关键字: 可以唯一地标识一个记录的关键字是主关键字;
次关键字: 反之,用以标识若干记录的关键字是次关键字;
若查找表中存在这样一个记录,则称为“查找成功”,查找结果给出整个记录的信息,或者指示该记录在查找表中的位置;否则称为“查找不成功”,查找结果给出空记录或者空指针
查找的目的: 查询某个特定的数据元素是否在查找表中;检索某个特定的数据元素的各种属性;在查找表中插入一个数据元素;删除查找表中的某个元素。
查找表的分类: 仅做查询(检索)操作的查找表叫做静态查找表;做插入和删除操作的查找表,称为动态查找表。
如何评价查找算法: 使用平均查找长度,即关键字的平均比较次数来评价查找算法:
2、线性表的查找
2.1 顺序查找
顺序查找应用于顺序表或者线性链表的静态查找表,表内的元素是无序的。顺序表通常使用一个结构类型来进行定义:
//数据项结构 typedef struct { KeyType key; //关键字域 ... ... //其他域 }ElemType; //线性表结构 typedef struct { ElemType *R; //表基址 int length; //表长度 }SSTable; SSTable ST; //定义顺序表
顺序查找的算法步骤如下:
int Search_Seq(SSTable ST, KeyType key) { for(int i = ST.length; i > 0; --i) if(ST.R[i].key == key) return i; return 0; }
上述查找算法每次循环需要对比key值和看下标i是否越界,可以增加一个监视哨,从而缩减掉检查下标越界的判断,将监视哨放在下标为0的位置,找到key值,则返回其在表中的位置,否则返回0。顺序查找代码如下所示:
int Search_Seq(SSTable ST, KeyType key) { ST.R[0].key = key; int i = ST.length; for(; ST.R[i].key != key; --i) ; return i; }
顺序查找的时间复杂度为O(n);空间复杂度为O(1),即需要一个辅助空间来存储哨兵的位置。
顺序查找的优点:算法简单,逻辑次序无要求,且不同的存储结构均适用;缺点是平均查找长度ASL过长,时间复杂度高。
2.2 二分查找
对于有序的顺序表可以采用二分查找的方式来加快查找速度。设表长为n,low,high和mid分别指向待查元素所在区间的上界,下界和终点,key为给定的要查找的值。初始时,令low=1,hign=n,mid=(low+high)/2; 让k与mid指向的记录比较:
若key==R[mid].key,查找成功
若key<R[mid].key,high = mid-1
若key>R[mid].key,low = mid+1
重复上述操作,直至low>high,查找失败
//循环实现二分查找 int Search_Bin(SSTable ST, KeyType key) { int low = 1; int high = ST.length; while(low<=high) { mid = (low+high)/2; if(ST.R[mid].key = key) return mid; else if(key < ST.R[mid].key) high = mid-1; else low = mid+1; } return 0; //查找元素不再顺序表中 } //递归实现二分查找 int Search_Bin(SSTable ST, KeyType key, int low, int high) { if(low > high) return 0;//查找不到返回0 mid = (low+high)/2; if(key == ST.elem[mid].key) return mid; else if(key < ST.elem[mid].key) Search_Bin(ST, key, low, mid-1); else Search_Bin(ST, key, mid+1, high); }
二分查找的时间复杂度可以使用判定树来计算,根据二分查找中每个元素查找需要比较的次数,将元素放在二叉判定树的不同层次上,最终可知在最坏情况下,比较的次数不会超过二叉判定树的最大深度: ⌊log2n⌋+1
二分查找的平均查找次数(ASL)计算方式如下所示:
所以二分查找算法对于顺序表而言时间复杂度为 log2n。二分查找的优点:效率比顺序查找高;缺点是:只适用于有序表,且限于顺序存储结构,对线性链表无效,因为高效获取mid值的位置。
2.3 分块查找
分块查找的条件,首先将表分成几块,且表或者有序,或者分块有序;若i<j,则第j块中所有记录的关键字均大于第i块中的最大关键字;之后建立索引表,每个结点均含有最大关键字域和指向本块第一个结点的指针,且按照关键字有序。
分块查找的过程:先确定待查记录所在的块(通过顺序或者折半查找),之后再在块内查找(采用顺序查找)。
分块查找的效率分析如下所示:
分块查找的优点:插入和删除比较容易,无需进行大量的移动;缺点:需要增加一个索引表的存储空间,并对初始索引表进行排序运算。适用于:如果线性表既要快速查找又要经常动态变化,则可以采用分块查找。
3、树表的查找
当表的插入,删除操作频繁时,需要移动表中很多记录,所以需要改用动态查找表,需要用到几种特殊的树:如二叉排序树,平衡二叉树,红黑树,B+树,B-树,键树等。对于给定的key值,若表中存在,则成功返回,否则插入关键字等于key的记录。
3.1 二叉排序树
二叉排序树定义:二叉排序树或者是空树,或者是满足如下性质的二叉树:(1) 若其左子树非空,则左子树上所有结点的值均小于根结点的值;若其右子树非空,则右子树上所有结点的值均大于等于根节点的值;其左右子树本身又各自是一颗二叉排序树。
中序遍历二叉排序树恰好是一个递增有序的序列。
二叉排序树的查找操作:若查找到的关键字等于根结点,成功查找到;否则若小于根结点,则查看其左子树;若大于根结点,则查看其右子树;在左右子树上的操作类似。
3.3.1.1 二叉排序树的存储结构
typedef struct ElemType { KeyType key; //关键字项 InfoType otherInfo;//其他数据域 }; typedef struct BSTNode { ElemType data; //数据域 struct BSTNode *lChild, *rChild; //左右孩子指针 }*BSTree; BSTree T; //定义二叉排序树T
3.3.1.2 二叉排序树的查找算法
BSTree SearchBST(BSTree &T, KeyType &key) { if((!T) || key == T->data.key) return T; else if(key < T->data.key) return SearchBST(T->lChild, key); else return SearchBST(T->rChild, key); }
3.3.1.2 二叉排序树的查找分析
二叉排序树上查找某关键字等于给定值的过程,其实就是走了一条从根到该结点的路径,所以比较的关键字次数等于此结点所在的层次数。所以最坏查找次数即为树的深度。
对于含有n个结点的二叉排序树的平均查找长度和树的形态有关,最好情况下,树的深度为⌊log2n⌋+1,则时间复杂度为 O(log2n)。最坏情况下,插入的n个元素从一开始就有序,则会变成单支树的形态,此时树的深度为n,平均查找次数和顺序查找相同,为 2 \frac{n+1}{2} 2n+1,时间复杂度为:O(n)。
所以需要对二叉排序树进行平衡化处理,使得二叉树形状尽量均衡,从而使得查找效率提高。
3.3.1.2 二叉排序树的插入操作
若二叉排序树为空,则插入结点作为跟结点插入到空树之中;否则,继续在其左、右子树上查找,若树中已有,则不再插入;若树中没有,则查找直至某个叶子结点的左子树或者右子树为空为止,则插入结点应该为该叶子结点的左孩子或者右孩子。插入的元素一定在叶子结点上。
从一颗空树出发,经过一系列的查找,插入操作之后,可以生成一颗二叉排序树。一个无序序列可以通过构造二叉排序树而变成一个有序序列。构造树的过程就是对无序序列进行排序的过程。不同插入次序的序列生成的二叉排序树的形态不同。二叉排序树的形态不同,查找效率也不同。
3.3.1.2 二叉排序树的删除操作
从二叉排序数中删除一个结点,不能把以该结点为根的子树都删去,只能删掉该结点,并且还需要保证删除后所得的二叉树仍然满足二叉排序树的性质不变。由于中序遍历二叉排序树可以得到一个递增有序的序列,那么在二叉排序树中删去一个结点相当于删去有序序列中的一个结点,需要将因为删除结点而断开的二叉链表重新链接起来,同时需要防止重新链接之后的数的高度增加。
若删除的节点是叶子结点,则直接删除该结点,将双亲节点的相应指针域变为空;
若被删除的结点只有左子树或者只有右子树,则用其左子树或者右子树直接替换它,将双亲结点的相应指针域的值改为:指向被删除结点的左子树或者右子树。
若被删除的结点既有左子树又有右子树,一种办法可以以中序遍历的前驱值替换它,然后在删除那个前驱值结点,前驱结点是左子树中最大的结点。另外一种办法是用中序遍历的后继值替换它,然后在删除后继值结点。后继结点是右子树中值最小的结点。
4、 平衡二叉树
二叉排序的查找效率和其形态有密切关系,当二叉排序树比较均衡时,查找效率最好可以达到 O(log2n),当二叉排序树不均衡时,最坏的查找效率为 O(n)。为了将二叉排序树均衡化,提出平衡二叉树。
平衡二叉树又称AVL树,平衡二叉树是一颗空树或者是具有以下性质的二叉排序树:左子树和右子树的高度之差绝对值小于等于1;左子树和右子树也是平衡二叉排序树。
为了方便起见,给每个结点附加一个数字,给出该结点左子树与右子树的高度差。这个数字称为结点的平衡因子(BF),根据平衡二叉树的定义,平衡二叉树上所有结点的平衡因子只能是-1,0,或者1。
对于一个有n个结点的AVL树,其高度保持在O(log2n)数量级。
4.1 失衡二叉排序树的调整
若出现不止一个失衡结点时,找最小的失衡子树的根节点作为失衡结点。平衡调整的四种类型如下所示:
平衡二叉树的调整原则:降低树的高度,并保持二叉排序树的性质。将上述四种情况中,值排序之后位于中间的节点作为根节点。
4、散列表的查找
散列方法(杂凑法): 选取某个函数,依照该函数按关键字计算元素的存储位置,并按此存放;在查找时,由同一个函数对给定值k计算地址,将k与地址单元中元素的关键码进行对比,确定查找是否成功。
散列函数(杂凑函数): 散列方法中使用的转换函数。
冲突: 不同关键码映射到同一个散列地址, key1= key2,但是 H(key1)=H(key2)。
同义词: 具有相同函数值的多个关键字。
在散列查找中,冲突是不可避免的,只能想办法去减少冲突。使用散列表要解决好下面两个问题:
\qquad 1) 构造好的散列函数,所选的散列函数应该尽可能地简单,以便提高转换速度;同时所选的函数对关键码计算出的地址应该在散列地址集合中均匀分布,以减少空间的浪费。
2) 需要指定一个好的解决冲突的方案。在查找时,如果从散列函数计算出的地址中查找不到关键码,则应当依据解决冲突的规则,有规律地查询其他相关单元。
4.1 散列函数的构造方法
构造散列函数需要考虑的因素:执行速度(计算散列函数的时间),关键字的长度,散列表的大小,关键字的分布情况,查找频率等。根据元素集合的特性构造,散列表的构造函数要求:一,n个数据原本仅占用n个地址,虽然散列表查找时以空间换时间,但是仍希望散列的地址空间尽量小;二,无论用什么方法存储,目的都是尽量均匀地存放元素,以避免冲突。
散列函数的种类包括以下:直接定址法,数字分析法,平方取中法,折叠法,除留余数法,随机数法等。
4.1.1 直接定址法
Hash(key)=a⋅key+b,其中a,b为常数。优点是以关键字key的某个线性函数值为散列地址,不会产生冲突;缺点是需要占用连续空间,空间效率低。
4.1.2 除留余数法
Hash(key)=key mod p,其中p是一个整数。关键是怎样选择合适的p。选取p的技巧是:设表长为m,取 p ≤ m p \leq m p≤m,并且p为质数。
4.2 散列函数冲突处理方法
散列表处理冲突的方法有以下几种:开放定址法(开地址法),链地址法(拉链法),再散列法(双散列函数法),建立一个公共溢出区,等。
4.2.1 开放定址法
基本思想是:当有冲突时,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将数据元素存入。例如除留余数法, Hi=(Hash(key)+di) mod m,其中di为增量序列,常用的增量方法有以下几种:
4.2.2 链定址法
基本思想是:相同散列地址的记录构成一个单链表,m个散列地址就设置m个单链表,然后用一个数据将m个单链表的头指针存储起来,形成一个动态的结构。
链地址法的优点:非同义词不会冲突,无“聚集”现象;链表上的空间动态申请,更适合用于表长不确定的情况。
4.3 散列表的查找方法
散列表的查找过程如下图所示:
下图展示了使用线性探测法处理只有的哈希表的查找过程,以及平均查找长度:
使用散列表的平均查找长度取决于以下三点:散列函数,处理冲突的方法和散列表的装填因子 α = 表 中 填 入 的 记 录 数 哈 希 表 的 长 度 = {表中填入的记录数}{哈希表的长度} α=哈希表的长度表中填入的记录数, α的值越大,表中的记录数越多,说明表装得越满,发生冲突的可能性就越大,查找时比较的次数就越多。
散列表技术具有较好的平均性能,由于一些传统的技术;链地址法优于开地址法,除留余数法作为散列表的的散列函数要优于其他类型的函数。