ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

双指针法全解析:从两数之和到滑动窗口的算法套路

双指针法全解析:从两数之和到滑动窗口的算法套路 算法题刷到一定量之后你会发现有个规律有些题看似毫无关联解法却总绕不开同一个套路那就是“双指针法”。两数之和、三数之和、最长无重复子串、链表判环、盛水容器这些面试高频题背后全是同一套思维模型。这篇文章我想从底层逻辑到具体代码把双指针法彻底拆开讲透把我实际刷题和面试中积累的经验、踩过的坑都写出来希望能帮你建立一套一眼识别“这题能用双指针”的能力。这篇文章适合两类人一类是刚开始刷题、对双指针只有模糊概念的初学者建议按顺序读把每个代码示例亲手敲一遍另一类是已经刷过不少题、但总在边界条件上翻车的进阶选手可以直接跳到第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指向数组末尾。第一次比较 left2 和 right15和是17比目标9大。关键推论来了因为数组是有序的left右边的数都比2大既然2加15都已经超过9了那让left再向右移动只会让和更大所以这里唯一合理的操作是让 right 向左移动。移动后 right112加11等于13还是大于9继续让 right 左移。直到 right72加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”你的回答要能落到单调性上因为这个区间里所有比当前值更小的组合已经不可能满足条件了移动它就是排除一批解。这种表达方式比单纯背答案有说服力得多也更容易让面试官认为你真的理解算法而不是背模板。刷题复盘同样重要。我给自己定的规矩是每道双指针题做错或卡壳后都会在题解旁边写三行笔记——它属于相向还是同向、单调性来自哪里、我卡在哪个边界条件。积累到二十道题左右你会有一种“看穿题目”的爽感大部分双指针题在看完题面的瞬间就能判断出该用什么模型。我个人到现在刷了近百道双指针相关题目最大的一个感受是双指针不是一种固定的“函数模板”而是一种“思维习惯”——永远问自己我能不能通过两个游标的有序移动把需要枚举的状态空间压缩掉一个维度。带着这个习惯去看新题准确率会大幅提升。最后再分享一个小技巧刷题时遇到一个模型比如最长无重复子串的滑动窗口就顺手把它的变体题全部做一遍。一个模型至少喂饱五道题比盲目刷新题效率高得多。希望这些从实战里磨出来的经验能帮你在双指针这条路上少走几段弯路。
返回列表