1. 程式人生 > 實用技巧 >《資料結構與演算法之美》29——動態規劃實戰

《資料結構與演算法之美》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];
    }
}

解答開篇

當用詞在搜尋框內,輸入一個拼寫錯誤的單詞時,我們就拿這個單詞跟詞庫中的單詞一一進行比較,計算編輯距離,將編輯距離最小的單詞,作為糾正之後的單詞,提示給用詞。

這是拼寫糾正最基本的原理。

參考資料