LeetCode 热题榜上,“盛水最多的容器”和“三数之和”这两道题常年排在前排。很多人刷的时候觉得答案看得懂、代码也能抄,但面试时换一道题还是不会。原因很简单:这两道题表面上考数组和枚举,实际上考的是双指针在不同约束下的移动决策。盛水最多的容器练的是“该移左还是移右”的取舍,三数之和练的是“排序之后如何定向搜索”和“如何不重不漏地输出结果”。
把这两题放在一起练,性价比很高。它们不依赖复杂数据结构,也不需要高深技巧,只要把推导过程想清楚,同类题目基本可以举一反三。下面我按自己刷题和带人面试时的思路,把这两道题完整拆一遍,包括代码、证明、常见坑和延伸题,尽量让你看完之后能真正讲清楚,而不是只会背模板。
1. 为什么把这两道题放在一起练
1.1 一道练“决策”,一道练“搜索”
盛水最多的容器,本质是在一个数组里选两个下标,让“最短的那块板 × 两板之间的距离”最大。它真正训练的是双指针在每一轮移动时,怎么作出一个“不会漏掉最优解”的决策。这种决策能力在算法面试里非常重要,因为很多最优解不是靠枚举出来的,而是靠“排除掉一批不可能更优的情况”得到的。
三数之和则是另一个模型:先把数组排序,然后用一个外层指针固定一个数,内部再用左右双指针找另外两个数。它练的是“排序之后,双指针可以根据当前和的大小定向移动”,以及“怎么跳过重复值而不产生重复结果”。这两道题合在一起,基本上就把面试中最常见的左右双指针场景覆盖得差不多了。
1.2 面试官真正想看的不是背题,而是推导能力
我见过不少候选人,一上来就能写出三数之和的完整代码,但问他“为什么 nums[i] == nums[i+1] 时不能直接 continue”,他反而卡住。这说明他只是背了模板,没有理解去重的边界。
面试官在算法题上的考察点,通常不是“你有没有见过原题”,而是这几项:
- 能不能先给一个直接的暴力解;
- 能不能清楚说明暴力解慢在哪里;
- 能不能说出优化思路为什么正确;
- 能不能处理边界条件和重复答案;
- 能不能准确给出时间复杂度和空间复杂度。
这两道题刚好把这些点都覆盖了。盛水容器要证明“移动短边不会漏解”,三数之和要解释“排序为什么有用”和“三个去重位置怎么处理”。所以我把它们当成双指针入门的第一组题目推荐给身边人。
2. 盛水最多的容器:从暴力枚举到双指针收缩
2.1 题意和暴力解的瓶颈
题目给一个非负整数数组 height,height[i] 表示 i 位置柱子的高度。我们要选两个柱子作为容器的左右边界,容器能装多少水,取决于两块板中较短的那块,以及两块板之间的距离。
如果用数学公式表示,就是:
面积 = min(height[i], height[j]) * (j - i)这就是一个木桶效应:容器的高度由短边决定,距离由下标差决定。
最直接的做法是枚举所有 i 和 j,计算每种组合的面积,取最大值。组合数量是 C(n, 2),也就是 O(n^2)。当 n 到 10^5 级别,这个量级是算不完的,所以需要把复杂度降下来。
2.2 双指针的推导过程
我习惯从“宽度最大”开始想:先让 left 指向数组最左边,right 指向数组最右边,这时候宽度是最大的。然后每次计算当前两个柱子围成的面积,并尝试更新答案。
问题来了:下一步该移动哪一边?
假设 left 高度小,right 高度大,那么当前容器的高度就是 height[left]。如果你移动 right,也就是把高的一侧往里收,宽度变小了,而新容器的高度不可能超过原来的短边 height[left],所以面积一定会变小。这意味着“保留当前短边,同时试图从右边寻找更高柱子”这条路,在已经有一个更短的左边存在时,很难产生更优解。
如果你移动 left,也就是把短板换掉,虽然宽度变小了,但新左边界可能变高,容器高度有可能变大,面积还有机会增加。
所以规则很简单:每次移动高度较小的一侧。如果两边高度相等,移动哪边都行,通常代码里统一移动左边。
2.3 完整代码
def max_area(height): n = len(height) if n < 2: return 0 left, right = 0, n - 1 ans = 0 while left < right: area = min(height[left], height[right]) * (right - left) ans = max(ans, area) if height[left] <= height[right]: left += 1 else: right -= 1 return ans这段代码很短,但每一轮都只移动一个指针,并且在移动前会计算一次面积。
注意:等号放在
<=里只是一种习惯。当 height[left] == height[right] 时,两边都是短板,移动任何一边都不会漏解,统一写成移动 left 更简单。
2.4 为什么移动短板一定不会漏解
这里值得多说几句,因为你面试时很可能被追问“为什么这样是对的”。
假设当前 left 指向的位置高度较小,也就是 height[left] <= height[right]。当前面积是:
height[left] * (right - left)现在考虑“以 left 作为左边界,在 left 右边找一个柱子作为右边界”的所有可能情况。因为这些右边界都在当前 right 的左边,所以宽度一定比 right - left 更小。同时,由于 left 本身是当前短板,无论右边换成谁,新容器的高度都不可能超过 height[left]。
也就是说,这些候选情况的面积最大也不会超过:
height[left] * (right - left - 1)这严格小于当前面积。既然以当前 left 为左边界的任何未来组合都不会比当前更优,那我们就可以放心把 left 丢掉,执行 left += 1。
反过来,如果 right 是矮边,就执行 right -= 1。每一轮都这么做,其实是在不断排除“不可能成为最优解”的候选组合,最后剩下的最优解一定会被遍历到。
这一步理解透之后,再看很多双指针题都会觉得顺很多:关键不是记住“移短边”,而是理解“为什么另一个候选集合被安全剪枝了”。
2.5 边界条件和复杂度
这种写法的复杂度非常漂亮:每个元素最多被 left 或 right 访问一次,所以时间复杂度是 O(n),额外空间只用了一个 ans 变量和两个指针,空间复杂度 O(1)。
边界情况也不复杂:
- 数组长度小于 2,不可能形成容器,直接返回 0;
- 数组里有大量相等元素,代码依然正确,只是会多走几步;
- 数组全为 0,面积一直是 0,最终返回 0。
每次面试前,我都建议自己心里默念一遍这个证明过程。它不只是为了这道题,而是为了以后遇到“双指针为什么不会漏解”这一类问题时,能给出同类解释。
3. 三数之和:排序、双指针、去重三板斧
3.1 题意和输出要求
题目给一个整数数组 nums,要求返回所有和为 0 且不重复的三元组。例如:
nums = [-1, 0, 1, 2, -1, -4]满足条件的结果是:
[[-1, -1, 2], [-1, 0, 1]]这里“不重复”指的是三元组的值不能重复,而不是索引不能重复。就算数组里有两个 -1,也只需要输出一次 [-1, 0, 1]。
这个去重要求,是这道题和简单“两数之和”最大的区别。
3.2 为什么先排序
如果不排序,直接三重循环虽然是暴力可行,但去重会非常麻烦,通常还要借助集合,效率低且容易出错。
排序之后有两个好处:
第一个好处是方便双指针移动。排完序后,数组从左到右递增。如果当前三个数的和小于 0,说明左边的数不够大,那 left 应该向右移动;如果和大于 0,说明右边的数太大,那 right 应该向左移动。双指针有了明确方向。
第二个好处是让重复元素相邻。只要在循环过程中跳过相同值,就能避免重复三元组。
整体思路是:先从小到大排序,然后固定最左边的一个数 nums[i],在 i 右侧用 left 和 right 两个指针找两个数,让三数之和为 0。这个过程实质上把“三数之和”降维成了“有序数组上的两数之和”。
3.3 完整代码
def three_sum(nums): nums.sort() n = len(nums) res = [] for i in range(n - 2): # 最左边的数都大于 0,后面不可能凑出 0 if nums[i] > 0: break # 跳过重复的固定数 if i > 0 and nums[i] == nums[i - 1]: continue left, right = i + 1, n - 1 while left < right: s = nums[i] + nums[left] + nums[right] if s == 0: res.append([nums[i], nums[left], nums[right]]) # 跳过 left 右侧的重复值 while left < right and nums[left] == nums[left + 1]: left += 1 # 跳过 right 左侧的重复值 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1 elif s < 0: left += 1 else: right -= 1 return res3.4 去重是三道题里最容易丢分的位置
我经常说,三数之和的代码其实不难,难点全在去重。去重有三个位置要处理,少一个就会出现重复结果或漏解。
第一个是外层固定数的去重。这里必须判断当前 nums[i] 是否和上一个数相同,也就是:
if i > 0 and nums[i] == nums[i - 1]: continue如果你写成:
if nums[i] == nums[i + 1]: continue就会在数组[-1, -1, 2]这种例子上漏解。因为 i=0 时 nums[0] == nums[1],按这个写法会直接跳过 i=0,但正确答案恰好就是[-1, -1, 2]。问题在于,当你用nums[i] == nums[i+1]去重时,你跳过了“当前这个值第一次出现的位置”,而正确做法应该是跳过“这个值第二次及以后出现的位置”。
第二个是找到答案之后,left 要去重。比如数组排完序后是[-2, 0, 0, 2, 2],i 固定为 -2,left 指向第一个 0,right 指向第一个 2,得到答案[-2, 0, 2]。如果这时候只移动 left 而不跳过后面那个 0,下一次 still 也是[-2, 0, 2],就会输出重复结果。
第三个是 right 的去重,逻辑和 left 对称。找到答案后,right 要向左跳过所有相同的值,然后再执行right -= 1。
记住一个口诀:外层去重用“当前值和前一个值比”,内层去重用“跳过下一个和自己相同的值”。
3.5 复杂度是多少
排序的时间复杂度是 O(n log n)。外层 for 循环每个 i 都会让 left 和 right 在内部扫描一遍,虽然有两个 while 跳过重复值的工作,但整体上每个 i 内部 left 和 right 从两端向中间走,最多走 n 步,所以内层是 O(n),总的是 O(n^2)。
空间复杂度如果不算返回结果,主要来自排序,不同语言实现不一样,可以按 O(log n) 到 O(n) 来说,面试官一般都能接受。如果再加一层稳妥的说法:不算输出,额外空间是排序产生的,常数额外空间为 O(n)。
4. 实操复盘:我踩过和看别人踩过的坑
4.1 “容器最大,是不是找两根最高的柱子就行?”
这是一个非常自然的错误思路。毕竟容器由两块板组成,直觉上最高两根应该最容易装水。
但距离同样是变量。比如数组[2, 4, 3, 2],最高的两根是 4 和 3,它们相邻,面积只有3 * 1 = 3。而两端的 2 和 2 虽然不高,但距离是 3,面积能达到2 * 3 = 6。
所以这道题不能只盯着“最高”这个局部信息,必须同时考虑距离。双指针的优势就在于它从最大宽度开始,逐步缩小范围,同时用短边决策排除大量组合。
4.2 容器题写成“两边都移动”
有些同学在 while 循环里会发现左右两边高度相等,于是顺手把 left 和 right 都往中间移动一步。这样写偶尔也能过,但容易打乱对算法正确性的理解。
正确做法是每轮只移动一个指针。等号情况只是“矮边”有两个选择,移动其中一个即可,并不需要同时移两个。保持“每轮只移一个”这个习惯,也方便你在面试时把证明讲得清晰。
4.3 三数之和没有处理数组长度不够的情况
如果数组长度小于 3,直接返回空列表。代码里写range(n - 2),如果 n=2,range 是空的,最后返回空结果,其实也能工作。但如果你在循环外就访问 nums[i+1] 或者 nums[i+2],就很容易越界。
建议第一行先做一次判断:
if len(nums) < 3: return []这样代码更稳,面试时也能体现边界意识。
4.4 外层第一个数大于 0 直接 break,不是 continue
排序之后,如果 nums[i] > 0,说明当前固定的数已经是最左边的正数,后面的数只会更大,三个正数相加肯定大于 0,所以后面的 i 都不用再看了,直接 break。
如果写成 continue,那也只是多跑几次无意义的循环,最后结果可能一样,但 break 更符合逻辑,也能少算几步。
4.5 C++ 和 Java 里的溢出问题
Python 写起来没有溢出顾虑,但如果用 C++ 或 Java,数组元素范围比较大的时候,三数之和直接相加可能溢出。更严谨的做法是先用 long 类型接住 sum,或者判断时把加法拆成减法。
比如:
long s = (long) nums[i] + nums[left] + nums[right];这个细节在刷题时不一定测出来,但在面试里主动说一句,会显得你对语言边界很敏感。
5. 从这两道题延伸出去:一张双指针题单
5.1 左右双指针的通用模板
这两道题其实都逃不出下面这个模板:
left, right = 0, len(arr) - 1 while left < right: # 根据题目计算当前结果 # 更新答案 if should_move_left: left += 1 else: right -= 1区别只在should_move_left这个条件怎么定义:
- 盛水容器:高度较小的一侧需要移动;
- 两数之和 II:当前和小于目标时移动左指针;
- 三数之和:当前和小于 0 时移动左指针,大于 0 时移动右指针。
只要理解了每一步如何在“不可能更优”的候选集中剪枝,这套模板就能迁移着用。
5.2 三数之和的常见变体
最典型的是“最接近的三数之和”。题目会让你找一个三元组,使三数之和最接近目标值 target。
做法依然是先排序,然后外层固定 i,内部 left 和 right 双指针扫描。每一轮计算当前和 s,用abs(s - target)更新答案。关键在移动规则:
- s 小于 target,说明需要更大的数,left 右移;
- s 大于 target,说明需要更小的数,right 左移;
- s 刚好等于 target,直接返回 target。
这个变体只改了一行更新逻辑,就能沿用三数之和的框架。
再往上是“四数之和”。做法是在三数之和外面再套一层循环,也就是固定前两个数,后面两个数仍然用双指针。复杂度从 O(n^2) 变成 O(n^3)。去重逻辑也是类似地处理四层:第一层用 prev,第二层用 prev,第三层和第四层用跳过重复值的方式。
5.3 值得顺带刷掉的题目
如果你想把双指针这一块练透,我建议按这个顺序刷:
| 题目 | 核心考点 | 目标复杂度 |
|---|---|---|
| 两数之和 II - 输入有序数组 | 双指针移动方向 | O(n) |
| 盛水最多的容器 | 短边决策与正确性证明 | O(n) |
| 三数之和 | 排序 + 双指针 + 去重 | O(n^2) |
| 最接近的三数之和 | 双指针 + 差值更新 | O(n^2) |
| 四数之和 | 双层循环 + 双指针 | O(n^3) |
这个顺序的好处是难度递增,每一题只比上一题多一个难点。先搞定移动方向,再理解剪枝,最后去重,就不会觉得一下子信息量太大。
6. 面试现场:怎么表达才能加分
6.1 先讲暴力解,再讲优化
面试时最忌讳一上来就甩最优解。就算你已经想到双指针,也可以先花三十秒把暴力思路表达清楚。
比如盛水容器,你可以说:“所有 i、j 的组合都能算面积,复杂度 O(n^2)。但我们可以从最大宽度开始,每次移动较矮的一侧,因为矮边限制了高度,移动高边不会让面积变优,所以用双指针到 O(n)。”
三数之和则可以说:“三重循环是 O(n^3),去重还很麻烦。排序后固定一个数,剩下两个数用双指针找,复杂度降到 O(n^2)。”
这种表达让面试官看到的不只是答案,而是你分析问题的路径。
6.2 主动跑一个边界用例
写完代码,别急着说“完成”。你可以主动说:“我拿几个边界用例检查一下。”
盛水容器可以用[1, 1],结果是 1;用[1, 2, 1],答案还是 2,因为两端的 1 和 1 面积是 2,中间的 2 和任意 1 面积也是 2。
三数之和可以用[0, 0, 0, 0],结果应该是[[0, 0, 0]],不能输出多个重复结果。这正好能验证你的去重是否写对。
6.3 复杂度要脱口而出
这两个答案必须很熟练:
- 盛水最多的容器:时间复杂度 O(n),空间复杂度 O(1);
- 三数之和:时间复杂度 O(n^2),排序额外空间看语言实现,输出结果不计入额外空间。
如果面试官问“还能不能更快”,你不需要硬凹一个不存在的更优解。重点是把为什么当前已经足够好讲清楚,尤其是三数之和这种本身输出规模就可能很大的题目,接受 O(n^2) 是合理的。
6.4 我自己的建议顺序
每次带人准备双指针,我都建议按照“两数之和 II -> 盛水最多的容器 -> 三数之和”的顺序来。两数之和 II 最纯粹,先理解指针怎么根据大小关系移动;盛水容器开始加入“剪枝证明”;三数之和再把排序和去重叠加进来。
这个过程看起来多刷了题,实际上是在给后面的复杂题打底。等哪天面试遇到“最接近的三数之和”或者“四数之和”,你会发现思考路径是连续下来的,而不是每个题都重新开始背模板。这是我实际刷题时体会最深的一点:算法题之间本来就有梯度,按照梯度一个个啃,比孤立地刷一百道题要靠谱得多。