@laigary.com~/interview/coding/11-container-with-mo….md$
$ cat ./coding/11-container-with-most-water.md
[Coding]·2023-01-29·8 min read

11. Container With Most Water

11. Container With Most Water

給一排高度不同的直線,任選兩根當作水桶的兩片壁,問能裝的最大水量。水量 = 兩根的距離(底邊)× 兩根中較矮的那根(水位)。

思路

先從暴力解出發:枚舉所有的配對,O(n2),然後取最大值。這個一定寫得出來,而且它給了一個好起點 —— 從暴力解往下優化才有東西可以講。

接著看核心想法,這是一句俗諺:水桶的最大容量決定於最矮的一邊

題目給出的正是水桶高度,只是俗諺中的水桶,底部的面積都一樣;這個題目裡面,我們有不同的水桶底邊與水桶壁高度的組合。

所以我們就從左右兩端向中間逼近。逼近的關鍵觀察是:不管移動哪一個指針,底邊都一定變短。 底邊只會變差,那唯一還能變好的因子就是高度 —— 於是策略很清楚:把矮的那邊丟掉,賭中間會出現更高的壁。

為什麼丟掉矮的那邊不會漏掉答案

這是這題真正的核心 —— 「怎麼知道往中間收的時候沒跳過真正的答案?」理由是一個交換論證:

假設現在 height[left] < height[right]。對任何夾在中間的 jleft < j < right),配對 (left, j) 的面積是 min(height[left], height[j]) × (j - left)

  • 底邊一定比現在小,因為 j - left < right - left
  • 水位一定不超過現在,因為 min(height[left], height[j]) ≤ height[left],而 height[left] 正是現在的水位

兩個因子都不可能變好,所以 left 不可能出現在任何比現在更好的答案裡,可以安全丟掉。反過來說,如果丟掉的是高的那邊,這個論證就不成立了 —— 高的那邊還可能跟中間某根更矮但距離仍夠遠的柱子組出更大面積。

想通這一段,這題就從「我記得這個 trick」變成「我能證明它是對的」。

解題方向

class Solution:
    def maxArea(self, height: List[int]) -> int:
        left = 0
        right = len(height) - 1
        max_area = 0
        while left < right:
            width = right - left
            max_area = max(max_area, min(height[left], height[right]) * width)
            if height[left] < height[right]:
                left += 1
            else:
                right -= 1
        return max_area

每一輪都先算完當前的面積再移動指針,順序不能顛倒 —— 否則會漏掉當前這組配對。

兩邊一樣高的時候往哪邊移? 這份程式碼走 else 分支移右邊。移左邊也可以,答案完全相同:兩邊一樣高時,上面那個論證對兩邊同時成立,所以丟哪一邊都不會漏答案。

補充

同樣是對撞指針的題167. Two Sum II(和太小就移左、太大就移右)、15. 3 Sum(固定一個數再對撞剩下的區間)、125. Valid Palindrome(兩端往中間比對)。共同點都是「有一個單調的量,讓你每一步都能安全丟掉一邊」,見 Two Pointers 模板

別跟 42. Trapping Rain Water 搞混。 兩題的圖看起來幾乎一樣,但問的是完全不同的事:

問什麼答案的形狀
11 Container With Most Water兩根柱子當桶壁,最多裝多少一個配對
42 Trapping Rain Water整片地形的所有凹陷總共積多少水每一格的貢獻之和

11 可以丟掉矮的那邊,因為只需要找一個最佳配對;42 每一格都要算,所以需要記住「左右兩側的最高值」,那是另一套解法(前綴最大值或單調棧)。

複雜度

  • 時間 O(n) — 兩個指針各自單向移動,總共走完陣列一次
  • 空間 O(1) — 只有兩個索引和一個最大值

其中 n 是柱子的數量。作為對照,暴力枚舉所有配對是 O(n2) 時間、O(1) 空間。