ARTICLE DETAIL

资讯详情

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

LeetCode 27 移除元素:双指针法与原地修改的数组操作指南

LeetCode 27 移除元素:双指针法与原地修改的数组操作指南 很多刷题的朋友都有过这种体验LeetCode 24、26、27这些数组题光看题目描述就觉得“这也太简单了吧”可一提交或者一被面试官追问就发现自己在“原地修改”“移除元素”这些词上抠得不够细。说说今天要拆的这道题——LeetCode 27 移除元素在代码随想录数组专题里正好排在第二篇。它不像二分查找那样需要背很多边界条件但如果你想真正理解数组的内存结构、双指针法和“原地操作”这几个概念这道题是绕不开的。这道题适合谁看刚刷完二分查找、准备进入数组进阶刷题的新手或者刷了不少题但总是凭感觉写双指针、一换场景就懵的老朋友。看完这篇你不只会 AC 这道题还能把快慢指针、相向指针这两种套路在一次代码里彻底理清楚顺便知道面试官在你写完暴力解法之后为什么总会追问一句“还能优化吗”。1. 题目在讲什么核心需求与思路拆解1.1 题意与容易被忽略的隐藏约束先原样复述一下题面给你一个数组nums和一个值val你需要原地移除所有数值等于val的元素并返回移除后数组的新长度。不要使用额外的数组空间必须仅使用 O(1) 额外空间并原地修改输入数组。元素的顺序可以改变。不需要考虑数组中超出新长度后面的元素。这短短几句话里藏着四个关键信息“原地移除”意味着你不能 new 一个新数组来存放剩余元素所有操作都必须发生在传入的这个数组里。“返回新长度”函数最后给的是一个整数而不是一个数组对象。判定时会去读原数组的前len个元素所以你的任务其实是“把该保留的元素搬到前面然后告诉系统前面有多少个是有效的”。“元素的顺序可以改变”这个约束给了很大自由度后面要讲的相向双指针就是靠它才能优化赋值次数。“不需要考虑数组中超出新长度后面的元素”这句话救了很多强迫症。即使nums末尾还躺着几个等于val的旧值也没有任何影响你不用去清空、不用去理会。再补一个常见的示例输入nums [0,1,2,2,3,0,4,2]val 2函数应当返回 5并且 nums 的前 5 个元素可以任意排列只要它们都等于[0,1,3,0,4]的某种顺序即可。理解了这四点暴力解法其实很容易想出来但为什么代码随想录要把“移除元素”单独拿出来讲一讲这就得从数组的内存结构说起了。1.2 为什么“删除元素”在数组里是件麻烦事数组在内存里是一段连续的存储空间这和链表完全不同。链表的节点分散在内存各处靠指针串起来删除一个节点只需要修改前后节点的指针时间复杂度 O(1)。但数组不行数组没有“删除中间元素”的天然操作你只能通过“把后面的元素整体往前搬”来模拟删除效果。比如一个数组[1, 2, 3, 4, 5]你想删掉 3那么后续的 4、5 都得依次往前移动一位变成[1, 2, 4, 5, 5]最后那个多出来的5属于“残留垃圾”不参与有效长度。一次搬移的时间复杂度是 O(n)如果需要删除多个元素再配合外层遍历整体就可能变成 O(n^2)。所以这道题表面上叫“移除元素”本质上是考察你如何避开数组删除操作的“搬移成本”。如果你在 C 里用vector::erase来做更得小心——每次erase会让迭代器失效如果边遍历边删很容易出现数组越界或漏判。这也是为什么代码随想录在数组篇要专门拿出一个位置来剖析它。1.3 代码随想录为什么把它放在数组篇第二道代码随想录的数组专题刷题路线大家应该不陌生通常是二分查找、移除元素、有序数组的平方、长度最小的子数组、螺旋矩阵。二分查找解决的是“有序数组中的搜索问题”移除元素解决的是“数组原地修改与双指针问题”。把两道题放在一起你能明显感受到一个递进二分查找的核心是“区间缩小”移除元素的核心是“双指针移动”。从这道题开始你会第一次正式接触“快慢指针”这个思想而它后面会反复出现在链表、字符串、滑动窗口等大量题目里。可以说移除元素是双指针方法论的启蒙题也是后续很多中等题、困难题的地基。我个人的习惯是遇到双指针新思路先在这道题上把指针移动的逻辑亲手画几遍再去做别的题会稳很多。2. 暴力解法能 AC 但不推荐的实现2.1 暴力遍历加搬移的完整逻辑如果不考虑优化最直觉的做法是从头到尾遍历数组碰到等于val的元素就把它之后的每一个元素都往前挪一位同时数组长度减 1。但这里有一个特别容易出错的点删除元素后原来i这个位置会被后面的元素顶上所以i必须跟着回退一步否则会漏掉相邻的相同元素。写出来大概是这样C 版class Solution { public: int removeElement(vectorint nums, int val) { int size nums.size(); for (int i 0; i size; i) { if (nums[i] val) { // 从 i 开始把后面的所有元素整体前移一位 for (int j i 1; j size; j) { nums[j - 1] nums[j]; } i--; // 因为 nums[i] 被后面的新元素覆盖所以 i 要返回去重新检查 size--; // 有效长度减 1 } } return size; } };Python 版本思路一样class Solution: def removeElement(self, nums: List[int], val: int) - int: size len(nums) i 0 while i size: if nums[i] val: # 整体前移 for j in range(i 1, size): nums[j - 1] nums[j] size - 1 # i 不自增因为新元素顶上来了要继续检查当前位置 else: i 1 return size这段代码能通过 LeetCode 的测试时间复杂度却不够体面。如果你在面试时只写成这样面试官大概率会让你优化。2.2 复杂度分析和为什么“能过但不推荐”暴力解法的时间复杂度是 O(n^2)空间复杂度是 O(1)。外层循环遍历每个元素内层循环在每次删除时都要搬移剩余元素。最坏情况发生在数组几乎全是val的时候比如nums [1,1,1,1]val 1第一次删除搬移 3 个元素第二次删除搬移 2 个元素第三次删除搬移 1 个元素。总共搬移 6 次而数组长度才 4。数据量一大这种平方级复杂度会很快拖垮性能。更重要的是暴力解法没有用到任何算法思想只是简单地“执行删除动作”。它没有回答“为什么数组删除要整体搬移”“能不能让每个元素只移动一次”这些更深层的问题。所以它在学习阶段的定位应该是“帮助理解问题本质的过渡解”而不是最终解法。3. 双指针法标准解法的完整拆解3.1 快慢指针思想先想清楚再说代码双指针不是什么玄学你可以想象一个整理队伍的场景一列队伍里有几个被点名要出列的人现在要让剩下的人重新排成紧密的一列。你安排两个人同时工作——快指针在前面挨个检查队员遇到要出列的队员就跨过去遇到要保留的队员就喊一声“保留”慢指针站在队伍前面只负责接收快指针喊“保留”的队员并放到当前位置然后往后挪一步。因为慢指针每次接收的都是“下一个有效位置”而快指针永远比慢指针走在更前面所以这个覆盖过程永远不会覆盖掉还没检查过的数据。等快指针走到队伍末尾慢指针指向的位置就是新队伍的“下一个空位”也正好等于新队伍的长度。这就是快慢指针法的核心逻辑一个指针负责探索一个指针负责记录。代码随想录里反复强调“双指针法”是移除元素的标准解法其本质就是这个思想。3.2 标准双指针代码实现C 实现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]; slow; } } return slow; // slow 正好等于移除后的有效长度 } };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 slow这段代码只有几行但每一步都对应着前面整理的思路fast从 0 开始遍历整个数组是所有元素的“检查官”。nums[fast] ! val时说明这个元素要被保留下来立刻交给slow指向的位置。slow表示有效队伍的下一空位往后移动。当fast遍历结束slow的数量就是保留下来的元素数量也就是新长度。3.3 用示例推演一遍把指针移动看清楚拿nums [0,1,2,2,3,0,4,2]val 2来手动走一遍fast0nums[0]0不等于 2nums[0]0slow1fast1nums[1]1不等于 2nums[1]1slow2fast2nums[2]2等于 2跳过slow不动fast3nums[3]2等于 2跳过fast4nums[4]3不等于 2nums[2]3slow3fast5nums[5]0不等于 2nums[3]0slow4fast6nums[6]4不等于 2nums[4]4slow5fast7nums[7]2等于 2跳过。最终slow5数组前 5 个元素变为[0,1,3,0,4]后面残留的 2 完全不需要管返回 5。和题目要求完全一致。3.4 为什么这样写不会覆盖没读过的元素这是一个很值得琢磨的点为什么nums[slow] nums[fast]是安全的因为fast始终以 1 步长向前移动而slow只有在写入有效元素后才会前进。也就是说slow fast这个不等式始终成立。当slow fast时赋值是自己覆盖自己没有影响当slow fast时slow指向的位置要么已经被处理过要么正在被当前fast指向的有效值填充不可能指向一个还没被fast扫描到的未来位置。如果你对指针有顾虑可以在草稿纸上画一个slow和fast的移动轨迹试着构造一个极端情况val全都集中在数组开头。此时slow一直停在 0fast一直跳到第一个非val元素后才赋值覆盖的只是开头那些等于val的“废弃位置”完全不会碰到后面的未读数据。4. 相向双指针另一种解法与思路拓展4.1 基于“元素顺序可以改变”的优化空间题目里有一句特别容易被忽略的话“元素的顺序可以改变。”这句话在快慢指针里其实没发挥太大作用但如果我们用两个指针从数组两端往中间走就能利用它做更少的赋值操作。思路是这样的维护一个左指针left和一个右指针right。left从左向右找第一个等于val的位置right从右向左找一个不等于val的位置然后把right位置的值直接覆盖到left位置同时缩小范围。这样每个等于val的元素最多被覆盖一次不用像快慢指针那样把所有有效元素都搬一遍。4.2 两种相向双指针的代码实现第一种写法比较直观左右各一个指针互相逼近class Solution { public: int removeElement(vectorint nums, int val) { int left 0; int right nums.size() - 1; while (left right) { // 左指针找到第一个等于 val 的位置 while (left right nums[left] ! val) { left; } // 右指针找到第一个不等于 val 的位置 while (left right nums[right] val) { right--; } // 用一个“该保留的值”覆盖“该删除的值” if (left right) { nums[left] nums[right]; left; right--; } } return left; } };第二种写法更简洁也更考验对边界的理解class Solution { public: int removeElement(vectorint nums, int val) { int left 0; int right nums.size(); while (left right) { if (nums[left] val) { // 用右边界前一个元素覆盖当前元素 nums[left] nums[right - 1]; right--; // 长度缩小 } else { left; } } return right; } };这里right直接作为新数组的长度来维护。如果当前left位置等于val就把数组末尾的元素拿过来顶替然后右边界收缩如果当前left位置不是val才让left前进。注意从末尾搬过来的元素也可能等于val所以此时left不能前进要在下一轮继续检查。4.3 两种双指针的对比维度快慢双指针相向双指针是否保留元素相对顺序保留不保留赋值次数每个有效元素可能被搬一次每个被删除元素才被覆盖一次遍历完成后的指针位置slow即新长度left或right即新长度代码难度简单直观略难边界条件多典型应用删除有序数组重复项、移动零每个题目要求顺序可改时从数据规模上看相向双指针确实能减少赋值操作如果你想极致追求效率或者面试官特意提出“能不能减少元素搬移次数”就可以甩出这个版本。但在平时的刷题训练中我更建议先把快慢指针写熟练因为它的通用性更强。4.4 代码随想录为什么主推快慢指针范本代码随想录的解法汇总里移除元素这道题的主推写法是快慢指针而不是相向指针。原因很简单快慢双指针不仅能解决这道题它更是一套可以复用到其他场景的方法论。比如你想删除有序数组里的重复项、把数组里的 0 都移到末尾、找链表的中间节点都可以用快慢指针这套思维去迁移。相向双指针更像是这道题的“特化优化版”针对性更强但普适性稍弱。所以我在学习时给两种方法排了个优先级先把快慢指针做到闭着眼睛能写再额外掌握相向双指针作为优化手段。两个都熟了之后遇到字符串、链表里的双指针题思路才不会局限在“左右夹逼”或“一快一慢”的单一样式里。5. 常见问题与实战避坑记录5.1 反复踩过的几个坑第一坑破坏原地约束。有人直接用一个新数组vectorint ans;把不等于val的元素塞进ans然后返回ans.size()。看起来时间复杂度 O(n)空间复杂度却变成了 O(n)直接被题目要求淘汰。第二坑C 里边遍历边erase。vector::erase会导致迭代器失效如果你用for (auto it nums.begin(); it ! nums.end(); it)然后在循环体里it nums.erase(it)处理不好索引就会越界。与其在这里纠结迭代器语义不如直接双指针。第三坑暴力解法里忘记i--。比如nums [1,1,2,3]val 1外层循环遍历到i0删除后原来的i1位置被后面元素补上变成1如果 i 不回去就会漏掉这个1最后返回长度多了 1。第四坑相向双指针里把while (left right)和while (left right)混用。左闭右闭和左闭右开的区间定义不同会影响最终返回的是left还是right。建议统一采用一种写法练熟比如第二种简洁版因为它直接用right当新长度逻辑自洽边界好记。5.2 面试官可能追问的四个问题这道题能在面试中被挖掘得很深这里列几个常见追问顺便给出回答思路“如果要求必须保持原顺序你还能用相向双指针吗”不能需要改回快慢双指针。“如果数组长度特别大且val出现频率很高怎么优化”相向双指针能减少赋值操作因为每个val位置的覆盖次数由尾部有效元素提供。“如果把数组换成一个单链表删除所有值为val的节点思路有什么不同”链表删除不需要搬移元素只需要修改前驱节点的next当前节点直接释放空间上不需要 O(1) 之外的辅助数组但要注意引入虚拟头节点简化删除头节点的逻辑。“你能不能用一次遍历完成但每个元素只移动一次”这正是快慢双指针能做到的每个保留元素至多被赋值一次。5.3 举一反三这三道题和它是一家人移除元素的价值在于它是一整个双指针系列的地基。刷完这道题建议顺手做这几道LeetCode 26 删除有序数组中的重复项核心代码几乎一样只是在判断条件上从nums[fast] ! val变成了nums[fast] ! nums[slow]唯一要注意的是数组有序重复元素连续出现。class Solution { public: 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; } };LeetCode 283 移动零可以先用移除元素的思路把 0 全部移除再把数组末尾补 0或者直接用快慢指针加交换。两种做法都能帮助你加深对“原地覆盖”的理解。LeetCode 844 比较含退格的字符串也是双指针从后往前遍历的经典应用这道题能帮你跳出“快慢指针只能从左往右”的惯性。5.4 从“能 AC”到“真正理解”的刷题复盘方法我见过不少朋友把这道题背下来下次换个场景就不会了。核心原因是没做复盘只是记住了代码没有记住“为什么”。我在刷完这道题后一般会做三件事第一不看题解把快慢指针和相向双指针各写一遍确保边界条件都能一次通过。特别是相向双指针建议亲手写一遍并打印每一步的nums状态哪怕只是脑内推演。第二把这道题和 LeetCode 26、283 放在同一个笔记里对比。你会发现它们的代码结构高度相似往往只是判断条件不同。这种“归并同类题型”的整理比盲目刷 100 道题更有效。第三重新把它变成一道面试题给自己 3 分钟讲清楚两种解法的复杂度和适用场景。讲不清楚的地方就是还没掌握的地方。我自己在代码随想录的数组专题里对这道题的体会是它不负责让你惊艳而是负责让你踏实地理解“双指针”这个工具。快慢指针的精髓不在于“慢指针接收、快指针探路”这八个字而在于你能够确信任何一次覆盖都是安全的任何一次前进都是有依据的。把这种感觉写进肌肉记忆后面的链表题、滑动窗口题都会顺手很多。如果你正刷到这里不妨慢下来把这题多默写两遍再去碰下一题。
返回列表