《Hello 演算法》最大容量問題:雙指標貪婪策略的推導、實現與正確性證明
【免费下载链接】hello-algo《Hello 算法》:动画图解、一键运行的数据结构与算法教程。支持简中、繁中、English、日本語,提供 Python, Java, C++, C, C#, JS, Go, Swift, Rust, Ruby, Kotlin, TS, Dart 等代码实现项目地址: https://gitcode.com/GitHub_Trending/he/hello-algo
本篇技術指南圍繞《Hello 演算法》貪婪章節中的經典例題「最大容量問題」展開,系統講解如何從暴力窮舉 $O(n^2)$ 優化到雙指標貪婪的 $O(n)$ 解法,並結合 hello-algo 倉庫內的多語言源碼(C、Python、Java、Go、TypeScript 等)展示完整可運行的實作,最後給出嚴謹的貪婪正確性證明。讀完本文,你將掌握「何時可以安全地跳過狀態」這類貪婪選擇性質的判斷方法,並能在真實程式碼中複現該演算法。
問題描述與數學建模
!!! question
輸入一個陣列 $ht$ ,其中的每個元素代表一個垂直隔板的高度。陣列中的任意兩個隔板,以及它們之間的空間可以組成一個容器。 容器的容量等於高度和寬度的乘積(面積),其中高度由較短的隔板決定,寬度是兩個隔板的陣列索引之差。 請在陣列中選擇兩個隔板,使得組成的容器的容量最大,返回最大容量。示例如下圖所示。狀態定義:一對隔板索引
容器由任意兩個隔板圍成,因此本題的狀態為兩個隔板的索引,記為 $[i, j]$。與「最長上升子序列」等需要記錄單一位置狀態的題目不同,本題的任何候選解都是一個二元組,狀態空間是陣列中所有索引對的集合。
容量計算公式
根據題意,容量等於高度乘以寬度,其中高度由短板決定,寬度是兩隔板的陣列索引之差。設容量為 $cap[i, j]$ ,則可得計算公式:
$$ cap[i, j] = \min(ht[i], ht[j]) \times (j - i) $$
這個公式包含兩個關鍵觀察:
- 高度取短板:$ht[i]$ 與 $ht[j]$ 中的較小值決定了容器的有效高度,長板多餘的部分對容量沒有任何貢獻;
- 寬度取索引差:兩個隔板之間的水平距離,等於 $j - i$(這裡約定 $i < j$)。
暴力窮舉:$O(n^2)$ 的樸素解法
設陣列長度為 $n$ ,兩個隔板的組合數量(狀態總數)為 $C_n^2 = \frac{n(n - 1)}{2}$ 個。最直接地,我們可以窮舉所有狀態,從而求得最大容量,時間複雜度為 $O(n^2)$ 。
具體做法是使用雙重迴圈遍歷所有 $i < j$ 的索引對,逐一計算容量並更新最大值。當 $n$ 較大時,$\frac{n(n-1)}{2}$ 個狀態的計算量會快速膨脹,例如 $n = 10^4$ 時就需要約 $5 \times 10^7$ 次計算。這說明需要尋找更高效率的解法。
貪婪策略確定:為什麼「移動短板」才是關鍵
這道題還有更高效率的解法。如下圖所示,現選取一個狀態 $[i, j]$ ,其滿足索引 $i < j$ 且高度 $ht[i] < ht[j]$ ,即 $i$ 為短板、$j$ 為長板。
內移長板:容量一定變小
如下圖所示,若此時將長板 $j$ 向短板 $i$ 靠近,則容量一定變小。
這是因為在移動長板 $j$ 後,寬度 $j-i$ 肯定變小;而高度由短板決定,因此高度只可能不變( $i$ 仍為短板)或變小(移動後的 $j$ 成為短板)。寬度嚴格遞減、高度非增,兩者相乘的容量必然不會超過原值——內移長板是一個「只虧不賺」的操作。
內移短板:容量有可能變大
反向思考,我們只有向內收縮短板 $i$ ,才有可能使容量變大。因為雖然寬度一定變小,但高度可能會變大(移動後的短板 $i$ 可能會變長)。例如在下圖中,移動短板後面積變大。
由此便可推出本題的貪婪策略:初始化兩指標,使其分列容器兩端,每輪向內收縮短板對應的指標,直至兩指標相遇。
貪婪策略的執行過程
下圖展示了貪婪策略的執行過程,每一步都對應一個具體的視覺化狀態:
- 初始狀態下,指標 $i$ 和 $j$ 分列陣列兩端。
- 計算當前狀態的容量 $cap[i, j]$ ,並更新最大容量。
- 比較板 $i$ 和板 $j$ 的高度,並將短板向內移動一格。
- 迴圈執行第
2.步和第3.步,直至 $i$ 和 $j$ 相遇時結束。
整個過程共需 $n - 1$ 輪比較(每輪收縮一格,兩指標從相距 $n-1$ 到相遇),每一輪只做常數次運算。
程式碼實現與複雜度分析
原文件通過max_capacity函式引用代碼。在 hello-algo 倉庫中,該演算法已在多種語言中完整實現並配有可執行的 Driver Code,核心邏輯完全一致:維護左右指標與當前最優值,每輪更新容量後移動短板。
以 Python 為例(codes/python/chapter_greedy/max_capacity.py):
def max_capacity(ht: list[int]) -> int: """最大容量:贪心""" # 初始化 i, j,使其分列数组两端 i, j = 0, len(ht) - 1 # 初始最大容量为 0 res = 0 # 循环贪心选择,直至两板相遇 while i < j: # 更新最大容量 cap = min(ht[i], ht[j]) * (j - i) res = max(res, cap) # 向内移动短板 if ht[i] < ht[j]: i += 1 else: j -= 1 return res以 C 語言為例(codes/c/chapter_greedy/max_capacity.c):
/* 最大容量:贪心 */ int maxCapacity(int ht[], int htLength) { // 初始化 i, j,使其分列数组两端 int i = 0; int j = htLength - 1; // 初始最大容量为 0 int res = 0; // 循环贪心选择,直至两板相遇 while (i < j) { // 更新最大容量 int capacity = myMin(ht[i], ht[j]) * (j - i); res = myMax(res, capacity); // 向内移动短板 if (ht[i] < ht[j]) { i++; } else { j--; } } return res; }需要注意的實現細節:
- 高度相等時的移動規則:當
ht[i] == ht[j]時,程式碼走入else分支移動j(即j--)。這只是實現上的約定,移動任一指標都不影響正確性,因為此時無論移動哪一側,寬度都會減少且高度不可能增加; - 短路更新:
res = max(res, capacity)保證res始終記錄已掃描狀態中的歷史最大值,即使某輪容量變小也不會被遺漏。
多語言實現清單
該演算法的邏輯在以下語言中均有對應實現,可對照閱讀:
- Python:codes/python/chapter_greedy/max_capacity.py
- Java:codes/java/chapter_greedy/max_capacity.java
- C:codes/c/chapter_greedy/max_capacity.c
- Go:codes/go/chapter_greedy/max_capacity.go
- TypeScript:codes/typescript/chapter_greedy/max_capacity.ts
- 其餘語言(C++、C#、Dart、Kotlin、Ruby、Rust、Swift、Zig 等)位於 codes 對應目錄的
chapter_greedy子目錄下
各版本 Driver Code 均使用測試陣列ht = [3, 8, 5, 2, 7, 7, 3, 4],可直接編譯運行驗證輸出結果,例如 C 版本通過printf("最大容量为 %d\n", res)輸出答案。
複雜度分析
- 時間複雜度 $O(n)$:程式碼迴圈最多 $n$ 輪,每輪只執行常數次比較與運算,因此時間複雜度為 $O(n)$ 。相比窮舉法的 $O(n^2)$,這是一步數量級的提升;
- 空間複雜度 $O(1)$:變數 $i$、$j$、$res$ 使用常數大小的額外空間,因此空間複雜度為 $O(1)$ 。演算法不需要任何與 $n$ 相關的輔助資料結構。
正確性證明:被「跳過」的狀態都是次優的
之所以貪婪比窮舉更快,是因為每輪的貪婪選擇都會「跳過」一些狀態。要證明演算法正確,就必須證明被跳過的狀態不可能是最優解。
比如在狀態 $cap[i, j]$ 下,$i$ 為短板、$j$ 為長板。若貪婪地將短板 $i$ 向內移動一格,會導致下圖所示的狀態被「跳過」。這意味著之後無法驗證這些狀態的容量大小。
$$ cap[i, i+1], cap[i, i+2], \dots, cap[i, j-2], cap[i, j-1] $$
觀察發現,這些被跳過的狀態實際上就是將長板 $j$ 向內移動的所有狀態。前面我們已經證明內移長板一定會導致容量變小。也就是說,被跳過的狀態都不可能是最優解,跳過它們不會導致錯過最優解。
以上分析說明,移動短板的操作是「安全」的,貪婪策略是有效的。整個證明可以總結為兩步:
- 引理:固定短板 $i$ 時,任何與 $i$ 搭配且索引更靠近的長板位置(即 $cap[i, k]$,其中 $i < k < j$)容量都小於等於 $cap[i, j]$——因為寬度更小且高度由同一塊短板 $i$ 決定,不會更大;
- 歸納:每一輪移動短板後,未被檢驗的狀態集合中永遠不包含最優解,因此最後一次更新得到的
res就是全域最優容量。
與貪婪演算法一般性質的聯繫
本題是貪婪演算法「可以保證找到最優解」的典型代表,與同章的零錢兌換問題形成鮮明對比。在 greedy_algorithm.md 中可以看到,零錢兌換的貪婪策略在部分硬幣組合下(如 $coins = [1, 20, 50]$、$amt = 60$)無法得到最優解,這說明:
- 貪婪選擇性質:只有當局部最優選擇始終可以導致全域性最優解時,貪婪演算法才能保證得到最優解。最大容量問題正是通過「內移長板必變小」這一關鍵性質,滿足了貪婪選擇性質;
- 最優子結構:原問題的最優解包含子問題的最優解。移動短板後剩下的區間構成規模更小的同構子問題,這為歸納證明提供了基礎。
因此,判斷一個問題能否使用貪婪演算法,關鍵在於證明「每步的貪婪選擇是否安全」——即被跳過的狀態是否必然非最優。最大容量問題是練習這種證明思路的理想範例。更多貪婪章節內容可參閱 zh-hant/docs/chapter_greedy 下的 index.md 與 summary.md。
總結
- 最大容量問題的狀態是隔板索引對 $[i, j]$,容量公式為 $cap[i, j] = \min(ht[i], ht[j]) \times (j - i)$;
- 暴力窮舉需遍歷 $C_n^2$ 個狀態,時間複雜度 $O(n^2)$;雙指標貪婪策略僅需 $O(n)$ 時間與 $O(1)$ 空間;
- 核心貪婪策略是「每輪移動短板」,其正確性依賴於「內移長板容量必然變小」的引理,以及「被跳過狀態皆非最優」的歸納證明;
- 該演算法在 hello-algo 倉庫中已覆蓋十餘種語言實現,均可直接編譯運行驗證。
【免费下载链接】hello-algo《Hello 算法》:动画图解、一键运行的数据结构与算法教程。支持简中、繁中、English、日本語,提供 Python, Java, C++, C, C#, JS, Go, Swift, Rust, Ruby, Kotlin, TS, Dart 等代码实现项目地址: https://gitcode.com/GitHub_Trending/he/hello-algo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考