動態規划之最長公共子序列


動態規划算法基本概念

動態規划算法的2個基本要素:最優子結構和子問題重疊。

最優子結構

應用動態規划算法第一步:刻畫最優解的結構。當問題的最優解包含其子問題的最優解時,稱該問題具有最優子結構性質。反過來說,可以利用子問題的最優解推導出問題的最優解。
通常,利用子問題的最優子結構性質,以自底向上的方式遞歸地從子問題的最優解逐步構造出整個問題的最優解。

子問題重疊

子問題重疊性質:遞歸算法自頂向下求解問題時,每次產生的子問題並不總是新問題,有些子問題被反復計算過多次。
動態規划算法對每個子問題只解一次,並將解存放到一個表格中(可能是一個值,也可能是一個一維數組等)。當后續需要解此子問題時,直接用常數時間查看一下結果。

最長公共子序列

什么是子序列?

對於一個給定的序列,刪去若干元素后得到的序列叫子序列。

子序列的元素下標順序是與原來一致的,也就是說子序列雖然刪去了元素,但不會改變以前的排列順序。注意與連續子序列區分開,子序列不一定是連續的。 如X={1,3,5,7,9},其中一個子序列是Z={3,9}。而序列{9,3}不是X子序列。


公共子序列:對於序列X和Y,當序列Z既是X子序列,也是Y子序列時,稱Z是X和Y的公共子序列。
最長公共子序列(Longest Common Sequence,簡稱LCS):在X和Y的公共子序列中,元素個數最多的序列,稱為最長公共子序列。
最長公共子序列問題,就是求解序列X、Y的最長公共子序列。

如X = {3,2,1,5,7,9,5,4}, Y = {6,3,1,4,7,4,2}
此時,序列X和Y最長公共子序列:{3,1,7,4}

最長公共子序列的最優子結構

如何求解序列X、Y的最長公共子序列?
如果用窮舉法,先窮舉X所有子序列,然后再逐個判斷是否為Y的子序列,這樣需要指數級時間。因為X如果有m個元素,那么X就有2^m個不同子序列。
有沒有更好的辦法?
嘗試用動態規划來構造最優解。


最長公共子序列問題的最優子結構
設序列X={x1,x2,...,xm},Y={y1,y2,...,yn},最長公共子序列Z={z1,z2,...,zk},則有
1. xm = yn => zk = xm = yn,且Z(k-1)是X(m-1)和Y(m-1)的最長公共子序列;
-- xm, yn, zk分別是X, Y, Z最后一項,如果xm = yn,最長公共子序列Z必然包含xm(yn)。由於子序列不會改變元素順序關系,必然有zk = xm = yn

2. xm ≠ yn且zk ≠ xm => Z是X(m-1)和Y的最長公共子序列;
-- zk ≠ xm意味着xm(X最后一個元素)不是Z的最后一個元素,也就是說Z不包含xm,因為如果Z包含xm,必有xm是Z最后一個元素(根據子序列不會改變元素順序特點)。這也就是說,Z也是X(m-1)和Y的最長子序列。
而xm ≠ yn是zk ≠ xm的必要條件,因為根據1,如果xm = yn,必有zk = xm。

3. xm ≠ yn且zk ≠ yn => Z是X和Y(n-1)的最長公共子序列;
-- 道理同2

遞歸結構

LCS問題的最優子結構,找的是問題規模為n時與子問題規模為n-1時的關系,根據n-1子問題最優解推導出n問題最優解。注意最優子結構問題規模不一定總是n-1,有可能是n-2,n/2,或者別的數,總之是要比母問題規模n小。

用c[i][j]表示序列Xi和Yj的LCS長度,其中Xi = {x1,x2,...,xi},Yj = {y1,y2,...,yj}。
當i=0或j=0時,Z=∅是Xi、Yi的LCS => c[i][j] = 0。其他情況,遞歸式:

\[c[i][j]= \begin{cases} 0\quad &i=0或j=0\\ c[i-1][j-1] + 1\quad &i,j>0; x_i=y_j\\ max(c[i][j-1], c[i-1[j])\quad &i,j>0; x_i≠y_j\\ \end{cases} \]

計算最優值

這里最優值指的是c[i][j]最大值,即LCS的最大值,而非最優解(LCS本身),后面利用最優值要構造最優解。
時間復雜度O(mn)

針對LCS問題的類Lcs示例代碼,需要注意的是例子中只用到x[1..m], y[1..n]用於存儲數據,而x[0], y[0]用做占位(示例放了空格字符,其值無實際含義),表示X、Y子序列為空的情況。
變量解釋:
x[i] 表示序列X的第i個元素(i=1..m),而i=0表示空;
y[j] 表示序列Y的第j個元素(j=1..n),而j=0表示空;
c[i][j] 表示序列x[1..i]和序列y[1..j]的Lcs長度。c[0][0] = 0,其實也沒有意義;
b[i][j] 表示得到c[i][j]的過程,值為1時,表示x[i] == y[j];值為2時,表示x[i] ≠ y[j]且c[i-1][j] >= c[i][j-1],這樣c[i][j] = c[i-1][j];值為3時,表示x[i] ≠ y[j]且c[i-1][j] < c[i][j-1],這樣c[i][j] = c[i][j-1];
注釋箭頭符號說明:
↖ 對應b[i][j] = 1,意味着c[i][j]值源於c[i-1][j-1];
↑ 對應b[i][j] = 2,意味着c[i][j]值源於c[i-1][j];
← 對應b[i][j] = 3,意味着c[i][j]值源於c[i][j-1];

/**
 * 動態規划算法求最長公共子序列(LCS)最大值
 * @param x 存放序列X, x[1..m]存放了序列X的有效數據, x[0]數據無實際含義, 占位代表序列為空的情況
 * @param y 存放序列Y, y[1..n]存放了序列Y的有效數據, y[0]數據無實際含義, 占位代表序列為空的情況
 * @param b [out] b[i][j]記錄c[i][j]由哪一個子問題的解得到, 在構造最優解(最長公共子序列 LCS)時, 會用到
 * @return LCS長度
 * @note 時間復雜度O(mn)
 */
int Lcs::lcsLength(string &x, string &y, int **b) {
    if (!b || !b[0]) {
        cout << "input memory is a null pointer" << endl;
        return 0;
    }

    int m = x.size() - 1;
    int n = y.size() - 1;
    int res = 0;

    // 申請二維數組c[m + 1][n + 1]
    auto c = new int*[m + 1];
    for (int i = 0; i < m + 1; ++i) {
        c[i] = new int[n + 1];
    }

    // 設置c[i][j]初值
    c[0][0] = 0;
    for (int i = 1; i <= m; ++i) c[i][0] = 0; // j = 0
    for (int j = 1; j <= n; ++j) c[0][j] = 0; // i = 0
    // 利用遞推式求解c[i][j]
    for (int i = 1; i <= m; ++i) {
        for (int j = 1; j <= n; ++j) {
            if (x[i] == y[j]) {
                c[i][j] = c[i - 1][j - 1] + 1;
                b[i][j] = 1;
            }
            else if (c[i - 1][j] >= c[i][j - 1]) {
                c[i][j] = c[i - 1][j];
                b[i][j] = 2;
            }
            else {
                c[i][j] = c[i][j - 1];
                b[i][j] = 3;
            }
        }
    }

    res = c[m][n];

    cout << "c[][]: " << endl;
    printArray(c, m + 1, n + 1);
    cout << "construct c[][] log: " << endl;
    printLog(c, b, m + 1, n + 1);

    cout << "\nb[][]: " << endl;
    printArray(b, m + 1, n + 1);

    // 釋放數組c[][]資源
    for (int i = 0; i < m + 1; ++i) {
        delete c[i];
    }
    delete c;

    return res;
}

構造最優解LCS

lcsLength只是求解出了最優值LCS長度,但並沒有求出最大公共子序列。要求最大公共子序列,可以通過b[m+1][n+1]記錄的由c[1][1]推導出c[m][n]的過程,打印出LCS。

/**
 * 打印LCS
 * @param i c[i][j]所在行位置, c[i][j]代表Xi和Yj的LCS長度
 * @param j c[i][j]所在列位置, c[i][j]代表Xi和Yj的LCS長度
 * @param x 存放序列X
 * @param b[out] b[i][j]記錄c[i][j]由哪一個子問題的解得到
 * @note 必須在用lcsLength得到b[][]之后調用, 根據b[i][j]記錄如何得到c[m][n](LCS長度)的, 反向搜尋路徑, 打印LCS
 * 時間復雜度O(m+n), 因為每次調用是讓i-1或j-1
 */
void Lcs::lcs(int i, int j, string &x, int **b) {
    if (i == 0 || j == 0) return ;
    if (b[i][j] == 1) {
        lcs(i - 1, j - 1, x, b); // ↖
        cout << x[i];
    }
    else if (b[i][j] == 2) {
        lcs(i - 1, j, x, b); // ↑
    }
    else lcs(i, j - 1, x, b); // ←
}

運行結果:

c[][]: 
0 0 0 0 0 0 0 0
0 0 1 1 1 1 1 1
0 0 1 1 1 1 1 2
0 0 1 2 2 2 2 2
0 0 1 2 2 2 2 2
0 0 1 2 2 3 3 3
0 0 1 2 2 3 3 3
0 0 1 2 2 3 3 3
0 0 1 2 3 3 4 4
construct c[][] log: 
0  0  0  0  0  0  0  0 
0  0↑ 1↖ 1← 1← 1← 1← 1←
0  0↑ 1↑ 1↑ 1↑ 1↑ 1↑ 2↖
0  0↑ 1↑ 2↖ 2← 2← 2← 2↑
0  0↑ 1↑ 2↑ 2↑ 2↑ 2↑ 2↑
0  0↑ 1↑ 2↑ 2↑ 3↖ 3← 3←
0  0↑ 1↑ 2↑ 2↑ 3↑ 3↑ 3↑
0  0↑ 1↑ 2↑ 2↑ 3↑ 3↑ 3↑
0  0↑ 1↑ 2↑ 3↖ 3↑ 4↖ 4←

b[][]: 
0 0 0 0 0 0 0 0
0 2 1 3 3 3 3 3
0 2 2 2 2 2 2 1
0 2 2 1 3 3 3 2
0 2 2 2 2 2 2 2
0 2 2 2 2 1 3 3
0 2 2 2 2 2 2 2
0 2 2 2 2 2 2 2
0 2 2 2 1 2 1 3
lcs length = 4
3174

如何理解運行結果,特別是c[][]的構建?
下面用表格方式,解釋c[][]的構建
序列X和Y,其中x[0], y[0]表示∅

構建c[8+1][7+1],初值c[][0] = 0, c[0][i] = 0

當i=1, j=1,X子序列x[1..i]={3}, y[1..i] = {6} => x[i] ≠ y[j] ,加上c[i-1][j]=0 >= c[i][j-1]=0 => c[i][j] = c[i-1][j]=0;
當i=1, j=2, X子序列x[1..i]={3}, y[1..i] = {6,3} => x[i] = y[j] => c[i][j] = c[i-1][j-1] + 1 = 1;
當i=1, j=3, X子序列x[1..i]={3}, y[1..i] = {6,3,1} => x[i] ≠ y[j],加上c[i-1][j]=0 < c[i][j-1]=1 => c[i][j] = c[i][j-1] = 1;
...

核心步驟是先比較子序列最后一個元素xi和yj,如果相等,則c[i][j] = c[i-1][j-1] + 1 = 1(為上一行斜對角元素+1);如果不等,取前一行和前一列元素值較大者。

而打印LCS的過程,是構建c[][]的相反過程,抓住了LCS元素一定來源於xi == yj的情況。利用b[][]從m, n 位置進行回溯到0, 0位置,中間碰到xi==yj,返回時打印;否則,回到上一行或者前一列(具體取決於b[i][j]值)。

附:完整源代碼

// Lcs.h
class Lcs {
public:
    static int lcsLength(std::string &x, std::string &y, int **b); // 求序列X,Y的LCS長度
    static void lcs(int i, int j, std::string &x, int **b); // 求X, Y的LCS
};
// Lcs.cpp
#include "Lcs.h"
#include <iostream>

using namespace std;

void printArray(int **arr, int m, int n) {
    for (int i = 0; i < m; ++i) {
        for (int j = 0; j < n; ++j) {
            cout << arr[i][j];

            if (j < n - 1) cout << " ";
            else cout << endl;
        }
    }
}

void printLog(int **arr, int **b, int m, int n, bool printArrow = true) {
    for (int i = 0; i < m; ++i) {
        for (int j = 0; j < n; ++j) {
            cout << arr[i][j];

            if (printArrow) {
                if (b[i][j] == 1) cout << "↖";
                else if (b[i][j] == 2) cout << "↑";
                else if (b[i][j] == 3) cout << "←";
                else cout << " ";
            }

            if (j < n - 1) cout << " ";
            else cout << endl;
        }
    }
}

/**
 * 動態規划算法求最長公共子序列(LCS)最大值
 * @param x 存放序列X, x[1..m]存放了序列X的有效數據, x[0]數據無實際含義, 占位代表序列為空的情況
 * @param y 存放序列Y, y[1..n]存放了序列Y的有效數據, y[0]數據無實際含義, 占位代表序列為空的情況
 * @param b [out] b[i][j]記錄c[i][j]由哪一個子問題的解得到, 在構造最優解(最長公共子序列 LCS)時, 會用到
 * @return LCS長度
 * @note 時間復雜度O(mn)
 */
int Lcs::lcsLength(string &x, string &y, int **b) {
    if (!b || !b[0]) {
        cout << "input memory is a null pointer" << endl;
        return 0;
    }

    int m = x.size() - 1;
    int n = y.size() - 1;
    int res = 0;

    // 申請二維數組c[m + 1][n + 1]
    auto c = new int*[m + 1];
    for (int i = 0; i < m + 1; ++i) {
        c[i] = new int[n + 1];
    }

    // 設置c[i][j]初值
    c[0][0] = 0;
    for (int i = 1; i <= m; ++i) c[i][0] = 0; // j = 0
    for (int j = 1; j <= n; ++j) c[0][j] = 0; // i = 0
    // 利用遞推式求解c[i][j]
    for (int i = 1; i <= m; ++i) {
        for (int j = 1; j <= n; ++j) {
            if (x[i] == y[j]) {
                c[i][j] = c[i - 1][j - 1] + 1;
                b[i][j] = 1;
            }
            else if (c[i - 1][j] >= c[i][j - 1]) {
                c[i][j] = c[i - 1][j];
                b[i][j] = 2;
            }
            else {
                c[i][j] = c[i][j - 1];
                b[i][j] = 3;
            }
        }
    }

    res = c[m][n];

    cout << "c[][]: " << endl;
    printArray(c, m + 1, n + 1);
    cout << "construct c[][] log: " << endl;
    printLog(c, b, m + 1, n + 1);

    cout << "\nb[][]: " << endl;
    printArray(b, m + 1, n + 1);

    // 釋放數組c[][]資源
    for (int i = 0; i < m + 1; ++i) {
        delete c[i];
    }
    delete c;

    return res;
}

/**
 * 打印LCS
 * @param i c[i][j]所在行位置, c[i][j]代表Xi和Yj的LCS長度
 * @param j c[i][j]所在列位置, c[i][j]代表Xi和Yj的LCS長度
 * @param x 存放序列X
 * @param b[out] b[i][j]記錄c[i][j]由哪一個子問題的解得到
 * @note 必須在用lcsLength得到b[][]之后調用, 根據b[i][j]記錄如何得到c[m][n](LCS長度)的, 反向搜尋路徑, 打印LCS
 * 時間復雜度O(m+n), 因為每次調用是讓i-1或j-1
 */
void Lcs::lcs(int i, int j, string &x, int **b) {
    if (i == 0 || j == 0) return ;
    if (b[i][j] == 1) {
        lcs(i - 1, j - 1, x, b);
        cout << x[i];
    }
    else if (b[i][j] == 2) {
        lcs(i - 1, j, x, b);
    }
    else lcs(i, j - 1, x, b);
}

客戶端測試Lcs功能

// TestLcs.cpp
#include "Lcs.h"
#include <iostream>

using namespace std;

int main() {
    // 注意程序只使用了x[1..m], y[1..n], 對於x[0]和y[0]只用於占位, 表示序列X和Y為空的情況, 其具體值無實際意義, 這里是設為' '(空格字符)
    string x = " 32157954";
    string y = " 6314742";

    int m = x.size() - 1;
    int n = y.size() - 1;

    // 申請b[][]資源並初始化為0
    int **b = new int *[m + 1];
    for (int i = 0; i <= m; ++i) {
        b[i] = new int[n + 1];
    }
    for (int i = 0; i < m + 1; ++i) {
        for (int j = 0; j < n + 1; ++j) {
            b[i][j] = 0;
        }
    }

    int clen = Lcs::lcsLength(x, y, b);
    cout << "lcs length = " << clen << endl;

    Lcs::lcs(m, n, x, b);

    // 釋放b資源
    if (b) {
        for (int i = 0; i < x.size(); ++i) {
            delete b[i];
        }
    }
    return 0;
}

參考

王曉東著《算法設計與分析》
動態規划 最長公共子序列 | CSDN
【動態規划理論】:一篇文章帶你徹底搞懂最優子結構、無后效性和重復子問題 | CSDN


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM