動態規劃之四鍵鍵盤

2022-07-04 02:18:09 字數 4406 閱讀 3218

四鍵鍵盤問題很有意思,而且可以明顯感受到:對 dp 陣列的不同定義需要完全不同的邏輯,從而產生完全不同的解法。

首先看一下題目:

如何在 n 次敲擊按鈕後得到最多的 a?我們窮舉唄,每次有對於每次按鍵,我們可以窮舉四種可能,很明顯就是乙個動態規劃問題。

這種思路會很容易理解,但是效率並不高,我們直接走流程:對於動態規劃問題,首先要明白有哪些「狀態」,有哪些「選擇」

具體到這個問題,對於每次敲擊按鍵,有哪些「選擇」是很明顯的:4 種,就是題目中提到的四個按鍵,分別是ac-ac-cc-vctrl簡寫為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 個運算元

這樣可以看到問題的規模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_numcopy最多為多少我們很難計算,複雜度起碼也有 o(n^3) 把。所以這個演算法並不好,複雜度太高,且已經無法優化了。

這也就說明,我們這樣定義「狀態」是不太優秀的,下面我們換一種定義 dp 的思路。

這種思路稍微有點複雜,但是效率高。繼續走流程,「選擇」還是那 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) 

}// 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..

根據這個事實,我們重新定義了狀態,重新尋找了狀態轉移,從邏輯上減少了無效的子問題個數,從而提高了演算法的效率。

lintcode 四鍵鍵盤

假設你有乙個特殊的鍵盤,鍵盤上有如下鍵 鍵1 a 在螢幕上列印乙個 a 鍵2 ctrl a 選擇整個螢幕。鍵3 ctrl c 複製選擇到緩衝區。鍵4 ctrl v 在螢幕上已有的內容後面追加列印緩衝區的內容。現在,你只能按鍵盤上n次 使用以上四個鍵 找出你可以在螢幕上列印的 a 的最大數量 輸入 3...

動態規劃(四)

你是乙個專業的小偷,計畫偷竊沿街的房屋,每間房內都藏有一定的現金。這個地方所有的房屋都圍成一圈,這意味著第乙個房屋和最後乙個房屋是緊挨著的。同時,相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。給定乙個代表每個房屋存放金額的非負整數陣列,計算你在不觸動警報裝...

動態規劃 (四)

最近依舊是看了很多資料,然後找了很多動態規劃的感覺,但覺得動態規劃其實並不簡單,雖然看了題解,但是還是無處下手,最後只能每道題都按照模板套進去,不過這也不失為一種方法,但畢竟只是練習題,希望還是能學到動態規劃的本質,能更多地解決一些問題。可能就是由於剛開始沒有學明白,揹包問題和區間dp的那一章的題現...