大话数据结构:串

Posted by Viletyy on 2020-06-15 10:01

(string)是由零个或多个字符组成的有限序列,又名叫字符串。

一般记为s="a1a2...an"(n>=0),其中s是串的名称,用双引号(有些地方也用作单引号)括起来的字符序列是串的值,注意引号不属于串的内容。ai(1<=1<=n)可以是字母、数字或其它字符,i就是该字符在串中的位置。串中字符数目n称为串的长度,定义中谈到“有限”是指长度n是一个有限的数值。零个字符的串称为空串(null string),它的长度为零,可以直接用两双引号““””表示,也可以用字母𝛷来表示。所谓的序列,说明串的相邻字符之间具有前驱和后继的关系。

空格串,是只包含空格的串。注意它与空串的区别,空格串是有内容有长度的,而且可以不止一个空格。

子串与主串,串中任意个数的连续字符组成的子序列称为该串的子串,相应地,包含子串的串称为主串。子串在主串中的位置就是子串的第一个字符在主串中的序号。

串的比较

给定两个串:s=”a1a2…an”, t=”b1b2…bn”,当满足以下条件之一时,s<t

  1. n<m, 且ai=bi(i=1, 2, …, n)
  2. 存在某个k <= min(m, n), 使得ai=bi(i=1, 2, …, k-1), ak<bk。

串的抽象数据类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ADT (string)
Data 
    串中元素仅由一个字符组成,相邻元素具有前驱和后继关系。
Operation
    StrAssign(T, *chars): 生成一个其值等于字符串常量chars的串T
    StrCopy(T, S): S存在,由串S复制得串T.
    ClearString(S): S存在,将串清空。
    StringEmpty(S): 若串S为空,返回true,否则返回false
    StrLength(S): 返回串S的元素个数,即串的长度。
    StrCompare(S, T): S>T,返回值>0,S=T,返回0,若S<T,返回值<0.
    Concat(T,S1,S2): T返回由S1S2联接而成的新串。
    SubString(Sub,S,pos,len): S存在,1<=pos<=StrLength(S)0<=len<=StrLength(S)-pos+1,Sub返回串S的第pos个字符起长度为len的子串。
    Index(S,T,pos): ST存在,T是非空串,1<=pos<=StrLength(S)。若主串S中存在和串T值相同的子串,则返回它在主串S中第pos个字符之后第一次出现的位置,否则返回0.
    Replace(S,T,V): STV存在,T是非空串。用V替换主串S中出现的所有与T相等的不重叠的子串。
    StrInsert(S,pos,T): ST存在,1<=pos<=StrLength(S)+1。在串S的第pos个字符之前插入串T
    StrDelete(S,pos,len): S存在,1<=pos<=StrLength(S)-len+1。从串S中删除第pos个字符起长度为len的子串。

endADT

操作Index

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*T为非空串。若主串S中第pos个字符之后存在与T相等的子串, 则返回第一个这样的子串在S中的位置,否则返回0*/
int Index(String S, String T, int pos)
{
    int n, m, i;
    String sub;
    if (pos > 0 )
    {
        n = StrLength(S);   /*得到主串S的长度*/
        m = StrLength(T);   /*得到子串T的长度*/
        i = pos;
        while (i <= n-m+1)
        {
            SubString(sub, S, i, m); /*取主串第i个位置,长度与T相等子串给sub*/
            if (StrCompare(sub, T) != 0) /*如果两串不相等*/
                ++i;
            else
                return i; /*如果两串相等,则返回i值*/
        }
    }
    return 0; /*若无子串与T相等,返回0*/
}

串的顺序存储结构

串的顺序存储结构是用一组地址连续的存储单元来存储串中的字符序列的。按照预定义的大小,为每个定义的串变量分配一个固定长度的存储区。一般是用定长数组来定义。

既然是定长数组,就存在一个与定义的最大串长度,一般可以将实际的串长度值保存在数组0下标位置,有的书中也会定义存储在数组的最后一个下标位置。但也有些编程语言不想这么干,觉得存个数字占个空间麻烦。它规定在串值后面加一个不计入串长度的结束标记字符,比如”\0”来表示串值的终结,这个时候,你要向知道此时的串长度,就需要遍历计算一下才知道了,其实这还是需要占用一个空间,何必呢。

刚才讲的串的顺序存储方式其实是有问题的,因为字符串的操作,比如两串的连接Concat,新串的插入StrInsert,以及字符串的替换Replace,都有可能使得串序列的长度超过了数组的长度MaxSize

串的链式存储结构

对于串的链式存储结构,与线性表是相似的,但由于串结构的特殊性,结构中的每个元素数据是一个字符,如果用简单的应用链表存储串值,一个结点对应一个字符,就会存在很大的空间浪费。因此,一个结点可以存放一个字符,也可以考虑存放多个字符,最后一个结点若是未被占满时,可以用“#”或者其它非串值字符补全。

朴素的模式匹配算法

找一个单词在一篇文章中的定位问题。这种子串的定位操作通常称作串的模式匹配。

从主串S=”goodgoogle”中,找到T=”google”这个子串的位置。步骤如下:

  1. 主串S第一位开始,S与T前三个字母都匹配成功,但S第四个字母是d而T的是g。第一位匹配失败。
  2. 主串S第二位开始,主串S首字母是o,要匹配的T首字母是g,匹配失败
  3. 主串S第三位开始,主串S首字母是o,要匹配的T首字母是g,匹配失败
  4. 主串S第四位开始,主串S首字母是d,要匹配的T首字母是g,匹配失败
  5. 主串S第五位开始,S与T,6个字母全匹配,匹配成功。

简单的说,就是对主串的每一个字符做为子串开头,与要匹配的字符串进行匹配。对主串做大循环,对每个字符开头做T的长度小循环,知道匹配成功或全部遍历完成为止。

用基本的数组来实现该算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*返回子串T在主串S中第pos个字符之后的位置。若不存在,则函数返回值为0*/
/*T非空,1<=pos<=StrLength(S)*/
int Index(String S, String T, int pos) 
{
    int i = pos; /*i用于主串S中当前位置下标,若pos不为1,则从pos位置开始匹配*/
    int j = 1; /*j用于子串T中当前位置下标值*/
    while (i <= S[0] && j <= T[0])
    {
        if (S[i] == T[j]) /*两字母相等则继续*/
        {
            ++i;
            ++j;
        }
        else     /*指针后退重新开始匹配*/
        {
            i = i-j+2; /*i退回到上次匹配首位的下一位*/
            j = 1; /*j退回到子串T的首位*/
        }
    }
    if (j > T[0])
        return i-T[0];
    else 
        return 0;
}

KMP模式匹配算法

在很多年前我们的科学家们,觉得像这种有多个0和1重复字符的字符串,却要挨个遍历的算法是非常糟糕的事情。于是有三位前辈,D.E.Knuth、J.H.Morris和V.R.Pratt(其中Knuth和Pratt共同研究,Morris独立研究)发表一个模式匹配算法,可以大大避免重复遍历的情况,我们把它称之为克努特-莫里斯-普拉特算法,简称KMP算法。

next数组值推导

image-20210628192152671

具体如何推导出一个串的next数组值呢。

T=”abcdex”

  1. 当j=1时,next[1]=0
  2. 当j=2时,j由1到j-1就只有字符“a”,属于其它情况next[2]=1;
  3. 当j=3时,j由1到j-1串是“ab”,显然“a”与“b”不相等,属其它情况,next[3]=1;
  4. 以后同理,所以最终此T串的next[j]为011111;

T=”abcabx”

  1. 当j=1时,next[1]=0;
  2. 当j=2时,同上例说明,next[2]=2;
  3. 当j=3时,同上,next[3]=1;
  4. 当j=4时,同上,next[4]=1;
  5. 当j=5时,此时j由1到j-1的串是”abca”,前缀字符“a”与后缀字符“a“相等,因此可推算出k的值为2,(由‘p1..pk-1’=’pj-k+1…pj-1’,得到p1=p4),因此next[5]=2;
  6. 当j=6时,j由1到j-1的串是”abcab“,由于前缀字符”ab“与后缀”ab“相等,所以next[6]=3。

我们可以根据经验得到如果钱后缀一个字符相等,k值是2,两个字符k值是3

T=”ababaaaba”

  1. 当j=1时,next[1]=0;
  2. 当j=2时,同上next[2] = 1;
  3. 当j=3时,同上next[3]=1;
  4. 当j=4时,j由1到j-1到串是“aba”,前缀字符“a”与后缀字符“a”相等,next[4]=2;
  5. 当j=5时,j由1到j-1的串是“abab”,由于前缀字符“ab”与后缀“ab”相等,所以next[5]=3;
  6. 当j=6时,j由1到j-1的串是“ababa”,由于前缀字符“aba”与后缀“aba”相等,所以next[6]=4;
  7. 当j=7时,j由1到j-1的串是“ababaa”,由于前缀字符“ab”与后缀“aa”并不相等,只有“a”相等,所以next[7]=2;
  8. 当j=8时,j由1到j-1的串是“ababaaa”,只有“a”相等,所以next[8]=2;
  9. 当j=9时,j由1到j-1到串是“ababaaab”,由于前缀字符“ab”与后缀“ab”相等,所以next[9]=3。

T=”aaaaaaaab”

  1. 当j=1时,next[1]=0;
  2. 当j=2时,同上next[2]=1;
  3. 当j=3时,j由1到j-1的串是“aa”,前缀字符“a”与后缀字符“a”相等,next[3]=2;
  4. 当j=4时,j由1到j-1的串是“aaa”,由于前缀字符“aa”与后缀“aa”相等,所以next[4] =3;
  5. ……
  6. 当j=9时,j由1到j-1的串是“aaaaaaaa”,由于前缀字符“aaaaaaa”与后缀“aaaaaaa”相等,所以next[9]=8。

KMP模式匹配算法实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*通过计算返回子串T的next数组*/
void get_next(String T, int *next)
{
  int i,j;
  i = 1;
  j = 0;
  next[1]=0;
  while (i<T[0])  /*此处T[0]表示串T的长度*/
  {
    if (j == 0 || T[i] == T[j]) /*T[i]表示后缀的单个字符,T[j]表示前缀的单个字符*/
    {
      ++i;
      ++j;
      next[i] = j;
    }
    else 
      	j = next[j]; /*若字符不相同,则j值回溯*/
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*返回子串T在主串S中第pos个字符之后的位置。若不存在,则函数返回值为0。*/
/*T非空,1<=pos<=StrLength(S)*/
int Index_KMP(String S, String T, int pos)
{
  int i = pos; /*i用于主串S当前位置下标值,若pos不为1,则从pos位置开始匹配*/
  int j = 1; /*j用于子串T中当前位置下标值*/
  int next[255]; /*定义一next数组*/
  get_next(T, next); /*对串T作分析,得到next数组*/
  while (i <= S[0] && j <=T[0]) /*若i小于S的长度且j小于T的长度时,循环继续*/
  {
    if (j==0 || S[i] == S[j]) /*两字母相等则继续,与朴素算法增加了j=0判断*/
    {
      ++i;
      ++j;
    }
    else   /*指针后退重新开始匹配*/
    {
      j = next[j]; /*j退回合适的位置,i值不变*/
    }
  }
  if (j > T[0]) 
  	return i-T[0];
  else
    return 0;
}

KMP模式匹配算法改进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/*求模式串T的next函数修正值并存入数组nextval*/
void get_nextval(String T, int *nextval)
{
  int i, j;
  i=1;
  j=0;
  nextval[1]=0;
  while (i<T[0]) /*此处T[0]表示串T的长度*/
  {
    if (j == 0 || T[i] == T[j]) /*T[i]表示后缀的单个字符,T[j]表示前缀的单个字符*/
    {
      ++i;
      ++j;
      if (T[i]!=T[j]) /*若当前字符与前缀字符不同,则当前的j为nextval在i位置的值*/
        nextval[i] = j;
      else
        nextval[i] = nextval[j]; /*如果与前缀字符相同,则将前缀字符的nextval值赋值给nextval在i位置的值*/
    }
    else
      j = nextval[j]; /*若字符不相同,则j值回溯*/
  }
}

nextval数组值推导

T=”ababaaaba”

先算出next数组的值分别为001234223,然后再分别判断

  1. 当j=1时,nextval[1]=0;
  2. 当j=2时,因第二位字符“b”的next值是1,而第一位就是“a”,它们不相等,所以nextval[2] = next[2] = 1,维持原值
  3. 当j=3时,因第三位字符”a”的next值为1,所以与第一位的“a”比较得知它们相等,所以nextval[3] = nextval[1] = 0;
  4. 当j=4时,第四位的字符”b”next值为2,所以与第二位的“b”相比较得到的结果时相等的,因此nextval[4] = nextval[2] = 1;
  5. 当j=5时,next值为3,第五个字符“a”与第三个字符“a”相等,因此nextval[5] = nextval[3] = 0;
  6. 当j=6时,next值为4,第六个字符“a”与第四个字符“b”不相等,因此nextval[6] = 4;
  7. 当j=7时,next值为2,第七个字符“a”与第二个字符“b”不相等,因此nextval[7] = 2;
  8. 当j=8时,next值为2,第八个字符“b”与第二个字符“b”相等,因此nextval[8] = nextval[2] = 1;
  9. 当j=9时,next值为3,第九个字符“a”与第三个字符“a”相等,因此nextval[9] = nextval[3] = 1。

T=”aaaaaaaab”

先算出next数组的值分别为012345678,然后再分别判断

  1. 当j=1时,nextval[1] = 0;
  2. 当j=2时,next值为1,第二个字符与第一个字符相等,所以nextval[2] = nextval[1] = 0;
  3. 同样的道理,其后都为0…….;
  4. 当j=9时,next值为8,第九个字符”b”与第八个字符”a”不相等,所以nextval[9] = 8。

总结改进过的KMP算法,它是在计算出next值的同时,如果a位字符与它next值指向的b位字符相等,则该a位的nextbal就指向b位的nextval值,如果不等,则该a位的nextval值就是它自己a位的next的值。

参考资料: