C#實現前向最大匹、字典樹(分詞、檢索)

Spring2Sun發表於2020-05-15

  場景:現在有一個錯詞庫,維護的是錯詞和正確詞對應關係。比如:錯詞“我門”對應的正確詞“我們”。然後在使用者輸入的文字進行錯詞校驗,需要判斷輸入的文字是否有錯詞,並找出錯詞以便提醒使用者,並且可以顯示出正確詞以便使用者確認,如果是錯詞就進行替換。

  首先想到的就是取出錯詞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     }

  這裡使用了單例模式用來在專案中共用,在第一次裝入了字典樹後就可以在其他地方匹配錯詞使用了。

  這個是結合我具體使用,簡化了些程式碼,如果只是分詞的話就是分詞那個實現方法就行了。最後分享就到這裡吧,如有不對之處,請加以指正。

相關文章