從“股票問題”談動態規劃問題的解決思路
總體思路
有過在Leetcode上練習經歷的同學們對股票問題肯定不會感到陌生,動態規劃問題的核心在於尋找狀態轉移方程,對於常規的動態規劃問題,如零錢問題、揹包問題,我們可能會覺得狀態轉移方程找起來並不費勁,但對於股票問題,可能很多同學都覺得狀態轉移方程難找。在我對股票問題進行了反覆研究之後,我發現其實之所以股票系列分析存在這種困難,並不是“轉移方程難找”,而是其具有多個維度的“狀態”,其狀態的複雜性導致我們在沒有處理好狀態的情況下便談不上解決“轉移方程”的問題。
狀態的確定與處理?
首先我們要考慮的是狀態有哪些,具體到本題,共有三個維度的狀態:
- 天數
- 允許交易的最大次數
- 使用者賬戶當前狀態(持有或者未持有股票)
其次是如何處理狀態,其實大家可以細細回想,對於動態規劃問題我們處理狀態,雖然你可能沒有注意,其實使用的是“窮舉”思想,比如說揹包問題中的物品數量和揹包容量。至於怎麼列舉,看下面的虛擬碼你肯定就明白啦!
for 狀態1 in 狀態1的所有取值:
for 狀態2 in 狀態2的所有取值:
for ...
dp[狀態1][狀態2][...] = 擇優(選擇1,選擇2...)
該虛擬碼參考自Leetcode,個人認為這個狀態列舉思路的虛擬碼寫的非常好,但是作者對於股票問題狀態劃分有些複雜。
那具體到本題,我們的狀態框架如下
dp[i][k][0 or 1] //前面說了三個維度,自然dp陣列也是三維的 for(int i = 0;i < n;i++) for(int j = 0;j < k;j++) dp[i][j][0] 取優 dp[i][j][1] 取優//賬戶狀態這個維度只有兩種可能,就直接計算就好啦。
那我們接下里將通過對股票問題的具體例項講解的方式來介紹具體解法,目前剩下的其實就是個狀態轉移方程的事情啦。
各個擊破
k = 1的情況
121. 買賣股票的最佳時機
k = 1是思路很清晰,其實只需要直接一趟遍歷,並且記錄下當前元素之前的最小元素並利用其計算當前天賣出最大收益即可。這個其實感覺不算典型動態規劃。
class Solution { public int maxProfit(int[] prices) { if(prices == null || prices.length == 0) return 0; int min = prices[0],max = 0; for(int i = 1;i < prices.length;i++){ max = Math.max(max,prices[i] - min); min = Math.min(min,prices[i]); } return max; } }
k值不受限
122. 買賣股票的最佳時機 II
列舉框架
按照我們最開始的狀態列舉框架,k不受限即從前向後遍歷(瞭解完全揹包問題的同學肯定熟悉),並且不設定k維度即可(只有天數、賬戶狀態兩個維度)。
狀態轉移方程
當前天未持有,則有兩種情況:
1、昨天就未持有,今天也不買,則為dp[i - 1][0]
2、昨天持有,今天賣出,則為dp[i - 1][1] + prices[i])
綜上,二者取優
dp[i][0] = Math.max(dp[i - 1][0],dp[i - 1][1] + prices[i]);
同樣,當前天持有也是兩種情況:
1、昨天持有,則為dp[i - 1][1]
2、昨天未持有,今天買入dp[i - 1][0] - prices[i]
綜上,二者取優
dp[i][1] = Math.max(dp[i - 1][1],dp[i - 1][0] - prices[i]);
class Solution {
public int maxProfit(int[] prices) {
int[][] dp = new int[prices.length][2];//0為未持有,1為持有
dp[0][0] = 0;
dp[0][1] = 0 - prices[0];
for(int i = 1;i < prices.length;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],dp[i - 1][0] - prices[i]);
}
return dp[prices.length - 1][0];
}
}
k值不受限且包含冷凍期
309. 最佳買賣股票時機含冷凍期
列舉框架
按照我們最開始的狀態列舉框架,k不受限即從前向後遍歷(瞭解完全揹包問題的同學肯定熟悉),並且不設定k維度即可(只有天數、賬戶狀態兩個維度)。
狀態轉移方程
當前天未持有,則有兩種情況:
1、昨天就未持有,今天也不買,則為dp[i - 1][0]
2、昨天持有,今天賣出,則為dp[i - 1][1] + prices[i - 1])
,注意,price從0開始索引
綜上,二者取優
dp[i][0] = Math.max(dp[i - 1][1] + prices[i - 1],dp[i - 1][0]);
同樣,當前天持有也是兩種情況,但由於存在冷凍期,買出的話需要從i-2轉移過來:
1、昨天持有,則為dp[i - 1][1]
2、昨天未持有,今天買入dp[i - 2][0] - prices[i - 1]
綜上,二者取優
dp[i][1] = Math.max(dp[i - 1][1],i - 2 >= 0 ? dp[i - 2][0] - prices[i - 1]: 0 - prices[i - 1])
class Solution {
public int maxProfit(int[] prices) {
int[][] dp = new int[prices.length + 1][2];
dp[0][1] = Integer.MIN_VALUE;
for(int i = 1;i <= prices.length;i++){
dp[i][0] = Math.max(dp[i - 1][1] + prices[i - 1],dp[i - 1][0]);
dp[i][1] = Math.max(dp[i - 1][1],i - 2 >= 0 ? dp[i - 2][0] - prices[i - 1]: 0 - prices[i - 1]);
}
return dp[prices.length][0];
}
}
k值不受限且包含手續費
狀態轉移方程與k值不受限完全相同,只是賣出時要減手續費即可
列舉框架
按照我們最開始的狀態列舉框架,k不受限即從前向後遍歷(瞭解完全揹包問題的同學肯定熟悉),並且不設定k維度即可(只有天數、賬戶狀態兩個維度)。
狀態轉移方程
當前天未持有,則有兩種情況:
1、昨天就未持有,今天也不買,則為dp[i - 1][0]
2、昨天持有,今天賣出,則為dp[i - 1][1] + prices[i] - fee)
綜上,二者取優
dp[i][0] = Math.max(dp[i - 1][0],dp[i - 1][1] + prices[i] - fee);
同樣,當前天持有也是兩種情況:
1、昨天持有,則為dp[i - 1][1]
2、昨天未持有,今天買入dp[i - 1][0] - prices[i]
綜上,二者取優
dp[i][1] = Math.max(dp[i - 1][1],dp[i - 1][0] - prices[i]);
class Solution {
public int maxProfit(int[] prices, int fee) {
int[][] dp = new int[prices.length][2];//0為未持有,1為持有
dp[0][0] = 0;
dp[0][1] = 0 - prices[0];
for(int i = 1;i < prices.length;i++){
dp[i][0] = Math.max(dp[i - 1][0],dp[i - 1][1] + prices[i] - fee);
dp[i][1] = Math.max(dp[i - 1][1],dp[i - 1][0] - prices[i]);
}
return dp[prices.length - 1][0];
}
}
k為任意整數
188. 買賣股票的最佳時機 IV
列舉框架
列舉框架與本文最開始分析的思路完全相同,只需要對天、最大交易次數、賬戶狀態這三個維度進行列舉即可。
狀態轉移方程
當前天未持有,則有兩種情況:
1、昨天就未持有,今天也不買,且顯然這種情況不會增加交易次數,則為dp[i - 1][j][0]
2、昨天持有,今天賣出,賣出操作並不會增加交易次數,仍然是本交易次數維度進行轉移,為dp[i - 1][j][1] + prices[i]
綜上,二者取優
dp[i][j][0] = Math.max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i]);
同樣,當前天持有也是兩種情況:
1、昨天持有,則為dp[i - 1][j][1]
2、昨天未持有,今天買入,注意買入會引起交易次數變化,所以為dp[i - 1][j - 1][0] - prices[i]
綜上,二者取優
dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i]);
class Solution {
public int maxProfit(int k, int[] prices) {
if(prices == null || prices.length < 2)
return 0;
int[][][] dp = new int[prices.length][k + 1][2];//0是未持有,1是持有
for(int i = 0;i <= k;i++){//第一天base case
dp[0][i][1] = 0 - prices[0];
}
for(int i = 1;i < prices.length;i++){
for(int j = 1;j <= k;j++){
dp[i][j][0] = Math.max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i]);
dp[i][j][1] = Math.max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i]);
}
}
return dp[prices.length - 1][k][0];
}
}
總結
看到這裡,其實我們就會明白,動態規劃其實要確定的三部分就是:
- dp語義
- 狀態列舉框架
- 轉移方程
確定了這三樣,一切便都迎刃而解了