JPinYin

本文主要介绍JPinYin的使用和配置,github的地址

简单介绍

Jpinyin是一个开源的用于将汉字转换为拼音的java库。

主要特性

1、准确、完善的字库:Unicode编码从4E00-9FA5范围及3007(〇)的20903个汉字中,除了46个异体字(不存在标准拼音)Jpinyin都能转换。
2、拼音转换速度快:经测试,转换Unicode编码范围的20902个汉字,Jpinyin耗时约为100毫秒。
3、支持多种拼音格式:Jpinyin支持多种拼音输出格式:带声调、不带声调、数字表示声调以及拼音首字母格式输出。
4、常见多音字识别:Jpinyin支持常见多音字的识别,其中包括词组、成语、地名等;
5、简繁体中文互转。
6、支持用户自定义字典。

Maven依赖

1
2
3
4
5
<dependency>
<groupId>com.github.stuxuhai</groupId>
<artifactId>jpinyin</artifactId>
<version>1.1.8</version>
</dependency>

用法

1
2
3
4
5
6
String str = "你好世界";
PinyinHelper.convertToPinyinString(str, ",", PinyinFormat.WITH_TONE_MARK); // nǐ,hǎo,shì,jiè
PinyinHelper.convertToPinyinString(str, ",", PinyinFormat.WITH_TONE_NUMBER); // ni3,hao3,shi4,jie4
PinyinHelper.convertToPinyinString(str, ",", PinyinFormat.WITHOUT_TONE); // ni,hao,shi,jie
PinyinHelper.getShortPinyin(str); // nhsj
PinyinHelper.addPinyinDict("user.dict"); // 添加用户自定义字典

源码分析

下面的部分,试着从源码来理解Jpinyin,以便更好的使用它。

结构

源代码部分
根据(图-1)中可以看出,Jpinyin的实现主要由6个Java类来实现,其中PinyinException是一个异常类,定义了Pinyin异常;PinyinFormat为枚举类,用于定义汉字转拼音的格式(前面提到的:带声调、不带声调和数字表示声调三种),主要作为PinyinHelper类中方法的参数;PinyinHelper类,用来将汉字转换为不同格式的拼音,以及将汉字转换为拼音首字母缩写;PinyinResource类,用来加载拼音资源,主要是提供了从文件加载字典数据,并将数据以字典(Map)类型的数据结构保存在内存中;ChinesHelper类,用来是实现汉字繁简互转的功能;DoubleArrayTrie类,是Double-Array trie的实现,用来将词组装配成一棵,并使用这棵树来检测词组。

资源部分
根据(图-2)中可以看出,Jpinyin的资源中有三个数据文件,分别为chinese.dict、mutil_pinyin.dict和pinyin.dict。其中,chinese.dict存储的是繁体字与简体字之间的对应关系;mutil_pinyin.dict存储的是词组;pinyin.dict存储的是单字的读音(如果是多音字则对一个多个拼音,多个拼音之间使用逗号分隔,如:重=zhòng,chóng)。

代码

了解了项目工程的结构,我们来具体的看一下Jpinyin的实现

PinyinException类

1
2
3
4
5
6
7
8
9
public class PinyinException extends Exception {
private static final long serialVersionUID = 1L;
public PinyinException(String message) {
super(message);
}
}

该类主要就是定义了一个异常类,用于区分普通异常和该项目的异常。

pinyinFormat类

1
2
3
public enum PinyinFormat {
WITH_TONE_MARK, WITHOUT_TONE, WITH_TONE_NUMBER;
}

该类为枚举类型,用来定义拼音的格式。主要作为PyinHelper类中方法的参数,用来在汉字转换为拼音时的格式,如:

1
PinyinHelper.convertToPinyinString(str, ",", PinyinFormat.WITH_TONE_MARK);

其中WITH_TONE_MARK表示带声调的拼音格式,如nǐ,hǎo,shì,jiè;WITHOUT_TONE表示不带声调的拼音格式,如ni,hao,shi,jie;WITH_TONE_NUMBER表示使用数字表示声调的拼音格式,如ni3,hao3,shi4,jie4。

PinyinResource类

PinyinResource类主要用来加载资源文件(resource/data目录中的数据文件),并将资源文件以Map为数据结构保存到内存中。该类提供了6个方法:
两个方法用于获取资源的输入,并以UTF-8格式读入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//以当前类的类加载器的输入来获取资源文件的输入
protected static Reader newClassPathReader(String classpath) {
InputStream is = PinyinResource.class.getResourceAsStream(classpath);
try {
return new InputStreamReader(is, "UTF-8");
} catch (UnsupportedEncodingException e) {
return null;
}
}
//以文件的输入来获取资源文件的输入
protected static Reader newFileReader(String path) throws FileNotFoundException {
try {
return new InputStreamReader(new FileInputStream(path), "UTF-8");
} catch (UnsupportedEncodingException e) {
return null;
}
}

一个资源加载方法,并以Map作为数据结构进行存储:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//根据输入源,将资源文件加载到内存中(以Map作为数据结构)
protected static Map<String, String> getResource(Reader reader) {
Map<String, String> map = new ConcurrentHashMap<String, String>();
try {
BufferedReader br = new BufferedReader(reader);
String line = null;
//循环读取资源文件中的每一行
while ((line = br.readLine()) != null) {
//由此可以看出,资源文件的格式必须是key=value,并且数据以key和value的方式加载到内存中
String[] tokens = line.trim().split("=");
map.put(tokens[0], tokens[1]);
}
br.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
return map;
}

三个资源加载调用方法,分别加载pinyin.dict、mutil_pinyin.dict、chinese.dict:

1
2
3
4
5
6
7
8
9
10
11
protected static Map<String, String> getPinyinResource() {
return getResource(newClassPathReader("/data/pinyin.dict"));
}
protected static Map<String, String> getMutilPinyinResource() {
return getResource(newClassPathReader("/data/mutil_pinyin.dict"));
}
protected static Map<String, String> getChineseResource() {
return getResource(newClassPathReader("/data/chinese.dict"));
}

ChineseHelper类

ChineseHelper类主要用于繁体字和简体字之间的转换、体字的检测,并提供了一个方法用来扩展用户自定义的繁体字对应简体字的字库(字库的格式必须是key=value,因为它使用的是PinyinResource中的资源加载方式)。
首先是两个类变量:

1
2
3
4
//汉字的正则表达式,这个值范围内的表示为汉字
private static final String CHINESE_REGEX = "[\\u4e00-\\u9fa5]";
//加载资源文件“/data/chinese.dict”中的数据,由这里看,好像由繁体转换为简体速度应该会很快
private static final Map<String, String> CHINESE_MAP = PinyinResource.getChineseResource();

简体字和繁体字之间的互转,包括单个字和多个字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//将繁体字转换为简体字,因为CHINESE_MAP就是繁体字->简体字的映射,因此直接在该Map中查找
public static char convertToSimplifiedChinese(char c) {
//因为CHINESE_MAP中存储的是字符串,因此需要将字节转换为字符串,然后进行查找
String simplifiedChinese = CHINESE_MAP.get(String.valueOf(c));
if (simplifiedChinese != null) {
//要求返回的是char类型
return simplifiedChinese.charAt(0);
}
return c;
}
/* 将简体字转换为繁体字,该方法效率很低,因为CHINESE_MAP是繁体字到简体字的转换,
* 而没有简体字到繁体字之间的映射数据,因此只能对CHINESE_MAP进行遍历,然后匹配每个entry的值来判断
*/
public static char convertToTraditionalChinese(char c) {
//因为CHINESE_MAP中存储的是字符串,因此需要将字符转换为字符串
String simplifiedChinese = String.valueOf(c);
//循环CHINESE_MAP中的所有entry,并对entry的value进行判断
for (Entry<String, String> entry : CHINESE_MAP.entrySet()) {
if (entry.getValue().equals(simplifiedChinese)) {
return entry.getKey().charAt(0);
}
}
return c;
}
//将繁体字符串转换为简体字符串,其实现就是循环字符串的每个字符的转换,然后拼接在一起
public static String convertToSimplifiedChinese(String str) {
StringBuilder sb = new StringBuilder();
for (int i = 0, len = str.length(); i < len; i++) {
char c = str.charAt(i);
sb.append(convertToSimplifiedChinese(c));
}
return sb.toString();
}
/* 将简体字符串转换为繁体字符串,效率很低,每个字的转换都需要遍历整个CHINESE_MAP,如果频繁使用需要优化
* 其实现就是循环简体字符串的每个字符,然后拼接在一起
*/
public static String convertToTraditionalChinese(String str) {
StringBuilder sb = new StringBuilder();
for (int i = 0, len = str.length(); i < len; i++) {
char c = str.charAt(i);
sb.append(convertToTraditionalChinese(c));
}
return sb.toString();
}

字体检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 检测是否是繁体字,因为该方法是从CHINESE_MAP中的keys中查找,如果chinese.dict数据文件中有遗漏,则检测是不准的
* 其实现就是判断CHINESE_MAP中是否包含了指定的key
*/
public static boolean isTraditionalChinese(char c) {
return CHINESE_MAP.containsKey(String.valueOf(c));
}
//判断给定的字符是否是中文,其实现是判断字符是否在CHINESE_REGEX所指定的范围内,或者是否等于'〇'
public static boolean isChinese(char c) {
return '〇' == c || String.valueOf(c).matches(CHINESE_REGEX);
}
//判断给定的字符串是否包含中文,循环字符串中的每个字符,逐个判断
public static boolean containsChinese(String str) {
for (int i = 0, len = str.length(); i < len; i++) {
if (isChinese(str.charAt(i))) {
return true;
}
}
return false;
}

添加用户自定义的繁体字对应简体字的字库:

1
2
3
4
5
6
/*将指定路径的数据资源添加到繁体字与简体字映射关系的数据结构中,要求path指定的数据文件必须是key=value格式,并且key为繁体字
* 其实现就是将要添加的数据资源的Map添加到已有映射关系的Map中
*/
public static void addChineseDict(String path) throws FileNotFoundException {
CHINESE_MAP.putAll(PinyinResource.getResource(PinyinResource.newFileReader(path)));
}

PinyinHelper类

PinyinHelper类主要用于将中文的汉字转换为拼音(支持三种格式),并提供了添加用户自定义的汉字与拼音关系(单字和词组)的资源库的功能。
首先是类变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private static List<String> dict = new ArrayList<String>();
//存储了汉字到拼音之间的映射关系
private static final Map<String, String> PINYIN_TABLE = PinyinResource.getPinyinResource();
//存储了词组(汉字)到拼音之间的映射关系
private static final Map<String, String> MUTIL_PINYIN_TABLE = PinyinResource.getMutilPinyinResource();
//一个double数组结构------------------------------干啥用的呢
private static final DoubleArrayTrie DOUBLE_ARRAY_TRIE = new DoubleArrayTrie();
//拼音的默认分隔符,用户在调用转换的时候可以自定义
private static final String PINYIN_SEPARATOR = ","; // 拼音分隔符
//没有在4E00-9FA5范围之间的第20903个汉字
private static final char CHINESE_LING = '〇';
//
private static final String ALL_UNMARKED_VOWEL = "aeiouv";
//所有可以标声调的字母
private static final String ALL_MARKED_VOWEL = "āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ"; // 所有带声调的拼音字母

静态块:

1
2
3
4
5
6
7
8
9
static {
//循环词组或短语库中的所有汉字,添加dict(ArrayList)中
for (String word : MUTIL_PINYIN_TABLE.keySet()) {
dict.add(word);
}
//将所有的词组进行排序,并使用排序后的词组集合构建一棵前缀树
Collections.sort(dict);
DOUBLE_ARRAY_TRIE.build(dict);
}

将声调格式的拼音转换为以数字表示声调的拼音

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
private static String[] convertWithToneNumber(String pinyinArrayString) {
//将拼音字符串按照指定的分隔符进行拆分,拆分后的每个数组项是一个单独的拼音
String[] pinyinArray = pinyinArrayString.split(PINYIN_SEPARATOR);
//循环拼音字符串的每个拼音
for (int i = pinyinArray.length - 1; i >= 0; i--) {
//用来记录是否有带声调的拼音字母,如果为false,可以是轻声的读音
boolean hasMarkedChar = false;
//将ü替换为v,因为在英文键盘中,是没有ü的,而是使用v代替
//这里的pinyinArray是拼音字符串中单个字的拼音
String originalPinyin = pinyinArray[i].replace("ü", "v"); // 将拼音中的ü替换为v,注意这里没有替换ǖǘǚǜ
//循环单个字的拼音中的每个字母
for (int j = originalPinyin.length() - 1; j >= 0; j--) {
char originalChar = originalPinyin.charAt(j);
// 搜索带声调的拼音字母,如果存在则替换为对应不带声调的英文字母
// 这里之所以使用(< 'a' || > 'z')这个条件来判断,是因为"āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ"没有在a-z之间,而其他的拼音均在,所以如果有
// 不在这个范围内的字符,则表示有带声调的字母
if (originalChar < 'a' || originalChar > 'z') {
//查找声调字母在āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ中的位置,通过位置可以在下面可以计算出声调的值(是一声、二声、三声还是四声)
int indexInAllMarked = ALL_MARKED_VOWEL.indexOf(originalChar);
//利用上面索引位置与4取模,可以可以计算声调,但是声调是从1到4,因此需要加1
int toneNumber = indexInAllMarked % 4 + 1; // 声调数
//计算需要将声调字母替换成为的不带声调的字母,这里使用了一个计算公式,将ALL_MARKED_VOWEL中的字母转化为ALL_UNMARKED_VOWEL中的一个
char replaceChar = ALL_UNMARKED_VOWEL.charAt((indexInAllMarked - indexInAllMarked % 4) / 4);
//对字拼音中的声调字母进行替换,并在末尾添加声调所对应的数字
pinyinArray[i] = originalPinyin.replace(String.valueOf(originalChar), String.valueOf(replaceChar))
+ toneNumber;
//替换成功则退出循环,因为拼音中只可能有一个声调,因此可以避免无谓的循环
hasMarkedChar = true;
break;
}
}
//如果在上面没有发生带声调字母的替换,则标记为5,表示轻声
if (!hasMarkedChar) {
// 找不到带声调的拼音字母说明是轻声,用数字5表示
pinyinArray[i] = originalPinyin + "5";
}
}
return pinyinArray;
}

将带声调的拼音转换为不带声调的拼音,并以数组的形式返回,每个数组项是一个字的拼音

1
2
3
4
5
6
7
8
9
10
11
12
13
private static String[] convertWithoutTone(String pinyinArrayString) {
String[] pinyinArray;
//循环"āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ"中的每个字母
for (int i = ALL_MARKED_VOWEL.length() - 1; i >= 0; i--) {
//计算"āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ"中的每个字母与"aeiouv"中字母的对应关系,并用"aeiouv"的字母来替换"āáǎàēéěèīíǐìōóǒòūúǔùǖǘǚǜ"中的字母
char originalChar = ALL_MARKED_VOWEL.charAt(i);
char replaceChar = ALL_UNMARKED_VOWEL.charAt((i - i % 4) / 4);
pinyinArrayString = pinyinArrayString.replace(String.valueOf(originalChar), String.valueOf(replaceChar));
}
// 将拼音中的ü替换为v
pinyinArray = pinyinArrayString.replace("ü", "v").split(PINYIN_SEPARATOR);
return pinyinArray;
}

将拼音字符串按照给定的格式进行格式化,并以数组的形式返回,需要注意的是,这个方法只是提供格式化,提供的字符串已经是拼音了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static String[] formatPinyin(String pinyinString, PinyinFormat pinyinFormat) {
//如果指定为带声调的字母的格式,则直接将字符串按照给定的分隔符进行拆分
if (pinyinFormat == PinyinFormat.WITH_TONE_MARK) {
return pinyinString.split(PINYIN_SEPARATOR);
}
//如果指定为以数字表示声调的格式,则调用convertWithToneNumber方法进行格式化
else if (pinyinFormat == PinyinFormat.WITH_TONE_NUMBER) {
return convertWithToneNumber(pinyinString);
}
//如果指定为不带声调格式的拼音,则调用converWithoutTone方法进行格式化
else if (pinyinFormat == PinyinFormat.WITHOUT_TONE) {
return convertWithoutTone(pinyinString);
}
return new String[0];
}

将单个汉字以pinyinFormat指定的格式进行转换,以数组的方式返回,是为了兼容多音字,多音字会返回多个拼音

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static String[] convertToPinyinArray(char c, PinyinFormat pinyinFormat) {
//获取c字符的拼音,PINYIN_TABLE表示的是/data/pinyin.dict中的数据,是单个字与拼音的对应关系
String pinyin = PINYIN_TABLE.get(String.valueOf(c));
//如果有对应的拼音信息,则将拼音按照指定的格式进行格式化(调用formatPinyin方法来实现)
if ((pinyin != null) && (!"null".equals(pinyin))) {
//这里使用set来接收,是为了防止有多音字的情况,多音字会返回多个拼音
Set<String> set = new LinkedHashSet<String>();
for (String str : formatPinyin(pinyin, pinyinFormat)) {
set.add(str);
}
//以数组的方式返回字的拼音,因为可能是多音字
return set.toArray(new String[set.size()]);
}
return new String[0];
}

将字转换为带有声调的拼音,通过调用convertToPinyinArray

1
2
3
public static String[] convertToPinyinArray(char c) {
return convertToPinyinArray(c, PinyinFormat.WITH_TONE_MARK);
}

将字符串转换为指定格式的拼音字符串,并且品字符串中各个字的拼音之间使用separator指定的分隔符分隔

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public static String convertToPinyinString(String str, String separator, PinyinFormat pinyinFormat) throws PinyinException {
//首先将字符串转换简体汉字
str = ChineseHelper.convertToSimplifiedChinese(str);
StringBuilder sb = new StringBuilder();
int i = 0;
int strLen = str.length();
//从字符串第一个字符开始循环
while (i < strLen) {
//获取字符串的第一个字符
String substr = str.substring(i);
//-----这里应该是使用的前缀树进行查找,这里查找的应该是词组或这短语
List<Integer> commonPrefixList = DOUBLE_ARRAY_TRIE.commonPrefixSearch(substr);
//如果通过上面的方法没有查找到以该字开头的词组,则进行单字的转换
if (commonPrefixList.size() == 0) {
//获取要进行转换的字符
char c = str.charAt(i);
// 判断是否为汉字或者〇,如果是,则调用上面的convertToPinyinArray方法将字符转化为拼音,由于存在多音字,转换后的结果可能为数组
if (ChineseHelper.isChinese(c) || c == CHINESE_LING) {
String[] pinyinArray = convertToPinyinArray(c, pinyinFormat);
//单字转换拼音时,如果结果不为空,则将拼音添加到拼音字符串的后面,如果返回的结果表示是多音字,则只取第一个读音
if (pinyinArray != null) {
if (pinyinArray.length > 0) {
sb.append(pinyinArray[0]);
}
// 如果在单个字中没有找到对应的拼音,这里只会抛出异常信息,应该想办法收集没有拼音的汉字,并进行补充
else {
throw new PinyinException("Can't convert to pinyin: " + c);
}
}
// 如果转换拼音失败,则表示字符可能46个异体字中的一个,直接添加到拼音字符串的后面
// -- 这里存在一个问题:假设任何符号都有拼音对应的意义,应该想办法收集起来,将这些符号转换为拼音
else {
sb.append(str.charAt(i));
}
}
//如果不是汉字,则直接添加拼音字符串的后面
/ -- 这里存在一个问题:假设任何符号都有拼音对应的意义,应该想办法收集起来,将这些符号转换为拼音
else {
sb.append(c);
}
//将转换的索引加一
i++;
}
//表示通过前缀树,已经查找到了词组或短语
else {
//获取从前缀树中查找到的词组或短语
String words = dict.get(commonPrefixList.get(commonPrefixList.size() - 1));
//将查找到的词组到词组拼音表中获取对应的拼音,并调用formatPinyin方法,按照指定的格式对拼音进行格式化
String[] pinyinArray = formatPinyin(MUTIL_PINYIN_TABLE.get(words), pinyinFormat);
//将格式化后的拼音添加到拼音字符串中,并且每个字的拼音之间使用分隔符进行分隔
for (int j = 0, l = pinyinArray.length; j < l; j++) {
sb.append(pinyinArray[j]);
if (j < l - 1) {
sb.append(separator);
}
}
//将汉字循环的索引进行前进
i += words.length();
}
//转换一部分拼音后,使用指定的分隔符进行分隔
if (i < strLen) {
sb.append(separator);
}
}
return sb.toString();
}

将汉字字符串转换为带声调格式的拼音,调用convertToPinyinString方法,并指定转换的格式

1
2
3
public static String convertToPinyinString(String str, String separator) throws PinyinException {
return convertToPinyinString(str, separator, PinyinFormat.WITH_TONE_MARK);
}

判断一个汉字是否是多音字,其实现就是将单个汉字转换为拼音数组,并判断返回的数组的元素的个数

1
2
3
4
5
6
7
8
9
public static boolean hasMultiPinyin(char c) {
//如果是多音字,convertToPinyinArray会返回一个长度大于1的数组
String[] pinyinArray = convertToPinyinArray(c);
if (pinyinArray != null && pinyinArray.length > 1) {
return true;
}
return false;
}

将汉字字符串转换拼音首字母

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public static String getShortPinyin(String str) throws PinyinException {
//作为汉字字符串转换拼音时,拼音的分隔符
String separator = "#"; // 使用#作为拼音分隔符
//该对象用来存储需要转换的汉字字符串,会去除汉字字符串中前面不属于汉字的那些字符,如 "*你好",sb在计算完成之后应该为"你好"
StringBuilder sb = new StringBuilder();
//用来存储字符串拼音的首字母
char[] charArray = new char[str.length()];
//循环汉字字符串中的每个汉字,该部分代码逻辑比较复杂
for (int i = 0, len = str.length(); i < len; i++) {
char c = str.charAt(i);
// 首先判断是否为汉字或者〇,不是的话直接将该字符返回,还可能是其他的字符,这里应该做一个收集并进行分析以便丰富词库
if (!ChineseHelper.isChinese(c) && c != CHINESE_LING) {
charArray[i] = c;
}
//到这里,则表示字符是汉字或着〇
else {
int j = i + 1;
sb.append(c);
// 从str第一个汉字字符之后,连续提取汉字字符,并将汉字添加到sb中,遇到非汉字后,则终止该循环
while (j < len && (ChineseHelper.isChinese(str.charAt(j)) || str.charAt(j) == CHINESE_LING)) {
sb.append(str.charAt(j));
j++;
}
//将上面提取出来的汉字字符串转换为拼音(没有声调的拼音)
String hanziPinyin = convertToPinyinString(sb.toString(), separator, PinyinFormat.WITHOUT_TONE);
//将拼音按照指定的分隔符进行拆分,然后提取才分后,各个数组元素的首字母
String[] pinyinArray = hanziPinyin.split(separator);
for (String string : pinyinArray) {
charArray[i] = string.charAt(0);
i++;
}
//如果在while循环中遇到了非汉字后,会走到这里从而再次进入for循环
i--;
sb.setLength(0);
}
}
//返回拼音首字母缩写
return String.valueOf(charArray);
}

添加用户自定义汉字与拼音的对应关系数据,要求添加的文件的格式必须是key=value,其中key是汉字,value是拼音。因为使用的统一的资源加载器。

1
2
3
public static void addPinyinDict(String path) throws FileNotFoundException {
PINYIN_TABLE.putAll(PinyinResource.getResource(PinyinResource.newFileReader(path)));
}

添加用户自定义的词组与拼音的对应关系数据,要求数据文件的格式必须是key=value,其中key是词组,value是对应的拼音。因为它使用的是统一的资源加载器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static void addMutilPinyinDict(String path) throws FileNotFoundException {
//将自定义数据的汉字词组与拼音的对应关系添加到MUTIL_PINYIN_TABLE中,以便根据词组查找对应的拼音
MUTIL_PINYIN_TABLE.putAll(PinyinResource.getResource(PinyinResource.newFileReader(path)));
//将ArrayList清空,该ArrayList用来构建前缀树
dict.clear();
//清空前缀树中的数据
DOUBLE_ARRAY_TRIE.clear();
//重新构建前缀树
for (String word : MUTIL_PINYIN_TABLE.keySet()) {
dict.add(word);
}
Collections.sort(dict);
DOUBLE_ARRAY_TRIE.build(dict);
}

DoubleArrayTrie类

DoubleArrayTrie类是一个前缀树的实现,主要用来查找词组或短语:
累的定义如下,首先是类变量和类的私有类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
//用来从文件中读取trie数据的缓冲区大小
private final static int BUF_SIZE = 16384;
//定义了数据单元的大小,主要在将Double-Array trie从文件中加载的时候使用,来判断trie的size
private final static int UNIT_SIZE = 8; // size of int + int
//私有类,作为Double-Array trie节点的对象
private static class Node {
int code;
//表示节点的深度
int depth;
// 与right配合,标记出子节点的范围
int left;
// 与left配合,标记出子节点的范围
int right;
};
//双数组树的核心check数组和base数组
private int check[];
private int base[];
private boolean used[];
private int size;
//核心数组check和base所分配的大小
private int allocSize;
//用来生成Double-Array trie的数据,是完整的数据集
private List<String> key;
private int keySize;
private int length[];
private int value[];
private int progress;
//插入节点时使用的位置起始点
private int nextCheckPos;
// boolean no_delete_;
int error_;

重新分配Double-Array trie内部核心数组check和base的方法,根据指定的大小生成新的数组,并将原来数组的数据完整的拷贝的新数组中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 重新将数据进行扩容
*/
private int resize(int newSize) {
//以指定的大小来生成所需大小的数组
int[] base2 = new int[newSize];
int[] check2 = new int[newSize];
boolean used2[] = new boolean[newSize];
//判断数据数组分配的大小是否有效,如果有,则将原来分配的数据数组的内容拷贝到新分配的数组中
if (allocSize > 0) {
System.arraycopy(base, 0, base2, 0, allocSize);
System.arraycopy(check, 0, check2, 0, allocSize);
System.arraycopy(used2, 0, used2, 0, allocSize);
}
//更改Double-Array trie内部check、base和used的引用关系
base = base2;
check = check2;
used = used2;
// 记录当前数据数组的分配大小
return allocSize = newSize;
}

根据父节点提取子节点数据的方法,并返回子节点的个数,所有子节点的数据通过参数siblings进行返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
private int fetch(Node parent, List<Node> siblings) {
//如果发生错误,直接返回,如果keys(数据集)没有排序,error的值为-3,因为没有排序的话,在构建树数据的时候会乱,因此需要数据集是排序后的
if (error_ < 0)
return 0;
//用来存储前一个节点的code值
int prev = 0;
//其实就是查找具有相同前缀的一组词
for (int i = parent.left; i < parent.right; i++) {
if ((length != null ? length[i] : key.get(i).length()) < parent.depth)
continue;
//获取下一个词语
String tmp = key.get(i);
//用来记录当前的层次深度
int cur = 0;
//当前的层次深度是父级深度+1
if ((length != null ? length[i] : tmp.length()) != parent.depth)
//注意这里使用的tmp,tmp是父节点的字节点的第i个词,因为所有的词都是从根节点过来的,因此
//词中的第N个字,就位于树中的第N层,因为数组和树都是从0开始算,因此当前层级=当前层级-1=父级的层级
//其实这里就是获取节点的常量值--使用的是char的值
cur = (int) tmp.charAt(parent.depth) + 1;
//if代码块是要求key或length中的数据应该是排序后(升序)的数据,否则就会出错,error_是类全局错误,如果error_小于0,则所有的方法都会出错而退出
if (prev > cur) {
error_ = -3;
return 0;
}
/**
* 该部分代码用来判断是否找到了新的节点,前节点值与当前节点值不相同或第一个节点(siblings.size == 0),表示找到了新节点。新节点使用Node来表示
*/
if (cur != prev || siblings.size() == 0) {
//生成一个新的树节点
Node tmp_node = new Node();
//depth表示节点从根节点开始算,所处层次
tmp_node.depth = parent.depth + 1;
//用来表示当前节点字的code值
tmp_node.code = cur;
//用来记录子节点在数据集中的左边界
tmp_node.left = i;
//用来记录子节点在数据集中的右边界
if (siblings.size() != 0)
siblings.get(siblings.size() - 1).right = i;
//siblings中存储的是去重后的字节点,但是每个字节点中包含有取词范围
siblings.add(tmp_node);
}
prev = cur;
}
//如果循环到了结尾,则parent最后一个字节点的最右边的界限为父节点的界限,将上面Mark-1中最后一个节点Node.right进行填充
if (siblings.size() != 0)
siblings.get(siblings.size() - 1).right = parent.right;
//返回父节点拥有子节点的个数
return siblings.size();
}

Double-Array trie的构建方法,通过上面的fetch获取子节点,然后利用本方法来构建两个数组的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
private int insert(List<Node> siblings) {
if (error_ < 0)
return 0;
//用来寻找base[i] + c(常量) = t的那个初始值,这个值会保存在base[i]中
//注意这里是一个局部变量
int begin = 0;
//nextCheckPost用来存储下一个检查点的值,根节点进入的时候,值为0
//pos表示从这个位置开始进行检查
//这里为什么要做这个检查呢?
//因为,在双数组树中,每个字都有一个变量(一个固定的值),Node.code就是这个变量的值,在对根节点进行定位的时候,下标就是这个变量值来定位的
//注意这里是一个局部变量
int pos = ((siblings.get(0).code + 1 > nextCheckPos) ? siblings.get(0).code + 1 : nextCheckPos) - 1;
//用来计算check数组中,从pos开始到pos + siblings.size()之间,非0元素的个数,并用该值去计算 nextCheckPos的值
int nonzero_num = 0;
//用来表示是否是对siblings中的元素第一次找到check[pos]=0的pos值
int first = 0;
//如果pos大于allocSize,则表示"状态"(每个字称为状态)超出了check数组和base数组的界限了,需要扩容
if (allocSize <= pos)
resize(pos + 1);
//这段代码就是为siblings中的Node寻找在check数组中那些连续的空间的起始值
outer: while (true) {
//增加下标值,用来寻找满足所有子节点在base数组中能够连续存储的起始位置
pos++;
//检查下标是否超出了check数组和base数组的界限
if (allocSize <= pos)
resize(pos + 1);
//判断check中下标位置的值是否为0,
// 如果不是0,则表示该位置已经被使用,因此需要增加回到开头,对pos进行前进
// 为0则表示可以用
if (check[pos] != 0) {
nonzero_num++;
continue;
} else if (first == 0) {
//nextCheckPos用来表示,下一次应该从这个位置开始检查check
//另外为啥要从nextCheckPos这个位置开始呢,因为构建双数组树的数据集是已经排过序的了,
// 所以以后的值都会比最最第一个字的code大,因此无论怎么加,都会比这个值要大
nextCheckPos = pos;
//已经寻找第一个check[pos]为0的pos位置
first = 1;
}
//pos-(siblings.get(0).code)(这个值相当于字的变量值),也就是说base中要存储的值可能为begin,最起码要从这个值开始尝试
//begin相当于base[s] + c = t公式中,base[s]中存储的值,用来计算当前字应该存储的下标t
begin = pos - siblings.get(0).code;
//继续判断check数组和base数组是否能够容纳当前字,如果不能够容纳,则需要根据 字的个数与progress的比值来扩容
if (allocSize <= (begin + siblings.get(siblings.size() - 1).code)) {
// progress can be zero
// 通过keySize和progress,可以计算base和check扩容的比率
double l = (1.05 > 1.0 * keySize / (progress + 1)) ? 1.05 : 1.0 * keySize / (progress + 1);
resize((int) (allocSize * l));
}
//判断used[begin]位置是否为true,used的作用是什么呢????总之如果used[begin]==true就需要进行循环
if (used[begin])
continue;
//这一段代码是在寻找 base[s] + c = t公式中,base[s]的值,使得siblings中的所有Node都满足check[begin + Node.code] = 0
for (int i = 1; i < siblings.size(); i++)
//在循环判断的过程中,只要siblings中的某个Node不满足check[begin + Node.code] = 0,则说明begin不好使,需要回到outer继续寻找
if (check[begin + siblings.get(i).code] != 0)
continue outer;
//如果程序运行到了这里,则表示找到了使得siblings中的所有Node都满足check[begin + Node.code] = 0的begin
break;
}
//------------如果运行到了这里,则表示base[s] + c = t,中base[s]的值已经找到,即为begin-----------------
// -- Simple heuristics --
// if the percentage of non-empty contents in check between the
// index
// 'next_check_pos' and 'check' is greater than some constant value
// (e.g. 0.9),
// new 'next_check_pos' index is written by 'check'.
//这块代码的意思就是计算当前位置和之前位置之间check中有多少位置已经不是0了,
// 如果比例超过了一个比率,就说明干[nextCheckPos, pos]区间几乎不可以使用了,
// 如果比例低于这个比率,则说明还有可以使用的空间
if (1.0 * nonzero_num / (pos - nextCheckPos + 1) >= 0.95)
nextCheckPos = pos;
//干啥的???--似乎没怎么用到
used[begin] = true;
//size用来记录了数组的有效长度(默认数组中的值为0,有效长度就是最后一个不是0的位置)
//begin代表了Node在check中寻找位置时的那个常量
size = (size > begin + siblings.get(siblings.size() - 1).code + 1) ? size : begin + siblings.get(siblings.size() - 1).code + 1;
//上面寻找到了常量,siblings中的字在check数组中对应位置的值设置为begin,begin
for (int i = 0; i < siblings.size(); i++)
check[begin + siblings.get(i).code] = begin;
//循环siblings中的所有Node,每个Node称为当前节点
for (int i = 0; i < siblings.size(); i++) {
List<Node> new_siblings = new ArrayList<Node>();
//获取当前节点的子节点,返回0则表示当前节点没有子节点,如果有字节点则插入子节点,这是一个迭代处理
if (fetch(siblings.get(i), new_siblings) == 0) {
//base用来记录什么???????
System.out.println(value);
base[begin + siblings.get(i).code] = (value != null) ? (-value[siblings.get(i).left] - 1) : (-siblings.get(i).left - 1);
if (value != null && (-value[siblings.get(i).left] - 1) >= 0) {
error_ = -2;
return 0;
}
progress++;
// if (progress_func_) (*progress_func_) (progress,
// keySize);
} else {
//为当前节点插入子节点,并记录子节点的位置
int h = insert(new_siblings);
base[begin + siblings.get(i).code] = h;
}
}
return begin;
}

Double-Array trie类的默认构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public DoubleArrayTrie() {
check = null;
base = null;
used = null;
size = 0;
allocSize = 0;
// no_delete_ = false;
error_ = 0;
}
``
用来清理Double-Array trie的方法:
``` Java
void clear() {
// if (! no_delete_)
check = null;
base = null;
used = null;
allocSize = 0;
size = 0;
// no_delete_ = false;
}

计算数组存储的基本单元的大小:

1
2
3
public int getUnitSize() {
return UNIT_SIZE;
}

计算Double-Array tire的有效大小,和分配的大小不同:

1
2
3
public int getSize() {
return size;
}

计算Double-Array trie所占用的有效空间:

1
2
3
public int getTotalSize() {
return size * UNIT_SIZE;
}

计算Double-Array trie中核心数组check和base中真正存储了数据的数据单元个数:

1
2
3
4
5
6
7
public int getNonzeroSize() {
int result = 0;
for (int i = 0; i < size; i++)
if (check[i] != 0)
result++;
return result;
}

构建Double-Array trie的方法,key是一个挣序排序后的集合,集合中包含了所需的所有数据:

1
2
3
public int build(List<String> key) {
return build(key, null, null, key.size());
}

构建Double-Array tire的方法,本方法为实际操作方法,并且提供了更加丰富的参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public int build(List<String> _key, int _length[], int _value[], int _keySize) {
if (_keySize > _key.size() || _key == null)
return 0;
// progress_func_ = progress_func;
key = _key;
length = _length;
keySize = _keySize;
value = _value;
progress = 0;
resize(65536 * 32);
base[0] = 1;
nextCheckPos = 0;
//生成double-array trie的根节点,根节点的left=0,right=keySize, depth=0,left和right指定的字节点的范围,因为是根节点,所以是所有数据
Node root_node = new Node();
root_node.left = 0;
root_node.right = keySize;
root_node.depth = 0;
//siblings用来存储根节点的字节点(去重后的)
List<Node> siblings = new ArrayList<Node>();
//查找根节点的子节点(去重)
fetch(root_node, siblings);
//构建根节点的子节点,内部有迭代查询,当根节点的字节点构造完成后,整棵树就构造完成了
insert(siblings);
// size += (1 << 8 * 2) + 1; // ???
// if (size >= allocSize) resize (size);
used = null;
key = null;
return error_;
}

从文件中加载已有的Double-Array trie的数据,并重新构建成可用的Double-Array trie:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public void open(String fileName) throws IOException {
File file = new File(fileName);
//base和check是分别作为int写到文件的,因此两个int代表一个数据,文件的大小与两个int大小的比值就是数据的个数
size = (int) file.length() / UNIT_SIZE;
//实例化空的check和base,数据为空,但是不是null
check = new int[size];
base = new int[size];
DataInputStream is = null;
try {
//打开文件,并将读取的数据填充到base和check中
is = new DataInputStream(new BufferedInputStream(new FileInputStream(file), BUF_SIZE));
for (int i = 0; i < size; i++) {
base[i] = is.readInt();
check[i] = is.readInt();
}
//关闭输入流
} finally {
if (is != null)
is.close();
}
}

将现有的Double-Array trie保存到文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void save(String fileName) throws IOException {
DataOutputStream out = null;
try {
//打开文件的输入流
out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(fileName)));
//将base和check对应元素的值作为一条数据写出到文件
for (int i = 0; i < size; i++) {
out.writeInt(base[i]);
out.writeInt(check[i]);
}
out.close();
//关闭输出流
} finally {
if (out != null)
out.close();
}
}

在Double-Array trie中精确匹配搜索给定的词组:

1
2
3
public int exactMatchSearch(String key) {
return exactMatchSearch(key, 0, 0, 0);
}

在Double-Array trie中精确匹配搜索,该方法为实际搜索方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public int exactMatchSearch(String key, int pos, int len, int nodePos) {
if (len <= 0)
len = key.length();
if (nodePos <= 0)
nodePos = 0;
int result = -1;
char[] keyChars = key.toCharArray();
//获取第一个下标值,应该为1
int b = base[nodePos];
int p;
for (int i = pos; i < len; i++) {
p = b + (int) (keyChars[i]) + 1;
if (b == check[p])
b = base[p];
else
return result;
}
p = b;
int n = base[p];
if (b == check[p] && n < 0) {
result = -n - 1;
}
return result;
}

该方法主要用来精确匹配搜索的,理解该方法也是理解inster方法的关键,接下来我们将详细介绍并举例该方法。
……
常用前缀搜索方法:

1
2
3
public List<Integer> commonPrefixSearch(String key) {
return commonPrefixSearch(key, 0, 0, 0);
}

常用前缀搜索方法的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public List<Integer> commonPrefixSearch(String key, int pos, int len, int nodePos) {
if (len <= 0)
len = key.length();
if (nodePos <= 0)
nodePos = 0;
List<Integer> result = new ArrayList<Integer>();
//将要搜索的字符串拆成字符数组
char[] keyChars = key.toCharArray();
int b = base[nodePos];
int n;
int p;
for (int i = pos; i < len; i++) {
p = b;
n = base[p];
if (b == check[p] && n < 0) {
result.add(-n - 1);
}
p = b + (int) (keyChars[i]) + 1;
if (b == check[p])
b = base[p];
else
return result;
}
p = b;
n = base[p];
if (b == check[p] && n < 0) {
result.add(-n - 1);
}
return result;
}

理解该方法也是理解前缀搜索的关键,更加有助于理解insert方法的实现。……

调试方法,会将Double-Array trie的关键数组check和base打印出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void dump() {
System.out.print("index:\t\t");
for (int i = 0; i < size; i++) {
System.out.print(i + "\t\t");
}
System.out.print("\r\nbase:\t\t");
for (int i = 0; i < size; i++) {
System.out.print(base[i] + "\t\t");
}
System.out.print("\r\ncheck:\t\t");
for (int i = 0; i < size; i++) {
System.out.print(check[i] + "\t\t");
}
}

总结

通过源码分析,Jpinyin可以方便的将汉字转换为拼音,也可以方便的将繁体字转换为简体字,但是不足之处是,在将简体字转换为繁体子的时候,效率会非常的低。另外,对于特殊的46个异体字,没有明确的指出,从而加以区分。
单单从汉字转拼音的角度来说,该功能比较好用,但是如果考虑到自然语言处理的应用,还是有很多不足的地方。例如,表情符号也可以代表汉字,也就对应着拼音;unicode其实也是汉字的另一种表示,也应该有对应的拼音;等等。

其他备注

moji表情一般是两个unicode,但也有一部分是一个unicode
String pattern = “[\ud83c\udc00-\ud83c\udfff]|[\ud83d\udc00-\ud83d\udfff]|[\u2600-\u27ff]”

moji表情与unicode的对应
moji表情和unicode字符串之间的相互转换可以使用apache的commons-lang包中StringEscapeUtils类的escapeJava(String)和unescapeJava(String)来进行相互转换。