動態規劃——股票系列問題

前言

  • leetcode題解 感謝神犇!!!文章主要用來自己做個筆記,常翻翻看看,啊,是蒟蒻的淚。
  • 一天內只能進行一次買入或賣出,即不能一天內賣了又買/買了又賣,即n天內最多進行 k = n/2 次買賣。
  • 這 6 道股票買賣問題是有共性的,我們通過對第四題(限制最大交易次數爲 k)的分析一道一道解決。因爲第四題是一個最泛化的形式,其他的問題都是這個形式的簡化。最後實際交易次數不一定等於k。
    第一題是至多進行一次交易,相當於 k = 1;第二題是不限交易次數,相當於 k = +infinity;第三題是至多進行 2 次交易,相當於 k = 2;剩下兩道也是不限次數,但是加了交易「冷凍期」和「手續費」的額外條件,其實就是第二題的變種,都很容易處理。
  • 下面所有的代碼中天數都是從0到n-1天。

一、窮舉框架

這裏利用「狀態」進行窮舉。我們具體到每一天,看看總共有幾種可能的「狀態」,再找出每個「狀態」對應的「選擇」。我們要窮舉所有「狀態」,窮舉的目的是根據對應的「選擇」更新狀態

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 還只能在還有剩餘次數的前提下操作。
很複雜,我們現在的目的只是窮舉。這個問題的「狀態」有三個,第一個是天數,第二個是允許交易的最大次數,第三個是當前的持有狀態(即之前說的 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 次交易,最多獲得多少利潤。

二、狀態轉移框架

下圖爲狀態轉移圖

通過這個圖可以很清楚地看到,每種狀態(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

121.Best Time to Buy and Sell Stock

思路一:dp

套用狀態轉移方程,化簡 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])

直接寫出代碼:

#define maxn 100000+1
int n = prices.size();
int dp[maxn][2];//第i天,0(手上無股票/剛買出)/1(手上有股票/剛買入)
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[i-1][0]//此處天數從0開始標的,故最後一天爲n-1

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

#define maxn 100000+1
int n = prices.size();
int dp[maxn][2];
for(int i = 0; i < n; i++){
	if(i == 0){
		dp[i][0] = 0;//第一天不買
		// 解釋:
        //   dp[i][0] 
        // = max(dp[-1][0], dp[-1][1] + prices[i])
        // = max(0, -infinity + prices[i]) = 0
        // 注意 dp[-1][k]的初始化爲負無窮,否則會出錯
		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]);//只能進行一次交易,故是第一次買入,故是-prices[i],而不是dp[i-1][0] - prices[i]
}
return dp[i-1][0]

這樣處理 base case 很麻煩,同時,N過大時,二維數組也開不了很大。注意一下狀態轉移方程,新狀態只和昨天的狀態有關,故不用整個 dp 數組,只需要一個變量儲存昨天的狀態就足夠了,這樣可以把空間複雜度降到 O(1):

int n = prices.size();
int dp_i_0 = 0;// = dp[-1][0] = 0;
int dp_i_1 = INT_MIN;// = dp[-1][1] = INT_MIN   
					 //from climits庫
for(int i = 0; i < n; i++){
	dp_i_0 = max(dp_i_0, dp_i_1 + prices[i]);//括號裏均是代表昨日的狀態
	dp_i_1 = max(dp_1_1, - prices[i]);//賦值後得到今天的狀態
									  //下一個循環裏,代表的又是相對明天的昨天(今天)的狀態
}
return dp_i_0;//最後一天肯定手裏沒股票
思路二:差分 最大連續子數組和

我的第一感覺,我就是真真正正的蒟蒻:
求出差分數組cf[n],再求cf[n]的最大連續子數組和。

int maxProfit(int* prices, int pricesSize){
    int cf[1000001];
    int profit[100001];
    int max_profit = 0;
    for(int i = 1; i < pricesSize; i++){
        cf[i] = prices[i] - prices[i-1];
        profit[i] = profit[i-1] + cf[i];//可去掉cf[]
        if(profit[i]<0){
            profit[i] = 0;
        }
        if(profit[i]>max_profit){
            max_profit = profit[i];
        }
    }
    return max_profit;
}

2. k = +infinity

122.Best Time to Buy and Sell Stock Ⅱ

思路一:dp

如果 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])

O(1)代碼:

int n = prices.size();
int dp_i_0 = 0;
int dp_i_1 = INT_MIN;
for(int i = 0;i < n; i++){
	int temp = dp_i_0;
	dp_i_0 = max(dp_i_0, dp_i_1 + prices[i]);
	dp_i_1 = max(dp_1_1, dp_i_0 - prices[i]);//dp_i_1 = max(dp_1_1, dp_i_0 - prices[i])由於dp_i_0在上一步會改變,不能代表昨日的狀態,故聲明一個temp
}
思路二:差價

較爲巧妙:
由於可以無限次交易,只要差價爲正,就是證明在賺,交易天數拉長一天。差價爲負,賣出,並跳過這些天,直到差價爲正時再買入。

int n = prices.size();
for(int i = 0; i < n; i++){
	long long profit = 0;
	for(int i = 1; i < n; i++){
	    long long temp = prices[i] - prices[i-1];
	    if(temp>0){
	        profit += temp;
	    }
	}
	return profit;
}

3. k = +infinity with cooldown

309.Best Time to Buy and Sell Stock 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 。

聲明變量記錄昨天和前天的狀態,O(1) 代碼:

int n = prices.size();
int dp_i_0 = 0;
int dp_i_1 = INT_MIN;
int dp_pre_0 = 0;
for(int i = 0;i < n; i++){
	int temp = dp_i_0;//temp爲昨日狀態
	dp_i_0 = max(dp_i_0, dp_i_1 + prices[i]);
	dp_i_1 = max(dp_1_1, dp_pre_0 - prices[i]);
	dp_pre_0 = temp;//dp_pre_0記錄了昨日狀態,到了明日,就成了前日的狀態
}

4. k = +infinity with fee

714.Best Time to Buy and Sell Stock with Transaction 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)//解釋:相當於買入股票的價格升高了。
在第一個式子裏減也是一樣的,相當於賣出股票的價格減小了。

O(1)代碼:

int n = prices.size();
int dp_i_0 = 0;
int dp_i_1 = INT_MIN;
for(int i = 0;i < n; i++){
	int temp = dp_i_0;
	dp_i_0 = max(dp_i_0, dp_i_1 + prices[i]);
	dp_i_1 = max(dp_1_1, temp - prices[i] - fee);
}

5. k = 2

123.Best Time to Buy and Sell Stock Ⅲ
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])

窮舉所有的狀態:此處 k 有作用,也要對 k 窮舉:

#define maxn 100000+1
#define maxk 100000+1
int n = prices.size();
int dp[maxn][maxk][2];
for(int i = 0; i < n; i++){
	for(int k = maxk; k >=1; k--){
		if(i == 0){
			dp[i][k][0] = 0;
			dp[i][k][1] = -prices[i];
			continue;
		}
		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 * maxk * 2 個狀態
 //k爲至今至多交易的次數,k==2時,實際交易次數可能是0/1(狀態轉移時有*繼承*的選擇)
return dp[n-1][maxk][0];

由於此題 k 值較小,可以窮舉,不用 for,O(1) 代碼:

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 dp_i_1_0 = 0, dp_i_1_1 = INT_MIN;
int dp_i_2_0 = 0, dp_i_2_1 = INT_MIN;
for (int i = 0; i< prices.size(); i++) {
    dp_i_2_0 = max(dp_i_2_0, dp_i_2_1 + prices[i]);
    dp_i_2_1 = max(dp_i_2_1, dp_i_1_0 - price[i]);
    dp_i_1_0 = max(dp_i_1_0, dp_i_1_1 + price[i]);
    dp_i_1_1 = max(dp_i_1_1, -price[i]);
}
return dp_i_2_0;

6. k = any integer

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

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

int n = prices.size();
if(the_k > n / 2){//買入和賣出不能在一天 故至多有n/2次交易;當k>n/2 相當於k爲無限大,轉爲F題
	long long profit = 0;
	for(int i = 2; i <= n; i++){
	    long long temp = prices[i] - prices[i-1];
	    if(temp > 0){
	        profit += temp;
	    }
	}
	return profit;
}
else{
	for(int i = 0; i < n; i++){
	    for(int k = the_k; k >= 1; k--){//此處k可以遞減也可以遞增,因爲引用的是昨天的狀態,第i天時各種k的狀態之間沒有關係
	        if(i == 1){
	            dp[i][k][0] = 0;
	            dp[i][k][1] = -prices[i];
	            continue;
	        }
	        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][the_k][0];
}

四、總結

  • 狀態、選擇
  • 框架
  • 狀態轉移方程
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章