构建健康游戏环境:DFA算法在敏感词过滤的应用
現在的游戲有敏感詞檢測這一點,相信大家也不陌生了,不管是聊天,起名,簽名還是簡介,只要是能讓玩家手動輸入的地方,一定少不了敏感詞識別,至于識別之后是拒絕修改還是星號替換,這個就各有各的做法了,但是繞不開的一定是需要高效的敏感詞檢測機制。
相信大家對于游戲里聊天框的以下內容已經不陌生了
- "我***"
- “你真牛*”
- “你是不是傻*”
一個垃圾的游戲環境是非常影響玩游戲的心情的,看到這些,就知道游戲已經幫我們屏蔽掉了那些屏蔽字了,對于玩游戲而言,心里會好受很多。敏感詞識別對于游戲的重要性不言而喻。當然,除了游戲,也有很多業務場景可能需要敏感詞檢測,如果你接到這樣一個需求的時候,你會怎么做?*
一、原生API
作為Java程序員,我的第一反應,一定是使用jdk原生的String類提供的contain或replace方法來進行包含判斷或字符替換,這是最簡單直接的方式。那我們就來看看String的實現方式:
contains
String在java中以char數組形式存儲,而String.contains的實現,實際上是對數組的遍歷查找匹配
// 最終調用方法
static int indexOf(char[] source, int sourceOffset, int sourceCount,
char[] target, int targetOffset, int targetCount,
int fromIndex) {
// ...
}
replace
String.replace有4個接口,實現為正則匹配替換或直接遍歷替換
public String replace(char oldChar, char newChar) {
// 直接進行字符串遍歷,替換第一個匹配的字符串
}
public String replace(CharSequence target, CharSequence replacement) {
// 創建Pattern,使用LITERAL模式進行正則匹配替換replaceAll
// 當設置LITERAL標志時,輸入字符串中的所有字符都被視為普通字符。
// 這意味著正則表達式的特殊字符,如點號(.)、星號(*)、加號(+)等,都將失去它們在正則表達式中的特殊意義,被直接視為普通字符。
}
public String replaceAll(String regex, String replacement) {
// 創建Pattern,使用正則表達式模式匹配替換replaceAll
}
public String replaceFirst(String regex, String replacement) {
// 創建Pattern,使用正則表達式模式匹配替換replaceFirst,僅替換第一個匹配的字符串
}
通過jdk提供的String源碼我們可以得到以下結果:
- 使用contains方法進行包含判斷,它的底層實現原理其實就是通過遍歷目標字符串的字符數組進行挨個匹配;少量敏感詞檢測的時候是可行的,但如果目標字符串很大,并且要匹配的敏感詞足夠多的時候,它的遍歷匹配效率是很低的。
- replace則分兩種實現,其中一種是類似contains方法,也是進行對目標字符串進行字符數組的遍歷替換。
- replace的另一種實現,是通過java的正則表達式去做匹配,正則匹配相比于遍歷匹配,效率上不會有明顯提升,但對于復雜模式的解析匹配會有比較明顯的優勢
其他語言的字符串操作API大同小異,具體看源碼的實現方式
二、正則表達式
另外一種我們能想到的方式就是進行正則表達式的匹配了。前面提到,在java中如果使用String的api,它有部分接口就是使用正則表達式來實現的。
使用正則表達式有一定優勢,也有一定缺陷。這就不得不提正則表達式的實現原理:FA(Finite Automaton:有限自動機)
DFA與NFA
FA又分為DFA和NFA,我們以正則ab|ac舉例
-
NFA(Nondeterministic finite automaton:非確定性有限狀態自動機)
在NFA中表達式會構建為以下結構- 非確定性:對于給定的輸入符號,NFA可以從一個狀態轉移到多個狀態。這意味著存在多種可能的狀態轉換路徑,NFA在任何時間點都可以處于多個狀態。
- 回溯:由于NFA在處理輸入時可以選擇多條路徑,因此可能需要回溯。當某條路徑未能達到接受狀態時,NFA會返回并嘗試其他可能的路徑。
- 構造:NFA相對容易構造,特別是對于復雜的或包含多種可能的語言(例如正則表達式)。
- 運行效率:由于其非確定性特性,NFA在運行時可能需要更多的計算資源,特別是在處理長輸入字符串時。
-
DFA(Deterministic finite automaton:確定性有限自動機)
在DFA中表達式會構建為以下結構- 確定性:對于給定的輸入符號,DFA從一個狀態轉移到另一個唯一確定的狀態。這意味著DFA在任何時間點只能處于一個狀態。
- 無回溯:由于每個輸入符號只對應一個狀態轉換,DFA在處理輸入時不需要回溯。
- 構造:相對于NFA,DFA可能更難直接構造,特別是對于復雜的語言,但它可以通過從NFA轉換得到。
- 運行效率:DFA在運行時通常更高效,因為它在處理輸入時不需要考慮多種可能的狀態路徑。
理論上,NFA和DFA等效,它們都可以識別相同的語言類型。但在實際應用中,它們各有優勢:NFA更適合于表示和構造復雜模式,而DFA在執行時更高效。
如果以上描述不能理解,這里其實可以做個不是特別恰當的比喻:廣度優先搜索BFS和深度優先搜索DFS。
- NFA可以轉移到多個不同的狀態。這就像是在圖中有多條邊從一個節點出發一樣。如果將NFA的操作類比為一種搜索算法,它更接近于廣度優先搜索(BFS)。在匹配過程中,NFA可以同時探索多條路徑(或狀態轉換),就像BFS在搜索時會先訪問所有鄰接節點。然而,NFA通常不會存儲所有可能的狀態轉換路徑,而是在運行時動態生成它們。
- DFA只能轉移到一個唯一確定的狀態。這就像是圖中的每個節點僅有一條出邊一樣。盡管DFA在每一步只選擇一條路徑,但將其類比為深度優先搜索(DFS)并不準確。DFS是一種搜索算法,用于探索所有可能的路徑直到它達到目標或結束條件。DFA則是一種確定性的狀態機,它不需要“搜索”;它只是在狀態之間單一確定地轉換。
在正則表達式的實現中,有的基于DFA,有的基于NFA;盡管DFA的搜索路徑比NFA短,但實際場景中,NFA更適合復雜模式的正則搜索。因此大多數正則實現還是基于NFA。java中的正則表達式是基于NFA的實現
使用局限
當然了,正則表達式的實現到底是NFA還是DFA,并不是今天討論的重點。
-
資源消耗
無論是NFA還是DFA,它們在匹配之前,都會先構造基于圖的數據結構,因此,使用正則表達式進行敏感詞匹配,一定逃不開構建這個數據結構的性能消耗和內存占用。 -
回溯陷阱
在使用正則表達式進行敏感詞匹配時,如果是基于NFA實現的正則算法,則很有可能出現回溯陷阱。上面提到NFA在匹配時是會進行回溯的,因為它不知道后面有沒有可能還會匹配成功,但是DFA從一開始就是確定的有限自動機,DFA是知道所有的匹配成功的情況,所以在使用NFA時,如果表達式寫的不注意,很可能出現大量回溯。這樣大量的回溯很可能造成在進行正則表達式的匹配時,CPU會飚高的情況。
解決方案
- 資源消耗很好解決:對于服務器來說,只需要在啟動服務器之前,對配置好的敏感詞做好正則表達式的初始化即可,即便是需要靈活配置,也可以通過動態加載再進行內存替換來解決。
- 要解決NFA回溯問題,也有很多方式:比如表達式中盡可能提取公共部分、適當拆分、不要量詞嵌套、使用非貪婪模式等多種優化手段。這些優化手段都是從表達式本身入手,這意味著所有人在編寫敏感詞匹配的正則表達式時,都需要時刻注意回溯陷阱,并且對每一個表達式都要做好性能測試。
如果注意好以上點,使用正則表達式進行敏感詞匹配在業務場景中也是可行的。甚至于對于復雜語義的敏感詞配置來說,只有正則表達式能實現需求
三、DFA
上文中其實已經提到,相比于NFA的不確定性,DFA是具有確定性的有限自動機。它之所以具有確定性,從結構上來說,它的每一個狀態都只對應一個狀態轉換,因此它也無需進行回溯,因此它的匹配性能也比NFA要高。
當然了DFA的缺點就是它很難處理復雜的語義。但是對于敏感詞來說,為了效率,我們其實可以把那些復雜的語義簡單化;另外一個和正則匹配一樣的點,就是構建DFA有向圖所帶來的開銷和內存占用,這一點也能通過服務器啟動加載和動態內存替換解決。
所以其實一旦我們解決掉DFA的痛點,便能揚長避短,既享受DFA高效率,又使其能勝任業務場景。
不過需要注意的是,這里我們就不再使用正則表達式進行敏感詞匹配了,而是直接實現一套基于DFA的敏感詞匹配算法。你可能會有疑問,既然正則表達式也可以使用DFA,那我們為什么不使用基于DFA的正則表達式呢?
這也很好理解,使用正則表達式,我們只能把每一條表達式單獨構建成一個個圖的數據結構,它的粒度只能到每一條表達式。而我們自己實現DFA,則可以把所有的敏感詞全部構建成同一個大的DFA圖,它維度則是全服所有敏感詞。這樣既可以省去一定的內存空間,也可以減少匹配次數。
使用原理
使用DFA來實現敏感詞匹配的原理,其實是在初始化時,把所有的敏感詞拆成一個個的字,然后組織成一個很大的有向圖的結構。其實也是用到編程思想中的空間換時間思想。比如有以下敏感詞:
- 打死你
- 打死他
- 打他
- 揍他
經過DFA的樹組織,最終會得到以下結構:
其中,綠色的Entry代表入口節點,而藍色的代表中止節點,當玩家輸入一句話時,會通過遍歷玩家發的每一個字,再去這個DFA有向圖中去匹配
如果玩家發送“我要揍他”,那么“揍他”兩個字就能通過“Entry->揍->他”這樣的路徑匹配上
如果玩家發送“我要揍你”,那么“揍”字能通過“Entry->揍”這樣的路徑匹配上,但因為“揍”不是中止節點,所以這句話不能算敏感詞
邏輯實現
1. DFA初始化
這一步作用是構建DFA圖
public boolean initialize(String[] keyWords) {
clear();
// 構造DFA
for (int s = 0; s < keyWords.length; s++) {
String _keyword = keyWords[s];
if (_keyword == null || (_keyword = _keyword.trim()).length() == 0) {
continue;
}
char[] patternTextArray = _keyword.toCharArray();
DFANode currentDFANode = dfaEntrance;
for (int i = 0; i < patternTextArray.length; i++) {
final char _c = patternTextArray[i];
// 逐點加入DFA
final Character _lc = toLowerCaseWithoutConfict(_c);
DFANode _next = currentDFANode.dfaTransition.get(_lc);
if (_next == null) {
_next = new DFANode();
currentDFANode.dfaTransition.put(_lc, _next);
}
currentDFANode = _next;
}
if (currentDFANode != dfaEntrance) {
currentDFANode.isTerminal = true;
}
}
buildFailNode();
return true;
}
2. DFA匹配檢測
匹配字檢測,一旦檢測到中止節點,則返回true
public boolean contain(final String inputMsg) {
char[] input = inputMsg.toCharArray();
DFANode currentDFANode = dfaEntrance;
DFANode _next = null;
for (int i = 0; i < input.length; i++) {
final Character _lc = this.toLowerCaseWithoutConfict(input[i]);
if (!isIgnore(_lc)) {
_next = currentDFANode.dfaTransition.get(_lc);
while (_next == null && currentDFANode != dfaEntrance) {
currentDFANode = currentDFANode.failNode;
_next = currentDFANode.dfaTransition.get(_lc);
}
}
if (_next != null) {
// 找到狀態轉移,可繼續
currentDFANode = _next;
}
// 看看當前狀態可退出否
if (currentDFANode.isTerminal) {
// 可退出,記錄,可以替換到這里
return true;
}
}
return false;
}
3. DFA字符替換
根據節點搜索匹配,走到中止節點則回溯依次替換
public String filt(String s) {
char[] input = s.toCharArray();
char[] result = s.toCharArray();
boolean _filted = false;
DFANode currentDFANode = dfaEntrance;
DFANode _next = null;
int replaceFrom = 0;
int ignoreLength = 0;
boolean endIgnore = false;
for (int i = 0; i < input.length; i++) {
final Character _lc = this.toLowerCaseWithoutConfict(input[i]);
_next = currentDFANode.dfaTransition.get(_lc);
while (_next == null && !isIgnore(_lc) && currentDFANode != dfaEntrance) {
currentDFANode = currentDFANode.failNode;
_next = currentDFANode.dfaTransition.get(_lc);
}
if (_next != null) {
// 找到狀態轉移,可繼續
currentDFANode = _next;
if(currentDFANode.level == 1) {
ignoreLength = 0;
}
}
if (!endIgnore && currentDFANode != dfaEntrance && isIgnore(_lc)) {
ignoreLength++;
}
// 看看當前狀態可退出否
if (currentDFANode.isTerminal) {
endIgnore = true;
// 可退出,記錄,可以替換到這里
int j = i - (currentDFANode.level - 1) - ignoreLength;
if (j < replaceFrom) {
j = replaceFrom;
}
replaceFrom = i + 1;
for (; j <= i; j++) {
result[j] = this.subChar;
_filted = true;
}
currentDFANode = dfaEntrance;
ignoreLength = 0;
endIgnore = false;
}
}
if (_filted) {
return String.valueOf(result);
} else {
return s;
}
}
怎么選擇
- 使用原生api進行遍歷匹配在數據達到一定量級時一定會有性能問題的,一般不采用這種方式。
- 使用正則表達式優勢在于靈活配置,但需注意回溯陷阱問題;正則表達式預編譯會占用一定內存空間。
- 使用DFA在簡單確定的語義中優勢明顯,但難以處理復雜語義;DFA初始化構建有向圖會占用內存空間,一般敏感詞數量是會達到二三十萬的量級的,有向圖大小會達到M級別,好在現在內存并不值錢,空間換時間是一個可取的辦法。
DFA應用場景
- 編譯器設計:DFA常用于詞法分析器,用于識別關鍵字、運算符、標識符等
- 字符串搜索和匹配:常用于字符串匹配算法,比如文本編輯器,敏感詞等
- 網絡安全檢測:DFA用快速識別惡意流量模式
- 自然語言(NPL)處理:用于文本分析和處理任務
- 正則表達式引擎:雖然很多正則表達式引擎基于非確定性有限自動機(NFA),但也有一些引擎或工具使用DFA來提高匹配效率,特別是在匹配簡單模式時
- 更多...
更多技術干貨,歡迎關注我!
總結
以上是生活随笔為你收集整理的构建健康游戏环境:DFA算法在敏感词过滤的应用的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 高三生如何做好两手准备?高考+国际本科两
- 下一篇: 显示所有中国城市需要多少个汉字?