ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java实现动态规划文献查重:LCS与编辑距离算法全解析

2026/10/5 8:33:24 拓冰建站 浏览量
Java实现动态规划文献查重:LCS与编辑距离算法全解析 如果你的算法课作业也卡在“Java实现基于动态规划的文献查重算法”这道题上先别急着去网上复制一份模板代码。这道题真正想考察的是你能不能把一个看似“很难办”的查重问题抽象成动态规划DP可解的最优子结构再把它翻译成能稳定运行的Java代码。我当时做这个作业时踩了不少坑内存直接爆掉、中文文本乱码导致匹配错乱、“为什么相似度结果看起来明显不对”这类让人抓狂的问题。这篇记录把我从拿到题目到最后交作业的完整思路、算法设计、代码实现和排查过程都写出来适合正在写大学算法作业的学生也适合想快速在Java里落地DP查重的人参考。1. 项目概述这题考的是DP建模不是查重工具开发1.1 先搞清楚“文献查重”底层在比较什么文献查重的本质是判断两段文字有多少共同内容。最朴素的想法是找两段文本中共同包含的片段片段越长、出现越密集说明内容重叠越明显。但“找公共片段”这件事如果用暴力子串比较来做两篇各8000字符的文章要两两枚举所有子串再逐个比对复杂度直接爆炸到不可接受。动态规划可以把“找最长公共子序列”LCS变成一个简单的填表问题这也是这道作业选择DP作为核心算法的根本原因。LCS不要求字符连续只要求字符顺序保持一致能够匹配到的字符越多说明两篇文章的结构越接近。放在文献查重场景里就是就算文本调换了个别词语、中间插了几句话LCS也能抓住主干重叠的部分。需要先分清一个概念题目里的“查重”并不是要你做一个能跑完整篇论文的商用查重系统而是在一个可控规模的文本上用DP精确计算出两篇文档的相似程度。生产环境里的查重系统会用n-gram、SimHash、局部敏感哈希这些方案复杂度可控、适合海量文档但大学算法作业要的是让你掌握DP建模过程而不是开发一个能横向扩展的分布式系统。1.2 我最终选定的算法组合与选型理由我最终采用“最长公共子序列LCS为主算法编辑距离Levenshtein Distance作为补充”的组合方案。这个选择有三个明确理由。第一LCS天然贴合“查重”的语义。抄袭文本的常见形态是保留语序的删减和替换LCS刻画的就是“保持顺序条件下最多能保留多少字符”。两篇文章就算局部差异很大只要主干顺序相同LCS会给出一个明显的峰值。第二DP递推公式很经典作业汇报时容易讲清楚状态定义、转移方程、边界条件。相比后缀数组和后缀自动机这类高级数据结构DP方案对算法课作业来说性价比最高也最容易证明复杂度。第三Java实现成本低只需要二维数组或滚动数组不依赖第三方库一个文件就能完成核心逻辑。我追加实现编辑距离是因为它能补充LCS看不到的信息LCS回答“最多保留了多少”编辑距离回答“最少修改多少次能变成对方”。两个指标配合起来能给出更立体的相似度评价。1.3 作业场景下的复杂度边界别一上来就想造火箭先明确这个问题的复杂度边界。经典DP求LCS的时间复杂度是O(nm)空间可以优化到O(min(n,m))。真实产品里用O(nm)跑大文本不现实但在大学作业场景下文本量级一般是几千字符到几万字符O(n*m)时间、O(min(n,m))空间完全能接受。以我实际测试为例两篇各8000字符的文章用滚动数组优化后单次LCS计算在普通笔记本上耗时几百毫秒到1秒出头。这个性能对作业演示、让老师看到算法运行过程来说已经足够。不要一上来就想用多线程、分片优化甚至分布式计算那不属于这道题的考核目标把DP的建模思想和实现正确性做好才是得分的重点。2. 核心算法原理动态规划是怎么一步步拆出来的2.1 最长公共子序列的“最优子结构”是怎么回事动态规划能解决问题的前提是问题具备最优子结构。LCS恰好满足这一点我用白话解释一下。假设有两个序列X和Y长度分别是m和n。我们要求X和Y的最长公共子序列。此时看最后两个字符如果X的最后一个字符等于Y的最后一个字符那么它一定属于最终答案的一部分于是问题变成求X去掉最后一个字符后与Y去掉最后一个字符后的LCS然后加上这个公共字符。如果最后一个字符不相等那这个字符不可能同时出现在公共子序列的末尾所以答案只能来自两种情况要么忽略X的最后一个字符求X前m-1个字符与Y的LCS要么忽略Y的最后一个字符求X与Y前n-1个字符的LCS。取两者中长度更大的那个就是答案。这个思路就是最优子结构大问题的最优解可以由规模更小的子问题推导出来。因为每一步只会收到“相同/不同”两种信号所以状态数量有限适合用二维表格逐个计算。2.2 状态转移方程与一张填表示例定义dp[i][j]表示X的前i个字符与Y的前j个字符的LCS长度。边界条件是dp[0][j]0、dp[i][0]0因为任一字符串为空时公共子序列长度必定为0。转移方程就两行如果X[i-1]等于Y[j-1]那么dp[i][j] dp[i-1][j-1] 1如果两者不等那么dp[i][j] max(dp[i-1][j], dp[i][j-1])我用一个最小例子演示填表过程。设X ABCY AC。初始化表格后第一行第一列全是0然后按行从上到下填。dp值AC000A011B011C012逐格看一遍dp[1][1]比较X的A和Y的A相等取左上角0加1得到1。dp[1][2]比较A和C不等取上方0和左方1中的较大值1。dp[2][2]比较B和C不等取上方1和左方1中的较大值1。dp[3][2]比较C和C相等取左上角1加1得到2。最终dp[3][2]就是LCS长度2AC确实也是ABC和AC的最长公共子序列。这个填表过程是最直观理解DP的方式作业汇报时能画这个表基本就是加分项。2.3 相似度指标设计LCS比值与编辑距离的取舍得到LCS长度后需要把它转换成有实际意义的相似度。最常用的公式是similarityLCS 2 * LCS长度 / (X长度 Y长度)这个公式的好处是结果落在0到1之间且当两篇文章完全相同时LCS长度等于两个串长度此时结果为1当完全没有公共字符时结果为0。分母用两个长度的平均值相当于把“公共内容占比”做了一次归一的度量。我同时实现了编辑距离作为补充指标。编辑距离的相似度公式是similarityED 1 - 编辑距离 / max(X长度, Y长度)编辑距离度量的是“把X改造成Y最少要多少步插入、删除、替换操作”。这个值越小文本越相似。两个指标的区别可以打个比方LCS看“这两篇文章有多少基因是共有的”编辑距离看“从这篇文章改成那篇文章要动几次大手术”。实际使用中如果两篇文章大面积改写LCS相似度会明显下降而编辑距离可能因为替换成本相对分散仍然给出中等偏上的分数两种指标结合解读会更合理。2.4 滚动数组优化从O(n*m)空间降到O(m)严格来说二维数组dp[m1][n1]在文本长度达到万级时已经会占用大量内存。比如两篇各10000字符的文章完整的int二维数组约1亿个元素换算下来接近400MB轻松触发堆溢出。但观察转移方程可以发现第i行的值只依赖第i-1行和当前行前面已经计算出的值。换句话说不需要保留所有历史行只需要保留当前行和上一行两个一维数组就够了。这就是滚动数组优化空间复杂度从O(n*m)降到O(m)。代码上每算完一行就把prev和curr两个引用交换一下并把新的curr数组重置。这里有个细节交换后真正的“上一行”变成了原来的curr真正的“当前行”变成了原来的prev必须把当前行全部清零否则上一次的残留数据会污染下一轮的计算。关于复杂度优化再补充一个重要技巧如果两个串长度悬殊可以先把长串放在外层循环、短串作为数组列数这样滚动数组的宽度取min(m,n)内存进一步缩小。LCS结果本身和遍历方向无关交换两个输入不改变最终长度。3. Java完整实现从读入文献到输出相似度3.1 工程结构与核心类划分我这里把工程分成四个核心类职责边界清晰也方便作业答辩时讲清楚模块划分。src/main/java/homework/plagiarism/ ├── TextPreprocessor.java // 负责文本清洗 ├── LcsSimilarity.java // 基于DP的LCS核心算法 ├── EditDistance.java // 编辑距离指标 └── Main.java // 主流程读文件、计算结果、输出报告TextPreprocessor只做一件事把原始文本转换成“可比较的干净字符串”。LcsSimilarity和EditDistance只负责算法不关心输入来源。Main负责组装流程。这种分层写法的好处是你想单独测试LCS算法时不用为了凑输入去处理文件编码问题。3.2 文本预处理标点、大小写、编码一个都不能漏文献查重不能直接拿原始文本比较。如果两篇文章只有标点符号不同核心内容完全一样直接比较原始文本会把注意力浪费在标点差异上。所以预处理是决定相似度合理性的第一道关口。我做三层清洗第一层把所有英文字母转成小写避免“Algorithm”和“algorithm”被当成两个字符第二层去掉所有非中英文、非数字的字符包括标点、空格、换行、全角符号第三层把连续空白符兜底替换掉防止因换行符差异引入噪声。这是核心代码package homework.plagiarism; public class TextPreprocessor { public static String clean(String text) { if (text null || text.isEmpty()) { return ; } // 转小写再剔除标点和空白只保留中文、英文字母和数字 return text.toLowerCase() .replaceAll([^\\u4e00-\\u9fa5a-z0-9], ); } }正则里的\\u4e00-\\u9fa5是中文字符的Unicode区间保留了所有汉字。英文字母经过toLowerCase后统一成小写数字原样保留。这样处理后的文本可以避免“空格数量不同”“中英文标点混用”导致的误判。编码问题也要注意。读文件时显式指定UTF-8不要用平台默认编码否则在Windows上容易以GBK读取导致中文变成乱码。Java 11及以上可以用Files.readString简化操作。3.3 LCS核心代码二维表和滚动数组两个版本先说二维表版本它逻辑最清晰适合理解算法本身也适合作业里展示完整状态表格。数组大小是(m1)*(n1)运行时内存开销大但代码几乎照搬状态转移方程。下面是我最终的滚动数组实现也是作业实际运行时使用的版本。相比二维数组版只多了一个数组交换逻辑但内存占用大幅下降。package homework.plagiarism; import java.util.Arrays; public class LcsSimilarity { public int lcs(String a, String b) { // 让较长的串作为外层循环短串作为数组列数 if (a.length() b.length()) { return lcs(b, a); } int m a.length(); int n b.length(); int[] prev new int[n 1]; int[] curr new int[n 1]; for (int i 1; i m; i) { char ca a.charAt(i - 1); for (int j 1; j n; j) { if (ca b.charAt(j - 1)) { curr[j] prev[j - 1] 1; } else { curr[j] Math.max(prev[j], curr[j - 1]); } } // 交换数组prev变成当前行curr变成上一行 int[] tmp prev; prev curr; curr tmp; // 重要清空新的curr避免旧数据污染下一轮 Arrays.fill(curr, 0); } return prev[n]; } }递归交换参数这块可以不加但要保证外层循环用的是长串。不加交换时内存宽度取决于第一个参数的长度如果第一个参数恰好很长数组就会偏大。加了交换之后数组宽度永远是短串长度加1更省空间。数组交换逻辑值得细看每轮结束时prev持有当前行的结果curr是旧数据。下一轮要计算新行时应该把新行写入curr所以交换后curr是空的旧数组执行Arrays.fill清零避免脏数据。3.4 编辑距离扩展与主流程组装编辑距离的滚动数组实现和LCS非常像区别在于转移方程里多了一个“替换”操作。dp[i][j]表示X前i个字符改成Y前j个字符的最小编辑次数。边界是dp[0][j]j、dp[i][0]i因为空串改成任意串只能靠插入。状态转移方程如果字符相等dp[i][j] dp[i-1][j-1]如果字符不等dp[i][j] 1 min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])其中dp[i-1][j]对应删除dp[i][j-1]对应插入dp[i-1][j-1]对应替换。package homework.plagiarism; public class EditDistance { public int distance(String a, String b) { if (a.length() b.length()) { return distance(b, a); } int m a.length(); int n b.length(); int[] prev new int[n 1]; int[] curr new int[n 1]; for (int j 0; j n; j) { prev[j] j; } for (int i 1; i m; i) { curr[0] i; for (int j 1; j n; j) { int cost a.charAt(i - 1) b.charAt(j - 1) ? 0 : 1; int delete prev[j] 1; int insert curr[j - 1] 1; int replace prev[j - 1] cost; curr[j] Math.min(Math.min(delete, insert), replace); } int[] tmp prev; prev curr; curr tmp; } return prev[n]; } }注意这里的初始化差异LCS的数组初始值全是0而编辑距离第一行必须初始化成0到n的自然序列因为空串变成任意前缀需要对应次数的插入。主流程这里组合所有模块读文件、算相似度、输出报告。package homework.plagiarism; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; public class Main { public static void main(String[] args) throws Exception { String textA Files.readString(Path.of(args[0]), StandardCharsets.UTF_8); String textB Files.readString(Path.of(args[1]), StandardCharsets.UTF_8); String cleanA TextPreprocessor.clean(textA); String cleanB TextPreprocessor.clean(textB); LcsSimilarity lcs new LcsSimilarity(); int lcsLen lcs.lcs(cleanA, cleanB); EditDistance edit new EditDistance(); int editDist edit.distance(cleanA, cleanB); // 两种相似度指标 double simLcs 2.0 * lcsLen / (cleanA.length() cleanB.length()); double simEdit 1.0 - editDist / (double) Math.max(cleanA.length(), cleanB.length()); System.out.println(文本A清洗后长度: cleanA.length()); System.out.println(文本B清洗后长度: cleanB.length()); System.out.println(LCS长度: lcsLen); System.out.println(LCS相似度: String.format(%.4f, simLcs)); System.out.println(编辑距离: editDist); System.out.println(编辑距离相似度: String.format(%.4f, simEdit)); } }整个流程是读文件 - 清洗 - 分别计算两个DP指标 - 打印结果。命令行执行时传入两个文件路径即可例如java Main a.txt b.txt。3.5 用一个最小样例验证算法正确性写完代码第一件事不是跑大文本而是先用一个能手工推导的极小样例验证逻辑。我当时的测试样例是“ABC”和“AC”手工计算的LCS长度是2。跑程序得到LCS长度2编辑距离1LCS相似度0.8编辑距离相似度0.6667。为什么编辑距离是1因为“ABC”要变成“AC”只需要删除一个字符“B”一次删除操作就完成。这个结果和手工推导一致说明两个DP算法在小型输入上行为正确。这个步骤很重要直接验证了状态转移方程和滚动数组交换逻辑没有出问题。如果一上来跑8000字大文本结果错了都不知道是算法错还是预处理错。4. 真实踩坑记录与性能调优过程4.1 最凶险的坑大数组直接把堆内存打爆第一次跑测试时我图省事直接用二维数组int[m1][n1]输入两篇各5000字符的文本程序直接抛java.lang.OutOfMemoryError: Java heap space。我当时第一反应是“这算法复杂度没问题啊”完全没意识到是内存布局出事了。算一笔账5001乘5001的int数组元素个数约2500万每个int占4字节光数据区就是100MB加上JVM对象头和二维数组的行引用实际占用远超100MB。如果文本到1万字符立刻变成400MB量级。JVM默认堆一般只有256MB左右必然爆。这就是为什么滚动数组优化不是锦上添花而是能让程序真正跑起来的关键。后来换成两行数组后同样的输入内存占用只有几十KB性能问题彻底消失。4.2 中文文本里的字符比较陷阱第二坑出现在中文文本上。我最初用charAt逐个比较字符第一版在大部分中文环境里跑没问题。但读到包含生僻字、特殊扩展区汉字或某些含组合字符的文档时结果莫名偏低排查后发现是UTF-16编码导致的。Java的char存储的是UTF-16编码单元。绝大多数常用汉字占用一个char但部分扩展B区及更后面的汉字、emoji字符会占用两个char也就是代理对。如果用charAt比较同一个字会被拆成两个不完整的代码单元自然匹配不上。作业层面我做了个折中文本预处理时通过正则保留中文但charAt比较仍然基于单char。对算法课作业来说常见的现代汉字不会触发这个坑但我建议在代码注释里写清楚局限性。如果文本来源确实包含大量生僻字可以考虑用codePointAt方法按完整码点遍历不过代码复杂度会明显上升。4.3 时间优化短串交换、提前终止与局部窗口时间优化上我实际用了三个手段按性价比排序。第一个是短串交换。先比较两个字符串长度让长的串做外层循环短串决定滚动数组宽度。这个改动对LCS结果没任何影响但能减少数组分配大小顺带提升缓存命中率。第二个是快速过滤。如果清洗后的文本字符集合重叠度很低比如A只有30%的字符在B中出现那LCS长度一定很小可以直接跳过完整DP过程只返回一个下界估算。这在批量查重时能省下大量无效计算。第三个是局部窗口近似。LCS的严格计算需要遍历整个表格但实际文本如果高度相似真正的匹配路径大概率落在主对角线附近。实现时可以把计算范围限制在对角线两侧宽度为k的带状区域内复杂度降为O(k*n)。代价是结果变成近似值。大学作业建议先把精确版本写好这个优化作为扩展点放在文档里展示即可。4.4 “四边形不等式优化”在LCS里为什么不套用很多人在面试题里看过“四边形不等式优化DP”误以为LCS也能套用。实际上四边形不等式优化主要针对dp[i][j] min(dp[i][k] dp[k1][j])这类区间DP经典案例是石子合并问题优化点在于利用决策单调性快速缩小k的枚举范围。LCS的转移方程是dp[i][j] max(dp[i-1][j], dp[i][j-1])状态转移里没有枚举决策点k不存在“哪个切分点更优”的问题。也就是说LCS根本不存在四边形不等式优化的应用场景硬套上去只会把简单问题复杂化。这是我踩过的一个“知识堆砌”的坑写作业时总想多写点高级优化展示水平结果发现方案根本牛头不对马嘴。后来我在报告里诚实地分析了一遍为什么不适用反而显得对DP理解更深。5. 常见问题速查与给作业党的三条经验5.1 常见问题与排查思路速查表我把实操中遇到的问题整理成了速查表方便后来者按现象定位原因。现象可能原因解决办法程序报堆内存不足使用了完整二维dp数组改为滚动数组只保留两行相似度输出恒为0文本预处理正则把中文字符误删检查正则确认包含\\u4e00-\\u9fa5区间中文乱码导致结果偏低文件读取使用了平台默认编码使用StandardCharsets.UTF_8显式读取两篇文章相同但结果不是1没有先清洗就计算先调用TextPreprocessor再送入算法结果不对称且差异大预处理过程不一致或编辑距离交换参数有误确认clean逻辑确定性确认算法对称性程序能跑但非常慢文本长度大且没有做快速过滤增加字符集合重叠度快速判断场景推荐指标理由一两段文本快速判断LCS相似度直接反映最大公共保留内容论文级段落改写检测结合编辑距离捕获大规模替换改写行为批量比对大量文档先字符集合过滤再DP或改用n-gram避免无效计算5.2 做完这个作业后我沉淀下来的三条经验第一条经验动态规划作业汇报时重点是讲清状态定义、转移方程、边界条件和复杂度分析代码反而是次要的。能口头推导一遍“为什么dp[i][j]等于这个值”比贴一大段代码更能体现你对算法的理解。建议在代码注释里直接写明每一步对应的数学定义。第二条经验算法正确性必须用最小样例验证。我建议每个DP实现都配一个3到5字符规模的手工用例把dp表格手算一遍和代码结果对照。这个习惯帮我至少节省了三四个小时的调试时间。最怕的是直接在真实文本上跑发现结果诡异根本分不清是算法逻辑错、预处理错还是数据编码错。第三条经验从“能用”到“好用”之间最值得投入的就是滚动数组和参数可配置化。我最后提交的作业里可以把阈值、清洗规则、指标权重都通过配置文件调整这样演示不同场景时不用重新编译代码。这种小优化能显著提升作业的展示效果也让整个项目的代码风格看起来更像一个正式工具而不只是一堆临时脚本。最后再说一个经历我最初坚持自己手推公式写实现没有直接抄在线模板结果虽然慢了一些但填表过程让我彻底理解了DP为什么高效。后来帮同学调试他们的代码几乎都是卡在状态定义不清或者忘记转移方程里取max/min。如果你现在也卡在某一版代码上出不来结果试着回到那张dp表格一格一格推回去问题通常就浮出水面了。