《資料結構與演算法之美》29——動態規劃實戰
前言
搜尋引擎在使用者體驗方面的優化還有很多,比如你可能經常會用的拼寫糾錯功能。
當你在搜尋框中,一不小心輸錯單詞時,搜尋引擎會非常智慧地檢測出你的拼寫錯誤,並且 用對應的正確單詞來進行搜尋。
如何量化兩個字元串的相似度?
計算機只認識數字,所以要解答上面的問題,我們就要先來看,如何量化兩個字元串之間的相似程度呢?有一個非常著名的量化方法,那就是編輯距離(Edit Distance)。
編輯距離指的就是,將一個字元串轉化成另一個字元串,需要的最少編輯操作次數(比如增加一個字元、刪除一個字元、替換一個字元)。
- 編輯距離越大,說明兩個字元串的相似程度越小;
- 編輯距離越小,說明兩個字元串的相似程度越大。
- 兩個完全相同的字元串來說,編輯距離就是0。
編輯距離有多種不同的計算方式,比較著名的有萊文斯坦距離(Levenshtein distance)和最長公共子串長度(Longest common substring length)。
- 萊文斯坦距離:允許增加、刪除、替換字元;萊文斯坦距離大小,表示兩個字串差異。
- 最長公共子串長度:允許增加、刪除字元;最長公共子串大小,表示兩個字串相似程度的大小。
這裡有兩個字串mitcmu和mtacnu,其中萊文斯坦距離是3,最長公共子串長度是4。
如何程式設計計算萊文斯坦距離?
上面的問題是求把一個字串變成另一個字串,需要的最少編輯次數。整個求解過程,涉及多個決策階段,需要依次考察一個字串中的每個字元,跟另一個字串中的字元是否匹配,匹配的話如何處理,不匹配的話又如何處理。所以這個問題符合多階段決策最優解模型
第1步:回溯演算法實現
回溯是一個遞迴處理的過程。如果a[i]與b[j]匹配,我們遞迴考察a[i + 1]和b[j + 1]。如果a[i]與b[j]不匹配,那有多種處理方式可選:
- 可以刪除a[i],然後遞迴考察a[i + 1]和b[j];
- 可以刪除b[j],然後遞迴考察a[i]和b[j + 1];
- 可以在a[i]前面新增一個跟b[j]相同的字元,然後遞迴考察a[i]和b[j + 1];
- 可以在b[j]前面新增一個跟a[i]相同的字元,然後遞迴考察a[i + 1]和b[j];
- 可以將a[i]替換成b[j],或者將b[j]替換成a[i],然後遞迴考察a[i + 1]和b[j + 1]。
翻譯成程式碼:
public class Solution {
private char[] a = "mitcmu".ToCharArray ();
private char[] b = "mtacnu".ToCharArray ();
private int n = 6;
private int m = 6;
private int minDist = int.MaxValue;
public int MinDist { get { return minDist; } }
// 呼叫LwstBT(0, 0, 0);
public void LwstBT (int i, int j, int edist) {
if (i == n || j == m) { // 考察結束,剩餘的子串長度要累加到edist
if (i < n) edist += (n - i);
if (j < m) edist += (m - j);
if (edist < minDist) minDist = edist;
return;
}
if (a[i] == b[j]) { // 兩個字元匹配
LwstBT (i + 1, j + 1, edist);
} else { // 兩個字元不匹配
LwstBT (i + 1, j, edist + 1); // 刪除a[i]或者b[j]前新增一個字元
LwstBT (i, j + 1, edist + 1); // 刪除b[j]或者a[i]前新增一個字元
LwstBT (i + 1, j + 1, edist + 1); // 將a[i]和b[j]替換為相同字元
}
}
}
第2步:定義狀態&畫遞迴樹
根據回溯演算法的程式碼實現,畫出遞迴樹,看是否存在重複子問題。
在遞迴樹中,每個節點代表一個狀態,狀態包含三個變數(i, j, edist),其中edist表示處理到a[i]和b[j]時,已經執行的編輯操作的次數。
在遞迴樹中,(i, j)兩個變數重複的節點很多,對於相同的節點,只需要保留edist最小的,繼續遞迴處理。所以狀態就從(i, j, edist)變成了(i, j, min_edist)。
第3步:找狀態轉移方式
狀態(i, j)可能從(i - 1, j),(i, j - 1),(i - 1, j - 1)三個狀態中的任意一個轉移過來。
基於上面的分析,用公式把狀態轉移方程寫出來,如下:
- 如果:a[i] != b[j],那麼:min_edist(i, j) = min(min_edist(i - 1, j) + 1, min_edist(i, j - 1) + 1, min_edist(i - 1, j - 1) + 1)
- 如果:a[i] == b[j],那麼:min_edist(i, j) = min(min_edist(i - 1, j) + 1, min_edist(i, j - 1) + 1, min_edist(i - 1, j - 1))
- 其中,min表示求三數中的最小值。
第4步:根據遞推關係填表
第5步:翻譯程式碼
public class Solution2 {
public int LwstDP (char[] a, int n, char[] b, int m) {
int[, ] minEdist = new int[n, m];
for (int j = 0; j < m; j++) { // 初始化第0行
if (a[0] == b[j]) minEdist[0, j] = j;
else if (j != 0) minEdist[0, j] = minEdist[0, j - 1] + 1;
else minEdist[0, j] = 1;
}
for (int i = 0; i < n; i++) { // 初始化第0列
if (a[i] == b[0]) minEdist[i, 0] = i;
else if (i != 0) minEdist[i, 0] = minEdist[i - 1, 0] + 1;
else minEdist[i, 0] = 1;
}
for (int i = 1; i < n; ++i) {
for (int j = 1; j < m; ++j) {
if (a[i] == b[j]) {
minEdist[i, j] = Math.Min (minEdist[i - 1, j] + 1,
Math.Min (minEdist[i, j - 1] + 1, minEdist[i - 1, j - 1])
);
} else {
minEdist[i, j] = Math.Min (minEdist[i - 1, j] + 1,
Math.Min (minEdist[i, j - 1] + 1, minEdist[i - 1, j - 1] + 1)
);
}
}
}
return minEdist[n - 1, m - 1];
}
}
如何程式設計計算最長公共子串長度?
第1步:回溯演算法實現
如果a[i]與b[j]匹配,最大公共子串長度加1,並繼續考察a[i + 1]和b[j + 1];
如果a[i]與b[j]不匹配,最長公共子串長度不變,有兩個不同的決策路線:
- 刪除a[i],或者在b[j]前面加上一個字元a[i],然後繼續a[i + 1]和b[j];
- 刪除b[j],或者在a[i]前面加上一個字元b[j],然後繼續a[i]和b[j + 1];
翻譯成程式碼:
public class Solution3 {
private char[] a = "mitcmu".ToCharArray ();
private char[] b = "mtacnu".ToCharArray ();
private int n = 6;
private int m = 6;
private int maxLen = int.MinValue;
public int MaxLen { get { return maxLen; } }
// 呼叫LcslBT(0, 0, 0);
public void LcslBT (int i, int j, int len) {
if (i == n || j == m) { // 考察結束
if (len > maxLen) maxLen = len;
return;
}
if (a[i] == b[j]) { // 兩個字元匹配
LcslBT (i + 1, j + 1, len + 1);
} else { // 兩個字元不匹配
LcslBT (i + 1, j, len); // 刪除a[i],或者在b[j]前面加上一個字元a[i]
LcslBT (i, j + 1, len); // 刪除b[j],或者在a[i]前面加上一個字元b[j]
}
}
}
第2步:定義狀態&畫遞迴樹
在遞迴樹中,每個節點代表一個狀態,狀態包含三個變數(i, j, len),其中len表示處理到a[i]和b[j]時,公共子串長度。
在遞迴樹中,(i, j)兩個變數重複的節點很多,對於相同的節點,只需要保留len最大的,繼續遞迴處理。所以狀態就從(i, j, len)變成了(i, j, max_len)。
第3步:找狀態轉移方式
狀態(i, j)可能從(i - 1, j),(i, j - 1),(i - 1, j - 1)三個狀態中的任意一個轉移過來。
基於上面的分析,用公式把狀態轉移方程寫出來,如下:
- 如果:a[i] != b[j],那麼:max_len(i, j) = max(max_len(i - 1, j), max_len(i, j - 1), max_len(i - 1, j - 1))
- 如果:a[i] == b[j],那麼:max_len(i, j) = max(max_len(i - 1, j), max_len(i, j - 1), max_len(i - 1, j - 1) + 1)
- 其中,max表示求三數中的最大值。
第4步:根據遞推關係填表
手畫,暫無。
第5步:翻譯程式碼
public class Solution4 {
public int LcslDP (char[] a, int n, char[] b, int m) {
int[, ] maxLen = new int[n, m];
for (int j = 0; j < m; ++j) { // 初始化第0行
if (a[0] == b[j]) maxLen[0, j] = 1;
else if (j != 0) maxLen[0, j] = maxLen[0, j - 1];
else maxLen[0, j] = 0;
}
for (int i = 0; i < n; ++i) { // 初始化第0列
if (a[i] == b[0]) maxLen[i, 0] = 1;
else if (i != 0) maxLen[i, 0] = maxLen[i - 1, 0];
else maxLen[i, 0] = 0;
}
for (int i = 1; i < n; ++i) {
for (int j = 1; j < m; ++j) {
if (a[i] == b[j]) maxLen[i, j] = Math.Max (Math.Max (maxLen[i - 1, j], maxLen[i, j - 1]), maxLen[i - 1, j - 1] + 1);
else maxLen[i, j] = Math.Max (Math.Max (maxLen[i - 1, j], maxLen[i, j - 1]), maxLen[i - 1, j - 1]);
}
}
return maxLen[n - 1, m - 1];
}
}
解答開篇
當用詞在搜尋框內,輸入一個拼寫錯誤的單詞時,我們就拿這個單詞跟詞庫中的單詞一一進行比較,計算編輯距離,將編輯距離最小的單詞,作為糾正之後的單詞,提示給用詞。
這是拼寫糾正最基本的原理。