原创 首页 算法

从 KMP 到 AC 自动机:单串匹配、Trie 与多模匹配

LinJHS 0

Trie 树

Trie 树一般指字典树,又称单词查找树,是一种树形结构,是一种哈希树的变种。

典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。

它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。

#include <cstdio>
#include <iostream>
#include <algorithm>
#include <cmath>
#include <cstring>
using namespace std;

//(POJ 3630) http://poj.org/problem?id=3630
/*
题目描述:给定 T 组数据,判断每组数据是否有存在前缀,有-NO,无-YES
解法:Trie树,参考 "字典树统计前缀1"
*/

int T, n, tot;
int ch[100005][10];
bool bo[100005];
char s[20];

void clean()
{
    memset(ch, 0, sizeof(ch));
    memset(bo, 0, sizeof(bo));
    return;
}

bool add(char *s)
{
    int len = strlen(s);
    int u = 1;
    bool flag = false;
    for (int i = 0; i < len; i++)
    {
        int c = s[i] - '0';
        if (!ch[u][c])
            ch[u][c] = ++tot;
        else if (i == len - 1) // 如果为当前字符串末尾,但该结点还有其他数据
            flag = true;
        u = ch[u][c];
        if (bo[u])
            flag = true;
    }
    bo[u] = true;
    return flag;
}

int main()
{
    bool ans;
    scanf("%d", &T);
    while (T--)
    {
        ans = false;
        scanf("%d", &n);
        tot = 1;
        clean(); // 初始化
        for (int i = 1; i <= n; i++)
        {
            scanf("%s", s);
            if (add(s))
                ans = true;
        }
        if (ans == true)
            printf("NO\n");
        else
            printf("YES\n");
    }

    return 0;
}
#include <cstdio>
#include <cstring>
using namespace std;

/*
题目描述:给定n个字符串,并给出m个询问,回答含有询问中的字符串个数
解法:Trie树每个节点存放符合要求的字符串个数
(和 1 的很像)
*/

struct node
{
    int s, v[27]; // s存放当前节点前缀的符合要求的字符串个数,v存放叶子结点位置
    node()        // 无参构造函数,用于进行初始化
    {
        s = 0;
        memset(v, -1, sizeof(v));
    }
} t[410000]; // 定义叶子结点

int tot = 1;
char a[100];

void bt(int root)
{
    int x = root;
    int len = strlen(a + 1);
    for (int i = 1; i <= len; ++i)
    {
        int y = a[i] - 'a' + 1;
        if (t[x].v[y] == -1)
            t[x].v[y] = ++tot;
        x = t[x].v[y];
        t[x].s++;
    }
}

int find(int root)
{
    int x = root;
    int len = strlen(a + 1);
    for (int i = 1; i <= len; ++i)
    {
        int y = a[i] - 'a' + 1;
        if (t[x].v[y] == -1)
            return 0;
        x = t[x].v[y];
    }
    return t[x].s;
}

int main()
{
    int n, m;
    scanf("%d", &n);
    for (int i = 1; i <= n; ++i)
    {
        scanf("%s", a + 1);
        bt(1);
    }
    scanf("%d", &m);
    for (int i = 1; i <= m; ++i)
    {
        scanf("%s", a + 1);
        printf("%d\n", find(1));
    }
    return 0;
}
#include <cstdio>
#include <cstring>
using namespace std;

/*
题目描述:给定n个字符串,并给出m个询问,回答含有询问中的字符串个数
解法:Trie树每个节点存放符合要求的字符串个数
*/

int ch[410000][27]; // 存放 Trie 树
int tot = 1;        // 记录 Trie树的大小
int s[410000];      // 记录该结点前缀的字符串个数
char a[100];

void bt(int root)
{
    int u = root;
    int len = strlen(a + 1);
    for (int i = 1; i <= len; ++i)
    {
        int c = a[i] - 'a' + 1; // 1-a 2-b ...
        if (!ch[u][c])          // 如果没有该叶子结点
            ch[u][c] = ++tot;   // 在当前节点加入子节点的编号
        u = ch[u][c];           // 将子节点设为当前节点
        s[u]++;                 // 子节点的数值加一,用于记录该前缀的字符串个数
    }
}

int find(int root)
{
    int u = root;
    int len = strlen(a + 1);
    for (int i = 1; i <= len; ++i)
    {
        int c = a[i] - 'a' + 1;
        if (!ch[u][c]) // 不存在叶子结点
            return 0;
        u = ch[u][c]; // 将叶子结点记为当前结点
    }
    return s[u]; // 返回个数
}

int main()
{
    int n, m;
    scanf("%d", &n); // 共n个字符串
    for (int i = 1; i <= n; ++i)
    {
        scanf("%s", a + 1); // 每个字符串从a[1]开始哦
        bt(1);              // 存进去
    }
    scanf("%d", &m);
    for (int i = 1; i <= m; ++i)
    {
        scanf("%s", a + 1);
        printf("%d\n", find(1));
    }
    return 0;
}

KMP

KMP 算法是一种改进的字符串匹配算法。

KMP 算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。

具体实现就是通过一个 next() 函数实现,函数本身包含了模式串的局部匹配信息。

KMP 算法的时间复杂度 O(m+n) 。

详见代码:

#include <cstdio>
#include <iostream>
#include <iomanip>
#include <algorithm>
#include <cmath>
#include <cstring>
#include <cstdlib>
using namespace std;

//(Hdu 2087) http://acm.hdu.edu.cn/showproblem.php?pid=2087
/*
题目描述:给定两个字符串 A、B ,求 A 中有多少个 B 的子串(不可重叠)
解法:KMP(详见txt文档)
*/

char A[1007], B[1007];
int P[1007]; // P 数组就是 next 数组。
int n, m;

void pre() // 求 P 数组(即 next 数组)
{
    P[1] = 0; // 记得本题中字符串从 1 开始
    int j = 0;
    for (int i = 1; i < m; i++)
    {
        while (j > 0 && B[j + 1] != B[i + 1])
            j = P[j];
        if (B[j + 1] == B[i + 1])
            j++;
        P[i + 1] = j;
    }
}

int kmp()
{
    int ans = 0, j = 0;
    for (int i = 0; i < n; i++)
    {
        while (j > 0 && B[j + 1] != A[i + 1])
            j = P[j];
        if (B[j + 1] == A[i + 1])
            j++;
        if (j == m)
        {
            ans++;
            j = 0;
        }
    }
    return ans;
}

int main()
{
    while (cin >> A + 1)
    {
        if (A[1] == '#')
            break;
        scanf("%s", B + 1);
        m = strlen(B + 1);
        n = strlen(A + 1);
        pre();
        printf("%d\n", kmp());
    }
    return 0;
}

核心代码:

void pre() // 求 P 数组(即 next 数组)
{
    P[1] = 0; // 记得本题中字符串从 1 开始
    int j = 0;
    for (int i = 1; i < m; i++)
    {
        while (j > 0 && B[j + 1] != B[i + 1])
            j = P[j];
        if (B[j + 1] == B[i + 1])
            j++;
        P[i + 1] = j;
    }
}

一、KMP 算法

目的:找到模式串 B 在原字符串 A 中 第一次出现的位置。

n、m 分别为 A 和 B 的长度

流程:

[STEP 1]
if A[i+1]==B[j+1]
    i++,j++;

   i
A: ababcabcacbab
B: abcac
   j
[STEP 2]
if A[i+1]==B[j+1]
    i++,j++;

    i
A: ababcabcacbab
B: abcac
    j
[STEP 3]
if A[i+1]!=B[j+1]
    j=next[j];

    i
A: ababcabcacbab
B:   abcac
    j

if A[i+1]==B[j+1](如果不等于的话就只加i)
    i++,j++;

     i
A: ababcabcacbab
B:   abcac
     j
[STEP 4]
略

二、next 数组的求法

next 数组记录最长相同前缀后缀

具体来说,就是 next 数组负责记录 B 字符串(也就是待查找子串)每个位置的属性,next[i] 记录 B[0…i] 中的前缀和后缀最长有多少一样的,比如 abcdab 中,[ab]cde[ab] 最长相同前缀后缀为 ab。

例:

a	next[0]=0(注意!这是默认值 0 ,不是 1 )
ab	next[1]=0(a!=b)
abc	next[2]=0(a!=c)
abca	next[3]=1(a==a)
abcab	next[4]=2(ab==ab)
abcabc	next[5]=3(abc==abc)

next 数组的操作与 KMP 类似

例:abcaba

初始状态
   0
   i
B: abcaba
  j
if B[j+1]!=B[i+1]
    i++;

   00
    i
B: abcaba
  j
if B[j+1]!=B[i+1]
    i++;

   000
     i
B: abcaba
  j
if B[j+1]==B[i+1]
    i++,j++;

   0001
      i
B: abcaba
   j
if B[j+1]==B[i+1]
    i++,j++;

   00012
       i
B: abcaba
    j
if B[j+1]!=B[i+1]
    j=next[j];

if B[j+1]==B[i+1]
    i++,j++;

   000121
        i
B: abcaba
   j

注:部分资料来源于百度百科

AC 自动机

Aho-Corasick automaton,是著名的多模匹配算法。

一个常见的例子就是给出 n 个单词,再给出一段包含 m 个字符的文章,让你找出有多少个单词在文章里出现过。

AC 自动机就是 Trie 树上的 KMP。

详见代码:

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

/*
题目描述:给定 n 个单词,求这 n 个单词出现在文章 S 中的个数(不重复计算)。
解法:AC自动机(Trie树上的KMP)
*/

int ans, cnt;
int ch[500005][26], bo[500005]; // ch 存放 Trie 树,bo 存放单词末尾标记
int nxt[500005];                // 失配指针,有时也写作 fail[]
int que[500005];                // 也就是 BFS 的 h 数组

void make(char *s) // 将字符串 s 存入 Trie 树中。
{
    int u = 1, len = strlen(s);
    for (int i = 0; i < len; ++i)
    {
        int c = s[i] - 'a';
        if (!ch[u][c])
        {
            ch[u][c] = ++cnt;
            memset(ch[cnt], 0, sizeof(ch[cnt]));
        }
        u = ch[u][c];
    }
    bo[u]++;
    return;
}

void bfs()
{
    for (int i = 0; i <= 25; ++i)
        ch[0][i] = 1; // 0 结点的每一个字符都指向 root 结点(初始化)
    que[1] = 1;
    nxt[1] = 0;                              // 构建 nxt 数组,root 默认指向 0 结点
    for (int q1 = 1, q2 = 1; q1 <= q2; ++q1) // BFS
    {
        int u = que[q1];
        for (int i = 0; i <= 25; ++i)
        {
            if (!ch[u][i]) // 如果到叶子结点的话则通过 nxt 往回跳
                ch[u][i] = ch[nxt[u]][i];
            else
            {
                que[++q2] = ch[u][i];
                int v = nxt[u];
                // 符合条件的 v :查找父亲的前缀指针(nxt)指向的节点,通过 nxt 一直跳,
                // 直到找到该结点的有通过 i 连接的儿子
                while (v > 0 && !ch[v][i]) // 通过 nxt 一直跳直到找到符合的
                    v = nxt[v];
                nxt[ch[u][i]] = ch[v][i]; // nxt 的跳转
            }
        }
    }
}

void find(char *s)
{
    int u = 1, len = strlen(s), c, k;
    for (int i = 0; i < len; ++i)
    {
        c = s[i] - 'a';
        k = ch[u][c];
        while (k > 1) // 跳到 0 结点自动退出循环
        {
            ans += bo[k]; // 单词末尾-1 非单词末尾-0
            bo[k] = 0;    // 防止重复计算
            k = nxt[k];   // 通过 nxt 往回跳
        }
        u = ch[u][c];
    }
    return;
}

int main()
{
    int t, n;
    char s[1000010];
    scanf("%d", &t);
    while (t--)
    {
        ans = 0;
        cnt = 1;
        memset(bo, 0, sizeof(bo));
        for (int i = 0; i < 26; ++i)
            ch[0][i] = 1, ch[1][i] = 0;
        scanf("%d", &n);
        for (int i = 1; i <= n; ++i)
        {
            scanf("%s", s);
            make(s);
        }
        bfs();
        scanf("%s", s);
        find(s);
        printf("%d\n", ans);
    }
    return 0;
}

AC 自动机的关键点与 KMP 一样,需要构建出失配指针 fail[] 数组(也就是 KMP 的 next[] )。

fail[] 构建的核心代码(即下面程序中的 nxt[] )

void bfs()
{
    for (int i = 0; i <= 25; ++i)
        ch[0][i] = 1; // 0 结点的每一个字符都指向 root 结点(初始化)
    que[1] = 1;
    nxt[1] = 0;                              // 构建 nxt 数组,root 默认指向 0 结点
    for (int q1 = 1, q2 = 1; q1 <= q2; ++q1) // BFS
    {
        int u = que[q1];
        for (int i = 0; i <= 25; ++i)
        {
            if (!ch[u][i]) // 如果到叶子结点的话则通过 nxt 往回跳
                ch[u][i] = ch[nxt[u]][i];
            else
            {
                que[++q2] = ch[u][i];
                int v = nxt[u];
                // 符合条件的 v :查找父亲的前缀指针(nxt)指向的节点,通过 nxt 一直跳,
                // 直到找到该结点的有通过 i 连接的儿子
                while (v > 0 && !ch[v][i]) // 通过 nxt 一直跳直到找到符合的
                    v = nxt[v];
                nxt[ch[u][i]] = ch[v][i]; // nxt 的跳转
            }
        }
    }
}

注:部分资料来源于百度百科

分享
如果这篇文章帮到你,可以请我喝一杯茶。

微信扫码打开

请我喝杯茶

如果这篇文章帮到你,可以请我喝一杯茶。

微信收款码
在商城请我一杯