☰
数字转英文中文表达:规则引擎设计与边界处理实战
2026/10/31 5:26:17 网站建设 项目流程

数字转英文和中文表达这件事,乍一看像是编程入门练习题,真动手写起来才发现坑比想象中多得多。我做过多语言账单系统、发票打印模块、语音播报文案生成,几乎每隔一段时间就要跟这套逻辑打一次交道。表面需求很简单:输入123,输出one hundred twenty-three和一百二十三。但实际业务里会遇到小数、负数、超大数、金额大写、口语化读法、中文的“两”和“二”之争、英文的连字符与and规则,还有各种边界值。这篇就把我这些年踩过的坑、总结的规则和可直接复用的实现思路完整摊开讲一遍,不管你是刚接触字符串处理的新手,还是需要落地到生产系统的开发者,都能从中拿到能直接抄作业的东西。

1. 先想清楚:这个需求到底在解决什么问题

1.1 需求边界比想象中宽

很多人接到“数字转英文中文”这个标题,第一反应是写个if-else把 0 到 9 映射一下就完事。我最初也这么干过,结果上线第二天就收到反馈:金额1001读成了“一千一”,而不是“一千零一”。这就是典型的边界没想清楚。

先把需求拆开看,一个完整的数字转写模块至少要覆盖这几类输入:

  • 整数:正数、负数、零,位数从个位到十几位甚至更大。
  • 小数:小数点后的读法,英文逐位读还是按数值读,中文“点”后面怎么处理。
  • 特殊场景:金额大写、序数词(第一、第二)、分数、百分比、电话号码式逐位读法。
  • 语言差异:英文的百位and、连字符规则;中文的“零”的省略规则、“两”的使用场景。

所以这个项目的本质不是“翻译数字”,而是建立一套从数值到自然语言字符串的映射规则引擎。理解这一点,后面的架构设计才不会走偏。

1.2 为什么值得单独做成一个模块

有人会问,这种逻辑随手写个函数不就行了,为什么要当成一个项目来做?我的经验是:数字转写逻辑一旦散落在业务代码里,维护成本会指数级上升。账单系统要英文,报表要中文,语音播报要口语化读法,三处各写一份,规则还不一致,改一个 bug 要改三个地方。

把它抽成独立模块的好处很直接:规则集中、测试集中、复用方便。而且数字转写是典型的纯函数场景——同样的输入永远得到同样的输出,没有副作用,特别适合写单元测试把边界全部锁死。我现在的习惯是,只要项目里出现两处以上数字转文字的需求,立刻抽模块,这个投入产出比非常高。

1.3 适合谁来参考

这篇内容对三类人最有价值:一是正在做国际化、多语言项目的开发者,账单、发票、报表场景几乎绕不开;二是准备面试或刷算法题的同学,这类题考察的就是边界处理能力;三是对字符串处理和规则引擎感兴趣、想找一个完整小项目练手的人。下面从设计思路到实操代码,一层层往下拆。

2. 整体设计:分组、映射、拼接三步走

2.1 核心思路:按位分组是通用解法

不管是英文还是中文,大数字的读法都遵循“分组”规律。英文每三位一组(thousand、million、billion),中文每四位一组(万、亿、万亿)。这个差异是设计时第一个要抓住的点。

我的做法是先写一个通用的分组器,把数字按目标语言的进制切分成小组,每组内部单独转写,最后按组拼接单位。这样英文和中文可以共享分组框架,只在“组内转写”和“组单位”两处做差异化实现。用生活化的类比:就像把一长串数字切成几段短号码,每段单独念,再在段与段之间加上“万”“亿”这样的连接词。

具体分组规则:

语言分组进制组单位序列
英文3 位空、thousand、million、billion、trillion
中文4 位空、万、亿、万亿

这个表看着简单,但它决定了整个模块的骨架。分组器写对了,后面就是填空题。

2.2 为什么不用递归硬解

有一种常见写法是用递归不断取模、除以 10 或 1000。递归不是不行,但调试起来很痛苦,尤其是中文“零”的省略规则,递归层级一深,中间状态很难追踪。我倾向于迭代式分组 + 显式状态标记,每一步的中间结果都能打印出来看,出问题定位快。这是工程实践和算法题解法的区别:算法题追求代码短,工程追求可维护、可调试。

2.3 模块划分建议

我通常把这个模块拆成四层:

  1. 输入校验层:处理非法输入、超范围、精度问题。
  2. 分组层:把数字切成小组,附带组序号。
  3. 组内转写层:英文和中文各一套映射表加规则。
  4. 拼接层:处理组间连接、零的省略、特殊读法。

分层的好处是每层可以独立测试。比如组内转写层,我可以用 0 到 9999 全量跑一遍,确认每个数都读对,再往上拼。这种自底向上的验证方式,比一上来就测大数要高效得多。

3. 英文转写:规则细节全拆解

3.1 基础映射表怎么建

英文的 0 到 19 是不规则词,必须硬编码;20 到 90 的整十也是不规则词;剩下的两位数用“整十 + 连字符 + 个位”拼。这三张表是整个英文转写的地基:

ONES = ["zero","one","two","three","four","five","six","seven","eight","nine", "ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen", "seventeen","eighteen","nineteen"] TENS = ["","","twenty","thirty","forty","fifty","sixty","seventy","eighty","ninety"]

注意forty不是fourty,这个拼写错误我见过不止一次出现在生产代码里。映射表建好后,0 到 99 的转写就是一个固定套路:小于 20 查ONES,否则查TENS加个位。

3.2 百位与 and 的规则

英文里 100 到 999 的读法是“X hundred and Y”。这里的and是英式英语的习惯,美式英语里经常省略。我的建议是做成可配置项,因为不同客户、不同地区的期望不一样。默认我倾向带and,因为它在正式文书里更常见。

百位转写的逻辑:百位数字查ONES加hundred,余数部分如果非零,加and再接余数的转写。这里有个细节:余数是 0 时不能加and,否则会读成“one hundred and”,尾巴拖个 and 很别扭。

3.3 千位以上:组单位拼接

三位一组往上拼时,每组的转写复用百位以内逻辑,然后接组单位。比如1234567分成1 | 234 | 567,读作one million two hundred thirty-four thousand five hundred sixty-seven。

拼接时最容易错的是中间组为零的情况。比如1000001分成1 | 000 | 001,中间的000组不发音,但两边的组都要读,结果是one million one。如果机械地按组拼接,会读成one million zero thousand one,这就错了。所以拼接层必须跳过值为零的组,同时保证非零组之间的连接自然。

3.4 连字符与空格的处理

英文里 21 到 99 的非整十数,中间用连字符,比如twenty-one。但跨组拼接时用空格。这个细节在生成语音播报文案时特别重要,因为连字符影响 TTS 引擎的断词。我的处理方式是:组内转写返回带连字符的字符串,拼接层统一用空格连接,两层职责分开,互不干扰。

4. 中文转写:零的省略是最大难点

4.1 中文数字映射与单位

中文的基础映射相对规整:零一二三四五六七八九,单位有个、十、百、千、万、亿。但规整不代表简单,中文的难点全在“零”的处理和“两”的用法上。

先看基础映射:

CN_DIGITS = ["零","一","二","三","四","五","六","七","八","九"] CN_UNITS = ["","十","百","千"] CN_GROUP_UNITS = ["","万","亿","万亿"]

四位一组,组内用“十百千”,组间用“万亿”。框架和英文一致,差异在组内规则。

4.2 零的省略规则

中文读法里,连续的零只读一个,末尾的零不读,组间的零要视情况补。举几个例子感受一下:

  • 1001读“一千零一”,中间的零要读。
  • 1100读“一千一百”,末尾两个零都不读。
  • 10000读“一万”,四个零全不读。
  • 10001读“一万零一”,组间的零要补一个。

我总结的规则是:组内从高位往低位转写时,遇到零先记一个标记,等遇到下一个非零数字时补一个“零”,末尾的标记直接丢弃。组与组之间,如果低位组的值小于 1000(也就是千位是零),且低位组非零,则要补一个“零”。这条规则我验证过大量用例,基本覆盖所有情况。

4.3 “两”和“二”的取舍

这是中文特有的坑。2在什么情况下读“两”?我的经验规则:

  • 单独的数字、序数、小数、电话号码,读“二”。
  • 量词前、百位以上作为开头时,倾向读“两”,比如“两百”“两千”“两万”。
  • 但“二十”不读“两十”,“十二”也不读“十两”。

严格来说这是语感问题,没有绝对规则。工程上的做法是默认全用“二”,在特定场景(如金额口语播报)提供“两”的替换开关。不要试图用一套规则覆盖所有语境,那样只会越写越乱。

4.4 十位的特殊读法

10到19读“十”到“十九”,不读“一十”。但110读“一百一十”,这里的“一十”又要读出来。这个差异取决于“十”是不是最高位。我的处理是:组内转写时,如果当前是十位且高位没有数字,省略“一”;否则保留。这个判断在分组框架下很好实现,因为组内转写知道自己的起始位。

5. 完整实操:从零写一个可复用的转换器

5.1 环境与依赖

这套逻辑用任何主流语言都能写,我用 Python 演示是因为它可读性最好,方便你对照移植。环境要求极低,标准库足够,不需要第三方包。如果你要处理超大数(超过 JavaScript 安全整数范围),Python 的原生大整数正好省事;用其他语言的话记得用大数类型或字符串处理。

5.2 英文转换核心实现

ONES = ["zero","one","two","three","four","five","six","seven","eight","nine", "ten","eleven","twelve","thirteen","fourteen","fifteen","sixteen", "seventeen","eighteen","nineteen"] TENS = ["","","twenty","thirty","forty","fifty","sixty","seventy","eighty","ninety"] SCALES = ["","thousand","million","billion","trillion"] def _under_1000(n, use_and=True): parts = [] if n >= 100: parts.append(ONES[n // 100] + " hundred") n %= 100 if n and use_and: parts.append("and") if n >= 20: word = TENS[n // 10] if n % 10: word += "-" + ONES[n % 10] parts.append(word) elif n > 0: parts.append(ONES[n]) return " ".join(parts) def to_english(num, use_and=True): if num == 0: return "zero" neg = num < 0 num = abs(num) groups = [] while num > 0: groups.append(num % 1000) num //= 1000 words = [] for i in range(len(groups) - 1, -1, -1): if groups[i] == 0: continue chunk = _under_1000(groups[i], use_and) if SCALES[i]: chunk += " " + SCALES[i] words.append(chunk) result = " ".join(words) return ("minus " + result) if neg else result

这段代码的关键点在groups[i] == 0时的continue,它保证了中间零组被跳过。use_and参数控制百位and的开关,方便适配不同地区习惯。

5.3 中文转换核心实现

CN_DIGITS = ["零","一","二","三","四","五","六","七","八","九"] CN_UNITS = ["","十","百","千"] CN_GROUPS = ["","万","亿","万亿"] def _under_10000(n): if n == 0: return "" s = "" zero_flag = False digits = [] while n > 0: digits.append(n % 10) n //= 10 for pos in range(len(digits) - 1, -1, -1): d = digits[pos] if d == 0: zero_flag = True else: if zero_flag and s: s += "零" zero_flag = False if pos == 1 and d == 1 and not s: s += "十" else: s += CN_DIGITS[d] + CN_UNITS[pos] return s def to_chinese(num): if num == 0: return "零" neg = num < 0 num = abs(num) groups = [] while num > 0: groups.append(num % 10000) num //= 10000 parts = [] for i in range(len(groups) - 1, -1, -1): g = groups[i] if g == 0: continue chunk = _under_10000(g) if CN_GROUPS[i]: chunk += CN_GROUPS[i] parts.append(chunk) result = "" for idx, p in enumerate(parts): if idx > 0 and groups[len(groups) - 1 - idx] < 1000: result += "零" result += p return ("负" + result) if neg else result

中文这段的难点在组间补零的判断:groups[len(groups) - 1 - idx] < 1000这一句,意思是当前组的高位(千位)是零时,前面要补一个“零”。这个判断我调了很久才稳定,建议你拿10001、100000001这类数反复验证。

5.4 小数与负数的处理

小数部分我建议逐位读,而不是按数值读。3.14读three point one four和“三点一四”,这样最不容易出错,也符合大多数场景(金额、科学计数)的习惯。负数在开头加minus或“负”即可。如果业务需要“负三点一四”这种,把符号处理放在最外层,整数和小数部分分别转写再拼接。

5.5 验证用例设计

写完代码必须跑用例,我习惯准备一张覆盖表:

输入英文期望中文期望
0zero零
13thirteen十三
100one hundred一百
1001one thousand and one一千零一
10000ten thousand一万
10001ten thousand and one一万零一
1234567one million two hundred thirty-four thousand five hundred sixty-seven一百二十三万四千五百六十七

这张表里的每个数都对应一个规则点,跑通它基本就稳了。建议再加一批随机数做对拍,用另一套独立实现(哪怕是暴力递归版)交叉验证。

6. 常见问题与排查实录

6.1 高频问题速查表

现象可能原因排查方向
中间零组被读出来拼接层没跳过零组检查分组循环里的零值判断
中文末尾多个“零”零标记没在末尾丢弃检查组内转写的收尾逻辑
英文forty拼错映射表手误全局搜索fourty
大数精度丢失用了浮点类型改用整数或字符串处理
负数符号位置错符号处理放在了拼接后符号应在最外层统一加

6.2 踩过的坑:浮点精度

早期我用float接收输入,结果0.1 + 0.2转出来是0.30000000000000004,逐位读法直接读出一长串。后来统一改成字符串接收小数部分,或者用Decimal类型,问题才消失。这个坑在金额场景特别致命,一定要在输入层就把类型定死。

6.3 踩过的坑:中文“零”的组间补位

100000001这个数,分成1 | 0000 | 0001,正确读法是“一亿零一”。我最初的实现漏了组间补零,读成了“一亿一”。修复方法就是前面代码里那句组间判断。这类 bug 的特点是只在特定数位组合下出现,所以测试用例一定要覆盖“高位组非零、中间组全零、低位组非零”这种结构。

6.4 实操心得:先写测试再写实现

这个模块我强烈建议测试先行。因为规则多、边界密,先写实现很容易顾此失彼。我的做法是先把上面那张验证表写成测试用例,然后让实现去跑,跑不通就改,改到全绿为止。这种开发方式在这个场景下效率极高,比边写边想快得多。

6.5 实操心得:把规则做成配置

英文的and、中文的“两”、小数的读法,这些都有地区差异和场景差异。我的经验是把它们做成配置项,默认值选最常见的,特殊场景传参覆盖。这样一套代码能服务多个业务线,不用为每个场景复制一份。配置项不要太多,三五个足够,多了反而增加理解成本。

数字转写这个项目,代码量不大,但规则密度极高,是锻炼边界思维的好素材。我做完第一版之后最大的体会是:任何涉及自然语言的规则,都不要相信自己的直觉,一定要用用例去验证。你以为“一千一”是“一千一百”的省略,但正式文书里就必须读全;你以为“两”可以随便替换“二”,但“十二”就不行。这些细节只有跑过大量真实数据才会暴露。后续如果要做语音播报,还可以在这个模块上叠加口语化规则,比如把“一百”读成“一百块”这种场景化处理,那就是另一个层次的扩展了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询