ฉันรักปัญหานี้ เป็นคำถามสัมภาษณ์คลาสสิกและขึ้นอยู่กับว่าคุณคิดอย่างไรคุณจะได้รับคำตอบที่ดีขึ้นและดีขึ้น เป็นไปได้อย่างแน่นอนที่จะทำสิ่งนี้ได้ดีกว่า O (n 2 ) และฉันได้ระบุวิธีต่างๆไว้สามวิธีที่คุณสามารถคิดเกี่ยวกับปัญหาได้ที่นี่ หวังว่าจะตอบคำถามของคุณได้!
ขั้นแรกวิธีแก้ปัญหาแบ่งและพิชิต ลองดูว่าเราสามารถแก้ปัญหานี้ได้หรือไม่โดยการแบ่งอินพุตออกเป็นครึ่งหนึ่งแก้ปัญหาในแต่ละ subarray จากนั้นรวมทั้งสองเข้าด้วยกัน ปรากฎว่าเราทำได้จริงและทำได้อย่างมีประสิทธิภาพ! สัญชาตญาณมีดังนี้ หากเรามีเวลาเพียงวันเดียวทางเลือกที่ดีที่สุดคือซื้อในวันนั้นแล้วขายคืนในวันเดียวกันโดยไม่มีกำไร มิฉะนั้นให้แบ่งอาร์เรย์ออกเป็นสองส่วน หากเราคิดว่าคำตอบที่ดีที่สุดคืออะไรคำตอบนั้นจะต้องเป็นหนึ่งในสามที่
- คู่ซื้อ / ขายที่ถูกต้องจะเกิดขึ้นอย่างสมบูรณ์ภายในครึ่งแรก
- คู่ซื้อ / ขายที่ถูกต้องจะเกิดขึ้นอย่างสมบูรณ์ภายในครึ่งหลัง
- คู่ซื้อ / ขายที่ถูกต้องเกิดขึ้นในทั้งสองครึ่ง - เราซื้อในครึ่งแรกจากนั้นขายในครึ่งหลัง
เราสามารถรับค่าสำหรับ (1) และ (2) ได้โดยการเรียกใช้อัลกอริทึมซ้ำ ๆ ในครึ่งแรกและครึ่งที่สอง สำหรับตัวเลือก (3) วิธีที่จะทำกำไรสูงสุดคือซื้อที่จุดต่ำสุดในครึ่งปีแรกและขายในจุดที่ยิ่งใหญ่ที่สุดในครึ่งปีหลัง เราสามารถค้นหาค่าต่ำสุดและค่าสูงสุดในสองส่วนครึ่งได้โดยทำการสแกนเชิงเส้นอย่างง่ายบนอินพุตและค้นหาทั้งสองค่า จากนั้นจะให้อัลกอริทึมที่มีการเกิดซ้ำดังต่อไปนี้:
T(1) <= O(1)
T(n) <= 2T(n / 2) + O(n)
การใช้Master Theoremเพื่อแก้ไขการเกิดซ้ำเราพบว่าสิ่งนี้ทำงานในเวลา O (n lg n) และจะใช้พื้นที่ O (lg n) สำหรับการเรียกซ้ำ เราเพิ่งเอาชนะวิธีแก้ปัญหาO (n 2 ) ที่ไร้เดียงสา!
แต่เดี๋ยวก่อน! เราทำได้ดีกว่านี้อีกมาก สังเกตว่าเหตุผลเดียวที่เรามีคำ O (n) ในการเกิดซ้ำของเราคือเราต้องสแกนอินพุตทั้งหมดเพื่อพยายามหาค่าต่ำสุดและสูงสุดในแต่ละครึ่ง เนื่องจากเราทำการสำรวจซ้ำในแต่ละครึ่งอยู่แล้วบางทีเราอาจจะทำได้ดีกว่านี้โดยให้การเรียกซ้ำยังส่งคืนค่าต่ำสุดและสูงสุดที่เก็บไว้ในแต่ละครึ่ง! กล่าวอีกนัยหนึ่งการเรียกซ้ำของเราให้กลับสามสิ่ง:
- เวลาซื้อและขายเพื่อเพิ่มผลกำไร
- ค่าต่ำสุดโดยรวมในช่วง
- ค่าสูงสุดโดยรวมในช่วง
ค่าสองค่าสุดท้ายนี้สามารถคำนวณแบบวนซ้ำได้โดยใช้การเรียกซ้ำแบบตรงไปตรงมาซึ่งเราสามารถเรียกใช้พร้อมกันกับการเรียกซ้ำเพื่อคำนวณ (1):
- ค่าสูงสุดและต่ำสุดของช่วงองค์ประกอบเดียวเป็นเพียงองค์ประกอบนั้น
- ค่าสูงสุดและต่ำสุดของช่วงองค์ประกอบหลายช่วงสามารถพบได้โดยการแบ่งอินพุตเป็นครึ่งหนึ่งค้นหาค่าสูงสุดและต่ำสุดของแต่ละครึ่งจากนั้นหาค่าสูงสุดและต่ำสุดตามลำดับ
หากเราใช้แนวทางนี้ความสัมพันธ์การเกิดซ้ำของเราจะอยู่ในขณะนี้
T(1) <= O(1)
T(n) <= 2T(n / 2) + O(1)
การใช้ Master Theorem ที่นี่ทำให้เรามีรันไทม์ O (n) พร้อม O (lg n) ซึ่งดีกว่าโซลูชันเดิมของเรา!
แต่เดี๋ยวก่อนเราจะทำได้ดีกว่านี้! ลองคิดเกี่ยวกับการแก้ปัญหานี้โดยใช้โปรแกรมไดนามิก แนวคิดที่จะคิดเกี่ยวกับปัญหาดังนี้ สมมติว่าเรารู้คำตอบของปัญหาหลังจากดูองค์ประกอบ k แรกแล้ว เราสามารถใช้ความรู้เกี่ยวกับองค์ประกอบ (k + 1) st รวมกับวิธีแก้ปัญหาเบื้องต้นเพื่อแก้ปัญหาสำหรับองค์ประกอบแรก (k + 1) ได้หรือไม่ ถ้าเป็นเช่นนั้นเราจะได้อัลกอริทึมที่ยอดเยี่ยมโดยการแก้ปัญหาสำหรับองค์ประกอบแรกจากนั้นสองตัวแรกจากนั้นสามตัวแรก ฯลฯ จนกว่าเราจะคำนวณมันสำหรับ n องค์ประกอบแรก
ลองคิดดูว่าจะทำอย่างไร หากเรามีเพียงองค์ประกอบเดียวเราก็รู้แล้วว่าต้องเป็นคู่ซื้อ / ขายที่ดีที่สุด ตอนนี้สมมติว่าเรารู้คำตอบที่ดีที่สุดสำหรับองค์ประกอบ k แรกและดูที่องค์ประกอบ (k + 1) st วิธีเดียวที่ค่านี้จะสร้างโซลูชันได้ดีกว่าที่เรามีสำหรับองค์ประกอบ 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) ได้ อาจมีการปรับให้เหมาะสมกับปัจจัยคงที่ที่ซ่อนอยู่ในสัญกรณ์ big-O แต่อย่างอื่นเราไม่สามารถคาดหวังว่าจะพบตัวเลือกที่ดีกว่าอย่างสิ้นเชิง
โดยรวมแล้วหมายความว่าเรามีอัลกอริทึมต่อไปนี้:
- ไร้เดียงสา: O (n 2 ) เวลาพื้นที่ O (1)
- Divide-and-Conquer: เวลา O (n lg n) พื้นที่ O (lg n)
- เพิ่มประสิทธิภาพ Divide-and-Conquer: O (n) time, O (lg n) space
- การเขียนโปรแกรมแบบไดนามิก: เวลา O (n) พื้นที่ O (1)
หวังว่านี่จะช่วยได้!
แก้ไข : หากคุณสนใจฉันได้เขียนโค้ดอัลกอริทึมทั้งสี่นี้ในเวอร์ชัน Pythonเพื่อให้คุณสามารถเล่นกับพวกเขาและตัดสินการแสดงที่สัมพันธ์กันได้ นี่คือรหัส:
# 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