双指针这个技巧,在 LeetCode 题解里出现的频率,基本上和大厂面试手撕算法的频率持平。说实话,我刷题到现在有个很深的感触:很多看似毫无关联的题,最后落到解法上,翻来覆去就是双指针的那么几种套路。这个系列前两篇聊了数组基础和一些入门暴力思路,这篇专门把“双指针”单独拎出来写,是因为它真的太容易被低估了。
很多人一开始觉得双指针不就是两个下标吗,有什么好学的。可真到了笔试或者周赛现场,遇到“三数之和”“最长无重复子串”“链表倒数第K个节点”这些变种,经常会出现两种情况:一是知道该用双指针,但边界处理不好;二是压根判断不出来这题其实是双指针的套壳。这篇刷题记录,我就把这几个月刷下来觉得最有代表性的双指针题目重新梳理一遍,包括左右对撞、快慢指针、滑动窗口三类模型,以及去重、判空、循环条件这些容易翻车的地方。
不管你是刚开始刷 LeetCode 的简单题,还是已经在冲 Hot 100、周赛,这篇文章都值得花二十分钟过一遍。我尽量不堆结论,直接把当时的思考过程、写错的版本和修正后的代码都贴出来,这样你复现的时候能少踩几个坑。
1. 双指针到底是什么:一次看清三类核心模型
1.1 为什么两个指针比一个指针快
先从最朴素的问题说起:一个数组,让你找两个数相加等于 target,你会怎么做?
最简单的暴力法是两层循环,外层固定一个数,内层再遍历剩下的所有数。时间复杂度 O(n^2),数据量一大就完蛋。双指针的思路是,与其让内层指针从头到尾扫描,不如利用数组本身的一些性质,让两个指针从两端或者同向移动,跳过那些明显不可能的区间。
用一个生活化的例子:想象你在一条排列整齐的货架前找两瓶酒,价格从左到右从低到高。暴力法是每拿一瓶,就把后面所有酒都拿起来看一遍;双指针的做法是你左手从最便宜那头开始,右手从最贵那头开始,两瓶价格加起来太贵了,就往左挪右手,太便宜了,就往右挪左手。每一步你都能排除一整段货架,而不是只看一瓶。
这里最关键的点在于“排除了不可能的区间”。双指针之所以能把 O(n^2) 降成 O(n),不是因为两个指针做了更复杂的事,而是因为指针移动的那一刻,你根据数据的有序性(或者某种约束),一次性砍掉了大量不需要比较的组合。
1.2 三种模型分别是干什么的
我刷了这几个月,自己心里把双指针分成了三类:
- 左右对撞指针:两个指针从数组两端出发,往中间走,通常处理有序数组、回文判断、容器面积、两数之和等问题。
- 快慢指针:两个指针从同一端出发,速度不一样,通常用来处理链表环、链表中点、链表倒数第K个节点、原地去重等问题。
- 滑动窗口:也是两个指针同向移动,但是维护的是一个连续的区间,通常处理子串、子数组的统计问题。
这三类对应着不同的输入形态和题目特征。比如输入是链表,大概率是快慢指针;输入是有序数组,优先想左右对撞;输入要求找连续一段、统计字符次数,基本就是滑动窗口。
有一个很常见的误区是:看到题目里有“连续”“子数组”就直接上滑动窗口,这是不行的。滑动窗口需要窗口的右边界移动时能明确判断出“什么时候该收缩左边界”,如果这个判断条件不存在,那滑动窗口就是伪命题。后面我会专门讲这个判断怎么建立。
1.3 反面教材:不是所有题都能套双指针
这里想提醒一句,判断题型比背模板更重要。拿热词里出现的两道题举例:
- LeetCode 994 腐烂的橘子。很多人一看矩阵、扩散、层数,就以为是双指针或者滑动窗口。实际上这题是标准的 BFS(广度优先搜索),要用队列按层传播腐烂状态。如果硬套双指针,连状态存储都会变成灾难。
- LeetCode 073 爱吃香蕉的狒狒。看似是遍历每一堆香蕉、计算吃的时间,好像有个“移动指针”的过程,但这题真正的解法是二分答案——通过猜总用时,判断当前速度是否可行。
这两道题被我拿出来说,不是否定双指针,而是想强调:双指针是一种“结构性”的解法,它依赖数据的排布方式。遇到一道新题,先判断输入形态和约束,再看是不是能套模型。顺序反了,代码写得再漂亮也是事倍功半。
2. 左右对撞指针:先解决“两数之和”到“三数之和”的进化
2.1 最基础模板:有序数组的两数之和
LeetCode 167 题是左右对撞最经典的入门题,输入是已经按升序排列的数组,让你找两个数使它们的和等于 target。这题不用哈希表,一个双指针就能搞定:
def two_sum(numbers, target): left = 0 right = 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 []这里的移动逻辑值得展开说。当 current_sum 小于 target 时,说明整体太小,右指针已经指向数组最大值,往左移动右指针只会让和更小,所以唯一的希望是把左指针往右挪,找一个更大的数。反过来也一样。每一步都能把搜索区间缩小一个单位,所以整体是 O(n)。
我一开始写的时候有个低级错误,把循环条件写成了while left <= right。表面看没什么,但如果是left == right时,两个指针指向同一个数,会导致下标重复或者死循环。对撞指针的终止条件永远是left < right,除非题目允许同一个元素用两次,但那种情况一般题目都会明确说。
2.2 三数之和:去重才是真正的考点
LeetCode 15 题三数之和,可以说是左右对撞里最经典也最容易犯错的一道。题目要求找出所有和为 0 的三元组,并且不能重复。
思路很好理解:先排序,然后固定一个数nums[i],在[i+1, n-1]区间内用双指针找两个数,使它们的和等于-nums[i]。这样就把时间复杂度控制在 O(n^2)。难点在于三个层面的去重:
- 外层固定的数不能重复。
- 找到一组解后,左指针要跳过重复的值。
- 右指针也要跳过重复的值。
我当时第一次写的时候只做了第一层去重,结果提交后返回了很多重复三元组。后来仔细想才明白,找到一组解之后,如果你不跳过那两个位置的重复值,下一轮可能又组出同样的三元组。
以下是我修正后的版本:
def three_sum(nums): nums.sort() result = [] n = len(nums) for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue left = i + 1 right = 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这里有个顺序问题值得强调:先记录有效答案,然后再跳过去重。如果顺序反了,先跳过重复值再记录,会漏掉正确结果。因为你是靠“当前这组值”判断是否等于 0 的,跳过之后指针指向的值已经变了。
2.3 从三数之和到四数之和:套娃可以,注意范围
四数之和(LeetCode 18)其实就是三数之和外面再套一层循环,先固定两个数,再对剩下的区间做双指针。复杂度和去重逻辑完全同理,只是代码上多一层continue判断。
我在刷这题时发现一个规律:这类“K 数之和”的题,难度不在双指针本身,而在于排序后有多少种“跳过重复”的组合。写之前先在纸上把所有去重位置标出来,代码才不会乱。具体做法是:
- 外层每一层
for循环都要判断当前值是否和上一个值相同,相同就跳过。 - 最内层双指针找到答案后,左右都跳过重复值。
- 如果当前的固定值已经让最小值大于 target 或者最大值小于 target,可以直接剪枝,能省不少时间。
说句实话,四数之和在面试中出现的频率不如三数之和,但它是很好的练习,能检验你是不是真的理解了嵌套循环里的指针关系,而不是背下了某个题的解。
2.4 面积和接水问题:左右对撞的另一个分支
除了求和类,左右对撞还经常用来解决“形状”类问题,最典型的就是 LeetCode 11 盛最多水的容器和 42 接雨水。
盛最多水的容器这题,两个指针分别指向容器左右边界,面积等于短边高度乘以宽度。移动指针时,总是移动较短的那一边,因为容器的盛水量由短板决定,移动长板只会让宽度减小,而高度不可能变大,面积必然不会增加。移动短板才有可能在“高度增长”和“宽度减少”的博弈中找到更大的面积。这个证明不复杂,但很多人会凭直觉写错,写成移动长板,结果答案差很多。
接雨水则稍微复杂一点,需要维护左右两侧的最大高度。用双指针时,每次比较left_max和right_max,哪边小就计算哪边的水量,然后移动对应指针。核心思想是当前位置能接多少水,取决于较矮一侧的最大高度。这题容易和单调栈混淆,其实两种都能做,双指针的优势是空间 O(1),代码也更简洁。
3. 快慢指针:链表问题的大半江山
3.1 快慢指针到底快在哪
数组有下标,链表没有。很多链表题天然适合用两个指针,一个走两步,一个走一步,用速度差来制造“位置关系”。
最经典的场景是判断链表有没有环。LeetCode 141 环形链表,快指针每次走两步,慢指针每次走一步,如果链表有环,两个指针最终一定会在环内相遇。这个结论的直觉是:进入环之后,快指针每次比慢指针多走一步,相当于在环形跑道上不断追赶,只要跑得足够久,必然套圈追上。
如果链表没有环,快指针会先到达链表尾部,遇到None就结束。所以在循环条件里要同时判断fast和fast.next是否为 None:
def has_cycle(head): if not head or not head.next: return False slow = head fast = head.next while slow != fast: if not fast or not fast.next: return False slow = slow.next fast = fast.next.next return True这里我把快指针初始化成了head.next,而不是head。两套写法差别不大,但初始化成head.next可以避免一上来就slow == fast导致误判成有环。这个细节,网上很多题解提了一嘴,但不注意的话会让你 debug 很久。
3.2 链表中点与回文链表
和环形链表紧密相关的是找链表中点。快指针每次走两步,慢指针每次走一步,当快指针到尾部时,慢指针正好在中点。
LeetCode 876 链表的中间节点,直接套用这个模板。这里需要注意的是偶数长度和奇数长度的差异:链表有 6 个节点时,慢指针会指向第 4 个节点(靠右的中点),有 5 个节点时指向第 3 个节点。如果你想取靠左的中点,需要调整初始化方式。
回文链表(LeetCode 234)就是把“找中点”和“反转链表”结合起来的经典题。先用快慢指针找到中点,再把后半段反转,然后前半段和后半段逐一比较。这题的考点不是双指针本身,而是双指针和其他技巧的配合。我第一遍刷的时候直接用了列表存所有节点值,虽然能过,但面试时面试官大概率会追问“能否用 O(1) 空间”,所以快慢指针版本才是更值得练的解法。
3.3 删除链表倒数第 N 个节点:一前一后指针的实战
除了快慢速度差,还有一种“间隔固定距离”的双指针用法,专门处理“倒数第 K 个”这类问题。
LeetCode 19 删除链表的倒数第 N 个节点。思路是让第一个指针先走 N 步,然后第二个指针从头部出发,两个指针保持 N 的距离一起走。当第一个指针走到链表末尾时,第二个指针正好指向倒数第 N 个节点的前一个节点,直接在它后面做删除操作。
这里有个非常重要的细节是使用 dummy node(哑节点)。如果不用 dummy,当链表长度等于 N 时,要删的就是头节点,逻辑上要多一个分支判断。用了 dummy 之后,所有情况统一处理,代码干净很多:
def remove_nth_from_end(head, n): dummy = ListNode(0, head) first = head second = dummy for _ in range(n): first = first.next while first: first = first.next second = second.next second.next = second.next.next return dummy.next我个人的习惯是:凡是可能删除头节点的链表题,一律先加 dummy。这不算性能优化,纯粹是让边界处理变简单。刷题时少一个分支,就少一个出错窗口。
3.4 快慢指针的进阶:环形链表入口
LeetCode 142 环形链表 II 算快慢指针里比较难的题,要返回环的入口节点。结论是:快慢指针第一次相遇后,把一个指针放回 head,另一个保持在相遇点,然后两个指针都一次走一步,再次相遇的位置就是环入口。
网上很多题解直接抛结论,没有解释为什么。我当时也困惑了很久,后来自己推了一遍:假设链表头到环入口的距离是 a,环入口到相遇点的距离是 b,环的长度是 c。慢指针走了 a+b,快指针走了 a+b+kc(k 是快指针绕环的圈数)。因为快指针速度是慢指针的两倍,所以:
2(a+b) = a+b+kc a+b = kc a = kc - b也就是说,从相遇点再走 a 步,刚好回到入口。这就是为什么放一个指针在 head,另一个在相遇点,每次走一步,相遇点就是入口。理解推导过程以后,这种题就不再是死记硬背了,遇到变化也能举一反三。
4. 滑动窗口:双指针的高级形态
4.1 窗口模板与无重复字符的最长子串
滑动窗口本质上也是两个指针,只是它们始终维护一个连续区间。右指针负责扩大窗口,左指针负责在条件不满足时收缩窗口。
LeetCode 3 无重复字符的最长子串是滑动窗口的入门题。思路是维护一个窗口,窗口内不能有重复字符。每加入一个新字符时,如果发现重复,就把左指针不断右移,直到窗口内不再包含重复字符。同时用哈希集合记录窗口内的字符,方便判断重复。
def length_of_longest_substring(s): window = set() left = 0 max_len = 0 for right, char in enumerate(s): while char in window: window.remove(s[left]) left += 1 window.add(char) max_len = max(max_len, right - left + 1) return max_len关键点在于:while循环把窗口收缩到合法状态之后,再加入新字符,最后再计算长度。为什么顺序不能变?因为如果你先算长度再收缩,算出来的长度可能包含了重复字符,是非法窗口。这个顺序问题,我见过很多人在白板上写错。
4.2 什么时候收缩窗口,什么时候更新答案
滑动窗口题最容易迷茫的地方是:收缩和更新答案的时机。
我总结了一套自己的判断方法:
- 如果题目求的是“最长子串/子数组”,通常是在窗口合法的时候更新答案,不合法时收缩窗口。
- 如果题目求的是“最短子串/子数组”,通常是在窗口满足条件时更新答案,然后收缩窗口尝试找更优解。
比如 LeetCode 76 最小覆盖子串,右指针每次扩展窗口,直到窗口包含所有目标字符,然后左指针收缩,同时更新最小长度。注意这里的更新答案发生在收缩过程中,因为每次收缩都可能得到一个更短的合法窗口。而最长无重复子串是收缩到合法后,再计算窗口长度。
很多新手把这两个顺序记反,导致“最短的题算出最长,最长的题算出最短”。我建议刷这几道题时,不要只背模板,而是自己拿一个简单用例在纸上走一遍,搞清楚每一步窗口里的内容,顺序自然就记住了。
4.3 滑动窗口的适用边界
滑动窗口虽然好用,但不是所有跟子串有关的题都能用。核心条件是:窗口的合法性必须能通过“加入右边界”“移出左边界”这两种操作维护。
举个例子,如果题目要求窗口内所有元素互不相同,这个条件可以用哈希集合维护,可以滑动。但如果题目要求窗口内所有元素之和等于一个固定值,并且元素可能是负数,那就不能用简单滑动窗口,因为负数的存在会让窗口的和既可能变大也可能变小,判断条件失去单调性,这时候可能要转换思路用前缀和。
LeetCode 周赛 430 里我印象比较深的一道题(具体题号记不太清了,不过思路一致)就有点类似这个情况,一开始想当然套滑动窗口,结果边界条件和负数的存在让窗口判断完全失效。后来转过头来重新分析约束,才发现本质上是一个前缀和加哈希的问题。这也是我想强调的:滑动窗口是双指针的一种,但它不是万能的,先判断单调性再使用,否则调试到怀疑人生。
4.4 滑动窗口的变式:固定窗口大小
还有一种更简单的形态是固定窗口大小。比如 LeetCode 643 子数组最大平均数 I,窗口长度固定为 k,你只需要先算前 k 个数的和,然后每次右移一位,减掉左边滑出的数,加上右边新进来的数,维护一个总和的最大值即可。
这种固定窗口本质上不是快慢指针,而是两个指针始终保持固定距离。和链表里的“删除倒数第 N 个节点”有异曲同工之处,都是利用间隔固定距离来定位。所以双指针的题型之间其实是有暗线互通的,刷到后面你会发现,很多技巧只是表达方式不同,核心逻辑都是在维护两个位置之间的关系。
5. 双指针高频踩坑实录:这些细节我花了大量时间才弄明白
5.1 去重:先收集结果再跳过重复值
三数之和这道题的去重,我前面已经写了正确版本,这里单独复盘一下错误版本。我一开始是这样写的:
# 错误示范 if total == 0: while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 result.append([nums[i], nums[left], nums[right]])我把跳过重复的代码放在了收集结果之前,导致什么结果呢?比如数组中有两个相同的值,我先把左指针跳到了最后一个相同值,再把结果收集上来,但实际上这可能已经不是当前left和right组合出来的结果了,会漏解或者出现错解。正确做法一定是先记录当前有效组合,然后再跳过重复值,最后再做一次left += 1和right -= 1,把指针移到下一组候选位置。
5.2 循环边界:<还是<=
对撞指针while left < right,快慢指针while fast and fast.next,滑动窗口while char in window,这些条件看着简单,写错一次就是死循环或者越界。
我最常犯的一个错误是在对撞指针里混用<=。当数组长度为奇数时,left == right意味着两个指针重合,指向的是同一个数。在“找两个数”的语境下,这通常是不允许的,因为你会拿同一个数当作两个数用。除非题目明确说同一个元素可以用两次,否则一律left < right。
还有链表的快慢指针,判断条件一定要先检查fast是否存在,再检查fast.next。顺序反了会直接抛空指针异常。正确写法是while fast and fast.next:,Python 里and是短路求值,先判断fast为真,才会判断fast.next,这个特性正好用来防止空指针访问。
5.3 空输入与单元素边界
别小看空输入,我有一段时间提交失败的案例,十有七八都是因为没处理空数组和单元素数组。
- 数组题:空数组直接返回空结果或者 0,单元素数组通常也不满足双指针的使用条件,要提前判断。
- 链表题:
head = None时,几乎所有的指针访问都会报错,所以每个双指针函数的第一步都该检查if not head。
为什么很多题解里没有单独写这个判断?因为有的解法天然覆盖了空输入。比如环形链表我写的if not head or not head.next就同时处理了空链表和单节点链表。但如果你的代码是slow = head; fast = head.next,那空链表直接崩溃。所以不是“要不要处理边界”的问题,而是“你的初始化方式决定了需不需要显式处理边界”。
5.4 复杂用例的测试顺序
我刷题时总结了一套针对双指针问题的自测用例,写完之后先跑这些,能少提交很多次:
- 空数组 / 空链表
- 只有一个元素
- 所有元素都相同
- 已经有序 / 完全逆序
- 答案在两端
- 答案在中间
- 目标不存在
- 大数据量下是否超时
比如三数之和,如果数组全是 0,正确结果应该是[[0,0,0]]而不是无限多个重复答案。如果数组是[1,2,3,4,5]且 target 是 9,双指针应该能快速定位[2,3,4]或类似组合。先在心里把这些用例过一遍,比直接提交官网测试要高效得多。
5.5 哈希表和双指针怎么选
很多双指针题,用哈希表也能做。比如两数之和的普通版本,哈希表是 O(n) 时间和 O(n) 空间;双指针加排序是 O(n log n) 时间和 O(1) 空间。两者各有优劣,不能因为刷了双指针就强行双指针。
我的选择标准很简单:
- 如果要求返回下标,而且原数组顺序不能动,优先用哈希表。
- 如果题目明确说可以排序,或者要求的不是下标而是值本身,才考虑双指针。
- 如果题目的输入已经有序,那双指针是绝对的优先选择。
这个选择逻辑在面试里很重要。你说你用的是双指针,面试官想听的是你为什么不用哈希表。你把时间和空间的 trade-off 说清楚,比直接默写代码更能加分。
6. 双指针的刷题策略:从母题出发建立题感
6.1 母题清单与变式练习
如果让我只推荐一组双指针母题,我会选这五个:
- LeetCode 167 两数之和 II(左右对撞入门)
- LeetCode 15 三数之和(去重与嵌套)
- LeetCode 11 盛最多水的容器(贪心与移动决策)
- LeetCode 141 环形链表(快慢指针入门)
- LeetCode 3 无重复字符的最长子串(滑动窗口入门)
为什么选这五道?因为它们分别对应了双指针的三个模型里最典型的场景,而且都能作为母题扩散出大量变式。比如会做三数之和,四数之和就只是多套一层循环;会做环形链表,找环入口就只需要多推一个数学公式;会做无重复字符的最长子串,最小覆盖子串就只是把集合换成计数映射并且调整收缩时机。
刷完母题以后,我建议按下面这张表做变式练习:
| 母题 | 变式题目 | 变化点 |
|---|---|---|
| 167 两数之和 II | 15 三数之和 / 18 四数之和 | 增加嵌套层数与去重逻辑 |
| 11 盛最多水的容器 | 42 接雨水 | 从单纯移动短边到维护左右最大高度 |
| 141 环形链表 | 142 环形链表 II | 加入数学推导,找入口 |
| 19 删除倒数第N个节点 | 876 链表的中间节点 | 从固定间隔改为速度差 |
| 3 无重复字符的最长子串 | 76 最小覆盖子串 / 209 长度最小的子数组 | 收缩时机和更新答案的时机不同 |
每次做完变式,对比母题解法,思考哪些地方变了、哪些地方完全没变。这样做的效果比盲目刷 50 道题要好得多。
6.2 复杂度分析与口头表述
面试的时候,代码写完只是第一步,把复杂度说清楚同样重要。
- 左右对撞:时间 O(n),空间 O(1),排序除外。
- 快慢指针:时间 O(n),空间 O(1)。
- 滑动窗口:时间 O(n),空间取决于窗口存储结构,通常是 O(k),k 是窗口大小或者字符集大小。
- 三数之和这类嵌套:时间 O(n^2),空间 O(1)(不考虑结果集)。
这里特别提一下,很多人会说滑动窗口是 O(n),这是对的,因为每个元素最多被左指针和右指针各访问一次,总操作数是 2n 量级,常系数不影响复杂度结论。我在周赛复盘时发现,如果能主动说出“每个元素最多进出窗口一次,所以总复杂度是 O(n)”,面试官通常会点点头,因为你不是死记复杂度,而是理解了指针移动的本质。
6.3 题感的建立:拿到新题先问三个问题
最后分享一个我自己的刷题方法。拿到一道题,不管难易,先在心里问三个问题:
- 输入是数组还是链表?
- 是否有序?或者是否可以通过排序获得某种单调性?
- 问题是找两个数、找一个位置、还是维护一个区间?
这三个问题的答案几乎能直接定位到双指针的具体分类。数组加有序,优先左右对撞;链表加位置关系,优先快慢指针;连续区间加统计条件,优先滑动窗口。
但这种“题感”不是看出来的,是写出来的。我建议你找一个周末,把上面的母题每个写两遍:第一遍不看任何题解,硬写,写不出来就看一眼提示然后合上屏幕自己写;第二遍隔一天再写,重点把去重和边界条件在注释里标出来。做完这个循环,你对双指针的理解会有一个肉眼可见的跃升。
6.4 从刷题到实际应用的一点延伸
刷题的最终目的是面对陌生问题时能快速找到思路。双指针的价值也不只在 LeetCode。比如在业务代码里处理两个有序列表的合并、在日志流里判断某个时间窗口内的事件数量、在链表结构里定位循环引用,这些场景底层都有双指针的影子。
我自己在实际工程里就遇到过一个问题:需要判断某个长字符串里是否存在一个覆盖了所有关键词的最短片段,当时第一反应当然是调库扫描,后来仔细一想,这不就是最小覆盖子串吗,直接用了滑动窗口的思路写了一个 O(n) 的版本,性能比之前的暴搜快了几个数量级。有时候算法题和业务并不割裂,只是换了一层外衣。
所以别觉得刷双指针只是为了应付面试。它训练的核心能力,是“在看似无序的数据里找到移动的规律”,这个能力放到哪里都值钱。如果你现在正好在刷双指针,看到某道题卡住,不妨停下来想一下它属于哪类模型,再用这个系列里的模板去套,多半会有新的突破。