刷LeetCode的人早晚会遇到这两道题——盛最多水的容器和三数之和,一个Medium,一个Medium,难度不一样,但核心解法都指向同一个关键词:双指针。不过大多数人在刷完这两题之后,只是记住了"左右指针往中间走"这个动作,却说不清为什么要这么走、什么时候该移动哪一边、三数之和里的去重到底去的是哪层重复。这种状态刷题是刷不深的,题目换个马甲就又认不出来了。
这篇文章不打算只贴题解。我会把两题放一起拆,重点讲清楚三件事:双指针为什么能优化掉一个维度、盛最多水的容器移动短边背后的不等式逻辑、三数之和里去重与指针移动的真实关系。适合刚学会双指针、想彻底搞懂套路的人,也适合刷过一遍但感觉"知其然不知其所以然"的读者。C++和Python代码都会给,其他语言思路一样。
1. 盛最多水的容器:暴力解法为什么会被卡住
先看题目。给定n个非负整数height[0], height[1], ..., height[n-1],每个数代表坐标中的一个点(i, height[i]),从每个点向x轴作垂直线。现在要找出其中两条线,让它们和x轴围成的容器能装下最多的水。容器能装多少水,取决于两条线的距离和较短那条线的高度,水的总量就是:
面积 = (j - i) * min(height[i], height[j])
这是第一道题的核心公式,很多答案一带而过,但建议你盯着这个公式看十秒钟。面积由两个变量决定:宽度(下标差)和高度(较短边)。后面所有优化思路,都是围绕这两个变量展开的。
1.1 暴力枚举的思路与瓶颈
如果你第一次看到这题,最直接的想法肯定是枚举所有可能的(i, j)组合,算一遍面积取最大值。伪代码是这样:
python复制# 暴力解法(会超时)
def maxArea(height):
n = len(height)
max_area = 0
for i in range(n):
for j in range(i + 1, n):
area = (j - i) * min(height[i], height[j])
max_area = max(max_area, area)
return max_area
两层循环把所有组合都扫一遍,时间复杂度O(n^2)。LeetCode上n最大到10^5,10^5的平方就是10^10次操作,正常环境下几秒钟跑不完,提交必超时。
这个超时不是优化代码能救回来的,而是算法复杂度本身不允许。但很多人在这一步就卡住了,不知道从哪往下走。这里有个值得记住的思维习惯:当暴力枚举需要两层循环、而数据规模不允许时,优先考虑能不能用某种顺序省掉一层循环。双指针就是在“有序排列”或“可比较的单调关系”上做文章的。
1.2 宽度和高度的博弈:为什么不是找最高的两根线
在继续往下之前,先破一个常见的直觉误区:很多人第一反应是找最高的两根线,认为这样容器一定最大。我当年也这么想过,直到被一个反例打脸。
数组[1, 8, 6, 2, 5, 4, 8, 3, 7],最高的两根线都是8,分别在下标1和6,它们能形成的容器面积是(6 - 1) * min(8, 8) = 5 * 8 = 40。但答案其实是49,来自下标1(高度8)和下标8(高度7),面积是(8 - 1) * min(8, 7) = 7 * 7 = 49。高度从8降到7,但宽度从5扩到7,多出来的2个单位宽度完全弥补了少掉的1个单位高度,反而让面积更大。
这个例子说明了什么?面积是宽度和短板高度的乘积,不是单纯看某一边。两根很高的线如果离得近,面积不一定比两根中等高度但距离很远的线大。所以任何忽视宽度的贪心策略,在这道题上都会翻车。真正的解法必须在“宽度逐渐减小、高度可能提升”的权衡中找最优。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 盛最多水的容器双指针解法:每一步为什么这么走
现在看双指针解法。初始化i = 0,j = n - 1,每次比较height[i]和height[j],移动较矮的那一根指针,直到i和j相遇。过程中不断更新最大面积。
python复制def maxArea(height):
i, j = 0, len(height) - 1
max_area = 0
while i < j:
area = (j - i) * min(height[i], height[j])
max_area = max(max_area, area)
if height[i] < height[j]:
i += 1
else:
j -= 1
return max_area
代码非常短,但很多人刷完就忘,因为没理解移动短边背后的逻辑。这里我把每一步的“为什么”掰开揉碎讲清楚。
2.1 为什么移动短边而不是移动长边
假设当前height[i] < height[j],也就是左指针指向的线比较矮。这时候考虑所有以i为左边界、右边界在(i, j]之间的容器。这些容器的面积会是多少?
第一,右边界的位置一定在j的左边,所以宽度一定小于当前宽度。第二,容器的高度由两条线中较短的那条决定,而左边界是短板height[i],不管右边换成多高的线,容器高度都不可能超过height[i]。宽度变小、高度不会变大,所以以i为左边界的任何容器,面积都严格不可能大于当前面积。
换句话说,以i作为左边界的所有候选组合,已经被排除掉了。i这根线可以直接丢弃,i++往下走。这就是“移动短边”的本质:不是因为你认为短边那边有更好的解,而是因为你已经证明了短边所在的这一整批组合不可能有更好的解。
反过来,如果移动的是高边j,会发生什么?宽度一样变小,但高度有可能变大,因为新的右边界可能与i形成更高的短板组合。问题是,移动高边是“碰运气”,它没有排除任何分支,只是换了一个候选位置继续试探。双指针需要的是确定性排除,而不是试探。
这个“排除掉不可能产生最优解的分支”,正是双指针快于暴力的本质。暴力解法把每一个组合都当成候选,双指针通过不等式把一大批组合直接判了死刑。
2.2 为什么双指针能保证不遗漏最优解
有人会问:移动短边之后,如果最优解恰好包含原来的短边呢?那它会不会被我们错过了?
不会。因为包含原短边的所有容器,在上面的不等式里已经被证明面积都不超过当前面积。既然都不超过当前已经算过的面积,那它们就不可能是全局最优解。关键在这里:我们并不是在“猜”最优解在哪边,而是在“证明”某些区间不可能藏最优解。每一步排除一个区间,最终剩下的就是答案。
这个思维方式,和二分查找有异曲同工之妙。二分查找每次排除一半的搜索空间,双指针每次排除一个指针所代表的整条射线上的候选。两者都是“排除不可能”的确定性搜索,而不是“碰运气”的随机试探。
2.3 一个容易忽略的细节:相等时移动哪边
如果height[i] == height[j],移动哪边?代码里写的是else分支移动j,其实移动i也可以,效果一样。原因很简单:两边高度相等,当前容器的高度已经被限定为这个值,无论移动哪边,宽度都在减少。但移动之后,新的高度关系可能不同,有可能产生更大的面积。你无法预判哪边会带来更大面积的后续组合,所以随便选一边即可。
实测中移动任意一边都不会漏解,因为另一边会作为后续候选继续参与比较。这个细节在面试中如果被追问,答出来很加分——它说明你不是死记硬背代码,而是理解了面积公式的对称性。
2.4 C++版本与复杂度分析
顺手给一个C++实现,方便刷C++的朋友对照:
cpp复制class Solution {
public:
int maxArea(vector<int>& height) {
int i = 0, j = height.size() - 1;
int max_area = 0;
while (i < j) {
int area = (j - i) * min(height[i], height[j]);
max_area = max(max_area, area);
if (height[i] < height[j]) {
i++;
} else {
j--;
}
}
return max_area;
}
};
时间复杂度O(n),因为i和j从两端往中间走,总共移动n-1步。空间复杂度O(1),只用了几个变量。相比暴力O(n^2),这是一个质的飞跃。也是双指针最吸引人的地方:代码几乎不占额外空间,却能把复杂度降一个维度。
3. 三数之和:从暴力到排序加双指针
三数之和是另一道经典题:给一个整数数组nums,找出所有满足a + b + c = 0的三元组,要求三元组不重复。所谓不重复,是指不能出现两个三元组,它们的元素完全相同(例如[-1,0,1]和[1,-1,0]算同一个三元组,只保留一个)。
这道题在LeetCode上的历史很悠久,面试出现频率极高。它的难点不在“找三元组”本身,而在“不重复”三个字。很多人能写出一个能跑的版本,但结果集里总有重复,改来改去就改乱了。
3.1 暴力解法的两个问题
最直接的暴力是三层循环枚举所有组合,O(n^3),显然不行。就算硬枚举出来,还会面临去重问题:数组里可能有重复元素,同样的三元组会被枚举很多次。去重要么用哈希表处理,要么给结果排序再放进set,但都会带来额外的时间和空间开销。
我见过不少人在这个路口选择了“排序+set去重”的方案,先无条件枚举所有凑成0的三元组,最后统一丢进set去重。这个方案能过,但效率差得远,而且代码写起来也很啰嗦。更关键的是,它没有利用三数之和本身的结构,属于“用暴力加补丁”的思路,不算是真正理解这道题。
3.2 排序为什么是前置步骤
三数之和的第一步是排序。排序乍一看是O(n log n),但它带来两个好处。
第一,有序数组让双指针能够发挥作用。固定第一个数a后,另外两个数的问题变成“在有序数组中找两个数,使它们的和等于-a”。两个指针从左右两端向中间逼近,可以根据当前和与目标值的大小关系,决定移动哪边,从而在线性时间内完成查找。如果没有排序,两个指针没有可依赖的偏序关系,移动哪边都说不通。
第二,排序让去重变得极其简单。相同的元素必然相邻,跳过前面的重复元素就能避免产生重复三元组。比起集合去重,排序去重的代码更直观、效率更高。这就是为什么很多数组类双指针题目都要求先排序,排序这个O(n log n)的开销,能在后面省下一个O(n^2)甚至O(n^3)的复杂度,这笔账怎么算都划算。
3.3 固定一个数,双指针找剩下两个数
具体思路:
- 对数组排序。
- 外层循环固定第一个数nums[i]。
- 内层用双指针left = i + 1、right = n - 1,找两个数使得nums[left] + nums[right] == -nums[i]。
- 如果和太小,left++;和太大,right--;相等则记录结果,然后left++、right--继续找。
这里有个优化:如果nums[i]本身大于0,那么它和后面的任意两个正数相加都不可能等于0,直接break。这个剪枝很小但有效,尤其在全是正数的测试用例上能省掉大量无效循环。
python复制def threeSum(nums):
nums.sort()
n = len(nums)
res = []
for i in range(n):
if nums[i] > 0:
break
if i > 0 and nums[i] == nums[i - 1]:
continue
target = -nums[i]
left, right = i + 1, n - 1
while left < right:
s = nums[left] + nums[right]
if s == target:
res.append([nums[i], nums[left], nums[right]])
left += 1
right -= 1
while left < right and nums[left] == nums[left - 1]:
left += 1
while left < right and nums[right] == nums[right + 1]:
right -= 1
elif s < target:
left += 1
else:
right -= 1
return res
这个写法是市面上最常见的版本,但很多人抄下来了却不理解每一行为什么要这么写。下面专门拆一下去重。
3.4 去重的两个层面,缺一个都会出问题
三数之和最容易错的地方就是去重。去重其实有两层。
第一层是外层循环跳过重复的nums[i]。如果i和i-1位置的值相同,说明以nums[i]开头的三元组已经在上一次循环中找过了,直接continue。这里有个很关键的小细节:判断的是nums[i] == nums[i - 1]而不是nums[i] == nums[i + 1]。
为什么不能用nums[i+1]判断?因为那相当于在i位置就把“当前元素与后续元素组成合法三元组”的机会给跳过了。举个例子,数组[-1, -1, 2],i=0时已经能得到[-1, -1, 2]这个合法答案,如果写成与nums[i+1]比较,在i=0时因为nums[0] == nums[1]就直接continue了,正确答案被活生生丢掉。所以“和上一个比”而不是“和下一个比”,这个细节面试高频,写错一题就报废。
第二层是内层双指针在找到一个结果后,要跳过重复的nums[left]和nums[right]。这个跳过逻辑要放在指针移动之后,用while循环把相同的值全部跨过去。不跳的话,同一个三元组会以不同排列反复出现。比如[-1, 0, 1]在找到一组后,如果不跳过left和right附近的重复值,下一个循环可能又会拼出[-1, 0, 1],造成重复。
再强调一个容易忽略的点:找到一组解后,left和right都要移动,不能只动一个。原因很简单,只动一边,另一边和当前值组合的和不可能再等于target,必然重复或无效。同时移动两边,才能进入新的区间。
3.5 C++版本与复杂度分析
cpp复制class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(), nums.end());
int n = nums.size();
vector<vector<int>> res;
for (int i = 0; i < n; i++) {
if (nums[i] > 0) break;
if (i > 0 && nums[i] == nums[i - 1]) continue;
int target = -nums[i];
int left = i + 1, right = n - 1;
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
res.push_back({nums[i], nums[left], nums[right]});
left++;
right--;
while (left < right && nums[left] == nums[left - 1]) left++;
while (left < right && nums[right] == nums[right + 1]) right--;
} else if (sum < target) {
left++;
} else {
right--;
}
}
}
return res;
}
};
时间复杂度O(n^2):外层循环n次,内层双指针最多O(n)。排序的O(n log n)被O(n^2)吸收。空间复杂度O(1)到O(n),取决于排序算法的实现,一般认为是O(log n)(栈空间)到O(n)。
4. 双指针的三种常见变体和套路识别
把两题做透了,可以顺手总结一下双指针的家族图谱。很多人刷题刷得混乱,就是因为没有把双指针相关的题型分类归纳。就我刷LeetCode的经验来看,双指针大致分三类:对撞指针、快慢指针、滑动窗口。每类的适用场景、指针移动方式和典型题目都不一样。
4.1 对撞指针:两题都属于的类型
盛最多水的容器和三数之和都是对撞指针。特征是两个指针从两端往中间移动,通常要求数据有序(盛最多水的容器比较特殊,不需要有序,因为它的移动策略由高度大小决定,本质是单调性比较)。典型题目还有两数之和 II、最接近的三数之和、接雨水(进阶版)。
识别信号:数组不是环、需要找一对或几个元素满足某个条件、暴力解法是多重循环。
对撞指针的通用模板是:
code复制while (left < right) {
// 计算当前状态
// 根据条件决定移动 left 还是 right
}
判断移动哪边的依据,一定是一个“不变式”:移动某一边后,能保证被排除的那部分组合不可能产生更优解。这是对撞指针的灵魂。
4.2 快慢指针:检测环和找中点
快慢指针一般用在链表或需要O(1)空间判断环路的问题里,比如环形链表、寻找重复数、删除链表的倒数第N个节点。它不依赖有序性,依赖的是“速度差”带来的路径比较。两个指针同向移动,一快一慢,如果链表有环,快指针最终会追上慢指针;如果找中点,快指针到终点时慢指针刚好在中间。
这里不展开细说,但要知道它属于双指针家族,千万别和数组对撞搞混。很多人在“双指针”这个总称下把不同题型的结论互相套用,结果越套越乱。
4.3 滑动窗口:连续子数组的最优选
滑动窗口也是一种双指针,两个指针同向移动,维护一个窗口。典型题目是无重复字符的最长子串、长度最小的子数组。它解决的问题是“连续区间”上的最优值,与对撞指针的“找二元组或三元组”场景不同,但底层都是利用单调性来减少不必要的枚举。
识别信号:题目要求的是子数组、子串,并且有连续性的约束条件。
把这三类分清楚,再看到“双指针”三个字就不会懵了。刷题的时候先判断:题目要的是环、区间还是组合?然后选对应的指针模式。
| 类型 | 指针方向 | 核心机制 | 典型题 |
|---|---|---|---|
| 对撞指针 | 相向移动 | 排除不可能的分支 | 盛最多水的容器、三数之和 |
| 快慢指针 | 同向不同速 | 速度差检测环/中点 | 环形链表、寻找重复数 |
| 滑动窗口 | 同向同速 | 维护连续区间 | 无重复字符的最长子串 |
这个表格我建议收藏一下,做题时拿出来对号入座。
5. 实战中容易踩的坑:去重、边界和调试技巧
做题和写业务代码不一样,业务代码错了会有报错堆栈,算法题错了只给你一个Wrong Answer,得自己去找逻辑漏洞。这里把我踩过的坑列一下,基本都集中在三数之和这道题上。
5.1 三数之和的“target = -nums[i]”溢出问题
在C++和Java里,-nums[i]存在溢出风险。如果nums[i]是INT_MIN,取负会溢出,结果还是负的。虽然LeetCode的测试用例很少触发这个极端值,但在面试手写时最好用long存target,或者先判断一下。Python没有这个问题,但如果你用C++,建议写成long long target = -static_cast
这个细节看起来不起眼,但面试官有时候会故意追问:“如果数组元素是INT_MIN呢?”你要是当场反应过来溢出问题,印象分会涨不少。
5.2 盛最多水的容器中不能用“最大两条边”代替
前面提到过,这里再强调一次:这道题的陷阱之一是,如果直观地认为“找最高的两根线就行”,那就错了。面积是宽度和短板高度的乘积,两根很高的线如果离得近,面积不一定比两根中等高度但距离很远的线大。测试用例[1, 8, 6, 2, 5, 4, 8, 3, 7]里,最大面积是49,来自下标1和8,也就是高度8和7的两根线,而不是最高的两根8和8。
为什么总会有人掉进这个坑?因为“最高”是一个很显眼的特征,大脑天然会被它吸引。但面积公式里有两个变量,只盯住其中一个就是局部思维,双指针的价值恰恰在于同时统筹宽度和高度两个维度。
5.3 调试建议:打印指针位置和面积或和值
写双指针题最容易晕的是“指针走到哪了”。建议调试时把每个循环里的i、left、right、当前面积或当前和值打印出来。不要觉得这是浪费时间,肉眼追踪指针移动比干瞪眼强得多。
我平时刷题的习惯是:先用小的手写用例跑一遍,把指针移动过程画在纸上,再去看代码是哪一步和预期不符。比如三数之和,我会自己写一个[-1, 0, 1, 2, -1, -4]的用例,按排序后的结果[-4, -1, -1, 0, 1, 2]一步步走:i=0,nums[i]=-4,双指针在[-1, 2]之间找和为4的组合,找不到;i=1,nums[i]=-1,双指针在[-1, 2]之间找和为1的组合,找到[-1, 2]和[0, 1]。这样走一遍,代码的逻辑就全印在脑子里了。
5.4 边界条件清单
刷这两题前,先用下面这些用例自测一下,能过基本就没大问题:
- 数组长度为2或3的最小规模
- 所有元素相同(如[1, 1, 1]或[0, 0, 0])
- 全部是负数
- 包含0
- 已经排好序的数组
- 答案有重复、需要去重的数组(如[-1, -1, 2])
这些边界用例的价值是逼你把代码里每一个if判断都走一遍,尤其是去重逻辑。很多时候代码在常规用例上跑得好好的,一到边界就崩,就是因为没有覆盖到这些分支。
6. 从这两题延伸:双指针题单与刷题路线建议
前面把两题讲透了,最后说一下怎么把这些能力迁移到其他题目上去。双指针不是孤立的知识点,它是一整套“利用单调性减少枚举”的思维方法。很多看似不相关的题目,底层都是同一个套路。
6.1 强烈推荐的后续题单
做完这两题,建议按顺序刷下面几道,难度递进:
- 两数之和 II —— 升序数组,双指针直接套
- 最接近的三数之和 —— 三数之和的变体,加一个差值比较
- 四数之和 —— 固定两层循环,双指针收尾
- 接雨水 —— 对撞指针最难的形态,需要额外维护左右最大值
- 长度最小的子数组 —— 滑动窗口入门
- 无重复字符的最长子串 —— 滑动窗口进阶
每刷一题都问自己三个问题:这题的指针为什么这样移动?能不能证明它不会漏解?暴力解法比它差在哪?能把这三个问题答清楚,才算真吃透。
6.2 我的刷题路线心得:先归类再刷题
很多人刷题喜欢按题号顺序从1刷到几百,这个方式效率很低。更好的做法是按题型分类,每类题型刷10到20道,集中击破。双指针这个大类,建议花一到两周集中刷,每天两到三道,配合上面提的“做题三问”复盘。
这样下来,再遇到一个数组相关的题目,你会下意识想:这题能不能排序后双指针?两个指针从哪头开始走?往哪个方向移动才是排除不可能的解?这种“条件反射”就是刷题量积累出来的资产,看再多题解也替代不了。
我自己的体会是,双指针的代码往往极短,但它的正确性证明却需要细心推导。所以我现在每做一道题,都会在代码旁边写两行注释:一行是指针的移动规则,一行是为什么不会漏解。哪怕只是半句话,这个习惯帮我避免了很多“下次见面又不会”的尴尬。
这两题刷完之后,别忘了回去看看盛最多水的容器那个“移动短边”的证明——那是双指针思维的核心种子。种下了这棵种子,三数之和、四数之和、接雨水这些题就都成了同一个套路的不同变奏,而不是一道比一道陌生。
