Tips:这是为本人所在公司设计的查找子串的算法的文档。“发明”出来之后才发现杯具了……原来这货叫字典树,是早已经有的东西。计算熵倒是自己引入的,但是后面发现很多情况下得不偿失,实际实现中去掉了这个功能。以下为文档最初始的版本,并不跟手上已经实现的玩意完全符合。仅仅希望大家看了之后能有所启发,以下是正文。
1 简介
2 测试数据
3 算法原理介绍
1.1 在数组查找单个字符
1.2 在树查找字符串
1.3 计算熵增益 构建决策树查找字符串
4 模糊查找
5 缺陷和优化
1 简介
本算法的应用场景为,在N个NWord长的字符串集合中,查找一个NWord长的字符串所在位置。
根据理论预期和实际检验,目前为止基本实现了O(1)时间复杂度的查找。也就是说,无论N为多少,查找时间基本为常数。严格来说,查找时间跟N无关,仅跟字符串长度NWord有关。但是字符串长度大部分情况下不会超过一定范围(比如10-20个汉字),所以可认为:查找时间 = f( NWord ) = f( 群名长度 ) = 常数。
本算法在设计中参考了两种算法:
1)根据ID3算法计算熵增益构建决策树。
2)参考暴雪游戏公司的哈希查找算法,使用字符串本身的值的一部分作为下标。
2 测试数据
算法Demo使用随机生成的大量字符串作为测试样本。主要变动参数有2个:N,查找的字符串样本集合的数量;NWord,待查找的字符串字节数,同时也是字符串样本集合每个元素的字节数。测试耗时单位为毫秒。查找内容为在有N个目标字符串的集合中进行查找N次查找,带查找字符串也从集合中抽取(即保证待查找字符串一定在字符串集合中)。
比较效率的目标算法为STL的unordered_map和暴雪的同样为O(1)复杂度的hash查找。机器环境为64位windows 7, Intel Core I5 四核CPU。
查询次数 |
目标字符串集合大小 即N |
待查找字符串长度即NWord |
stl::unordered_map |
blizzard字符串查找算法 |
原创查找算法—精确模式 |
原创查找算法—模糊模式 |
1*10000 |
1*10000 |
20 |
64 |
9 |
11 |
3 |
2*10000 |
2*10000 |
20 |
137 |
18 |
31 |
5 |
3*10000 |
3*10000 |
20 |
238 |
26 |
48 |
12 |
4*10000 |
4*10000 |
20 |
301 |
34 |
42 |
22 |
5*10000 |
5*10000 |
20 |
340 |
42 |
77 |
21 |
6*10000 |
6*10000 |
20 |
468 |
49 |
88 |
18 |
7*10000 |
7*10000 |
20 |
489 |
58 |
110 |
33 |
8*10000 |
8*10000 |
20 |
556 |
66 |
92 |
34 |
9*10000 |
9*10000 |
20 |
636 |
75 |
117 |
39 |
10*10000 |
10*10000 |
20 |
662 |
85 |
117 |
38 |
1*10000 |
1*10000 |
40 |
60 |
10 |
10 |
3 |
2*10000 |
2*10000 |
40 |
120 |
17 |
20 |
6 |
3*10000 |
3*10000 |
40 |
200 |
25 |
32 |
10 |
4*10000 |
4*10000 |
40 |
290 |
33 |
41 |
13 |
5*10000 |
5*10000 |
40 |
315 |
41 |
58 |
17 |
6*10000 |
6*10000 |
40 |
390 |
49 |
68 |
22 |
7*10000 |
7*10000 |
40 |
432 |
57 |
74 |
27 |
8*10000 |
8*10000 |
40 |
495 |
69 |
87 |
30 |
9*10000 |
9*10000 |
40 |
568 |
75 |
101 |
34 |
10*10000 |
10*10000 |
40 |
636 |
84 |
108 |
39 |
结论:
当数据量N在1万到10万之间时,无论字符串长度为多少,以下成立:
使用STL内置的unordered_map算法时,每万次查找消耗时间在50~60毫秒左右
使用暴雪blizzard字符串查找算法时,每万次查询时间都稳定在8毫秒左右;
使用自定义算法精确查找时,每万次查询时间都稳定在10毫秒左右;
使用自定义算法模糊查找时,每万次查询时间都稳定在3-4毫秒左右。
3 算法原理介绍
3.1 在数组查找单个字符
我们先考虑在一个1维数组中查找NWord为1的字符串,也就是单个字节。如果我们直接把字节的值作为下标,那么O(1)的时间复杂度是很明显的。
3.2 在树查找字符串
考虑多个字节的字符串,就不能再使用1维数组来存储。可以把字符串切割为字节放在树结构之中。每个字节在本层子节点通过该字节的值来进行索引。这样,当需要进行查找时,即不使用深度遍历,也不需要使用广度遍历,只要遍历字符串的每一个字节,不断取字节值作为下标即可。
3.3 计算熵增益 构建决策树查找字符串
3.2中是顺序使用每一个字节的值作为下标。但是每个字符的重要性,或者说代表性,可以使不同的。假设我们在三个样本组成的集合中进行查找,即Hell,Heat,High。如果我们把3个词组织成树,可以根据根节点的选取组织不同的树。
我们考虑三个单词的字节1和字节3。如果我们在决策树中第一步判断的是单词的第3字节(也就是说以第3字节的值构建根节点的下级),那么可以一步到位查找出来,可以不进行后续的判断。如果第一步判断的是第1字节,那么就是在做无用功,还要进行后续判断。
有NWord个字节,就要计算NWord次熵增益。每次计算熵增益的参数为所有字符串中相同位置的字节,即N个字符。
我们令S为样本集合,A为某个字节,则字节A的熵增益的计算公式如下:
其中,是样本内所有N个字符串的总熵,这里取;是S的不同子集,这里就是某个字节取不同值的集合。是不同取值的数量;是总样本数量N。即是。例如第3字节的i,a,g分别是一个集合,其分别是1,1,1。第2字节的e,i分别是一个集合,其分别是2和1。我们根据公式计算二者的熵增益如下:
总熵 = log(2,N) = log(2,3) = 1.584
第三字节的熵增益 = log(2,3) - ( log(2,1)/3 + log(2,1)/3 + log(2,1)/3 ) =log(2,3) = 1.584
第二字节的熵增益 = log(2,3) - ( log(2,2)/3 + log(2,1)/3) = log(2,3) - 1/3 = 1.251
也就是说,第三字节的“价值”更大,应该取第三字节在更靠近根节点的层次上。进一步地说,第三字节的熵增益等于总熵,也就是说其拥有完全的信息——只要判断第三字节就可以完成查找。
4 模糊查找与精确查找
决策树查找之所以快,在于可以在适当的时机停止下来。参考下面的例子:
就如同第3节所说的,如果我们在树的某i层,对应所有字符串的第j个字节,如果每个字符串的j字节都两两不同,那么查找到该层的适当位置时并且子树数量仅为1时即可停止,因为接下来的必是要查找的字符串。比如例3的第3个字节——l,a,g——两两不同,我们只要判断第三字节即可。
但是如果集合中没有我们要查找的字符串,而查找算法执行到只剩下1个子树时,若此时停止,找到的就不是跟待查找字符串完全匹配的字符串,而仅仅是集合中最接近的。
5 缺陷和优化
在最新的实现中,首先我去掉了计算熵增益的部分;否则插入时耗时太长。其次,为了应付当保存的字符串长度为60,数量大于等于10万条时内存的爆炸性增长,我把树节点的数据部分设置成动态分配。内存池使用了谷歌的je_malloc,否则大量的内存碎片会使内存回收变成大问题。