1. 程式人生 > >演算法細節系列(13):買賣股票

演算法細節系列(13):買賣股票

買賣股票

詳細程式碼可以fork下Github上leetcode專案,不定期更新。

該系列的題目意思很簡單,但要在規定的時間複雜度內完成演算法頗有難度。它有趣的地方在於它的解決思路。如果上一篇文章是為了破除想當然,那麼這篇文章一定可以用異想天開來總結,我們一定得拎出一些核心的想法來引導演算法。

題目均摘自leetcode,分為以下五題(買賣股票系列)。

  • 121 Best Time to Buy and Sell Stock
  • 122 Best Time to Buy and Sell Stock II
  • 123 Best Time to Buy and Sell Stock III
  • 188 Best Time to Buy and Sell Stock IV
  • 309 Best Time to Buy and Sell Stock with Cooldown

121. Best Time to Buy and Sell Stock

Problem:

Say you have an array for which the ith element is the price of a given stock on day i.

If you were only permitted to complete at most one transaction (ie, buy one and sell one share of the stock), design an algorithm to find the maximum profit.

Example 1:

Input: [7, 1, 5, 3, 6, 4]
Output: 5

max. difference = 6-1 = 5 (not 7-1 = 6, as selling price needs to be larger than buying price)

Example 2:

Input: [7, 6, 4, 3, 1]
Output: 0

In this case, no transaction is done, i.e. max profit = 0.

看到這道題的一瞬間,心裡飄忽忽,心想,還不簡單麼。找個歷史最低點,再找個歷史最高點,求出maxProfit

,呵呵,剛準備敲程式碼發現,不對啊,歷史最高點由歷史最低點決定啊(歷史最高一定出現在歷史最低後頭),那就意味著得遍歷所有歷史最低點,然後尋找後續的歷史最高點來求得maxProfit,那麼最差就得O(n2)的時間複雜度。

顯然這思路就不符合題目對時間的要求。
alt text

思路1

上述問題的解決方案是最低階且暴力的,但我們會發現一個模式,它的每一個歷史最低點都會向後找【所有】歷史最高點去比較(你也可以看作是跟每個向後的歷史值去比較,只要維護一個maxProfit即可),所以它可以逆向思考,也就是說,遍歷到當前點,我們可以向前去比較歷史最低點,不斷更新maxProfit,遍歷結束總能找到正確值。

所以一份簡單的O(n)程式碼就出來了,如下:

    public int maxProfit(int[] prices) {
        int minprice = Integer.MAX_VALUE;
        int maxprofit = 0;
        for (int i =0;i<prices.length;i++){
            if (prices[i] < minprice){
                minprice = prices[i];
            }else if (prices[i]-minprice > maxprofit){
                maxprofit = prices[i] - minprice;
            }
        }
        return maxprofit;
    }

但需要明確一點,之所以可以這樣做,無非兩點,之前的資訊我們可記錄(維護一個最小的minPrice變數),其次遍歷的後向性所帶來的好處,由問題本身決定(股票一定是先買後賣)。

思路2

這是一種思路,帶來了O(n)的解決方案。再來看一種解決方案,核心思想如下:
1. 賣的同時可以瞬間買進。(多個操作可以看成一個操作)
2. 沒錢的情況下,可以看作向別人借錢(記憶)

繼續看另一版本的程式碼:

    public int maxProfit(int[] prices) {

        int buy = Integer.MIN_VALUE;
        int sell = 0;

        for (int price : prices){
            buy = Math.max(buy, -price);
            sell = Math.max(sell, buy + price);
        }

        return sell;
    }

雖然程式碼形式和上一種不太一樣,但它們本質上是一種形式,可以互相轉換。但該程式碼的好處在於它更加親民接地氣。更符合人的認知,buy可以看作是借錢的過程,而max是為了搜尋價格最低的買點,sell是維護了最大的利益,而且很重要的一點它是勢能突破函式,高一點就向上頂一下,非常形象。buy和sell同時操作price,這是另外一個神奇的地方,因為核心思想提到,賣的同時可以買進,如果只存在一個元素,該操作返回的sell還是為0,可以看作無操作,符合邊界條件。

思路3

哈哈,它還有另外一種解法,它的買賣同時更加形象。利用的是勢能不斷增加,突破max就更新max,當價格下降時,勢能降低,但最低不超過0。(sum減到0就停止更新),所以程式碼如下:

public int maxProfit(int[] prices) {
        int sum = 0;
        int max = 0;

        for (int i = 1; i < prices.length;i++){
            sum = Math.max(0, sum += prices[i]-prices[i-1]);
            max = Math.max(max, sum);
        }

        return max;
    }

就從整個prices的價格走勢來看,只要有上升的情況,我們就可以使用sum += prices[i]-prices[i-1]來不斷累計(賣的瞬間可以立馬買進,多個操作的組合可以看成一個操作)。而當價格走勢下降時,處於虧損狀態,sum不斷減小,而不會取負值。(此處是不會影響max的)。所以維持一個sum很關鍵,簡單總結下,它是個變態勢能累積函式(不公平勢能累積),上升趨勢總能被更新,而下降趨勢,下降到0時,不記錄勢能sum中。好處是把上升趨勢的最低點拔高到0勢能點,從而可以不斷更新較大的max。

以上三種思路都能引向正確答案,是不是很神奇。

122. Best Time to Buy and Sell Stock II

Problem:

Say you have an array for which the ith element is the price of a given stock on day i.

Design an algorithm to find the maximum profit. You may complete as many transactions as you like (ie, buy one and sell one share of the stock multiple times). However, you may not engage in multiple transactions at the same time (ie, you must sell the stock before you buy again).

解釋了這麼多,看到這道題你心裡應該有答案了,選擇哪種思路呢?

核心思想:
1. 檢測出上升趨勢
2. 勢能函式直接累加上升趨勢(單調遞增的多個買賣操作可合併)

所以程式碼如下:

    public int maxProfit(int[] prices) {
        int max = 0;
        for (int i = 0; i < prices.length -1; i++){
            if (prices[i+1] > prices[i]) max += prices[i+1] - prices[i];
        }
        return max;
    }

123. Best Time to Buy and Sell Stock III

Problem:

Say you have an array for which the ith element is the price of a given stock on day i.

Design an algorithm to find the maximum profit. You may complete at most two transactions.

Note:

You may not engage in multiple transactions at the same time (ie, you must sell the stock before you buy again).

該題目主要約束了交易次數,最多隻能2次。顯然,以上提到的一些思路是無法擴充套件到該問題上的。如思路1所提到的後向查詢,它本質上認為後續的最高點都是一樣的,所以無法求解。思路3,同樣地,對多斷交易無法區分,它只能處理兩種情況,一段交易和“無數段”交易。

思路2的想法相當獨特,buy被看作“借錢”,而sell則看作是當前的即得利益。借錢的思路讓人映象深刻,因為有了借錢我們就能聯想到原本的財富餘額(本金),無非剛開始本金為0咯,這就說明上一輪的sell可以轉換成下一輪的本金,狀態就出來了。再來看看思路1和思路3,你會發現,它們很難表示既得利益和本金的狀態轉換。所以程式碼如下:

 public int maxProfit(int[] prices) {
        int sell1 = 0, sell2 = 0, buy1 = Integer.MIN_VALUE, buy2 = Integer.MIN_VALUE;
        for (int i = 0; i < prices.length; i++) {
            buy1 = Math.max(buy1, 0-prices[i]);
            sell1 = Math.max(sell1, buy1 + prices[i]);
            buy2 = Math.max(buy2, sell1 - prices[i]);
            sell2 = Math.max(sell2, buy2 + prices[i]);
        }
        return sell2;
    }

非常巧妙,在O(n)內就把問題給解決了,你會問了該思路怎麼才能想到,我只能說神人自知。

簡單說明一下該程式碼為何是正確的。首先看

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

如果把i當作不斷變動的變數的話,你可以總結出max的作用,有下降趨勢的prices[i]總是被更新,所以剛開始的buy1一定能被更新到第一個下降趨勢的最低點,就不再更新了。這就好像讓buy1找到了一個最合適的位置。好,此時再看。

sell1 = Math.max(sell1, buy1 + prices[i]);

同理,當有下降趨勢時,該函式不做任何操作,因為在下降過程,buy1+prices[i] = 0,而上升時,由於buy1不再更新,此時sell1將不斷更新,所以sell1記錄的就是不斷上升的第一段maxProfit

那麼,為什麼直接把buy2 = Math.max(buy2,sell1-prices[i])加在sell1後面就好了呢?它記錄的也是最低點,且剛開始那一段下降和buy1的值幾乎一模一樣,此時的sell1 = 0,所以buy1 = buy2。當第一段下降結束開始上升時,sell1開始不斷增大,而buy1停止了更新,buy2 = buy1 + prices[i] - prices[i] = buy1,所以它也始終不動。而此時的sell2 = buy2 + prices[i] = buy1 + prices[i] = sell1,所以得出第一段的上升和下降,buy1 = buy2, sell1 = sell2。這也就表明了,如果最多隻能交易一次時,返回sell2同樣正確。

第二段的下降,buy1總是【尋找歷史最低點】,所以暫且不去看它,重點關注buy2的變化,因為buy2 = Math.max(buy2, sell1-prices[i]),而我們知道一旦產生利益,sell1 > 0,且第二段的下降總是出現在sell1達到最大值的下一時刻,所以buy2的範圍在(0,sell1],且時刻更新。這也就是說,不管第二階段中的buy1 or sell1如何變化,在sell1剛開始下降的那個時刻,buy2會找到那個時刻後的最低點,此時再上升時,sell2的更新則在原有sell1的基礎上,不斷突破最大值。

那麼你會問了,buy1有沒有可能再次更新,答案是肯定有可能的。但它的更新不會影響在第一個時刻求得的sell1對後續sell2的影響。那它是如何選擇最大的兩次交易呢?很明顯,因為buy1還在時刻變化著,如果它同樣再更新過程再次突破sell1的最高值,那麼我們又選定了一個候選點,此時,又開始一輪sell2的更新,如果同樣地也突破了sell2的峰值,那麼就被更新成了新的兩次交易,依此類推,直到陣列遍歷結束。

的確比較繞,中間還有一些細節沒搞明白,如為何每次按照歷史最低點生成的sell1去更新sell2能夠得到全域性最優解?不求甚解。

188. Best Time to Buy and Sell Stock IV

Problem:

Say you have an array for which the ith element is the price of a given stock on day i.

Design an algorithm to find the maximum profit. You may complete at most k transactions.

Note:

You may not engage in multiple transactions at the same time (ie, you must sell the stock before you buy again).

這次從2次變成了k次,自己做一次就知道了,就按照上述的思路,兩次購買變成多次購買即可,但不幸會TLE,在這裡需要做一些預處理。程式碼如下:

public int maxProfit(int k, int[] prices) {

        if (k == 0) return 0;

        if (k >=  prices.length/2) {
        int maxPro = 0;
        for (int i = 1; i < prices.length; i++) {
            if (prices[i] > prices[i-1])
                maxPro += prices[i] - prices[i-1];
        }
        return maxPro;
    }

        int[] sell = new int[k];
        int[] buy = new int[k];
        Arrays.fill(buy, Integer.MIN_VALUE);

        for (int i = 0; i < prices.length;i++){
            buy[0] = Math.max(buy[0], -prices[i]);
            sell[0] = Math.max(sell[0], buy[0]+prices[i]);
            for (int j = 1; j < k; j++){
                buy[j] = Math.max(sell[j-1]-prices[i], buy[j]);
                sell[j] = Math.max(buy[j]+prices[i], sell[j]);
            }
        }
        return sell[k-1];
    }

注意下開頭的k >= prices.length / 2的判斷,prices最多有prices/2次交易,當k超過次數時,說明它沒有選擇交易的餘地,直接計算所有可能的上升趨勢即可。

309. Best Time to Buy and Sell Stock with Cooldown

Problem:

Say you have an array for which the ith element is the price of a given stock on day i.

Design an algorithm to find the maximum profit. You may complete as many transactions as you like (ie, buy one and sell one share of the stock multiple times) with the following restrictions:

You may not engage in multiple transactions at the same time (ie, you must sell the stock before you buy again).
After you sell your stock, you cannot buy stock on next day. (ie, cooldown 1 day)

Example:

prices = [1, 2, 3, 0, 2]
maxProfit = 3
transactions = [buy, sell, cooldown, buy, sell]

可以多筆交易,但中間至少有一次停頓(不能交易),題目意思很簡單。寫出狀態方程即可。

buy[i]  = max(rest[i-1]-price, buy[i-1]) 
sell[i] = max(buy[i-1]+price, sell[i-1])
rest[i] = max(sell[i-1], buy[i-1], rest[i-1])

程式碼如下:

public int maxProfit(int[] prices) {

        int n = prices.length;

        int[] buy = new int[n+1];
        int[] sell = new int[n+1];
        int[] rest = new int[n+1];

        buy[0] = Integer.MIN_VALUE;
        sell[0] = rest[0] = 0;

        for (int i = 0; i < n; i++) {

            buy[i+1] = Math.max(rest[i] - prices[i], buy[i]);
            sell[i+1] = Math.max(buy[i] + prices[i], sell[i]);
            rest[i+1] = Math.max(sell[i], Math.max(rest[i], buy[i]));
        }

        return sell[n];
    }