collect的功能是什么?其底层如何实现的?_计算的本质1 - 从自动机到正则表达式 Java实现...

现代计算机的强大能力伴随着同样的复杂性,我们很难了解所有的细节,此时计算机的简化模型就显得很有用了,可以帮助我们建立完整的认识。

有限自动机(Finite Automaton)是一台计算机的极简模型,没有持久化的存储,只有极少的RAM,最简单输入输出模式,我们从FA开始,看下它能做到什么。

这篇文章是《计算的本质》第3章的读书笔记,通过Java一步一步实现DFA、NFA,最后实现一个简单的正则表达式引擎。

DFA(确定性有限自动机)

e9929b78e3898ec5fc0d873c1fd14e82.png

这是一个非常简单的DFA,起始状态为1,接受状态为2,状态之间的箭头代表规则:

  • 状态 1 并且读入字符 • a 时,切换到状态 2。
  • 处于状态 2 并且读入字符 • a 时,切换到状态 1。

这个自动机接受奇数数量的a字符串,“a”,“aaa”等,拒绝其他的字符串输入。并且提供一个“是 / 否”的输出,以表明这个序列是否已经被接受,它已经可以被称之为称为简单计算机了。

904347f6e3a515a0b1b787d60cd974e3.png

确定性

上面说的这台自动机的状态始终是确定的,如果满足下面两个条件,就可以称之为DFA:

  • 不存在这样的状态:它的下一次转换状态因为有彼此冲突的规则而有二义性(这意味着一个状态对于同样的输入,不能有多个规则)
  • 没有遗漏:不存在这样的状态:它的下一次转换状态因为缺失规则而未知(这意味着每个状态都必须针对每个可能的输入字符有至少一个规则)

代码模拟

DFA很容易用代码对其进行模拟,然后就可以进行一些交互测试了。不得不说ruby的确是非常优雅的语言,用java描述稍显冗长。

FA规则:

public 

规则表:

public 

DFA:

public 

我们只需要这三个类就可以模拟一个DFA了,再增加一个DfaDesign类用来简化操作:

public 

好了,现在我们来模拟这样一个DFA,接受"ab","baba","baaab"这样的字符串,拒绝"a","baa"等形式的字符串:

c83337d7e4cb6d7ff971ba6ffc883fe1.png
List

现在看起来还没什么用,只能模拟一些非常简单的东西,后面我们可以看到更实际的应用。

NFA(非确定性有限自动机)

DFA理解和实现都很简单,如果我们去掉确定性约束,让自动机拥有对立的规则和多条可能路径,会怎么样呢?

非确定性

如果我们想设计一台DFA,接受a和b组成,倒数第三个字符是a的字符串。会发现比较麻烦,无法预先知道什么时候能读到倒数第三个字符。

如果我们放松确定性的限制,并且允许规则手册对于一个状态和输入包含多条规则(或者根本没有规则),那么就可以设计一台能完成任务的机器,即一台NFA:

d56207b0029a6bd658d40086761a6d7f.png

如果存在某条路径能让 NFA 按照它的某些规则执行并终止于一个接受状态,那它就能接受这个字符串。

代码模拟

NFA规则表:

public 

NFA:

public 

NfaDesign:

public 

至此,已经可以模拟一台带有自由移动的NFA了,它可以在没有输入的情况下,在不同的状态之间做出转移。

现在我们设计一台可以接受2或3倍数长度的a字符串的NFA,其中的虚线箭头表示自由移动:

cda3f34d9f314509af9ee2ceb464fa73.png

现在用代码模拟试试:

List

正则表达式

上面说了这么多,那它到底有什么用呢?一个比较典型的应用就是正则表达式,基本上所有的语言都实现了对正则表达式的支持,大部分也都采用了NFA来实现。

试一试用Java实现一个简易的正则表达式引擎,支持这样几个功能:

  1. 空字符串
  2. 'a','b'这样的字符
  3. |或运算符
  4. *重复运算符
  5. 把正则连接起来,如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 的接受状态:

725fe123dca9eda5a580fc101cf38e2b.png

选择操作转换NFA

在最简单的情况下,正则表达式 a 和 b 的 NFA 能结合起来构造成正则表达式 a|b 的 NFA,方法是增加一个新的起始状态并使用自由移动把它与两台原始机器之前的起始状态连接起来:

b5622b369eefb51077c30f4902a4b0a2.png

重复操作转换NFA

如何把与一个字符串匹配的 NFA,转换成能匹配同一个字符串重复零次或者更多次的 NFA 呢?我们为 a* 构造一个 NFA,其开头是一个 a 对应的NFA,然后做两个补充:

  1. 从它的接受状态到开始状态增加一个自由移动,这样它就可以与多于一个 • 'a' 匹配了。
  2. 增加一个可自由移动到旧的开始状态的新状态,并且使其作为接受状态,这样它就可以匹配空字符串了。

19de9afbab01f41ecd2126cb60d15c77.png

再来测试一下,已经可以构造复杂一些的模式来匹配字符串了,把正则转换成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实现正则呢?有这么两个原因:

  1. NFA可以保留正则表达式的结构,实现捕获组等特性需要。
  2. DFA构造,最小化时间复杂度高,占用空间大(极限情况是NFA子集数量的节点数)。

Java原生Pattern的实现,是直接将输入字符串编译成NFA结构,匹配时用回溯的方式进行深度优先遍历(书里是广度优先遍历,每一步计算出集合所有元素)

完整代码放到了我的github上:

https://github.com/koshox/understanding-computation-java

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值