现代计算机的强大能力伴随着同样的复杂性,我们很难了解所有的细节,此时计算机的简化模型就显得很有用了,可以帮助我们建立完整的认识。
有限自动机(Finite Automaton)是一台计算机的极简模型,没有持久化的存储,只有极少的RAM,最简单输入输出模式,我们从FA开始,看下它能做到什么。
这篇文章是《计算的本质》第3章的读书笔记,通过Java一步一步实现DFA、NFA,最后实现一个简单的正则表达式引擎。
DFA(确定性有限自动机)
这是一个非常简单的DFA,起始状态为1,接受状态为2,状态之间的箭头代表规则:
- 状态 1 并且读入字符 • a 时,切换到状态 2。
- 处于状态 2 并且读入字符 • a 时,切换到状态 1。
这个自动机接受奇数数量的a字符串,“a”,“aaa”等,拒绝其他的字符串输入。并且提供一个“是 / 否”的输出,以表明这个序列是否已经被接受,它已经可以被称之为称为简单计算机了。
确定性
上面说的这台自动机的状态始终是确定的,如果满足下面两个条件,就可以称之为DFA:
- 不存在这样的状态:它的下一次转换状态因为有彼此冲突的规则而有二义性(这意味着一个状态对于同样的输入,不能有多个规则)
- 没有遗漏:不存在这样的状态:它的下一次转换状态因为缺失规则而未知(这意味着每个状态都必须针对每个可能的输入字符有至少一个规则)
代码模拟
DFA很容易用代码对其进行模拟,然后就可以进行一些交互测试了。不得不说ruby的确是非常优雅的语言,用java描述稍显冗长。
FA规则:
public
规则表:
public
DFA:
public
我们只需要这三个类就可以模拟一个DFA了,再增加一个DfaDesign类用来简化操作:
public
好了,现在我们来模拟这样一个DFA,接受"ab","baba","baaab"这样的字符串,拒绝"a","baa"等形式的字符串:
List
现在看起来还没什么用,只能模拟一些非常简单的东西,后面我们可以看到更实际的应用。
NFA(非确定性有限自动机)
DFA理解和实现都很简单,如果我们去掉确定性约束,让自动机拥有对立的规则和多条可能路径,会怎么样呢?
非确定性
如果我们想设计一台DFA,接受a和b组成,倒数第三个字符是a的字符串。会发现比较麻烦,无法预先知道什么时候能读到倒数第三个字符。
如果我们放松确定性的限制,并且允许规则手册对于一个状态和输入包含多条规则(或者根本没有规则),那么就可以设计一台能完成任务的机器,即一台NFA:
如果存在某条路径能让 NFA 按照它的某些规则执行并终止于一个接受状态,那它就能接受这个字符串。
代码模拟
NFA规则表:
public
NFA:
public
NfaDesign:
public
至此,已经可以模拟一台带有自由移动的NFA了,它可以在没有输入的情况下,在不同的状态之间做出转移。
现在我们设计一台可以接受2或3倍数长度的a字符串的NFA,其中的虚线箭头表示自由移动:
现在用代码模拟试试:
List
正则表达式
上面说了这么多,那它到底有什么用呢?一个比较典型的应用就是正则表达式,基本上所有的语言都实现了对正则表达式的支持,大部分也都采用了NFA来实现。
试一试用Java实现一个简易的正则表达式引擎,支持这样几个功能:
- 空字符串
- 'a','b'这样的字符
- |或运算符
- *重复运算符
- 把正则连接起来,如a和b连接得到ab
这里没有实现?和+这些功能,其实ab?等同于ab|a,而ab+等同于abb*,其他计数重复(如 a{2,5} )和字符组(如 [abc] )等方便的特性也是这样,可以用已有的特性实现。至于捕获组、反向引用,那就是高级特性了。
我们为每类正则表达式定义一个类,用它们来表示语法树,同时,可以将它们转换成对应的NFA。
// 正则基类
precedence是为了toString的时候正确展示括号,核心的操作是toNfaDesign,把正则转换为NFA。
空字符串和字符转换NFA非常简单,不必多说。对于连接,|,*这三种运算,如何构造NFA呢:
连接操作转换NFA
现在能把单个字符的正则表达式 a和 b 转换成 NFA,那怎么把 ab 转成一个 NFA 呢? 对于 ab ,我们可以把两个 NFA 按顺序连接到一起,用自由移动把它们联结在一起,并且保留第二个 NFA 的接受状态:
选择操作转换NFA
在最简单的情况下,正则表达式 a 和 b 的 NFA 能结合起来构造成正则表达式 a|b 的 NFA,方法是增加一个新的起始状态并使用自由移动把它与两台原始机器之前的起始状态连接起来:
重复操作转换NFA
如何把与一个字符串匹配的 NFA,转换成能匹配同一个字符串重复零次或者更多次的 NFA 呢?我们为 a* 构造一个 NFA,其开头是一个 a 对应的NFA,然后做两个补充:
- 从它的接受状态到开始状态增加一个自由移动,这样它就可以与多于一个 • 'a' 匹配了。
- 增加一个可自由移动到旧的开始状态的新状态,并且使其作为接受状态,这样它就可以匹配空字符串了。
再来测试一下,已经可以构造复杂一些的模式来匹配字符串了,把正则转换成NFA来执行。
Pattern
正则语法树解析
虽然我们已经构建了一个正则实现,但是还缺一个语法解析器,如果我们能直接写(ab|a)*然后转换成AST(抽象语法树),而不是上面这一堆的new Repeat xxx手动构造语法树,那就方便多了。
书里用了ruby的一个叫Treetop的库来做这个事,Java的话可以用antlr或者javacc,他们都是类似yacc的语法解析器,可以很方便的实现自己的DSL,我们项目里的类Sql的DSL就是用antlr实现的。
不过这个正则语法比较简单,可以手写递归下降语法分析。
Token定义:
public
词法解析器:
public
语法解析器:
public
运算符的优先级从上到下越来越高,| 运算符的绑定最宽松,因此 choose 规则在最前面。另外还要考虑带有括号的情况。
最后给Pattern类加上编译函数
public
现在测试一下这个正则解析器:
Pattern
完全没毛病。
等价性
非确定性和自由移动让设计有限状态机执行特定的工作更容易,可以很方便的把正则转换为NFA。但是NFA做了什么DFA做不了的事吗?其实没有,他们完全等价。
DFA是从一个当前状态移动到另一个,而NFA是从一个当前可能状态的集合移动到另一个可能状态的集合。尽管一个 NFA 的规则手册可以是非确定性的,但是对于一个给定的输入从当前状态出发移动到哪些状态,这个决定总是完全确定性的。
所以完全可以用一台DFA来模拟NFA:
public
再次测试:
Pattern
从性能上来说,DFA显然要比NFA高不少。DFA的时间复杂度是O(n),NFA则是O(nm^2),n是字符串长度,m是节点数。
那为什么不用DFA实现正则呢?有这么两个原因:
- NFA可以保留正则表达式的结构,实现捕获组等特性需要。
- DFA构造,最小化时间复杂度高,占用空间大(极限情况是NFA子集数量的节点数)。
Java原生Pattern的实现,是直接将输入字符串编译成NFA结构,匹配时用回溯的方式进行深度优先遍历(书里是广度优先遍历,每一步计算出集合所有元素)
完整代码放到了我的github上:
https://github.com/koshox/understanding-computation-java