LeetCode《買賣股票的最佳時機》系列題目,最詳解

迷失技術de小豬 發表於 2021-09-01
LeetCode

hello,我是 Johngo!

股市一點紅,股市一點綠!

激動的心,顫抖的手,無法控制的身子骨!

今天聊一聊股市,股價相關的問題,關係到身家的漲跌。

怎麼用「動態規劃」的思想去獲得股市中最大利潤。(LeetCode中的股價問題 手動狗頭)!

說在前面

這周總算是把和大家一起刷題的「動態規劃」的題目搞的差不多了。

在上一次的動態規劃總結中已經把基本的解題方法四步驟以及無後效性解釋的很清楚了。

而動態規劃的題目中,有一類「股票」問題,很值得大家進一步分析、學習、研究。

本文就來詳細說說《買賣股票的最佳時機》系列題目,看看如果我們如果在知道未來幾天的股價,怎麼樣買賣能夠達到最大的利潤(有人說了,這不胡扯嗎?不胡扯!股價預測、動態規劃都用起來)。

《買賣股票的最佳時機》系列題目,最主要的是四個題目:分別是 LeetCode 對應的 121、122、123 和 188。

引例:只能交易一次

首先用 LeetCode121《買賣股票的最佳時機》引出。

給定一個陣列 prices ,它的第 i 個元素 prices[i] 表示一支給定股票第 i 天的價格。

你只能選擇 某一天 買入這隻股票,並選擇在 未來的某一個不同的日子 賣出該股票。設計一個演算法來計算你所能獲取的最大利潤。

返回你可以從這筆交易中獲取的最大利潤。如果你不能獲取任何利潤,返回 0 。

因為在前後購買的時間點是沒有固定的,所以可以考慮「動態規劃」的思路來解決。暴力方法就不解釋了。

題目中強調只能在 某一天 買入這隻股票,並選擇在 未來的某一個不同的日子 賣出該股票,因此,買和賣發生在不同天,不能再同一天完成。這就涉及到在某一天是否持股。

另外,由於需要求得最大的利潤,所以,定義動態陣列 dp 用來表示當天手中的最大利潤。

注意:dp 陣列表示某一天結束的時候,手中的利潤。(利潤指的是手中的現金數,不算已買股票的價值)。

下面按照之前說的四步走的方法進行解題。

一、動態陣列定義

dp[i][j],表示持股情況為i,第 j天結束,情況下,手中的最大利潤。

i代表是否持股,i=0不持股,i=1持股

j代表第j

所以,dp 是二維陣列,並且是 2 行 j 列的二維陣列。

舉例:股票每天的價格 prices = [7, 1, 5, 3, 6, 4]

LeetCode《買賣股票的最佳時機》系列題目,最詳解

二、狀態轉移方程

dp[0][j]:表示該天不持股

很容易想到,如果今天不持股,那麼昨天可能持股也可能不持股。分別討論:

① 今天不持股,並且在昨天也不持股的情況下,手中的利潤是不變的:

dp[0][j] = dp[0][j-1]

② 今天不持股,而在昨天持股的情況下,手中的利潤一定是增加的(賣掉股票):

dp[0][j] = dp[1][j-1] + prices[j]

所以,今天的最大價值是:dp[0][j] = max(dp[0][j-1], dp[1][j-1] + prices[j])

dp[1][j]:表示該天持股

① 今天持股,並且在昨天也持股的情況下,手中的利潤是不變的:

dp[1][j] = dp[1][j-1]

② 今天持股,而在昨天不持股的情況下,手中的利潤一定是減少的,因為進行了買操作

另外,由於本題規定只發生買賣一次,所以,在發生買操作的時候,直接就是減去當天的股價。

dp[1][j] = -prices[j]

所以,今天的最大價值是:dp[1][j] = max(dp[1][j-1], -prices[j])

三、初始化

如果第 0 天不發生買入操作:dp[0][0] = 0

如果第 0 天發生了買入操作:dp[1][0] = -prices[0]

下面,用一個長圖進行一步一步把上述的二維陣列填滿:

LeetCode《買賣股票的最佳時機》系列題目,最詳解
LeetCode《買賣股票的最佳時機》系列題目,最詳解

因為要取最優利潤值。所以,賣掉股票後才能有最大利潤,除非,一次都沒有交易。

故:max_profit = dp[0][-1]

看下核心程式碼:

def maxProfit(self, prices):
    size = len(prices)
    if size == 0 or size == 1:
        return 0
    # 定義動態陣列
    dp = [[0 for _ in range(size)] for _ in range(2)]
    # 初始化動態陣列
    dp[0][0] = 0
    dp[1][0] = -prices[0]
    # 動態方程
    for j in range(1, size):
        dp[0][j] = max(dp[0][j - 1], dp[1][j - 1] + prices[j])
        dp[1][j] = max(dp[1][j - 1], -prices[j])
    return dp[0][-1]

是不是看完圖中描述和程式碼後,這個題目的思路就很明顯並且很通暢了。

彆著急,我們們再看看優化項,除了思路的清晰通暢,看了下面的優化點思路會更加覺得優秀!(呃。。。)

四、優化

在進行每一步計算的過程中可以發現,在每一天的計算中,只與前一天的計算結果有關係,與再之前的資料是沒有關係的。

比如,在計算第 3 天的利潤時,只與第 2 天的兩個狀態下的值有關係。

所以,只需要保留兩個變數就可以將空間方面進行優化。可以動手按照上述思路畫一下,很清晰的思路就出來了。

空間方面的優化程式碼:

def maxProfit_opt(self, prices):
    size = len(prices)
    if size == 0 or size == 1:
        return 0
    dp1 = 0
    dp2 = -prices[0]
    for j in range(1, size):
        tmp1 = max(dp1, dp2+prices[j])
        tmp2 = max(dp2, -prices[j])
        dp1, dp2 = tmp1, tmp2
    return dp1

無限制買賣

上面 LeetCode121 題目限制了在給定的範圍內,只能進行一次買賣。

下面的 LeetCode122 無限制進行買賣,進行求解最大利潤。

給定一個陣列 prices ,其中 prices[i] 是一支給定股票第 i 天的價格。

設計一個演算法來計算你所能獲取的最大利潤。你可以儘可能地完成更多的交易(多次買賣一支股票)。

注意:你不能同時參與多筆交易(你必須在再次購買前出售掉之前的股票)。

近乎同樣的解決邏輯,只是在進行買入股票的時候需要考慮之前的利潤狀態,上一個題目買入股票不需要考慮之前的利潤狀態,因為只進行一次買賣。

還是詳細的來說說,每個步驟具體怎麼狀態轉移。

一、動態陣列定義

dp[i][j],表示持股情況為i,第 j天結束,情況下,手中的最大利潤。

i代表是否持股,i=0不持股,i=1持股

j代表第j

所以,dp 是依然是二維陣列,並且是 2 行 j 列的二維陣列。

舉例:股票每天的價格 prices = [7, 1, 5, 3, 6, 4]
LeetCode《買賣股票的最佳時機》系列題目,最詳解

二、狀態轉移方程

dp[0][j]:表示該天不持股

很容易想到,如果今天不持股,那麼昨天可能持股也可能不持股。分別討論:

① 今天不持股,並且在昨天也不持股的情況下,手中的利潤是不變的:

dp[0][j] = dp[0][j-1]

② 今天不持股,而在昨天持股的情況下,手中的利潤一定是增加的:

dp[0][j] = dp[1][j-1] + prices[j]

今天的最大價值是:dp[0][j] = max(dp[0][j-1], dp[1][j-1] + prices[j])

dp[1][j]:表示該天持股

① 今天持股,並且在昨天也持股的情況下,手中的利潤是不變的:

dp[1][j] = dp[1][j-1]

② 今天持股,而在昨天不持股的情況下,手中的利潤一定是減少的,因為進行了買操作

因為無限次買賣。所以,在發生買操作的時候,需要將之前的利潤狀態減去當天的股價。

dp[1][j] = dp[0][j-1] - prices[j]

所以,今天的最大價值是:dp[1][j] = max(dp[1][j-1], dp[0][j-1] - prices[j])

三、初始化

如果第 0 天不發生買入操作:dp[0][0] = 0

如果第 0 天發生買入操作:dp[1][0] = -prices[0]

下面,依然用一個長圖進行一步一步把上述的二維陣列填滿:
LeetCode《買賣股票的最佳時機》系列題目,最詳解
LeetCode《買賣股票的最佳時機》系列題目,最詳解

最後拿到 dp[0]的最後一個元素就是最大利潤值,因為不持股手中的利潤就是多的情況。

即:max_profit = dp[0][-1]

核心程式碼:

def maxProfit(self, prices):
    size = len(prices)
    if size == 0 or size == 1:
        return 0
    # 定義動態陣列
    dp = [[0 for _ in range(size)] for _ in range(2)]
    # 初始化動態陣列
    dp[0][0] = 0
    dp[1][0] = -prices[0]
    # 動態方程
    for j in range(1, size):
        dp[0][j] = max(dp[0][j - 1], dp[1][j - 1] + prices[j])
        dp[1][j] = max(dp[1][j - 1], dp[0][j - 1] - prices[j])
    print(dp)
    return dp[0][-1]

四、優化

同樣的優化方案,還是從空間的角度進行優化。

同樣很顯然的,每一天利潤值計算無論是買股票還是賣股票,都是隻與前一天有關係。

因此,只需要設定兩個值(dp1、dp2)存放持有和不持有股票的最大利潤值,就可以簡化空間計算。

核心程式碼:

def maxProfit_opt(self, prices):

    size = len(prices)
    if size == 0 or size == 1:
        return 0
    # 初始化動態陣列
    dp1 = 0
    dp2 = -prices[0]
    for j in range(1, size):
        tmp1 = max(dp1, dp2 + prices[j])
        tmp2 = max(dp2, dp1 - prices[j])
        dp1, dp2 = tmp1, tmp2
    return dp1

以上,在 LeetCode 中都屬於 easy 模式的題目。

如果說,在一段時間內,只允許交易固定次數的時候,該怎麼做?

比如,在 6 天時間內,允許交易 2 次,求最大利潤?或者交易 k 次,求最大利潤?

交易 2 次,最大利潤?

下面的 LeetCode123 規定只進行 2 次買賣,進行求解最大利潤。

給定一個陣列,它的第 i 個元素是一支給定的股票在第 i 天的價格。

設計一個演算法來計算你所能獲取的最大利潤。你最多可以完成 兩筆 交易。

注意:你不能同時參與多筆交易(你必須在再次購買前出售掉之前的股票)。

還有一點注意,之前的題目強調,同一天不能既買又賣,但是當前的問題沒有強調這一點,是可以在同一天進行買賣的。

思路和之前的題目還是有一點區別的, 建議一定細緻讀每一個字。

下面詳細的來說說,每個步驟具體怎麼狀態轉移。

一、動態陣列定義

dp[i][j],代表進行 i次交易,在第 j 天的時候的最大利潤。

i代表交易次數

j代表天數

舉例:股票每天的價格 prices = [1, 3, 0, 2, 1, 5]

根據案例,定義 dp 陣列為 3 行 6 列。
LeetCode《買賣股票的最佳時機》系列題目,最詳解

二、狀態轉移方程

和之前的有點不一樣

動態方程:dp[i][j]=max{dp[i][j-1], max{prices[i]-prices[n]+dp[i-1][n]}}, n=0,1,…,j-1

看起來很複雜,公式複雜,其實思路還是比較簡單。

不要著急,也不要被嚇退,後面會每一步將上述動態陣列填滿,填滿之後發現真的比較簡單。

三、初始化

dp[0]=0 :如果一直進行 0 次交易。那麼,無論到第幾天,利潤都為 0

dp[1][0]:第 0 天,進行 1 次交易,無論是買、賣還是買+賣都進行,最大利潤必為 0

dp[2][0]:第 0 天,進行 2 次交易,無論是買、賣還是買+賣都進行,最大利潤還為 0

初始化之後,就可以將上述二維陣列填滿,即可清晰看到每一步的計算過程。

【建議檢視高清圖片或者直接到 github 進行讀取】

LeetCode《買賣股票的最佳時機》系列題目,最詳解

注意,以上紅框中也是要取最大值,對應動態方程中的第二個max。下面所有圖示均符合此規律。

LeetCode《買賣股票的最佳時機》系列題目,最詳解

上述步驟中,只填寫了第 i=1 這一行。

最大利潤值,要麼取前一天的資料,要麼就是公式中的計算邏輯。取其最大值。

下面再把第 i=2 這一行填完,大家的思路會更加清晰起來。

LeetCode《買賣股票的最佳時機》系列題目,最詳解

這一頓操作,我簡直差點要放棄了。

就按照上述思路,程式碼下來,發現執行超時,又一次差點放棄解答。

也不可謂難度為困難,而困難點就在這裡。

至此,必須要進行優化,將時間複雜度降低下來。一般來說,動態規劃的題目是將空間複雜度降低,時間複雜度降低的題目相對比較少一點。

四、優化

從上面圖中,很容易發現一點重複計算的部分。

下面就拿出來最後 2 個步驟的其中一些公式對照:

prices[4]-prices[0]+dp[0][0]
prices[4]-prices[1]+dp[0][1]
prices[4]-prices[2]+dp[0][1]
prices[4]-prices[3]+dp[0][1]

prices[5]-prices[0]+dp[0][0]
prices[5]-prices[1]+dp[0][1]
prices[5]-prices[2]+dp[0][1]
prices[5]-prices[3]+dp[0][1]
prices[5]-prices[4]+dp[0][1]

可以明顯看出來,上述被標註的部分又是重複的計算。

最左側一列都是當前的股票價格,也是不變的。

那麼,這個時候就可以使用一個變數max_profit來進行記錄右側被標記部分的最大值。

將之前的動態方程:

dp[i][j]=max{dp[i][j-1], max{prices[i]-prices[n]+dp[i-1][n]}}, n=0,1,…,j-1

改為:

dp[i][j]=max{dp[i][j-1], prices[j]+max_profit}

其中,max_profilt=max{dp[i-1][j]-prices[j], max_profit}

這裡這裡這裡很重要,一定畫圖理解

也許就是該題目難度被設定為“困難”的地方!

看程式碼,很簡單的實現:

def maxProfit(self, prices):
    """
    使用動態規劃解決: 最多完成 2 次交易
    """
    size = len(prices)
    if size == 0:
        return 0
    dp = [[0 for _ in range(size)] for _ in range(3)]
    print(dp)

    for i in range(1, 3):
        # 每一次交易的最大利潤
        max_profit = -prices[0]
        for j in range(1, size):
            dp[i][j] = max(dp[i][j-1], max_profit + prices[j])
            max_profit = max(max_profit, dp[i-1][j] - prices[j])
    print(dp)
    return dp[-1][-1]

這樣就完美解決了!

交易多次,最大利潤?

LeetCode123 規定最多交易 2 次,下面再上升一個難度。

在 LeetCode188. 買賣股票的最佳時機 IV,最多可以完成 k 筆交易,求最大的利潤。

給定一個整數陣列 prices ,它的第 i 個元素 prices[i] 是一支給定的股票在第 i 天的價格。

設計一個演算法來計算你所能獲取的最大利潤。你最多可以完成 k 筆交易。

注意:你不能同時參與多筆交易(你必須在再次購買前出售掉之前的股票)。

如果 LeetCode123 題目理解清楚的話,這個題目很快就可以解決了。

上一題規定最多交易 2 次,改題目最多交易 k 次。即:把 2 次的邏輯換為 k 次邏輯,就可以解決了。

直接看程式碼吧,和上一個題很類似:

def maxProfit(self, k, prices):

    size = len(prices)
    if size == 0:
        return 0
    dp = [[0 for _ in range(size)] for _ in range(k+1)]
    print(dp)

    for i in range(1, k+1):
        # 每一次交易的最大利潤
        max_profit = -prices[0]
        for j in range(1, size):
            dp[i][j] = max(dp[i][j-1], max_profit + prices[j])
            max_profit = max(max_profit, dp[i-1][j] - prices[j])
    print(dp)
    return dp[-1][-1]

看出來不一樣的地方了吧,就是在之前邏輯設定為 2 的地方,在本地改為 k 次即可!

注意:之前是 range(1, 3)代表 1 和 2, range(1,k+1) 代表 1,2,3,...,k

以上!

《買賣股票的最佳時機》系列題目詳細的進行了解釋,後面還有其他的股票相關題目,但基本是基於今天文章的思路進行解決的。

刷題計劃已經進行了有一段時間,需要一起來搞的,加我微信,我拉你進群哈!