From 9a4510f99f553475396dd95348da228950ae30b5 Mon Sep 17 00:00:00 2001 From: y <123@12.com> Date: Wed, 8 Mar 2023 00:44:15 +0800 Subject: [PATCH] simhash --- simhash/src/main/java/Main.java | 3 + .../java/exceptions/FileAnalyseException.java | 4 +- .../main/java/exceptions/HashException.java | 4 +- .../exceptions/NotExistFileException.java | 5 +- .../src/main/java/utils/CalculationUtils.java | 59 ++--- simhash/src/test/java/MainTest.java | 237 +++++++++++++++++- simhash/target/classes/Main.class | Bin 2480 -> 2599 bytes .../exceptions/FileAnalyseException.class | Bin 374 -> 374 bytes .../classes/exceptions/HashException.class | Bin 372 -> 372 bytes .../exceptions/NotExistFileException.class | Bin 387 -> 387 bytes .../classes/utils/CalculationUtils.class | Bin 5023 -> 5051 bytes simhash/target/test-classes/MainTest.class | Bin 842 -> 7780 bytes 12 files changed, 263 insertions(+), 49 deletions(-) diff --git a/simhash/src/main/java/Main.java b/simhash/src/main/java/Main.java index 40c8b93..1355d7e 100644 --- a/simhash/src/main/java/Main.java +++ b/simhash/src/main/java/Main.java @@ -17,7 +17,9 @@ public class Main { Map originWordCount = null; Map compareWordCount = null; try { + //得到原文本的关键词和词频 originWordCount = CommonUtils.analyseText(CommonUtils.readFileToStr(args[0])); + //以及比对文本的关键词的关键词和词频 compareWordCount = CommonUtils.analyseText(CommonUtils.readFileToStr(args[1])); } catch (FileAnalyseException | NotExistFileException e) { e.printStackTrace(); @@ -28,6 +30,7 @@ public class Main { //计算相似度,保留两位小数 double result = CalculationUtils.getSimilarity(simHash1, simHash2); String format = String.format("相似度为:%.2f", result); + System.out.println(format); String writeFileContent = "---------------------------------------" + "\n" + "原文件:" + args[0] + "\n" + "对比文件:" + args[1] + "\n" + diff --git a/simhash/src/main/java/exceptions/FileAnalyseException.java b/simhash/src/main/java/exceptions/FileAnalyseException.java index 11aec77..31a1e64 100644 --- a/simhash/src/main/java/exceptions/FileAnalyseException.java +++ b/simhash/src/main/java/exceptions/FileAnalyseException.java @@ -1,9 +1,7 @@ package exceptions; /** - * @author HJW - * @date 2022-09-21 12:57 - * 文件解析异常(转字符串为空或者过滤时没有可用词) + * 文件解析异常 */ public class FileAnalyseException extends Exception { public FileAnalyseException(String message) { diff --git a/simhash/src/main/java/exceptions/HashException.java b/simhash/src/main/java/exceptions/HashException.java index c9e1902..4259661 100644 --- a/simhash/src/main/java/exceptions/HashException.java +++ b/simhash/src/main/java/exceptions/HashException.java @@ -3,9 +3,7 @@ package exceptions; import java.security.NoSuchAlgorithmException; /** - * @author HJW - * @date 2022-09-21 12:57 - * hash异常 md5 + * MD5算法hash异常 */ public class HashException extends NoSuchAlgorithmException { public HashException(String message) { diff --git a/simhash/src/main/java/exceptions/NotExistFileException.java b/simhash/src/main/java/exceptions/NotExistFileException.java index 9dc0f96..377c24e 100644 --- a/simhash/src/main/java/exceptions/NotExistFileException.java +++ b/simhash/src/main/java/exceptions/NotExistFileException.java @@ -1,11 +1,8 @@ package exceptions; import java.io.FileNotFoundException; - - /** - * @author HJW - * 找不到文件的自定义异常 + * 找不到文件的文件解析异常 */ public class NotExistFileException extends FileNotFoundException { public NotExistFileException(String message) { diff --git a/simhash/src/main/java/utils/CalculationUtils.java b/simhash/src/main/java/utils/CalculationUtils.java index 664a0a9..da8a6c3 100644 --- a/simhash/src/main/java/utils/CalculationUtils.java +++ b/simhash/src/main/java/utils/CalculationUtils.java @@ -11,15 +11,12 @@ import java.util.Map; * 与计算有关的工具类 */ public class CalculationUtils { + //hash码长度为128 static final int HASH_BIT = 128; - static final int DISTANCE_WAY1 = 16; - static final int DISTANCE_WAY2 = 32; - static final int DISTANCE_WAY3 = 64; - /** - * 采用MD5进行对词语进行hash,得到的hash值使用16进制解析 再利用算法取128位二进制 + * 采用MD5算法对关键词进行hash,得到的hash值使用16进制解析,再利用算法取128位二进制数作为hash值 * @param word 词语 - * @return 128位二进制 + * @return 128位二进制hash值 */ public static String wordHash(String word) throws HashException { //如果传入词语为null或“”或“ ” @@ -30,36 +27,31 @@ public class CalculationUtils { // 采用MD5算法进行hash MessageDigest digest = MessageDigest.getInstance("MD5"); digest.update(word.getBytes(StandardCharsets.UTF_8)); - // hash值转为32位16进制 + // hash值转为32位16进制的散列值 StringBuilder hash = new StringBuilder(); for (byte b : digest.digest()) { hash.append(String.format("%02x", b)); } - - // 16进制转为128位2进制码 + // 16进制的散列值转为128位二进制码 StringBuilder finalHash = new StringBuilder(); String strTemp; - for (int i = 0; i < hash.length(); i ++) { - // 每一位16进制数加上0000 最后截取后面的4位 得到便是这位数的二进制 + for (int i = 0; i < hash.length(); i++) { + // 每一位16进制数加上0000,最后截取后4位,得到便是这位数的二进制 strTemp = "0000" + Integer.toBinaryString(Integer.parseInt(hash.substring(i, i + 1), 16)); finalHash.append(strTemp.substring(strTemp.length() - 4)); } - - // 不为128直接报错 + // 不为128则为hash异常 if (finalHash.length() != HASH_BIT) { throw new HashException("hash值长度不为128"); } - return finalHash.toString(); - } catch (NoSuchAlgorithmException e) { throw new HashException("MD5算法异常"); } - } /** - * 给二进制哈希值加权 + * 给二进制hash值加权 * @param hash 二进制哈希值 * @param weight 权重 * @return 加权后的二进制哈希值 @@ -75,12 +67,11 @@ public class CalculationUtils { hashArray[i] = -1 * weight; } } - return hashArray; } /** - * 得到的合并后的hash值进行降维,最终得到simHash + * 合并后的hash进行降维,最终得到simHash * @param mergeHash 合并后的hash值 * @return sim哈希值 */ @@ -98,7 +89,6 @@ public class CalculationUtils { return simHash.toString(); } - /** * 根据词语得到simHash * @param wordCount 词语及其出现次数 @@ -113,7 +103,7 @@ public class CalculationUtils { // 遍历词语及其出现次数,对每一个词语进行hash加权,然后合并 wordCount.forEach((word,count) -> { try { - int[] tempHash = hashWeight(wordHash(word),count); + int[] tempHash = hashWeight(wordHash(word),count);//加权后的hash值 for (int i = 0; i < tempHash.length; i++) { mergeHash[i] += tempHash[i]; } @@ -121,7 +111,6 @@ public class CalculationUtils { e.printStackTrace(); } }); - // 降维得到simHash return getSimHash(mergeHash); } @@ -133,26 +122,20 @@ public class CalculationUtils { * @return 相似度 */ public static double getSimilarity(String simHash1, String simHash2) { - // 汉明距离 - int distance = 0; + // 得到两个simHash的汉明距离 // 遍历simHash1和simHash2,不相同则汉明距离加1 + int hamingDistance = 0; + int same=0; for (int i = 0; i < simHash1.length(); i++) { if (simHash1.charAt(i) != simHash2.charAt(i)) { - distance++; + hamingDistance++; + } + if (simHash1.charAt(i)=='1' && simHash2.charAt(i)=='1') { + same++; } } -// System.out.println("汉明距离为:" + distance); - // 更换计算策略 - if (distance >= 0 && distance <= DISTANCE_WAY1) { - return 1 - (double) distance / 256; - } else if (distance > 16 && distance <= DISTANCE_WAY2) { - return 1 - (double) distance / 128; - }else if (distance > 32 && distance <= DISTANCE_WAY3) { - return 1 - (double) distance / 64; - }else { - return 0; - } - + System.out.println("两个simHash的汉明距离为:" + hamingDistance); + // 用杰卡德系数计算文本相似度 + return (double)same/(hamingDistance+same); } - } \ No newline at end of file diff --git a/simhash/src/test/java/MainTest.java b/simhash/src/test/java/MainTest.java index c9879a9..e1d7f0a 100644 --- a/simhash/src/test/java/MainTest.java +++ b/simhash/src/test/java/MainTest.java @@ -1,8 +1,233 @@ +import com.hankcs.hanlp.HanLP; +import exceptions.HashException; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import exceptions.FileAnalyseException; +import exceptions.NotExistFileException; +import utils.CalculationUtils; +import utils.CommonUtils; +import java.util.Arrays; +import java.util.Map; + + public class MainTest { + //读取文件后得到的文本 + static String analyseStr; + //两个示例句子 + static String originSentence = "今天是星期天,天气晴,今天晚上我要去看电影。"; + static String compareSentence = "今天是周天,天气晴朗,我晚上要去看电影。"; + //比对结果写入的文件 static String writeFilePath = "E:\\测试文本\\write.txt"; + //原文件 static String OrigFilePath = "E:\\测试文本\\orig.txt"; + //5个比对文件 static String CopyFilePath1 = "E:\\测试文本\\orig_0.8_add.txt"; + static String CopyFilePath2 = "E:\\测试文本\\orig_0.8_del.txt"; + static String CopyFilePath3 = "E:\\测试文本\\orig_0.8_dis_1.txt"; + static String CopyFilePath4 = "E:\\测试文本\\orig_0.8_dis_10.txt"; + static String CopyFilePath5 = "E:\\测试文本\\orig_0.8_dis_15.txt"; + + /** + * 测试写入文件 + */ + @Test + void testWriteFile(){ + CommonUtils.writeFile(writeFilePath, "------successfully content entry------"); + try { + String s = CommonUtils.readFileToStr(writeFilePath); + Assertions.assertTrue(s.contains("------successfully content entry------"),"写入文件失败"); + } catch (NotExistFileException e) { + e.printStackTrace(); + Assertions.fail("写入文件失败"); + } + } + + /** + * 测试读取不存在的文件 + */ + @Test + void testReadFileNotExist(){ + try { + CommonUtils.readFileToStr("E:\\not existing.txt"); + Assertions.fail("没有抛出异常"); + } catch (NotExistFileException e) { + e.printStackTrace(); + Assertions.assertTrue(true); + } + } + + /** + * 测试文件解析异常(为null,为“”,为“ ”) + */ + @Test + void testFileAnalyseException(){ + try { + CommonUtils.analyseText(null); + Assertions.fail("没有抛出异常"); + } catch (FileAnalyseException e) { + e.printStackTrace(); + Assertions.assertTrue(true); + } + try { + CommonUtils.analyseText(""); + Assertions.fail("没有抛出异常"); + } catch (FileAnalyseException e) { + e.printStackTrace(); + Assertions.assertTrue(true); + } + try { + CommonUtils.analyseText(" "); + Assertions.fail("没有抛出异常"); + } catch (FileAnalyseException e) { + e.printStackTrace(); + Assertions.assertTrue(true); + } + } + + /** + * 测试读取文件并查看分词结果 + */ + @Test + void testReadFile(){ + try { + //测试句子分词 + System.out.println("分词结果为:"+CommonUtils.analyseText(originSentence)); + //测试文本分词 + analyseStr = CommonUtils.readFileToStr(OrigFilePath); + System.out.println("分词结果为:"+CommonUtils.analyseText(analyseStr)); + } catch (Exception e) { + e.printStackTrace(); + Assertions.fail("分词结果有误"); + } + } + + /** + * 测试MD5算法hash计算hash,检查所得到hash值是否为128位 + */ + @Test + void testWordHash(){ + HanLP.extractKeyword(originSentence, originSentence.length()).forEach( + word -> { + try { + String hash = CalculationUtils.wordHash(word); + System.out.println(word +" : "+ hash); + Assertions.assertEquals(128, hash.length(), "hash值长度不是128"); + } catch (HashException e) { + Assertions.fail("哈希出错"); + e.printStackTrace(); + } + } + ); + } + + /** + * 测试哈希异常(得到hash值为空) + */ + @Test + void testHashException(){ + try { + CalculationUtils.wordHash(""); + Assertions.fail("没有抛出异常"); + } catch (HashException e) { + e.printStackTrace(); + Assertions.assertTrue(true); + } + try { + CalculationUtils.wordHash(null); + Assertions.fail("没有抛出异常"); + } catch (HashException e) { + e.printStackTrace(); + Assertions.assertTrue(true); + } + try { + CalculationUtils.wordHash(" "); + Assertions.fail("没有抛出异常"); + } catch (HashException e) { + e.printStackTrace(); + Assertions.assertTrue(true); + } + } + + /** + * 测试加权算法 + */ + @Test + void testHashWeight(){ + Map map = null; + try { + map = CommonUtils.analyseText(originSentence); + } catch (FileAnalyseException e) { + e.printStackTrace(); + Assertions.fail("解析错误"); + } + map.forEach((word, count) -> { + try { + String hash = CalculationUtils.wordHash(word); + int[] hashWeight = CalculationUtils.hashWeight(hash,count); + //打印加权后的hash值 + System.out.println(word +" : "+ Arrays.toString(hashWeight)); + Assertions.assertEquals(128, hashWeight.length, "加权后的hash值长度不是128"); + } catch (HashException e) { + Assertions.fail("哈希出错"); + e.printStackTrace(); + } + }); + } + + /** + * 测试计算simHash + */ + @Test + void testCalculateSimHash() { + try { + String hash1 = CalculationUtils.calculateSimHash(CommonUtils.analyseText(originSentence)); + System.out.println("原句子\"" + originSentence + "\"的simhash值为:" + hash1); + Assertions.assertEquals(hash1.length(), 128, "hash值长度不是128"); + String hash2=CalculationUtils.calculateSimHash(CommonUtils.analyseText((CommonUtils.readFileToStr(OrigFilePath)))); + System.out.println("原文本的simhash值为:" + hash2); + Assertions.assertEquals(hash2.length(), 128, "hash值长度不是128"); + } catch (FileAnalyseException | NotExistFileException e) { + e.printStackTrace(); + } + } + /** + * 测试计算句子相似度 + */ + @Test + void testGetSimilarity1(){ + String hash1 = null; + String hash2 = null; + try { + hash1 = CalculationUtils.calculateSimHash(CommonUtils.analyseText(originSentence)); + hash2 = CalculationUtils.calculateSimHash(CommonUtils.analyseText(compareSentence)); + } catch (FileAnalyseException e) { + e.printStackTrace(); + Assertions.fail("解析错误"); + } + double similarity = CalculationUtils.getSimilarity(hash1, hash2); + String format = String.format("两个句子的相似度为:%.2f", similarity); + System.out.println(format); + Assertions.assertTrue(0 <= similarity && similarity <= 1, "相似度不在0-1之间"); + } + /** + * 测试计算文本相似度 + */ + @Test + void testGetSimilarity2(){ + String hash1; + String hash2; + try { + hash1 = CalculationUtils.calculateSimHash(CommonUtils.analyseText(CommonUtils.readFileToStr(OrigFilePath))); + hash2 = CalculationUtils.calculateSimHash(CommonUtils.analyseText(CommonUtils.readFileToStr(CopyFilePath1))); + double similarity = CalculationUtils.getSimilarity(hash1, hash2); + String format = String.format("两个文本的相似度为:%.2f", similarity); + System.out.println(format); + Assertions.assertTrue(0 <= similarity && similarity <= 1, "相似度不在0-1之间"); + } catch (FileAnalyseException | NotExistFileException e) { + e.printStackTrace(); + } + } + /** * 测试主函数 */ @@ -10,8 +235,18 @@ public class MainTest { void testMain(){ String[] args = new String[3]; args[0] = OrigFilePath; - args[1] = CopyFilePath1; + args[1]=CopyFilePath1; args[2] = writeFilePath; Main.main(args); + + args[1]=CopyFilePath2; + Main.main(args); + args[1]=CopyFilePath3; + Main.main(args); + args[1]=CopyFilePath4; + Main.main(args); + args[1]=CopyFilePath5; + Main.main(args); + args[0] = CopyFilePath3; } } \ No newline at end of file diff --git a/simhash/target/classes/Main.class b/simhash/target/classes/Main.class index 52bc6bc9e84e51a69a679bb356dc83289f94bd74..0f561640eb051ae4284750fe8ad3a87322ec6501 100644 GIT binary patch delta 678 zcmZvYOHWf#6otRTZST2lDpsDBR1ghDD@hFr8mxc{iXtdl#TTgMArEOmC7n1nJ8!JvGm3`$8JOQ ze=r8S5HRKnQTn)Gxfr9LONQilAwBJ%a5~(<7?-)?uQ==O)fm^ve*ES9FrhKaWB+@& zyK*JU6ILy20q&{a?0yYBH;no}-KICz^W(NJR5G;qgKmp!d1+`=*yz8tbz|4Lfnit& zS8!9$5XP0grAk`f$cIy(F`H$`ZP}m=9(QD8I{@x7qAbEF_f!$tYzP-oZOu=5!=(>o z67~~kQchbc)RwSYoZn^uPI{?uTf*C7Xj8*$ILuKR{`n*!f_eQ^$?lR~C2{sBs}iD? zM(Txc5MwW`BsfNrcJ`5>i5~XrsAg?*fQK9^u^{LwN6Jf&Xt7p_-{%2Nup&9es<)FbC;S%}PJQBSju_QmPYkM=f7g8wlj` tDi+kN$BF;Z-i~En#DHroO(D%#RJ(#Dit?VA%X-gPR~A+Df(>4g_zM;6Y?A;0 delta 508 zcmX|+OHWf#6otRrdwb9AO(6wq;bJTUg^)^&sZpZ^3L*~$<)IcIRTS|BYFl|ZF$|qJ zJ41hfQwS5nfDt4AfMfrFiGM&`7ZP*M*=O%{)?RC$@7b*r;g7$2`#?XRw!_G63hATY zFp#Fqp#A0!`q$G8bHn~}Kl`I;#u(pO^nN<=nZhxCA(M@jQFd@?flR6Gbe8t;#Ajn<3BRsAkU`jE4lqZ>H zMpcSg?#5hjR>eyRYuGPArK8{$3tllYyOj1Af@?t`S&V{RM(kmb+l*yLX;ExaTQeEu zty)>FJSXX)opa>qrHu=8aEVitIIS<`snE$&&hUmV>U1BCRh4xw9{0G98xJAYobFcU zdB8)BnB@tNlqICqx5;CwB9^!;xT?ls&P#M&f3L{usxTvi6wj0eJeO2Xt;cc;|K%2h tz0g}5Zst&9x`su=OKluOrzeZodQM2{t?odLOFH!oA614*S6E|%=pUZ|M{NKA diff --git a/simhash/target/classes/exceptions/FileAnalyseException.class b/simhash/target/classes/exceptions/FileAnalyseException.class index 11551ebc3366ded552220d16ff4e26675736aec0..bd5ef4da37b065e7b1ab4dcc42274e28beda9337 100644 GIT binary patch delta 17 Ycmeyy^o?nQHzO+t11kgPZf4#P!N|(Vz{Zf4#P!N|(Pz{!Q0*)1Ow6 zB(khL6-FpzrbQP+3RbY2S*BK+-ES5}U|IUNn3SIHz%>16clP_3bEfB(k>T$ zyVbpUQ-omFhgsC0!qZJvzp?LPd%mMQ+%f3bBXi|YqfsZ_mzMvk00St z@r{me@tub64V=agy^E$OLJV0&MsDKIGW)QB4{+3r5rOP%>zq5Ycy75HX9Q*s$950L zx+7I}68&g<^GHwY$o8#g`ge@(861ucoIltu1dL^&P%vClYepi$h=#KQnb!I#nNAIV zSVP8wbb(YnjSdLxU|Ss635~J~D%VcSj8Dnx;=Mcp@py)-VaqP;!m~WZ$uiQf)7lw- zHrmCT_{~5%a&ajNFcUXn7Am+`<1&OX2lpWxZJ3MaDZhewh|%&80yu&^ zvYv0}TM0M)Qm`A(5l0&%@4;RuXv8A=br5R?HTie}qJ&Adp&Ks}P#9%+iIS6nbiB;) zlENwMqciX-e+6(S#*Gt+s$q@>pN4>j9_xC~tfXG56*_pykG-eVX+=H7HoqCHkQ`tY zS5aRKAC{6;39@+3#kDqOKfT?^wDCD<9Uv%~R!Xng)mF*+b@UOagfbP%1-Lczi&WPb z8O0%r4Kgcr{j^cJ7Y@QI^8OdY3g%eJ3YRkft;j(+uHbngZnH7WB=t1bt=q1VL>yy2 z(5Cs+JEzkvxi5=|55coQ81NR3c?VNT((zC(LK8yxQrUm6 zF5mnl+anAr$>!z6uytM^9*aRdZXHOR9o^nT6L9eSgf;;=gY5;Mmw_6#zLtYgL;X5h zgg7Ae9KZE8(jvCO!v`uKkV5NAaEv8z(njH1IZlZQCkIAK^3}*(dH4_?5xvB`JV8#a yM^5cun0B=WuR_0%sd3;0PI6D>`4jqmiLWXBh@bF_eICWH)aumyhTm}x)Bgb`rbK=K delta 1552 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 delta 483 zcmYLFyH3ME5S+8)*m2H@F_1tWyvfUep`Zc^goG$SAccm~QlelK1r#h0RVoS!ipbx9 zL`zbDkl+LI9h6A?0bHVt2^r|GzRm`y>gwV+iEyf zzwNaeG3bb^;2Z`L4BCO~wT|nTJ_A2&dyVc%ep|Gd93z4^cFr$ucD-i($n{Ux1T)Gp z7Bbgu&#(VcI#NihnBbU1Cb-v%vW8r6r%$n|;8D-Bnc!2;$!@I{+{X8UFQXJ>_(t%M zD0DDOOC_=`Llc_`X~J8W$wSC6D2RelFW^vSl$FXe*b@N>a})(F@H|C6Y`_8*sR9bb zO5t#gWQ15Q`UZ91mU|twlrU7GticYk@V*)v157L1hh^paD95bnK9WL`h5ty9LvaiR5ju=QuS--* GSpEUbR6aZa -- GitLab