形式语言与自动机

形式语言与自动机

乔姆斯基(C.Chomsky)最初从产生语言的角度研究语言;他将语言L形式地定义为由一个字母表中的字母∑组成的一些串的集合 \forall L, \exists \Sigma, L\subseteq \Sigma^* :,可以在字母表上按照一定的规则定义一个方法,该文法产生的所有句子组成的集合就是该文法产生的语言。

形式语言是模拟这些语言的一类数学语言,它采用数学符号,按照严格的语法规则构成。从广义上说,形式语言是符号取自某个字母表的字符串的集合。如同自然语言具有语法规则一样,形式语言也是由形式文法生成的;一个形式文法是一个有穷变元集合,这些变元也称为非终结符或语法范畴。每个变元都可以用来定义语言,定义方式可以是递归的,即通过一些称为终结符的原始符号,加上变元自身,递归地加以定义。

Part 1 形式语言与文法

(一)文法与自动机

文法G是一个四元组:G=(V, T, P, S)

  • V:变量/非终极符号的非空有穷集;
  • T:终极符的非空有穷集,V∩T=Ø;
  • P:产生式的非空有穷集合;
  • S:开始符号,S是V中的元素。

P是生成式的有穷集合,生成式的基本形式是α→β,这里α和β都是(V∪T)中的元素,即它们都是由变元和终结符组成的符号串,但要求α至少含有一个非终结符;在形式文法定义中生成式集合P是至关重要的,它决定了语言是如何构造出来的。

由形式文法G=(V,T,P,S)产生的形式语言记为L(G);L(G)中的字符串ω都具有如下特点:该字符串仅由终结符组成,即ω∈T;该字符串能由开始符号S派生出来。

根据P中生成式α→β的特点,可以将形式文法及其产生的形式语言分类,构成所谓的形式语言谱系,对应四种自动机:

0型文法,又称为短语文法。这种文法对生成式α→β不作特殊限制,α和β可以是任意的文法符号串,当然α不能是空字符串;0型文法是形式语言谱系中最大的文法类;由0型文法产生的形式语言恰是图灵机所识别的语言类,即递归可枚举语言。

1型文法,又称为上下文有关文法;这种文法要求生成式α→β满足|α|≤|β|,即β要至少和α一样长;由1型文法产生的语言称为1型语言或上下文有关语言;1型语言恰是非确定型线性有界自动机所识别的语言类。

2型文法,又称为上下文无关文法;这种文法要求生成式α→β中的α必须是变元;由2型文法产生的语言称为2型语言或上下文无关语言;2型语言恰是由下推自动机所识别的语言类。

3型文法,又称为正则文法;这种文法分为两种类型:生成式的形式必须是A→ωB或A→ω,其中A,B都是变元,ω是终结符串(可以是空串),这种特殊的正则文法称为右线性文法;第二类正则文法称为左线性文法,它要求生成式必须是A→Bω,或A→ω的形式。由正则文法生成的语言称为正则语言,它恰是有穷自动机所识别的语言类。

上述定义的4种语言类具有依次包含关系,即对于i=0,1,2,在不考虑空字符串时,i型语言都真包含i+1型语言。

最简单的来说,自动机就是具有离散输入输出的数学模型,接受一定的输入,执行一定的动作,产生一定的结果。使用状态迁移描述整个工作过程。状态是一个标识,能区分自动机在不同时刻的状况。有限状态系统具有任意有限数目的内部“状态”;自动机的本质是,根据状态、输入和规则决定下一个状态,即状态 + 输入(激励)+ 规则→状态迁移。这样,可能的状态、运行的规则都是事先确定的。一旦开始运行,就按照事先确定的规则工作,因此叫“自动机”。

根据结构不同,自动机又可分为有限自动机、下推自动机、图灵机等:

有限自动机可以认为是由一个带有读头的有限控制器和一条写有字符的输入带组成;

下推自动机可以看作是由一条输入带、一个有限控制器和一个下推栈组成;

基本图灵机由一个具有读写头的有限控制器和一条无限带组成,参见

Calvin Shi:非数学,非计算机专业的我,对编程什么的一窍不通的我要怎么理解图灵机的概念?

使用自动机,可以形式化的描述现实世界中的一些问题。形式语言和自动机是密切相关的:形式语言——字符串;自动机——字符串的识别系统。要理解一个语句,需建立起一个和该简单句相对应的机内表达。而要建立机内表达,需要做以下两方面的工作:

(1)理解语句中的每一个词。

(2)以这些词为基础组成一个可以表达整个语句意义的结构。

由于这个解释过程涉及到许多事情,因而常常将这项工作分成以下三个部分来进行:

  • (1)语法分析。将单词之间的线性次序变换成一个显示单词如何与其他单词相关联的结构。语法分析确定语句是否合乎语法,因为一个不合语法的语句就更难理解。
  • (2)语义分析。各种意义被赋予由语法分析程序所建立的结构,即在语法结构和任务领域内对象之间进行映射变换。
  • (3)语用分析。为确定真正含义,对表达的结构重新加以解释。

(二)语义和语用

要进行语法分析,必须首先给出该语言的文法规则,以便为语法分析提供一个准则和依据。对于自然语言人们已提出了许多种文法,例如,乔姆斯基(Chomsky)提出的上下文无关文法就是一种常用的文法。一个语言的文法一般用一组文法规则(称为产生式或重写规则)以及非终结符与终结符来定义和描述。例如,下面就是一个英语子集的上下文无关文法:

<sentence>∷=<noun-phrase><verbphrase>
<noun-phrase>∷=<determiner><noun>
<verb-phrase>∷=<verb><nounphrase>|<verb>
<determiner>∷=the|a|an
<noun>∷=man|student|apple|computer
<verb>∷=eats|operats

有了文法规则,对于一个给定的句子,就可以进行语法分析,即根据文法规则来判断其是否合乎语法。可以看出,上面的文法规则实际是非终结符的分解、变换规则。分解、变换从起始符开始,到终结符结束。所以,全体文法规则就构成一棵如下图所示的与或树,我们称其为文法树。



语义分析就是要识别一个语句所表达的意思。语义分析的方法很多,如运用格文法、语义文法等。这里仅介绍其中的语义文法方法。语义文法是进行语义分析的一种简单方法。所谓语义文法,就是在传统的短语结构文法的基础上,将名词短语、动词短语等不含语义信息的纯语法类别,用所讨论领域的专门类别来代替。语义分析树:



例如,下面就是一个语义文法的例子:

S→PRESENT the
ATTRIBUTE of SHIP
PRESENT→what is|can
you tell me
ATTRIBUTE→length|class
SHIP→the
SHIPNAME|CLASSNAME class ship
SHIPNAME→Kunming|Liaoning
CLASSNAME→destroyer|aircraft
carrier

简单句的理解不涉及句与句之间的关系,它的理解过程首先是赋单词以意义,然后再给整个语句赋予一种结构。而一组语句的理解,无论它是一个文章选段,还是对话节录,句子之间都有相互关系。所以,复合句的理解,就不仅要分析各个简单句,而且要找出句子之间的关系。这些关系的发现,对于理解起着十分重要的作用。

句子之间关系包括以下几种:

(1)相同的事物,例如:“小华有个计算器,小刘想用它。”单词“它”和“计算器”指的是同一物体。

(2)事物的一部分,例如:“小林穿上她刚买的大衣,发现掉了一个扣子。”“扣子”指的是“刚买的大衣”的一部分。

(3)行动的一部分,例如:“王宏去北京出差,他乘早班飞机动身。”

乘飞机应看成是出差的一部分。

(4)与行动有关的事物,例如:“李明准备骑车去上学,但他骑上车子时,发现车胎没气了。”李明的自行车应理解为是与他骑车去上学这一行动有关的事物。

(5)因果关系,例如:“今天下雨,所以不能上早操。”下雨应理解为是不能上操的原因。

(6)计划次序,例如:“小张准备结婚,他决定再找一份工作干。”

Part 2 语言学与计算机科学:从字符串匹配到元胞自动机

(一)KMP算法

字符串匹配最基础的算法是BF(brute force)算法,从主串的第1个字符起和模式串的第一个字符比较,若相等,则继续逐个比较后续字符,否则从主串的第2字符起重新和模式串的字符比较。依次类推,直到模式串t中的每个字符依次和主串s中的一个连续的字符序列相等,则匹配成功。否则匹配不成功。如果文本串的长度为n,模式串的长度为m,BF算法最好情况下的时间复杂度是O(n+m),最坏情况下的时间复杂度是O(nm)。

KMP算法于1977年由Knuth(TAOCP作者), Morris和Pratt提出,将字符串匹配的时间复杂度降至O(m+n),其基本思路就是:假设现在文本串s匹配到 i 位置,模式串t匹配到 j 位置,如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符;如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]。此举意味着失配时,模式串t相对于文本串s向右移动了j - next [j] 位。换言之,当匹配失败时,模式串向右移动的位数=失配字符所在位置 - 失配字符对应的next 值,即移动的实际位数为j,且此值大于等于1。

next 数组各值的含义:代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如如果next [j] = k,代表j 之前的字符串中有最大长度为k 的相同前缀后缀。这也意味着在某个字符失配时,该字符对应的next 值会告诉你下一步匹配中,模式串应该跳到哪个位置(跳到next [j] 的位置)。如果next [j] 等于0或-1,则跳到模式串的开头字符,若next [j] = k 且 k > 0,代表下次匹配跳到j 之前的某个字符,而不是跳到开头,且具体跳过了k 个字符。

KMP的next 数组相当于告诉我们:当模式串中的某个字符跟文本串中的某个字符匹配失配时,模式串下一步应该跳到哪个位置。如模式串中在j 处的字符跟文本串在i 处的字符匹配失配时,下一步用next [j] 处的字符继续跟文本串i 处的字符匹配,相当于模式串向右移动 j - next[j] 位。

这样,当p[j] != s[i] 时,下次匹配必然是p[ next [j]] 跟s[i]匹配,如果p[j] = p[ next[j] ],必然导致后一步匹配失败(因为p[j]已经跟s[i]失配,然后你还用跟p[j]等同的值p[next[j]]去跟s[i]匹配,很显然,必然失配),所以不能允许p[j] = p[ next[j ]]。因此,如果出现了p[j] = p[ next[j] ],则需要再次递归,即令next[j] = next[ next[j] ],只要出现了p[next[j]] = p[j]的情况,则把next[j]的值再次递归。

C++代码如下:

void getNext(string s, int *next){
	int n = s.length(), i = 0, j = -1;
	next[0] = -1;
	while (i < n - 1)
	{
		if (j == -1 || s[i] == s[j])
			next[++i] = ++j;
		else
			j = next[j];
	}
}

int kmp(string s, string t){
	int n1 = s.length(), n2 = t.length(), i = 0, j = 0;
	int *next = new int[n2];
	getNext(t, next);
	while (i<n1&&j<n2){
		if (j == -1 || s[i] == t[j]){
			i++;
			j++;
		}
		else{
			j = next[j];
		}
	}
	if (j==n2){
		return i - j + 1;
	}
	else{
		return 0;
	}
}

(二) TRIE树与AC自动机

Trie树又名字典树,从字面意思即可理解,这种树的结构像英文字典一样,相邻的单词一般前缀相同,之所以时间复杂度低,是因为其采用了以空间换取时间的策略。将每个字符串插入到trie树中,到达特定的结尾节点时,在这个节点上进行标记,通过前序遍历此树,即可得到字符串从小到大的顺序。

class Node{
public:
    int count; //记录该处字符串个数
    Node* char_arr[NUM];  //分支
    char* current_str;   //记录到达此处的路径上的所有字母组成的字符串
    Node();//构造函数略,下同
};

class Trie{
public:
    Node* root;     
    Trie();

    void insert(char* str){
        int i = 0;
        Node* parent = root;
        while(str[i] != '\0'){         
            if(parent->char_arr[str[i] - 'a'] == NULL){
                parent->char_arr[str[i] - 'a'] = new Node();
                strcat(parent->char_arr[str[i] - 'a']->current_str, parent->current_str);
                char str_tmp[2];
                str_tmp[0] = str[i];
                str_tmp[1] = '\0';
                strcat(parent->char_arr[str[i] - 'a']->current_str, str_tmp);
                parent = parent->char_arr[str[i] - 'a'];
            }
            else{
                parent = parent->char_arr[str[i] - 'a'];
            }
            i++;
        }
        parent->count++;
    }

    void output(Node* &node, char** str, int& count){
        if(node != NULL){
            if(node->count != 0){             
                for(int i = 0; i < node->count; i++){
                    str[count++] = node->current_str;
                }
                for(int i = 0; i < NUM; i++){
                    output(node->char_arr[i], str, count);
                }
            }
        }
    }
};

AC(Aho-Corasick)自动机于1975年由贝尔实验室的A.Aho(《龙书》作者)和M.Corasick提出,可以看成是kmp在多字符串情况下扩展形式,可以用来处理多模式串匹配。只要为这些模式串建立一个trie树,然后再为每个节点建立一个失败指针,也就是类似与kmp的next函数,让我们知道如果匹配失败,可以再从哪个位置重新开始匹配。

在kmp构造next数组时,我们是从前往后构造,即先构造1...i-1,然后再利用它们计算next[i],这里也是类似。不过这个先后,是通过bfs的顺序来体现的。AC自动机的fail指针具有同样的功能,也就是说当我们的模式串在Trie上进行匹配时,如果与当前节点的关键字不能继续匹配的时候,就应该去当前节点的失败指针所指向的节点继续进行匹配。而从根到这个失败指针指向的节点组成的字符串,实际上就是跟当前节点的后缀的匹配最长的字符串。

如同KMP中模式串的自我匹配一样,从根节点开始,对于每个结点:设该结点上的字符为k,沿着其父亲结点的fail指针走,直到到达根节点或者当前失败指针结点也存在字符为k的孩子结点,那么前一种情况当然是把失败指针设为根节点,而后一种情况则设为当前失败指针结点的字符为k的孩子结点。接下来要做的就是进行文本匹配:首先,Trie树中有一个指针p1指向root,而文本串中有一个指针p2指向串头。下面的操作和KMP很类似:如果设k为p2指向的字母 ,而在Trie中p1指向的节点存在字符为k的儿子,那么p2++,p1则改为指向那个字符为k的儿子,否则p1顺着当前节点的失败指针向上找,直到p1存在一个字符为k的儿子,或者p1指向根结点。如果p1路过一个标记为模式串终点的结点,那么以这个点为终点的的模式串就已经匹配过了.或者如果p1所在的点可以顺着失败指针走到一个模式串的终结结点,那么以那个结点结尾的模式串也已经匹配过了。如下所示:

struct Trie { 
    int next[4]; 
    int fail, flag; 
    void init () 
    { 
        memset(next, 0, sizeof(next)); 
        fail = -1; 
        flag = 0; 
    } 
} trie[MAXM];

void make_ac_automation() { 
    int h = 0, t = 0; 
    q[t++] = 0; 
    while(h < t) 
    { 
        int u = q[h++], v, j; 
        for(int i = 0; i < 4; i++) 
        { 
            if(trie[u].next[i]) 
            { 
                v = trie[u].next[i]; 
                j = trie[u].fail; 
                while(j != -1 && !trie[j].next[i]) j = trie[j].fail; 
                if(j == -1) trie[v].fail = 0; 
                else 
                { 
                    trie[v].fail = trie[j].next[i]; 
                    trie[v].flag |= trie[trie[v].fail].flag; 
                } 
                q[t++] = v; 
            } 
            else 
            { 
                j = trie[u].fail; 
                while(j != -1 && !trie[j].next[i]) j = trie[j].fail; 
                if(j == -1) trie[u].next[i] = 0; 
                else trie[u].next[i] = trie[j].next[i]; 
            } 
        } 
    } 
} 

(三)TRIE图

Trie图实际上是一个确定性自动机,比AC自动机增加了确定性这个属性,对于AC自动机来说,当碰到一个不匹配的节点后可能要进行好几次回溯才能进行下一次匹配。但是对于trie图来说,可以每一步进行一次匹配,每碰到一个输入字符都有一个确定的状态节点。trie图的后缀节点跟ac自动机的后缀指针基本一致,区别在于trie图的根添加了了所有字符集的边。另外trie图还会为每个节点补上所有字符集中的字符的边,而这个补边的过程实际上也是一个求节点的后缀节点的过程,不过这些节点都是虚的,我们不把它们加到图中,而是找到它们的等价节点即它们的后缀节点,从而让这些边指向后缀节点就可以了。

Trie图主要利用两个概念实现这种目的。一个是后缀节点,也就是每个节点的路径字符串去掉第一个字符后的字符串对应的节点。计算这个节点的方法,是通过它父亲节点的后缀节点,很明显它父亲的后缀节点与它的后缀节点的区别就是还少一个尾字符,设为c。所以节点的父节点的指针的c孩子就是该节点的后缀节点。但是因为有时候它父亲不一定有c孩子,所以还得找一个与父亲的c孩子等价的节点。于是就碰到一个寻找等价节点的问题。

而Trie图还有一个补边的操作,不存在的那个字符对应的边指向的节点实际上可以看成一个虚节点,我们要找一个现有的并且与它等价的节点,将这个边指向它。这样也实际上是要寻找等价节点。

我们看怎么找到一个节点的等价节点,我们所谓的等价是指它们的危险性一致。那我们再看一个节点是危险节点的充要条件是:它的路径字符串本身就是一个危险单词,或者它的路径字符串的后缀对应的节点是一个危险节点。因此我们可以看到,如果这个节点对应的路径字符串本身不是一个危险单词,那它就与它的后缀节点是等价的。所以我们补边的时候,实际指向的是节点的后缀节点就可以了。Trie图实际上对trie树进行了改进,添加了额外的信息。使得可以利用它方便的解决多模式串的匹配问题。匹配函数如下

bool IsMatch(string str)  {  
    int len = str.length();  
    Node* tmp = root;  
    for (int i = 0; i < len; i++) {  
        while (tmp != root && tmp->kids[str[i] - 'a'] == NULL)  
            tmp = tmp->fail;  
        if (tmp->kids[str[i] - 'a'] != NULL)  
            tmp = tmp->kids[str[i] - 'a'];  
        else  
            tmp = root;  
        if (tmp->isEnd == true)  
            return true;  
    }  
    return false;  
}  

(四)DFA与NFA

一个确定的有限自动机(DFA)M可以定义为一个五元组,M=(K,∑,F,S,Z),其中:

(1) K是一个有穷非空集,集合中的每个元素称为一个状态;

(2) ∑是一个有穷字母表,∑中的每个元素称为一个输入符号;

(3) F是一个从K×∑→K的单值转换函数,即F(R,a)=Q,(R,Q∈K)表示当前状态为R,如果输入字符a,则转到状态Q,状态Q称为状态R的后继状态;

(4) S∈K,是惟一的初态;

(5) Z是K的子集,是一个终态集。

由定义可见,确定有限自动机只有惟一的一个初态,但可以有多个终态,每个状态对字母表中的任一输入符号,最多只有一个后继状态。

对于DFA M,若存在一条从某个初态结点到某一个终态结点的通路,则称这条通路上的所有弧的标记符连接形成的字符串可为DFA M所接受。若M的初态结点同时又是终态结点,则称ε可为M所接受(或识别),DFA M所能接受的全部字符串(字)组成的集合记作L(M)。

一个不确定有限自动机(NFA)M可以定义为一个五元组,M=(K,∑,F,S,Z),其中:

(1) k是一个有穷非空集,集合中的每个元素称为一个状态;

(2) ∑是一个有穷字母表,∑中的每个元素称为一个输入符号;

(3) F是一个从K×∑→K的子集的转换函数;

(4) SK,是一个非空的初态集;

(5) ZK,是一个终态集。

由定义可见,不确定有限自动机NFA与确定有限自动机DFA的主要区别是:

(1)NFA的初始状态S为一个状态集,即允许有多个初始状态;

(2)NFA中允许状态在某输出边上有相同的符号,即对同一个输入符号可以有多个后继状态。即DFA中的F是单值函数,而NFA中的F是多值函数。

因此,可以将确定有限自动机DFA看作是不确定有限自动机NFA的特例。和DFA一样,NFA也可以用矩阵和状态转换图来表示。

对于NFA M,若存在一条从某个初态结点到某一个终态结点的通路,则称这条通路上的所有弧的标记(ε除外)连接形成的字符串可为M所接受。NFA M所能接受的全部字符串(字)组成的集合记作L(M)。

由于DFA是NFA的特例,所以能被DFA所接受的符号串必能被NFA所接受。

设M和N是同一个字母集∑上的有限自动机,若L(M)=L(N),则称有限自动机M和N等价。

由以上定义可知,若两个自动机能够接受相同的语言,则称这两个自动机等价。DFA是NFA的特例,因此对任意NFA M总存在一个DFA N,使得L(M)=L(N)。即一个不确定有限自动机能接受的语言总可以找到一个等价的确定有限自动机来接受该语言。

同一个字符串α可以由多条通路产生,而在实际应用中,作为描述控制过程的自动机,通常都是确定有限自动机DFA,因此这就需要将不确定有限自动机转换成等价的确定有限自动机,这个过程称为不确定有限自动机的确定化,即NFA确定化为DFA。

下面介绍一种NFA的确定化算法,这种算法称为子集法:

若NFA的全部初态集合为S,则令DFA的初态为S;

(1) 设DFA的状态集K中有一状态为S(i_to_j)(Si到Sj所有元素的集合,是S的子集),若对某符号a∈∑,在NFA中有F{[S(i_to_j) ],a}=S′(i_to_k),则令F为DFA的一个转换函数。若S′(i_to_k)不在K中,则将其作为新的状态加入到K中。

(2) 重复第2步,直到K中不再有新的状态加入为止。

(3) 上面得到的所有状态构成DFA的状态集K,转换函数构成DFA的F,DFA的字母表仍然是NFA的字母表∑。

(4) DFA中凡是含有NFA终态的状态都是DFA的终态。

对于上述NFA确定化算法——子集法,还可以采用另一种操作性更强的描述方式,下面我们给出其详细描述。首先给出两个相关定义。

假设I是NFA M状态集K的一个子集(即I∈K),则定义ε-closure(I)为:

(1) 若Q∈I,则Q∈ε-closure(I);

(2) 若Q∈I,则从Q出发经过任意条ε弧而能到达的任何状态Q’,则Q’∈ε-closure(I)。

状态集ε-closure(I)称为状态I的ε闭包。

假设NFA M=(K,∑,F,S,Z),若I∈K,a∈∑,则定义Ia=ε-closure(J),其中J是所有从ε-closure(I)出发,经过一条a弧而到达的状态集。

NFA确定化的实质是以原有状态集上的子集作为DFA上的一个状态,将原状态间的转换为该子集间的转换,从而把不确定有限自动机确定化。经过确定化后,状态数可能增加,而且可能出现一些等价状态,这时就需要简化。

string NODE; //结点集合 
string CHANGE; //终结符集合 
int N;    //NFA边数 

struct edge{ 
    string first; 
    string change; 
    string last; 
}edge;  

struct my_change{ 
    string ltab;  
    string myset[MAXS]; 
}my_change;  

void my_sort(string &a) {  
    int i,j; 
    char b;  
    for(j=0;j<a.length();j++){    
        for(i=0;i<a.length();i++){      
            if(NODE.find(a[i])>NODE.find(a[i+1])) {       
                b=a[i];       
                a[i]=a[i+1];      
                a[i+1]=b;     
            }  
        }
    }
}  

void eclousure(char c,string &he,edge b[]) {  
    int k;  
    for(k=0;k<N;k++) { 
        if(c==b[k].first[0]){
            if(b[k].change=="*") {       
                if(he.find(b[k].last)>he.length()){
                    he+=b[k].last;       
                    eclousure(b[k].last[0],he,b);     
                } 
            } 
        }  
    }
}

void move(change &he,int m,edge b[]) {  
    int i,j,k,l;  
    k=he.ltab.length(); 
    l=he.myset[m].length(); 

    for(i=0;i<k;i++){    
        for(j=0;j<N;j++){      
            if((CHANGE[m]==b[j].change[0])&&(he.ltab[i]==b[j].first[0])){
                if(he.myset[m].find(b[j].last[0])>he.myset[m].length()){      
                    he.myset[m]+=b[j].last[0];  
                }
            }
        }
    }

    for(i=0;i<l;i++){
        for(j=0;j<N;j++){
            if((CHANGE[m]==b[j].change[0])&&(he.jihe[m][i]==b[j].first[0])){    
                if(he.myset[m].find(b[j].last[0])>he.myset[m].length()){
                    he.myset[m]+=b[j].last[0]; 
                }
            }
        }
    }

}

参考资料:

m.blog.csdn.net/article

cnblogs.com/shuaiwhu/ar

duanple.blog.163.com/bl

http://blog.csdn.net/hpugym/article/details/52276181?locationNum=3&amp;fps=1

http://www.cs.uku.fi/~kilpelai/BSA05/lectures/slides04.pdf

(五)小结

其实Trie图所起到的作用就是建立一个确定性有限自动机DFA,图中的每点都是一个状态,状态之间的转换用有向边来表示。Trie图是在Tire的基础上补边过来的,其实他应该算是AC自动机的衍生,AC自动机只保存其后缀节点,在使用时再利用后缀节点进行跳转,并一直迭代到找到相应的状态转移为止,这个应该算是KMP的思想。Trie图直接将AC自动机在状态转移计算后的值保存在当前节点,使得不必再对后缀节点进行迭代。所以Trie图的每个节点都会有|∑|个状态转移(∑指字符集)。流程:

  • (1)构建Trie,并保证根节点一定有|∑|个儿子。
  • (2)层次遍历Trie,计算后缀节点,节点标记,没有|∑|个儿子的对其进行补边。

后缀节点的计算:

(1)根结点的后缀节点是它本身。

(2)处于Trie树第二层的节点的后缀结点也是根结点。

(3)其余节点的后缀节点,是其父节点的后缀节点中有相应状态转移的节点(这里类似AC自动机的迭代过程)。

节点标记: (1)本身就有标记。 (2)其后缀节点有标记。

补边: 用其后缀节点相应的状态转移来填补当前节点的空白。 最后Trie图中任意一个节点均有相应的状态转移,我们就用这个状态转移做动态规划。 设dp[i][j]表示第i个状态产生j个字符时,与DFA序列最小的改变值。 假设Tire图中根节点是0,则初始化dp[0][0]=1。 其后,对图进行BFS遍历,可知处于第j层时,就说明以产生了j长度的字符串。

编辑于 2017-08-19