【ZH奶酪】如何用Python實現編輯距離?

1. 什麼是編輯距離?

編輯距離(Edit Distance),又稱Levenshtein距離,是指兩個字串之間,由一個轉成另外一個所需的最少編輯操做次數。許可的編輯操做包括將一個字符替換成另外一個字符,插入一個字符,刪除一個字符。通常來講,編輯距離越小,兩個串的類似度越大。算法

舉個例子,給定 2 個字符串str_a=「yes」, str_b=「yeah」. 編輯距離是將 str_a 轉換爲 str_b 的最少操做次數,操做只容許以下 3 種:數組

  • 插入一個字符,例如:abc -> ab
  • 刪除一個字符,例如:ab -> abc
  • 替換一個字符,例如:abc -> abd

那麼從str_a到str_b的轉換過程總共須要兩步:yes > yeas > yeah 或者 yes > yea > yeah,因此str_a和str_b的編輯距離爲2。app

2. 如何計算編輯距離?

假設字符串a, 共m位,從a[1]a[m], 字符串b, 共m位, 從b[1]b[m]. 用二維數組D來保存由ab的編輯距離,其中D[i][j]表示字符串a[1]-a[i]轉換爲b[1]-b[i]的編輯距離.優化

2.1 遞歸算法

遞歸的思想須要能夠將問題拆解,假設a[i]b[j]分別是字符串ab的最後一位,那麼要把問題拆解,有三種選擇:spa

  • a[i-1], b[j],即用a[1:i-1]繼續和b[1:j]比較,刪除了a[i],須要額外一步代價;
  • a[i-1], b[j-1],即用a[1:i-1]繼續和b[1:j-1]比較,若是a[i]b[j]相等,那麼無需額外代價,不然須要額外一步代價將a[i]修改成b[j]
  • a[i], b[j-1],即用a[1:i]繼續和b[1:j-1]比較,刪除了b[j],須要額外一步代價;

換一種說法,也就是說具體要拆解爲哪種,須要考慮a[i]b[j]的比值,以及這三種方法的代價。即以下遞歸規律:code

  • a[i]等於b[j]時,好比 abcbbc,那麼D[i][j] = D[i-1][j-1], 即等於abbb的編輯距離;
  • a[i]不等於b[j]時,D[i][j]等於以下3項的最小值:
    1. D[i-1][j] + 1,即刪除a[i], 好比abcd -> abc的編輯距離 = abc -> abc 的編輯距離 + 1
    2. D[i][j-1] + 1,即插入b[j], 好比ab -> abc 的編輯距離 = abc -> abc 的編輯距離 + 1
    3. D[i-1][j-1] + 1,將a[i]替換爲b[j], 好比abd -> abc 的編輯距離 = abc -> abc 的編輯距離 + 1

那麼遞歸邊界如何設定呢?orm

遞歸邊界就是a[1:i]或者b[1:j]'爲空的時候,即:遞歸

a[i][0] = i, b字符串爲空,那麼須要將a[1]-a[i]所有刪除,因此編輯距離爲i a[0][j] = j, a字符串爲空,那麼須要向a插入b[1]-b[j],因此編輯距離爲j資源

Python代碼:rem

def recursive_edit_distance(str_a, str_b):
  if len(str_a) == 0:
    return len(str_b)
  elif len(str_b) == 0:
    return len(str_a)
  elif str_a[len(str_a)-1] == str_b[len(str_b)-1]:
    return recursive_edit_distance(str_a[0:-1], str_b[0:-1])
  else:
    return min([
      recursive_edit_distance(str_a[:-1], str_b),
      recursive_edit_distance(str_a, str_b[:-1]),
      recursive_edit_distance(str_a[:-1], str_b[:-1])
    ]) + 1
str_a = "yes"
str_b = "yeah"
print(recursive_edit_distance(str_a, str_b))
# output is : 2

算法分析:該算法邏輯清晰,可讀性較高,可是對於計算機而言卻很不友好,時間複雜度高,隨字符串長度呈指數級增加,並且遞歸算法的通病就是調用棧太深的時候,須要佔用較多計算機資源。

2.2 動態規劃

若是熟悉動態規劃的同窗,從上邊的思路能夠很容易推理出動態規劃的遞推公式:

if a[i] == b[j]:
    edit_distance(a[i], b[j]) = edit_distance(a[i-1], b[j-1]) 
if a[i] != b[j]:
    edit_distance(a[i], b[j]) = MIN (
        edit_distance(a[i-1], b[j]) + 1,   # 從a中刪除a[i]
        edit_distance(a[i], b[j-1]) + 1,  # 向a中插入b[j]
        edit_distance(a[i-1], b[j-1]) + 1  # 將a[i]修改成b[j]
    )

轉換爲Python,也就是用二維數組D來記錄從a向b的轉換過程:

def edit_distance(str_a, str_b):
  if str_a == str_b:
    return 0
  if len(str_a) == 0:
    return len(str_b)
  if len(str_b) == 0:
    return len(str_a)
# 初始化dp矩陣
  dp = [[0 for _ in range(len(str_a) + 1)] for _ in range(len(str_b) + 1)]
# 當a爲空,距離和b的長度相同
  for i in range(len(str_b) + 1):
    dp[i][0] = i
# 當b爲空,距離和a和長度相同
  for j in range(len(str_a) + 1):
    dp[0][j] = j
# 遞歸計算
  for i in range(1, len(str_b) + 1):
    for j in range(1, len(str_a) + 1):
      dp[i][j] = dp[i-1][j-1]
      if str_a[j-1] != str_b[i-1]:
        dp[i][j] = min([dp[i-1][j-1], dp[i-1][j], dp[i][j-1]]) + 1
  return dp[len(str_b)][len(str_a)]
str_a = "yes"
str_b = "yeah"
print(edit_distance(str_a, str_b))
# output is : 2

2.3 動態規劃, 優化空間複雜度

上邊的算法中用二維數組來存儲從a到b的距離,從遞推公式來看,其實每一步dp[i][j]的計算只依賴a[i]和b[j]是否相等以及矩陣中的三個值

  • 左邊的值,left = dp[i-1][j]
  • 左上角的值,left_up = dp[i-1][j-1]
  • 上邊的值,up = dp[i][j-1]

其實咱們能夠用一維數組來達到上述目的,具體能夠看Python代碼:

def edit_distance(str_a, str_b):
  if str_a == str_b:
    return 0
  if len(str_a) == 0:
    return len(str_b)
  if len(str_b) == 0:
    return len(str_a)
  dp = [x for x in range(len(str_b) + 1)]
  for i in range(1, len(str_a) + 1):
    # 注意每次left_up和dp[0]的初始化
    left_up = i - 1
    dp[0] = i # 當前輪最左的left
    for j in range(1, len(str_b) + 1):
      up= dp[j]  # j是上一輪的值,即up
      left = dp[j-1]  # j-1是當前輪的值,即left
      if str_a[i-1] == str_b[j-1]:
        dp[j] = left_up
      else:
        dp[j] = min([left, up, left_up]) + 1
      left_up = up # 每移動一步,上一輪的up就變成了left_up
  return dp[len(str_b)]
str_a = "yes"
str_b = "yeah"
print(edit_distance(str_a, str_b))
# output is : 2

2.4 打印編輯過程

def edit_distance_Omn(str_a, str_b):
  if str_a == str_b:
    return 0
  if len(str_a) == 0:
    return len(str_b)
  if len(str_b) == 0:
    return len(str_a)
  dp = [[0 for _ in range(len(str_a) + 1)] for _ in range(len(str_b) + 1)]
  for i in range(len(str_b) + 1):
    dp[i][0] = i
  for j in range(len(str_a) + 1):
    dp[0][j] = j
  for i in range(1, len(str_b) + 1):
    for j in range(1, len(str_a) + 1):
      dp[i][j] = dp[i-1][j-1]
      if str_a[j-1] != str_b[i-1]:
        dp[i][j] = min([dp[i-1][j-1], dp[i-1][j], dp[i][j-1]]) + 1

  #打印完整路徑矩陣(這一步非必要)
  for i in range(len(str_b) + 1):
    for j in range(len(str_a) + 1):
      print dp[i][j],
    print
  # 準備倒着查詢編輯路徑,從右下角開始
  i , j = len(str_b), len(str_a)
  op_list = []  # 記錄編輯操做
  while i > 0 and j > 0:
    if dp[i][j] == dp[i-1][j-1]:
      op_list.append("keep [ {} ]".format(str_b[i-1]))
      i, j = i-1, j-1
      continue
    if dp[i][j] == dp[i-1][j]  + 1:
      op_list.append("remove [ {} ]".format(str_b[i-1]))
      i, j = i-1, j
      continue
    if dp[i][j] == dp[i-1][j-1] + 1:
      op_list.append("change [ {} ] to [ {} ]".format(str_b[i-1], str_a[j-1]))
      i, j = i-1, j-1
      continue
    if dp[i][j] == dp[i][j-1] + 1:
      op_list.append("insert [ {} ]".format(str_a[j-1]))
      i, j = i, j-1
  for i in range(len(op_list)):
    print op_list[len(op_list)-i-1]
  return dp[len(str_b)][len(str_a)]
str_a = "yesxxxxxx"
str_b = "yeahxxxxxhh"
print(edit_distance(str_a, str_b))

輸出

0 1 2 3 4 5 6 7 8 9
1 0 1 2 3 4 5 6 7 8
2 1 0 1 2 3 4 5 6 7
3 2 1 1 2 3 4 5 6 7
4 3 2 2 2 3 4 5 6 7
5 4 3 3 2 2 3 4 5 6
6 5 4 4 3 2 2 3 4 5
7 6 5 5 4 3 2 2 3 4
8 7 6 6 5 4 3 2 2 3
9 8 7 7 6 5 4 3 2 2
10 9 8 8 7 6 5 4 3 3
11 10 9 9 8 7 6 5 4 4
keep [ y ]
keep [ e ]
change [ a ] to [ s ]
change [ h ] to [ x ]
keep [ x ]
keep [ x ]
keep [ x ]
keep [ x ]
keep [ x ]
remove [ h ]
remove [ h ]
4
相關文章
相關標籤/搜索