在此先感谢 elulis,开源项目主页:https://github.com/elulis/sensitive-words
关键词:敏感词 过滤 DFA
封装代码
StringPointer
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;}}@Overridepublic int length(){return length;}@Overridepublic 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);}@Overridepublic CharSequence subSequence(int start, int end) {return substring(start, end);}@Overridepublic String toString(){return new String(value, offset, length);}@Overridepublic 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;}@Overridepublic 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;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;}}
SensitiveNode
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;}}
SensitiveFilter
import java.io.BufferedReader;import java.io.IOException;import java.io.InputStreamReader;import java.io.Serializable;import java.nio.charset.StandardCharsets;import java.util.Iterator;import java.util.NavigableSet;/*** 敏感词过滤器,以过滤速度优化为主。<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 String regex = "[`~!@#$%^&*()+=|{}':;',\\\\[\\\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?]|\\s*";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 boolean put(String word) {// 长度小于2的不加入if (word == null || word.trim().length() < 2) {return false;}// 两个字符的不考虑if (word.length() == 2 && word.matches("\\w\\w")) {return false;}StringPointer sp = new StringPointer(word.trim());// 计算头两个字符的hashint 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 true;}// 如果匹配到最后仍然不成功,则追加一个节点if (node.next == null) {new SensitiveNode(mix, node).words.add(sp);return true;}}}return true;}/*** 对句子进行敏感词过滤<br/>* 如果无敏感词返回输入的sentence对象,即可以用下面的方式判断是否有敏感词:<br/><code>* String result = filter.filter(sentence, '*');<br/>* if(result != sentence){<br/>* // 有敏感词<br/>* }* </code>** @param sentence 句子* @param replace 敏感词的替换字符* @return 过滤后的句子* @author ZhangXiaoye* @date 2017年1月5日 下午4:16:31*/public String filter(String sentence, char replace) {// 先转换为StringPointersentence = sentence.replaceAll(regex, "");StringPointer sp = new StringPointer(sentence);// 标示是否替换boolean replaced = false;// 匹配的起始位置int i = 0;while (i < sp.length - 2) {/** 移动到下一个匹配位置的步进:* 如果未匹配为1,如果匹配是匹配的词长度*/int step = 1;// 计算此位置开始2个字符的hashint 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相同的概率非常低,提高效率*/outer:for (; node != null; node = node.next) {/** 对于一个节点,先根据头2个字符判断是否属于这个节点。* 如果属于这个节点,看这个节点的词库是否命中。* 此代码块中访问次数已经很少,不是优化重点*/if (node.headTwoCharMix == mix) {/** 查出比剩余sentence小的最大的词。* 例如剩余sentence为"色情电影哪家强?",* 这个节点含三个词从小到大为:"色情"、"色情电影"、"色情信息"。* 则从“色情电影”开始向前匹配*/NavigableSet<StringPointer> desSet = node.words.headSet(sp.substring(i), true);if (desSet != null) {for (StringPointer word : desSet.descendingSet()) {/** 仍然需要再判断一次,例如"色情信息哪里有?",* 如果节点只包含"色情电影"一个词,* 仍然能够取到word为"色情电影",但是不该匹配。*/if (sp.nextStartsWith(i, word)) {// 匹配成功,将匹配的部分,用replace制定的内容替代sp.fill(i, i + word.length, replace);// 跳过已经替代的部分step = word.length;// 标示有替换replaced = true;// 跳出循环(然后是while循环的下一个位置)break outer;}}}}}}// 移动到下一个匹配位置i += step;}// 如果没有替换,直接返回入参(节约String的构造copy)if (replaced) {return sp.toString();} else {return sentence;}}}
使用
import java.io.BufferedReader;import java.io.File;import java.io.FileInputStream;import java.io.InputStreamReader;import java.io.PrintStream;import java.util.ArrayList;import java.util.List;import junit.framework.TestCase;public class SensitiveFilterTest extends TestCase {public void test() throws Exception {// 使用默认单例(加载默认词典)SensitiveFilter filter = SensitiveFilter.DEFAULT;// 向过滤器增加一个词filter.put("婚礼上唱春天在哪里");// 待过滤的句子String sentence = "然后,市长在婚礼上唱春天在哪里。";// 进行过滤String filted = filter.filter(sentence, '*');// 如果未过滤,则返回输入的String引用if (sentence != filted) {// 句子中有敏感词System.out.println(filted);}}public void testLogic() {SensitiveFilter filter = new SensitiveFilter();filter.put("你好");filter.put("你好1");filter.put("你好2");filter.put("你好3");filter.put("你好4");System.out.println(filter.filter("你好3天不见", '*'));}public void testSpeed() throws Exception {PrintStream ps = new PrintStream("123Out.txt");File file = new File("123.txt");List<String> testSuit = new ArrayList<String>(1048576);long length = 0;if (file.isFile() && file.getName().endsWith(".txt")) {BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), "gb18030"));for (String line = br.readLine(); line != null; line = br.readLine()) {if (line.trim().length() > 0) {testSuit.add(line);length += line.length();}}br.close();}System.out.println(String.format("待过滤文本共 %d 行,%d 字符。", testSuit.size(), length));SensitiveFilter filter = SensitiveFilter.DEFAULT;int replaced = 0;for (String sentence : testSuit) {String result = filter.filter(sentence, '*');if (result != sentence) {ps.println(sentence);ps.println(result);ps.println();replaced++;}}ps.close();long timer = System.currentTimeMillis();for (String line : testSuit) {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("其中 %d 行有替换", replaced));}}
