1. 问题与需求
好,接下来我们就对这一章的主角,也就是串匹配问题,作一概述。
- 包括这个问题是什么?
- 有哪些不同层次的功能要求?
- 以及如何评测相应算法的性能,尽管我们还没有涉及到具体的算法。
如果你使用Unix 或 Linux 那么对于 grep 这个命令肯定就不会陌生, 这个支持正则表达式搜索的命令功能非常强大,其中最基本的一项功能就是,在某个文本中去查找特定的模式串。
比如,这就是一次成功的查找,因为我们注意到 people 这个单词的确在上面这个句子中出现了。其实,类似的这种搜索在当下是无处不在的,无时不在的。
~想想你在 Google 或百度上通过关键词搜索网页就不难理解的一点。是的,对于这类搜索引擎来说,所输入的关键词就相当于这里的模式串,而文本串 T呢?是的,它们是 Internet 上所有的网页。
由此,我们也可以看出此类问题的一个鲜明特点,这体现在两个串的长度上。按照我们的惯例,通常都将文本串和模式串的长度分别记作 n 和 m,通常 m 本身就足够大,因此不能视作是一个常数。比如你所搜索的关键词,通常都有几十个到100个字符组成。
另一方面,相对于已经比较大的 m 而言,n 的规模,又要比 m 大上若干个甚至很多个数量级。仍然以刚才的搜索引擎为例,整个 Internet 上的所有网页的长度之和必然是惊人的,即便是单站网页,其规模通常也在几十到几百 k。
当然,我们所说的模式匹配问题,从功能和难度上,可以分为若干个递进的层次。
-
首先是所谓的检测,detection。也就说,我们只关心模式串是否在文本串中出现过,至于出现在哪,以至出现多少次,相对而言我们都不是那么关心。
比如病毒的监控系统,更在意的是病毒的特征码在对应的邮件或文件中是否出现,只有不包含病毒特征码的文件或邮件才允许通过。
-
当然,接下来的一个层次自然是定位。也就是说,如果模式串出现,我们还关心它具体出现在文本串中的哪个位置。
例如,你在一份很长的网页上要查找某个特定的入口,就需要用到这样的功能。
-
当然,通常而言,模式串有可能会出现多次,而此时,我们有可能会关心它总共出现过几次。
比如,根据一份学生的花名册。借助这种功能,我们就可以统计出特定届次的学生总数。
-
当然,再进一步地,是所谓的给 numeration 枚举问题。也就说我们需要知道模式串在文本串中具体都出现在哪几个位置。
比如在刚才的例子中,我们有可能需要进一步的确定,特定界次的学生具体是哪几位。
纵观这四个层次,不难发现,其中核心的是第二个层次。 实际上,只要这个层次的问题能够得以高效地求解,后续的问题也自然可以迎刃而解。因此,这一层次的问题也是我们在这一章中将主要讨论的范畴。
鉴于串匹配问题的特殊性,再给出具体的算法之前,我们需要首先来确定应该如何的测量和评判此类算法的性能。
2. 算法测评
关于算法的性能评测,在第一章中,已介绍过一般性的原则和方法,而且总体的原则和方法,在此自然仍可适用。
但是鉴于串匹配问题的特殊性,为了更为客观而准确地评判相应算法性能,我们需要对相应的标准和策略做针对性的细化与调整。
例如,参照其他算法通用的模式,我们很有可能首先会想到将自己的输入,也就是文本串 T 以及模式串 P 同时作随机采样,然后通过数学上的概率分析或实际测量的统计,来评判算法的性能。
然而很遗憾,这种生搬硬套的模式并不适用于模式匹配这一问题。什么原因呢?其背后的根本原因在于,关于什么是查找成功与失败,现在这一问题,与此前我们所遇到的问题已有本质的区别。 比如,我们此前所谓的 x 是否等于 y?完全是一对一的,然而在这里,情况已经大不相同。具体来说,无论是主串还是模式串,都是由多个元素,也就是字符组成的。两个串在某种意义上的相等,意味着多对字符的同时相等。也就说,此时匹配成功的概率,必然会远远地低于匹配失败的概率。
我不妨以二进制串为例。对于这样的字符表,我们知道长度为 m 的模式串,总共可能会有 2 m 2^m 2m次方种。而在 T 中,所有长度为 m 的子串,累计也不会超过 n 个。
~尽管我们讲过,通常 n 会远远的大于 m,但是 m本身也并非是常数,因此如果是以2为底的幂次,又会反过来,远远的大于 n。因此,如果按照刚才完全随机的测试方法,匹配成功的概率应该不会超过 n / 2 m n/2^m n/2m。
~这个数大致是多大呢?依然参照我们刚才网页搜索的那个实例作为估算,将 n 大致取作 1 0 m 10^m 10m,将 m 大致取作 1 0 2 10^2 102,经过封底估算,知道分母大致是 1 0 3 0 10^30 1030。因此,这个概率不会超过 1 0 − 25 10^{-25} 10−25。
如此之低的概率,完全有可能会被任何一种微小的波动所掩盖。因此,这种测试方法的确是非常不妥的。
那么反过来又有什么行之有效的方法呢?实际上,一种简明而有效的方法就是将成功与失败的情况分别考虑。
- 针对于失败的情况,我们可以继续沿用此前完全随机的方法。
- 而针对成功的情况,为了更准确的评估算法的性能,我们就需要将文本串中所有长度为 m 的子串悉数取出,并以它们作为测试的输入实例。
好,现在我们已经针对串匹配的问题给出了算法评测的标准与策略,那么接下来,我们自然就可以着手讨论都有哪些串匹配的算法。
3. 蛮力匹配:构思
好,在接下来这节,我们就来介绍所谓的蛮力匹配算法。
顾名思义,这类算法的思路与策略都是直截了当的,非常直观且易于理解。但是反过来,这个名字也暗示着它的效率是非常低下的。不过,这类算法也有它的存在价值,它们可以帮助你理解串匹配的计算过程,同时也为我们后续的改进提供一个起点与参照。
我们这里所说的蛮力算法,在思路上的确是直截了当的,我们不难发现,所谓的匹配成功,必然是相对于某一个对齐位置而言的,这样的对齐位置充其量不会超过文本串的长度,也就是 n。
因此,如果不在意计算的成本,我们只需逐一地尝试并核对每一个对齐位置,也自然就可以解决串匹配的问题。
而这样最自然的一种方式,莫过于自前向后,以单个字符为间隔,依次地将模式串与文本串对齐,并进行核对。我们来看这样一个实例:
在一个相对更长的文本串中,查一个长度为4的模式串,按照刚才所说的策略,我们首先要将二者的首字符彼此对齐,然后将模式串中的每一个字符,分别于主串中对应的字符进行比对。
~如果这样的比对依然也是自左向右的话,于是我们就会发现二者最前端的两个字符都是匹配的,也就是1 对1、 0对0,然而很不幸,接下来却是1对0,我们称之为失配。显然,只要有一对字符是失配的,那么整体就不可能完全匹配,因此,这也就意味着,当前的这个对齐位置是可以排除掉的,因此接下来我们需要尝试下一个对齐位置。
~就效果而言,这等同于文本串保持不动,而模式串向右侧滑动一个字符。一旦这样对齐之后,我们就会随机启动一轮逐个字符的比对。可以看到,当前的这轮比对,会失配于首字符处,这也意味着第二个对齐位置也可排除。
~于是接下来,我们可以将模式串继续向后滑动一个字符,从而尝试下一个对齐的位置。在接下来的这轮比对中,依然存在失配,因此这个对齐位置也会被淘汰掉。
~接下来我们再进而尝试下一个对齐位置,新的一轮比对在经过了一次成功之后,依然遇到了一次失配,这也意味着这个对齐位置也是可以排除的。
~于是接下来,我们再次的向后移动模式串,并尝试新的一个对齐位置。非常幸运,在这样一个对齐位置,所有的字符都是匹配的。这也意味着,当前的这个对齐位置就是一个成功的匹配。
如果我们暂且不关心这个算法的效率,它的正确性是显而易见的。那么,这样的一个计算过程应该如何地表述为代码呢?
4. 蛮力匹配:版本一
上述蛮力算法至少有两种实现版本,因为由它们可以很方便地分别导出后续更为高效的算法,因此,这里不妨对它们都做一介绍。
这两个版本对外的接口都是一样的,按照我们这里的命名习惯,入口参数 p 和 t 分别指向模式串和文本串。而且这里我们采用了最为基本的字符串表示方法,而且约定在每一个串的末尾都有一个数值为 '\0' 的哨兵。而且按照我们一贯的约定,串长并不计入尾部的这个哨兵。
-
为了分别指示在模式串和文本串中当前的字符位置,这里还需要使用两个整数 i 和 j ,在后一版本中,同样会用到这样的两个整数,但它们的语义却不尽相同,这也是前后两个版本的本质区别所在。
如上图所示,在这里 i 和 j 所指示的分别是当前主串中与模式串中,接受比对的那样一对字符。因此二者同时初始化为0,也再自然不过了。
-
算法将两个串逐位对齐,并进行比对的过程,兑现为这样一个 while 循环。正如刚才所介绍的,在每一对齐位置,我们都需要将文本串与模式串的当前字符取出,并将二者做一比对。
如果相等,则令两个整数携手并进,从而分别指向下一对字符。否则,意味着失配。你应该记得此时我们应该令模式串相对于文本串向后滑动一个字符,并重新对齐。为此,对于 j 而言,我们只需另其复位为0。那么文本串的指针 i 呢? 为了确定指针 i 的更新方法,我们需要重新回到这幅图。
这图告诉我们,在算法的任何一个时刻,模式串相对于文本串的对齐位置,都是由 i 和 j 的差来指定的。因此,既然 j 的更新等效于其在数值上减少了 j,i 的更新也就应该等效于其在数值上减少 j -1。如果能悟到这一点,也就自然可以理解在这里对 i 的更新方法了。
总而言之,这里的 if 相当于在保持相对位置不变的情况下,去比较每一对字符,而 else 才对应于 p 与 t 之间的相对滑动。
-
我们再来考察这个循环的退出条件,不能看出有两种情况:无论是 j 越过了它的上界 m, 或者 i 越过了它的上界 n,这个循环都可随机退出。也能看出这两种情况分别对应于什么吗?
没错,分别对应于整体的匹配成功与否。为此我们需要注意到定算法的另一个不变性,考察这里作为指针的整数 j, 实际上在整个算法过程中的任何一个时刻,j 的数值就对应于在当前的对齐位置下,已经做过的成功比对次数。
因此一旦 j 达到它的上界 m,也就意味着模式串中的这 m 个字符都得到了匹配。这难道不正是一次整体的匹配吗?在这种情况下,我们返回 i - j 是再自然不过的了。因为通过它可以向这个算法的上层调用者报告,就在文本串的这个位置发现了一处完全匹配。
我再考虑 i 越界的情况,因为 i 是逐一增加的,因此它在越界的时候必然会恰好等于 n,而此时的 j 依然处于合法的区间。综合这两条件不难得知,按照这一分支退出时,返回值 i - j 必然会大于 n - m。我们知道 n - m 应该是模式串相对于文本串而言,能够对齐的最靠右、也是最后一个位置。因此,这时的 i - j 既然已经超越了这个合法的位置,这个算法的上层调用者自然就可以据此断定,整个匹配是以失败告终的。
总而言之,在这里通过简明的返回对齐位置 i - j, 就可以准确地向算法的上层调用者报告,究竟是匹配成功还是失败。
5. 蛮力匹配:版本二
再来考察蛮力匹配算法的另一版本:
如我们刚才所说,算法的接口形式与前一版本完全一致。而且这里也同样的设置了 i 和 j 两个整数,用于指示当前接受比对的字符。
当然,具体的指示方式与前一版本略有不同。通过上图,可以很清楚地看出这种新的指示方式。具体来说,模式串中当前的字符编号依然为 j,而在文本串中,当前字符的编号则为 i + j。
参照代码可以看到,我们的确每次都是将文本串中编号为 i + j 的那个字符与模式串中编号为 j 的那个字符进行比对,并根据比对的结果确定算法的下一步走向。相对于前一版本,在这里,每经过一次成功的比对,我们只需简明地令 j 递增,就可便捷地指向下一对字符。
当然,这种优势只是形式上的,实质上并没有任何改进。因为我们注意到,在每次真正实施比对时,我们还需补上那次貌似省略掉的加法运算。当然上一版本的不变性在这里依然成立。比如,模式串相对于文本串的对齐位置依然是当前这对字符各自位置的差。
具体来说,在这里也就是 i ,既然对其位置始终都是由 i 指定的,在算法的出口处,以 i 作为返回值也是在自然不过了。这个返回值与上一个版本的返回值 i - j 有着异曲同工之妙。
不难看出,无论是前一版本还是后一版本,都忠实地体现了我们最初的蛮力策略。那么,这一策略所对应的计算效率究竟有多高呢?
6. 蛮力匹配:性能
以下,就分别从最好和最坏情况两个角度,对蛮力算法的计算效率作一评估:
-
首先,最好情况显而易见。也就是我们在第一个对齐位置只经过一轮比对之后就能确定整体匹配。在这种情况下,我们累计只需进行 m 次比对,因此整体消耗的时间可以度量为 O(m) ,与文本串的长度 n 无关。
-
相对而言,最坏情况要复杂很多。可以想象在最坏情况下,我可能需要尝试所有的对齐位置,而且在每一个对齐位置情况都糟糕透顶。具体来说,我们都需要经过 m -1 次成功的比对,并失败于最后一次比对,从而在每一个对齐位置,我们都需要付出 m 次比对的成本。因此累计的成本应该是这两项的乘积。
考虑到在通常的情况下,n 要远远大于 m,所以借助大 O 记号,可以更为简明的度量为 O(nm)。如果考虑到无论 n 或者 m 都要远远大于常数,那么,这样一个复杂度的确是不能令我们满意的。
当然,至此你可能会怀疑,以上所设想的最坏情况的确会发生吗?坏消息是很不幸,的确可能发生。
比如,这就是一种最坏情况(上图右下角),此时的文本串和模式串类似,二者都是除了末字符为1,其余的字符都为0。不能发现,在任何一个对齐位置的故事都是一样的,每次对齐之后,我们都会经过连续的 m -1 次成功的比对,并最终失败于最后一次比对。
当然这类最坏情况的出现概率受到很多因素的影响。
- 在通常的情况下,其中最主要的一个因素,莫过于字母表自身的大小。 我们注意到,此类蛮力算法的效率之所以很低,其原因可以理解为它不足以处理这类大量的局部匹配。而字母表越小,可出现字母的种类也就越少,相应地,局部匹配的概率也就更高,因此也相对更有可能导致最坏的情况。
- 另一方面,最快情况下的效率之所以极低,也可以理解为蛮力算法没有能够有效地避免这类局部的匹配,从而每次都是直到最终才会发觉此前所获得的一系列局部匹配都是徒劳的。 在这种极端的情况下,局部匹配的次数取决于模式串的长度 m,因此 m 越大,最快情况所带来后果也更为严重。
然而反过来一个好消息是,蛮力算法也并非如它的名字所暗示的那样百无一用。实际上,随着字母表规模的扩大,以上最坏情况出现的概率将急剧下降,以至平均而言,在每一对齐位置,我们都大致只需常数次比对。 这就意味着,就期望的意义而言,蛮理算法的时间复杂度可以接近甚至达到线性。当然,我们并不满足于此,而是希望能够将这个前提条件抹掉,从而得到一个堂堂正正的,即便在最坏情况下,也只需运行线性时间的算法。
而更好的消息是,这类算法的确存在,比如我们接下来就要介绍的著名的 KMP 算法。