動態規劃之四鍵鍵盤
讀完本文,你可以去力扣拿下如下題目:
-----------
PS:現在這到題好想變成會員題目了?我當時做的時候還是免費的。
四鍵鍵盤問題很有意思,而且可以明顯感受到:對 dp 陣列的不同定義需要完全不同的邏輯,從而產生完全不同的解法。
首先看一下題目:
如何在 N 次敲擊按鈕後得到最多的 A?我們窮舉唄,每次有對於每次按鍵,我們可以窮舉四種可能,很明顯就是一個動態規劃問題。
第一種思路
這種思路會很容易理解,但是效率並不高,我們直接走流程:對於動態規劃問題,首先要明白有哪些「狀態」,有哪些「選擇」。
具體到這個問題,對於每次敲擊按鍵,有哪些「選擇」是很明顯的:4 種,就是題目中提到的四個按鍵,分別是 A
C-A
、C-C
、C-V
(Ctrl
簡寫為 C
)。
接下來,思考一下對於這個問題有哪些「狀態」?或者換句話說,我們需要知道什麼資訊,才能將原問題分解為規模更小的子問題?
你看我這樣定義三個狀態行不行:第一個狀態是剩餘的按鍵次數,用 n
表示;第二個狀態是當前螢幕上字元 A 的數量,用 a_num
表示;第三個狀態是剪下板中字元 A 的數量,用 copy
表示。
如此定義「狀態」,就可以知道 base case:當剩餘次數 n
為 0 時,a_num
就是我們想要的答案。
結合剛才說的 4 種「選擇」,我們可以把這幾種選擇通過狀態轉移表示出來:
dp(n - 1, a_num + 1, copy), # A 解釋:按下 A 鍵,螢幕上加一個字元 同時消耗 1 個運算元 dp(n - 1, a_num + copy, copy), # C-V 解釋:按下 C-V 貼上,剪下板中的字元加入螢幕 同時消耗 1 個運算元 dp(n - 2, a_num, a_num) # C-A C-C 解釋:全選和複製必然是聯合使用的, 剪下板中 A 的數量變為螢幕上 A 的數量 同時消耗 2 個運算元
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。
這樣可以看到問題的規模 n
在不斷減小,肯定可以到達 n = 0
的 base case,所以這個思路是正確的:
def maxA(N: int) -> int: # 對於 (n, a_num, copy) 這個狀態, # 螢幕上能最終最多能有 dp(n, a_num, copy) 個 A def dp(n, a_num, copy): # base case if n <= 0: return a_num; # 幾種選擇全試一遍,選擇最大的結果 return max( dp(n - 1, a_num + 1, copy), # A dp(n - 1, a_num + copy, copy), # C-V dp(n - 2, a_num, a_num) # C-A C-C ) # 可以按 N 次按鍵,螢幕和剪下板裡都還沒有 A return dp(N, 0, 0)
這個解法應該很好理解,因為語義明確。下面就繼續走流程,用備忘錄消除一下重疊子問題:
def maxA(N: int) -> int:
# 備忘錄
memo = dict()
def dp(n, a_num, copy):
if n <= 0: return a_num;
# 避免計算重疊子問題
if (n, a_num, copy) in memo:
return memo[(n, a_num, copy)]
memo[(n, a_num, copy)] = max(
# 幾種選擇還是一樣的
)
return memo[(n, a_num, copy)]
return dp(N, 0, 0)
這樣優化程式碼之後,子問題雖然沒有重複了,但數目仍然很多,在 LeetCode 提交會超時的。
我們嘗試分析一下這個演算法的時間複雜度,就會發現不容易分析。我們可以把這個 dp 函式寫成 dp 陣列:
dp[n][a_num][copy]
# 狀態的總數(時空複雜度)就是這個三維陣列的體積
我們知道變數 n
最多為 N
,但是 a_num
和 copy
最多為多少我們很難計算,複雜度起碼也有 O(N^3) 把。所以這個演算法並不好,複雜度太高,且已經無法優化了。
這也就說明,我們這樣定義「狀態」是不太優秀的,下面我們換一種定義 dp 的思路。
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。
第二種思路
這種思路稍微有點複雜,但是效率高。繼續走流程,「選擇」還是那 4 個,但是這次我們只定義一個「狀態」,也就是剩餘的敲擊次數 n
。
這個演算法基於這樣一個事實,最優按鍵序列一定只有兩種情況:
要麼一直按 A
:A,A,...A(當 N 比較小時)。
要麼是這麼一個形式:A,A,...C-A,C-C,C-V,C-V,...C-V(當 N 比較大時)。
因為字元數量少(N 比較小)時,C-A C-C C-V
這一套操作的代價相對比較高,可能不如一個個按 A
;而當 N 比較大時,後期 C-V
的收穫肯定很大。這種情況下整個操作序列大致是:開頭連按幾個 A
,然後 C-A C-C
組合再接若干 C-V
,然後再 C-A C-C
接著若干 C-V
,迴圈下去。
換句話說,最後一次按鍵要麼是 A
要麼是 C-V
。明確了這一點,可以通過這兩種情況來設計演算法:
int[] dp = new int[N + 1];
// 定義:dp[i] 表示 i 次操作後最多能顯示多少個 A
for (int i = 0; i <= N; i++)
dp[i] = max(
這次按 A 鍵,
這次按 C-V
)
對於「按 A
鍵」這種情況,就是狀態 i - 1
的螢幕上新增了一個 A 而已,很容易得到結果:
// 按 A 鍵,就比上次多一個 A 而已
dp[i] = dp[i - 1] + 1;
但是,如果要按 C-V
,還要考慮之前是在哪裡 C-A C-C
的。
剛才說了,最優的操作序列一定是 C-A C-C
接著若干 C-V
,所以我們用一個變數 j
作為若干 C-V
的起點。那麼 j
之前的 2 個操作就應該是 C-A C-C
了:
public int maxA(int N) {
int[] dp = new int[N + 1];
dp[0] = 0;
for (int i = 1; i <= N; i++) {
// 按 A 鍵
dp[i] = dp[i - 1] + 1;
for (int j = 2; j < i; j++) {
// 全選 & 複製 dp[j-2],連續貼上 i - j 次
// 螢幕上共 dp[j - 2] * (i - j + 1) 個 A
dp[i] = Math.max(dp[i], dp[j - 2] * (i - j + 1));
}
}
// N 次按鍵之後最多有幾個 A?
return dp[N];
}
其中 j
變數減 2 是給 C-A C-C
留下運算元,看個圖就明白了:
這樣,此演算法就完成了,時間複雜度 O(N^2),空間複雜度 O(N),這種解法應該是比較高效的了。
最後總結
動態規劃難就難在尋找狀態轉移,不同的定義可以產生不同的狀態轉移邏輯,雖然最後都能得到正確的結果,但是效率可能有巨大的差異。
回顧第一種解法,重疊子問題已經消除了,但是效率還是低,到底低在哪裡呢?抽象出遞迴框架:
def dp(n, a_num, copy):
dp(n - 1, a_num + 1, copy), # A
dp(n - 1, a_num + copy, copy), # C-V
dp(n - 2, a_num, a_num) # C-A C-C
看這個窮舉邏輯,是有可能出現這樣的操作序列 C-A C-C,C-A C-C...
或者 C-V,C-V,...
。然這種操作序列的結果不是最優的,但是我們並沒有想辦法規避這些情況的發生,從而增加了很多沒必要的子問題計算。
回顧第二種解法,我們稍加思考就能想到,最優的序列應該是這種形式:A,A..C-A,C-C,C-V,C-V..C-A,C-C,C-V..
。
根據這個事實,我們重新定義了狀態,重新尋找了狀態轉移,從邏輯上減少了無效的子問題個數,從而提高了演算法的效率。
_____________
我的 線上電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 演算法倉庫 已經獲得了 70k star,歡迎標星!