動態規劃之四鍵鍵盤

PS:如今這到題好想變成會員題目了?我當時作的時候仍是免費的。git

四鍵鍵盤問題頗有意思,並且能夠明顯感覺到:對 dp 數組的不一樣定義須要徹底不一樣的邏輯,從而產生徹底不一樣的解法。算法

首先看一下題目:數組

如何在 N 次敲擊按鈕後獲得最多的 A?咱們窮舉唄,每次有對於每次按鍵,咱們能夠窮舉四種可能,很明顯就是一個動態規劃問題。app

第一種思路

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

具體到這個問題,對於每次敲擊按鍵,有哪些「選擇」是很明顯的:4 種,就是題目中提到的四個按鍵,分別是 AC-AC-CC-VCtrl 簡寫爲 C)。ide

接下來,思考一下對於這個問題有哪些「狀態」?或者換句話說,咱們須要知道什麼信息,才能將原問題分解爲規模更小的子問題函數

你看我這樣定義三個狀態行不行:第一個狀態是剩餘的按鍵次數,用 n 表示;第二個狀態是當前屏幕上字符 A 的數量,用 a_num 表示;第三個狀態是剪切板中字符 A 的數量,用 copy 表示。優化

如此定義「狀態」,就能夠知道 base case:當剩餘次數 n 爲 0 時,a_num 就是咱們想要的答案。spa

結合剛纔說的 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..

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

相關文章
相關標籤/搜索