字符串相关的各种算法,以及用到的各种数据结构,包括前缀树后缀树等各种树

时间:2023-01-07 11:13:21

另开一文分析字符串相关的各种算法,以及用到的各种数据结构,包括前缀树后缀树等各种树。

 

先来一个汇总,

算法:

本文中提到的字符串匹配算法有:KMP, BM, Horspool, Sunday, BF, KR, AC(其中用到了Trie树)

统计字符出现个数、获取KV内容:Trie树(字典树、前缀树)

回文子串长度算法有:Manacher’s Algorithm

 

题目:

最长回文子串

最长重复子串

最长不重复子串

 

以下为正文:

 

最长连续回文串(Longest Palindromic Substring)

 

里面用到了 DP 和 KMP . 

KMP算法。其思想就如同我们上边所看到的一样:“利用已经部分匹配这个有效信息,保持i指针不回溯,通过修改j指针,让模式串尽量地移动到有效的位置。”

匹配失败时,j要移动的下一个位置k。存在着这样的性质:最前面的k个字符和j之前的最后k个字符是一样的

如果用数学公式来表示是这样的

P[0 ~ k-1] == P[j-k ~ j-1]

 

用一个数组next来保存,next[j] = k,表示当T[i] != P[j]时,j指针的下一个位置。

public static int[] getNext(String ps) {

char[] p = ps.toCharArray();

int[] next = new int[p.length];

next[
0] = -1;

int j = 0;

int k = -1;

while (j < p.length - 1) {

if (k == -1 || p[j] == p[k]) {

next[
++j] = ++k;

}
else {

k
= next[k];

}

}

return next;

}

上面的详细解释见:

http://www.cnblogs.com/yjiyjige/p/3263858.html

 

上面算法代码有一步,有一个问题,如下图,B和C已经不等,却又把B和C对比了一下。发生问题的原因在于P[j] == P[next[j]]。需要添加一个判断条件即可:

字符串相关的各种算法,以及用到的各种数据结构,包括前缀树后缀树等各种树

优化后的代码如下:

public static int[] getNext(String ps) {

char[] p = ps.toCharArray();

int[] next = new int[p.length];

next[
0] = -1;

int j = 0;

int k = -1;

while (j < p.length - 1) {

if (k == -1 || p[j] == p[k]) {

if (p[++j] == p[++k]) { // 当两个字符相等时要跳过

next[j]
= next[k];

}
else {

next[j]
= k;

}

}
else {

k
= next[k];

}

}

return next;

}

以上为KMP。

如果文本串的长度为n,模式串的长度为m,那么匹配过程的时间复杂度为O(n),算上计算next的O(m)时间,KMP的整体时间复杂度为O(m + n)。

 

 

还有一种求回文子串最大长度的方法是:Manacher’s Algorithm的算法

解释可以看上面原文。

代码如下,很巧妙:

// Transform S into T.  
// For example, S = "abba", T = "^#a#b#b#a#$".
// ^ and $ signs are sentinels appended to each end to avoid bounds checking
string preProcess(string s) {
int n = s.length();
if (n == 0) return "^$";
string ret = "^";
for (int i = 0; i < n; i++)
ret
+= "#" + s.substr(i, 1);

ret
+= "#$";
return ret;
}

string longestPalindrome(string s) {
string T = preProcess(s);
int n = T.length();
int *P = new int[n];
int C = 0, R = 0;
for (int i = 1; i < n-1; i++) {
int i_mirror = 2*C-i; // equals to i' = C - (i-C)

P[i]
= (R > i) ? min(R-i, P[i_mirror]) : 0;

// Attempt to expand palindrome centered at i
while (T[i + 1 + P[i]] == T[i - 1 - P[i]])
P[i]
++;

// If palindrome centered at i expand past R,
// adjust center based on expanded palindrome.
if (i + P[i] > R) {
C
= i;
R
= i + P[i];
}
}

// Find the maximum element in P.
int maxLen = 0;
int centerIndex = 0;
for (int i = 1; i < n-1; i++) {
if (P[i] > maxLen) {
maxLen
= P[i];
centerIndex
= i;
}
}
delete[] P;

return s.substr((centerIndex - 1 - maxLen)/2, maxLen);
}

 

 

另外字符串匹配还有一个sunday算法,很不错的。可以参考下文:

http://dsqiu.iteye.com/blog/1700312

 

其中先介绍了BM Boyer-Moore(BM)算法

字符串匹配的关键就是模式串的如何移动才是最高效的,Boyer-Moore为了做到这点定义了两个规则:坏字符规则好后缀规则,下面图解给出定义:

字符串相关的各种算法,以及用到的各种数据结构,包括前缀树后缀树等各种树

模式串,先比后面,而不是比前面。

具体对于坏字符和好后缀的移动规则,可以参考上文。

算法步骤

1.对模式子串进行预处理,获得坏字符规则和好后缀规则移动的映射表

2.利用这两个规则,取大者。

模式表生成的代码见上文。比较的逻辑如下:

        skip_stride = skip[(unsigned char)buf[b_idx]];//根据坏字符规则计算跳跃的距离  
shift_stride = shift[p_idx];//根据好后缀规则计算跳跃的距离
b_idx += (skip_stride > shift_stride) ? skip_stride : shift_stride;//取大者

不确定:最差(匹配不上)是O(n×m),平均 O(n),其中n为母串的长度,m为模式串的长度。BM算法时间复杂度最好是O(n/(m+1))

 

Horspool算法

Horspool算法相对于Boyer-Moore算法改进了坏字符规则。不看好后缀前不匹配的字符的位置,而是看母串匹配窗口最后一个字符在模式串最后一次的位置。

 

主串的长度为n,模式串的长度为m,那么Horspool算法最坏情况下的时间复杂度是O(mn),但平均情况下它的时间复杂度是O(n)。

 

Sunday算法

Sunday算法是Daniel M.Sunday于1990年提出的字符串模式匹配。其效率在匹配随机的字符串时比其他匹配算法还要更快。Sunday算法的实现可比KMP,BM的实现容易太多。(参考 Link)

在匹配失败时关注的是文本串中参加匹配的最末位字符的下一位字符。

如果该字符没有在匹配串中出现则直接跳过,即移动步长= 匹配串长度+1;否则,移动步长=匹配串中最右端的该字符到末尾的距离+1。

其实也是基于后缀数组的比较,因为关注的是字符从后往前看出现的位置。

直接上代码

#include <iostream>  
#include
<cstring>
using namespace std;

int sunday(const char* src, const char* des)
{
int len_s = strlen(src);
int len_d = strlen(des);
int next[26] = {0};
for (int j = 0; j < 26; ++j)
next[j]
= len_d + 1;
for (int j = 0; j < len_d; ++j)
next[des[j]
- 'a'] = len_d - j; //记录字符到最右段的最短距离+1
//例如:des = "abcedfb"
//next = {7 1 5 4 3 2 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8}
int pos = 0;
while (pos < (len_s - len_d + 1)) //末端对齐
{
int i = pos;
int j;
for (j = 0; j < len_d; ++j, ++i)
{
if (src[i] != des[j])
{
pos
+= next[src[pos + len_d] - 'a'];
//不等于就跳跃,跳跃是核心
break;
}
}
if ( j == len_d )
return pos;
}
return -1;
}


int main()
{
char src[]="abcdacdaahfacabcdabcdeaa";
char des[]="abcde";
cout
<<sunday(src,des)<<endl;
return 0;
}

原理与BM算法相仿(忽略好后缀规则),有点像其删减版,所以其时间复杂度和BM算法差不多,平均性能的时间复杂度也为O(n),最差情况的时间复杂度为O(n * m),但是要容易理解。

如上文所示,直接用字符作为key,每个key只记录Pattern中最右出现的位置,不随着Pattern规模增加而增加记录表的大小。(这也是为什么数据越大,BM/Sunday算法越高效的原因之一;KMP需要有跟Pattern长度匹配的next数组)。

 

Boyer-Moore、Horspool、Sunday算法小结

               Boyer-Moore、Horspool、Sunday算法都是基于后缀数组的匹配算法,区别在于移动的方式不一样(好像网上有些都没有说的Boyer-Moore算法的好后缀规则,有可能是优化方法吧,没有去深究,抱歉)。下面给出三种方法的对比:

 

0 1 2 3 4 5 6 7 8 9 ...
a b c a b d a a c b a
b c a a b            
  b c a a b          
  0 1 2 3 4 5 6 7 8 9 ...
a b c a b d a a c b a
b c a a b            
        b c a a b    
  0 1 2 3 4 5 6 7 8 9 ...
a b c a b d a a c b a
b c a a b            
            b c a a b
         
(a)   Boyer-Moore   (b)   Horspool   (c)   Sunday
 

 

这里面也提到了性能:

http://blog.csdn.net/chong232/article/details/5806968

 

与经典的KMP算法相比,BM算法在很多情况下效率更高,它有两个特点:

  一是它在遍历正文时的平均比较次数与pattern的长度反比,这一点很历害。

  二是它在处理大字母表时候性能更佳。

  从表面上看KMP算法号称O(m+n)的时间复杂度,BM最坏为O(m*n),但实际效率反而是BM更高(见上文参考3),这是由于实际情况时经常能达到BM的平均效率。所以在内核的iptables匹配时,选择bm算法的时候居多。

 

上文中对BM再做一下复习:

BM算法的核心是两个原则,这在下面的上文参考2中可以找到,具体原理它已经说的很清楚了。  
一。 坏字符原则i. 如果字符x在模式P中没有出现,那么从字符x开始的m个文本显然不可能与P匹配成功,直接全部跳过该区域即可。
ii. 如果x在模式P中出现,则以该字符进行对齐。

二。 好后缀原则
i. 如果在P中位置t处已匹配部分P
'在P中的某位置t'也出现,且位置t'的前一个字符与位置t的前一个字符不相同,则将P右移使t'对应t方才的所在的位置。ii. 如果在P中任何位置已匹配部分P'都没有再出现,则找到与P'的后缀P''相同的P的最长前缀x,向右移动P,使x对应方才P''后缀所在的位置。

个人的一点理解
需要说明的是原则一是核心,原则二是对原则一的补充优化;换句话说,仅仅利用原则一就能在正文中完成匹配,但如果结合原则二可以很好地优化某些情况,从而提高效率。

 

这篇文章也讲了性能:http://blog.csdn.net/huangkuizuiniu/article/details/51971619

KMP算法和BM算法,这两个算法在最坏情况下均具有线性的查找时间。但实际上,KMP算法并不比最简单的c库函数strstr()快多少,而BM算法虽然通常比KMP算法快,但BM算法也还不是现有字符串查找算法中最快的算法,还有一种比BM算法更快的查找算法即Sunday算法。 

回顾Sunday算法的整个过程,我们只移动了两次模式串就找到了匹配位置,缘于Sunday算法每一步的移动量都比较大,效率很高。 另外,Sunday算法,看起来用不到好后缀规则。

当然还有个BF算法,Bruce-Force,当然是最差的,复杂度O(m*n)。

 

还有个Karp-Rabin(KR)算法

Karp-Rabin算法是利用hash函数的特性进行字符串匹配的。 KR算法对模式串和循环中每一次要匹配的子串按一定的hash函数求值,如果hash值相同,才进一步比较这两个串是否真正相等。

Karp-Rabin算法适用于多个字符串匹配较好。

 

Aho-Corasick算法

Aho-Corasick算法又叫AC自动机算法,是一种多模式匹配算法。Aho-Corasick算法可以在目标串查找多个模式串,出现次数以及出现的位置。

 

下图是由多模式串{he,she,his,hers}构成的一个有限状态机:

字符串相关的各种算法,以及用到的各种数据结构,包括前缀树后缀树等各种树

 

1.该状态当字符匹配是按实线标注的状态进行转换,当所有实线路径都不满足(即下一个字符都不匹配时)按虚线状态进行转换。

2.对ushers匹配过程如下图所示:

字符串相关的各种算法,以及用到的各种数据结构,包括前缀树后缀树等各种树

 

Aho-Corasick算法和前面的算法一样都要对模式串进行预处理,预处理主要包括字典树Tire的构造,构建状态转移表(goto),失效函数(failure function),输出表(Output)。

Aho-Corasick算法包括以下3个步骤

1.构建字典树Tire

2.构建状态转移表,失效函数(failure function),输出表(Output)

3.搜索路径(进行匹配)

 

1. 构建字典树Tire

Tire是哈希树的变种,Tire树的边是模式串的字符,结点就是Tire的状态表,下图是多模式串{he,she,his,hers}的Tire树结构:

字符串相关的各种算法,以及用到的各种数据结构,包括前缀树后缀树等各种树

 

2. 构建goto函数、failure function和Output函数               

goto函数(状态转移函数):goto(pre,v)=next,完成这样的任务:在当前状态pre,输入字符v,得到下一个状态next,如果没有下个状态则next=failure。

failure function:失效函数是处理当前状态是failure时的处理。

output函数:当完成匹配是根据状态输出匹配的模式串。

 

看代码如下,其中也有Trie树的构造。对于goto函数的构造,非常精妙。利用了队列,每个节点的fail指针其实也包括了goto,构造依赖了parent节点的fail指针的构造,再加上字符[i]来进行。对于找不到的字符,因为Trie树里面直接就是Null。统一了处理。

////////////////////////////////////////////////////  
/*

程序说明:多模式串匹配的AC自动机算法
自动机算法可以参考《柔性字符串匹配》里的相应章节,讲的很清楚
*/
#include
<stdio.h>
#include
<string.h>


const int MAXQ = 500000+10;
const int MAXN = 1000000+10;
const int MAXK = 26; //自动机里字符集的大小
struct TrieNode
{
TrieNode
* fail;
TrieNode
* next[MAXK];
bool danger;
//该节点是否为某模式串的终结点
int cnt; //以该节点为终结点的模式串个数
TrieNode()
{
fail
= NULL;
memset(next, NULL, sizeof(next));
danger
= false;
cnt
= 0;
}
}
*que[MAXQ], *root;
//文本字符串
char msg[MAXN];
int N;
void TrieInsert(char *s)
{
int i = 0;
TrieNode
*ptr = root;
while(s[i])
{
int idx = s[i]-'a';
if(ptr->next[idx] == NULL)
ptr
->next[idx] = new TrieNode();
ptr
= ptr->next[idx];
i
++;
}
ptr
->danger = true; // 单词结束的地方标记此处可以构成一个单词  
ptr
->cnt++; // 这行代码用于统计每个单词前缀出现的次数(也包括统计每个单词出现的次数) 
}

void Init()
{
int i;
char s[100];
root
= new TrieNode();
scanf(
"%d", &N);
for(i = 0; i < N; i++)
{
scanf(
"%s", s);
TrieInsert(s);
}
}

void Build_AC_Automation()
{
int rear = 1, front = 0, i;
que[
0] = root;
root
->fail = NULL;
while(rear != front)
{
TrieNode
*cur = que[front++];
for(i = 0; i < 26; i++)
if(cur->next[i] != NULL)
{
if(cur == root)
cur
->next[i]->fail = root;
else
{
TrieNode
*ptr = cur->fail;
while(ptr != NULL)
{
if(ptr->next[i] != NULL)
{
cur
->next[i]->fail = ptr->next[i];
if(ptr->next[i]->danger == true)
cur
->next[i]->danger = true;
break;
}
ptr
= ptr->fail;
}
if(ptr == NULL) cur->next[i]->fail = root;
}
que[rear
++] = cur->next[i];
}
}
}
int AC_Search()
{
int i = 0, ans = 0;
TrieNode
*ptr = root;
while(msg[i])
{
int idx = msg[i]-'a';
while(ptr->next[idx] == NULL && ptr != root) ptr = ptr->fail;
ptr
= ptr->next[idx];
if(ptr == NULL) ptr = root;
TrieNode
*tmp = ptr;
while(tmp != NULL && tmp->cnt != -1)
{
ans
+= tmp->cnt;
tmp
->cnt = -1;
tmp
= tmp->fail;
}
i
++;
}
return ans;
}
int main()
{
int T;
scanf(
"%d", &T);
while(T--)
{
Init();
Build_AC_Automation();
//文本
scanf("%s", msg);
printf(
"%d\n", AC_Search());
}
return 0;
}

小结

       这篇文章把字符串匹配的六个算法——BM、Horspool、Sunday、KMP、KR、AC算法,从原理到步骤,再从流程到实现都做了讲解了,能有了一定的认识和理解,基本可以掌握这些算法。

 

Trie树也叫前缀树。除了上面的代码构造,也可以参考这篇文章,关于其应用方法:

http://blog.csdn.net/hackbuteer1/article/details/7964147

 

直接上代码,其中还用到了Trans,作为字典value的记录,用于结果返回输出的:

下面的应用是获取字符串出现次数

#include<iostream>
#include
<cstring>
using namespace std;

typedef struct Trie_node
{
int count; // 统计单词前缀出现的次数
struct Trie_node* next[26]; // 指向各个子树的指针
bool exist; // 标记该结点处是否构成单词
}TrieNode , *Trie;

TrieNode
* createTrieNode()
{
TrieNode
* node = (TrieNode *)malloc(sizeof(TrieNode));
node
->count = 0;
node
->exist = false;
memset(node
->next , 0 , sizeof(node->next)); // 初始化为空指针
return node;
}

void Trie_insert(Trie root, char* word)
{
Trie node
= root;
char *p = word;
int id;
while( *p )
{
id
= *p - 'a';
if(node->next[id] == NULL)
{
node
->next[id] = createTrieNode();
}
node
= node->next[id]; // 每插入一步,相当于有一个新串经过,指针向下移动
++p;
node
->count += 1; // 这行代码用于统计每个单词前缀出现的次数(也包括统计每个单词出现的次数)
}
node
->exist = true; // 单词结束的地方标记此处可以构成一个单词
}

int Trie_search(Trie root, char* word)
{
Trie node
= root;
char *p = word;
int id;
while( *p )
{
id
= *p - 'a';
node
= node->next[id];
++p;
if(node == NULL)
return 0;
}
return node->count;
}

int main(void)
{
Trie root
= createTrieNode(); // 初始化字典树的根节点
char str[12] ;
bool flag
= false;
while(gets(str))
{
if(flag)
printf(
"%d\n",Trie_search(root , str));
else
{
if(strlen(str) != 0)
{
Trie_insert(root , str);
}
else
flag
= true;
}
}

return 0;
}

 

下面的应用是字典树的KV(翻译)查找

#include<iostream>
#include
<cstring>
using namespace std;

typedef struct Trie_node
{
int count; // 统计单词前缀出现的次数
struct Trie_node* next[26]; // 指向各个子树的指针
bool exist; // 标记该结点处是否构成单词
char trans[11]; // 翻译
}TrieNode , *Trie;

TrieNode
* createTrieNode()
{
TrieNode
* node = (TrieNode *)malloc(sizeof(TrieNode));
node
->count = 0;
node
->exist = false;
memset(node
->next , 0 , sizeof(node->next)); // 初始化为空指针
return node;
}

void Trie_insert(Trie root, char* word , char* trans)
{
Trie node
= root;
char *p = word;
int id;
while( *p )
{
id
= *p - 'a';
if(node->next[id] == NULL)
{
node
->next[id] = createTrieNode();
}
node
= node->next[id]; // 每插入一步,相当于有一个新串经过,指针向下移动
++p;
node
->count += 1; // 这行代码用于统计每个单词前缀出现的次数(也包括统计每个单词出现的次数)
}
node
->exist = true; // 单词结束的地方标记此处可以构成一个单词
strcpy(node->trans , trans);
}

char* Trie_search(Trie root, char* word)
{
Trie node
= root;
char *p = word;
int id;
while( *p )
{
id
= *p - 'a';
node
= node->next[id];
++p;
if(node == NULL)
return 0;
}
if(node->exist) // 查找成功
return node->trans;
else // 查找失败
return NULL;
}

int main(void)
{
Trie root
= createTrieNode(); // 初始化字典树的根节点
char str1[3003] , str2[3003] , str[3003] , *p;
int i , k;

scanf(
"%s",str1);
while(scanf("%s",str1) && strcmp(str1 , "END") != 0)
{
scanf(
"%s",str2);
Trie_insert(root , str2 , str1);
}

getchar();
gets(str1);
k
= 0;
while(gets(str1))
{
if(strcmp(str1 , "END") == 0)
break;
for(i = 0 ; str1[i] != '\0' ; ++i)
{
if(str1[i] >= 'a' && str1[i] <= 'z')
{
str[k
++] = str1[i];
}
else
{
str[k]
= '\0';
p
= Trie_search(root , str);
if(p)
printf(
"%s", p);
else
printf(
"%s", str);
k
= 0;
printf(
"%c", str1[i]);
}
}
printf(
"\n");
}

return 0;
}

从上可以看出,Trie树(字典树、前缀树),可以用于统计出现次数,获取字典key-value内容等。

 

 (完)