ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java实现基于动态规划的文献查重算法

Java实现基于动态规划的文献查重算法 前几天翻到了大学时代算法课的一份作业——Java实现基于动态规划DP的文献查重算法。就是这道题当年我们寝室四个人对着教材啃了整整三天才真正弄明白。前两天又有人私信问这个题怎么做说老师不允许用现成的查重网站也不许直接调第三方库必须自己从动态规划开始推导并实现。这篇文章我把当年的做题思路、完整代码、踩坑过程和优化方案全部整理出来包括文本预处理、LCS递推、回溯提取公共子序列、滚动数组优化以及各种异常排查。不管你是正在赶这份作业还是单纯想搞懂动态规划在文本相似度计算里到底怎么落地这篇的内容都能直接帮到你。1. 项目拆解这个算法作业到底在考什么1.1 需求本质查重不是“灵魂拷问”而是“数相同字符”先把这个题目翻译成人话。老师说要做一个“文献查重算法”本质上就是给你两段文本让你算出它们有多像。而“基于动态规划DP”这句话才是真正的题眼——老师想考的并不是你有多会写爬虫或者多会调接口而是你有没有能力把一个看起来模糊的问题“文本像不像”转化为一个可以用数学语言精确描述的问题。我当时第一步就是把问题形式化给定两个字符串 A 和 B它们之间的“相似程度”可以用“最长公共子序列Longest Common Subsequence简称 LCS”的长度来衡量。子序列不要求连续只要保持相对顺序就行。比如 A算法作业B作业算法它们的最长公共子序列是算法或作业长度为2。这种不连续匹配的好处是哪怕对方把原文的段落顺序打乱、中间插入了废话只要关键内容还在就能被识别出来。这个特性正好命中文献查重的核心场景——抄袭最典型的表现就是“换汤不换药”字句顺序调整、同义词替换但主干逻辑还是原来那套。那为什么偏偏用动态规划因为LCS问题有非常标准的动态规划解法是教科书级的DP入门案例老师在课上大概率讲过最长公共子序列布置这道题就是想看你能不能把这个经典模型迁移到真实的文本场景里。1.2 为什么不用暴力枚举写作业的时候最怕的就是拿到题目就开写写完才发现复杂度爆炸。我第一版确实试过暴力解法枚举 A 的所有子序列然后去 B 里判断是否存在。这个思路对不对对。能不能跑完全不能。这里我要给所有同学一个忠告遇到“求两个序列之间某种最大/最小关系”的问题先想想枚举的代价。A 的长度为 n它的子序列数量是 2^n 个也就是说每多一个字符工作量翻一倍。你随便贴一段几百字的论文子序列数量就比宇宙里的原子还多就算用世界上最快的计算机也枚举不完。动态规划能够把这种指数级的问题降成多项式级靠的是两个性质最优子结构和重叠子问题。LCS 问题的核心递推关系是在比较 A 的第 i 个字符和 B 的第 j 个字符时如果它们相等那么当前的最优答案可以直接从 dp[i-1][j-1] 推出来如果不相等就只能从 dp[i-1][j] 和 dp[i][j-1] 中取较大值。这意味着每一个子问题的答案只依赖前几个子问题而且大量子问题被重复计算。用一张二维表把它们全部缓存起来每个格子只算一次总时间复杂度就是 O(n*m)这就是动态规划的核心思想——用空间换时间、用已知推未知。1.3 算法选型LCS、编辑距离还是别的做这道题之前我还纠结过要不要用编辑距离Levenshtein Distance。编辑距离的思想是算把一个字符串变成另一个字符串最少需要多少次插入、删除、替换操作它跟LCS在数学上是非常近亲的关系对于两个长度分别为 n 和 m 的字符串编辑距离只允许插入和删除等于 n m - 2 * LCS长度。如果老师要求实现“查重”用编辑距离算出来的“相似度”其实也可以但很多老师点名要 DP 的时候默认就是让你写 LCS因为它在教材里讲得更详细、回溯更容易解释。我当时的选择是主算法用 LCS 求最长公共子序列长度再用一个比例公式换算成可读的相似度百分比同时把回溯出来的公共子序列作为证据输出来。这样做的好处是交报告的时候你能给老师展示“这篇论文跟原始文献的重合片段是这些”而不是只给一个冷冰冰的数字。一句话总结做一个能自圆其说、能展示中间结果的完成品永远比糊一个能跑的结果得分高。2. 动态规划核心原理从状态定义到递推公式2.1 状态定义和递推关系写DP代码之前先把状态定义写清楚。这一点几乎所有老师都会强调但很多同学一拿到题就跳进代码结果越写越乱。我建议你在草稿纸上先写三行字状态dp[i][j] 表示 A 的前 i 个字符组成的子串和 B 的前 j 个字符组成的子串的LCS长度。初始化dp[0][j] 0dp[i][0] 0因为空串跟任何字符串的公共子序列长度都是0。递推如果 A[i-1] B[j-1]dp[i][j] dp[i-1][j-1] 1否则 dp[i][j] max(dp[i-1][j], dp[i][j-1])。注意这里为什么 A 的第 i 个字符要写成 A[i-1]原因很简单Java 的字符串下标从 0 开始而我们定义的 dp 表格为了留出“空串”这一行一列下标多偏移了一位。这是整个实现里最容易出错的地方后面排查部分我还会重点说。递推公式背后的直觉是两个串末尾字符相同那这个字符一定可以加进公共子序列里长度在去掉这两个末尾字符的子问题答案基础上加1末尾字符不同那就只能舍弃其中一个串的末尾字符看看哪边舍弃之后的结果更大。这个过程不断把一个大规模问题缩小成小规模问题就能一路推到边界条件这就是“最优子结构”的含义。2.2 手推一遍DP表光看公式体会不深我当年是亲手在纸上画了一张表才彻底理解的。这里用一个最简单的例子AABCDBACBD肉眼可以看出最长公共子序列是ABD或ACD长度为3。我们把 dp 表逐行填一遍dp[i][j]ACBD00000A01111B01122C01222D01223填表的过程就是你模拟程序运行的过程。第二行A 和 A 相等dp[1][1]dp[0][0]11然后 A 和 C 不等取 dp[0][1]0 和 dp[1][0]0 的较大值依然是0不对这里我填表时容易犯迷糊其实 dp[1][2] 应该看 A 和 AC 的LCS明明是1。因为 A[0]A 与 C[1]C 不相等此时要看 dp[0][2] 与 AC的LCS0和 dp[1][1]A 与 A的LCS1取较大值为1。所以我上面表格里第二行 A 与 C 交叉的格子填的是1。记住这个原则每个格子都只取“左上方、上方、左方”三个方向的信息填的时候千万别跳步。你只要有耐心手推一个 4×5 的小表DP 的执行过程就能彻底刻在脑子里。写代码的时候我甚至建议你先用一个极小的输入跑通再丢进真正的文献文本里测试这种“从小验证到大”的习惯能帮你省下大量调试时间。2.3 复杂度分析和优化方向一个 n×m 的二维表时间和空间复杂度都是 O(n*m)。对作业里常见的文本规模比如几千字的文章算一下就是几百万次操作Java 完全可以扛住。但如果文档达到几万、几十万字二维表的内存开销就会急剧上升。比如两端文本各 50000 字符dp 表就需要 50001×50001 个 int每个 int 占 4 字节算下来大约是 10GB这显然不现实。所以这套标准二维表实现是“正确但不完美”的。真正要用于大文本必须在空间上做优化。这也是我后面单独开一章讲滚动数组的原因——这是一个能让你从“作业及格”升级到“作业优秀”的关键优化点。3. Java实现文本预处理、DP表与回溯3.1 先做文本预处理真实场景下的文献不会像算法题里的字符串那么干净里面全是标点符号、换行、空格、大小写差异。如果直接拿原始文本去跑LCS两个几乎一样、只是标点不同的段落算出来的相似度会被严重拉低。所以第一步是对文本做归一化处理。我当时写了一个 normalize 方法作用是把所有字符转成小写、把非字母非数字的符号统一替换成空格、再把连续多个空白压缩成一个。这里用到 Java 正则表达式中的一个冷门特性\\p{L}能匹配任意语言的字母包括中文汉字。用[^\\p{L}\\p{N}]做匹配可以一次性把所有标点、空格、特殊符号全干掉同时保留中英文和数字。这一步做完文本就被处理成了干净的“字串”方便后续逐字符比较。import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; public class TextPreprocessor { public static String normalize(String raw) { if (raw null || raw.isEmpty()) { return ; } return raw.toLowerCase() .replaceAll([^\\p{L}\\p{N}], ) .replaceAll(\\s, ) .trim(); } public static String readFileAsString(Path path) throws Exception { return Files.readString(path, StandardCharsets.UTF_8); } }这里有个细节值得提如果你们老师给的测试文档是用 Windows 笔记本写的中文编码可能是 GBK而你直接用 Files.readString 按 UTF-8 读就会乱码。统一改成显式指定 UTF-8 读取或者让老师确认文件编码能避免大量莫名其妙的“相似度偏低”问题。3.2 核心DP代码预处理做完接着就是算法的重头戏。我先把最标准的二维表实现贴出来方便你对照理解。这个方法返回的是完整的 dp 表后续回溯会用到。public class LcsDp { /** * 计算两个字符数组的LCS dp表 * dp[i][j] 表示 a[0..i-1] 和 b[0..j-1] 的LCS长度 */ public static int[][] lcsTable(char[] a, char[] b) { int n a.length; int m b.length; int[][] dp new int[n 1][m 1]; for (int i 1; i n; i) { for (int j 1; j m; j) { if (a[i - 1] b[j - 1]) { dp[i][j] dp[i - 1][j - 1] 1; } else { dp[i][j] Math.max(dp[i - 1][j], dp[i][j - 1]); } } } return dp; } public static int lcsLength(char[] a, char[] b) { return lcsTable(a, b)[a.length][b.length]; } }这段代码有几个地方我当年反复出错现在用血的教训告诉你重点第一数组长度申请的是(n 1) × (m 1)因为要留出第0行和第0列代表空串第二循环从 1 开始比较时用的是a[i - 1]和b[j - 1]第三别忘了Math.max(dp[i - 1][j], dp[i][j - 1])——两个方向都要比只取一个方向会漏掉答案。只要这三处都写对LCS长度几乎不可能算错。3.3 回溯提取公共子序列算长度只是第一步真正让项目显得完整的是把“重合内容”找出来。回溯的思路是从表右下角往左上角走如果当前两个字符相等这个字符就是公共子序列的一部分向左上移动不相等的话哪个方向的值大就往哪边走。public static String lcsString(String a, String b) { char[] ac a.toCharArray(); char[] bc b.toCharArray(); int[][] dp lcsTable(ac, bc); int i ac.length; int j bc.length; StringBuilder sb new StringBuilder(); while (i 0 j 0) { if (ac[i - 1] bc[j - 1]) { sb.append(ac[i - 1]); i--; j--; } else if (dp[i - 1][j] dp[i][j - 1]) { i--; } else { j--; } } return sb.reverse().toString(); }注意最后一定要 reverse因为回溯是从右往左收集字符的得到的顺序恰好是逆序。另外回溯结果对文本归一化后的串才有意义如果你想展示原始文本里到底哪句话重合了需要记录下标映射关系但我个人建议作业阶段做到“归一化文本层面的回溯”就足够交差了加上原始文本映射会让代码复杂很多而且不是老师考察的重点。3.4 相似度指标怎么算有了 LCS 长度最后一步就是把它换算成人类能读的相似度。这里有一个算法上没标准答案、但报告里必须说清楚的问题分母到底用谁。我见过三种常见方案用min(lenA, lenB)做分母适合判断“短文本是不是长文本的一部分”比如查重时想知道一篇短文有没有整段复制另一篇长文。缺陷是只要短文本完全嵌入长文本相似度直接就是100%可能会误伤正常引用。用max(lenA, lenB)做分母适合判断两篇等长文本的差异长文本占便宜短文本很容易被判成低相似。用2 * LCS / (lenA lenB)骰子系数思想这是折中方案两边都不偏袒更接近人对“整体相似”的直觉。我自己最后用的是第二种变体相似度 lcs / max(lenA, lenB)再乘100转成百分比。因为文献查重里一篇 2000 字的文章和一篇 6000 字的文章哪怕短文章完全来自长文章你也不该说它们“100%相似”而“80%重合”这个说法更符合实际查重报告的语义。public static double similarity(String a, String b, String normalizedA, String normalizedB) { int lcs lcsLength(normalizedA.toCharArray(), normalizedB.toCharArray()); int maxLen Math.max(normalizedA.length(), normalizedB.length()); if (maxLen 0) { return 1.0; // 两篇空文本视为完全相似 } return lcs * 100.0 / maxLen; }空文本的处理容易被忽略但作业里老师可能故意构造空串的测试用例如果不加判断除零异常直接让你白丢分。4. 内存与性能优化滚动数组和分段查重4.1 滚动数组把空间从O(nm)降到O(m)前面算过大文本场景下二维表内存爆炸。实际上我们在算LCS长度时根本没用到整张表——递推公式里第 i 行只依赖第 i-1 行再往前就没用了。所以可以用两个一维数组滚动复用把空间复杂度降到 O(m)。这个优化思路在动态规划里非常经典很多老师会在课堂延伸里提到但并不是所有同学会主动用。在作业报告里单独开一节写这个优化是肉眼可见的加分项。public static int lcsLengthOptimized(char[] a, char[] b) { int n a.length; int m b.length; int[] dp new int[m 1]; for (int i 1; i n; i) { int prev 0; // 相当于 dp[i-1][j-1] for (int j 1; j m; j) { int temp dp[j]; // 保存 dp[i-1][j]下一轮变成 prev if (a[i - 1] b[j - 1]) { dp[j] prev 1; } else { dp[j] Math.max(dp[j], dp[j - 1]); } prev temp; } } return dp[m]; }这段代码的关键是那个prev变量。在一维数组里dp[j] 在更新前存的是上一行的值理论上我们更新 dp[j] 时需要三个数据左上角dp[i-1][j-1]、正上方dp[i-1][j]、正左方dp[i][j-1]。正上方就是更新前的 dp[j]正左方是已经更新过的 dp[j-1]唯一麻烦的是左上角所以要用 prev 提前把上一行 j-1 位置的值存下来。每一轮循环开始时prev 先置0然后在 j 循环里 “先取旧值、再更新、再传递”顺序一乱结果必错。4.2 分段查重更像一个真正的查重系统如果只是对两篇整文算一个相似度数字报告会显得单薄。我当时为了让项目有辨识度加了段落级对比先把文档按空行或句号分成多个段落然后两两组合计算相似度把相似度超过阈值的段落对输出到控制台。这个功能其实就是现网查重系统的基础形态——查重工具从来不会只给你一个总百分比而是会标出“第几段与哪个来源高度相似”。实现思路不复杂用 split 把归一化后的文本按段落切开放进一个列表双重循环遍历段落对调用 lcsLengthOptimized 计算每一对的相似度相似度大于 0.6 就记录下来。这个功能一加作业报告的“系统设计”部分就有东西写了。public static ListString findSuspiciousParagraphs(String docA, String docB, double threshold) { String[] pa docA.split(\\s{2,}|\\n); String[] pb docB.split(\\s{2,}|\\n); ListString result new ArrayList(); for (String a : pa) { for (String b : pb) { if (a.length() 20 || b.length() 20) { continue; // 太短的段落没有统计意义 } double sim similarity(a, b, a, b); if (sim threshold) { result.add(String.format(相似度 %.1f%% | 原文段落: %s | 对比段落: %s, sim, a, b)); } } } return result; }这里加了个长度过滤太短的段落比如几个字很容易偶然相似影响判断。这个细节可以在报告里写出来说明你考虑到了实际场景的干扰因素。4.3 其他可选算法的横向对比写报告时我还放了一张对比表把几种文本相似度算法的特点和适用场景列出来用于解释“为什么作业选DPLCS”。这个表格不需要做得太深但能证明你做过调研。算法核心思想优点缺点适用场景DP-LCS最长公共子序列顺序敏感抗插入删除可回溯证据时间复杂度较高文献查重、代码抄袭检测编辑距离最少编辑操作数能反映修改程度和查重语义略有偏差拼写纠错、DNA比对N-gram Jaccard滑动窗口词片段的集合交并比速度快实现简单丢顺序信息碎片化大规模网页去重SimHash哈希指纹降维海量文本可并行精度粗糙不适合精确检测搜索引擎指纹库这个表格点到为止不需要展开成论文篇幅。核心结论就一句话作业指定DPDP也确实是文本查重的合理基础。5. 常见问题与排查实录5.1 高频Bug速查表赶作业期间遇到的所有问题我整理成一张表每个都是真实踩过的坑现象根本原因解决方案数组越界异常dp表尺寸和循环下标不匹配常见于忘记留出第0行/列统一用dp[n1][m1]字符比较用i-1/j-1结果比预期大把dp[i-1][j-1]1写成了dp[i][j]1重复累加严格按递推公式逐行核对结果比预期小Math.max只比较了一个方向漏掉另一侧状态确认两个方向都参与比较中文全变乱码文件编码不是UTF-8读取时显式指定StandardCharsets.UTF_8或转换为统一编码文本越大越卡双重循环O(n*m)在几十万字时耗时严重先用长度差快速过滤再用滚动数组优化内存溢出二维表太大用滚动数组还不行就分段比较回溯结果为空传入的空串或全被预处理清空先打印预处理后的文本检查5.2 中文与编码问题Java 的 char 是 UTF-16 编码一个汉字在基本平面内占用一个 char所以在toCharArray()后逐字符比较中文是可以直接处理的。但有一个隐藏问题如果你处理的是 Emoji 或者某些生僻字辅助平面字符它们会占两个 char也就是所谓的代理对。逐字符LCS会把一个完整的字符拆成两半来比较可能导致边界情况下公共子序列计算不精准。作业场景里基本用不到辅助平面字符但如果测试文档里混进了这类字符你至少要知道原因在哪。最稳妥的做法是先把字符串里的代理对合并成逻辑字符或者干脆跟老师确认测试数据不包含特殊字符。我自己当时是直接忽略了这个情况后来在写“局限与改进”那节报告时意识到了也算是个加分的反思点。5.3 越界与整数溢出风险还有一个很少人注意的细节LCS长度本身不会超过min(n, m)这在 int 范围内没问题但如果用二维数组int[n1][m1]当 n 和 m 都超过 46340 时数组元素个数 n*m 会超过 int 可表示的范围虽然 Java 里分配数组是用 long 计算大小的但连续 4 字节乘上亿次的内存分配大概率会直接 OOM。所以说到底大文本还是要靠滚动数组。这个问题我在作业里没遇到是在给一个开源项目提 PR 时被提醒的写在这里让你们少走弯路。6. 测试用例设计与验收心得6.1 手工构造的验证用例算法写完不是结束验证才是拉分关键。我从第一天做这个项目就建立了一个习惯先把所有边界用例跑一遍再处理真实文档。下面这组用例是我当时固定跑的完全相同的文本预期相似度 100%用来验证最基础的正确性。一篇为空字符串预期程序不崩溃空串与任何非空串相似度为0。完全无关的随机文本预期相似度非常低。段落顺序打乱的文本验证 LCS 对顺序调整的容忍度。插入大量标点、字母大小写混用的文本验证预处理模块是否生效。这组用例跑完基本上算法的正确性就站得住脚了。如果连这些简单场景都出错直接上真实文献大概率会翻车。举个例子验证一下归一化后的 A 是abcdB 是acdb肉眼能看出 LCS 是acd或abd长度3按max_len4算相似度是75%。如果你跑出来是50%那大概率是递推公式里方向漏了或者 char 下标写错。6.2 基准测试与结果合理性判断作业里我加了一个简单的时间统计读入段落之前先记录System.nanoTime()算完再记录一次输出耗时。这个做法不是为了炫技而是为了让老师看到一个工程化的思考过程——你关心性能是否可接受。我当时用两篇各约 5 万字的文本测过优化前二维表跑了约 1.2 秒内存峰值接近 1.5GB优化后滚动数组时间几乎不变内存峰值降到几百MB。你在报告里写这么一组对比数据比写一百句“我做了优化”都管用。6.3 给赶作业同学的建议最后说点掏心窝子的话。这份作业想拿高分关键不在算法本身有多难而在于你能不能把“算法题”包装成“工程任务”。我的建议是代码文件按模块拆开分别放文本预处理、LCS计算、回溯、相似度计算、测试入口README里写好怎么运行报告里把递推公式推导过程写清楚再附上那组性能对比。这些加起来就是一份完整、体面、能拿高分的作业。这段经历对我后来理解动态规划帮助特别大。很多同学觉得 DP 难是因为只刷题、不落地当你把它用来解决一个真实的问题比如查重递推公式就不再是抽象符号而是有了明确的业务含义。如果你们老师后续要求扩展到“多文档两两查重”在现在的代码上套一个二重循环遍历文档列表就行架构完全不用改。希望这篇能把你们从“对着题目发呆”的状态里捞出来早日交出一份自己满意的作业。
返回列表