📖 第8课:盛最多水的容器

5 阅读19分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第8课:盛最多水的容器

模块:双指针 | 难度:Medium ⭐⭐⭐ LeetCode 链接:leetcode.cn/problems/co… 前置知识:第7课(移动零-快慢指针基础) 预计学习时间:20分钟


🎯 题目描述

给你一个长度为 n 的整数数组 height,其中每个元素代表一条竖线的高度。这些竖线与 x 轴共同构成容器的边界。你需要找到两条线,使它们与 x 轴围成的容器能够容纳最多的水

返回容器可以储存的最大水量

注意:容器不能倾斜,水的高度由两条线中较短的那条决定,容器的宽度由两条线的水平距离决定。

示例:

输入:height = [1,8,6,2,5,4,8,3,7]
输出:49
解释:选择下标 1 和 8 的两条线(高度分别为 8 和 7),宽度为 8-1=7
     面积 = min(8, 7) × 7 = 7 × 7 = 49
输入:height = [1,1]
输出:1
解释:只有两条线,高度都是1,宽度为1,面积为 1×1 = 1

约束条件:

  • 2 <= height.length <= 10^5 (至少2条线,最多10万条)
  • 0 <= height[i] <= 10^4 (高度可以为0)

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
最小输入[1, 1]1只有2条线的基本情况
含零高度[0, 2, 0, 3]3高度为0的线不贡献面积
递增序列[1, 2, 3, 4, 5]6最优解在两端(1×4 vs 2×3 vs 3×2)
递减序列[5, 4, 3, 2, 1]6同样,两端可能是最优
相同高度[4, 4, 4, 4]12高度相同时,宽度最大即最优
大规模n=10^5暴力法 O(n²) 会超时,必须 O(n)

💡 思路引导

生活化比喻

想象你在一个停车场,两侧有很多不同高度的围栏。现在下大雨了,你想知道:如果选两块围栏作为"挡板",中间能积多少水?

🐌 笨办法:你从第一块围栏开始,依次和后面每一块围栏都试一遍——"我和第2块能积多少水?和第3块呢?和第4块呢?……" 测完第一块,再换第二块重复。这样两两组合全试一遍,累得要命,还要算 n×(n-1)/2 次!

🚀 聪明办法:你站在停车场中间,左手指着最左边的围栏,右手指着最右边的围栏(这样宽度最大!)。然后你发现:"咦,左边这块围栏比较矮,那水的高度就被它限制了。如果我把左手往右移一格(放弃这块矮围栏),也许能找到更高的围栏,虽然宽度少了1,但高度增加可能让总面积更大!" 就这样,每次移动较矮的那一侧,直到两手碰到一起。这样只需要扫一遍,就能找到最优解!

这就是对撞双指针 + 贪心的精髓:从最宽开始,逐步缩小宽度的同时,尽量提升高度。

关键洞察

面积 = min(height[left], height[right]) × (right - left)。移动较高的边只会让高度不变或更低且宽度减小,所以必然变差;只有移动较矮的边,才有可能找到更高的边来弥补宽度的损失。


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:整数数组 height,每个元素代表一条竖线的高度
  • 输出:一个整数,表示最大水量(面积)
  • 核心公式:面积 = min(左边高度, 右边高度) × 宽度
  • 限制:线段本身不占宽度,宽度 = 下标差

Step 2:先想笨办法(暴力法)

最直觉的想法:枚举所有可能的两条线组合,计算每对的面积,取最大值。

  • 双层循环:i 从 0 到 n-2,j 从 i+1 到 n-1
  • 对每对 (i, j),计算 area = min(height[i], height[j]) × (j - i)
  • 时间复杂度:O(n²)
  • 瓶颈在哪:需要检查 n×(n-1)/2 对组合,当 n=10^5 时,约 50 亿次计算,会超时

Step 3:瓶颈分析 → 优化方向

暴力法的问题:很多组合根本不需要检查!比如,如果当前左边界很矮,那它和右边任何一条线组合,面积都被这个矮边限制住了。继续用这条矮边去试更近的线(宽度更小),只会让面积更小——这是在浪费时间!

  • 核心问题:我们不需要试遍所有组合,因为大部分组合注定不是最优解
  • 优化思路:从宽度最大的两端开始(这样至少宽度有优势),然后每次放弃较矮的边(因为它已经没有潜力了),向内收缩,直到两指针相遇

Step 4:选择武器

  • 选用:对撞双指针
  • 理由:
    1. 初始时宽度最大,面积有竞争力
    2. 每次移动矮边,是唯一可能让面积变大的策略(移动高边必然变差)
    3. 只需一次遍历,O(n) 时间

🔑 模式识别提示:当题目涉及"两端向中间"、"寻找最优配对"、"有序/单调性"(这里宽度单调递减),优先考虑"对撞双指针"模式


🔑 解法一:暴力双循环(直觉法)

思路

枚举所有可能的 (i, j) 组合,计算每对的容水量,取最大值。虽然直观,但时间复杂度 O(n²),大数据会超时。

图解过程

示例:height = [1, 8, 6, 2, 5, 4, 8, 3, 7]
索引:         0  1  2  3  4  5  6  7  8

暴力法:依次计算所有组合
(0,1): min(1,8)×1 = 1
(0,2): min(1,6)×2 = 2
(0,3): min(1,2)×3 = 3
...
(1,6): min(8,8)×5 = 40  ← 不错,但还不是最优
(1,8): min(8,7)×7 = 49  ← 最优解!
...
总共需要计算 9×8/2 = 36

Python代码

from typing import List


def max_area_brute(height: List[int]) -> int:
    """
    解法一:暴力双循环
    思路:枚举所有 (i,j) 对,计算面积,取最大
    """
    n = len(height)
    max_water = 0                                    # 记录最大面积

    for i in range(n):                               # 左边界
        for j in range(i + 1, n):                    # 右边界(j > i)
            width = j - i                            # 宽度 = 下标差
            h = min(height[i], height[j])            # 高度 = 较矮的那条线
            area = h * width                         # 面积 = 高×宽
            max_water = max(max_water, area)         # 更新最大值

    return max_water


# ✅ 测试
print(max_area_brute([1, 8, 6, 2, 5, 4, 8, 3, 7]))  # 期望输出:49
print(max_area_brute([1, 1]))                       # 期望输出:1
print(max_area_brute([4, 3, 2, 1, 4]))              # 期望输出:16
print(max_area_brute([1, 2, 1]))                    # 期望输出:2

复杂度分析

  • 时间复杂度:O(n²) — 双层循环,外层 n 次,内层平均 n/2 次
    • 具体地说:如果 n=1000,需要约 500,000 次计算;如果 n=10^5,需要约 50 亿次,会超时
  • 空间复杂度:O(1) — 只用了几个变量

优缺点

  • ✅ 思路简单,容易理解和实现
  • ✅ 一定能找到正确答案
  • ❌ 时间复杂度 O(n²),大数据会超时,不符合面试要求

⚡ 解法二:对撞双指针 + 贪心(最优解)

优化思路

暴力法浪费在:很多明显不优的组合也被计算了。我们可以用贪心策略:从最宽的两端开始,每次移动较矮的边

为什么这样做是对的?

  1. 初始状态:left=0, right=n-1,宽度最大
  2. 贪心抉择:当前面积 = min(h[left], h[right]) × (right-left)
    • 如果移动较高的边,宽度减1,高度最多不变(可能更矮) → 面积必然≤当前
    • 如果移动较矮的边,宽度减1,但高度有可能变大 → 这是唯一有希望超越当前面积的选择
  3. 终止条件:leftright 相遇时,所有有潜力的组合都已考察过

💡 关键想法:每次放弃的那条矮边,和它对面所有线的组合都不可能比当前更优(因为高度被它限制,宽度只会更小),所以可以安全丢弃。

图解过程

示例:height = [1, 8, 6, 2, 5, 4, 8, 3, 7]
索引:         0  1  2  3  4  5  6  7  8

初始状态:
  left=0 (h=1), right=8 (h=7)
  面积 = min(1,7) × 8 = 1×8 = 8
  max_area = 8

  [1] ←left          8  6  2  5  4  8  3 [7]← right
   ↑                                        ↑
   矮边(1 < 7),移动 left++

Step 1: left=1 (h=8), right=8 (h=7)
  面积 = min(8,7) × 7 = 7×7 = 49 ✅ 更新最大值!
  max_area = 49

   1 [8]←left      6  2  5  4  8  3 [7]← right
       ↑                               ↑
       高边(8 > 7),移动 right--

Step 2: left=1 (h=8), right=7 (h=3)
  面积 = min(8,3) × 6 = 3×6 = 18 < 49

   1 [8]←left      6  2  5  4  8 [3] 7 ← right
       ↑                          ↑
       高边(8 > 3),移动 right--

Step 3: left=1 (h=8), right=6 (h=8)
  面积 = min(8,8) × 5 = 8×5 = 40 < 49

   1 [8]←left      6  2  5  4 [8] 3  7 ← right
       ↑                       ↑
       相等(8 == 8),移动任意一个,这里选 left++

Step 4: left=2 (h=6), right=6 (h=8)
  面积 = min(6,8) × 4 = 6×4 = 24 < 49

   1  8 [6]←left   2  5  4 [8] 3  7 ← right
         ↑                  ↑
         矮边(6 < 8),移动 left++

Step 5~7: 继续收缩...直到 left >= right

最终答案:49

Python代码

from typing import List


def max_area_two_pointer(height: List[int]) -> int:
    """
    解法二:对撞双指针
    思路:从两端开始,每次移动较矮的边,更新最大面积
    """
    left, right = 0, len(height) - 1         # 左右指针从两端开始
    max_water = 0                            # 记录最大面积

    while left < right:                      # 当两指针未相遇
        width = right - left                 # 宽度
        h = min(height[left], height[right]) # 高度(取较矮的)
        area = h * width                     # 当前面积
        max_water = max(max_water, area)     # 更新最大值

        # 贪心:移动较矮的边(这是唯一可能变大的选择)
        if height[left] < height[right]:
            left += 1                        # 左边矮,右移左指针
        else:
            right -= 1                       # 右边矮(或相等),左移右指针

    return max_water


# ✅ 测试
print(max_area_two_pointer([1, 8, 6, 2, 5, 4, 8, 3, 7]))  # 期望输出:49
print(max_area_two_pointer([1, 1]))                       # 期望输出:1
print(max_area_two_pointer([4, 3, 2, 1, 4]))              # 期望输出:16
print(max_area_two_pointer([1, 2, 1]))                    # 期望输出:2
print(max_area_two_pointer([2, 3, 4, 5, 18, 17, 6]))      # 期望输出:17

复杂度分析

  • 时间复杂度:O(n) — 每个元素最多被访问一次,左右指针共移动 n-1 次
    • 具体地说:如果 n=10^5,只需要约 10 万次操作,比暴力法快 50,000 倍!
  • 空间复杂度:O(1) — 只用了 3 个变量(left, right, max_water)

🐍 Pythonic 写法

利用 Python 的三元表达式可以让代码更简洁:

def max_area_pythonic(height: List[int]) -> int:
    """一行流写法(不推荐面试用,但展示 Python 技巧)"""
    left, right, max_water = 0, len(height) - 1, 0
    while left < right:
        max_water = max(max_water, min(height[left], height[right]) * (right - left))
        left, right = (left + 1, right) if height[left] < height[right] else (left, right - 1)
    return max_water

或者用更极致的函数式风格(仅供欣赏,实际不推荐):

from functools import reduce

def max_area_functional(height: List[int]) -> int:
    """函数式编程风格 — 仅用于学习,面试别用!"""
    def step(state, _):
        left, right, max_area = state
        if left >= right:
            return state
        area = min(height[left], height[right]) * (right - left)
        new_max = max(max_area, area)
        if height[left] < height[right]:
            return (left + 1, right, new_max)
        else:
            return (left, right - 1, new_max)

    final_state = reduce(step, range(len(height)), (0, len(height) - 1, 0))
    return final_state[2]

⚠️ 面试建议:先写清晰版本(解法二)展示思路,代码通过后可以说:"我可以用 Python 的三元表达式优化一下",但千万别一上来就写晦涩代码。面试官更看重你的思考过程和沟通能力,而非炫技。


📊 解法对比

维度解法一:暴力双循环解法二:对撞双指针
时间复杂度O(n²)O(n)
空间复杂度O(1)O(1)
代码难度简单中等(需要理解贪心正确性)
面试推荐⭐⭐⭐
适用场景n ≤ 1000 的小数据所有情况,尤其 n > 10^4
正确性证明显然(枚举所有)需要证明贪心不漏最优解

面试建议:

  1. 先说暴力法:"最直接的想法是枚举所有组合,O(n²),但这对 10 万数据会超时"
  2. 引出优化:"我注意到,如果左边很矮,那它和右边所有线的组合都被限制了,可以直接放弃它。这启发我用双指针从两端开始,每次移动矮边,这样能保证不漏掉最优解,且只需 O(n)"
  3. 写代码 + 讲解:边写边说"这里移动矮边是因为…"
  4. 测试边界:自己举例 [1,1], [1,2,1] 验证

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道题(给出题目)。

:(审题30秒)好的,这道题是要找两条线,使它们与 x 轴围成的容器容水量最大。容水量 = min(两条线的高度) × 宽度。

面试官:对的,有什么想法吗?

:我的第一个想法是暴力枚举所有 (i, j) 对,计算每对的面积,取最大值。这样时间复杂度是 O(n²),对小数据可行,但题目约束 n 可以到 10^5,暴力法会超时。

面试官:嗯,那怎么优化?

:我注意到一个规律:假如当前左边界很矮,那它和右边任何一条线的组合,面积都被它限制住了,而且往右移(宽度变小)只会更差。所以可以直接放弃这条矮边。这启发我用对撞双指针:从两端开始,每次移动较矮的边,这样既不会漏掉最优解,又只需遍历一次,O(n)。

面试官:很好,为什么移动矮边是对的?移动高边不行吗?

:因为面积 = min(左,右) × 宽度。如果我移动高边,宽度减 1,而高度最多保持不变(因为 min 取决于矮边),所以面积必然 ≤ 当前值。但如果移动矮边,虽然宽度也减 1,但高度有可能变大(如果新边更高),这是唯一可能超越当前面积的选择。所以贪心策略是:总是移动矮边。

面试官:好的,请写一下代码。

:(边写边说)我用 leftright 两个指针从两端开始,每次计算当前面积,更新最大值。然后比较 height[left]height[right],移动较矮的那个。循环直到两指针相遇。(写完)

面试官:测试一下?

:好的,用 [1, 8, 6, 2, 5, 4, 8, 3, 7]。初始 left=0(h=1), right=8(h=7),面积 1×8=8。左边矮,右移左指针到 left=1(h=8),面积 7×7=49,这是最大值。继续收缩...最终返回 49。再测一个边界 [1,1]:只有两条线,面积 1×1=1,正确。

面试官:不错!

高频追问

追问应答策略
"为什么对撞指针不会漏掉最优解?""每次放弃的矮边,和它对面所有线的组合都不如当前(高度被限制,宽度只会更小),所以不会漏。最优解要么在当前,要么在剩余区间,递归必能找到。"
"如果有多个最优解怎么办?""题目只要求返回最大值,不需要返回下标。如果要返回所有最优对,可以在更新 max 时用列表记录所有达到最大值的 (i,j)。"
"空间能不能更优?""已经是 O(1) 了,只用了 3 个变量,无法再优化。"
"这个算法能推广到其他问题吗?""能!对撞指针 + 贪心在很多问题中适用,比如三数之和(LeetCode 15)、接雨水(LeetCode 42,虽然也可用单调栈)。核心是:从两端开始,根据某个规则(这里是高度)决定移动哪一端。"

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:同时更新多个变量 — 避免中间变量
left, right = (left + 1, right) if height[left] < height[right] else (left, right - 1)
# 等价于:
# if height[left] < height[right]:
#     left += 1
# else:
#     right -= 1

# 技巧2:max() 的链式调用 — 多个值中取最大
max_area = max(max_area, min(height[left], height[right]) * (right - left))

# 技巧3:enumerate() 虽然本题用不上,但在类似问题中很有用
for i, h in enumerate(height):
    print(f"索引 {i} 的高度是 {h}")

💡 底层原理(选读)

为什么对撞指针的贪心策略是正确的?

数学证明(反证法):

  1. 假设最优解是 (i*, j*),面积为 A*
  2. 我们的算法从 (0, n-1) 开始,假设某一步跳过了 (i*, j*)
  3. 算法跳过 (i*, j*) 只有一种可能:在到达 i*j* 之前,就让另一个指针越过了它
  4. 不失一般性,假设是在 left < i* 时,right 就从 j* 越过了(即 right 移到了 j*-1)
  5. 这说明在某个状态 (left, j*) 时,height[left] > height[j*],所以移动了 right
  6. 但此时 left < i*,所以 height[left] ≤ height[i*](否则 (left, j*) 面积更大,矛盾)
  7. 因此 min(height[left], height[j*]) ≤ min(height[i*], height[j*]) 且宽度更大
  8. 这意味着 (left, j*) 的面积 ≥ A*,与 A* 是最优解矛盾!

结论:算法不会漏掉最优解。

Python 的 min/max 是怎么实现的?

  • Python 内置的 min(a, b) 是 C 语言实现的,时间复杂度 O(1)
  • 如果传入的是可迭代对象 min([1,2,3]),则是 O(n) 遍历
  • 本题中 min(height[left], height[right]) 只比较两个数,所以是 O(1)

算法模式卡片 📐

  • 模式名称:对撞双指针 + 贪心
  • 适用条件:
    1. 问题涉及"两端"或"区间"
    2. 存在某种单调性或优化方向(这里是:移动矮边才可能变优)
    3. 可以通过局部决策(移动哪个指针)逐步逼近最优解
  • 识别关键词:"最大面积"、"两端"、"容器"、"配对"、"有序/单调"
  • 模板代码:
def two_pointer_converge(arr):
    left, right = 0, len(arr) - 1
    result = 初始值

    while left < right:
        # 计算当前状态的值
        current = 某个函数(arr[left], arr[right])
        result = max/min(result, current)

        # 根据贪心策略移动指针
        if 某个条件(arr[left], arr[right]):
            left += 1
        else:
            right -= 1

    return result

易错点 ⚠️

  1. 错误:移动错了指针

    • 常见错误:if height[left] > height[right]: left += 1(应该是 < 时移动 left!)
    • 原因:逻辑搞反了,记住:移动矮边
    • 正确做法:画图理解,矮边限制了高度,继续用它只会更差
  2. 错误:宽度计算错了

    • 常见错误:width = right - left + 1(以为包含端点)
    • 原因:混淆了"线段数量"和"间距"。两条线之间的间距是下标差,不需要 +1
    • 正确做法:width = right - left
  3. 错误:初始化 max_area 为负数

    • 常见错误:max_area = float('-inf')(高度可以是 0,但面积 ≥ 0)
    • 原因:题目约束 height[i] ≥ 0,所以最小面积是 0,不是负无穷
    • 正确做法:max_area = 0
  4. 错误:两指针相等时还在计算

    • 常见错误:while left <= right(应该是 <)
    • 原因:left == right 时只有一条线,无法构成容器
    • 正确做法:while left < right

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:数据库查询优化 — 区间查询 在时序数据库中,经常需要查询某个时间范围内的最大值。如果数据按时间排序,可以用类似的双指针策略优化查询。

  • 场景2:图像处理 — 最大矩形检测 在计算机视觉中,检测图像中的最大矩形区域(如文档边框检测)时,需要找两条边界线使围成的矩形面积最大,可以用对撞指针加速。

  • 场景3:股票交易策略 — 买卖时机 虽然本题不直接对应股票,但双指针思想在"买卖股票的最佳时机"(LeetCode 121)中也有应用:左指针记录最低价,右指针扫描计算最大利润。


🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 42. 接雨水Hard对撞指针/单调栈/DP和本题类似,但高度是"凹陷处能接多少水",也可用双指针
LeetCode 15. 三数之和Medium对撞指针+去重固定一个数,对剩余数组用双指针找另外两个数
LeetCode 167. 两数之和 IIEasy对撞指针(有序数组)本题的简化版,数组已排序,直接双指针
LeetCode 611. 有效三角形的个数Medium对撞指针排序后,固定最长边,用双指针找满足 a+b>c 的对数

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:给定一个整数数组 height,你可以选择三条线与 x 轴围成一个区域(类似本题,但是三条线)。计算这三条线能围成的最大面积。注意:三条线必须形成一个封闭区域,且不能重叠。

💡 提示(实在想不出来再点开)

可以先固定中间的一条线,然后对剩余两侧用对撞双指针找最优的两条线。或者用三重循环暴力(如果数据量小)。

✅ 参考答案
def max_area_three_lines(height: List[int]) -> int:
    """
    三条线围成最大面积 — 暴力法
    思路:枚举所有 (i, j, k) 三元组,计算面积
    注意:三条线需要满足 i < j < k,面积计算比较复杂,取决于如何定义"围成"
    """
    # 简化版:假设面积 = min(h[i], h[j], h[k]) × (k - i)
    n = len(height)
    max_area = 0
    for i in range(n - 2):
        for j in range(i + 1, n - 1):
            for k in range(j + 1, n):
                h = min(height[i], height[j], height[k])
                width = k - i
                area = h * width
                max_area = max(max_area, area)
    return max_area

复杂度:O(n³),只适用于小数据。如果要优化到 O(n²),需要更复杂的策略(固定中间线 + 双指针)。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。