[LeetCode] 651. 四鍵鍵盤 ☆☆☆(動態規划)


動態規划之四鍵鍵盤

描述

假設你有一個特殊的鍵盤,包含以下的按鍵:

key 1:(A):在屏幕上打印一個 A

key 2:(Ctrl-A):選中整個屏幕

key 3:(Ctrl-C):復制選中區域到緩沖區

key 4:(Ctrl-V):將緩沖區內容輸出到上次輸入的結束位置,並顯示在屏幕上

現在,你只可以按鍵N次(使用上述四種按鍵),請問屏幕上最多可以顯示幾個A?

樣例1:

輸入:N=3

輸出:3

解釋:我們最多可以在屏幕上顯示3個A,通過如下順序按鍵:A, A, A

樣例2:

輸入:N=7

輸出:N=9

解釋:我們最多可以在屏幕上顯示9個A,通過如下順序按鍵:A, A, A, Ctrl-A, Ctrl-C, Ctrl-V, Ctrl-V

解析

如何在 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_num 和 copy 最多為多少我們很難計算,復雜度起碼也有 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 的起點(應該看作C-C的位置)。那么 j - 1 、j 操作就應該是 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++) {//j是C-C的位置,j - 2就是j之前的最后按鍵A的位置
            // 全選 & 復制 dp[j-2],連續粘貼 i - j 次
            // 屏幕上共 dp[j - 2] * (i - j + 1) 個 A(包含j位置前的A的總數,所以這里 + 1)
            dp[i] = Math.max(dp[i], dp[j - 2] * (i - j + 1));//j一直小於i,說明 j - 2 的位置,一定是按鍵A,即j - 2位置上表明當前位置上A的最大個數
        }
    }
    // N 次按鍵之后最多有幾個 A?
    return dp[N];
}

這樣,此算法就完成了,時間復雜度 O(N^2),空間復雜度 O(N),這種解法應該是比較高效的了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM