中文分词功能是一项常用的基础功能,有很多开源的工程实现,目前能应用于Android手机端的中文分词器没有很完善的版本。经过调研,我选择了结巴分词,该开源工程思路简单,易于理解,分词效果也还不错,目前有众多语言版本,PYTHON、C++、JAVA、IOS等,暂时还没有Android版本,所以我在Java版本的基础上进行了移植,开发了适用于Android手机的结巴分词Android版(Github)。
相比于Java版本的实现,Android版将字典文件存放在Asset目录下进行读取,同时对字典加载速度进行了大幅优化。原始的Java版本加载完整的字典文件在测试手机上需要28秒,时间太长,经过优化,成功将加载时间降到1.5秒,分词速度1秒以内,满足了Android手机的启动速度要求。
本文将结合代码通过以下三个方面展开介绍:结巴分词的基本原理,Android版的接入方式,以及启动速度优化的实现。
结巴分词的原理
结巴分词采用两种方式进行分词,基于字典的分词和基于HMM(隐马尔科夫模型)的分词。模型会首先加载词典文件生成一个字典树,并利用该字典树进行一段中文的分词,比如“我要去五道口吃肯德基”被分词成“我/要/去/五道口/吃/肯德基”,其中被分成单蹦个的连续中文字符,如“我/要/去”会继续经过HMM模型进行二次分词,看能不能合并成完整的单词,这种设计是为了对不在字典中的字符提供一种兜底的分词方案,可以尽可能的避免单蹦个的分词结果,优化分词的效果。
下面是进行分词的主函数:
private List<String> sentenceProcess(String sentence) {
List<String> tokens = new ArrayList<String>();
int N = sentence.length();
long start = System.currentTimeMillis();
// 将一段文字转换成有向无环图,该有向无环图包含了跟字典文件得出的所有可能的单词切分
Map<Integer, List<Integer>> dag = createDAG(sentence);
Map<Integer, Pair<Integer>> route = calc(sentence, dag);
int x = 0;
int y = 0;
String buf;
StringBuilder sb = new StringBuilder();
while (x < N) { // 遍历一遍贪心算法生成的最小路径分词结果,对单蹦个的字符看看能不能粘合成一个词汇
y = route.get(x).key + 1;
String lWord = sentence.substring(x, y);
if (y - x == 1)
sb.append(lWord);
else {
if (sb.length() > 0) {
buf = sb.toString();
sb = new StringBuilder();
if (buf.length() == 1) { // 如果两个单词之间只有一个单蹦个的字符,添加
tokens.add(buf);
} else {
if (wordDict.containsWord(buf)) { // 如果连续单蹦个的字符粘合成的一个单词在字典树里,作为一个单词添加
tokens.add(buf);
} else {
finalSeg.cut(buf, tokens); // 如果连续单蹦个的字符粘合成的一个单词不在字典树里,使用维特比算法计算每个字符BMES如何选择使得概率最大
}
}
}
tokens.add(lWord);
}
x = y;
}
buf = sb.toString();
if (buf.length() > 0) { // 处理余下的部分
if (buf.length() == 1) {
tokens.add(buf);
} else {
if (wordDict.containsWord(buf)) {
tokens.add(buf);
} else {
finalSeg.cut(buf, tokens);
}
}
}
return tokens;
}
该函数首先通过createDAG将输入的一段文字转换成有向无环图(DAG),该有向无环图包含了根据字典文件得出的所有可能的单词切分,以每个字为单位,比如“我去五道口吃肯德基”,经过createDAG处理后会生成每个字和后面字符可能的单词组合,比如“我/去/五道口/吃/肯德基”/“我去/五道口/吃/肯德基”/“我去/五/道口/吃/肯德基”/“我/去/五道口/吃/肯德/基”等等。
然后经过calc函数,对这个DAG从后向前依据贪婪算法选择一种分词方式。实现比较简单,从最后一个字开始,找出从该字符前面字符跳转到当前字符概率最大的切分方式,然后依次往前走,直到完成整句话的切分。概率的大小依据是字典中该单词的频率值。
/**
* 计算有向无环图的一条最大路径,从后向前,利用贪心算法,每一步只需要找出到达该字符的最大概率字符作为所选择的路径
*
* @param sentence
* @param dag
* @return
*/
private Map<Integer, Pair<Integer>> calc(String sentence, Map<Integer, List<Integer>> dag) {
int N = sentence.length();
HashMap<Integer, Pair<Integer>> route = new HashMap<Integer, Pair<Integer>>();
route.put(N, new Pair<Integer>(0, 0.0));
for (int i = N - 1; i > -1; i--) {
Pair<Integer> candidate = null;
for (Integer x : dag.get(i)) {
double freq = wordDict.getFreq(sentence.substring(i, x + 1)) + route.get(x + 1).freq;
if (null == candidate) {
candidate = new Pair<Integer>(x, freq);
} else if (candidate.freq < freq) {
candidate.freq = freq;
candidate.key = x;
}
}
route.put(i, candidate);
}
return route;
}
经过上面calc函数的切分,整段话会被切分成一些单词和一些单蹦个的字符的组合,对于单蹦个的字符,再调用finalSeg.cut函数进行HMM模型切分,试图尽可能组合成完整的单词,优化切词效果。
finalSeg.cut函数实现如下:
public void cut(String sentence, List<String> tokens) {
StringBuilder chinese = new StringBuilder();
StringBuilder other = new StringBuilder();
for (int i = 0; i < sentence.length(); ++i) {
char ch = sentence.charAt(i);
if (CharacterUtil.isChineseLetter(ch)) { // 遇到一个汉字,就把之前累积的非汉字处理一下加入最终结果
if (other.length() > 0) {
processOtherUnknownWords(other.toString(), tokens);
other = new StringBuilder();
}
chinese.append(ch);
}
else {
if (chinese.length() > 0) { // 遇到一个非汉字符号,就把之前累加的单蹦个汉字处理一下加入最终结果
viterbi(chinese.toString(), tokens); // 处理一串单蹦个汉字的方法是维特比算法
chinese = new StringBuilder();
}
other.append(ch);
}
}
if (chinese.length() > 0) // 处理余下的汉字
viterbi(chinese.toString(), tokens);
else { // 处理余下的非汉字字符
processOtherUnknownWords(other.toString(), tokens);
}
}
finalSeg.cut函数考虑了中文字符和非中文字符的情况,将非中文字符利用正则表达式切成单个的英文单词,将中文字符利用B(Begin)、M(Middle)、E(End)、S(Single)标记方式对每个汉字做出标记,标价的依据是每个汉字选择一个标记符号,使得整串汉字从头到尾行程的路径概率最大。这样就转换成了每一字符到下一个字符跳转概率给定情况下的最短路径问题。最短路径问题有两种标准解法,维特比算法和迪杰斯特拉算法。迪杰斯特拉算法是一种贪心策略,只能保证局部最优,不能保证全局最优。维特比算法能保证求得全局最优解,所以这里使用维特比算法求解。
/**
* 利用维特比算法计算对于一串单蹦个的字符,每个字符到下一个字符如何跳转,以实现整条路径的概率最大
* 例如:我 去 五 道 口
* B B B B B
* M M M M M
* E E E E E
* S S S S S
* @param sentence
* @param tokens
*/
public void viterbi(String sentence, List<String> tokens) {
Vector<Map<Character, Double>> v = new Vector<Map<Character, Double>>();
Map<Character, Node> path = new HashMap<Character, Node>();
v.add(new HashMap<Character, Double>());
for (char state : states) {
Double emP = emit.get(state).get(sentence.charAt(0));
if (null == emP)
emP = MIN_FLOAT;
v.get(0).put(state, start.get(state) + emP);
path.put(state, new Node(state, null));
}
......
}
这样就既保证了词典分词的准确性,又能对没有出现在词典中的单蹦个的汉字进行一定程度的优化分词,具备了一定的灵活性。
接入方式
具体接入方式可以参照结巴分词Android版(Github)进行接入,既可以源码接入,也可以通过gradle接入。
使用的时候首先进行初始化,一般在MyApplication里进行:
// 异步初始化
JiebaSegmenter.init(getApplicationContext());
该初始化是异步进行的,速度仅需1.5秒即可完成包含35万词典的字典树的生成。
该Android分词器提供了三个接口用于分词。
下面两个简单接口分别是同步和异步分词接口:
// 异步接口
public void getDividedStringAsync(final String query, final RequestCallback<ArrayList<String>> callback) {...}
// 同步接口
public ArrayList<String> getDividedString(String query) {...}
同时保留了结巴分词原有的分词接口process,可以指定分词模式是索引模式(INDEX)或搜索引擎模式(SEARCH),两者的差别在于搜索引擎模式分词更精细,索引模式相对更粗粒度。
public static enum SegMode {
INDEX,
SEARCH
}
public List<SegToken> process(String query, SegMode mode) {...}
启动速度优化
原始的Java版本的结巴分词在手机上加载词典速度很慢,35万的词典需要28秒,不能直接使用。这是由于需要根据词典生成字典树(TireTree),每加入一个单词都需要进行查找和比较,很耗时。为此,在Android版本里,我做了预处理,将加载词典生成的字典树按照特定格式存储到了文本中,实际运行的时候,直接从Asset下加载该中间文件,将原来单词随机插入字典树的方式该为顺序插入,极大的加快了速度。
首先通过loadDict函数加载词典,生成字典树:
public boolean loadDict(AssetManager assetManager) {
element = new Element((char) 0); // 创建一个根Element,只有一个,其他的Element全是其子孙节点
InputStream is = null;
try {
long start = System.currentTimeMillis();
is = assetManager.open(MAIN_DICT);
if (is == null) {
Log.e(LOGTAG, "Load asset file error:" + MAIN_DICT);
return false;
}
BufferedReader br = new BufferedReader(new InputStreamReader(is, Charset.forName("UTF-8")));
long s = System.currentTimeMillis();
while (br.ready()) {
String line = br.readLine();
String[] tokens = line.split("[\t ]+");
if (tokens.length < 2)
continue;
String word = tokens[0]; // eg:一两千块
double freq = Double.valueOf(tokens[1]);
total += freq;
String trimmedword = addWord(word); // 将一个单词的每个字递归的插入字典树 eg:一两千块
freqs.put(trimmedword, freq); // 并统计单词首个字的频率
}
// normalize
for (Map.Entry<String, Double> entry : freqs.entrySet()) {
entry.setValue((Math.log(entry.getValue() / total)));
minFreq = Math.min(entry.getValue(), minFreq);
}
Log.d(LOGTAG, String.format("main dict load finished, time elapsed %d ms",
System.currentTimeMillis() - s));
} catch (IOException e) {
Log.e(LOGTAG, String.format("%s load failure!", MAIN_DICT));
return false;
} finally {
try {
if (null != is)
is.close();
}
catch (IOException e) {
Log.e(LOGTAG, String.format("%s close failure!", MAIN_DICT));
return false;
}
}
return true;
}
element是整棵字典树的根节点。
然后通过saveDictToFile函数按层存储该字典树:
/**
* ROOT
* b/ -- c$/ -- d/
* e$/f/ -- #/ -- g/
* h$/ ---- #/ ---- i$/
* #/ --------- #/
* @param elementArray
*/
private void saveDictToFile(ArrayList<Element> elementArray) {
if (elementArray.size() <= 0) {
Log.d(LOGTAG, "saveDictToFile final str: " + dicLineBuild.toString());
try {
File file = new File(Environment.getExternalStorageDirectory(), MAIN_PROCESSED);
if (!file.exists()) {
file.createNewFile();
}
FileOutputStream fos = new FileOutputStream(file);
// 第一行是字典数据
dicLineBuild.append("\r\n");
// 第二行: 最小频率 TAB 单词1 TAB 频率 TAB 单词2 TAB 频率 ...
dicLineBuild.append(minFreq);
for (Map.Entry<String, Double> entry : freqs.entrySet()) {
dicLineBuild.append(TAB);
dicLineBuild.append(entry.getKey());
dicLineBuild.append(TAB);
dicLineBuild.append(entry.getValue());
}
fos.write(dicLineBuild.toString().getBytes());
fos.close();
Log.d(LOGTAG, String.format("字典中间文件生成成功,存储在%s", file.getAbsolutePath()));
} catch (Exception e) {
Log.d(LOGTAG, "字典中间文件生成失败!");
e.printStackTrace();
}
return;
}
ArrayList<Element> childArray = new ArrayList();
// elementArray有几个元素,就要添加TAB分割的几个数据段,每个数据段是该Element的子节点的字+"/",比如 e/f/ TAB #/ TAB g/
// 如果从根节点到当前节点的路径表示一个词,那么在后面添加$符号,如 e$/f/ TAB #/ TAB g/
for (int i = 0; i < elementArray.size(); i++) {
Element element = elementArray.get(i);
// e/f/
if (element.hasNextNode()) {
for (Map.Entry<Character, Element> entry : element.childrenMap.entrySet()) {
dicLineBuild.append(entry.getKey());
if (entry.getValue().nodeState == 1) {
dicLineBuild.append(DOLLAR); // 从根节点到当前节点的路径表示一个词,那么在后面添加$符号,如 e$/f/ TAB #/ TAB g/
}
dicLineBuild.append(SLASH);
// 将该节点的所有子节点入列表,供下一次递归
childArray.add(entry.getValue());
}
} else { // #/
dicLineBuild.append(SHARP);
dicLineBuild.append(SLASH);
}
// TAB
dicLineBuild.append(TAB);
}
saveDictToFile(childArray);
}
该文件共两行,第一行是按层存储的字典文件,第二行是每个单词的频率。每行都很长。其中第一行的生成比较复杂,我们从根节点element开始,用一个列表存储每一层的节点。首先将根节点加入该列表,依次遍历列表中的同层节点,该列表有m个节点,就添加TAB分割的m个数据段,每个数据段是该节点的所有子节点字符,用"/"符号连接,比如明天/明年/明月,哦,后天/后面,三个单词,其中明/哦/后是同一层的三个根节点,根节点是明,三个子节点分别是天/年/月, 那么会在文本中写入天/年/月/。哦没有子节点,会写入#/,后天/后面会写入天/面/,通过TAB将三部分连接起来,就是天/年/月/ TAB #/ TAB 天/面/。通过这种方式,递归将整棵树按层存入文件,在手机加载的时候逆向按顺序完成字典树的恢复。
恢复的字典树的过程代码如下,也是递归进行的:
/**
* d/b/c/ g/ f/e/ #/ j/ #/ h/ #/ #/
*/
private void restoreElement(ArrayList<Element> elemArray, List<String> strArray, int startIndex) {
if (elemArray.size() <= 0) {
return;
}
ArrayList<Element> newElemArray = new ArrayList<>();
for (int i = 0; i < elemArray.size(); i++) {
String strCluster = strArray.get(startIndex);
String[] strList = strCluster.split(SLASH);
Element e = elemArray.get(i);
// #/
if (strList.length == 1 && strList[0].equalsIgnoreCase(SHARP)) {
e.nodeState = 1;
e.storeSize = 0;
} else { // f/e/
e.childrenMap = new HashMap<>();
for (int j = 0; j < strList.length; j++) {
String s = strList[j];
boolean isWord = s.length() == 2;
Character ch = new Character(s.charAt(0));
Element childElem = new Element(ch);
childElem.nodeState = isWord ? 1 : 0;
e.childrenMap.put(ch, childElem);
e.storeSize++;
newElemArray.add(childElem);
}
}
startIndex++;
}
restoreElement(newElemArray, strArray, startIndex);
}
这样,加载35万词典可以在1.5秒内完成,分词速度在一秒以内,满足了Android手机上的可用性。希望该分词器能够为大家的Android App提供更多围绕分词的功能亮点,做出更优秀的APP。