☰
双指针法全解析:从两数之和到滑动窗口的算法套路
2026/10/6 16:58:46 网站建设 项目流程

算法题刷到一定量之后,你会发现有个规律:有些题看似毫无关联,解法却总绕不开同一个套路,那就是“双指针法”。两数之和、三数之和、最长无重复子串、链表判环、盛水容器,这些面试高频题背后全是同一套思维模型。这篇文章我想从底层逻辑到具体代码,把双指针法彻底拆开讲透,把我实际刷题和面试中积累的经验、踩过的坑都写出来,希望能帮你建立一套一眼识别“这题能用双指针”的能力。

这篇文章适合两类人:一类是刚开始刷题、对双指针只有模糊概念的初学者,建议按顺序读,把每个代码示例亲手敲一遍;另一类是已经刷过不少题、但总在边界条件上翻车的进阶选手,可以直接跳到第5章,那些坑我基本都替你踩过了。

1. 双指针法的本质:从两两组合到单调收敛

1.1 暴力解法真正的痛点在哪

先看一个最基础的场景:在有序数组里找两个数,使它们的和等于目标值。大多数人第一反应是嵌套两层循环,枚举所有组合。这确实能做,但问题在于它枚举了大量明显无效的组合。比如数组是[1, 3, 5, 7, 9],目标是12,第一轮外层循环固定1,内层循环会遍历3、5、7、9。当你发现1加9等于10已经小于目标12时,其实1和剩下的其他数都更小,更不可能凑到12,但程序仍然会傻乎乎地全部试一遍。

暴力解法的时间复杂度是O(n²)。当数组长度是1000时还好,一旦变成10万,那就是100亿次操作,任何线上服务都扛不住。但更值得思考的是:我们到底浪费在哪里?答案是,我们浪费在“没有利用数据本身的顺序信息”。外层循环每固定一个数,内层循环明明可以根据当前值的大小,直接决定下一步往哪个方向走,却非要从头到尾扫一遍。

双指针就是冲着这个痛点来的。它不盲目枚举所有组合,而是通过两个指针的移动,让每一轮比较都排除一大批候选组合。你可以把暴力解法想象成循环赛,每两个人必须交手一次;而双指针更像是淘汰赛,输一次就整组淘汰,需要比较的次数自然大幅下降。

1.2 为什么双指针能把O(n²)降到O(n)

双指针能降低复杂度的核心秘密,在于它把二维的枚举压缩成了一维的线性扫描。用一个具体的例子最容易说清楚。

假设有序数组是[2, 7, 11, 15],目标是9。用两个指针,一个叫left指向数组开头,一个叫right指向数组末尾。第一次比较 left=2 和 right=15,和是17,比目标9大。关键推论来了:因为数组是有序的,left右边的数都比2大,既然2加15都已经超过9了,那让left再向右移动只会让和更大,所以这里唯一合理的操作是让 right 向左移动。移动后 right=11,2加11等于13,还是大于9,继续让 right 左移。直到 right=7,2加7等于9,命中目标。

整个过程只移动了3次指针,每次移动都排除了一个方向上的一大片无效组合。这就是双指针的底层逻辑:在有序数组的约束下,left和 right 的调整方向是确定的,不存在回溯的必要,所以每个指针最多走n步,总复杂度就是O(n)。

我当年理解这个思想时,用过一个类比:两个人面对面站在一根数轴的两端,每次根据当前总和与目标的大小关系,决定哪一侧的人往中间挪一步。因为数组有序,这个决策永远是“贪心”且正确的,就像两边同时向中间收缩的钳子,最终钳住答案。这个“钳形攻势”的直观图景,直到今天都是我判断能否用双指针的第一反应。

2. 相向双指针:有序数组里的黄金搭档

2.1 两数之和II:从左右两端逼近目标

先写出最经典的两数之和II完整代码,注意题目要求数组是有序的:

def two_sum_sorted(nums, target): left, right = 0, len(nums) - 1 while left < right: current_sum = nums[left] + nums[right] if current_sum == target: return [left + 1, right + 1] # 题目要求返回下标从1开始 elif current_sum < target: left += 1 else: right -= 1 return []

这段代码值得注意的细节有三个。第一是循环条件用了left < right而不是left <= right,因为两个指针不能指向同一个元素,否则就变成自己加自己了,语义不对。第二是当current_sum < target时,必须移动left而不是right,因为right已经指向当前区间最大值,左移只会让和更小,完全背离目标;反过来也一样。第三是每次指针移动后,新区间仍然是“可能包含答案”的最小候选区间,不会漏解。

我最初写这道题时犯过一个低级错误:当和小于目标时,我习惯性让left += 1,但写着写着就忘了比较的是“移动之后的新值”而不是“刚才那个值”,好在调试两轮就发现了。这里建议你刻意练习一个习惯:每次指针移动后,用大脑走一遍新区间的含义,确认它是否还覆盖所有可能的答案组合。

2.2 三数之和:固定一个点,剩下交给相向双指针

三数之和是两数之和的升级版,也是面试中出场率最高的双指针题目之一。它的思路是先排序,然后固定一个数 nums[i],剩下两个数用双指针在 i 右侧区间里寻找,使nums[i] + nums[left] + nums[right] == 0。

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, 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]]) left += 1 right -= 1 while left < right and nums[left] == nums[left - 1]: left += 1 while left < right and nums[right] == nums[right + 1]: right -= 1 elif total < 0: left += 1 else: right -= 1 return result

这道题真正的难点是去重。我记得第一次写三数之和,没加去重逻辑,一跑测试用例直接就重复了。去重分为两层:外层循环如果nums[i]和上一个数相同,说明以这个数开头的所有组合已经在上轮算过了,直接跳过;内层双指针命中一组答案后,也要跳过所有与当前 left、right 相等的数,否则同一个三元组会以不同的 left/right 位置反复出现。

更需要注意的细节是:内层去重里我用的是nums[left] == nums[left - 1]和nums[right] == nums[right + 1],因为指针已经移动过了,所以要跟“上一步移动后的位置”比较。有些写法会在命中后先跳过重复,再统一移动指针,效果一样,但逻辑上更容易绕晕。我建议你固定自己的写法,每次写代码时保持统一。

2.3 盛最多水的容器:面积公式背后的指针选择

盛水容器这道题,表面看和“找数字”完全不同,但它把双指针的决策逻辑体现得最纯粹。题目给了一堆竖线高度,让你选两根线,使它们和x轴围成的容器能装最多水。容器的容积是min(height[left], height[right]) * (right - left),也就是短板乘以间距。

def max_area(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

思路的关键逻辑是:面积由短的那块板决定。如果当前是左板矮,那么把右板往左移动,虽然间距变小了,但宽度损失不会超过“短板的损失”,因为右板本来就更高,移动后的面积只可能由新左板决定,期待有更高的新左板出现。而如果把左板继续往右移,宽度已经变小了,高度上限也不会超过原来的短板,面积必然下降,所以短板的移动方向是唯一合理的。

这道题给了我一个非常重要的启发:双指针的移动依据,不一定是对比“数值和目标的关系”,也可能是对比“两根指针指向元素之间的性质”。你要找到那个决定问题走向的“主导变量”,并让指针围绕它做单调移动。这类题做多了之后,视觉上就像在扫描一个逐步收缩的水池边界。

3. 同向双指针:链表与滑动窗口的统一视角

3.1 快慢指针判环:Floyd算法背后的直觉

同向双指针里最出名的一个应用,就是快慢指针判定链表是否有环。一个指针每次走一步,另一个指针每次走两步,如果链表有环,两者必然在环内相遇。这个结论第一次看到的人会觉得像魔术,其实背后的数学很简单:进入环之后,快指针相对于慢指针的速度差是每步1个节点,相当于慢指针不动、快指针以每步1个节点的速度追它,而环是有限的,所以必然追上。

def has_cycle(head): if not head or not head.next: return False slow, fast = head, head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: return True return False

写这个代码时,我最常遇到的问题是循环条件。while fast and fast.next这两个判断缺一不可,因为 fast 每次跳两步,如果fast.next为 None,访问fast.next.next就会抛空指针。另外初始时两个指针都指向 head,也符合“从同一起点出发”的语义,如果让 fast 先走一步,代码也能跑通,但会让人想半天才反应过来,可读性反而不好。

我后来还推导过“找到环入口”的进阶版本,核心是利用一个恒等式:相遇点到环入口的距离,等于头节点到环入口的距离。所以相遇后把 slow 放回 head,两个指针各走一步,再相遇的位置就是入口。这个推导建议你自己用纸笔画一下,理解比背代码重要得多,面试时能现场推出来是加分项。

3.2 最长无重复子串:右指针扩张,左指针收缩

滑动窗口本质就是同向双指针的一种高级形式。它维护一个“当前满足条件的区间”,右指针负责扩张窗口,左指针负责在条件不满足时收缩窗口。最长无重复子串是最典型的入门题。

def length_of_longest_substring(s): window = set() left = 0 max_len = 0 for right in range(len(s)): while s[right] in window: window.remove(s[left]) left += 1 window.add(s[right]) max_len = max(max_len, right - left + 1) return max_len

理解这段代码的关键在于:右指针每扫描到一个新字符,如果这个字符已经在窗口里,说明出现了重复,此时只有不断收缩左指针,把窗口里那个重复字符之前的元素全部移出去,才能让右指针的新字符合法入窗。这个“移出窗口”的过程,每次可能不止移一个,所以用了 while 而不是 if。

我做过一个统计:这个模板可以平移到至少十几道题,包括字符串排列、最小覆盖子串、替换后的最长重复字符等。差异只在于“窗口里维护什么数据结构”和“什么时候收缩左边界”。比如最小覆盖子串需要维护字符计数和已覆盖字符数,窗口里存的是字典而不是集合。建议你把最长无重复子串这个模板练到肌肉记忆,再触类旁通。

3.3 同向双指针的区间维护技巧

同向双指针还有一类常见应用,就是处理数组中的“原地操作”问题,比如删除有序数组中的重复项、移除指定元素、移动零。这类问题的通用姿势是:用慢指针指向新区间的写入位置,快指针遍历旧区间,只把符合条件的元素写到慢指针位置。

def remove_duplicates(nums): if not nums: return 0 slow = 0 for fast in range(1, len(nums)): if nums[fast] != nums[slow]: slow += 1 nums[slow] = nums[fast] return slow + 1

这里 slow 维护的是“最后一个保留元素的位置”,fast 负责在前面探路。每次 fast 发现新元素和 slow 指向的元素不同,就把它搬到 slow 的下一个位置。这个代码的精妙之处在于,它天然处理了重复元素连续出现的情况,只要相同就忽略,直到遇到不同才搬移。

这类题的共同规律是:同向双指针维护的区间通常被划分为两段或三段,比如“已处理区”和“未处理区”。你要想清楚哪段是结果、哪段是待遍历的、哪段是可以覆盖的垃圾区。有了这个心理模型,写代码时就不容易乱。

4. 双指针的时间复杂度与适用边界

4.1 复杂度推导:为什么是O(n)

双指针算法的复杂度推导有一个通用公式:如果两个指针的移动方向都是单调的,即 left 只向右、right 只向左,或者 fast 只向前、slow 也只向前,那么每个指针最多移动 n 次,总操作次数上界就是 2n,复杂度必然是 O(n)。

但需要注意,这个结论成立的前提是“每一轮循环至少移动一个指针”,且“指针不会回头”。有些题目看着像双指针,但你在循环体里可能会对同一个指针连续移动多次,比如三数之和的外层 for 循环加上内层 while 双指针,总复杂度是 O(n²)。因为外层每固定一个 i,内层双指针都要扫描一次它右侧的区间,所有 i 加起来就是 n 次内层扫描,所以是 O(n²)。

我自己判断复杂度时有一个实用方法:看两个指针“总移动次数”。不要只看最内层循环的长相。即使是两层循环,只要内层两个指针的移动总量在外层单次迭代内是 O(n),而外层有 n 次迭代,那结果就是 O(n²)。如果内层指针在整个函数执行期间从头到尾只走一遍,那才是 O(n)。这个概念区分清楚,面试时能少踩很多坑。

4.2 什么场景不能用双指针:误用与失效边界

双指针最核心的前提是“单调性”。数组有序时,left 右移和、right 左移减,单调的方向非常明确。但如果你面对的数组无序,双指针往往会给出错误答案。比如在两数之和的原题里,数组是无序的,你直接套相向双指针,排序后下标信息也变了,正确做法只有用哈希表。

另一个容易误用的情况是“需要穷举所有组合”的问题。双指针每次排除一批组合,这是它的优势,同时也是它的限制。如果你必须收集所有满足条件的组合、且无法通过排序获得单调性,那双指针大概率不是答案,回溯法才是。

还有一种边界是在含负数的数组里做“找固定和”的问题。负数会破坏单调性,比如 target 是负数时,数组有序并不能保证 left 右移一定让和变大,因为移动到一个负数会让和变小。这也是我实际刷题中踩过的一个很隐蔽的坑。所以遇到负数时,先停下来想一想,单调性是否真的存在,不要盲目套模板。

4.3 多指针扩展:三指针、四指针和更多变体

双指针的思想可以自然扩展到更多指针。三数之和里实际上已经是“外层指针 + 内层双指针”三指针协作。四数之和则在外层套两层固定指针,内层再用双指针扫描剩余区间。

def four_sum(nums, target): nums.sort() result = [] n = len(nums) for i in range(n - 3): if i > 0 and nums[i] == nums[i - 1]: continue for j in range(i + 1, n - 2): if j > i + 1 and nums[j] == nums[j - 1]: continue left, right = j + 1, n - 1 while left < right: total = nums[i] + nums[j] + nums[left] + nums[right] if total == target: result.append([nums[i], nums[j], nums[left], nums[right]]) left += 1 right -= 1 while left < right and nums[left] == nums[left - 1]: left += 1 while left < right and nums[right] == nums[right + 1]: right -= 1 elif total < target: left += 1 else: right -= 1 return result

多指针变体的核心逻辑和双指针一脉相承,只是去重层数变多了。你只要熟练掌握三数之和的去重技巧,四数之和就是加一层循环的问题。再往上的五数之和、六数之和,解法都是一样的套路,只是复杂度越来越高,实际面试很少考到。掌握到四数之和其实已经足够应对绝大多数题目。

我个人的体会是,多指针问题的本质是把“穷举复杂度”从 O(n^k) 降一个数量级变成 O(n^(k-1)),而底层思维始终是“固定一些指针,剩下的交给相向或同向双指针”。这是算法里少有的“一个模板套到底”的题型,一定值得花时间吃透。

5. 避坑指南与实战经验

5.1 死循环问题:指针不动的根源

双指针最常见、最隐蔽的错误是死循环。触发原因往往是某个分支忘记移动指针,或者移动条件写反了。比如在两数之和中,如果current_sum < target分支写成了right -= 1,由于 right 本来就是从最右往左走的,这里继续左移会让区间越来越小,可能错过答案,但不至于死循环。真正死循环的场景是外层的固定指针没有在循环里递增,或滑动窗口的 left 在 while 里移动时没有正确递增。

排查死循环我有一个土办法:在循环体开头打印 left 和 right 的当前值。如果连续很多轮它们在原地踏步,说明逻辑分支里漏了指针更新。刷题环境里不可能一上来就上调试器,print 是最快的。另一个更系统的做法是,写代码前先口头告诉自己“每一轮循环里,我一定会移动至少一个指针”,这个原则能避免绝大多数死循环。

我见过不少人(包括曾经的我自己)在滑动窗口的 while 里写了window.remove(s[left])却忘了left += 1,导致 left 一直在原地删除同一个字符,直到窗口清空还是删不完,最终超时。这种问题一旦卡住,心态很容易崩,学会用 print 快速定位比硬看代码高效得多。

5.2 边界条件:等号的神奇作用

双指针的边界条件非常密集,多少个 <=、<、>=、> 都会直接影响正确性。拿快慢指针判环举例,while fast and fast.next如果只写成while fast,当链表没有环且长度为偶数时,最后一次循环 fast 为 None,但循环体已经执行,访问fast.next直接抛错。反过来,如果多写一个条件,在某些有环情况下反而保护了代码不访问空指针。

另一个等号陷阱在最大容器问题里。当height[left] == height[right]时,你移动哪一边都可以,因为任何一边的移动都不会让面积变大,但它们保留的可能性是一样的。有些教程建议相等时同时移动两边,我个人不太推荐,因为同时移动可能跳过一种答案组合;不过这个题的答案只关乎最大值,跳过的组合面积不会超过当前值,所以同时移动也不会出错。核心是你要给自己一个统一的约定,不要每次写到这再临时想。

我建议你在刷题时专门准备一个“边界清单”,记录每一道双指针题里你踩过的边界条件。做几道题之后你就会发现,大部分边界条件都集中在“指针重合时能不能用这个值”和“空数组/单元素数组”这两类问题上。

5.3 面试中的表达技巧与复盘心法

面试时写双指针题,代码能力只占一半,另一半是沟通。我后来面试别人时发现,候选人最大的问题是直接闷头写代码,写完也不解释为什么这么移动指针。正确的节奏应该是:先说出大思路,比如“这个题我打算先排序,然后用相向双指针逼近目标,因为数组有序可以保证单调性”,然后边写边补细节,最后写完主动做一次复杂度分析。

如果面试官追问“为什么移动 left 而不是 right”,你的回答要能落到单调性上:因为这个区间里所有比当前值更小的组合已经不可能满足条件了,移动它就是排除一批解。这种表达方式比单纯背答案有说服力得多,也更容易让面试官认为你真的理解算法而不是背模板。

刷题复盘同样重要。我给自己定的规矩是:每道双指针题做错或卡壳后,都会在题解旁边写三行笔记——它属于相向还是同向、单调性来自哪里、我卡在哪个边界条件。积累到二十道题左右,你会有一种“看穿题目”的爽感:大部分双指针题在看完题面的瞬间,就能判断出该用什么模型。

我个人到现在刷了近百道双指针相关题目,最大的一个感受是:双指针不是一种固定的“函数模板”,而是一种“思维习惯”——永远问自己,我能不能通过两个游标的有序移动,把需要枚举的状态空间压缩掉一个维度。带着这个习惯去看新题,准确率会大幅提升。最后再分享一个小技巧:刷题时遇到一个模型,比如最长无重复子串的滑动窗口,就顺手把它的变体题全部做一遍。一个模型至少喂饱五道题,比盲目刷新题效率高得多。希望这些从实战里磨出来的经验,能帮你在双指针这条路上少走几段弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询