多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

双指针算法进阶:边界条件、单调性与经典模型实战拆解

双指针算法进阶:边界条件、单调性与经典模型实战拆解 最近在整理优选算法的双指针专题这是第二期。第一期把双指针的底层逻辑、指针定义和最高频的几种使用场景做了梳理这一期我想把难度稍微往上提一提重点聊那些真正会让代码“翻车”的细节边界条件怎么设、指针什么时候动、窗口怎么扩怎么缩以及几类经典模型从暴力解到双指针解的完整推导过程。如果你已经在刷题途中或者刚把双指针基础过完一遍但一写复杂用例就暴露问题这篇内容非常适合你。双指针算法看起来逻辑简单无非就是两个下标配合移动但实际写起来十个里面有八个是在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-left1很多人会搞混。这取决于你的区间定义。如果是左闭右闭也就是left和right都包含在窗口内那么窗口长度是right - left 1。如果是左闭右开那么长度是right - left。我在写题时习惯统一用左闭右闭因为返回长度时加一比较好记。但代价是左闭右闭区间初始时left0、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也欢迎在评论区记录下来每次复盘都是对自己的一次训练。算法这条路没有捷径但每总结一个模型后面的路就会顺畅一点。完
返回列表