雙數組Trie樹 (Double-array Trie) 及其應用


雙數組Trie樹(Double-array Trie, DAT)是由三個日本人提出的一種Trie樹的高效實現 [1],兼顧了查詢效率與空間存儲。Ansj便是用DAT(雖然作者宣稱是三數組Trie樹,但本質上還是DAT)構造詞典用作初次分詞,極大地節省了內存占用。本文將簡要地介紹DAT,並實現了基於DAT的前向最大匹配的中文分詞算法。

1. Trie樹

兩種實現

Trie樹(也稱為字典樹、前綴樹)是一種常被用於詞檢索的樹結構,其思想非常簡單:利用詞的共同前綴以達到節省空間的目的;基本的實現有array與linked-list兩種。array實現需要為每一個字符開辟一個字母表大小的數組:

上圖給出四個單詞bachelor, baby, badge, jar的Trie樹array實現示例圖;對應的Java代碼如下:

class TrieNode {
  public Character value;
  public TrieNode[] next = new TrieNode[65536]; // 65536 = 2^16
}

雖然,array的查詢時間復雜度為\(O(1)\);但是,從圖中可以看出,存在着大量的空間浪費。當然,有人會想到用HashMap來代替數組,以減少空間浪費:

class TrieNode {
  public Character value;
  public Map<Character, TrieNode> next = new HashMap<Character, TrieNode>();
}

mmseg4j便是以此來實現Trie樹的。但是,HashMap本質上就是一個hash table;存在着一定程度上的空間浪費。由此,容易想到用linked-list實現Trie樹:

雖然linked-list避免了空間浪費,卻增加了查詢時間復雜度,因為公共前綴就意味着多次回溯。

Double-array實現

Double-array結合了array查詢效率高、list節省空間的優點,具體是通過兩個數組basecheck來實現。Trie樹可以等同於一個自動機,狀態為樹節點的編號,邊為字符;那么goto函數\(g(r,c) = s\)則表示狀態r可以按字符c轉移到狀態s。base數組便是goto函數array實現,check數組為驗證轉移的有效性;兩個數組滿足如下轉移方程

base[r] + c = s
check[s] = r

值得指出的是,代入上述式子中的c為該字符的整數編碼值。那么,bachelor, baby, badge, jar的DAT如下圖所示:

其中,字符的編碼表為{'#'=1, 'a'=2, 'b'=3, 'c'=4, etc. }。為了對Trie做進一步的壓縮,用tail數組存儲無公共前綴的尾字符串,且滿足如下的特點:

tail of string [b1..bh] has no common prefix and the corresponding state is m:
    base[m] < 0;
    p = -base[m], tail[p] = b1, tail[p+1] = b2, ..., tail[p+h-1] = bh;

那么,用DAT檢索詞badge的過程如下:

// root -> b
base[1] + 'b' = 4 + 3 = 7
// root -> b -> a
base[7] + 'a' = 1 + 2 = 3
// root -> b -> a -> d
base[3] + 'd' = 1 + 5 = 6
// badge#
base[6] = -12
tail[12..14] = 'ge#'

至於如何構造數組base、check,可參考原論文 [1]及文章 [2].

2. DAT應用

以下代碼分析基於ansj-5.1.1 版本。

詞典

Ansjcore.dic給出中文詞典的DAT實現:

249952
37	%	65536	-1	3	{q=1}
39	'	65536	-1	4	{en=1}
46	.	65536	-1	5	{nb=1}
...
21360	印	92338	-1	2	{j=24, n=1, ng=2, nr=0, v=32}
24230	度	89338	-1	2	{k=0, ng=2, q=28, v=7, vg=2}
27827	河	142597	-1	2	{n=29, q=0}
...
116568	印度	71557	21360	2	{ns=51}
99384	印度河	65536	116568	3	{ns=0}
116553	振臂一	94926	129740	1	null
116566	捅婁子	65536	116571	3	{v=0}
65333	U	65536	-1	4	{en=1}
...

詞典共有6列,分別為

index	name	base	check	status	{詞性->詞頻}  

其中,index表示字符串的id(若為單字符,則為其unicode編碼對應的整數值),name為詞,base、check分別為DAT的base數組、check數組,status記錄當前詞的狀態,最后一列表示詞性集合,對應於類org.ansj.domain.AnsjItem中的成員變量termNatures。那么,根據DAT的轉移方程則有

index['印度'] = 116568 = base['印'] + index['度'] = 92338 + 24230
check['印度'] = 21360 = index['印']
index['印度河'] = 99384 = base['印度'] + index['河'] = 71557 + 27827
check['印度河'] = 116568 = index['印度']

此外,status的數值具有如下含義:

  • 1對應的詞性為null,name不能單獨成詞,應繼續,比如“振臂一”;
  • 2表示name既可單獨成詞,也可與其他字符組成新詞,比如詞“印度”;
  • 3表示詞結束,name成詞不再繼續,比如詞“捅婁子”;
  • 4表示英文字母(包括全角)+字符',共計105(26*4+1)個字符;
  • 5表示數字(包括全角)+小數點,共有21(10*2+1)個字符.

分詞

正向最大匹配(Forward Maximum Matching, FMM)的分詞思路非常簡單:正向匹配詞典中的詞,取最長匹配者。Scala 2.11 實現FMM如下:

import org.ansj.library.DATDictionary
import scala.collection.mutable.ArrayBuffer

// max-matching algorithm for CWS
def maxMatching(sentence: String): Array[String] = {
  val segmented = ArrayBuffer.empty[String]
  val chars = sentence.toCharArray
  var i = 0
  while (i < chars.length) {
    DATDictionary.status(chars(i)) match {
      // not in core.dic or word-end or last char
      case t if t == 0 || t == 3 || i == chars.length - 1 =>
        i = singleCharWord(chars, i, segmented)
      // word-start
      case t if t == 1 || t == 2 =>
        i = goOnWord(chars, i, segmented)
      // English character or number
      case _ =>
        i = goOnEnNum(chars, i, segmented)
    }
  }
  segmented.toArray
}

// a single character segment
private def singleCharWord(chars: Array[Char], start: Int, arr: ArrayBuffer[String]): Int = {
  arr += chars(start).toString
  start + 1
}

// word segment which is in core.dic
private def goOnWord(chars: Array[Char], start: Int, arr: ArrayBuffer[String]): Int = {
  var nextIndex: Int = chars(start).toInt
  for (j <- start + 1 until chars.length) {
    val preIndex = nextIndex
    nextIndex = DATDictionary.getItem(nextIndex).getBase + chars(j).toInt
    if (DATDictionary.getItem(nextIndex).getCheck != preIndex) {
      arr += chars.subSequence(start, j).toString
      return j
    }
  }
  chars.length
}

// English chars and numbers compose a word
private def goOnEnNum(chars: Array[Char], start: Int, arr: ArrayBuffer[String]): Int = {
  for (j <- start + 1 until chars.length) {
    val status = DATDictionary.status(chars(j))
    if (status != 4 && status != 5) {
      arr += chars.subSequence(start, j).toString
      return j
    }
  }
  chars.length
}

函數goOnWord用到了DAT的轉移方程。直觀感受下FMM的分詞效果:

val sentence = "非農一觸即發,現貨原油撲朔迷離,倫敦金回暖已定"
println(maxMatching(sentence).mkString("/"))
// 非農/一觸即發/,/現貨/原油/撲朔迷離/,/倫敦/金/回暖/已/定

我實現了一個DAT生成算法,扔在中文分詞項目thulac4j

3. 參考資料

[1] Aoe, J. I., Morimoto, K., & Sato, T. (1992). An efficient implementation of trie structures. Software: Practice and Experience, 22(9), 695-721.
[2] Theppitak Karoonboonyanan, An Implementation of Double-Array Trie.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM