ARTICLE DETAIL

资讯详情

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

快慢指针详解:从LeetCode 26和27题看双指针的通用套路

快慢指针详解:从LeetCode 26和27题看双指针的通用套路 刷力扣刷到第26题和第27题的时候很多新手会有一个疑惑一个是“删除有序数组中的重复项”一个是“移除元素”看起来题干都不一样为什么那么多题解都把这两题放在一起讲答案很简单——它们本质上都是同一类问题在一个数组里按条件把一部分元素“挑出来”放到前面把剩下的元素丢到后面不去管。这类问题最经典的解法就是快慢指针。今天这篇笔记我就围绕这两道题把快慢指针的来龙去脉、代码细节、还有我自己踩过的坑一次说清楚。不管你是刚开始刷力扣还是已经刷了一阵子卡在“双指针”这个知识点上这篇文章都能帮上忙。先说下我的背景方便你对号入座。我是那种半路出家的程序员算法基础不算扎实刷力扣主要靠“题海战术总结套路”。快慢指针这套东西我一开始看题解觉得很简单不就是两个下标一快一慢嘛但实际上手写的时候边界条件、指针移动顺序、返回值含义哪哪都能出错。所以这篇文章里我不会只贴官方题解我会把我自己从“看题解一脸懵”到“闭着眼写对”的过程拆给你看。你跟着走一遍这两题就能吃透顺带还能把快慢指针这个套路用到后面很多题上去。1. 先看清题目两道看似不同实则一家的题1.1 第26题删除有序数组中的重复项第26题的完整描述是给你一个升序排列的数组nums请你原地删除重复出现的元素使每个元素只出现一次返回删除后数组的新长度。元素的相对顺序应该保持一致。不需要考虑数组中超出新长度后面的元素。注意几个关键词升序排列、原地删除、相对顺序、返回新长度。这几个词直接决定了我们能不能用快慢指针。因为数组是有序的所以重复元素一定相邻。这意味着我们不需要用一个哈希表去记录某个元素是否出现过只需要比较相邻元素就行。这是这道题能用快慢指针、而且能做得那么干净的前提。“原地”的意思是不能额外开一个新数组然后把不重复的元素拷进去再让原数组等于新数组。力扣的判题逻辑是它会把nums的前len个元素拿出来检查是否符合“无重复”的要求len就是你返回的那个整数。所以我们要做的就是通过某种方式把不重复的元素依次放到数组开头然后返回不重复元素的数量。举个例子nums [0,0,1,1,1,2,2,3,3,4]最后应该变成[0,1,2,3,4,...]前面的0,1,2,3,4是有效部分后面是什么无所谓返回5。1.2 第27题移除元素第27题的描述是给你一个数组nums和一个值val你需要原地移除所有数值等于val的元素并返回移除后数组的新长度。元素的顺序可以改变不需要考虑数组中超出新长度后面的元素。和第26题最大的区别是第27题不需要保持剩下的元素“唯一”只需要把不等于val的元素都留在前面另外它也没有“有序”这个前提数组可以是乱序的。顺序可以改变这给了我们更大的自由度。但实际上用快慢指针来做顺序也不会变而且代码和第26题几乎一模一样。举个例子nums [3,2,2,3], val 3有效的部分是[2,2]返回2。注意第27题没有说“有序”所以你不能像第26题那样只依赖相邻比较。这里的判断条件是“是否等于给定的val”这个条件足够简单可以独立判断每个元素。1.3 为什么这两题放一起刷刷题讲究“一次掌握一类题”。第26题和第27题虽然一个是去重一个是移除指定值但它们的解题模型完全一致用一个慢指针维护“已经处理好的区间”的末尾用一个快指针遍历整个数组当快指针指向的元素满足条件时把它搬到慢指针的位置然后慢指针前进一格。这个模型串起来的题目特别多。比如第283题“移动零”本质上就是把所有非零元素按顺序搬到前面把零扔到后面也是这个套路再比如第80题“删除有序数组中的重复项 II”允许每个元素最多出现两次稍微变形一下同样能用快慢指针解决。所以把第26和第27题放在一起刷不是偷懒而是刻意练习“同构问题”。这也是为什么很多刷题攻略会把这两题排在“力扣热题100”或入门列表的前面。它们不会涉及复杂的递归、动态规划只需要你想明白“两个指针分别代表什么”就够了。想明白了后面的链表中环、环形链表找入口、寻找重复数等经典题你都会有熟悉感。2. 快慢指针核心思想不是算法炫技而是最朴素的覆盖思想2.1 快慢指针到底在干嘛我第一次看快慢指针的题解时脑子里想的是一快一慢两个箭头在数组上跑一个跑得快一个跑得慢像电影里的追逐戏。但实际代码里两个指针并不是“以不同速度移动”而是按照不同节奏移动。我们可以这样理解慢指针slow指向“下一个可以放入有效元素的位置”快指针fast遍历每一个元素负责“审查”它是否符合保留条件。如果符合就把nums[fast]赋值给nums[slow]然后slow如果不符合slow原地不动fast继续前进。生活化的类比是这样的你有一条流水线前面放了一排箱子你要把其中合格的箱子全部搬到仓库的最前面摆好。fast就像质检员一个箱子一个箱子看过去slow就像仓库的摆放位置指示器始终指向“下一个空位”。合格的箱子就放到当前空位然后空位往后挪一格不合格的箱子直接无视。整个过程不需要把仓库清空重来只需要在原地搬动。第26题里“合格”的条件是当前元素nums[fast]和已经放进仓库的最后一个元素不同。因为数组有序所以只需要比较nums[fast]和nums[slow - 1]。如果不同说明出现了一个新元素可以放进仓库。第27题里“合格”的条件是nums[fast] ! val。就这么简单。2.2 覆盖与保留指针移动的节奏这里有一个特别容易绕晕的点为什么可以直接用nums[slow] nums[fast]不会把还没遍历到的元素覆盖掉吗答案是因为slow fast恒成立。慢指针指向的位置要么是快指针已经经过的位置要么就是快指针当前所在的位置它永远不会跑到快指针前面去。所以把nums[fast]赋给nums[slow]覆盖掉的都是“已经不需要的元素”。以第26题为例nums [1,1,2]。初始slow 1fast 1。nums[slow] nums[fast]也就是1 1重复了于是fast加1变成2。此时nums[fast] 2nums[slow - 1] nums[0] 1不相等执行nums[slow] nums[fast]即nums[1] 2数组变成了[1,2,2]然后slow 2。最后返回slow也就是2前两个元素是[1,2]正确。注意这里nums[1]原来的值也是1被覆盖成2了但我们不在乎。因为我们已经把fast遍历到了21这个位置早就不需要保留了。很多题解里slow初始值有两个流派流派一让slow 1fast 1前提是数组至少有一个元素流派二让slow 0fast 0先处理第一个元素再说。我个人建议用流派一因为第26题中第一个元素必然要保留慢指针直接从1开始正好指向“第二个位置”逻辑更直观。第27题则可以用slow 0因为第一个元素也可能是要被移除的val必须从头判断。2.3 复杂度分析与空间优势这两道题的时间复杂度都是 O(n)空间复杂度 O(1)。fast指针从1或0遍历到nums.length - 1每个元素访问一次没有嵌套循环所以是线性时间。全程只用了两个整数下标做指针没有额外数组没有哈希表所以是常数空间。对比一下暴力解法第26题一个粗暴的思路是每找到一个重复元素就把它后面的所有元素往前移一位这样最坏情况下时间复杂度会到 O(n^2)因为每次删除都要移动后续元素。第27题如果用splice之类的操作每删一个元素也要移动后续元素同样是 O(n^2)。快慢指针的巧妙之处在于它把“删除”变成了“覆盖”把“移动多次”变成了“最多移动一次”既省时间又省空间。这也是为什么大厂面试时特别喜欢考这类题。它们考察的不是你会不会调 API而是能不能理解数组底层“元素搬移”的本质并在常数空间内完成。你给面试官讲清楚 slow 和 fast 的语义比背十道题都有用。3. 手把手写代码两种语言的实现细节3.1 第26题代码实现先看第26题最经典的写法我平时用 C 和 Python 各写一遍方便你对照。C版本int removeDuplicates(vectorint nums) { if (nums.empty()) return 0; int slow 1; for (int fast 1; fast nums.size(); fast) { if (nums[fast] ! nums[slow - 1]) { nums[slow] nums[fast]; slow; } } return slow; }Python版本def removeDuplicates(nums): if not nums: return 0 slow 1 for fast in range(1, len(nums)): if nums[fast] ! nums[slow - 1]: nums[slow] nums[fast] slow 1 return slow这段代码的精髓就在if (nums[fast] ! nums[slow - 1])。slow - 1指向的是已经处理好的、最后一个“保留元素”。因为数组有序所以nums[fast]只需要和这个最后一个保留元素比较。如果相等说明nums[fast]是重复项如果不相等说明出现了一个新的值需要保留。注意slow从1开始不是因为最小下标是1而是因为下标0的元素我们可以无条件保留——反正第一个元素不可能重复。如果数组为空直接返回0。3.2 第27题代码实现第27题的代码长得几乎一样但判断条件和初始化有一点不同。C版本int removeElement(vectorint nums, int val) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! val) { nums[slow] nums[fast]; slow; } } return slow; }Python版本def removeElement(nums, val): slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slow这里slow必须从0开始因为数组的第一个位置完全有可能是val不能无条件保留。同时这个写法不需要特判空数组因为如果nums为空for循环不执行直接返回slow 0。另一个值得注意的点第27题允许改变元素顺序所以还有另一种“相向双指针”的写法从数组两端往中间走把后面的元素搬到前面来覆盖val这样可以减少搬移次数。但那个写法虽然常数更优可读性和思路复杂一点。这里我们先掌握快慢指针这种“保序但不省搬移”的写法因为这个思路更通用后续很多题都是建立在它的语义上的。3.3 关键步骤逐行解读很多初学者看代码能看懂但自己写就出错问题往往出在没有“按行理解”上。我拿第26题举例把每一行都拆开讲一遍你跟着想一遍就通了。第一行if (nums.empty()) return 0;。空数组没有元素新长度为0。注意这一步必不可少否则后面访问nums[slow - 1]时会越界。虽然力扣的测试用例一般会给非空数组但作为健壮性处理最好加上。第二行int slow 1;。明确 slow 的语义慢指针指向“下一个保留元素应该放的槽位”。初始时我们已经默认保留了下标0的元素所以下一个槽位是1。第三行for (int fast 1; fast nums.size(); fast)。快指针从1开始逐个遍历剩下的元素。为什么从1开始因为下标0已经处理过了。这里fast完全等价于“当前正在审查的元素下标”。第四行if (nums[fast] ! nums[slow - 1])。比较当前元素和“上一个保留元素”是否相同。slow - 1就是最近一个被保留的元素的下标。别写成nums[fast - 1]那是拿当前元素和它紧挨着的上一个元素比较在重复元素连续多个的情况下会出错。第五行nums[slow] nums[fast];。把当前元素复制到慢指针指向的槽位。这一步之所以安全是因为slow fast复制不会覆盖任何未来要遍历的元素。第六行slow;。槽位后移等待下一个保留元素。循环结束后slow的值正好是保留元素的数量同时也是下一个空闲槽位的下标返回slow即可。第27题的逐行逻辑基本一样只是不需要empty特判slow初始值是0条件变成nums[fast] ! val。你可以自己把上面六行换成这个条件跑几个例子感受一下差别。4. 实战中踩过的坑与排查技巧4.1 空数组和单元素数组空数组的问题很好理解但单元素数组是个隐藏的坑。比如第26题nums [1]如果按照slow 1, fast 1来写for循环里fast nums.size()直接不成立返回slow 1正确。所以你不需要特别特判单元素数组。第27题也一样nums [1], val 1slow 0, fast 0nums[0] val什么都不做返回slow 0正确。nums [1], val 2nums[0] ! val执行赋值nums[0] nums[0]然后slow 1返回1正确。真正容易出错的是把第26题的写法直接套到第27题上。你在第26题里写了if (nums.empty()) return 0; int slow 1;如果套到第27题对于nums [1], val 1你会进入循环吗会因为fast 1但nums.size() 1所以不会进入。返回slow 1但实际应该是0。这就是初始化语义不同导致的 bug。我的建议是每次写代码之前先问自己 slow 是“下一个槽位”还是“已经保留的末尾”。第26题里 slow 指向下一个槽位但slow - 1是末尾第27题里 slow 就是下一个槽位没有“末尾”概念。想清楚再动手。4.2 慢指针的边界含义慢指针的值在循环结束时既表示“下一个空闲槽位的下标”又表示“有效元素的数量”。这两个含义在大多数情况下是统一的因为数组下标从0开始如果有 n 个有效元素它们占据下标0到 n-1那么下一个空闲槽位就是 n。这个二义性有时候会让人迷糊。比如第26题返回slow你心里想着“返回的是数量”但代码里slow同时也是下标。如果哪一天你把slow初始化为0写成了返回slow 1那就会多算一个。我自己就犯过这种错误。为了避免混淆可以在代码里加上注释写清楚slow的语义。例如int slow 1; // slow 表示下一个保留元素应该放置的位置 // 同时循环结束后 slow 就是去重后元素的个数这样做的好处是当你回头调试某个 bug 时不用重新推导一遍slow代表什么。这算是一个很小但很实用的编码习惯。4.3 移动顺序错了会怎样快慢指针写法里一个最容易错的地方是先slow再赋值还是先赋值再slow答案一定是先赋值后slow。如果把顺序搞反比如写成if (nums[fast] ! nums[slow - 1]) { slow; nums[slow] nums[fast]; }那么第一次遇到新元素时slow从1变成2然后把元素放到了下标2等于跳过了下标1最终数组前面会空出一个位置返回的长度也会偏大。这个问题在纸上模拟一遍就能发现但面试紧张的时候特别容易手滑。我的排查技巧是在循环里加一个print打印每次赋值后slow和fast的值以及数组当前的样子。比如cout fast fast slow slow nums[slow-1] nums[slow-1] nums[fast] nums[fast] endl;跑几个用例马上就能看出来槽位是不是跳着更新的。平时刷题可以用这个办法但面试时不一定允许你用调试器所以最好还是养成“先想清楚再写”的习惯。4.4 常见错误速查表我把这两题里最常见的错误整理成一个速查表你可以收藏起来下次刷题前扫一眼。错误类型错误示例正确做法原因条件写错nums[fast] nums[fast-1]nums[fast] ! nums[slow-1]必须和“最后一个保留元素”比较而不是前一个遍历元素慢指针初始值错第27题把 slow 初始化为1第27题 slow 初始化为0第27题第一个元素可能等于 val不能默认保留返回长度错返回slow1或slow-1返回slow循环结束时 slow 已经等于有效元素个数忘记空数组判断第26题直接访问nums[slow-1]先判断nums.empty()空数组时slow-1越界赋值顺序颠倒先slow再赋值先赋值再slow否则会跳过一个槽位导致数据错位用 for 的变量名覆盖原义把fast写成i但内心没分清它是在遍历或是在标记位置明确slow是槽位fast是遍历下标变量名混乱容易导致逻辑混乱这张表里的错误我基本都犯过。特别是第27题我一开始顺手把第26题的代码改过来只改了if条件忘了改slow的初始值结果怎么调都不对。从那以后我每次做这类题都会格外检查初始条件。5. 快慢指针的延伸从这题到力扣热题1005.1 还有哪些经典场景第26、27题是快慢指针在数组上的入门。往后面走这个思想还可以应用到很多看起来完全不像的题目上。这里我列几个典型的你会发现它们的核心代码结构有高度的相似性。第一个是力扣第283题“移动零”。题目要求把数组中的所有0移动到末尾同时保持非零元素的相对顺序。解法是用快慢指针快指针遍历遇到非零元素就放到慢指针位置慢指针后移遍历结束后从慢指针开始到数组末尾全部补0。这和第27题几乎一摸一样唯一的区别是第27题是“移除指定值”移动零是“把指定值移到末尾”本质上都是分离两类元素。第二个是第80题“删除有序数组中的重复项 II”。这题允许每个元素最多出现两次不能用“和前一个保留元素比较”的简单规则了而是需要比较nums[fast]和nums[slow - 2]。为什么是slow - 2因为如果nums[fast]和nums[slow - 2]相等说明在这个位置之前已经有两个相同的元素了再放就超过两个了。慢指针仍然指向槽位但判断逻辑从“和前一个比”变成“和前两个比”。你看核心框架没变变的只是比较的偏移量。第三个是链表相关的题目比如第876题“链表的中间结点”。这里快指针每次走两步慢指针每次走一步快指针到终点时慢指针正好在中间。虽然“速度”真的不同了但本质思想还是“两个指针协同一个定位一个探索”。第四类是力扣热题100里的“寻找重复数”第287题这个稍微进阶一点用快慢指针在数组的“下标和值之间形成的链表”中找环。但如果你把前面的基础打牢你会发现它依然没有逃出“快慢指针”的范畴。5.2 怎样练才能举一反三很多刷题攻略都会说“掌握快慢指针一劳永逸”但实际难的不是理解而是遇到新题时能识别出“这题可以用快慢指针”。我的经验是当你看到题目里出现这些关键词时可以优先往这个方向想原地操作不能开新数组只能在原数组上改动。删除/移除/移动要把满足某些条件的元素去掉或挪位置。保持相对顺序剩下的元素顺序不能变。数组有序往往意味着可以用更简单的比较条件。当这四个词同时出现时十有八九就是快慢指针的题。如果只看前三个词也有可能是覆盖型双指针。练习的时候我建议不要只看一道题就急着看下一道而是把同类题集中起来刷。比如今天刷完第26、27题明天做移动零后天做删除有序数组中的重复项 II。你会发现你只需要修改一两行代码就能通吃一组题。这个过程能帮你深度理解“慢指针表示下一个有效位置”这个核心语义而不是单纯记住某道题的代码。另外刷题时一定要亲手在草稿纸上画一画数组下标的变化。我见过不少同学代码能跑过但问他 slow 为什么最终等于结果长度他答不上来。画画图把每一步的数组状态写出来比看一百遍题解都有用。比如nums [0,0,1,1,1,2,2,3,3,4]你把每一步slow和fast的值标出来很快就能看出规律当fast遇到新值slow就会加1而slow就是“已经发现的新值数量”。最后再说一个刷题时的心态问题不要觉得写对一次就完事了。我建议隔两三天在不看题解的情况下把第26、27题再默写一遍。默写的时候你会发现自己可能忘了空数组判断或者忘了slow - 1的含义。这很正常重复默写就是为了把“快慢指针”这套动作变成肌肉记忆。等你能在不假思索的情况下写对这两题再往后的移动零、删除重复项 II基本就是顺水推舟了。我个人在实际操作中的体会是快慢指针这套东西最怕的就是死记代码。你只要理解了“慢指针指的永远是下一个有效位置快指针负责遍历并按要求投递元素”所有题目都会变得很亲切。第26题和第27题是这个模型的起点也是我最推荐的入门组合。把这两题吃透后面刷题会上一个台阶。
返回列表