使用動態規劃 實現字元級Diff & Patch

lqt0223發表於2018-02-13

文章開頭先上demo,只需鍵入任意內容的兩個字串,頁面上就能自動計算並呈現字串之間的差分。

demo地址:string-diff-demo.herokuapp.com

原始碼地址:github.com/lqt0223/str…

動態規劃

動態規劃(dynamic programming)是大家在演算法學習中都會遇到的話題之一。我個人對於它的理解是:

  • 動態規劃針對的是規模較大的問題 
  • 但就像遞迴那樣,問題的base case是有解的
  • 並且同一個問題的較大規模版本的解,可以通過一組規則,從已有的較小規模的同一問題的解中推導而出
  • 動態規劃與遞迴不同的是,後者是利用了方法定義了在其過程中呼叫自己,“宣告式”(declaratively)地形成了整個求解的過程;前者需要建立動態規劃矩陣,用以記錄每一問題規模下的解,並需要顯式地執行迴圈來不斷擴大問題規模和求解,這是“命令式”(imperatively)地形式了求解的過程

可以被動態規劃解決的問題,常見的有:

  • 揹包問題(knapsack problem):給定一個容量有限的揹包,與一組帶有重量和價值的物品,如何選擇其中的幾個放入揹包使得價值的和最大
  • 最長公共序列問題(longest common sequence problem,下文簡稱為LCS問題):求兩個字串中,最長的公共序列。公共序列指的是在兩個字串中都出現的序列,這個序列在原字串中不一定是連續的
  • 子集和問題(subset sum problem):給定一個整數集合,是否存在它的非空子集使子集內的數字和為0
  • ...

最長公共序列問題與Diff & Patch演算法的關係

曾經,我自己在codewar等網站上做演算法題時,很多次刷到"longest common sequence"或者類似的題目,也通過一些演算法書瞭解到這類問題的一個比較易於理解的演算法是動態規劃。但我一直不太明白這類問題的實際應用何在。直到最近看到了下面的論文:

An O(ND) Difference Algorithm and Its Variations - EUGENE W. MYERS

此文我只讀了其中的1-2節,總結一下它的內容其實是:使用圖演算法,求解兩個字串之間的LCS,以及最短編輯步驟(shortest edit script,以下簡稱SES,指的是從字串A變換至字串B,所需要的步驟。步驟是針對字串的操作,例如刪除某一位置上的字元、在某一位置上插入字元等)。

從此文可知:LCS和SES是對偶問題(dual problem),這兩個問題只不過是一個優化問題的兩個方面。即,當我們尋找兩個字串的公共子序列時,如果已經找到了最優解(最長公共子序列),那麼在此最優解情形下的兩個字串之間的編輯步驟,也就是最短編輯步驟。通俗地講,求解LCS的過程中,我們就可以得到SES

由於SES描述了從一個字串到另一個字串的一系列操作步驟,這就類似於各類資料比較工具產生的差量資料。於是我們知道了,LCS問題的實際應用之一,就是資料的比較、差量計算和差量更新。

使用矩陣轉化LCS和SES問題

由於是用動態規劃來求解LCS和SES問題,我們需要用到矩陣(二維陣列)來記錄最優解的一些資訊。

這一小節主要是說明在使用矩陣求解以上問題的過程中,矩陣有哪些性質,以及這些性質對應著LCS或SES問題的什麼方面。這些內容也是對於上一小節中提到的論文第2節內容的歸納和簡化。

如果之前沒有接觸過使用動態規劃求解LCS問題的話,可以看一下下面的視訊,從而對於這一求解過程有一個基本概念。

Longest Common Subsequence - Tushar Roy - Youtube

總體來說,使用矩陣轉化並求解LCS和SES問題需要以下三個階段:

  1. 初始化階段。假設字串A長度為m,字串B長度為n,則初始化一個m + 1 * n + 1的矩陣,將矩陣的第一列和第一行都初始化為0(矩陣中後續需要填入的是LCS的長度,所以在初始化時,第一行或第一列表示兩個字串中的任意一個為空的情況,需要填入0)
  2. 推算階段。從左至右從上到下,根據一定的推演規則,填寫矩陣。即,不斷地求解字串A或B的字首之間的LCS的長度
  3. 回溯(backtracking)階段。當矩陣填滿時,位於矩陣最右下角的值即是字串A和B的LCS的長度。如果需要進一步找出LCS是什麼,則需要從矩陣的右下角出發,按一定的規則,找到一個到達矩陣左上角的路徑,保證經過路徑時,LCS的長度值每次減小0或1。

經過三個階段後,矩陣會變成類似下圖的形式。

使用動態規劃 實現字元級Diff & Patch

圖中是字串A為"abcabba",字串B為"cbabac"時,使用動態規劃求解LCS和SES形成的矩陣。由此矩陣我們可以得出以下關於兩個字串之間的LCS和SES的相關答案:

  1. 兩個字串的LCS長度為4(即矩陣最右下角位置所填入的值)
  2. 兩個字串的LCS為"caba"(這是完成回溯後,通過觀察紅色箭頭所形成的路徑而得來;觀察上圖可知,回溯階段時,每一次遇到需要向左上角移動的情況下,該座標對應的字串A內的某一字元與字串B內的某一字元相同,即這個字元可以作為LCS的組成字元之一)
  3. 回溯時的每一次移動都可以對映為SES中的某一步:
    1. 向左上角移動,意味著找到了組成LCS的一個字串,對於SES來說,表示不需要操作
    2. 向左移動,對於SES來說,意味著在字串A中的指定位置刪除字元
    3. 向上移動
      1. 如果是在矩陣的第1列(也就是全部被初始化為0的最左邊一列)向上移動,對於SES來說,意味著在字串A的頭部新增字元
      2. 如果是在矩陣的其他列向上移動,對於SES來說,意味著在字串A的指定位置的後面新增字元

例:字串A為"abcabba",字串B為"cbabac"時,如何知道經過什麼樣的步驟,可以最快地將字串A變為字串B呢?我們可以使用上面的規則,將紅色路徑翻譯成我們需要的SES

  1. 刪除字串A的第1、2個字元(最左上角的兩個向左箭頭)
  2. 在字串A的第3個位置新增字元"b"(從左上至右下的第四個向上箭頭) 
  3. 刪除字串A的第6個字元(從左上至右下的倒數第三個向左箭頭)
  4. 在字串A的第7個位置新增字元"c"(最右下角的向上箭頭)

經過上述操作後我們就可以將字串A變換為字串B

SES的同時操作問題

上一節的末尾給出了從"abcabba"到"cbabac"的SES,也許你試著用草稿紙或者其他工具來使用這段SES,但卻無法順利地完成字串的轉換。這是因為:SES所表示的編譯步驟,需要被同時操作。這個說法比較抽象,下面使用"abcabba"到"cbabac"例子,說明SES的正確用法:

原字串

      a b c a b b a
複製程式碼
  1. 刪除字串A的第1、2個字元(最左上角的兩個向左箭頭)(這裡用*標記將要被刪除的字元)

      * * c a b b a
    複製程式碼
  2. 在字串A的第3個位置新增字元"b"(從左上至右下的第四個向上箭頭)

      * * c a b b a
          b
    複製程式碼
  3. 刪除字串A的第6個字元(從左上至右下的倒數第三個向左箭頭)

      * * c a b * a
          b
    複製程式碼
  4. 在字串A的第7個位置新增字元"c"(最右下角的向上箭頭)

      * * c a b * a
          b       c
    複製程式碼
  5. 將以上類似於hashTable的結構還原為一個字串,規則為:遇到需要刪除的字元時則忽略,遇到縱向伸展的list時將其連綴為一個子字串,最後將所有子字串按順序連線,即得到"cbabac"

由此可知,SES的同時操作,指的是任何一個操作步驟,都不應該影響到字串最初的字元排列。我們可以用這種縱向的資料結構,重新整理字串操作,並在最後轉換成目標字串。

差分視覺化

如上一小節所示,SES的應用之一就是直接執行,其結果就是生成目標字串。

我們也可以結合原字串和SES,生成DOM String,在瀏覽器中將原字串到目標字串的差分呈現出來。本文開頭的demo即是對於這種應用方式的展示。

後記

不僅是字元級的diff & patch,如果在不考慮演算法空間複雜度的情況下,動態規劃也可以簡單地實現單詞級、行級的diff & patch。

學習和實現這個演算法給我最大的體會是:

  • 使用圖形化的表示和求解過程來轉化問題,能讓一些看似複雜的問題變得直觀和簡單(例如使用矩陣來記錄和求解LCS)
  • 一些已經掌握的演算法和演算法思想,經過再思考,有時能得到意想不到的更大的收穫

相關文章