主題: 學習筆記

LeetCode 53:最大子陣列,從向左重算到保存上一輪結果

記錄我的 Maximum Subarray 解題過程,從 O(n²) 向左掃描改成 O(n),解釋 endingHere 與 best 的差別、全負數邊界與可執行測試。

我最初固定目前的位置,再往左把每一段加起來,找出最大的和。這樣能直接對照題意,但陣列一長,重複計算就太多。

在 AI 提示與程式骨架協助下,我把內層 while 移掉,改成保存上一輪的結果。這篇保留我的初版與最後補完的版本,另外補上為什麼只留一個局部最佳值就夠,以及全負數時要注意的初始化。

題目

LeetCode 53:Maximum Subarray 要找出非空連續子陣列的最大總和,只回傳總和。元素必須相鄰,不能排序後再選,也不能跳過中間的負數。

例如 [-2,1,-3,4,-1,2,1,-5,4] 的答案為 6,對應 [4,-1,2,1]。輸入長度為 1 到 100000,元素介於 -10000 到 10000。本篇處理 O(n) 解法,不展開分治進階題。

我原本向左重算的版本

為了讓兩版能放在同一個檔案,這裡只將初版函式改名。

function maxSubArrayOriginal(nums: number[]): number {
    let max = nums[0];
    let minIndex = 0;
    for (let i = 0; i < nums.length; i++) {
        let sum = 0;
        let maxSum = nums[i];
        let j = i;
        let minIdx = 0;
        while (j >= minIndex) {
            sum += nums[j];
            if (sum >= maxSum) {
                maxSum = sum;
                minIdx = j;
            }
            j--;
        }
        if (maxSum > max) {
            max = maxSum;
            minIndex = minIdx;
        }
    }
    return max;
}

外層固定右端點 i,內層從 i 往左累加。我試著用 minIndex 縮小之後搜尋的範圍,但這無法保證每輪都省下足夠的工作。

若陣列全部是 1,每次往左加,總和都會變大,最後 minIdx 仍然回到 0。下一輪又從目前位置一路加到開頭,累計工作量就是 1 + 2 + ... + n,因此最差時間為 O(n²),額外空間為 O(1)。

這裡不靠移動搜尋邊界來保證效能,也不把 minIndex 當成通用的剪枝規則。真正要省掉的是每一輪對各個起點的重新比較。

最後解法:只保存兩種最大值

下面是我依提示補完的最終版本。演算法沒有再另外替換。

function maxSubArray(nums: number[]): number {
    let endingHere = nums[0];
    let best = nums[0];
    for (let i = 1; i < nums.length; i++) {
        endingHere = Math.max(endingHere + nums[i], nums[i]);
        best = Math.max(endingHere, best);
    }
    return best;
}

兩個變數有不同的範圍:

  • endingHere 是一定以目前位置結尾的最大總和。
  • best 是截至目前為止,所有結尾位置中最大的總和。

endingHere 可以變小。它必須包含目前元素,不能繼續停在前一格。best 則可以保留先前的結果,不一定在目前位置結束。

為什麼只比較兩個選項

任何以 i 結尾的非空連續子陣列,只會是目前元素自己,或是一段以 i − 1 結尾的子陣列再接上目前元素。

如果選擇接上去,所有候選都會加同一個 nums[i]。原本比較小的總和,加上相同數字後仍然比較小,所以只需保留上一輪最大的那個。

因此:

endingHere = Math.max(上一輪 endingHere + nums[i], nums[i])

不是看到負數就丟掉。例如前段總和是 5,目前數字是 -1,接起來的 4 仍然比重新開始的 -1 大。但前段是 -3、目前數字是 4,重新開始的 4 就比接起來的 1 好。

更新局部結果後,再與歷史最大值比較:

best = Math.max(endingHere, best)

走一次例子

[-2,1,-3,4,-1,2,1,-5,4] 為例:

i nums[i] endingHere best
0 -2 -2 -2
1 1 1 1
2 -3 -2 1
3 4 4 4
4 -1 3 4
5 2 5 5
6 1 6 6
7 -5 1 6
8 4 5 6

最後 endingHere 是 5,但答案仍是 6。若只回傳最後一輪的局部結果,就會漏掉中途出現的最大區間。

不變量與初始化

處理完位置 i 後,endingHere 等於所有以 i 結尾的非空連續子陣列總和的最大值;best 等於截至 i 為止所有非空連續子陣列的最大總和。

起點 i = 0 只有 [nums[0]] 可選,所以兩個變數都設為 nums[0]。之後根據前面的兩種選擇,能得到新的 endingHere,再更新 best。依序處理完整個陣列後,best 就涵蓋所有可能的結尾位置。

不要把 best 初始化成 0。[-3,-1,-2] 的答案是 -1,因為不能選空陣列。迴圈從 1 開始,則是因為第一個元素已經包含在初始狀態中。

複雜度與取捨

最後版本只走訪一次陣列,每輪固定次數的加法與比較,時間為 O(n)。只保存兩個數值與索引,額外空間為 O(1),回傳結果也是單一數值。輸入陣列不會被修改。

它沒有保存每個位置的最佳值,也沒有記錄答案的起訖索引,因為題目只要總和。若未來要回傳區間,才需要另外追蹤起點及最佳區間的端點。

可執行測試與暴力解對照

把最終函式與以下程式放在同一個 TypeScript 檔案,在支援 TypeScript 的環境執行。暴力參考版不使用初版的搜尋邊界,直接枚舉所有連續區間,適合拿來比對小陣列。

import assert from "node:assert/strict";

assert.equal(maxSubArray([1]), 1);
assert.equal(maxSubArray([5, 4, -1, 7, 8]), 23);
assert.equal(maxSubArray([-1, 0]), 0);
assert.equal(maxSubArray([-3, -1, -2]), -1);
assert.equal(maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4]), 6);
assert.equal(maxSubArray([0, 0]), 0);
assert.equal(maxSubArray(new Array(100000).fill(1)), 100000);

function bruteForce(nums: number[]): number {
    let best = nums[0];
    for (let i = 0; i < nums.length; i++) {
        let sum = 0;
        for (let j = i; j < nums.length; j++) {
            sum += nums[j];
            best = Math.max(best, sum);
        }
    }
    return best;
}

let cases = 0;
for (let n = 1; n <= 6; n++) {
    for (let encoded = 0; encoded < 5 ** n; encoded++) {
        let value = encoded;
        const nums = Array.from({ length: n }, () => {
            const element = value % 5 - 2;
            value = Math.floor(value / 5);
            return element;
        });
        const before = [...nums];
        assert.equal(maxSubArray(nums), bruteForce(nums));
        assert.deepStrictEqual(nums, before);
        cases++;
    }
}
assert.equal(cases, 19530);

這段檢查包含單一元素、全負數、零、中間出現最大值,以及十萬個元素的輸入。另外用長度 1 到 6、元素為 -2 到 2 的 19530 組陣列,比對暴力參考版,並確認輸入沒有被修改。測試已執行通過;有限範圍測試不取代前面的正確性說明。

這次要記住的差別

我原本每輪都重新找「以目前位置結尾的最大和」。最後版本把這個結果留下來,下一輪只需決定要延續還是重新開始。

之後遇到類似題目,我要先分清楚局部狀態與歷史答案,並說明為什麼其他候選可以不保留。這比只記得少寫一層迴圈更有用。

參考資料