智能拨号匹配算法

转载 2016年08月30日 15:08:57

本文出自 “NashLegend” 博客,请务必保留此出处http://nashlegend.blog.51cto.com/5635342/1566108 

完整源码在我的github上 https://github.com/NashLegend/QuicKid  

智能拨号是指,呃不用解释了,国内拨号软件都带的大家都知道,就是输入姓名拼音的一部分就可快速搜索出联系人的拨号方式。如下图

wKioL1RQ4j_Bu7A3AAE0u2jIJJM541.jpg


    智能匹配,很容易想到的就是先把九宫格输入键盘上输入的数字转换成可能的拼音组合,然后再用这些可能的拼音与联系人列表中的姓名拼音一一匹配,取匹配度最高的排到最前,但是这有一个问题就是数组对应的可能的拼音组合实在是点儿多,跑一下下面的代码就知道了。如果想智能一些的话还要先剔除一些不可能的拼音组合实在有点麻烦。

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
public static HashMap<Character, String[]> keyMaps;
 
public static void main(String[] args) {
    keyMaps = new HashMap<Character, String[]>();
    keyMaps.put('0'new String[0]);
    keyMaps.put('1'new String[0]);
    keyMaps.put('2'new String[] { "a""b""c" });
    keyMaps.put('3'new String[] { "d""e""f" });
    keyMaps.put('4'new String[] { "g""h""i" });
    keyMaps.put('5'new String[] { "j""k""l" });
    keyMaps.put('6'new String[] { "m""n""o" });
    keyMaps.put('7'new String[] { "p""q""r""s" });
    keyMaps.put('8'new String[] { "t""u""v" });
    keyMaps.put('9'new String[] { "w""x""y""z" });
 
    List<String> lss = getPossibleKeys("726");
    System.out.println(lss.size());
}
 
public static ArrayList<String> getPossibleKeys(String key) {
    ArrayList<String> list = new ArrayList<String>();
    if (key.length() > 0) {
        if (key.contains("1") || key.contains("0")) {
            list.add(key);
        else {
            int keyLen = key.length();
            String[] words;
            if (keyLen == 1) {
                words = keyMaps.get(key.charAt(0));
                for (int i = 0; i < words.length; i++) {
                    list.add(words[i]);
                }
            else {
                ArrayList<String> sonList = getPossibleKeys(key.substring(
                        0, key.length() - 1));
                words = keyMaps.get(key.charAt(key.length() - 1));
                for (int i = 0; i < words.length; i++) {
                    for (Iterator<String> iterator = sonList.iterator(); iterator
                            .hasNext();) {
                        String sonStr = iterator.next();
                        list.add(sonStr + words[i]);
                    }
                }
            }
        }
    }
    return list;
}


    所以可以反过来想,为什么一定要匹配拼音呢。其实我们可以匹配数字,将姓名的拼音转化成九宫格上的数字,比如张三就是94264,726。用输入的数字来匹配这些数字,匹配次数将大大减少。匹配出的数值越高,匹配度越强。


下面先定义一下几个匹配规则

  1. 完全匹配用来匹配姓名和电话号码。指输入字符串与联系人内某一匹配项完全匹配。无加减分项。

    PanZhiHui-->PanZhiHui

  2. 前置首字母完全匹配用来匹配姓名。指输入字符串与联系人前几个首字母完全匹配。用来匹配姓名。是前置首字母溢出匹配的特殊形式。 无加分项,减分项为不匹配的首字母个数。

    PZH-->PanZhiHui。+2-0
    PZ-->PanZhiHui。+2-1

  3. 前置首字母溢出匹配用来匹配姓名。指在匹配首字母的情况下,还匹配了某一个或者几个首字母后一段连贯的字符串。加分项为匹配到的首字母个数,减分项为不匹配的首字母个数。

    PanZH-->PanZhiHui。+1-0
    PZhiHui-->PanZhiHui。+1-0
    PZHui-->PanZhiHui。+1-0
    PZHu-->PanZhiHui。+1-0
    PZhi-->PanZhiHui。+1-1

  4. 前置段匹配用来匹配姓名。指一个长度为N的连贯字符与联系人内某一匹配项的前N个字符完全匹配。是前置首字母溢出匹配的特殊形式。

    panzh-->PanZhiHui

  5. 后置首字母完全匹配用来匹配姓名。指输入字符串匹配除第一个首字母以外的其他几个连续首字母。 无加分项,减分项为不匹配的首字母个数。

    ZH-->PanZhiHui

  6. 后置首字母溢出匹配用来匹配姓名。后置首字母完全匹配的情况下,还匹配了某一个或者几个首字母后一段连贯的字符串。加分项为匹配的首字母的数量,减分项为不匹配的首字母个数。

    ZHu-->PanZhiHui。+1-0
    Zh-->PanZhiRui。+1-1

  7. 后置段匹配用来匹配姓名。指有一串长度为N的连贯字符与与联系人内某一匹配项的后半部的一段N个字符串匹配,且此连贯字符的开头位置必须是某一首字母位置。是后置首字母溢出匹配的特殊形式,同时意味着后置首字母溢出匹配事实上不需要加分项,只要保证后置首字母完全匹配的加分项比它大就足够了

    ZhiHui/Zhi/Hui-->PanZhiHui

  8. 后置无头匹配用来匹配姓名和电话号码。指一串连贯字符在前7种全部未匹配成功的情况下,却被包含在字符串里。加分项为-index,减分项为长度差

    hiHui-->PanZhiHui

每个规则都有一个基础数值,以及加分减分项,基本数值不同。取减分项为0.001,加分项为1。至于为什么,在下一段。

查询时匹配以上8种,其他情况不匹配。

匹配的原则是匹配尽可能多的单词。

上面这些名字完全是临时胡编乱造的好么 0.0j_0004.gif

排列规则

  1. 查询出的列表将按匹配度排序,匹配度是一个float(当然double也一样),优先级别从高到低如下(减分项足够小以至于高优先级的匹配度无论如何减分都仍然会高于下面的优先级,因此减分项事实上只用来区别同一优先级中不同联系人匹配程度的高低)。

  • 完全匹配,对应的基础数值为4000。

  • 前置首字母完全匹配、前置首字母溢出匹配、前置段匹配,这三个其实都可以视作前置首字母溢出匹配,对应的基础数值为3000。(当只有一个字母时,按规则#1算)

  • 后置首字母完全匹配、后置首字母溢出匹配、后置段匹配,这三个其实都可以视作后置首字母溢出匹配对应的基础数值为2000。(当只有一个字母时,按规则#5算)

  • 后置无头匹配。对应的基础数值为1000。(可以考虑摒弃此匹配,没有人会这么按,而按键出错的可能性导致无头匹配的可能性又极小,往往不是想要的结果)



输入的一列查询字符串将同时与联系人的名字和电话匹配。对于一个联系人,他的名字可能有多种发音,这时候要取匹配度最高的。对于一个联系人,他可能有两个甚至更多的电话号码,匹配的时候要分别匹配,而不是单独取匹配度最高的。




    


    好了,先写一个类Contact。

    添加几个常量,看字面意思应该看得懂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static final int Match_Type_Name = 1;
static final int Match_Type_Phone = 2;
 
static final int Level_Complete = 4;
static final int Level_Fore_Acronym_Overflow = 3;
static final int Level_Back_Acronym_Overflow = 2;
static final int Level_Headless = 1;
static final int Level_None = 0;
 
static final float Match_Level_None = 0;
static final float Match_Level_Headless = 1000;
static final float Match_Level_Back_Acronym_Overflow = 2000;
static final float Match_Level_Fore_Acronym_Overflow = 3000;
static final float Match_Level_Complete = 4000;
static final float Match_Score_Reward = 1;
static final float Match_Miss_Punish = 0.001f;
static final int Max_Reward_Times = 999;
static final int Max_Punish_Times = 999;

    再添加下面几条字段

1
2
3
    List<ArrayList<String>> fullNameNumber = new ArrayList<ArrayList<String>>();
    List<String> fullNameNumberWithoutSpace = new ArrayList<String>();
    List<String> abbreviationNumber = new ArrayList<String>();

    fullNameNumber是一个二维的ArrayList,它存放的是将一个联系人打散后数字后的List。比如张三的fullNameString就是{{94264,726}},之所以是二维的,原因是有可能姓名是含有多音字……

    fullNameNumberWithoutSpace是联系人姓名的全拼对应的数字,比如张三就是{94264726},之所以是二维的,原因是有可能姓名是含有多音字……

    abbreviationNumber是联系人姓名首字母对应的数字,比如张三对应的就是{97},之所以是二维的,原因是有可能姓名是含有多音字……

    在设置了Contact的名字后上面三个字段将同时生成数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    synchronized public void initPinyin() {
            String trimmed = name.replaceAll(" """);
            //将姓名转化为拼音
            String fullNamesString = HanyuPinyinHelper.hanyuPinYinConvert(trimmed, false);
            for (Iterator<String> iterator = fullNamesString.iterator(); iterator
                    .hasNext();) {
                String str = iterator.next();
                ArrayList<String> lss = new ArrayList<String>();
                String[] pinyins = TextUtil.splitIgnoringEmpty(str, " ");
                String abbra = "";
                String fullNameNumberWithoutSpaceString = "";
                for (int i = 0; i < pinyins.length; i++) {
                    String string = pinyins[i];
                    String res = convertString2Number(string);
                    abbra += res.charAt(0);
                    fullNameNumberWithoutSpaceString += res;
                    lss.add(res);
                }
                abbreviationNumber.add(abbra);
                fullNameNumberWithoutSpace
                        .add(fullNameNumberWithoutSpaceString);
                fullNameNumber.add(lss);
            }
    }

给它一个match方法。下面调用的xxxMatch()方法都是针对四种不同种类的匹配的对应方法。

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
public float match(String reg) {
    // 无法通过第一个字母来判断是不是后置匹配
    // 但是可以通过第一个字母判断是不是前置匹配
    // match的原则是匹配尽可能多的字符
    // 事实上前五种匹配方式都可以使用crossMatch来实现
    ScoreAndHits scoreAndHits = new ScoreAndHits(-1, 0f,
            new ArrayList<PointPair>());
    if (!TextUtils.isEmpty(reg)) {
        boolean checkBack = !canPrematch(reg);
        if (!checkBack) {
            if ((scoreAndHits = completeMatch(reg)).score == 0f) {
                if ((scoreAndHits = foreAcronymOverFlowMatch(reg)).score == 0f) {
                    checkBack = true;
                }
            }
        }
        if (checkBack) {
            if ((scoreAndHits = backAcronymOverFlowMatch(reg)).score == 0f) {
                scoreAndHits = backHeadlessParagraphMatch(reg);
            }
        }
    }
    scoreAndHits.reg = reg;
    matchValue = scoreAndHits;
    return scoreAndHits.score;
}

所有的xxxMatch返回的结果是一个自定义类ScoreAndHits。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static class ScoreAndHits {
    public float score = 0f;
    public int nameIndex;
    public ArrayList<PointPair> pairs = new ArrayList<PointPair>();
    public int matchType = Match_Type_Name;
    public int matchLevel = Level_None;
    public String reg = "";
 
    public ScoreAndHits(int nameIndex, float score,
            ArrayList<PointPair> pairs) {
        this.nameIndex = nameIndex;
        this.score = score;
        this.pairs = pairs;
    }
}

nameIndex是匹配到了第几个拼音。score是匹配度。pairs是指匹配到的数字在对应的二维list中的位置,用来将来高亮显示匹配的字符用的。如果完全匹配的话,就用不到pairs了。


几个匹配方法的具体内容看下一篇,超过字数限制,写不开了j_0012.gif

1.完全匹配

    完全匹配很简单了,只要判断string是否相等就行了。这里要判断所有拼音和所有号码。如果拼音已经符合,就不再判断号码。反正是一个人……

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
private ScoreAndHits completeMatch(String reg) {
    ScoreAndHits scoreAndHits = new ScoreAndHits(-1, 0f,
            new ArrayList<PointPair>());
    for (int i = 0; i < fullNameNumberWithoutSpace.size(); i++) {
        String str = fullNameNumberWithoutSpace.get(i);
        if (reg.equals(str)) {
            scoreAndHits.nameIndex = i;
            scoreAndHits.score = Match_Level_Complete;
            scoreAndHits.pairs.add(new PointPair(i, -1));
            scoreAndHits.matchLevel = Level_Complete;
            return scoreAndHits;
        }
    }
    for (int i = 0; i < phones.size(); i++) {
        PhoneStruct phone = phones.get(i);
        if (reg.equals(phone.phoneNumber)) {
            scoreAndHits.nameIndex = i;
            scoreAndHits.score = Match_Level_Complete;
            scoreAndHits.pairs.add(new PointPair(i, -1));
            scoreAndHits.matchType = Match_Type_Phone;
            scoreAndHits.matchLevel = Level_Complete;
            return scoreAndHits;
        }
    }
    // 走到这里说明没有匹配
    return new ScoreAndHits(-1, 0f, new ArrayList<PointPair>());
}

2.前置首字母溢出匹配。(能不能想个好听的名字j_0004.gif

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
private ScoreAndHits foreAcronymOverFlowMatch(String reg) {
        // 因为有可能是多音字,所以这个方法用来对比不同拼音的匹配度,并取最大的那个
    ScoreAndHits scoreAndHits = new ScoreAndHits(-1, 0f,
            new ArrayList<PointPair>());
    for (int i = 0; i < fullNameNumber.size(); i++) {
        ArrayList<String> names = fullNameNumber.get(i);
        ScoreAndHits tmpscore = foreAcronymOverFlowMatch(names, reg);
        if (tmpscore.score > scoreAndHits.score) {
            scoreAndHits = tmpscore;
            scoreAndHits.nameIndex = i;
        }
    }
    scoreAndHits.matchLevel = Level_Fore_Acronym_Overflow;
    return scoreAndHits;
}
 
// 在第一个字母确定的情况下,第二个字母有可能有三种情况
// 一、在第一个字母所在单词的邻居位置charAt(x+1);
// 二、在第二个单词的首字母处
// 三、以上两种情况皆不符合,不匹配,出局
 
private ScoreAndHits foreAcronymOverFlowMatch(ArrayList<String> names,
        String reg) {
    // 用来得出某一个拼音的匹配值。
    ScoreAndHits scoreAndHits = new ScoreAndHits(-1, 0f,
            new ArrayList<PointPair>());
    if (names.get(0).charAt(0) == reg.charAt(0)) {
            //其实crossWords()方法才是求匹配值的方法,lol
        OverflowMatchValue value = crossWords(names, reg, 000);
        int cross = crossWords(names, reg, 000).crossed;
        if (cross > 0) {
            scoreAndHits.score = Match_Level_Fore_Acronym_Overflow + cross
                    * Match_Score_Reward - (names.size() - cross)
                    * Match_Miss_Punish;
            scoreAndHits.pairs = value.pairs;
        }
 
    }
    return scoreAndHits;
}
 
/**
 * 返回一串字符能跨越另一串字符的长度,根据上面的匹配规则,要尽可能的多匹配单词。若要保证
 * 能匹配最长的长度,只要保证下一个字符开始的一段字符能匹配最长的长度即可,换名话说,
 * 如果想要让96758匹配最长的字符串,那么只要保证6758能匹配最长的字符串即可,
 * 然后758,再然后58……。例如,名字叫PanAnNing,输入pan,那么应该匹配三个首字母,
 * PAN,而不是第一姓的拼音Pan.这是一个递归。
 
 
 * @param names
 * @param regString
 *            匹配字符串
 * @param listIndex
 *            匹配到的list的第listIndex个单词
 * @param strIndex
 *            匹配到第listIndex个单词中的第strIndex个字母
 * @param regIndex
 *            regchar的匹配位置,比如匹配到了96758的7上,也就是regIndex==2.
 * @return
 */
private OverflowMatchValue crossWords(ArrayList<String> names,
        String regString, int listIndex, int strIndex, int regIndex) {
        //在进入此方法时,第listIndex个单词的第strIndex的字母肯定是
        // 与regString的第regIndex个字母相等的
    OverflowMatchValue result = new OverflowMatchValue(0false);
    OverflowMatchValue reser = new OverflowMatchValue(0false);//返回如果匹配到本单词的下一个字母能得到的匹配值
    OverflowMatchValue impul = new OverflowMatchValue(0false);//返回如果匹配到下一个单词的第一个字母的匹配值
     
    // 仍然以【名字叫PanAnNing,输入pan(其实对比的是数字,这里转化成字母为了方便)】举例
    // 假设这时listIndex,strIndex,regIndex都是0,所以现在匹配的是p字母,它毫无疑问对应姓名的第一个P,
    // 那么下一步应该怎么做呢,由上面所说【保证下一个字符开始的一段字符能匹配最长的长度即可】
    // 也就是说,我们输入的pan中的第二个字母a匹配哪个位置将得到最优结果。这个盒子中显然有两种情况。
    // 一是匹配姓氏Pan中的a,另一个是匹配名字AnNing中的A。
    // reser就表示如果a匹配到Pan中的a最终的匹配值。
    // impul就表示如果a匹配到AnNing中的A得到的最终的匹配值。
     
    if (regIndex < regString.length() - 1) {
        //如果还没匹配到最后一个字母,也就是regString还没匹配到最后一个,那么将检测如
        //果将regString的下一个字母放到哪里将得到最优结果
        char nextChar = regString.charAt(regIndex + 1);
        if (listIndex < names.size() - 1
                && nextChar == names.get(listIndex + 1).charAt(0)) {
            impul = crossWords(names, regString, listIndex + 10,
                    regIndex + 1);
        }
        if (strIndex < names.get(listIndex).length() - 1
                && nextChar == names.get(listIndex).charAt(strIndex + 1)) {
            reser = crossWords(names, regString, listIndex, strIndex + 1,
                    regIndex + 1);
        }
        //如果上面两个条件都不成立,那么就表示本次匹配失败
    else {
        result = new OverflowMatchValue((strIndex == 0) ? 1 0true);
        result.pairs.add(0new PointPair(listIndex, strIndex));
    }
 
    if (reser.matched || impul.matched) {
        //如果其中任意一个方式可以匹配,那么结果最大的那个就是最优结果
        if (impul.crossed > reser.crossed) {
            result = impul;
        else {
            result = reser;
        }
        result.matched = true;
        result.crossed = ((strIndex == 0) ? 1 0)
                + Math.max(result.crossed, result.crossed);
        result.pairs.add(0new PointPair(listIndex, strIndex));
    }
    return result;
}
 
static class OverflowMatchValue {
    public int crossed = 0;
    public boolean matched = false;
    public ArrayList<PointPair> pairs = new ArrayList<PointPair>();
 
    public OverflowMatchValue(int c, boolean m) {
        this.crossed = c;
        this.matched = m;
    }
}

3.后置首字母溢出匹配。(能不能想个好听的名字j_0004.gif

跟前置首字母溢出匹配基本一样,只不过匹配的第一个字母不再是姓的首字母。

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
private ScoreAndHits backAcronymOverFlowMatch(String reg) {
        //跟上面差不多
    ScoreAndHits scoreAndHits = new ScoreAndHits(-1, 0f,
            new ArrayList<PointPair>());
    for (int i = 0; i < fullNameNumber.size(); i++) {
        ArrayList<String> names = fullNameNumber.get(i);
        ScoreAndHits tmp = backAcronymOverFlowMatch(names, reg);
        if (tmp.score > scoreAndHits.score) {
            scoreAndHits = tmp;
            scoreAndHits.nameIndex = i;
        }
    }
    scoreAndHits.matchLevel = Level_Back_Acronym_Overflow;
    return scoreAndHits;
}
 
private ScoreAndHits backAcronymOverFlowMatch(ArrayList<String> names,
        String reg) {
    int score = 0;
    int punish = 0;
    ScoreAndHits scoreAndHits = new ScoreAndHits(-1, 0f,
            new ArrayList<PointPair>());
    // 有可能会调用多次crossWords,取决于名字的长度。这是跟前面的不同
    for (int i = 0; i < names.size(); i++) {
        String string = (String) names.get(i);
        if (string.charAt(0) == reg.charAt(0)) {