从一道算法题实现一个文本diff小工具
众所周知,很多社区都是有内容审核机制的,除了第一次发布,后续的修改也需要审核,最粗暴的方式当然是从头再看一遍,但是编辑肯定想弄死你,显然这样效率比较低,比如就改了一个错别字,再看几遍可能也看不出来,所以如果能知道每次都修改了些什么,就像git
的diff
一样,那就方便很多了,本文就来简单实现一个。
求最长公共子序列
想要知道两段文本有什么差异,我们可以先求出它们的公共内容,剩下的就是被删除或新增的。在算法中,这是一道经典的题目,力扣上就有这道题1143. 最长公共子序列,题目描述如下:
这种求最值的题一般都是使用动态规划来做,动态规划比较像推理题,可以使用递归来自顶向下求解,也可以使用for
循环自底向上来做,使用for
循环一般会使用一个叫dp
的备忘录来存储信息,具体使用几维数组要视题目而定,这道题因为有两个变量(两个字符串的长度)所以我们使用二维数组,我们定义dp[i][j]
表示text1
从0-i
的子串和text2
从0-j
的子串的最长公共子序列长度,接下来需要考虑边界情况,首先当i
为0
的时候text1
的子串为空字符串,所以无论j
为多少最长公共子序列的长度都为0
,j
为0
的情况也是一样,所以我们可以初始化一个初始值全部为0
的dp
数组:
let longestCommonSubsequence = function (text1, text2) {
let m = text1.length
let n = text2.length
let dp = new Array(m + 1)
dp.forEach((item, index) => {
dp[index] = new Array(n + 1).fill(0)
})
}
当i
和j
都不为0
的情况下,需要分几种情况来看:
1.当text1[i - 1] === text2[j - 1]
时,说明这两个位置的字符相同,那么它们肯定在最长子序列里,当前最长的子序列就依赖于它们前面的子串,也就是dp[i][j] = 1 + dp[i - 1][j - 1]
;
2.当text1[i - 1] !== text2[j - 1]
时,很明显dp[i][j]
完全取决于之前的情况,一共有三种:dp[i - 1][j - 1]
、dp[i][j - 1]
、dp[i - 1][j]
,不过第一种情况可以排除掉,因为它显然不会有后面两种情况长,因为后面两种都比第一种多了一个字符,所以可能长度会多1
,那么我们取后面两种情况的最优值即可;
接下来我们只要一个二重循环遍历二维数组的所有情况即可:
let longestCommonSubsequence = function (text1, text2) {
let m = text1.length
let n = text2.length
// 初始化二维数组
let dp = new Array(m + 1).fill(0)
dp.forEach((item, index) => {
dp[index] = new Array(n + 1).fill(0)
})
for(let i = 1; i <= m; i++) {
// 因为i和j都是从1开始的,所以要减1
let t1 = text1[i - 1]
for(let j = 1; j <= n; j++) {
let t2 = text2[j - 1]
// 情况1
if (t1 === t2) {
dp[i][j] = 1 + dp[i - 1][j - 1]
} else {// 情况2
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
}
dp[m][n]
的值就是最长公共子序列的长度,但是只知道长度并没啥用,我们要知道具体哪些位置才行,需要再来一次递归,为什么不能在上述循环里面t1 === t2
的分支里收集位置呢,因为两个字符串的所有位置都会进行两两比较,当存在多个相同的字符时会存在重复,就像下面这样:
我们定义一个collect
函数,递归判断i
和j
位置是否在最长子序列里,比如对于i
和j
位置,如果text1[i - 1] === text2[j - 1]
,那么显然这两个位置在最长子序列内,接下来只要判断i - 1
和j - 1
的位置,以此类推,如果当前位置不相同则我们可以根据dp
数组来判断,因为此时我们已经知道整个dp
数组的值了:
所以不需要再去尝试每个位置,也就不会造成重复,比如dp[i - 1] > dp[j]
,那么接下来要判断的就是i-1
和j
位置,否则判断i
和j-1
位置,递归结束的条件是i
和j
有一个已经到达0
的位置:
let arr1 = []
let arr2 = []
let collect = function (dp, text1, text2, i, j) {
if (i <= 0 || j <= 0) {
return
}
if (text1[i - 1] === text2[j - 1]) {
// 收集两个字符串里相同字符的索引
arr1.push(i - 1)
arr2.push(j - 1)
return collect(dp, text1, text2, i - 1, j - 1)
} else {
if (dp[i][j - 1] > dp[i - 1][j]) {
return collect(dp, text1, text2, i, j - 1)
} else {
return collect(dp, text1, text2, i - 1, j)
}
}
}
结果如下:
可以看到是倒序的,如果不喜欢也可以排个序:
arr1.sort((a, b) => {
return a - b
});
arr2.sort((a, b) => {
return a - b
});
到这里依然没有结束,我们还得根据最长子序列来计算出删除和新增的位置,这个比较简单,直接遍历一下两个字符串,不在arr1
和arr2
里的其他位置的字符就是被删掉的或新增的:
let getDiffList = (text1, text2, arr1, arr2) => {
let delList = []
let addList = []
// 遍历旧的字符串
for (let i = 0; i < text1.length; i++) {
// 旧字符串里不在公共子序列里的位置的字符代表是被删除的
if (!arr1.includes(i)) {
delList.push(i)
}
}
// 遍历新字符串
for (let i = 0; i < text2.length; i++) {
// 新字符串里不在公共子序列里的位置的字符代表是新增的
if (!arr2.includes(i)) {
addList.push(i)
}
}
return {
delList,
addList
}
}
标注删除和新增
公共子序列和新增删除的索引我们都知道了,那么就可以把它给标注出来,比如删除的用红色背景,新增的用绿色背景,这样一眼就能肯定哪里改变了。
简单起见,我们把新增和删除都在同一段文字上显示出来,就像这样:
假设有两段需要比较的文本,每段文本内部都以\n
分隔来换行,我们先把它们分割成数组,然后再依次两两进行比较,如果新旧文本相等那么直接添加到显示的数组里,否则我们在新文本基础上操作,如果某个位置的字符是新增的那么给它包裹一个新增的标签,被删除的字符也在新文本里找到对应的位置并包裹一个标签再插进去,模板部分是这样的:
<template>
<div class="content">
<div class="row" v-for="(item, index) in showTextArr" :key="index">
<span class="rowIndex">{{ index + 1 }}</span>
<span class="rowText" v-html="item"></span>
</div>
</div>
</template>
然后进行两两比较:
export default {
data () {
return {
oldTextArr: [],
newTextArr: [],
showTextArr: []
}
},
mounted () {
this.diff()
},
methods: {
diff () {
// 新旧文本分割成数组
this.oldTextArr = oldText.split(/\n+/g)
this.newTextArr = newText.split(/\n+/g)
let len = this.newTextArr.length
for (let row = 0; row < len; row++) {
// 如果新旧文本完全相同就不用比较了
if (this.oldTextArr[row] === this.newTextArr[row]) {
this.showTextArr.push(this.newTextArr[row])
continue
}
// 否则计算新旧文本的最长公共子序列的位置
let [arr1, arr2] = longestCommonSubsequence(
this.oldTextArr[row],
this.newTextArr[row]
)
// 进行标注操作
this.mark(row, arr1, arr2)
}
}
}
}
mark
方法用来生成最终带差异信息的字符串,先通过上面的getDiffList
方法获取到删除和新增的索引信息,因为我们是在新文本的基础上进行,所以对于新增的操作比较简单,直接遍历新增的索引,然后找到新字符串里对应位置的字符,前后都拼接上标签元素的字符即可:
/*
oldArr:旧文本的最长公共子序列索引数组
newArr:新文本的最长公共子序列索引数组
*/
mark (row, oldArr, newArr) {
let oldText = this.oldTextArr[row];
let newText = this.newTextArr[row];
// 获取删除和新增的位置索引
let { delList, addList } = getDiffList(
oldText,
newText,
oldArr,
newArr
);
// 因为添加的span标签也会占位置,所以会导致我们的新增索引发生偏移,需要减去标签所占的长度来修正
let addTagLength = 0;
// 遍历新增位置数组
addList.forEach((index) => {
let pos = index + addTagLength;
// 截取当前位置前面的字符串
let pre = newText.slice(0, pos);
// 截取后面的字符串
let post = newText.slice(pos + 1);
newText = pre + `<span class="add">${newText[pos]}</span>` + post;
addTagLength += 25;// <span class="add"></span>的长度为25
});
this.showTextArr.push(newText);
}
效果如下:
删除稍微会麻烦一点,因为显然被删除的字符在新文本里是不存在的,我们要找出如果它没被删的话它应该在哪里,然后在这里再把它插回去,我们画图来看:
先看被删掉的闪
,它在旧字符串里的位置是3
,通过最长公共子序列,我们可以找到它前面的字符在新列表里的索引,那么很明显该索引后面就是该被删除字符在新字符串里的位置:
先写一个函数来获取被删除字符在新文本里的索引:
getDelIndexInNewTextIndex (index, oldArr, newArr) {
for (let i = oldArr.length - 1; i >= 0; i--) {
if (index > oldArr[i]) {
return newArr[i] + 1;
}
}
return 0;
}
}
接下来就是计算在字符串里具体的位置,对于闪
来说它的位置计算如下:
mark (row, oldArr, newArr) {
// ...
// 遍历删除的索引数组
delList.forEach((index) => {
let newIndex = this.getDelIndexInNewTextIndex(index, oldArr, newArr);
// 前面新增的字符数量
let addLength = addList.filter((item) => {
return item < newIndex;
}).length;
// 前面没有变化的字符数量
let noChangeLength = newArr.filter((item) => {
return item < newIndex;
}).length;
let pos = addLength * 26 + noChangeLength;
let pre = newText.slice(0, pos);
let post = newText.slice(pos);
newText = pre + `<span class="del">${oldText[index]}</span>` + post;
});
this.showTextArr.push(newText);
}
到这里闪
的位置就知道了,看效果:
可以看到后面已经乱了,原因很简单,对于晶
来说,新插入的闪
所占的位置我们没有把它加上:
// 插入的字符所占的位置
let insertStrLength = 0;
delList.forEach((index) => {
let newIndex = this.getDelIndexInNewTextIndex(index, oldArr, newArr);
let addLength = addList.filter((item) => {
return item < newIndex;
}).length;
let noChangeLength = newArr.filter((item) => {
return item < newIndex;
}).length;
// 加上新插入字符所占的总长度
let pos = insertStrLength + addLength * 26 + noChangeLength;
let pre = newText.slice(0, pos);
let post = newText.slice(pos);
newText = pre + `<span class="del">${oldText[index]}</span>` + post;
// <span class="del">x</span>的长度为26
insertStrLength += 26;
});
到这里我们草率的diff
工具就完成了:
存在的问题
相信聪明的你一定发现上述实现是有问题的,如果我把某行完整的删掉了,或者完整的新增了一行,那么首先新旧的行数就不一样了,先修复一下diff
函数:
diff () {
this.oldTextArr = oldText.split(/\n+/g);
this.newTextArr = newText.split(/\n+/g);
// 如果新旧行数不一样,用空字符串来补齐
let oldTextArrLen = this.oldTextArr.length;
let newTextArrLen = this.newTextArr.length;
let diffRow = Math.abs(oldTextArrLen - newTextArrLen);
if (diffRow > 0) {
let fixArr = oldTextArrLen > newTextArrLen ? this.newTextArr : this.oldTextArr;
for (let i = 0; i < diffRow; i++) {
fixArr.push('');
}
}
// ...
}
如果我们是最后一行新增了或删除了,那么问题不大:
但是,如果是中间的某行新增或删除了,那么该行后面所有的diff
都会失去意义:
原因很简单,删除了某一行,导致后面的两两对比刚好错开了,这咋办呢,一种思路是通过发现某行被删除了或某行是新增的,然后修正对比的行数,另一种方法是不再每一行单独diff
,而是直接diff
整个文本,这样就无所谓删除新增行了。
第一种思路反正笔者搞不定,那就只能看第二种了,我们删掉通过换行符分割的逻辑,直接diff
整个文本:
diff () {
this.oldTextArr = [oldText];// .split(/\n+/g);
this.newTextArr = [newText];// .split(/\n+/g);
// ...
}
看起来好像可以,让我们加大文本的文字数量:
果然它凉了,显然我们之前简单的求最长公共子序列的算法是无法承受太多文字的,无论是dp
数组所占的空间过大,还是递归算法的层数过深导致内存溢出。
对于算法渣渣的笔者来说这也搞不定,那怎么办呢,只能使用开源的力量了,当当当当,就是它:diff-match-patch。
diff-match-patch库
diff-match-patch
是一个高性能的用来操作文本的库,支持多种编程语言,除了计算两个文本的差异外,它还可以用来进行模糊匹配及打补丁,从名字也能看得出来。
使用很简单,我们先把它引进来,import
方式引入的话需要修改一下源码文件,源码默认是把类挂到全局环境上的,我们要手动把类给导出来,然后new
一个实例,调用diff
方法即可:
import diff_match_patch from './diff_match_patch_uncompressed';
const dmp = new diff_match_patch();
diffAll () {
let diffList = dmp.diff_main(oldText, newText);
console.log(diffList);
}
返回的结果是这样的:
返回的是一个数组,每一项都代表是一个差异,0
代表没有差异,1
代表是新增的,-1
代表是删除,我们只要遍历这个数组把字符串拼接起来就可以了,非常简单:
diffAll () {
let diffList = dmp.diff_main(oldText, newText);
let htmlStr = '';
diffList.forEach((item) => {
switch (item[0]) {
case 0:
htmlStr += item[1];
break;
case 1:
htmlStr += `<span class="add">${item[1]}</span>`;
break;
case -1:
htmlStr += `<span class="del">${item[1]}</span>`;
break;
default:
break;
}
});
this.showTextArr = htmlStr.split(/\n+/);
}
实测21432
个字符diff
耗时为4ms
左右,还是很快的。
好了,以后编辑都可以愉快的摸鱼了~
总结
本文简单做了一道【求最长公共子序列】的算法题,并分析了一下它在文本diff
上的一个实际用处,但是我们简单的算法终究无法支撑实际的项目,所以如果有相关需求可以使用文中介绍的一个开源库。
完整示例代码:https://github.com/wanglin2/text_diff_demo
从一道算法题实现一个文本diff小工具的更多相关文章
- 每天一道算法题(4)——O(1)时间内删除链表节点
1.思路 假设链表......---A--B--C--D....,要删除B.一般的做法是遍历链表并记录前驱节点,修改指针,时间为O(n).删除节点的实质为更改后驱指针指向.这里,复制C的内容至B(此时 ...
- 用 Python 制作一个艺术签名小工具,给自己设计一个优雅的签名
生活中有很多场景都需要我们签字(签名),如果是一些不重要的场景,我们的签名好坏基本无所谓了,但如果是一些比较重要的场景,如果我们的签名比较差的话,就有可能给别人留下不太好的印象了,俗话说字如其人嘛,本 ...
- 利用 Python 写一个颜值测试小工具
我们知道现在有一些利用照片来测试颜值的网站或软件,其实使用 Python 就可以实现这一功能,本文我们使用 Python 来写一个颜值测试小工具. 很多人学习python,不知道从何学起.很多人学习p ...
- 【每天一道算法题】时间复杂度为O(n)的排序
有1,2,……一直到n的无序数组,求排序算法,并且要求时间复杂度为O(n),空间复杂度为O(1),使用交换,而且一次只能交换两个数. 这个是以前看到的算法题,题目不难.但是要求比较多,排序算法中,时间 ...
- [java大数据面试] 2018年4月百度面试经过+三面算法题:给定一个数组,求和为定值的所有组合.
给定一个数组,求和为定值的所有组合, 这道算法题在leetcode应该算是中等偏下难度, 对三到五年工作经验主要做业务开发的同学来说, 一般较难的也就是这种程度了. 简述经过: 不算hr面,总计四面, ...
- 提前批笔试一道算法题的Java实现
题目描述 这是2021广联达校招提前批笔试算法题之一. 我们希望一个序列中的元素是各不相同的,但是理想和显示往往是有差距的.现在给出一个序列A,其中难免有相同的元素,现在提供了一种变化方式,使得经过若 ...
- 撸一个JS正则小工具
写完正则在浏览器上检测自己写得对不对实在是不方便,于是就撸了一个JS正则小demo出来. demo demo展示 项目地址 代码部分 首先把布局样式先写好. <!DOCTYPE html> ...
- 使用PyQt4制作一个正则表达式测试小工具
最近在做一些网络爬虫的时候,会经常用到正则表达式.为了写出正确的正则表达式,我经常在这个网站上进行测试:Regex Tester.这个页面上面一个输入框输入正则表达式,下面一个输入框输入测试数据,上面 ...
- Python学习之旅:用Python制作一个打字训练小工具
一.写在前面 说道程序员,你会想到什么呢?有人认为程序员象征着高薪,有人认为程序员都是死肥宅,还有人想到的则是996和 ICU. 别人眼中的程序员:飞快的敲击键盘.酷炫的切换屏幕.各种看不懂的字符代码 ...
随机推荐
- Java学习day39
类加载的作用:将class文件字节码内容加载到内存中,并将这些静态数据转换成方法区的运行时数据结构,然后在堆中生成一个代表这个类的java.lang.Class对象,作为方法区中类数据的访问入口. 类 ...
- node.js - 包、express
首先,要先在这里分享一下我的喜悦,从昨天开始其实一直都在喜悦当中的,我收到了我的第一份offer,这感觉不摆了,比第一桶金都还舒服,虽然我还没收到第一桶金哈哈,不过offer都得了应该也快了. 今天的 ...
- Python学习之路——类-面向对象编程
类 面向对象编程 通过类获取一个对象的过程 - 实例化 类名()会自动调用类中的__init__方法 类和对象之间的关系? 类 是一个大范围 是一个模子 它约束了事务有哪些属性 但是不能约束具体的值 ...
- XCTF练习题---MISC---Banmabanma
XCTF练习题---MISC---Banmabanma flag:flag{TENSHINE} 解题步骤: 1.观察题目,打开附件 2.发现是一张斑马图片,这是典型的条形码啊,直接开网站识别 网站:h ...
- 【ACM程序设计】差分
差分 假设有一个数列,我们需要对数列中的一个区间加上或减去一个值,直接想到的便是对该区间进行一次循环逐项加减. 但是当请求的操作变得非常多的时候,每次请求都进行一次循环会很容易爆时间,因此我们引入了差 ...
- jfinal极速开发
下载jfinal项目,上面都配置好了不用自己新建从头配置.https://jfinal.com/ idea打开项目 配置数据库 resources目录下demo-config-dev.txt # co ...
- SpringBoot整合MybatisPlus基本的增删改查,保姆级教程
概述 MybatisPlus是国产的第三方插件, 它封装了许多常用的CURDapi,免去了我们写mapper.xml的重复劳动,这里介绍了基本的整合SpringBoot和基础用法. 引入依赖 在项目中 ...
- Python3获取5000个元素的单字符表
技术背景 此前考虑过一个问题,有没有办法获取到python里面所有定义好的单字符的表,比如我们获取5000个不一样的单字符,但是常用的chr(number)的方法里面包含了太多的非字母条目,比如缩进换 ...
- Spring Authorization Server 实现授权中心
Spring Authorization Server 实现授权中心 源码地址 当前,Spring Security 对 OAuth 2.0 框架提供了全面的支持.Spring Authorizati ...
- 3.Docker常用命令
帮助启动类命令 启动docker: systemctl start docker 停止docker: systemctl stop docker 重启docker: systemctl restart ...