繁体是一种历史悠久的汉字书写形式,至今港澳台等地区仍然广泛使用。由于大中华地区长期保持着文化交流,我们使用的现代汉语也没有出现太大的差别,这让机器完成简繁转换成为可能。虽然没有很大差异,但我们汉字简化本身,就造成了许多一对多的关系。虽然单个字在简繁转换之后,基本上已经能达到可以阅读的程度,但如果追求更高品质的转换,就需要以词汇为基础来实现简繁转换。
很多开发者一提到简繁转换,最先想到的是OpenCC。这是一个应用广泛的开源库,各种中文输入法以及大数据语料处理一般都首选这个库。不过,在OpenCC出现之前,我们中文维基百科从2004年起就在使用自动的简繁转换了。而自动的转换转换也成为了全球中文用户可以共用一个中文维基百科站点的重要基础。OpenCC作者的博客也提到了有关维基百科上转换的情况1。
我之所以这次采用维基百科数据来构建这个简繁转换,主要是因为我接触这个系统很久,我知道它的优缺点在哪。而且,我想要至少达到与维基百科的简繁转换一样的水平。另外还有一个原因是,这部分代码我将用于LuaWiki项目的简繁转换,而LuaWiki的其中一个目标也是能够实现MediaWiki的部分功能。
我想我也有必要提一下维基百科简繁转换的好的地方。
(图片来自网络,原始来源可能为HarukawaSayaka的Twitter,仅用于说明简繁转换这一主题)
在维基百科上,用对转换组,至少不会出现上图这样的“海记忆体知己”的问题。(在IT转换组下,对于台湾正体来说,“内存”才会被转换成“记忆体”)不想过多夸它,接下来就来谈谈我是如何实现它的功能的吧。
作为一个曾经热爱算法的青年,实现简繁转换不可能不用算法的。这次我用到的是字典树。因为我想也就字典树最适合这样的场景。
字典树是什么?就是查字典呗,一个一个字母查呗。用字典树有什么好处呢?就是查词方便呗。再具体点呢,就是当你不知道词有多长的时候,你查起来依然很方便。简繁转换的一个原则是,找到最长匹配的词,加以替换。字典树让我们像查字典一样,翻到一个字母,就只用在这个字母范围内找了。差不多就是这个意思。最后如果匹配不上,也可以知道我们之前哪里匹配上了。
维基百科的简繁转换如果简单来说分为两类,内置转换和语法转换。这两种编写的方式是不一样的。内置转换全部都是一对一的转换,转换的来源认为是zh语言,所以这个简繁转换是可以处理中文维基中的简繁混合的情况。转换成的语言变种也是唯一的。这个数据我们可以直接用来构建字典树了。构建过程不必多说,因为这种树实在太简单了,又不需要保持树结构的平衡,只要把所有数据插入就有了一棵树了。
{
"乾": {
_: "干",
"一": { "坛": { _: "乾一坛" }, "壇": { _: "乾一坛" }, "組": { _: "乾一组" }, "组": { _: "乾一组" } },
"上": { "乾": { "下": [Object] } },
"东": { _: "乾东" },
"東": { _: "乾东" },
"为": { "天": { _: "乾为天" }, "阳": { _: "乾为阳" } },
"為": { "天": { _: "乾为天" }, "陽": { _: "乾为阳" } },
"九": { _: "乾九" },
"乾": { _: "乾乾", "淨": { "淨": [Object] }, "脆": { "脆": [Object] } },
}
}
这就是建立好的字典树的样子。我用_
来标明某个节点的值,因为有值的节点才能真正构成一个词,才能进行简繁转换。这棵树就是为简繁转换而建的,所以节点上存储的值也是转换的结果。而真正把如此的字典树用在转换上,就要一层层往里找到对应的词汇。具体代码贴在这。我不是太爱贴代码,但这个代码真的没什么可讲的,就是简单的循环啦。
xxxxxxxxxx
function doConvert(str) {
let cursor = 0;
let strlen = str.length;
const resList = [];
let getMap = function() {
let start = cursor;
let matched = [];
let curNode = trie;
while (true) {
if (cursor === strlen) break;
let result = curNode[str[cursor]];
if (!result) break;
curNode = result;
cursor++;
result._cursor = cursor;
matched.push(result);
}
let curMatchIndex = matched.length - 1;
while (curMatchIndex >= 0) {
let curMatch = matched[curMatchIndex];
if (curMatch && curMatch._) {
resList.push(curMatch._);
cursor = curMatch._cursor
return;
}
curMatchIndex--;
}
// when no matches can be used
resList.push(str[start]);
cursor = start + 1;
}
while (cursor < strlen) {
getMap();
}
return resList.join('');
}
我想说一下,对于已经确定目标变种的情况下,构建字典树只针对一种语言变种就足够了。维基百科的简繁转换文法,也可以自己实现。我这个人比较喜欢摸索大概的规律,不太喜欢直接改写(抄)MediaWiki的源码。我的实现或许会与原版有偏差,但通常满足我认为的简洁性。
那么我就来介绍一下我摸索到的转换语法的内部机理。
当我们对内部机理有了认识之后,解析转换规则就不再有难点了。一条一条地读规则,然后将它们加到trie上就好。不需要担心前面的规则优先级比后面的高的问题,因为上面的内部机理告诉我们,没有那么多幺蛾子。
再说说维基百科上的转换组。维基百科上目前大多数转换组都是采用Lua模块实现。而这些Lua模块基本就是JSON数据,没有什么别的(大多数维基人不会编程,所以也不会有什么别的)。我们把它转换成真正的JSON数据,然后JS就可以自如地读取了。不同的转换组之间也遵守后面覆盖前面的原则,非常的直白。
当然如此的简繁转换是非常依赖于维基编者人工的维护的。维基百科上之所以能做到在不对文本进行分词的情况下,让这个转换能够正常工作,因为维基人会在转换错误的地方人工使用转换语法分开词汇。这虽然不是自动的,但通过人工很好地解决了这个问题。
我设想过先进行分词再简繁转换。对于维基百科这种内文简繁混合的情况,在分词之前也许需要先进行文字的归一化2,而分词系统对应的也需要处理归一化后的词汇。虽然现在分词系统已经非常准确了,但如果用在维基百科这类系统中,可能依然需要能够人工干预以达到万无一失。∎