违禁词过滤完整设计与优化(前缀匹配、二分查找)

2019独角兽企业重金招聘Python工程师标准>>> hot3.png

     可能不止在天朝,绝大多数网站都会需要违禁词过滤模块,用于对不雅言论进行屏蔽;所以这个应该算是网站的基础功能。大概在去年的时候我开发过这个功能,当时用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

转载于:https://my.oschina.net/BreathL/blog/56265

你可能感兴趣的:(违禁词过滤完整设计与优化(前缀匹配、二分查找))