最近在阅读吴军博士的<<数学之美>>这门书,得到了很多的启发和思考,里面提到了一个概念---信息指纹。一般正常人提到这个概念,第一个想到的词应该是哈希映射算法,将任何对象都映射成一个独立的变量,一般这个变量是一个独有的数字,当然也不排除哈希碰撞的可能行。论单个对象,用哈希算法做一次映射,比较对象是否一致,这固然是可以的,但是如果想用哈希算法做一些文章之间的相似度计算的时候,可能传统的哈希算法就不见得是最佳的选择了,如果把整篇文章都作为一个超长字符串的去计算,准确率无法保证,因为字符串中的任何字符的变化,也许都会造成后面的值变得差异很大。那么如果单个单个去计算的话,如何用一个有效的标准去衡量这些看似没有关联的哈希值呢。还好我们是站在巨人的肩膀上的,有一种叫Simhash算法正好巧妙的解决了我们的问题。
Simhash当初比较早些的时候用在了Google的网页爬虫中,用于重复网页的去重。可以说,Simhash就是拿来计算网页的信息指纹。Simhash是在2002年的时候由Moses Chariker 提出的。
Simhash的设计非常的巧妙,这里举一个实际的使用场景,来使大家直观的感受这个神奇的算法。比如比较2个网页的相似度,那么比较的关键就是里面的各种词,假设有t1,t2.t3等等,每个词都会自己的信息指纹,最简单的就是字符串的hashcode值,而Simhash的作用就是把这些值进行一个汇总,得到一个整个网页的哈希值。下面是2个大的模块。
一.扩展。将上个步骤中的各个词的哈希值取余数,做一个位数的限制,比如最终的展现需要为8位的二进制数,就要把哈希值取2的8次方就是256的余数,方便后面的加减权重操作。然后初始化一个8位数组,默认每个位上的值都为0。下面的步骤是第一个模块的关键步骤了。
因为刚刚有t1,t2,t3等等这些词语的哈希值,然后取余数后就是1个个的8位的二进制整数,下面是遍历操作,取第一个词的信息指纹,假设是10000010,(随便假设的),此时总的哈希值为:
r1=1 0+w1
r2=0 0-w1
r3=0 0-w1
r4=0 0-w1
r5=0 0-w1
r6=0 0-w1
r7=1 0+w1
r8=0 0-w1
规则很简单,就是根据二进制位,根据1加0减的规则,做相应的权重操作,w指的是该词语在网页中的权重,这个可以用分词系统来做,然后统计词频,用词频作为权重值来算,当然也可以默认权重都相同,都为1,。然后是遍历第二个词,操作的过程一样,只是要在t1的基础上进行权重的操作,而不是初始值。
二、收缩。将最后的由权重值计算得到的网页总哈希数组,进行规约化操作,如果某个位的值大于0,则此位置上的值设置为1,否则设置为0。比如下面这个经过各个词的权重的加加减减,最后的结果是
0.1, 0.1,0.1,0.1,-0.8,-0.3,0.4,0.5
那么最后的结果就会是
11110011
最后的相似度比较就可以用最好的哈希对应位置上的值的相同个数做比较。如果2个网页相同,则相似哈希值必定相同,如果存在极个别少量的权重低的词不同,他们的相似哈希值也可能会相同。
给出一个算法的主要实现类,全部代码链接以及测试数据:https://github.com/linyiqun/lyq-algorithms-lib/tree/master/Simhash
package Simhash; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; import java.io.IOException; /** * 相似哈希算法工具类 * * @author lyq * */ public class SimHashTool { // 二进制哈希位数 private int hashBitNum; // 相同位数最小阈值 private double minSupportValue; public SimHashTool(int hashBitNum, double minSupportValue) { this.hashBitNum = hashBitNum; this.minSupportValue = minSupportValue; } /** * 比较文章的相似度 * * @param news1 * 文章路径1 * @param news2 * 文章路径2 */ public void compareArticals(String newsPath1, String newsPath2) { String content1; String content2; int sameNum; int[] hashArray1; int[] hashArray2; // 读取分词结果 content1 = readDataFile(newsPath1); content2 = readDataFile(newsPath2); hashArray1 = calSimHashValue(content1); hashArray2 = calSimHashValue(content2); // 比较哈希位数相同个数 sameNum = 0; for (int i = 0; i < hashBitNum; i++) { if (hashArray1[i] == hashArray2[i]) { sameNum++; } } // 与最小阈值进行比较 if (sameNum > this.hashBitNum * this.minSupportValue) { System.out.println(String.format("相似度为%s,超过阈值%s,所以新闻1与新闻2是相似的", sameNum * 1.0 / hashBitNum, minSupportValue)); } else { System.out.println(String.format("相似度为%s,小于阈值%s,所以新闻1与新闻2不是相似的", sameNum * 1.0 / hashBitNum, minSupportValue)); } } /** * 计算文本的相似哈希值 * * @param content * 新闻内容数据 * @return */ private int[] calSimHashValue(String content) { int index; long hashValue; double weight; int[] binaryArray; int[] resultValue; double[] hashArray; String w; String[] words; News news; news = new News(content); news.statWords(); hashArray = new double[hashBitNum]; resultValue = new int[hashBitNum]; words = content.split(" "); for (String str : words) { index = str.indexOf('/'); if (index == -1) { continue; } w = str.substring(0, index); // 获取权重值,根据词频所得 weight = news.getWordFrequentValue(w); if(weight == -1){ continue; } // 进行哈希值的计算 hashValue = BKDRHash(w); // 取余把位数变为n位 hashValue %= Math.pow(2, hashBitNum); // 转为二进制的形式 binaryArray = new int[hashBitNum]; numToBinaryArray(binaryArray, (int) hashValue); for (int i = 0; i < binaryArray.length; i++) { // 如果此位置上为1,加权重 if (binaryArray[i] == 1) { hashArray[i] += weight; } else { // 为0则减权重操作 hashArray[i] -= weight; } } } // 进行数组收缩操作,根据值的正负号,重新改为二进制数据形式 for (int i = 0; i < hashArray.length; i++) { if (hashArray[i] > 0) { resultValue[i] = 1; } else { resultValue[i] = 0; } } return resultValue; } /** * 数字转为二进制形式 * * @param binaryArray * 转化后的二进制数组形式 * @param num * 待转化数字 */ private void numToBinaryArray(int[] binaryArray, int num) { int index = 0; int temp = 0; while (num != 0) { binaryArray[index] = num % 2; index++; num /= 2; } // 进行数组前和尾部的调换 for (int i = 0; i < binaryArray.length / 2; i++) { temp = binaryArray[i]; binaryArray[i] = binaryArray[binaryArray.length - 1 - i]; binaryArray[binaryArray.length - 1 - i] = temp; } } /** * BKDR字符哈希算法 * * @param str * @return */ public static long BKDRHash(String str) { int seed = 31; /* 31 131 1313 13131 131313 etc.. */ long hash = 0; int i = 0; for (i = 0; i < str.length(); i++) { hash = (hash * seed) + (str.charAt(i)); } hash = Math.abs(hash); return hash; } /** * 从文件中读取数据 */ private String readDataFile(String filePath) { File file = new File(filePath); StringBuilder strBuilder = null; try { BufferedReader in = new BufferedReader(new FileReader(file)); String str; strBuilder = new StringBuilder(); while ((str = in.readLine()) != null) { strBuilder.append(str); } in.close(); } catch (IOException e) { e.getStackTrace(); } return strBuilder.toString(); } /** * 利用分词系统进行新闻内容的分词 * * @param srcPath * 新闻文件路径 */ private void parseNewsContent(String srcPath) { // TODO Auto-generated method stub int index; String dirApi; String desPath; dirApi = System.getProperty("user.dir") + "\\lib"; // 组装输出路径值 index = srcPath.indexOf('.'); desPath = srcPath.substring(0, index) + "-split.txt"; try { ICTCLAS50 testICTCLAS50 = new ICTCLAS50(); // 分词所需库的路径、初始化 if (testICTCLAS50.ICTCLAS_Init(dirApi.getBytes("GB2312")) == false) { System.out.println("Init Fail!"); return; } // 将文件名string类型转为byte类型 byte[] Inputfilenameb = srcPath.getBytes(); // 分词处理后输出文件名、将文件名string类型转为byte类型 byte[] Outputfilenameb = desPath.getBytes(); // 文件分词(第一个参数为输入文件的名,第二个参数为文件编码类型,第三个参数为是否标记词性集1 yes,0 // no,第四个参数为输出文件名) testICTCLAS50.ICTCLAS_FileProcess(Inputfilenameb, 0, 1, Outputfilenameb); // 退出分词器 testICTCLAS50.ICTCLAS_Exit(); } catch (Exception ex) { ex.printStackTrace(); } } }结果输出:
相似度为0.75,超过阈值0.5,所以新闻1与新闻2是相似的 相似度为0.875,超过阈值0.5,所以新闻1与新闻2是相似的
百度百科
<<数学之美>>第二版-吴军博士