如果本文对你有帮助,麻烦点个star! pinyin-math
本文同步发布在掘金 掘金--如何实现一个高效的拼音匹配库?
首先看看列表效果:
在线演示地址:http://139.199.181.200:8686/
接下来讲讲实现
探索微信的拼音匹配规则
通过参考微信,分为两种情况,一种不包含多音字,一种包含,我们先从简单的开始。
1.不包含多音字,以“你真好(nizhenhao)”为例
命中匹配:
- 完整的拼音输入√ (当然只输入 zhenhao / hao 也是OK的)
- 拼音首字母 √
- 最后一个音未输入完整 √(打字打到一半)
无法命中匹配:
- 起始字母不是分词点 x (z)henhao
- 有的采用缩写有的采用全写 x niz(hen)hao
2.包含多音字以“骑马(qima jima)”为例
直接套用非多音字的规则就OK
匹配规则总结
- 起始字母必须为拼音分词点
- 除了最后一个字,不允许存在有的输入为全写,有的为缩写(注意最后一个字的拼音也只允许从前往后按顺序输入到一半)
- 多音字只需把对应的多音列举出来,套用上述规则即可
接下来用JS来实现一个匹配库pinyin-match.js
步骤:
- 对输入的拼音进行分词
- 将中文转为拼音
- 第一步分词的结果和第二步的拼音进行匹配,返回是否命中
1.拼音分词
需要对输入的拼音进行合法性检测,以及分词的处理,这一步是匹配算法高效的关键
首先举个例子:
在搜索的时候,输入"jinan" 能匹配哪些读音的字呢?
- ji nan (如:济南)
- jin an (如:晋安)
除此之外,还要考虑输入不完整的情况
- ji nan(g) (如:济囊)
- jin an(g) (如:金昂)
- ji na n(eng) (如:济那能)
- jin a n(a) (如:金阿拿)
- ……还有好多好多好多 就不写了
- j i n a n (最后还要考虑每个字都是首字母,不过因为没有i开头的拼音,所以这里没有)
本着循序渐进的原则,我们先解决拼音输入是完整的情况,即最前面的两种。
这里的核心算法其实就是 word-break,先附上leetcode的链接Word Break II
关于word-break
word-break解决的问题如下:
给定一个字符串s和单词字典dict,求出s根据dict拆分出的所有可能。
例子:
let s = ‘catsanddog’
const dict = ['cat', 'cats', 'and', 'sand', 'dog']
wordBreak(s) // return ['cat sand dog', 'cats and dog']
- 从第一个字符p开始,查找是否在字典中。不在字典中,字符p+1,重新查找;如果在字典中,记录下命中的词,进行2
- 如果有剩余字符串,对剩余字符串重复1;如果没有剩余的字符串,则完成当次查找,找到的命中词即可构成完整句子。回溯到上一次成功的状态,字符p+1继续进行1。
- 退出的条件为把整个字符串丢进去字典查的时候(即字符p++++++++1,一直到末尾)
代码实现:
function wordBreak(s) {
let result = [] // 当前处理的结果
let solutions = [] // 保存完整的结果
getAllSolutions(0, s, result, solutions)
return solutions
}
function getAllSolutions(start, s, result, solutions) {
if (start === s.length) { // 后续没有s,即找到了命中结果,存如solutions
solutions.push(result.join(' '))
return
}
for (let i = start; i < s.length; i++) {
let piece = s.substring(start, i + 1) // 这个piece即上面说的字符p
if (dict.indexOf(piece) !== -1) {
result.push(piece)
getAllSolutions(i + 1, s, result, solutions) // 命中一个词,向后找(递归该方法即可)
result.pop() // 回溯到上一次结果 然后往后找
}
}
}
以上就是分词算法的核心实现了,接下来我们对这个算法再做进一步的优化--剪枝,所谓剪枝就是把已经确认后续不会有完整结果的index存下来,下次再遇到直接跳过即可,我们对上述的dict做一个扩展,加入两个个词san an 去掉 and
const dict = ['cat', 'cats', 'san', 'an', 'sand', 'dog']
按照上述的算法,过程图解如下:
注意上图的两个红框部分,因为 ddog 已经被证明过,后续无法命中了,所以后面再遇到ddog 直接跳过即可。我们用一个possible数组来记录后续有没有可能找到结果,初始化为true,possible的长度为catsanddog的长度(10),然后对 getAllSolutions进行补充
function getAllSolutions(start, s, result, solutions, possible) {
if (start === s.length) { // 后续没有s,即找到了命中结果,存如solutions
solutions.push(result.join(' '))
return
}
for (let i = start; i < s.length; i++) {
let piece = s.substring(start, i + 1)
if (dict.indexOf(piece) !== -1 && possible[i + 1]) {
result.push(piece)
let beforeChange = solutions.length // 记录下当前结果的长度,等下一次getAllSolutions返回,如果没变化,说明后续找不到结果
getAllSolutions(i + 1, s, result, solutions, possible) // 命中一个词,向后找(递归该方法即可)
if (solutions.length === beforeChange) {
possible[i + 1] = false
}
result.pop() // 回溯到上一次结果 然后往后找
}
}
}
OK,现在如果给到一个完整的拼音字典dict(所有汉字的读音,大概是400个出头),调用wordBeak('jinan')输出如下
接下来我们继续改进wordBreak函数,使它可以输出我们前面提到的,输入不完整的情况
- 比如 'jinan' 应当可以匹配 '济囊' (打字打到一半)
针对这种打字打到一半的情况,其实就是最后一个音不需要完整匹配,仅需从前往后匹配即可,例: (例子简化一下拼音字典,只有3个音)
s = 'jinani'
dict = ['ji', 'na', 'ning']
用图来演示一下wordBreak过程(跟之前一样)
这里就不贴整段代码了,通过上图我们可以知道,最后一块piece并不一定需要在字典库中找到匹配项,仅需在字典中存在某个音x,x.indexOf(piece) === 0即可:
dict.some(item => item.indexOf(piece) === 0)
- 假定输入的所有字符全部是首字母
这种情况就简单多了,直接把输入的每个字符拆开即可(实际上这里偷懒了,因为有些单音,并不在字典中,不过这点影响可以忽略)
把上述两种不完整的情况也考虑进去,我们就完成了最核心的wordBreak函数了。接下来看看中文转成拼音的方法
2.中文转拼音
1.首先需要找一个拼音字典,从开源库pinyinjs找到了一个比较合适的字典,收录了6763个常用汉字,原始字典的结构如下图:
3.将分词的结果和中文pinyin进行匹配
直接用代码展示,以输入 'cengd', 匹配'我曾大'为例:
var a = getPinyin('我曾大') // a = ['wo', 'zeng ceng', 'da'] 通过pinyin函数的结果整理得出
var b = getFullKey('cengd') // b = [['ceng d', ['c', 'e', 'n', 'g', 'd']] 通过wordBreak的结果整理得出
匹配函数为源码中的 getIndex函数,受限于篇(懒)幅(惰),这里用图来展示匹配过程:
简单总结这个匹配函数就是 b中存在一个元素,能完整连续匹配上a(当然这里也要考虑最后一个音 仅需部分匹配即可)
写在最后
本文主要讲解了pinyin-match中 比较核心的分词算法,其余部分简单做了一下演示,并没有完整分析pinyin-match的所有方法(比如获取匹配的index,原文存在空格影响index等问题),如果对文章中没说明的地方有疑问,欢迎在评论区提出~
如果有帮助,请点个star!pinyin-math
插个无关的: 本人最近在寻找新的工作机会,前端,base期望:广州>厦门>深圳(暂不考虑其他地方)