基于LCS(最长公共子序列)的文本比对

555 阅读3分钟

@[toc] 最近因为项目需求需要实现一个文本比对的功能,自然的就想到了git的文本比对功能,于是网上查阅了一些资料,看到了一个关键字(最长公共子序列),感觉又回到了大学刷题的时候了。

最长公共子序列

引用LeetCode第1143题的描述

给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。

一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。 例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。

若这两个字符串没有公共子序列,则返回 0。

示例 1:

输入:text1 = "abcde", text2 = "ace" 输出:3 解释:最长公共子序列是 "ace",它的长度为 3。 示例 2:

输入:text1 = "abc", text2 = "abc" 输出:3 解释:最长公共子序列是 "abc",它的长度为 3。 示例 3:

输入:text1 = "abc", text2 = "def" 输出:0 解释:两个字符串没有公共子序列,返回 0。

提示:

1 <= text1.length <= 1000 1 <= text2.length <= 1000 输入的字符串只含有小写英文字符。

从上面可以知道最长公共子序列是指两个字符串里面连续不一定相邻的最长的公共字符

求解最长公共子序列

最长公共子序列的问题可以使用dp(动态规划)的方式来求解,但是使用动态规划需要确定状态转移方程。 首先我们假定需要求解的字符串是 sda3b225cwsadbs 和 ass3bcdssd2cpsekld998l ,其中他们的公共子串是 s3b2csd

确定状态转移方程

我们可以使用一个比较简单的例子来推导状态转移方程 比如 1a2b3cdef , asdcdrf 子串为 acdf 首先我们假设现在有一个方法可以计算出最长的公共子序列,我们把它设为 lcs(m,n) 那么 lcs(1,"") = 0,lcs("",a) = 0,lcs(1,a) = 0,lcs(1a,a)=1 ,lcs(1,as) =0,lcs(1a,as) =1 上面的推导里面可以得知 如果我们需要需要求出字符串 1a和as的最长公共子序列那么我们需要知道 字符串 1,as 以及 字符串1a,a 的最长公共子序列。 因此我们可以得到如下公式: 已知 lcs(x,"") =0 , lcs("",y) =0 那么 lcs(x,y) 可以求解: if x=y lcs(x,y) = max(lcs(x,"") , lcs("",y) ) +1 else lcs(x,y) = max(lcs(x,"") , lcs("",y) ) 根据上面的状态转移方程我们可以很轻易的写出对应的代码

 public LcsResult lcs(List<String> source, List<String> target){
        int[][] dp = new int[source.size()+1][target.size()+1];
        int max = 0;
        for (int i = 1; i < (source.size() + 1); i++) {
            for (int j = 1; j < (target.size() + 1); j++) {
                String str1 = source.get(i-1);
                String str2 = target.get(j-1);
                if(str1.equals(str2)){
                    int temp =dp[i][j] =dp[i-1][j-1]+1;
                    max = Math.max(max,temp);
                }else{
                    dp[i][j] = Math.max(dp[i-1][j],dp[i][j-1]);
                }
            }
        }
        return findStr(dp,target);
    }

如何求出最长的公共子序列

在上面的动态规划代码里面我们已经得到了一个状态矩阵 如下图: 在这里插入图片描述 通过动态规划矩阵找出最长的子序列,我们只需要通过回溯dp数组就可以找出子序列 我们从最后一个位置开始往前回溯,分别判断左边和上边与当前位置是否相等,如果相等那么向左或者向上移动一位,如果都不相等那么这个就是我们需要的字符,记录下来,然后同时向左和向上移动一位 但是我们在通过回溯求出最长的公共子序列的时候会遇到多解的问题,如果左边和上边都与当前位置相等怎么办,这时候既可以向左也可以向上,不过得出来的结果可能会不一样 下面这张图的结果是采用了默认向上的做法,得出来的子序列是:acdf 在这里插入图片描述 代码如下:

 private LcsResult  findStr(int[][] dp, List<String> target) {
        LcsResult lcsResult = new LcsResult();
        lcsResult.setCommonString(new ArrayList<>());
        lcsResult.setSourceIndex(new ArrayList<>());
        lcsResult.setTargetIndex(new ArrayList<>());
        int i=dp.length-1;

        for (int j = dp[i].length-1; j > 0 && i>0;) {
            int k = dp[i][j];
            int up = dp[i-1][j];
            int left = dp[i][j-1];
            if(up==k ){
                i--;
            } else if(left==k) {
                j--;
            }else{
                int targetIndex= j-1;
                int sourceIndex = i-1;
                lcsResult.getSourceIndex().add(sourceIndex);
                lcsResult.getTargetIndex().add(targetIndex);
                lcsResult.getCommonString().add(target.get(targetIndex));
                i--;
                j--;
            }
        }
        Collections.reverse(lcsResult.getCommonString());
        Collections.reverse(lcsResult.getSourceIndex());
        Collections.reverse(lcsResult.getTargetIndex());
        return lcsResult;
    }

如何实现文本比对

当我们求出来最长的公共子序列之后那么我们如何对这两个字符串进行比对 现在有如下两个字符串 str1= 1a2b3cdef
str2= asdcdrf 公共子序列为 acdf 如下图,我们以公共子串为标准进行错位对比,可以看到,我们能够很容易的分辨出 在str1上面新增了一个字符串 a,同时str1上面的 2b3 被修改成了 sd ,e 被修改成了f 在这里插入图片描述 通过上面的对比,我们只需要把元素换成字符串就可以进行文本比对了,代码如下

 /**
     * 比较文本,如果两个相等的串之间有多个串,并且数量不相等且都不为0,那么认为是无法准确比较
     * 那么在后面的标记过程中就不需要逐字比较
     * 采用首部优先进行比较
     * @param source 源数据
     * @param target 对比数据
     * @return 文本比对结果
     */
    @Override
    public List<CompareResult> compare(List<String> source, List<String> target) {
        LCS lcs = new LCS();
        LcsResult lcsResult = lcs.lcs(source, target);
        return compare(lcsResult,source,target);
    }

    /**
     * 比较文本,如果两个相等的串之间有多个串,并且数量不相等且都不为0,那么认为是无法准确比较
     * 那么在后面的标记过程中就不需要逐字比较
     * 采用首部优先进行比较
     * @param lcsResult 最长公共子序列结果
     * @param source 源数据
     * @param target 对比数据
     */
    public List<CompareResult> compare(LcsResult lcsResult, List<String> source, List<String> target) {
        List<CompareResult> res = new ArrayList<>();
        int lastSourceIndex = 0;
        int lastTargetIndex = 0;

        for (int i = 0; i < lcsResult.getCommonString().size(); i++) {
            int sourceIndex = lcsResult.getSourceIndex().get(i);
            int targetIndex = lcsResult.getTargetIndex().get(i);
            List<String> sourceTemp = source.subList(lastSourceIndex,sourceIndex);
            List<String> targetTemp = target.subList(lastTargetIndex,targetIndex);
            compareLeftAndRight(sourceTemp,targetTemp,res);
            lastSourceIndex = sourceIndex+1;
            lastTargetIndex = targetIndex+1;
            res.add(new CompareResult(CompareResult.RESULT_EQUAL,source.get(sourceIndex),target.get(targetIndex),true));
        }
        List<String> sourceTemp = lastSourceIndex>=source.size()?new ArrayList<>():source.subList(lastSourceIndex,source.size());
        List<String> targetTemp = lastTargetIndex>=target.size()?new ArrayList<>():target.subList(lastTargetIndex,target.size());
        compareLeftAndRight(sourceTemp,targetTemp,res);
        return res;
    }

    private void compareLeftAndRight(List<String> sourceTemp, List<String> targetTemp, List<CompareResult> res) {

        if(CollectionUtils.isEmpty(sourceTemp)){
            targetTemp.forEach(item-> res.add(new CompareResult(CompareResult.RESULT_INSERT,"",item,true)));
        }else if(CollectionUtils.isEmpty(targetTemp)){
            sourceTemp.forEach(item-> res.add(new CompareResult(CompareResult.RESULT_DELETE,item,"",true)));
        }else if(targetTemp.size()==sourceTemp.size()){
            for (int k = 0; k < targetTemp.size(); k++) {
                res.add(new CompareResult(CompareResult.RESULT_CHANGE,sourceTemp.get(k),targetTemp.get(k),true));
            }
        }else if(sourceTemp.size()>targetTemp.size()){
            for (int k = 0; k < sourceTemp.size(); k++) {
                res.add(new CompareResult(k>=targetTemp.size()?CompareResult.RESULT_DELETE:CompareResult.RESULT_CHANGE,
                        sourceTemp.get(k),k>=targetTemp.size()?"":targetTemp.get(k),false));
            }
        }else{
            for (int k = 0; k < targetTemp.size(); k++) {
                res.add(new CompareResult(k>=sourceTemp.size()?CompareResult.RESULT_INSERT:CompareResult.RESULT_CHANGE,
                        k>=sourceTemp.size()?"":sourceTemp.get(k),targetTemp.get(k),false));
            }
        }


    }

CompareResult

@Data
@AllArgsConstructor
public class CompareResult implements Serializable {
    public static final String RESULT_EQUAL = "EQUAL";
    public static final String RESULT_INSERT = "INSERT";
    public static final String RESULT_DELETE = "DELETE";
    public static final String RESULT_CHANGE = "CHANGE";
    /**
     * 内容行标记,删除还是新增还是修改
     */
    private String tag;
    /**
     * 旧文本
     */
    private String oldText;
    /**
     * 新文本
     */
    private String newText;
    /**
     * 是否需要逐字比对样式,如果不需要,那么就整体标记
     */
    private boolean isNeedCheckDetail;

}

比对效果图

当然,效果图是Word的文本比对,其中还有很大一部分关于Word方面的代码,就不贴出来了,核心思想就是上面的文本比对了 在这里插入图片描述

参考文章:

类似git/linux的文件对比功能(diff)是怎么实现的?

最长公共子串