把自動機用做 Key-Value 存儲

之前只有代碼,最近簡單寫了一點文檔: google code 上的連接(老是最新)正則表達式


本文只關係到 有窮狀態自動機 ,本文也不講具體的算法,只講一些基本概念,以及用本軟件的自動機 能作什麼 , 怎麼用算法

也有其餘一些開源軟件實現了本軟件的部分功能,但整體上,不管是從功能仍是性能(運行速度,內存用量)上考慮,到目前爲止,我能找到的其它全部同類開源軟件都徹底沒法與本軟件競爭!歡迎你們一塊兒交流!api

自動機是什麼

關於自動機的形式化定義,能夠參考 wikipedia:數組

DFA 的最小化

  • DFA 的等同
    • 若是兩個dfa的狀態轉移圖同構,那麼這兩個 DFA 等同
  • DFA 的等價
    • 若是兩個 DFA 能接受的語言集合相同,那麼這兩個 DFA 等價
    • 等價的 DFA 不必定等同
  • 最小化的 DFA
    • 對於任何一個 DFA,存在惟一一個與該 DFA 等價的 MinDFA,該 MinDFA 的狀態數是與原 DFA 等價的全部 DFA 中狀態數最小的
    • 最小化的 DFA 須要的內存更小
    • 各類優化的 DFA 最小化算法是本軟件的核心競爭力之一

將 DFA 用作字典

什麼是字典

字典,有時候也叫關聯數組,能夠認爲就是一個 map<string, Value>,這是最簡單直接的表達,在 C++ 標準庫中,map是用 RBTree 實現的,固然,也能夠用 hash_map(或稱爲 unordered_map)。這些字典在標準庫中都有,不是特別追求cpu和內存效率的話,能夠直接拿來時使用。svn

可是,要知道,對於通常應用,將字典文件(假定是文本文件)加載到 map/hash_map 以後,內存佔用量要比字典文件大兩三倍。當數據源很大時,是不可接受的,雖然在如今這年代,幾G可能都算不上很大,可是,若是再乘以3,可能就是十幾二十G了,姑且不論數據加載產生的載入延遲(可能得幾十分鐘甚至一兩個小時)。函數

用 DFA 存儲字典,在不少專門的領域中是一個標準作法,例如不少分詞庫都用 DoubleArray Trie DFA 存儲詞庫,語音識別軟件通常也用 DFA 來存儲語音。性能

無環DFA (ADFA, Acyclic DFA)

用作字典的 DFA 是無環DFA (ADFA, Acyclic DFA),ADFA 的狀態轉移圖是 DAG(有向無環圖)。Trie 是一種最簡單的 ADFA,同時也是(全部ADFA等價類中)最大的 ADFA。DoubleArray Trie 雖然廣爲人知,但相比 MinADFA,內存消耗要大得多。測試

ADFA 可接受的語言是有限集合,從喬姆斯基語言分類的角度,是4型語言(General的NFA和DFA是3型語言)。固然,有限,只是有限而已,但這個集合可能很是大,一個很小的ADFA也能表達很是大的字符串集合,用正則表達式舉例: [a-z]{9} ,這個正則表達式對應的DFA包含10個狀態,除終止狀態外,其餘每一個狀態都有26個轉移(圖的邊),這個語言集合的大小是 269 = 5,429,503,678,976:從 aaaaaaaaa 一直到zzzzzzzzz。想象一下,用 HashMap 或者 TreeMap,或者 DoubleArray Trie 來表達該集合的話,將是多麼恐怖!優化

編譯

目前,要編譯 febird 中的自動機庫,須要 C++11 編譯器,推薦 gcc4.7 以上版本

$ svn checkout http://febird.googlecode.com/svn/trunk/ febird-read-only
$ cd febird-read-only
$ make
$
$ cd tools/automata/
$ make
$ ll rls/*.exe # 後面會講到詳細用法
-rwxrwxrwx 1 root root 27520456  8月 17 14:34 rls/adfa_build.exe*
-rwxrwxrwx 1 root root 12265256  8月 17 14:34 rls/adfa_unzip.exe*
-rwxrwxrwx 1 root root 16994384  8月 17 14:34 rls/dawg_build.exe*
$
$ cd netbeans-cygwin/automata/
$ make
$ ll rls/*/*.exe # 這兩個 exe 用來測試自動機生成的 (key,val) 二進制數據文件
-rwxrwxr-x 1 user user  9050159 2013-08-08 16:26 rls/forward_match/on_key_value.exe
-rwxrwxr-x 1 user user  8994311 2013-08-08 16:26 rls/forward_match/on_suffix_of.exe

map 與 set

傳統上,ADFA 只能用做 set<Key> ,也就是字符串的集合。可是,本軟件能夠把 ADFA 用做 map<Key, Value>,經過兩種方式能夠達到這個目標:ui

  • map1: 擴展 ADFA(從而 DFA 的尺寸會大一點),查找 key 時,同時計算出一個整數 index,該 index 取值範圍是 [0, n),n 是 map.size()。從而,應用程序能夠在外部存儲一個大小爲 n 的數組,用該 index 去數組直接訪問 value。
    • 本軟件中有一個 utility 類用來簡化這個流程
    • 本質上,這種方法沒法動態插入 (key,val);但能夠追加 (key,val),追加的意思是說,前一個加入的 key,按字典序必須小於後一個加入的 key
  • map2: 將 Value 編碼成 string 形式,而後再生成一個新的 string kv = key + '\t' + value,將 kv 加入 ADFA,在這種狀況下,同一個 key 能夠有多個 value,至關於 std::multimap<string, Value>,這種方法的妙處在於,若是多個key對應的value相同,這些value就被自動機壓縮成一份了!
    • 這種方法能夠動態插入、刪除 (key,val),不過,要支持動態插入、刪除功能,須要大約4~5倍的額外內存
    • 更進一步,這種方法能夠擴展到容許 key 是一個正則表達式!(目前還不支持)

內存用量/查詢性能

本軟件實現了兩種 DFA,一種爲運行速度優化,另外一種爲內存用量優化,前者通常比後者快4~6倍,後者通常比前者節省內存30~40%,具體使用哪種,由使用者作權衡決策。

不一樣的數據,DFA有不一樣的壓縮率。 對於典型的應用,爲內存優化的DFA,壓縮率通常在3倍到20之間,相比RBTree/HashMap的膨脹3倍,內存節省就有9倍到60倍!同時仍然能夠保持極高的查詢速度(keylen=16字節,QPS 在 40萬到60萬之間),爲速度優化的版本,QPS 有 250萬。下面是幾個性能數據(map指map1,爲了對齊,在一些數字前面補了0):

  size(bytes) gzip DFA(small+slow) DFA(big+fast) KeyLen QPS(big+fast) DFA Build time
File1(Query) 226,433,393 96,293,588 map:101,125,415
set:073,122,182
170,139,298 016.7 24,000,000 47'seconds
File2(URL) 485,968,345 25,094,568 map:13,990,737
set:10,850,052
035,548,376 109.2 00,900,000 16'seconds

URL文件的冗餘比較大,雖然文件尺寸大一倍多,但最終的dfa文件卻要小得多,建立dfa用的時間也少得多!dfa文件的加載速度很是快,至關於整個文件讀取,若是文件已在緩衝區,則至關於memcpy (如今已支持mmap,因此連memcpy也省了, 2014-01-12

警告: 若是key中包含有隨機串,例如 guid、md5 等,會大大增長自動機的內存用量!不要自做聰明把天然的串轉化成 md5 等 hash 串再插入自動機!

自動機實用程序

本軟件包含幾個程序,用來從文本文件生成自動機,生成的自動機能夠用C++接口訪問,這樣,就將自動機的存儲與業務邏輯徹底分離。

  • adfa_build.exe options < input_text_file
輸入文件的格式是:  key \t value,  \t 是 key, value 的分隔符, \t 也能夠是其它字符,只要該分隔符不在 key 中出現便可,value 中則能夠包含分隔符。該程序本質上無論每行的的內容是什麼,只是忠實地將每行文本加入自動機。分隔符的做用體如今後面將要提到的 api:  DFA_Interface::match_key 中。
||
options arguments comments
-o 輸出文件:爲 尺寸 優化的自動機 匹配速度較慢,尺寸較小,因此是小寫o
-O 輸出文件:爲 速度 優化的自動機 匹配速度很快,尺寸較大,因此是大寫O
-l 狀態字節:爲 尺寸 優化的自動機,可取值 4,5,6,7 自動機的每一個狀態佔幾個字節,越大的數字表示自動機能支持的最大狀態數也越大,

通常5就能夠知足大多數需求了
-b 狀態字節:爲 速度 優化的自動機,可取值 4,5,6,7
-t 無參數 輸出文本,僅用於測試
-c 無參數 檢查自動機正確性,僅用於測試

  • dawg_build.exe options < input_text_file
生成擴展的 DFA,能夠計算 key 的 index 號(字典序號),對應 map 的第一種實現方式(map1),使用方法同  adfa_build.exe ,輸入文件的每行是一個 Key。
  • adfa_unzip.exe < dfa_binary_file
解壓 dfa_binary_file,按字典序將建立自動計時的輸入文件  input_text_file 的每行寫到標準輸出 stdout ,能夠接受基本 dfa(由 adfa_build.exe 生成的)文件 和擴展dfa(由 dawg_build.exe 生成的)文件
  • on_suffix_of.exe P1 P2 ... < dfa_binary_file
打印全部前綴爲 P n 的行 (adfa_build.exe 或 dawg_build.exe 輸入文本的行) 的後綴
  • on_key_value.exe text1 text2 ... < dfa_binary_file
打印匹配全部 text n 的前綴的 Key (adfa_build.exe 或 dawg_build.exe 輸入文本的行) 的 value, 用於測試 map 實現方法2 (Key Value 之間加分隔符)

自動機的 C++接口

本軟件使用了 C++11 中的新特性,因此必須使用支持 C++11 的編譯器,目前測試過的編譯器有 gcc4.7 和 clang3.1。不過爲了兼容,我提供了C++98 的接口,一旦編譯出了靜態庫/動態庫,C++11 就再也不是必需的了。

#include <febird/automata/dfa_interface.hpp>

automata/dfa_interface.hpp

頭文件 febird/automata/dfa_interface.hpp 中主要包含如下 class:

DFA_Interface

這個類是最主要的 DFA 接口,對於應用程序,老是從 DFA_Interface::load_from(file) 加載一個自動機(adfa_build.exe 或 dawg_build.exe 生成的自動機文件),而後調用各類查找方法/成員函數。

  • size_t for_each_suffix(prefix, on_suffix[, tr])
    • 該方法接受一個字符串prefix,若是prefix是自動機中某些字符串的前綴,則經過 on_suffix(nth,suffix) 回調,告訴應用程序,前綴是prefix的那些字符串的後綴(去除prefix以後的剩餘部分),nth 是後綴集合中字符串的字典序。 tr 是一個可選參數,用來轉換字符,例如所有轉小寫,將 ::tolower 傳做 tr 便可
    • 例如:對字符串集合 {com,comp,comparable,comparation,compare,compile,compiler,computer}, prefix=com 能匹配全部字符串(其中nth=0的後綴是空串),prefix=comp能匹配除com以外的全部其它字符串,此時nth=0的也是空串,而 compare 的後綴 are 對應的 nth=1
    • 返回值是後綴集合的尺寸,通常狀況下沒什麼用處,能夠忽略
  • size_t match_key(delim,str,on_match[, tr])
    • 該方法用於實現 map2,delim 是 key,val 之間的分隔符(如 '\t' ),key中不可包含delimstr 是掃描的文本,若是在掃描過程當中,發現 str 的長度爲 Kn 的前綴 P 匹配某個 key,就將該 key 對應的全部 value 經過 on_match(Kn,idx, value) 回調告訴調用方, idx是同一個key對應的value集合中當前value的字典序。
    • 返回值是最長的 部分匹配 的長度,通常狀況下沒什麼用處,能夠忽略

DAWG_Interface

這個類用來實現 map1,DAWG 的全稱是 Directed Acyclic Word Graph,能夠在 ADFA 的基礎上,在匹配的同時,計算一個字符串在 ADFA 中的字典序號(若是存在的話),同時,也能夠從字典序號計算出相應的字符串,時間複雜度都是O(strlen(word))。

  • size_t index(string word)
    • 返回 word 的字典序,若是不存在,返回 size_t(-1)
  • string nth_word(size_t nth)
    • 從字典序 nth 計算對應的 word,若是 nth 在 [0, n) 以內,必定能獲得相應的 word,若是 nth 不在 [0, n) 以內,會拋出異常
  • size_t v_match_words(string, on_match[, tr])
    • 依次對 string 的全部前綴計算 index,並經過 on_match(prelen,nth) 回調返回計算結果,prelen是匹配的前綴長度,該函數也有可選的 tr 參數
    • 返回值是最長的 部分匹配 的長度,通常狀況下沒什麼用處,能夠忽略
  • size_t longest_prefix(string, size_t*len, size_t*nth[, tr])
    • 至關於 v_match_words 的特化版,只返回最長的那個 prefix
    • 返回值是最長的 部分匹配 的長度,通常狀況下沒什麼用處,能夠忽略

API使用方法的示例程序

對應於svn代碼目錄: febird-read-only/netbeans-cygwin/automata/samples

超級功能

以拼音輸入法爲例

爲了保證輸入效率,咱們須要有一個從 詞條拼音 到 詞條漢字 的映射表,好比,拼音序列 ZiDongJi 對應的詞條是 自動機 , 自凍雞 ;從而,邏輯上講,這是一個 map<string,list<string> >

假定咱們有一個漢語詞表,該詞表的詞條超過千萬,每一個詞條多是一句話(好比名言警句),而且,由於漢語中存在多音字,從而,包含多個多音字的詞條均可能有不少種發音,這個數目在最壞狀況下是指數級的,第一個字有 X1 個讀音,第二個字有 X2 個讀音,...,n 個字的詞條就有 X1*X2*X3*...*Xn 種讀音。另外,除了多音字,還有簡拼,對於短詞來講,簡拼的重碼率很高,不實用,可是對於長詞/句,簡拼的重碼率就很低了。

若是咱們用 HashMap/std::unordered_map 或 TreeMap/std::map 保存這個註音詞典,對於普通無多音字的詞條,無任何問題。一旦有多音字, X1*X2*X3*...*Xn 多是一個很是大的數字,幾十,幾百,幾千,幾千萬都有可能,這徹底是不可接受的!一個折衷的辦法就是僅選擇機率最大的拼音,但很惋惜,有些狀況下這個拼音多是錯的!

用自動機解決該問題,最簡單的方法就是用 string kv = X1*X2*X3*...*Xn + '\t' + 漢字詞條 來逐個插入,動態 MinADFA 算法能夠保證內存用量不會組合爆炸,可是,除了內存,還有時間,如此逐個展開,時間複雜度也是指數的!

這個問題我想了好久,終於有一天,想出了一個完美的解決辦法:

  1. 在 MinADFA 中加入一個功能:在線性時間內,給一個惟一的前綴,追加一個 ADFA後綴
    • 該 ADFA後綴 能夠包含 X1*X2*X3*...*Xn 個 word,而且結構還能夠任意複雜(最近個人研究發現,該後綴甚至沒必要是ADFA,能夠包含環!)
    • 這個算法是整個問題的關鍵,餘下的,就只是簡單的技巧而已
  2. 將 string kv = X1*X2*X3*...*Xn + '\t' + 漢字詞條 翻轉
    • rev(X1*X2*X3*...*Xn) 構成一個 ADFA
    • rev('\t'+漢字詞條) 是一個惟一前綴
  3. 將全部的詞條作這樣的處理,就構成一個 DFA({rev(拼音+'\t'+漢字)})
  4. 將該 DFA 翻轉,獲得 NFA(rev(DFA({rev(拼音+'\t'+漢字)})),再將該 NFA 轉化成 DFA
  5. 查找時,使用 map2 的方法(DFA_Interface::match_key),由於組合爆炸,不能用 map1

這個方法很是完美!雖然 NFA 轉化 DFA 最差狀況下是 NSpace(比NP還難) 的,可是在這裏,能夠證實,這個轉化是線性的:O(n)。

相關文章
相關標籤/搜索