cn2an 核心代码解析(快速转化「中文数字」和「阿拉伯数字」)

最初,我是在开发聊天机器人的时候用到这个功能,比如用户提问 一千米以内有那些场地可用?,我需要在数据库中查询范围小于一千米的场地,SQL 语句大致为 WHEN distant<1000,但我只能在原语句中提取到 一千 这个词语。数据库的判断条件是无法识别中文数字的,这时就先需要转化一下了。
当时我搜索一些资料,看到有一些零散的代码,并没有找到合适的开源库,于是自己动手实现了一个非常粗糙的转化函数,粗糙到连最基本的异常判断都没有。后来我发现这个功能在自然语言处理领域还挺常用的,所以就打算把它抽象出来,方便自己和他人复用。我抽了一些空闲的时间,完善了部分功能后把它开源了。当时并没有想到会有很多人用,我也知道里面有很多 bug,但懒得修。直到我逐渐发现有人在不同的任务中使用它,比如爬虫、自然语言转SQL、聊天机器人、语音识别等,所以后面就很积极的在更新了。
1 任务简介 这个任务的核心就是「中文数字」和「阿拉伯数字」相互转化,这两种数字的描述方式非常规则,比如 一百二十三123。如果是标准格式的数据,转化算法也许并不难,而真正困难的是如何在数据不规范的情况下做好转化,比如三万五,我们一听就知道要转化成 35000,而机器却可能理解为 30005。那我们今天要讲如何将不规则数据转化吗?不!今天这篇文章我们主要讨论的还是标准情况下的数字转化。

我:在啃骨头之前,先把大块的肉撕下来!
一般来说,我们常见的数字的最大值是 千万亿,即 10**16,写出来大概这么长:10000000000000000,极少有数字会超过这个,因此我们也把算法的输入范围定为 0 - 10**16,下面我们来看如何用算法转化这么长的数字的。
2 核心算法 我们要将核心算法是指剔除了一些无关代码,大致包括数据预处理、异常判断、数据后处理等功能,是只针对数字转化的算法描述,毕竟复杂的细枝末节不是我们今天要讨论的内容。
2.1 正向遍历法
首先,我们以 一百二十三 作为第一个要转化的例子,你大概会说,这个用小学学过的知识就可以做到,的确如此!先把中文数字和单位做个映射,然后正向遍历,用数字乘以单位,然后直接把他们累加起来就搞定了。
一百二十三 的解析式为 1*100 + 2*10 + 3,代码如下:
# 数字映射 number_map = { "零": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9 }# 单位映射 unit_map = { "十": 10, "百": 100, "千": 1000, "万": 10000, "亿": 100000000 }# 正向遍历 1 def forward_cn2an_one(inputs): output = 0 unit = 1 num = 0 for index, cn_num in enumerate(inputs): if cn_num in number_map: # 数字 num = number_map[cn_num] # 最后的个位数字 if index == len(inputs) - 1: output = output + num elif cn_num in unit_map: # 单位 unit = unit_map[cn_num] # 累加 output = output + num * unit num = 0 else: raise ValueError(f"{cn_num} 不在转化范围内")return outputoutput = forward_cn2an_one("一百二十三") # output: 123

是不是如你想象的那么简单,但这种基础算法到十万以上就不行了。比如 一十二万(通常情况下,你会更习惯说 十二万,但那个是口语说法),用上述算法就会得到 20010,而不是 120000
output = forward_cn2an_one("一十二万") # output: 20010

仔细想想问题出在了哪里。没错!就出在万位那里,由于算法是单位乘以数字然后直接累加,因此一十二万被解析成了 1*10 + 2*10000,而不是 (1*10 + 2)*10000
问题找到了,那解决起来还是比较简单的。我们直接在万位那里做个判断,即当识别到万的时候,先把前面所有数字加起来,再乘以单位万。
# 正向遍历 2 def forward_cn2an_two(inputs): output = 0 unit = 1 num = 0 for index, cn_num in enumerate(inputs): if cn_num in number_map: # 数字 num = number_map[cn_num] # 最后的个位数字 if index == len(inputs) - 1: output = output + num elif cn_num in unit_map: # 单位 unit = unit_map[cn_num] # 判断出万、亿,先把前面的累加再乘以单位万、亿 if unit % 10000 == 0: output = (output + num) * unit else: # 累加 output = output + num * unit num = 0 else: raise ValueError(f"{cn_num} 不在转化范围内")return outputoutput = forward_cn2an_two("一十二万") # output: 120000

这下在万上的问题似乎解决了,那这个方法在亿上能不能运行呢?我们用 一亿二千三百四十五万六千七百八十一 来测试,得到了 1000023456781,又错了!
output = forward_cn2an_two("一亿二千三百四十五万六千七百八十一") # output: 1000023456781

别急!我们来分析一下,这是 一亿二千三百四十五万六千七百八十一 的解析式: (1*100000000 + 2*1000 + 3*100 + 4*10 + 5)*10000 + 6*1000 + 7*100 + 8*10 + 1,从解析式中可以看到,亿位上的数字被错误得乘以了万,这种情况也需要做处理。
# 正向遍历 3 def forward_cn2an_three(inputs): output = 0 unit = 1 num = 0 # 亿位以上的输出 hundred_million_output = 0 for index, cn_num in enumerate(inputs): if cn_num in number_map: # 数字 num = number_map[cn_num] # 最后的个位数字 if index == len(inputs) - 1: # 把亿位和中间输出以及个位上的一起加起来 output = hundred_million_output + output + num elif cn_num in unit_map: # 单位 unit = unit_map[cn_num] # 判断出万,前面的累加再乘以单位万 if unit == 10000: output = (output + num) * unit # 判断出亿,前面累加乘以亿后赋值给 hundred_million_output, output 重置为 0 elif unit == 100000000: hundred_million_output = (output + num) * unit output = 0 else: # 累加 output = output + num * unit num =0 else: raise ValueError(f"{cn_num} 不在转化范围内")return outputoutput = forward_cn2an_three("一亿二千三百四十五万六千七百八十一") # output: 123456781

仔细阅读上文代码,我增加了一个新的变量 hundred_million_output,用于存储亿位以上的数字,等所有计算完成时,再把它和后面的数字相加即可得到正确结果,解析式为:1*100000000 + (2*1000 + 3*100 + 4*10 + 5)*10000 + 6*1000 + 7*100 + 8*10 + 1
同样的,我们用一个几乎接近上限的数字 一千二百三十四万五千六百七十八亿一千二百三十四万五千六百七十八 来测试,结果也是正确的。
output = forward_cn2an_three("一千二百三十四万五千六百七十八亿一千二百三十四万五千六百七十八") # output: 1234567812345678

目前为止,千万亿规模的数字上,我们的算法似乎已经可以很好的转化了!
2.2 反向遍历法
不知道你看完上面的算法是不是有点膈应,先用变量存储再相加的方式似乎不是那么优雅。有没有更好方法的解决它呢?
雷军:有人说我写的代码,像诗一样优雅。
咦~有了!我们可以使用反向(倒序)遍历,虽然还是数字乘以单位,但我们是不断的和更大的数字累加,只要处理好单位问题,就不会出现需要把部分值先暂存,然后再加到一起的方式,从而节省一个变量的空间!
# 反向遍历 1 def backward_cn2an_one(inputs): output = 0 unit = 1 num = 0 for index, cn_num in enumerate(reversed(inputs)): if cn_num in number_map: # 数字 num = number_map[cn_num] # 累加 output = output + num * unit elif cn_num in unit_map: # 单位 unit = unit_map[cn_num] else: raise ValueError(f"{cn_num} 不在转化范围内")return outputoutput = backward_cn2an_one("一百二十三") # output: 123

上文是反向遍历的最基本实现,但这种算法依然处理不了十万以上的数字,主要是因为单位问题。
output = backward_cn2an_one("一十二万") # output: 20010

一十二万 被解析成了 2*10000 + 1*10,仔细看和正向遍历得到的解析式 1*10 + 2*10000 的不同,1*10 中的 10 本应该是十万位。我们需要在遍历到万位时,记录当前的单位是万,后面的单位再乘以万就搞定了!代码如下:
# 反向遍历 2 def backward_cn2an_two(inputs): output = 0 unit = 1 # 万、亿的单位 ten_thousand_unit = 1 num = 0 for index, cn_num in enumerate(reversed(inputs)): if cn_num in number_map: # 数字 num = number_map[cn_num] # 累加 output = output + num * unit elif cn_num in unit_map: # 单位 unit = unit_map[cn_num] # 判断出万、亿 if unit % 10000 == 0: ten_thousand_unit = unitif unit < ten_thousand_unit: unit = ten_thousand_unit * unit else: raise ValueError(f"{cn_num} 不在转化范围内") return outputoutput = backward_cn2an_two("一十二万") # output: 120000

当然除了万位以上,亿位以上时我们也这样处理。
output = backward_cn2an_two("一亿二千三百四十五万六千七百八十一") # output: 123456781

亿以上数字测试通过!反向遍历就真这么简单?!不,还有更大的数字没有测试。
output = backward_cn2an_two("一千二百三十四万五千六百七十八亿一千二百三十四万五千六百七十八") # output: 567824685678

凉了,输出错误...这么优雅的代码哪里出问题了呢?
我们来看下解析式:8 + 7*10 + 6*100 + 5*1000 + 4*10000 + 3*100000 + 2*1000000 + 1*10000000 + 8*100000000 + 7*1000000000 + 6*10000000000 + 5*100000000000 + 4*10000 + 3*100000 + 2*1000000 + 1*10000000,这么长...估计你已经看晕了。现在仔细看解析式的最后四个单位 4*10000 + 3*100000 + 2*1000000 + 1*10000000,它们都是万亿之后的值,本应该是最大最长的四个,也就是说代码中没有判断出这里的万应该是万亿!
我们需要让代码在判断出万位的时候,朝前面单位看一下,如果有一个比它还大的单位(即亿)时,就把它们乘起来。
# 反向遍历 3 def backward_cn2an_three(inputs): output = 0 unit = 1 # 万、亿的单位 ten_thousand_unit = 1 num = 0 for index, cn_num in enumerate(reversed(inputs)): if cn_num in number_map: # 数字 num = number_map[cn_num] # 累加 output = output + num * unit elif cn_num in unit_map: # 单位 unit = unit_map[cn_num] # 判断出万、亿 if unit % 10000 == 0: # 万、亿 if unit > ten_thousand_unit: ten_thousand_unit = unit # 万亿 else: ten_thousand_unit = unit * ten_thousand_unit unit = ten_thousand_unitif unit < ten_thousand_unit: unit = ten_thousand_unit * unit else: raise ValueError(f"{cn_num} 不在转化范围内")return outputoutput = backward_cn2an_three("一千二百三十四万五千六百七十八亿一千二百三十四万五千六百七十八") # output: 1234567812345678

至此,我们的反向遍历算法也完成了,本来想省掉一个变量,似乎最后没有做到...
如你所见,这两个算法的时间复杂度都是 O(n),空间复杂度都是 O(1),本质上并没有什么优劣,但我依然感觉反向遍历在思维上流畅很多!因此我在 cn2an 这个库中用的就是反向遍历法啦!
另外,这里有上文的所有代码。
3 模式匹配 如果你给正确的算法丢进一个错误的输入,那么你大概率得到也是一个错误的输出!
郭冬临:用谎言验证谎言,得到的一定是谎言!
比如我们把 十七十八 输入上述两个算法的最终版本,你可能期望的结果是 1718,而实际上会是 78,具体原因我就不细说了,你可以自己思考一下。
output = forward_cn2an_three("十七十八") # output: 78output = backward_cn2an_three("十七十八") # output: 78

因此我们必须要限制输入,所有格式不正确的输入都要排除在外。俗话说的好:人生苦短,我用正则!,我们现在就来写个正则表达式。
先给大家看我写的第一版有问题的模式:
import renums = "零一二三四五六七八九" nums_ten = "零一二三四五六七八九十" units = "十百千万亿" pattern = re.compile(f"(([{nums_ten}]+[{units}]+)+零?[{nums}]|([{nums_ten}]+[{units}]+)+|十[{nums}]?|[{nums}])$")result = pattern.search("十七十八") if result: print(result.group()) # 十七十八

这个正则看似很简单,实则问题非常大。我的思路是匹配带零的数字(比如一千零二十三)、不带零的数字(比如一百二十三)、十几(比如十一)和几(比如一)的任意一种,但它会把很多有问题的模式匹配进来,比如刚刚的 十七十八 这种,反正问题很多!
后来这个问题一直有人向我提,虽然我知道这不是算法的问题,但却是这个库需要解决的问题。有一天晚上,我苦苦想了好几个小时,尝试很多方法,最后想到一个笨方法——枚举所有需要匹配的数字!
import re_0 = "[零]" _1_9 = f"[一二三四五六七八九]" _10_99 = f"{_1_9}?[十]{_1_9}?" _1_99 = f"({_10_99}|{_1_9})" _100_999 = f"({_1_9}[百]([零]{_1_9})?|{_1_9}[百]{_10_99})" _1_999 = f"({_100_999}|{_1_99})" _1000_9999 = f"({_1_9}[千]([零]{_1_99})?|{_1_9}[千]{_100_999})" _1_9999 = f"({_1000_9999}|{_1_999})" _10000_99999999 = f"({_1_9999}[万]([零]{_1_999})?|{_1_9999}[万]{_1000_9999})" _1_99999999 = f"({_10000_99999999}|{_1_9999})" _100000000_9999999999999999 = f"({_1_99999999}[亿]([零]{_1_99999999})?|{_1_99999999}[亿]{_10000_99999999})" _1_9999999999999999 = f"({_100000000_9999999999999999}|{_1_99999999})" pattern = re.compile(f"^({_0}|{_1_9999999999999999})$")result = pattern.search("十七十八") if result: print(result.group()) # 输出为空,因为没有匹配到

看到上面一层又一层的嵌套,我想你应该猜到这个正则表达式会特别长,居然达到了 5323 个字符...令我惊讶的是,它匹配起来依然很快,对性能来说几乎没有影响。
目前为止,我依然是个正则新手,上面的解决方法肯定不是最优的,大家如果有好的方法可以提 PR ,或者开 Issue 和我讨论,一起精进!
4 cn2an 介绍 【cn2an 核心代码解析(快速转化「中文数字」和「阿拉伯数字」)】如果你的需求简单,可以直接把上面代码嵌入到自己的程序中,如果你想有更多的功能,来试试 cn2an 吧!
cn2an 是一个快速转化 中文数字阿拉伯数字 的工具包!
cn2an 核心代码解析(快速转化「中文数字」和「阿拉伯数字」)
文章图片

点我访问 DEMO
4.1 功能
  • 支持 中文数字 => 阿拉伯数字
  • 支持 大写中文数字 => 阿拉伯数字
  • 支持 中文数字和阿拉伯数字 => 阿拉伯数字
  • 支持 阿拉伯数字 => 中文数字
  • 支持 阿拉伯数字 => 大写中文数字
  • 支持 阿拉伯数字 => 大写人民币
  • 支持 中文数字 的句子 => 阿拉伯数字 的句子;
  • 支持 阿拉伯数字 的句子 => 中文数字 的句子;
  • 支持 小数
  • 支持 负数
  • 支持 HTTP API
4.2 安装
# pip 安装 pip install cn2an -U#从代码库安装 git clone https://github.com/Ailln/cn2an.git cd cn2an && python setup.py install

4.3 使用
# 在文件首部引入包 import cn2an# 查看版本 print(cn2an.__version__) # 0.4.5# 在 strict 模式(默认)下,只有严格符合数字拼写的才可以进行转化 output = cn2an.cn2an("一百二十三") # 或者 output = cn2an.cn2an("一百二十三", "strict") # output: # 123# 在 normal 模式下,还可以将 一二三 进行转化 output = cn2an.cn2an("一二三", "normal") # output: # 123# 在 smart 模式下,还可以将混合拼写的 1百23 进行转化 output = cn2an.cn2an("1百23", "smart") # output: # 123# 以上三种模式均支持负数 output = cn2an.cn2an("负一百二十三") # output: # -123# strict 和 normal 模式支持小数,smart 模式暂不支持 output = cn2an.cn2an("一点二三") # output: # 1.23

更多使用方法请访问 Github。
4.4 API 访问
Java
import java.net.URL; import java.net.HttpURLConnection; import java.io.BufferedReader; import java.io.InputStreamReader; public class HttpGetExample { public static void main(String[] args) throws Exception { HttpGetExample http = new HttpGetExample(); String url = "https://api.dovolopor.com/v1/cn2an"; String params = "?text=123&function=an2cn&method=low"; http.get(url + params); }private void get(String url) throws Exception { URL obj = new URL(url); HttpURLConnection con = (HttpURLConnection) obj.openConnection(); con.setRequestMethod("GET"); con.setRequestProperty("User-Agent", "Mozilla/5.0"); BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream())); String inputLine; StringBuffer response = new StringBuffer(); while ((inputLine = in.readLine()) != null) { response.append(inputLine); } in.close(); System.out.println(response.toString()); } } // { output: "一百二十三", msg: "转化成功" }

Javascript
const axios = require("axios")axios.get("https://api.dovolopor.com/v1/cn2an", { params: { text: "123", function: "an2cn", method: "low" } }).then( function (res) { console.log(res.data); } ) // { output: "一百二十三", msg: "转化成功" }

Go
package mainimport ( "fmt" "io/ioutil" "net/http" "net/url" )func main(){ params := url.Values{} Url, err := url.Parse("https://api.dovolopor.com/v1/cn2an") if err != nil { return } params.Set("text", "123") params.Set("function", "an2cn") params.Set("method", "low")Url.RawQuery = params.Encode() urlPath := Url.String() resp,err := http.Get(urlPath) defer resp.Body.Close() body, _ := ioutil.ReadAll(resp.Body) fmt.Println(string(body)) } // { output: "一百二十三", msg: "转化成功" }

Python
import requestsresponse = requests.get("https://api.dovolopor.com/v1/cn2an", params={ "text": "1234567890", "function": "an2cn", "method": "low" } ) print(response.json()) # { output: "一百二十三", msg: "转化成功" }

4.5 性能
  • 测试版本:v0.4.3
  • 测试设备:2.3 GHz 双核Intel Core i5 MacBook Pro
  • 测试代码:performance.py
  • 测试方法:
    pip install -r requirements_test.txtpython -m cn2an.performance

  • 测试结果:
    | 序号 | 功能 | 执行次数 | 执行时间(平均) | 性能(次/秒)
    | :-: | :-: | :-: | :-: | :-: |
    | 1 | an2cn | 10000 | 0.20 | 50k |
    | 2 | cn2an | 10000 | 0.35 | 29k |
测试时,我使用的是最大长度的测试数据!因此,大多数情况下该库的性能会更好~
4.6 许可证
cn2an 核心代码解析(快速转化「中文数字」和「阿拉伯数字」)
文章图片
cn2an 核心代码解析(快速转化「中文数字」和「阿拉伯数字」)
文章图片

    推荐阅读