字符串多模式精确匹配(脏字/敏感词汇搜索算法) 之算法前传II

字符串多模式精确匹配(脏字/敏感词汇搜索算法) 之算法前传II

真是被人言中了,这简直就是一个星战系列。原因很简单,回到我的老本行——算法。这么一个领域,哪怕是芝麻绿豆那么小的问题,我也会发现很多有趣的东西。上一篇前传说完以后,意犹未尽。不过当时还没有更多可写的,而今天突然发现了那么几个问题,于是又可以写出一篇长篇大论了。
事情的缘由是这样的:早上看到XingD的一个修正,赶紧实测一下,把我发现另一些问题修正之后,还是发现有不匹配的状况。后来才发现原来是XingD的算法本身不处理大小写问题,或者说大小写是敏感的,而解决的办法是脏字表和待检文本都做一个ToLower这样的操作。当我如是修改之后发现,如果脏字过滤是地球问题,那么ToLower就是一个太阳问题了:ToLower消耗掉的时间,几乎是算法本身的500倍。实际上有的时候真是理论归理论,实战归实战。实战的东西就必须放到战场上反复检验,你才会发现这种你脑袋像破了也未必想出来的问题。
其实.NET的任务和我们的任务目标是不一致的:.NET要求绝对严格遵循标准,否则估计又会有敌对阵营的人跳出来说,喏,微软的东西就是破坏标准的东西。而我们要求的是什么呢?要求的是能把脏字检索出来就好了,至于哪些字符要变小写,不需要全照着标准来,只要按照我们自己的定义来说,是正确的,那就可以了。由于两者的任务不一样,导致了.NET的代码会比较复杂,要考虑是否不同文化转变的方式不一样的因素等,于是会消耗比较多的时间。而我们任务相对简单,于是如果我们自己写代码的话,速度可能会快很多。这是其一。
其二,由于ToLower必然会生成一个新的字符串副本,因此很明显,这样的操作会消耗内存分配的时间。这种时间可长可短,如果内存不足还需要动用VM,这个时候估计就会不只500倍的差异了。
那么怎么做这个大小写“脱敏”处理呢?很简单,自己做一个FastLower(char c)的函数即可,然后自己做一个StringCompare的函数。包括StringCompare,其他的函数也一样,只要涉及大小写的,都要调用这个FastLower来处理。看起来很笨的办法,看起来会要产生很多次调用,但是不要忘了,即使你用ToLower,里面也一样会有同样多,甚至更多的运算量在里面。这个FastLower我提供两个方案:
1、给出一个byte[] LowerAdd = new int[char.MaxValue]的数组,只要是大写字母,就给一个指向小写字母的增量,其他字符都是0。于是乎,FastLower不需要对近来的字符作什么判断,只要直接加这个偏移量就好了。
2、乖乖的写一大堆逻辑比较。
大家猜哪一个快?答案是第二个——跟我原来的想法不一样,为什么我就不管了,有兴趣的人自己研究去。使用方法1的过滤系统,总运行时间在50-60ms左右,而方法2的系统只需要运行21-26ms左右。下面给出一个差不多凑合的FastLower函数(注意,由于我的TTMP算法,需要计算HashCode,因此返回的是uint。各位可以根据自己的需要进行调整):

static public uint FastLower(char ch) { uint val = (uint) ch; if (val > 0x2179 && val < 0xff21 || val <0x41) { return val; } else { if ((val >= 0x41 && ch <= 0x5A) || (val >=0xC0 && val <=0xDE && val!=0xD7) || (val >=0x391 && val <=0x3A9) ) { val |= 0x20; } else if (val >= 0x400 && val <= 0x42F) { if (val >= 0x410) { val += 0x20; } else { val += 0x50; } } else if (val >= 0x2160 && val <= 0x2169) { val += 10; } else if ((val >= 0xff21 && ch <= 0xff3A)) { val += 0x20; } } return val; }

这个函数看着不怎么严谨,我自己觉得没关系,能用就行。这个函数可以转换的大小写包括:

半角英文 A-Z

全角英文 A-Z

特殊发音符号(那种a上面带两点等符号)

希腊字母 Α-Ω

西里尔文 Ё、А-Я

罗马数字 Ⅰ-Ⅹ

基本上算是涵盖了,如果觉得没有涵盖的也没有关系,自己给改改就好了,应该很简单的。上面的算法有个特别给改的地方,就是把大部分中文用第一个判断给排除了,因为对于经常遇到又不需要处理的字符,尽可能减少判断可以提升性能。

除了ToLower之外,还有一个问题引起了我的注意:被匹配的,通常都是些什么样的条目呢?我稍微分析了一下,发现对于正常文本,1-2个字符的命中率合起来超过95%!我有两个稍微大一点的文本,都在280K个字符左右。这两个文本的1-2字符命中率分别为99.5%和98%。这其中1个字符的命中率在20%以内,也就是说,绝大多数命中的,还是2个字符的词条。这样就有几个可以值得思考的地方了:

1、如果我们为了提高性能,对于第3到n个字符采取空间换时间的策略,是否值得?如果算法正确,是不值得的。除非说算法有问题,会经常在其实不可能命中的情况下,检索第3-n个字符。从这个角度来看,TTMP算法应该是正常发挥了它的优势的:

如果找不到结束符,它是不会傻乎乎的就开始检索的。而基于上述的统计结果,命中条目的长度绝大多数集中在2个字符以内。那么对于TTMP算法,它通常能够拒绝绝大多数的无效检索。

2、另外一个值得我们考虑的地方是,对于1-2个字符的情况做优化是最划算的。其实这个结果,我在XingD的一篇文章里面的回复里面已经详细分析了一遍,可以说超过第3个字符开始,优化对算法的影响就已经是万分之一级别的了。当然了,这是理论,是在一个完美的前提下的,即你不会对不可能的命中进行检索。而XingD后来的改进能够提高命中率,实际上是另外一个原因:把需要检索的概率降低了。原来是所有出现的字符都在一个表里面,于是k2、k3的概率会相对较大。而后来的改进,主要是把各个位置出现的字符分离开,于是可能第二个字符的位置所出现的字符集会相对减小,于是k2、k3就减小了,运算量也就相对减小了。这也应该是XingD的改进,其性能提高并不仅仅是提高了万分之一的原因。

3、回过头来看,TTMP可能还没有发挥最高的效率,因为TTMP对于1-2个字符的作用并不明显,越长的关键字,其可能节省的运算量可能会越多。因此如果再要提高性能,应该考虑对TTMP做一些针对1-2个字符的改进。1字符命中很简单,就是对1个字符做一个单独的命中处理,不做复杂的记录初始位置、哈希值运算、查表等运算。对于2个字符,目前没有怎么想好。在原本已经非常高的效率下,任何优化都需要特别谨慎的考虑,因为很容易就会造成优化得不偿失的状况。比如说,也许有人会考虑利用编码压缩的办法来提供一个更大的字符类型映射表(表明是直接命中,还是起始符,还是中间最多会有多少个字符,或者组合起来的情况等)。但这样的优化我认为是极有可能得不偿失的,因为相对于命中来讲,绝大多数的还应该是不命中的部分。如果采用编码压缩的方案,那我们就不得不对每一次扫描都进行这样的压缩运算,而这些运算量总量说不定比该方案所“优化掉不执行”的运算量要大几个数量级。也许等我想好了,会把解决思路奉献给大家。不管如何,可以肯定地是TTMP-B模式,肯定会比我现在使用的TTMP-F模式要高效,后面会有文章进行解释的。

4、WM等算法也许对于我们的“脏字过滤”任务真是毫无发挥余地的。理由太简单了:首先我们的多个模式里面有1个字符长度的情况;其次,即使能够进行分化,剩下来的匹配中占绝对地位的2个字符情况,也是WM所无法发挥作用的(相对TTMP来说)。因为WM算法其根本逻辑就是,构造一个2-3个字符的前缀表,这也就以为这几件事情:

a、模式的长度至少是2个字符以上。搞不好大多数情况下,也只能够乖乖的继续扫描下一个字符;

b、由于2个字符的长度是32位,因此如果不进行编码压缩,至少需要{ [(2^32)/8] * 2 }个字节来构造他的转移表,也就是1GB。对于普通的服务器来说,这有点太奢侈了。如果说要进行编码压缩,参考上面第三点的解释,可以肯定是不值得的。比如在我的两个正常文本里面,命中数量都在1000个左右,也就是说非命中字符数,是命中的字符数的140倍。即使WM算法全部都给跳跃了,可能带来的性能优势,真是不好说能否覆盖编码压缩所带来的性能损耗。

最后小结一下(无论是否是核心算法,只要是代码应该都适用):

1、一定要注意你的任务目标,如果目标和框架的目标不一样,即使功能是近似甚至相同的,都需要考虑是否要自己写一个东西了;

2、做优化的时候,一定要考虑到底那些状况是最常见的。例如在这里,非命中是最常见的,触发了扫描不命中是最常见的,命中的时候极短的字符是最常见的。只有搞清楚了这些问题,你才知道性能瓶颈都在哪里,而不是瞎优化一气。很多时候,有些优秀的算法只是在特定的状况下是有效的,对于例如扫描不命中时的问题,大小写问题是无能为力的,或者不是你的处理范围。如果你选择的一个核心算法能达到10ms级别的效率,却使用了ToLower,那么有可能你的努力就白费了。

和星战一样,前传也是不可不看的。

此条目发表在未分类分类目录,贴了标签。将固定链接加入收藏夹。