使用 JavaScript 实现有限状态机的经典问题

前言

在编译技术领域需要分词,而分词需要使用有限状态机来实现,所以只有彻底掌握了状态机的原理,才可以入门编译领域。我们在上一篇中已经初步讲解了状态机的实现原理以及它的一种实现方式,也就是状态模式,本篇我们将进入状态的实战部分,编程技术不可能只通过干瘪的技术原理讲解就可以掌握的,必须通过实战,所以让我们进入状态机的实战部分吧。

在状态机中有一个经典的问题,就是在通讯序列当中查找"帧头"。通过快速准确地查找帧头,接收端可以迅速定位到数据帧的起始位置,并开始处理数据。这有助于提高数据传输的效率,减少因等待帧头而导致的延迟。同时只有正确识别出帧头,接收端才能根据帧头中的控制信息对后续的数据进行正确的解析和处理。如果帧头被误读或遗漏,那么整个数据帧都可能被错误地处理。

那么有限状态机是怎么应用到这里面的呢?又怎么通过 JavaScript 来实现呢?

有限状态机的经典问题

在状态机有一个经典问题,也是光纤通讯系统中的一个经典问题,就是我们前言中说的,在通讯序列当中找"帧头"(如"1011"模式),比如在一个特别长的二进制串当中,找到连续的1011

例如下面的例子:

输入:11110111101111010001010010110100001011011111101011001001010010010111

输出:00000010000100000000000000010000000001001000000001000000000000000010

这个例子想表达的是当输入信号与1011匹配时,状态机会按照预定的逻辑进行状态转换,并产生相应的输出信号。这种状态机在数字电路、控制系统和通信系统中有着广泛的应用。

具体来说就是当输入 1011 的时候,就要输出 0001,也就是还没匹配到完整的 1011 时,不论输入是 0 还是 1 输出都是 0。此外当前一个1011和后一个1011有一个比特重叠的时候,需要输出两个 1。比如输入 1011011 就要输出 0001001,因为前面一个 1011 的最后一个 1 也是后面一个 1011 的第一个 1

那么我们要实现上述功能,我们就可以使用状态机来实现。那么根据我们前面对状态机的了解,我们首先要给状态机定义状态,然后再设定转换的规则,在我们上一篇中的红绿灯的例子,状态机的状态就是红、绿、黄,状态的转换规则是红转绿、绿转黄、黄转红。

那么对于帧头 1011,我们可以定义四个状态:

  • S1:初始状态,尚未匹配到任何部分

  • S2:已经匹配到第一个 1

  • S3:已经匹配到 10

  • S4:已经匹配到 101

设置状态转换规则:

  • 在 S1 状态下输入 1 输出 0,状态切换到 S2

  • 在 S2 状态下输入 0 输出 0,状态切换到 S3,(匹配到 1)

  • 在 S3 状态下输入 1 输出 0,状态切换到 S4,(匹配到 10)

  • 在 S4 状态下输入 1 输出 1,状态切换到 S2,(匹配到 101)

上述状态转换规则就是 1011 的理想状态转换规则。

那么根据我们前面所学的状态机知识,我们可以通过以下代码实现上述帧头 1011 的状态机,即通过状态机找到帧头时输出相应的信号。实现代码如下:

javascript 复制代码
// 定义状态
const State = {
    'S1': 1,
    'S2': 2,
    'S3': 3,
    'S4': 4
}
// 用于追踪当前的状态,初始状态被设置为 S1
let state = State.S1
// 用于存储状态机在处理输入序列时产生的输出
const output = []
// parse 函数接收一个字符 c 作为输入,并根据当前状态和输入字符来决定下一步的状态和输出
function parse(c) {
    switch(state) {
        case State.S1: {
            if (c === '1') {
                // 在 S1 状态下,如果输入是 '1',则输出 '0',并将状态切换到 S2
                output.push('0')
                state = State.S2
            }
            break
        }
        case State.S2: {
            if (c === '0') {
                // 在 S2 状态下,如果输入是 '0',则输出 '0',并将状态切换到 S3
                output.push('0')
                state = State.S3
            }
            break
        }
        case State.S3: {
            if (c === '1') {
                // 在 S3 状态下,如果输入是 '1',则输出 '0',并将状态切换到 S4
                output.push('0')
                state = State.S4
            }
            break
        }
        case State.S4: {
            if (c === '1') {
                // 在 S4 状态下,如果输入是 '1',则输出 '1',并将状态切换到 S2
                output.push('1')
                state = State.S2
            }
            break
        }                
    }
}
// 输入序列
const input = '1011'

for (let i = 0; i < input.length; i ++) {
    // 输入序列 '1011' 被逐个字符地传递给 parse 函数进行处理
    parse(input[i])
}
// 输出结果
console.log('输出:', output.join(''))

一个状态机最核心的三要素:拥有多种状态(即变量 State)、当前处于其中一种状态(即变量 state)、状态切换(即 parse 函数)。上段代码就通过实现一个状态机的最核心三要素来实现了一个简单的状态机(State Machine),用于处理一个特定的输入序列(在这个例子中是字符串 1011),然后状态机根据当前的状态和接收到的字符来决定下一步的状态和输出,最终实现我们想要的效果,即输入 1011,输出 0001

通过面向对象编程(OOP)迭代状态机

其实状态机的实现跟发布订阅模式一样,并没有固定的代码结构,我们上述状态机的实现是通过过程式实现的,那么根据我们上一篇文章可以知道这种方式存在很多问题,例如 State、state、output 都是全局变量,这可能导致命名冲突和其他潜在的问题。我们可以通过 OOP 的方式将数据(属性)和处理数据的操作(方法)封装在一起,形成一个不可分割的整体,从而避免全局变量污染。

重新使用 OOP 的方式实现上述状态机功能,代码如下:

javascript 复制代码
// 定义状态
const State = {
'S1': 1,
'S2': 2,
'S3': 3,
'S4': 4
}

class StateMachine {
// 用于追踪当前的状态,初始状态被设置为 S1
state = State.S1
// 用于存储输入字符序列的属性,在 `parse` 方法中,输入字符串被赋值给这个属性
buffer = ''
// 用于追踪 `buffer` 中当前正在处理的字符的索引
index = 0
// 用于存储状态机在处理输入序列时产生的所有输出的数组
output = []
constructor() {

}
// parse 函数接收一个字符 c 作为输入,并根据当前状态和输入字符来决定下一步的状态和输出
parse(input) {
    this.buffer = input
    while (this.index < this.buffer.length) {
        const c = this.buffer[this.index]
        switch(this.state) {
            case State.S1: {
                if (c === '1') {
                  // 在 S1 状态下,如果输入是 '1',则输出 '0',并将状态切换到 S2
                  this.output.push('0')
                  this.state = State.S2
                }
                break
            }
            case State.S2: {
                if (c === '0') {
                  // 在 S2 状态下,如果输入是 '0',则输出 '0',并将状态切换到 S3
                  this.output.push('0')
                  this.state = State.S3
                }
                break
            }
            case State.S3: {
                if (c === '1') {
                  // 在 S3 状态下,如果输入是 '1',则输出 '0',并将状态切换到 S4
                  this.output.push('0')
                  this.state = State.S4
                }
                break
            }
            case State.S4: {
                if (c === '1') {
                  // 在 S4 状态下,如果输入是 '1',则输出 '1',并将状态切换到 S2
                  this.output.push('1')
                  state = State.S2
                }
                break
            }                
        }
        this.index++
    }
}
}
// 实例化状态机
const stateMachine = new StateMachine()
// 输入特定序列
stateMachine.parse('1011')
// 输出
console.log(stateMachine.output.join(''))

通过上述状态机的迭代例子,我们可以很好地学习了 OOP 在 JavaScript 中的思想与作用,更多的是封装性,我们一般可以通过函数进行封装相关的功能,而相关联的一组函数,我们又可以通过 OOP 来进行封装,使得代码更加模块化,易于理解和维护。一旦代码被封装成类,它可以在多个项目中被重用,而不需要修改。

状态转换表的引入

我们上述状态机的实现只针对 1011 实现的,如果输入不是 1011 的情况,我们还没有实现,例如在状态 S1 下,输入的不是 1,而是 0,我们在上述代码中还没有实现,同样的如果在状态 S2 中输入的不是 0 而是 1,我们同样也还没实现,而这么多状态,我们先要把它们之间的转换关系梳理清楚。为了更直观清晰地表达它们状态之间的转换关系,我们可以通过一个表来描述清楚。

当前状态 已经匹配 输入 下一个状态 输出
S1 0 S1 0
S1 1 S2 0
S2 1 0 S3 0
S2 1 1 S2 0
S3 10 0 S1 0
S3 10 1 S4 0
S4 101 0 S3 0
S4 101 1 S2 1

总的来说就是先判断当前状态,再看输入,再决定输出。

  1. 在 S1 状态下(什么也没匹配到):

    如果输入是 '0',则输出 '0',并继续保持在 S1 状态。

    如果输入是 '1',则输出 '0',则切换到 S2 状态。

  2. 在 S2 状态下(已经匹配到 1):

    如果输入是 '0',则输出 '0',并切换到 S3 状态。

    如果输入是 '1',则输出 '0',并继续保持在 S2 状态。

  3. 在 S3 状态下(已经匹配到 10):

    如果输入是 '0',则输出 '0',并切换到 S1 状态。

    如果输入是 '1',则输出 '0',并切换到 S4 状态。

  4. 在 S4 状态下(已经匹配到 101):

    如果输入是 '0',则输出 '0',并切换到 S3 状态。

    如果输入是 '1',则输出 '1',并切换到 S2 状态。

上述就是在通讯序列当中找"帧头"(如"1011"模式)中的状态机的所有状态的转换规则。除了通过表来描述状态的转换规则还可以通过画图进行表示。

状态机经典图示

除了使用表格对状态机的状态转换规则进行描述外,我们还可以通过图示进行描述,以下就是通过图示表示的上述1011状态机的状态转换规则。

上面的图示就是状态机的经典图示。

每一个状态都是通过一个圆圈来表示,我们上述S1、S2、S3和S4 四种状态则通过四个圆圈来表示。其中状态机启动的状态则使用两个圆圈表示,状态之间使用箭头连接起来,箭头从当前状态指向下一个状态,表示状态转换的方向,在连线上使用 / 分为左右两部分,左边为输入,右边为输出。

完善状态机代码

那么我们根据上面的状态转换表以及状态机图示,再进行完善我们的状态机代码:

javascript 复制代码
// 定义状态
const State = {
'S1': 1,
'S2': 2,
'S3': 3,
'S4': 4
}

class StateMachine {
    // 用于追踪当前的状态,初始状态被设置为 S1
    state = State.S1
    // 缓冲区内容
    buffer = ''
    // 正在查看的缓冲区内的索引
    index = 0
    // 用于存储状态机在处理输入序列时产生的输出
    output = []
    constructor() {

    }
    // parse 函数接收一个字符 c 作为输入,并根据当前状态和输入字符来决定下一步的状态和输出
    parse(input) {
        this.buffer = input
        while (this.index < this.buffer.length) {
            const c = this.buffer[this.index]
            switch(this.state) {
                case State.S1: {
                    if (c === '1') {
                      // 在 S1 状态下,如果输入是 '1',则输出 '0',并将状态切换到 S2
                      this.output.push('0')
                      this.state = State.S2
                    } else if (c === '0') {
                      // 在 S1 状态下,如果输入是 '0',则输出 '0',并将状态切换到 S1
                      this.output.push('0')
                      this.state = State.S1
                    }
                    break
                }
                case State.S2: {
                    if (c === '0') {
                      // 在 S2 状态下,如果输入是 '0',则输出 '0',并将状态切换到 S3
                      this.output.push('0')
                      this.state = State.S3
                    } else if (c === '1') {
                      // 在 S2 状态下,如果输入是 '1',则输出 '0',并将状态切换到 S2
                      this.output.push('0')
                      this.state = State.S2
                    }
                    break
                }
                case State.S3: {
                    if (c === '1') {
                      // 在 S3 状态下,如果输入是 '1',则输出 '0',并将状态切换到 S4
                      this.output.push('0')
                      this.state = State.S4
                    } else if (c === '0') {
                      // 在 S3 状态下,如果输入是 '0',则输出 '0',并将状态切换到 S1
                      this.output.push('0')
                      this.state = State.S1
                    }
                    break
                }
                case State.S4: {
                    if (c === '1') {
                      // 在 S4 状态下,如果输入是 '1',则输出 '1',并将状态切换到 S2
                      this.output.push('1')
                      this.state = State.S2
                    } else if (c === '0') {
                      // 在 S4 状态下,如果输入是 '0',则输出 '0',并将状态切换到 S3
                      this.output.push('0')
                      this.state = State.S3
                    }
                    break
                }                
            }
            this.index++
        }
    }
}

// 实例化状态机
const stateMachine = new StateMachine()
// 输入特定序列
stateMachine.parse('11110111101111010001010010110100001011011111101011001001010010010111')
// 输出 00000010000100000000000000010000000001001000000001000000000000000010
console.log(stateMachine.output.join(''))

最终我们可以看到输出的结果如我们所预期的了。

有限状态机与编译技术的联系

在编译过程的词法分析阶段 ,编译器需要将源代码字符串分解成一系列的标记(token)。这个过程可以看作是一个有限状态机在处理输入字符流,根据当前状态和输入的字符转移到下一个状态,并输出相应的标记。这个过程跟我们上述找帧头1011的例子是一样的,源代码可以看作原始字符串 1011,然后进行转换成 0001,而在编译中则可能是将源代码先转换成一系列的标记(token),也有可能是直接转换成 AST(即抽象语法树)。有限状态机在这里起到了识别和转换输入字符为标记的关键作用。

语法分析阶段主要关注于检查标记流是否符合语言的语法规则。尽管这一阶段的状态机可能不如词法分析阶段那样直观,但编译器在处理语法时,内部往往也采用了类似状态机的逻辑,通过维护一个状态栈或状态表来跟踪当前的语法结构和可能的转移路径。

有限状态机和编译技术都擅长将复杂的问题分解成多个简单的小问题,即善于分解复杂性。在编译过程中,编译器通过词法分析、语法分析等阶段逐步将源代码分解成更易于处理的部分。同样,状态机通过将系统状态分解为有限个状态,并在状态之间建立明确的转移关系,从而简化了复杂系统的建模和控制。

无论是编译技术还是有限状态机,都强调基于状态的逻辑处理。编译器在处理源代码时,会根据当前的状态(如当前正在分析的词法单元、语法结构等)来决定下一步的操作。状态机则通过维护当前状态并根据输入事件来触发状态转移和相应的动作。

总结

本文主要讲解了状态机在处理通讯序列中查找特定"帧头"(如"1011"模式)的应用,并通过几个关键部分逐步深入介绍了状态机的实现和进行面向对象编程优化。主要有以下六点内容:

  1. 首先回顾了状态机的基本原理,包括状态的定义、状态之间的转换规则以及状态机在编译技术和通信领域中的重要性。
  2. 通过一个状态机的经典例子(查找二进制串中的"1011"帧头),详细展示了如何使用状态机来解决问题。定义了四个状态(S1-S4),并设定了状态之间的转换规则,然后使用JavaScript代码实现了这个状态机。
  3. 接着通过面向对象编程(OOP)的进行迭代状态机代码,因为过程式实现状态机可能存在的问题(如全局变量污染),所以通过OOP的思想重新实现了状态机。具体就是将状态机的状态、缓冲区、索引和输出等封装成一个类(StateMachine),使得代码更加模块化,易于理解和维护。
  4. 为了更清晰地描述状态之间的转换关系,引入了状态转换表。通过表格列出了所有可能的状态转换情况,包括当前状态、输入、下一个状态和输出,使得状态机的行为一目了然。
  5. 除了状态转换表,还介绍了如何使用图示来表示状态机的状态转换规则。图示通过圆圈表示状态,箭头表示状态转换方向,并在箭头上标注输入和输出,直观展示了状态机的运行流程。
  6. 最后根据状态转换表和图示,进一步完善了状态机的代码,处理了所有可能的输入情况,并确保了状态机能够正确输出预期的结果。
  7. 有限状态机与编译技术之间存在着紧密的联系。有限状态机在编译过程中得到了广泛的应用,特别是在词法分析和语法分析阶段。同时,有限状态机和编译技术都采用了分解复杂性和基于状态的逻辑处理的方式来解决问题。这种联系不仅体现了两种技术在方法论上的相似性,也展示了它们在解决实际问题时的强大能力。

总的来说,本文通过理论讲解和实战示例相结合的方式,深入探讨了状态机的原理、实现和优化方法,帮助我们理解并掌握状态机在编程中的应用。

相关推荐
早期的虫儿有鸟吃8 小时前
vue2--Vuex 模块化
开发语言·前端·javascript
C++、Java和Python的菜鸟8 小时前
第5章 后端Web基础 (MySQL基础)
前端·mysql·adb
门前大桥下.8 小时前
HTML-01我的第一个网页
前端·html
我是大卫8 小时前
【图】React源码解析-从数据结构、依赖追踪机制、值传播与更新触发、以及性能陷阱与优化,深挖useContext的底层原理
前端·react.js·源码
谭光志8 小时前
深入浅出 RAG:用一个可运行的 Demo 讲透完整链路
前端·后端·ai编程
道友可好8 小时前
前端工程师的 AI 时代生存指南
前端·人工智能·后端
●VON8 小时前
鸿蒙 PC Markdown 编辑器质量流水线:Web 构建、回归与 Release 门禁
前端·华为·编辑器·harmonyos·鸿蒙
acheding8 小时前
File System Access API 实战:让网页真正读写本地文件
前端·javascript·vue.js·编辑器·markdown
何时梦醒8 小时前
⚛️ React 19 + TypeScript 深度学习笔记 —— 从组件化思维到 WebGPU 端侧 AI 落地
前端·javascript·人工智能