ARTICLE DETAIL

资讯详情

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

双指针算法从原理到变种:滑动窗口、快慢指针与边界处理详解

双指针算法从原理到变种:滑动窗口、快慢指针与边界处理详解 双指针是个挺有意思的话题——表面上看它只是“用两个下标代替一个下标”但真正把它用明白的人并不多。我见过不少同学在 LeetCode 上刷了上百道双指针题遇到新题还是卡壳原因往往不在于“不知道双指针这回事”而在于搞不清楚三件事什么时候该用双指针、用哪种双指针、边界到底怎么收敛。这篇就围绕这三点展开把双指针从原理到变种再到最容易翻车的边界处理完整捋一遍也会穿插一些我实际写代码时的习惯和踩过的坑希望对正在学算法或者准备面试的朋友有帮助。1. 为什么暴力解法会被双指针替代从复杂度困境说起先看一个最常见的场景给定一个有序数组找出两个数使它们的和等于目标值。最容易想到的解法是两层循环枚举所有数对时间复杂度 O(n²)空间复杂度 O(1)。这个解法在数组长度只有几百的时候完全够用可一旦数据量涨到十万、百万级别O(n²) 的耗时就会从毫秒级直接膨胀到小时级。这个例子常常被拿来当双指针的入门题但很多人只是背下了“左指针往右、右指针往左”的写法并没真正理解它为什么能把 O(n²) 降成 O(n)。1.1 暴力解法的信息浪费两个下标都在做无用功两层循环的问题在于它对每一个左下标 i都要把右边所有的 j 全部遍历一遍。但你要知道在很多问题里当 i 变化时j 的最优位置并不是从 i1 重新开始找而是沿着某个方向单调移动的。一旦我们意识到这个单调性就可以让 j 不回头地一路扫过去两个下标各扫各的总共只走 O(n) 步。举个例子在一个升序数组里找两数之和。如果当前 nums[i] nums[j] 小于目标值说明什么说明 nums[i] 太小了应该让 i 往右移动增大和如果大于目标值说明 nums[j] 太大了应该让 j 往左移动减小和。每一步都只有一个正确的移动方向不存在“先试试左边再试试右边”的分支这就是双指针能保持线性复杂度的底气。1.2 双指针的通用模型两个下标 一个单调关系我把双指针的适用条件总结成一个三元组有序性或部分有序性、单调性、可收敛性。有序性是前提单调性是效率来源可收敛性保证循环一定能结束。注意这里的有序性不一定是数组本身有序也可能指“答案的搜索空间存在某种方向性”。比如在链表里找环快慢指针的移动是受链表拓扑结构约束的本身并没有“大小顺序”但快慢指针之间的相对距离却在单调变化这同样是双指针的用武之地。很多教材把双指针归类为“优化技巧”我觉得它更接近一种“剪枝策略”——它没有改变问题本身的搜索空间只是利用额外的约束条件让大量不可能产生最优解的状态根本不会被访问到。理解这一点之后你面对的不再是“背模板”而是“找单调性”。2. 双指针的核心原理拆解为什么它能保证不遗漏最优解很多人用双指针时心里不踏实总担心“我这样跳着走会不会把正确答案跳过去了”。这种担心非常合理——如果只是盲目地移动指针确实会漏解。关键在于双指针的每一次移动都必须建立在一个足够强的逻辑之上当前状态不可能再产生全局最优解所以可以安全地排除。2.1 滑动窗口的收缩逻辑与反证法拿滑动窗口举例这是双指针最重要的变种之一。问题找出数组中最长的连续子数组使子数组的和不超过 K。我们用 left 和 right 两个指针维护当前窗口当窗口内元素和大于 K 时left 向右移动缩小窗口。这里有一个初学者普遍存在的疑问为什么当窗口和超限时只移动 left而不考虑把 right 向左移动答案是right 已经走到当前位置了如果此时 right 向左退那么得到的新窗口一定被当前窗口或更早的窗口包含长度一定更短。既然我们找的是“最长”子数组那些更短的窗口就算和不超过 K也不可能刷新答案。因此right 向左退是在做无用功可以直接剪掉。这个逻辑用反证法表达会更严谨假设存在一个最优窗口 [L, R]它的左边界是 L右边界是 R。在双指针扫描的过程中当 right 第一次越过 R 的时候left 一定还没有越过 L否则 [L, R] 就已经被访问过了。只要我们能证明“当 right 到达 R 位置时left 不会越过 L”那么 [L, R] 就一定会被完整地访问一次答案就不会漏。这个证明在具体的题目里各有各的细节但核心思想都是“当前被排除的状态不可能优于已知最优解”。2.2 单调性分析双指针效率的数学本质为什么双指针能做到 O(n)更本质地说是因为整个扫描过程里left 和 right 都各自最多移动 n 次。无论循环体内部做了多少判断两个指针的总移动次数被限制在 O(n) 级别这就是线性复杂度的来源。我曾经写过一段非常糟糕的滑动窗口代码——在窗口内用了额外一层循环来找最小值结果复杂度变成了 O(n·m)m 是窗口长度。后来用单调队列优化掉了内层循环才真正还原了 O(n) 的复杂度。这个教训想说明的是双指针本身只保证了外层扫描的线性如果窗口内部还有额外的高复杂度操作整体复杂度会被拖累。这里还要提醒一点双指针并不总是最优解。当数组无序、且不存在任何形式的单调关系时双指针无法直接使用往往需要先排序代价 O(n log n)或借助哈希表空间换时间。所以正确的做题顺序应该是先看数据是否有序或者题目是否隐含有序性再决定要不要用双指针而不是拿到题就默认双指针是正解。3. 双指针的主要变种及各自的适用边界双指针不是一种固定的写法而是同一思想在不同约束条件下的具体化。我习惯把它分成三大类相向双指针对撞指针、同向双指针快慢指针/滑动窗口、分离双指针分别在两个数组/链表上移动。三类变种的适用场景差异很大很多人在实际做题时搞混导致代码写得别扭甚至出错。3.1 相向双指针有序数组与回文判断的经典场景相向双指针是入门最常见的形式left 从最左开始right 从最右开始两个指针向中间靠拢直到相遇。典型应用包括有序数组的两数之和、反转数组、判断回文字符串、“盛最多水的容器”等。这类题的关键约束是每次移动哪一边取决于当前状态和目标之间的关系。以“盛最多水的容器”为例面积 距离 × 短边高度。如果当前左指针高度小于右指针那么移动右指针是没有意义的——因为距离在减小而短板仍然由左指针决定面积只可能更小。所以唯一的正确选择是移动左指针期待找到一个更高的左边界。这个推理模式在每一道相向双指针题里几乎都会出现哪个是瓶颈就移动哪个。3.2 同向双指针与滑动窗口子数组问题的通用解法同向双指针的核心特征是左指针和右指针同方向移动右指针负责“扩大探索范围”左指针负责“收缩满足条件”窗口在数组上滑动。它特别适合“连续子序列/子数组”类问题比如无重复字符的最长子串、长度最小的子数组、字符串最小覆盖子串等。滑动窗口有一个极其重要的细节窗口是左闭右开还是左闭右闭。这两种写法在代码上只差一两行但直接影响边界条件的处理。我个人更推荐左闭右开[left, right)因为它的循环不变量更好描述窗口内的元素是 [left, right) 这个范围right 指向的是“下一个待加入的元素”。这样一来初始化时 left0, right0 表示空窗口循环里先移动 right 再调整 left逻辑上很干净。3.3 快慢指针链表环检测与中点定位快慢指针是同向双指针的特例只不过两个指针的速度不同。弗洛伊德判圈算法就是典型的应用慢指针每次走一步快指针每次走两步如果链表有环两个指针必然在环内相遇如果无环快指针会先到达末尾。判圈算法里有个容易被忽略的数学结论相遇点不是环的入口。如果要找环的入口需要“相遇后把一个指针放回起点两个指针各走一步再次相遇的点就是环入口”。这个结论很多人背下来了但没想明白原因。实际上它等价于一个等式从链表头到环入口的距离等于环入口到相遇点沿环方向的距离的整数倍。理解了这一点你就不容易在代码里把“相遇点”和“环入口”搞混。快慢指针的另一个高频应用是找链表中点。慢指针走一步快指针走两步快指针到末尾时慢指针正好在中点。这个方法在不允许额外空间、只允许遍历一次的约束下几乎是唯一解。3.4 分离双指针归并排序与有序数组合并的骨架分离双指针指两个指针分别位于两个不同的数组/链表上各自独立移动典型场景是合并两个有序数组、判断一个字符串是否为另一个字符串的子序列、求两个有序数组的交集等。它的核心逻辑是每一次比较都只推进应该推进的那一侧。比如合并两个有序数组每次取两个指针指向元素中较小的一个放入结果数组然后移动对应的指针。这个逻辑本身不难但要注意“当一个数组遍历完时另一侧的剩余元素要整体追加到结果末尾”这种收尾操作经常被遗忘导致结果缺项。4. 边界处理双指针最容易翻车的地方标题里专门提到边界处理这确实是我在 debug 上花时间最多的地方。很多人的双指针代码思路完全正确但一提交就报错问题几乎都出在边界的三个维度上初始值怎么定、循环条件用什么、指针移动的时机。4.1 初始值的选定偏左还是偏右取决于你的循环不变量以二分查找和滑动窗口为例初始值的差别会导致整套边界逻辑的变化。如果你定义的是左闭右闭区间 [0, n-1]那么循环条件是 left right因为当 left right 时区间内还有一个元素需要检查如果你定义的是左闭右开区间 [0, n)循环条件就是 left right因为左闭右开意味着 left right 时区间已为空。很多同学在这两种写法之间反复横跳一会儿写 一会儿写 最后自己都晕了。我的建议是选定一种区间定义后全程保持一致不要混用。我从一开始就统一使用左闭右开 [left, right)因为它在处理“空区间”时特别直观配合 C 的迭代器风格或者 Python 的切片风格都很自然。在滑动窗口里初始时 left 0, right 0 表示一个包含 0 个元素的空窗口。右指针每向右移动一步窗口就“吃进”一个元素左指针每向右移动一步窗口就“吐出”一个元素。用这种视角看代码里的每一步都有明确的语义。4.2 循环条件的三种形态与对应的退出时机循环条件大致有三种典型形态场景类型循环条件退出时机相向双指针left right两指针相遇或交错滑动窗口扩展阶段right n右指针越界滑动窗口收缩阶段left right或由条件触发左指针超过右指针或条件不满足以“找无重复字符的最长子串”为例如果用左闭右开 [left, right)外层循环是 while (right n)每次把 s[right] 加入窗口然后通过内层 while 调整 left 直到窗口不再有重复字符。这里的关键是外层循环负责推进 right内层循环负责收缩 left两者职责分离。如果你把调整 left 的逻辑也写进外层 while 为条件的一部分代码会变得很难推理。另外要警惕“交错”的情况。有些题里left 可能超过 right例如 while (left right) 内执行 left 到 left right 才退出此时你必须在循环体内先判断 left 是否仍然合法否则下一步访问 nums[left] 就会越界。这种越界不是“偶发”而是逻辑设计时就没想清楚区间定义导致的系统性 bug。4.3 指针移动时机与收尾操作移动时机的问题可以浓缩成一个原则在每次移动之前确认当前状态已经被完整利用。比如在判断回文时left 和 right 各自指向待比较字符比较完再同时向中间移动在合并有序数组时比较完两个指针指向的元素后只移动“胜出”的那一侧。收尾操作是另一个高频失分点。以“合并两个有序数组”为例主循环结束后必然有一个数组还剩一些元素你必须单独处理剩下的部分。如果你用的是 C 的 vector可以用 insert如果用的是手动循环记得把剩余元素逐个拷贝过去。这个收尾操作看起来简单但非常多人在面试现场写漏导致结果比预期短一截。5. 实战案例从“两数之和”到“最小区间”的逐步推导理论说再多不如完整走一遍实战。这一节我选了四道难度递增的题从最基础的开始逐步展示双指针在真实题目中是如何变形的。我会尽量把每一步的思考过程写出来而不只是给出最终代码。5.1 两数之和 II有序数组最朴素的对撞题目是 LeetCode 167。给定一个已按升序排列的整数数组和一个目标值找出两个数使它们的和等于目标值返回它们的下标下标从 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 [-1, -1]关键点在于为什么 current_sum target 时要移动 left 而不是移动 right因为如果移动 right值只会更小和 target 的距离只会更远这个方向完全不可能通向答案。反之亦然。每一步只保留一个有希望的移动方向这就是对撞双指针的全部秘密。这道题还有一个变体如果数组不是有序的怎么办那就只有两种选择先排序再用双指针注意排序后下标意义会变或者用哈希表记录已访问元素。哪种更好取决于题目要求你返回的是值还是下标——如果要求返回值排序没问题如果要求返回原始下标用哈希表才是正解。这个区别能帮你避免面试中“方法选对但输出不对”的尴尬。5.2 盛最多水的容器瓶颈决定移动方向这道题是 LeetCode 11。给定 n 个非负整数每个数表示一个柱子的高度选择两根柱子与 x 轴构成一个容器求容器能容纳的最大水量。容量的计算是两个柱子之间的距离 × 较矮柱子的高度。算法思路依然是对撞双指针。关键推理是如果 height[left] height[right]那么无论 right 怎么向左移动right-1, right-2, ...容器的宽度在减小而高度上限仍然是 height[left]因为较矮的柱子没变所以面积只会更小。唯一可能让面积变大的做法是移动 left期望找到更高的左柱子。def max_area(height): left, right 0, len(height) - 1 best 0 while left right: width right - left h min(height[left], height[right]) best max(best, width * h) if height[left] height[right]: left 1 else: right - 1 return best这里的细节是 height[left] height[right] 时移动哪边其实都一样因为无论移动哪边宽度减小高度不可能超过当前高度面积都不会变大。所以随便选一边移动即可。这种“相等时任意选择”的情况在双指针题里出现过很多次没必要纠结。5.3 无重复字符的最长子串滑动窗口的状态维护这道题是 LeetCode 3是滑动窗口最经典的入门题。要求给定一个字符串找出其中不含有重复字符的最长子串的长度。用滑动窗口解决时的核心问题是怎么判断窗口内有没有重复字符。最直接的做法是用一个哈希集合HashSet记录窗口内出现过的字符。右指针每扩展一格就把新字符加入集合如果发现新字符已经在集合里就收缩左指针直到把重复字符“挤出”窗口外。def length_of_longest_substring(s): seen set() left 0 best 0 for right, ch in enumerate(s): while ch in seen: seen.remove(s[left]) left 1 seen.add(ch) best max(best, right - left 1) return best这段代码里的 while ch in seen 是精华它不是在判断“整个窗口是否合法”而是专门处理“新加入的 ch 引发了重复”这种情况。一旦 ch 重复就把左指针一步步右移同时把移出窗口的字符从集合里删掉直到 ch 不再重复。这里也要注意从集合中删除的是 s[left]而不是 ch 本身很多人会在这里写错导致集合状态和实际窗口不一致。这套“一边扩一边修”的流程几乎适用于所有窗口类问题。5.4 最小区间问题从“两指针”到“多指针”的升级最后看一道稍微进阶的题给定 k 个有序数组找一个最小的区间 [a, b]使得每个数组至少有一个元素落在区间内。这道题的暴力做法是枚举所有区间并验证复杂度爆炸。用双指针思想扩展出来的方法是先把每个数组的第一个元素连同它所属的数组编号放进一个最小堆同时维护当前区间的最大值和最小值——最小值从堆顶取最大值在每次插入时更新然后不断地从堆中弹出最小元素再从它所属的数组里取下一位元素入堆直到某个数组被取完。import heapq def smallest_range(nums): heap [] max_val float(-inf) for i, arr in enumerate(nums): heapq.heappush(heap, (arr[0], i, 0)) max_val max(max_val, arr[0]) best_range float(inf) answer [0, 0] while heap: min_val, arr_idx, pos heapq.heappop(heap) current_range max_val - min_val if current_range best_range: best_range current_range answer [min_val, max_val] if pos 1 len(nums[arr_idx]): break next_val nums[arr_idx][pos 1] heapq.heappush(heap, (next_val, arr_idx, pos 1)) max_val max(max_val, next_val) return answer这里的“多指针”指的是我们有 k 个指针分别指向 k 个数组的当前位置它们之间通过堆来维护最小值。这个思路本质上还是双指针的推广整个过程中每个指针只会从前往后移动不会后退。如果把 k2 代入它就退化成了双指针找最小覆盖区间的问题。6. 双指针的常见误区与调试经验写双指针代码最让人头疼的往往不是思路而是一些反复出现的细节问题。我总结了几类经典误区你在自查代码时可以逐条比对。6.1 死循环指针没有正确步进最常见的原因是某个分支里忘了移动指针或者移动的方向写反了。比如对撞双指针里如果 current_sum target 时不小心写了 right - 1在部分输入上会死循环。这种 bug 很难通过看代码发现因为“方向反了”写在纸面上非常隐蔽。我调试死循环的经验是在循环体开头打印三个值——left、right、以及当前状态的关键量比如 current_sum。一旦发现 left 和 right 在连续多次迭代中数值不变就说明某条路径上没有触发指针移动。另一种办法是给循环加一个计数器超过 n5 步就强制退出并报错这能在不干扰正常运行的情况下快速暴露问题。6.2 越界访问不存在的数组元素越界分为两种一种是 left 或 right 指向了 -1 或 n另一种是窗口内部逻辑越界。第一种通常发生在循环条件的边界——比如 while (left right) 里left 最后可能变成 right1此时如果循环体里直接使用 nums[left] 就会越界。第二种发生在字符串处理时比如判断子序列时短串的指针已经走完但长串的指针还在继续移动此时要对“短串指针已越界”做特殊处理。避免越界的终极手段不是小心翼翼而是在每次访问数组元素前先写一条断言或边界检查。Python 的 -1 索引会自动访问最后一个元素这有时反而会掩盖 bug所以写 Python 时更要注意下标合法性。6.3 漏解过早排除了正确状态这是最严重的一类错误因为它不会报错只会默默地给出一个次优答案。根本原因通常是**“单调性判断”出错**——你误以为某个方向不可能产生最优解于是把它永久剪掉了。举个例子在滑动窗口求最短覆盖子串时窗口收缩阶段如果只判断“窗口是否还包含所有目标字符”而忽略了“当窗口已经包含所有字符时应该先记录答案再收缩”那么答案会一直停留在初始的极大窗口而不是最小覆盖窗口。正确的顺序是先尝试记录当前窗口长度再收缩左指针。这个顺序一旦颠倒漏掉的就是最优解。排查漏解问题时我会用两种手段交叉验证一是写一个最朴素的暴力解O(n²) 甚至 O(n³)在随机小数据上对比双指针解法的输出二是针对边界构造极端数据比如所有元素相同、数组长度为 1、目标值刚好等于某个元素等。两种手段结合基本能把 90% 的漏解问题逼出来。7. 双指针的扩展视野从力扣题到真实工程刷题归刷题双指针在真实工程里的应用其实非常广泛。如果你觉得双指针只是在刷题和面试里才有用那就低估了它。7.1 数据库与分布式排序的归并思想一个非常接近生活的例子是数据库里多路归并排序的底层实现。当内存放不下全部数据时数据库会把大文件切分成多个有序小文件然后使用一个多指针每个文件一个指针的归并过程不断取出当前最小的元素写入结果文件。这个过程的本质就是“分离双指针”的 k 路扩展版。在分布式计算框架里也有类似场景多个节点返回的有序结果需要被合并成一个全局有序流常见实现就是用一个小顶堆承载多个“文件指针”不断弹出最小值。你如果在自己的代码里实现过一个多路归并再回头看双指针会发现它的应用边界远不止数组和链表。7.2 网络包重组与流式数据处理处理网络数据包重组的场景里也有双指针的影子。当数据包乱序到达时接收端维护一个缓冲区用读指针和写指针来管理当前已经连续收到的数据范围。写指针负责写入新到的数据读指针负责消费连续的数据。这正是滑动窗口在真实世界里的缩影。我个人在做一个日志分析工具时也用过类似思路多个日志流按时间戳乱序到达需要按时间顺序输出。我维护了一个“最小时间戳堆 双缓冲”结构本质上也是一个多指针归并问题。当时做完之后我才意识到刷题时反复训练的滑动窗口和双指针真的会在某个工程场景里以另一种形式出现。7.3 与其它算法思想的交叉从二分到双指针双指针经常和二分查找配合使用。比如在“有序数组中查找目标值的左右边界”这类问题里你可以用两次二分分别定位左边界和右边界但如果你需要“找到所有满足条件的区间”双指针可能更合适。更复杂的题目里双指针甚至会作为二分查找的 check 函数给定一个候选答案用双指针 O(n) 判断当前答案是否可行外层再用二分压缩答案区间。这种“二分答案 双指针验证”的组合非常万能值得你在刷题时多加留意。8. 高效学习双指针的路径与面试应对策略如果你现在正准备面试或者刚开始系统刷算法题我给一些“少走弯路”的学习建议。这些建议不是从教科书里抄来的是我自己从零基础学算法、又带过不少人刷题后的实际体会。8.1 分类刷题每一类至少精做五道再换类我的建议是把双指针分成四类对撞、快慢、滑动窗口、分离。每类先精做五道题不要贪多。精做指的是不看题解独立完成、能清晰讲出每一步的“为什么”、能写出边界测试用例。只有达到这三条标准这道题才算真正会了。这里特别推荐一个学习技巧每做完一道题把这道题的“单调性证据”单独写下来。所谓单调性证据就是一句话说明“为什么当 left 向右移动时right 不需要向左回退”。比如两数之和里的证据是“数组递增左指针右移和变大”盛水容器里的证据是“短板不变时面积不可能增大”。把这句话写出来比抄十遍代码都有用。8.2 手写代码与口头讲解的注意点面试现场手写双指针代码有几个容易被扣分的细节命名要清晰。left、right 比 i、j 好一万倍别人读起来省力你自己写起来也不容易弄混。循环条件先写注释说明区间定义。比如“// [left, right) 区间内是当前窗口”这行注释能防止你在紧张时把区间搞混。主动讲边界情况。写完代码后自己先说出“如果数组长度为 0 或 1我的代码会怎样”这比等面试官追问要好得多。不要着急写代码。先用 30 秒把算法思路讲清楚再动手。很多双指针题本身不难但思路没讲清楚会显得准备不足。8.3 从刷题到融会贯通的复盘方法最后说一个我很推荐的做法每周对同一道题写两种不同写法的双指针版本。比如这道题既可以用左闭右闭写法也可以用左闭右开写法你就把两种写法都写一遍然后对比它们的边界条件差异。这种“对比练习”能让你真正理解区间定义对代码的影响而不是死记硬背某一种模板。学习双指针最忌讳的是“眼高手低”——看题解觉得很简单合上书自己写却卡在 while 条件上。我见过太多人栽在这个坎上而唯一的破解方法就是多写、多 debug、多复盘。指针的移动方向、循环的终止条件、区间的开闭选择这些东西只有在代码里反复摩擦过才能长成一种直觉。对我来说双指针让我受益最大的地方倒不是面试而是它培养了一种思维习惯当一个问题看起来需要遍历整个状态空间时先别急着暴力枚举而是问一句“状态之间有没有单调关系能不能通过限制搜索方向来剪枝”带着这个问题去看很多算法题会发现柳暗花明。真心建议你在刷题时把每个双指针题的“单调性依据”写成一两句话积少成多之后你对算法的理解会上一个台阶。
返回列表