目录
Brute-Force算法
Knuth-Morris-Pratt算法
确定有限状态自动机
部分匹配表
Boyer-Moore算法
Rabin-Karp算法
总结
网络信息中充满大量的字符串,对信息的搜寻至关重要,因此子字符串查找(即字符串匹配)是使用频率非常高的操作:给定一段长度为N的文本和长度为M的模式字符串(N≥M),在文本中找到一个和模式串相匹配的子串。由这个问题可以延伸至统计模式串在文本中出现的次数、找出上下文(和该模式串相符的子字符串周围的文字)等更复杂的问题。
Brute-Force算法属于暴力搜索,它在文本中对可能匹配模式串的任何位置检查匹配是否存在。一个指针i跟踪文本,另一个指针j跟踪模式串。对于每一个i都会启动一次匹配搜寻,若模式匹配则返回i,否则重置j为0并将i移动到下一个位置进行下一次匹配。
int BruteForce(const string &str, const string &pat)
{
for (string::size_type i = 0; i <= str.size()-pat.size(); ++i)
{
string::size_type j;
for (j = 0; i < pat.size(); ++j)
{
if (str.at(i+j) != pat.at(j))
{
break;
}
}
if (j == pat.size())
{
return i;
}
}
return -1;
}
下面的程序提供了另一种实现,这种实现中指针i相当于上一段代码中的i+j,即指向文本中已经匹配过的字符串的末端,指针j则记录应该回退的位置。如果i不匹配则回退两个指针:将j重新指向模式串的开头,将i指向文本中本次匹配的开始位置的下一个位置。
这种实现的代码并不比上一段代码优雅,对于第一个字符就不匹配的情况下还多了一次减法运算和赋值操作。但这种指针回退的实现思路对于理解KMP算法具有指导意义。
int BruteForce(const string &str, const string &pat)
{
string::size_type i, j;
for (i = 0, j = 0; i < str.size() && j < pat.size(); ++i)
{
if (str.at(i) == pat.at(j))
{
++j;
}
else
{
i -= j; //j记录了当字符串不匹配时应该回退的位置
j = 0;
}
}
if (j == pat.size())
{
return i-pat.size();
}
return -1;
}
BF算法是一个简单而广泛使用的暴力算法,虽然它在最坏情况下的运行时间与MN成正比,但在实际应用场景中,大部分情况它的运行时间一般与M+N成正比。
在某些字符串匹配中,文本串中有许多子串与模式串相似但又不相同。如在aaaaaaaaaaaaab中寻找aab,如果用BF算法,每一次不匹配时文本串指针i都要回退到上一次匹配的开始位置的下一位置重新开始,这实际上对i~i+j之间的字符做了多次比较,重复做了许多无用功。实际上文本串指针i可以不回退,只要回退模式串指针j就可以了。
KMP算法的目标就是免去这些无意义的重复工作,它可以让模式串指针j回退尽可能少,因为在一次不匹配时,其前面检测过已经匹配的部分字符是有可能在下一次匹配时使用的。
算法涉及到前缀和后缀的概念:如果存在A=Sb(A、S为非空字符串),则称S为A的前缀;同样,如果存在A=bS(A、S为非空字符串),则称S为A的后缀。因此只要找到已匹配的子串中相等且最长的前缀和后缀,前缀(或后缀)的长度k就是在下一轮匹配中可以跳过无需检验(因为已经匹配)的子串长度,那么模式串指针j只需要回退j-k即可。
KMP算法寻找匹配字符串的核心过程可以用确定有限状态自动机(Deterministic Finite Automation,DFA),对于每一个状态的转换都有一定的转换条件,在字符串匹配中,已匹配的字符串长度就是状态,而当前状态的转换则由下一个字符来决定。如下图所示,对于每一个状态的所有转换,只有一条是匹配转换(从j到j+1),其他都是非匹配转换。
KMP算法就实现了这么一个有限状态自动机dfa[][]。对于每一个字符c,在比较了c和pat[j]之后,dfa[c][j]表示的是应该和下一个文本字符比较的模式字符的位置。在查找中,dfa[str[i][j]是在比较了str[i]和pat[j]之后应该和str[i+1]比较的模式字符的位置。在匹配成功时会继续比较下一个字符,因此dfa[pat[j]][j]总是j+1。在不匹配时,不仅可以知道str[i]的字符,也可以知道文本串中的前j-1个字符,它们就是模式中的前j-1个字符。
搞明白了dfa的作用后,下一步就是如何构造dfa的问题。以下图为例:
ababac在第6个字符不匹配时,我们已经知道前5个字符“ababa”的信息。从前后缀的角度考虑,已匹配的字符串的前缀集为{a, ab, aba, abab},后缀集为{a, ba, aba, baba},从而得出前缀集和后缀集的交集中最长的是“aba”,长度为3,因此模式串指针j应该回退到第3个位置(即pat[2]),在下一轮匹配时从第4个位置开始比较。寻找最长相同前后缀最简单的办法就是固定文本串,并向右移动模式串,就像扫描已匹配的子串一样。
那么dfa应该如何处理下一个字符?通过DFA可以知道完全回退之后算法会扫描ababa并到达第4个状态(序号为3),因此可以将dfa[c][3]复制到dfa[c][5](c为字符)并将c所对应的元素的值设为6,因为pat[5]=c。因为在计算DFA的第j个状态时只需要知道DFA是如何处理前j-1个字符的,所以总能从尚不完整的DFA中得到所需的信息。
const int k = 256; //字符范围
void buildDFA(vector> &dfa, const string &pat)
{
dfa[pat[0]][0] = 1;
for (int i = 0, j = 1; j < pat.size(); ++j)
{
for (int c = 0; c < k; ++c)
{
dfa[c][j] = dfa[c][i]; //匹配失败情况下需要将dfa[][i]复制到dfa[][j]
}
dfa[pat[j]][j] = j+1; //设置匹配成功情况下的值
i = dfa[pat[j]][i];
}
}
int KMP(const string &str, const string &pat)
{
vector> dfa(k);
for (auto &vec : dfa)
{
vec.resize(pat.size());
}
buildDFA(dfa, pat); //构造dfa
string::size_type i, j;
for (i = 0, j = 0; i < str.size() && j < pat.size(); ++i)
{
j = dfa[str[i]][j];
}
if (j == pat.size())
{
return i-pat.size();
}
return -1;
}
按照上述方法构造的DFA会占用RM空间(R为字母表大小),另一种方法是在构造DFA时为每个状态设置一个匹配转换和一个非匹配转换(而非指向每个可能出现的字符的多个转换),即我们仅仅追踪每个状态对应的prev状态,然后建立一种动态的有限自动机——每当读入一个新的字符以后,如果匹配,则跳到下一个状态,否则回溯(退化)到prev状态(上一个状态),再看是否匹配。根据KMP状态机的结构特性,这样的过程最终会在0状态收敛。对于非零状态,我们知道状态数会递增的条件是当且仅当发生匹配且匹配连续,一旦有不连续情况发生,则必然产生状态退化。
这种动态的DFA需要一个叫部分匹配表的数组的支持。
部分匹配表(Partial Match Table,PMT)是KMP算法使用动态DFA匹配的核心。PMT的每一个元素值都代表着当前已匹配子串的前缀集和后缀集的交集中最长的元素。以字符串“abababca”为例,其PMT如下图所示:
例如对子串“aba”来说,其前缀集为{a, ab},后缀集为{a, ba},交集为{a},即前后缀交集中最长的元素长度为1,因此pmt[2]为1。
理解了PMT后,算法步骤也就很清晰了:
(1)寻找前缀后缀最长公共元素长度,构造PMT
(2)根据PMT构造next数组
next数组考虑的是当前字符之前的字符串前后缀的相似度,所以通过步骤(1)求得各个前缀后缀的公共元素的最大长度后,只要稍作变形即可:将第①步骤中求得的值整体右移一位,然后初值赋为-1,因此next数组可以直接在PMT上构造。
(3)根据next数组进行匹配
void buildNext(vector &next, const string &pat)
{
next[0] = -1;
int i = 0, j = -1;
while (i < pat.size())
{
if (j == -1 || pat[i] == pat[j])
{
++i;
++j;
next[i] = j;
}
else
{
j = next[j];
}
}
}
int KMP(const string &str, const string &pat)
{
vector next(str.size());
buildNext(next, pat);
int i = 0, j = 0;
while (i < str.size() && j < pat.size())
{
if (j == -1 || str[i] == pat[j])
{
++i;
++j;
}
else
{
j = next[j];
}
}
if (j == pat.size())
{
return i-j;
}
return -1;
}
这种KMP的实现方式的空间复杂度与M成正比,相对于上一种实现方式节省了不少空间,但理解起来也相对更困难一些。 KMP算法为最坏情况提供了线性级别的运行时间保证,但在实际应用中,它的速度优势相比于BF算法并不十分明显,因为极少有应用程序需要在重复性很高的文本中查找重复性很高的模式串。然而,KMP算法的一个优点是不需要再输入中回退(即不需要回退文本指针i),这使得KMP算法更适合在长度不确定的输入流(例如标准输入)中进行查找,需要回退的算法在这种情况下需要复杂的缓冲机制。
如果回退很容易,还有一些算法比KMP快得多,Boyer-Moore算法就是一种利用回退来获取巨大性能收益的算法。
当可以在文本字符串中回退时,如果从右向左扫描模式字符串并将它和文本串匹配,那么就能得到一种非常快的字符串查找算法——Boyer-Moore算法。事实上,BM(Boyer-Moore)算法是目前被认为最高效的字符串搜索算法, 一般情况下,比KMP算法快3-5倍,它由Bob Boyer和J Strother Moore设计于1977年。该算法常用于文本编辑器中的搜索匹配功能,比如GNU grep命令使用的就是该算法。
同样是文本回退,相对于BF算法,BM算法的优势在于当不匹配的时候一次性可以跳过不止一个字符。即它不需要对被搜索的字符串中的字符进行逐一比较,而会跳过其中某些部分。通常搜索关键字越长,算法速度越快。它的效率来自于这样的事实:对于每一次失败的匹配尝试,算法都能够使用这些信息来排除尽可能多的无法匹配的位置。即它充分利用待搜索字符串的一些特征,加快了搜索的步骤。如下所示:
index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
text | F | I | N | D | I | N | A | H | A | Y | S | T | A | C | K | N | E | E | D | L | E | I | N |
N | E | E | D | L | E | ||||||||||||||||||
N | E | E | D | L | E | ||||||||||||||||||
N | E | E | D | L | E | ||||||||||||||||||
N | E | E | D | L | E |
要实现启发式地处理不匹配的字符,我们需要记录下模式串中每一种字符在模式串中出现的最靠右的位置。这个值揭示了如果该字符出现在文本中且在查找时造成了一次匹配失败,模式串应该向右移动(跳跃)多远。对于模式串“NEEDLE”来说,应该记录如下信息:
字符 | N | E | D | L |
---|---|---|---|---|
最靠右的位置 | 0 | 5 | 3 | 4 |
记录好模式串的相关信息后,BM算法的实现就很简单了。我们依然用指针i在文本串中从左向右移动,但模式串的指针则是从右向左移动。内循环会检查正文和模式字符串在位置i是否一致,如果从M-1到0的所有j,str[i+j]=pat[j],则匹配成功。否则匹配失败,会遇到以下两种情况:
(1)如果造成匹配失败的文本串字符不包含在模式串中,说明在当前情况下肯定无法匹配整个模式串,因此将模式串向右移动j+1个位置(即i += j+1)。
(2)如果造成匹配失败的文本串字符包含在模式串中,则找到这个字符在模式串中最靠右的位置,对齐模式串和文本串,使得该字符和它在模式串中出现的最右位置相匹配。
int BM(const string &str, const string &pat)
{
//定义并构造bm_map
unordered_map bm_map;
for (int k = 0; k < pat.size(); ++k)
{
bm_map[pat[i]] = i;
}
//BM算法主体
int i = 0, j = 0;
unordered_map::const_iterator ite;
while (i <= str.size() - pat.size())
{
for (j = pat.size()-1; j >= 0; --j)
{
if (pat[j] != str[i+j])
{
if (bm_map.cend() != (ite = bm_map.find(str[i+j])))
{
i += j - ite->second;
}
else
{
i += j+1;
}
break;
}
}
if (j < 0)
{
return i;
}
}
return -1;
}
Boyer-Moore算法预计算了模式字符串与自身的不匹配情况(和KMP算法的方式类似)并为最坏情况提供了线性时间保证。简明的算法思想使得即使在对于需要在输入流中匹配字符串时,构造缓冲机制也是可接受的选择。
实际上,BM算法还可以更快,可以移动更大的距离。以下图为例:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
text | a | b | b | a | d | c | a | b | a | b | a | c | a | b |
b | a | b | a | c | ||||||||||
传统BM | b | a | b | a | c | |||||||||
优化后的BM | b | a | b | a | c |
传统BM算法对于上表的情况会直接将模式串移动j+1个位置,但其实从j+1开始的两个字符中都没有模式串的开头字符b,因此完全可以移动到b出现的位置。要实现这种模式串移动需要另外增加一个表来记录下模式串开头字符在文本串中的所有位置,是一种以空间换时间的优化,但如果这样的字符在文本串中大量存在,优化带来的效率提升并不明显,甚至可能因为多构造了一个表而导致运行时间变慢。
Michael O. Rabin和Richard M. Karp在1987年提出一个算法——对模式串进行哈希运算并将其哈希值与文本中子串的哈希值进行比对。因此RK算法成功的关键就在于如何设计哈希函数,构造出足够出色的哈希表来。
以除留取余的哈希策略为例,假设字符串都由'0'~'9'构成,在文本串"3141592653569793"中寻找模式串"26535",首先选择哈希表大小(这里取997),则散列值为26535%997=613,然后计算文本中所有长度为5个数字的子字符串中的散列值并寻找匹配。如下图所示:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
text | 3 | 1 | 4 | 1 | 5 | 9 | 2 | 6 | 5 | 3 | 5 | 6 | 9 | 7 | 9 | 3 |
3 | 1 | 4 | 1 | 5 | % 997 = 508 | |||||||||||
1 | 4 | 1 | 5 | 9 | % 997 = 201 | |||||||||||
4 | 1 | 5 | 9 | 2 | % 997 = 715 | |||||||||||
1 | 5 | 9 | 2 | 6 | % 997 = 971 | |||||||||||
5 | 9 | 2 | 6 | 5 | % 997 = 442 | |||||||||||
9 | 2 | 6 | 5 | 3 | % 997 = 929 | |||||||||||
2 | 6 | 5 | 3 | 5 | % 997 = 613(匹配) |
C++ STL的unordered_set就是一个哈希容器,它采用了哈希桶+完全散列来实现这个容器。RK算法的实现我也偷个懒,直接使用了这一容器。
int RK(const string &str, const string &pat)
{
unordered_set Hash;
m.insert(pat);
for (int i = 0; i <= str.size() - pat.size(); ++i)
{
if (Hash.find(str.substr(i, pat.size())) == Hash.end())
{
++i;
}
else
{
return i;
}
}
return -1;
}
RK算法相对于BF算法的好处在于BF算法的每一次内循环都需要N个字符进行逐一比较,而RK算法则是采用哈希策略对其每一次内循环中的待检验字符串进行哈希运算后和模式串的哈希值进行比较。
事实上,由于哈希函数无法保证对不同的字符串产生不同的哈希值,有哈希冲突的现象存在,所以即使模式串的哈希值和文本子串的哈希值相等,也需要对这两个长度为m的字符串进行额外的比对(当然,如果不相等也就不用比对了,其实大部分的时间省在这上面),这时比对的开销是O(M)。最坏情况下,文本中所有长度为m的子串(一共N-M+1个)都和模式串匹配,所以算法复杂度为O((N-M+1)m)。然而实际情况下,需要进一步比对的子串个数总是有限的(假设为c个),那么算法的期望匹配时间就变成O((N-M+1)+cM)=O(N+M)。
显然,RK算法的性能在很大程度上取决于一个好的哈希函数。虽然在最坏情况下RK算法的运行时间仍然是O(NM),但在实际使用过程中,Rabin-Karp的复杂度通常被认为是O(N+M)。
Rabin-Karp算法的优势还在于,Rabin-Karp算法非常适用于多模式匹配(multiple pattern match),事实上,它天生就能够支持此类的操作。由于这个特点,它能应用于检测抄袭(查重)。
上述几种字符串匹配算法都各有特点,且在工业生产中都着应用。Broute-Force(暴力查找)算法的实现非常简单且在一般情况下都工作良好(Java的String类型的indexOf()方法使用的就是BF算法);Knuth-Morris-Pratt算法设计巧妙但复杂,能够保证最坏情况下也是线性级别的性能,且不需要回退文本串指针;Boyer-Moore算法的性能在一般情况下都是亚线性级别(可能是线性级别的M倍),且对于越长的模式串其速度可能会越快;Rabin-Karp算法的内循环不同于前面三种算法,它的内循环的主要工作是计算哈希值,RK算法还支持多模式匹配。