敏感詞匹配演算法記錄
記錄做敏感匹配演算法的過程。
介紹
敏感詞遮蔽是很多內容網站都需要做的事情,而根據公安提供的敏感詞列表,具體格式如下:

從上圖可以看出,敏感詞分為三類: 動詞 、 名詞 、 專屬詞語 ,三種敏感詞匹配的方式也有些不同。
專屬詞語是隻要出現就需要遮蔽,例如:今天 中午
我不知道吃什麼了。如果 中午
是一個專屬敏感詞的話,那麼這段話中的中午就需要被遮蔽掉了。
動詞和 名詞 是需要組合才能進行匹配的,並且同一分類下的動名詞都可以進行組合,如前面的圖中就能組合出: 動詞1名詞1
、 動詞1名詞2
、 動詞2名詞1
、 動詞2名詞2
....,匹配的方式則是組合起來之後和專屬敏感詞一致,而組合之後的敏感詞個數則是:動詞個數 V
* 名詞個數 N
( V * N
)。
說到這裡,可能很快就會得出一個解決方法。
#1
由於動名詞最後的匹配方式是將動詞和名詞組合起來再進行匹配的,那麼我們可以將所以分類的動名詞組合起來,然後放入快取中,這樣就能大大節省在匹配敏感詞的過程中進行重複組合動名詞的開銷。而根據公安提供的詞庫,最終得到的敏感詞個數為: 40k+
,其中專屬詞: 5.3k
,動名詞組合: 40k
,那麼此時的敏感詞列表格式如下:
['專屬詞1','專屬詞2','專屬詞3'....,'動名詞組合1','動名詞組合2','動名詞組合3'....]
匹配演算法如下:
public List<string> MatchingSensitive(List<string> senlist, string txt) { var returnlist = new List<string>(); foreach(var item in senlist) { if(txt.IndexOf(item)) { returnlist.Add(item); } } return returnlist; }
以上這種方式雖然很簡單的就能匹配出銘感詞,但是效能極差,即使我們已經將所有的動名詞組合放入快取中,省去了一部分的計算開銷,但是敏感詞的陣列大小卻依然是 40k+
的大小,也就意味著每次都需要迴圈 40k+
次才能校驗完成。並且以上程式碼使用了 IndexOf
預設方法,效能遠不如 Contains
,具體原因可以去看看 IndexOf
和 Contains
的原始碼,所以我們需要把上面的程式碼改為:
public List<string> MatchingSensitive(List<string> senlist, string txt) { var returnlist = new List<string>(); foreach(var item in senlist) { if(txt.Contains(item)) { returnlist.Add(item); } } return returnlist; }
現在這種方式雖然在效能上有提升,但是時間複雜度依然沒有降低。我們再回過來仔細看這張圖

我們將所有的動名詞進行組合的時候,即是對所有的敏感詞進行了全量的匹配,但是真的需要這麼做嗎?如果將匹配的拆分為原來的動名詞的話,匹配的過程如下圖:

按照我們之前的全量匹配, B4
會和 A1
、 A2
、 A3
、 A4
... D4
都進行匹配,但是在拆分動名詞的情況下, B4
沒有包含 A
,視乎根本沒有必要再和 A1
進行匹配,因為 A
包含於 A1
、 A2
、 A3
...,若 B4
不包含 A
,即 B4
也不包含 A1
、 A2
、 A3
...,同理 B4
若不包含 C
,也就不會包含 C1
、 C2
、 C3
...。那麼這樣的話,匹配過程如下圖:

#2
根據上面的結論,如果匹配內容不包含動詞,那麼就無需匹配當前動詞和名詞組合的敏感詞,所以快取的銘感詞列表資料結構需要更改為如下:
[ { "CategoryName":"分類1", "SensitiveList":["專屬敏感詞1","專屬敏感詞2"...], "VerbList": [ { "Word":"動詞1", "CombList":["動詞1名詞1","動詞1名詞2","動詞1名詞3"...] }, { "Word":"動詞2", "CombList":["動詞2名詞1","動詞2名詞2","動詞2名詞3"...] } ] }, { "CategoryName":"分類2", "SensitiveList":["專屬敏感詞1","專屬敏感詞2"...], "VerbList": [ { "Word":"動詞1", "CombList":["動詞1名詞1","動詞1名詞2","動詞1名詞3"...] }, { "Word":"動詞2", "CombList":["動詞2名詞1","動詞2名詞2","動詞2名詞3"...] } ] } ]
CategoryName
分類名稱
SensitiveList
專屬敏感詞
VerbList
動詞列表
CombList
動名詞組合列表
程式碼如下
public class SenEntity { public string CategoryName { get; set; } public List<string> SensitiveList { get; set; } public List<SenVerbEntity> VerbList { get; set; } } public class SenVerbEntity { public string Word { get; set; } public List<string> CombList { get; set; } } public List<string> MatchingSensitive(List<SenEntity> list, string txt) { List<string> senlist = new List<string>(); foreach(var sen in list) { //專屬敏感詞匹配 foreach (var senstr in sen.SensitiveList) { if (txt.Contains(senstr)) { senlist.Add(senstr); } } //動詞匹配 foreach (var verb in sen.VerbList) { // 如果匹配的內容中包含了動詞 if (txt.Contains(verb.Word)) { //進行下一步的動名詞組合匹配 for (int i = 0; i < verb.CombList.Count; i++) { var combstr = verb.CombList[i]; //如果匹配存在動名組合詞 if (txt.Contains(combstr)) { //新增動詞 senlist.Add(verb.Word); //新增名詞 senlist.Add(combstr.Replace(verb.Word,"")); } } } } } return senlist; }
這樣一來,遍歷的陣列長度將大大減少,時間複雜度也得到了降低,但是這就是最好的辦法了嗎?
我們來看一下現在匹配過程:

當前的資料結構由於對敏感詞進行了分類,所以在匹配的時候最多會出現三層迴圈,並且其中不同的分類中間可能存在著相同的 動詞
,這些資料的結構是冗餘的。
#3
為了保證敏感詞只匹配一次,並減少迴圈的複雜度。我們可以將資料結構改為如下:
專屬敏感詞,字典儲存,key為專屬敏感詞
{ "專屬敏感詞1" : "", "專屬敏感詞1" : "", .... }
動詞和動名詞組合,字典儲存,key為動詞
{ "動詞1" : ["動詞1名詞1","動詞1名詞2",,"動詞1名詞2"...], "動詞1" : ["動詞1名詞1","動詞1名詞2",,"動詞1名詞2"...], }
原本的陣列結構都改為了字典,使用字典可以保證 專屬敏感詞
或者 動詞
不會因為在不同的分類中出現重複,這樣可以簡化資料的結構,並且使用兩個字典來儲存 專屬敏感詞
和 動名詞
,將可以將匹配的迴圈縮小到兩層,降低匹配過程中的時間複雜度。
匹配的程式碼如下:
public List<string> MatchingSensitive(Dictionary<string,string> sensitiveDic, Dictionary<string,List<string>> verbDic, string txt) { List<string> senlist = new List<string>(); //專屬敏感詞匹配 foreach(var sen in sensitiveDic.Keys()) { if (txt.Contains(sen)) { senlist.Add(sen); } } //動詞匹配 foreach (var verb in verbDic.Keys()) { // 如果匹配的內容中包含了動詞 if (txt.Contains(verb)) { var combList = verbDic[verb]; foreach(var comb in combList) { //動名詞組合匹配 if (txt.Contains(comb)) { senlist.Add(comb); } } } } return senlist; }
匹配的過程如下圖:

以上就是對敏感詞匹配過程的理解,啟發於 將低時間複雜度
這一詞,如有更好的方法,歡迎在下面留言。