貪心演算法應用#
買賣股票的最佳時機 II ↗#
題目規則#
給定股票每日價格陣列,求最大利潤。
- 持倉限制:同一時間最多持有 1 股
- 交易次數:無限制
- 手續費:無
解法比較#
| 方法 | 時間複雜度 | 說明 |
|---|---|---|
| 暴力搜尋(DFS) | O(2ⁿ) | 模擬所有決策路徑 |
| 貪心演算法 | O(n) | 本題最佳解 |
| 動態規劃 | O(n) | 更通用的解法 |
貪心策略#
核心邏輯:只要明天的股價比今天高,就今天買、明天賣。
收集所有「上漲」的價差,就是最大總利潤。
數學證明#
將「長期波段利潤」拆解為「每日價差利潤」:
股價由 1 漲到 5:
- 長線操作:第 1 天買,第 5 天賣,利潤 = 4
- 貪心操作:(2-1) + (3-2) + (4-3) + (5-4) = 4
兩者數學等價!
案例分析#
漲跌交錯:[7, 1, 5, 3, 6, 4]
| 價格變化 | 操作 | 利潤 |
|---|---|---|
| 7 ➡️ 1 | 跌,不操作 | 0 |
| 1 ➡️ 5 | 漲,買賣 | +4 |
| 5 ➡️ 3 | 跌,不操作 | 0 |
| 3 ➡️ 6 | 漲,買賣 | +3 |
| 6 ➡️ 4 | 跌,不操作 | 0 |
總利潤:7
連續上漲:[1, 2, 3, 4, 5]
每日買賣:(2-1) + (3-2) + (4-3) + (5-4) = 4
等同於第 1 天買、第 5 天賣,結果一致。
程式碼實作
fun maxProfit(prices: IntArray): Int {
var totalProfit = 0
for (i in 1 until prices.size) {
if (prices[i] > prices[i - 1]) {
// 只要今天比昨天高,就把價差吃下來
totalProfit += prices[i] - prices[i - 1]
}
}
return totalProfit
}時間複雜度: O(n)
空間複雜度: O(1)
貪心的局限性#
此策略成立的前提是「無交易手續費」。
如果每筆交易都有手續費,這種頻繁買賣會導致虧損。真實股市中此策略完全不可行,因為:
- 無法預知明天股價
- 交易成本會侵蝕利潤
延伸:股票系列問題#
| 題目 | 限制 | 推薦解法 |
|---|---|---|
| 買賣股票的最佳時機 ↗ | 只能交易 1 次 | 單次走訪 |
| 買賣股票的最佳時機 II ↗ | 無限次交易 | 貪心 |
| 買賣股票的最佳時機 III ↗ | 最多 2 次 | 動態規劃 |
| 買賣股票的最佳時機 IV ↗ | 最多 k 次 | 動態規劃 |
| 買賣股票的最佳時機含冷凍期 ↗ | 含冷凍期 | 動態規劃 |
| 買賣股票的最佳時機含手續費 ↗ | 含手續費 | 動態規劃 |
買賣股票的最佳時機 II ↗ 能用貪心是因為給了「上帝視角」且無交易成本。更複雜的變體需要動態規劃。
排程問題:先定義「優化指標」,再選貪心策略#
排程是貪心演算法最精華的應用場景之一,但它有一個很多人沒想清楚的前提。
假設只有一台機器,要依序做完一批任務。這裡有個關鍵觀察:不管你怎麼排,總工時都是固定的——所有任務的處理時間加起來就是那麼多,最後一個任務的完成時刻不會因為換順序而改變。
所以排程要優化的,從來不是「總時間」,而是某個更細緻的指標:是要讓「最晚交件的延遲」最小?還是讓「平均每個任務的等待時間」最短?不同的指標,對應到完全不同的最佳貪心策略。這正是貪心思維的核心:動手排之前,先想清楚到底要最佳化什麼。
指標 ↔️ 策略對應表#
| 優化指標 | 最佳貪心策略 | 排序依據 |
|---|---|---|
| 最小化「最大延遲」 | 最早截止時間優先(EDD, Earliest Due Date) | 只看截止時間,任務長度不重要 |
| 最小化「總完成時間 / 平均等待時間」 | 最短處理時間優先(SPT, Shortest Processing Time) | 按處理時間由短到長 |
| 任務有輕重之分 | 加權最短處理時間(Weighted SPT) | 按「權重 ÷ 處理時間」由大到小 |
| 最小化「逾期任務數」 | Moore 演算法 | 依截止時間排,排不下時丟掉已排中最耗時的那個 |
幾個值得留意的地方:
- EDD 完全不在乎任務做多久,只在乎誰先到期。直覺上很合理——要讓最嚴重的遲交盡量不嚴重,當然先處理快到期的。
- Weighted SPT 排序的是「權重 ÷ 時間」,可以理解成每單位時間能消化掉多少重要性,也就是任務的「重要性密度」。密度高的先做,整體加權等待最小。
- Moore 演算法的巧思在於「反悔」:先樂觀地照截止時間塞進去,一旦發現某個任務塞不下了,就回頭把目前已排清單中最耗時的那個踢掉——犧牲一個換來後面好幾個能準時,逾期數因此最少。
直覺範例:為什麼 SPT 最佳#
假設有兩個案子:一個要做 4 天,一個要做 1 天。兩種排法的「總等待時間」(每個案子完成時刻的加總):
| 順序 | 大案完成 | 小案完成 | 總等待 |
|---|---|---|---|
| 先做大的(4 天 ➡️ 1 天) | 第 4 天 | 第 5 天 | 4 + 5 = 9 天 |
| 先做小的(1 天 ➡️ 4 天) | 第 5 天 | 第 1 天 | 5 + 1 = 6 天 |
差別一眼可見:先做大的,那個只要 1 天的小案被硬生生拖到第 5 天才完成。把短任務排前面,能讓它早早結束、不再累積等待——這就是 SPT 最佳的根本原因。
SPT 的實作其實就是一次排序:
// 任務以處理時間排序,回傳最小總完成時間
fun minTotalCompletionTime(processingTimes: IntArray): Long {
val times = processingTimes.sorted() // 短任務排前面,即 SPT
var elapsed = 0L // 目前累積的時刻
var totalCompletion = 0L
for (t in times) {
elapsed += t // 這個任務的完成時刻
totalCompletion += elapsed
}
return totalCompletion
}這些貪心策略對各自的指標是「可證明最佳」,不是退而求其次的近似答案。EDD 給出的最大延遲、SPT 給出的總完成時間,都是理論上的最小值。
這點要和「貪心只能拿到近似解」的問題(見近似演算法與鬆弛)嚴格區分開:在那些問題裡貪心只是個堪用的捷徑,在排程這裡貪心就是正解。