trie樹,可能大家都比較陌生。trie樹,又叫字典樹,是一種樹形結構,一種哈希樹的變種。典型應用是用於統計,排序和保存大量的字符串(但不僅限於字符串),所以經常被搜索引擎系統用於文本詞頻統計。它的優點是:利用字符串的公共前綴來減少查詢時間,最大限度地減少無謂的字符串比較,查詢效率比哈希樹高。
trie樹有3個基本性質:
- 根節點不包含字符,除根節點外每一個節點都只包含一個字符
- 從根節點到某一節點,路徑上經過的字符連接起來,爲該節點對應的字符串
- 每個節點的所有子節點包含的字符都不相同
我們今天準備用Java來實現trie樹,然後利用其高效的搜索功能,搜索句子裏面的敏感詞,並將敏感詞替換爲*。trie樹的搜索原理如下:
- 從根結點開始一次搜索
- 取得要查找關鍵詞的第一個字母,並根據該字母選擇對應的子樹並轉到該子樹繼續進行檢索
- 在相應的子樹上,取得要查找關鍵詞的第二個字母,並進一步選擇對應的子樹進行檢索
- 迭代上述過程……
- 在某個結點處,關鍵詞的所有字母已被取出,則讀取附在該結點上的信息,即完成查找
- 而敏感詞比對的話,如果比對過程中某一個字母在子樹中不存在,那麼第一個字母開頭的敏感詞就不存在,就從第一個字母的下一個字母爲首繼續找
- 如果句子結束而trie樹還沒查找完畢,也算是沒找到
- 如果某個字母已經找到了trie樹的葉結點了,那麼就找到了一個敏感詞,就把第一個找的字母和最後一個字母全部替換爲*,完成過濾功能
此外一個問題,那就是一個敏感詞是另一個敏感詞的前綴的問題。比如說有三個敏感詞:hello、hell、he。假設這三個敏感詞加入到trie樹,trie樹的最終效果就是隻增加了hello這一個單詞,也就只會過濾hello,hell和he就無法過濾了。
我的實現原理,就是每一個結點新增一個布爾類型的屬性,是否是結束字母(isEndLetter)。也就是在敏感詞加入到trie樹的過程中,加到了最後一個字母,那最後一個字母的結點,該布爾屬性設置爲true,不管是在沒有的樹枝上新增結點,還是在已有的樹枝上走,反正最後一個字母的結點(創建的或者是找到的),都設置該布爾類型爲true。當敏感詞過濾的時候,設置一個變量爲敏感詞查找的檢查點checkpoint,如果在某一個樹枝上找到了某個結點爲true,則記錄下來,後面又找到就再更新記錄(保證是最長的敏感詞)。如果找到樹的葉子結點,找到了全樹枝單詞,那麼全樹枝單詞就是敏感詞。如果沒有找到葉子結點下一個單詞就不對了,那麼就以上一個檢查點(當前checkpoint)找到的敏感詞爲準。
本次Java實現trie樹,根節點爲 ‘/’ ,測試的敏感詞爲:hello、hell、he、hero、see、sun、right(注意,敏感詞需要不包含 * 號,否則會拋出非法參數異常)。加上每個結點的isEndLetter的布爾值,畫成trie樹,就是下面的樣子:
接下來上代碼,我用Java編寫了TrieTree類,可以實現添加敏感詞,以及根據添加的敏感詞過濾句子,返回過濾後的句子,其中敏感詞的字母都會被替換成*號:
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* @Author: LiYang
* @Date: 2020/2/19 20:43
* @Description: trie樹(字典樹)做敏感詞過濾
*/
public class TrieTree {
/**
* trie樹的結點類(TrieTree的靜態內部類)
*/
private static class TrieNode {
//當前結點的字母
private char letter;
//當前節點字母是否可以作爲結束字母
private boolean isEndLetter = false;
//當前結點的下一結點集合
private Set<TrieNode> next;
/**
* 空構造方法
*/
public TrieNode() {
}
/**
* 入參letter的構造方法
* @param letter
*/
public TrieNode(char letter) {
this.letter = letter;
//爲每一個結點創建一個空的next集合
this.next = new HashSet<>();
}
/**
* 重寫equals方法,結點字母相等,結點即相等
* @param obj
* @return
*/
@Override
public boolean equals(Object obj) {
//如果不是同一類,則絕對不等
if (!(obj instanceof TrieNode)) {
//返回不等
return false;
}
//字母相等,則結點相等,否則不等
return this.letter == ((TrieNode)obj).letter;
}
/**
* 重寫了equals方法,就得跟着重寫hashCode方法
* @return
*/
@Override
public int hashCode() {
//以letter的數值代表hashcode
return this.letter;
}
/**
* 查找某字母的下級字母結點
* @param letter 查找的下一個字母
* @return 下一個字母的結點
*/
public TrieNode findNext(char letter) {
//遍歷查找
for (TrieNode node : next) {
//如果找到了
if (node.letter == letter) {
//返回該結點
return node;
}
}
//沒找到,返回null
return null;
}
}
/************** 接下來是TrieTree類的屬性和方法 **************/
//trie樹的根結點,默認爲'/'
private TrieNode root = new TrieNode('/');
//trie樹的公共後綴(句子尾部的處理需要)
private static final String PUBLIC_SUFFIX = "********";
/**
* 空構造方法
*/
public TrieTree() {
}
/**
* 帶初始敏感詞數組的構造方法
* @param words
*/
public TrieTree(String[] words) {
//遍歷敏感詞數組
for (int i = 0; i < words.length; i++) {
//加入初始敏感詞
addWord(words[i]);
}
}
/**
* 加入需要過濾的敏感詞(敏感詞不能帶*號)
* @param word 待加入的敏感詞
*/
public void addWord(String word) {
//驗證敏感詞裏面是否帶有*號(否則程序可能會出錯)
if (word.contains("*")) {
//拋出非法參數異常
throw new IllegalArgumentException("敏感詞中不能含有*號");
}
//將敏感詞轉化爲字符數組
char[] charArray = word.toCharArray();
//當前trie樹結點爲根結點
TrieNode currentNode = root;
//遍歷敏感詞每一個字母
for (int i = 0; i < charArray.length; i++) {
//在當前trie樹結點的下一個字母結點集合裏,尋找當前字母結點
TrieNode nextNode = currentNode.findNext(charArray[i]);
//如果當前字母存在
if (nextNode != null) {
//找到的當前字母結點作爲當前trie樹結點
//接下來在當前字母結點的下一個字母集合裏找下一個字母
currentNode = nextNode;
//如果當前字母不存在
} else {
//爲當前字母創造trie樹結點
TrieNode trieNode = new TrieNode(charArray[i]);
//將當前字母創造的trie樹結點,加入到當前結點的下一個結點集合裏
currentNode.next.add(trieNode);
//以當前字母創造的trie樹結點作爲當前結點,繼續找下一個字母
//有就繼續看下一個字母,沒有就創建,直到創建完整敏感詞樹枝
currentNode = trieNode;
}
}
//將最後一個字母,設置爲結束字母
currentNode.isEndLetter = true;
}
/**
* 過濾敏感詞(用當前TrieTree類的敏感詞庫來過濾)
* @param sentence 待過濾的原始句子
* @return 敏感詞字母被替換爲*的句子
*/
public String filtrate(String sentence) {
//要處理的句子,統一加上公共後綴(句子尾部的處理需要)
sentence = sentence + PUBLIC_SUFFIX;
//將原始句子轉化爲字符數組
char[] charArray = sentence.toCharArray();
//過濾後的句子的字符列表
List<Character> filtrated = new ArrayList<>();
//遍歷原始句子字符數組,從每一個字母開始向右同步走一遍trie樹
for (int i = 0; i < charArray.length; i++) {
//字母和trie樹的比對指標,就是同步比對到第幾個字母了
int index = i;
//結束字母比對檢查點,記錄找到的從起始字母
//到所有結束字母中最大長度的結束字母的下標
int checkpoint = -1;
//當前比對的trie樹結點,用它的下一字母集合比對,初始化爲root
TrieNode currentNode = root;
//持續比對
while (true) {
//如果比對的字母已經超過了原始句子的字符數組邊界
if (index > charArray.length - 1) {
//停止比對
break;
}
//當前trie樹結點的下一字母集合的元素個數
int nextSize = currentNode.next.size();
//將當前字母在當前trie樹結點的下一字母集合中尋找
//是否存在當前字母的trie樹結點
currentNode = currentNode.findNext(charArray[index]);
//如果在trie樹上找到了
if (currentNode != null) {
//如果是結束字母
if (currentNode.isEndLetter) {
//更新結束字母檢查點
checkpoint = index;
}
//指標+1,也就是繼續拿下一個字母
//和下一個trie樹結點繼續比對
index ++;
//如果trie樹上沒找到,且還沒找到trie樹的盡頭,即葉結點
} else if (nextSize > 0) {
//如果檢查點不爲初始值-1,也就是存在前綴敏感詞
if (checkpoint != -1) {
//首先我們得到前綴敏感詞的長度
int prefixWordLength = checkpoint - i + 1;
//遍歷敏感詞長度的次數
for (int j = 0; j < prefixWordLength; j++) {
//加入等長的*符號
filtrated.add('*');
}
//將原始句子的遍歷指標置爲前綴
//敏感詞最後一個字母的位置
i = checkpoint;
//如果檢查點爲i,也就是不存在前綴敏感詞
} else {
//說明以原始句子字母開頭的所有詞中不存在敏感詞
//將該字母原樣裝入到過濾後的句子的字符列表
filtrated.add(charArray[i]);
}
//結束比對
break;
//如果trie樹上沒找到,且找到了trie樹的盡頭,即葉結點
//那就是找到了以原始句子字母開頭的敏感詞
//我們的處理方式是用 "*" 代替敏感詞的字母
} else {
//首先,我們得到敏感詞的長度
int wordLength = index - i;
//遍歷敏感詞長度的次數
for (int j = 0; j < wordLength; j++) {
//加入等長的*符號
filtrated.add('*');
}
//將原始句子的遍歷指標置爲
//敏感詞最後一個字母的位置
i = index - 1;
//結束比對
break;
}
}
}
//至此,過濾後的句子的字符列表已創建完畢
//創建StringBuffer對象
StringBuffer sbuf = new StringBuffer();
//遍歷過濾後的句子字符列表
for (Character character : filtrated) {
//將過濾後句子的字符拼接
sbuf.append(character);
}
//返回原始句子過濾後的句子(截取尾部公共後綴前的句子)
return sbuf.substring(0, sbuf.length() - PUBLIC_SUFFIX.length());
}
/**
* 測試trie樹的敏感詞過濾功能
* @param args
*/
public static void main(String[] args) {
//敏感詞字符串數組
String[] words = {"hello", "hell", "he", "hero", "see", "sun"};
//額外加入的敏感詞
String extraWord = "right";
//創建TrieTree實例,初始化加入words的六個敏感詞
TrieTree trieTree = new TrieTree(words);
//繼續加入第七個敏感詞
trieTree.addWord(extraWord);
//過濾前的句子
String sentence = "hellofsxhelllgfeheldxghergasgherohgseevsunfright";
//打印過濾前的句子
System.out.println("過濾前:" + sentence);
//調用TrieTree示例的方法,將句子進行敏感詞過濾
String filtrated = trieTree.filtrate(sentence);
//打印過濾後的句子,用作比對
System.out.println("過濾後:" + filtrated);
}
}
運行新的TrieTree類的main方法,控制檯打印如下,測試通過:
過濾前:hellofsxhelllgfeheldxghergasgherohgseevsunfright
過濾後:*****fsx****lgfe**ldxg**rgasg****hg***v***f*****