文本模糊匹配,例如搜索引擎Lucene 从4.0版本开始就使用了LevenshteinAutomaton对用户输入的搜索字符进行模糊查询
计算用户输入字符串和索引字符串之间的编辑距离, 最常用的是Levenshtein距离。基于Levenshtein距离的模糊匹配允许用户即使输入的查询字符串与匹配的目标字符串存在n个字符的插入/删除/替换 差异, 匹配仍然能够成功。 例如, 索引字符串 'ab' 的匹配Levenshtein距离为1的全部DFA状态转移图如下 , 其中深色表示accepting state
实际使用的时候不一会构建一个这样复杂的匹配所有全部可能状态的DFA,这里为举例说明
Levenshtein距离公式
表示字符串a的前i个字符与字符串b的前j个字符的Levenshtein距离, min函数对应的第一项表示从a[i] 删除最后一个字符时的情况, 第二项表示从插入一个字符的情况, 第3项表示替换, 即*a[i]与b[j]*匹配或者不匹配时候的情况
例如, 输入字符串 'cabana', 匹配目标 'banana', Levenshtein矩阵计算过程如下:
b, a, n, a, n, a
[0, 1, 2, 3, 4, 5, 6]
c [1, 1, 2, 3, 3, 3, 3]
a [2, 2, 1, 2, 3, 3, 3]
b [3, 2, 2, 2, 3, 3, 3]
a [3, 3, 2, 3, 2, 3, 3]
n [3, 3, 3, 2, 3, 2, 3]
a [3, 3, 3, 3, 2, 3, 2]
Damerau-Levenshtein距离公式
该公式与Levenshtein距离公式的主要区别是加入了一项 用来计算相邻两个字符的置换( transposition)
Damerau-Levenshtein DFA, string='ab', n = 1 , 深色表示accepting state
可以看出, 该DFA与纯Levenshtein的主要区别是可以接受 'ba' 作为输入。实际使用的时候不一定会构建一个这样复杂的匹配所有全部可能状态的DFA,这里仅为举例说明
与trie比较
缺点
- 字符串匹配速度较marisa trie慢, 匹配时的时间复杂度里面包括索引数量项,而trie树一旦构建完成就与索引数量无关搜索速度只与最大索引长度有关
- 自动机前缀匹配结果远不如trie, 例如输入‘快乐’无法匹配到‘快乐大本营’, 因为这两个字符串相差3个字符,而实际使用时一般最多设置编辑距离为2.
优点
- 容错,支持模糊查询,DamerauLevenshtein还支持匹配任意两个相邻字符换位后的结果,非常适合用来做拼写检查以及语义分析
与KMP算法比较
优点
- 本文实现的SparseDamerauLevenshtein速度比KMP算法快,时间复杂度为_M * N * C_, 其中M为索引数量, N为输入的搜索字符串长度, C为设定的最大编辑距离,一般为1或者2. 而Kmp算法时间复杂度为_N + M * avg_len_, 其中avg_len为平均索引长度, N是需要匹配前先计算的,由于一般搜索时输入的字符串不会太长,这一项开销可以忽略不计。综上, 比较SparseDamerauLevenshtein和KMP的时间复杂度实际上就是比较_M * N_和_M * avg_len_的复杂度。由于N一般都小于索引的平均长度avg_len, 所以实际用于自动完成或者前缀搜索时 SparseDamerauLevenshtein 比 KMP 算法快。
缺点
- 前后缀搜索效果均不如KMP(Trie树只能搜索前缀, 基于编辑距离的匹配算法在两个字符串长度相差较大时无能为力), 尤其是用户输入的搜索字符串较短时.
SparseLevenshteinAutomaton的实现基于github repo, 本文实现与该repo相比的主要区别是
- jules jacobs的实现主要是simple proof of concept, 本文的实现是一个包含了测试代码,提供了隐藏了具体实现细节、包括完整字符串模糊匹配接口的wrapper class
- 扩展了jules jacobs实现的功能。 本文实现了基于Damerau-Levenshtein距离的Sparse Damerau-Levenshtein Automaton, 可以识别任意相邻两个字符置换/换位后的字符串, 提供了state_equal等验证空间压缩版本的Sparse Automaton功能正确性的检验函数。
实现细节
- 不保存整个Levenshtein距离矩阵,只保存矩阵当前行(DameruLevenshteinAutomaton会保存矩阵的前一行作为内部状态, 而LevenshteinAutomaton类不直接保存任何状态)
- SparseLevenshteinAutomaton和SparseDameruLevenshteinAutomaton类每一行只保存当前小于max_edit的矩阵行元素的下标和值, 这样每一次匹配一个输入字符的时候只需要处理最多2 * max_edit + 1个元素, 使得匹配单个字符的时间复杂度由Length(string) * 下降为c*, 其中*Length(string)*为索引字符串长度,c为最大编辑距离,一般可忽略, 从而使得整个算法变得实际可用
- SparseDameruLevenshteinAutomaton实现使用optimal string alignment算法, 该算法的伪码来自wiki
本文所述python实现github, 其中包括字符串模糊匹配类定义及实现文件,
测试代码文件, 测试输出文件
DamerauLevenshteinAutomaton测试输出
SparseLevenshteinAutomaton test
快乐大本营 -> 0.9
天天向上 -> 0.85
快乐大本营: 大电影 -> 0.8
大本营花絮 -> 0.75
快乐购 -> 0.7
快乐家族 -> 0.6
快乐男声 -> 0.5
快乐** -> 0.4
快乐垂钓 -> 0.3
快乐本大营 -> 0.1
lev distance = 0
lev distance 0, time: 0.000234
'快乐大本营' -> "快乐大本营" "----"
lev distance 0, time: 0.000047
'大本营' -> "----"
lev distance = 1
lev distance 1, time: 0.000439
'快乐大本营' -> "快乐大本营" "----"
lev distance 1, time: 0.000067
'大本营' -> "----"
lev distance = 2
prefix: 快乐大本营, word: 快乐本大营, cur_i: 4, match error count 2
lev distance 2, time: 0.000830
'快乐大本营' -> "快乐大本营" "----" "快乐本大营"
prefix: 大本营, word: 大本营花絮, cur_i: 2, match error count 2
lev distance 2, time: 0.000134
'大本营' -> "----" "大本营花絮"
lev distance = 3
prefix: 快乐大本营, word: 快乐本大营, cur_i: 4, match error count 2
prefix: 快乐大本营, word: 快乐家族, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐购, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐男声, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐**, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐垂钓, cur_i: 4, match error count 3
lev distance 3, time: 0.000943
'快乐大本营' -> "快乐大本营" "----" "快乐购" "快乐家族" "快乐男声" "快乐**" "快乐垂钓" "快乐本大营"
prefix: 大本营, word: 大本营花絮, cur_i: 2, match error count 2
lev distance 3, time: 0.000144
'大本营' -> "----" "大本营花絮"
lev distance = 4
prefix: 快乐大本营, word: 快乐本大营, cur_i: 4, match error count 2
prefix: 快乐大本营, word: 快乐家族, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐购, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐男声, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐**, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐垂钓, cur_i: 4, match error count 3
lev distance 4, time: 0.000998
'快乐大本营' -> "快乐大本营" "----" "快乐购" "快乐家族" "快乐男声" "快乐**" "快乐垂钓" "快乐本大营"
prefix: 大本营, word: 大本营花絮, cur_i: 2, match error count 2
lev distance 4, time: 0.000148
'大本营' -> "----" "大本营花絮"
DamerauLevenshteinAutomaton测试输出
DamerauLevenshteinAutomaton test
快乐大本营 -> 0.9
天天向上 -> 0.85
快乐大本营: 大电影 -> 0.8
大本营花絮 -> 0.75
快乐购 -> 0.7
快乐家族 -> 0.6
快乐男声 -> 0.5
快乐** -> 0.4
快乐垂钓 -> 0.3
快乐本大营 -> 0.1
lev distance = 0
lev distance 0, time: 0.000271
'快乐大本营' -> "快乐大本营" "----"
lev distance 0, time: 0.000051
'大本营' -> "----"
lev distance = 1
prefix: 快乐大本营, word: 快乐本大营, cur_i: 4, match error count 1
lev distance 1, time: 0.000553
'快乐大本营' -> "快乐大本营" "----" "快乐本大营"
lev distance 1, time: 0.000076
'大本营' -> "----"
lev distance = 2
prefix: 快乐大本营, word: 快乐本大营, cur_i: 4, match error count 1
lev distance 2, time: 0.000786
'快乐大本营' -> "快乐大本营" "----" "快乐本大营"
prefix: 大本营, word: 大本营花絮, cur_i: 2, match error count 2
lev distance 2, time: 0.000146
'大本营' -> "----" "大本营花絮"
lev distance = 3
prefix: 快乐大本营, word: 快乐本大营, cur_i: 4, match error count 1
prefix: 快乐大本营, word: 快乐家族, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐购, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐男声, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐**, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐垂钓, cur_i: 4, match error count 3
lev distance 3, time: 0.001079
'快乐大本营' -> "快乐大本营" "----" "快乐购" "快乐家族" "快乐男声" "快乐**" "快乐垂钓" "快乐本大营"
prefix: 大本营, word: 大本营花絮, cur_i: 2, match error count 2
lev distance 3, time: 0.000155
'大本营' -> "----" "大本营花絮"
lev distance = 4
prefix: 快乐大本营, word: 快乐本大营, cur_i: 4, match error count 1
prefix: 快乐大本营, word: 快乐家族, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐购, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐男声, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐**, cur_i: 4, match error count 3
prefix: 快乐大本营, word: 快乐垂钓, cur_i: 4, match error count 3
lev distance 4, time: 0.001142
'快乐大本营' -> "快乐大本营" "----" "快乐购" "快乐家族" "快乐男声" "快乐**" "快乐垂钓" "快乐本大营"
prefix: 大本营, word: 大本营花絮, cur_i: 2, match error count 2
lev distance 4, time: 0.000160
'大本营' -> "----" "大本营花絮"
Levenshtein automata can be simple and fast
Levenshtein_distance
Damerau–Levenshtein distance