是否是子串——KMP算法

1. 引入

  KMP算法解决的是判断一个字符串 m m m是否是另一个字符串 s s s的子串(包含在一个字符串中的连续字符串)这一问题。
  我们很容易想到对 s s s字符串的每个字符逐个开始与 m m m字符串进行比较,若是中间有字符不相同则从 s s s中取下一个字符重新与 m m m进行比较。这就是暴力方法判断是否为子串的方法,可以看出其时间复杂度为 O ( n k ) O(nk) O(nk)的,其中 n n n s s s的长度, k k k m m m的长度。
  而KMP算法是在以上暴力方法为基础,避免进行重复的比较操作,从而降低总体的时间复杂度,最终降低到 O ( n + k ) O(n+k) O(n+k)的程度。

2. KMP如何避免重复比较

2.1 最大前缀后缀匹配长度

  首先先引入字符串的前缀字符串后缀字符串的概念,前缀字符串指的是包含首字符的字符子串,后缀字符串指的是包含尾字母的字符子串。
  再引入最大前缀后缀匹配长度的概念,该概念是对字符串中的每个字符 s t r [ i ] str[i] str[i]而言的,对该字符前的子串 s t r [ 0 : i − 1 ] str[0:i-1] str[0:i1],该子串相同长度的前缀字符串和后缀字符串完全相同时的最长长度即为最大前缀后缀匹配长度。
  需要注意的是这里的前缀字符串不能包含尾字母,同样的,后缀字符串不能包含首字母,所以对于长度为 1 1 1的字符串,人为规定其最大前缀后缀匹配长度为 − 1 -1 1,长度为 2 2 2的字符串,规定为 0 0 0

有了如上概念,对于字符串abcabcde,寻找其每个字符的最大前缀后缀的过程:

子串 最大前缀后缀匹配长度
0-0:a -1
0-1: a b 0
0-2: a b c 0
0-3: a b c a 0
0-4: a b c a b 1
0-5: a b c a b c 2
0-6: a b c a b c d 3
0-7: a b c a b c d e 0

2.2 最大前缀后缀匹配长度有何用处

以字符串 s : s: s:abcabccabcaaabcabcd, m : m: m:abcabcd为例:
  暴力方法先将 s s s的第一个字符与 m m m的第一个字符对准进行第一轮 m m m的比较,发现比较到m最后一位的时候不同,则第一轮比较结束。

s:	abcabc'c'abcaaabcabcd
m:	abcabc'd'

  将 s s s的第二个字符与 m m m的第一个字符对准进行第二轮 m m m的比较,第一次比较即不相同,则第二轮比较结束。

s:	a'b'cabccabcaaabcabcd
m:	 'a'bcabcd

  重复进行如上操作,直到第四轮,才能遇到前三位相同的情况,然而此时较第一轮结束又进行了3次移位操作,3次比较操作,总共6次操作才得以成行。

s:	abc'abc'cabcaaabcabcd
m:	   'abc'abcd

  若是我们可以根据abc这个信息,直接将 m m m字符串挪动至与 s s s的第四个字符对齐,岂不是可以节省很多次操作。这也即是KMP算法的实际操作的过程。
  让我们将目光转至第一轮当中的最后一次比较。会发现对于字符串m中参与最后一次比较的字符为b,其最长前缀后缀匹配长度为 3 3 3,对应的前后缀字符串均为abc(参考上表中子串0-6)。利用该最长前缀后缀匹配长度的信息,若此时,我们将 m m m的在字符 d d d处的前缀字符串与后缀字符串对齐,会发现如下情景:

s:	abcabc'c'abcaaabcabcd
m1:	abcabc'd'
m2:	   abc'a'bcd

  若是 s s s m 1 m1 m1进行组合,是否就是以上又进行了 6 6 6次操作后的刚开始的第四轮的结果?也即发现了 s s s m 1 m1 m1中的 c c c d d d不等之后,直接比较 s s s m 2 m2 m2当中的 c c c a a a是否相等,还省去了比较前缀abc是否相等的 3 3 3次比较操作,相当于以 1 1 1步操作完成了暴力方法中的 9 9 9次移动和比较操作,这也即是KMP更够避免重复比较操作,更快的原因。

画一简单的示意图表示如上过程:

其中 i i i表示该轮比较 s s s m m m的对齐位置, j j j表示最长匹配后缀开始的位置,所画 椭 圆 椭圆 表示 m m m Y Y Y字符的最长前缀后缀匹配长度。当遇到字符 X X X Y Y Y不相等时,将Y的最长匹配前缀字符串和其最长匹配后缀对齐,转换为 X X X m m m中字符 Z Z Z是否相等的问题。

3. KMP算法的实现(C++版本)

3.1 求取字符的最长前缀后缀匹配长度

  对于一个字符串m,创建一与该字符串等长的数组 n e x t [ ] next[] next[]用来记录字符串中每一个字符的最长前缀后缀匹配长度。由2.1节可知, n e x t [ 0 ] = − 1 next[0] = -1 next[0]=1 n e x t [ 1 ] = 0 next[1] = 0 next[1]=0。那么对于 ∀ i ∈ [ 2 , m . s i z e ( ) ] , n e x t [ i ] \forall i \in[2,m.size()],next[i] i[2,m.size()]next[i]如何求取呢?

思路 n e x t [ i ] next[i] next[i]取决于 n e x t [ i − 1 ] next[i-1] next[i1],还有 m [ i − 1 ] m[i-1] m[i1] m [ n e x t [ i − 1 ] ] m[next[i-1]] m[next[i1]]是否相等:

  • m [ i − 1 ] = m [ n e x t [ i − 1 ] ] m[i-1] = m[next[i-1]] m[i1]=m[next[i1]],则 n e x t [ i ] = n e x t [ i − 1 ] + 1 next[i] = next[i-1] + 1 next[i]=next[i1]+1
  • m [ i − 1 ] ≠ m [ n e x t [ i − 1 ] ] m[i-1] \neq m[next[i-1]] m[i1]=m[next[i1]],即匹配失败,则查询 m [ i ] m[i] m[i] m [ n e x t [ n e x t [ i − 1 ] ] ] m[next[next[i-1]]] m[next[next[i1]]]是否相等,若相等则 n e x t [ i ] = n e x t [ n e x t [ i − 1 ] ] + 1 next[i] = next[next[i-1]] + 1 next[i]=next[next[i1]]+1,否则进行迭代,直至 n e x t next next中的数值为 0 0 0,再次比较对应字符是否相等,相等则 n e x t [ i ] = 1 next[i]=1 next[i]=1否则为0。

  以字符串ababcababtk为例,对于其中的字符k i = 10 i=10 i=10,已知前面的 n e x t next next数组为[-1, 0, 0, 1, 2, 0, 1, 2 ,3, 4],需要求取 n e x t [ 10 ] next[10] next[10]。此时, m [ 9 ] = t m[9]=t m[9]=t, n e x t [ 9 ] = 4 next[9]=4 next[9]=4, m [ n e x t [ 9 ] ] = c m[next[9]]=c m[next[9]]=c, 则有 m [ 9 ] ≠ m [ n e x t [ 9 ] ] m[9] \neq m[next[9]] m[9]=m[next[9]],即 t ≠ c t \neq c t=c。进行下一步迭代,此时 n e x t [ n e x t [ 9 ] ] = n e x t [ 2 ] = 0 next[next[9]] = next[2] = 0 next[next[9]]=next[2]=0,此时迭代终止,比较 m [ i − 1 ] = m [ 9 ] = t m[i-1]=m[9]=t m[i1]=m[9]=t m [ n e x t [ n e x t [ 9 ] ] ] = m [ n e x t [ 4 ] ] = m [ 2 ] = a m[next[next[9]]] = m[next[4]] = m[2] = a m[next[next[9]]]=m[next[4]]=m[2]=a,有 t ≠ a t \neq a t=a,所以 m [ i ] = m [ 10 ] = 0 m[i] = m[10] = 0 m[i]=m[10]=0

代码如下:

vector<int> getNextArray(string s) {
    
    
		//求取next[i]思路为:
		//next[i]的值与s[i-1]和s[next[i-1]]有关,又可分为两种情况:
		//1.s[i-1]==s[next[i-1]],此时next[i]=next[i-1]+1
		//2.s[i-1]!=s[next[i-1]],此时前向寻找,直到满足如下两个情况停止
		//	①s[i-1]==s[next[next[i-1]]],此时s[i-1]=s[next[next[i-1]]](第一次不匹配,第二次匹配的情况,会存在多次匹配的情况)
		//	②若是进行①中匹配之后,发现匹配的下标到0了,此时说明没有可重复利用的前缀,则s[i-1]=0
		vector<int> next(s.length(), -1); //约定字符串第一个字符的next[0]=-1
		if (s.size() == 1) return next;
		next[1] = 0; //字符串第二个字符即next[0]=0,因为其前面只有一个字符,且后缀不能包含第一个字符,前缀不能包含最后一个字符,所以为0
		int i = 2, idx = 0; //idx记录当前字符前一字符最长前缀的长度/下标右移一位的下标
		while (i < s.size()) {
    
    
			if (s[i - 1] == s[idx]) {
    
     //对应1,此时next[i]=next[i-1]+1
				next[i++] = ++idx;
			}
			else if (idx > 0) {
    
     //对应2①,此时idx!=0,做循环查询
				idx = next[idx];
			}
			else {
    
    
				next[i++] = 0; //对应2②,此时idx==0
			}
		}
		return next;
	}

3.1 KMP算法的实现

  对于KMP算法的实现,即是暴力方法和利用最长前缀后缀匹配的结合。从 0 0 0开始,对 s s s m m m创建两个指针 i d x s idx_s idxs, i d x m idx_m idxm。将两指针所对应的两个字符进行比较,相同时,指针同时右移一位;不相同时,若是 m m m n e x t next next数组不为 0 0 0 − 1 -1 1,则将前缀和后缀进行对齐, i d x m = n e x t [ i d x m ] idx_m = next[idx_m] idxm=next[idxm],若是为 0 0 0 − 1 -1 1,则将 s s s的指针右移一位,即 i d x s + 1 idx_s+1 idxs+1

//判断m是否是s的子串,如果是则返回m在s中开始的下标,否则返回-1
	int getIndexOf(string s, string m) {
    
    
		//如果s,m是空串,或是s的长度小于m的长度,则必定不是子串,返回-1
		if (s.size() == 0 || m.size() == 0 || s.size() < m.size()) {
    
     
			return -1;
		}
		int idx_s = 0, idx_m = 0;
		vector<int> next = getNextArray(m);
		while (idx_s < s.size() && idx_m < m.size()) {
    
    
			if (s[idx_s] == m[idx_m]) {
    
     //如果两字符相等,则两指针均向右移一位
				idx_m++;
				idx_s++;
			}
			else if (next[idx_m] == -1) {
    
     //若是当下m下标的next数组值为-1,则说明无重复前缀后缀,s指针右移一位
				idx_s++;
			}
			else {
    
     //当下m下标的next数组值
				idx_m = next[idx_m];
			}
		}
		//若是m串的下标为m串的长度,则返回s下标减去m长度即为m在s中开始的下标,否则返回-1
		return idx_m == m.size() ? idx_s - idx_m : -1;
	}

猜你喜欢

转载自blog.csdn.net/yueguangmuyu/article/details/111746606