bigywater.tech
算法与数据结构 · LeetCode 65

LeetCode 65. 有效数字

给定一个字符串,判断它是否是一个有效的数字。有效数字由可选的符号、一个数字部分(整数,或者带小数点的小数,小数点前后至少有一侧有数字)、和可选的指数部分(e 或 E,后面跟可选的符号和至少一位整数)组成,中间不能有空格。规则不多,但细节很杂:"4." 和 ".5" 合法,"." 和 "e3" 不合法,"1e" 不合法,"1.5.5" 不合法。本文给出一个从左到右的手写扫描器,与一条正则表达式对拍,用真实数据展示"用语言内置的转数字函数来判断"为什么不行,给出五种语言的实现,并把扫描的阶段做成分步演示。

50,000
组随机对拍,与暴力解的结果全部一致
10 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。这道题只涉及十进制,字符只有数字、+、-、.、e、E。基准解是一条正则表达式 [+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?,要求整个字符串完全匹配,与手写的扫描器是两种独立的写法。一共 32 组用例,覆盖官方所有的合法和不合法示例,以及空串、只有符号、指数缺少数字、多个小数点、首尾有空格等情形:

真实实测 · 本机执行输出
输入 ["0"]
输出 true
输入 ["e"]
输出 false
输入 ["."]
输出 false
输入 ["2"]
输出 true
输入 ["0089"]
输出 true
输入 ["-0.1"]
输出 true

陷阱:语言内置的转换函数接受的范围更广

一个偷懒的想法是尝试把字符串转成浮点数,能转成功就是有效数字。但各语言的转换函数都比这道题的规则宽松。下面在 Python 里用 float() 做对比:

真实实测 · 本机执行输出
# 用 Python 内置的 float() 判断「能否转成数」,与 solve 的差别
        'inf': float() 接受=True     solve -> False
  '-Infinity': float() 接受=True     solve -> False
        'nan': float() 接受=True     solve -> False
      '1_000': float() 接受=True     solve -> False
        ' 1 ': float() 接受=True     solve -> False
        '1e5': float() 接受=True     solve -> True
          '٣': float() 接受=True     solve -> False
       '0x10': float() 接受=False    solve -> False
        '1.5': float() 接受=True     solve -> True
         'e5': float() 接受=False    solve -> False
         '5e': float() 接受=False    solve -> False
        '+.5': float() 接受=True     solve -> True
两者不一致的输入:['inf', '-Infinity', 'nan', '1_000', ' 1 ', '٣']

float() 接受 inf、-Infinity、nan、带下划线的 1_000、首尾有空格的 1 ,甚至阿拉伯-印度数字 ٣(Unicode 里的数字 3),而这道题都不认为是有效数字;两者不一致的输入一共 6 个。0x10、e5、5e 这几个 float() 也拒绝,与本文的规则一致。这个对比只在 Python 里做了,其他语言的转换函数各有各的宽松规则,这里没有实测。

手写扫描器:五种语言的实现

从左到右一个指针 i。第一步:可选的符号。第二步:数字部分:连续读取数字和至多一个小数点,同时统计数字个数;如果数字个数是 0(例如只有一个 .),不合法。第三步:如果下一个字符是 e 或 E,读指数部分:可选的符号,然后必须至少有一位数字,否则不合法。最后要求指针刚好走到字符串末尾,否则说明后面还有多余的字符。字符判断只接受 ASCII 的 0 到 9。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。

Python · 解法代码def solve(s): n = len(s) i = 0 if i < n and s[i] in "+-": # 可选的符号 i += 1 digits, seen_dot = 0, False while i < n and ("0" <= s[i] <= "9" or (s[i] == "." and not seen_dot)): # 数字和至多一个小数点 if s[i] == ".": seen_dot = True else: digits += 1 i += 1 if digits == 0: # 小数点前后至少要有一位数字("." 不合法) return False if i < n and s[i] in "eE": # 可选的指数部分 i += 1 if i < n and s[i] in "+-": i += 1 exp_digits = 0 while i < n and "0" <= s[i] <= "9": # 指数必须是整数,且至少一位 exp_digits += 1 i += 1 if exp_digits == 0: return False return i == n # 必须刚好消耗完整个字符串
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
["0"]  →  true
["e"]  →  false
["."]  →  false
["2"]  →  true
["0089"]  →  true
["-0.1"]  →  true
Java · 解法代码class Solution { public boolean isNumber(String s) { int n = s.length(), i = 0; if (i < n && (s.charAt(i) == '+' || s.charAt(i) == '-')) i++; // 可选的符号 int digits = 0; boolean seenDot = false; while (i < n && ((s.charAt(i) >= '0' && s.charAt(i) <= '9') || (s.charAt(i) == '.' && !seenDot))) { // 数字和至多一个小数点 if (s.charAt(i) == '.') seenDot = true; else digits++; i++; } if (digits == 0) return false; // 小数点前后至少要有一位数字("." 不合法) if (i < n && (s.charAt(i) == 'e' || s.charAt(i) == 'E')) { // 可选的指数部分 i++; if (i < n && (s.charAt(i) == '+' || s.charAt(i) == '-')) i++; int expDigits = 0; while (i < n && s.charAt(i) >= '0' && s.charAt(i) <= '9') { // 指数必须是整数,且至少一位 expDigits++; i++; } if (expDigits == 0) return false; } return i == n; // 必须刚好消耗完整个字符串 } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
["0"]  →  true
["e"]  →  false
["."]  →  false
["2"]  →  true
["0089"]  →  true
["-0.1"]  →  true
Go · 解法代码func isNumber(s string) bool { n, i := len(s), 0 if i < n && (s[i] == '+' || s[i] == '-') { // 可选的符号 i++ } digits, seenDot := 0, false for i < n && ((s[i] >= '0' && s[i] <= '9') || (s[i] == '.' && !seenDot)) { // 数字和至多一个小数点 if s[i] == '.' { seenDot = true } else { digits++ } i++ } if digits == 0 { // 小数点前后至少要有一位数字("." 不合法) return false } if i < n && (s[i] == 'e' || s[i] == 'E') { // 可选的指数部分 i++ if i < n && (s[i] == '+' || s[i] == '-') { i++ } expDigits := 0 for i < n && s[i] >= '0' && s[i] <= '9' { // 指数必须是整数,且至少一位 expDigits++ i++ } if expDigits == 0 { return false } } return i == n // 必须刚好消耗完整个字符串 }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
["0"]  →  true
["e"]  →  false
["."]  →  false
["2"]  →  true
["0089"]  →  true
["-0.1"]  →  true
JavaScript · 解法代码function isNumber(s) { const n = s.length; let i = 0; if (i < n && (s[i] === "+" || s[i] === "-")) i++; // 可选的符号 let digits = 0, seenDot = false; while (i < n && ((s[i] >= "0" && s[i] <= "9") || (s[i] === "." && !seenDot))) { // 数字和至多一个小数点 if (s[i] === ".") seenDot = true; else digits++; i++; } if (digits === 0) return false; // 小数点前后至少要有一位数字("." 不合法) if (i < n && (s[i] === "e" || s[i] === "E")) { // 可选的指数部分 i++; if (i < n && (s[i] === "+" || s[i] === "-")) i++; let expDigits = 0; while (i < n && s[i] >= "0" && s[i] <= "9") { // 指数必须是整数,且至少一位 expDigits++; i++; } if (expDigits === 0) return false; } return i === n; // 必须刚好消耗完整个字符串 }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
["0"]  →  true
["e"]  →  false
["."]  →  false
["2"]  →  true
["0089"]  →  true
["-0.1"]  →  true
C++ · 解法代码#include <string> using namespace std; class Solution { public: bool isNumber(string s) { int n = s.size(), i = 0; if (i < n && (s[i] == '+' || s[i] == '-')) i++; // 可选的符号 int digits = 0; bool seenDot = false; while (i < n && ((s[i] >= '0' && s[i] <= '9') || (s[i] == '.' && !seenDot))) { // 数字和至多一个小数点 if (s[i] == '.') seenDot = true; else digits++; i++; } if (digits == 0) return false; // 小数点前后至少要有一位数字("." 不合法) if (i < n && (s[i] == 'e' || s[i] == 'E')) { // 可选的指数部分 i++; if (i < n && (s[i] == '+' || s[i] == '-')) i++; int expDigits = 0; while (i < n && s[i] >= '0' && s[i] <= '9') { // 指数必须是整数,且至少一位 expDigits++; i++; } if (expDigits == 0) return false; } return i == n; // 必须刚好消耗完整个字符串 } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
["0"]  →  true
["e"]  →  false
["."]  →  false
["2"]  →  true
["0089"]  →  true
["-0.1"]  →  true
真实实测 · 本机执行输出
共 32 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 32/32
  Java        通过 32/32
  Go          通过 32/32
  JavaScript  通过 32/32
  C++         通过 32/32

随机对拍

Python 版本另外和正则表达式做了随机对拍:随机串长 0 到 7,字符取自 0159+-.eE 加空格和字母 a。因为随机串大多不合法,我统计了合法串的占比:

真实实测 · 本机执行输出
随机对拍 50000 组(长度 0 到 7、字符取自 0、1、5、9、+、-、.、e、E、空格、字母 a 的随机串(大多不合法;少数合法的会覆盖各种写法)),solve 与 brute 的结果全部一致。
真实实测 · 本机执行输出
对拍用的 50000 个随机串里,合法的有 5837 个(11.7%),其余 44163 个不合法。

50000 个随机串里合法的有 5837 个,占 11.7%,不合法的占大多数,两边的情形都有覆盖。另外四种语言只在上面 32 组固定用例上验证过,没有做大规模随机对拍。这道题只扫描字符串一遍,是线性的,所以没有做规模对比实验。

分步演示:符号、数字部分、指数

下面的演示用 -12.5e+3。先读符号 -,再读数字部分 12.5(数字 1、2,小数点,数字 5),遇到 e 进入指数,读指数的符号 + 和数字 3,最后指针刚好走到字符串末尾,合法。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。

有效数字:"-12.5e+3"
正在读的字符已接受的部分

    参考与说明

    • 官方题目页:LeetCode 65. 有效数字。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚。
    • 没有覆盖:有限状态机的写法;各语言内置转换函数的宽松规则(只对比了 Python 的 float())。
    京ICP备2026067502号-1京公网安备11010502063487号