DFA 每次只处在一个状态里。读取一个字符,规则表给出唯一的下一个状态。这个约束让 DFA 很容易实现,也让它的执行轨迹很干净:current_state + character -> next_state

非确定性有限自动机(Nondeterministic Finite Automaton,NFA)放宽了这个约束。同一个状态读取同一个字符时,可以走到多个下一状态;机器还可以在不消耗字符的情况下移动到别的状态。NFA 的实现方式并不神秘:把“当前状态”从单个值改成一个集合,一次保留所有可能路径。

这篇文章用 Python 写一个 NFA,识别两个字符串:abba。这个示例足够小,但能完整覆盖 NFA 的两个核心机制:分支和空转移。

非确定性不是随机

“非确定性”容易被误解成机器随机选择一条路。NFA 的更好理解是:同一时刻保留多条候选路径,只要其中一条路径最后进入接受状态,整个输入就被接受。

问题 DFA NFA
当前状态 一个状态 一组状态
同一输入的下一状态 唯一 可以有多个
是否允许不读字符就移动 不允许 允许
接受条件 当前状态属于接受集合 当前状态集合与接受集合有交集
工程直觉 状态流转表 候选集推进

如果 DFA 的状态轨迹是一条线,NFA 的状态轨迹就是一组同时推进的候选线。实现上不需要真的启动多个线程,也不需要随机试错;集合运算就足够表达这个模型。

模式提炼:单值状态变成候选集

current_state -> current_states

NFA 的关键变化是把单个当前状态提升成状态集合。只要系统需要同时保留多个解释、多个候选、多个分支,就可以先尝试这个模式。

场景 单值模型 候选集模型
DFA / NFA 一个当前状态 多个可能状态
parser 一个解析位置 多个解析分支
规则引擎 一条命中路径 多条候选规则
搜索系统 一个最佳候选 一组候选结果

候选集模型牺牲了一部分空间,换来更直接的分支表达。正则表达式里的 |?* 之所以适合先编译成 NFA,就是因为分支天然可以放进集合里。

分支规则和空转移

本文的 NFA 接受 abba。起始状态先通过空转移进入两个分支:

1
2
3
4
5
6
7
8
start --ε--> try_ab
start --ε--> try_ba

try_ab --a--> seen_a
seen_a --b--> accept

try_ba --b--> seen_b
seen_b --a--> accept

ε 表示不消耗输入字符的移动。代码里会用 None 表示这种空转移。机器刚启动时,虽然显式起点只有 start,但通过空转移可以立刻到达 try_abtry_ba。因此,初始当前状态集合是:

1
{start, try_ab, try_ba}

输入 ab 时,try_ab 分支会走到 accept;输入 ba 时,try_ba 分支会走到 accept。只要当前状态集合里有 accept,输入就被接受。

规则对象多了一个空字符

FARule 仍然表示一条状态转移。和 DFA 版本相比,唯一变化是 character 可以是 None

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from dataclasses import dataclass


State = str
Character = str | None


@dataclass(frozen=True)
class FARule:
state: State
character: Character
next_state: State

def applies_to(self, state: State, character: Character) -> bool:
return self.state == state and self.character == character

def follow(self) -> State:
return self.next_state

NFA 的规则表要回答两个问题。第一个问题和 DFA 类似:给定一组当前状态和一个输入字符,可以到达哪些下一状态。第二个问题是 NFA 特有的:不消耗字符时,还能通过空转移扩展出哪些状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class NFARulebook:
def __init__(self, rules: list[FARule]) -> None:
self.rules = rules

def next_states(self, states: set[State], character: Character) -> set[State]:
next_states = set()

for state in states:
for rule in self.rules_for(state, character):
next_states.add(rule.follow())

return next_states

def rules_for(self, state: State, character: Character) -> list[FARule]:
return [rule for rule in self.rules if rule.applies_to(state, character)]

def follow_free_moves(self, states: set[State]) -> set[State]:
more_states = self.next_states(states, None)

if more_states <= states:
return states

return self.follow_free_moves(states | more_states)

follow_free_moves 做的是 epsilon closure,也就是空转移闭包。只要还能通过空转移找到新状态,就继续扩展;直到状态集合不再变大为止。

模式提炼:闭包扩展

seed_states -> expand_until_fixed_point(seed_states)

空转移闭包是一种固定点计算。先从一组种子状态出发,不断应用扩展规则;当扩展结果不再增加新成员时,计算停止。

场景 种子 扩展规则 停止条件
NFA 空转移 当前状态集合 ε 转移 没有新状态
依赖分析 直接依赖 依赖的依赖 依赖集合稳定
权限继承 直接角色 父角色 / 组合角色 角色集合稳定
图遍历 起点节点 邻接边 可达集合稳定

听到“从一批对象出发,沿某种关系一直展开到不再变化”时,就可以把它看成闭包扩展。

NFA 的接受条件

NFA 保存的是一组当前状态。每次读取字符前,当前状态集合都要先补齐空转移能到达的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class NFA:
def __init__(
self,
current_states: set[State],
accept_states: set[State],
rulebook: NFARulebook,
) -> None:
self._current_states = current_states
self.accept_states = accept_states
self.rulebook = rulebook

@property
def current_states(self) -> set[State]:
return self.rulebook.follow_free_moves(self._current_states)

def accepting(self) -> bool:
return bool(self.current_states & self.accept_states)

def read_character(self, character: str) -> None:
self._current_states = self.rulebook.next_states(
self.current_states,
character,
)

def read_string(self, text: str) -> None:
for character in text:
self.read_character(character)

和 DFA 一样,NFA 会在读取过程中改变当前状态。为了反复测试不同字符串,可以再加一个 NFADesign,每次判断时都创建新的 NFA。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class NFADesign:
def __init__(
self,
start_state: State,
accept_states: set[State],
rulebook: NFARulebook,
) -> None:
self.start_state = start_state
self.accept_states = accept_states
self.rulebook = rulebook

def to_nfa(self) -> NFA:
return NFA({self.start_state}, self.accept_states, self.rulebook)

def accepts(self, text: str) -> bool:
nfa = self.to_nfa()
nfa.read_string(text)
return nfa.accepting()

完整示例

完整代码如下,可以直接复制运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
from dataclasses import dataclass


State = str
Character = str | None


@dataclass(frozen=True)
class FARule:
state: State
character: Character
next_state: State

def applies_to(self, state: State, character: Character) -> bool:
return self.state == state and self.character == character

def follow(self) -> State:
return self.next_state


class NFARulebook:
def __init__(self, rules: list[FARule]) -> None:
self.rules = rules

def next_states(self, states: set[State], character: Character) -> set[State]:
next_states = set()

for state in states:
for rule in self.rules_for(state, character):
next_states.add(rule.follow())

return next_states

def rules_for(self, state: State, character: Character) -> list[FARule]:
return [rule for rule in self.rules if rule.applies_to(state, character)]

def follow_free_moves(self, states: set[State]) -> set[State]:
more_states = self.next_states(states, None)

if more_states <= states:
return states

return self.follow_free_moves(states | more_states)


class NFA:
def __init__(
self,
current_states: set[State],
accept_states: set[State],
rulebook: NFARulebook,
) -> None:
self._current_states = current_states
self.accept_states = accept_states
self.rulebook = rulebook

@property
def current_states(self) -> set[State]:
return self.rulebook.follow_free_moves(self._current_states)

def accepting(self) -> bool:
return bool(self.current_states & self.accept_states)

def read_character(self, character: str) -> None:
self._current_states = self.rulebook.next_states(
self.current_states,
character,
)

def read_string(self, text: str) -> None:
for character in text:
self.read_character(character)


class NFADesign:
def __init__(
self,
start_state: State,
accept_states: set[State],
rulebook: NFARulebook,
) -> None:
self.start_state = start_state
self.accept_states = accept_states
self.rulebook = rulebook

def to_nfa(self) -> NFA:
return NFA({self.start_state}, self.accept_states, self.rulebook)

def accepts(self, text: str) -> bool:
nfa = self.to_nfa()
nfa.read_string(text)
return nfa.accepting()


rulebook = NFARulebook(
[
FARule("start", None, "try_ab"),
FARule("start", None, "try_ba"),
FARule("try_ab", "a", "seen_a"),
FARule("seen_a", "b", "accept"),
FARule("try_ba", "b", "seen_b"),
FARule("seen_b", "a", "accept"),
]
)

design = NFADesign("start", {"accept"}, rulebook)

for sample in ["", "a", "b", "ab", "ba", "aa", "bb", "aba"]:
print(f"{sample!r:5} -> {design.accepts(sample)}")

运行结果:

1
2
3
4
5
6
7
8
''    -> False
'a' -> False
'b' -> False
'ab' -> True
'ba' -> True
'aa' -> False
'bb' -> False
'aba' -> False

abba 被接受,其余字符串被拒绝。aba 的前两个字符虽然可以形成 ab,但这台 NFA 没有多余输入的转移规则,读到最后一个 a 后状态集合变空,所以最终拒绝。

看一条状态集合轨迹

对 NFA 来说,轨迹不再是单个状态序列,而是状态集合序列。

1
2
3
4
5
6
7
8
9
10
def format_states(states: set[State]) -> str:
return "{" + ", ".join(sorted(states)) + "}"


nfa = design.to_nfa()
print(format_states(nfa.current_states))

for character in "ab":
nfa.read_character(character)
print(format_states(nfa.current_states))

运行结果:

1
2
3
{start, try_ab, try_ba}
{seen_a}
{accept}

初始集合里有三个状态,因为 start 可以通过两条空转移同时展开到 try_abtry_ba。读取 a 后,只有 try_ab 分支还能继续;读取 b 后,机器进入 accept

如果输入是 b,第一步会走到 seen_b;再读 a,同样会进入 accept。NFA 不需要在启动时猜哪条分支正确,它把分支都放进集合里,后续输入会自然淘汰走不通的分支。

Java 程序员的迁移表

Python 写法 Java 对照 作用
set[State] EnumSet<State> 当前状态集合
`Character = str None` Optional<Character> 或专门的 EPSILON 常量
next_states Set<State> nextStates(Set<State>, Event) 推进候选集
follow_free_moves 固定点循环 / BFS 计算空转移闭包
current_states & accept_states !Collections.disjoint(current, accept) 判断是否接受
NFADesign 工厂对象 / 不可变配置 避免复用运行态对象

Java 里可以把空转移写成一个专门的事件值,而不是用 nullnull 作为转移键容易和“没有规则”混在一起,长期维护不如显式常量清楚。

1
2
3
enum Symbol {
A, B, EPSILON
}

工程代码通常还会把 followFreeMoves 写成循环,避免递归深度问题。核心思想不变:把直接可达状态加入集合,如果集合变大,就继续扩展。

NFA 和工程里的分支搜索

NFA 的价值不在于运行效率,而在于表达力。很多系统都需要先保留多个候选,再让后续输入继续筛选。

规则引擎里,一个事实可能命中多条规则;消息路由里,一条消息可能匹配多个订阅条件;parser 里,一个 token 序列可能对应多个解析分支;搜索系统里,一个查询可能同时保留多个召回结果。只要后续信息还能淘汰候选,就不要过早压成单个结果。

这种思路和 DFA 的“状态压缩历史”正好互补。DFA 强调把历史压成一个状态,NFA 强调在信息不足时保留多个状态。到了正则表达式编译时,两者会合在一起:正则表达式先自然地编译成 NFA,再在需要时通过子集构造法转成 DFA。

练习

第一,把示例改成接受 abbaaa 三个字符串。提示:从 start 增加一条空转移到新的分支。

第二,给 accept 增加对 ab 的自循环,让机器接受所有以 abba 开头的字符串。

第三,改写 follow_free_moves,用 while 循环代替递归。观察状态集合不再变大时怎样退出。

第四,构造一个状态图,让同一个状态读取同一个字符后能到达两个不同状态。打印每一步的状态集合,观察候选路径怎样分裂。

参考资料