在此先感谢 elulis,开源项目主页:https://github.com/elulis/sensitive-words

关键词:敏感词 过滤 DFA

封装代码

StringPointer

  1. import java.io.Serializable;
  2. import java.util.HashMap;
  3. import java.util.TreeMap;
  4. /**
  5. * 没有注释的方法与{@link String}类似<br/>
  6. * <b>注意:</b>没有(数组越界等的)安全检查<br/>
  7. * 可以作为{@link HashMap}和{@link TreeMap}的key
  8. *
  9. * @author ZhangXiaoye
  10. * @date 2017年1月5日 下午2:11:56
  11. */
  12. public class StringPointer implements Serializable, CharSequence, Comparable<StringPointer>{
  13. private static final long serialVersionUID = 1L;
  14. protected final char[] value;
  15. protected final int offset;
  16. protected final int length;
  17. private int hash = 0;
  18. public StringPointer(String str){
  19. value = str.toCharArray();
  20. offset = 0;
  21. length = value.length;
  22. }
  23. public StringPointer(char[] value, int offset, int length){
  24. this.value = value;
  25. this.offset = offset;
  26. this.length = length;
  27. }
  28. /**
  29. * 计算该位置后(包含)2个字符的hash值
  30. *
  31. * @param i 从 0 到 length - 2
  32. * @return hash值
  33. * @author ZhangXiaoye
  34. * @date 2017年1月5日 下午2:23:02
  35. */
  36. public int nextTwoCharHash(int i){
  37. return 31 * value[offset + i] + value[offset + i + 1];
  38. }
  39. /**
  40. * 计算该位置后(包含)2个字符和为1个int型的值<br/>
  41. * int值相同表示2个字符相同
  42. *
  43. * @param i 从 0 到 length - 2
  44. * @return int值
  45. * @author ZhangXiaoye
  46. * @date 2017年1月5日 下午2:46:58
  47. */
  48. public int nextTwoCharMix(int i){
  49. return (value[offset + i] << 16) | value[offset + i + 1];
  50. }
  51. /**
  52. * 该位置后(包含)的字符串,是否以某个词(word)开头
  53. *
  54. * @param i 从 0 到 length - 2
  55. * @param word 词
  56. * @return 是否?
  57. * @author ZhangXiaoye
  58. * @date 2017年1月5日 下午3:13:49
  59. */
  60. public boolean nextStartsWith(int i, StringPointer word){
  61. // 是否长度超出
  62. if(word.length > length - i){
  63. return false;
  64. }
  65. // 从尾开始判断
  66. for(int c = word.length - 1; c >= 0; c --){
  67. if(value[offset + i + c] != word.value[word.offset + c]){
  68. return false;
  69. }
  70. }
  71. return true;
  72. }
  73. /**
  74. * 填充(替换)
  75. *
  76. * @param begin 从此位置开始(含)
  77. * @param end 到此位置结束(不含)
  78. * @param fillWith 以此字符填充(替换)
  79. * @author ZhangXiaoye
  80. * @date 2017年1月5日 下午3:29:21
  81. */
  82. public void fill(int begin, int end, char fillWith){
  83. for(int i = begin; i < end; i ++){
  84. value[offset + i] = fillWith;
  85. }
  86. }
  87. @Override
  88. public int length(){
  89. return length;
  90. }
  91. @Override
  92. public char charAt(int i){
  93. return value[offset + i];
  94. }
  95. public StringPointer substring(int begin){
  96. return new StringPointer(value, offset + begin, length - begin);
  97. }
  98. public StringPointer substring(int begin, int end){
  99. return new StringPointer(value, offset + begin, end - begin);
  100. }
  101. @Override
  102. public CharSequence subSequence(int start, int end) {
  103. return substring(start, end);
  104. }
  105. @Override
  106. public String toString(){
  107. return new String(value, offset, length);
  108. }
  109. @Override
  110. public int hashCode() {
  111. int h = hash;
  112. if (h == 0 && length > 0) {
  113. for (int i = 0; i < length; i++) {
  114. h = 31 * h + value[offset + i];
  115. }
  116. hash = h;
  117. }
  118. return h;
  119. }
  120. public boolean equals(Object anObject) {
  121. if (this == anObject) {
  122. return true;
  123. }
  124. if (anObject instanceof StringPointer) {
  125. StringPointer that = (StringPointer)anObject;
  126. if (length == that.length) {
  127. char v1[] = this.value;
  128. char v2[] = that.value;
  129. for(int i = 0; i < this.length; i ++){
  130. if(v1[this.offset + i] != v2[that.offset + i]){
  131. return false;
  132. }
  133. }
  134. return true;
  135. }
  136. }
  137. return false;
  138. }
  139. @Override
  140. public int compareTo(StringPointer that) {
  141. int len1 = this.length;
  142. int len2 = that.length;
  143. int lim = Math.min(len1, len2);
  144. char v1[] = this.value;
  145. char v2[] = that.value;
  146. int k = 0;
  147. while (k < lim) {
  148. char c1 = v1[this.offset + k];
  149. char c2 = v2[that.offset + k];
  150. if (c1 != c2) {
  151. return c1 - c2;
  152. }
  153. k++;
  154. }
  155. return len1 - len2;
  156. }
  157. }

SensitiveNode

  1. import java.io.Serializable;
  2. import java.util.TreeSet;
  3. /**
  4. * 敏感词节点,每个节点包含了以相同的2个字符开头的所有词
  5. *
  6. * @author ZhangXiaoye
  7. * @date 2017年1月5日 下午5:06:26
  8. */
  9. public class SensitiveNode implements Serializable{
  10. private static final long serialVersionUID = 1L;
  11. /**
  12. * 头两个字符的mix,mix相同,两个字符相同
  13. */
  14. protected final int headTwoCharMix;
  15. /**
  16. * 所有以这两个字符开头的词表
  17. */
  18. protected final TreeSet<StringPointer> words = new TreeSet<StringPointer>();
  19. /**
  20. * 下一个节点
  21. */
  22. protected SensitiveNode next;
  23. public SensitiveNode(int headTwoCharMix){
  24. this.headTwoCharMix = headTwoCharMix;
  25. }
  26. public SensitiveNode(int headTwoCharMix, SensitiveNode parent){
  27. this.headTwoCharMix = headTwoCharMix;
  28. parent.next = this;
  29. }
  30. }

SensitiveFilter

  1. import java.io.BufferedReader;
  2. import java.io.IOException;
  3. import java.io.InputStreamReader;
  4. import java.io.Serializable;
  5. import java.nio.charset.StandardCharsets;
  6. import java.util.Iterator;
  7. import java.util.NavigableSet;
  8. /**
  9. * 敏感词过滤器,以过滤速度优化为主。<br/>
  10. * * 增加一个敏感词:{@link #put(String)} <br/>
  11. * * 过滤一个句子:{@link #filter(String, char)} <br/>
  12. * * 获取默认的单例:{@link #DEFAULT}
  13. *
  14. * @author ZhangXiaoye
  15. * @date 2017年1月5日 下午4:18:38
  16. */
  17. public class SensitiveFilter implements Serializable {
  18. private static String regex = "[`~!@#$%^&*()+=|{}':;',\\\\[\\\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?]|\\s*";
  19. private static final long serialVersionUID = 1L;
  20. /**
  21. * 默认的单例,使用自带的敏感词库
  22. */
  23. public static final SensitiveFilter DEFAULT = new SensitiveFilter(
  24. new BufferedReader(new InputStreamReader(
  25. ClassLoader.getSystemResourceAsStream("sensi_words.txt")
  26. , StandardCharsets.UTF_8)));
  27. /**
  28. * 为2的n次方,考虑到敏感词大概在10k左右,
  29. * 这个数量应为词数的数倍,使得桶很稀疏
  30. * 提高不命中时hash指向null的概率,
  31. * 加快访问速度。
  32. */
  33. static final int DEFAULT_INITIAL_CAPACITY = 131072;
  34. /**
  35. * 类似HashMap的桶,比较稀疏。
  36. * 使用2个字符的hash定位。
  37. */
  38. protected SensitiveNode[] nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY];
  39. /**
  40. * 构建一个空的filter
  41. *
  42. * @author ZhangXiaoye
  43. * @date 2017年1月5日 下午4:18:07
  44. */
  45. public SensitiveFilter() {
  46. }
  47. /**
  48. * 加载一个文件中的词典,并构建filter<br/>
  49. * 文件中,每行一个敏感词条<br/>
  50. * <b>注意:</b>读取完成后会调用{@link BufferedReader#close()}方法。<br/>
  51. * <b>注意:</b>读取中的{@link IOException}不会抛出
  52. *
  53. * @param reader
  54. * @author ZhangXiaoye
  55. * @date 2017年1月5日 下午4:21:06
  56. */
  57. public SensitiveFilter(BufferedReader reader) {
  58. try {
  59. for (String line = reader.readLine(); line != null; line = reader.readLine()) {
  60. put(line);
  61. }
  62. reader.close();
  63. } catch (IOException e) {
  64. e.printStackTrace();
  65. }
  66. }
  67. /**
  68. * 增加一个敏感词,如果词的长度(trim后)小于2,则丢弃<br/>
  69. * 此方法(构建)并不是主要的性能优化点。
  70. *
  71. * @param word
  72. * @author ZhangXiaoye
  73. * @date 2017年1月5日 下午2:35:21
  74. */
  75. public boolean put(String word) {
  76. // 长度小于2的不加入
  77. if (word == null || word.trim().length() < 2) {
  78. return false;
  79. }
  80. // 两个字符的不考虑
  81. if (word.length() == 2 && word.matches("\\w\\w")) {
  82. return false;
  83. }
  84. StringPointer sp = new StringPointer(word.trim());
  85. // 计算头两个字符的hash
  86. int hash = sp.nextTwoCharHash(0);
  87. // 计算头两个字符的mix表示(mix相同,两个字符相同)
  88. int mix = sp.nextTwoCharMix(0);
  89. // 转为在hash桶中的位置
  90. int index = hash & (nodes.length - 1);
  91. // 从桶里拿第一个节点
  92. SensitiveNode node = nodes[index];
  93. if (node == null) {
  94. // 如果没有节点,则放进去一个
  95. node = new SensitiveNode(mix);
  96. // 并添加词
  97. node.words.add(sp);
  98. // 放入桶里
  99. nodes[index] = node;
  100. } else {
  101. // 如果已经有节点(1个或多个),找到正确的节点
  102. for (; node != null; node = node.next) {
  103. // 匹配节点
  104. if (node.headTwoCharMix == mix) {
  105. node.words.add(sp);
  106. return true;
  107. }
  108. // 如果匹配到最后仍然不成功,则追加一个节点
  109. if (node.next == null) {
  110. new SensitiveNode(mix, node).words.add(sp);
  111. return true;
  112. }
  113. }
  114. }
  115. return true;
  116. }
  117. /**
  118. * 对句子进行敏感词过滤<br/>
  119. * 如果无敏感词返回输入的sentence对象,即可以用下面的方式判断是否有敏感词:<br/><code>
  120. * String result = filter.filter(sentence, '*');<br/>
  121. * if(result != sentence){<br/>
  122. * &nbsp;&nbsp;// 有敏感词<br/>
  123. * }
  124. * </code>
  125. *
  126. * @param sentence 句子
  127. * @param replace 敏感词的替换字符
  128. * @return 过滤后的句子
  129. * @author ZhangXiaoye
  130. * @date 2017年1月5日 下午4:16:31
  131. */
  132. public String filter(String sentence, char replace) {
  133. // 先转换为StringPointer
  134. sentence = sentence.replaceAll(regex, "");
  135. StringPointer sp = new StringPointer(sentence);
  136. // 标示是否替换
  137. boolean replaced = false;
  138. // 匹配的起始位置
  139. int i = 0;
  140. while (i < sp.length - 2) {
  141. /*
  142. * 移动到下一个匹配位置的步进:
  143. * 如果未匹配为1,如果匹配是匹配的词长度
  144. */
  145. int step = 1;
  146. // 计算此位置开始2个字符的hash
  147. int hash = sp.nextTwoCharHash(i);
  148. /*
  149. * 根据hash获取第一个节点,
  150. * 真正匹配的节点可能不是第一个,
  151. * 所以有后面的for循环。
  152. */
  153. SensitiveNode node = nodes[hash & (nodes.length - 1)];
  154. /*
  155. * 如果非敏感词,node基本为null。
  156. * 这一步大幅提升效率
  157. */
  158. if (node != null) {
  159. /*
  160. * 如果能拿到第一个节点,
  161. * 才计算mix(mix相同表示2个字符相同)。
  162. * mix的意义和HashMap先hash再equals的equals部分类似。
  163. */
  164. int mix = sp.nextTwoCharMix(i);
  165. /*
  166. * 循环所有的节点,如果非敏感词,
  167. * mix相同的概率非常低,提高效率
  168. */
  169. outer:
  170. for (; node != null; node = node.next) {
  171. /*
  172. * 对于一个节点,先根据头2个字符判断是否属于这个节点。
  173. * 如果属于这个节点,看这个节点的词库是否命中。
  174. * 此代码块中访问次数已经很少,不是优化重点
  175. */
  176. if (node.headTwoCharMix == mix) {
  177. /*
  178. * 查出比剩余sentence小的最大的词。
  179. * 例如剩余sentence为"色情电影哪家强?",
  180. * 这个节点含三个词从小到大为:"色情"、"色情电影"、"色情信息"。
  181. * 则从“色情电影”开始向前匹配
  182. */
  183. NavigableSet<StringPointer> desSet = node.words.headSet(sp.substring(i), true);
  184. if (desSet != null) {
  185. for (StringPointer word : desSet.descendingSet()) {
  186. /*
  187. * 仍然需要再判断一次,例如"色情信息哪里有?",
  188. * 如果节点只包含"色情电影"一个词,
  189. * 仍然能够取到word为"色情电影",但是不该匹配。
  190. */
  191. if (sp.nextStartsWith(i, word)) {
  192. // 匹配成功,将匹配的部分,用replace制定的内容替代
  193. sp.fill(i, i + word.length, replace);
  194. // 跳过已经替代的部分
  195. step = word.length;
  196. // 标示有替换
  197. replaced = true;
  198. // 跳出循环(然后是while循环的下一个位置)
  199. break outer;
  200. }
  201. }
  202. }
  203. }
  204. }
  205. }
  206. // 移动到下一个匹配位置
  207. i += step;
  208. }
  209. // 如果没有替换,直接返回入参(节约String的构造copy)
  210. if (replaced) {
  211. return sp.toString();
  212. } else {
  213. return sentence;
  214. }
  215. }
  216. }

使用

  1. import java.io.BufferedReader;
  2. import java.io.File;
  3. import java.io.FileInputStream;
  4. import java.io.InputStreamReader;
  5. import java.io.PrintStream;
  6. import java.util.ArrayList;
  7. import java.util.List;
  8. import junit.framework.TestCase;
  9. public class SensitiveFilterTest extends TestCase {
  10. public void test() throws Exception {
  11. // 使用默认单例(加载默认词典)
  12. SensitiveFilter filter = SensitiveFilter.DEFAULT;
  13. // 向过滤器增加一个词
  14. filter.put("婚礼上唱春天在哪里");
  15. // 待过滤的句子
  16. String sentence = "然后,市长在婚礼上唱春天在哪里。";
  17. // 进行过滤
  18. String filted = filter.filter(sentence, '*');
  19. // 如果未过滤,则返回输入的String引用
  20. if (sentence != filted) {
  21. // 句子中有敏感词
  22. System.out.println(filted);
  23. }
  24. }
  25. public void testLogic() {
  26. SensitiveFilter filter = new SensitiveFilter();
  27. filter.put("你好");
  28. filter.put("你好1");
  29. filter.put("你好2");
  30. filter.put("你好3");
  31. filter.put("你好4");
  32. System.out.println(filter.filter("你好3天不见", '*'));
  33. }
  34. public void testSpeed() throws Exception {
  35. PrintStream ps = new PrintStream("123Out.txt");
  36. File file = new File("123.txt");
  37. List<String> testSuit = new ArrayList<String>(1048576);
  38. long length = 0;
  39. if (file.isFile() && file.getName().endsWith(".txt")) {
  40. BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), "gb18030"));
  41. for (String line = br.readLine(); line != null; line = br.readLine()) {
  42. if (line.trim().length() > 0) {
  43. testSuit.add(line);
  44. length += line.length();
  45. }
  46. }
  47. br.close();
  48. }
  49. System.out.println(String.format("待过滤文本共 %d 行,%d 字符。", testSuit.size(), length));
  50. SensitiveFilter filter = SensitiveFilter.DEFAULT;
  51. int replaced = 0;
  52. for (String sentence : testSuit) {
  53. String result = filter.filter(sentence, '*');
  54. if (result != sentence) {
  55. ps.println(sentence);
  56. ps.println(result);
  57. ps.println();
  58. replaced++;
  59. }
  60. }
  61. ps.close();
  62. long timer = System.currentTimeMillis();
  63. for (String line : testSuit) {
  64. filter.filter(line, '*');
  65. }
  66. timer = System.currentTimeMillis() - timer;
  67. System.out.println(String.format("过滤耗时 %1.3f 秒, 速度为 %1.1f字符/毫秒", timer * 1E-3, length / (double) timer));
  68. System.out.println(String.format("其中 %d 行有替换", replaced));
  69. }
  70. }