違禁詞過濾完整設計與優化(前綴匹配、二分查找)

     可能不止在天朝,絕大多數網站都會須要違禁詞過濾模塊,用於對不雅言論進行屏蔽;因此這個應該算是網站的基礎功能。大概在去年的時候我開發過這個功能,當時用6600+(詞數)的違禁詞庫,過濾2000+(字數)的文章,耗時大概20ms左右,當時自我感受還挺良好。過了這一段時間,回想一下,其實有很多地方能夠作優化、能夠總結;因而從頭至尾捋了一遍。java

原始需求:

     違禁詞最原始的需求,也是最直觀的,便是查找一篇未知的文檔中,是否包含了一個指定詞語集合(違禁詞庫)中的詞語。其實要實現這個功能的思路有許多,也能夠很簡單;但爲了達到必定的效率,須要一些算法和數據結構的設計。

邏輯整理:

     將原始的需求轉換成可實現的邏輯,這裏根據思惟方向(出發點),能夠有兩個不一樣的選擇: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
相關文章
相關標籤/搜索