数据结构:串 及串的模式匹配(KMP)

时间:2024-09-29 15:05:32

串的定义

串(String)是由零个或多个字符组成的有限序列,又名叫字符串。在计算机科学中,串是一种重要的数据结构,用于表示文本数据。串中的元素称为字符,字符可以是字母、数字或其他符号,这些字符可以是任意字符集中的成员。串是许多编程语言中的基本数据类型,用于处理文本数据。

串的基本操作

无论是基于数组还是基于链表的实现,串都支持以下基本操作:

  • StrAssign(&S, chars):赋值操作,将串chars的值赋给串S。
  • StrLength(S):求串的长度,返回串S的元素个数。
  • StrCopy(&T, S):串复制,将串S复制给串T。
  • StrConcat(&T, S1, S2):串连接,将串S1和串S2连接成新串T。
  • StrCompare(S1, S2):串比较,比较串S1和串S2,若S1=S2,则返回0;若S1<S2,则返回负数;若S1>S2,则返回正数。
  • SubString(&Sub, S, pos, len):子串获取,用Sub返回串S中第pos个字符起长度为len的子串。
  • StrInsert(&S, pos, T):串插入,将串T插入到串S的第pos个位置。
  • StrDelete(&S, pos, len):串删除,从串S中删除第pos个字符起长度为len的子串。

串的实现

串的实现方式主要有两种:基于数组(或称为顺序存储结构)和基于链表(或称为链式存储结构)。

1. 基于数组的实现

在基于数组的实现中,串的存储结构是用一个字符数组以及记录串的实际长度的变量来表示。这种实现方式可以方便地通过下标访问串中的任意字符,时间复杂度为O(1)。但是,当需要插入或删除串中的字符时,可能需要移动大量的字符,时间复杂度较高。

#define MAXLEN 255
typedef struct
{
    char ch[MAXLEN];
    int length;
}SString;

//动态分配 克服字符串超过最大长度的"截断"
typedef struct 
{
    char *ch;
    int length;
}HString;

2. 基于链表的实现

在基于链表的实现中,串的每个字符都存储在链表的一个节点中。这种实现方式在插入和删除字符时非常高效,因为只需要修改指针,时间复杂度为O(1)。但是,访问串中的任意字符时,可能需要从头节点遍历链表,时间复杂度为O(n)。

串的模式匹配:

int index(SString S,SString T){
    int i=1;
    int j=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;
    else
    return 0;
}

图解(举例):

KMP算法

(关于KMp算法中如何求next数组以及 算法中的指针变换在我写的另一篇博客中) 

几个概念:

1、字符串的前缀、后缀和部分匹配值
要了解子串的结构,首先要弄清楚几个概念:前缀、后缀和部分匹配值。前缀指除最后一个字符以外,字符串的所有头部子串;后缀指除第一个字符外,字符串的所有尾部子串;部分匹配值则为字符串的前缀和后缀的最长相等前后缀长度。下面以' ababa '为例进行说明:
' a '的前缀和后缀都为空集,最长相等前后缀长度为0。
' ab '的前缀为{ a },后缀为( b },{ a } ∩ { b }=0,最长相等前后缀长度为0。

' aba '的前缀为{ a  ab ),后缀为( a , ba },( a , ab } ∩ { a , ba }={ a ),最长相等前后缀长度为1。
' abab '的前缀( a , ab , aba } ∩ 后缀( b , ab , bab }={ ab ),最长相等前后缀长度为2。
' ababa '的前缀{ a , ab , aba , abab ) ∩ 后缀( a , ba , aba , baba }={ a , aba ),公共元素有两个,最长相等前后缀长度为3。
因此,字符串' ababa 的部分匹配值(PM表为00123。

右移位数=已匹配的字符数-对应的部分匹配值
Move=(j-1)-PM[j-i]

next数组就是将PM表的值全部向右移1位,即字符串' ababa 的next值为-1 0 0 1 2(这样哪个元素匹配失败就直接找next数组里自己对应的值,不用去找匹配失败元素的前一个元素的PM值。

Move=(j-1)-next[j]
//j回退到
j=j-Move=j-((j-1)-next[j])=next[j]+1

为了使公式更加简洁/计算方便可以将next数组整体+1,即 即字符串' ababa 的next值为0 1 1 2 3

实现过程:

为了避免朴素算法的低效,几位计算机科学家辈D.E.Knuth、J.H.MorTis和V.R.Pratt发表了一个模式匹配算法,可以一定程度上避免重复遍历的问题,该算法就是大名鼎鼎的KMP算法。

从暴力匹配到KMP
要理解KMP算法的原理,我们首先需要批判一下朴素算法中有哪些做的不好的地方。

我们将之前的朴素算法的匹配过程合起来看如下面的图所示:

我们可以发现,在2、3、4步骤中,主串的首字符与子串的首字符均不等。我们仔细观察可以发现,对于子串"google"来说,首字母"g"与后面的两个字母是不相等的,而在步骤1中主串与子串的前三个字符相等,这就意味着子串的首字符"g"不可能与主串的二、三位相等,故上图中步骤2、3完全是多余的。

也就是说,如果我们知道子串的首字符"g"与后面两个字符不相等(此处需要进行一些预处理,这是后面的重点),我们就不需要再进行2、3步操作,只保留1、4、5步即可。


从上面这幅图我们可以惊喜地发现,在使用朴素算法进行匹配时,主串指针需要进行一些回退;而在知道了子串的一些性质后,主串指针不需要再进行回退,只需一直往前走就行,这样就节省了一些时间开销。

你或许还会有疑问,主串指针是不需要回退了,但为啥我的子串指针还要一直回退到开头呢,有没有办法避免这样呢?

当然是有的,我们再举一个例子,假设主串S=“abcababca”,子串T=“abcabx”,那我们采用朴素算法在进行模式匹配的步骤如下所示:


由之前一个例子的分析可知由于T串的首字母"a"与之后的字母"b"、"c"不等,故步骤2、3均是可以省略的。

又因为T的首位"a"与T第四位的"a"相等,第二位的"b"与第五位的"b"相等。而在步骤1中,第四位的"a"与第五位的"b"已经与主串s中的相应位置比较过了是相等的。因此可以断定, T的首字符“a”、第二位的字符“b”与S的第四位字符和第五位字符也不需要比较了,肯定也是相等的。所以步骤4、5这两个比较得出字符相等的步骤也可以省略。

所以在这个例子中我们模式匹配的流程为:


在这个例子中我们发现,在得知了子串中有相等的前后缀之后,匹配失败时子串指针不需要回退到开头处,而是回退到相等前缀的后一个位置。

补充一下前后缀的概念:前缀指的是不包含尾字符的所有子串;后缀指的是不包含首字符的所有子串

对比两个例子中朴素做法与改进做法的差别,我们可以发现这两种方法的相同点为都是依靠两个指针的移动对比来实现字符串模式匹配,不同之处在于在改进做法中主串的指针i不需要进行回溯,子串的指针j会根据子串中的最长相等前后缀来进行回溯,这样就省去了一些不需要的回溯步骤,从而降低了时间复杂度。

注意事项

  • 在使用串时,应注意串的边界条件,如索引越界等问题。
  • 根据不同的应用场景,选择合适的串实现方式,以达到最优的性能。
  • 在某些编程语言中,字符串的实现可能更加复杂,如Python中的字符串是不可变的,这意呀着每次修改字符串都会生成一个新的字符串对象。