作者 Eluli Simpray

first commit

  1 +# 高效敏感词过滤
  2 +
  3 +[TOC]
  4 +
  5 +## 性能概述
  6 +
  7 +在共60M穿越小说上测试,单核性能为80M字符每秒(i7 2.3GHz)。
  8 +相比类似原理的正向最大匹配分词,性能一般在1M字节每秒左右有很大提升,类似的优化方式可以用在分词器上。
  9 +
  10 +```
  11 +敏感词 14580 条
  12 +共加载 599254 行,30613005 字符。
  13 +共耗时 0.381 秒, 速度为 80349.1字符/毫秒
  14 +```
  15 +
  16 +## 优化方式
  17 +
  18 +主要的优化目标是速度,从以下方面优化:
  19 +1. 敏感词都是2个字以上的,
  20 +2. 对于句子中的一个位置,用2个字符的hash在稀疏的hash桶中查找,如果查不到说明一定不是敏感词,则继续下一个位置。
  21 +3. 2个字符(2x16位),可以预先组合为1个int(32位)的mix,即使hash命中,如果mix不同则跳过。
  22 +4. StringPointer,在不生成新实例的情况下计算任意位置2个字符的hash和mix
  23 +5. StringPointer,尽量减少实例生成和char数组的拷贝。
  24 +
  25 +## 敏感词库
  26 +
  27 +自带敏感词库拷贝自 https://github.com/observerss/textfilter ,并删除如`女人`、`然后`这样的几个常用词。
  28 +如果需要自带敏感词的实例,可以直接使用下面的方式:
  29 +
  30 +
  31 +```java
  32 +// 使用默认的单例(即加载了自带敏感词库的)
  33 +SensitiveFilter filter = SensitiveFilter.DEFAULT;
  34 +// 对一个句子过滤
  35 +System.out.println(filter.filter("会上,主席进行了发言。", '*'));
  36 +```
  37 +
  38 +打印结果
  39 +
  40 +```
  41 +会上,**进行了发言。
  42 +```
  43 +
  44 +## 代码只有3个类如下
  45 +
  46 +
  47 +### SensitiveFilter.java
  48 +```java
  49 +package com.odianyun.util.sensi;
  50 +
  51 +import java.io.BufferedReader;
  52 +import java.io.IOException;
  53 +import java.io.InputStreamReader;
  54 +import java.io.Serializable;
  55 +import java.nio.charset.StandardCharsets;
  56 +
  57 +/**
  58 + * 敏感词过滤器,以过滤速度优化为主。<br/>
  59 + * * 增加一个敏感词:{@link #put(String)} <br/>
  60 + * * 过滤一个句子:{@link #filter(String, char)} <br/>
  61 + * * 获取默认的单例:{@link #DEFAULT}
  62 + *
  63 + * @author ZhangXiaoye
  64 + * @date 2017年1月5日 下午4:18:38
  65 + */
  66 +public class SensitiveFilter implements Serializable{
  67 +
  68 + private static final long serialVersionUID = 1L;
  69 +
  70 + /**
  71 + * 默认的单例,使用自带的敏感词库
  72 + */
  73 + public static final SensitiveFilter DEFAULT = new SensitiveFilter(
  74 + new BufferedReader(new InputStreamReader(
  75 + ClassLoader.getSystemResourceAsStream("sensi_words.txt")
  76 + , StandardCharsets.UTF_8)));
  77 +
  78 + /**
  79 + * 为2的n次方,考虑到敏感词大概在10k左右,
  80 + * 这个数量应为词数的数倍,使得桶很稀疏
  81 + * 提高不命中时hash指向null的概率,
  82 + * 加快访问速度。
  83 + */
  84 + static final int DEFAULT_INITIAL_CAPACITY = 131072;
  85 +
  86 + /**
  87 + * 类似HashMap的桶,比较稀疏。
  88 + * 使用2个字符的hash定位。
  89 + */
  90 + protected SensitiveNode[] nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY];
  91 +
  92 + /**
  93 + * 构建一个空的filter
  94 + *
  95 + * @author ZhangXiaoye
  96 + * @date 2017年1月5日 下午4:18:07
  97 + */
  98 + public SensitiveFilter(){
  99 +
  100 + }
  101 +
  102 + /**
  103 + * 加载一个文件中的词典,并构建filter<br/>
  104 + * 文件中,每行一个敏感词条<br/>
  105 + * <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/>
  106 + * <b>注意:</b>读取中的{@link IOException}不会抛出
  107 + *
  108 + * @param reader
  109 + * @author ZhangXiaoye
  110 + * @date 2017年1月5日 下午4:21:06
  111 + */
  112 + public SensitiveFilter(BufferedReader reader){
  113 + try{
  114 + for(String line = reader.readLine(); line != null; line = reader.readLine()){
  115 + put(line);
  116 + }
  117 + reader.close();
  118 + }catch(IOException e){
  119 + e.printStackTrace();
  120 + }
  121 + }
  122 +
  123 + /**
  124 + * 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃<br/>
  125 + * 此方法(构建)并不是主要的性能优化点。
  126 + *
  127 + * @param word
  128 + * @author ZhangXiaoye
  129 + * @date 2017年1月5日 下午2:35:21
  130 + */
  131 + public void put(String word){
  132 + if(word == null || word.trim().length() < 2){
  133 + return;
  134 + }
  135 + StringPointer sp = new StringPointer(word.trim());
  136 + // 计算头两个字符的hash
  137 + int hash = sp.nextTwoCharHash(0);
  138 + // 计算头两个字符的mix表示(mix相同,两个字符相同)
  139 + int mix = sp.nextTwoCharMix(0);
  140 + // 转为在hash桶中的位置
  141 + int index = hash & (nodes.length - 1);
  142 +
  143 + // 从桶里拿第一个节点
  144 + SensitiveNode node = nodes[index];
  145 + if(node == null){
  146 + // 如果没有节点,则放进去一个
  147 + node = new SensitiveNode(mix);
  148 + // 并添加词
  149 + node.words.add(sp);
  150 + // 放入桶里
  151 + nodes[index] = node;
  152 + }else{
  153 + // 如果已经有节点(1个或多个),找到正确的节点
  154 + for(;node != null; node = node.next){
  155 + // 匹配节点
  156 + if(node.headTwoCharMix == mix){
  157 + node.words.add(sp);
  158 + return;
  159 + }
  160 + // 如果匹配到最后仍然不成功,则追加一个节点
  161 + if(node.next == null){
  162 + new SensitiveNode(mix, node).words.add(sp);
  163 + return;
  164 + }
  165 + }
  166 + }
  167 + }
  168 +
  169 + /**
  170 + * 对句子进行敏感词过滤
  171 + *
  172 + * @param sentence 句子
  173 + * @param replace 敏感词的替换字符
  174 + * @return 过滤后的句子
  175 + * @author ZhangXiaoye
  176 + * @date 2017年1月5日 下午4:16:31
  177 + */
  178 + public String filter(String sentence, char replace){
  179 + // 先转换为StringPointer
  180 + StringPointer sp = new StringPointer(sentence);
  181 +
  182 + // 标示是否替换
  183 + boolean replaced = false;
  184 +
  185 + // 匹配的起始位置
  186 + int i = 0;
  187 + while(i < sp.length - 2){
  188 + /*
  189 + * 移动到下一个匹配位置的步进:
  190 + * 如果未匹配为1,如果匹配是匹配的词长度
  191 + */
  192 + int step = 1;
  193 + // 计算此位置开始2个字符的hash
  194 + int hash = sp.nextTwoCharHash(i);
  195 + /*
  196 + * 根据hash获取第一个节点,
  197 + * 真正匹配的节点可能不是第一个,
  198 + * 所以有后面的for循环。
  199 + */
  200 + SensitiveNode node = nodes[hash & (nodes.length - 1)];
  201 + /*
  202 + * 如果非敏感词,node基本为null。
  203 + * 这一步大幅提升效率
  204 + */
  205 + if(node != null){
  206 + /*
  207 + * 如果能拿到第一个节点,
  208 + * 才计算mix(mix相同表示2个字符相同)。
  209 + * mix的意义和HashMap先hash再equals的equals部分类似。
  210 + */
  211 + int mix = sp.nextTwoCharMix(i);
  212 + /*
  213 + * 循环所有的节点,如果非敏感词,
  214 + * mix相同的概率非常低,提高效率
  215 + */
  216 + for(; node != null; node = node.next){
  217 + /*
  218 + * 对于一个节点,先根据头2个字符判断是否属于这个节点。
  219 + * 如果属于这个节点,看这个节点的词库是否命中。
  220 + * 此代码块中访问次数已经很少,不是优化重点
  221 + */
  222 + if(node.headTwoCharMix == mix){
  223 + /*
  224 + * 查出比剩余sentence小的最大的词。
  225 + * 例如剩余sentence为"色情电影哪家强?",
  226 + * 这个节点含三个词从小到大为:“色情”、“色情电影”、“色情信息”。
  227 + * 则取到的word为“色情电影”
  228 + */
  229 + StringPointer word = node.words.floor(sp.substring(i));
  230 + /*
  231 + * 仍然需要再判断一次,例如“色情信息哪里有?”,
  232 + * 如果节点只包含“色情电影”一个词,
  233 + * 仍然能够取到word为“色情电影”,但是不该匹配。
  234 + */
  235 + if(word != null && sp.nextStartsWith(i, word)){
  236 + // 匹配成功,将匹配的部分,用replace制定的内容替代
  237 + sp.fill(i, i + word.length, replace);
  238 + // 跳过已经替代的部分
  239 + step = word.length;
  240 + // 标示有替换
  241 + replaced = true;
  242 + // 跳出for循环(然后是while循环的下一个位置)
  243 + break;
  244 + }
  245 + }
  246 + }
  247 + }
  248 +
  249 + // 移动到下一个匹配位置
  250 + i += step;
  251 + }
  252 +
  253 + // 如果没有替换,直接返回入参(节约String的构造copy)
  254 + if(replaced){
  255 + return sp.toString();
  256 + }else{
  257 + return sentence;
  258 + }
  259 + }
  260 +
  261 +}
  262 +```
  263 +
  264 +### SensitiveNode.java
  265 +
  266 +
  267 +```java
  268 +package com.odianyun.util.sensi;
  269 +
  270 +import java.io.Serializable;
  271 +import java.util.TreeSet;
  272 +
  273 +/**
  274 + * 敏感词节点,每个节点包含了以相同的2个字符开头的所有词
  275 + *
  276 + * @author ZhangXiaoye
  277 + * @date 2017年1月5日 下午5:06:26
  278 + */
  279 +public class SensitiveNode implements Serializable{
  280 +
  281 + private static final long serialVersionUID = 1L;
  282 +
  283 + /**
  284 + * 头两个字符的mix,mix相同,两个字符相同
  285 + */
  286 + protected final int headTwoCharMix;
  287 +
  288 + /**
  289 + * 所有以这两个字符开头的词表
  290 + */
  291 + protected final TreeSet<StringPointer> words = new TreeSet<StringPointer>();
  292 +
  293 + /**
  294 + * 下一个节点
  295 + */
  296 + protected SensitiveNode next;
  297 +
  298 + public SensitiveNode(int headTwoCharMix){
  299 + this.headTwoCharMix = headTwoCharMix;
  300 + }
  301 +
  302 + public SensitiveNode(int headTwoCharMix, SensitiveNode parent){
  303 + this.headTwoCharMix = headTwoCharMix;
  304 + parent.next = this;
  305 + }
  306 +
  307 +}
  308 +```
  309 +
  310 +### StringPointer.java
  311 +
  312 +```java
  313 +package com.odianyun.util.sensi;
  314 +
  315 +import java.io.Serializable;
  316 +import java.util.HashMap;
  317 +import java.util.TreeMap;
  318 +
  319 +/**
  320 + * 没有注释的方法与{@link String}类似<br/>
  321 + * <b>注意:</b>没有(数组越界等的)安全检查<br/>
  322 + * 可以作为{@link HashMap}和{@link TreeMap}的key
  323 + *
  324 + * @author ZhangXiaoye
  325 + * @date 2017年1月5日 下午2:11:56
  326 + */
  327 +public class StringPointer implements Serializable, CharSequence, Comparable<StringPointer>{
  328 +
  329 + private static final long serialVersionUID = 1L;
  330 +
  331 + protected final char[] value;
  332 +
  333 + protected final int offset;
  334 +
  335 + protected final int length;
  336 +
  337 + private int hash = 0;
  338 +
  339 + public StringPointer(String str){
  340 + value = str.toCharArray();
  341 + offset = 0;
  342 + length = value.length;
  343 + }
  344 +
  345 + public StringPointer(char[] value, int offset, int length){
  346 + this.value = value;
  347 + this.offset = 0;
  348 + this.length = value.length;
  349 + }
  350 +
  351 + /**
  352 + * 计算该位置后(包含)2个字符的hash值
  353 + *
  354 + * @param i 从 0 到 length - 2
  355 + * @return hash值
  356 + * @author ZhangXiaoye
  357 + * @date 2017年1月5日 下午2:23:02
  358 + */
  359 + public int nextTwoCharHash(int i){
  360 + return 31 * value[offset + i] + value[offset + i + 1];
  361 + }
  362 +
  363 + /**
  364 + * 计算该位置后(包含)2个字符和为1个int型的值<br/>
  365 + * int值相同表示2个字符相同
  366 + *
  367 + * @param i 从 0 到 length - 2
  368 + * @return int值
  369 + * @author ZhangXiaoye
  370 + * @date 2017年1月5日 下午2:46:58
  371 + */
  372 + public int nextTwoCharMix(int i){
  373 + return (value[offset + i] << 16) | value[offset + i + 1];
  374 + }
  375 +
  376 + /**
  377 + * 该位置后(包含)的字符串,是否以某个词(word)开头
  378 + *
  379 + * @param i 从 0 到 length - 2
  380 + * @param word 词
  381 + * @return 是否?
  382 + * @author ZhangXiaoye
  383 + * @date 2017年1月5日 下午3:13:49
  384 + */
  385 + public boolean nextStartsWith(int i, StringPointer word){
  386 + // 是否长度超出
  387 + if(word.length > length - i){
  388 + return false;
  389 + }
  390 + // 从尾开始判断
  391 + for(int c = word.length - 1; c >= 0; c --){
  392 + if(value[offset + i + c] != word.value[word.offset + c]){
  393 + return false;
  394 + }
  395 + }
  396 + return true;
  397 + }
  398 +
  399 + /**
  400 + * 填充(替换)
  401 + *
  402 + * @param begin 从此位置开始(含)
  403 + * @param end 到此位置结束(不含)
  404 + * @param fillWith 以此字符填充(替换)
  405 + * @author ZhangXiaoye
  406 + * @date 2017年1月5日 下午3:29:21
  407 + */
  408 + public void fill(int begin, int end, char fillWith){
  409 + for(int i = begin; i < end; i ++){
  410 + value[offset + i] = fillWith;
  411 + }
  412 + }
  413 +
  414 + public int length(){
  415 + return length;
  416 + }
  417 +
  418 + public char charAt(int i){
  419 + return value[offset + i];
  420 + }
  421 +
  422 + public StringPointer substring(int begin){
  423 + return new StringPointer(value, offset + begin, length - begin);
  424 + }
  425 +
  426 + public StringPointer substring(int begin, int end){
  427 + return new StringPointer(value, offset + begin, end - begin);
  428 + }
  429 +
  430 + @Override
  431 + public CharSequence subSequence(int start, int end) {
  432 + return substring(start, end);
  433 + }
  434 +
  435 + public String toString(){
  436 + return new String(value, offset, length);
  437 + }
  438 +
  439 + public int hashCode() {
  440 + int h = hash;
  441 + if (h == 0 && length > 0) {
  442 + for (int i = 0; i < length; i++) {
  443 + h = 31 * h + value[offset + i];
  444 + }
  445 + hash = h;
  446 + }
  447 + return h;
  448 + }
  449 +
  450 + public boolean equals(Object anObject) {
  451 + if (this == anObject) {
  452 + return true;
  453 + }
  454 + if (anObject instanceof StringPointer) {
  455 + StringPointer that = (StringPointer)anObject;
  456 + if (length == that.length) {
  457 + char v1[] = this.value;
  458 + char v2[] = that.value;
  459 + for(int i = 0; i < this.length; i ++){
  460 + if(v1[this.offset + i] != v2[that.offset + i]){
  461 + return false;
  462 + }
  463 + }
  464 + return true;
  465 + }
  466 + }
  467 + return false;
  468 + }
  469 +
  470 + @Override
  471 + public int compareTo(StringPointer that) {
  472 + int len1 = this.length;
  473 + int len2 = that.length;
  474 + int lim = Math.min(len1, len2);
  475 + char v1[] = this.value;
  476 + char v2[] = that.value;
  477 +
  478 + int k = 0;
  479 + while (k < lim) {
  480 + char c1 = v1[this.offset + k];
  481 + char c2 = v2[that.offset + k];
  482 + if (c1 != c2) {
  483 + return c1 - c2;
  484 + }
  485 + k++;
  486 + }
  487 + return len1 - len2;
  488 + }
  489 +
  490 +}
  491 +```
  492 +
  1 +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  2 + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  3 + <modelVersion>4.0.0</modelVersion>
  4 +
  5 + <groupId>com.odianyun.util</groupId>
  6 + <artifactId>sensitive-words</artifactId>
  7 + <version>1.0.1</version>
  8 + <packaging>jar</packaging>
  9 +
  10 + <name>sensitive-words</name>
  11 + <url>http://www.odianyun.com</url>
  12 +
  13 + <properties>
  14 + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  15 + </properties>
  16 +
  17 + <dependencies>
  18 + <dependency>
  19 + <groupId>junit</groupId>
  20 + <artifactId>junit</artifactId>
  21 + <version>3.8.1</version>
  22 + <scope>test</scope>
  23 + </dependency>
  24 + </dependencies>
  25 +
  26 +</project>
  1 +package com.odianyun.util.sensi;
  2 +
  3 +import java.io.BufferedReader;
  4 +import java.io.IOException;
  5 +import java.io.InputStreamReader;
  6 +import java.io.Serializable;
  7 +import java.nio.charset.StandardCharsets;
  8 +
  9 +/**
  10 + * 敏感词过滤器,以过滤速度优化为主。<br/>
  11 + * * 增加一个敏感词:{@link #put(String)} <br/>
  12 + * * 过滤一个句子:{@link #filter(String, char)} <br/>
  13 + * * 获取默认的单例:{@link #DEFAULT}
  14 + *
  15 + * @author ZhangXiaoye
  16 + * @date 2017年1月5日 下午4:18:38
  17 + */
  18 +public class SensitiveFilter implements Serializable{
  19 +
  20 + private static final long serialVersionUID = 1L;
  21 +
  22 + /**
  23 + * 默认的单例,使用自带的敏感词库
  24 + */
  25 + public static final SensitiveFilter DEFAULT = new SensitiveFilter(
  26 + new BufferedReader(new InputStreamReader(
  27 + ClassLoader.getSystemResourceAsStream("sensi_words.txt")
  28 + , StandardCharsets.UTF_8)));
  29 +
  30 + /**
  31 + * 为2的n次方,考虑到敏感词大概在10k左右,
  32 + * 这个数量应为词数的数倍,使得桶很稀疏
  33 + * 提高不命中时hash指向null的概率,
  34 + * 加快访问速度。
  35 + */
  36 + static final int DEFAULT_INITIAL_CAPACITY = 131072;
  37 +
  38 + /**
  39 + * 类似HashMap的桶,比较稀疏。
  40 + * 使用2个字符的hash定位。
  41 + */
  42 + protected SensitiveNode[] nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY];
  43 +
  44 + /**
  45 + * 构建一个空的filter
  46 + *
  47 + * @author ZhangXiaoye
  48 + * @date 2017年1月5日 下午4:18:07
  49 + */
  50 + public SensitiveFilter(){
  51 +
  52 + }
  53 +
  54 + /**
  55 + * 加载一个文件中的词典,并构建filter<br/>
  56 + * 文件中,每行一个敏感词条<br/>
  57 + * <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/>
  58 + * <b>注意:</b>读取中的{@link IOException}不会抛出
  59 + *
  60 + * @param reader
  61 + * @author ZhangXiaoye
  62 + * @date 2017年1月5日 下午4:21:06
  63 + */
  64 + public SensitiveFilter(BufferedReader reader){
  65 + try{
  66 + for(String line = reader.readLine(); line != null; line = reader.readLine()){
  67 + put(line);
  68 + }
  69 + reader.close();
  70 + }catch(IOException e){
  71 + e.printStackTrace();
  72 + }
  73 + }
  74 +
  75 + /**
  76 + * 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃<br/>
  77 + * 此方法(构建)并不是主要的性能优化点。
  78 + *
  79 + * @param word
  80 + * @author ZhangXiaoye
  81 + * @date 2017年1月5日 下午2:35:21
  82 + */
  83 + public void put(String word){
  84 + if(word == null || word.trim().length() < 2){
  85 + return;
  86 + }
  87 + StringPointer sp = new StringPointer(word.trim());
  88 + // 计算头两个字符的hash
  89 + int hash = sp.nextTwoCharHash(0);
  90 + // 计算头两个字符的mix表示(mix相同,两个字符相同)
  91 + int mix = sp.nextTwoCharMix(0);
  92 + // 转为在hash桶中的位置
  93 + int index = hash & (nodes.length - 1);
  94 +
  95 + // 从桶里拿第一个节点
  96 + SensitiveNode node = nodes[index];
  97 + if(node == null){
  98 + // 如果没有节点,则放进去一个
  99 + node = new SensitiveNode(mix);
  100 + // 并添加词
  101 + node.words.add(sp);
  102 + // 放入桶里
  103 + nodes[index] = node;
  104 + }else{
  105 + // 如果已经有节点(1个或多个),找到正确的节点
  106 + for(;node != null; node = node.next){
  107 + // 匹配节点
  108 + if(node.headTwoCharMix == mix){
  109 + node.words.add(sp);
  110 + return;
  111 + }
  112 + // 如果匹配到最后仍然不成功,则追加一个节点
  113 + if(node.next == null){
  114 + new SensitiveNode(mix, node).words.add(sp);
  115 + return;
  116 + }
  117 + }
  118 + }
  119 + }
  120 +
  121 + /**
  122 + * 对句子进行敏感词过滤
  123 + *
  124 + * @param sentence 句子
  125 + * @param replace 敏感词的替换字符
  126 + * @return 过滤后的句子
  127 + * @author ZhangXiaoye
  128 + * @date 2017年1月5日 下午4:16:31
  129 + */
  130 + public String filter(String sentence, char replace){
  131 + // 先转换为StringPointer
  132 + StringPointer sp = new StringPointer(sentence);
  133 +
  134 + // 标示是否替换
  135 + boolean replaced = false;
  136 +
  137 + // 匹配的起始位置
  138 + int i = 0;
  139 + while(i < sp.length - 2){
  140 + /*
  141 + * 移动到下一个匹配位置的步进:
  142 + * 如果未匹配为1,如果匹配是匹配的词长度
  143 + */
  144 + int step = 1;
  145 + // 计算此位置开始2个字符的hash
  146 + int hash = sp.nextTwoCharHash(i);
  147 + /*
  148 + * 根据hash获取第一个节点,
  149 + * 真正匹配的节点可能不是第一个,
  150 + * 所以有后面的for循环。
  151 + */
  152 + SensitiveNode node = nodes[hash & (nodes.length - 1)];
  153 + /*
  154 + * 如果非敏感词,node基本为null。
  155 + * 这一步大幅提升效率
  156 + */
  157 + if(node != null){
  158 + /*
  159 + * 如果能拿到第一个节点,
  160 + * 才计算mix(mix相同表示2个字符相同)。
  161 + * mix的意义和HashMap先hash再equals的equals部分类似。
  162 + */
  163 + int mix = sp.nextTwoCharMix(i);
  164 + /*
  165 + * 循环所有的节点,如果非敏感词,
  166 + * mix相同的概率非常低,提高效率
  167 + */
  168 + for(; node != null; node = node.next){
  169 + /*
  170 + * 对于一个节点,先根据头2个字符判断是否属于这个节点。
  171 + * 如果属于这个节点,看这个节点的词库是否命中。
  172 + * 此代码块中访问次数已经很少,不是优化重点
  173 + */
  174 + if(node.headTwoCharMix == mix){
  175 + /*
  176 + * 查出比剩余sentence小的最大的词。
  177 + * 例如剩余sentence为"色情电影哪家强?",
  178 + * 这个节点含三个词从小到大为:“色情”、“色情电影”、“色情信息”。
  179 + * 则取到的word为“色情电影”
  180 + */
  181 + StringPointer word = node.words.floor(sp.substring(i));
  182 + /*
  183 + * 仍然需要再判断一次,例如“色情信息哪里有?”,
  184 + * 如果节点只包含“色情电影”一个词,
  185 + * 仍然能够取到word为“色情电影”,但是不该匹配。
  186 + */
  187 + if(word != null && sp.nextStartsWith(i, word)){
  188 + // 匹配成功,将匹配的部分,用replace制定的内容替代
  189 + sp.fill(i, i + word.length, replace);
  190 + // 跳过已经替代的部分
  191 + step = word.length;
  192 + // 标示有替换
  193 + replaced = true;
  194 + // 跳出for循环(然后是while循环的下一个位置)
  195 + break;
  196 + }
  197 + }
  198 + }
  199 + }
  200 +
  201 + // 移动到下一个匹配位置
  202 + i += step;
  203 + }
  204 +
  205 + // 如果没有替换,直接返回入参(节约String的构造copy)
  206 + if(replaced){
  207 + return sp.toString();
  208 + }else{
  209 + return sentence;
  210 + }
  211 + }
  212 +
  213 +}
  1 +package com.odianyun.util.sensi;
  2 +
  3 +import java.io.Serializable;
  4 +import java.util.TreeSet;
  5 +
  6 +/**
  7 + * 敏感词节点,每个节点包含了以相同的2个字符开头的所有词
  8 + *
  9 + * @author ZhangXiaoye
  10 + * @date 2017年1月5日 下午5:06:26
  11 + */
  12 +public class SensitiveNode implements Serializable{
  13 +
  14 + private static final long serialVersionUID = 1L;
  15 +
  16 + /**
  17 + * 头两个字符的mix,mix相同,两个字符相同
  18 + */
  19 + protected final int headTwoCharMix;
  20 +
  21 + /**
  22 + * 所有以这两个字符开头的词表
  23 + */
  24 + protected final TreeSet<StringPointer> words = new TreeSet<StringPointer>();
  25 +
  26 + /**
  27 + * 下一个节点
  28 + */
  29 + protected SensitiveNode next;
  30 +
  31 + public SensitiveNode(int headTwoCharMix){
  32 + this.headTwoCharMix = headTwoCharMix;
  33 + }
  34 +
  35 + public SensitiveNode(int headTwoCharMix, SensitiveNode parent){
  36 + this.headTwoCharMix = headTwoCharMix;
  37 + parent.next = this;
  38 + }
  39 +
  40 +}
  1 +package com.odianyun.util.sensi;
  2 +
  3 +import java.io.Serializable;
  4 +import java.util.HashMap;
  5 +import java.util.TreeMap;
  6 +
  7 +/**
  8 + * 没有注释的方法与{@link String}类似<br/>
  9 + * <b>注意:</b>没有(数组越界等的)安全检查<br/>
  10 + * 可以作为{@link HashMap}和{@link TreeMap}的key
  11 + *
  12 + * @author ZhangXiaoye
  13 + * @date 2017年1月5日 下午2:11:56
  14 + */
  15 +public class StringPointer implements Serializable, CharSequence, Comparable<StringPointer>{
  16 +
  17 + private static final long serialVersionUID = 1L;
  18 +
  19 + protected final char[] value;
  20 +
  21 + protected final int offset;
  22 +
  23 + protected final int length;
  24 +
  25 + private int hash = 0;
  26 +
  27 + public StringPointer(String str){
  28 + value = str.toCharArray();
  29 + offset = 0;
  30 + length = value.length;
  31 + }
  32 +
  33 + public StringPointer(char[] value, int offset, int length){
  34 + this.value = value;
  35 + this.offset = 0;
  36 + this.length = value.length;
  37 + }
  38 +
  39 + /**
  40 + * 计算该位置后(包含)2个字符的hash值
  41 + *
  42 + * @param i 从 0 到 length - 2
  43 + * @return hash值
  44 + * @author ZhangXiaoye
  45 + * @date 2017年1月5日 下午2:23:02
  46 + */
  47 + public int nextTwoCharHash(int i){
  48 + return 31 * value[offset + i] + value[offset + i + 1];
  49 + }
  50 +
  51 + /**
  52 + * 计算该位置后(包含)2个字符和为1个int型的值<br/>
  53 + * int值相同表示2个字符相同
  54 + *
  55 + * @param i 从 0 到 length - 2
  56 + * @return int值
  57 + * @author ZhangXiaoye
  58 + * @date 2017年1月5日 下午2:46:58
  59 + */
  60 + public int nextTwoCharMix(int i){
  61 + return (value[offset + i] << 16) | value[offset + i + 1];
  62 + }
  63 +
  64 + /**
  65 + * 该位置后(包含)的字符串,是否以某个词(word)开头
  66 + *
  67 + * @param i 从 0 到 length - 2
  68 + * @param word 词
  69 + * @return 是否?
  70 + * @author ZhangXiaoye
  71 + * @date 2017年1月5日 下午3:13:49
  72 + */
  73 + public boolean nextStartsWith(int i, StringPointer word){
  74 + // 是否长度超出
  75 + if(word.length > length - i){
  76 + return false;
  77 + }
  78 + // 从尾开始判断
  79 + for(int c = word.length - 1; c >= 0; c --){
  80 + if(value[offset + i + c] != word.value[word.offset + c]){
  81 + return false;
  82 + }
  83 + }
  84 + return true;
  85 + }
  86 +
  87 + /**
  88 + * 填充(替换)
  89 + *
  90 + * @param begin 从此位置开始(含)
  91 + * @param end 到此位置结束(不含)
  92 + * @param fillWith 以此字符填充(替换)
  93 + * @author ZhangXiaoye
  94 + * @date 2017年1月5日 下午3:29:21
  95 + */
  96 + public void fill(int begin, int end, char fillWith){
  97 + for(int i = begin; i < end; i ++){
  98 + value[offset + i] = fillWith;
  99 + }
  100 + }
  101 +
  102 + public int length(){
  103 + return length;
  104 + }
  105 +
  106 + public char charAt(int i){
  107 + return value[offset + i];
  108 + }
  109 +
  110 + public StringPointer substring(int begin){
  111 + return new StringPointer(value, offset + begin, length - begin);
  112 + }
  113 +
  114 + public StringPointer substring(int begin, int end){
  115 + return new StringPointer(value, offset + begin, end - begin);
  116 + }
  117 +
  118 + @Override
  119 + public CharSequence subSequence(int start, int end) {
  120 + return substring(start, end);
  121 + }
  122 +
  123 + public String toString(){
  124 + return new String(value, offset, length);
  125 + }
  126 +
  127 + public int hashCode() {
  128 + int h = hash;
  129 + if (h == 0 && length > 0) {
  130 + for (int i = 0; i < length; i++) {
  131 + h = 31 * h + value[offset + i];
  132 + }
  133 + hash = h;
  134 + }
  135 + return h;
  136 + }
  137 +
  138 + public boolean equals(Object anObject) {
  139 + if (this == anObject) {
  140 + return true;
  141 + }
  142 + if (anObject instanceof StringPointer) {
  143 + StringPointer that = (StringPointer)anObject;
  144 + if (length == that.length) {
  145 + char v1[] = this.value;
  146 + char v2[] = that.value;
  147 + for(int i = 0; i < this.length; i ++){
  148 + if(v1[this.offset + i] != v2[that.offset + i]){
  149 + return false;
  150 + }
  151 + }
  152 + return true;
  153 + }
  154 + }
  155 + return false;
  156 + }
  157 +
  158 + @Override
  159 + public int compareTo(StringPointer that) {
  160 + int len1 = this.length;
  161 + int len2 = that.length;
  162 + int lim = Math.min(len1, len2);
  163 + char v1[] = this.value;
  164 + char v2[] = that.value;
  165 +
  166 + int k = 0;
  167 + while (k < lim) {
  168 + char c1 = v1[this.offset + k];
  169 + char c2 = v2[that.offset + k];
  170 + if (c1 != c2) {
  171 + return c1 - c2;
  172 + }
  173 + k++;
  174 + }
  175 + return len1 - len2;
  176 + }
  177 +
  178 +}
此 diff 太大无法显示。
  1 +package com.odianyun.util.sensi;
  2 +
  3 +import java.io.BufferedReader;
  4 +import java.io.File;
  5 +import java.io.FileInputStream;
  6 +import java.io.InputStreamReader;
  7 +import java.io.PrintStream;
  8 +import java.util.ArrayList;
  9 +import java.util.List;
  10 +
  11 +import junit.framework.TestCase;
  12 +
  13 +public class SensitiveFilterTest extends TestCase{
  14 +
  15 + public void test() throws Exception{
  16 +
  17 + SensitiveFilter filter = SensitiveFilter.DEFAULT;
  18 +
  19 + System.out.println(filter.filter("会上,主席进行了发言。", '*'));
  20 +
  21 + }
  22 +
  23 + public void testSpeed() throws Exception{
  24 +
  25 + PrintStream ps = new PrintStream("/data/敏感词替换结果.txt");
  26 +
  27 + File dir = new File("/data/穿越小说2011-10-14");
  28 +
  29 + List<String> testSuit = new ArrayList<String>(1048576);
  30 + long length = 0;
  31 +
  32 + for(File file: dir.listFiles()){
  33 + if(file.isFile() && file.getName().endsWith(".txt")){
  34 + BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), "gb18030"));
  35 + for(String line = br.readLine(); line != null; line = br.readLine()){
  36 + if(line.trim().length() > 0){
  37 + testSuit.add(line);
  38 + length += line.length();
  39 + }
  40 + }
  41 + br.close();
  42 + }
  43 + }
  44 +
  45 + System.out.println(String.format("共加载 %d 行,%d 字符。", testSuit.size(), length));
  46 +
  47 +
  48 + SensitiveFilter filter = SensitiveFilter.DEFAULT;
  49 +
  50 + int replaced = 0;
  51 +
  52 + for(String line: testSuit){
  53 + if(! line.contains("`")){
  54 + String result = filter.filter(line, '`');
  55 + if(result.contains("`")){
  56 + ps.println(line);
  57 + ps.println(result);
  58 + ps.println();
  59 + replaced ++;
  60 + }
  61 + }
  62 + }
  63 + ps.close();
  64 +
  65 + long timer = System.currentTimeMillis();
  66 + for(String line: testSuit){
  67 + filter.filter(line, '*');
  68 + }
  69 + timer = System.currentTimeMillis() - timer;
  70 + System.out.println(String.format("共耗时 %1.3f 秒, 速度为 %1.1f字符/毫秒", timer * 1E-3, length / (double) timer));
  71 + System.out.println(String.format("其中 %d 行有替换", replaced));
  72 +
  73 + }
  74 +
  75 +}