📘 第3章 自动机和人工生命程序
📄 一句话概括

有限自动机是「状态」这个东西最干净的形态:一张转移表,加上「现在在哪」。没有变量、 没有循环、没有计算,只有「遇到什么、去哪」。 它强的地方是把「有哪些情况」变成可数的东西 —— 表有几行就是几种情况,漏没漏一眼能看出来。 弱的地方在「有限」两个字:状态数是硬上限,一旦要记住一个数,就顶到天花板。

📝 本节和前面几节的关系

上一节的生命游戏,每个格子就是一个只有死活两态的自动机,四千多个并排跑。 这一节把自动机缩小到一个,看清它内部那张表长什么样。 这一节也是后面所有「状态」概念的底座。 马尔可夫模型给转移加了概率,状态驱动智能体给状态加了动作,加了这两样之后,本质还是这张表。

一、一个自动机只有三样东西

状态集合、输入、转移。数学上叫五元组(再多一个起始状态、一个接受状态), 但真正要写的只有这三样。 教材用的例子是自动售货机。它里面记的不是「收了多少钱」,而是「攒到哪一档了」—— 这就是状态。

当前状态 收到 下一状态
待机 五角 五角
待机 一元 一元
五角 五角 一元
五角 一元 该出货
一元 五角 该出货
一元 一元 该出货
退币那几行省略了,它们都指回「待机」。
四个状态、三个动作,最多十二行。这张表就是整个程序:没有变量、没有计算、没有循环。
它凭什么能算账?因为「攒到哪一档」这个状态本身就把钱数编进去了。四个状态能表示零元、五角、
一元、够出货 —— 一共四档。这也是它的天花板:想卖更贵的东西,就得多加状态。

二、表驱动:把「有哪些情况」和「怎么处理」拆开

同一件事也可以写成一串条件判断。两种写法在各件事上的代价不一样,Demo 第三段列了五项:

要做的事 查表 if 堆
加一个状态 加几行数据 重排判断顺序
加一个动作 加几行数据 到处补条件
查某个组合 一次取值 顺着读完
验证没漏组合 数表的行数 跑用例才知道
解释给人听 把表画出来 把代码读一遍
这张表里最要紧的是第四行:验证没漏组合。十二种组合,表里写了十行,
剩下的两种靠「表里没有」兜住 ——
这个「剩下两种」是数出来的,不是猜的。if 堆要靠跑用例才发现漏了哪一条。
工程上这套写法有名字:状态机模式。编译器、协议栈、游戏引擎里到处都是。
但它不是万能的。表是数据,编译器不会帮你检查「状态名是不是拼错了」;
if 是代码,拼错了编译不过。所以真实系统常常是「表 + 一层校验」,写完之后用脚本把表整个过一遍。

三、状态会相乘

Demo 第四段做了一件很朴素的事: 一扇门有 2 个状态,一盏灯有 2 个状态。要描述「门和灯同时处在什么状况」,就得用组合状态。 2 × 2 = 4。这不是巧合,是乘法:两套独立的状态合起来,状态数是乘的。 三个各有 4 个状态的部件凑一起,就是 4 的 3 次方 = 64;五个部件 1024;六个部件 4096。 Demo 那张表列到第六行,就是为了让这个数字难看。 这就是「状态爆炸」。真实的系统不会把状态摊平成一张大表,而是分成几个小自动机各管一摊, 只在对得上的时候对一下暗号。这套办法叫层级状态机,或者直接叫状态图。 顺带说一句:并发程序里的「状态空间爆炸」是同一个东西。验证一个多线程程序为什么难, 就是因为线程数一多,组合状态数就把内存吃完了。

四、「有限」这两个字卡在哪

Demo 第五段出了一道题:一串 a 后面跟同样多的 b,判断 a 和 b 个数是不是一样多。 自动机没有变量,想数数只能靠「数到第几个就停在第几个状态」。

状态数 最长认得的 n 实际用掉
4 3 4 个
8 7 8 个
16 15 16 个
32 31 32 个
64 63 64 个
关系是线性的:状态数 K,最长能认 n = K - 1。状态翻一倍,长度也只翻一倍。
想让它在任意长的串上都不出错,状态数就得是无穷大 —— 那就不是「有限」自动机了。
认 a^n b^n 需要的是一个栈(下推自动机),认更复杂的需要一条纸带(图灵机)。
这三层的分界线,是 1950 年代乔姆斯基那套语言分层里的核心结论。
但现实里没人真去数状态。程序里随便一个整数计数器能到 20 亿,等于白送了 20 亿个状态。
自动机理论里的「有限」,说的是不许有这种计数器。这条界线在理论上很硬,在工程上基本不构成限制。

五、它在软件里的位置

四个地方,一句话一个。

场合 状态是什么
词法分析 读到哪一类字符了
协议 握手到第几步了
游戏 AI 敌人现在是巡逻还是追击
硬件电路 每个触发器存一位
词法分析那条值得多说一句。正则表达式和有限自动机是等价的(Kleene 定理):
任何一条正则表达式都能机械地转成一张转移表,反过来也一样。所以你写的每一条正则,
最后都变成了一张表在跑。
一句话收尾:凡是能说清楚「现在处在哪一档」的地方,都有有限自动机;
凡是需要「记住一个数」的地方,它就不够用了。

六、动手 Demo:从售货机到「数不清的 a」

这段代码里有两件事:一张表怎么跑,和一台机器怎么被「有限」卡住。 前台那部分是个字典,键是「(当前状态, 输入)」,值是下一状态。查一次就是一次取值,没有分支。 后面那部分用同一个构造函数造了一串自动机,状态数从 4 试到 64,各自去认 a^n b^n。 它们只有 K 不一样。

TABLE = {
    (u'待机', u'五角'): u'五角',
    (u'待机', u'一元'): u'一元',
    (u'五角', u'五角'): u'一元',
    (u'五角', u'一元'): u'该出货',
    (u'一元', u'五角'): u'该出货',
    (u'一元', u'一元'): u'该出货',
    (u'待机', u'退币'): u'待机',
    ...
}

def run(seq):
    """按表跑一串输入,碰见表里没有的组合就停。"""
    st = u'待机'
    for act in seq:
        nxt = TABLE.get((st, act))
        if nxt is None:
            return u'卡住'
        st = nxt
    return st

def build(K):
    """K 个状态的自动机:a 让计数加一,b 让计数减一。"""
    trans = {}
    for s in range(K):
        trans[(s, u'a')] = s + 1 if s < K - 1 else None
        trans[(s, u'b')] = s - 1 if s > 0 else None
    return trans

实跑输出:

--------------------------------------------------------------------
状态转移表
--------------------------------------------------------------------
==============================================================

[1] 自动机就是一张表

  卖饮料的机器,只认三个动作:投五角、投一元、退币。
  它里面记的不是钱,是「攒了多少」这一个状态:

  状态:待机、五角、一元、该出货

    当前状态    收到      下一状态
    ----------------------------------
    待机        五角      五角
    待机        一元      一元
    待机        退币      待机
    五角        五角      一元
    五角        一元      该出货
    五角        退币      待机
    一元        五角      该出货
    一元        一元      该出货
    一元        退币      待机
    该出货      退币      待机

  这张表就是整个程序。没有循环、没有计算、没有变量,
  只有「现在在哪、听到什么、去哪」三件事。
  状态一共 4 个,动作一共 3 个,表最多 12 行。

[2] 按表跑一串输入

  输入:五角、五角、一元

    待机     收到「五角  」→ 五角
    五角     收到「五角  」→ 一元
    一元     收到「一元  」→ 该出货

  最后落在「该出货」,可以出货

  输入:五角、退币、一元、五角

    待机     收到「五角  」→ 五角
    五角     收到「退币  」→ 待机
    待机     收到「一元  」→ 一元
    一元     收到「五角  」→ 该出货

  最后落在「该出货」,可以出货
  退币之后状态回到待机,前面投的那五角等于白投——
  状态一清,历史就没了。这是自动机最要紧的一条性质。

[3] 同一件事,用 if 堆出来会怎样

  不用表,改成一串条件判断,最直接的写法是这样:

    if 状态 == 待机 and 动作 == 五角: 状态 = 五角
    elif 状态 == 待机 and 动作 == 一元: 状态 = 一元
    elif 状态 == 五角 and 动作 == 五角: 状态 = 一元
    ...

  状态 4 个、动作 3 个,组合 12 种;表里写了 10 行,
  剩下的 2 种组合得靠 else 兜住。

  两种写法在各件事上的代价:

    要做的事              查表        if 堆
    ----------------------------------------------
    加一个状态「两元」    加几行数据  重排判断顺序
    加一个动作「扫码」    加几行数据  到处补条件
    查「某个组合怎么办」  一次取值    顺着读完
    验证没漏组合          数表的行数  跑用例才知道
    解释给同事听          把表画出来  把代码读一遍

  这不是风格问题。表把「有哪些情况」和「怎么处理」拆开了,
  所以情况有几种能数出来,漏没漏也能看出来。

[4] 两个自动机一起跑,状态数是乘起来的

  一扇门有 2 个状态(关、开),一盏灯有 2 个状态(灭、亮)。
  要描述「门和灯同时处在什么状况」,就得用组合状态:

    关-灭  关-亮  开-灭  开-亮
    一共 2 x 2 = 4 种

  这不是巧合,是乘法。三个各 4 状态的部件凑在一起,
  组合状态就是 4 的 3 次方 = 64 种:

    1 个部件(每个 4 状态):4 种组合状态
    2 个部件(每个 4 状态):16 种组合状态
    3 个部件(每个 4 状态):64 种组合状态
    4 个部件(每个 4 状态):256 种组合状态
    5 个部件(每个 4 状态):1024 种组合状态  ← 已经数不过来了
    6 个部件(每个 4 状态):4096 种组合状态  ← 已经数不过来了

  所以真实的系统不会把状态摊平成一张大表,
  而是分成几个小自动机,各管一摊,只在对得上的时候对一下暗号。
  状态爆炸是有限自动机最现实的敌人。

[5] 「有限」这两个字卡在哪里

  出一道理题:一串 a 后面跟同样多的 b,比如 ab、aabb、aaabbb,
  判断「a 和 b 的个数是不是一样多」。

  自动机要判断这件事,只能靠「数到几」。可状态是有限的,
  数到第几个就得记在第几个状态里。下面真造若干台这样的机器,
  状态数从 4 试到 64,看每台最多能认多长的串:

    状态数    最长认得的 n    实际用掉
    --------------------------------------------
    4         3               4 个
    8         7               8 个
    16        15              16 个
    32        31              32 个
    64        63              64 个

  状态翻一倍,能数的长度也只翻一倍。想让它在任意长的串上都不出错,
  状态数就得是无穷大 —— 那就不是「有限」自动机了。

  实际写程序时没人真去数状态,因为程序里有个整数计数器,
  计数器能到 20 亿,等于白送了 20 亿个状态。
  自动机理论里的「有限」,说的是不许有这种计数器。

  记住这条界线就够了:能用「现在处在哪一档」说清楚的事,
  自动机都干得漂亮(词法分析、协议握手、电梯调度、游戏里的敌人 AI);
  一旦要「记住一个数」,就轮到带栈或带内存的模型出场了。

第一段先把表摆出来。注意 TABLE.get((st, act)) 返回 None 的时候程序停住 —— 这就是「表里没写的情况」,不是错误,是设计的一部分。十二种组合只写了十行, 另外两种(该出货状态又收到硬币)按物理常识该忽略。 第二段跑两串输入。第二串里有个「退币」, 状态从「五角」回到「待机」—— 前面投的那五角等于白投,因为状态一清,历史就没了。这是自动机最要紧的性质: 记忆全在状态里,状态没了,记忆就没了。 第三段那张对照表里没有数字,但它是这一节最该记住的。 表把「有哪些情况」和「怎么处理」拆成两件事: 情况有几行、漏没漏,是数出来的;处理写在哪,是另一件事。if 堆把两件事搅在一起, 改一处得重读一遍。 第四段是乘法。2 × 2 = 4 太小儿科,但 4 的 5 次方 = 1024、4 的 6 次方 = 4096 就不是了。 真实系统的状态从来不是「设计出来的」,是「乘出来的」。 第五段是这一节的骨头。4 个状态认到 n=3,8 个认到 n=7,64 个认到 n=63。 一条直线,斜率是 1。这就是「有限」的代价:想多认一位,就得多一个状态。 最后那列「实际用掉」是给较真的人看的。每台机器都刚好用掉全部 K 个状态 —— 不是浪费, 是这种构造恰好把状态用满了。换个构造能省一些,但省不出数量级:线性就是线性。

30 秒实验

  1. 在 TABLE 里加一行 (u'待机', u'扫码'): u'两元',重跑第二段。 不用改 run,一个字都不用改 —— 这就是表驱动的好处。
  2. 把第五段的 K 换成 128,看最长能认的 n。应该是 127。不信这个规律的话再试 100 —— 应该是 99。
  3. 把 build 里撞到顶时返回的 None 改成 s(原地不动)。 那么它会把 a^10 b^10 在 K=8 时一路认下去、最后判错 —— 错法变了,错还是错。

常见误区

  1. 以为状态机就是 if-else 换了种写法。差别不在写法,在「情况」变成了一份可数的数据。 数得出来,才能验证没漏。
  2. 以为状态越多越聪明。状态是乘起来的,加一个维度可能翻几倍。 智能体设计里最常见的错误就是「再加一个状态就能解决」,结果状态空间炸了。
  3. 以为「有限」是个理论玩具。一个整数计数器就是 20 亿个状态,程序员的日常早就越过了这条线。 「有限」约束的是理论模型,不是你的代码。
  4. 以为自动机只能处理字符串。售货机、电梯、红绿灯、TCP 连接,都是自动机。 它的适用面是「一档一档的处境」,跟字符没关系。
自测三问(点开对答案) 1. 为什么自动机描述「攒了多少钱」可以,描述「攒了多少个 a」就有上限? —— 因为钱只有几档(免费、五角、一元、够出货),**档数固定,用有限个状态就能穷举**。 而 a 的个数没有上限,**要认任意长的串就得有任意多个状态**。 差别不在难度,在「要记的东西取值有没有上限」。 2. 两个 4 状态的部件合起来为什么是 16 个状态,不是 8 个? —— 因为组合状态要能区分「A 在第几档、B 在第几档」的每一种搭配。 **A 有 4 种、B 有 4 种,搭配就是 4 × 4 = 16 种。**如果只有 8 个, 那只能记住「两边加起来在第几档」,丢掉了一部分信息。 3. 表驱动和 if 堆,最实质的区别是什么? —— **表把「有哪些情况」变成了一份可以数的数据。**十二种组合,表里写了十行,剩下两行是数出来的; if 堆要靠跑用例才发现漏了哪条。 **另外加状态、加动作,表只加数据,if 堆要重排逻辑。**代价是表不会被编译器检查。

自动机的下一个状态是确定的 —— 现在是这一步,下一步一定去那儿。 可现实里下一步往往是「大概去哪」。 下一节把转移表里的箭头换成概率,同一个骨架换一种读法,就成了马尔可夫模型。

相关笔记