以最長公共子序列問題理解動態規劃演算法(DP)

_蓑衣客發表於2020-12-29

一、動態規劃(Dynamic Programming)

動態規劃方法通常用於求解最優化問題。我們希望找到一個解使其取得最優值,而不是所有最優解,可能有多個解都達到最優值。

二、什麼問題適合DP解法

如何判斷一個問題是不是DP問題呢?適合DP求解的最優化問題通常具有以下兩個特徵:

  • 最優子結構

    如果一個問題的最優解包含其子問題的最優解,我們就稱此問題具有最優子結構性質。

    0-1揹包問題(給你一個可裝載重量為W的揹包和N個物品,每個物品有重量和價值兩個屬性。其中第i個物品的重量為wt[i],價值為val[i],現在讓你用這個揹包裝物品,最多能裝的價值是多少?)為例說明什麼是子問題:假設你已經選擇了第i個物品,那麼問題就變為”如何在可裝載重量為W-wt[i]和剩下的N-1個物品的情況下求最多能裝的價值“,這就是原問題的一個子問題。

    我們可以通過以下步驟來判斷一個問題是否具有最優子結構性質:

    ​ 1.先做出一個選擇。做出這次選擇會產生一個或多個待解的子問題。

    ​ 2.假定第1步的選擇是最優解的一個選擇,確定第1步的選擇會產生哪些子問題

    ​ 3.判斷以下結論是否成立:作為構成原問題的最優解的組成部分,每個子問題的解就是它本身的最優解。證明這個結論的最好辦法是反證法,還是以上述的0-1揹包問題為例:如果存在一個選擇方案使得子問題”在可裝載重量為W-wt[i]和剩下的N-1個物品的情況下求最多能裝的價值“的解大於原來的解,那麼用這個選擇方案替換原來的選擇方案,就存在一個整體選擇方案使得原問題”在可裝載重量為WN個物品的情況下求最多能裝的價值“解大於最優解,這就與最優解的定義矛盾了,因此結論成立。

  • 重疊子問題

    如果演算法反覆求解相同的子問題,就稱最優化問題具有重疊子問題性質。

三、DP問題求解思路

下面以力扣的1143題最長公共子序列為例講解DP問題的求解思路。

LCS

一個簡單暴力的演算法是窮舉出兩個字串的所有子序列,但是這種方法複雜度太高,顯然不可行。

如果用DP的思想,就要尋找遞推關係式,只要遞推關係式出來了,寫出程式碼就是很簡單的事了。

首先我們應該分析問題的最優子結構。最長公共子序列(Longest Common Subsequence, LCS)問題的最優子結構:設有兩個字串\(A,B\),其中\(A={a_1,a_2,...,a_m}\),有m個字元;\(B={b_1,b_2,...,b_n}\),有n個字元。\(C\)為字串\(A\)\(B\)的一個LCS,\(C={c_1,c_2,...,c_k}\)有k個字元。那麼,很容易有以下結論:

  • 如果\(a_m=b_n\),則必有\(a_m=b_n=c_k\),且\(Z_{k-1}\)(表示由Z的前k-1個字元組成的字串)是\(A_{m-1}\)\(B_{n-1}\)的一個LCS;
  • 如果\(a_m\ne b_n\)\(a_m\ne c_k\),則表示\(Z\)\(A_{m-1}\)\(B\)的一個LCS;
  • 如果\(a_m\ne b_n\)\(b_n\ne c_k\),則表示\(Z\)\(A\)\(B_{n-1}\)的一個LCS;

那麼,原問題的求解過程就被劃分為三種情況討論,定義函式\(f(i,j)\)為由\(A\)的前\(i\)個字元組成的字串和由\(B\)的前\(j\)個字元組成的字串的LCS長度。基於這三種情況我們可以寫出動態規劃的遞推式:

\[f(i,j)=\begin{cases}0,\quad &若 i=0\quad or\quad j=0 \\ 1+f(i-1,j-1),\quad &若a_i=b_j \\ max(f(i,j-1),f(i-1,j)),\quad &若a_i\ne b_j \end{cases} \]

  • 當$ i=0或者j=0$時,意味著字串為空串,這時返回0;
  • \(a_i=b_j\),也就是最後一個字元相同時,那麼這個字元一定在LCS裡,LCS長度加1,所以返回值為\(1+LCS(A_{i-1},B_{j-1})\)​;
  • \(a_i\ne b_j\),也就是最後一個字元不同時,那麼這兩個字元至少有一個字元不在LCS裡,若\(a_i\)不在LCS,結果為\(LCS(A_{i-1},B_{j})\);若\(b_j\)不在LCS,結果為\(LCS(A_{i},B_{j-1})\);若\(a_i,b_j\)均不在LCS,結果為\(LCS(A_{i-1},B_{j-1})\);因為我們不知道是哪種情況,所以我們返回三種情況的最大值。又因為\(LCS(A_{i-1},B_{j-1})\)的結果一定是不大於\(LCS(A_{i-1},B_{j})\)的,畢竟\(B_{j-1}\)\(B_{j}\)少了一個字元嘛,同樣也是不大於\(LCS(A_{i},B_{j-1})\)的,所以就省去了兩個字元均不在LCS的情況。

1. 一個遞迴解法

基於上述的DP遞推式,我們寫出LCS的一個遞迴解法:

    def longestCommonSubsequence(text1: str, text2: str) -> int:
        len1,len2 = len(text1),len(text2)
        
        # 函式返回text1的前i個字元組成的字串與text2的前j個字元組成的字串的LCS長度
        def dp_core(i, j):
            if i == 0 or j == 0:
                return 0
            if text1[i-1] == text2[j-1]:
                ret = 1 + dp_core(i-1,j-1)
            else:
                ret = max(dp_core(i, j-1), dp_core(i-1, j))
            return ret
        
        return dp_core(len1,len2)

2. 演算法優化之消除重疊子問題

通過觀察上述\(f(i,j)\)遞推式,可以發現,我們在分別求解\(f(i,j-1),f(i-1,j)\)時,有可能都會求\(f(i-1,j-1)\)的值,也就是會重複求解子問題。DP問題裡,發現了一個重疊子問題,就有非常多個子問題,消除子問題是提高DP演算法效率的關鍵點。

使用備忘錄的遞迴方法

怎麼消除子問題呢,我們很容易想到的就是設定一個備忘錄陣列把計算過的值存起來。每次求解之前都先檢查是否計算過,已計算的就直接返回儲存的值。

    def longestCommonSubsequence(text1: str, text2: str) -> int:
    	# 備忘錄,儲存已計算的值
        memo = {}
        len1,len2 = len(text1),len(text2)
		
        # 初始狀態
        for i in range(len1+1):
            memo[(i,0)] = 0
        for j in range(len2+1):
            memo[(0,j)] = 0

        def dp_core(i, j):
            if (i,j) in memo:
                return memo[(i,j)]
            if text1[i-1] == text2[j-1]:
                memo[(i,j)] = 1 + dp_core(i-1,j-1)
            else:
                memo[(i,j)] = max(dp_core(i, j-1), dp_core(i-1, j))
            return memo[(i,j)]

        return dp_core(len1,len2)

自底向上法的非遞迴方法

遞迴的程式碼雖然思路清晰,可讀性較高,但是遞迴函式會有額外的呼叫開銷。遞迴的思想是自頂向下,但是最先返回計算值的子問題卻是最下層的子問題,上層問題的解依賴於下層子問題的解。因此,理解了這個關係,我們可以拋棄遞迴,自底向上地計運算元問題。

對於上邊LCS的\(f(i,j)\)遞推式來說,計算\(f(i,j)\)的值的時候,我們需要先求出\(f(i,j-1),f(i-1,j)\)或者\(f(i-1,j-1)\),其依賴於下層幾個子問題的解。如果知道了這幾個子問題的解,那麼就可以推出\(f(i,j)\)的解。也就是說,我們可以先計算下層子問題的解。

基於自底向上的思想,我們就可以從\(i=0,j=0\)開始計算,一直向上計算到\(i=len(text1),j=len(text2)\)時,就是我們要求的最優解了。

	# 自底向上版本
    def longestCommonSubsequence(text1: str, text2: str) -> int:
        # 記錄最優解的值
        memo = {}
        len1,len2 = len(text1),len(text2)
		# 初始狀態
        for i in range(len1+1):
            memo[(i,0)] = 0
        for j in range(len2+1):
            memo[(0,j)] = 0

        for i in range(1,len1+1):
            for j in range(1,len2+1):
                if text1[i-1] == text2[j-1]:
                    memo[(i,j)] = 1 + memo[(i-1,j-1)]
                else:
                    memo[(i,j)] = max(memo[(i, j-1)], memo[(i-1, j)])

        return memo[(len1,len2)]

通常情況下,如果每個子問題都需要求解一次,自底向上的動態規劃演算法會比帶備忘錄的自頂向下演算法快,因為自底向上演算法沒有遞迴呼叫的開銷。

對於有些DP問題,還可以使用狀態壓縮來優化備忘錄所佔用的空間,有興趣的可以參看這篇文章,這裡略去。

3. 重構最優解

有時候題目不僅讓我們求出最優解的值,還需要重構出最優解。對於LCS問題而言,就是不僅要求出LCS的長度,還要求出這個LCS序列。那麼,我們就需要另外開闢一個空間來記錄我們求解最優解過程中所做的每一個選擇。

在自底向上的非遞迴演算法上加上記錄選擇的程式碼後為:

# 記錄最優解的自底向上版本
def longestCommonSubsequence(text1: str, text2: str) -> int:
    # 記錄最優解的值
    memo = {}
    # 記錄產生最優解時的選擇
    choices = {}
    len1,len2 = len(text1),len(text2)
    # 初始狀態
    for i in range(len1+1):
        memo[(i,0)] = 0
    for j in range(len2+1):
        memo[(0,j)] = 0

    for i in range(1,len1+1):
        for j in range(1,len2+1):
            if text1[i-1] == text2[j-1]:
                memo[(i,j)] = 1 + memo[(i-1,j-1)]
                choices[(i,j)] = 'ij--'
            elif memo[(i, j-1)] >= memo[(i-1, j)]:
                memo[(i, j)] = memo[(i, j-1)]
                choices[(i,j)] = 'j--'
            else:
                memo[(i, j)] = memo[(i-1, j)]
                choices[(i, j)] = 'i--'

    return memo[(len1,len2)], choices

上述程式碼的choices字典就是記錄求解每個子問題最優解時所做的選擇,對於LCS問題來說,記錄的就是每一步字元比較的結果。

我們可以用以下函式來重構並列印出最優解,即最長公共子序列。

'''
choices: 動態規劃演算法求解最優解時每一步的選擇
text1: 原輸入字串
i: 表示字串text1的前i個字元
j: 表示字串text2的前j個字元
'''
def print_LCS(choices, text1, i, j):
    if i == 0 or j == 0:
        return

    if choices[(i,j)] == 'ij--':
        print_LCS(choices, text1, i - 1, j - 1)
        print(text1[i-1]) # 因為字串中第i個字元的索引為i-1
    elif choices[(i,j)] == 'i--':
        print_LCS(choices, text1, i - 1, j)
    else:  # choices[(i, j)] == 'j--'
        print_LCS(choices, text1, i, j - 1)

示例程式碼

s1 = 'abcdefg'
s2 = 'acf'
max_length, choices = longestCommonSubsequence(s1,s2)
print(max_length)
print_LCS(choices, s1, len(s1), len(s2))

上述程式碼執行的結果為:

3
a
c
f

四、總結

DP問題的核心在於找出遞推關係,也稱狀態轉移方程。一般遵循這個思路:

確定基礎狀態,明確狀態(原問題和子問題中會變化的量),做出選擇(導致狀態變化的量),明確備忘錄應記錄的量,寫出遞推關係。

在優化重疊子問題部分,我們分別說明了如何通過備忘錄的遞迴方法和自底向上的非遞迴方法來優化遞迴樹,實際上這兩種方法本質上是一樣的,只是自頂向下和自底向上的求解順序不同。

以下給出力扣上的幾個LCS相關題目:

相關文章