最近在整理优选算法的双指针专题,这是第二期。第一期把双指针的底层逻辑、指针定义和最高频的几种使用场景做了梳理,这一期我想把难度稍微往上提一提,重点聊那些真正会让代码“翻车”的细节:边界条件怎么设、指针什么时候动、窗口怎么扩怎么缩,以及几类经典模型从暴力解到双指针解的完整推导过程。如果你已经在刷题途中,或者刚把双指针基础过完一遍,但一写复杂用例就暴露问题,这篇内容非常适合你。
双指针算法看起来逻辑简单,无非就是两个下标配合移动,但实际写起来,十个里面有八个是在while条件上卡死,还有一堆是“答案差一位”的边界问题。这篇文章我会尽量把每个步骤背后的理由讲清楚,不只是给代码,而是讲清楚代码为什么要这么写,踩过的坑也一并列出来,希望能帮你少走弯路。
1. 双指针算法的整体设计与思路拆解
1.1 暴力解法为什么慢:看一个具体例子
很多人第一次接触双指针,是遇到“有序数组中找到两个数,使它们的和等于目标值”这个问题。最直白的做法是双重循环,外层选第一个数,内层依次遍历它后面的所有数,看两数之和是否等于目标。这个思路完全正确,但问题在于复杂度,n个元素需要比较n乘以n减1再除以2对组合,写成大O就是O(n²)。这个复杂度意味着什么?当n是1万时,要执行将近5000万次比较;当n是10万时,计算量直接到50亿,基本跑不动。
暴力的浪费点在哪?在于它重复扫描了大量“不可能成立”的组合。举个例子,数组是[1, 2, 3, 4, 6, 9],目标值是10,外层第一个数选1,内层从2扫到9,算出来1加任何数都不等于10,这趟扫描其实只确定了一件事:1和它后面的所有数都不满足条件。但下一层选2的时候,它又从3扫到9,把2和后面每个数的和重新算了一遍。问题是,上一次扫描已经证明了1加后面任何数都偏小,2加上同样这些数只会更大,但仍然更小,这层信息被暴力解法完全丢弃了。双指针的价值就在于,它不丢弃这些信息,而是利用数组的有序性,把大量无效组合一次性排除掉。
1.2 双指针三大流派:模型、特征与适用场景
双指针不是某一种固定写法,而是一族问题的统称。根据两个指针的移动方向和数据结构的差异,通常可以分成三类,我刷题时习惯这样分类:
| 模型类型 | 指针移动方式 | 适用前提 | 典型场景 |
|---|---|---|---|
| 左右指针 | 左指针向右,右指针向左,相向而行 | 数组有序或问题天然有单调性 | 两数之和、三数之和、盛最多水的容器 |
| 快慢指针 | 一个指针走得快,一个走得慢,同向而行 | 适合链表或需要O(1)空间判重的场景 | 环形链表检测、链表中点、原地去重 |
| 滑动窗口 | 左右指针同向移动,维护一段区间 | 连续子数组、子串问题 | 无重复字符最长子串、最小覆盖子串 |
这三大类基本覆盖了绝大多数面试和笔试中会出现的双指针题目。你可能会看到一些变体,比如三指针,本质是固定一个指针,剩下两个指针做左右指针的移动,仍然逃不出这套分类体系。
1.3 单调性才是双指针能用的底层前提
很多人以为双指针的核心是“两个指针一起走”,其实这只是表象,真正的底层逻辑是单调性。什么叫单调性?以有序数组两数之和为例,如果左指针向右移动,两数之和会变大;如果右指针向左移动,两数之和会变小。这种“方向明确、不会反复”的性质,就是单调性。它保证了指针只需要一直往同一个方向移动,不需要回头。
滑动窗口能用的前提也是单调性。以无重复字符的最长子串为例,当右指针向右扩展,窗口内字符变多,重复的可能性只增不减;当左指针向右收缩,窗口内字符变少,重复的可能性只减不增。正是这种单调性,让我们可以放心地先扩展右指针,再收缩左指针,而不需要尝试所有窗口区间。如果没有这个性质,比如让你求一个数组里“和刚好等于target的所有子数组”,且数组里允许有负数,这时窗口就不具备单调性,因为加入负数后窗口和反而变小,右指针再往右,窗口和可能反弹,这种情况就不能直接用滑动窗口,得换前缀和加哈希表。
判断一个问题能不能用双指针,本质上就是判断:随着指针的移动,问题的状态是否朝某个确定方向单调变化。这个判断能力,比会写代码本身更重要。
2. 核心细节解析与实操要点
2.1 边界条件怎么定:left与right的三种比较关系
双指针题目中,最常见的错误来源就是while循环的边界条件。初学者经常会纠结到底写left < right还是left <= right。这个问题没有统一答案,取决于你定义的区间是左闭右闭还是左闭右开。
如果是左闭右闭区间,也就是[left, right]两端都可能包含有效元素,那么循环应该写成while (left <= right),因为当left等于right时,中间那个元素还没被处理。如果是左闭右开区间,也就是[left, right)只包含left到right减1之间的元素,那循环应该写成while (left < right),因为当left等于right时,区间已经为空。我用Python写题时,默认更偏爱左闭右开,因为它和Python的切片思路天然一致,比如nums[left:right]表示从left到right-1,恰好就是区间长度right-left。但用这个思路时必须时刻记住,右边那个位置是不能直接取的,否则会越界或漏判。
还有一个细节:在左右指针模型中,最终左指针一般会停在右指针的旁边,也就是left > right,这时候你需要注意,循环结束后left指向的位置可能是有意义的。比如在“搜索插入位置”这类二分双指针问题里,循环结束后left恰好是目标值应该插入的位置。这说明,不要以为循环结束就是结束,结束时的指针状态往往是答案的一部分。
2.2 指针移动的时机与方向:先移动谁,什么时候停
很多双指针代码的问题不是“不知道要移动指针”,而是“不知道该先移动哪个指针”。这里有一个通用判断框架:先看当前状态是否满足题目约束,再决定是扩还是缩。
以滑动窗口为例,处理无重复字符的最长子串时,右指针每次向右走一步,扩进一个新字符,同时立刻检查窗口内是否出现重复,一旦出现重复,左指针就一步步向右移动,直到窗口重新干净。这个“先扩右,再收左”的顺序不是固定的,但绝大多数滑动窗口题都可以套用。另一种情况是“最小覆盖子串”,右指针扩到满足覆盖条件后,再移动左指针尝试缩短窗口,同时继续检查是否仍然覆盖,一旦不满足覆盖条件,就停止收缩,重新回到右指针扩张阶段。这个过程中,两个指针交替前进,永远不会回退,但谁先动谁后动直接决定了代码是否正确。
左右指针模型中,移动方向更简单:比较当前组合和目标值的大小关系,偏小就移动左指针向右,偏大就移动右指针向左。核心思想是每次比较后排除掉一大段不可能区间,而不是小心翼翼地尝试。这个思想在盛最多水的容器里也适用:两个板子围成的容器容量由较短的那块决定,所以每次移动较短的板子对应的指针,因为如果移动较长的板子,容器的高度只会不变或变小,而宽度还在缩小,容量必然不增,这一步可以直接排除以短边为边界的所有更大宽度组合。
2.3 双指针与排序、哈希、频次数组的组合套路
双指针很少孤立出现,它经常需要和其他技巧搭配。最典型的搭配是“排序加左右指针”。比如三数之和,如果数组无序,你无法利用单调性来移动指针,所以第一步必须排序,排序的代价是O(n log n),但它足以让后面的双指针扫描降到O(n²)而不是O(n³)。排序在这里的作用是给双指针创造单调的环境。
另一个常见搭配是“频次数组加滑动窗口”。处理字符串相关的双指针题,很多同学喜欢用Python的Counter或者字典来记录窗口内字符出现次数,这当然能work,但如果字符集较小,直接用数组会更稳更快。比如题目只涉及ASCII字符,就可以开一个长度128的数组,用字符的ASCII码做索引,每次扩进字符就加一,移出字符就减一。这样不仅省去了哈希表动态扩容的开销,还能避免一些边界情况下Counter取值缺失的Bug。判断窗口内是否有重复字符,直接看频次数组里对应位置是否大于1就行,clean且高效。
还有一类题目,双指针和二分查找可以互相替代或者互相配合。比如在“找到两个有序数组的中位数”这种难题里,就同时用到了二分和双指针对数组进行切分。我的建议是先把基础的双指针模型练熟,再去碰组合题,否则容易把问题复杂化。
3. 实战演练:六个经典模型的完整拆解
3.1 左右指针模型:有序数组的两数之和
先把最经典的模型跑通。题目:给定一个已按非递减顺序排列的整数数组,从1开始计数,找到两个数使它们的和等于目标值,返回两个数的下标。
暴力解法的缺点前面已经说过,这里直接看双指针解法:
def two_sum(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 []关键点在于:为什么current_sum小于target时可以直接left加一,而不是先尝试right减一?因为数组有序,如果当前左指针指向的数加上最右边的数都小于target,那这个左指针对应的数加上右边任何数也都小于target,因为右边那些数比当前右指针的数更小。所以这趟比较排除的不是一个组合,而是“以当前左指针为起点的所有剩余组合”。这样每次迭代至少排除一个元素,总复杂度是O(n)。这个模型是所有左右指针题的基石,强烈建议背下来。
3.2 快慢指针模型:链表环检测与环入口
链表题里经常限制空间复杂度为O(1),这时候哈希表记录访问过的节点就不符合要求,快慢指针就派上用场了。经典问题是“判断链表中是否有环”。
def has_cycle(head): slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: return True return False为什么快指针每次走两步而不是走三步?关键在于相对速度。当两个指针都进入环以后,快指针每次比慢指针多走一步,相当于相对速度是1,那么快指针一定可以追上慢指针。如果快指针每次走三步,相对速度是2,理论上也能追上,但可能会出现快指针直接“跳”过慢指针的情况,而且还要额外处理步长跨越带来的边界问题,复杂度并没有改善,风险却增加了。所以两步是一个兼顾简单与安全的选择。
如果想进一步找到环的入口,在快慢指针第一次相遇后,可以让慢指针继续从相遇点出发,同时另一个指针从头节点出发,两者每次都走一步,它们会在环入口处相遇。这个结论背后的数学推导稍微有点绕,但结论很实用,遇到相关题目可以直接使用。
3.3 滑动窗口模型:无重复字符的最长子串
这是滑动窗口里最经典的入门题。题目给一个字符串s,找出其中不含有重复字符的最长子串的长度。
def length_of_longest_substring(s: str) -> int: left = 0 freq = [0] * 128 ans = 0 for right in range(len(s)): freq[ord(s[right])] += 1 while freq[ord(s[right])] > 1: freq[ord(s[left])] -= 1 left += 1 ans = max(ans, right - left + 1) return ans这里用数组freq记录每个字符在当前窗口内出现的次数。右指针每扩进一个字符,先把它对应的频次加一;如果这个字符的频次大于1,说明窗口内有重复字符,于是开始收缩左指针,每移动一位就减少对应字符的频次,直到重复字符被移出窗口。每次调整完窗口后,当前窗口的右端点必然是合法的无重复窗口,这时更新答案。
这个模型里最需要理解的是while循环:右指针每走一步,最多只会导致一个字符重复,所以内层while把重复字符清理干净即可。以字符串"abcabcbb"为例,右指针走到索引3的'a'时,窗口内是"abc",发现'a'重复,左指针逐步右移,最终窗口变成"bca",长度依然是3,但已经不含重复字符。这个过程中,答案从3更新到3,一直到后续出现"abc"又重复,再调整,最终答案还是3,符合预期。这个代码最大的好处是逻辑统一,任何类似的子串问题都能套用同一个框架。
3.4 排序加左右指针:三数之和去重技巧
三数之和是两数之和的升级版。题目要求找出数组中所有和为0的三元组,并且结果中不能包含重复三元组。直接三重循环复杂度是O(n³),必须优化。经典做法是先排序,再固定一个数字,剩下两个数字用双指针去寻找。
def three_sum(nums): nums.sort() n = len(nums) res = [] 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: 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这里的去重是整个题目的灵魂。第一个去重点在外层循环:如果当前固定数字和前一个数字相同,直接跳过,因为固定这个相同数字得到的三元组在前一轮已经找完了。第二个去重点在找到一组答案之后:左指针要跳过所有和当前值相同的元素,右指针同理,然后再各自移动一位。不这样做会得到大量重复结果,比如数组[-1,-1,0,1,1],不跳过的话会出现两遍[-1,0,1]。很多人在去重顺序上栽跟头,我一开始也是这样——先移动指针再去重,结果漏掉了一些元素。正确的顺序一定是:先把当前这组答案记录下来,再跳过重复元素,最后再各走一步。
3.5 原地覆盖模型:移除元素与压缩数组
这类题目的特点是要求在O(1)额外空间下原地修改数组。比如“移除数组中的所有指定值,返回新数组的长度”。快慢指针在这里非常顺手:
def remove_element(nums, val): slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow逻辑一句话:慢指针指向下一个可以写入的位置,快指针负责扫描整个数组,凡是遇到不等于val的元素,就把它搬到慢指针的位置,然后慢指针前进一位。快指针走得快,慢指针走得慢,真正被保留下来的元素在数组前部紧凑排列。这个模型特别适合处理“删除重复项”“移动零”等一系列原地操作问题。移动零问题其实也是同一个套路,只不过写入的值只能是0,思路完全一致。
这套代码看起来短,但容易忽略一个细节:写入操作发生的那一轮,慢指针和快指针指向同一个位置时,直接原地写是安全的;只有当快指针已经跳过若干被删除元素时,慢指针和快指针才会错开,这时候原地覆盖才会产生实际效果。理解这一层后,遇到变形题脑子里能很快反应出来。
3.6 经典脑筋急转弯:盛最多水的容器
这道题非常能考验对双指针“排除不可能区间”的理解。给定一个非负整数数组height,每个元素代表坐标点上的柱子高度,找出两条线使容器能装最多水。
def max_area(height): left, right = 0, len(height) - 1 ans = 0 while left < right: current_height = min(height[left], height[right]) ans = max(ans, current_height * (right - left)) if height[left] < height[right]: left += 1 else: right -= 1 return ans为什么移动较短的那一边?容器的容量由短板决定,这在数学上已经确定。假设左板高度小于右板,当前容量就是左板高度乘以宽度。如果移动右板,也就是把较高的板向内移,首先宽度变小了,其次新的容量高度可能不变,也可能变小,因为它还要取左右板的较小值,所以容量不可能增大。也就是说,以当前左板为边界的所有组合,最优值就是当前这个组合,不可能再更优,所以直接排除左边界,移动左指针。反之亦然。这个排除逻辑非常漂亮,也是很多商业面试很喜欢考的思想。
4. 常见问题与排查技巧实录
4.1 死循环:指针没有按预期走动
双指针最常见问题是程序进入死循环。原因一般是某个分支下指针没有移动。比如两数之和中,如果漏写left加一或right减一,当current_sum不等于target时,指针不动,下一次循环还是同样的状态,自然死循环。排查办法很简单:在每个分支里都追问一句“这个分支会让哪个指针向哪个方向移动至少一步吗”。如果某个分支下两个指针都不动,那一定有问题。我自己踩过的一个坑是在滑动窗口内部调整left时,忘了调整频次数组,导致left虽然在移动,但窗口状态一直不更新,while条件永远为真,程序直接卡死。
还有一类是快慢指针的while条件写成了while fast.next和while fast.next.next,导致在链表只有两个节点时,fast.next.next可能已经为空,进入循环后取fast.next.next的时候直接报空指针异常。正确的写法是while fast and fast.next,先保证fast本身非空,再保证fast.next存在,这样取fast.next.next才安全。
4.2 答案差一:窗口长度计算错误
滑动窗口里,窗口长度到底是right-left,还是right-left+1,很多人会搞混。这取决于你的区间定义。如果是左闭右闭,也就是left和right都包含在窗口内,那么窗口长度是right - left + 1。如果是左闭右开,那么长度是right - left。我在写题时习惯统一用左闭右闭,因为返回长度时加一比较好记。但代价是,左闭右闭区间初始时left=0、right=-1才表示空区间,这个初始值看起来有点反直觉,需要适应。
还有一个容易错的场景:当循环结束时,left和right的位置关系往往决定了答案是否正确。比如在寻找最小覆盖子串时,如果你在左指针收缩过程中没有把长度更新放在“收缩后的瞬间”,而是放在了收缩循环外面,那么计算出来的长度可能会偏大,因为循环结束后left已经移动过头了。
4.3 快慢指针边界怎么处理
链表相关的双指针,最怕处理空链表和单节点链表。判断环时,如果head为空,直接返回False;如果链表只有一个节点,且没有自环,fast第一步就会变成空,循环正常退出,返回False。这里的关键是while fast and fast.next的判断顺序,一旦fast为空,后面的fast.next就不会执行,所以不会报错。如果是找链表中点,快慢指针的终止条件是fast和fast.next中任何一个为空,此时slow恰好在中点或中点偏左的位置,具体偏左还是偏右由链表长度的奇偶决定,这个细节需要根据题目要求调整初始指针位置。
我自己在链表中点这个问题上出过订单:题目要求如果链表长度为偶数,返回中间偏右的节点,但我没有处理,直接返回了偏左的节点。后来总结出一个通用公式:快指针从head出发,慢指针也从head出发,要求偏右时,可以让快指针先走一步,或者将循环条件改为while fast and fast.next,两种做法等价,但需要结合题目说明选择。
4.4 三数之和去重失效的常见原因
三数之和去重失效,大多数出在对“重复”的定义理解不透。第一种错误是只在外层去重,内层找到答案后直接left加一、right减一,结果产生重复三元组。第二种错误是内层去重写成while nums[left] == nums[left + 1],但没有加上left < right的条件,导致指针一路越界。第三种错误是去重写在了记录答案之前,导致漏掉正确答案。比如数组[-2, 0, 0, 2, 2],固定-2后,左指针在0,右指针在2,得到一组[-2, 0, 2],记录后左指针需要跳过右边的0,右指针需要跳过左边的2,然后left加一、right减一,数组内已经没有其他组合,否则可能出现重复。我在调试这类题目时,常用一个输出调试法:在记录答案那行打印三元组,再打印left和right的位置,很快就能看出是去重顺序出了问题。
4.5 调试双指针的三个实用小工具
调试双指针题,光靠看代码很费劲,我一般会用三种方式。
第一,打印每一步的指针状态。在循环开头用print输出当前left、right的索引值和对应的元素值,尤其是涉及数组索引时非常直观。第二,构造最小测试用例。不要一上来就跑大数组,先用几个极端用例验证逻辑:空数组、单一元素、全部相同元素、全部递增元素、全部递减元素。这能把90%的边界错误暴露出来。第三,画图辅助。虽然阅读器里没法画图,但我自己在纸上或者白板上把数组画一条线,把两个指针的位置用不同颜色标出来,每次移动一步就更新位置,很快能发现问题。我个人体会:双指针题写代码之前先在纸上把两个指针的移动过程走一遍,比直接写代码有效得多。
5. 专题二之后的刷题路线与语言选型建议
5.1 用什么语言练双指针最舒服
双指针算法对语言没有硬性要求,但不同语言会有不同的手感。Python写起来最简洁,列表切片和动态数组让边界处理变得很舒服,适合快速验证思路,也是我日常刷题的主要工具。它的循环结合range和while都能用,但要注意切片会创建新列表,不要在算法题里为了取子数组疯狂切片,否则时间复杂度会退化。
Java和C++的数组是固定大小的,适合训练对索引和边界的敏感度,面试时如果和面试官讨论内存布局,这些语言能聊得更深。C++的指针概念和链表操作天然契合,写快慢指针时会有一种“底层操控感”。我的建议是,如果你是为了准备面试,选一个你最有信心的语言把解法写稳,比临场换语言更重要。双指针的核心思想跨语言通用,把模型吃透了,换语言只需要改语法。
5.2 专题二结束后怎么安排专题三
如果把双指针当作一个知识体系,专题一应该是入门和基础模型,专题二是进阶模型和大量细节,专题三就可以去碰那些更复杂的组合题了。我个人推荐的顺序是:先把左右指针、快慢指针、滑动窗口这三种模型用至少二十道题练熟,再去接触“双指针加二分”“双指针加堆”“双指针加前缀和”这类复合题型。比如接雨水问题,本质上可以用左右指针加两个变量维护左右最大高度来解决;最小覆盖子串问题需要滑动窗口配合计数数组;合并两个有序数组则考察逆向双指针的写法。这些题目都是专题三的好素材。
这个阶段最容易出现的误区是贪多嚼不烂。我见过不少同学,刷了三十道题但还是觉得没底,原因是每道题都没有深入总结。我自己在整理专题笔记时,会给每一类模型建一个“模型卡片”,包括适用条件、代码模板、易错点、复盘错题,这样以后再遇到类似题,脑子里的检索速度会快很多。
5.3 一条实用的训练路径
如果你现在正处于“题目看着眼熟但写不出来”的阶段,我建议你按下面的顺序做刻意练习:第一周只做左右指针的题目,从两数之和开始,依次做三数之和、盛最多水的容器、判断回文串,每天两到三题,重点练“每次比较排除一段区间”的思维。第二周做快慢指针,集中在环形链表、链表中点、删除链表倒数第N个节点,同时把链表操作的细节吃透。第三周做滑动窗口,从无重复字符的最长子串开始,慢慢做最小覆盖子串、字符串排列、找到字符串中所有字母异位词。三周下来,双指针的主体框架基本就能印在脑子里了。
做每一道题的时候,我都建议你在评论区或者笔记本上写一行总结:这题用的是什么模型,为什么能用,不能用的条件是什么。这个习惯比刷题数量更重要。我也在专题一里强调过,算法题不是比谁记得多,而是比谁能最快判断出题目背后隐藏的结构。
最后再分享一点个人经验
双指针这个技巧,写出来往往不到十行,但它背后代表的是“淘汰不可能区间”的高效思维。我自己从刷题小白到现在,最大的体会是:遇到一道数组或字符串题,先不要急着写代码,先问自己三个问题:这个数组有序吗?这个问题有单调性吗?两个指针各自移动一格,问题的状态是变好还是变坏?这三个问题的答案,基本能决定双指针能不能用,以及怎么用。
还有一个小技巧我一直会提醒自己用:写双指针题时,永远先定义清楚你维护的区间是什么。是左闭右闭,还是左闭右开,还是两个指针之间没有实际区间只是两个游标?把这一点想清楚,while条件和最后的答案计算就不会错。如果某道题死活调不对,大多数时候不是代码问题,而是你对区间的定义和代码里的实际操作不一致。
这个专题之后,我还会继续整理双指针在复杂场景下的应用,以及和二分、堆等技巧的组合题。如果你在练习过程中遇到特别典型的Bug,也欢迎在评论区记录下来,每次复盘都是对自己的一次训练。算法这条路没有捷径,但每总结一个模型,后面的路就会顺畅一点。
(完)