買賣股票的最佳時機系列問題

Grey Zeng發表於2022-04-23

作者:Grey

原文地址:買賣股票的最佳時機系列問題

LeetCode 121. 買賣股票的最佳時機

主要思路:因為只有一股可以交易,所以我們可以列舉必須以i位置作為賣出時機的情況下,得到的最大收益是多少。如果我們得到每個i位置的最大收益,那麼最大收益必是所有位置的最大收益的最大值

使用兩個變數:

min變數:表示遍歷到的位置之前的最小值是什麼。

max變數:表示當前收集到必須以i位置賣出的最大收益是多少。

遍歷陣列一遍,在遍歷到i位置的時候,minmax的更新邏輯如下:

min = Math.min(arr[i], min); // 每次遍歷到的arr[i]和全域性min進行比較,看能否重新整理min的值
max = Math.max(arr[i] - min, max); // arr[i] - min 表示必須以i位置賣出時候的最大收益是什麼,和全域性的max值pk的最大值賦予max

遍歷完陣列,返回max的值就是最終答案。完整程式碼見:

public class LeetCode_0121_BestTimeToBuyAndSellStock {
    public int maxProfit(int[] arr) {
        int max = 0;
        int min = arr[0];
        for (int i = 1; i < arr.length; i++) {
            min = Math.min(arr[i], min);
            max = Math.max(arr[i] - min, max);
        }
        return max;
    }
}

LeetCode 122. 買賣股票的最佳時機 II

主要思路:由於可以進行任意次的交易,但是任何時候最多隻能持有一股股票,所以我們可以把股票曲線的所有上升段都抓取到,累加收益就是最大收益。遍歷陣列,遍歷到的位置減去前一個位置的值,如果是正數,就收集,如果是負數,就把本次收益置為0(就等於沒有做這次交易),這樣遍歷一遍陣列,就不會錯過所有的收益。

設定一個變數max,初始為0,用於收集最大收益值,來到i位置,max更新邏輯如下:

max += Math.max((prices[i] - prices[i - 1]), 0);

完整程式碼如下:

public int maxProfit(int[] prices) {
   int max = 0;
   for (int i = 1; i < prices.length; i++) {
        // 把所有上坡都給抓到
        max += Math.max((prices[i] - prices[i - 1]), 0);
   }
   return max;
}

由本題可以簡單得出一個結論:如果陣列元素個數為N,則最多執行N/2次交易就可以抓取所有的上升段的值(極端情況下,當前時刻買,下一個時刻賣,保持這樣的交易一直到最後,執行的交易次數就是N/2

LeetCode 188. 買賣股票的最佳時機 IV

主要思路:

  1. 如果k的值大於等於陣列長度的二分之一,就等於有無限次交易,在這樣的情況下,可以直接用問題二的解法來做。
  2. 如果k的值小於陣列長度的二分之一,就需要單獨考慮了。

在第2種情況下,我們定義

int[][] dp = new int[N][k+1]

其中dp[i][j]表示[0...i]範圍內交易j次獲得的最大收益是多少。如果可以把dp這個二維表填好,那麼返回dp[N-1][k]的值就是題目要的答案。

dp這個二維矩陣中,

第一行的值表示陣列[0..0]範圍內,交易若干次的最大收益,顯然,都是0。

第一列的值表示陣列[0...i]範圍內,交易0次獲得的最大收益,顯然,也都是0。

針對任何一個普遍位置dp[i][j]的值,

我們可以列舉i位置是否參與交易,如果i位置不參與交易,那麼dp[i][j] = dp[i-1][j],如果i位置參與交易,那麼i位置一定是最後一次的賣出時機。

那最後一次買入的時機,可以是如下情況:

最後一次買入的時機在i位置,那麼dp[i][j] = dp[i][j-1] - arr[i] + arr[i]

最後一次買入的時機在i-1位置,那麼dp[i][j] = dp[i-1][j-1] - arr[i-1] + arr[i]

最後一次買入的時機在i-2位置,那麼dp[i][j] = dp[i-2][j-1] - arr[i-2] + arr[i]

...

最後一次買入的時機在0位置,那麼dp[i][j] = dp[0][j-1] - arr[0] + arr[i]

// i位置不參與交易,則dp[i][j]至少是dp[i-1][j]
dp[i][j] = dp[i - 1][j];
for (int m = 0; m <= i; m++) {
    // 列舉每次買入的時機
    dp[i][j] = Math.max(dp[m][j - 1] - arr[m] + arr[i] , dp[i][j]);
}

完整程式碼如下:

public class LeetCode_0188_BestTimeToBuyAndSellStockIV {
    public static int maxProfit(int k, int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int N = arr.length;
        if (k >= N >> 1) {
            return infinityMax(arr);
        }
        int[][] dp = new int[N][k + 1];
        for (int i = 1; i < N; i++) {
            for (int j = 1; j <= k; j++) {
                // i位置不參與交易,則dp[i][j]至少是dp[i-1][j]
                dp[i][j] = dp[i - 1][j];
                for (int m = 0; m <= i; m++) {
                    // 列舉每次買入的時機
                    dp[i][j] = Math.max(dp[m][j - 1] - arr[m] + arr[i], dp[i][j]);
                }
            }
        }
        return dp[N - 1][k];
    }


    public static int infinityMax(int[] arr) {
        int ans = 0;
        for (int i = 1; i < arr.length; i++) {
            ans += Math.max(arr[i] - arr[i - 1], 0);
        }
        return ans;
    }
}


上述程式碼中包含一個列舉行為

dp[i][j] = dp[i - 1][j] + arr[i] - arr[i];
for (int m = 0; m <= i; m++) {
   // 列舉每次買入的時機
   dp[i][j] = Math.max(dp[m][j - 1] - arr[m] + arr[i], dp[i][j]);
}

增加了時間複雜度,我們可以優化這個列舉。

我們可以舉一個具體的例子來說明如何優化,

比如,

當我們求dp[5][3]這個值,我們可以列舉5位置是否參與交易,假設5位置不參與交易,那麼dp[5][3] = dp[4][3],假設5位置參與交易,那麼5位置一定是最後一次的賣出時機。那最後一次買入的時機,可以是如下情況:

最後一次買入的時機在5位置,那麼dp[5][3] = dp[5][2] - arr[5] + arr[5]

最後一次買入的時機在4位置,那麼dp[5][3] = dp[4][2] - arr[4] + arr[5]

最後一次買入的時機在3位置,那麼dp[5][3] = dp[3][2] - arr[3] + arr[5]

最後一次買入的時機在2位置,那麼dp[5][3] = dp[2][2] - arr[2] + arr[5]

最後一次買入的時機在1位置,那麼dp[5][3] = dp[1][2] - arr[1] + arr[5]

最後一次買入的時機在0位置,那麼dp[5][3] = dp[0][2] - arr[0] + arr[5]

我們求dp[4][3]這個值,我們可以列舉4位置是否參與交易,假設4位置不參與交易,那麼dp[4][3] = dp[3][3],假設4位置參與交易,那麼4位置一定是最後一次的賣出時機。那最後一次買入的時機,可以是如下情況:

最後一次買入的時機在4位置,那麼dp[4][3] = dp[4][2] - arr[4] + arr[4]

最後一次買入的時機在3位置,那麼dp[4][3] = dp[3][2] - arr[3] + arr[4]

最後一次買入的時機在2位置,那麼dp[4][3] = dp[2][2] - arr[2] + arr[4]

最後一次買入的時機在1位置,那麼dp[4][3] = dp[1][2] - arr[1] + arr[4]

最後一次買入的時機在0位置,那麼dp[4][3] = dp[0][2] - arr[0] + arr[4]

比較dp[5][3]dp[4][3]的依賴關係,可以得到如下結論:

假設在求dp[4][3]的過程中,以下遞推式的最大值我們可以得到

dp[4][2] - arr[4]

dp[3][2] - arr[3]

dp[2][2] - arr[2]

dp[1][2] - arr[1]

dp[0][2] - arr[0]

我們把以上式子的最大值定義為best,那麼

dp[5][3] = Math.max(dp[4][3],Math.max(dp[5][2] - arr[5] + arr[5], best + arr[5]))

所以dp[5][3]可以由dp[4][3]加速得到,

同理,

dp[4][3]可以通過dp[3][3]加速得到,

dp[3][3]可以通過dp[2][3]加速得到,

dp[2][3]可以通過dp[1][3]加速得到,

dp[1][3]可以很簡單得出,dp[1][3]有如下幾種可能性:

可能性1,1位置完全不參與,則

int p1 = dp[0][3]

可能性2,1位置作為最後一次的賣出時機,買入時機是1位置

int p2 = dp[1][2] + arr[1] - arr[1]

可能性3,1位置作為最後一次的賣出時機,買入時機是0位置

int p3 = dp[0][2] + arr[1] - arr[0]

此時,best的值為

int best = Math.max(p2 - arr[1], p3 - arr[1])

然後通過dp[1][3]加速dp[2][3],通過dp[2][3]加速dp[3][3]......,所以二維dp的填寫方式是按列填,

先填dp[1][0]dp[1][2]一直到dp[1][k],填好第一列;

然後填dp[2][0],dp[2][1]一直到dp[2][k],填好第二列;

...

依次填好每一列,直到填完第N-1列。

列舉行為被優化,優化列舉後的完整程式碼如下:

public class LeetCode_0188_BestTimeToBuyAndSellStockIV {

    public static int maxProfit(int k, int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int N = arr.length;
        if (k >= N >> 1) {
            return infinityMax(arr);
        }
        int[][] dp = new int[N][k + 1];
        for (int j = 1; j <= k; j++) {
            int p1 = dp[0][j];
            int best = Math.max(dp[1][j - 1] - arr[1], dp[0][j - 1] - arr[0]);
            dp[1][j] = Math.max(p1, best + arr[1]);
            for (int i = 2; i < N; i++) {
                p1 = dp[i - 1][j];
                best = Math.max(dp[i][j - 1] - arr[i], best);
                dp[i][j] = Math.max(p1, best + arr[i]);
            }
        }
        return dp[N - 1][k];
    }

    public static int infinityMax(int[] arr) {
        int ans = 0;
        for (int i = 1; i < arr.length; i++) {
            ans += Math.max(arr[i] - arr[i - 1], 0);
        }
        return ans;
    }
}

LeetCode 123. 買賣股票的最佳時機 III

主要思路:上一個問題中,令k=2就是本題的答案。

LeetCode 309. 最佳買賣股票時機含冷凍期

主要思路:因為有了冷凍期,所以每個位置的狀態有如下三種:

  1. 冷凍期

  2. 持有股票

  3. 不持有股票,不在冷凍期

定義三個陣列,分別表示i位置這三種情況下的最大值是多少

// 處於冷凍期
int[] cooldown = new int[N];
// 持有股票
int[] withStock = new int[N];
// 不持有股票,也不處於冷凍期
int[] noStock = new int[N];

顯然有如下結論:

// 0位置需要處於冷凍期,說明0位置買了又賣掉,收益是0
cooldown[0] = 0; 
// 0位置需要持有股票,只有可能在0位置買了一股,這個時候收益為0-arr[0]
withStock[0] = -arr[0];
// 0位置沒有股票,也不在冷凍期,說明在0位置就沒有做任何決策。此時收益也是0
noStock[0] = 0;

針對一個普遍位置i

// 如果i位置要處於冷凍期,那麼前一個位置必須持有股票,且在當前位置賣掉,處於cooldown狀態
cooldown[i] = withStock[i - 1] + arr[i];
// 如果i位置要持有股票,那麼前一個位置可以持有股票,到當前位置不做決策,或者前一個位置沒有股票,當前位置買入一股
withStock[i] = Math.max(withStock[i - 1], noStock[i - 1] - arr[i]);
// 如果i位置沒有股票,那麼前一個位置可能也沒股票,或者前一個位置是冷凍期,到當前位置也沒有進行買入動作
noStock[i] = Math.max(noStock[i - 1], cooldown[i - 1]);

最大收益就是如上三種方式的最大值。完整程式碼見:

public class LeetCode_0309_BestTimeToBuyAndSellStockWithCooldown {
    public static int maxProfit(int[] arr) {
        if (arr.length < 2) {
            return 0;
        }
        int N = arr.length;
        // 處於冷凍期
        int[] cooldown = new int[N];
        // 持有股票
        int[] withStock = new int[N];
        // 不持有股票,也不處於冷凍期
        int[] noStock = new int[N];
        cooldown[0] = 0;
        withStock[0] = -arr[0];
        noStock[0] = 0;
        for (int i = 1; i < arr.length; i++) {
            withStock[i] = Math.max(withStock[i - 1], noStock[i - 1] - arr[i]);
            cooldown[i] = withStock[i - 1] + arr[i];
            noStock[i] = Math.max(noStock[i - 1], cooldown[i - 1]);
        }
        return Math.max(cooldown[N - 1], Math.max(withStock[N - 1], noStock[N - 1]));
    }
}

由於三個陣列有遞推關係,所以可以用三個變數替換三個陣列,做空間壓縮,優化後的程式碼如下:

public class LeetCode_0309_BestTimeToBuyAndSellStockWithCooldown {
   
    // 空間壓縮版本
    public static int maxProfit(int[] arr) {
        if (arr.length < 2) {
            return 0;
        }
        // 處於冷凍期
        int cooldown = 0;
        // 持有股票
        int withStock = -arr[0];
        // 不持有股票,也不處於冷凍期
        int noStock = 0;

        for (int i = 1; i < arr.length; i++) {
            int next1 = Math.max(withStock, noStock - arr[i]);
            int next2 = withStock + arr[i];
            int next3 = Math.max(noStock, cooldown);
            withStock = next1;
            cooldown = next2;
            noStock = next3;
        }
        return Math.max(cooldown, Math.max(withStock, noStock));
    }
}

LeetCode 714. 買賣股票的最佳時機含手續費

主要思路:由於沒有冷凍期,所以在i位置的時候,狀態只有兩種

// withStock[i]表示:i位置有股票的狀態下,最大收益
int[] withStock = new int[arr.length];
// noStock[i]表示:i位置沒有股票的狀態下,最大收益
int[] noStock = new int[arr.length];

針對0位置

// 0位置持有股票,最大收益,只可能是0位置買入一股
withStock[0] = -arr[0];
// 0位置不持有股票,最大收益,只能是0位置不做交易,收益為0,如果0位置做交易,收益就是(0 - arr[i] + arr[i] - fee),顯然小於0
noStock[0] = 0;

針對普遍位置i

// i位置需要有股票,說明i位置的股票可以是i-1位置到現在不交易獲得的,也可以是i-1位置沒有股票,買下當前這一股獲得的
withStock[i] = Math.max(withStock[i - 1], noStock[i - 1] - arr[i]);
// i位置沒有股票,說明i位置的股票可以由i-1位置上有股票的狀態到當前位置賣出一股(含手續費),也可以是沿用上一個位置沒有股票的最大收益
noStock[i] = Math.max(withStock[i - 1] + arr[i] - fee, noStock[i - 1]);

完整程式碼如下:

public class LeetCode_0714_BestTimeToBuyAndSellStockWithTransactionFee {
    public static int maxProfit1(int[] arr, int fee) {
        if (arr.length < 2) {
            return 0;
        }
        int[] withStock = new int[arr.length];
        int[] noStock = new int[arr.length];
        // 持有股票
        withStock[0] = -arr[0];
        // 不持有股票
        noStock[0] = 0;
        for (int i = 1; i < arr.length; i++) {
            withStock[i] = Math.max(withStock[i - 1], noStock[i - 1] - arr[i]);
            noStock[i] = Math.max(withStock[i - 1] + arr[i] - fee, noStock[i - 1]);
        }
        return Math.max(withStock[arr.length - 1], noStock[arr.length - 1]);
    }
}

同樣的,兩個陣列都有遞推關係,可以做空間壓縮,簡化後的程式碼如下:

public class LeetCode_0714_BestTimeToBuyAndSellStockWithTransactionFee {

    public static int maxProfit(int[] arr, int fee) {
        if (arr.length < 2) {
            return 0;
        }
        // 持有股票
        int withStock = -arr[0];
        // 不持有股票
        int noStock = 0;
        for (int i = 1; i < arr.length; i++) {
            int next1 = Math.max(withStock, noStock - arr[i]);
            int next3 = Math.max(withStock + arr[i] - fee, noStock);
            withStock = next1;
            noStock = next3;
        }
        return Math.max(withStock, noStock);
    }
}

更多

演算法和資料結構筆記

參考資料

演算法和資料結構體系班-左程雲

相關文章