把自动机用做 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)。

相关文章
相关标签/搜索