演算法圖鑑
Divide & Conquer · 02 / 04

Maximum Subarray最大子陣列

分治版與 Kadane 線性版的對照

用在:股票最佳買賣區間、訊號分析

時間複雜度O(n log n) / O(n)
空間複雜度O(log n)
難度進階
前置知識Recursion、Prefix Sum

01為什麼需要它

股票:哪一段持有期間賺最多

有一年份的每日漲跌。想知道如果只能買一次賣一次,哪一天買、哪一天賣最賺。試每一對買賣日是 O(n²),250 天還好,十年的分鐘線就撐不住。

為什麼用它每日漲跌加起來就是持有期間的獲利,問題變成「連續一段加總最大」。分治把它切半,答案不是在左半、就在右半、不然就跨過中線,O(n log n)。Kadane 再壓到 O(n)。

訊號裡最強的那一段

感測器回傳一串數值,扣掉基準線後有正有負。要找出「訊號最集中」的連續時段,也就是加總最大的區間。

為什麼用它和股票是同一題。Kadane 一路掃過去,遇到累積變負就重新開始,因為帶著負的前綴只會拖累後面。

分治的形狀,之後會再用到

線段樹要支援「任意區間的最大子陣列和」,每次查詢不能重掃整段。

為什麼用它分治版合併左右兩半的方式(左半最佳、右半最佳、左後綴加右前綴)正是線段樹節點要存的四個值。這一課先把合併的邏輯練熟,之後就是把它放進樹裡。

看到這些關鍵字就想到它:連續子陣列、加總最大、最佳買賣區間、一段最強的訊號、可以切半再合併。

02核心概念

最大子陣列:在一串有正有負的數裡,找連續一段使加總最大。暴力枚舉所有 (l, r) 是 O(n²),用前綴和也只是把內層加總變 O(1),枚舉本身還是 n²。

分治的觀察是:把陣列從中間切開,答案的區間只有三種位置,完全在左半完全在右半跨過中線。前兩種遞迴解決。第三種一定包含 mid 和 mid+1,所以它等於「以 mid 結尾的最大後綴」加「從 mid+1 開始的最大前綴」,各掃一次 O(n) 就能算出。三者取最大。遞迴式 T(n) = 2T(n/2) + n,由 Master Theorem 得 O(n log n),空間是遞迴深度 O(log n)。

Kadane 換一個角度:定義 cur 為「以第 i 個元素結尾的最大和」。要嘛把 a[i] 接在前一段後面(cur + a[i]),要嘛從 a[i] 重新開始,取大的那個。等價地說,前面累積若是負的,帶著只會拖累,直接丟掉。整體答案是所有 cur 的最大值。一次掃描 O(n)、O(1) 空間。這其實是一維 DP,之後會在 DP 主題再遇到它。

兩者的取捨:Kadane 更快也更短,面試寫它。分治的價值在合併的形狀:一個區段的資訊只要記「總和、最大前綴、最大後綴、最大子段」四個數,兩個相鄰區段就能 O(1) 合併,這正是線段樹處理區間最大子陣列的做法。常見錯誤:全是負數時答案是最大的那個負數而不是 0,所以 best 要初始化成 a[0] 而不是 0。

03演算法步驟

  1. 1分治:solve(lo, hi) 回傳該區間的最大子陣列和。若 lo == hi,回傳 a[lo]
  2. 2mid,遞迴求 left = solve(lo, mid)right = solve(mid+1, hi)
  3. 3跨中線:從 mid 往左累加,記錄最大值 bestL;從 mid+1 往右累加,記錄最大值 bestR。跨中線的答案是 bestL + bestR
  4. 4回傳 max(left, right, bestL + bestR)。每層 O(n),共 log n 層。
  5. 5Kadane:cur = max(a[i], cur + a[i])best = max(best, cur),從 a[0] 開始初始化,一次掃完。

04互動示範

八天的漲跌。前半段是分治:遞迴樹上藍色是正在處理的區間,綠色是已解出的;陣列裡黃色是跨中線掃描的範圍,綠色是這一層的答案區間。分治做完後,同一串步驟接著跑 Kadane:陣列裡黃色是 cur 對應的區間,綠色是目前的 best,看它怎麼用兩個變數掃一遍就得到同樣的答案。

開始8 天的漲跌 · 分治 → Kadane
陣列黃色:正在掃的跨中線區段 · 綠色:這層的答案區間
01234567
-21-34-121-5
遞迴樹(節點下方是解出的答案)
0-70-30-1012-3234-74-5456-767
目前區間
左半最佳右半最佳跨中線這層答案
步驟 0/27每格是當天的漲跌。要找連續一段加總最大的區間。分治:切半、各自解、再算跨中線的那一種。

05程式碼

分治版與 Kadane 版並列,加上一個回傳區間位置的變形,股票題要的「哪天買哪天賣」就是它。

# 最大子陣列(LeetCode 53):分治版
# 答案只有三種可能:全在左半、全在右半、跨過中線
def max_subarray_dc(a):
    def solve(lo, hi):
        if lo == hi:
            return a[lo]                          # 單一元素
        mid = (lo + hi) // 2
        left = solve(lo, mid)                     # 全在左半
        right = solve(mid + 1, hi)                # 全在右半
        # 跨中線:從 mid 往左的最大後綴 + 從 mid+1 往右的最大前綴
        s, best_l = 0, float("-inf")
        for i in range(mid, lo - 1, -1):
            s += a[i]
            best_l = max(best_l, s)
        s, best_r = 0, float("-inf")
        for i in range(mid + 1, hi + 1):
            s += a[i]
            best_r = max(best_r, s)
        return max(left, right, best_l + best_r)  # 三者取最大
    return solve(0, len(a) - 1)


# Kadane:cur 是「以 i 結尾」的最大和,負的就丟掉重來
def max_subarray_kadane(a):
    cur = best = a[0]
    for x in a[1:]:
        cur = max(x, cur + x)                     # 接上去,或從 x 重新開始
        best = max(best, cur)
    return best


# 變形:同時回傳區間 [l, r]
def max_subarray_range(a):
    cur, best = a[0], a[0]
    start, l, r = 0, 0, 0
    for i in range(1, len(a)):
        if cur < 0:
            cur, start = a[i], i                  # 重新開始
        else:
            cur += a[i]
        if cur > best:
            best, l, r = cur, start, i
    return best, l, r


if __name__ == "__main__":
    a = [-2, 1, -3, 4, -1, 2, 1, -5]
    print(max_subarray_dc(a), max_subarray_kadane(a), max_subarray_range(a))  # 6 6 (6, 3, 6)

06練習題

  • LeetCode 121Best Time to Buy and Sell Stock(把價格轉成每日漲跌)Easy
  • LeetCode 53Maximum Subarray(分治和 Kadane 各寫一次)Medium
  • LeetCode 152Maximum Product Subarray(同時追蹤最大與最小)Medium
  • LeetCode 918Maximum Sum Circular Subarray(總和減最小子陣列)Medium
  • LeetCode 1186Maximum Subarray Sum with One DeletionMedium
  • LeetCode 363Max Sum of Rectangle No Larger Than K(二維壓成一維)Hard