前言
之前还写过一个版本的词法分析,但是对类的设计不太满意以及扩展性比较差。所以,又重新设计再写了一份,解决了之前遗留的一些问题。
项目地址
github(还没有上传)
前导知识
- 什么是正则表达式?
- 什么是状态转换图?
- 非确定的有穷状态自动机(NFA)和确定的有穷状态自动机(DFA)的区别
- 如何将正则表达式构造成状态转换图?(Thompson算法)
1. 什么是正则表达式?
2. 什么是状态转换图?
正则引擎主要有两大类,一种是
N
F
A
NFA
NFA,一种是
D
F
A
DFA
DFA;
N
F
A
NFA
NFA与
D
F
A
DFA
DFA就是由状态转换图组成的(比较复杂而已),我们需要通过自动化的方式将一组正则表达式集合构造出状态转换图(transition diagram)。
组成:
状态转换图有一组被称为“状态(state)”的结点或圆圈。
状态转换图的边(edge)从图的一个状态指向另一个状态,每条边的标号包含了一个或多个符号;假设我们处于某个状态 S S S,并且下一个输入符号是 a a a,我们就会寻找一条从 S S S离开且标号为 a a a的边,到达另一个状态 F F F。边通常称为转换函数,即有 F = f ( S , a ) F = f(S, a) F=f(S,a)
一个状态(有且只能有一个)没有前驱状态,称为开始状态或初始状态。
某些状态(多个)没有后继状态,称为接受状态或最终状态。通常使用两个圆圈表示。
3. 非确定的有穷状态自动机(NFA)和确定的有穷状态自动机(DFA)的区别
对于NFA的某个状态 S S S, 存在输入符号 I I I, S S S能通过 I I I到达一组后继状态 P { p ∣ p ∈ P } P\{p | p ∈ P\} P{p∣p∈P}。
对于DFA的某个状态 S S S, 存在输入符号 I I I, S S S能通过 I I I只能到达一个后继状态 Q Q Q。
4. 如何将正则表达式构造成状态转换图?
语言:
字母表(alphabet)是一个有限的符号集合。
符号的典型例子包括字母、数位和标点符号。
集合{0, 1}是二进制字母表,ASCII是一个字母表的重要例子。
语言是某个给定字母表上一个任意的可数的串的集合,像∅和仅包含空串的集合{ ε ε ε}都是语言。
如果 x x x和 y y y是串,那么 x x x和 y y y的连接(concatenation)(记作 x y xy xy)是把 y y y附加到 x x x后面形成的串。例如 x = d o g x=dog x=dog且 y = h o u s e y=house y=house,那么 x y = d o g h o u s e xy=doghouse xy=doghouse。空串ε是连接运算的单位元,也就是,对于任何串 s s s都有 s ε = ε s = s sε=εs=s sε=εs=s。
如果把两个串的连接看成是这两个串的“乘积”,我们可以定义串的“指数”运算,即: s 0 s^0 s0为 ε ε ε,并且对于 i > 0 i>0 i>0, s i s^i si为 s i − 1 s s^{i-1}s si−1s。
因为 ε s = s εs=s εs=s,由此可知 s 1 = s s^1=s s1=s, s 2 = s s s^2=ss s2=ss, s 3 = s s s s^3=sss s3=sss以此类推。
语言的运算
Kleene闭包是将 L L L连接 0 0 0次或多次得到的串集;正闭包是将 L L L连接 1 1 1次或多次得到的串集;注意, L 0 L^0 L0是将 L L L连接 0 0 0次得到的集合,被定义为 { ε } \{ε\} {ε}。
L L L和 M M M的并 → L ∪ M = { s ∣ s 属 于 L 或 者 s 属 于 M } L∪M = \{s | s 属于L或者s属于M\} L∪M={s∣s属于L或者s属于M}
L L L和 M M M的连接 → L M = { s t ∣ s 属 于 L 且 t 属 于 M } LM = \{st | s 属于L且t属于M\} LM={st∣s属于L且t属于M}
L L L的Kleene闭包 → L ∗ = ∪ i = 0 ∞ L i L^* =∪_{i=0}^∞L^i L∗=∪i=0∞Li
L L L的正闭包 → L + = ∪ i = 1 ∞ L i L^+ = ∪_{i=1}^∞L^i L+=∪i=1∞Li
补充: L L L的零一闭包 → L ? 是 将 L 连 接 0 次 或 1 次 得 到 的 串 集 L^?是将L连接0次或1次得到的串集 L?是将L连接0次或1次得到的串集
正则定义:为了方便表示,我们希望给某些正则表达式命名,并在之后的正则表达式像使用符号一样使用这些名字。如果 ∑ ∑ ∑是基本符号的集合,那么一个正则定义(regular definition)是具有如下形式的定义序列。
d 1 → r 1 d 2 → r 2 . . . d n → r n d_1 → r_1 \\ d_2 → r_2 \\ ...\\ d_n → r_n d1→r1d2→r2...dn→rn
其中,每个 d i d_i di都是新符号,都不在 ∑ ∑ ∑中,并且都不相同。
每个 r i r_i ri是字母表 ∑ ∪ { d 1 , d 2 , d 3 . . . d n } ∑∪\{d_1, d_2, d_3 ... d_n\} ∑∪{d1,d2,d3...dn}上的正则表达式。
限制每个 r i r_i ri只含有 ∑ ∑ ∑中的符号和在它之前定义的各个 d j d_j dj。
例如,Java语言的标识符是由字母,数字,下划线组成,其中第一个符号不能是数字,所以,我们可以很容易的构造出标识符的正则定义(注意顺序):
l e t t e r _ → A ∣ B ∣ . . . Z ∣ a ∣ b ∣ . . . ∣ z ∣ _ d i g i t → 0 ∣ 1 ∣ . . . ∣ 9 i d → l e t t e r _ ( l e t t e r _ ∣ d i g i t ) ∗ letter\_ → A |B|...Z|a|b|...|z|\_ \\ digit → 0 |1|...|9 \\ id → letter\_ (letter\_|digit)* letter_→A∣B∣...Z∣a∣b∣...∣z∣_digit→0∣1∣...∣9id→letter_(letter_∣digit)∗
假设需要匹配一个字符 a a a,那么我们能很轻松地构造出它的转换转换图 N ( R ) N(R) N(R),只需要将输入符号(标号)设置为 a a a。正则定义为: R → a R → a R→a
构造连接运算的状态转换图时,需要将每个正则表达式的状态转换图都构造出来,如 N ( R 1 ) N(R_1) N(R1)和 N ( R 2 ) N(R_2) N(R2),然后使用连接运算符的单位元连接;
以 N ( R 1 ) N(R_1) N(R1)的开始状态为新的开始状态,再以 N ( R 1 ) N(R_1) N(R1)的接受状态构造出一条ε边到 N ( R 2 ) N(R_2) N(R2)的开始状态,以 N ( R 2 ) N(R_2) N(R2)的接受状态为新的接受状态。最后得到 N ( R ) N(R) N(R).
在例子中只能先匹配输入符号 a a a到达 1 1 1号状态,然后再匹配输入符号 b b b到达 3 3 3号状态[单位元无意义, 不匹配字符] 。假设正则定义是:
R 1 → a R 2 → b R → R 1 R 2 R_1 → a \\ R_2 → b \\ R → R_1R_2 R1→aR2→bR→R1R2
构造或运算时,同样需要将每个正则表达式的状态转换图构造出来,如 N ( R 1 ) N(R_1) N(R1)和 N ( R 2 ) N(R_2) N(R2),然后使用单位元连接。
新建一个开始状态 S S S,S分别有一个ε边连接到 N ( R 1 ) N(R_1) N(R1)和 N ( R 2 ) N(R_2) N(R2)的开始状态,且 N ( R 1 ) N(R_1) N(R1)和 N ( R 2 ) N(R_2) N(R2)的接受状态都有一条单位元边连接到新建的接受状态 F F F。
(在例子中,开始状态要么通过 R 1 R_1 R1,要么通过 R 2 R_2 R2到达接受状态[单位元无意义, 不匹配字符] ),假设正则定义是:
R 1 → a R 2 → b R → R 1 ∣ R 2 R_1 → a \\ R_2 → b \\ R → R_1|R_2 R1→aR2→bR→R1∣R2
构造Kleene闭包,Kleene是将 L L L连接 0 0 0次或多次得到的串集:
构造0次的状态转换图,只需要从开始状态构造一条ε边到接受状态(即不需要任何输入字符);构造多次的状态转换图,只需要从 N ( R ) N(R) N(R)的接受状态构造出条到 N ( R ) N(R) N(R)的开始状态(假设串是 a a aa aa,当分析第一个 a a a时, 0 0 0号状态可以通过输入符号 a a a到达 2 2 2号状态;此时, 2 2 2号状态可以直接到达 3 3 3号接受状态,也可以回到 1 1 1号状态再接受输入字符 a a a,以此循环)。
R → a ∗ R →a^* R→a∗
同样,也能构造正闭包和零一闭包:
R → a + R →a^+ R→a+
R → a ? R →a^? R→a?
注意,当某几个状态转换图合成一个时,那几个状态转换图的开始状态和接受状态将变成普通状态,然后根据不同的Thompson转换方法,重新设置不同的开始状态和接受状态。
支持文法(Lex的正则表达式)
相对于上面的正则定义,下面的表示方法更简洁。
表达式 | 匹配 | 例子 |
---|---|---|
c | 单个非运算字符c | a, b, z |
\c | 字符c的字面值 | \\ |
“s” | 串s的字面值 | “.abc” |
. | 除换行符以外的所有字符 | .o |
[s] | 字符串s中的任意一个字符 | [abc], [a-z], [0-9] |
[^s] | 不在字符串s中的任意一个字符 | [^abc] |
r1r2 | r1后加上r2 | ac |
(r) | 与r相同 | (ab) |
r{m, n} | 最少m个,最多n个r重复出现 | r{1, 5} |
r1 | r2 | r1或r2 | a | b |
r* | 和r匹配的零个或多个串连接的串 | a*, [abc]* |
r+ | 和r匹配的一个或多个串连接的串 | a+, [0-9]+ |
r? | 零个或一个r | a?, (ab)? |
正文
正文部分都是遇到的一些比较有意思的问题,不会讲全部代码,只讲遇到过哪些问题,如果有更好的办法,欢迎讨论~
一、 如何设计‘状态转换图’类?
1. 这部分比较容易设计,State(状态)通过Transition Function(状态)到达下一个State(状态);
2. 每个State都有一个id属性和一个StateType属性(是开始状态,还是普通状态,还是接受状态)。
3. Transition Function有一个match方法,检查匹配符号
I
I
I是否是该转换所需要(能匹配)的值。
!! 为了某些情况的方便(试着去掉了TransitionProcedure,有点紧耦合),将match这个过程,分离出一个类----Transition Procedure。
二、转换图的简化
这里的简化是或运算 的简化。
假设正则定义是:
R
→
[
a
−
z
]
R→ [a-z]
R→[a−z]
那么,状态转换图(后文称NFA ---- Thompson算法是将正则表达式构造成NFA, 然后NFA通过子集构造算法再转换为状态更少更精简的DFA)为:
足足21个状态!! NFA的效率可是跟状态数量的多寡有关。所以有一种更精简(更让脑壳疼)的方法,“连词符 ‘-’”(Conjunction Symbol)。例如
R
→
[
a
−
z
]
R→ [a-z]
R→[a−z]中‘-’就是连词符。
在子集构造算法,只要一个输入符号而不是连词符,使用连词符稍微有点别扭…如果不用,状态数量又太多了!!
补充:每个TransitionProcedure有一个左边界与右边界,当是连词符时,左边界是连词符左边的字符值,右边界是连词符右边的字符值;当输入字符是一个字符时,TransitionProcedure的左边界等于右边界。
三、元字符如何匹配?
什么是元字符?
类似‘.’在Lex正则表达式中是指通配符,""是指串s的字面值… 这样类似的,在正则表达式中有特殊意义的字符称为元字符。
我如何设计?
例如正则定义
R
R
R:
R
→
a
b
c
.
\
.
R → abc.\backslash.
R→abc.\.
因为类的设计问题,这样一段正则表达式会转换为CharacterRegular(后文实例讲解提到该类),而每个Regular(是CharacterRegular的基类)会根据属性expression生成状态图(diagram)。而expression则是该正则表达式的文本值,最终,该值是:
e
x
p
r
e
s
s
i
o
n
=
"
a
b
c
.
.
"
expression = "abc.."
expression="abc.."
当调用generateDiagram方法时(生成状态图),分析expression读到第二个‘.’时,到底是通配符呢?还是普通字符呢?
所以,在Character中,增加一个Set<Integer>,用于存放元字符位于字符串中的下标,当读到第二个‘.’字符时,并且Set<Integer>中有该下标值,则代表命中元字符,构造出特殊的TransitionProcedure(通配符的TransitionProcedure左边界是0, 右边界是0xFFFF, 0xFFFF是假设的unicode字符上限,能匹配绝大部分了)。
(其实好像可以边读取边生成?)
四、[^a-z0-9RE] 如何构造?
如果是[a-z0-9RE] 就很容易构造啦!
构造出a-z,0-9,R,E的转换图,再用或运算连接就好了。
思考:如何构造带有取反性质的或运算??(先别往下看,先想想)
我是将a-z,0-9,R,E每个都取反,然后求出它们的交集;
例如a-z 就可以转换为min—(a-1),(z+1)—max;(min和max是某个数值取值)
0-9就可以转换为min—(0-1),(9+1)—max;
R就相当于min—(R-1),(R+1)—max;
再想想:如何快速求出它们的交集?(可是试试)
橙色部分即是我们想要求解的区域,只需要以左端排序一遍,然后在遍历一遍取出橙色部分就好了!
五、([a-zA-Z] | [0-9]){6, 16} 如何构造?
只包含数字和字母正则定义是:
p
a
s
s
w
o
r
d
→
(
[
a
−
z
A
−
Z
]
∣
[
0
−
9
]
)
{
6
,
16
}
password→([a-zA-Z] | [0-9])\{6, 16\}
password→([a−zA−Z]∣[0−9]){6,16}
该密码匹配6~16位字符,如果是5个字符以下或超过16个字符,那么则不匹配该正则表达式。
如何设计状态转换图?
我的办法比较… 笨点,因为会导致状态数量很多,跟
m
,
n
{m,n}
m,n中
n
n
n的值相关。
p
a
s
s
w
o
r
d
password
password的状态数量多达
200
200
200多个。
首先,我构造出
(
[
a
−
z
A
−
Z
]
∣
[
0
−
9
]
)
([a-zA-Z] | [0-9])
([a−zA−Z]∣[0−9])的
N
F
A
−
N
(
B
)
NFA-N(B)
NFA−N(B),即:
B
→
(
[
a
−
z
A
−
Z
]
∣
[
0
−
9
]
)
B→([a-zA-Z] | [0-9])
B→([a−zA−Z]∣[0−9])
再以
N
(
B
)
N(B)
N(B)为基础,重复连接
m
个
N
(
B
)
m个N(B)
m个N(B), 再连接
n
−
m
个
N
(
B
)
n-m个N(B)
n−m个N(B),每个
n
−
m
的
N
(
B
)
n-m的N(B)
n−m的N(B)都会有一条从
n
−
m
的
N
(
B
)
n-m的N(B)
n−m的N(B)的接受状态到
N
(
p
a
s
s
w
o
r
d
)
N(password)
N(password)的接受状态。
例如这样一个匹配
a
{
4
,
6
}
a\{4, 6\}
a{4,6}的正则表达式,每组颜色都以不同颜色标明。在7号状态和9号状态有一条ε边到接受状态。
六、优化与不足
因为隔了好久才写了这篇blog…突然忘记优化点和不足了。
总体上仍然不是很满意,有的地方设计地奇奇怪怪的,不过还好.
七、实例讲解
产生式:
产生式的概念是在文法中称呼的,即:
H e a d → B o d y Head → Body Head→Body
跟正则定义相似,head部分叫做产生式头部或产生式左部,body部分称为产生式体或产生式右部。那么有正则定义的名称可以称为产生式头部,正则表达式部分可以称为产生式体。
在这部分以匹配一个整数的正则表达式举例,讲解代码如何运行的,该正则定义有:
d
i
g
i
t
→
[
0
−
9
]
n
u
m
b
e
r
→
[
+
−
]
?
[
1
−
9
]
{
d
i
g
i
t
}
∗
digit → [0-9] \\ number→[+-]^?[1-9]\{digit\}^*
digit→[0−9]number→[+−]?[1−9]{digit}∗
该正则表达式匹配整数,包括 +13,-10,1,10302…
首先匹配一个+\-,是一个01闭包,即可以有+符号,也可以没有; 然后再匹配一个字符,该字符取值范围是1-9;最后是一个0-9的Kleene闭包。
- 从文件中读取正则定义,分离正则定义的名称和正则表达式的主体部分。例子将被分为 n u m b e r number number和 [ + − ] ? [ 1 − 9 ] { d i g i t } ∗ [+-]^?[1-9]\{digit\}^* [+−]?[1−9]{digit}∗两部分。
- Thompson的analyze算法接收产生式体部分,遍历产生式体中每个字符。
- 对于产生式体(正则表达式)
[
+
−
]
?
[
1
−
9
]
{
d
i
g
i
t
}
∗
[+-]^?[1-9]\{digit\}^*
[+−]?[1−9]{digit}∗,要做的就是将产生式体分解(我使用策略模式,根据不同的需求,分析不同的正则表达式),比如该产生式体可以分解为三个部分:
[ + − ] ? [ 1 − 9 ] { d i g i t } ∗ [+-]^? \\ [1-9] \\ \{digit\}^* [+−]?[1−9]{digit}∗ - 其中, [ + − ] ? [+-]^? [+−]?又可以分解为 [ + − ] [+-] [+−](OrRegular,Regular这些是类),然后再以此构造出它的闭包(ClosureRegular);思考:当前例子中的 [ + − ] [+-] [+−]的 ‘ − ’ ‘-’ ‘−’是会被当做连词符?还是会被当做普通字符 ‘ − ’ ‘-’ ‘−’?判断依据是 ‘ − ’ ‘-’ ‘−’的前后是否存在相同属性的字符,例如前后都是小写字母,前后都是数字…等等。
- [1-9]则直接生成OrRegular。并且 ‘ − ’ ‘-’ ‘−’符号是作为连词符使用。
- 当遇到 { d i g i t } ∗ \{digit\}^* {digit}∗时,同样,这是两个部分,一个是 { d i g i t } \{digit\} {digit},然后则是它的闭包(ClosureRegular)。但是, { d i g i t } \{digit\} {digit}是什么?
-
{
d
i
g
i
t
}
\{digit\}
{digit}是指引用另外一个名称为
d
i
g
i
t
digit
digit的正则表达式,当遇到该符号时,会在map中(每次分析完一个正则表达式都会放入map,以正则表达式的名词为key)查找,如果存在,那么将当前位置的引用替换为表达式,有:
n u m b e r → [ + − ] ? [ 1 − 9 ] [ 0 − 9 ] ∗ number→[+-]^?[1-9][0-9]^* number→[+−]?[1−9][0−9]∗ - 当每个部分分析完后,再用单位元连接,使之成为一个整体(CombinationRegular)。
9. 将组合好的CombinationRegular调用generateDiagram方法,生成NFA再传入NondeterministicFiniteAutomaton类,此类会分析转换状态图,然后构造NFA五元组。
有限状态自动机是一个五元组
M = ( Q , Σ , δ , q 0 , F ) M=(Q, Σ, δ, q0, F) M=(Q,Σ,δ,q0,F)
Q Q Q 状态的非空有穷集合, ∀ q ∈ Q ∀q∈Q ∀q∈Q, q q q称为 M M M的一个状态
Σ Σ Σ 输入字母表
δ δ δ 状态转移函数,有时又叫作状态转换函数, δ : Q × Σ → Q , δ ( q , a ) = p δ:Q×Σ→Q,δ(q,a)=p δ:Q×Σ→Q,δ(q,a)=p
q 0 q0 q0 M M M的开始状态,也可叫作初始状态或启动状态, q 0 ∈ Q q0∈Q q0∈Q
F F F M M M的终止状态集合, F F F被 Q Q Q包含,任给 q ∈ F q∈F q∈F, q q q称为 M M M的终止状态
10. 最后调用NondeterministicFiniteAutomaton类中的match(String)函数即可。(还有更多的细节详见github代码)
标签
正则表达式,正则引擎,RE,有穷状态自动机,NFA
正则表达式转不确定的有限状态自动机
词法分析 JAVA实现