Start learning free

LESSON 332 OF 348 · GREEDY

Best Time to Buy and Sell Stock IV in TypeScript: At Most k Trades

Practice O(n) Read the full explanation

Say you have an array for which the i-th 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).

Example 1:

Input: [2,4,1], k = 2
Output: 2
Explanation: Buy on day 1 (price = 2) and sell on day 2 (price = 4), profit = 4-2 = 2.

Example 2:

Input: [3,2,6,5,0,3], k = 2
Output: 7
Explanation: Buy on day 2 (price = 2) and sell on day 3 (price = 6), profit = 6-2 = 4.
             Then buy on day 5 (price = 0) and sell on day 6 (price = 3), profit = 3-0 = 3.
STEP 1 OF 28
    Ctrl + Enter to check

    TYPESCRIPT · GREEDY

    Best Time To Buy Stock in TypeScript, explained

    Problem overview

    The method maxProfit receives prices, where prices[i] is the price of a stock on day i, and an integer k. You may complete at most k transactions, a transaction being one buy followed by one sell. You can't hold two shares at once, so each sale must happen before the next purchase. The method returns the largest total profit.

    For prices = [3, 2, 6, 5, 0, 3] and k = 2, the answer is 7. Buy at 2 and sell at 6 for a profit of 4, then buy at 0 and sell at 3 for a profit of 3. With prices = [2, 4, 1] and k = 2, only one trade pays off (buy at 2, sell at 4), so the answer is 2.

    Approach: find the trades, then pick the k best

    The usual solution to this problem is dynamic programming over days and trades, which costs O(n * k). This lesson uses a different idea that doesn't depend on k until the very end, and it also relates to the single-trade and unlimited-trade versions of the problem.

    The chart is a series of dips and rises. Every optimal trade buys at the bottom of a dip and sells at the top of the following rise, so the first job is to extract those [bottom, top] price pairs.

    The pairs are not yet independent trades, though. Take [1, 4, 2, 7]. The pairs are [1, 4] and [2, 7]. With two trades you take both, for 3 + 5 = 8. With a single trade, the best choice is buy at 1 and sell at 7, for 6, which merges the two pairs into one. The merged trade earns 6, and the profit given up by merging, 4 - 2 = 2, is the extra amount a second trade can add back: 6 + 2 = 8.

    The stack-based pass turns the list of pairs into a list of candidate profits that behave as independent trades. Merging two overlapping intervals (b1, t1) and (b2, t2) into (b1, t2) always comes with a second candidate, t1 - b2, so that taking both candidates equals the two separate trades. Once the candidates are independent, the best answer for k trades is simply the sum of the k largest candidates.

    Step by step

    The lesson builds two helpers first and then maxProfit itself.

    1. Extract the bottoms and tops in findBottomTopPrices. Declare n and i, an empty bottomTopPrices, and an outer loop that runs while i < n.
    2. Slide bottom down the dip. It starts at i and moves right while the price is not lower than the next one. Check bottom + 1 < n first, so the comparison never reads past the end.
    3. Slide top up the rise. It starts at bottom and moves right while the price is not higher than the next one, with the same bounds check first.
    4. Store the pair and move on. Push [prices[bottom], prices[top]], set i = top + 1, and return the list after the loop.
    5. Turn the pairs into candidate profits in computeProfits. Keep a stack of intervals still open to merging and a profits array.
    6. Loop over the pairs with for...of, destructuring each into bottom and top. They are declared with let, since bottom can change during a merge.
    7. Settle intervals that can't grow. While the stack is non-empty and the new bottom is lower than the stacked interval's bottom, the stacked interval can never be extended further. Record its profit and pop it.
    8. Merge intervals the new one covers. While the stack is non-empty and top is at least the stacked top, record stackedTop - bottom as an extra candidate, take over the stacked interval's lower bottom, and pop it.
    9. Push [bottom, top] so later intervals can still merge with it.
    10. Drain the stack. After the loop, every remaining interval contributes its own profit.
    11. Finish in maxProfit. Call the two helpers through this, cap k at the number of candidates with Math.min, sort the profits in decreasing order and add the first k.

    The solution in TypeScript

    class Solution {
    
        computeProfits(bottomTopPrices: number[][]): number[] {
            let stack: number[][] = [];
            let profits: number[] = [];
            for (let [bottom, top] of bottomTopPrices) {
                while (stack.length && bottom < stack[stack.length - 1][0]) {
                    profits.push(stack[stack.length - 1][1] - stack[stack.length - 1][0]);
                    stack.pop();
                }
                while (stack.length && top >= stack[stack.length - 1][1]) {
                    profits.push(stack[stack.length - 1][1] - bottom);
                    bottom = stack[stack.length - 1][0];
                    stack.pop();
                }
                stack.push([bottom, top]);
            }
            while (stack.length) {
                profits.push(stack[stack.length - 1][1] - stack[stack.length - 1][0]);
                stack.pop();
            }
            return profits;
        }
    
        findBottomTopPrices(prices: number[]): number[][] {
            let n = prices.length, i = 0;
            let bottomTopPrices: number[][] = [];
            while (i < n) {
                let bottom = i;
                while (bottom + 1 < n && prices[bottom] >= prices[bottom + 1]) {
                    ++bottom;
                }
                let top = bottom;
                while (top + 1 < n && prices[top] <= prices[top + 1]) {
                    ++top;
                }
                bottomTopPrices.push([prices[bottom], prices[top]]);
                i = top + 1;
            }
            return bottomTopPrices;
        }
    
        maxProfit(prices: number[], k: number): number {
            let bottomTopPrices = this.findBottomTopPrices(prices);
            let profits = this.computeProfits(bottomTopPrices);
            k = Math.min(k, profits.length);
            profits.sort((first, second) => second - first);
            let result = 0;
            for (let i = 0; i < k; ++i) {
                result += profits[i];
            }
            return result;
        }
    }
    

    Here is what the helpers return on the two examples:

    prices [2,4,1], k = 2
    findBottomTopPrices -> [[2,4],[1,1]]
    computeProfits      -> [2,0]
    maxProfit           -> 2
    
    prices [3,2,6,5,0,3], k = 2
    findBottomTopPrices -> [[2,6],[0,3]]
    computeProfits      -> [4,3]
    maxProfit           -> 7
    

    TypeScript details worth a look:

    • Nested array types. number[][] is an array of number arrays. Writing let stack: number[][] = []; matters: without the annotation, an empty array literal may be inferred as any[] and the compiler would not check what you push.
    • Destructuring in for...of. for (let [bottom, top] of bottomTopPrices) unpacks each two-element array into two variables. Because they are declared with let, the assignment bottom = ... is allowed.
    • Truthy stack.length. A length of 0 is falsy, so stack.length && ... stops when the stack is empty. Because && short-circuits, the check is also what keeps stack[stack.length - 1] from being read on an empty array, where it would give undefined and then fail on [0].
    • Sorting needs a comparator. With no comparator, sort() compares elements as strings, so [10, 9, 1].sort() gives [1, 10, 9]. The comparator second - first sorts numbers in decreasing order.
    • this in methods. The helpers are methods of the class, so they are called as this.findBottomTopPrices(prices).
    • Reassigning a parameter. k = Math.min(k, profits.length) changes only the local variable k, not the caller's value.

    Complexity

    Let n be the number of days.

    Time: O(n log n). Extracting the pairs is one pass, since i only moves forward. Each pair is pushed onto the stack once and popped at most once, so computeProfits is linear. The sort of the candidates costs O(n log n) and dominates. A selection algorithm that finds the k largest values would bring the total down to O(n).

    Space: O(n). The pairs, the stack and the profits each hold at most one entry per dip-and-rise.

    Edge cases and common mistakes

    • No prices. An empty array gives no pairs and no profits, so the result is 0.
    • Prices that only fall. [5, 4, 3] produces the single pair [3, 3] with profit 0, and the answer is 0. Taking zero-profit candidates never hurts.
    • Prices that only rise. [1, 2, 3, 4, 5] is a single pair [1, 5], so the answer is 4 for any k from 1 up.
    • k larger than the number of candidates. This is what Math.min is for. Without it, the loop would add undefined from outside the array, and the sum would become NaN.
    • Merging when only one trade is allowed. For [1, 4, 2, 7] and k = 1 the answer is 6 (buy at 1, sell at 7), not 5, which is the best single pair. With k = 2 it is 8.
    • Using > instead of >= in the bottom scan. Equal prices in a row would stop the slide too early, leaving a bottom that is not the end of the dip.
    • Forgetting the comparator. The profits are small integers here, so a default sort can look right on small tests and still go wrong once a profit of 10 or more meets a smaller one.

    Testing

    • [2, 4, 1] with k = 2 gives 2.
    • [3, 2, 6, 5, 0, 3] with k = 2 gives 7, and with k = 1 gives 4.
    • [1, 4, 2, 7] with k = 1 gives 6, and with k = 2 gives 8.
    • [5, 4, 3] with k = 2 gives 0.
    • [1, 5, 2, 6, 3, 7] with k = 2 gives 9.
    Keep going with TypeScriptAbout 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free