ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

双指针进阶:盛水容器、三数之和与移动零的套路总结

双指针进阶:盛水容器、三数之和与移动零的套路总结 如果你在刷 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 0right 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 的。这个解法在思路上完全正确但有两个问题时间复杂度 O(n³)n 稍大一点就超时。结果去重特别麻烦。比如 [-1, 0, 1] 和 [0, -1, 1]明明包含的元素一样只是顺序不同算作重复。这也是为什么我说“排序带来去重红利”排序之后同一个组合里的元素只会以一种顺序出现那就是从小到大。这样去重只需要判断“相邻位置的元素是否相同”不需要开一个 set 去存 tuple。3.2 一维双指针变二维先排序然后固定第一个数 nums[i]剩下两个数 left i 1right 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[i1]。for i in range(n - 2): if i 0 and nums[i] nums[i-1]: continue如果用nums[i] nums[i1]会直接跳过那些“当前值和下一个值相同”的情况导致漏解。举个例子数组[-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] -1left 指向第二个 -1right 指向 2得到 [-1, -1, 2]继续移动后left 指向 0right 指向 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 永远 fastpos 位置上的旧值要么已经被处理过要么就是当前要处理的元素本身所以覆盖是安全的。这个“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]fast0nums[0]0不交换slow0。fast1nums[1]1交换 nums[0] 和 nums[1]数组变成 [1, 0, 0, 3, 12]slow1。fast2nums[2]0不交换。fast3nums[3]3交换 nums[1] 和 nums[3]数组变成 [1, 3, 0, 0, 12]slow2。fast4nums[4]12交换 nums[2] 和 nums[4]数组变成 [1, 3, 12, 0, 0]slow3。一轮结束目标达成。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)或popappend能实现但时间复杂度和整体移动次数都不好看而且面试官会觉得你没有真正理解数组的内存连续性。老老实实用双指针。5.4 面试与刷题的效率建议这三道题不太需要“背答案”更需要“背思路”。我的建议是把每个题解都压缩成一个自己说得出的话——例如盛水容器是“每次移动短板”三数之和是“固定一个双指针找两个去重看 i-1”移动零是“快慢指针一边交换一边推进”。面试时能把核心策略用一句话说出来已经赢了一半。平时刷题如果卡在双指针上我的排查顺序是先判断自己的指针移动条件是否基于单调性再检查去重或者边界有没有处理最后才怀疑代码拼写。80% 的 bug 都出在前两个环节。最后再分享一个小技巧很多人在刷题时会遇到这样的困境明明这题看懂了过两天又忘。我自己的做法是给每道题写一个“一句话笔记”不写长解析只写触发条件和核心策略。比如这三道题11 盛水最多的容器两根柱子围容器移动短板才有机会变大。15 三数之和排序后固定一个数剩下用双指针夹去重用 nums[i-1]。283 移动零快慢指针非零往前换零自然到后面。这比反复刷十遍管用得多。等你看完这篇不妨打开编辑器把三道题不靠提示写一遍。能流畅写出来说明双指针这个套路已经完全长在你脑子里了。这三道题只是双指针的敲门砖后面还有接雨水、最长回文子串、无重复字符的最长子串等一堆相关题目等着你。把这组基础打扎实刷那些题时会顺手很多。
返回列表