描述
假設你有一個特殊的鍵盤,包含以下的按鍵:
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 種,就是題目中提到的四個按鍵,分別是 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 個操作數
這樣可以看到問題的規模 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),這種解法應該是比較高效的了。