LeetCode 65 有效数字 (状态机解法)

187 阅读14分钟

前言

最近刷力扣题目,看到一个有意思的题目,判断当前字符串是否是有效数字。这题本身可以有多种解法,基本可以归为三种:正则表达式、if-else多重判定、状态机法。刚巧最近补了点儿状态机知识,所以准备拿这个题目练练手(leetcode.cn/problems/va… )。

题目

给定一个字符串 s ,返回 s 是否是一个 有效数字

例如,下面的都是有效数字:"2", "0089", "-0.1", "+3.14", "4.", "-.9", "2e10", "-90E3", "3e+7", "+6e-1", "53.5e93", "-123.456e789",而接下来的不是:"abc", "1a", "1e", "e3", "99e2.5", "--6", "-+3", "95a54e53"

一般的,一个 有效数字 可以用以下的规则之一定义:

  1. 一个 整数 后面跟着一个 可选指数
  2. 一个 十进制数 后面跟着一个 可选指数

一个 整数 定义为一个 可选符号 '-' 或 '+' 后面跟着 数字

一个 十进制数 定义为一个 可选符号 '-' 或 '+' 后面跟着下述规则:

  1. 数字 后跟着一个 小数点 .
  2. 数字 后跟着一个 小数点 . 再跟着 数位
  3. 一个 小数点 . 后跟着 数位

指数 定义为指数符号 'e' 或 'E',后面跟着一个 整数

数字 定义为一个或多个数位。

示例 1 输入: s = "0",输出: true

示例 2 输入: s = "e",输出: false

示例 3 输入: s = ".",输出: false

热身准备

状态机的基本工作流程就是三步:获取当前状态,接收下一个输入(字符)、进入下一个状态或保持当前状态。这些状态变换通常可以用状态迁移图来表示。

这边先用个简单的例子演示一下状态迁移图的画法,假设现在的题目是判断输入的字符串是不是一个无符号整数。首先我们要准备两个状态:

  1. 开机状态0:代表初始状态,也就是还没接收任何输入字符的状态,此时接收任何一个字符都应该跳转到一个非0状态
  2. 错误状态F:代表匹配失败的状态,接收到任何不合法的字符都自动跳转到此状态,此状态一旦进入就不可跳出

然后根据题目,对输入字符进行归类,本题中其实只要判断输入字符是不是数字即可,所以将输入字符归为两种:数字非数字

继续进一步分析,当前为开机状态0

  • 输入一个非数字字符,很明显应该跳转到错误状态F,代表匹配失败;
  • 输入一个数字字符,此时应当进入一个新的匹配状态,我们给新状态起名为状态1

现在多出来一个新的状态1,继续对状态1进行分析:

  • 输入一个非数字字符,很明显应该跳转到错误状态F,代表匹配失败;
  • 输入一个数字字符,此时我们可以选择进入一个新的匹配状态2,但更简单的做法是我们继续停在状态1,因为你会发现状态2的后续处理逻辑和状态1是一模一样的,这种情况下完全没必要引入一个新状态

以上文字用图的表示出来就是(圆圈代表状态,箭头代表输入字符的动作):

力扣65.000-第 7 页.jpg

如果状态机接收完所有的输入字符后,停留在状态1的话,代表输入的字符串是合法的无符号整数,否则非法。所以状态1就是这个状态机的可接受状态

从上面的步骤可以看出,除了 初始状态0错误状态F 是固定的外,其它状态都是在作图过程中边画边加的,那到底加到几个状态才算结束呢?我的答案是加到不能再加为止。作图的步骤,本质上是从当前状态出发,对每一个输入字符画一个箭头,指向下一个状态:

  • 当图中所有状态出发的箭头都能指向图中另一个状态(或自己)时,我们的作图就结束了。
  • 如果新箭头不能指向当前图中的任何一个状态,那就添加一个新状态,继续对新状态重复以上步骤。

正式答题

有了上面基础,现在我们可以尝试解决原本的问题了。 使用状态机解题的话,基本可以分为三步:画图、填表格、写代码。其中画图占据80%的工作量,最好是在笔记本上用笔画一下,过程中需要反复修改,需要非常细心和耐心。

一、画状态迁移图

这个题目的复杂地方在于,光是合法的输入字符就比热身题目多了好几种,所以我们不能简单的将输入字符分为数字非数字,需要更精细的分类。

首先我们无视输入顺序,找出合法的输入字符有四种:正负号(+/-),数字(0~9),小数点号(.),科学计数分隔符(E/e)。 剩余的其它字符都是非法字符。请注意,接收到一个合法字符不代表输入字符串一定合法,只是有可能合法而已,但接收到一个非法字符则整个字符串必定不合法。

正负号数字点号E/e其它字符
待定待定待定待定非法

我们找出了五种字符,意味着在画状态迁移图时:对于每个状态,都应该画出五个箭头指向下一个状态。我们先从最简单的开机状态0开始画起:

  • 输入正负号,合法,进入一个新状态,起名为状态1,代表以正负号开头的字符串
  • 输入点号,合法,进入一个新状态,起名为状态2,代表以小数点为开头的字符串
  • 输入数字,合法,进入一个新状态,起名为状态3,代表以数字开头的字符串
  • 输入E/e,不合法,进入状态F,合法的数字不可以以 E/e 开头
  • 输入其它字符,不合法,进入状态F

作图如下:

力扣65.000-Page-1-1.jpg

可以发现最后的两个箭头是可以合并的,不过为了作图更简洁,我们将省略所有指向状态F的箭头,简化后如下:

力扣65.000-Page-1-2.jpg

请牢记每个圆圈都应当发出五个箭头,图中未画出的箭头全都默认指向状态F

接下来我们要分别对状态1状态2状态3进行状态迁移分析。


状态1

  • 输入正负号,不合法,进入状态F,以正负号开头,第二个字符不允许再是正负号
  • 输入点号,合法,进入一个新状态,起名为状态1-1,代表以正负号+小数点为开头的字符串
  • 输入数字,合法,进入一个新状态,起名为状态1-2,代表以正负号+数字开头的字符串
  • 输入E/e,不合法,进入状态F
  • 输入其它字符,不合法,进入状态F

力扣65.000-第 2 页-1.jpg


状态2

  • 输入正负号,不合法,进入状态F,以小数点开头,第二个字符不允许是正负号
  • 输入点号,不合法,进入状态F,不允许出现两个小数点
  • 输入数字,合法,进入一个新状态,起名为状态2-1,代表以小数点+数字开头的字符串
  • 输入E/e,不合法,进入状态F
  • 输入其它字符,不合法,进入状态F

力扣65.000-第 2 页-2.jpg

状态3

  • 输入正负号,不合法,进入状态F,以数字开头,第二个字符不允许是正负号
  • 输入点号,合法,进入新状态3-1,以数字+小数点开头的字符串
  • 输入数字,合法,保持当前状态3即可,后续处理逻辑和当前状态一样
  • 输入E/e,合法,进入新状态3-2
  • 输入其它字符,不合法,进入状态F

力扣65.000-第 2 页-3.jpg

此时我们新增了1-1, 1-2, 2-1, 3-1, 3-2五个状态,但先不急着给他们分配一个单独的状态编号,先分析一下这五个状态是否可以和已有的状态合并。我们先分析状态1-1的状态迁移:

  • 输入正负号,不合法,进入状态F,以正负号+小数点为开头,后续不允许出现正负号
  • 输入点号,不合法,进入状态F,以正负号+小数点为开头,不允许出现两个小数点
  • 输入数字,合法,进入一个新状态,起名为状态1-1-1,代表以正负号+小数点+数字开头的字符串
  • 输入E/e,不合法,进入状态F
  • 输入其它字符,不合法,进入状态F

画图出来的话,我们就可以很明显的看到,状态1-1的状态迁移图和状态2可以说是一模一样的:

力扣65.000-第 2 页-4.jpg

从逻辑上分析,他们的区别仅仅是输入字符的前缀不同,状态1-1状态2多处理了一个正负号的前缀而已,后续处理逻辑完全一致,所以这俩状态可以说是等价的。我们可以删掉状态1-1,直接让指向状态1-1的箭头指向状态2即可。

运用同样的分析方式状态1-2,可以发现它跟状态3也是等价的,区别也仅仅是处理的前缀不一样而已,因而可以把指向状态1-2的箭头改为指向状态3。经过两次合并后的状态转移图:

力扣65.000-第 3 页-1.jpg


接下来继续分析剩下的状态2-2,状态3-1

状态2-2

  • 输入正负号,不合法,进入状态F,小数点后面,E/e前面不允许出现正负号
  • 输入点号,不合法,进入状态F,不允许出现两个小数点
  • 输入数字,合法,保持当前状态2-2即可,后续处理逻辑和当前状态一样
  • 输入E/e,合法,进入新状态2-2-1
  • 输入其它字符,不合法,进入状态F

状态3-1

  • 输入正负号,不合法,进入状态F,小数点后面,E/e前面不允许出现正负号
  • 输入点号,不合法,进入状态F,不允许出现两个小数点
  • 输入数字,合法,保持当前状态3-1即可,后续处理逻辑和当前状态一样
  • 输入E/e,合法,进入新状态3-1-1
  • 输入其它字符,不合法,进入状态F

力扣65.000-第 3 页-2.jpg

从图中很容易看出 状态2-2状态3-1 也是两个等价状态,而且是个全新的状态,为了简化称呼,我们将它俩命名为 状态4 再引入,将本来指向的 状态2-2状态3-1 的箭头都指向状态4

力扣65.000-第 4 页-1.jpg

现在图中只剩状态4-1状态3-2没有分析过了,但机智一点的同学会发现这两很明显又是等价的,因为他俩都是接收到第一个 科学计数分隔符号(E/e) 后的状态,后续的处理逻辑必然一致。不过按照流程我们还是继续分析下状态4-1

  • 输入正负号,合法,进入4-1-1,E/e后面第一位允许出现正负号
  • 输入点号,不合法,进入状态F,E/e后面不允许出现小数点
  • 输入数字,合法,进入新状态4-1-2,代表E/e后面跟着无符号整数
  • 输入E/e,不合法,进入新状态F
  • 输入其它字符,不合法,进入状态F

力扣65.000-第 4 页-2.jpg

对于状态3-2,这里就不再赘述了,分析结果和状态4-1一模一样。所以我们把他俩合并为 状态5 引入。

接下来对上图里剩余的状态5-1状态5-2进行分析,这里不一条条的分析了,根据题目条件迅速推理结论就好:根据题目,E/e 后面只允许出现有符号或无符号的整数,对于已经接收了一个整数符号的状态5-2而言,它的后续合法输入只有数字字符,然后继续停留在当前状态就好;对于已经接收了一个正负号的状态5-1,它的后续也是只能是数字字符,并且停留在状态5-2即可。此时画图如下:

力扣65.000-第 5 页.jpg

至此我们发现,图中所有的状态发出的箭头都指向了图中的某一个状态,已经无法再往图里加新箭头了,意味着我们的状态迁移图已经完工了,我们将状态5-1改名为状态6状态5-2改名为状态7,再微调下布局:

力扣65.000-第 6 页.jpg

输入一个字符串,沿着状态迁移图的箭头走完,最终一定会停在某个状态上,那么哪个状态代表字符串合法呢?我们可以沿着箭头追加对应的字符,看看停留在每个状态上的字符是什么样子:

状态示例字符串
0空字符串
1+ 或 -
2.(单独一个小数点) 或 +. 或 -.
31234 或 +1234
41234.1234 或 +.1234 或 .1
51234E 或 1234e
61234E+ 或 1234E-
71234EE+1234 或 1234E1234
Fasdsad

根据题目要求和以上表格可以看出,只有停留在状态3,状态4,状态7 上时,接收的才是合法字符串,这三个状态也称为当前状态机的可接受状态

二、填状态转移表格

接下来就是根据状态迁移图填表格了,也叫状态转移表。 这张表格的每一列代表一种输入字符,对于这道题,一共只有五种输入,所以分为五列(列号从0开始)。表格的每一行代表一种状态,因为状态迁移图中只有0~7的状态,我们可以将F状态的编号设为8。

表格第一行第一列的数字含义代表:处于当前行代表的状态0下,接收到当前列的字符(正负号),应该跳转到的下一个状态编号。我们可以参考第一步画出的状态迁移图,可以得知这一格应该填状态1

重复以上步骤,最终完整的状态迁移表如下:

状态编号正负号数字点号E/e其它字符
0132FF
1F32FF
2F4FFF
3F345F
4F4F5F
567FFF
6F7FFF
7F7FFF
8(F)FFFFF
列号->01234

三、代码实现

第二步完成的表格可以直接存为二位数组,也就是状态转移矩阵。然后就可以直接进行编码了,Python代码比较简单,直接贴出来:


#状态转移矩阵
states = [
    [1, 3, 2, 8, 8],
    [8, 3, 2, 8, 8],
    [8, 4, 8, 8, 8],
    [8, 3, 4, 5, 8],
    [8, 4, 8, 5, 8],
    [6, 7, 8, 8, 8],
    [8, 7, 8, 8, 8],
    [8, 7, 8, 8, 8],
    [8, 8, 8, 8, 8],
]

#字符=>列号
char_map = {
    "+": 0,
    "-": 0,
    "0": 1,
    "1": 1,
    "2": 1,
    "3": 1,
    "4": 1,
    "5": 1,
    "6": 1,
    "7": 1,
    "8": 1,
    "9": 1,
    ".": 2,
    "e": 3,
    "E": 3,
}

#获取字符列号
def get_char_index(c):
    if c in char_map:
        return char_map[c]
    return 4

#解答题目:判断字符串是否为合法数字
def isNumber(s):
    cur_state = 0
    for c in s:
        cur_state = states[cur_state][get_char_index(c)]
        if cur_state==8:
            return False
    return cur_state == 3 or cur_state == 4 or cur_state == 7

测试通过全部力扣的用例,收工~