場景:現在有一個錯詞庫,維護的是錯詞和正確詞對應關係。比如:錯詞“我門”對應的正確詞“我們”。然後在使用者輸入的文字進行錯詞校驗,需要判斷輸入的文字是否有錯詞,並找出錯詞以便提醒使用者,並且可以顯示出正確詞以便使用者確認,如果是錯詞就進行替換。
首先想到的就是取出錯詞List放在記憶體中,當使用者輸入完成後用錯詞List來foreach每個錯詞,然後查詢輸入的字串中是否包含錯詞。這是一種有效的方法,並且能夠實現。問題是錯詞的數量比較多,目前有10多萬條,將來也會不斷更新擴充套件。所以pass了這種方案,為了讓錯詞查詢提高速度就用了字典樹來儲存錯詞。
字典樹
Trie樹,即字典樹,又稱單詞查詢樹或鍵樹,是一種樹形結構,是一種雜湊樹的變種。典型應用是用於統計和排序大量的字串(但不僅限於字串),所以經常被搜尋引擎系統用於文字詞頻統計。它的優點是:最大限度地減少無謂的字串比較。
Trie的核心思想是空間換時間。利用字串的公共字首來降低查詢時間的開銷以達到提高效率的目的。
通常字典樹的查詢時間複雜度是O(logL),L是字串的長度。所以效率還是比較高的。而我們上面說的foreach迴圈則時間複雜度為O(n),根據時間複雜度來看,字典樹效率應該是可行方案。
字典樹原理
根節點不包含字元,除根節點外每一個節點都只包含一個字元; 從根節點到某一節點,路徑上經過的字元連線起來,為該節點對應的字串; 每個節點的所有子節點包含的字元都不相同。
比如現在有錯詞:“我門”、“旱睡”、“旱起”。那麼字典樹如下圖
其中紅色的點就表示詞結束節點,也就是從根節點往下連線成我們的詞。
實現字典樹:
1 public class Trie 2 { 3 private class Node 4 { 5 /// <summary> 6 /// 是否單詞根節點 7 /// </summary> 8 public bool isTail = false; 9 10 public Dictionary<char, Node> nextNode; 11 12 public Node(bool isTail) 13 { 14 this.isTail = isTail; 15 this.nextNode = new Dictionary<char, Node>(); 16 } 17 public Node() : this(false) 18 { 19 } 20 } 21 22 /// <summary> 23 /// 根節點 24 /// </summary> 25 private Node rootNode; 26 private int size; 27 private int maxLength; 28 29 public Trie() 30 { 31 this.rootNode = new Node(); 32 this.size = 0; 33 this.maxLength = 0; 34 } 35 36 /// <summary> 37 /// 字典樹中儲存的單詞的最大長度 38 /// </summary> 39 /// <returns></returns> 40 public int MaxLength() 41 { 42 return maxLength; 43 } 44 45 /// <summary> 46 /// 字典樹中儲存的單詞數量 47 /// </summary> 48 public int Size() 49 { 50 return size; 51 } 52 53 /// <summary> 54 /// 獲取字典樹中所有的詞 55 /// </summary> 56 public List<string> GetWordList() 57 { 58 return GetStrList(this.rootNode); 59 } 60 61 private List<string> GetStrList(Node node) 62 { 63 List<string> wordList = new List<string>(); 64 65 foreach (char nextChar in node.nextNode.Keys) 66 { 67 string firstWord = Convert.ToString(nextChar); 68 Node childNode = node.nextNode[nextChar]; 69 70 if (childNode == null || childNode.nextNode.Count == 0) 71 { 72 wordList.Add(firstWord); 73 } 74 else 75 { 76 77 if (childNode.isTail) 78 { 79 wordList.Add(firstWord); 80 } 81 82 List<string> subWordList = GetStrList(childNode); 83 foreach (string subWord in subWordList) 84 { 85 wordList.Add(firstWord + subWord); 86 } 87 } 88 } 89 90 return wordList; 91 } 92 93 /// <summary> 94 /// 向字典中新增新的單詞 95 /// </summary> 96 /// <param name="word"></param> 97 public void Add(string word) 98 { 99 //從根節點開始 100 Node cur = this.rootNode; 101 //迴圈遍歷單詞 102 foreach (char c in word.ToCharArray()) 103 { 104 //如果字典樹節點中沒有這個字母,則新增 105 if (!cur.nextNode.ContainsKey(c)) 106 { 107 cur.nextNode.Add(c, new Node()); 108 } 109 cur = cur.nextNode[c]; 110 } 111 cur.isTail = true; 112 113 if (word.Length > this.maxLength) 114 { 115 this.maxLength = word.Length; 116 } 117 size++; 118 } 119 120 /// <summary> 121 /// 查詢字典中某單詞是否存在 122 /// </summary> 123 /// <param name="word"></param> 124 /// <returns></returns> 125 public bool Contains(string word) 126 { 127 return Match(rootNode, word); 128 } 129 130 /// <summary> 131 /// 查詢匹配 132 /// </summary> 133 /// <param name="node"></param> 134 /// <param name="word"></param> 135 /// <returns></returns> 136 private bool Match(Node node, string word) 137 { 138 if (word.Length == 0) 139 { 140 if (node.isTail) 141 { 142 return true; 143 } 144 else 145 { 146 return false; 147 } 148 } 149 else 150 { 151 char firstChar = word.ElementAt(0); 152 if (!node.nextNode.ContainsKey(firstChar)) 153 { 154 return false; 155 } 156 else 157 { 158 Node childNode = node.nextNode[firstChar]; 159 return Match(childNode, word.Substring(1, word.Length - 1)); 160 } 161 } 162 } 163 }
測試下:
現在我們有了字典樹,然後就不能以字典樹來foreach,字典樹用於檢索。我們就以使用者輸入的字串為資料來源,去字典樹種查詢是否存在錯詞。因此需要對輸入字串進行取詞檢索。也就是分詞,分詞我們採用前向最大匹配。
前向最大匹配
我們分詞的目的是將輸入字串分成若干個詞語,前向最大匹配就是從前向後尋找在詞典中存在的詞。
例子:我們假設maxLength= 3,即假設單詞的最大長度為3。實際上我們應該以字典樹中的最大單詞長度,作為最大長度來分詞(上面我們的字典最大長度應該是2)。這樣效率更高,為了演示匹配過程就假設maxLength為3,這樣演示的更清楚。
用前向最大匹配來劃分“我們應該早睡早起” 這句話。因為我是錯詞匹配,所以這句話我改成“我門應該旱睡旱起”。
第一次:取子串 “我門應”,正向取詞,如果匹配失敗,每次去掉匹配欄位最後面的一個字。
“我門應”,掃描詞典中單詞,沒有匹配,子串長度減 1 變為“我門”。
“我門”,掃描詞典中的單詞,匹配成功,得到“我門”錯詞,輸入變為“應該旱”。
第二次:取子串“應該旱”
“應該旱”,掃描詞典中單詞,沒有匹配,子串長度減 1 變為“應該”。
“應該”,掃描詞典中的單詞,沒有匹配,輸入變為“應”。
“應”,掃描詞典中的單詞,沒有匹配,輸入變為“該旱睡”。
第三次:取子串“該旱睡”
“該旱睡”,掃描詞典中單詞,沒有匹配,子串長度減 1 變為“該旱”。
“該旱”,掃描詞典中的單詞,沒有匹配,輸入變為“該”。
“該”,掃描詞典中的單詞,沒有匹配,輸入變為“旱睡旱”。
第四次:取子串“旱睡旱”
“旱睡旱”,掃描詞典中單詞,沒有匹配,子串長度減 1 變為“旱睡”。
“旱睡”,掃描詞典中的單詞,匹配成功,得到“旱睡”錯詞,輸入變為“早起”。
以此類推,我們得到錯詞 我們/旱睡/旱起。
因為我是結合字典樹匹配錯詞所以一個字也可能是錯字,則匹配到單個字,如果只是分詞則上面的到一個字的時候就應該停止分詞了,直接字串長度減1。
這種匹配方式還有後向最大匹配以及雙向匹配,這個大家可以去了解下。
實現前向最大匹配,這裡後向最大匹配也可以一起實現。
1 public class ErrorWordMatch 2 { 3 private static ErrorWordMatch singleton = new ErrorWordMatch(); 4 private static Trie trie = new Trie(); 5 private ErrorWordMatch() 6 { 7 8 } 9 10 public static ErrorWordMatch Singleton() 11 { 12 return singleton; 13 } 14 15 public void LoadTrieData(List<string> errorWords) 16 { 17 foreach (var errorWord in errorWords) 18 { 19 trie.Add(errorWord); 20 } 21 } 22 23 /// <summary> 24 /// 最大 正向/逆向 匹配錯詞 25 /// </summary> 26 /// <param name="inputStr">需要匹配錯詞的字串</param> 27 /// <param name="leftToRight">true為從左到右分詞,false為從右到左分詞</param> 28 /// <returns>匹配到的錯詞</returns> 29 public List<string> MatchErrorWord(string inputStr, bool leftToRight) 30 { 31 if (string.IsNullOrWhiteSpace(inputStr)) 32 return null; 33 if (trie.Size() == 0) 34 { 35 throw new ArgumentException("字典樹沒有資料,請先呼叫 LoadTrieData 方法裝載字典樹"); 36 } 37 //取詞的最大長度 38 int maxLength = trie.MaxLength(); 39 //取詞的當前長度 40 int wordLength = maxLength; 41 //分詞操作中,處於字串中的當前位置 42 int position = 0; 43 //分詞操作中,已經處理的字串總長度 44 int segLength = 0; 45 //用於嘗試分詞的取詞字串 46 string word = ""; 47 48 //用於儲存正向分詞的字串陣列 49 List<string> segWords = new List<string>(); 50 //用於儲存逆向分詞的字串陣列 51 List<string> segWordsReverse = new List<string>(); 52 53 //開始分詞,迴圈以下操作,直到全部完成 54 while (segLength < inputStr.Length) 55 { 56 //如果剩餘沒分詞的字串長度<取詞的最大長度,則取詞長度等於剩餘未分詞長度 57 if ((inputStr.Length - segLength) < maxLength) 58 wordLength = inputStr.Length - segLength; 59 //否則,按最大長度處理 60 else 61 wordLength = maxLength; 62 63 //從左到右 和 從右到左擷取時,起始位置不同 64 //剛開始,擷取位置是字串兩頭,隨著不斷迴圈分詞,擷取位置會不斷推進 65 if (leftToRight) 66 position = segLength; 67 else 68 position = inputStr.Length - segLength - wordLength; 69 70 //按照指定長度,從字串擷取一個詞 71 word = inputStr.Substring(position, wordLength); 72 73 74 //在字典中查詢,是否存在這樣一個詞 75 //如果不包含,就減少一個字元,再次在字典中查詢 76 //如此迴圈,直到只剩下一個字為止 77 while (!trie.Contains(word)) 78 { 79 //如果最後一個字都沒有匹配,則把word設定為空,用來表示沒有匹配項(如果是分詞直接break) 80 if (word.Length == 1) 81 { 82 word = null; 83 break; 84 } 85 86 //把擷取的字串,最邊上的一個字去掉 87 //從左到右 和 從右到左時,截掉的字元的位置不同 88 if (leftToRight) 89 word = word.Substring(0, word.Length - 1); 90 else 91 word = word.Substring(1); 92 } 93 94 //將分出匹配上的詞,加入到分詞字串陣列中,正向和逆向不同 95 if (word != null) 96 { 97 if (leftToRight) 98 segWords.Add(word); 99 else 100 segWordsReverse.Add(word); 101 //已經完成分詞的字串長度,要相應增加 102 segLength += word.Length; 103 } 104 else 105 { 106 //沒匹配上的則+1,丟掉一個字(如果是分詞 則不用判斷word是否為空,單個字也返回) 107 segLength += 1; 108 } 109 } 110 111 //如果是逆向分詞,對分詞結果反轉排序 112 if (!leftToRight) 113 { 114 for (int i = segWordsReverse.Count - 1; i >= 0; i--) 115 { 116 //將反轉的結果,儲存在正向分詞陣列中 以便最後return 同一個變數segWords 117 segWords.Add(segWordsReverse[i]); 118 } 119 } 120 121 return segWords; 122 } 123 }
這裡使用了單例模式用來在專案中共用,在第一次裝入了字典樹後就可以在其他地方匹配錯詞使用了。
這個是結合我具體使用,簡化了些程式碼,如果只是分詞的話就是分詞那個實現方法就行了。最後分享就到這裡吧,如有不對之處,請加以指正。