问题来源
这个问题,是看到有人提到带中文数字的章节标题,要排序的问题引起的。比如对于:
title_list = [
'第一章',
'第三章',
'第五章',
'第四章',
'第二章',
]
想“正确”排序的话,你直接 title_list.sort()
是不行地:
zys@tower:~$ python3
Python 3.7.1 (default, Apr 1 2019, 01:01:48)
[GCC 7.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> title_list = [
... '第一章',
... '第三章',
... '第五章',
... '第四章',
... '第二章',
... ]
>>> title_list.sort()
>>> title_list
['第一章', '第三章', '第二章', '第五章', '第四章']
>>>
原因是在具体编码的码表中,“三”并不一定排在“二”前面,码表的编撰可不会特别考虑中文的排序这种事。
当然,对于不同的编码,这几个数字字符的相对位置,可能会有差异。上面的例子,只代表 Unicode 的一个结果。你转成 GB18030 或者 BIG5 的字节,结果可能不同。但这也足够说明,中文的数字依赖字典序是不可靠的。编码不同,结果就不同,这里的“字典序”本身就没有标准可言,如果你假设 Unicode ,那事实上这些字符的顺序也不是我们希望的“1 2 3 4”这样的顺序。
所以,这里就引出,我们可以把中文数字,转成阿拉伯数字,然后使用整数大小来排序就可以准确控制了。
光是 “一”,“二”,“三” 这些单个数字,倒是好办,我们写死一位数的映射都可以解决问题。接下来要说的,自然是更一般性的处理方法。
比如:
一千零一亿一千零一万一千零一
要处理的问题
看上面的中文数字,直观地,可以看出中文数字其实是由“数字”和“单位”构成的:
- 数字就是“一”,“二”,“三”这些。
- 单位则是“千”,“万”,“亿”。
这里我们先明确一下“十”这个“搅屎棍”的位置,它到底算数字,还是算单位。从我们日常习惯用法中看,它是两者都有的:
- 用于单位,“一十”,“一十五万”。
- 用于数字,“一千零十万”。(注意区分,“一千”个“十万”,和“一千零十”个“万”的不同)
我们会把“十”作为单位处理,把它当数字时的情况作为特例。这样面对的特例,要比作为数字少得多。
如果我们能正确解析出汉字数字中的“数字”与“单位”的话,那么通过映射与简单运算,就可以得到需要的阿拉伯数字结果。
比如对于:
一千零一亿一千零一万一千零一
如果我们可以解析到:
- 1001
- 亿
- 1001
- 万
- 1001
那最后的结果就很容易了,是 1001 * 10^8 + 1001 * 10^4 + 1001
。
实际的问题当然没这么简单,上面的结果没问题,但首先你要能得到 1001
啊:
-
1
- 千
- 零
- 1
- 亿
-
1
- 千
- 零
- 1
- 万
-
1
- 千
- 零
- 1
这才是我们从文字中看到的状况。
上面的结构,像是一颗树,按这个方向,为了方便定义节点和分支,我们可以整理得到:
-
亿
- 1
- 千
- 零
- 1
-
万
- 1
- 千
- 零
- 1
-
1
- 1
- 千
- 零
- 1
继续分解:
-
亿
-
千
- 1
- 零
- 1
-
-
万
-
千
- 1
- 零
- 1
-
-
1
-
千
- 1
- 零
- 1
-
对于这个结构,针对每一个节点,我们从下往上的顺序计算,就可以计算出每个节点的值——平行节点的值相加,父子节点的值相乘,过而得到最终值。再推广一下,整颗树从下往上顺序遍历就可以算出结果。
上面的分拆过程,有助于我们看到这个问题更深的一些信息,这些信息就是我们设计代码的参考。
我们的分拆,在思维上最开始可能是依据结果的倒推(我们知道“一千零一亿一千零一万一千零一”就是 1001 * 10^8 + 1001 * 10^4 + 1001
),但我们写代码需要的是正向的逻辑,所以回到最初,去找出我们直觉上的逻辑依据:
对于:
一千零一亿一千零一万一千零一
我们怎么发现中间这些字应该提出来作为子节点?
一千零一亿一千零一万一千零一
这个问题不难。
前面介绍了“数字”和“单位”的概念,我们把原始汉字中这两者分开:
一千零一亿一千零一万一千零一
只看标色的“单位”,单位的值我们可以以 10
的指数来表示,从左右到绘制到直角坐标系上就是:
$data<<EOF
1 3
2 8
3 3
4 4
5 3
EOF
$data2<<EOF
2 8
4 4
EOF
set xrange [0:6]
set yrange [0:10]
set ytics ("10" 1, "100" 2, "1000" 3, "10000" 4, "10^5" 5, "10^6" 6, "10^7" 7, "10^8" 8)
set pointsize 2
plot $data using 1:2 notitle with line, $data using 1:2 notitle with point ls 6, $data2 using 1:2 notitle with point ls 7
从图上可以看出,我们要提出来的节点,正好是“波峰”。
再找个例子看看:
一千一百 万 零 十一亿一千一百一十一万一千一百一十一
$data<<EOF
1 3
2 2
3 4
4 1
5 8
6 3
7 2
8 1
9 4
10 3
11 2
12 1
EOF
$data2<<EOF
3 4
5 8
9 4
EOF
set xrange [0:13]
set yrange [0:10]
set ytics ("10" 1, "100" 2, "1000" 3, "10000" 4, "10^5" 5, "10^6" 6, "10^7" 7, "10^8" 8)
set pointsize 2
plot $data using 1:2 notitle with line, $data using 1:2 notitle with point ls 6, $data2 using 1:2 notitle with point ls 7
中文的习惯,报数是从大单位到小单位报,所以单位的连线应该是一直向下的。而突然向上,出现波峰,那么肯定是某个大单位的阶段性结束,前面说的那些都是为了“修辞”这个大单位的。
比如上图的,一千一百,单位是变小的,但马上接了一个万,至此,“万”这个范围,就结束了。
接着万,后面是 十,这是一个缩小的单位,没有特殊,再来就是亿,它是一个比“万”大的单位,同理,意味着“亿”这个范围结束了。
后面还有一个极点,在 万 上,也是同样的道理。
再往上看一层,整个过程,波峰处的单位顺序是 万 - 亿 - 万 。
这是否意味着子节点是这三个呢?
-
万
- x
- x
-
亿
- x
- x
-
万
- x
- x
答案是否定的,“万亿”,是“万” x “亿”,不是“万” + “亿”。
所以正确的结构应该是:
-
亿
- x
- x
-
万
- x
- x
-
万
- x
- x
从 万 - 亿 - 万 要得到这样的结构,又有什么规则?
回溯。
如果从左往右扫,即当碰到一个更大的单位时,需要回溯之前的节点,之前所有不比它大的单位,都应该成为其子节点。
到此,一般性的规则就介绍完了。(其实就这么点)
运用这个规则,就可以把中文数字,转成一个树结构,进而运算出阿拉伯数字结果。
回溯的优化
这点算是经验之谈吧。
当一个场景,如果你从左到右遍历,需要回溯处理。那么换个方向,从右到左遍历,可能就不需要回溯了。如果遍历对象长度很大,这通常会带来可观的性能提升,及代码编写上的简单。
看之前示例的图和树结果:
$data<<EOF
1 3
2 2
3 4
4 1
5 8
6 3
7 2
8 1
9 4
10 3
11 2
12 1
EOF
$data2<<EOF
3 4
5 8
9 4
EOF
set xrange [0:13]
set yrange [0:10]
set ytics ("10" 1, "100" 2, "1000" 3, "10000" 4, "10^5" 5, "10^6" 6, "10^7" 7, "10^8" 8)
set pointsize 2
plot $data using 1:2 notitle with line, $data using 1:2 notitle with point ls 6, $data2 using 1:2 notitle with point ls 7
-
亿
- x
- x
-
万
- x
- x
-
万
- x
- x
- x
- x
- x
从左到右遍历之所以要回溯,是因为相对于这个树结构,从左到右遍历的过程是后找到父节点的。当你确定了一个父节点之后,它的子节点你需要重新组织调整。
如果我们把方向反过来,从右到左遍历的话,对于这个树,总是先遇到父节点。需要做的判断,仅仅是当前节点是否结束,然后开始一个新的父节点,并不需要不断折返来调整节点结构。
新节点开始的规则也简单明了,就是碰到了一个比之前都大的单位。
这些规则弄明白之后,编码就相对容易了。剩下的更多时间,是处理各种不合逻辑的例外情况,毕竟,人说话是依赖“习惯”,而不是“逻辑”。
编码
基本配置准备
就是数字和单位的映射,一些信息可以从网上搜索到:
num_map = {
u'零': 0,
u'〇': 0,
u'两': 2,
u'一': 1, u'二': 2, u'三': 3, u'四': 4, u'五': 5, u'六': 6, u'七': 7, u'八': 8, u'九': 9,
u'壹': 1, u'贰': 2, u'叁': 3, u'肆': 4, u'伍': 5, u'陆': 6, u'柒': 7, u'捌': 8, u'玖': 9,
u'廿': 20,
u'卅': 30,
u'卌': 40,
u'圩': 50,
u'圆': 60,
u'进': 70,
u'枯': 80,
u'枠': 90,
}
rank_map = {
u'十': 10, u'百': 100, u'千': 1000, u'万': 10000, u'亿': 100000000,
u'拾': 10, u'佰': 100, u'仟': 1000,
u'兆': 10 ** 16
}
后面如果找到新的词,也可以直接添加进去。
解析中文
这一步做的事,是把原始中文字符转换成我们好处理的,中间状态的数据结构:
先用比较直接简单的例子来随时测试我们代码的基本功能,如: 四千五百万八千零一 。
简单地使用一个 dict ,有 type 和 value 两个值就好。
设计上,一点技巧,一是把 0
单独处理,因为其实我们知道 零 有些特殊,但还没有事例来说明它到底特殊在哪里,所以,给它一点特殊待遇,专门设计一个 zero
的类型。
另一个,是在最后添加一个 complete
类型。这个明确的收尾信息,可以方便我们处理一些兜底工作,有助于更好的代码结构。(这个场景,类似于在一个迭代过程中,有一个临时容器。当迭代结束后,因为在迭代里面的代码并不知道迭代何时结束,所以一般你需要在迭代外再额外处理一下这个临时容器。如果在迭代过程中有一个明确的结束信号,比如明确知道这是最后一个条目了,那么在迭代内部就可以完成所有工作,代码会好看点。)
s = '四千五百万八千零一'
def parse(s):
gen = []
for c in s:
if c in num_map:
if num_map[c] == 0:
gen.append({'type': 'zero'})
else:
gen.append({'type': 'number', 'value': num_map[c]})
if c in rank_map:
gen.append({'type': 'rank', 'value': rank_map[c]})
gen.append({'type': 'complete'})
return gen
执行 parse
可以得到这样的结果:
[
{'type': 'number', 'value': 4},
{'type': 'rank', 'value': 1000},
{'type': 'number', 'value': 5},
{'type': 'rank', 'value': 100},
{'type': 'rank', 'value': 10000},
{'type': 'number', 'value': 8},
{'type': 'rank', 'value': 1000},
{'type': 'zero'},
{'type': 'number', 'value': 1},
{'type': 'complete'}
]
对于理想状态,这个结果足够我们进行接下来的工作了。
生成阿拉伯数字
这一步的一个正常过程,就是前面我们讲“规则”的过程。先做一个从左到右的正向遍历,不加回溯,处理一些最简单场景。接着加入更多场景,发现不加回溯搞不定。最后想到,换个方面遍历更直接方便。
项目上的代码并不是一蹴而就的,中间会经历很多次的重构。
这里就直接给出反向遍历的一个简单结果:
def generate(token_list):
gen = token_list[:-1]
gen.reverse()
gen.append(token_list[-1])
block = []
level_rank = 1
current_rank = 1
for o in gen:
if o['type'] == 'number':
if not block:
block.append([])
block[-1].append(o['value'] * current_rank)
if o['type'] == 'rank':
rank = o['value']
if not block:
level_rank = rank
current_rank = rank
block.append([])
continue
if rank > level_rank:
level_rank = rank
current_rank = rank
block[-1] = sum(block[-1])
block.append([])
else:
current_rank = rank * level_rank
block[-1] = sum(block[-1])
block.append([])
if o['type'] == 'zero':
continue
if (o['type'] == 'complete'):
if isinstance(block[-1], list):
block[-1] = sum(block[-1])
return sum(block)
-
token_list
先作了reverse
。 - 遍历过程中,小心处理
number
值和rank
值就好。(这点可能需要反复调试和思考)
最终,这个 generate
就可以得到我们期望的结果, 45008001 。
不错的开始。
前面一直在提“理想状况”,是我们的代码只是针对 45008001 这一个普通例子完成。接下来,像这种业务场景,我们就可以做很多的测试用例,然后来“测试驱动完善”了。
更多测试用例
下面的测试用例,绝大部分,是从: https://github.com/HaveTwoBrush/cn2an/blob/master/cn2an/cn2an_test.py 这里获取的。
TEST_SUIT = {
u"一": 1,
u"两": 2,
u"十": 10,
u"十一": 11,
u"一十一": 11,
u"一百一十一": 111,
u"一千一百一十一": 1111,
u"一万一千一百一十一": 11111,
u"一十一万一千一百一十一": 111111,
u"一百一十一万一千一百一十一": 1111111,
u"一千一百一十一万一千一百一十一": 11111111,
u"一亿一千一百一十一万一千一百一十一": 111111111,
u"一十一亿一千一百一十一万一千一百一十一": 1111111111,
u"一百一十一亿一千一百一十一万一千一百一十一": 11111111111,
u"一千一百一十一亿一千一百一十一万一千一百一十一": 111111111111,
u"壹": 1,
u"拾": 10,
u"拾壹": 11,
u"壹拾壹": 11,
u"壹佰壹拾壹": 111,
u"壹仟壹佰壹拾壹": 1111,
u"壹万壹仟壹佰壹拾壹": 11111,
u"壹拾壹万壹仟壹佰壹拾壹": 111111,
u"壹佰壹拾壹万壹仟壹佰壹拾壹": 1111111,
u"壹仟壹佰壹拾壹万壹仟壹佰壹拾壹": 11111111,
u"壹亿壹仟壹佰壹拾壹万壹仟壹佰壹拾壹": 111111111,
u"壹拾壹亿壹仟壹佰壹拾壹万壹仟壹佰壹拾壹": 1111111111,
u"壹佰壹拾壹亿壹仟壹佰壹拾壹万壹仟壹佰壹拾壹": 11111111111,
u"壹仟壹佰壹拾壹亿壹仟壹佰壹拾壹万壹仟壹佰壹拾壹": 111111111111,
u"一百零一": 101,
u"一千零一": 1001,
u"一千零一十一": 1011,
u"一千一百零一": 1101,
u"一万零一": 10001,
u"一万零一十一": 10011,
u"一万零一百一十一": 10111,
u"一十万零一": 100001,
u"一百万零一": 1000001,
u"一千万零一": 10000001,
u"一千零一万一千零一": 10011001,
u"一千零一万零一": 10010001,
u"一亿零一": 100000001,
u"一十亿零一": 1000000001,
u"一百亿零一": 10000000001,
u"一千零一亿一千零一万一千零一": 100110011001,
u"一千亿一千万一千零一": 100010001001,
u"一千亿零一": 100000000001,
u"十万": 100000,
u"十万零一": 100001,
u"十亿零一万零一": 1000010001,
u"拾万零壹": 100001,
u"拾亿零壹万零壹": 1000010001,
u'一千一': 1100,
u'一千一零一': 1101,
u'一千零十亿': 101000000000,
u'一一': 11,
u'一二三': 123,
}
拿着这些测试用例去验证 parse
和 generate
肯定会发现很多异常的,错误的情况。再针对这些错误的用例,去补充,修改代码就好。语言不是数学,追求系统层面的完备我觉得没必要,特殊情况一个 if
就行,实在不行,就再加一个 if
。
完整实现
下面是一个我多次修改后的一个相对完整的实现,已经通过上面列出的所有测试。
# -*- coding: utf-8 -*-
num_map = {
u'零': 0,
u'〇': 0,
u'两': 2,
u'一': 1, u'二': 2, u'三': 3, u'四': 4, u'五': 5, u'六': 6, u'七': 7, u'八': 8, u'九': 9,
u'壹': 1, u'贰': 2, u'叁': 3, u'肆': 4, u'伍': 5, u'陆': 6, u'柒': 7, u'捌': 8, u'玖': 9,
u'廿': 20,
u'卅': 30,
u'卌': 40,
u'圩': 50,
u'圆': 60,
u'进': 70,
u'枯': 80,
u'枠': 90,
}
rank_map = {
u'十': 10, u'百': 100, u'千': 1000, u'万': 10000, u'亿': 100000000,
u'拾': 10, u'佰': 100, u'仟': 1000,
u'兆': 10 ** 16
}
s = u'一兆零一'
def convert(s):
gen = []
last_rank = 1
#十 起头,其它 rank 起头, 都要补一,否则后面逻辑无法统一解释
if s[0] in rank_map:
gen.append({'type': 'number', 'value': 1})
for c in s:
if c in num_map:
if num_map[c] == 0:
#前面是数字,要补 rank , 1101
if gen[-1]['type'] == 'number':
gen.append({'type': 'rank', 'value': int(last_rank / 10)})
gen.append({'type': 'zero'})
else:
#连着两个数字, 如 一一
if gen and (gen[-1]['type'] == 'number'):
gen.append({'type': 'rank', 'value': 10})
gen.append({'type': 'number', 'value': num_map[c]})
if c in rank_map:
# 连续两个 rank ,合并
# 十 有歧义,如果它前面带零不能合,如 一千零十亿, 还要把它改成数字
# 不带零要合, 如 一十万零一
last_rank = rank_map[c]
if gen and gen[-1]['type'] == 'rank':
if (len(gen) > 1) and (gen[-1]['value'] == 10) and (gen[-2]['type'] == 'zero'):
gen[-1]['type'] = 'number'
gen.append({'type': 'rank', 'value': rank_map[c]})
else:
gen[-1]['value'] *= rank_map[c]
continue
gen.append({'type': 'rank', 'value': rank_map[c]})
#补末位的省略, 如 一千一
if gen and len(gen) > 1:
if (gen[-1]['type'] == 'number') and (gen[-2]['type'] == 'rank'):
gen.append({'type': 'rank', 'value': int(gen[-2]['value'] / 10)})
gen.reverse()
gen.append({'type': 'complete'})
block = []
level_rank = 1
current_rank = 1
for o in gen:
if o['type'] == 'number':
if not block:
block.append([])
block[-1].append(o['value'] * current_rank)
if o['type'] == 'rank':
rank = o['value']
if not block:
level_rank = rank
current_rank = rank
block.append([])
continue
if rank > level_rank:
level_rank = rank
current_rank = rank
block[-1] = sum(block[-1])
block.append([])
else:
current_rank = rank * level_rank
block[-1] = sum(block[-1])
block.append([])
if o['type'] == 'zero':
continue
if (o['type'] == 'complete'):
if isinstance(block[-1], list):
block[-1] = sum(block[-1])
return sum(block)
if __name__ == '__main__':
print(s, convert(s))
# from: https://github.com/HaveTwoBrush/cn2an/blob/master/cn2an/cn2an_test.py
TEST_SUIT = {
u"一": 1,
u"两": 2,
...
u'一千一': 1100,
u'一千一零一': 1101,
u'一千零十亿': 101000000000,
u'一一': 11,
u'一二三': 123,
}
count = 0
error = 0
for c, v in TEST_SUIT.items():
r = convert(c)
count += 1
if v != r:
error += 1
print('ERROR', c, v, r)
print('SUM: {} ERROR: {}'.format(count, error))