一個通用方法團滅 6 道股票問題!

轉載自:https://github.com/labuladong/fucking-algorithm/blob/master/%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E7%B3%BB%E5%88%97/%E5%9B%A2%E7%81%AD%E8%82%A1%E7%A5%A8%E9%97%AE%E9%A2%98.md

6 道股票問題

買賣股票的最佳時機
買賣股票的最佳時機 II
買賣股票的最佳時機 III
買賣股票的最佳時機 IV
最佳買賣股票時機含冷凍期
買賣股票的最佳時機含手續費

這 6 道股票買賣問題是有共性的,我們通過對第四題(限制最大交易次數爲 k)的分析一道一道解決。因爲第四題是一個最泛化的形式,其他的問題都是這個形式的簡化。

第一題是隻進行一次交易,相當於 k = 1;第二題是不限交易次數,相當於 k = +infinity(正無窮);第三題是隻進行 2 次交易,相當於 k = 2;剩下兩道也是不限次數,但是加了交易「冷凍期」和「手續費」的額外條件,其實就是第二題的變種,都很容易處理。

一、動態規劃通用框架

利用「狀態」進行窮舉。我們具體到每一天,看看總共有幾種可能的「狀態」,再找出每個「狀態」對應的「選擇」。我們要窮舉所有「狀態」,窮舉的目的是根據對應的「選擇」更新狀態。聽起來抽象,你只要記住「狀態」和「選擇」兩個詞就行,下面實操一下就很容易明白了。

for 狀態1 in 狀態1的所有取值:
    for 狀態2 in 狀態2的所有取值:
        for ...
            dp[狀態1][狀態2][...] = 擇優(選擇1,選擇2...)

比如說這個問題,每天都有三種「選擇」:買入、賣出、無操作,我們用 buy, sell, rest 表示這三種選擇。但問題是,並不是每天都可以任意選擇這三種選擇的,因爲 sell 必須在 buy 之後,buy 必須在 sell 之後。那麼 rest 操作還應該分兩種狀態,一種是 buy 之後的 rest(持有了股票),一種是 sell 之後的 rest(沒有持有股票)。而且別忘了,我們還有交易次數 k 的限制,就是說你 buy 還只能在 k > 0 的前提下操作。

很複雜對吧,不要怕,我們現在的目的只是窮舉,你有再多的狀態,老夫要做的就是一把梭全部列舉出來。這個問題的「狀態」有三個,第一個是天數,第二個是允許交易的最大次數,第三個是當前的持有狀態(即之前說的 rest 的狀態,我們不妨用 1 表示持有,0 表示沒有持有)。然後我們用一個三維數組就可以裝下這幾種狀態的全部組合:

dp[i][k][0 or 1]
0 <= i <= n-1, 1 <= k <= K
n 爲天數,大 K 爲最多交易數
此問題共 n × K × 2 種狀態,全部窮舉就能搞定。

for 0 <= i < n:
    for 1 <= k <= K:
        for s in {0, 1}:
            dp[i][k][s] = max(buy, sell, rest)

而且我們可以用自然語言描述出每一個狀態的含義,比如說 dp[3][2][1] 的含義就是:今天是第三天,我現在手上持有着股票,至今最多進行 2 次交易。再比如 dp[2][3][0] 的含義:今天是第二天,我現在手上沒有持有股票,至今最多進行 3 次交易。很容易理解,對吧?

我們想求的最終答案是 dp[n - 1][K][0],即最後一天,最多允許 K 次交易,最多獲得多少利潤。讀者可能問爲什麼不是 dp[n - 1][K][1]?因爲 [1] 代表手上還持有股票,[0] 表示手上的股票已經賣出去了,很顯然後者得到的利潤一定大於前者。

記住如何解釋「狀態」,一旦你覺得哪裏不好理解,把它翻譯成自然語言就容易理解了。

二、狀態轉移框架

現在,我們完成了「狀態」的窮舉,我們開始思考每種「狀態」有哪些「選擇」,應該如何更新「狀態」。只看「持有狀態」,可以畫個狀態轉移圖。
狀態轉移
通過這個圖可以很清楚地看到,每種狀態(0 和 1)是如何轉移而來的。根據這個圖,我們來寫一下狀態轉移方程:

dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
              max(   選擇 rest  ,           選擇 sell      )

解釋:今天我沒有持有股票,有兩種可能:
要麼是我昨天就沒有持有,然後今天選擇 rest,所以我今天還是沒有持有;
要麼是我昨天持有股票,但是今天我 sell 了,所以我今天沒有持有股票了。

dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
              max(   選擇 rest  ,           選擇 buy         )

解釋:今天我持有着股票,有兩種可能:
要麼我昨天就持有着股票,然後今天選擇 rest,所以我今天還持有着股票;
要麼我昨天本沒有持有,但今天我選擇 buy,所以今天我就持有股票了。

這個解釋應該很清楚了,如果 buy,就要從利潤中減去 prices[i],如果 sell,就要給利潤增加 prices[i]。今天的最大利潤就是這兩種可能選擇中較大的那個。而且注意 k 的限制,我們在選擇 buy 的時候,把 k 減小了 1,很好理解吧,當然你也可以在 sell 的時候減 1,一樣的。

現在,我們已經完成了動態規劃中最困難的一步:狀態轉移方程。如果之前的內容你都可以理解,那麼你已經可以秒殺所有問題了,只要套這個框架就行了。不過還差最後一點點,就是定義 base case,即最簡單的情況。

dp[-1][k][0] = 0
解釋:因爲 i 是從 0 開始的,所以 i = -1 意味着還沒有開始,這時候的利潤當然是 0 。
dp[-1][k][1] = -infinity
解釋:還沒開始的時候,是不可能持有股票的,用負無窮表示這種不可能。
dp[i][0][0] = 0
解釋:因爲 k 是從 1 開始的,所以 k = 0 意味着根本不允許交易,這時候利潤當然是 0 。
dp[i][0][1] = -infinity
解釋:不允許交易的情況下,是不可能持有股票的,用負無窮表示這種不可能。

把上面的狀態轉移方程總結一下:

base case:
dp[-1][k][0] = dp[i][0][0] = 0
dp[-1][k][1] = dp[i][0][1] = -infinity

狀態轉移方程:
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])

讀者可能會問,這個數組索引是 -1 怎麼編程表示出來呢,負無窮怎麼表示呢?這都是細節問題,有很多方法實現。現在完整的框架已經完成,下面開始具體化。

三、秒殺題目

第一題,k = 1

直接套狀態轉移方程,根據 base case,可以做一些化簡:

dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][1][1] + prices[i])
dp[i][1][1] = max(dp[i-1][1][1], dp[i-1][0][0] - prices[i]) 
            = max(dp[i-1][1][1], -prices[i])
解釋:k = 0 的 base case,所以 dp[i-1][0][0] = 0。

現在發現 k 都是 1,不會改變,即 k 對狀態轉移已經沒有影響了。
可以進行進一步化簡去掉所有 k:
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], -prices[i])

直接寫出代碼:

int n = prices.length;
int[][] dp = new int[n][2];
for (int i = 0; i < n; i++) {
    dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
    dp[i][1] = Math.max(dp[i-1][1], -prices[i]);
}
return dp[n - 1][0];

顯然 i = 0dp[i-1] 是不合法的。這是因爲我們沒有對 i 的 base case 進行處理。可以這樣處理:

for (int i = 0; i < n; i++) {
    if (i - 1 == -1) {
        dp[i][0] = 0;
        // 解釋:
        //   dp[i][0] 
        // = max(dp[-1][0], dp[-1][1] + prices[i])
        // = max(0, -infinity + prices[i]) = 0
        dp[i][1] = -prices[i];
        //解釋:
        //   dp[i][1] 
        // = max(dp[-1][1], dp[-1][0] - prices[i])
        // = max(-infinity, 0 - prices[i]) 
        // = -prices[i]
        continue;
    }
    dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i]);
    dp[i][1] = Math.max(dp[i-1][1], -prices[i]);
}
return dp[n - 1][0];

第一題就解決了,但是這樣處理 base case 很麻煩,而且注意一下狀態轉移方程,新狀態只和相鄰的一個狀態有關,其實不用整個 dp 數組,只需要一個變量儲存相鄰的那個狀態就足夠了,這樣可以把空間複雜度降到 O(1):

// k == 1
int maxProfit_k_1(int[] prices) {
    int n = prices.length;
    // base case: dp[-1][0] = 0, dp[-1][1] = -infinity
    int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE;
    for (int i = 0; i < n; i++) {
        // dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
        dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]);
        // dp[i][1] = max(dp[i-1][1], -prices[i])
        dp_i_1 = Math.max(dp_i_1, -prices[i]);
    }
    return dp_i_0;
}

兩種方式都是一樣的,不過這種編程方法簡潔很多。但是如果沒有前面狀態轉移方程的引導,是肯定看不懂的。後續的題目,我主要寫這種空間複雜度 O(1) 的解法。

第二題,k = +infinity

如果 k 爲正無窮,那麼就可以認爲 k 和 k - 1 是一樣的。可以這樣改寫框架:

dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
            = max(dp[i-1][k][1], dp[i-1][k][0] - prices[i])

我們發現數組中的 k 已經不會改變了,也就是說不需要記錄 k 這個狀態了:
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])

直接翻譯成代碼:

int maxProfit_k_inf(int[] prices) {
    int n = prices.length;
    int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE;
    for (int i = 0; i < n; i++) {
        int temp = dp_i_0;
        dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]);
        dp_i_1 = Math.max(dp_i_1, temp - prices[i]);
    }
    return dp_i_0;
}

第三題,k = +infinity with cooldown

每次 sell 之後要等一天才能繼續交易。只要把這個特點融入上一題的狀態轉移方程即可:

dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], dp[i-2][0] - prices[i])
解釋:第 i 天選擇 buy 的時候,要從 i-2 的狀態轉移,而不是 i-1

翻譯成代碼:

int maxProfit_with_cool(int[] prices) {
    int n = prices.length;
    int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE;
    int dp_pre_0 = 0; // 代表 dp[i-2][0]
    for (int i = 0; i < n; i++) {
        int temp = dp_i_0;
        dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]);
        dp_i_1 = Math.max(dp_i_1, dp_pre_0 - prices[i]);
        dp_pre_0 = temp;
    }
    return dp_i_0;
}

第四題,k = +infinity with fee

每次交易要支付手續費,只要把手續費從利潤中減去即可。改寫方程:

dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i] - fee)
解釋:相當於買入股票的價格升高了。
在第一個式子裏減也是一樣的,相當於賣出股票的價格減小了。

直接翻譯成代碼:

int maxProfit_with_fee(int[] prices, int fee) {
    int n = prices.length;
    int dp_i_0 = 0, dp_i_1 = Integer.MIN_VALUE;
    for (int i = 0; i < n; i++) {
        int temp = dp_i_0;
        dp_i_0 = Math.max(dp_i_0, dp_i_1 + prices[i]);
        dp_i_1 = Math.max(dp_i_1, temp - prices[i] - fee);
    }
    return dp_i_0;
}

第五題,k = 2

k = 2 和前面題目的情況稍微不同,因爲上面的情況都和 k 的關係不太大。要麼 k 是正無窮,狀態轉移和 k 沒關係了;要麼 k = 1,跟 k = 0 這個 base case 捱得近,最後也沒有存在感。

這道題 k = 2 和後面要講的 k 是任意正整數的情況中,對 k 的處理就凸顯出來了。我們直接寫代碼,邊寫邊分析原因。

原始的動態轉移方程,沒有可化簡的地方

dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])

按照之前的代碼,我們可能想當然這樣寫代碼(錯誤的):

int k = 2;
int[][][] dp = new int[n][k + 1][2];
for (int i = 0; i < n; i++)
    if (i - 1 == -1) { /* 處理一下 base case*/ }
    dp[i][k][0] = Math.max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]);
    dp[i][k][1] = Math.max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]);
}
return dp[n - 1][k][0];

爲什麼錯誤?我這不是照着狀態轉移方程寫的嗎?

還記得前面總結的「窮舉框架」嗎?就是說我們必須窮舉所有狀態。其實我們之前的解法,都在窮舉所有狀態,只是之前的題目中 k 都被化簡掉了。這道題由於沒有消掉 k 的影響,所以必須要對 k 進行窮舉:

int max_k = 2;
int[][][] dp = new int[n][max_k + 1][2];
for (int i = 0; i < n; i++) {
    for (int k = max_k; k >= 1; k--) {
        if (i - 1 == -1) { /*處理 base case */ }
        dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]);
        dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]);
    }
}
// 窮舉了 n × max_k × 2 個狀態,正確。
return dp[n - 1][max_k][0];

如果你不理解,可以返回第一點「窮舉框架」重新閱讀體會一下。

這裏 k 取值範圍比較小,所以可以不用 for 循環,直接把 k = 1 和 2 的情況手動列舉出來也可以:

dp[i][2][0] = max(dp[i-1][2][0], dp[i-1][2][1] + prices[i])
dp[i][2][1] = max(dp[i-1][2][1], dp[i-1][1][0] - prices[i])
dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][1][1] + prices[i])
dp[i][1][1] = max(dp[i-1][1][1], -prices[i])

最終代碼:

int maxProfit_k_2(int[] prices) {
    int dp_i10 = 0, dp_i11 = Integer.MIN_VALUE;
    int dp_i20 = 0, dp_i21 = Integer.MIN_VALUE;
    for (int price : prices) {
        dp_i20 = Math.max(dp_i20, dp_i21 + price);
        dp_i21 = Math.max(dp_i21, dp_i10 - price);
        dp_i10 = Math.max(dp_i10, dp_i11 + price);
        dp_i11 = Math.max(dp_i11, -price);
    }
    return dp_i20;
}

有狀態轉移方程和含義明確的變量名指導,相信你很容易看懂。其實我們可以故弄玄虛,把上述四個變量換成 a, b, c, d。這樣當別人看到你的代碼時就會一頭霧水,大驚失色,不得不對你肅然起敬。

第六題,k = any integer

有了上一題 k = 2 的鋪墊,這題應該和上一題的第一個解法沒啥區別。但是出現了一個超內存的錯誤,原來是傳入的 k 值會非常大,dp 數組太大了。現在想想,交易次數 k 最多有多大呢?

一次交易由買入和賣出構成,至少需要兩天。所以說有效的限制 k 應該不超過 n/2,如果超過,就沒有約束作用了,相當於 k = +infinity。這種情況是之前解決過的。

直接把之前的代碼重用:

int maxProfit_k_any(int max_k, int[] prices) {
    int n = prices.length;
    if (max_k > n / 2) 
        return maxProfit_k_inf(prices);

    int[][][] dp = new int[n][max_k + 1][2];
    for (int i = 0; i < n; i++) 
        for (int k = max_k; k >= 1; k--) {
            if (i - 1 == -1) { /* 處理 base case */ }
            dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]);
            dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i]);     
        }
    return dp[n - 1][max_k][0];
}

至此,6 道題目通過一個狀態轉移方程全部解決。

四、最後總結

本文給大家講了如何通過狀態轉移的方法解決複雜的問題,用一個狀態轉移方程秒殺了 6 道股票買賣問題,現在想想,其實也不算難對吧?這已經屬於動態規劃問題中較困難的了。

關鍵就在於列舉出所有可能的「狀態」,然後想想怎麼窮舉更新這些「狀態」。一般用一個多維 dp 數組儲存這些狀態,從 base case 開始向後推進,推進到最後的狀態,就是我們想要的答案。想想這個過程,你是不是有點理解「動態規劃」這個名詞的意義了呢?

具體到股票買賣問題,我們發現了三個狀態,使用了一個三維數組,無非還是窮舉 + 更新,不過我們可以說的高大上一點,這叫「三維 DP」,怕不怕?這個大實話一說,立刻顯得你高人一等,名利雙收有沒有。

所以,大家不要被各種高大上的名詞嚇到,再多的困難問題,奇技淫巧,也不過是基本套路的不斷升級組合產生的。只要把住算法的底層原理,即可舉一反三,逐個擊破。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章