打造高效编译器的基石:词法分析(Lexical Analysis)与表驱动DFA/NFA详解

544 阅读29分钟

词法分析(Lexical Analysis)概述

词法分析(Lexical Analysis),也称为扫描(Scanning),是编译器的第一个阶段,它的主要任务是将源代码(通常是文本形式的程序代码)转化为一系列的记号(tokens) ,以便后续的语法分析和语义分析等过程能够更加高效地处理程序。

词法分析的目标是将源代码中复杂的字符序列(比如字母、数字、符号等)分解成有意义的单元(即记号),这些记号可以是关键字、标识符、常量、运算符等。

词法分析的步骤

词法分析的过程包括以下几个主要步骤:

  1. 输入:源代码(如 C、Java、Python 等编程语言的源代码)作为字符流输入给词法分析器。
  2. 扫描字符流:词法分析器逐个字符地扫描输入的源代码,识别出符合某种模式的字符组合作为记号。
  3. 生成记号:识别出的一系列字符(如字母、数字、运算符等)被归类为不同类型的记号,例如:int(关键字)、x(标识符)、+(运算符)等。
  4. 输出:生成的记号将被传递给语法分析阶段,语法分析器将进一步解析这些记号,构造语法树。

词法分析的目标

词法分析的目标是将源代码中的字符序列划分成有意义的符号,通常这些符号是:

  • 关键字(Keywords) :如 int, if, else 等,编程语言中预定义的保留字。
  • 标识符(Identifiers) :如变量名、函数名、类名等。
  • 常量(Literals) :如整数、浮点数、字符串常量等。
  • 运算符(Operators) :如 +, , , / 等,用于执行各种操作。
  • 分隔符(Delimiters) :如 ;, {}, [] 等,用于分隔不同的代码块或语句。
  • 注释(Comments) :程序中被忽略的部分,通常用于代码的说明。

词法分析的基本操作

  1. 字符分类(Character Classification) : 词法分析器需要将字符流中的每个字符分类。例如,字符 'a' 是字母、字符 '1' 是数字,字符 + 是运算符等等。
  2. 模式匹配(Pattern Matching) : 通过正则表达式或状态机,词法分析器根据字符的组合来匹配不同的记号。例如,识别数字常量、标识符、关键字等。
  3. 记号生成(Token Generation) : 根据匹配到的字符模式,生成对应的记号,并记录记号的类型和位置(例如行号和列号),以便后续阶段使用。

词法分析器的实现

词法分析器通常由两个主要部分组成:

  1. 词法规则(Lexical Rules) : 词法分析器需要一组规则来描述每种记号的模式。这些规则通常用正则表达式表示,例如:

    • 标识符规则:由字母和数字组成,首字母必须是字母。
    • 整数常量规则:一串数字(如 123, 456 等)。
    • 运算符规则:如 +, , , / 等。
  2. 有限自动机(Finite Automaton) : 词法分析器内部通常使用有限自动机(Finite Automaton)来执行模式匹配。有限自动机是一个有状态的模型,通过读取字符流并根据当前状态决定下一个状态,从而识别出记号。

    • DFA(确定性有限自动机) :每个输入字符都对应唯一的状态转移。
    • NFA(非确定性有限自动机) :可以在某些状态下做出多个选择,通常通过某种转换方式(如子集构造法)将其转化为 DFA 来实现。

词法分析器的示例

假设我们有以下简短的源代码:

int a = 10;
  1. 字符流i n t a = 1 0 ;

    词法分析器逐个读取字符并识别出以下记号:

    • int → 关键字 int
    • a → 标识符 a
    • = → 运算符 =
    • 10 → 整数常量 10
    • ; → 分隔符 ;
  2. 记号流(输出):[KEYWORD:int] [IDENTIFIER:a] [ASSIGNMENT:=] [NUMBER:10] [SEMICOLON:]

这些记号会被传递给语法分析器,语法分析器基于这些记号构建出语法树。

词法分析的工具

很多现代编译器使用自动化工具来生成词法分析器。常见的词法分析工具包括:

  • Lex/Flex:用于生成词法分析器,通常与 Yacc/Bison(语法分析器生成器)结合使用。
  • ANTLR:一个强大的语法和词法分析工具,支持多种编程语言。
  • Re2c:一种高效的正则表达式到C语言代码的转换工具,用于生成词法分析器。

词法分析器的优化

词法分析器通常需要进行优化,以提高性能。常见的优化方法包括:

  1. 缓冲区技术:为了减少字符流的读取次数,通常会采用双缓冲区或多缓冲区来预读字符。
  2. 查找表:常见的关键字、运算符和分隔符可以通过查找表进行快速匹配。
  3. 最小化自动机:通过最小化有限自动机的状态数,减少匹配过程中的计算量。

总结

词法分析是编译过程中的第一步,它的作用是将源代码转换为记号流,便于后续的语法分析和语义分析。词法分析器通过扫描字符流,应用词法规则,使用有限自动机等技术,生成关键字、标识符、常量、运算符等记号。词法分析器的输出是供语法分析器使用的记号流,这为编译器或解释器的后续阶段奠定了基础。

词法分析中的 Look-Ahead 概念

词法分析(Lexical Analysis)中,Look-Ahead 是指词法分析器在处理输入流时,除了考虑当前字符外,还会查看未来几个字符的内容,以帮助做出决定。这种机制尤其有用,特别是在处理 模糊的记号 或需要依赖上下文来做出决策的情况下。

为什么需要 Look-Ahead

在某些情况下,单个字符 并不足以决定当前应该生成哪个记号。例如:

  • 关键字和标识符的区分:有时候,一个词可能是一个 关键字(如 if, int),但它也可能是一个 标识符(如变量名)。在这种情况下,词法分析器需要看更多的字符,判断这个词是否是一个关键字。
  • 多字符运算符:例如,== 是等于运算符,而 = 是赋值运算符。如果我们只看到一个 =,我们无法立即判断它是赋值还是比较运算符,因此需要 Look-Ahead 额外查看下一个字符。

Look-Ahead 的作用

Look-Ahead 允许词法分析器在决定当前记号时查看 当前字符后面的若干字符,以便进行更准确的匹配。通常情况下,Look-Ahead 是指向前看一个或多个字符

常见的 Look-Ahead 使用场景

  1. 关键字 vs 标识符

    • 如果字符流中的一个单词(如 int)是一个合法的标识符,也可能是一个关键字。为了判断这个单词是关键字还是标识符,词法分析器需要查找更多的信息。比如,如果遇到字符 i, n, t,需要查看后续的字符是否为空格、分号等分隔符,才能确认它是否是 int 关键字。
  2. 多字符运算符

    • 类似 ===,或 <=< 这样的运算符,词法分析器必须通过 Look-Ahead 确认当前字符和下一个字符是否形成一个多字符的运算符。
  3. 注释和字符串常量的嵌套问题

    • 在某些语言中(例如 C 和 C++),注释(/* ... */)或者字符串常量("...")可能会嵌套或包含特殊字符。通过 Look-Ahead,词法分析器可以更准确地确定注释或字符串的边界。

Look-Ahead 的类型

  1. 1-字符 Look-Ahead

    • 最常见的情况是仅查看下一个字符。这通常用于检测简单的情形,例如单个字符标识符、常量、运算符等。
    • 示例:如果词法分析器看到字符 =,它可能会 Look-Ahead 1 个字符,查看是否紧跟着是另一个 =,从而判断是否是 ==
  2. N-字符 Look-Ahead

    • 对于更复杂的情况,词法分析器可能需要 Look-Ahead 多个字符。这样可以帮助它处理更复杂的多字符运算符或关键字。
    • 示例:在处理 <= 运算符时,词法分析器会 Look-Ahead 1 个字符,检查是否是 =,然后判断它是否是 <= 运算符。

词法分析中的 Look-Ahead 示例

假设我们有以下源代码:


int x = 10;

词法分析器在分析 int 时,看到字符 int。此时,它必须 Look-Ahead 以确保 int 是一个关键字,而不是一个标识符。在这个例子中,int 后面跟的是一个空格或换行符,确认它是一个关键字,而不是一个普通的标识符。

对于表达式:


x == y;

词法分析器看到 = 字符时,它需要 Look-Ahead 1 个字符,检查下一个字符是否也是 =,以便确定它是 == 运算符,而不是一个赋值运算符 =

Look-Ahead 在有限自动机中的实现

在实现词法分析器时,有限自动机(Finite Automaton)通常用来处理输入字符流,并根据状态转移来生成记号。在 Look-Ahead 的情形下,词法分析器可能会用一个额外的缓冲区来存储 下一个字符多个字符,从而使它能够做出正确的决策。

  1. DFA(确定性有限自动机)

    • 通过 Look-Ahead 机制,DFA 会在读取字符后,进入一个新的状态,或进行状态跳转,继续处理后续字符。
  2. NFA(非确定性有限自动机)

    • NFA 可能需要多个候选状态进行计算,Look-Ahead 可以帮助它决定进入哪个状态。

优缺点

优点

  • 更精确的匹配:通过 Look-Ahead,词法分析器能够处理更复杂的语言结构,例如关键字与标识符的区分、多字符运算符等。
  • 简化规则:使用 Look-Ahead 可以减少一些复杂的条件判断,使得词法分析器的实现更加直观。

缺点

  • 性能开销:增加 Look-Ahead 会导致词法分析器需要处理更多的字符,从而增加性能开销,尤其是在需要多字符 Look-Ahead 时。
  • 实现复杂度:需要为 Look-Ahead 设计额外的缓冲区或状态机,增加了实现的复杂性。

正则语言的基本构成

正则语言的构成通常涉及以下几个基本元素:

  1. 字母表(Alphabet) :正则语言由某个字母表上的符号构成,常用的字母表包括二进制字母表 {0, 1} 或更复杂的字母表。

  2. 基本操作

    • 空串εlambda(表示空字符串,长度为 0)。
    • 单个符号:例如 ab(这两个是正则语言,表示只包含字符 'a' 或 'b' 的语言)。
    • 并集(Alternation)a|b,表示匹配字符 'a' 或字符 'b'。
    • 连接(Concatenation)ab,表示匹配 'a' 后跟 'b'。
    • Kleene 星号a*,表示零个或多个 'a' 的序列。
    • 正则表达式的组合:可以通过并集、连接和 Kleene 星号将多个简单的正则语言结合成复杂的正则语言。
  3. 正规文法

    • 正则语言也可以通过正规文法来定义。正规文法是一种产生语言的规则,定义了如何通过有限的替换规则生成语言的所有字符串。

正则语言的例子

  1. a*

    • 该语言包含所有由零个或多个 'a' 组成的字符串,例如:""(空串)、aaaaaa 等等。
  2. ab|cd

    • 该语言包含两个字符串:abcd
  3. (0|1)*

    • 该语言包含由任意数量的 01 组成的字符串,包括空串。例如:""0110111001 等等。
  4. a(b|c)*d

    • 该语言包含由一个 'a' 开头,一个或多个 'b''c' 字符组成的部分,最后以 'd' 结尾的字符串。例如:adabdacdabbd 等。

优先级和匹配规则 (Lexical Specification)

在设计词法分析器时,当字符流中同时符合多个标记类别的模式时,我们需要决定:

  • 哪个模式应该优先匹配。
  • 如果有多个标记模式匹配同一序列,应该选择哪个。

通常来说,优先级较高的模式会先被匹配,较低优先级的则会被推迟。以下是常见的优先级策略:

  • 最长匹配原则:如果一个标记的正则表达式匹配了更多的字符,那么它应该被优先选中。例如,对于数字常量和标识符:

    • 标识符可能以字母或下划线开始,但 123abc 既可能是标识符也可能是数字常量的一部分。
    • 由于标识符通常比数字常量的优先级低,因此 123 会先被识别为数字常量,剩下的部分 abc 会被识别为标识符。
  • 显式优先级设置:某些语言设计明确规定了优先级。例如,操作符(如 ==, =, +, ``)的优先级通常会比关键字(如 if, while)高,因此在词法分析时,操作符通常优先于关键字进行识别。

2. 操作符优先级

例如,对于表达式中出现的运算符,词法分析器需要按以下顺序优先识别不同类型的操作符:

  • 高级操作符(如 , `/`)优先于低级操作符(如 `+`, )。
  • 逻辑操作符(如 &&, ||)可能优先于关系操作符(如 ==, !=)。
  • 赋值操作符(如 =, +=)通常优先级较低。

在这种情况下,词法分析器需要设计正则表达式时明确优先级。例如,== 应该在 = 之前匹配,因为 == 是两个字符组成的标记。

3. 关键字与标识符的优先级

另一个典型的例子是 关键字标识符 的优先级问题。假设我们有一个语言,其中 if 是一个关键字,而 if123 是一个合法的标识符。此时,如果 ifif123 都在同一字符流中,if 需要优先匹配,if123 才能被识别为标识符。

4. 具体例子

假设我们有以下几种标记:

  • 关键字if, else, for
  • 标识符:以字母或下划线开头,后跟字母、数字或下划线
  • 操作符:如 +, , , /
  • 数字常量:整数或浮点数

当遇到如下字符流:if123 + 45 时,如何处理?

  1. if 优先匹配为关键字,而不是标识符。if123 是一个合法的标识符,但优先匹配 if 关键字。
  2. 然后,123 会匹配为数字常量。
  3. 最后,+ 会匹配为操作符,45 会作为数字常量。

在词法分析器的实现中,我们通常按照以下顺序设定优先级:

  1. 关键字if, else, for 等)优先匹配。
  2. 操作符+, , , / 等)匹配。
  3. 标识符(如变量名、函数名等)匹配。
  4. 数字常量(如整数、浮点数等)匹配。

5. 如何实现优先级处理

在实现词法分析时,优先级的处理一般通过以下方式:

  • 正则表达式的顺序:在实现时,词法分析器按正则表达式的顺序检查输入流。如果输入流匹配多个正则表达式,匹配顺序较先的表达式会被选择。
  • 贪婪匹配:通过贪婪匹配确保更长的字符串会被优先匹配,尤其是在标识符和数字常量之间的选择上。

💡

有限自动机(Finite Automata,简称 FA) 是一种数学模型,用于描述具有有限个状态的系统。在这个模型中,系统的状态在有限的集合中变化,并且根据输入符号的不同,系统的状态会发生转移。有限自动机广泛应用于语言识别、正则表达式匹配等领域,尤其是在编译器和文本处理工具中。

1. 有限自动机的组成部分

一个有限自动机由以下几个部分组成:

  • 状态集合(States) :有限自动机的状态是有限的,通常标记为 S1, S2, S3 等。
  • 字母表(Alphabet) :输入符号的集合,通常表示为 Σ。例如,Σ = {a, b},表示输入只能是字母 'a' 或 'b'。
  • 转移函数(Transition Function) :定义了在给定状态和输入符号时,如何转换到下一个状态。通常表示为 δ(state, input)
  • 初始状态(Start State) :系统开始时的状态,通常用箭头指向该状态。
  • 接受状态(Accept States) :当有限自动机处理完输入后,处于接受状态表示输入被接受。通常用双圈表示。

2. 有限自动机的类型

有限自动机有两种主要类型

  • 确定性有限自动机(DFA) :在每个状态下,对于每个可能的输入符号,都有且仅有一个确定的下一个状态。这意味着在任何时刻,机器都处于一个明确的状态,并且根据输入符号可以准确预测下一个状态。DFA 不允许存在 ε(空输入)转移,这使得它的行为更加可预测和高效。

  • 非确定性有限自动机(NFA) :相比 DFA 更加灵活,在某些状态下,对于同一个输入符号可能存在多个可能的转移路径,或者完全没有转移路径。

    NFA example

    NFA example

NFAs and DFAs represent a fundamental trade-off between space and time complexity.

3. Mermaid Diagram 示例

以下是一个简单的 确定性有限自动机(DFA) 的 Mermaid 图示,表示一个接收字符串中是否包含偶数个字母 "a" 的自动机。

示例:

  • 初始状态为 q0,表示当前已经读取的 "a" 的个数是偶数。
  • 状态 q1 表示已经读取的 "a" 的个数是奇数。
  • 字符串中的每个 "a" 切换状态(从 q0q1 或从 q1q0)。
  • 如果输入字符串结束时处于状态 q0,表示接受字符串(偶数个 "a"),否则拒绝。

stateDiagram-v2 [*] --> q0 q0 : Start, Even "a"s q1 : Odd "a"s

    q0 -->|a| q1
    q1 -->|a| q0
    q0 -->|b| q0
    q1 -->|b| q1
    q0 -->|Accept| q0
    q1 -->|Reject| q1

解释:

  • q0: 表示已经读取的 "a" 的个数是偶数。
  • q1: 表示已经读取的 "a" 的个数是奇数。
  • 输入字符 "a" 时,在 q0q1 之间切换。
  • 输入字符 "b" 时,自动机保持在当前状态。
  • 最终,如果机器停在 q0,说明输入字符串包含偶数个 "a",输入被接受。如果停在 q1,则输入被拒绝。

4. 如何构造一个有限自动机

构造有限自动机的步骤如下:

  1. 定义状态集合:确定有多少种不同的状态,以及每个状态的意义。
  2. 定义字母表:确定输入符号的集合。
  3. 设计转移函数:定义状态之间如何根据输入符号发生转移。
  4. 确定初始状态:定义从哪个状态开始。
  5. 确定接受状态:定义哪些状态表示输入被接受。

Any number of 1’s followed by a single 0

Any number of 1’s followed by a single 0

熟悉了基本概念之后,可以开始考虑implementation

Lexical Analysis 词法分析的实现流程

image.png

正则表达式 (Regular Expression) → NFA(非确定性有限自动机) 的转换步骤

正则表达式转换为NFA(非确定性有限自动机)是词法分析的重要步骤。这个转换过程的核心目标是通过构建一个非确定性状态机来匹配正则表达式描述的字符串模式。


NFA 的概念

NFA(Non-deterministic Finite Automaton) 是一种状态机,其特征是:

  1. 非确定性:对于相同的输入符号,状态机可能有多个可能的转移路径。
  2. ε-转移(Epsilon transitions) :状态之间可以通过空字符 ε 转移,表示无需消耗任何输入即可跳转状态。
  3. 状态集合:包含初始状态、接受状态及其他中间状态。

NFA 是正则表达式的自然表示形式,因为正则表达式本身允许:

  • 并行选择(|),对应非确定性分支。
  • 重复运算(``),对应循环路径。
  • 串接运算(Concatenation),对应顺序状态转移。

转换步骤概述

将正则表达式转换为 NFA 的过程,通常使用以下步骤:

  1. 基本规则:为正则表达式的每个基本单元构建最简单的 NFA。
  2. 组合操作:根据正则表达式的操作(如并集 |、连接 ab、闭包 ``),合并基本 NFA 来构建更复杂的 NFA。
  3. 递归构建:将复杂的正则表达式拆解成更简单的子表达式,逐步构建对应的 NFA。

基本规则与操作

  1. 单字符(a)

    • 对于单个字符 a,创建一个简单的 NFA,包括初始状态、接受状态以及一个带 a 转移的边。

    示意图

    graph LR
        q0 -->|a| q1
        q1((Accept))
    
  2. 空字符(ε)

    • 对于空字符 ε,创建一个简单的 NFA,可以直接从初始状态到接受状态。

    示意图

    graph LR
        q0 -->|ε| q1
        q1((Accept))
    
  3. 并集(a | b)

    • 表示字符 ab,对应 NFA 的非确定性分支:

      • 添加一个新的初始状态和接受状态。
      • 使用 ε 转移连接初始状态到 ab 的子 NFA 的入口。
      • 使用 ε 转移连接子 NFA 的出口到新的接受状态。

    示意图

    graph LR
        q0 -->|ε| A1
        q0 -->|ε| B1
        A1 -->|a| A2
        B1 -->|b| B2
        A2 -->|ε| qf
        B2 -->|ε| qf
        qf((Accept))
    
  4. 连接(Concatenation)

    • 表示 ab,即先匹配 a,再匹配 b

      • a 的 NFA 的接受状态与 b 的 NFA 的初始状态用 ε 转移连接起来。

    示意图

    graph LR
        q0 -->|a| q1
        q1 -->|ε| q2
        q2 -->|b| q3((Accept))
    
  5. 闭包(a) *

    • 表示字符 a 的零次或多次重复:

      • 添加新的初始状态和接受状态。

      • 使用 ε 转移连接:

        • 初始状态 → 子 NFA 的初始状态。
        • 子 NFA 的接受状态 → 初始状态(表示循环)。
        • 初始状态 → 接受状态(表示零次匹配)。
        • 子 NFA 的接受状态 → 接受状态。

    示意图

    graph LR
        q0 -->|ε| A1
        A1 -->|a| A2
        A2 -->|ε| A1
        A1 -->|ε| qf
        A2 -->|ε| qf
        qf((Accept))
    

完整的示例:转换一个正则表达式

假设我们要将正则表达式 a|b* 转换为 NFA。

步骤 1:构建基本单元

  • ab:为它们分别构建基本 NFA。
  • b*:为 b 添加闭包(零次或多次匹配)。

步骤 2:合并

  • 使用 | 运算符,将 ab* 合并,形成非确定性分支。

结果示意图

graph LR
    q0 -->|ε| A1
    q0 -->|ε| B1
    A1 -->|a| A2
    A2 -->|ε| qf
    B1 -->|ε| C1
    C1 -->|b| C2
    C2 -->|ε| C1
    C1 -->|ε| qf
    C2 -->|ε| qf
    qf((Accept))

总结

  1. 正则表达式 通过一系列规则和操作被逐步转换成 NFA
  2. 每个正则运算(单字符、连接、并集、闭包)都有相应的 NFA 构建规则。
  3. 转换结果是一个非确定性有限自动机,它可以表示正则表达式所描述的所有字符串模式。
  4. NFA 是实现正则表达式匹配的基础,后续可以进一步转换为 DFA 以提高匹配效率。

这种方法在词法分析器(如 LexFlexANTLR)中广泛使用。


NFA 转换为 DFA 的必要性与过程原理


www.youtube.com/watch?v=jMx…

1. 为什么需要将 NFA 转换为 DFA?

NFA(非确定性有限自动机)DFA(确定性有限自动机) 都是用来识别正则语言的状态机,但两者有一些关键区别:

  • NFA 的问题

    1. 非确定性:在某个状态下,NFA 可能存在多个选择路径,甚至可以通过 ε-转移跳转到其他状态,这使得在执行时必须考虑所有可能的路径。
    2. 复杂执行:对于同一输入符号,NFA 可能同时存在多个状态需要跟踪,这会使执行效率降低。
  • DFA 的优势

    1. 确定性:对于每个状态和输入符号,DFA 只有一个明确的下一个状态。
    2. 高效执行:DFA 只需单一状态跟踪,适合在实际系统(如词法分析器)中高效运行。
    3. 简单实现:通过 状态转换表 实现 DFA 时,状态转移只需简单查表即可完成。

因此,虽然 NFA 表述更为直观,适合从正则表达式直接构建,但实际执行时需要将 NFA 转换为 DFA,以提高效率并简化实现。


2. NFA 转换为 DFA 的原理

NFA 转 DFA 的核心思想

  • NFA 的一组状态 合并成 DFA 的一个状态。
  • 通过 子集构造法(Subset Construction) ,逐步构建 DFA,使得每个 DFA 状态对应于 NFA 的一个 状态集合

3. NFA 转 DFA 的步骤

假设有一个 NFA,我们通过以下步骤将其转换为 DFA:

  1. 初始状态

    • 从 NFA 的初始状态出发,计算它的 ε闭包(epsilon closure,即能通过 ε 转移到达的所有状态)。
    • 这个状态集合成为 DFA 的初始状态。
  2. 状态转换

    • 对于每个 DFA 状态(即 NFA 状态的集合)和输入符号:

      • 计算所有状态的输入符号所导致的下一组状态。
      • 对这组状态计算 ε闭包,得到一个新的状态集合。
    • 如果这个状态集合没有出现过,将其加入到 DFA 的状态集合中。

  3. 接受状态

    • 如果 NFA 的某个接受状态属于 DFA 状态中的任何状态集合,则该状态集合为 DFA 的接受状态。
  4. 重复上述步骤

    • 直到所有状态和输入符号的转换都被处理完毕。

最终得到的 DFA 会是等价于原 NFA 的确定性自动机。


4. 使用 Mermaid 图示例:NFA 转 DFA

我们用一个简单的 NFA 来说明转换过程。

示例 NFA

正则表达式:a* | b(零个或多个 a,或单个 b

NFA 表述

graph TD
    q0 -->|ε| q1
    q1 -->|a| q1
    q0 -->|ε| q2
    q2 -->|b| q3
    q3((Accept))
    q1 -->|ε| q3

步骤 1: 计算初始状态的 ε闭包

  • 从初始状态 q0 出发,经过 ε 转移可以到达 q1q2
  • ε闭包(q0) = {q0, q1, q2} → DFA 的初始状态。

步骤 2: 输入符号的转换

  • 输入 a

    • 在状态集合 {q0, q1, q2} 中,q1 通过输入 a 可以转移到自己。

    • 然后计算新的状态集合的 ε闭包:

      • {q1} 的 ε闭包 = {q1, q3}
    • 得到新的状态集合 {q1, q3}

  • 输入 b

    • 在状态集合 {q0, q1, q2} 中,q2 通过输入 b 转移到 q3
    • {q3} 的 ε闭包是它本身,新的状态集合 = {q3}

步骤 3: 确定接受状态

  • 如果某个状态集合包含 NFA 的接受状态 q3,那么这个状态集合为 DFA 的接受状态。

最终 DFA 表述

graph TD
    DFA_Start((q0,q1,q2)) -->|a| A((q1,q3))
    DFA_Start -->|b| B((q3 Accept))
    A -->|a| A
    A -->|b| B
    B -->|a| B
    B -->|b| B

解释

  1. DFA_Start: 合并了 NFA 的初始状态 q0,以及 ε 转移到的 q1q2
  2. A: 输入 a 会导致状态集合 {q1, q3}
  3. B: 输入 b 会导致接受状态 {q3}
  4. 接受状态: {q3} 包含 NFA 的接受状态,因此它是 DFA 的接受状态。

5. 总结

  1. 必要性:将 NFA 转换为 DFA 是为了消除非确定性,使状态转移更加简单、明确,执行更高效。
  2. 原理:通过子集构造法,将 NFA 的状态集合合并成 DFA 的单个状态。
  3. 结果:得到一个等价于 NFA 的 DFA,可以通过表驱动法高效实现。

DFA → Table Driven implementation of DFA

DFA → 表驱动实现的解释

表驱动实现 DFA(确定性有限自动机) 是一种高效的方法,通过使用状态转换表来表示和执行 DFA。它将 DFA 的状态转换图转换成一个二维表格,根据当前状态和输入符号快速确定下一个状态。这种方法广泛用于词法分析器(如 Lex)以及编译器等领域。

💡

除了先将 NFA 转换为 DFA 再生成状态转换表,我们也可以直接从 NFA 生成状态转换表。这种方法在以下情况特别有用:

  • DFA 状态爆炸:当 NFA 转 DFA 会产生大量状态时,直接构建 NFA 的状态转换表可能更节省空间。
  • 运行时效率要求不高:虽然 NFA 的状态转换需要考虑多个可能的状态,但在某些应用场景下这种开销是可接受的。
  • 实现简单性:直接使用 NFA 的转换表可以避免复杂的子集构造过程。

这种方法的关键是在状态转换表中允许一个输入对应多个可能的下一状态,并在运行时维护当前可能状态的集合。


1. 为什么使用表驱动实现 DFA?

  1. 简单高效

    • 使用查表操作可以快速执行状态转换,复杂度为 。

      O(1)O(1)

    • 消除了图结构遍历的复杂性。

  2. 程序生成容易

    • 状态转换表可以自动生成,无需手工编码状态转换逻辑。
  3. 确定性

    • 在 DFA 中,每个状态对每个输入符号只有唯一的下一个状态,查表简单直观。
  4. 空间和时间的平衡

    • 状态转换表在空间上是固定的二维数组,但运行时效率极高。

2. DFA 的结构回顾

DFA 由以下部分组成:

  1. 状态集合 QQ:有限个状态,如 q0, q1, ...

  2. 输入符号集合 ΣΣ:例如 {0, 1}

  3. 状态转换函数 δδ:,表示在状态 下,接收输入 后转换到状态 。

    δ(q,a)=q′δ(q, a) = q'

    qq

    aa

    q′q'

  4. 初始状态 q0q0:DFA 的起始状态。

  5. 接受状态 FF:表示输入字符串被接受的状态集合。


3. DFA 的状态转换表

将 DFA 的状态转换函数 δδ 表示为一个二维表,其中:

  • 表示 DFA 的状态
  • 表示输入符号
  • 每个单元格存储下一个状态

示例 DFA

假设我们有一个 DFA,接收由 01 组成的字符串,规则是1 结尾的字符串被接受

DFA 状态图

graph LR
    q0 -->|0| q0
    q0 -->|1| q1
    q1 -->|0| q0
    q1 -->|1| q1
    q1((接受状态))

转换表表示

状态输入 0输入 1
q0q0q1
q1q0q1
  • 状态 q0:接收输入 0 仍停留在 q0,接收输入 1 转换到 q1
  • 状态 q1:接收输入 0 返回 q0,接收输入 1 仍停留在 q1

4. 表驱动 DFA 实现的步骤

步骤 1: 定义状态转换表

状态转换表通常使用一个二维数组或字典表示,例如:

# DFA 状态转换表
dfa_table = {
    'q0': {'0': 'q0', '1': 'q1'},
    'q1': {'0': 'q0', '1': 'q1'}
}

步骤 2: 定义初始状态和接受状态

start_state = 'q0'  # 初始状态
accepting_states = {'q1'}  # 接受状态集合

步骤 3: 运行 DFA

  • 逐个读取输入字符串中的字符。
  • 根据当前状态和输入字符,通过状态转换表查找下一个状态
  • 如果字符串处理完后,当前状态是接受状态,则接受该字符串,否则拒绝。

5. 表驱动 DFA 的实现代码

# 定义 DFA 状态转换表
dfa_table = {
    'q0': {'0': 'q0', '1': 'q1'},  # q0 状态
    'q1': {'0': 'q0', '1': 'q1'}   # q1 状态
}

# 起始状态和接受状态
start_state = 'q0'
accepting_states = {'q1'}

# 运行 DFA 的函数
def run_dfa(input_string):
    current_state = start_state
    for symbol in input_string:
        if symbol not in dfa_table[current_state]:
            return "拒绝"  # 遇到非法输入
        current_state = dfa_table[current_state][symbol]  # 状态转换
    return "接受" if current_state in accepting_states else "拒绝"

# 测试 DFA
print(run_dfa("101"))  # 输出: 接受
print(run_dfa("100"))  # 输出: 拒绝
print(run_dfa("111"))  # 输出: 接受

6. 执行流程示例

输入字符串 101

  1. 初始状态 q0
  2. 读取 1 → 转换到 q1
  3. 读取 0 → 转换回 q0
  4. 读取 1 → 转换到 q1
  5. 结束于状态 q1(接受状态) → 输入字符串 被接受

输入字符串 100

  1. 初始状态 q0
  2. 读取 1 → 转换到 q1
  3. 读取 0 → 转换回 q0
  4. 读取 0 → 停留在 q0
  5. 结束于状态 q0(非接受状态) → 输入字符串 被拒绝

7. 表驱动实现的优势

  1. 高效:状态转换通过查表操作实现,复杂度为 。

    O(1)O(1)

  2. 简单明了:将 DFA 的逻辑清晰地表示成一个表格。

  3. 易于自动生成:工具(如 Lex)可以自动生成状态转换表。

  4. 易于维护和修改:添加状态或输入符号只需修改表格,而无需重写代码逻辑。


总结

  1. 将 DFA 的状态转换图转换成状态转换表
  2. 通过查表操作,根据当前状态和输入符号快速确定下一个状态。
  3. 判断结束时的状态是否为接受状态,以决定输入字符串是否被接受。

这种 表驱动实现 是 DFA 执行的标准方法,广泛用于词法分析、正则表达式匹配和编译器设计中。