動態規劃方法通常用於求解最優化問題。我們希望找到乙個解使其取得最優值,而不是所有最優解,可能有多個解都達到最優值。
如何判斷乙個問題是不是dp問題呢?適合dp求解的最優化問題通常具有以下兩個特徵:
下面以力扣的1143題最長公共子串行為例講解dp問題的求解思路。
乙個簡單暴力的演算法是窮舉出兩個字串的所有子串行,但是這種方法複雜度太高,顯然不可行。
如果用dp的思想,就要尋找遞推關係式,只要遞推關係式出來了,寫出**就是很簡單的事了。
首先我們應該分析問題的最優子結構。最長公共子串行(longest common subsequence, lcs)問題的最優子結構:設有兩個字串a,ba,b,其中a=a1,a2,...,ama=a1,a2,...,am,有m個字元;b=b1,b2,...,bnb=b1,b2,...,bn,有n個字元。cc為字串aa和bb的乙個lcs,c=c1,c2,...,ckc=c1,c2,...,ck有k個字元。那麼,很容易有以下結論:
那麼,原問題的求解過程就被劃分為三種情況討論,定義函式f(i,j)f(i,j)為由aa的前ii個字元組成的字串和由bb的前jj個字元組成的字串的lcs長度。基於這三種情況我們可以寫出動態規劃的遞推式:
f(i,j)=⎧⎩⎨0,1+f(i−1,j−1),max(f(i,j−1),f(i−1,j)),若i=0orj=0若ai=bj若ai≠bjf(i,j)=
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)f(i,j)的值的時候,我們需要先求出f(i,j−1),f(i−1,j)f(i,j−1),f(i−1,j)或者f(i−1,j−1)f(i−1,j−1),其依賴於下層幾個子問題的解。如果知道了這幾個子問題的解,那麼就可以推出f(i,j)f(i,j)的解。也就是說,我們可以先計算下層子問題的解。
基於自底向上的思想,我們就可以從i=0,j=0i=0,j=0開始計算,一直向上計算到i=len(text1),j=len(text2)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問題,還可以使用狀態壓縮來優化備忘錄所占用的空間,有興趣的可以參看這篇文章,這裡略去。
有時候題目不僅讓我們求出最優解的值,還需要重構出最優解。對於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))
上述**執行的結果為:
3ac
f
dp問題的核心在於找出遞推關係,也稱狀態轉移方程。一般遵循這個思路:
確定基礎狀態,明確狀態(原問題和子問題中會變化的量),做出選擇(導致狀態變化的量),明確備忘錄應記錄的量,寫出遞推關係。
在優化重疊子問題部分,我們分別說明了如何通過備忘錄的遞迴方法和自底向上的非遞迴方法來優化遞迴樹,實際上這兩種方法本質上是一樣的,只是自頂向下和自底向上的求解順序不同。
最長公共子串行問題 最長公共子串問題 動態規劃
longest common subsequence problem 序列x和y,找到z為x和y的最大公共子串行 蠻力列舉 從x的長度為1序列開始列舉,在y中查詢是否有該序列 列舉觀察,長度為x 1的子串行是長度為x的子串行的一部分 存在最優子結構和重疊子問題,適合動態規劃 1 問題結構分析 c i...
最長公共子串與最長公共子串行問題(動態規劃)
公共子串是連續的,而公共子串行不是連續的。x1 abcc x2 acbcc x1和x2的公共子串為 bcc 公共子串行為 abcc 我們來看一下兩個問題的轉移方程 最長公共子串 dp i j 0 if i 0 or j 0 dp i j dp i 1 j 1 1 if x1 i x2 j dp i ...
動態規則 最長公共子串行問題
給定兩個序列 x y 求x和y的乙個最長公共子串行 舉例x y 最長公共子串行為 lsc 分析 最長公共子串行問題具有最優子結構性質 設x y 及它們的最長子序列 z 則1 若 xm yn 則 zk xm yn,且z k 1 是 x m 1 和 y n 1 的最長公共子串行 2 若 xm yn 且 ...