标签:形式 回顾 查找 abc 空闲 注意 副作用 key 所有结点
目录
顺序查找,又称为线性査找,主要用于在线性表中进行査找。
顺序査找通常分为对一般的无序线性表的顺序査找和对按关键字有序的顺序表的顺序査找。
下面分别进行讨论。
作为一种最直观的查找方法,其基本思想是:
从线性表的一端开始,逐个检査关键字是否满足给定的条件。
若査找到某个元素的关键字满足给定条件,则査找成功,返回该元素在线性表中的位置;
若已经査找到表的另一端,还没有查找到符合给定条件的元素,则返回査找失败的信息。
下面给出其算法,主要是为了说明其中引入的“哨兵”的作用。
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\)。
査找成功时,顺序査找的平均长度为 \(\text{ASL}_\text{成功}=\sum_{i=1}^n P_i(n-i+1)\)
当每个元素的査找概率相等时,即 \(P_i=1/n\),则有
\[\text{ASL}_\text{成功}=\sum_{i=1}^n P_i(n-i+1)=\frac{n+1}{2}\]
査找不成功时,与表中各关键字的比较次数显然是 \(n+1\) 次,从而顺序査找不成功的平均査找
长度为 \(\text{ASL}_\text{不成功}=n+1\)。
通常,査找表中记录的査找概率并不相等。
若能预先得知每个记录的査找概率,则应先对记录的査找概率进行排序,使表中记录按査找概率由小至大重新排列。
综上所述,顺序査找的缺点是当 n 较大时,平均査找长度较大,效率低;
优点是对数据元素的存储没有要求,顺序存储或链式存储皆可。
对表中记录的有序性也没有要求,无论记录是否按关键码有序均可应用。
同时还需注意,对线性的链表只能进行顺序査找。
如果在査找之前就己经知道表是按关键字有序的,那么当査找失败时可以不用再比较到表的另一端就能返回査找失败的信息,这样能降低顺序査找失败的平均査找长度。
假设表 L
是按关键字从小到大排列的,查找的顺序是从前往后查找,待査找元素的关键字为 key
,
当査找到第 \(i\) 个元素时,发现第 \(i\) 个元素对应的关键字小于 key, 但第 \(i+1\) 个元素对应的关键字大于 key
,
这时就可以返回査找失败的信息了,因为第 \(i\) 个元素之后的元素的关键字均大于 key
, 所以表中不存在关键字为 key
的元素。
可以用如图 6-1 所示的判定树来描述有序顺序表的査找过程。
树中的圆形结点表示有序顺序表中存在的元素;
树中的矩形结点称为失败结点(注意,若有 n 个査找成功结点,则必相应的有 n+1 个査找失败结点),它描述的是那些不在表中的数据值的集合。
如果査找到失败结点,则说明査找不成功。
在有序表的顺序査找中,査找成功的平均査找长度和一般线性表的顺序査找一样。
査找失败时,査找指针一定走到了某个失败结点。
这些失败结点是我们虚构的空结点,实际上是不存在的,所以到达失败结点所査找的长度等于它上面一个圆形结点的所在层数。
查找不成功的平均査找长度在相等査找概率的情形下有
\[\text{ASL}_\text{不成功} = \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\) 时,\(\text{ASL}_\text{不成功}=6/2+6/7=3.86\),比一般的顺序査找算法好一些。
请注意,有序表的顺序査找和后面的折半査找的思想是不一样的,而且有序表的顺序査找中的线性表可以是链式存储结构的。
折半查找,又称为二分査找,它仅适用于有序的顺序表。
基本思路是:首先将给定值 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\rceil\)。
以下说明査找 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\ne 11\),故表中不存在待査元素。
折半査找的过程可用图 6-2 所示的二叉树来描述,称为判定树。
树中每个圆形结点表示一个记录,结点中的值为该记录的关键字值;树中最下面的叶结点都是方形的,它表示査找不成功的情况。
从判定树可以看出,査找成功时的査找长度为从根结点到目的结点的路径上的结点数,
而査找不成功时的査找长度为从根结点到对应失败结点的父结点的路径上的结点数;
每个结点值均大于其左子结点值,且均小于于其右子结点值。
若有序序列有 n 个元素,则对应的判定树有 n 个圆形的非叶结点和 \(n+1\) 个方形的叶结点。
由上述的分析可知,用折半査找法査找到给定值的比较次数最多不会超过树的高度。
在等概率査找时,査找成功的平均査找长度为
\[\begin{aligned} \text{ASL} &= \frac{1}{n} \sum_{i=1}^{n} l_i = \frac{1}{n} (1\times 1+2\times 2 + \cdots + h\times 2^{h-1}) \\ &= \frac{n+1}{n} \log_2{(n+1)}-1 \approx \log_2{(n+1)}-1 \end{aligned}\]
式中,\(h\) 是树的髙度,并且元素个数为 n 时树高 \(h = \lceil\log_2{(n+1)}\rceil\)。
所以,折半査找的时间复杂度为 \(\mathcal{O}(\log_2{n})\),平均情况下比顺序査找的效率高。
在图 6-2 所示的判定树中,在等概率的情况下,査找成功的 \(\text{ASL}= (1\times 1+2\times 2+3\times 4+4\times 4)/11=3\),
査找不成功的 \(\text{ASL} = (3\times 4+4\times 8)/12=11/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\),则分块査找的平均査找长度为
\[\text{ASL} = L_I + L_S\]
设将长度为 n 的査找表均匀的分为 b 块,每块有 s 个记录,在等概率的情况下,若在块内和索引表中均采用顺序査找,则平均査找长度为
\[\text{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\);
若对索引表采用折半査找时,则平均査找长度为
\[\text{ASL} = L_I + L_S = \lceil\log_2(b+1)\rceil + \frac{s+1}{2}\]
考试大纲对 B 树和 B+树的要求各不相同,重点在于考査 B 树,
不仅要求理解 B 树的基本特点,还要求掌握 B 树的建立、插入和删除操作,
而对 B+ 树则只考査基本概念。
B 树,又称为多路平衡査找树,B 树中所有结点的孩子结点数的最大值称为 B 树的阶,通常用 m 表示。
一棵 m 阶 B 树或为空树,或为满足如下特性的 m 叉树:
所有非叶结点的结构如下:
| \(n\) | \(P_0\) | \(K_1\) | \(P_1\) | \(K_2\) | \(P_2\) | \(\cdots\) | \(K_n\) | \(P_n\) |
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
所有的叶结点都出现在同一层次上,并且不带信息(可以看做是外部结点或者类似于折半査找判定树的査找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。
B 树是所有结点的平衡因子均等于 0 的多路査找树,如图 6-5 所示为一棵 3 阶 B 树。
其中底层方形结点表示叶结点,在这些结点中没有存储任何信息。
由下一节可知,B 树中的大部分操作所需的磁盘存取次数与 B 树的高度成正比。
下面来分析 B 树在不同的情况下的高度。
当然,首先应该明确 B 树的高度不包括最后的不带任何信息的叶结点所处的那一层。(注:有些书上对于 B 树的高度定义中是包括了最后的那一层的,本书中不包括这一层,所以希望读者在阅读的时候注意一下)。
如果 \(n\ge 1\),则对任意一棵包含 n 个关键字、髙度为 h、阶数为 m 的 B 树:
在 B 树上进行査找与二叉査找树很相似,只是每个结点都是多个关键字的有序表,
在每个结点上所做的不是两路分支决定,而是根据该结点的子树所做的多路分支决定。
B 树的査找包含两个基本操作:
由于 B 树常存储在磁盘上,则前一个査找操作是在磁盘上进行的,而后一个査找操作是在内存中进行的,
即在找到目标结点后,先将结点中的信息读入内存,然后再采用顺序査找法或折半査找法査找等于 K 的关键字。
在 B 树上査找到某个结点后,先在有序表中进行査找,
若找到则査找成功,否则按照对应的指针信息到所指的子树中去査找(例如,
在图 6-5 中査找到第一层的第一个结点时,若发现关键字大于 18 而小于 33,则在这个结点上査找失败,将根据 18 与 33 之间的指针到结点的第二个子树中继续査找)。
当査找到叶结点时(对应的指针为空指针),则说明树中没有对应的关键字,査找失败。
与二叉査找树的插入操作相比,B 树的插入操作要复杂得多。
在二叉査找树中,仅需査找到需插入的终端结点的位置。
但是,在 B 树中找到插入的位置后,并不能简单地将其添加到终端结点中去,因为此时可能会导致整棵树不再满足 B 树中定义中的要求。
将关键字 key 插入到 B 树的过程如下:
对于 m=3 的 B 树,所有结点中最多有 \(m-1=2\) 个关键字,若某结点中己有两个关键字时,则结点己满,如图 6-6(a) 所示。
当插入一个关键字 60 后,结点内的关键字个数超出了 m-1, 如图 6-6(b) 所示,此时必须进行结点分裂,分裂的结果如图 6-6(c) 所示。
B 树中的删除操作与插入操作类似,但要稍微复杂些,要使得删除后的结点中的关键字个数大于等于 \(\lceil m/2\rceil-1\),因此将涉及结点的“合并”问题。
当所删除的关键字 k 不在终端结点(最底层非叶结点)中时,有下列几种情况:
当被删除的关键字在终端结点(最底层非叶结点)中时,有下列几种情况:
兄弟够借:若被删除关键字所在结点删除前的关键字个数等于 \(\lceil m/2\rceil-1\),且与此结点相邻的右(左)兄弟结点的关键字个数大于等于 \(\lceil m/2\rceil\),需要调整该结点、右(左)兄弟结点以及其双亲结点(父子换位法),以达到新的平衡,如图 6-8(a) 所示。
兄弟不够借:若被删除关键字所在结点删除前的关键字个数等于 \(\lceil m/2\rceil-1\),且此时与该结点相邻的右(左)兄弟结点的关键字个数等于 \(\lceil m/2\rceil-1\),则将关键字删除后与右(左)兄弟结点及双亲结点中的关键字进行合并,如图 6-8(b) 所示。
在合并的过程中,双亲结点中的关键字个数会减少。
若其双亲结点是根结点并且关键字个数减少至 0(根结点关键字个数为 1 时,有 2 棵子树),则直接将根结点删除,合并后的新结点成为根;
若双亲结点不是根结点,且关键字个数减少到 \(\lceil m/2\rceil-2\),又要与它自己的兄弟结点进行调整或合并操作,并重复上述步骤,直至符合 B 树的要求为止。
B+ 树是应数据库所需而出现的一种 B 树的变形树。
一棵 m 阶的 B+树需满足下列条件:
m 阶的 B+ 树与 m 阶的 B 树的主要差异在于:
如图 6-9 所示为一棵 4 阶 B+ 树的示例。
从图中可以看出,分支结点的某个关键字是其子树中最大关键字的副本。
通常在 B+ 树中有两个头指针:一个指向根结点,另一个指向关键字最小的叶结点。
因此,可以对 B+ 树进行两种査找运算:一种是从最小关键字开始的顺序査找,另一种是从根结点开始,进行多路査找。
B+树的査找、插入和删除操作和 B 树基本类似。
只是在査找过程中,如果非叶结点上的关键字值等于给定值时并不终止,而是继续向下査找直到叶结点上的该关键字为止。
所以,在 B+ 年树中査找,无论査找成功与否,每次査找都是一条从根结点到叶结点的路径。
在前面介绍的线性表和树表的査找中,记录在表中的位置跟记录的关键字之间不存在确定关系,因此,在这些表中査找记录时需进行一系列的关键字比较。
这一类査找方法是建立在“比较”的基础上,査找的效率取决于比较的次数。
散列函数:一个把査找表中的关键字映射成该关键字对应的地址的函数,记为 \(\text{Hash}(key)=Addr\)。(这里的地址可以是数组下标、索引、或内存地址等)
散列函数可能会把两个或两个以上的不同关键字映射到同一地址,称这种情况为“冲突”,这些发生碰揸的不同关键字称为同义词。
一方面,设计好的散列函数应尽量减少这样的冲突;另一方面,由于这样的冲突总是不可避免的,所以还要设计好处理冲突的方法。
散列表:是根据关键字而直接进行访问的数据结构。
也就是说,散列表建立了关键字和存储地址之间的一种直接映射关系。
理想情况下,对散列表进行査找的时间复杂度为 \(\mathcal{O}(1)\),即与表中元素个数无关。
下面将分别介绍常用的散列函数和处理冲突的方法。
在构造散列函数时,必须注意以下几点:
下面介绍常用的散列函数:
在不同的情况下,不同的散列函数会发挥出不同的性能,因此不能笼统地说哪种散列函数最好。
在实际的选择中,采用何种构造散列函数的方法取决于关键字集合的情况,但是目标是为了使产生冲突的可能性尽量地降低。
应该注意到,任何设计出来的散列函数都不可能绝对地避免冲突,为此,必须考虑在发生冲突时应该如何进行处理,即为产生冲突的关键字寻找下一个“空”的 Hash 地址。
假设己经选定散列函数 H(key)
,下面用 表示发生冲突后第 i 次探测的散列地址。
所谓开放定址法,指的是可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。
其数学递推公式为 \[H_i= (H(key)+d_i)% m\]
式中,\(i=0,1,2,\cdots,k\)(\(k\le m-1\)),m 表示散列表表长,\(d_i\) 为增量序列。
当取定某一增量序列后,则对应的处理方法是确定的。通常有以下四种取法:
线性探测法:
当 \(d_i=0,1,2,\cdots,m-1\),称为线性探测法。
这种方法的特点是:冲突发生时,顺序查看表中下一个单元(当探测到表尾地址 m-1 时,下一个探测地址是表首地址 0),直到找出一个空闲单元(当表未填满时一定能找到一个空闲单元)或査遍全表。
线性探测法可能使第 i 个散列地址的同义词存入第 i+1 个散列地址,
这样本应存入第 i+1 个散列地址的元素就争夺第 i+2 个散列地址的元素的地址……从而造成大量元素在相邻的散列地址上“聚集”(或堆积)起来,大大降低了査找效率。
伪随机序列法:当 \(d_i\) 等于伪随机数序列,称为伪随机序列法。
注意:
在开放定址的情形下,不能随便物理删除表中已有元素,因为若删除元素将会截断其他具有相同散列地址的元素的查找地址。
所以若想删除一个元素时,给它做一个删除标记,进行逻辑删除。
但这样做的副作用是:在执行多次删除后,表面上看起来散列表很满,实际上有许多位里没有利用,因此需要定期维护散列表,要把删除标记的元素物理删除。
显然,对于不同的关键字可能会通过散列函数映射到同一地址,为了避免非同义词发生冲突,可以把所有的同义词存储在一个线性链表中,这个线性链表由其散列地址唯一标识。
假设散列地址为 i 的同义词链表的头指针存放在散列表的第 i 个单元中,因而査找、插入和删除操作主要在同义词链中进行。
拉链法适用于经常进行插入和删除的情况。例如,关键字序列为 \(\{19,14,23,01,68,20,84,27,55,11,10,79\}\),散列函数 \(H(key)=key%13\),用拉链法处理冲突,建立的表如图 6-12 所示。
散列表的査找过程与构造散列表的过程基本一致。
对于一个给定的关键字 key,根据散列函数可以计算出其散列地址,执行步骤如下:
初始化:\(Addr=Hash(key)\);
例如,关键字序列 \(\{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]\ne 84\),则找第一次冲突处理后的地址 \(H_1=(6+1)%16=7\),而 \(L[7]\) 不空且 \(L[7]\ne84\),则找第二次冲突处理后的地址 \(H_2=(6+2)%16=8\),\(L[8]\) 不空且 \(L[8]=84\),査找成功,返回记录在表中的序号 8。
给定值 38 的査找过程为:先求散列地址 \(H(38)=12\),因 \(L[12]\) 不空且 \(L[12]\ne 38\),则找下一地址 \(H_1=(12+1)%16=13\),由于 L[13]是空记录,故表中不存在关键字为 38 的记录。
散列表的査找效率取决于三个因素:散列函数、 处理冲突的方法和装填因子。
装填因子:散列表的装填因子一般记为 \(\alpha\),定义为一个表的装满程度,即:
\[\alpha = \frac{\text{表中记录数}_n} {\text{散列表长度}_m}\]
散列表的平均査找长度依赖于散列表的填装因子 \(\alpha\),而不直接依赖于 n 或 m。
直观地看,a 越大,表示装填的记录越“满”,发生冲突的可能性就越大,反之发生冲突的可能性越小。
虽然散列表在关键字与记录的存储位置之间建立了直接映像,但“冲突”的产生,使得散列表的査找过程仍然是一个给定值和关键字进行比较的过程。
因此,仍需以平均査找长度作为衡量散列表的査找效率的度量。
读者应能在给出了散列表的长度、 元素个数,以及散列函数和解决冲突方法后,可以在求出散列表的基础上计算査找成功时的平均査找长度和査找不成功的平均査找长度。
串的模式匹配,是求第一个字符串(模式串)在第二个字符串(主串)中的位置。
下面给出一种简单的字符串模式匹配算法:
从主串 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 分别为主串和模式串的长度。
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。
目前匹配到如下下划线的位置:\[\begin{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_1\cdots 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 数组的定义为:
\(\text{next}[j] = \begin{cases} 0 &,\, 当 j = 1时 \\ \text{Max}\{k|1\lt k\lt j, \text{‘}p_1\cdots p_{k-1}\text{‘}=\text{‘}p_{j-k+1}\cdots p_{j-1}\text{‘}\} &,\, 当此集合不空时 \\ 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 数组的方法:
例如模式串 S=‘abaabcac‘,依照以上算法求 next 数组:
与 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 数组在上面己经求出。
匹配过程如下:
以上就是手工模拟 KMP 算法的过程。
尽管朴素的模式匹配的时间复杂度是 \(\mathcal{O} (mn)\),KMP 算法的时间复杂度是 \(\mathcal{O}(m+n)\)。
但在一般情况下,朴素的模式匹配算法的实际执行时间近似\(\mathcal{O}(m+n)\),因此至今仍然被采用。
KMP 算法仅仅是在主串与子串有很多“部分匹配”时才显得比朴素的算法快得多,其主要优点是主串不回溯。
KMP 算法对于初学者来说有些不容易理解,读者可以尝试多读几遍本章内容,并参考一些其他教材的相关内容来巩固这个知识点。
标签:形式 回顾 查找 abc 空闲 注意 副作用 key 所有结点
原文地址:https://www.cnblogs.com/4thirteen2one/p/9537225.html