2019独角兽企业重金招聘Python工程师标准>>>
可能不止在天朝,绝大多数网站都会需要违禁词过滤模块,用于对不雅言论进行屏蔽;所以这个应该算是网站的基础功能。大概在去年的时候我开发过这个功能,当时用6600+(词数)的违禁词库,过滤2000+(字数)的文章,耗时大概20ms左右,当时自我感觉还挺良好。过了这一段时间,回想一下,其实有不少地方可以做优化、可以总结;于是从头到尾捋了一遍。
原始需求:
违禁词最原始的需求,也是最直观的,即是查找一篇未知的文档中,是否包含了一个指定词语集合(违禁词库)中的词语。其实要实现这个功能的思路有许多,也可以很简单;但为了达到一定的效率,需要一些算法和数据结构的设计。
逻辑整理:
将原始的需求转换成可实现的逻辑,这里根据思维方向(出发点),可以有两个不同的选择:1. 遍历文档的每个词,查看违禁词库中是否包含这个词; 2. 遍历违禁词库中的每个词, 查看文档中是否包含这个词。
我这里采用的是第一种思维方向,原因有二:
a. 我要过滤的文档的字数,大部分集中的2000~5000之间,少于违禁词的个数;遍历少的从性能上讲,有先天的优势。
b. 待过滤的文档是未知的,且变化的,而违禁词已知且固定;于是我们可对违禁词的数据结构做一定的设计,加快一个词在其中的查找,所以需要遍历的是文档(较主要的一个原因)。
思路有了,简单概括为:从文档中取词—>该词是否属于违禁词。
下一步我们需要整理出实现逻辑的步骤,在针对每一步骤做设计和优化。步骤如下:
1. 取出下一个字节(若最后一个字节:跳至结束),
2. 判断是否为汉字,是:记录该字节的位置w,并继续下一步;否:返回第1步。
3. 判断此汉字是否是某个违禁词的开头,是:继续下一步;否:返回第1步。
4. 继续读取下一个字符(若最后一个字节:跳至结束),判断是否为汉字,是:继续下一步;否:返回第一步。
5. 将上一步得到汉字和前面的汉字组成字符串,判断是否是某个违禁词的前缀。是:继续下一步;否:跳回第1步(取w+1字节)。
6. 查看这个前缀是否就是违禁词。是继续下一步;否:返回第4步。
7. 记录下这个违禁词的信息(词,长度,位置等)。
8. 返回第1步(从w+该违禁词长度+1处取词)
9. 结束。
老鸟们,可能都熟悉,这是分词中的前缀匹配法,其实违禁词过滤的思路和搜索中分词的思路相似,所以我也有参考Lucene在分词时的源代码来实现。另:我目前处理的违禁词中只有汉字,若您处理时有其他符号,可增加些判断。
下面是这部分逻辑的源代码:
/**
* 过滤违禁词
* @param sentence:待过滤字符串
* @return
*/
private BadInfo findBadWord(String sentence) {
CharType[] charTypeArray = getCharTypes(sentence);//获取出每个字符的类型
BadInfo result = new BadInfo(sentence);
BadWordToken token;
int i = 0, j;
int length = sentence.length();
int foundIndex;
char[] charArray;
StringBuffer wordBuf = new StringBuffer();
while (i < length) {
// 只处理汉字和字母
if (CharType.HANZI == charTypeArray[i]
|| CharType.LETTER == charTypeArray[i]) {
j = i + 1;
wordBuf.delete(0, wordBuf.length());//新的一轮匹配,清除掉原来的
wordBuf.append(sentence.charAt(i));
charArray = new char[] { sentence.charAt(i) };
foundIndex = wordDict.getPrefixMatch(charArray);//前缀匹配违禁词
//foundIndex表示记录了前缀匹配的位置
while (j <= length && foundIndex != -1) {
// 表示找到了
if (wordDict.isEqual(charArray, foundIndex)
&& charArray.length > 1) {
token = new BadWordToken(new String(charArray), i, j);
result.addToken(token);//记录下来
i = j - 1; // j在匹配成功时已经自加了,这里是验证确实是违禁词,所以需要将j前一个位置给i
}
// 去掉空格
while (j < length
&& charTypeArray[j] == CharType.SPACE_LIKE)
j++;
if (j < length
&& (charTypeArray[j] == CharType.HANZI || CharType.LETTER == charTypeArray[j])) {
//将下个字符和前面的组合起来, 继续前缀匹配
wordBuf.append(sentence.charAt(j));
charArray = new char[wordBuf.length()];
wordBuf.getChars(0, charArray.length, charArray, 0);
foundIndex = wordDict.getPrefixMatch(charArray,
foundIndex);//前缀匹配违禁词
j++;
} else {
break;
}
}
}
i++;
}
return result;
}
上面的逻辑和代码实现只是过滤违禁词外层实现,具体如何在违禁词库中,查询指定字符串,是最为关键的,即:词典WordDict的数据结构,和它的算法getPrefixMatch() 方法,也是涉及到性能优化的地方。
数据结构:词典
先来说说词典WordDict的数据结构吧,它作为一个容器,里面记录所有违禁词。
为了快速查找,使用了散列的思想和类似索引倒排的结构,通过一个三维的char 数组来实现。
private char[][][] wordItem_real;
第一维 wordItem_real[i] 其含义是:具有相同开头汉字X,的所有违禁词(一组)。其中下标 i 为 X 的 GB2312 码,这样只要对文档中的某一个汉字一转码,就能马上找到以此汉字开头的所有违禁词,算是一种散列吧;
另:每组违禁词 是有序的(升序),先按长度排序,再按 char 排序。查找时用到了二分查找所以需要保持有序。
第二维 wordItem_real[i][j] 其含义是:具体的一个违禁词的字符串数组,例如违禁词“红薯” = {'红','薯'}。
第三维 wordItem_real[i][j][k] 就是 词中某个汉字了。
词典的初始化代码,这里就不贴了,主要都是些读文件,扫描单词,和排序等一些基础代码。
算法:二分查找与前缀匹配
接下来是 getPrefixMatch() 算法,它肯定依赖于 WordDict 词典的数据结构,就不多说了。它的目的是:从词典中查找以charArray对应的单词为前缀(prefix)的单词的位置, 并返回第一个满足条件的位置。为了减小搜索代价, 可以根据已有知识设置起始搜索位置, 如果不知道起始位置,默认是0
它的实现思路是:首先通过对参数中第一个字符 转GB2312 码,并根据此码获得 具有相同开头汉字的那组违禁词。然后在通过二分查找的方式,查看这组违禁词中是否包含 参数字符串前缀的 词;二分查找中具体的比较方法在稍后贴出。
/**
*
*
* @see{getPrefixMatch(char[] charArray)}
* @param charArray
* 前缀单词
* @param knownStart
* 已知的起始位置
* @return 满足前缀条件的第一个单词的位置
*/
public int getPrefixMatch(char[] charArray, int knownStart) {
int index = Utility.getGB2312Id(charArray[0]);
if (index == -1)
return -1;
char[][] items = wordItem_real[index];
if(items == null){
return -1; //没有以此字开头的违禁词
}
int start = knownStart, end = items.length - 1;
int mid = (start + end) / 2, cmpResult;
// 二分查找法
while (start <= end) {
cmpResult = Utility.compareArrayByPrefix(charArray, 1, items[mid],
0);
if (cmpResult == 0) {
// 获取第一个匹配到的(短的优先)
while (mid >= 0
&& Utility.compareArrayByPrefix(charArray, 1,
items[mid], 0) == 0)
mid--;
mid++;
return mid;// 找到第一个以charArray为前缀的单词
} else if (cmpResult < 0)
end = mid - 1;
else
start = mid + 1;
mid = (start + end) / 2;
}
return -1;
}
下面是上述代码中,二分查找的比较方式:根据前缀来判断两个字符数组的大小,当前者为后者的前缀时,表示相等,当不为前缀时,按照普通字符串方式比较。呵呵,这里算是盗用lucene 源代码了。
public static int compareArrayByPrefix(char[] shortArray, int shortIndex,
char[] longArray, int longIndex) {
// 空数组是所有数组的前缀,不考虑index
if (shortArray == null)
return 0;
else if (longArray == null)
return (shortIndex < shortArray.length) ? 1 : 0;
int si = shortIndex, li = longIndex;
while (si < shortArray.length && li < longArray.length
&& shortArray[si] == longArray[li]) {
si++;
li++;
}
if (si == shortArray.length) {
// shortArray 是 longArray的prefix
return 0;
} else {
// 此时不可能si>shortArray.length因此只有si <
// shortArray.length,表示si没有到达shortArray末尾
// shortArray没有结束,但是longArray已经结束,因此shortArray > longArray
if (li == longArray.length)
return 1;
else
// 此时不可能li>longArray.length因此只有li < longArray.length
// 表示shortArray和longArray都没有结束,因此按下一个数的大小判断
return (shortArray[si] > longArray[li]) ? 1 : -1;
}
}
主要的思路和实现代码都已经讲明了,若大家有更好的过滤违禁词的算法,希望分享,周末愉快。
参考资料:Lucene 源代码
原创博客,转载请注明: http://my.oschina.net/BreathL/blog/56265