經(jīng)典動態(tài)規(guī)劃問題:最短編輯距離算法的原理及實現(xiàn)

編輯距離的定義

編輯距離(Edit Distance)最常用的定義就是Levenstein距離,是由俄國科學(xué)家Vladimir Levenshtein于1965年提出的,所以編輯距離一般又稱Levenshtein距離。它主要作用是測量兩個字符串的差異化程度,表示字符串a(chǎn)至少要經(jīng)過多少個操作才能轉(zhuǎn)換為字符串b,這里的操作包括三種:增加、刪除、替換。

舉個例子:
(1)增加:對于字符串a(chǎn):abc 和 字符串b:abcde,顯然,只需要在字符串a(chǎn)的末尾增加字符'd'和'e'就能變成字符串b了,所以a和b的最短編輯距離為2。
(2)刪除:對于字符串a(chǎn):abcd 和字符串b:abc,顯然,只需要在字符串a(chǎn)的末尾刪除字符'd'就能變成字符串b了,所以a和b的最短編輯距離為1。
(3)替換:對于字符串a(chǎn):abcd 和 字符串b:abce,顯然,只需要把字符串a(chǎn)的'd'替換成'e'就可以了,此時二者的最短編輯距離是1。

一般字符串都是需要增加、刪除、替換三者結(jié)合起來一起使用,因為字符串a(chǎn)到b可能存在多種變化的方法,而我們往往最關(guān)心的是最短的編輯距離,這樣才能得出a和b的相似程度,最短編輯距離越小,表示a到b所需要的操作越少,a和b的相似度也就越高。因此,Levenstein距離的一個應(yīng)用場景就是判斷兩個字符串的相似度,可以用在字符串的模糊搜索上面。

Levenshtein 算法原理

先從一個問題談起:對于字符串"xyz"和"xcz",它們的最短距離是多少?我們從兩個字符串的最后一個字符開始比較,它們都是'z',是相同的,我們可以不用做任何操作,此時二者的距離實際上等于"xy"和"xc"的距離,即d(xyz,xcz) = d(xy,xc)。也即是說,如果在比較的過程中,遇到了相同的字符,那么二者的距離是除了這個相同字符之外剩下字符的距離。即d(i,j) = d(i - 1,j-1)。

接著,我們把問題拓展一下,最后一個字符不相同的情況:字符串A("xyzab")和字符串B("axyzc"),問至少經(jīng)過多少步操作可以把A變成B。

我們還是從兩個字符串的最后一個字符來考察即'b'和'c'。顯然二者不相同,那么我們有以下三種處理辦法:
(1)增加:在A末尾增加一個'c',那么A變成了"xyzabc",B仍然是"axyzc",由于此時末尾字符相同了,那么就變成了比較"xyzab"和"axyz"的距離,即d(xyzab,axyzc) = d(xyzab,axyz) + 1。可以寫成d(i,j) = d(i,j - 1) + 1。表示下次比較的字符串B的長度減少了1,而加1表示當(dāng)前進(jìn)行了一次字符的操作。

(2)刪除:刪除A末尾的字符'b',考察A剩下的部分與B的距離。即d(xyzab,axyzc) = d(xyza,axyzc) + 1。可以寫成d(i,j) = d(i - 1,j) + 1。表示下次比較的字符串A的長度減少了1。

(3)替換:把A末尾的字符替換成'c',這樣就與B的末尾字符一樣了,那么接下來就要考察出了末尾'c'部分的字符,即d(xyzab,axyzc) = d(xyza,axyz) + 1。寫成d(i,j) = d(i -1,j-1) + 1表示字符串A和B的長度均減少了1。

由于我們要求的是最短的編輯距離,所以我們?nèi)∫陨先齻€步驟得出的距離的最小值為最短編輯距離。由上面的步驟可得,這是一個遞歸的過程,因為除掉最后一個字符之后,剩下的字符串的最后一位仍然是最后一個字符,我們?nèi)匀豢梢园凑丈厦娴娜N操作來進(jìn)行,經(jīng)過這樣的不斷遞歸,直到比較到第一個字符為止,遞歸結(jié)束。

按照以上思路,我們很容易寫出下面的方程:


最短編輯距離方程

注釋:該方程的第一個條件min(i,j) = 0,表示若某一字符串為空,轉(zhuǎn)換成另一個字符串所需的操作次數(shù),顯然,就是另一個字符串的長度(添加length個字符就能轉(zhuǎn)換)。這個條件可以看成是遞歸的出口條件,此時i或j減到了0。

根據(jù)以上方程,我們能快速寫出遞歸代碼,但由于遞歸包含了大量的重復(fù)計算,并且如果初始字符串過長,會造成遞歸層次過深,容易造成棧溢出的問題,所以我們這里可以用動態(tài)規(guī)劃來實現(xiàn)。如果說遞歸是自頂向下的運算過程,那么動態(tài)規(guī)劃就是自底向上的過程。它從i和j的最小值開始,不斷地增大i和j,同時對于一個i和j都會算出當(dāng)前地最短距離,因為下一個i和j的距離會與當(dāng)前的有關(guān),所以通過一個數(shù)組來保存每一步的運算結(jié)果來避免重復(fù)的計算過程,當(dāng)i和j增加到最大值length時,結(jié)果也就出來了,即d[length][length]為A、B的最短編輯距離。

動態(tài)規(guī)劃中,i和j的增加需要兩層循環(huán)來完成,外層循環(huán)遍歷i,內(nèi)層循環(huán)遍歷j,也即是,對于每一行,會掃描行內(nèi)的每一列的元素進(jìn)行運算。因此,時間復(fù)雜度為o(n2),空間復(fù)雜度為o(n2)。

圖解動態(tài)規(guī)劃求最短編輯距離過程

在寫代碼之前,為了讓讀者對動態(tài)規(guī)劃有一個直觀的感受,筆者以表格的形式,列出動態(tài)規(guī)劃是如何一步步地工作的。
下面以字符串"xyzab"和"axyzc"為例來講解。



圖解

由上面可以看出,動態(tài)規(guī)劃就是逐行逐列地運算,逐漸填滿整個數(shù)組,最后得到結(jié)果恰好保存在數(shù)組的最后一行和最后一列的元素上。

代碼實現(xiàn)

一、基本實現(xiàn)

public class LevenshteinDistance {

    private static int minimum(int a,int b,int c){
        return Math.min(Math.min(a,b),c);
    }

    public static int computeLevenshteinDistance(CharSequence src,CharSequence dst){
        int[][] distance = new int[src.length() + 1][dst.length() + 1];

        for (int i = 0;i <= src.length();i++)
            distance[i][0] = i;
        for (int j = 0;j <= dst.length();j++)
            distance[0][j] = j;

        for (int i = 1;i <= src.length();i++){
            for (int j = 1;j <= dst.length();j++){
                int flag = (src.charAt(i - 1) == dst.charAt(j - 1)) ? 0 : 1;
                distance[i][j] = minimum(
                        distance[i - 1][j] + 1,
                        distance[i][j - 1] + 1,
                        distance[i - 1][j - 1] + flag);
            }
        }
        return distance[src.length()][dst.length()];
    }

    //測試方法
    public static void main(String args[]){
        String s1 = "xyzab";
        String s2 = "axyzc";

        String s3 = "等啊高原";
        String s4 = "阿登高原";

        String s5 = "xyz阿登高原";
        String s6 = "1y3等啊高原x";

        System.out.println("字符串(\"" + s1 + "\")和字符串(\"" + s2 + "\")的最小編輯距離為:"+ computeLevenshteinDistance(s1,s2));
        System.out.println("字符串(\"" + s3 + "\")和字符串(\"" + s4 + "\")的最小編輯距離為:"+ computeLevenshteinDistance(s3,s4));
        System.out.println("字符串(\"" + s5 + "\")和字符串(\"" + s6 + "\")的最小編輯距離為:"+ computeLevenshteinDistance(s5,s6));

    }
}

上面的代碼是利用了動態(tài)規(guī)劃的思想來實現(xiàn)的最短編輯距離算法,它的實現(xiàn)與原理方程基本上是一致的,都是先對第一行和第一列的數(shù)據(jù)進(jìn)行初始化,然后開始逐行逐列進(jìn)行計算,填充滿整個數(shù)組,即自底向上的思想,通過這樣減少了大量的遞歸重復(fù)計算,實現(xiàn)了運算速度的提升。上面提到,這種實現(xiàn)的時間復(fù)雜度和空間復(fù)雜度都是n2級別的(實際上是m×n,兩個字符串長度的乘積)。實際上,我們可以對代碼進(jìn)行優(yōu)化,降低空間復(fù)雜度。

二、利用滾動數(shù)組進(jìn)行空間復(fù)雜度的優(yōu)化
滾動數(shù)組是動態(tài)規(guī)劃中一種常見的優(yōu)化思想。為了理解滾動數(shù)組的思想,我們先來看看如何進(jìn)行空間復(fù)雜度的優(yōu)化。回到原理方程,我們可以觀察到d(i,j)只與上一行的元素d(i-1,j)、d(i,j-1)和d(i-1,j-1)有關(guān),而上一行之前的元素沒有關(guān)系,也就是說,對于某一行的d(i,j),我們只需要知道上一行的數(shù)據(jù)就行,別的數(shù)據(jù)都是無效數(shù)據(jù)。實際上,我們只需要兩行的數(shù)組就可以了。

舉個例子:還是上面的"xyzab"和"axyzc",當(dāng)我們計算完第一行和第二行的數(shù)據(jù)后,到達(dá)第三行時,我們以第二行為上一行結(jié)果來計算,并把計算結(jié)果放到第一行內(nèi);到達(dá)第四行時,由于第三行的數(shù)據(jù)實際上保存在第一行,所以我們根據(jù)第一行來計算,把結(jié)果保存在第二行……以此類推,直到計算到最后一行,即不斷交替使用兩行數(shù)組的空間,“滾動數(shù)組”也因此得名。通過使用滾動數(shù)組的形式,我們不需要n×m的空間,只需要2×min(n,m)的空間,這樣便能把空間復(fù)雜度降到線性范圍內(nèi),節(jié)省了大量的空間。

利用滾動數(shù)組后的空間復(fù)雜度為o(2×n)或者o(2×m),這取決于代碼的實現(xiàn),即取字符串A還是B的長度為數(shù)組的列數(shù)。(因為無論把哪一個字符串作為src或dst,都是等價的,結(jié)果都是一樣的。)其實我們可以通過判斷A、B的長度,來選取一個最小值作為列數(shù),此時空間復(fù)雜度變?yōu)閛(2×min(n,m))。下面給出基于滾動數(shù)組的最小編輯距離的優(yōu)化版本,由Java實現(xiàn)。

/**
     *  利用滾動數(shù)組優(yōu)化過的最小編輯距離算法。空間復(fù)雜度為O(2×min(lenSrc,lenDst))
     * @param src 動態(tài)規(guī)劃數(shù)組的行元素
     * @param dst 動態(tài)規(guī)劃數(shù)組的列元素
     * @return
     */
    public static int computeLevenshteinDistance_Optimized(CharSequence src,CharSequence dst){
        int lenSrc = src.length() + 1;
        int lenDst = dst.length() + 1;

        CharSequence newSrc = src;
        CharSequence newDst = dst;
        //如果src長度比dst的短,表示數(shù)組的列數(shù)更多,此時我們
        //交換二者的位置,使得數(shù)組的列數(shù)變?yōu)檩^小的值。
        if (lenSrc < lenDst){
            newSrc = dst;
            newDst = src;
            int temp = lenDst;
            lenDst = lenSrc;
            lenSrc = temp;
        }

        //創(chuàng)建滾動數(shù)組,此時列數(shù)為lenDst,是最小的
        int[] cost = new int[lenDst];   //當(dāng)前行依賴的上一行數(shù)據(jù)
        int[] newCost = new int[lenDst];//當(dāng)前行正在修改的數(shù)據(jù)

        //對第一行進(jìn)行初始化
        for(int i = 0;i < lenDst;i++)
            cost[i] = i;

        for(int i = 1;i < lenSrc;i++){
            //對第一列進(jìn)行初始化
            newCost[0] = i;

            for(int j = 1;j < lenDst;j++){
                int flag = (newDst.charAt(j - 1) == newSrc.charAt(i - 1)) ? 0 : 1;

                int cost_insert = cost[j] + 1;        //表示“上面”的數(shù)據(jù),即對應(yīng)d(i - 1,j)
                int cost_replace = cost[j - 1] + flag;//表示“左上方的數(shù)據(jù)”,即對應(yīng)d(i - 1,j - 1)
                int cost_delete = newCost[j - 1] + 1; //表示“左邊的數(shù)據(jù)”,對應(yīng)d(i,j - 1)

                newCost[j] = minimum(cost_insert,cost_replace,cost_delete); //對應(yīng)d(i,j)
            }

            //把當(dāng)前行的數(shù)據(jù)交換到上一行內(nèi)
            int[] temp = cost;
            cost = newCost;
            newCost = temp;
        }

        return cost[lenDst - 1];
    }

把main()方法的方法調(diào)用改為上述方法,比較前后兩個方法的輸出結(jié)果,結(jié)果一致,符合預(yù)期。


輸出結(jié)果

三、對空間復(fù)雜度的進(jìn)一步優(yōu)化
實際上,我們還能對這個進(jìn)行進(jìn)一步的優(yōu)化,把空間復(fù)雜度減少為o(min(n,m)),即我們只需要一行的數(shù)組d加一個額外的臨時變量就可以實現(xiàn)。比如說我們要修改d[i]的值時,只需知道它的左邊、上邊和左上方的元素的值,而左邊的值就是d[i-1],上邊的值是修改之前的d[i],左上方的值是d[i-1]修改之前的值。每一次需要修改d[i-1]的時候,都用臨時變量把他保存起來,這樣i位置就能直接獲取這三個值進(jìn)行比較,得到結(jié)果之后,先用這個臨時變量把d[i]保存起來,然后再寫入d[i]內(nèi),……以此類推,直到遍歷完一行。

其核心思想是:把求得的數(shù)據(jù),再次寫回這一行數(shù)據(jù)對應(yīng)下標(biāo)元素的位置,而臨時變量temp則保存當(dāng)前位置左上方元素的值,以提供給下一個位置的計算。總的來說,數(shù)據(jù)的操作只集中在一行之內(nèi),所以空間復(fù)雜度就是o(n)。

下面以圖解的形式表達(dá)這一過程,方便讀者理解。


單行數(shù)組

代碼實現(xiàn)也不復(fù)雜,有興趣的同學(xué)可以根據(jù)上圖或者思路來實現(xiàn),這里就不再實現(xiàn)了。

好了,這篇文章寫到這里就結(jié)束了,希望能對各位同學(xué)有所裨益,謝謝你們的耐心閱讀~

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 在C語言中,五種基本數(shù)據(jù)類型存儲空間長度的排列順序是: A)char B)char=int<=float C)ch...
    夏天再來閱讀 3,402評論 0 2
  • 專業(yè)考題類型管理運行工作負(fù)責(zé)人一般作業(yè)考題內(nèi)容選項A選項B選項C選項D選項E選項F正確答案 變電單選GYSZ本規(guī)程...
    小白兔去釣魚閱讀 9,051評論 0 13
  • 動態(tài)規(guī)劃(Dynamic Programming) 本文包括: 動態(tài)規(guī)劃定義 狀態(tài)轉(zhuǎn)移方程 動態(tài)規(guī)劃算法步驟 最長...
    廖少少閱讀 3,327評論 0 18
  • 回溯算法 回溯法:也稱為試探法,它并不考慮問題規(guī)模的大小,而是從問題的最明顯的最小規(guī)模開始逐步求解出可能的答案,并...
    fredal閱讀 13,728評論 0 89
  • 道德經(jīng)第二章 [原文] 天下皆知美之為美,惡已①;皆知善,斯不善矣②。有無之相生也③,難易之相成也,長短之相刑也④...
    傳遞快樂閱讀 397評論 0 0