数据结构之串与KMP算法详解

一. 定义(了解)

串,即字符串,是计算机系统和网络传输中最常用的数据类型,任何非数值型的处理都会以字符串的形式存储和使用。

串(String)是由零个或多个字符组成的有限序列,一般记为:
S = ′ a 1 a 2 … a n ′ ( n ≥ 0 ) S='a_1a_2 \dots a_n' \quad (n \geq 0) S=a1a2an(n0)

  • S :串名

  • a_i :字符(任意一种可被编码的符号,即为字符)

  • n :串的长度(n=0S=空

  • 子串:串的子序列(如abc即为dabcd的子串)

  • 主串:子串的原始串

  • 位置:字符在串中的序号(子串的位置由首字符标识)

  • 串相等:长度相等且每个字符相等。

  • 串的大小比较规则:

    • 从第一个字符依次往后比较,以第一个不相等的字符的大小标识串的大小

    • 若每个字符都相等,则短串小于长串。

    • 若串长度也相等,则串相等。

    • 注: 字符的大小比较规则,以编码值的大小为准.

      如按ASCALL编码,字符A编码63,字符a编码97,则可认为A<a

    对于串的正确理解,需要注意以下几点:

    1. 空格串与空串并不等价,如串' '是长度为2的空格串(实际上,除了空格还有许多不可见字符如'\n'等,对于这些字符在字符串里的地位和可见字符完全等价)。
    2. 串的逻辑结构和线性表非常相似,实际上,将线性表中的元素类型设为字符型就是串结构的一种实现方式。区别在于线性表主要关注的是元素"crud"等操作,而其中元素类型是具有抽象性的;而串则限定了其中的元素类型为字符型,其关注的重点主要是查找删除或者插入一个子串这种独属于字符串的操作。

串同样具有顺序链式两种存储结构,这里不过多赘述,它的基本操作如下:

  • StrAssign(&T, chars): 赋值操作。把串 T 赋值为 chars
  • StrCopy(&T, S): 复制操作。由串 S 复制得到串 T
  • StrEmpty(S): 判空操作。若 S 为空串,则返回 TRUE,否则返回 FALSE
  • StrCompare(S, T): 比较操作。若 S > T,则返回值 > 0;若 S = T,则返回值 = 0;若 S < T,则返回值 < 0。
  • StrLength(S): 求串长。返回串 S 的元素个数。
  • SubString(&Sub, S, pos, len): 求子串。用 Sub 返回串 S 的第 pos 个字符起长度为 len 的子串。
  • Concat(&T, S1, S2): 串联接。用 T 返回由 S1S2 联接而成的新串。
  • Index(S, T): 定位操作。若主串 S 中存在与串 T 值相同的子串,则返回它在主串 S 中第一次出现的位置;否则函数值为 0
  • ClearString(&S): 清空操作。将 S 清为空串。
  • DestroyString(&S): 销毁操作。将串 S 销毁。

二. 串的模式匹配(重要)

以下讨论中的串结构我们均采用顺序存储的模式,其声明如下:

typedef struct {
    char ch[MAXLEN]; //注意,这里我们统一从下标为1的位置开始使用数据
    int length;
}String; //string(首字母小写)为C++语言内置的数据类型,这里使用首字母大写以示区别
1. 朴素算法

所谓串的模式匹配,即是串的基本操作中的Index(S, T)的实现。

如对于串S="google", T="ogl"的测试用例,函数应该返回TS中的位置,即应返回2

而对于串S="google", T="ogld"的测试用例来说,由于T并不是S的子串,所以应该返回-1

这里暴力匹配的方法应该很容易理解:

int Index(String S, String T) {
    int i = 1, j = 1; //字符串的下标从1开始
    while (i <= S.length && j <= T.length) {
        if (S.ch[i] == T.ch[j]) {
            ++i, ++j;
        }
        else {
            i = i - j + 2;
            j = 1;
        }
    }
    if (j > T.length) {
        return i - T.length;
    }
    return 0; //匹配失败
}

朴素模式匹配

这种模式匹配的时间复杂度为 O(mn) 其中n和m分别是串S和T的长度,因为在最坏的情况下(每次都是T字符串的最后一位不匹配),针对S中的每一个字符,T都需要遍历整个字符串。

// 朴素模式匹配最坏情况
S = "000000000000000000000000000001";
T = "000001";
2. KMP算法

显然,朴素匹配的思想太过”朴素“,无法满足我们对Index函数的性能要求,那么,我们该如何优化呢?

试想上面提到的朴素算法最坏情况的那个例子:

S = "000000000000000000000000000001";
T = "000001";

朴素算法耗时之处就在于,我们在比对子串时需要从T字符串开头逐字符进行对比,一旦匹配失败,则主串i指针需要回溯到当前对比子串的下一个子串开头,正是这个回溯的过程,才导致了朴素匹配算法的效率低下。

00000'0'000000000000000000000001
00000'1'
//匹配失败后,需要进行回溯
0'0'0000000000000000000000000001
 '0'00001   //需要从这里开始重新对比

但 Knuth Morris Pratt (KMP算法的三位创始人,KMP算法因此得名) 认为,这种回溯操作不是必要的:

00000'0'000000000000000000000001
00000'1' 
/*
当最后一个字符匹配失败后,实际上前面的字符信息我们已经已知
即,当T字符串中第j个字符不匹配时,实际上就代表着,S和T的前j-1个字符是匹配的!
*/

那么,将i指针回溯到下一个子串的初始位置就不必要的,因为在上次匹配中,我们已经知道了:

  • 子串的第二位和主串的第二位匹配
  • 根据子串本身的特点(KMP算法的核心所在),子串的第一位与子串第二位相同。
  • 所以,我们得出主串的第二位和子串的第一位一定匹配!那么,i指针回溯到下一个子串的位置(这里指第二位)就没有必要。

那么,i指针到底应该回溯到哪呢?事实上,i指针并不需要回溯!

理由是,既然子串匹配失败,那就代表着假如可以匹配成功,那么子串的结尾字符一定会在i之后(否则不会匹配失败)。

所以,我们只需要将j(指向子串)指针回溯到一个合理的位置继续对比即可。

00000'0'000000000000000000000001
00000'1'
//只需要将j指针回溯到第一位即可
00000'0'000000000000000000000001
     '0'00001 //i指针不回溯,继续对比

其实i不回溯本质上的原因就在于在扫描的过程中,位置i之前串的信息我们已经掌握,没有回溯的必要。

所以现在的问题就变成了,当不匹配发生时,j指针应该回溯到什么位置?

所以到这就可以看出,解决问题的关键实际上不在于主串,而在于子串,所以KMP算法的核心就是利用子串本身的特点简化匹配过程

这里我们引入next数组next[j]的含义是当子串第j位发生不匹配时,j指针应该回退到next[j]的位置。

我们先介绍其手算的方式,这也是考研数据结构的核心考点

假设子串T="abaabc";

  1. T[1]不匹配时

    由于是子串的首字符不匹配,所以此时应当进行的操作实际上是将主串指针i后移一位继续对比,但是为了程序书写的一致性,我们让j回退到0,然后让ij同时后移一位,即**next[1]=0;**。

  2. T[2]不匹配时

    需要将j回溯到首位继续对比,故**next[2]=1**。

  3. T[3]不匹配时

    ***ab'*'********
       ab'a'  //发生不匹配
     
    ***ab'*'********
         'a'ba //你应该让j指针回到第一位进行对比
    

    所以,next[3]=1

  4. T[4]不匹配时

    ***aba'*'*******
       aba'a'  //发生不匹配
     
    ***aba'*'*******
         a'b'aa //你应该让j指针回到第2位进行对比
    

    所以,next[4]=2

  5. T[5]不匹配时

    ***abaa'*'******
       abaa'b'  //发生不匹配
     
    ***abaa'*'******
          a'b'aab //你应该让j指针回到第2位进行对比
    

    所以,next[5]=2

  6. T[6]不匹配时

    ***abaab'*'******
       abaab'c'  //发生不匹配
     
    ***abaab'*'******
          ab'a'abc //你应该让j指针回到第3位进行对比
    

    所以,next[6]=3

所以对于子串"abaabc"来说,其next数组情况如下:

j123456
next[j]011223

那么,我们使用next数组来模拟一下匹配过程:

假设主串S="abaccabaacabaabca",子串T="abaabc",那么,匹配流程为:

1. 
aba'c'cabaacabaabca
aba'a'bc  //T[4]不匹配,j=next[4]=2
    
2. 
aba'c'cabaacabaabca
  a'b'aabc  //T[2]不匹配,j=next[2]=1
    
3. 
aba'c'cabaacabaabca
   'a'baabc  //T[1]不匹配,j=next[1]=0, 然后i和j同时++
    
4. 
abac'c'abaacabaabca
    'a'baabc  //T[1]不匹配,j=next[1]=0, 然后i和j同时++
    
5. 
abaccabaa'c'abaabca
     abaa'b'c  //T[5]不匹配,j=next[5]=2
    
6. 
abaccabaa'c'abaabca
        a'b'aabc  //T[5]不匹配,j=next[5]=2
    
7. 
abaccabaa'c'abaabca
         'a'baabc  //T[2]不匹配,j=next[2]=1
    
8. 
abaccabaac'abaabc'a
          'abaabc'  //完全匹配,算法结束

所以得到了next数组以后,模式匹配的代码如下:

int Index(String S, String T, int next[]) {
    int i = 1, j = 1; //字符串的下标从1开始
    while (i <= S.length && j <= T.length) {
        if (j = 0 || S.ch[i] == T.ch[j]) {
            ++i, ++j;
        }
        else {
            j = next[j]; //发生匹配失败的情况,i指针不回溯
        }
    }
    if (j > T.length) {
        return i - T.length;
    }
    return 0; //匹配失败
}

接下来,我们总结一下next数组的手算方式:

对于任意一个子串:

  • next[1]=0:首字符不匹配,等于0之后两个指针同时++。
  • next[2]=1:第二个字符不匹配,则回到第一个字符开始比较。

从第三个字符开始,我们可以采用手动模拟的形式计算,即自己观察若第j (j > 2) 个字符不匹配,则j指针应该回溯到哪里才能让前j-1个字符与主串对应位置的字符相等。

例:求"aabaac"next数组。

0 1 2 1 2 3

下面我们分析一下如何编程求出next数组(考研了解即可)

我们假设子串 T = T 1 T 2 … T m T=T_1T_2 \dots T_m T=T1T2Tm

我们已知 next[1]=0,我们假设 next[j]=k (j>1),那么就代表着,当第j个元素匹配失败时,我们应该从第k个元素开始继续比较,那就意味着:
T 1 T 2 … T k − 1 = T j − k + 1 T j − k + 2 … T j − 1 T_1T_2 \dots T_{k-1}=T_{j-k+1}T_{j-k+2} \dots T_{j-1} T1T2Tk1=Tjk+1Tjk+2Tj1
且不存在更长的子序列满足这个条件。

就像,T=abaabc,其next[6]=3 (next[c]=a)的真正原因在于,失配的c的前两个字符组成的子串ab,与T[3]的前缀ab相同,所以当最后一个c失配时,只需要从T[3]开始匹配即可。

那么,我们现在来讨论next[j+1]的情况。

  • T k = T j T_k = T_j Tk=Tj 时,next[j+1] = k+1,即 next[j+1]=next[j]+1

  • T k ≠ T j T_k \neq T_j Tk=Tj 时,

    代表着此时j+1位置的前k个字符,和字符串的前k个字符就不相等了,那么我们怎么办呢?

    答案是显然的,找更短的子串使其相等。即让 k1=next[k],接着判断前k1个元素和j+1位置的前k1个元素组成的子序列是否相等,若相等,则next[j+1]=k1+1;若还不相等,则继续缩短子序列进行判断(k2=next[k1])。

    若一直不相等,则子序列的长度会被缩短为0,因为next[1]=0,此时,next[j+1]=1,即从头开始比较。

kmp

将以上思路,转换为代码,就可以得到next数组的编程求法了:

void get_next(String T, int next[])
{
    int i = 1, k = 0;
    next[1] = 0;
    while (i < T.length) {
        if (k == 0 || T.ch[i] == T.ch[k]) {
            i++, k++;
            next[i] = k;
        }
        else {
            k = next[k];
        }
    }
}

到这里为止,我们就可以分析KMP算法的时间复杂度了(假设主串长为n,子串长为m):

  • 对于匹配的过程,由于主串指针i不回溯,故时间复杂度 O ( n ) O(n) O(n)
  • 对于求解next数组的过程,由于程序主体是从第二个字符开始依次求解,而next数组的回溯速度较快(近乎常数),所以时间复杂度近似 O ( m ) O(m) O(m)

故整体的时间复杂度为: O ( m + n ) O(m+n) O(m+n)

这里时间复杂度的分析仅为了便于理解,本篇文章不详细解析KMP算法时间复杂度的计算,感兴趣可自行查阅资料。

3. KMP算法进一步优化

这里让我们回到朴素的那个例子:

S = "000000000000000000000000000001";
T = "000001";

试着求解一下这里子串Tnext数组,其值如下:

j123456
T[j]‘0’‘0’‘0’‘0’‘0’‘1’
next[j]012345

假设当T[5]发生失配时,按照next数组,我们应该将j指针回溯到4的位置:

***0000'*'********* 
   0000'0'1  //T[5]发生失配, '*'必然不是0
 
***0000'*'*********
    000'0'01 //回溯到4的位置, 必然失配

但是这样就会产生一个问题,当T[5]发生失配时,我们可以知道主串对应的位置的值必然不等于T[5],而T[4]==T[5],所以j指针回溯到4的位置必然失配。

事实上,观察子串"000001"子串可知,当T[5]发生失配时,回溯到除0以外的任何位置都会失配。

那么问题出在哪了呢?我们观察为什么会出现这种现象,很容易看出这里的 T[5]==T[next[5]],即出现了T[j]==T[next[j]]的现象,这其实是不应该出现的,因为如果T[j]发生了失配,且T[j]==T[next[j]],那么T[next[j]]就一定会发生失配。

所以在这里,我们就可以对next数组的生成进行优化,如果T[j]==T[next[j]],那么我们应该递归似的将next[j]赋值为next[next[j]],直到二者不相等为止。优化以后的数组,我们习惯上称为nextVal数组,其生成代码如下:

void get_nextVal(String T, int nextVal[])
{
    int i = 1, k = 0;
    nextVal[1] = 0;
    while (i < T.length) {
        if (k == 0 || T.ch[i] == T.ch[k]) {
            i++, k++;
            if (T.ch[i] != T.ch[k]) {
                nextVal[i] = k;
            } else {
                //实际计算时是从前往后遍历,这样可以保证这条语句最多只需要执行一次
                nextVal[i] = nextVal[k];
            }
        }
        else {
            k = nextVal[k];
        }
    }
}

考研的同学需要注意题目需要求解的是next还是nextVal

  • 9
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值