[譯] Swift 演算法學院 - 最長公共子序列演算法

KeithSummer發表於2018-04-23

本篇是來自 Swift 演算法學院的翻譯的系列文章,Swift 演算法學院 致力於使用 Swift 實現各種演算法,對想學習演算法或者複習演算法的同學非常有幫助,講解思路非常清楚,每一篇都有詳細的例子解釋。 更多翻譯的文章還可以檢視這裡

最長公共子序列演算法

兩個字串的最長公共子序列(LCS)是指這兩個字串中最長的有相同順序的子序列。

舉例說明一下,"Hello World""Bonjour le monde" 的 LCS 是 "oorld"。如果從左到右依次掃過字串,你會發現 oorld 在兩個字串中出現的順序是一樣的。

其他的子序列為 "ed""old",但是它們都比 "oorld" 要短。

注意:不要和最長公共字串混淆了,後者必須是兩個字串的子字串,也就是字元是直接相鄰的。但對公共序列來說,字元之間並不是連續,但是它們必須有相同的順序。

計算兩個字串 ab 的 LCS 方法之一是通過動態規劃和回溯法。

通過動態規劃計算 LCS 的長度

首先,我們需要計算 ab 最長的公共子序列,先不需要查詢確切的子序列,只是確定長度是多少。

為了計算 ab 所有子混合字串 LCS 的長度,我們可以使用 動態規劃技術。動態規劃基本方法是計算出所有的可能並存入一個待查詢的表中。

注意:在下面介紹中, na 字元的長度, mb 的字元長度。

為了找出所有可能的子序列,先寫一個幫助函式 lcsLength(_:)。這個函式會建立一個 (n+1) * (m+1) 的矩陣,這裡 matrix[x][y] 是 字串 a[0...x-1]b[0...y-1] 的 LCS 長度。

比如字串如下 "ABCBX""ABDCAB" ,函式 lcsLength(_:) 矩陣輸出如下:

|   | Ø | A | B | D | C | A | B |
| Ø | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| A | 0 | 1 | 1 | 1 | 1 | 1 | 1 |  
| B | 0 | 1 | 2 | 2 | 2 | 2 | 2 |
| C | 0 | 1 | 2 | 2 | 3 | 3 | 3 |
| B | 0 | 1 | 2 | 2 | 3 | 3 | 4 |
| X | 0 | 1 | 2 | 2 | 3 | 3 | 4 |
複製程式碼

在這個例子中,檢視 matrix[3][4] 的值為 3。 這意味著子字串 a[0...2]b[0...3] 或者說 "ABC""ABDC" LCS 的長度是 3。 這個值確實正確,因為兩字串有相同的子序列 ABC。(注意:第一行列的矩陣值用 0 填充。)

lcsLength(_:) 的原始碼如下,這段程式碼在 String 擴充套件中:

func lcsLength(_ other: String) -> [[Int]] {

  var matrix = [[Int]](repeating: [Int](repeating: 0, count: other.characters.count+1), count: self.characters.count+1)

  for (i, selfChar) in self.characters.enumerated() {
	for (j, otherChar) in other.characters.enumerated() {
	  if otherChar == selfChar {
        // 找到公共字元,當前 lcs 的最大長度加 1。
		matrix[i+1][j+1] = matrix[i][j] + 1
	  } else {
        // 沒有找到匹配的,接著使用當前最大的 lcs 長度
		matrix[i+1][j+1] = max(matrix[i][j+1], matrix[i+1][j])
	  }
	}
  }

  return matrix
}
複製程式碼

首先,建立一個新的矩陣 —— 二維陣列 —— 全部用零填充。然後在 selfother 兩個字串中查詢,比較它們的字串後按順序填充到矩陣中。如果兩個字元相同,增大序列的長度。如果兩個字元不同,“複製” 當前最大 LCS。

比如如下的情況:

|   | Ø | A | B | D | C | A | B |
| Ø | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| A | 0 | 1 | 1 | 1 | 1 | 1 | 1 |  
| B | 0 | 1 | 2 | 2 | 2 | 2 | 2 |
| C | 0 | 1 | 2 | * |   |   |   |
| B | 0 |   |   |   |   |   |   |
| X | 0 |   |   |   |   |   |   |
複製程式碼

* 表示我們當前的比較的兩個字元 CD 。這兩個字元不相同,因此用之前找到的最大長度 2 作為結果:

|   | Ø | A | B | D | C | A | B |
| Ø | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| A | 0 | 1 | 1 | 1 | 1 | 1 | 1 |  
| B | 0 | 1 | 2 | 2 | 2 | 2 | 2 |
| C | 0 | 1 | 2 | 2 | * |   |   |
| B | 0 |   |   |   |   |   |   |
| X | 0 |   |   |   |   |   |   |
複製程式碼

現在比較 CC。 他們兩個相同,因此增加長度到 3

|   | Ø | A | B | D | C | A | B |
| Ø | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| A | 0 | 1 | 1 | 1 | 1 | 1 | 1 |  
| B | 0 | 1 | 2 | 2 | 2 | 2 | 2 |
| C | 0 | 1 | 2 | 2 | 3 | * |   |
| B | 0 |   |   |   |   |   |   |
| X | 0 |   |   |   |   |   |   |
複製程式碼

一次類推,這就是 lcsLength(_:) 如何計算填充整個矩陣的過程。

回溯查詢出序列

目前我們計算出每個可能的序列的長度。最長的序列可以再矩陣的右下角,位置為 matrix[n+1][m+1]。在上面的例子值為 4 ,因此 LCS 包含 4 個字元。

計算出全部公共子序列的長度後就可以通過回溯法計算出那些字元是 LCS 的組成部分。

回溯法從 matrix[n+1][m+1] 開始,向左邊和上邊(以此為優先順序)方向搜尋沒有簡單傳播的變化。

|   |  Ø|  A|  B|  D|  C|  A|  B|
| Ø |  0|  0|  0|  0|  0|  0|  0|
| A |  0|↖ 1|  1|  1|  1|  1|  1|  
| B |  0|  1|↖ 2|← 2|  2|  2|  2|
| C |  0|  1|  2|  2|↖ 3|← 3|  3|
| B |  0|  1|  2|  2|  3|  3|↖ 4|
| X |  0|  1|  2|  2|  3|  3|↑ 4|
複製程式碼

每一個 代表一個屬於 LCS 的字元(在行/列的頭部)。

如果左邊和上邊的數字與當前單元的數字不同,那就沒有產生傳播。這樣的情況下 matrix[i][j] 代表字串 ab 的一個公共字元,因此 a[i-1]b[j-1] 就是正在尋找的 LCS 的組成部分。

需要注意的是,因為是反向執行的, LCS 是倒序組成的,在返回結果之前,需要把結果做反向排序後才是正確的 LCS。

下面是回溯的程式碼:

func backtrack(_ matrix: [[Int]]) -> String {
  var i = self.characters.count
  var j = other.characters.count
  
  var charInSequence = self.endIndex
  
  var lcs = String()
  
  while i >= 1 && j >= 1 {
    // 表示傳播沒有變化:沒有新字元新增到 lcs
	if matrix[i][j] == matrix[i][j - 1] {
	  j -= 1
	}
    // 表示傳播沒有變化:沒有新字元新增到 lcs
	else if matrix[i][j] == matrix[i - 1][j] {
	  i -= 1
	  charInSequence = self.index(before: charInSequence)
	}
    // 左邊和上面的字元與當前單元均不相同
    // 意味著 lcs 長度加1
	else {
	  i -= 1
	  j -= 1
	  charInSequence = self.index(before: charInSequence)
	  lcs.append(self[charInSequence])
	}
  }
  
  return String(lcs.characters.reversed())
}
複製程式碼

回溯法從 matrix[n+1][m+1](右下角)到 matrix[1][1] (左上角),查詢兩個字元的公共字串,新增這些字元到新字串 lcs 中。

charInSequence 變數是 self 字串的索引。開始時指向字串的最後一個位置。每次我們減小 i ,也會將 charInSequence 回退。當兩個字元相同時,將處於 self[charInSequence] 的字元新增到新 lcs 字串中。(不能直接寫 self[i] 因為 i 可能在 Swift 字串中並不指向此位置。)

由於回溯法是倒序新增字元,所以在函式最後呼叫 reversed() 把字串調整成正確的順序。(每次新增到字元尾部然後一次性反過來要比每次把字元插到字串的前面要快。)

整合一下

先呼叫 lcsLength(_:) 找到兩個字串的 LCS,然後再呼叫 backtrack(_:)

extension String {
  public func longestCommonSubsequence(_ other: String) -> String {

    func lcsLength(_ other: String) -> [[Int]] {
      ...
    }
    
    func backtrack(_ matrix: [[Int]]) -> String {
      ...
    }

    return backtrack(lcsLength(other))
  }
}
複製程式碼

為了保持程式碼整潔,兩個幫助函式在主函式 longestCommonSubsequence() 中摺疊起來了。

在 Playground 中試試下面程式碼:

let a = "ABCBX"
let b = "ABDCAB"
a.longestCommonSubsequence(b)   // "ABCB"

let c = "KLMK"
a.longestCommonSubsequence(c)   // "" (no common subsequence)

"Hello World".longestCommonSubsequence("Bonjour le monde")   // "oorld"
複製程式碼

作者 Pedro Vereza, 譯者 KeithMorning

相關文章