ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

双指针算法详解:三种范式、边界条件与实战避坑

双指针算法详解:三种范式、边界条件与实战避坑 开头兄弟们双指针two pointers这个算法刷过题的人应该都不陌生但能把它的门道彻底讲透的还真不多。很多人对它的理解停留在两个变量一左一右往中间走套几个模板题能过换道新题就抓瞎。今天这篇东西我打算把这套算法的底层逻辑掰开揉碎讲清楚——它凭什么能省时间、它的三种典型范式各自适合什么场景、边界条件和去重这些坑具体长什么样。不论你是准备算法面试还是刷LeetCode卡在中等题上这篇都能帮你把思路理顺。我自己当初也是从暴力枚举一路踩坑走过来的这篇里写的每一个细节都是实操中真正被坑过的地方。1. 双指针算法到底是什么从暴力枚举讲起1.1 暴力枚举为什么慢先问个很基础的问题当你面对一个数组要在里面找两个数满足某个条件你的第一反应是什么绝大多数人的第一反应是双重循环——外层遍历第一个数内层遍历第二个数把所有组合都试一遍。这种暴力枚举的思路正确性没有任何问题它慢就慢在把可能性的空间扫了个底朝天。以有序数组中找两数之和等于target为例暴力解法的代码大概长这样def twoSum_bruteforce(nums, target): n len(nums) for i in range(n): for j in range(i 1, n): if nums[i] nums[j] target: return [i, j] return []这个代码的时间复杂度是O(n²)。当n是1000时你大概要做50万次加法比较当n是10万时你要做50亿次。差距就是这么翻着倍地增长。暴力枚举的核心问题在于它把每一对组合都当作完全独立的、值得尝试的情况来处理而完全无视了数组本身已经存在的信息——比如有序性、单调性、位置远近。很多人觉得算法优化是个玄学其实不是。优化的本质就是找到信息冗余。暴力枚举之所以慢就是因为它在反复计算那些从数据规律上就能直接判断出答案不在这里的情况。我们下面要讲的双指针干的恰恰是这件事。1.2 双指针的核心思想双指针的核心思想用一句话概括就是通过两个指针的协同移动让每次判断都能排除掉一批不可能是答案的候选组合从而把问题规模逐步压缩。我拿生活里的例子来解释。想象你面前有一排从矮到高排列的人你要找出两个人他们的身高之和恰好等于一个固定值。暴力做法是先固定第一个人然后挨个问剩下所有人谁的身高能凑出目标找不到就换下一个人再挨个问。这个方法当然能找到答案但效率极低。双指针的做法完全不同。你让最矮的人站在左边最高的人站在右边两个人先加一下。如果和小于目标说明最矮的人跟任何站在更高位置的人相加都只会更大那最矮的人这边就可以整体排除往下移动一个。如果和大于目标说明最高的人跟任何更矮的人相加都只会更小最高的人这边就可以排除往左移动一个。每一步淘汰一整行候选这就是双指针省时间的本质。这里有个关键逻辑需要理解清楚双指针依赖的是数据的单调性才能成立。如果数组无序你无法判断左边这个数跟右边的数相加偏小了之后左边就该右移是否安全——因为右边可能还存在一个足够大的数。所以双指针前面往往跟着一个排序操作排序本身就是把双指针可以发挥作用的单调信息注入数组。1.3 三大经典范式总览双指针不是只有一种形态。我总结下来题目虽然千变万化但核心范式逃不出三种理解了这三种你是真的能举一反三第一种是左右对撞指针也叫首尾指针。两个指针分别从头尾出发向中间靠拢典型题型是两数之和、三数之和、盛最多水的容器、回文判断。这类问题的共同特点是数据有序或者可以排序答案组合在数组两侧的可能性更大通过比较当前两端之和与目标值的大小来决定移动哪一端。第二种是快慢指针也常被称为龟兔赛跑。两个指针从同一个起点出发一个走得快每次走两步一个走得慢每次走一步。典型题型是链表中找环、找链表中点、找倒数第K个节点。这类问题充分利用了速度差带来的距离差从而在不额外申请空间的前提下定位特殊位置。第三种是滑动窗口可以看作是同向双指针。两个指针一前一后像一个可以伸缩的窗口一样从左往右滑动窗口维护着一个连续的子结构。典型题型是找最长无重复子串、最小覆盖子串、长度最小的子数组。这类问题的共同特点是求的是连续区间的最优解且区间左右边界只会单调右移。这三种范式的代码长相差别挺大但底层逻辑是同一个——利用单调性信息让指针移动一次就能安全排除一批情况避免无效遍历。后面我会一章一章地拆。2. 三大范式逐一拆解什么时候用哪种2.1 左右对撞指针左右对撞指针的应用面最广也最容易理解。它做的事情很直观一个指针放在左端一个指针放在右端根据当前状态下两个指针指向元素的和或某个判定条件决定是移动左指针还是右指针直到两个指针相遇或者找到答案。我以接雨水这个题目为例来多说一句。网上很多讲双指针的帖子都会拿它当进阶题但其实它的核心逻辑仍然是左右对撞。左右两个指针分别维护当前已知的左右最大高度哪边的最大高度偏低哪边就是当前能积水的瓶颈于是移动哪边的指针。这个思路特别典型——双指针的移动方向永远取决于当前状态下的决定性因素。在实际做题的时候对撞指针有几个判断经验我总结成下面这张表场景特征推荐套路典型题目有序数组、找两元素之和等于target首尾相加、对比target决定移动方向两数之和 II无序数组、找三数之和等于0先排序固定一个数再对撞找两数三数之和数组按高度排列、求最大容器面积哪边矮移动哪边盛最多水的容器判断字符串是否为回文首尾字符对比不等则失败验证回文串对撞指针的移动是否安全靠的是你已经提前证明了被移动过去的那一侧所有剩余元素都不可能是答案。这个证明过程才是面试官真正想听的而不是你代码能跑通。代码谁都能背能把为什么移动左指针讲清楚的人才算真的掌握了这个算法。2.2 快慢指针快慢指针是链表题里的常客也是唯一一种经常不需要排序就能用的双指针形态因为链表的单调性不体现在数值上而体现在结构上。最经典的题目就是环形链表检测。一个链表如果存在环你用普通遍历的话会陷入死循环。快慢指针的解法是快指针每次走两步慢指针每次走一步如果链表里有环快慢指针一定会在某个位置相遇。如果链表无环快指针会先走到null循环正常结束。为什么有环就一定会相遇很多人背结论不理解背后的数学原理。我简单推导一下假设快指针进入环的时候慢指针还在环的入口处前方。快指针落后慢指针的距离设为d按环内圈数取模后的余数每走一轮快指针比慢指针多走一步那么经过d轮之后快指针就追上慢指针了。这个每轮多一步就是整个算法的数学根基也是快指针步长固定为2的原因——步长可以是3、可以是4但步长太大反而可能跳过慢指针步长2是最稳妥的选择。快慢指针还有个衍生用途找链表中点。同样是快走两步慢走一步当快指针到达链表尾部时慢指针正好停在中间。这个技巧在很多算法题里都是前置步骤比如归并排序的链表版本、回文链表的判断、重新排列链表都需要先找到中点。链表里找倒数第K个节点也可以用快指针先走K步的变体本质仍是速度差的利用——快慢指针不是只能快2倍。2.3 滑动窗口滑动窗口是我个人觉得这三种范式里最需要动脑子的因为它的核心不在指针移动的对撞逻辑而在于窗口合法性维护。滑动窗口解决的问题有一个明显的共同点题目里总会出现连续子串、连续子数组、最长、最短这类字眼。比如找不含重复字符的最长子串长度、找元素和大于等于target的最短子数组。这类问题如果暴力枚举所有子串复杂度是O(n²)因为子串本身就是O(n²)量级的。滑动窗口的思路是既然窗口左右边界都只会单调向右移动那窗口在遍历过程中经历的状态总数只有O(n)个复杂度自然降到了O(n)。滑动窗口的代码模板我写了无数遍已经形成肌肉记忆了def sliding_window(s): n len(s) left 0 window {} # 或者其他维护窗口状态的数据结构 result 0 for right in range(n): # 1. 把新元素加入窗口 # 2. 当窗口不满足要求时不断移动left收缩窗口 # 3. 更新答案 return result这个模板的关键点在于第二行的while循环——窗口收缩的条件怎么写什么时候移动left很多新手写滑动窗口写不好就是卡在这个窗口什么时候该缩的判定上。我自己的经验是先把窗口的合法定义用一句人话写出来再翻译成代码条件。比如不含重复字符就是窗口内的字符集合大小等于窗口长度比如此时发现下一个字符已经在窗口里了就要一直左移left直到冲突解除。滑动窗口的答更新时机也有两种一种是收缩前更新求最长一种是收缩后更新求最短。求最长的时候窗口合法时记录长度求最短的时候窗口不合法时收缩收缩后窗口刚合法的那一刻记录长度。这两个时机搞反了输出结果就会差1属于特别容易被忽视的细节。3. 实操过程与核心案例实现3.1 入门案例两数之和 II这道题是双指针的Hello World级题目。给定一个已按升序排列的整数数组和一个目标值找出两个数使得它们的和等于目标值返回它们的下标且下标从1开始计数。我先写一遍完整的代码再解释每一步的意图def twoSum(numbers, target): left, right 0, len(numbers) - 1 while left right: current_sum numbers[left] numbers[right] if current_sum target: return [left 1, right 1] elif current_sum target: left 1 else: right - 1 return [-1, -1]这段代码只有20行不到但里面有两个需要理解的为什么。第一个为什么为什么 current_sum target 时移动的是 left因为数组是有序递增的此时 left 指向的是当前搜索区间里的最小值之一与 right 搭配当前和小于目标说明 left 这个值太轻了。如果保持 left 不动、去尝试更小的 right和只会更小所以 left 位置这个元素可以彻底排除左指针右移。第二个为什么为什么循环条件是 left right 而不是 left right因为我们要找的是两个不同位置上的数指针相遇时指向的是同一个元素不可能组成一对有效答案。循环结束条件写 left right 也没有语法错误但会多一次无意义的判断而且如果返回结果时 left 和 right 相等还会把同一个元素用两次这是逻辑错误。这道题里用 left right 是语义上的必然不是无谓的细节。这道题的时间复杂度是O(n)空间复杂度是O(1)。相比于暴力解的O(n²)这是一个质的飞跃——数组长度扩大10倍暴力解慢100倍双指针只慢10倍。我面试的时候喜欢用这道题做暖场因为它的代码量小但足以考察候选人是否真的理解如何排除不可能的组合。3.2 进阶案例三数之和三数之和是面试高频题也是双指针排序去重组合拳的最典型代表。题目要求给定一个整数数组找出所有三元组使得三个数之和等于0且答案中不能包含重复的三元组。如果直接暴力枚举三层循环O(n³)直接爆炸。标准的双指针解法是先排序然后固定最左边的数 i再用对撞指针在 i 的右侧区间里找两数之和等于 -nums[i]。def threeSum(nums): nums.sort() n len(nums) result [] for i in range(n - 2): 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: result.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 elif total 0: left 1 else: right - 1 return result这道题的复杂度是O(n²)排序O(n log n) 固定i的n次对撞每次对撞O(n)对三层暴力来说已经降了一个数量级。真正容易错的不是双指针本身而是去重。三个位置都可能产生重复固定元素 i 重复、左指针元素重复、右指针元素重复。这三处去重全写对代码才没有瑕疵。我踩过的坑是固定元素 i 的去重条件写成if nums[i] nums[i - 1]或者写成nums[i] nums[i 1]都会出错。写成后者会把合法的组合错误地跳过比如[-1, -1, 2]这个三元组如果因为nums[i] nums[i1]就把第二个-1跳过了那这个组合就永远找不出来了。正确写法是比起前面一个元素、而不是后面一个元素。3.3 进阶案例盛最多水的容器这题考察双指针的反直觉之处。给定一串垂直线的高度每两条线和x轴组成一个容器要找出能装最多水的两条线。很多新手第一次做这题会本能地想从最高的那对线开始找起不是的标准的双指针解法是哪边矮就移动哪边。def maxArea(height): left, right 0, len(height) - 1 max_water 0 while left right: area min(height[left], height[right]) * (right - left) max_water max(max_water, area) if height[left] height[right]: left 1 else: right - 1 return max_water为什么哪边矮就移哪边因为容器的盛水量由短板决定min(height[left], height[right])再乘以底边长度。当 height[left] height[right] 时当前 left 这个位置已经是固定不变的最小值——如果保持 left 不动、只移动 right底边一定变短而短板高度不可能超过 height[left]因为你把 right 往左移只会遇到更低或更高的线但短板依然是 height[left] 或更低所以容器的装水量一定不超过当前值。既然如此left 这个位置在当前状态下已经天花板封死于是放心左移。这道题的理论基础是数学上的逐步排除法每一步都排除了一个不可能成为最优解的边界线剩下的区间继续探索。它和三数之和不同之处在于它不需要排序因为数组的原始顺序就是x轴坐标排序会破坏这个信息。这也是我很推荐的区分双指针题型的思路——先判断这个题目的单调性从哪来。三数之和的单调性来自排序后的数值盛水容器的单调性来自坐标顺序本身就是底边长度的递减关系。3.4 经典变体环形链表与滑动窗口实战环形链表的快慢指针实现我这篇再放一次完整代码因为后面要针对它讲调试经验def hasCycle(head): slow, fast head, head while fast is not None and fast.next is not None: slow slow.next fast fast.next.next if slow fast: return True return False滑动窗口方面我拿无重复字符的最长子串作为配套实战题def lengthOfLongestSubstring(s): window set() left 0 result 0 for right in range(len(s)): while s[right] in window: window.remove(s[left]) left 1 window.add(s[right]) result max(result, right - left 1) return result这两道题放在一起说是因为它们各自代表了一个容易踩坑的模式。环形链表里如果 while 条件写错比如只判断 fast 不为空、不判断 fast.next 不为空当 fast 已经在链表末尾时访问 fast.next.next 会直接抛空指针异常。滑动窗口里如果把while s[right] in window误写成if s[right] in window就可能在多个重复字符连续出现时窗口收缩不到位结果偏大。这些坑我后面在常见问题章节里展开细讲。4. 常见问题与排查技巧实录4.1 边界条件到底怎么写left right 还是 left right这是一个被我反复念叨、但依然有无数人写错的问题。双指针的循环终止条件到底怎么写取决于你找的是两个不同元素还是允许指针指向同一个元素。左右对撞类型的题目比如两数之和、三数之和、盛水容器、验证回文串几乎都是找两个不同位置的元素所以用 left right。你要是写成 left right就会出现一种隐蔽的bug当 left 和 right 指向同一个元素时这个元素被当成了两个数来参与计算在某些特例下会给出错误答案。比如[1, 3, 5]中找和为6的两个数正确答案是(1, 5)如果你允许 left 和 right 相等时循环继续那 leftright1索引为1的元素3时 336就会错误地返回[3, 3]。快慢指针的终止条件和对撞指针不同。环形链表里终止条件是 fast 到达 null 或 fast.next 为 null找链表中点时终止条件是 fast 到达末尾。这类问题的终止条件要结合链表的结构来判断不能套用 left right 的模板。滑动窗口的终止条件则隐藏在 for 循环的自然结束里面left 收缩的 while 子循环条件需要额外注意不要 left 越过 right。比如有时候 while left n and window 不合法 这种情况就要加上 left 本身也不会越界的保护。我自己的习惯是写循环条件之前先在注释里写一句话明确退出循环时指针应该处于什么状态。这步虽然多花十秒钟但可以少调半天bug。4.2 死循环和越界的常见原因双指针的死循环十有八九是同一个原因指针更新逻辑只写在某一个分支里而另一个分支忘记更新指针。拿三数之和来说有人会在 total 0 的分支里忘记写 right - 1或者写了左指针去重循环后没有再次更新 left。一旦某个分支没有指针移动while 循环里的判断条件永远不变化程序就卡死了。越界问题则更常见于链表题和滑动窗口题。链表里访问 fast.next.next 之前必须先保证 fast 和 fast.next 都不为 None否则就会出现空指针异常。顺序不能反必须先判断 fast 非空再判断 fast.next 非空。很多面试者在写代码时容易漏掉 fast.next 的判断因为他们只想着快指针要跳两步忘了跳之前得确认脚下有路。滑动窗口里越界比较容易发生在 left 向右收缩的时候——如果 while 收缩条件是直接操作数组索引 s[left]就要时刻警惕 left 可能越过 right。比如求最小覆盖子串这类复杂滑动窗口题当 left 已经把窗口收缩到和 right 重合甚至越过时再引用 s[left] 就可能越界。稳妥的做法是在缩小窗口的 while 循环条件里加上 left right 的保护或者先从代码逻辑上保证窗口一定非空。4.3 去重问题的三处细节三数之和的独家避坑指南三数之和的去重我真的是被折磨了好久才彻底搞明白。很多人包括我以前只记得固定元素要去重结果左指针和右指针的重复杂交叠产生重复结果。这里我给出一套经过验证的排查清单第一处固定元素 i 的去重必须是当前元素和前一个元素比较即if i 0 and nums[i] nums[i-1]: continue。不能写成和后一个元素比较。这个原因前面分析过核心是保证每组相同值的固定元素只处理第一个出现的同时不影响后面第二个同样的值在另外的 i 上下文中被用作合法组合成员。第二处找到一组解之后左右指针内部要连环跳过所有重复值。这部分的正确顺序是先跳过重复的左指针元素再跳过重复的右指针元素最后统一执行 left 1 和 right - 1 进入下一组搜索。如果顺序乱了比如先 left 1 再跳重就可能跳过边界或者跳不干净。第三处去重条件里的边界保护。这个细节最小的题目也最容易忽略while left right and nums[left] nums[left 1]中必须把 left right 放在前面。否则在 left 已经到达 right 附近、且相邻两个元素相等时left 会一路越界到数组末尾。Python 在这个问题上不会报错但会返回一个完全错乱的结果。只要把这三处去重全写上三数之和的输出结果就不会有任何重复。我面试时经常看到候选人写出一种看起来对但提交超时或重复的版本根源就是这三处细节遗漏。4.4 复杂度分析怎么讲清楚双指针的面试光写对代码还不够你得把自己的算法复杂度讲明白。很多候选人背书背得溜一问为什么是O(n)就开始含糊。我来提供一个稳的输出框架。先讲为什么暴力解是那个复杂度——暴力枚举把所有组合全试了一遍组合数量本身就是O(n²)所以它是O(n²)。然后讲双指针是如何把组合数压缩到O(n)——每次移动一个指针就排除掉了当前指针位置的一整批无效候选总的排除次数不超过两个指针总的移动距离之和而这个总距离是O(n)。用大白话讲就是指针从两端走到中间左指针最多走n步右指针最多走n步两步加起来就是2n步所以复杂度是O(n)。滑动窗口的复杂度分析略有不同。很多人会误以为窗口收缩的 while 循环让总复杂度变成了O(n²)。实际上每个元素最多被加入窗口一次、被移出窗口一次left 和 right 各自单调移动所以总操作数仍是O(2n)整体线性。这个点在面试中特别值钱——你说出每个元素进出窗口恰好至多一次面试官就知道你真的理解了滑动窗口的摊销分析。链表快慢指针的复杂度是O(n)因为快指针是2倍速它遍历完整个链表或环耗时仍然是线性级别。额外空间复杂度是O(1)这个也是快慢指针相比哈希表法O(n)空间的核心优势。我通常会提醒一句空间复杂度为O(1)意味着这个解法可以在无法申请额外空间的嵌入式场景下直接用。4.5 我的调试三板斧最后分享三个我平时调试双指针代码的实用技巧。第一招打印指针轨迹。写一个小的辅助日志输出每一步的 left、right 以及当前判断值。有时候肉眼看到指针怎么移动瞬间就明白逻辑哪里断了。尤其是滑动窗口类的题目打印出窗口区间帮助极大因为你能直观看到窗口收缩晚了或者窗口缩过头了。第二招用小数组手算。不要一上来就测大数组先用[-1, 0, 1, 2, -1, -4]这种长度的用例自己拿笔在纸上画一遍双指针的移动过程。这种手工推演特别管用尤其是三数之和的去重逻辑画一遍比调十次print都有效。第三招面向极端用例测试。双指针代码对边界极其敏感。测试用例至少覆盖空数组、只有一个元素、左右指针一开始就相遇、数组全相等、数组递增、数组递减。[1, 2]找两数之和3[]找三数之和0单节点链表判环这些极端用例一跑大多数隐性bug都会现形。以上就是双指针算法从原理到实战的全部核心内容。我在实际刷题和面试经历中最大的体会就是背模板只能保底理解指针移动的方向实际上是数据规律给出来的才是真正拉开差距的地方。把这个问题想通了以后遇到任何变体题目你都能在脑海里模拟指针怎么走做到真正的举一反三。三数之和、盛水容器、滑动窗口这几道题我建议你用这套思路亲手推几遍卡住了就回来对照这篇的排查清单相信你很快就能把它收进自己的武器库。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进