数据结构6. 查找

Posted 4thirteen2one

tags:

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

目录

6.1 查找的基本概念

  1. 查找在数据集合中寻找满足某种条件的数据元素的过程称为査找。
    査找的结果一般分为两种:
    查找成功,即在数据集合中找到了满足条件的数据元素;
    另一种是查找失败
  2. 查找表(查找结构):用于査找的数据集合称为査找表,它由同一类型的数据元素(或记录)组成,可以是一个数组或链表等数据类型。
    对査找表经常进行的操作一般有四种:
    1. 査询某个特定的数据元素是否在査找表中;
    2. 检索满足条件的某个特定的数据元素的各种属性;
    3. 在査找表中插入一个数据元素;
    4. 从査找表中删除某个数据元素。
  3. 静态查找表:如果一个査找表的操作只涉及①和②的操作,则无需动态地修改査找表,此类査找表称为静态査找表。
    与此对应,需要动态地插入或删除的査找表则称为动态查找表
    • 适合静态査找表的査找方法有:顺序査找、折半査找、散列査找等;
    • 适合动态査找表的査找方法有:二叉排序树的査找、散列査找等。
      二叉平衡树和 B 树都是二叉排序树的改进。
      二叉排序树与二叉平衡树己在第 4 章介绍过。
  4. 关键字数据元素中唯一标识该元素的某个数据项的值
    使用基于关键字的査找,査找结果应该是唯一的。
    比如由一个学生元素构成的数据集合,则学生元素中“学号”这一数据项的值唯一地标识一个学生。
  5. 平均查找长度:在査找的过程中,一次査找的长度是指需要比较的关键字次数
    而平均査找长度则是所有査找过程中进行关键字的比较次数的平均值
    其数学定义为 [ ext{ASL}=sum_{i=1}^n P_i C_i]
    式中,(n) 是査找表的长度,
    (P_i) 是査找第 (i) 个数据元素的概率,一般认为每个数据元素的査找概率相等,即 (P_i=1/n)
    (C_i) 是找到第 (i) 个数据元素所需进行的比较次数。
    平均査找长度是衡量査找算法效率的最主要的指标

6.2 顺序查找和折半查找

6.2.1 顺序查找

顺序查找,又称为线性査找,主要用于在线性表中进行査找。
顺序査找通常分为对一般的无序线性表的顺序査找对按关键字有序的顺序表的顺序査找
下面分别进行讨论。

1. 一般线性表的顺序查找

作为一种最直观的查找方法,其基本思想是:

从线性表的一端开始,逐个检査关键字是否满足给定的条件。
若査找到某个元素的关键字满足给定条件,则査找成功,返回该元素在线性表中的位置;
若已经査找到表的另一端,还没有查找到符合给定条件的元素,则返回査找失败的信息。

下面给出其算法,主要是为了说明其中引入的“哨兵”的作用。

typedef struct { //査找表的数据结构
    ElemType elem; //元素存储空间基址,建表时按实际长度分配,0 号单元留空
    int TableLen; //表的长度
} SSTable;
int Search_Seq(SSTable ST, ElemType key) {
//在顺序表si 中顺序査找关键字为 key 的元素。若找到则返回该元素在表中的位置
    ST.elem[0] = key; // “哨兵”
    for(i=ST.TableLen; ST.elem[i]!=key; --i); //从后往前找
    return i; //若表中不存在关键字为 key 的元素,将査找到 i 为 0 时退出 for 循环
}

在上述算法中,将 ST.elem[0] 称为“哨兵”。
引入的目的是使得 Search_Seq 内的循环不必判断数组是否会越界,因为当满足 i==0 时,循环一定会跳出。
需要说明的是,在程序中引入“哨兵”并不是这个算法独有的。
通过引入“哨兵”,可以避免很多不必要的判断语句,从而提高程序效率。

对于有 (n) 个元素的表,给定值 key 与表中第 (i) 个元素的关键字相等,即定位第 (i) 个元素时,需进行 (n-i+1) 次关键字的比较,即 (C_i=n-i+1)
査找成功时,顺序査找的平均长度为 ( ext{ASL}_ ext{成功}=sum_{i=1}^n P_i(n-i+1))
当每个元素的査找概率相等时,即 (P_i=1/n),则有
[ ext{ASL}_ ext{成功}=sum_{i=1}^n P_i(n-i+1)=frac{n+1}{2}]

査找不成功时,与表中各关键字的比较次数显然是 (n+1) 次,从而顺序査找不成功的平均査找
长度为 ( ext{ASL}_ ext{不成功}=n+1)

通常,査找表中记录的査找概率并不相等。
若能预先得知每个记录的査找概率,则应先对记录的査找概率进行排序,使表中记录按査找概率由小至大重新排列。

综上所述,顺序査找的缺点是当 n 较大时,平均査找长度较大,效率低;
优点是对数据元素的存储没有要求,顺序存储或链式存储皆可。
对表中记录的有序性也没有要求,无论记录是否按关键码有序均可应用。
同时还需注意,对线性的链表只能进行顺序査找。

2. 有序表的顺序查找

如果在査找之前就己经知道表是按关键字有序的,那么当査找失败时可以不用再比较到表的另一端就能返回査找失败的信息,这样能降低顺序査找失败的平均査找长度。

假设表 L 是按关键字从小到大排列的,查找的顺序是从前往后查找,待査找元素的关键字为 key
当査找到第 (i) 个元素时,发现第 (i) 个元素对应的关键字小于 key, 但第 (i+1) 个元素对应的关键字大于 key,
这时就可以返回査找失败的信息了,因为第 (i) 个元素之后的元素的关键字均大于 key, 所以表中不存在关键字为 key 的元素。

可以用如图 6-1 所示的判定树来描述有序顺序表的査找过程。
技术分享图片

树中的圆形结点表示有序顺序表中存在的元素;
树中的矩形结点称为失败结点(注意,若有 n 个査找成功结点,则必相应的有 n+1 个査找失败结点),它描述的是那些不在表中的数据值的集合。
如果査找到失败结点,则说明査找不成功。

在有序表的顺序査找中,査找成功的平均査找长度和一般线性表的顺序査找一样。
査找失败时,査找指针一定走到了某个失败结点。
这些失败结点是我们虚构的空结点,实际上是不存在的,所以到达失败结点所査找的长度等于它上面一个圆形结点的所在层数。
查找不成功的平均査找长度在相等査找概率的情形下有
[ ext{ASL}_ ext{不成功} = sum_{j=1}^n q_j(l_j-1)=frac{1+2+cdots+n+n}{n+1}=frac{n}{2}+frac{n}{n+1}]
式中,(q_j) 是到达第 j 个失败结点的概率,在相等査找概率的情形下,为 (1/(n+1))
(l_j) 是第 j 个失败结点所在的层数。
(n=6) 时,( ext{ASL}_ ext{不成功}=6/2+6/7=3.86),比一般的顺序査找算法好一些。

请注意,有序表的顺序査找和后面的折半査找的思想是不一样的,而且有序表的顺序査找中的线性表可以是链式存储结构的。

6.2.2 折半查找

折半查找,又称为二分査找,它仅适用于有序的顺序表。
基本思路是:首先将给定值 key 与表中中间位置元素的关键字比较,
若相等,则査找成功,返回该元素的存储位置;
若不等,则所需査找的元素只能在中间元素以外的前半部分或后半部分中
(例如,在査找表升序排列时,若给定值 key 大于中间元素的关键字,则所査找的元素只可能在后半部分)。
然后在缩小的范围内继续进行同样的査找,如此重复直到找到为止,或者确定表中没有所需要査找的元素,则査找不成
功,返回査找失败的信息。)

算法如下:

int Binary_Search(SeqList L, ElemType key) {
//在有序表 L 中査找关键字为 key 的元素,若存在则返回其位置,不存在则返回 -1
    int low = 0, high = L.TableLen-1, mid;
    while(low <= high) {
        mid = (low+high)/2; // 取中间位置
        if(L.elem[mid] == key)
            return mid; // 査找成功则返回所在位置
        else if(L.elem[mid] > key)
            high = mid-1; // 从前半部分继续査找
        else
            low = mid+1; // 从后半部分继续査找
    }
    return -1;
}

例如,已知 11 个元素的有序表 ({7,10,13,16,19,29,32,33,37,41,43}),要査找值为 11 和 32 的元素,
指针 low 和 high 分别指向表的下界和上界,mid 则指向表的中间位置 (lfloor(low+high)/2 ceil)
以下说明査找 11 的过程(査找 32 的过程请读者自行分析):
7 10 13 16 19 29 32 33 37 41 43

第一次査找,将中间位置元素与 key 值比较。
因为 (11<29),说明待査元素若存在,必在 ([low, mid-1]) 的范围内,
令指针 high 指向 mid-1 的位置,(high=mid-1=5),重新求得 (mid=(1+5)/2=3)
第二次的査找范围为 ([1, 5])
7 10 13 16 19 29 32 33 37 41 43

第二次査找,同样将中间位置元素与 key 值比较。
因为 (11<13),说明待査元素若存在,必在 ([low, mid-1]) 的范围内,
令指针 high 指向 mid-1 的位置,(high=mid-1=2),重新求得 (mid=(1+2)/2=1)
第三次的査找范围为 ([1, 2])

第三次査找,将中间位置元素与 key 值比较。
因为 (11>7),说明待査元素若存在,必在 $[mid+1, high]的范围内。
(low=mid+1=2)(mid=(2+2)/2=2),第四次的査找范围为 ([2, 2])
7 10 13 16 19 29 32 33 37 41 43

第四次査找,此时子表只含有一个元素,且 (10 e 11),故表中不存在待査元素。

折半査找的过程可用图 6-2 所示的二叉树来描述,称为判定树
技术分享图片

树中每个圆形结点表示一个记录,结点中的值为该记录的关键字值;树中最下面的叶结点都是方形的,它表示査找不成功的情况。
从判定树可以看出,査找成功时的査找长度为从根结点到目的结点的路径上的结点数,
而査找不成功时的査找长度为从根结点到对应失败结点的父结点的路径上的结点数;
每个结点值均大于其左子结点值,且均小于于其右子结点值。
若有序序列有 n 个元素,则对应的判定树有 n 个圆形的非叶结点和 (n+1) 个方形的叶结点。

由上述的分析可知,用折半査找法査找到给定值的比较次数最多不会超过树的高度。
在等概率査找时,査找成功的平均査找长度为
[egin{aligned} ext{ASL} &= frac{1}{n} sum_{i=1}^{n} l_i = frac{1}{n} (1 imes 1+2 imes 2 + cdots + h imes 2^{h-1}) \\ &= frac{n+1}{n} log_2{(n+1)}-1 approx log_2{(n+1)}-1 end{aligned}]
式中,(h) 是树的髙度,并且元素个数为 n 时树高 (h = lceillog_2{(n+1)} ceil)
所以,折半査找的时间复杂度为 (mathcal{O}(log_2{n})),平均情况下比顺序査找的效率高。

在图 6-2 所示的判定树中,在等概率的情况下,査找成功的 ( ext{ASL}= (1 imes 1+2 imes 2+3 imes 4+4 imes 4)/11=3)
査找不成功的 ( ext{ASL} = (3 imes 4+4 imes 8)/12=11/3)

因为折半査找需要方便地定位査找区域,所以适合折半査找的存储结构必须具有随机存取的特性。
因此,该査找法仅适合于线性表的顺序存储结构,不适合链式存储结构,且要求元素按关键字有序排列。

6.2.3 分块查找

分块查找,又称为索引顺序査找,吸取了顺序査找和折半査找各自的优点,既有动态结构,又适于快速査找。

分块査找的基本思想:将査找表分为若干个子块。块内的元素可以无序,但块之间是有序的,
即第一个块中的最大关键字小于第二个块中的所有记录的关键字,
第二个块中的最大关键字小于第三个块中的所有记录的关键字,依次类推。
再建立一个索引表,索引表中的每个元素含有各块的最大关键字和各块中第一个元素的地址,索引表按关键字有序排列。

分块査找的过程分为两步:第一步在索引表中确定待査记录所在的块,可以顺序査找或折半査找索引表; 第二步在块内顺序査找。

例如,关键码集合为 ({88,24,72,61,21,6,32,11,8,31,22,83,78,54}),按照关键码值为 24、54、78、88,分为四个块和索引表,如图 6-3 所示。
技术分享图片

分块査找的平均査找长度为索引査找和块内査找的平均长度之和,设索引査找和块内査找的平均査找长度分别为 (L_I)(L_S),则分块査找的平均査找长度为
[ ext{ASL} = L_I + L_S]
设将长度为 n 的査找表均匀的分为 b 块,每块有 s 个记录,在等概率的情况下,若在块内和索引表中均采用顺序査找,则平均査找长度为
[ ext{ASL} = L_I + L_S = frac{b+1}{2}+frac{s+1}{2} = frac{s^2+2s+n}{2s}]
此时,若 (s-sqrt{n}),则平均査找长度取最小值 (sqrt{n}+1)
若对索引表采用折半査找时,则平均査找长度为
[ ext{ASL} = L_I + L_S = lceillog_2(b+1) ceil + frac{s+1}{2}]

6.3 B 树和 B+ 树

考试大纲对 B 树和 B+树的要求各不相同,重点在于考査 B 树,
不仅要求理解 B 树的基本特点,还要求掌握 B 树的建立、插入和删除操作,
而对 B+ 树则只考査基本概念。

6.3.1 B 树及其基本操作

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

  1. 树中每个结点至多有 m 棵子树(即至多含有 m-1 个关键字)。
  2. 若根结点不是终端结点,则至少有两棵子树。
  3. 除根结点外的所有非叶结点至少有 (lceil m/2 ceil) 棵子树(即至少含有个关键字)。
  4. 所有非叶结点的结构如下:
    | (n) | (P_0) | (K_1) | (P_1) | (K_2) | (P_2) | (cdots) | (K_n) | (P_n) |
    | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |

    其中,(K_i)(i=1,2,cdots,n))为结点的关键字,且满足 (K_1<K_2<cdots<K_n)
    (P_i)(i=0,1,cdots,n))为指向子树根结点的指针,且指针 (P_{i-1}) 所指子树中所有结点的关键字均小于 (K_i)(P_i) 所指子树中所有结点的关键字均大于 (K_i)
    (n)(lceil m/2 ceil-1 le n le m-1))为结点中关键字的个数
  5. 所有的叶结点都出现在同一层次上,并且不带信息(可以看做是外部结点或者类似于折半査找判定树的査找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。

B 树是所有结点的平衡因子均等于 0 的多路査找树,如图 6-5 所示为一棵 3 阶 B 树。
其中底层方形结点表示叶结点,在这些结点中没有存储任何信息。
技术分享图片

1. B 树的高度(磁盘存取次数)

由下一节可知,B 树中的大部分操作所需的磁盘存取次数与 B 树的高度成正比。

下面来分析 B 树在不同的情况下的高度。
当然,首先应该明确 B 树的高度不包括最后的不带任何信息的叶结点所处的那一层。(注:有些书上对于 B 树的高度定义中是包括了最后的那一层的,本书中不包括这一层,所以希望读者在阅读的时候注意一下)。

如果 (nge 1),则对任意一棵包含 n 个关键字、髙度为 h、阶数为 m 的 B 树:

  1. 因为 B 树中每个结点最多有 m 棵子树,m-1 个关键字,
    所以在一棵高度为 h 的 m 阶 B 树中关键字的个数应满足 (nle (m-1)*(1+m+m^2+cdots+m^{h-1})=m^h-1)
    因此有 [hge log_m{(n+1)}]
  2. 若让每个结点中的关键字个数达到最少,则容纳同样多关键字的 B 树的高度可达到最大。
    由 B 树定义:第一层至少有 1 个结点;第二层至少有 2 个结点;
    除根结点以外的每个非终端结点至少有 (lceil m/2 ceil) 棵子树,
    则第三层至少有 (2lceil m/2 ceil) 个结点……第 (h+1) 层至少有 (2(lceil m/2 ceil)^{h-1}) 个结点,注意到第 h+1 层是不包含任何信息的叶结点。
    对于关键字个数为 n 的 B 树,叶结点即査找不成功的结点为 (n+1)
    由此有 (n+1ge 2(lceil m/2 ceil)^{h-1}),即 (hle log_{lceil m/2 ceil}((n+1)/2)+1)
    例如,假设一棵 3 阶 B 树,共有 8 个关键字,则其高度范围为 (2le hle 3.17)

2. B 树的查找

在 B 树上进行査找与二叉査找树很相似,只是每个结点都是多个关键字的有序表,
在每个结点上所做的不是两路分支决定,而是根据该结点的子树所做的多路分支决定。

B 树的査找包含两个基本操作:

  1. 在 B 树中找结点;
  2. 在结点内找关键字。

由于 B 树常存储在磁盘上,则前一个査找操作是在磁盘上进行的,而后一个査找操作是在内存中进行的,
即在找到目标结点后,先将结点中的信息读入内存,然后再采用顺序査找法或折半査找法査找等于 K 的关键字。

在 B 树上査找到某个结点后,先在有序表中进行査找,
若找到则査找成功,否则按照对应的指针信息到所指的子树中去査找(例如,
在图 6-5 中査找到第一层的第一个结点时,若发现关键字大于 18 而小于 33,则在这个结点上査找失败,将根据 18 与 33 之间的指针到结点的第二个子树中继续査找)。
当査找到叶结点时(对应的指针为空指针),则说明树中没有对应的关键字,査找失败。

3. B 树的接入

与二叉査找树的插入操作相比,B 树的插入操作要复杂得多。
在二叉査找树中,仅需査找到需插入的终端结点的位置。
但是,在 B 树中找到插入的位置后,并不能简单地将其添加到终端结点中去,因为此时可能会导致整棵树不再满足 B 树中定义中的要求。
将关键字 key 插入到 B 树的过程如下:

  1. 定位:利用前述的 B 树査找算法,找出插入该关键字的最底层中某个非叶结点(注意,
    B树中的插入关键字一定是插入在最底层中的某个非叶结点内)。
  2. 插入:在 B 树中,每个非失败结点的关键字个数都在 ([lceil m/2 ceil-1, m-1]) 之间。
    当插入后的结点关键字个数小于 m,则可以直接插入;
    插入后检査被插入结点内关键字的个数,当插入后的结点关键字个数大于 m-1 时,则必须对结点进行分裂。
    分裂的方法是:取一个新结点,将插入 key 后的原结点从中间位罝将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新的结点中,中间位置 ((lceil m/2 ceil)) 的结点插入到原结点的父结点中。
    若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,这样导致 B 树高度增 1。

对于 m=3 的 B 树,所有结点中最多有 (m-1=2) 个关键字,若某结点中己有两个关键字时,则结点己满,如图 6-6(a) 所示。
当插入一个关键字 60 后,结点内的关键字个数超出了 m-1, 如图 6-6(b) 所示,此时必须进行结点分裂,分裂的结果如图 6-6(c) 所示。
技术分享图片

4. B 树的删除

B 树中的删除操作与插入操作类似,但要稍微复杂些,要使得删除后的结点中的关键字个数大于等于 (lceil m/2 ceil-1),因此将涉及结点的“合并”问题。

当所删除的关键字 k 不在终端结点(最底层非叶结点)中时,有下列几种情况:

  1. 如果小于 k 的子树中关键字个数大于 (lceil m/2 ceil-1),则找出 k 的前驱值 (k‘),并且用 (k‘) 来取代 (k),再递归地删除 (k‘) 即可。
  2. 如果大于 k 的子树中关键字个数大于 (lceil m/2 ceil-1),则找出 k 的后继值 (k‘),并且用 (k‘) 来取代 (k),再递归地删除 (k‘) 即可。
  3. 如果前后两个子树中关键字个数均为 (lceil m/2 ceil-1),则直接将两个子结点合并,直接删除 k 即可。图 6-7 所示为某 4 阶 B 树的一部分。
    技术分享图片

当被删除的关键字在终端结点(最底层非叶结点)中时,有下列几种情况:

  1. 直接删除关键字:若被删除关键字所在结点的关键字个数表明删除该关键字后仍满足 B 树的定义,则直接删去该关键字。
  2. 兄弟够借:若被删除关键字所在结点删除前的关键字个数等于 (lceil m/2 ceil-1),且与此结点相邻的右(左)兄弟结点的关键字个数大于等于 (lceil m/2 ceil),需要调整该结点、右(左)兄弟结点以及其双亲结点(父子换位法),以达到新的平衡,如图 6-8(a) 所示。
    技术分享图片

  3. 兄弟不够借:若被删除关键字所在结点删除前的关键字个数等于 (lceil m/2 ceil-1),且此时与该结点相邻的右(左)兄弟结点的关键字个数等于 (lceil m/2 ceil-1),则将关键字删除后与右(左)兄弟结点及双亲结点中的关键字进行合并,如图 6-8(b) 所示。
    在合并的过程中,双亲结点中的关键字个数会减少。
    若其双亲结点是根结点并且关键字个数减少至 0(根结点关键字个数为 1 时,有 2 棵子树),则直接将根结点删除,合并后的新结点成为根;
    若双亲结点不是根结点,且关键字个数减少到 (lceil m/2 ceil-2),又要与它自己的兄弟结点进行调整或合并操作,并重复上述步骤,直至符合 B 树的要求为止。

6.3.2 B+ 树基本概念

B+ 树是应数据库所需而出现的一种 B 树的变形树。
一棵 m 阶的 B+树需满足下列条件:

  1. 每个分支结点最多有 m 棵子树(子结点)。
  2. 非叶根结点至少有两棵子树,其他每个分支结点至少有 (lceil m/2 ceil) 棵子树。
  3. 结点的子树个数与关键字个数相等。
  4. 所有叶结点包含全部关键字及指向相应记录的指针,而且叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来。
  5. 所有分支结点(可看成是索引的索引)中仅包含它的各个子结点(即下一级的索引块)中关键字的最大值及指向其子结点的指针。

m 阶的 B+ 树与 m 阶的 B 树的主要差异在于:

  1. 在 B+树中,具有 n 个关键字的结点只含有 n 棵子树,即每个关键字对应一棵子树;
    而在 B 树中,具有 n 个关键字的结点含有 ((n+1)) 棵子树。
  2. 在 B+ 树中,每个结点(非根内部结点)关键字个数 n 的范围是 (lceil m/2 ceil le nle m)(根结点:(1le nle m));
    在 B 树中,每个结点(非根内部结点)关键字个数 n 的范围是 (lceil m/2 ceil-1 le nle m-1)(根结点:(1le n le m-1))。
  3. 在 B+ 树中,叶结点包含信息,所有非叶结点仅起到索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址。
    4) 在 B+ 树中,叶结点包含了全部关键字,即在非叶结点中出现的关键字也会出现在叶结点中;
    而在 B 树中,叶结点包含的关键字和其他结点包含的关键字是不重复的。

如图 6-9 所示为一棵 4 阶 B+ 树的示例。
从图中可以看出,分支结点的某个关键字是其子树中最大关键字的副本。
通常在 B+ 树中有两个头指针:一个指向根结点,另一个指向关键字最小的叶结点。
因此,可以对 B+ 树进行两种査找运算:一种是从最小关键字开始的顺序査找,另一种是从根结点开始,进行多路査找。
技术分享图片

B+树的査找、插入和删除操作和 B 树基本类似。
只是在査找过程中,如果非叶结点上的关键字值等于给定值时并不终止,而是继续向下査找直到叶结点上的该关键字为止。
所以,在 B+ 年树中査找,无论査找成功与否,每次査找都是一条从根结点到叶结点的路径。

6.4 散列( Hash) 表

6.4.1 散列表的基本概念

在前面介绍的线性表和树表的査找中,记录在表中的位置跟记录的关键字之间不存在确定关系,因此,在这些表中査找记录时需进行一系列的关键字比较。
这一类査找方法是建立在“比较”的基础上,査找的效率取决于比较的次数。

散列函数:一个把査找表中的关键字映射成该关键字对应的地址的函数,记为 ( ext{Hash}(key)=Addr)。(这里的地址可以是数组下标、索引、或内存地址等)

散列函数可能会把两个或两个以上的不同关键字映射到同一地址,称这种情况为“冲突”,这些发生碰揸的不同关键字称为同义词。
一方面,设计好的散列函数应尽量减少这样的冲突;另一方面,由于这样的冲突总是不可避免的,所以还要设计好处理冲突的方法。

散列表:是根据关键字而直接进行访问的数据结构。
也就是说,散列表建立了关键字和存储地址之间的一种直接映射关系。

理想情况下,对散列表进行査找的时间复杂度为 (mathcal{O}(1)),即与表中元素个数无关。
下面将分别介绍常用的散列函数和处理冲突的方法。

6.4.2 散列函数的构造方法

在构造散列函数时,必须注意以下几点:

  1. 散列函数的定义域必须包含全部箱要存储的关键字,而值域的范围则依赖于散列表的大
    小或地址范围。
  2. 散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间,从而减少冲突的
    发生
  3. 散列函数应尽置简单,能够在较短的时间内就计算出任一关键字对应的散列地址。

下面介绍常用的散列函数:

  1. 直接定址法
    直接取关键字的某个线性函数值为散列地址,散列函数为
    [H(key) =a imes key+b]
    式中,a 和 b 是常数。
    这种方法计算最简单,并且不会产生冲突。
    它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,将造成存储空间的浪费。
  2. 除留余数法
    这是一种最简单、最常用的方法,假定散列表表长为 m,取一个不大于 m 但最接近或等于 m
    的质数 P,利用以下公式把关键字转换成散列地址。散列函数为
    [H(key)=key % p]
    除留余数法的关键是选好 p,使得每一个关键字通过该函数转换后等概率地映射到散列空间上的任一地址,从而尽可能减少冲突的可能性。
  3. 数字分析法
    设关键字是 r 进制数(如十进制数),而 r 个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀些,每种数码出现的机会均等;
    而在某些位上分布不均匀,只有某几种数码经常出现,则应选取数码分布较为均匀的若干位作为散列地址。
    这种方法适合于已知的关键字集合,如果更换了关键字,就需要重新构造新的散列函数。
  4. 平方取中法
    顾名思义,取关键字的平方值的中间几位作为散列地址。
    具体取多少位要看实际情况而定。
    这种方法得到的散列地址与关键字的每一位都有关系,使得散列地址分布比较均匀。
    适用于关键字的每一位取值都不够均匀或均小于散列地址所需的位数。
  5. 折叠法
    将关键字分割成位数相同的几部分(最后一部分的位数可以短一些),然后取这几部分的叠加和作为散列地址,这种方法称为折叠法。
    关键字位数很多,而且关键字中每一位上数字分布大致均匀时,可以采用折叠法得到散列地址。

在不同的情况下,不同的散列函数会发挥出不同的性能,因此不能笼统地说哪种散列函数最好。
在实际的选择中,采用何种构造散列函数的方法取决于关键字集合的情况,但是目标是为了使产生冲突的可能性尽量地降低。

6.4.3 处理冲突的方法

应该注意到,任何设计出来的散列函数都不可能绝对地避免冲突,为此,必须考虑在发生冲突时应该如何进行处理,即为产生冲突的关键字寻找下一个“空”的 Hash 地址。

假设己经选定散列函数 H(key),下面用 表示发生冲突后第 i 次探测的散列地址。

1. 开放定址法

所谓开放定址法,指的是可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。
其数学递推公式为 [H_i= (H(key)+d_i)% m]
式中,(i=0,1,2,cdots,k)(kle m-1)),m 表示散列表表长,(d_i) 为增量序列。
当取定某一增量序列后,则对应的处理方法是确定的。通常有以下四种取法:

  1. 线性探测法:
    (d_i=0,1,2,cdots,m-1),称为线性探测法。
    这种方法的特点是:冲突发生时,顺序查看表中下一个单元(当探测到表尾地址 m-1 时,下一个探测地址是表首地址 0),直到找出一个空闲单元(当表未填满时一定能找到一个空闲单元)或査遍全表。
    线性探测法可能使第 i 个散列地址的同义词存入第 i+1 个散列地址,
    这样本应存入第 i+1 个散列地址的元素就争夺第 i+2 个散列地址的元素的地址……从而造成大量元素在相邻的散列地址上“聚集”(或堆积)起来,大大降低了査找效率。

  2. 平方探测法:当 (d_i=0^2,1^2,-1^2,2^2,-2^2,cdots,k^2,-k^2),其中 (kle m/2),散列表长度 m 必须是一个可以表示成 4k+3 的素数,又称二次探测法
    平方探测法是一种较好的处理冲突的方法,可以避免出现 “堆积” 问题,它的缺点是不能探测到散列表上的所有单元,但至少能探测到一半单元。
  3. 再散列法:当 (d_i=Hash_2(Key)),又称为双散列法。
    需要使用两个散列函数,当通过第一个散列函数 H(Key) 得到的地址发生冲突时,则利用第二个散列函数 Hash2(Key) 计算该关键字的地址增量。
    它的具体散列函数形式如下:
    [H_i=(H(Key)+i*Hash_2(Key))%m]
    初始探测位置 (H_0=H(Key)/m)
    i 是冲突的次数,初始为 0。
    在散列法中,最多经过 m-1 次探测会遍历表中所有位置,回到 (H_0) 位置,
  4. 伪随机序列法:当 (d_i) 等于伪随机数序列,称为伪随机序列法。

注意:
在开放定址的情形下,不能随便物理删除表中已有元素,因为若删除元素将会截断其他具有相同散列地址的元素的查找地址。
所以若想删除一个元素时,给它做一个删除标记,进行逻辑删除。
但这样做的副作用是:在执行多次删除后,表面上看起来散列表很满,实际上有许多位里没有利用,因此需要定期维护散列表,要把删除标记的元素物理删除。

2. 拉链法(链接法,chaining)

显然,对于不同的关键字可能会通过散列函数映射到同一地址,为了避免非同义词发生冲突,可以把所有的同义词存储在一个线性链表中,这个线性链表由其散列地址唯一标识。
假设散列地址为 i 的同义词链表的头指针存放在散列表的第 i 个单元中,因而査找、插入和删除操作主要在同义词链中进行。
拉链法适用于经常进行插入和删除的情况。例如,关键字序列为 ({19,14,23,01,68,20,84,27,55,11,10,79}),散列函数 (H(key)=key%13),用拉链法处理冲突,建立的表如图 6-12 所示。
技术分享图片

6.4.4 散列查找及性能分析

散列表的査找过程与构造散列表的过程基本一致。
对于一个给定的关键字 key,根据散列函数可以计算出其散列地址,执行步骤如下:
初始化:(Addr=Hash(key))

  1. 检测査找表中地址为 Addr 的位置上是否有记录,若没有记录,返回査找失败;若有记录,比较它与 key 值,若相等,返回査找成功标志,否则执行步骤 2。
  2. 用给定的处理冲突方法计算“下一个散列地址”,并把 Addr 置为此地址,转入步骤 1。

例如,关键字序列 ({19,14,23,01,68,20,84,27,55,11,10,79}) 按散列函数 (H(key)=key %13) 和线性探测处理冲突构造所得的散列表 L 如图 6-13 所示。
技术分享图片

给定值 84 的査找过程为:先求散列地址 (H(84)=6),因 (L[6]) 不空且 (L[6] e 84),则找第一次冲突处理后的地址 (H_1=(6+1)%16=7),而 (L[7]) 不空且 (L[7] e84),则找第二次冲突处理后的地址 (H_2=(6+2)%16=8)(L[8]) 不空且 (L[8]=84),査找成功,返回记录在表中的序号 8。

给定值 38 的査找过程为:先求散列地址 (H(38)=12),因 (L[12]) 不空且 (L[12] e 38),则找下一地址 (H_1=(12+1)%16=13),由于 L[13]是空记录,故表中不存在关键字为 38 的记录。

散列表的査找效率取决于三个因素:散列函数、 处理冲突的方法和装填因子。
装填因子:散列表的装填因子一般记为 (alpha),定义为一个表的装满程度,即:
[alpha = frac{ ext{表中记录数}_n} { ext{散列表长度}_m}]

散列表的平均査找长度依赖于散列表的填装因子 (alpha),而不直接依赖于 n 或 m。
直观地看,a 越大,表示装填的记录越“满”,发生冲突的可能性就越大,反之发生冲突的可能性越小。

虽然散列表在关键字与记录的存储位置之间建立了直接映像,但“冲突”的产生,使得散列表的査找过程仍然是一个给定值和关键字进行比较的过程。
因此,仍需以平均査找长度作为衡量散列表的査找效率的度量。
读者应能在给出了散列表的长度、 元素个数,以及散列函数和解决冲突方法后,可以在求出散列表的基础上计算査找成功时的平均査找长度和査找不成功的平均査找长度。

6.5 字符串模式匹配

6.5.1 简单的模式匹配算法

串的模式匹配,是求第一个字符串(模式串)在第二个字符串(主串)中的位置。

下面给出一种简单的字符串模式匹配算法:
从主串 S 指定的字符开始(一般为第一个)和模式串 T 的第一个字符比较,
若相等,则继续逐个比较后续字符,直到 T 中的每个字符依次和 S 中的一个连续的字符序列相等,则称匹配成功;
如果比较过程中有某对字符不相等,则从主串 S 的下一个字符起再重新和 T 的第一个字符比较。
如果 S 中的字符都比完了仍然没有匹配成功,则称匹配不成功。
代码如下:

int Index(SString S, SString T){
    int i=1, j=1;
    while(i<=S[0] && j<=T[0]){
        if(S[i] == T[j]) {
            ++i;++j; //继续比较后继字符
        } else {
            i = i-j+2;
            j=1; //指针后退重新开始匹配
        }
    }
    if(j>T[0])
        return i-T[0];
    else
        return 0;
}

图 6-12 展示了模式 T=‘abcac‘ 和主串 S 的匹配过程。
技术分享图片

简单模式匹配算法的最坏时间复杂度为 (mathcal{O}(nm)),n、m 分别为主串和模式串的长度。

6.5.2 改进的模式匹配算法一KMP 算法

KMP 算法可以在 (mathcal{O}(n+m)) 的时间数量级上完成串的模式匹配操作。
其改进在于:每当一趟匹配过程中出现字符比较不等时,不需回溯 i 指针,而是利用己经得到的“部分匹配”的结果将模式向右“滑动”尽可能远的一段距离后,继续进行比较。

回顾图 6-12 的匹配过程,在第三趟的匹配中,当 (i=7)(j=5) 字符比较不等时,又从 (i=4)(j=1) 重新开始比较。

然而,经仔细观察可发现,在 (i=4)(j=1)(i=5)(j=1) 以及 (i=6)(j=1) 这 3 次比较都是不必进行的。
因为从第三趟部分匹配的结果就可得出,主串中第 4、5 和 6 个字符必然是“b”、“c”和“a”(即模式串中第 2、3 和 4 个字符) 。
因为模式中第一个字符是 a,因此它无需再和这 3 个字符进行比较,而仅需将模式向右滑动 3 个字符的位置继续进行 (i=7)(j=2) 时的字符比较即可。
同理,在第一趟匹配中出现字符不等时,仅需将模式向右移动两个字符的位置继续进行 (i=3)(j=1) 时的字符比较。
由此,在整个匹配的过程中,i 指针没有回溯,如图 6-14 所示。
技术分享图片

KMP 算法的每趟比较过程让子串向后滑动一个合适的位置,让这个位置上的字符和主串中的那个字符比较,这个合适的位置与子串本身的结构有关。

下面来看一种更一般的情况。
假设原始串为 S,长度为 m 模式串为 T,长度为 m。
目前匹配到如下下划线的位置:[egin{aligned}S_0,S_1,S_2,cdots,&,S_{i-j},S_{i-j+1},cdots,S_{i-1},S_i,S_{i+1},cdots,S_{n-1} \\ &,T_0,T_1,cdots,T_{j-i},T_j,cdots,T_{m-1} end{aligned}]

(S_{i-j}S_{i-j+1}cdots S_{i-1})”和“(T_0 T_1cdots T_{j-1})”的部分匹配成功,恰好到 (S_i)(T_j) 的时候匹配失败,如果要保持
i 不变,同时达到让模式串 T 相对原始串 S 右移的话,我们可以想办法更新 j 的值,找到一个最
大的 k,满足 “(S_{i-k}S_{i-k+1}cdots S_{i-1} = T_0 T_1 cdots T_{k-1})”使新的 (j=k),然后让 (S_i)(T_j) 进行匹配,假设新的 j 用 next[j] 表示,即 next[j] 表示当模式串匹配到 T[j] 遇到失配时,在模式串中需要重新和主串匹配的位置。
换而言之,next 数组的求解实际是对每个位置找到最长的公共前缀。
所以 next 数组的定义为:
( ext{next}[j] = egin{cases} 0 &,, 当 j = 1时 \\ ext{Max}{k|1lt klt j, ext{‘}p_1cdots p_{k-1} ext{‘}= ext{‘}p_{j-k+1}cdots p_{j-1} ext{‘}} &,, 当此集合不空时 \\ 1 &,, 其他情况 end{cases})

求 next 函数值的算法如下:

void get_next(char T[], int next[]){
    i = 1;
    next[1] = 0;
    j = 0;
    while(i<=T[0]){ //T[0] 用于保存字符串的长度
        if(j==0 || T[i]==T[j]) {
            ++i;++j;
            next[i] = j;
        } else
            j = next[j];
    } //while
}

下面介绍一个手工求解 next 数组的方法:

  1. (next[1]=0)(next[2]=1)。(next[0]不使用)
  2. 后面求解每一位的 (next[j]) 值时,根据 j 的前一位进行比较,令 (k=next[j-1])
  3. (S[j-1])(S[k]) 进行比较:
    • 如果相等,则该 (next[j]=k+1) ;
    • 如果不等,令 (k=next[k]),若 k 不等于 0,跳到(3);
      若 k 等于 0,(next[j]=1)

例如模式串 S=‘abaabcac‘,依照以上算法求 next 数组:

  1. (next[1]=0)(next[2]=1)
    | 编号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
    | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
    | S | a | b | a | a | b | c | a | c |
    | next | 0 | 1 | | | | | | |
  2. (j=3),此时 (k=next[j-1]=next[2]=1),观察 (S[2])(S[k])(S[1]))是否相等,(S[2]=b)(S[1]=a)(S[2]!=S[1]),此时 (k=next[k]=0),所以 (next[j]=1)
    (egin{aligned} &downarrow j-1=2 \\ a quad &bquad a quad a quad b quad c quad a quad c \\ &a quad b quad a quad a quad b quad c quad a quad c \\ &uparrow k=1 end{aligned})
  3. (j=4),此时 (k=next[3]=1),观察 (S[3])(S[k])(S[1])),(S[3]=a)(S[1]=a)(S[3]=S[1]),所以 (next[j]=k+1=1+1=2)
    (egin{aligned} &downarrow j-1=3 \\ a quad b quad &a quad a quad b quad c quad a quad c \\ &a quad b quad a quad a quad b quad c quad a quad c \\ &uparrow k=1 end{aligned})
  4. (j=5),此时 (k=next[4]=2),观察 (S[4])(S[k])(S[2])),(S[4]=a)(S[2]=b)(S[4]!=S[2])
    (egin{aligned} &downarrow j-1=4 \\ a quad b quad a quad &a quad b quad c quad a quad c \\ a quad &b quad a quad a quad b quad c quad a quad c \\ &uparrow k=2 end{aligned})
    此时 (k=next[k]=next[2]=1),继续比较 (S[4])(S[k])(S[1])),(S[1]=a)(S[4]=S[1]),所以 (next[j]=k+1=2)
    (egin{aligned} &downarrow j-1=4 \\ a quad b quad a quad &a quad b quad c quad a quad c \\ &a quad b quad a quad a quad b quad c quad a quad c \\ &uparrow k=1 end{aligned})
  5. (j=6),此时 (k=next[5]=2),观察 (S[5])(S[k])(S[2])),(S[5]=b)(S[2]=b),所以 (next[j]=k+1=3)
    (egin{aligned} &downarrow j-1=5 \\ a quad b quad a quad a quad &b quad c quad a quad c \\ a quad &b quad a quad a quad b quad c quad a quad c \\ &uparrow k=2 end{aligned})
  6. (j=7),此时 (k=next[6]=3),观察 (S[6])(S[k])(S[3]),(S[6]=c)(S[3]=a)(S[6]!=S[3])
    (egin{aligned} &downarrow j-1=6 \\ a quad b quad a quad a quad b quad &c quad a quad c \\ a quad b quad &a quad a quad b quad c quad a quad c \\ &uparrow k=3 end{aligned})
    此时 (k=next[k]=1),继续比较 (S[6])(S[k])(S[1])),(S[1]=a),所以 (S[6]!=S[1])
    (egin{aligned} &downarrow j-1=4 \\ a quad b quad a quad a quad b quad &c quad a quad c \\ &a quad b quad a quad a quad b quad c quad a quad c \\ &uparrow k=1 end{aligned})
    此时 (k=next[k]=0),所以 (next[j]=1)
  7. (j=8),此时 (k=next[7]=1),观察 (S[7])(S[k])(S[1])),(S[1]=a)(S[7]=a)(S[7]=S[1]),所以 (next[j]=k+1=2)
    (egin{aligned} &downarrow j-1=7 \\ a quad b quad a quad a quad b quad c quad &a quad c \\ &a quad b quad a quad a quad b quad c quad a quad c \\ &uparrow k=1 end{aligned})
    最后结果如下:
    | 编号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
    | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
    | S | a | b | a | a | b | c | a | c |
    | next | 0 | 1 | 1 | 2 | 2 | 3 | 1 | 2 |

与 next 数组的求解相比,KMP 的匹配算法就相对简单很多,它在形式上与简单的模式匹配算法很相似。
不同之处仅在于当匹配过程产生失配时,指针 i 不变,指针 j 退回到 next[j] 的位置并重新进行比较,并且当指针 j 为 0 时,指针 i 和 j 同时加 1。
即若主串的第 i 个位置和模式串的第一个字符不等,应从主串的第 i+1 个位置开始匹配。
具体代码如下:

int KMP(char S[], char T[], int next[], int pos) {
    //利用模式串 T 的 next 函数求 T 在主串 S 中第 pos 个字符之后的位置的 KMP 算法。
    //其中,T 非空,1<=pos<=strlen(S)
    i = pos;
    j = 1;
    while(i<=S[0] && j<=T[0]) {
        if(j==0 || S[i]==T[j]) {
            ++i;
            ++j;
        } else
            j=next[j];
    }
    if(j > T[0])
        return i-T[0];
    else
        return 0;

下面用一个实例来说明 KMP 算法的匹配过程。
假设主串 S=‘abcabaaabaabcac‘,子串 T=‘abaabcac‘,其中子串的 next 数组在上面己经求出。
匹配过程如下:

  1. 初始化,i=1,j=1
    (egin{aligned} &downarrow i=1 \\ &a quad b quad c quad a quad b quad a quad a quad a quad b quad a quad a quad b quad c quad a quad c \\ &a quad b quad a quad a quad b quad c quad a quad c \\ &uparrow j=1 end{aligned})
  2. (S[1]=T[1]),匹配,i 和 j 自加 1
    (egin{aligned} &downarrow i=2 \\ a quad &b quad c quad a quad b quad a quad a quad a quad b quad a quad a quad b quad c quad a quad c \\ a quad &b quad a quad a quad b quad c quad a quad c \\ &uparrow j=2 end{aligned})
  3. (S[2]=T[2]),匹配,i 和 j 自加 1
    (egin{aligned} &downarrow i=3 \\ a quad b quad &c quad a quad b quad a quad a quad a quad b quad a quad a quad b quad c quad a quad c \\ a quad b quad &a quad a quad b quad c quad a quad c \\ &uparrow j=3 end{aligned})
  4. (S[3]!=T[3]),不匹配,(j=next[j]=next[3]=1)
    (egin{aligned} &downarrow i=3 \\ a quad b quad &c quad a quad b quad a quad a quad a quad b quad a quad a quad b quad c quad a quad c \\ &a quad b quad a quad a quad b quad c quad a quad c \\ &uparrow j=1 end{aligned})
  5. (S[3]!=T[1]),不匹配,且 (j=next[j]=0)
    (egin{aligned} &downarrow i=3 \\ a quad b quad &c quad a quad b quad a quad a quad a quad b quad a quad a quad b quad c quad a quad c \\ &a quad b quad a quad a quad b quad c quad a quad c \\ uparrow j&=0 end{aligned})
  6. (j=0),i 和 j 自加 1
    (egin{aligned} &downarrow i=4 \\ a quad b quad c quad &a quad b quad a quad a quad a quad b quad a quad a quad b quad c quad a quad c \\ &a quad b quad a quad a quad b quad c quad a quad c \\ &uparrow j=1 end{aligned})
  7. (S[4]=T[1]),i 和 j 自加 1
    (egin{aligned} &downarrow i=5 \\ a quad b quad c quad a quad &b quad a quad a quad a quad b quad a quad a quad b quad c quad a quad c \\ a quad &b quad a quad a quad b quad c quad a quad c \\ &uparrow j=2 end{aligned})
  8. (S[5]=T[2]),i 和 j 自加 1
    (egin{aligned} &downarrow i=6 \\ a quad b quad c quad a quad b quad &a quad a quad a quad b quad a quad a quad b quad c quad a quad c \\ a quad b quad &a quad a quad b quad c quad a quad c \\ &uparrow j=3 end{aligned})
  9. (S[6]=T[3]),i 和 j 自加 1
    (egin{aligned} &downarrow i=7 \\ a quad b quad c quad a quad b quad a quad &a quad a quad b quad a quad a quad b quad c quad a quad c \\ a quad b quad a quad &a quad b quad c quad a quad c \\ &uparrow j=4 end{aligned})
  10. (S[7]=T[4]),i 和 j 自加 1
    (egin{aligned} &downarrow i=8 \\ a quad b quad c quad a quad b quad a quad a quad &a quad b quad a quad a quad b quad c quad a quad c \\ a quad b quad a quad a quad &b quad c quad a quad c \\ &uparrow j=5 end{aligned})
  11. (S[8]!=T[5]),不匹配,(j=next[j]=2)
    (egin{aligned} &downarrow i=8 \\ a quad b quad c quad a quad b quad a quad a quad &a quad b quad a quad a quad b quad c quad a quad c \\ a quad &b quad a quad a quad b quad c quad a quad c \\ &uparrow j=2 end{aligned})
  12. (S[8]!=T[2]),不匹配,(j=next[j]=1)
    (egin{aligned} &downarrow i=8 \\ a quad b quad c quad a quad b quad a quad a quad &a quad b quad a quad a quad b quad c quad a quad c \\ &a quad b quad a quad a quad b quad c quad a quad c \\ &uparrow j=1 end{aligned})
  13. 以下全部匹配, 过程略
    (egin{aligned} &downarrow i=15 \\ a quad b quad c quad a quad b quad a quad a quad a quad b quad a quad a quad b quad c quad a quad &c \\ a quad b quad a quad a quad b quad c quad a quad &c \\ &uparrow j=8 end{aligned})

以上就是手工模拟 KMP 算法的过程。
尽管朴素的模式匹配的时间复杂度是 (mathcal{O} (mn)),KMP 算法的时间复杂度是 (mathcal{O}(m+n))
但在一般情况下,朴素的模式匹配算法的实际执行时间近似(mathcal{O}(m+n)),因此至今仍然被采用。
KMP 算法仅仅是在主串与子串有很多“部分匹配”时才显得比朴素的算法快得多,其主要优点是主串不回溯。

KMP 算法对于初学者来说有些不容易理解,读者可以尝试多读几遍本章内容,并参考一些其他教材的相关内容来巩固这个知识点。






















































































































































































































































以上是关于数据结构6. 查找的主要内容,如果未能解决你的问题,请参考以下文章

linux中怎么查看mysql数据库版本

Drupal 6 视图 2:PHP 片段

找到我的自定义代码片段 Xcode 6?

查找上一个活动片段

记录C#常用的代码片段

如何使用导航架构组件查找子片段