资讯动态

双指针算法详解:三大范式与五道LeetCode经典例题推演

发布时间:2026/9/7 18:53:37 来源:尧图企业网站定制
很多刷 LeetCode 的朋友第一次接触“双指针算法”都会以为它只是一种“高级循环优化技巧”。我最早也是这样理解的直到后面越刷越发现这个想法会严重限制你的思路。双指针真正的价值不是让某个 for 循环运行得更快而是把问题从“逐个比较”变成“有方向地排除”直接把时间复杂度从 O(n²) 拉低到 O(n)。这句理解到位了很多题你会豁然开朗。这篇文章我会把双指针的原理、三种常见范式、五道 LeetCode 经典例题的完整推演过程以及我踩过的一些坑全部写清楚。适合刚开始刷题、准备面试的读者也适合已经刷过一些题但总觉得双指针思路转不过弯的朋友。我会尽量把每一步“为什么这么做”讲透而不是只丢个模板让你背。1. 先别急着刷题双指针到底省在哪一步1.1 暴力解法的瓶颈在哪我们拿最简单的场景举例有一个升序数组让你找两个数使它们的和等于 target。最直觉的写法就是两层 for 循环外层选第一个数内层选第二个数逐一配对比较。代码写出来很顺但问题是这个方法的比较次数是 C(n,2)也就是 O(n²) 级别。当数组长度从 10 变成 10 万计算量会放大 1 亿倍这显然不行。那暴力解法的“浪费”在哪在于它把所有组合都当作“等可能有效”来对待。可实际上数组是升序的这个信息本身就是线索。当 numbers[left] numbers[right] 比 target 大时说明什么说明在保持 left 不动的情况下right 右边所有候选都不可能更小同时 right 再往右走只会更大。所以那一整片组合就都不需要看了直接 right-- 就能跳过一大批无效比较。这种“一次移动排除一片”的能力才是双指针效率高的根本原因。1.2 双指针削掉的是“不可能区间”你可以把双指针理解成一个“搜索空间的压缩过程”。如果我们有一维数组暴力解是在二维矩阵里做全面搜索每一个坐标 (i, j) 都是一次比较而双指针从矩阵的两个角出发每次根据当前结果判断“哪半边已经没有希望了”然后把它整个扔掉。很多人学双指针时只记住了代码长什么样却忽略了一个关键前提指针移动必须有一个单调的依据。有序数组可以利用大小关系做判断链表可以利用“是否存在环导致速度差”滑动窗口可以利用“子数组满足某种单调性质”。如果题目本身不提供这种单调性双指针是不能硬套的。明白这一点比背一百道题的模板都有用。我在带训练营时经常发现一些学员明明写过不少双指针题可碰到变形题还是不会。原因就是他们只记忆了“left 还是 right--”的机械动作而没有去问自己这一步移动让我排除了哪些组合这个排除是否永远安全这篇文章后面所有例题我都会围绕这个问题展开。2. 双指针的三种范式先建立肌肉记忆双指针听起来是同一个概念但实际刷题时你会遇到三种不同的形态。我这里先把它们的场景和模板讲清楚后面例题再一个个对应。2.1 对撞指针相向双指针对撞指针也叫左右指针就是初始化 left 指向数组头部、right 指向尾部然后根据当前判断结果决定移动左边还是移动右边直到两个指针相遇为止。它最典型的应用场景是有序数组、回文判断、两数之和、三数之和、盛水容器这类问题。它的伪代码长这样left, right 0, len(arr) - 1 while left right: # 计算当前状态 if 满足条件: 记录答案 # 根据单调性决定移动哪一侧 if 应该增大某一指标: left 1 else: right - 1题目千变万化但都逃不出“移动哪边、为什么移动”这个核心。做对撞指针题你每次写移动语句之前都要逼自己说清楚一句话“我现在把 left 往右挪一下是因为 left 右边的所有位置结合当前 right 都不可能成为答案。”这句话说得出来你的代码就不会错。2.2 快慢指针快慢指针通常用于链表结构典型特征是一个每次走一步、一个每次走两步。它最常见的功能是环形检测如果链表中存在环快指针最终会追上慢指针如果不存在环快指针会先走到链表尾部。快慢指针的证明思路并不复杂。可以这样理解快指针每次比慢指针多走一步那么每过一个时间单位它和慢指针的距离就缩短一。如果环存在这个距离会在绕圈的过程中被缩短到零两个人必然相遇。反过来如果链表中没有环快指针先到达空节点直接结束。它不只能判环还可以用来找链表中间节点、找链表中倒数第 k 个节点等。核心代码模板如下slow fast head while fast and fast.next: slow slow.next fast fast.next.next if slow fast: # 存在环 return True return False这里特别提醒一句很多人写 while 循环时只判断 fast 不为空却忘了判断 fast.next 是否为空结果当 fast 位于链表最后一个节点时fast.next.next 会直接抛空指针异常。这种错误不调试个十分钟很难发现。2.3 同向双指针滑动窗口同向双指针也就是常说的滑动窗口两个指针都从数组头部出发right 负责扩张窗口left 负责收缩窗口。它最适合处理“连续子数组”“连续子串”满足某种条件的极值问题比如无重复字符的最长子串、长度最小的子数组、字符串排列判断等。滑动窗口的模板比前两种稍微多变一点但骨架是稳定的left 0 for right in range(len(s)): # 1. 把 right 指的元素纳入窗口更新状态 # 2. 当窗口不满足条件时移动 left 收缩窗口直到重新满足 # 3. 更新极值答案滑动窗口和双指针本质上是一回事差别只是在“两个指针同向移动、维护一个动态窗口”这个动作上。我们可以把窗口理解成一个滑动的水管右端一直往前通水左端看到有杂质就慢慢关小最后量一量水管最长能保持多长长度内的水是干净的。这个视角会让你写代码时非常顺手。3. 五道 LeetCode 经典例题完整推演下面我从 LeetCode 里挑五道覆盖面比较广的例题每一道都会先讲暴力解的痛点再推演双指针为什么能这么做。代码我用 Python 写但逻辑通用于任何语言。3.1 两数之和 IILeetCode 167题目大意给定一个已经按升序排列的整数数组 numbers请你找出两个数使它们的和等于目标值 target返回这两个数的下标且下标从 1 开始计数。暴力解法是两层循环枚举所有配对O(n²)。但数组是排序好的这一点非常关键。我们初始化 left 0right len(numbers) - 1然后看 numbers[left] numbers[right] 和 target 的关系如果 sum 恰好等于 target直接返回如果 sum 小于 target说明当前组合小了要在整体上增大加和。left 已经是最左边的元素想增大只能让 left 右移一位如果 sum 大于 target说明当前组合大了只能让 right 左移一位来减小加和。很多人会觉得这个逻辑太简单了但它背后正好对应我们第 1 节说的“排除不可能区间”。当 sum 小于 target 时可以确认以当前 left 为左指针、right 右边的任意元素为右指针的组合它们的和只会更大不可能等于 target。所以你完全可以把这些组合一次性忽略。每一个指针移动都意味着排除了很多“无效组合”这也就是为什么它能达到 O(n) 的时间复杂度。对应代码def twoSum(self, numbers: List[int], target: int) - List[int]: left, right 0, len(numbers) - 1 while left right: cur_sum numbers[left] numbers[right] if cur_sum target: return [left 1, right 1] elif cur_sum target: left 1 else: right - 1 return [-1, -1]这道题是双指针的“入门题中的入门题”但它把核心思想体现得很完整。注意一点题目要求返回下标从 1 开始所以代码里要补一个 1。很多人面试一紧张就漏掉这个小细节表面上不影响算法逻辑但在 LeetCode 上会直接 Wrong Answer。3.2 盛最多水的容器LeetCode 11题目大意给定一个数组 heightheight[i] 表示柱子的高度选择两个柱子作为边界形成一个容器容器能装的水量等于两根柱子之间距离乘以两个柱子的较小高度求最大水量。这道题很多人面试时会先想到枚举所有左边界和右边界然后计算水量取最大值O(n²)。LeetCode 上数据规模比较大的版本这样写会超时。改成双指针后我们仍然初始化 left 指向最左right 指向最右。当前水量 min(height[left], height[right]) × (right - left)。然后在移动时我们会遇到一个之前两数之和里没有出现过的抉择当两边的柱子高度不一致时应该移动高的那边还是矮的那边答案是移动矮的那边。原因值得好好推敲。容器高度由较短的那根柱子决定这是“短板效应”。假设 height[left] height[right]此时我们如果移动 right也就是把较高的右侧柱子向内挪会发生什么右侧柱子向内挪不管挪到哪一根宽度都变小了而短板还是左侧这根新高度不会超过原来的高度因此容器的总水量只可能减少而不可能增加。也就是说以当前 left 为左边界的所有组合都已经试过了最好的就是现在这个。所以让 left 右移看看换一根左侧柱子有没有机会更大。如果左边高于右边则对称地移动 right。这里有个小细节如果两边高度一样移动哪边都可以因为两边都是当前的“短板”后续结果由新的更高或相等柱子去突破。写成代码就是这样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这道题的“为什么移动短板”是我在面试里最喜欢追问的一个点。刷题时你可以直接背结论但面试官一旦追问“那你为什么不移动高的那边”你要能像上面那样把宽度变小、高度不可能增大的推理链条说清楚。能把这道题讲明白双指针的证明能力基本就过关了。3.3 环形链表LeetCode 141题目大意给定一个链表的头结点 head判断链表中是否有环。环的定义是链表中某个节点的 next 指针恰好指向链表中在它之前出现的某个节点。常见的做法是用哈希表记录访问过的节点如果某个节点被访问两次就说明有环。这个方法空间复杂度是 O(n)。但如果采用快慢指针可以把空间复杂度降到 O(1)。慢指针每次走一步快指针每次走两步。如果链表无环快指针会先走到链表末尾也就是 fast 变为 null 或者 fast.next 变为 null这时直接返回 False。如果链表有环那么快指针会进入环内一直绕慢指针也会进入环内之后因为两者速度差相当于快指针每次追近一步所以经过有限次循环后必然追上慢指针这时返回 True。这里有一个值得展开的细节为什么快指针每次走两步而不是走三步、四步两步确实不是唯一选择但两步写起来最安全。步长越大可能出现“跳过”慢指针的情况比如慢指针在节点 A快指针从 A 的后面一个节点一步跳到 A 的前面一个节点导致无法直接相遇。步长增大后依然可能在多次循环中追上但证明和边界处理会更复杂。面试或比赛场景下两步已经是最稳妥的选择。代码def hasCycle(self, head: Optional[ListNode]) - bool: slow fast head while fast and fast.next: slow slow.next fast fast.next.next if slow fast: return True return False我见过不少人会问“为什么 slow 不用记录走过的路径”因为快慢指针相遇的判定就是两个节点在内存中的引用相等不需要额外路径记录。这一点想通了其实非常有美感速度不同但在同一条环形跑道上跑步跑得快的终究会追上跑得慢的不用记录任何历史轨迹。3.4 删除有序数组中的重复项LeetCode 26题目大意给你一个按升序排列的数组 nums请你原地删除重复出现的元素使每个元素只出现一次并返回删除后数组的新长度。要求不使用额外数组空间只能在原数组上修改。这道题用到的双指针是同向双指针经常被称为“读写指针”。我们可以把 left 理解为“已完成处理的数组末尾”的写入位置right 理解为“正在扫描的数组位置”。初始时 left 1right 1因为数组第一个元素天然保留。然后让 right 从第二个元素开始遍历如果 nums[right] 不等于 nums[left - 1]说明遇到了一个新元素把它写到 nums[left]然后 left 加一如果相同right 继续往前走。这里指针移动的依据是“数组是升序的所以重复元素必然相邻”。只要发现当前位置的元素和前一个已保留元素不同它就是一个新的唯一元素。因为我们是原地修改所以直接把值拷贝到前面即可后面多出来的部分可以忽略只要返回的新长度是 left 就行。代码def removeDuplicates(self, nums: List[int]) - int: left 1 for right in range(1, len(nums)): if nums[right] ! nums[left - 1]: nums[left] nums[right] left 1 return left注意一个经常被忽略的细节为什么对比的是 nums[left - 1] 而不是 nums[right - 1]原因是 nums[left - 1] 是“最后一个已被保留的元素”而 nums[right - 1] 可能是一个已经被跳过的重复值用它做比较可能会导致漏保。这个点我在代码审查时看到过不止一次写错结果出现奇怪的错误。你可以停下来跑一个例子比如 nums [1, 1, 2, 2, 3]分别用两种方式比一比就会知道差异在哪。3.5 无重复字符的最长子串LeetCode 3题目大意给定一个字符串 s请你找出其中不含有重复字符的最长子串的长度。这道题是滑动窗口同向双指针的经典代表。我们用 left 表示窗口左边界right 表示窗口右边界并借助一个集合 seen 来记录当前窗口里出现过的字符。right 每向右移动一步就把新字符纳入窗口如果这个字符没在集合中出现说明当前窗口仍然合法记录当前窗口长度如果这个字符已经在集合中出现说明窗口需要收缩于是不断从窗口左侧移除字符、同时 left 右移一直到这个重复字符被移出窗口为止。注意这里循环的条件当 s[right] 已存在于 seen 中时我们移除的是 seen.remove(s[left])也就是窗口最左边的字符。这个过程可能循环多次直到那个和 s[right] 重复的字符真正被移出为止。然后我们再把 s[right] 加入集合窗口又恢复成一个不含重复字符的状态。def lengthOfLongestSubstring(self, s: str) - int: left 0 seen set() ans 0 for right in range(len(s)): while s[right] in seen: seen.remove(s[left]) left 1 seen.add(s[right]) ans max(ans, right - left 1) return ans这个写法的时间复杂度是 O(n)因为每个字符最多被 left 移除一次、被 right 加入一次虽然是嵌套循环但整体操作次数是线性的。这比 O(n²) 的暴力枚举快得多。我想额外提一个问题为什么这里用哈希集合而不是一个简单的计数数组对于字符串字符集是有限的情况计数数组当然可以但集合的好处是逻辑更直观清理状态时只要把重复元素从窗口里挪出去不需要维护每个字符的出现次数。不过如果题目要求你输出最长子串本身而不是长度你可能还需要维护一个 map 来记录每个字符最近一次出现的位置从而让左指针跳得更快。这类变体题在面试中也很常见建议刷完这道题后去试试 LeetCode 3 的相似题比如 LeetCode 159 和 340加深对窗口收缩时机的理解。4. 我踩过最深的几个坑一次性说清楚双指针代码看着短但写错的概率真的不低。我把这几年帮学员 debug 时遇到最多的几个问题列出来每一个都是真实踩过的坑。4.1 排序、去重、指针移动的顺序遇到无序数组需要用到对撞指针时第一件事就是想到排序。但排序不是万能的如果题目要求返回原始数组的下标排序后下标就丢了。这种时候你需要考虑是否提前构造一个包含原始下标的结构体或者在排序前记录好映射关系。另一个和顺序相关的坑是去重。很多人在三数之和这类题里去重逻辑放错位置导致重复解或者漏解。正确做法是外层循环对第一个数去重判断if i 0 and nums[i] nums[i - 1]: continue双指针内部找到一组解后也要移动指针跳过所有相同值。去重位置写错的人我会让他用[-2, 0, 0, 2, 2]这组数据手跑一遍马上就能发现问题。4.2 while 边界写得太松或太紧对撞指针最常见的循环条件是left right写错成left right会出现什么在部分题目里会重复计算同一个元素或者导致越界访问。比如两数之和中如果 left 和 right 指向同一个元素你把同一个值用了两次但数组中其实只有一个这样的数结果就不符合题意。同向双指针也容易犯类似的错误。滑动窗口的收缩条件是while那种“不满足条件就一直收”有些人会写成if导致窗口没有收缩彻底后续状态不正确。区别就是while是“收干净”if是“只收一次”。写代码前先想清楚当前条件最多可能需要连续收缩几次。4.3 快慢指针漏判空指针环形链表那道题我前面已经提过fast and fast.next这个条件必须两个都写。但还有一个更隐蔽的坑如果你把slow slow.next放在判断相等之前已经错误地移动了慢指针可能在有环的情况下让相遇检测失真。正确做法是先分别移动快慢指针再判断是否相等不能先判断再移动也不能只移动一个就判断。链表题还有一个老生常谈的问题如果传入的 head 本身就是空指针很多人的代码会直接崩。需要在循环开头就判断if not head: return False或者利用循环条件天然处理掉。这个虽然简单但确实能决定面试时第一道题能不能快速通过。4.4 滑动窗口不该收缩时收缩滑动窗口里最容易出问题的就是“什么时候收缩窗口”的定义不清晰。拿无重复字符的最长子串为例有人会在 right 移动到重复字符时直接把 left 跳到那个和 s[right] 重复的字符的下一个位置。这个思路本身没问题比一个个地移除字符效率更高。但你跳过去之后集合里需要同时清理掉所有被跨过的字符如果清理不干净后面状态就会混乱。我建议初学者先把“一个一个移除”的朴素写法练熟再去优化成“map 记录位置直接跳”。别一上来就追求最简代码结果写出来自己都看不懂。滑动窗口的调试通常很折磨人因为窗口内状态是被隐藏的。我的习惯是在 while 循环里打印当前 left、right 以及 seen 集合的内容肉眼确认状态变化是否符合预期。5. 怎么练出“一看就知道用双指针”的题感5.1 三类题型的关键特征很多人刷题时最苦恼的不是写不出代码而是拿到题根本不知道往哪个方向想。这里我总结了一套自己的直觉流程不一定适用所有人但值得一试。第一类对撞指针。看到关键词有序数组、回文、两数之和/三数之和、盛水容器第一反应就应该是左右双指针往中间夹逼。这种题的共同点是你需要在数组中找一对元素满足某种大小或匹配关系。第二类快慢指针。看到关键词链表、环、中间节点、倒数第 k 个第一反应就应该是快慢指针。链表不像数组可以用下标随机访问所以双指针是最自然的加速工具。第三类滑动窗口。看到关键词连续子数组、子串、无重复、覆盖、最大/最小长度第一反应就应该是同向双指针维护窗口。这类题的共同点是你的答案一定对应一个连续区间而且这个区间在扩张或收缩时具有单调性质。5.2 不适合双指针的场景双指针不是万能的。如果问题要求的不是“一对元素”而是“所有组合”例如组合总和需要输出所有可能的组合那就必然要依赖回溯双指针只能作为剪枝辅助。如果问题涉及子序列可以不连续的匹配或最值也通常不能用双指针因为子序列失去了数组的连续性双指针无法单靠移动位置来排除解。另外要注意一点滑动窗口只能用于单调收缩不破坏解的寻找的场景。如果窗口缩小之后可能把更优解也一并缩掉了那这个窗口模型就不能成立。判断方法很简单——问自己right 继续往前走left 可以往后收缩这个收缩是否会让“更大的窗口长度”无法再次出现如果会就一定不是滑动窗口能解决的。5.3 写题前的模拟习惯最后分享一个让我受益匪浅的习惯拿到一道双指针题先不要急着写代码而是拿一个简单的测试用例在纸上一步一步模拟指针移动。比如[1, 2, 3, 6, 8]target 是 9你把 left 和 right 分别指到两头走一遍完整流程记录每次移动后的 sum 值。跑完之后你会非常清楚地看到哪些组合被跳过了为什么可以跳过。这个模拟过程既帮你验证思路又能在面试时向面试官展示你思考问题的完整性。我在带新人时前两周都不允许他们直接看题解代码必须先在纸上模拟。等他们习惯了这个流程再难的变形题也能自己抠出解法。这个过程看似慢实际上是最快的。因为很多边界条件的坑你在模拟时会自己发现而不是提交代码后被判错再回来改。双指针这套思想说到底是“用已知信息排除未知解”的思维方式。代码短不代表它简单真正的高手是在三五行代码里把每一步的移动逻辑讲得滴水不漏。想掌握它没有捷径把经典题吃透、把坑踩一遍、把为什么想明白自然就通透了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价