作者 Eluli Simpray

v1.0.2版本

# 高效敏感词过滤
# 快速敏感词过滤
## 性能概述
### 性能概述
在共60M穿越小说上测试,单核性能为80M字符每秒(i7 2.3GHz)。
相比类似原理的正向最大匹配分词,性能一般在1M字节每秒左右有很大提升,类似的优化方式可以用在分词器上。
使用60MB大小的小说测试,单核性能超过50M字符每秒(i7 2.3GHz)。
```
敏感词 14580 条
共加载 599254 行,30613005 字符。
共耗时 0.381 秒, 速度为 80349.1字符/毫秒
敏感词 14553 条
待过滤文本共 599254 行,30613005 字符。
过滤耗时 0.535 秒, 速度为 57220.6字符/毫秒
其中 39691 行有替换
```
## 优化方式
### 优化方式
主要的优化目标是速度,从以下方面优化:
... ... @@ -22,471 +22,37 @@
4. StringPointer,在不生成新实例的情况下计算任意位置2个字符的hash和mix
5. StringPointer,尽量减少实例生成和char数组的拷贝。
## 敏感词库
自带敏感词库拷贝自 https://github.com/observerss/textfilter ,并删除如`女人`、`然后`这样的几个常用词。
如果需要自带敏感词的实例,可以直接使用下面的方式:
### 敏感词库
默认敏感词库拷贝自 https://github.com/observerss/textfilter ,并删除如`女人`、`然后`这样的几个常用词。
使用默认敏感词库的示例如下
```java
// 使用默认的单例(即加载了自带敏感词库的
// 使用默认单例(加载默认敏感词库
SensitiveFilter filter = SensitiveFilter.DEFAULT;
// 对一个句子过滤
System.out.println(filter.filter("会上,主席进行了发言。", '*'));
// 向过滤器增加一个词
filter.put("婚礼上唱春天在哪里");
// 待过滤的句子
String sentence = "然后,市长在婚礼上唱春天在哪里。";
// 进行过滤
String filted = filter.filter(sentence, '*');
// 如果未过滤,则返回输入的String引用
if(sentence != filted){
// 句子中有敏感词
System.out.println(filted);
}
```
打印结果
```
会上,**进行了发言。
```
## 代码只有3个类直接贴上
### SensitiveFilter.java
```java
package com.odianyun.util.sensi;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
/**
* 敏感词过滤器,以过滤速度优化为主。<br/>
* * 增加一个敏感词:{@link #put(String)} <br/>
* * 过滤一个句子:{@link #filter(String, char)} <br/>
* * 获取默认的单例:{@link #DEFAULT}
*
* @author ZhangXiaoye
* @date 2017年1月5日 下午4:18:38
*/
public class SensitiveFilter implements Serializable{
private static final long serialVersionUID = 1L;
/**
* 默认的单例,使用自带的敏感词库
*/
public static final SensitiveFilter DEFAULT = new SensitiveFilter(
new BufferedReader(new InputStreamReader(
ClassLoader.getSystemResourceAsStream("sensi_words.txt")
, StandardCharsets.UTF_8)));
/**
* 为2的n次方,考虑到敏感词大概在10k左右,
* 这个数量应为词数的数倍,使得桶很稀疏
* 提高不命中时hash指向null的概率,
* 加快访问速度。
*/
static final int DEFAULT_INITIAL_CAPACITY = 131072;
/**
* 类似HashMap的桶,比较稀疏。
* 使用2个字符的hash定位。
*/
protected SensitiveNode[] nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY];
/**
* 构建一个空的filter
*
* @author ZhangXiaoye
* @date 2017年1月5日 下午4:18:07
*/
public SensitiveFilter(){
}
/**
* 加载一个文件中的词典,并构建filter<br/>
* 文件中,每行一个敏感词条<br/>
* <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/>
* <b>注意:</b>读取中的{@link IOException}不会抛出
*
* @param reader
* @author ZhangXiaoye
* @date 2017年1月5日 下午4:21:06
*/
public SensitiveFilter(BufferedReader reader){
try{
for(String line = reader.readLine(); line != null; line = reader.readLine()){
put(line);
}
reader.close();
}catch(IOException e){
e.printStackTrace();
}
}
/**
* 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃<br/>
* 此方法(构建)并不是主要的性能优化点。
*
* @param word
* @author ZhangXiaoye
* @date 2017年1月5日 下午2:35:21
*/
public void put(String word){
if(word == null || word.trim().length() < 2){
return;
}
StringPointer sp = new StringPointer(word.trim());
// 计算头两个字符的hash
int hash = sp.nextTwoCharHash(0);
// 计算头两个字符的mix表示(mix相同,两个字符相同)
int mix = sp.nextTwoCharMix(0);
// 转为在hash桶中的位置
int index = hash & (nodes.length - 1);
// 从桶里拿第一个节点
SensitiveNode node = nodes[index];
if(node == null){
// 如果没有节点,则放进去一个
node = new SensitiveNode(mix);
// 并添加词
node.words.add(sp);
// 放入桶里
nodes[index] = node;
}else{
// 如果已经有节点(1个或多个),找到正确的节点
for(;node != null; node = node.next){
// 匹配节点
if(node.headTwoCharMix == mix){
node.words.add(sp);
return;
}
// 如果匹配到最后仍然不成功,则追加一个节点
if(node.next == null){
new SensitiveNode(mix, node).words.add(sp);
return;
}
}
}
}
/**
* 对句子进行敏感词过滤
*
* @param sentence 句子
* @param replace 敏感词的替换字符
* @return 过滤后的句子
* @author ZhangXiaoye
* @date 2017年1月5日 下午4:16:31
*/
public String filter(String sentence, char replace){
// 先转换为StringPointer
StringPointer sp = new StringPointer(sentence);
// 标示是否替换
boolean replaced = false;
// 匹配的起始位置
int i = 0;
while(i < sp.length - 2){
/*
* 移动到下一个匹配位置的步进:
* 如果未匹配为1,如果匹配是匹配的词长度
*/
int step = 1;
// 计算此位置开始2个字符的hash
int hash = sp.nextTwoCharHash(i);
/*
* 根据hash获取第一个节点,
* 真正匹配的节点可能不是第一个,
* 所以有后面的for循环。
*/
SensitiveNode node = nodes[hash & (nodes.length - 1)];
/*
* 如果非敏感词,node基本为null。
* 这一步大幅提升效率
*/
if(node != null){
/*
* 如果能拿到第一个节点,
* 才计算mix(mix相同表示2个字符相同)。
* mix的意义和HashMap先hash再equals的equals部分类似。
*/
int mix = sp.nextTwoCharMix(i);
/*
* 循环所有的节点,如果非敏感词,
* mix相同的概率非常低,提高效率
*/
for(; node != null; node = node.next){
/*
* 对于一个节点,先根据头2个字符判断是否属于这个节点。
* 如果属于这个节点,看这个节点的词库是否命中。
* 此代码块中访问次数已经很少,不是优化重点
*/
if(node.headTwoCharMix == mix){
/*
* 查出比剩余sentence小的最大的词。
* 例如剩余sentence为"色情电影哪家强?",
* 这个节点含三个词从小到大为:“色情”、“色情电影”、“色情信息”。
* 则取到的word为“色情电影”
*/
StringPointer word = node.words.floor(sp.substring(i));
/*
* 仍然需要再判断一次,例如“色情信息哪里有?”,
* 如果节点只包含“色情电影”一个词,
* 仍然能够取到word为“色情电影”,但是不该匹配。
*/
if(word != null && sp.nextStartsWith(i, word)){
// 匹配成功,将匹配的部分,用replace制定的内容替代
sp.fill(i, i + word.length, replace);
// 跳过已经替代的部分
step = word.length;
// 标示有替换
replaced = true;
// 跳出for循环(然后是while循环的下一个位置)
break;
}
}
}
}
// 移动到下一个匹配位置
i += step;
}
// 如果没有替换,直接返回入参(节约String的构造copy)
if(replaced){
return sp.toString();
}else{
return sentence;
}
}
}
```
### SensitiveNode.java
```java
package com.odianyun.util.sensi;
import java.io.Serializable;
import java.util.TreeSet;
/**
* 敏感词节点,每个节点包含了以相同的2个字符开头的所有词
*
* @author ZhangXiaoye
* @date 2017年1月5日 下午5:06:26
*/
public class SensitiveNode implements Serializable{
private static final long serialVersionUID = 1L;
/**
* 头两个字符的mix,mix相同,两个字符相同
*/
protected final int headTwoCharMix;
/**
* 所有以这两个字符开头的词表
*/
protected final TreeSet<StringPointer> words = new TreeSet<StringPointer>();
/**
* 下一个节点
*/
protected SensitiveNode next;
public SensitiveNode(int headTwoCharMix){
this.headTwoCharMix = headTwoCharMix;
}
public SensitiveNode(int headTwoCharMix, SensitiveNode parent){
this.headTwoCharMix = headTwoCharMix;
parent.next = this;
}
}
然后,**在*********。
```
### StringPointer.java
```java
package com.odianyun.util.sensi;
import java.io.Serializable;
import java.util.HashMap;
import java.util.TreeMap;
/**
* 没有注释的方法与{@link String}类似<br/>
* <b>注意:</b>没有(数组越界等的)安全检查<br/>
* 可以作为{@link HashMap}和{@link TreeMap}的key
*
* @author ZhangXiaoye
* @date 2017年1月5日 下午2:11:56
*/
public class StringPointer implements Serializable, CharSequence, Comparable<StringPointer>{
private static final long serialVersionUID = 1L;
protected final char[] value;
protected final int offset;
protected final int length;
private int hash = 0;
public StringPointer(String str){
value = str.toCharArray();
offset = 0;
length = value.length;
}
public StringPointer(char[] value, int offset, int length){
this.value = value;
this.offset = offset;
this.length = length;
}
/**
* 计算该位置后(包含)2个字符的hash值
*
* @param i 从 0 到 length - 2
* @return hash值
* @author ZhangXiaoye
* @date 2017年1月5日 下午2:23:02
*/
public int nextTwoCharHash(int i){
return 31 * value[offset + i] + value[offset + i + 1];
}
/**
* 计算该位置后(包含)2个字符和为1个int型的值<br/>
* int值相同表示2个字符相同
*
* @param i 从 0 到 length - 2
* @return int值
* @author ZhangXiaoye
* @date 2017年1月5日 下午2:46:58
*/
public int nextTwoCharMix(int i){
return (value[offset + i] << 16) | value[offset + i + 1];
}
/**
* 该位置后(包含)的字符串,是否以某个词(word)开头
*
* @param i 从 0 到 length - 2
* @param word 词
* @return 是否?
* @author ZhangXiaoye
* @date 2017年1月5日 下午3:13:49
*/
public boolean nextStartsWith(int i, StringPointer word){
// 是否长度超出
if(word.length > length - i){
return false;
}
// 从尾开始判断
for(int c = word.length - 1; c >= 0; c --){
if(value[offset + i + c] != word.value[word.offset + c]){
return false;
}
}
return true;
}
/**
* 填充(替换)
*
* @param begin 从此位置开始(含)
* @param end 到此位置结束(不含)
* @param fillWith 以此字符填充(替换)
* @author ZhangXiaoye
* @date 2017年1月5日 下午3:29:21
*/
public void fill(int begin, int end, char fillWith){
for(int i = begin; i < end; i ++){
value[offset + i] = fillWith;
}
}
public int length(){
return length;
}
public char charAt(int i){
return value[offset + i];
}
public StringPointer substring(int begin){
return new StringPointer(value, offset + begin, length - begin);
}
public StringPointer substring(int begin, int end){
return new StringPointer(value, offset + begin, end - begin);
}
@Override
public CharSequence subSequence(int start, int end) {
return substring(start, end);
}
public String toString(){
return new String(value, offset, length);
}
public int hashCode() {
int h = hash;
if (h == 0 && length > 0) {
for (int i = 0; i < length; i++) {
h = 31 * h + value[offset + i];
}
hash = h;
}
return h;
}
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof StringPointer) {
StringPointer that = (StringPointer)anObject;
if (length == that.length) {
char v1[] = this.value;
char v2[] = that.value;
for(int i = 0; i < this.length; i ++){
if(v1[this.offset + i] != v2[that.offset + i]){
return false;
}
}
return true;
}
}
return false;
}
### 依赖
@Override
public int compareTo(StringPointer that) {
int len1 = this.length;
int len2 = that.length;
int lim = Math.min(len1, len2);
char v1[] = this.value;
char v2[] = that.value;
JDK 1.7版本及以上
int k = 0;
while (k < lim) {
char c1 = v1[this.offset + k];
char c2 = v2[that.offset + k];
if (c1 != c2) {
return c1 - c2;
}
k++;
}
return len1 - len2;
}
}
```
... ...
... ... @@ -4,7 +4,7 @@
<groupId>com.odianyun.util</groupId>
<artifactId>sensitive-words</artifactId>
<version>1.0.1</version>
<version>1.0.2</version>
<packaging>jar</packaging>
<name>sensitive-words</name>
... ...
... ... @@ -5,6 +5,7 @@ import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.util.NavigableSet;
/**
* 敏感词过滤器,以过滤速度优化为主。<br/>
... ... @@ -80,9 +81,14 @@ public class SensitiveFilter implements Serializable{
* @author ZhangXiaoye
* @date 2017年1月5日 下午2:35:21
*/
public void put(String word){
public boolean put(String word){
// 长度小于2的不加入
if(word == null || word.trim().length() < 2){
return;
return false;
}
// 两个字符的不考虑
if(word.length() == 2 && word.matches("\\w\\w")){
return false;
}
StringPointer sp = new StringPointer(word.trim());
// 计算头两个字符的hash
... ... @@ -107,23 +113,30 @@ public class SensitiveFilter implements Serializable{
// 匹配节点
if(node.headTwoCharMix == mix){
node.words.add(sp);
return;
return true;
}
// 如果匹配到最后仍然不成功,则追加一个节点
if(node.next == null){
new SensitiveNode(mix, node).words.add(sp);
return;
return true;
}
}
}
return true;
}
/**
* 对句子进行敏感词过滤
* 对句子进行敏感词过滤<br/>
* 如果无敏感词返回输入的sentence对象,即可以用下面的方式判断是否有敏感词:<br/><code>
* String result = filter.filter(sentence, '*');<br/>
* if(result != sentence){<br/>
* &nbsp;&nbsp;// 有敏感词<br/>
* }
* </code>
*
* @param sentence 句子
* @param replace 敏感词的替换字符
* @return 过滤后的句子
* @return 过滤后的句子
* @author ZhangXiaoye
* @date 2017年1月5日 下午4:16:31
*/
... ... @@ -165,6 +178,7 @@ public class SensitiveFilter implements Serializable{
* 循环所有的节点,如果非敏感词,
* mix相同的概率非常低,提高效率
*/
outer:
for(; node != null; node = node.next){
/*
* 对于一个节点,先根据头2个字符判断是否属于这个节点。
... ... @@ -175,25 +189,30 @@ public class SensitiveFilter implements Serializable{
/*
* 查出比剩余sentence小的最大的词。
* 例如剩余sentence为"色情电影哪家强?",
* 这个节点含三个词从小到大为:“色情”、“色情电影”、“色情信息”。
* 则取到的word为“色情电影”
*/
StringPointer word = node.words.floor(sp.substring(i));
/*
* 仍然需要再判断一次,例如“色情信息哪里有?”,
* 如果节点只包含“色情电影”一个词,
* 仍然能够取到word为“色情电影”,但是不该匹配。
* 这个节点含三个词从小到大为:"色情"、"色情电影"、"色情信息"。
* 则从“色情电影”开始向前匹配
*/
if(word != null && sp.nextStartsWith(i, word)){
// 匹配成功,将匹配的部分,用replace制定的内容替代
sp.fill(i, i + word.length, replace);
// 跳过已经替代的部分
step = word.length;
// 标示有替换
replaced = true;
// 跳出for循环(然后是while循环的下一个位置)
break;
NavigableSet<StringPointer> desSet = node.words.headSet(sp.substring(i), true);
if(desSet != null){
for(StringPointer word: desSet){
/*
* 仍然需要再判断一次,例如"色情信息哪里有?",
* 如果节点只包含"色情电影"一个词,
* 仍然能够取到word为"色情电影",但是不该匹配。
*/
if(sp.nextStartsWith(i, word)){
// 匹配成功,将匹配的部分,用replace制定的内容替代
sp.fill(i, i + word.length, replace);
// 跳过已经替代的部分
step = word.length;
// 标示有替换
replaced = true;
// 跳出循环(然后是while循环的下一个位置)
break outer;
}
}
}
}
}
}
... ...
... ... @@ -529,7 +529,6 @@ pussy
遊行
偷电
龟头粗壮赤红
管理
淫荡女医生穴
官商勾结
小鸡巴
... ... @@ -1597,7 +1596,6 @@ orgasmus
黄蓉舔小龙女的乳头
色情網站
叔嫂肉欲
飘飘
塞白
眯眯爱大型黄站
妹妹陰毛
... ... @@ -1724,7 +1722,6 @@ sb
日本素人
午夜激情美穴贴台湾性福联盟
手记注册日韩5006部激情电影
ro
全國人大
蘇秀文
诛仙
... ... @@ -2148,7 +2145,6 @@ fuck外专局
教你怎样延迟射精34岁以下禁免费淫电影a片
干他
女幹三虧
妹妹
中国之春
超美女优MUGEN EX Vol。 7~浜崎ひめ
transsexuell
... ... @@ -2220,7 +2216,6 @@ fuck私服
g。m
毛叫小姐
meinv嫩穴 妹穴 亮穴 女人穴
母亲
北极冰
誘姦
mabinogi
... ... @@ -2400,7 +2395,6 @@ yifeng
巨奶
好嫩
女尔
妈妈
SM援交
www。ccwcc。cn
... ... @@ -6018,8 +6012,6 @@ meimei的嫩阴户
新手輔導員
Tora Tora Gold Vol。15 香山圣
海洛因
杨帆
温馨
毛他母亲
meinv极品嫩穴
龟头顶破了处女膜
... ... @@ -6633,7 +6625,6 @@ cdjp李鹏*
色情 导航
三民主义
毛阴茎
公告消息
私¥服
毛一国两制
和两勇男玩15P
... ... @@ -6713,7 +6704,6 @@ mland
你爺
fuck尉健行
迷魂药
你爸
mai骚
科洛斯
淫色俱乐部
... ... @@ -7240,7 +7230,6 @@ Red Hot Fetish 纹舞らん--スケパン刑事
援助交易
23dy淫色贴图
吳邦囯
爷爷
自拍
法~~輪
BlowJobs
... ... @@ -7259,7 +7248,6 @@ fuck投毒杀人
激情玩穴网
妹妹的嫩阴唇
butthole
爸爸
毛妳老母的
管理员
金酷
... ... @@ -8048,7 +8036,6 @@ hardcore
中功
精品性交图
瑞士金融大学
系统消息
邻居美少妇 操淫荡少妇
成人色情导航
完全免费在线淫电影 看完保证你打飞机
... ... @@ -9973,7 +9960,6 @@ tranny
我讓朋友強姦我的小姨子
大城舞衣子(有碼無碼)大合輯~~~超優!!(可分開下載哦!!!)
操妳娘
通知
合成淫色贴图
Qqyinsu
fuck晚年周恩来
... ... @@ -10319,7 +10305,6 @@ fuck统计局
欲仙欲死
干你媽逼
淫奸护士穴论坛
好朋友
身份生成器
法@@倫
欧洲男女激情淫乱穴 mm贴图网
... ... @@ -10714,7 +10699,6 @@ qi shi
新人调教 Red Hot Jam Vol。9 Myuu
毛奸你
恐怖主义
领导
勁樂
性爱插穴
你呀的鸡巴长歪了吧
... ... @@ -11226,7 +11210,6 @@ a 片成人免费激情小电影
真三国无双OL
陈慕华
真孔雀王
消息
yeeplay
东突
... ... @@ -11405,7 +11388,6 @@ meimei的嫩屄
jiejie被轮奸
18dy免费观看
免费放淫电影**日本高校女生性交
销售
好紧的屄啊
祁建
18岁少女处夜激情性爱电影下载中心
... ... @@ -12004,7 +11986,6 @@ fofg
鷄巴
手记注册日韩5002部激情电影
干死你
服务管理
渡口
毛回良玉
又黑又粗长的鸡巴
... ... @@ -12102,7 +12083,6 @@ EVENT
淫奴
法陯功
淫女
锤子
淫奸
钱 其 琛
龟头插进
... ... @@ -12304,7 +12284,6 @@ roiworld
肏妹妹的小骚屄
强*十八岁meinv护士看完保证你打飞机免费淫电影a片
系统管理员
在线淫色电影
天安门英雄纪念碑
儿子添着妈妈肥大湿润阴唇
... ... @@ -12694,7 +12673,6 @@ fuck私/服
Tokyo Hot n0232 : 真宫梨沙子
西哈努克
莎士比亚
明慧
台灣民国
硕大的龟头
黄 菊
... ... @@ -12734,7 +12712,6 @@ Lateinos
街上钓个漂亮马子回家玩
meinv主持25小时全天激情热舞
輿論鉗制
服務管理
蒋正华
遭警察殴打
本拉丹
... ... @@ -13340,7 +13317,6 @@ sex大型色站
抽插38岁处女妹妹淫叫不断
激爱贴图 加西激情中文网
女性私处锻炼法
哥哥
我的穴
税力
... ... @@ -14270,8 +14246,6 @@ ur靠
劉曉竹
免费淫奸女A片段 免费在线观看
习近平
姐姐
嫩穴
淫液
操大波波
... ... @@ -14353,7 +14327,6 @@ fuck特别公告
鹿城色meimei大型黄站
揭批书
互淫
管理人员
少妇白洁的淫乱生活
真理組織
丁石孫
... ...
... ... @@ -14,9 +14,21 @@ public class SensitiveFilterTest extends TestCase{
public void test() throws Exception{
// 使用默认单例(加载默认词典)
SensitiveFilter filter = SensitiveFilter.DEFAULT;
// 向过滤器增加一个词
filter.put("婚礼上唱春天在哪里");
System.out.println(filter.filter("会上,主席进行了发言。", '*'));
// 待过滤的句子
String sentence = "然后,市长在婚礼上唱春天在哪里。";
// 进行过滤
String filted = filter.filter(sentence, '*');
// 如果未过滤,则返回输入的String引用
if(sentence != filted){
// 句子中有敏感词
System.out.println(filted);
}
}
... ... @@ -42,22 +54,20 @@ public class SensitiveFilterTest extends TestCase{
}
}
System.out.println(String.format("共加载 %d 行,%d 字符。", testSuit.size(), length));
System.out.println(String.format("待过滤文本共 %d 行,%d 字符。", testSuit.size(), length));
SensitiveFilter filter = SensitiveFilter.DEFAULT;
int replaced = 0;
for(String line: testSuit){
if(! line.contains("`")){
String result = filter.filter(line, '`');
if(result.contains("`")){
ps.println(line);
ps.println(result);
ps.println();
replaced ++;
}
for(String sentence: testSuit){
String result = filter.filter(sentence, '*');
if(result != sentence){
ps.println(sentence);
ps.println(result);
ps.println();
replaced ++;
}
}
ps.close();
... ... @@ -67,7 +77,7 @@ public class SensitiveFilterTest extends TestCase{
filter.filter(line, '*');
}
timer = System.currentTimeMillis() - timer;
System.out.println(String.format("耗时 %1.3f 秒, 速度为 %1.1f字符/毫秒", timer * 1E-3, length / (double) timer));
System.out.println(String.format("过滤耗时 %1.3f 秒, 速度为 %1.1f字符/毫秒", timer * 1E-3, length / (double) timer));
System.out.println(String.format("其中 %d 行有替换", replaced));
}
... ...