xingd.net

.net related techonology
posts - 54, comments - 616, trackbacks - 6, articles - 2

(重发).NET脏字过滤算法

Posted on 2008-01-23 18:18 xingd 阅读(4696) 评论(34)  编辑 收藏 网摘 所属分类: Framework
我们网站的脏字字典中大概有600多个词,而且会发生变化,因此简单的在数据新增/修改的时候做一次脏字过滤是不够的。在网站从.NET 1.1到2.0改版的时候,对新版的测试发现旧的脏字过滤算法耗费的时间过长,需要做一些优化。

旧的算法是简单对每一个脏字调用一遍 string.replace,当然是用了StringBuilder。在cnblogs里发现了一篇讨论脏字过滤的blog http://www.cnblogs.com/goody9807/archive/2006/09/12/502094.html。在我这里测试的时候,RegEx要快一倍左右。但是还是不太满意,应为我们网站上脏字过滤用的相当多,经过一番思考后,自己做了一个算法。在自己的机器上测试了一下,使用原文中的脏字库,0x19c的字符串长度,1000次循环,文本查找耗时1933.47ms,RegEx用了1216.719ms,而我的算法只用了34.125ms.

算法的关键,还是使用空间来换时间,使用了2个全局的BitArray, 长度均为Char.MaxValue。其中一个BitArray用来判断是否有某个char开头的脏字,另一个BitArray用来判断所有脏字中是否包含某个char。经过这两个BitArray,可以做出快速判断,之后就使用Hash Code来判断完整的脏字,通过预先获取的最大脏字长度优化遍历过程。

需要的变量如下:
private Dictionary<stringobject> hash = new Dictionary<stringobject>();
private BitArray firstCharCheck = new BitArray(char.MaxValue);
private BitArray allCharCheck = new BitArray(char.MaxValue);        
private int maxLength = 0;

其中hash只使用到了key,value都置为null。也可以使用.NET 3.5中的HashSet,或者使用Dictionary<string, int>,记录脏字的出现次数。

初始化这些数据的方法如下:
foreach (string word in badwords)
{
    
if (!hash.ContainsKey(word))
    {
        hash.Add(word, 
null);
        maxlength 
= Math.Max(maxlength, word.Length);
        firstCharCheck[word[
0]] = true;

        
foreach (char c in word)
        {
            allCharCheck[c] 
= true;
        }
    }
}

判断脏字是否出现在一个字符串中的代码如下:

int index = 0;
int offset = 0;
while (index < text.Length)
{
    
if (!firstCharCheck[text[index]])
    {
        
while (index < text.Length - 1 && !firstCharCheck[text[++index]]) ;
    }

    
for (int j = 1; j <= Math.Min(maxlength, text.Length - index); j++)
    {
        
if (!allCharCheck[text[index + j - 1]])
        {
           
break;
        }

        
string sub = text.Substring(index, j); 
        
        
if (hash.ContainsKey(sub))
        {
            
return true;
        }
    }

    index
++;
}

return false;

替换的代码就不贴了,跟判断包含类似,只不过不能发现一个脏字后就退出循环。如果出现脏字的可能不是很高,就没有必要创建一个临时的StringBuilder。

进一步,可以通过借鉴.NET源码中string.GetHashCode()的实现,避免一次Substring的调用,提高性能。也可以设计递进的HashCode实现,比如"helloworld"可以用"helloworl"的hash进一步计算,优化效率。

另外,也可以抛弃Hash,改用排序过的string[],用BinarySearch来判断sub是否为脏字。BinarySearch的结果是可以递进的,即可以用查找"helloworl"的结果来加速判断"helloworld"。 (已测试,700个脏字,BinarySearch的效率有时会低很多。)

最后发一点牢骚,当初最早发的时候(http://www.cnblogs.com/xingd/archive/2007/09/26/906013.html),仅仅是为了说明下自己的算法,具体的代码甚至还有一点错误。两个事情让我觉得心里不很爽,一个是被乱七八糟的无数网站转载而不说明出处,导致我后来的改进和错误修正达不到效果,二是一些人都愿意看到最终的代码,而不是理解我想要表达的最核心的设计,然后自己去考虑实现。

Feedback

#1楼   回复  引用  查看    

2008-01-23 19:18 by 金色海洋(jyk)      
支持。

#2楼   回复  引用  查看    

2008-01-23 20:24 by Ariel Y.      
支持,不过放出完整的源代码还是对一些不太能理解的新手有良好的示范作用。

#3楼[楼主]   回复  引用  查看    

2008-01-23 20:38 by xingd      
@Ariel Y.
这样的代码在团队里是不会让新手去做的,新手也不应该过早深入这些内容。不同的帖子有不同的目标,最早发帖是仅是说明思路。

#4楼   回复  引用  查看    

2008-01-23 20:40 by Ariel Y.      
新手一词也许不太恰当吧,总之就是没看懂的,呵呵

#5楼[楼主]   回复  引用  查看    

2008-01-23 20:41 by xingd      
主要还是因为第一点而不爽了,很多网站都没有保留引用位置的意识。

#6楼[楼主]   回复  引用  查看    

2008-01-23 20:44 by xingd      
主要是从鼓励自己做一些实现尝试的角度开始没有发下详细源码。

好像是说Linux核心开发不允许去开Windows源码,mono不允许看.NET源码。虽然目的不同,是不希望读者局限在我的代码实现上。

#7楼   回复  引用  查看    

2008-01-23 20:51 by 老Q      
呵呵,现在已经没有刚毕业时的激情了,
现在写程序就是一份工作而已,看到好的代码收藏下,用的时候拿来用。
这就是大多数人的心态。

#8楼   回复  引用  查看    

2008-01-23 22:29 by 李华星      
收藏了,以后肯定能用上

#9楼   回复  引用  查看    

2008-01-24 00:53 by Soli      

注明出处也不是件容易的事。
又一次我要引用一篇文章,跟着给出的链接一步步追溯过去,发现越追分支越多,最后有的分支断了,只有“转载”两字却不给出链接,有的分支却实死链。

#10楼   回复  引用  查看    

2008-01-24 05:14 by sban      
@Soli
前日于网上见到《浅析flex开发的瓶颈》一文,想引用,但没有作者;为了找到作者,我各在搜索引擎里搜索这篇文章,结果作者却出来了二三个。最终引用作罢。

许多文章被转载后没有作者及原文链接,是数据抓取程序造成的。在随笔中部或尾部故意渗入作者及原文信息,可以有所弥补。

在自己的文章中故意加入自己的名字,外人看来此人有点自大。但我们知道,这只是一个易于后来捕捉原文信息的一个技巧。:)

#11楼   回复  引用  查看    

2008-01-24 08:31 by 王立斌      
写的很不错,受教了,支持正版、原创,呵呵。

#12楼   回复  引用  查看    

2008-01-24 09:02 by 留恋星空      
收藏下

#13楼   回复  引用  查看    

2008-01-24 09:48 by 戏水      
小弟愚钝 ,还是没能搞明白您的算法

#14楼   回复  引用    

2008-01-24 10:18 by 信息谷[未注册用户]
我测试了一下 非常不错

#15楼[楼主]   回复  引用  查看    

2008-01-24 10:34 by xingd      
@戏水
把不明白的点讲一下。

如果附近有可以一起讨论的同事/同学,可以理解的快一些。

#16楼   回复  引用    

2008-01-24 10:51 by wfe[未注册用户]
考虑下前缀树吧,性能更好。

#17楼[楼主]   回复  引用  查看    

2008-01-24 10:56 by xingd      
@wfe
前缀树算法复杂,而且在600左右的脏字量,大多脏字长度为2~4时,体现不出性能优势。

请拿出具体的测试数据来,才能说明对于大多数需要做脏字而不是全文检索时,前缀树的性能更好。

#18楼   回复  引用    

2008-01-24 11:23 by Everett[未注册用户]
挺好的, 以后可以用用

#19楼   回复  引用    

2008-01-24 13:30 by Faster[未注册用户]
用RegEx,然后不要每个脏字去匹配一次,而是一次匹配所有脏字,结果会怎样?

#20楼   回复  引用  查看    

2008-01-24 17:33 by Dflying Chen      
很不错,正好要用到

#21楼   回复  引用    

2008-01-24 18:49 by ivist[未注册用户]
不错,我引用的时间一般都是自己试过了没错才分开,同时都会说明出处,多多帮帮笨鸟们哦(我也是一只笨鸟来的^_^)

#22楼   回复  引用    

2008-01-24 19:02 by sumtec@beijing[未注册用户]
这个效率还可以再改进。

#23楼[楼主]   回复  引用  查看    

2008-01-24 19:39 by xingd      
@sumtec@beijing
可以分享下思路吗?

我想的的一是Hash方面的改进,二是增加更多的BitArray,比如判断第二个字符,或者一个BitArray检查奇数位,另一个检查偶数位。

一个BitArray(Char.MaxValue)理论上只需要8K内存,多弄几个BitArray还是蛮划算的。

或者直接要一个byte[Char.MaxValue],将8个bit用来做快速判断,也只要64K内存。

#24楼   回复  引用    

2008-01-25 14:07 by sumtec@beijing[未注册用户]
呵呵,其实我也还没有仔细构思过,我确实做了一个检索脏词的东西,效率分析上,估计还不如你现在这个。可以说,你的思路确实很值得借鉴。
怎么改进会更好,我还没有仔细构思,心里面是有几个初步的想法。
至于说为什么我说有可以挖掘的地方,是因为我直觉上如此,并且不是那种女人的直觉,而是公式上的直觉。下面我给说一下:

实际上,我们需要分析为什么会慢。
用空间换时间,这是一个很笼统的说法。
比如说,你的字库最大字符数是20个字符,极致的空间换时间,就是用一个超大的BitArray来映射。这个BitArray的大小是2^(20*16)/8=2^317=...
纯理论上,这是最快的。可是问题是,你有这大的空间吗?没有!或者即使你有超大的硬盘作为虚拟空间支持,这种动用虚拟空间带来的性能损失很可能得不偿失。

BitArray和Hashtable的时间复杂度差异有多大呢?BitArray是O(1),Hashtable是O(c),其中c>1。
按照你的方法,找完BitArray之后,还是要找Hashtable的。
所以,就单个字符来说,其性能应该是取决于BitArray过滤字符的时候的“过滤率”,比如我们称为f。
当然,一般来讲,一篇文章里面大部分的字符都不在字典的首字符里面。理论上首字符数量越少,效果要越好。但是当首字符数量较大,甚至包含一些高频率出现的字符,例如:“他”,效能就可能开始下降。

纯Hashtable的全文比对时间复杂度公式是:
Ot = O(n*c),其中n是文章长度
而你的方式的全文比对,先刨掉中间一些复杂的东西不说,其时间复杂度公式是:
Ot = O(n) + O(k1*n*c),其中k是BitArray的“命中率”,k1=1-f,在0到1之间。

从这两个公式可以看出来,如果要你的方法效率更高,必须有下列公式成立:
n+k1*n*c < n*c =>
1+k1*c < c =>
1<(1-k1)*c =>
f>1/c

这个公式,能明白点什么了吧?
如果c=20,那么一片文章不在首字母里面的字符必须占文章总量的5%,这基本上不肯能达不到。
这也是为什么你的方法效率会比纯Hashtable要高的原因。但是这里面我已经刨掉中间一些复杂的过程。

我们回过头再来分析后面的复杂问题部分:
你的算法是,如果发现首字符命中了,接下来,每一个字符都必须要命中才能够继续走下去找Hashtable。于是乎,你的算法在检索两个字母时,其时间复杂度就变成了:
Ot = O(n) + O(k1 * n) + O(k'2 * n * c),
其中k还是原来的含义,
为了简化,O(x)就简化为x了,即原公式可变为
Ot/n = 1+k1+k'2*c,其中k'2 = k2 * k1,k2是首字母命中的情况下,下一字母的命中率。

k1值通常是很小的,但是k2'就不一定了。比如说“操”后面可以跟“作”,还可以跟“你”或者“他”或者“她”或者“它”或者“蛋”或者……。可以说很多时候首字母命中之后,后面的字母可能就比较容易命中了。我们先不讨论这个命中率是多少,我们先把公式再度分解一下:
Ot/n = 1 + k1 + k'2 * c
=1 + k1 + k1 * k2 * c
=1 + k1 * [ 1 + k2 * c ]
令X(P) = 1 + P * c,得
Ot/n = 1 + k1*X(k2)

这还只是两个字符的情况,如果是全序列(从2个字符到20个字符),公式就变成:
Ot/n =
1 + k1*X(k2) + k*k2*X(k3) + ... + k*k2*..*k19*X(k20)

首先我们回过头来看这个问题,k2到底怎么构成的?
如果我们有“ma个B”,“?蛋”,“狗ri的”(其实可以去掉“的”,因为会影响效率,但为了演示,这里没有去掉)。
那么k1取决于文章有多少个“妈”“CAO”“狗”,嗯,应该很少。
k2呢?不是取决于“妈”后面出现“个”的机率,而是什么呢?
是出现“妈”、“个”、“B”、“蛋”、“狗”、“日”、“的”。
这样的话有多糟糕估计你就清楚了:
妈妈、早操的时候、他小名叫狗蛋(这样的文章后面可能会提到“狗蛋的”破袜子之类的)
统统都在命中列表里面,而且是全命中。
从上述的推导中可以看出来,k2是首字母表明中的情况下,命中全字符表的命中率。而从k3到k20其实都是相等的,令它为U,他是在全字符表明中的情况下,再次命中全字符表的命中率。

于是公式就变成了:
Ot/n = 1 + k1 * X(k2) + k1*k2*X(U) + k1*k2*U*X(U) + .. + k1*(U^17)*X(U)
= 1 + k1 * X(k2) + k1*k2*X(U)*(1+U + U^2 + U^3 + ... + U^17)

好了,问题就变成U到底是什么的问题上了。我不知道你的脏字表都有些什么,我这里还会包含上面发下来的一些涉X的词,甚至是短语。那么全字表里面可能就会出现“双”、“飞”、“女”、“无”、“码”、“鸡”。我们不说k2会如何,U我们稍微夸大一点,比如说可能会等于10%,那么上面这个公式就等于:
Ot/n = 1 + k1 * X(k2) + k1*k2*X(0.1)*(1+0.1+0.01+..)
约等于1 + k1 * X(k2) + k1*k2*1.1*X(0.1)
=1 + k1* [1+k2*c + k2*1.1*(1+0.1*c)]
=1 + k1* [1+k2*c+1.21*k2*c]
=1 + k1* [1+2.21*k2*c]
加入我们取k2等于1%,那么
Ot/n = 1 + 1.221 * k1 * c

这个U和k2到底是多少其实不重要,重要的是,这个公式最后的结果,无论如何都不会小于1+k1*c,所以我说后面一定还有效率可以挖掘。
我的结论是:
1、这个的算法之所以快,取决于k1、k2非常小。
2、能够挖掘的方向有两个,一个是在如何降低k2和U上,但这个效果比较有限;另一个办法在于如何减少字符扫描的次数,这个才是关键。
事实上,我认为,效率损失不再Hashtable上面,而在于Substring等字符操作上,因此如何减少来回重复扫描字符串,可能效率会更高。
呵呵,至于怎么挖掘,其实方法也一样,关键是需要在公式推导的基础上,实际测试一把。

#25楼   回复  引用    

2008-01-25 14:27 by sumtec@beijing[未注册用户]
不好意思,更正一个地方:
最后一个公式

Ot/n = 1 + 1.221 * k1 * c

应该是
Ot/n = 1 + 1.0221 * k1 * c

#26楼[楼主]   回复  引用  查看    

2008-01-25 17:05 by xingd      
本意还是提供一种简单高效的过滤脏字的算法,所以会有不少的假设,比如脏字字典1000个以下,最大长度在10以内,脏字出现的几率不大。对于目前我们网站的实际情况来讲,这些条件都是存在的。

BitArray的作用是fast check,仅仅做一些必要的过滤,最大脏字的长度不影响BitArray占用的内存。比如利用64k内存,就可以为每个char分配一个byte,其中1~4bit用来判断前4个char,5~8用来做其他字序mod4为0,1,2,3的检查。因为每个bit但是独立的,所以即便8个bit的fast check都通过了,也未必就是脏字。但是因为大多数用户的内容是不会包含脏字的,所以BitArray主要作用是验证不包含脏字,而不是包含。

包含所有情况的一个超大BitArray是不合适的,当然对应分布式处理的话,还是可以实现的,不过那估计要到GFW的数据量了。

SubString的确是没必要的代价,我之前看过GetHashCode的实现,下次会改进。也可能需要自己实现Hashtable。

从目前实际运行的情况看,已经足够用了。将BitArray扩展到byte[]是可以的,太大的修改一般必要性不大了。

#27楼   回复  引用    

2008-07-09 21:53 by cxw[未注册用户]
好贴,支持一下!我正好要用上,谢谢了!

#28楼   回复  引用  查看    

2008-07-22 16:42 by 逖靖寒      
空间换时间

#29楼   回复  引用    

2008-08-10 22:54 by 您好[未注册用户]
您好,能提供一份脏字字典吗?自己实在想不出多少,谢了!
email:leeses@126.com

#30楼   回复  引用  查看    

2008-12-01 14:23 by 极品菜鸟      
博主你好,你上面写的东东只是做判断一个字符串中是否存在脏字,相当于只是做一个判断啊,真正的替换算法没有写出来哟,能不能贴贴一贴?

#31楼   回复  引用  查看    

2008-12-10 18:41 by 极品菜鸟      
楼主,你好,我看了好久你的方法,今天终于做了一个小总结,封装成了一个类,哈哈..http://www.cnblogs.com/bbqqqbq/archive/2008/12/10/1352142.html" target="_new">http://www.cnblogs.com/bbqqqbq/archive/2008/12/10/1352142.html
楼主不要见怪啊..给指点一下,没有注释真的很痛苦..

#32楼   回复  引用  查看    

2008-12-25 13:10 by 半克拉鹅卵石      
mark

#33楼   回复  引用    

2009-01-10 15:59 by ssssssssssssssssssssss[未注册用户]
if (!firstCharCheck[text[index]])
{
while (index < text.Length - 1 && !firstCharCheck[text[++index]]) ;
}


不明白,这个空循环是什么意思?

#34楼   回复  引用  查看    

2009-05-30 19:32 by jay tian      
感谢楼主,把自己的算法分享!



发表评论

昵称: [登录] [注册]

主页:

邮箱:(仅博主可见)

评论内容:

  登录  注册

[使用Ctrl+Enter键快速提交评论]

0 1050443




相关文章:

相关链接: