(C++)数据结构课程笔记4/9 - 串和KMP算法

187 阅读3分钟

§4 - 串

1 - 串的定义和操作

相关概念

串(String)是由零个或多个字符组成的有限序列,串中任意个连续字符组成的子序列称为该串的子串(约定空串和该串也为该串的字串),对应地,该串称为主串,子串在主串中的位置用子串的第一个字符在主串中的位置来表示

抽象数据类型和相关操作

ADT String {
	数据对象:
		D={a_i|a_i∈CharacterSet,i=1,2,...,n}
	数据关系:
		R={<a_i-1,a_i>|a_i-1,a_i∈D,i=2,...,n}
	基本操作:
		// 销毁串S
		StrDestroy(&S)
		// 清空串S
		StrClear(&S)
		
		最小操作子集
		// 把字符串常量chars赋值给串T
		StrAssign(&T,chars)
		// 由串S复制得串T
		StrCopy(&T,S)
		// 由串S1和串S2连接得串T
		Concat(&T,S1,S2)
		// 按照字典序,若串S1>/=/<串S2,则返回值>/=/<0
		StrCompare(S1,S2)
		// 求串长
		StrLength(S)
		// 用Sub返回串S第pos个字符起长度为len的子串(1≤pos≤StrLength(S)且0≤len≤StrLength(S)-pos+1)
		Substring(&Sub,S,pos,len)
		
		在最小操作子集上实现的串操作
		// 在串S第pos个字符之前插入串T(1≤pos≤StrLength(S)+1)
		StrInsert(&S,pos,T)
		// 从串S中删除第pos个字符起长度为len的子串(1≤pos≤StrLength(S)且0≤len≤StrLength(S)-pos+1)
		StrDelete(&S,pos,len)
		// 用串V替换主串S中所有与串T相等的不重叠的子串(串T非空)
		Replace(&S,T,V)
		// 在主串S第pos个字符后求子串T出现的位置(串T非空且1≤pos≤StrLength(S))
		Index(S,pos,T)
		// 若串S为空,则返回true,否则返回false
		StrEmpty(S)
} ADT String

在抽象数据类型定义的 13 种基本操作中,赋值、复制、连接、比较、求串长、求子串构成最小操作子集,即这些操作无法利用其他操作来实现,而其他操作(除 StrDestroy(&S) 和 StrClear(&S) 外)可以在这个最小操作子集上实现;例如:Index(S,pos,T) 可以利用比较、求串长、求子串的操作来实现

int Index(String S,int pos,String T) {
    if (pos>0) {
        int n=StrLength(S);
        int m=StrLength(T);
        int i=pos;
        while (i<=n-m+1) {
            String sub;
            Substring(sub,S,i,m);
            if (StrCompare(sub,T))
                ++i;
            else
                return i;
        }
    }
    return 0;
}

串的逻辑结构与线性表很相似,区别仅在于串的数据对象约束为字符集;但串的基本操作与线性表很不同:线性表通常以“单个元素”作为操作对象,而串通常以“串的整体”作为操作对象

2 - 串的表示和实现

有时,串只是输入或以供输出的常量,此时只需存储此串的串值,即字符序列;但有时,串是需要处理的变量,此时需要较好的对其的表示和对其相关操作的实现

串的顺序存储表示和相关操作的实现

存储在栈上
#define MAXSTRLEN 255 // 最大串长

typedef unsigned char String[MAXSTRLEN+1]; // 0号单元存放串长

第一个例子:实现串连接操作 Concat(&T,S1,S2)

基于串 S1 和串 S2 长度的不同情况,串 T 值的产生可能有以下两种情况:

  • 若 S1[0] + S2[0] ≤ MAXSTRLEN,则得到完整的串 T
  • 若 S1[0] + S2[0] > MAXSTRLEN,则将串 S2 的一部分截断,得到的串 T 只包含串 S2 的一个子串
// 若得到完整的串T,则返回true,否则返回false
bool Concat(String &T,String S1,String S2) {
    for (int i=1;i<=S1[0];++i)
        T[i]=S1[i];
    if (S1[0]+S2[0]<=MAXSTRLEN) {
        for (int i=1;i<=S2[0];++i)
            T[S1[0]+i]=S2[i];
        T[0]=S1[0]+S2[0];
        return true;
    }
    else {
        for (int i=S1[0]+1;i<=MAXSTRLEN;++i)
            T[i]=S2[i-S1[0]];
        T[0]=MAXSTRLEN;
        return false;
    }
}

第二个例子:实现求字串操作 Substring(&Sub,S,pos,len)

// 需满足1≤pos≤StrLength(S)且0≤len≤StrLength(S)-pos+1,若满足,则用Sub返回结果,且返回true,否则返回false
bool Substring(String &Sub,String S,int pos,int len) {
    if (pos<1||pos>S[0]||len<0||len>S[0]-pos+1)
        return false;
    for (int i=1;i<=len;++i)
        Sub[i]=S[pos+i-1];
    Sub[0]=len;
    return true;
}

小结

存储在栈上的顺序串有固定的最大串长,实现相关操作时,其基本方法为字符序列的复制,要注意截断超过最大串长的部分

存储在栈上的顺序串适合实现相关操作时不大可能产生截断(即串长远小于最大串长)的情况

如果需要完全避免产生截断,需要动态分配内存即存储在堆上的顺序串

存储在堆上
typedef struct {
    char* pstr; // 若非空串,则按串长分配内存,否则为NULL
    int len;
} String;

第一个例子:实现串连接操作 Concat(&T,S1,S2)

void Concat(String &T,String S1,String S2) {
    if (T.pstr)
        free(T.pstr);
    T.pstr=NULL;
    T.len=0;
    if (S1.len+S2.len!=0) {
    	T.pstr=(char*)malloc((S1.len+S2.len)*sizeof(char));
    	if (!T.pstr)
        	exit(1);
    	for (int i=0;i<S1.len;++i)
        	T.pstr[i]=S1.pstr[i];
    	for (int i=0;i<S2.len;++i)
        	T.pstr[S1.len+i]=S2.pstr[i];
    	T.len=S1.len+S2.len;
    }
}

第二个例子:实现求字串操作 Substring(&Sub,S,pos,len)

// 需满足1≤pos≤StrLength(S)且0≤len≤StrLength(S)-pos+1,若满足,则用Sub返回结果,且返回true,否则返回false
bool Substring(String &Sub,String S,int pos,int len) {
    if (pos<1||pos>S.len||len<0||len>S.len-pos+1)
        return false;
    if (Sub.pstr)
        free(Sub.pstr);
    Sub.pstr=NULL;
    Sub.len=0;
    if (len!=0) {
    	Sub.pstr=(char*)malloc(len*sizeof(char));
    	if (!Sub.pstr)
        	exit(1);
    	for (int i=0;i<len;++i)
        	Sub.pstr[i]=S.pstr[pos+i-1];
    	Sub.len=len;
    }
    return true;
}

第三个例子:实现在串 S 第 pos 个字符之前插入串 T 的操作 StrInsert(&S,pos,T)

// 需满足1≤pos≤StrLength(S)+1,若满足,则返回true,否则返回false
bool StrInsert(String &S,int pos,String T) {
    if (pos<1||pos>S.len+1)
        return false;
    if (T.len!=0) {
        S.pstr=(char*)realloc(S.pstr,(S.len+T.len)*sizeof(char));
        if (!S.pstr)
            exit(1);
        for (int i=S.len-1;i>=pos-1;--i)
            S.pstr[T.len+i]=S.pstr[i]; // 后移T.len个位置
        for (int i=pos-1;i<=pos+T.len-2;++i)
        	S.pstr[i]=T.pstr[i-pos+1];
        S.len+=T.len;
    }
    return true;
}

小结

存储在堆上的顺序串按串长分配内存,实现相关操作时,其基本方法为若新串非空,则先为新串分配内存、再进行字符序列的复制,要注意在此之前确保新串旧内存被释放

由于存储在堆上的顺序串既有一片连续内存,又对串长没有任何限制,因此,常用这一存储结构

串的链式存储表示

在链式串中,每个结点可以存放一个字符,也可以存放多个字符;当每个结点存放多个字符时,由于串长不一定是结点可存字符数量的整数倍,因此最后一个结点不一定全被串值占满,此时通常补上“#”或其他非串值字符

#define CHUNKSIZE 80 // 结点可存字符数量

typedef struct Chunk {
    char ch[CHUNKSIZE];
    struct Chunk* next;
} Chunk;

typedef struct {
    Chunk *head,*tail; // 头指针和尾指针
    int len; // 串长
} String;

小结:串的链式存储结构对于某些操作(如串连接等)有一定方便之处,但不如顺序存储结构灵活,链式串占用存储量大且操作复杂

3 - 串的模式匹配算法

串的模式匹配就是在主串中定位子串(或称模式串)的 Index 操作,其实际应用就是众多软件的“查找”功能,以下是以在栈上的顺序存储结构表示串时的模式匹配算法:

第一种算法:暴力算法

原理

若匹配失败(将模式串从第一个字符到最后一个字符与主串对应位置的字符进行比对,出现不同即匹配失败),则回退 i 并将模式串往后移动一格,继续匹配

实现

参考“1 - 串的定义和操作”中 Index 操作的实现

int Index(String S,int pos,String T) {
    if (pos>0) {
        int i=pos,j=1;
        while (i<=S[0]&&j<=T[0]) {
            if (S[i]==T[j]) {
                ++i;
                ++j;
            }
            else { // 将模式串往后移动一格,继续匹配
                i=i-j+2;
                j=1;
            }
        }
        if (j>T[0])
            return i-j+1;
        else
            return 0;
    }
    return 0;
}
分析

时间复杂度:O(n*m),其中 n 为主串长度,m 为模式串长度

第二种算法:先进行首尾匹配的暴力算法

原理

与普通的暴力算法相同,只是匹配时先比对模式串的第一个字符,再比对模式串的最后一个字符,最后将模式串从第二个字符到倒数第二个字符与主串对应位置的字符进行比对,出现不同即匹配失败

实现
int Index(String S,int pos,String T) {
    if (pos>0) {
        int i=pos,j=1,flag=1;
        while (i<=S[0]&&j<=T[0]-1) {
            if (flag&&(S[i]!=T[1]||S[i+T[0]-1]!=T[T[0]])) {
                ++i;
                continue;
            }
            else if (flag) {
                ++i;
                ++j;
                flag=0;
                continue;
            }
            if (S[i]==T[j]) {
                ++i;
                ++j;
            }
            else {
                i=i-j+2;
                j=1;
                flag=1;
            }
        }
        if (j==T[0])
            return i-j+1;
        else
            return 0;
    }
    return 0;
}
分析

与普通的暴力算法相比,有了一定优化,但时间复杂度仍为 O(n*m)

第三种算法:KMP 算法

原理
  • 模式串的“预处理”过程:对模式串的每个前缀求其最长公共前后缀的长度,得到前缀表(或称 Next 数组)

    • 一些规定

      1. 前缀:以串"ABCDE"为例,前缀有"A", "AB", "ABC", "ABCD"(不含自身)

      2. 后缀:以串"ABCDE"为例,后缀有"E", "DE", "CDE", "BCDE"(不含自身)

      3. 最长公共前后缀:串中长度相等的前缀和后缀所能达到的满足“前后缀相等”条件的最大长度,其对应前后缀就是最长公共前后缀

        例如:串"ABCAB"的最长公共前后缀为"AB",其长度为 2;串"ABABA"的最长公共前后缀为"ABA",其长度为 3;串"ABCDE"的最长公共前后缀的长度为 0

    • 以串"ABABCABAA"为例

      “对模式串的每个前缀求其最长公共前后缀的长度”的过程

      前缀最长公共前后缀长度
      "A"0
      "AB"0
      "ABA"1
      "ABAB"2
      "ABABC"0
      "ABABCA"1
      "ABABCAB"2
      "ABABCABA"3

      “得到前缀表”的过程*:先将上面求得的“最长公共前后缀长度”统一加 1,再在前面添 0,通常将模式串和前缀表写在一起

      模式串ABABCABAA
      前缀表011231234

      *模式串有下标从 0 起、从 1 起等等约定方式,针对不同约定方式,自行尝试得到求前缀表的方法

  • 具体原理

    • 若匹配失败,按照暴力算法应该回退 i 并将模式串往后移动一格,继续匹配,如图示 1:

      已知:1 部分 = 2 部分(平移),3 部分 = 4 部分(比对成功所得信息)

      假设:2 部分 ≠ 3 部分

      那么:1 部分 ≠ 4 部分,因此下一次匹配是无用的

    • 将模式串往后移动两格,如图示 2:

      已知:1 部分 = 2 部分(平移),3 部分 = 4 部分(比对成功所得信息)

      假设:2 部分 = 3 部分(即 2 部分和 3 部分为匹配失败处之前的模式串的最长公共前后缀)

      那么:1 部分 = 4 部分,因此下一次匹配是有效的,并且因为保证了 1 部分和 4 部分相等,i 无需回退,继续往后移动

    • KMP 算法就是暴力算法的延申:在匹配失败时,暴力算法将模式串往后移动一格,但很有可能下一次匹配是无用的;匹配失败处之前的模式串已经与主串比对过,KMP 算法利用比对成功所得信息、结合“预处理”所得前缀表,将模式串往后精确地移动若干格,避免了回退 i,降低了时间复杂度

实现
  • 主算法

    1. 假设指针 i 和 j 分别指示主串 S 和模式串 T 中正待比对的字符,i 初始化为 pos,j 初始化为 1

    2. 比对过程:

      • 若 S[i] = T[j],则 i 和 j 分别增 1
      • 若 S[i] ≠ T[j] 且 Next[j] ≠ 0,则 i 不变,j 变为 Next[j]
      • 若 S[i] ≠ T[j] 且 Next[j] = 0,则 i 增 1,j 变为 1

      重复上述过程,直至 j > T[0](成功)或 i > S[0](失败)

  • 求 Next 数组(即前缀表)算法

    求 Next 数组的过程是一个递推过程,分析如下:

    已知:Next[1] = 0

    假设:Next[j] = k,即在模式串中:"T[1]...T[k-1]" = "T[j-k+1]...T[j-1]"

    • 若 T[k] = T[j],即在模式串中:"T[1]...T[k-1]T[k]" = "T[j-k+1]...T[j-1]T[j]",则 Next[j+1] = k+1,即 Next[j+1] = Next[j]+1

    • 若 T[k] ≠ T[j],即在模式串中:"T[1]...T[k-1]T[k]" ≠ "T[j-k+1]...T[j-1]T[j]",则令 k = Next[k],若 T[k] ≠ T[j],循环此步直至 T[k] = T[j] 或 k = 0,该过程如下图:

      • 若因为 T[k] = T[j] 退出循环,则 Next[j+1] = k+1
      • 若因为 k = 0 退出循环,则 Next[j+1] = 1
void GetNext(String T,int Next[]) {
    Next[1]=0;
    int j=1,k=0;
    while (j<T[0]) {
        if (T[k]==T[j]||k==0) {
            ++j;
            ++k;
            Next[j]=k;
        }
        else
            k=Next[k];
    }
}

int Index(String S,int pos,String T,int Next[]) {
    if (pos>0) {
        int i=pos,j=1;
        while (i<=S[0]&&j<=T[0]) {
            if (S[i]==T[j]) {
                ++i;
                ++j;
            }
            else if (Next[j]!=0)
                j=Next[j];
            else {
                ++i;
                j=1;
            }
        }
        if (j>T[0])
            return i-j+1;
        else
            return 0;
    }
    return 0;
}
分析
  1. 时间复杂度:O(n+m)

  2. Next 数组(即前缀表)还可以改进,在下述的特殊情况下提高效率:

    主串: "aaabaaabaaabaaabaaab" 模式串:"aaaab" 前缀表: 01234

    当 i = 4, j = 4 时,S[i] ≠ T[j],根据 Next[j] 还需进行 i = 4, j = 3 以及 i = 4, j = 2 以及 i = 4, j = 1 三次比较。实际上,因为在模式串中第 1, 2, 3 个字符和第 4 个字符都相等,所以不需要再三比较,而可以将模式串直接向右滑动 4 个字符进行 i = 5, j = 1 时的字符比较

第四种算法:BM 算法

字符串搜索-BM - 哔哩哔哩