arrays - 最大单笔销售利润

标签 arrays algorithm big-o time-complexity

假设我们有一个由 n 个整数组成的数组,表示一天的股票价格。我们想要找到一对 (buyDay, sellDay),其中buyDay ≤ sellDay,这样如果我们在buyDay 买入股票并在sellDay 卖出,我们的利润将最大化。

显然,通过尝试所有可能的(buyDay、sellDay)对并从所有对中取出最好的对,该算法有一个 O(n2) 解决方案。但是,是否有更好的算法,也许是在 O(n) 时间内运行的算法?

最佳答案

我喜欢这个问题。这是一个经典的面试问题,根据你的想法,你最终会得到越来越好的解决方案。当然可以在比 O(n2) 时间更好的时间内做到这一点,我在这里列出了三种不同的方式来思考这个问题。希望这能回答你的问题!

一是分治法。让我们看看我们是否可以通过将输入分成两半,解决每个子数组中的问题,然后将两者组合在一起来解决这个问题。事实证明,我们实际上可以做到这一点,而且可以有效地做到这一点!直觉如下。如果我们只有一天,最好的选择是在当天买入,然后在同一天卖出,没有利润。否则,将数组分成两半。如果我们考虑最佳答案可能是什么,它必须位于以下三个位置之一:

  • 正确的买/卖对完全发生在上半场。
  • 正确的买/卖对完全发生在下半场。
  • 正确的买/卖对发生在两半 - 我们在上半场买入,然后在下半场卖出。

  • 我们可以通过在前半部分和后半部分递归调用我们的算法来获得 (1) 和 (2) 的值。对于选项(3),获得最高利润的方式是在上半年最低点买入,在下半年最高点卖出。我们可以通过对输入进行简单的线性扫描并找到两个值来找到两半中的最小值和最大值。这给了我们一个具有以下递归的算法:
    T(1) <= O(1)
    T(n) <= 2T(n / 2) + O(n)
    

    使用 Master Theorem为了解决递归问题,我们发现这在 O(n lg n) 时间内运行,并将使用 O(lg n) 空间进行递归调用。我们刚刚击败了朴素的 O(n2) 解决方案!

    可是等等!我们可以做得比这更好。请注意,我们在循环中使用 O(n) 项的唯一原因是我们必须扫描整个输入,试图找到每一半的最小值和最大值。由于我们已经在递归地探索每一半,也许我们可以通过让递归返回存储在每一半中的最小值和最大值来做得更好!换句话说,我们的递归传递了三件事:
  • 买入和卖出时间以最大化利润。
  • 范围内的整体最小值。
  • 范围内的最大值。

  • 可以使用直接递归来递归计算这最后两个值,我们可以在递归计算 (1) 的同时运行该递归:
  • 单个元素范围的最大值和最小值就是该元素。
  • 多元素范围的最大值和最小值可以通过将输入分成两半,找到每一半的最大值和最小值,然后取它们各自的最大值和最小值来找到。

  • 如果我们使用这种方法,我们的递推关系现在是
    T(1) <= O(1)
    T(n) <= 2T(n / 2) + O(1)
    

    在这里使用主定理为我们提供了 O(n) 的运行时间和 O(lg n) 空间,这甚至比我们的原始解决方案更好!

    但是等一下 - 我们可以做得比这更好!让我们考虑使用动态规划解决这个问题。这个想法将是考虑如下问题。假设我们在查看前 k 个元素后知道问题的答案。我们可以使用我们对第 (k+1) 个元素的知识,结合我们的初始解决方案,来解决第 (k+1) 个元素的问题吗?如果是这样,我们可以通过解决第一个元素的问题,然后是前两个,然后是前三个,等等,直到我们为前 n 个元素计算它,从而得到一个很好的算法。

    让我们想想如何做到这一点。如果我们只有一个元素,我们已经知道它必须是最佳买入/卖出对。现在假设我们知道前 k 个元素的最佳答案并查看第 (k+1) 个元素。然后,该值可以创建比前 k 个元素更好的解决方案的唯一方法是,前 k 个元素中最小的元素与新元素之间的差异是否大于我们迄今为止计算的最大差异。因此,假设当我们遍历元素时,我们会跟踪两个值 - 到目前为止我们看到的最小值,以及仅使用前 k 个元素可以获得的最大利润。最初,我们目前看到的最小值是第一个元素,最大利润为零。当我们看到一个新元素时,我们首先通过计算我们以目前所见的最低价格买入并以当前价格卖出能赚多少钱来更新我们的最佳利润。如果这比我们迄今为止计算的最优值更好,那么我们将最优解更新为这个新的利润。接下来,我们将目前看到的最小元素更新为当前最小元素和新元素中的最小值。

    因为在每一步我们只做 O(1) 的工作并且我们只访问 n 个元素中的每一个,所以这需要 O(n) 时间来完成!此外,它只使用 O(1) 辅助存储。这和我们目前所得到的一样好!

    例如,根据您的输入,以下是该算法的运行方式。数组的每个值之间的数字对应于算法在该点持有的值。您实际上不会存储所有这些(它需要 O(n) 内存!),但是看到算法的发展很有帮助:
                5        10        4          6         7
    min         5         5        4          4         4    
    best      (5,5)     (5,10)   (5,10)     (5,10)    (5,10)
    

    答案:(5, 10)
                5        10        4          6        12
    min         5         5        4          4         4    
    best      (5,5)     (5,10)   (5,10)     (5,10)    (4,12)
    

    答案:(4, 12)
                1       2       3      4      5
    min         1       1       1      1      1
    best      (1,1)   (1,2)   (1,3)  (1,4)  (1,5)
    

    答案:(1, 5)

    我们现在能做得更好吗?不幸的是,不是在渐近意义上。如果我们使用少于 O(n) 的时间,我们就不能查看大输入的所有数字,因此不能保证我们不会错过最佳答案(我们可以将它“隐藏”在我们需要的元素中)没看)。另外,我们不能使用小于 O(1) 的空间。可能对隐藏在大 O 符号中的常数因子进行了一些优化,但除此之外我们不能指望找到任何更好的选择。

    总的来说,这意味着我们有以下算法:
  • 朴素:O(n2) 时间,O(1) 空间。
  • 分而治之:O(n lg n) 时间,O(lg n) 空间。
  • 优化的分而治之:O(n) 时间,O(lg n) 空间。
  • 动态规划:O(n) 时间,O(1) 空间。

  • 希望这可以帮助!

    编辑 : 如果你有兴趣,我已经编码了 a Python version of these four algorithms 这样你就可以和他们一起玩耍并判断他们的相对表现。这是代码:
    # Four different algorithms for solving the maximum single-sell profit problem,
    # each of which have different time and space complexity.  This is one of my
    # all-time favorite algorithms questions, since there are so many different
    # answers that you can arrive at by thinking about the problem in slightly
    # different ways.
    #
    # The maximum single-sell profit problem is defined as follows.  You are given
    # an array of stock prices representing the value of some stock over time.
    # Assuming that you are allowed to buy the stock exactly once and sell the
    # stock exactly once, what is the maximum profit you can make?  For example,
    # given the prices
    #
    #                        2, 7, 1, 8, 2, 8, 4, 5, 9, 0, 4, 5
    #
    # The maximum profit you can make is 8, by buying when the stock price is 1 and
    # selling when the stock price is 9.  Note that while the greatest difference
    # in the array is 9 (by subtracting 9 - 0), we cannot actually make a profit of
    # 9 here because the stock price of 0 comes after the stock price of 9 (though
    # if we wanted to lose a lot of money, buying high and selling low would be a
    # great idea!)
    #
    # In the event that there's no profit to be made at all, we can always buy and
    # sell on the same date.  For example, given these prices (which might
    # represent a buggy-whip manufacturer:)
    #
    #                            9, 8, 7, 6, 5, 4, 3, 2, 1, 0
    #
    # The best profit we can make is 0 by buying and selling on the same day.
    #
    # Let's begin by writing the simplest and easiest algorithm we know of that
    # can solve this problem - brute force.  We will just consider all O(n^2) pairs
    # of values, and then pick the one with the highest net profit.  There are
    # exactly n + (n - 1) + (n - 2) + ... + 1 = n(n + 1)/2 different pairs to pick
    # from, so this algorithm will grow quadratically in the worst-case.  However,
    # it uses only O(1) memory, which is a somewhat attractive feature.  Plus, if
    # our first intuition for the problem gives a quadratic solution, we can be
    # satisfied that if we don't come up with anything else, we can always have a
    # polynomial-time solution.
    
    def BruteForceSingleSellProfit(arr):
        # Store the best possible profit we can make; initially this is 0.
        bestProfit = 0;
    
        # Iterate across all pairs and find the best out of all of them.  As a
        # minor optimization, we don't consider any pair consisting of a single
        # element twice, since we already know that we get profit 0 from this.
        for i in range(0, len(arr)):
            for j in range (i + 1, len(arr)):
                bestProfit = max(bestProfit, arr[j] - arr[i])
    
        return bestProfit
    
    # This solution is extremely inelegant, and it seems like there just *has* to
    # be a better solution.  In fact, there are many better solutions, and we'll
    # see three of them.
    #
    # The first insight comes if we try to solve this problem by using a divide-
    # and-conquer strategy.  Let's consider what happens if we split the array into
    # two (roughly equal) halves.  If we do so, then there are three possible
    # options about where the best buy and sell times are:
    #
    # 1. We should buy and sell purely in the left half of the array.
    # 2. We should buy and sell purely in the right half of the array.
    # 3. We should buy in the left half of the array and sell in the right half of
    #    the array.
    #
    # (Note that we don't need to consider selling in the left half of the array
    # and buying in the right half of the array, since the buy time must always
    # come before the sell time)
    #
    # If we want to solve this problem recursively, then we can get values for (1)
    # and (2) by recursively invoking the algorithm on the left and right
    # subarrays.  But what about (3)?  Well, if we want to maximize our profit, we
    # should be buying at the lowest possible cost in the left half of the array
    # and selling at the highest possible cost in the right half of the array.
    # This gives a very elegant algorithm for solving this problem:
    #
    #    If the array has size 0 or size 1, the maximum profit is 0.
    #    Otherwise:
    #       Split the array in half.
    #       Compute the maximum single-sell profit in the left array, call it L.
    #       Compute the maximum single-sell profit in the right array, call it R.
    #       Find the minimum of the first half of the array, call it Min
    #       Find the maximum of the second half of the array, call it Max
    #       Return the maximum of L, R, and Max - Min.
    #
    # Let's consider the time and space complexity of this algorithm.  Our base
    # case takes O(1) time, and in our recursive step we make two recursive calls,
    # one on each half of the array, and then does O(n) work to scan the array
    # elements to find the minimum and maximum values.  This gives the recurrence
    #
    #    T(1)     = O(1)
    #    T(n / 2) = 2T(n / 2) + O(n)
    #
    # Using the Master Theorem, this recurrence solves to O(n log n), which is
    # asymptotically faster than our original approach!  However, we do pay a
    # (slight) cost in memory usage.  Because we need to maintain space for all of
    # the stack frames we use.  Since on each recursive call we cut the array size
    # in half, the maximum number of recursive calls we can make is O(log n), so
    # this algorithm uses O(n log n) time and O(log n) memory.
    
    def DivideAndConquerSingleSellProfit(arr):
        # Base case: If the array has zero or one elements in it, the maximum
        # profit is 0.
        if len(arr) <= 1:
            return 0;
    
        # Cut the array into two roughly equal pieces.
        left  = arr[ : len(arr) / 2]
        right = arr[len(arr) / 2 : ]
    
        # Find the values for buying and selling purely in the left or purely in
        # the right.
        leftBest  = DivideAndConquerSingleSellProfit(left)
        rightBest = DivideAndConquerSingleSellProfit(right)
    
        # Compute the best profit for buying in the left and selling in the right.
        crossBest = max(right) - min(left)
    
        # Return the best of the three
        return max(leftBest, rightBest, crossBest)
    
    # While the above algorithm for computing the maximum single-sell profit is
    # better timewise than what we started with (O(n log n) versus O(n^2)), we can
    # still improve the time performance.  In particular, recall our recurrence
    # relation:
    #
    #    T(1) = O(1)
    #    T(n) = 2T(n / 2) + O(n)
    #
    # Here, the O(n) term in the T(n) case comes from the work being done to find
    # the maximum and minimum values in the right and left halves of the array,
    # respectively.  If we could find these values faster than what we're doing
    # right now, we could potentially decrease the function's runtime.
    #
    # The key observation here is that we can compute the minimum and maximum
    # values of an array using a divide-and-conquer approach.  Specifically:
    #
    #    If the array has just one element, it is the minimum and maximum value.
    #    Otherwise:
    #       Split the array in half.
    #       Find the minimum and maximum values from the left and right halves.
    #       Return the minimum and maximum of these two values.
    #
    # Notice that our base case does only O(1) work, and our recursive case manages
    # to do only O(1) work in addition to the recursive calls.  This gives us the
    # recurrence relation
    #
    #    T(1) = O(1)
    #    T(n) = 2T(n / 2) + O(1)
    #
    # Using the Master Theorem, this solves to O(n).
    #
    # How can we make use of this result?  Well, in our current divide-and-conquer
    # solution, we split the array in half anyway to find the maximum profit we
    # could make in the left and right subarrays.  Could we have those recursive
    # calls also hand back the maximum and minimum values of the respective arrays?
    # If so, we could rewrite our solution as follows:
    #
    #    If the array has size 1, the maximum profit is zero and the maximum and
    #       minimum values are the single array element.
    #    Otherwise:
    #       Split the array in half.
    #       Compute the maximum single-sell profit in the left array, call it L.
    #       Compute the maximum single-sell profit in the right array, call it R.
    #       Let Min be the minimum value in the left array, which we got from our
    #           first recursive call.
    #       Let Max be the maximum value in the right array, which we got from our
    #           second recursive call.
    #       Return the maximum of L, R, and Max - Min for the maximum single-sell
    #           profit, and the appropriate maximum and minimum values found from
    #           the recursive calls.
    #
    # The correctness proof for this algorithm works just as it did before, but now
    # we never actually do a scan of the array at each step.  In fact, we do only
    # O(1) work at each level.  This gives a new recurrence
    #
    #     T(1) = O(1)
    #     T(n) = 2T(n / 2) + O(1)
    #
    # Which solves to O(n).  We're now using O(n) time and O(log n) memory, which
    # is asymptotically faster than before!
    #
    # The code for this is given below:
    
    def OptimizedDivideAndConquerSingleSellProfit(arr):
        # If the array is empty, the maximum profit is zero.
        if len(arr) == 0:
            return 0
    
        # This recursive helper function implements the above recurrence.  It
        # returns a triple of (max profit, min array value, max array value).  For
        # efficiency reasons, we always reuse the array and specify the bounds as
        # [lhs, rhs]
        def Recursion(arr, lhs, rhs):
            # If the array has just one element, we return that the profit is zero
            # but the minimum and maximum values are just that array value.
            if lhs == rhs:
                return (0, arr[lhs], arr[rhs])
    
            # Recursively compute the values for the first and latter half of the
            # array.  To do this, we need to split the array in half.  The line
            # below accomplishes this in a way that, if ported to other languages,
            # cannot result in an integer overflow.
            mid = lhs + (rhs - lhs) / 2
    
            # Perform the recursion.
            ( leftProfit,  leftMin,  leftMax) = Recursion(arr, lhs, mid)
            (rightProfit, rightMin, rightMax) = Recursion(arr, mid + 1, rhs)
    
            # Our result is the maximum possible profit, the minimum of the two
            # minima we've found (since the minimum of these two values gives the
            # minimum of the overall array), and the maximum of the two maxima.
            maxProfit = max(leftProfit, rightProfit, rightMax - leftMin)
            return (maxProfit, min(leftMin, rightMin), max(leftMax, rightMax))
    
        # Using our recursive helper function, compute the resulting value.
        profit, _, _ = Recursion(arr, 0, len(arr) - 1)
        return profit
    
    # At this point we've traded our O(n^2)-time, O(1)-space solution for an O(n)-
    # time, O(log n) space solution.  But can we do better than this?
    #
    # To find a better algorithm, we'll need to switch our line of reasoning.
    # Rather than using divide-and-conquer, let's see what happens if we use
    # dynamic programming.  In particular, let's think about the following problem.
    # If we knew the maximum single-sell profit that we could get in just the first
    # k array elements, could we use this information to determine what the
    # maximum single-sell profit would be in the first k + 1 array elements?  If we
    # could do this, we could use the following algorithm:
    #
    #   Find the maximum single-sell profit to be made in the first 1 elements.
    #   For i = 2 to n:
    #      Compute the maximum single-sell profit using the first i elements.
    #
    # How might we do this?  One intuition is as follows.  Suppose that we know the
    # maximum single-sell profit of the first k elements.  If we look at k + 1
    # elements, then either the maximum profit we could make by buying and selling
    # within the first k elements (in which case nothing changes), or we're
    # supposed to sell at the (k + 1)st price.  If we wanted to sell at this price
    # for a maximum profit, then we would want to do so by buying at the lowest of
    # the first k + 1 prices, then selling at the (k + 1)st price.
    #
    # To accomplish this, suppose that we keep track of the minimum value in the
    # first k elements, along with the maximum profit we could make in the first
    # k elements.  Upon seeing the (k + 1)st element, we update what the current
    # minimum value is, then update what the maximum profit we can make is by
    # seeing whether the difference between the (k + 1)st element and the new
    # minimum value is.  Note that it doesn't matter what order we do this in; if
    # the (k + 1)st element is the smallest element so far, there's no possible way
    # that we could increase our profit by selling at that point.
    #
    # To finish up this algorithm, we should note that given just the first price,
    # the maximum possible profit is 0.
    #
    # This gives the following simple and elegant algorithm for the maximum single-
    # sell profit problem:
    #
    #   Let profit = 0.
    #   Let min = arr[0]
    #   For k = 1 to length(arr):
    #       If arr[k] < min, set min = arr[k]
    #       If profit < arr[k] - min, set profit = arr[k] - min
    #
    # This is short, sweet, and uses only O(n) time and O(1) memory.  The beauty of
    # this solution is that we are quite naturally led there by thinking about how
    # to update our answer to the problem in response to seeing some new element.
    # In fact, we could consider implementing this algorithm as a streaming
    # algorithm, where at each point in time we maintain the maximum possible
    # profit and then update our answer every time new data becomes available.
    #
    # The final version of this algorithm is shown here:
    
    def DynamicProgrammingSingleSellProfit(arr):
        # If the array is empty, we cannot make a profit.
        if len(arr) == 0:
            return 0
    
        # Otherwise, keep track of the best possible profit and the lowest value
        # seen so far.
        profit = 0
        cheapest = arr[0]
    
        # Iterate across the array, updating our answer as we go according to the
        # above pseudocode.
        for i in range(1, len(arr)):
            # Update the minimum value to be the lower of the existing minimum and
            # the new minimum.
            cheapest = min(cheapest, arr[i])
    
            # Update the maximum profit to be the larger of the old profit and the
            # profit made by buying at the lowest value and selling at the current
            # price.
            profit = max(profit, arr[i] - cheapest)
    
        return profit
    
    # To summarize our algorithms, we have seen
    #
    # Naive:                        O(n ^ 2)   time, O(1)     space
    # Divide-and-conquer:           O(n log n) time, O(log n) space
    # Optimized divide-and-conquer: O(n)       time, O(log n) space
    # Dynamic programming:          O(n)       time, O(1)     space
    

    关于arrays - 最大单笔销售利润,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/7086464/

    相关文章:

    algorithm - Big-O 和精确运行时

    algorithm - 时间复杂度和操作的元素数量

    ios - NSXMLParser\n 和\t 文本内部

    javascript - 如何根据当前日期从数组中找到最接近的日期值?

    c - 如何知道 C 中数组中元素的确切数量?

    algorithm - Brodal优先级队列实现

    typescript - 广度优先搜索实现陷入无限循环

    c - 基于值的二叉搜索树复杂性

    Javascript 嵌套数组返回偶数

    php - 夹具 PHP 算法