String Matching 字符串匹配算法——干货从头放到尾

需要的先验知识:动态规划,有限状态机,搜索算法(就是含有state,action和policy)的模型,java。上面这些不需要知道很细,大概懂这些都是啥就可以读懂本文。

写这篇技术博客的动机是因为做 Leetcode “Implement strStr” 一题学会了KMP算法,觉得这个第一次学还挺绕的就想记录一下解题思路,不过后来又补充了好多好多前前后后关于字符串匹配的算法知识,这篇文章就变成了一篇干货分享啦hhh。

Problem Definition

在网页和文档的中的所搜功能肯定大家都用过,Ctrl+F,输入要搜索的关键词,文本中出现的搜索词就都给你标出来了。这个其实用到的就是字符串匹配。

  1. 定义字母表(Alphabet) Σ \Sigma 。Size of Alphabet = | Σ \Sigma |。
    eg: Σ \Sigma = { a, b, …, z }, | Σ \Sigma | = 26

  2. 定义文本(Text) T [1, … n]. T[i] 是字母表中的字符。|T| = n (文本长度)

  3. 定义模式(Pattern): P [1, … m]. P[i]是字母表中的字符。|P| = m

  4. 定义术语 “shift”:If T[i+s] = P[i] for i=0,…, m-1, then s is a valid shift. We say: P occurs with shift s in T.

String Matching 算法将回答两个问题:1)Does P occur in T? If yes, find its first occurrence. 2)Find all occurrences of P in T.
用刚刚定义的shift来描述这两个问题就是:1)Does P occur in T? If yes, find the first valid shift. 2)Find all valid shifts with which P occurs in T.

暴力破解法

for s=0 to n-m: 检查 if T[i+s] = P[i] for i=0,…,m-1. 这个算法复杂度为O(mn).

改善一丢丢的解法:Rabin-Karp

思路:Treat alphabet characters as numbers. Treat strings as polynomials.
eg. " abcdz" → 26 + 4×10 + 3×100 + 2×1000 + 1×10000 → 12366
由此一来可以用Hash给每个string算一个指纹(fingerprint)。
eg. Hash function is "Taking the value of polynomial modulo some prime q. Let’s say q=13.
“abcdz” → 12366 → 12366%13 = 3. “abcdz”的指纹就是3.

于是很快就可以想到基于Hash指纹的算法:
算 fingerprint (Pattern)

for s=0 to n-m:
	算 fingerprint (T[s:s+m-1]). 
	如果它和 fingerprint(Pattern)相等,再花O(m)时间比较具体T[s:s+m-1] 
	和 P这两个string。(因为不同的string可能对应同一个指纹)

不过这样简单粗暴的运用Hash还是too young too simple! 仔细想一下,每一轮循环算一个T[s:s+m-1]的指纹需要O(m), 共n轮循环因此总时间还是O(mn)!

Hash高段位玩法:将上一轮的fingerprint记下来,巧妙利用Hash的特性,将每次算指纹的时间降至O(1).

eg. Text=“abcdeg”
s=0:“abcde” → 12345 假如已经算完12345%13 = 8
s=1:“bcdeg” → 23457 s=0的结果为计算23457%13提供了不少信息有木有??

23457%13 = ((12345 - 10000)× 10 + 7)%13 = ((12345%13 - 1×(10000%13))× 10 + 7)%13 = ((8-1×3)× 10 + 7)%13 = 57%13 = 5
上面的式子中标亮的第一块正好是上一步算的指纹,第二块我们可以在拿到Pattern的时候算好10^m %13.

于是,Find s such that T[s:s+m-1] & P[0:m-1] have the same fingerprint 这一步的复杂度降至O(m+n). +m是因为s=0的时候没有上一步提供的信息帮忙,需要O(m),此外算P的指纹也需要O(m).
整体的时间复杂度是O(m+n) + mM. M代表有多少指纹能对上。若Hash function选得好的话,极少会出现不同string对应同样指纹的情况,此时Rabin-Karp算法的时间复杂度逼近O(m+n) + mM*. (M* = # of real matches)

Rabin-Karp 小结
  • 将string的比较转变为指纹的比较,可以快速排出invalid shift。
  • 算指纹时可利用上一步提供的信息,在O(1)内完成一步(相较于对比string的O(m)省时不少。
  • 算指纹用的Hash相当于“初筛”, 筛出少量的候选人再每个花O(m)时间比较。初筛的效率跟Hash function设计的好不好有关。
  • 唯一小缺陷是,当M*逼近n的时候,或者是P在T当中频繁出现使得real match很多的时候,这个算法就丧失了省时的功效,因为“初筛”并不会筛掉很多。

终极大招:KMP

汲取从Rabin-Karp学到的套路 —— Reuse Information! 最大化利用从上一步循环算出的信息。
我们来看看从Rabin——karp能抽象出哪些算法框架级别的套路:

String_matching_csdn

  • 扫描单位是长度为m的窗户。
  • 窗户每向右移一格,根据右边新看到的字符和上一个窗户的信息即可算出当前窗户的信息(这里说的信息比较抽象,下面具体化)

定义每个扫描位置的“状态(state)”为:已经从左到右匹配了P里的多少个字符。state=m代表完全匹配成功。这个思路在T包含多个重复出现的字符串模式时比较容易理解。来看看这个例子:

T=“abaababac” P=“abac”
for i=0 to m-1:
        i=0,看到T[i] = ‘a’. state=1
        i=1,看到T[i] = ‘b’. state=2
        i=2,看到T[i] = ‘a’. state=3
        i=3,看到T[i] = ‘a’. state=1
        i=4,看到T[i] = ‘b’. state=2
        i=5,看到T[i] = ‘a’. state=3
        i=6,看到T[i] = ‘b’. state=2
        i=7,看到T[i] = ‘a’. state=3
        i=8,看到T[i] = ‘c’. state=4 ← Bingo!

每个state和上一步的state以及当前步新看到的一个字符有关。像不像search里面的state 和 action?再想想还像啥?对!Finite State Machine!
而且Finite State Machine的转移图只跟P有关跟T无关。不信你看↓ ( P=“abac”) String_matching_FSM
于是现在思路就有了,先拿P建好Finite State Machine, 再扫描一遍T, 每个扫描步根据新看到的字符(action),上一步的状态(state)和FSM (policy)更新状态。这样扫描T的时候不走回头路,在O(n)内就能完成。实现FSM用到的具体数据结构是

Map<Integer, Map<Character, Integer>>
              ↑                     ↑                ↑

    上一步状态     看到的新字符   新状态

由于state是从0开始编号的,所以也可以用ArrayList<Map<Character, Integer>>. ArrayList的index代表上一步状态。

Finite State Machine该怎么建 →_→

我们还是从举例中找规律:
FSM1
State3 看到b转移到2是因为“aba”和“a”有相同的后缀,即“右对齐”。因此state3看到b跟State1看到b的去向是一致的。事实上,state3除了看到c之后能成功向前推进一个state,看到其他任意一个字符都跟state1看到同样字符的去向一致。在这里插入图片描述
再试验一个例子:state3看到a转移到1是因为“aba”与“a”有相同的后缀,因此除了看见“c”, state3看见其他任意字符与state1看见这个字符的去向一致。我们给state1起一个名字叫做state3的“影子状态”(之前看到某一篇大神的干货分享见到的这个名字)

state1是怎么找出来的呢?从直观上讲,我们希望找一个尽可能长的前缀与“aba”有相同的后缀。

再举例:eg. P = “aaab”.

“aaa” 与 “aa” 有common suffix,那么上一步是state3时,若当前看到的字符不是“b”, 去向就和state2看到同样字符的去向一致。虽然“aaa” 与 “a”也有相同的后缀,但不是最长的。

漫漫地我们的目的开始明晰了。找状态 i 的“影子状态 k ”就是要找与P的前 i 个字符具有公共后缀的P的最大前缀的长度。若到了状态 i 以后没有见到继续向前匹配的字符,就退回状态k,按照状态 k 的转移图决定去向。

补课

刚才看到各种前缀后缀以及有公共后缀的最长前缀是不是要被整蒙了?下面先补补课吧~ (读完上一段感觉依旧爽歪歪的可以跳过这一段哈~)

  1. Prefix

P[0,…m-1] is a pattern. Pk = P[0,…k-1] is the k-th prefix of P.
eg. P=“abac”. P0 = “”, P1 = “a”, P2 = “ab”, P3 = “aba”, P4 = “abac”

  1. Suffix Function

Given pattern P and text x, σ \sigma (x) is the largest k such that Pk is a suffix of x (or Pk and x have common suffix). σ \sigma (x) = 跟x有公共后缀的P的最大前缀长度。上例子:
eg. P = “abac”
x = “abaaaba” → σ \sigma (x) = 3
x = “abaa” → σ \sigma (x) = 1
x = "abab → σ \sigma (x) =2

---------------------我是宣告补课结束的分割线---------------------
 

终于要拨云见日啦!

纵观这么长的知识总结,到现在可以梳理出很清晰的字符串匹配算法思路了

Step 1: 算每一个状态 i 的影子向状态 shadow[i]. (注意shadow[i] < i).

用补课里的suffix function 来描述shadow[i]代表啥就是:Regard P[0,…i-1] as text, and regard P[0,…i-2] = pattern. Then shadow[i] = σ \sigma (text) given pattern.

eg. P=“abaaba”, i=5. At i=5, regard P[0,…4] = “abaab” as text, and regard “abaa” as pattern. Text and P2 = “ab” have common suffix and k=2 is the largest number such that Pk and text have common suffic. So, shadow[5] = k = σ \sigma (P[0,…4]) = 2

具体实现:


shadow[0] = 0; shadow[1] = 0;
// 必须先填好shadow[1].
// 因为如果放到循环里的话,第一个字符将会等于状态1的影子状态(0)的下一个字符,
// 于是shadow[1]就会被写成1,随后每个shadow[i]都会被写成i。
for i=2 to m-1 {
	// 注意这块的i-1容易弄迷糊。
	// i代表状态i,就是已经匹配了P开头的多少个字符shadow[i-1],不是index。
	// shadow[i-1]代表上一个状态的“影子状态”,是在上一步循环算好的。
	// 代表在上一步已经匹配了shadow[i-1]个字符,所以接下来要看第shadow[i-1]+1 个字符了,它的index又刚好是shadow[i-1]
	// P[i-1]其实是即将算状态i的影子的时候刚刚见到的第i个字符,它的index是i-1。
	
	if (P[i-1]==P[shadow[i-1]]{
		shadow[i] = shadow[i-1] + 1;
	}else{
                int X = shadow[i-1];
                while(true){
                    X = shadow[X];
                    if(needle.charAt(i-1)==needle.charAt(X)){
                        shadow[i] = X+1; break;
                    }
                    if(X==0) {
                        if(needle.charAt(i-1)==needle.charAt(0)){
                            shadow[i] = 1; break;}
                        else {shadow[i] = 0; break;}
                    }
               }
        }
}

影子状态的算法框架是动态规划,相当于填一个一维的shadow 表。不过每一次循环的时候,并不是靠表格前一个以及新看到的字符就可以了的,有可能按照“前任影子”往前跳好几格才能填完当前格。

因此,填每个格子的时候最坏的时间复杂度都是O(m), 填完整个shadow表需要O(m^2)。

Step2:根据shadow表建立Finite State Machine

依旧遵循动态规划的思想,假设已经到达了状态i, 新看到了字符 c,我们要建立的 ArrayList<Map<Character, Integer>> 叫 FSM。这个代码只写了其中一个循环步,完整代码可以看 Leetcode Implement strStr 题解与代码实现的文章

FSM[i] = new HashMap();
if(P[i] == c) {FSM[i].put(c, i+1);}
else {
	int k = shadow[i];
	int new_state = FSM[k].get(c);
	FSM[i].put(c, new_state);
}

FSM的创建需要对每个状态,以及在这个状态基础之上遇到每个可能的新字符进行迭代,需要的时间是O(m| Σ \Sigma |)。

Step3: 拿着FSM把Text扫描一遍

定义table[i] = 读到text[i] 时的state,即加上text[i],已经匹配了P开头的几个字符。每读一个新字符,就根据FSM定义的转移方式得到更新后的state。table[i] = m 代表text[i-m+1,…i]完全匹配。这个时候就已经不需要P了哟~ 全部信息FSM都已经保存啦!

int state = 0;
for i=0 to n-1{
	state = FSM[state].get(text[i])
	if (state==m) {return text[i-m+1,...i]; }
}
return null; // 没有找到匹配

看完“补课”的小伙伴可以发现,table[i] = σ \sigma (x), where x = Text[0,…i]. 填完table以后,有多少个 i such that table[i] = m, 就代表多少个完全匹配。table[i] = m ⇐⇒ i=m+1 is a valid shift.

Step3需要的时间是O(n)。跟O(n)比,前两步的时间复杂度O(m^2)和O(m| Σ \Sigma |)都可以忽略,因为在实际中,n可能是好几页(或一整个文档)的长度,而m一般就是一个单词或者短语的长度,字母表 Σ \Sigma 的长度也就是几十,并不会再大了(说的是英语,中文的话字典size确实挺大的)。

Bingo!万!事!大!吉!啦!!!现在又有了一种吸饱精神食粮的成就感(^-^)!

总结:All things 趋于大同

详细整理这个算法的时候意识到了可以以一概全的大抽象框架 ⇒ 其实动态规划的思想,跟 “Reuse Information" 的策略如出一辙呀!

动态规划就是根据以前已经填好的格子 (state) 的信息,以及当前step看到的新内容 (action) ,根据一种 policy 决定当前格子的内容 (new state) 。简单的动态规划填每一格估计只需要与它相邻的前一格的信息就可以了,但是今天这个问题需要回退到之前某个格子,比如建立FSM的时候;或者回退好几格,比如在算影子状态这个“子问题”的时候。这个“回退”的路线怎么走要么提前算好,要么就是一点一点往前迭代(有人会担心迭代浪费时间,但前面说了是在算子问题的时候,这时候问题的scale不会很大)。迭代虽然存储很多格子的信息会耗费空间,但是会保证最后解决“主问题”的时候填每一格的时间都是O(1)!

读者看到上一段我把state,action,policy和new state标亮了,就大概能意识到,原来动态规划和search也扯到一起了!可以说动态规划其实就是一种search。并且今天这个算法感觉绕来绕去步骤这么多,其实是因为这次的动态规划的policy比较复杂!一般的动态规划问题在自己脑子里想想就想出来了,无非就是这种感觉:

if (i==0 && j==0) { table[i][j] = ...; }
else if (i==0) { ... }
else if (j==0) { ... }
else { table[i][j] = Math.min(table[i-1][j-1] ...); }

但今天我们“主问题”的policy需要用FSM,然后这个FSM还得自己建立,而建FSM的时候,policy得用到shadow表,这个shadow表也还得自己先建好。这就是为啥String matching算法一开始看的云里雾里觉得纷繁缭绕23333.

今天这个问题的三步:算shadow表,算FSM, 最后解决主问题,其实全都是动态规划,有一种兜兜转转最后还是回到的最初的起点的感觉~ 不过,以后再也不敢说动态规划很简单了红红火火恍恍惚惚。

写在结尾

这是芝麻挞第一篇以自己的思路为主的知识整理,以后希望还要多多学习然后总结归纳。把知识学厚的过程时自己积累、丰富自身的过程,再把知识学薄就是梳理、抽象、输出知识的过程。

本文如果有哪些不严谨的地方,欢迎在评论区指正!如果小伙伴有哪里觉得讲的不清楚或者太啰嗦的也欢迎指出!

嘻嘻第一篇自己写的技术博客要发布了美滋滋~

猜你喜欢

转载自blog.csdn.net/weixin_43928665/article/details/104498829