什么是Diff
在日常工作中,diff是大家常用的一個工具,它能快速的計算出兩個文本的差異,並將差異結果一目了然的展示出來,幫助我們快速定位在不同版本中文件的修改位置。
以上流程圖簡單描述了我們使用diff程序的流程,只需往diff程序中輸入text1與text2(我們規定,text1為初始文本,text2為編輯文本),diff程序會自動計算出兩個文本的差異並可視化輸出。
在這篇文章中,將會簡單的談一談diff程序內部流程是如何流轉並實現的,以及介紹在實現過程中會遇到的一些算法問題。
問題分析
舉個簡單的例子:
text1:
console.log('hello world');
text2:
console.log('hi js');
|
console.log(‘helloi worldjs‘);
從結果中我們可以看出,diff程序的可視化輸出,無非是將文本中未改變的部分原樣輸出,其余字符,若是在text1中,則被標記為刪除;若是在text2中,則被標記為新增。如下使用紅色標記兩個文本的未改變部分:
text1: console.log(‘hello-world‘);
text2: console.log(‘hi-js‘);
紅色部分除外,在text1中的ello
和world
在結果中都將被標記為刪除;text2中的i
和js
在結果中都將被標記為新增。
因此,diff程序的關鍵就在於如何找到text1與text2所有相同文本(即找到標記紅色的文本),然后再分別標記出剩下的刪除或新增文本即可。
如何diff
LCS
計算兩個文本相同部分的問題,即求兩個字符串的最長公共子序列(Longest Common Subsequence,簡稱LCS)問題,介紹LCS問題解法之前,先了解以下概念。
Subsequence(NOT Substring)
設序列Xm=<x1, x2, …, xm>
,從Xm序列中任意取出若干個元素,按照元素下標從小到大的順序排列得到一個新的序列Zk=<z1, z2, ..., zk>
,則Zk為Xm的一個子序列。子序列只要求元素的前后位置關系與原序列中保持一致即可,不必保證元素必需是連續的。
公共子序列
給出兩個序列Xm與Yn,找到一個序列Zk,滿足:Zk即是Xm的子序列,又是Yn的子序列,則Zk為Xm與Yn的一個公共子序列
最長公共子序列
給出兩個序列Xm與Yn,找到一個Xm與Yn的公共子序列Zk,Zk的長度是Xm與Yn所有公共子序列中長度最長的一個。
暴力美學
最簡單直接的,就是暴力求解,如下為暴力法求解LCS的偽代碼:
Zk = 空
for Xm的所有子序列 Zx
if Zx是Yn的子序列 && Zx長度大於Zk
Zk = Zx
|
一個長度為m的序列,存在2^m個子序列;判斷Zx序列是否為Yn序列的子序列,時間復雜度為O(n);因此暴力求解算法的時間復雜度為O(n*2^m)。指數級別時間復雜度的算法,基本上是要把電腦搞掛的,這顯然不是我們想要的結果。
動態規划
動態規划一般也只能應用於有最優子結構的問題。最優子結構的意思是局部最優解能決定全局最優解(對有些問題這個要求並不能完全滿足,故有時需要引入一定的近似)。簡單地說,問題能夠分解成子問題來解決。
使用動態規划算法求解最優解的時候,最關鍵的問題在於如何找到最優解問題的轉化方程。
設序列Xm=<x1, x2, …, xm>
和Yn=<y1, y2, …, yn>
的一個最長公共子序列Zk=<z1, z2, …, zk>
,則:
- 若xm=yn,可以使用反證法證明:xm(yn)必然是Zk的最后一個字符,即zk=xm=yn,且Zk-1是Xm-1和Yn-1的最長公共子序列。因此
LCS(X, Y)
即可以轉化成LCS(Xm-1, Yn-1) + 1
; - 若xm≠yn且zk≠xm ,亦可用反證法證明:Z是Xm-1和Y的最長公共子序列;
- 若xm≠yn且zk≠yn ,同第二點,可得出Z是X和Yn-1的最長公共子序列。
其中:Xm-1=<x1, x2, …, xm-1>
,Yn-1=<y1, y2, …, yn-1>
,Zk-1=<z1, z2, …, zk-1>
。
整合2、3點的結論,得出當xm≠yn時,可以將 LCS(X, Y)
轉化成 MAX(LCS(Xm-1, Y), LCS(X, Yn-1))
。
設dp[i][j]表示序列Xi和Yj的一個最長公共子序列Zk的長度,即dp[i][j] = k。
因此我們可以得到以下公式:
根據此公式,我們定義一個二維數組dp,size為m*n,dp數組中的每個元素的值,都可以通過其左上/左/上三個方位的單元格得到,因此我們只需順序填滿dp表格,在dp[m][n]處就可以得到Xm與Yn的最長公共子序列長度。如下所示(X: ABCABBA, Y: CBABAC):
僅僅得到最長公共子序列的長度顯然不足以達到我們的目的,為了能夠得到一個完整的LCS,我們在計算dp表格的過程中,還需要同時記錄計算每個單元格值的來源位置,使用↖︎
、↑
、←
三個字符來表示,如下圖(若dp[i-1][j]與dp[i][j-1],即當前位置的左單元格和上單元格相等時,默認取左邊單元格,即dp[i][j-1]),:
在上圖的表格中,我們只需要從最右下角的單元格開始,按照箭頭的指示就可以一步步往左上角靠近,如圖中標記紅色的路線。在此路線中,←
符號表示Y掃過一個字符,此字符需標記為新增;↑
符號表示X掃過一個字符,此字符需標記為刪除;↖︎
表示X、Y同時掃過一個字符,此字符為LCS中的元素,為未改變字符。
如上操作,我們就可以得到X與Y的一個ABCABABAC
至此我們已經可以實現一個簡單的diff程序了。
前往閱讀“閹割版”Diff插件源碼
點我查看“閹割版”Diff插件Demo演示
SED
除了上面介紹的LCS算法之外,最短編輯距離(Shortest Edit Distance,簡稱SED)算法同樣可以用來實現文本diff。
SED問題的描述是:給出兩個字符串text1與text2,求將text1編輯成text2所需要的最短編輯距離。定義有效的編輯操作有:刪除一個字符、新增一個字符、替換一個字符,其中一次刪除或新增操作編輯距離記為1,替換操作的編輯距離記為2(因為一個替換操作相當於一個刪除操作加上一個新增操作)。
仔細觀察可以發現,SED問題其實是LCS問題的一個逆向描述。LCS求盡可能多的相同部分,而SED求盡可能少的編輯部分(即不同部分)。他們必然滿足一個關系:m + n === 2 * LCS(X, Y) + SED(X, Y);
其中m、n分別為X與Y的長度。因此SED問題的結果與LCS是等效的,求解SED問題同樣可以使用動態規划的思想來解,只是在公式上有所不同罷了,以下給出SED的求解公式:
設f[i][j]表示從字符xi轉化成yj的編輯距離;
設dp[i][j]表示序列Xi=<x1, x2, …, xi>
轉化成Yj=<y1, y2, …, yj>
的最短編輯距離;
同樣,根據公式,依次填滿dp表格,同時標記上每個單元格的來源方位,即可找到兩個文本的差異,演算過程類似LCS,在此不再贅述。
優化
上面介紹的LCS與SED算法,時間復雜度都是O(n*m),因此精確計算LCS/SED是非常耗費性能的。
對於基於字符的diff程序,n與m分別為text1與text2的字符數。一個簡單有效的優化方式就是轉化成基於行的diff,轉化后n與m分別為text1與text2的行數,這就可以大大加速diff的耗時,同時也使得diff結果更具語義(svn/git 的diff命令,就是基於行的diff)。
預處理
除了轉化成基於行的diff之外,在進行LCS/SED之前,可以進行以下預處理操作來加速diff程序
Equality
簡單的判斷text1與text2是否全等,可以免去許多不必要的計算。在一個龐大的項目庫中,往往每次修改的文件只是其中很小的一部分,因此絕大部分的文件都可以通過判斷內容是否全等直接得到結果,無需再進行LCS/SED計算。
Common Prefix/Suffix
提取text1與text2的公共前綴與后綴,再對剩余部分進行diff,可以縮小text1與text2的diff范圍。提取公共前綴/后綴的操作,可以使用二分查找的算法,可以在O(logn)的時間復雜度內完成(log以2為底數)。
Singular Insertion/Deletion
在上一步提取完公共前綴/后綴之后,若是text1剩下的為空字符串,則表示僅僅是新增了text2剩下部分的字符串;若是text2剩下的為空字符串,則表示僅僅是刪除了text1剩下部分的字符串。因此在這種情況下,也可以直接得到結果,無需再進行LCS/SED計算。
Two Edits
同樣再提取公共前綴/后綴之后,剩余部分在頭尾都不會有相同的字符,但是在中間部分可能仍有大量的相同部分,若我們能找到剩余部分的一個公共子串,該公共子串可以將text1、text2剩余部分都分割成兩個部分[text1_pre, text1_suf]、[text2_pre, text2_suf],那么我們就可以轉化成求diff(text1_pre, text2_pre)和diff(text1_suf, text2_suf),再將得到的結果拼接在一起,就可以得到完整的diff結果。為了保證這個分割是有效的,可以規定這個公共字串的長度必需大於較長串長度的二分之一。
計算最長公共子串仍然是一個復雜的程序,但是我們可以利用滿足長度大於二分之一較長串長度的特殊條件,進行特殊查找即可。
拖動一個二分之一長度的字符串,我們可以發現,這個字符串必然會包含第二個四分之一子串(圖中紅色部分)或者第三個四分之一子串。利用這個關系,我們只需要分別取這個四分之一子串,判斷是否是另一個字符串的子串,若是,則先用此四分之一子串將兩個字符串分隔開,如下圖所示,分別求前部分的公共后綴,以及后半部分的公共前綴,然后將公共后綴+四分之一子串+公共前綴
即可得到一個公共子串,如此求完所有的公共子串,取最長的一個,判斷其長度是否大於長串的二分之一即可。
后處理
后處理主要是將前面計算得到的diffs數組進行merge合並,並且做一些語義化的處理,是的輸出結果更具語義,利於閱讀。
Semantics
對於下圖所示的例子,以下計算出的6個diff結果都是等效的。之所以有這么多等效的結果,是因為diffs的編輯具有可移動性。若一個編輯操作的左右兩邊都是相同部分,滿足:
- 編輯部分的第一個字符等於其后相同部分的第一個字符,則此編輯部分可右移一個元素;
- 編輯部分的最后一個字符等於其前面相同部分的第一個字符,則此編輯部分可左移一個元素。
這6個diff結果中,顯然Diff 3與Diff 4是更具有語義的,因此,我們可以指定一個規則,給每個diff結果進行一個評分,得分高的diff結果,則表示它更具有語義。可以有一下幾點評分項目:
- 編輯的邊界是一個非字母數字字符,1分;
- 編輯的邊界是一個空白符,2分;
- 編輯的邊界是一個換行符,3分;
- 編輯的邊界是一個空行,4分;
- 編輯的邊界不再是相等部分(移動消耗了整個相等部分),5分。
依據以上幾點評分規則,我們可以得到,Diff 1、2、5、6的結果,的0分;Diff 3、4的結果,因為邊界分別有兩個空白符,因此得4分。所以程序就可以判斷出,Diff 3、4得結果,才是更具有語義的。
總結
總結以上的描述,我們可以得出以下完整的Diff程序流程圖:
閱讀資料
Diff Strategies
An O(ND) Difference Algorithm and Its Variations∗
/** * Diff Match —— Longest Common Subsequence */ var DiffMatch = (function(){ var DIFF_EQUAL = 0; var DIFF_DELETE = -1; var DIFF_INSERT = 1; var DIFF_PATH_DIAGONA = '↖︎'; var DIFF_PATH_VERTICAL = '↑'; var DIFF_PATH_HORIZONTAL = '←'; function DiffMatch(){ this.text1 = ''; this.text2 = ''; this.dp = []; this.path = []; } /** * 找出兩個文本的差異 以數組返回 * 返回數組結構: [[DIFF_type, string], ...] * @param {string} text1 old string * @param {string} text2 new string * @return {array} array of diffs * @author DHB(daihuibin@weidian.com) */ DiffMatch.prototype.diff = function(text1, text2, line_based){ // 開始時間 var st = new Date().getTime(); if(line_based){ text1 = text1.split('\n'); text2 = text2.split('\n'); text1.forEach(function(text, index) { text1[index] = text + '\n'; }); text2.forEach(function(text, index) { text2[index] = text + '\n'; }); } var len1 = text1.length; var len2 = text2.length; this.init(text1, text2); // 空間優化 var k = 1; var k_subtract_one = 0; for(var i = 1; i <= len1; (++i, k_subtract_one = k, k = Number(!k))){ for(var j = 1; j <= len2; ++j){ if(text1[i - 1] === text2[j - 1]){ this.dp[k][j] = this.dp[k_subtract_one][j - 1] + 1; this.path[i][j] = DIFF_PATH_DIAGONA; } else if(this.dp[k_subtract_one][j] > this.dp[k][j - 1]){ this.dp[k][j] = this.dp[k_subtract_one][j]; this.path[i][j] = DIFF_PATH_VERTICAL; } else { this.dp[k][j] = this.dp[k][j - 1]; this.path[i][j] = DIFF_PATH_HORIZONTAL; } } } this.diffs = this.getDiffsFromPath(); var et = new Date().getTime(); this.timeConsumed = et - st; return this.diffs; }; /** * 數據初始化 * @param {string} text1 old string * @param {string} text2 new string * @return {null} null * @author DHB(daihuibin@weidian.com) */ DiffMatch.prototype.init = function(text1, text2){ this.text1 = text1; this.text2 = text2; var len1 = text1.length; var len2 = text2.length; // 空間優化 2*len2 this.dp = []; for(var i = 0; i < 2; ++i){ this.dp.push([]); for(var j = 0; j <= len2; ++j){ this.dp[i].push(0); } } this.path = []; for(var i = 0; i <= len1; ++i){ this.path.push([]); for(var j = 0; j <= len2; ++j){ this.path[i].push(0); } } }; /** * 從path中讀出最長公共子序列 * @return {string} Longest common Subsequence * @author DHB(daihuibin@weidian.com) */ DiffMatch.prototype.getLCS = function(){ var lcs = ''; var path = this.path; var text = this.text1; function path_lcs(i, j){ if(i === 0 || j === 0){ return; } if(path[i][j] === DIFF_PATH_DIAGONA){ path_lcs(i - 1, j - 1); lcs += text[i-1]; } else if(path[i][j] === DIFF_PATH_VERTICAL){ path_lcs(i - 1, j); } else if(path[i][j] === DIFF_PATH_HORIZONTAL){ path_lcs(i, j - 1); } } path_lcs(this.text1.length, this.text2.length); return lcs; }; /** * 從path中獲取diffs * @return {array} 差異數組 * @author DHB(daihuibin@weidian.com) */ DiffMatch.prototype.getDiffsFromPath = function(){ var diffs = []; var path = this.path; var text1 = this.text1; var text2 = this.text2; function path_diffs(i, j){ if(i === 0 || j === 0){ if(i !== 0){ if(typeof text1 === 'string'){ diffs.push([DIFF_DELETE, text1.substring(0, i)]); } else { diffs.push([DIFF_DELETE, text1.splice(0, i).join('')]); } } if(j !== 0){ if(typeof text2 === 'string'){ diffs.push([DIFF_INSERT, text2.substring(0, j)]); } else { diffs.push([DIFF_INSERT, text2.splice(0, j).join('')]); } } return; } if(path[i][j] === DIFF_PATH_DIAGONA){ path_diffs(i - 1, j - 1); diffs.push([DIFF_EQUAL, text1[i - 1]]); } else if(path[i][j] === DIFF_PATH_VERTICAL){ path_diffs(i - 1, j); diffs.push([DIFF_DELETE, text1[i - 1]]); } else if(path[i][j] === DIFF_PATH_HORIZONTAL){ path_diffs(i, j - 1); diffs.push([DIFF_INSERT, text2[j - 1]]); } } path_diffs(text1.length, text2.length); return this.mergeDiffs(diffs); }; /** * 合並diffs中的連續相同編輯 * @param {array} df 差異數組 * @return {array} 合並后的差異數組 * @author DHB(daihuibin@weidian.com) */ DiffMatch.prototype.mergeDiffs = function(df){ if(!df || !df.length){ return []; } var diffs = []; var lst_op = df[0][0]; var lst_st = df[0][1]; for(var i = 1; i < df.length; ++i){ while(i < df.length && df[i][0] === lst_op){ lst_st += df[i++][1]; } diffs.push([lst_op, lst_st]); lst_st = ''; if(i >= df.length){ break; } lst_op = df[i][0]; lst_st = df[i][1]; } if(lst_st){ diffs.push([lst_op, lst_st]); } return diffs; }; /** * 獲取用以展示的差異html代碼 * @return {string} html代碼 * @author DHB(daihuibin@weidian.com) */ DiffMatch.prototype.prettyHtml = function(){ var diffs = this.diffs; var html = []; var pattern_amp = /&/g; var pattern_lt = /</g; var pattern_gt = />/g; var pattern_para = /\n/g; var pattern_blank = / /g; var pattern_tab = /\t/g; for (var x = 0; x < diffs.length; x++) { var op = diffs[x][0]; // Operation (insert, devare, equal) var data = diffs[x][1]; // Text of change. var text = data.replace(pattern_amp, '&').replace(pattern_lt, '<') .replace(pattern_gt, '>') .replace(pattern_para, '¶<br>') .replace(pattern_blank, ' ') .replace(pattern_tab, ' '); switch (op) { case DIFF_INSERT: html[x] = '<ins style="background:#e6ffe6;">' + text + '</ins>'; break; case DIFF_DELETE: html[x] = '<del style="background:#ffe6e6;">' + text + '</del>'; break; case DIFF_EQUAL: html[x] = '<span>' + text + '</span>'; break; } } return html.join(''); } /** * 查找路徑圖 * @param {string} text1 old string * @param {string} text2 new string * @return {string} html code of path * @author DHB(daihuibin@weidian.com) */ DiffMatch.prototype.pathHtml = function(mark_path){ var path = this.path; var len1 = this.text1.length; var len2 = this.text2.length; var paths = []; if(mark_path){ var text = this.text1; function path_lcs(i, j){ if(i === 0 || j === 0){ return; } paths.push(i + '_' + j); if(path[i][j] === DIFF_PATH_DIAGONA){ path_lcs(i - 1, j - 1); } else if(path[i][j] === DIFF_PATH_VERTICAL){ path_lcs(i - 1, j); } else if(path[i][j] === DIFF_PATH_HORIZONTAL){ path_lcs(i, j - 1); } } path_lcs(this.text1.length, this.text2.length); } var text = '<table><tr><td> </td>'; for(var i = 0; i < len2; ++i){ text += '<td>' + (this.text2[i] !== '\n' ? this.text2[i] : '\\n') + '</td>'; } text += '</tr>'; for(var i = 1; i <= len1; ++i){ text += '<tr><td>' + (this.text1[i-1] !== '\n' ? this.text1[i-1] : '\\n') + '</td>'; for(var j = 1; j <= len2; ++j){ text += '<td' + (mark_path && paths.indexOf(i+'_'+j) !== -1 ? ' style="color: red;"' : '') + '>' + path[i][j] + '</td>'; } text += '</tr>'; } text += '</table>' return text; }; return DiffMatch; })(); window.DiffMatch = DiffMatch; // module.exports = DiffMatch;