确定有限状态自动机: 从游戏中学习并应用
1 前言
最近在码上掘金上发现一个名为“机器人流水线(manufactoria)”的小游戏,复刻这个游戏对作者也对这个游戏的规则和实现原理做了介绍。
今天我们是来在游戏的玩法和通关思路上做文章的,因此如果不了解游戏规则的话,可以阅读一下作者的介绍(开头的一小部分内容)。刚上手的时候可能对游戏的玩法和目标深感迷惑。这时候请务必坚持读一读介绍并实际操作一下。
当你在游戏中遇到困难,或者是成功通关了全部关卡,此时再回头看一看这篇文章下面的内容,相信你能有所收获。
2 确定有限状态机
2.1 定义
什么是确定有限状态机,根据 Wiki 上的定义
确定有限状态自动机或确定有限自动机(英語:deterministic finite automaton, DFA)是一个能实现状态转移的自动机。对于一个给定的属于该自动机的状态和一个属于该自动机字母表 的字符,它都能根据事先给定的转移函数转移到下一个状态(这个状态可以是先前那个状态)。
他可以被形式化地定义为五元组:
确定有限状态自动机 是由
- 一个非空有限的状态集合
- 一个输入字母表(非空有限的字符集合)
- 一个转移函数 (例如:)
- 一个开始状态
- 一个接受状态的集合
所组成的5-元组。
因此一个DFA可以写成这样的形式:。
不过放一堆符号在这显得过于晦涩难懂了。下面我举个例子,并且用自然语言给大家翻译一下:
- 状态集合就是状态机能表达的根据实际问题能够抽象出的全部状态。比如说我们要讨论一个人饿还是饱的问题,就存在两个状态:饿,饱。
- 输入字母表可以理解为引起状态变化的所有原因。比如说,导致人饿或者饱的可能原因有:吃饭、喝水、运动等动作。
- 转移函数代表状态集合和输入字母表的关联关系。比如说,“如果我饿了,我吃饭就饱了”,这一句话就说明了
饿 --吃饭--> 饱
这一条转移关系。 - 开始状态好理解,就是状态机初始处于哪个状态。比如说开始是饿的,或者是饱的。
- 接受状态代表了有限状态机的终止。就是说达到了什么状态后,状态机就结束了,不可再运行。
2.2 应用
属于是计算理论的东西,常用于计算机科学的形式语言领域和编译原理领域。而这个思想也能够被用来辅助思考和解决很多实际问题,比如说分析操作系统上程序的运行状态(此时状态集合就是程序的堆栈、寄存器等上下文信息),前端状态管理框架的设计,或者解决本文的游戏。
2.3 例子
下面举一个直观的例子来帮助大家理解这些东西
- 状态集合: {饿,饱,死}
- 输入字母表: {吃饭,喝水,运动}
- 转移函数:
吃饭 | 喝水 | 运动 | |
---|---|---|---|
饿 | 饱 | 饿 | 死 |
饱 | 死 | 饱 | 饿 |
死 | - | - | - |
- 开始状态:饿
- 结束状态:{死}
graph LR
饿((饿))
饱((饱))
死((死))
饿 --吃饭--> 饱
饿 --喝水--> 饿
饿 --运动--> 死
饱 --吃饭--> 死
饱 --喝水--> 饱
饱 --运动--> 饿
2.4 代码
如果你觉得理论知识还是不够清晰,擅长通过代码理解的话,可以看看下面基于 Java 状态机模式的 Demo
// State.java
/**
* 抽象状态接
* 每一个方法对应一个输入字母表中的元素
* 每一个方法返回转移到的下一个状态
*/
public interface State {
State eat();
State drink();
State exercise();
}
// StateHungry.java
/**
* 饥饿状态
*/
public class StateHungry implements State {
@Override
public State eat() {
System.out.println("饿 --吃饭--> 饱");
return Context.STATE_FULL;
}
@Override
public State drink() {
System.out.println("饿 --喝水--> 饿");
return Context.STATE_HUNGRY;
}
@Override
public State exercise() {
System.out.println("饿 --运动--> 死");
return Context.STATE_DEAD;
}
}
// StateFull.java
/**
* 饱腹状态
*/
public class StateFull implements State {
@Override
public State eat() {
System.out.println("饱 --吃饭--> 死");
return Context.STATE_DEAD;
}
@Override
public State drink() {
System.out.println("饱 --喝水--> 饱");
return Context.STATE_FULL;
}
@Override
public State exercise() {
System.out.println("饱 --运动--> 饿");
return Context.STATE_HUNGRY;
}
}
// StateDead.java
/**
* 死亡状态
*/
public class StateDead implements State {
@Override
public State eat() {
System.err.println("接受状态,不可转移");
return Context.STATE_DEAD;
}
@Override
public State drink() {
System.err.println("接受状态,不可转移");
return Context.STATE_DEAD;
}
@Override
public State exercise() {
System.err.println("接受状态,不可转移");
return Context.STATE_DEAD;
}
}
// Context.java
public class Context {
// 状态单例
public static final State STATE_HUNGRY = new StateHungry();
public static final State STATE_FULL = new StateHungry();
public static final State STATE_DEAD = new StateHungry();
private State currentState;
public Context() {
this(STATE_HUNGRY);
}
public Context(State initState) {
currentState = initState;
}
void eat() {
currentState.eat();
}
void drink() {
currentState.drink();
}
void exercise() {
currentState.exercise();
}
}
2.4 工作方式
确定有限状态自动机从起始状态开始,一个字符接一个字符地读入一个字符串 (这里的 指示Kleene星号算子。),并根据给定的转移函数一步一步地转移至下一个状态。在读完该字符串后,如果该自动机停在一个属于F的接受状态,那么它就接受该字符串,反之则拒绝该字符串。
简单地来说,对于确定有限状态自动机可以用来检验一个字符序列是不是符合规则的。
检测的过程为:
- 从初始状态开始,按顺序读取字符序列中的每一个字符
- 不断根据读入的字符进行状态转移
- 如果最后能够处于“接受状态”,那么这个字符序列就是合法的
- 如果读到一半发现转移不了,或者读完了不处于“接受状态”,那么这个字符序列就是不合法的。
这里其实对流程做了简化。严格按照定义,必须在读完所有字符后处于接受状态
3 状态机与游戏
该游戏的第1关至第7关解决的是同一类问题,即验证给定的输入序列是否满足特定的条件。
结合 2.4 工作方式,发现这两者之间确实是存在联系的。
于是我们将解决这个游戏的问题转化为以下两个步骤:
- 根据每个关卡的要求,建立状态机模型
- 利用游戏中的元件,将状态机模型表示出来
3.1 建立状态机模型
不难理解,以下是针对这个游戏能够建立的通用状态机模型
- 状态集合: 根据题目抽象出「合法」的状态
- 输入字母表: {开始结束符
#
,红球,蓝球}
- 输入字母表: {开始结束符
- 转移函数: 根据题目抽象
- 开始状态: {开始}
- 接受状态: {成功}
其中,我们为每一个检测序列的开头和结尾添加符号 #
,便于表示出“序列开始”和“序列结束”两种语义。例如,对于序列 {红,红,蓝},我们在状态机分析过程中认为他是 { #
,红,红,蓝, #
}
而状态集合需要根据题目进行抽象,这就是一个思维过程了(毕竟是个思维游戏)。不过这一思维过程是人类能够从多个例子从学习出潜在经验的。以下给出几个例子:
- 接受2个及以上蓝球的序列:{已接受0个蓝球,已接受1个蓝球,已接受2个蓝球}
- 队列必须以2个蓝球结尾:{已连续0个蓝球,已连续1个蓝球,已连续2个蓝球}
- 队列中不能出现红球:{未接受过红球} 因为一旦出现红球就是非法状态了
最后是转移函数的抽象,也是思维过程。针对上一步已经抽象出的状态集合,思考这样一个问题 「状态A接受输入字母表c中的字母后,会转移到哪个状态」,例如:
-
接受2个及以上蓝球的序列:「已接受0个蓝球」如果输入一个「蓝球」就会转移到「已接受1个蓝球」,那么就有转换关系
graph LR S1((已接受0个蓝球)) --蓝球--> S2((已接受1个蓝球))
-
队列中不能出现红球:「未接受过红球」如果输入一个「蓝球」就会转移到「未接受过红球」,那么就有转换关系(如果输入红球就失败了,因为根据前面的定义,不能读到一半就转移不下去)
graph LR S1((队列里未出现红球)) --蓝球--> S1((队列里未出现红球))
3.2 表示状态机模型
从建立好的状态机模型到游戏组件的表示是比较死板的过程。
- 状态机中的每个状态是必然会根据下一个输入转移的,这一行为和游戏中的「比较器」完全吻合
- 状态转移中的自环,可以用一个指向「比较器」自身的箭头表示
- 状态转移中不存在的转移关系,「比较器」对应的移动位置留白(这样就肯定达到不了终点了)
- 考虑每个状态对于输入
#
的转移关系,他代表“序列结束”,也就意味着这个状态对应的「比较器」将往弧形方向移动
4 关卡攻略
以下解法均为个人通关思路,仅供参考
4.1 第1关
第1关没有什么难点,纯粹是让新手熟悉游戏流程和操作模式的
graph LR
Start((开始)) --#--> E1((E1))
E1 --#--> Success((成功))
4.2 第2关
第2关用于熟悉比较器的使用方法。
在实现上,根据前文所述,我们可以将一个比较器视为一个状态,根据下一个状态的不同指向不同的分支。
graph LR
Start((开始))
S1((S1))
Success((成功))
Start --#--> S1
S1 --蓝--> Success
4.3 第3关
在状态机的构造上,题目是要求序列中有3个及以上蓝球。从状态转移的角度考虑,就是接收到红球时就保持状态不变;接收到蓝球时转移至下一个状态;接收到结束符号 #
时,必须处于已经接收了3个蓝球的状态,否则就失败。
在实现上,主要是利用比较器表达自环的状态,利用一个指向比较器自己的箭头元件就能实现。
- S1: 已接受0个蓝球
- S2: 已接受1个蓝球
- S3: 已接受2个蓝球
graph LR
Start((开始))
S1((S1))
S2((S2))
S3((S3))
Suc((成功))
Start --#--> S1
S1 --红--> S1
S1 --蓝--> S2
S2 --红--> S2
S2 --蓝--> S3
S3 --红--> S3
S3 --蓝--> Suc
4.4 第4关
在状态机的构造上,题目是要求序列不能有红球。那么我们就先走完整个序列,一旦遇到红球就失败,遇到蓝球就保持状态不变。只要不失败(也就是没有遇到红球),那么遇到结束符 #
时就说明成功。
在实现上,也是利用比较器构造自环状态。
- S1: 未接受过红球
graph LR
Start((开始))
S1((S1))
Suc((成功))
Start --#--> S1
S1 --蓝--> S1
S1 --#--> Suc
4.5 第5关
这一关相比前面的关卡复杂了一些。
在状态机的构造上,题目要求不能出现不同颜色的球。换句话说,一旦第一个球是蓝色,那么后面的球也必须都是蓝色;一旦第一个球是红色,那么后面的球也必须都是红色。基于这个思路的转换,结合上一关的通关思路,就能解决这个问题了。
- S1: 未接受任何球
- S2: 已接受蓝球
- S3: 已接受红球
graph LR
Start((开始))
S1((S1))
S2((S2))
S3((S3))
Suc((成功))
Start --#--> S1
S1 --蓝--> S2
S1 --红--> S3
S2 --蓝--> S2
S3 --红--> S3
S1 --#--> Suc
S2 --#--> Suc
S3 --#--> Suc
4.6 第6关
第6关是要检验以两个蓝色球结尾的序列。可以结合第3关的思路,碰到蓝球的时候就转移到下一个状态,碰到红球时就返回最开始的状态。碰到结束符 #
时必须处于已经连续碰到2个蓝球的状态才能成功。
- S1: 已连续接受0个蓝球
- S2: 已连续接受1个蓝球
- S3: 已连续接受2个蓝球
graph LR
Start((开始))
S1((S1))
S2((S2))
S3((S3))
Suc((成功))
Start --#--> S1
S1 --红--> S1
S1 --蓝--> S2
S2 --红--> S1
S2 --蓝--> S3
S3 --红--> S1
S3 --蓝--> S3
S3 --#--> Suc
4.7 第7关
这一关是要验证序列的开头和结尾球颜色相同。从状态的角度考虑,首先开头的球的颜色肯定是要做一个分支的。我们以开头为红球的路线为例(开头为蓝球的情况是对称的),当我们遇到结束符 #
的时候,如果前一个球是红球,那么就成功;如果前一个球是蓝球,那么就失败。因此,在序列的解析过程中,我们可以不断地在“即将成功”和“即将失败”两个状态间转移,即遇到红球是转移到“即将成功”状态,遇到蓝球是转移到“即将失败状态”。
- S1: 未接受任何球
- S2: 队列开头为红球
- S3: 队列开头为红球,最近接收到的是蓝球
- S4: 队列开头为蓝球
- S5: 队列开头为蓝球,最近接收到的是红球
graph LR
Start((开始))
S1((S1))
S2((S2))
S3((S3))
S4((S4))
S5((S5))
Suc((成功))
Start --#--> S1
S1 --红--> S2
S1 --蓝--> S4
S2 --红--> S2
S2 --蓝--> S3
S3 --红--> S2
S3 --蓝--> S3
S4 --红--> S5
S4 --蓝--> S4
S5 --蓝--> S4
S5 --红--> S5
S1 --#--> Suc
S2 --#--> Suc
S4 --#---> Suc
5 课后作业
除了第1关至第7关外,还有第11关和第13关也可以用有限状态机的思想解决。其中第11关和上述某个关卡的解决方案是类似的,而第13关可能需要一点点的技巧对题目做一些转换。相信读者在看完这篇文章之后,能够自己通过自己的思考和对状态机的运用解决这两关。
这两关的解题思路将在下一期揭晓(如果还有下一期的话)。