☰
双指针进阶:盛水容器、三数之和与移动零的套路总结
2026/9/28 13:44:09 网站建设 项目流程

如果你在刷 LeetCode 热题 100,或者准备技术面试,这三道题几乎是绕不开的:11 盛水最多的容器、15 三数之和、283 移动零。它们的难度看着不吓人,但出现频率极高,而且背后都指向同一个核心技巧——双指针。更妙的是,三道题分别展示了双指针的三种不同玩法:对撞、排序后对撞、快慢指针。放在一起刷,性价比非常高。

这篇文章不打算做成题解复制粘贴,而是用我实际刷题和面试复盘的经验,把这三道题的思路、推导、代码、坑一次讲透。你会看到每一步操作的“为什么”,也会看到我为啥反复强调那几个特别容易出错的细节。无论是新手还是已经刷过一遍想巩固的人,都能从中拿到点东西。

作为热身,我先说结论:双指针的本质,是用“单调性”把暴力解法中大量不可能产生最优解的枚举一次性干掉。理解了这点,三道题其实是同一个故事。

1. 三道题放在一起刷:双指针的三种形态

很多人刷题是一题一题孤立地刷,刷完就忘。我后来发现,按“思想”归类刷题效率高得多。这三道题就是最典型的例子,它们都叫双指针,但用法完全不同。

1.1 对撞指针:从两端往中间逼近

对撞指针也叫相向双指针。一个指针从头往右走,一个指针从尾往左走,两个指针在中间某个位置相遇时结束。盛水最多的容器就是这类题的标准模板。

对撞指针的适用场景是:答案区间越窄,判断条件越明确。你可以把它理解成“左右夹逼”——每一步操作都在缩小搜索范围,但缩小的依据必须是数学上站得住脚的,否则就会漏解。这是对撞指针的灵魂:不是盲目地夹,而是每次都能证明“被舍弃的那部分一定不是最优解”。

1.2 排序预处理:让双指针有了单调性

三数之和的前提是排序。排序本身是 O(n log n),但有了排序之后,数组满足单调性,内层的双指针才能根据“当前和比目标大还是小”来移动。如果数组是无序的,双指针一点用都没有,因为两个指针的移动方向没有任何依据。

这一步很多人不理解:为什么非要排序?排序不改变问题的答案,因为最终返回的是元素组合,不是下标。这正是排序能在这里使用的关键前提。一旦数组有序,“和太大就左移右指针,和太小就右移左指针”这个规则就成立了。这是一个非常典型的“预处理设计”——先付出一点复杂度,换来解决主问题的巨大便利。

1.3 快慢指针:同一个方向各司其职

移动零的快慢指针是第三种形态:两个指针从同一个起点出发,一个跑得快(fast),一个走得慢(slow)。fast 负责扫描整个数组,slow 负责记录下一个非零元素应该放的位置。两者配合,一次性完成了“把非零往前移、把零往后放”的原地整理。

快慢指针最常见于数组原地去重、原地删除、链表判环等场景。它的核心思路是“让一个指针负责扫描发现,一个指针负责位置占位”,本质上也是一种“一读一写”的配合。移动零这道题,就是快慢指针在数组处理里最简单、最干净的一次展示。

2. 盛水最多的容器:双指针为什么移动短板

先看题目:给定一个长度为 n 的整数数组 height,数组中的每个元素代表一个垂直于 x 轴的柱子的高度。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。注意,你不能倾斜容器。

用大白话说:选两根柱子,它们之间的距离乘以较短那根的高度,就是这个容器的容量,要找最大值。

2.1 暴力解法先拿到正确性

暴力思路非常简单:枚举所有 i < j,计算min(height[i], height[j]) * (j - i),取最大值。

def maxArea_bruteforce(height): n = len(height) ans = 0 for i in range(n): for j in range(i + 1, n): area = min(height[i], height[j]) * (j - i) ans = max(ans, area) return ans

这个解法时间复杂度 O(n²),n 到 10⁵ 级别时明显跑不动。但它的价值在于提供了一个“标准答案”,后面优化出来的结果可以拿它验证正确性。我刷题时习惯先写暴力,不是为了交差,而是为了确认自己对题目的理解没问题,这个习惯在面试中也很管用——先给一个可行解,再给最优解,本身就是很好的沟通节奏。

2.2 双指针的核心推导:移动长板一定不行

现在把左右指针放在数组两端:left = 0,right = n - 1。当前面积是min(height[left], height[right]) * (right - left)。

关键问题来了:下一步该移动哪个指针?

假设 height[left] < height[right],当前短板在左边。如果现在移动右指针,会出现什么情况?

  • 宽度从right - left变成了right - 1 - left,一定变小了。
  • 新高度是min(height[left], height[right - 1]),因为 height[left] 本来就是短板,这个 min 值无论如何都不会超过 height[left]。

也就是说,移动长板之后,新面积一定小于等于height[left] * (right - left - 1),比当前面积还小。既然移动长板不可能得到更大的面积,那以当前右指针为右边界的、和 left 之间所有还没枚举的配对(也就是 left 和 right-1、right-2……之间那些组合),就都可以被“无脑舍弃”了。这就是双指针的效率来源。

反过来,移动短板 left 时,虽然宽度减小,但新的高度可能变大,面积有变大的可能。所以正确的策略是:每次移动较短的那一根柱子,记录过程中出现的最大面积。

这里我再用生活化类比解释一遍:一个木桶能装多少水,取决于最短的那块木板。你想通过加高长板来让桶装更多水,是不可能的,因为短板根本不变;但如果你把短板换掉,桶的容积就有机会变大。双指针的每一步,实际上都在执行“换掉最短木板”这个动作。

2.3 代码实现与复杂度

class Solution: def maxArea(self, height: List[int]) -> int: left, right = 0, len(height) - 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

整体时间复杂度 O(n),因为 left 和 right 总共只会移动 n 次,空间复杂度 O(1)。从 O(n²) 到 O(n),降了一个量级,这就是双指针的威力。

实际写代码时有个细节要注意:当height[left] == height[right]时,我选择移动哪边都可以,因为移动任意一边都不会漏掉最优解。你可以用else: right -= 1,也可以用>=,影响不大。但千万别在这一步写成“随机移动”,逻辑不清晰的话面试官追问时容易露馅。

3. 三数之和:排序带来的去重红利

再来看第二题:给你一个整数数组 nums,判断是否存在三元组 [nums[i], nums[j], nums[k]],满足 i != j、i != k、j != k,同时还满足 nums[i] + nums[j] + nums[k] == 0。请你返回所有和为 0 且不重复的三元组。

注意“不重复”三个字。这是全题最麻烦的地方。

3.1 题目核心难点的拆解

很多人第一反应:三重循环枚举所有三元组,找出和为 0 的。这个解法在思路上完全正确,但有两个问题:

  1. 时间复杂度 O(n³),n 稍大一点就超时。
  2. 结果去重特别麻烦。比如 [-1, 0, 1] 和 [0, -1, 1],明明包含的元素一样,只是顺序不同,算作重复。

这也是为什么我说“排序带来去重红利”:排序之后,同一个组合里的元素只会以一种顺序出现,那就是从小到大。这样去重只需要判断“相邻位置的元素是否相同”,不需要开一个 set 去存 tuple。

3.2 一维双指针变二维

先排序,然后固定第一个数 nums[i],剩下两个数 left = i + 1,right = n - 1,在 i 之后的区间里进行双指针寻找。整个过程从“三重循环找三元组”变成了“一层外层循环 + 一层双指针扫描”,复杂度从 O(n³) 降到了 O(n²)。

双指针具体怎么走:

  • 计算s = nums[i] + nums[left] + nums[right]。
  • 如果 s < 0,说明总和太小,需要更大的数,left 右移。
  • 如果 s > 0,说明总和太大,需要更小的数,right 左移。
  • 如果 s == 0,记录结果,然后 left 和 right 同时向中间移动。

这套逻辑和两数之和的双指针思路完全一致。区别在于,多了一个外层固定元素,多了一堆去重判断。

3.3 去重的三个坑

去重是三数之和题解里最容易被写错的地方。我在面试现场和刷题群里都见过好多次,基本上都是这三个坑。

第一个坑:外层循环去重写错。应该用nums[i] == nums[i-1]做判断,而不是nums[i] == nums[i+1]。

for i in range(n - 2): if i > 0 and nums[i] == nums[i-1]: continue

如果用nums[i] == nums[i+1],会直接跳过那些“当前值和下一个值相同”的情况,导致漏解。举个例子,数组[-1, -1, 0, 1],正确答案是[-1, 0, 1]。如果你在 i = 0 时发现nums[0] == nums[1]就 continue,这个最优解就被你亲手跳过了。去重的前提是“已经处理过相同的值”,所以必须拿当前值和上一个值比。

第二个坑:找到一组答案后,忘记让左右指针跳过重复元素。

while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1

很多新手在if s == 0的分支里只写left += 1; right -= 1,遇到[-1, -1, -1, 2]这种数组,就会产生重复答案,甚至陷入死循环。正确的写法是:先让左右指针跳过所有重复值,然后再额外移动一次,跳到完全不同的新位置。

第三个坑:把剪枝条件写错。排序之后,如果nums[i] > 0,可以直接 break,因为后面的数都比 nums[i] 大,三个正数不可能和为 0。这个剪枝不复杂,但忘了写的话,只是多跑几次循环;写错了位置,反而会影响正确性。我一般把它放在外层循环的最前面,逻辑最清晰。

3.4 完整代码与边界剪枝

结合前面三个坑,完整代码如下:

class Solution: def threeSum(self, nums: List[int]) -> List[List[int]]: 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: total = nums[i] + nums[left] + nums[right] if total < 0: left += 1 elif total > 0: right -= 1 else: res.append([nums[i], nums[left], nums[right]]) # 内层去重:跳过多余的相同元素 while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1 return res

这段代码拿 [-4, -1, -1, 0, 1, 2] 手跑一遍会非常直观:i = 1 时 nums[i] = -1,left 指向第二个 -1,right 指向 2,得到 [-1, -1, 2];继续移动后,left 指向 0,right 指向 1,得到 [-1, 0, 1]。由于外层去重,i = 2 时不会重复处理 -1,结果收集得干净利落。

复杂度方面:排序 O(n log n),外层循环加双指针 O(n²),整体 O(n²)。空间复杂度取决于排序实现,通常是 O(log n),返回值不算额外开销。面试时这个复杂度一定要说清楚。

4. 移动零:快慢指针的原地艺术

第三题看起来最简单:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。要求是原地操作,也就是不能拷贝额外的数组。

这题入选热题 100 不是因为难,而是因为它考察了一个非常基础的工程能力:原地整理数据。

4.1 题目理解与解法演进

先理解“保持非零元素的相对顺序”这个要求。数组[0, 1, 0, 3, 12],移动后应该是[1, 3, 12, 0, 0],1 必须在 3 前面,3 必须在 12 前面。如果允许乱序,那直接 count 零的个数再重新填就行,但这题明确要求保持相对顺序,所以必须用稳定算法。

最容易想到的“双数组”解法:开一个新数组,非零依次写入,末尾补零。这个解法正确性毫无问题,但不满足 O(1) 空间的额外要求。这也是题目最核心的限制:你必须在一个数组内部把这件事做完。

4.2 覆盖法:直观的两步走

覆盖法是我在面试时最推荐先讲的方案,因为它思路最容易被面试官理解。

  • 第一步:用一个慢指针 pos 记录“下一个非零元素应该放的位置”,遍历数组,遇到非零就写到 nums[pos],pos 加一。
  • 第二步:遍历结束后,pos 后面的所有位置统一填零。
class Solution: def moveZeroes(self, nums: List[int]) -> None: pos = 0 for x in nums: if x != 0: nums[pos] = x pos += 1 for i in range(pos, len(nums)): nums[i] = 0

这个方案的时间复杂度 O(n),空间复杂度 O(1)。它只是把非零元素“搬”到了前面,后面全部清零。

不过需要注意一点:覆盖法的“搬”是覆盖写入。如果原数组里某个位置本来就有非零元素,直接覆盖没问题;但如果这段代码把某个非零元素覆盖了,而那个元素后续还需要被读取呢?实际上不会,因为 pos 永远 <= fast,pos 位置上的旧值要么已经被处理过,要么就是当前要处理的元素本身,所以覆盖是安全的。这个“pos <= fast”的隐藏不变量,正好保证了覆盖法的正确性。

4.3 交换法:一步到位的区隔操作

覆盖法需要两遍处理,虽然也是 O(n),但和交换法比,后者更优雅:一遍扫描,边读边交换,把非零元素“推”到前面,零自然就沉到了后面。

思路还是快慢指针:slow 指向当前已经处理好的非零区间的下一个位置,fast 负责扫描。每当 fast 遇到非零,就把 nums[slow] 和 nums[fast] 交换,slow 前进一位。

class Solution: def moveZeroes(self, nums: List[int]) -> None: slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1

有人会问:这样交换不会破坏非零元素的相对顺序吗?答案是:不会。因为 slow 永远指向“第一个零”或“当前处理位置”,fast 发现一个非零,把它和第一个零交换,这个非零就成了非零区间的最后一个元素。所有非零元素都是按扫描顺序依次进入非零区间的,所以相对顺序保持得非常好。

举个例子手跑一遍[0, 1, 0, 3, 12]:

  • fast=0,nums[0]=0,不交换,slow=0。
  • fast=1,nums[1]=1,交换 nums[0] 和 nums[1],数组变成 [1, 0, 0, 3, 12],slow=1。
  • fast=2,nums[2]=0,不交换。
  • fast=3,nums[3]=3,交换 nums[1] 和 nums[3],数组变成 [1, 3, 0, 0, 12],slow=2。
  • fast=4,nums[4]=12,交换 nums[2] 和 nums[4],数组变成 [1, 3, 12, 0, 0],slow=3。

一轮结束,目标达成。

4.4 两种写法怎么选

从代码简洁度看,交换法更短,而且在全非零数组的情况下有一个小优势:不会执行无意义的覆盖写。不过交换法在slow == fast时会有一次自己跟自己交换,虽然结果没影响,但在某些语言里会多一次无谓的写操作。如果追求极致性能,可以在交换前加个判断:if slow != fast。

从可读性看,覆盖法更好讲清楚,适合在面试开场给出;交换法更适合作为“优化”展示给面试官。我自己的习惯是:面试时先讲覆盖法,把正确性和复杂度讲清楚,再补充“其实可以一次交换完成”,然后写交换法。这个递进关系,本身就是很好的面试表现。

5. 常见问题与排查技巧实录

前面讲了三道题的核心解法,这一节我把自己刷题和模拟面试里反复遇到的典型问题整理出来,排查思路和解决办法直接给结论。

5.1 三数之和死活 AC 不了:八成是去重逻辑不对

很多人在三数之和这道题上会经历“思路秒懂、代码狂错”的阶段。最常见的报错是输出里面有重复三元组。我之前遇到一个同学,他把外层去重写成这样:

if i > 0 and nums[i] == nums[i + 1]: continue

看着和标准答案只差一个符号,但完全变味了。这个写法会在[-1, -1, 0, 1]这种用例上直接漏掉正确答案。排查方法非常简单:把所有continue的条件打印出来,看看是不是在“第一次遇到重复值”时就开始跳过了。标准写法是用nums[i] == nums[i-1],因为只有“已经处理过一次这个值”才需要跳过,而不是“下一个值和你相同就跳过”。

还有个隐蔽问题:在s == 0的分支里,去重 while 循环执行完之后,有同学会忘记再left += 1或right -= 1。这样会造成死循环,因为 left 和 right 指向的还是已经记录过的位置,后续又会计算同一个组合。记住:两个 while 只是跳过重复项,真正让双指针往前走的是最后那两行。

5.2 盛水容器少了情况:指针移动方向写反

盛水最多的容器在逻辑上其实比三数之和简单,但新手偶尔会把移动条件写反。比如:

if height[left] < height[right]: right -= 1 else: left += 1

这就是经典的“移动了长板”。这样写也不会立刻报错,因为还是有答案输出,但答案往往是错的。我从正确性角度再强调一次:移动长板的时候,下一个面积的上限已经被当前短板限制死了,不可能比当前面积大。所以这种写法等于放弃了所有潜在的最优解。

建议手推一遍[1, 8, 6, 2, 5, 4, 8, 3, 7],正确答案是 49,也就是左边下标 1(高度 8)、右边下标 8(高度 7)那两根柱子。如果你用错误方向跑一遍,会发现怎么都到不了 49。亲手验证一次,比背十遍结论都管用。

5.3 移动零的原地限制:空间复杂度 O(1) 的含义

移动零最容易出的问题就是没看清题:开了一个新数组来存结果。这在力扣上也能过一部分用例,但一旦遇到“不允许复制数组”的明确要求,面试官就直接扣分。O(1) 额外空间的意思是:除了几个变量和函数调用栈,不许使用与 n 相关的存储空间。

如果你习惯了 Python,写列表推导式瞬间生成新数组非常方便,但这正是本题的大忌。原地操作考验的是“在已有的数组结构里通过交换、覆盖完成整理”的能力,这也是工程中频繁遇到的需求:不希望在内存紧张时复制整份数据。

另外,还有一种错误做法:先看当前元素是不是 0,是零就往前删、往末尾追加。在 Python 里nums.remove(0)或pop+append能实现,但时间复杂度和整体移动次数都不好看,而且面试官会觉得你没有真正理解数组的内存连续性。老老实实用双指针。

5.4 面试与刷题的效率建议

这三道题不太需要“背答案”,更需要“背思路”。我的建议是:把每个题解都压缩成一个自己说得出的话——例如盛水容器是“每次移动短板”;三数之和是“固定一个,双指针找两个,去重看 i-1”;移动零是“快慢指针一边交换一边推进”。面试时能把核心策略用一句话说出来,已经赢了一半。

平时刷题如果卡在双指针上,我的排查顺序是:先判断自己的指针移动条件是否基于单调性;再检查去重或者边界有没有处理;最后才怀疑代码拼写。80% 的 bug 都出在前两个环节。

最后再分享一个小技巧

很多人在刷题时会遇到这样的困境:明明这题看懂了,过两天又忘。我自己的做法是给每道题写一个“一句话笔记”,不写长解析,只写触发条件和核心策略。比如这三道题:

  • 11 盛水最多的容器:两根柱子围容器,移动短板才有机会变大。
  • 15 三数之和:排序后固定一个数,剩下用双指针夹,去重用 nums[i-1]。
  • 283 移动零:快慢指针,非零往前换,零自然到后面。

这比反复刷十遍管用得多。等你看完这篇,不妨打开编辑器,把三道题不靠提示写一遍。能流畅写出来,说明双指针这个套路已经完全长在你脑子里了。

这三道题只是双指针的敲门砖,后面还有接雨水、最长回文子串、无重复字符的最长子串等一堆相关题目等着你。把这组基础打扎实,刷那些题时会顺手很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询