1. 程式人生 > 其它 >從一道演算法題實現一個文字diff小工具

從一道演算法題實現一個文字diff小工具

眾所周知,很多社群都是有內容稽核機制的,除了第一次釋出,後續的修改也需要稽核,最粗暴的方式當然是從頭再看一遍,但是編輯肯定想弄死你,顯然這樣效率比較低,比如就改了一個錯別字,再看幾遍可能也看不出來,所以如果能知道每次都修改了些什麼,就像gitdiff一樣,那就方便很多了,本文就來簡單實現一個。

求最長公共子序列

想要知道兩段文字有什麼差異,我們可以先求出它們的公共內容,剩下的就是被刪除或新增的。在演算法中,這是一道經典的題目,力扣上就有這道題1143. 最長公共子序列,題目描述如下:

這種求最值的題一般都是使用動態規劃來做,動態規劃比較像推理題,可以使用遞迴來自頂向下求解,也可以使用for

迴圈自底向上來做,使用for迴圈一般會使用一個叫dp的備忘錄來儲存資訊,具體使用幾維陣列要視題目而定,這道題因為有兩個變數(兩個字串的長度)所以我們使用二維陣列,我們定義dp[i][j]表示text10-i的子串和text20-j的子串的最長公共子序列長度,接下來需要考慮邊界情況,首先當i0的時候text1的子串為空字串,所以無論j為多少最長公共子序列的長度都為0j0的情況也是一樣,所以我們可以初始化一個初始值全部為0dp陣列:

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)
    })
}

ij都不為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函式,遞迴判斷ij位置是否在最長子序列裡,比如對於ij位置,如果text1[i - 1] === text2[j - 1],那麼顯然這兩個位置在最長子序列內,接下來只要判斷i - 1j - 1的位置,以此類推,如果當前位置不相同則我們可以根據dp陣列來判斷,因為此時我們已經知道整個dp陣列的值了:

所以不需要再去嘗試每個位置,也就不會造成重複,比如dp[i - 1] > dp[j],那麼接下來要判斷的就是i-1j位置,否則判斷ij-1位置,遞迴結束的條件是ij有一個已經到達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
});

到這裡依然沒有結束,我們還得根據最長子序列來計算出刪除和新增的位置,這個比較簡單,直接遍歷一下兩個字串,不在arr1arr2裡的其他位置的字元就是被刪掉的或新增的:

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