最近在使用java模拟一个中文拼音输入法,之所以说模拟是因为此输入法只能够在特定的文本框中输入(用java编写嘛)。为了要能够实现连续拼音序列的识别,我们使用HMM作为模型,而大名鼎鼎的Viterbi算法也是动态规划的经典应用,此算法名气之大已无需我来解释,网络上自有高人。为了能够找到构建HMM模型的观察序列(建模的第一步),对于用户输入的连续的字母序列,我们需要得到对这个序列用户最可能输入的音节。
最先想到的解决方案,当然是正向最长匹配。方法足够地简单,没有难度,但也有非常大的缺陷。比如,用户输入xianguo,按照正向最大匹配算法,xiang是一个合法的拼音,xiangu不是,所以,我们在xiang后面加入分割。然而剩下的uo却不好分了,因为,uo或者u'o都是不合法的拼音。
那么,分出什么样的音节,对于一个输入的字母序列来说,是一个好的分隔呢?
首先,分出的音节应当尽量地少。对于输入danteng,当然,可以分成d'a'n't'e'n'g,这样分是没有问题的,不过确实很蛋疼。用户最有可能的意图应该是dan'teng。
其次,分出的音节应该尽量地完整。如何定义完整呢?我们对于一个可能的音节,分为3种状态,我们定义costs函数。当这个音节Py是一个完整的合法音节时,costs(Py) = 0;当这个音节Py不是一个完整的合法音节,但是它是至少一个合法音节的前缀时,costs(Py) = 1;当它连合法音节的前缀都不可能是的时候,我们定义costs(Py) = 2.我们用一个音节的costs的值作为对其完整性的判断,显然,costs值越低越好。比如,对于输入:gonga,对于第一点要求,分割gong'a与gon'ga都符合要求。但对于第二个要求,gon不是一个完整的合法拼音,它只是一个合法完整拼音的前缀,而在第一个分割中,两个都是完整合法的拼音,所以,这里选择前者作为最优分割。
这样,我们形式化定义问题为:
输入:一个可能含有多个音节的拼音序列
输出:满足上述条件(音节最少,音节最完整)的分隔好的音节
对于这个输入序列,假设我们通过某种方法找到了第一个分隔的地方,我们便有了两个长度稍短的子序列,按照同样的方法处理这两个子序列,我们可以得到这两个子序列的分隔方式,合并起来就是整个序列的分隔方式。这是基本的分而治之策略。那么,对于一个序列来说,如何找到它满足上述条件的分隔点呢?我们很自然地想到,搜索所有可能的分隔点,比较它们的效果,然后选择最优的分隔点作为这个序列的分隔点。看起来,似乎可以用动态规划算法来解决。我们找到了子问题的分割方法,我们可以基于这个子问题写出递归式来表示每个子问题的评分,作为日后选择分割方案的依据。这个我们定义一个在原有的输入序列中从第i位置开始到第j位置结束的子串的评分为:
M(i, j) = costs(INPUT(i, j)) if i = j
M(i, j) = min(M(i, t) + M(t + 1, j) + costs(INPUT(i, j))) if i != j
对于动态规划,我们分割了子问题,我们找出了递归式,似乎大功告成。
但是,对于输入长度为n的输入序列,我们最多可以分隔为n个音节(需要n-1个分隔符),最少分隔为1个音节,这样就有2^n-1种分隔方法,不是一个多项式时间的解决方案。
。动态规划同样是搜索问题所有可能的空间,但可以在多项式时间内解决,原因就在于对于之前对子问题运算结果的存储,在遇到相同的子问题时,不用重复计算,就可以直接利用结果。比如说,我们的输入:xuanbu,其中,任意一个子串,比如说,an,它在许多的长度更长的子串中,比如xuan,uan,xuanb,uanb,uanbu,anb,anbu,xuanbu中。包括这些子串都有相互包含的迹象。这样,我可以不使用递归的方法,而是使用迭代的方法,以一种合理的计算顺序,从最小的问题开始计算,然后根据之前的计算结果,计算新的结果。
由于计算长度为l的子串,需要优先知道长度比l小的子串的M值,而同样长度的子串间计算没有依赖关系。所以,我们从长度为1的所有子串开始,依次计算,知道计算到长度和输入序列长度n相同为止。
首先,我们做一个M矩阵(保存子串的最优M值)和同样大小的P矩阵(保存最优分割的位置),行数i表示子串开始的位置,列数表示子串结束的位置。矩阵的维数等于输入序列的长度n。对于输入序列xuanbu,我们有如下的矩阵(左边的,数字为计算顺序):
x | u | a | n | b | u | |
---|---|---|---|---|---|---|
x | 1 | 7 | 12 | 16 | 19 | 21 |
u | 2 | 8 | 13 | 17 | 20 | |
a | 3 | 9 | 14 | 18 | ||
n | 4 | 10 | 15 | |||
b | 5 | 11 | ||||
u | 6 |
P(i, j) = i if i = j
P(i, j) = M(i, j) =argmin t(M(i, t) + M(t + 1, j) + costs(INPUT(i, j))) if i != j
我们依据上述关于M和P的递归式,可以得出矩阵P和矩阵M。
如此,我们似乎已经有了所有分割的位置,似乎在递归式中我们只考虑了我们对结果要求的第二点,如何将这个矩阵中的结果转化为最终的分隔结果呢?如果按照P的值一直分割下去,我们又将分成单个字母为一个拼音的情况。我这样处理,先从整个序列找到最佳的分隔位置p,然后做分割,分割出的两个子序列,对于每次分割出的子序列,只要是完整合法的音节或者是某个完整合法音节的前缀,就不继续分割,把不符合条件的子序列在矩阵P中找到分割位置,然后按同样的方法处理。直到每个分割出来的子序列都是完整合法的音节或者是某个完整合法音节的前缀。最后将结果按照输入序列的顺序调整好返回。
算法的源代码(java) GitHub传送门这里:
PYSeparator.java
import java.util.ArrayList; import java.util.LinkedList; import java.util.List; import java.util.Map.Entry; import java.util.AbstractMap.SimpleEntry; import Building.TrieTree; public class PYSeparator implements Separator { Dict dict; public PYSeparator(Dict d){ this.dict = d; } public ArrayList<String> separate(String str){ ArrayList<String> ret = new ArrayList<String>(); if(dict.costs(str) != 2){ ret.add(str); return ret; } int size = str.length(); int[][] vMat = new int[size][size], posMat = new int[size][size];//posMat记录分割线前面一个字母的位置 int row, col; for(int i = 0; i < size; i++){ int ceil = size - i; col = i; row = 0; for(int j = 0; j < ceil; j++){ if(row == col){ vMat[row][col] = dict.costs("" + str.charAt(row)); posMat[row][col] = row; } else{ int min = Integer.MAX_VALUE, pos = row; for(int t = row; t < col; t++){ int value = vMat[row][t] + vMat[t + 1][col] + dict.costs(str.substring(row, col + 1)); if(value <= min){ min = value; pos = t; } } vMat[row][col] = min; posMat[row][col] = pos; } col++; row++; } } LinkedList<Entry<Integer, Integer>> splits = new LinkedList<Entry<Integer, Integer>>(); splits.add(new SimpleEntry<Integer, Integer>(0, str.length() - 1)); while(!splits.isEmpty()){ Entry<Integer, Integer> span = splits.pollFirst(); int st = span.getKey(), end = span.getValue(); String nowSt = str.substring(st, end + 1); if(dict.costs(nowSt) != 2){ ret.add(nowSt); continue; } int sp = posMat[st][end]; if(sp + 1 <= end) splits.addFirst(new SimpleEntry<Integer, Integer>(sp + 1, end)); if(st <= sp) splits.addFirst(new SimpleEntry<Integer, Integer>(st, sp)); } return ret; }Dict.java
public interface Dict { /** * * @param py * @return 0 when py is a legal Pinyin; * 1 when py is the prefix of a legal Pinyin * 2 when py is not possibly a legal Pinyin */ public int costs(String py); }Separator.java
import java.util.List; public interface Separator { public List<String> separate(String str); }