ARTICLE DETAIL

资讯详情

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

双指针法详解:力扣27题移除元素的底层逻辑与面试要点

双指针法详解:力扣27题移除元素的底层逻辑与面试要点 如果我说力扣第27题“移除元素”比你想象的更能暴露编程功底你可能觉得我在夸大其词。但这个看似平平无奇的题目确实是《代码随想录》数组系列里特别值得反复琢磨的一道。它坑过我的面试候选人也坑过我早年刷题时的提交记录。今天就把它彻底讲清楚。写算法题这件事很多人的误区是“会做题就行”。但真正拉开差距的是你能不能把每一步选择背后的道理讲明白为什么双指针能省时间为什么不能直接删除边界条件为什么这么处理把这些问题想透你刷的不是一道题而是一类题的底层逻辑。这篇文章按《代码随想录》的口径把“移除元素”从暴力解法到双指针优化、从边界条件到面试追问、从同源扩展题到复杂度证明完整地拆一遍。适合正在刷力扣进入瓶颈期的读者也适合准备面试想让自己表达更清楚的人。我还会把我实际踩过的坑、面试别人时看到的典型失误一并写进来。1. 题目拆解移除元素到底在考什么1.1 一道题背后的三个“考点”先把题目完整地抄下来给你一个数组 nums 和一个值 val你需要原地移除所有数值等于 val 的元素并返回移除后数组的新长度。不要使用额外的数组空间。元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。这道题表面是“移除”两个字的语文题实际上考的是三个点。第一个数组的存储结构。数组这一章为什么放在《代码随想录》的最前面因为数组是所有线性结构的地基。数组在内存里是连续空间这意味着你“删”一个元素并不像删链表节点那样改个指针就算完数组的删除本质上是一个“覆盖”操作。这是这道题首先要意识到的。第二个空间复杂度的约束。题目明确说“不要使用额外的数组空间”这直接封死了你开一个新数组、把不等于 val 的元素装进去再拷回来的幼稚思路。面试官要的就是你在原地解决这背后考的是对原地算法in-place algorithm的理解。第三个边界条件的敏感度。返回值到底是长度还是数组空数组怎么办val 出现在数组头部怎么办这些细节是提交代码时反复吃“Wrong Answer”的地方。所以这道题做对不算本事做对还知道为什么才是本事。1.2 为什么“直接删除”这事根本不存在很多刚刷题的人看到“移除”第一反应是调用语言自带的删除方法不行吗在 Python 里list.remove(val)或者写个循环nums.pop(i)多简单。我见过不少候选人真就这么答。这个思路错在三个地方。第一pop和remove的时间复杂度不是 O(1)。Python 的list底层是动态数组pop(i)一旦删掉中间某个位置所有后续元素都要往前挪单次操作就是 O(n)。最坏情况下你每个元素都调一次pop整体复杂度直接变成 O(n²)。第二循环里一边遍历一边删还会引发经典的“索引错位”问题。你用for i in range(len(nums))去遍历删掉一个元素后后面所有元素下标集体前移下一次循环访问的i已经指向了原来i1的位置结果就是漏掉元素。这就是我常说的“删着删着把自己绕进去了”。第三有些语言里数组长度是固定的根本没有“删除”这个动作。C 和 C 的静态数组长度编译期就定死了你能做的只有“覆盖”。所以这道题的真正含义是把不等于 val 的元素重新排到数组前面并返回有效长度让后面那些多余元素“没人管”。题目最后那句“你不需要考虑数组中超出新长度后面的元素”就是在告诉你别管尾巴前面的东西才是关键。想明白这一点整道题的思路就顺了。2. 双指针法为什么它是最优解2.1 暴力法先看清它的上限在哪在讲双指针之前我先把暴力法说完不是浪费时间而是为了让你有一个对比的坐标系。暴力法的思路很直白从头扫描数组碰到一个等于 val 的元素就把后面所有元素整体往前挪一位把当前这个位置盖掉。然后指针停留在原地继续检查新挪过来的这个元素。伪代码大概是这样的def remove_element_brute(nums, val): i 0 while i len(nums): if nums[i] val: # 把 i 之后的元素整体前移 for j in range(i, len(nums) - 1): nums[j] nums[j 1] else: i 1 return len(nums)看着好像挺合理但你把脑子里的运行过程放慢就会发现问题每删除一个元素都要把后面所有元素搬一次。如果数组里有 k 个等于 val 的元素总搬运次数就是 k × (n - 平均删除位置)。最坏情况下数组全等于 val你每删一个都要搬动剩余所有元素总时间是 n (n-1) ... 1 O(n²)。在 n 是 10⁵ 级别的测试数据面前O(n²) 直接超时惨不忍睹。我当年第一次提交这道题就用这个方法在力扣上吃了一个 TLE从此记住了这个教训。暴力法的价值在于它给了你一个“下限”。你后续想出的所有优化本质上都是在减少“搬运次数”。2.2 快慢指针的思维跃迁从“删掉”变成“保留”暴力法的问题在于它把注意力放在“怎么删”上。而双指针法的精髓是把注意力放在“怎么留”上。我给你一个生活化的比喻。你想在一堆苹果里挑出所有烂苹果扔掉暴力法是“看见一个烂的就把后面所有苹果往前挪”。快慢指针则是你准备一个空篮子左手慢指针指向篮子里要放的位置右手快指针挨个检查每个苹果。好的放入篮子烂的直接忽略。最后篮子里的数量就是答案。在这个比喻里快指针fast负责“探索”把数组从头到尾扫一遍。慢指针slow负责“记账”记录下一个可以用来覆盖的位置。每找到一个不等于 val 的元素就把它写到nums[slow]上然后 slow 前进一位。核心代码如下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你对照动作拆一遍fast 从 0 开始逐个元素看。如果nums[fast] ! val说明它值得保留。把它覆盖到 slow 指向的位置。如果nums[fast] val跳过fast 继续走slow 不动。循环结束slow 正好等于保留元素的数量。我把这个叫“一快一慢快的负责看慢的负责写”。快指针扫完整个数组只需要 O(n) 次操作慢指针最多也只往前走 n 次整体就是 O(n)空间复杂度 O(1)。你可能会问难道真的不用管数组后面的那些残留值吗再读一遍题目最后那句话——不需要考虑超出新长度的元素。面试时你也可以主动提一句“我保留的元素都放在前 slow 个位置后面是什么无所谓”这本身就是一种对题目理解的体现。2.3 代码落地Python 和 C 的两版参考既然说到了实现我就把两个最常用来面试的语言版本都放出来。Python 版class Solution: def removeElement(self, nums: List[int], val: int) - int: slow 0 for fast in range(len(nums)): if nums[fast] ! val: nums[slow] nums[fast] slow 1 return slowC 版class Solution { public: 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]; } } return slow; } };Java 版逻辑完全一致只是类型声明不同我就不多贴了。这里有一个细节值得注意nums[slow] nums[fast]这一行很多人会加一个判断问“要不要先判断 fast 和 slow 是否相同相同就不赋值”。加不加这个判断在结果上没有差别因为数组原地覆盖nums[fast]在 fast 大于 slow 时就是快指针自己读出来的值即使 fast 还没超过 slow也只是自己给自己赋值浪费一次读写而已。性能优化角度可以在赋值前加一个if (fast slow)来省掉无意义的自我赋值。但说实话这个优化对时间影响微乎其微反而会让代码可读性变差。我个人的建议是在面试中不要做这种微优化把逻辑写清楚比什么都重要。2.4 为什么快慢指针不会漏元素关于双指针我见过太多人学会写法之后依然心里打鼓fast 跳过了等于 val 的元素这些“被跳过的”值会不会还留在数组前面导致结果不对答案是不会。原因在于slow 永远只覆盖那些已经“处理完毕”的位置。快指针沿途遇到不等于 val 的元素时会把它们搬到 slow 的位置而等于 val 的元素从来没有被搬到 slow 区域里。所以从下标 0 到 slow-1 这个区间内装的全都是不等于 val 的元素。你可以把 slow 当成一条“分界线”分界线左侧是已经筛选完成的区域右侧是尚未处理的区域。快指针一路向右不断把右侧有价值的元素“搬运”到分界线处分界线也随之右移。这个“分界线”思想是双指针类题目里反复出现的核心意象理解透了后面刷“删除排序数组中的重复项”“移动零”都会很轻松。3. 边界条件最容易翻车的四种场景3.1 空数组和单元素数组很多人写完代码直接提交连边界都不测。结果空数组时直接访问nums[0]报错。空数组的情况很简单len(nums) 0for 循环根本不执行slow 直接返回 0完全没问题。也就是说双指针写法天然免疫空数组。单元素数组要注意的是两种情况唯一元素等于 val 时fast 扫到它被跳过slow 返回 0。唯一元素不等于 val 时slow 返回 1。都正确。我面试时经常追加一个问题“如果数组长度是 1 并且唯一元素就是要删的 val你的循环会不会越界”很多候选人一紧张就开始怀疑自己的代码。其实只要理解 fast 是range(len(nums))最大也就是访问nums[len-1]根本不会越界。3.2 数组里全是 val这种情况最考验你对返回值语义的理解。假设nums [3, 3, 3, 3]val 3。快指针从头扫到尾每个元素都等于 val全部跳过slow 从头到尾一直等于 0。函数返回 0。从题目角度你的“有效数组”是空的长度 0完全合理。可如果测试代码里写assert len(nums) 0这就错了。力扣的判题方式是它只检查nums的前 0 个元素也就是什么都不检查自然通过。这里也是很多候选人在面试时跟面试官扯不清的地方数组本身长度没变还是 4为什么函数返回 0你只要解释清楚“返回值是有效长度不是物理长度”问题就不存在了。3.3 val 在数组头部连成串如果nums [2, 2, 2, 3, 4]val 2。前三个元素全要删。很多人担心“前三个位置被覆盖会不会把后面的值弄丢”。我们按代码模拟一遍fast 0值为 2跳过。fast 1值为 2跳过。fast 2值为 2跳过。fast 3值为 3不等于 val写入nums[0]。数组变为[3, 2, 2, 3, 4]slow 1。fast 4值为 4写入nums[1]。数组变为[3, 4, 2, 3, 4]slow 2。你会发现真正要保留的 3 和 4 根本没丢因为它们在 fast 扫描时已经被读出来了写到了前面的位置。那些残留的 2 和 3 都在 slow 之后属于“无人关心的区域”。这就是双指针法的自洽性。3.4 val 根本不存在nums [1, 2, 3, 4]val 5。快指针扫完所有元素全部不等于 val一个个写入 slow 位置。因为 fast 和 slow 永远同步增长数组完全没有变化slow 返回值等于原数组长度。这个场景的潜在陷阱是有初学者会担心“自己给自己赋值有没有问题”。再次强调没问题顶多是无意义的读写开销。真正的正确性只看最终 slow 位置前的元素是否都是“幸存者”。4. 左右指针法另一种“删除”的哲学4.1 思路区别从“覆盖”到“交换”快慢指针是面试中最稳妥、最通用的答案。但有时候面试官会追问一句“如果数组顺序不重要有没有更省事的办法”这就引出了第二种双指针——左右指针法。快慢指针的思路是“保留所有要留下的东西把要删的挤到后面”。左右指针的思路则是“看到等于 val 的元素直接从右边拉一个元素过来填上”。具体做法左指针从数组头部出发右指针从尾部出发。如果左指针指向的元素等于 val就把右指针指向的元素复制到左指针位置右指针左移一位。如果左指针指向的元素不等于 val左指针右移一位。循环直到左指针和右指针相遇。这个做法的巧妙之处在于它不会反复复制整个数组每删除一个元素只需要复制一次。而且因为题目说“顺序可以改变”所以你完全不需要维护原来的相对次序。4.2 代码实现和它的坑def remove_element_two_pointer(nums, val): left 0 right len(nums) - 1 while left right: if nums[left] val: nums[left] nums[right] right - 1 else: left 1 return left注意一个关键细节把右边的值搬过来之后左边这个位置的新值还没检查所以不能立刻 left 1只能 right - 1然后在下一次循环里重新检查这个位置。这是左右指针法最容易出 bug 的地方。我见过很多候选人把nums[left] nums[right]和left 1写在一起结果搬过来的新值又恰好等于 val直接漏删。举个例子nums [2, 2, 3, 4]val 2。left 0value 2把nums[3] 4复制到nums[0]right 2。数组变为[4, 2, 3, 4]。下一轮left 0value 4不等于 valleft 1。left 1value 2把nums[2] 3复制到nums[1]right 1。数组变为[4, 3, 3, 4]。下一轮left 1right 1此时left right依然成立检查nums[1] 3不等于 valleft 2。循环结束返回 left 2。看起来一切正常。但如果你刚才错误地把 left 1 写进了 if 分支第二次删的时候left 会直接从 0 变成 1又因为数组前面已经被替换成 4这个误操作不会立刻暴露直到数组里出现“搬过来的元素依然等于 val”时才会翻车。这种 bug 藏在逻辑深处调试起来非常难受。4.3 左右指针法 vs 快慢指针法怎么选两种方法都能过但适用场景不完全一样如果题目要求“保持元素原有顺序”必须用快慢指针。如果不能额外使用空间但允许改变顺序左右指针法可以省去一些不必要的复制。如果数组里等于 val 的元素特别多左右指针法的平均赋值次数可能更少因为它是“从尾部拿一个来顶替”赋值次数等于被删元素个数。快慢指针的赋值次数等于保留元素个数。当要删的多、留的少时快慢指针其实更吃亏因为每留一个都要写一次而左右指针法每删一个才写一次。我在面试中看到很多候选人只会一种写法面试官一旦追问“换种思路能做到 O(1) 吗”当场卡壳。所以两种解法最好都能手写并且能说清楚各自适合什么情况。5. 常见错误和避坑实战5.1 高频 Bug 速查表我把这些年看到的典型错误整理成一个速查表你可以直接截图放到刷题笔记里错误类型错误表现原因修正方法索引越界空数组访问 nums[0]没考虑 len0用 forrange 或先判空返回值错误返回原数组长度没改有效长度返回 slow / left漏删元素等于 val 的元素残留用 list.remove 边删边遍历时索引错位改用双指针覆盖左右指针死循环left 一直卡住复制右边值后没 right-1检查 right 的更新过度优化代码里到处都是 if太在意微优化保证正确性优先顺序遍历pop性能超时每次 pop 都是 O(n)不要用 pop 实现这条表里每一行都是真实案例尤其是第一行我面试的候选人里至少有三次出现“空数组 Access 越界”这种低级错误。你可以在本地写一个测试函数专门测空数组、满数组、全相等数组、穿插数组这四种情况基本能覆盖 90% 的隐性 bug。5.2 本地如何做自查和调试我带实习生的习惯是要求他们提交前必须跑一组自测用例。你甚至不需要写测试框架一个函数搞定def test(nums, val): new_len remove_element(nums, val) print(new_len, nums[:new_len]) assert all(x ! val for x in nums[:new_len]) test([], 3) test([1], 1) test([1], 2) test([3, 2, 2, 3], 3) test([0, 1, 2, 2, 3, 0, 4, 2], 2)这个简单的打印加断言能在三秒内告诉你算法对不对。不要嫌它简陋实际调试中它比断点调试高效得多。5.3 常见误区把“双指针”当成银弹我也见过有人学会这题之后看到任何数组题都往双指针上套。这是另一个极端。双指针适用于“有序性、对比性、原地重排”这类场景。如果是需要计数、需要哈希去重、需要动态规划的问题硬套双指针只会把简单问题复杂化。移除元素这题之所以用双指针是因为它本质上是过滤过滤天然适合用一快一慢两个指针去完成。你在后续刷题时要有意识地放下模板先想清楚这个问题是在问“保留某些元素”还是“查找某些元素”还是“统计某些元素”。目的不同工具就不同。6. 同源扩展移除元素家族还有这两道必刷6.1 删除有序数组中的重复项力扣 26 题和 27 题可以说是双胞胎原题要求给定一个有序数组原地删除重复出现的元素使每个元素只出现一次返回新长度。核心思路和移除元素一模一样快慢指针唯一区别是判断条件def remove_duplicates(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注意这里 slow 从 1 开始因为第一个元素天然保留。fast 从 1 开始扫描只有遇到和前一个保留元素不同的值才写入。一个细节是这个版本的判断条件是nums[fast] ! nums[slow - 1]而不是nums[fast] ! nums[fast - 1]。两者差别在哪nums[slow-1]是“已经决定保留的最后一个元素”而nums[fast-1]可能是一个已经被跳过的重复值。如果用后者结果大概率不对。连刷 27 题和 26 题是个好策略因为它们互相强化“快慢指针 覆盖”的心智模型一石二鸟。6.2 移动零力扣 283 题“移动零”本质上是移除元素的一个变体把数组中所有 0 移到末尾同时保持非零元素的相对顺序。思路是一样的只是这次你不仅要“移除 0”还要“把 0 补到后面”。一种直观做法先按移除元素的逻辑把非零元素全部排到前面得到有效长度 slow然后在数组末尾原地补零。def move_zeroes(nums): slow 0 for fast in range(len(nums)): if nums[fast] ! 0: nums[slow] nums[fast] slow 1 for i in range(slow, len(nums)): nums[i] 0这个写法背后其实蕴含了一个很实用的工程思路先压缩后填充。你把一个“移动问题”拆成了“保留”和“清零”两个动作比原地交换更易读也更不容易错。如果你面试时能主动关联“这道题和我刚才做的移除元素思路是一样的只是多加一步”这种横向迁移能力是面试官很看重的信号。6.3 力扣 844比较含退格的字符串这道题稍微进阶一点但核心还是双指针。给定s和t两个字符串#代表退格键判断两个字符串经过退格处理后是否相等。最简单的思路是用栈模拟退格但空间复杂度 O(n)。双指针解法可以从后往前扫描遇到#跳过同时统计需要忽略的字符数空间 O(1)。如果 27、26、283 这三道题你都吃透了再看 844会发现双指针的思想是一个连续谱系只是应用场景从数组换到了字符串。这个关联梳理一遍双指针的底子就算真正建立起来了。我的几点真实体会最后说点刷题之外的事。我见过太多人LeetCode 刷了几百道面试时依然答不好“你是怎么想的”这个问题。原因就在于他们刷题像背答案没有建立“从问题到解法”的推导链条。移除元素这个题恰好是建立链条的绝佳样本你先想清楚数组删除的本质是覆盖发现暴力法有大量重复搬运然后想到用快慢指针一次搬运完成再根据题目“顺序可改变”的提示想到左右指针法。每一步都是问题驱动不是套路驱动。我自己在刷这题时最获益的瞬间不是 AC 的那一刻而是当我尝试去解释“为什么 slow 一定指向待覆盖的位置”时我突然发现自己对数组的理解变深了。刷题不是为了那个绿色的 accepted是为了在“解释”这个动作中真正掌握它。如果你现在正卡在双指针或者数组相关的题上不妨停下来别急着写下一道先把“移除元素”这题用两种方法各写三遍再试着用大白话讲给身边人听。讲明白了你就真的会了。愿你顺利通过后续的每一道算法题。稳扎稳打比什么都重要。
返回列表