ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

LeetCode 26题双指针原地去重详解:从有序数组到算法变体

LeetCode 26题双指针原地去重详解:从有序数组到算法变体 1. 题目到底在考什么审题比做题更重要LeetCode 26题“删除有序数组中的重复项”是我刷题列表里第一个标记✅的简单题但说实话越是这种“简单题”越容易让人翻车。因为它表面上是让你去重骨子里考的是两件事你有没有真正理解数组的存储特性以及你能不能写出原地修改的代码。题目给的是一个非严格递增排列的数组也就是有序数组重复元素一定相邻。要求你原地删除重复项使每个元素只出现一次然后返回新数组的长度。注意关键词——“原地”意思是不能新建一个数组来存结果只能在这个数组的内存空间上做文章。最后判题时会检查你返回的长度k同时检查数组前k个元素是否等于预期结果。很多人在这一步就踩坑了以为自己只需要“算出去重后的数量”就行结果写得稀碎。LeetCode的判题逻辑是同时校验nums数组的内容和返回值你只返回一个数、数组不改或者改了一半都会失败。为什么这道题适合作为入门因为它的输入限制干净有序数组、重复相邻、元素类型是整数。这就让解法有了固定的套路——双指针。而且这个双指针是快慢指针的经典雏形后面你会遇到一大堆它的变体比如移除元素、移动零、去重保留K个等全都能用同一套思维做出来。所以这道题不是刷完就扔的题它是后面一整套家族题的“母题”。从实际面试的角度看这道题出现的频率其实很高很多公司会把它作为一面算法环节的“热身体”用来确认候选人有没有最基本的编码能力。如果这道题写不顺大概率面试官对你的算法基础印象分会直接扣到底。所以别因为它简单就跳过值得认真对待。注意题目名字里写的是“删除”但这里的“删除”不是真正释放内存、缩短数组而是“覆盖”。这个认知差异很重要后面讲暴力解法时你会越发体会深刻。2. 双指针解法完整拆解为什么快慢指针能一次遍历搞定2.1 核心思路用覆盖代替删除先想一下最朴素的做法你拿到一个有序数组重复元素相邻于是你从前往后扫描每次发现当前元素和上一个相同就把后面所有元素往前挪一位。这个做法的优点是思路直白缺点也很明显——时间复杂度到了O(n²)在最坏情况下会进行大量的元素搬移完全不可取。双指针的思路则是另一条路。我们让一个慢指针slow指向“已经去重完毕的区域的最后一个位置”让一个快指针fast去扫描整个数组。每当我们发现nums[fast]和nums[slow]不一样就说明遇到了一个全新的元素这时候把slow向前推一步再把nums[fast]的值复制到nums[slow]上。这个过程看起来是“覆盖”但它保证了两个关键性质第一快指针扫描过的区域中所有新元素都被保留下来了第二慢指针之前的区域永远是“已去重”的合法前缀。最终slow 1就是去重后的长度。用一个生活化的例子来类比想象你在整理一排抽屉抽屉里有些东西是重复的。慢指针就是你的左手放在“已经整理好的最后一个位置”快指针是右手一个抽屉一个抽屉地检查。右手发现新东西就喊一声左手往前挪一格右手把东西放进左手的新位置。所有检查过的抽屉里新东西都留在了前半段重复的直接跳过。2.2 代码模板详解先给出最标准的实现以C为例class Solution { public: int removeDuplicates(vectorint nums) { if (nums.empty()) return 0; // 空数组直接返回 int slow 0; // 慢指针已去重区域最后一个元素的下标 for (int fast 1; fast nums.size(); fast) { if (nums[fast] ! nums[slow]) { // 找到新元素慢指针前进并覆盖 slow; nums[slow] nums[fast]; } } return slow 1; // 长度 最后一个下标 1 } };这里有一个特别容易出错的地方为什么判断条件是nums[fast] ! nums[slow]而不是nums[fast] ! nums[fast - 1]两种写法在大多数情况下效果是一样的但语义上却有区别。nums[fast] ! nums[slow]强调的是“和已经保留的最后一个元素比较”而nums[fast] ! nums[fast - 1]只是“和前一个位置比较”。在双指针覆盖的过程中nums[fast - 1]这个位置的值可能已经被覆盖过了不一定还是原始数组的值所以用nums[slow]做比较更贴近逻辑本质也更不容易出错。另一个容易踩坑的点是slow的初始值和返回值。slow 0表示第一个元素一定保留从fast 1开始扫描最终slow 1才是长度。有同学会写成slow 1或者返回slow测试一跑就错。2.3 为什么有序是解题的前提这道题能这么解最大前提是数组有序。因为有序所以所有相同的元素都挤在一起快指针只需要跟慢指针指向的最后一个元素比较就能判断当前是不是新的元素。如果数组是无序的双指针这个写法就行不通。比如[1, 3, 2, 1, 2]重复元素不相邻nums[fast]和nums[slow]比较半天也判断不出来到底有没有出现过。这种情况就要换思路要么先排序再双指针要么用哈希表记录出现过的元素。这也是为什么很多LeetCode题解都在强调“先审题看清是不是有序”。我在实际刷题时养成的一个习惯是拿到题目先看三样东西——输入是否有序、是否需要原地修改、返回值到底是什么含义。这三样确定下来解法基本就有方向了。3. 多语言实现与边界条件代码细节才是拿分关键3.1 C、Java、Python三种写法对比这道题我在C、Java、Python三种语言里都实现过每种语言的侧重点略有不同但核心逻辑一致。放一份对比// C class Solution { public: int removeDuplicates(vectorint nums) { int n nums.size(); if (n 0) return 0; int slow 0; for (int fast 1; fast n; fast) { if (nums[fast] ! nums[slow]) { nums[slow] nums[fast]; } } return slow 1; } };// Java class Solution { public int removeDuplicates(int[] nums) { int n nums.length; if (n 0) return 0; int slow 0; for (int fast 1; fast n; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; } }# Python class Solution: def removeDuplicates(self, nums: List[int]) - int: if not nums: return 0 slow 0 for fast in range(1, len(nums)): if nums[fast] ! nums[slow]: slow 1 nums[slow] nums[fast] return slow 1C的写法里nums[slow]是先移动再赋值Java和Python则是显式分两步。无论哪种语言核心的循环不变式都是同一个[0, slow]区间内的元素保持不重复。3.2 边界条件逐一测试边界条件是面试时最容易考到的隐藏点。我第一次提交这道题的时候就因为没有处理空数组而报错。整理一份边界条件清单测试场景输入示例预期输出代码是否覆盖空数组[]0需要特判单元素[1]1循环不执行slow0返回1全相同[1, 1, 1]1循环内判断恒为falseslow不变无重复[1, 2, 3]3每次判断都成立slow逐步递增混合情况[0, 0, 1, 1, 1, 2, 3, 3]4正常流程覆盖负数[-3, -3, -2, -1, -1, 0]4比较逻辑不受值大小影响这里特别提一下“全相同”的场景nums[fast] ! nums[slow]这个条件从头到尾都不成立slow一直停留在0最后返回1这个结果是正确的。如果你把判断条件写成nums[fast] ! nums[fast - 1]在这个场景下同样不会触发赋值也能得到正确结果。但换一种情况比如[1, 1, 2]nums[fast] ! nums[fast - 1]的判断在fast2时比较的是nums[2]和nums[1]也就是2和1成立于是slow变为1并赋值。这个结果也对。但如果你在处理完第一个重复后某个位置的值已经被覆盖过了两种写法就会产生差异所以始终建议以nums[slow]为基准。3.3 复杂度分析面试必问时间复杂度上快指针fast从1走到n-1一共遍历一遍数组每个位置只被访问一次所以是O(n)。空间复杂度上除了几个指针变量外没有额外容器所以是O(1)。这个复杂度是这道题的“标准答案”面试时会被反复追问尤其是“能不能做到O(1)空间”这一点。如果你理解双指针的覆盖逻辑就能很自然地回答上来。4. 暴力方法为什么不行从erase到原地覆盖的思维转变4.1 为什么不能用“边遍历边删除”很多人拿到这道题的第一反应是遍历数组遇到重复项就用erase或remove删掉。在C里可以写nums.erase(iterator)在Python里可以写nums.remove(x)或直接构建新列表。但这里有个致命问题数组的删除操作是O(n)的。数组在内存里是一段连续的存储空间删除一个元素后它后面的所有元素都要往前移动一位。如果数组里有大量重复元素每次删除都触发一次大规模移动最坏情况下时间复杂度会变成O(n²)。再加上erase之后迭代器可能失效还需要处理索引偏移代码既低效又容易写错。更关键的是LeetCode 26明确要求“原地”修改你新建一个数组或列表来过滤本身就违背了题目的考核目标。这道题练习的价值就在于用覆盖逻辑避开昂贵的删除操作。4.2 真正理解“覆盖”的含义双指针方案里nums[slow] nums[fast]这一步其实就是覆盖。你可能会问这样不会把还没处理过的元素给覆盖掉吗答案是不会。因为slow永远小于或等于fast。当slow被推进一步并赋值时它指向的位置要么是fast当前的位置要么是fast之前已经扫描过的位置。举个例子在[1, 1, 2]这个数组中当fast 2时slow 0判断2不等于1于是slow变成1执行nums[1] nums[2]也就是把最后一个2覆盖到第二个1的位置上。此时nums [1, 2, 2]看上去最后一个2是“多余”的但没关系因为返回值是2数组前2个元素[1, 2]就是正确的去重结果最后一个位置没人会去管。这就是覆盖的精髓我们不关心数组后半段的残留值只保证前k个有效元素正确即可。这也是为什么这个算法的空间复杂度是O(1)——本质上就是在同一个数组上做原地整理。4.3 C里std::unique到底是什么如果你用的是Calgorithm头文件里其实有一个现成函数std::unique专门做这件事。它做的事情和双指针方案一模一样把不重复的元素依次往前覆盖然后返回一个指向“新逻辑末尾”的迭代器。#include algorithm #include vector vectorint nums {1, 1, 2, 3, 3, 4}; auto new_end std::unique(nums.begin(), nums.end()); // 去重后的元素在 [nums.begin(), new_end) 范围内 // 但 nums.size() 并没有变化 nums.erase(new_end, nums.end()); // 此时才真正缩容std::unique的原理就是双指针覆盖底层实现和手写版本几乎一致。所以如果你在笔试环境里可以用STL直接调std::unique也是合法的。但我不建议在练题阶段这么做因为题目考察的就是你手写双指针的能力。面试的时候如果面试官看到你熟练地调STL解决也许不会说什么但某些面试官可能会追问“如果让你手写unique怎么做”到那时候你就露馅了。4.4 思路升级为什么不能只用哈希表也有同学想我直接用哈希集合记录出现过的元素遍历一遍没见过的就加入结果行不行行但也要分场景。这道题的输入是有序数组用哈希表可以把时间控制在O(n)但空间是O(n)不如双指针的O(1)空间。而哈希表方案在无序数组去重场景下才是正解。所以刷题要避免“一把锤子砸所有钉子”——看到去重就用哈希集合看到排序就写冒泡这种惯性思维会限制你的解题灵活性。我的建议是根据输入是否有序、是否要求原地、是否要求稳定来决定用哪种策略。这道题既然有序又要求原地双指针就是最优解。5. 进阶变体从一道题带出一串题5.1 同源题LeetCode 80——删除有序数组中的重复项 IILeetCode 80是这道题的直系升级版每个元素最多出现两次而不是一次。解决方法依然用双指针只是判断条件从“和slow比较”变成了“和slow的前一个位置比较”。核心写法是int removeDuplicates2(vectorint nums) { if (nums.size() 2) return nums.size(); int slow 2; // 前两个元素一定保留 for (int fast 2; fast nums.size(); fast) { if (nums[fast] ! nums[slow - 2]) { nums[slow] nums[fast]; } } return slow; }这个变体非常经典它的本质是“允许最多重复K次”的一般情况。如果K1就是你刚才写的26题如果K2就是80题。面试时如果你能顺手写出这个通解会是很大的加分项。5.2 同类型题LeetCode 27——移除元素LeetCode 27是另一个常见变体删除数组中所有等于给定值val的元素保留其余元素的相对顺序。解法依然是双指针但更简单——因为不需要比较相邻元素只需要判断当前元素是否等于val。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; }这个题我经常和26题放一起刷因为两者的代码结构几乎一样唯一的差别是“判断条件”的来源不同——一个是与nums[slow]比一个是与固定值val比。理解了这个差异你会发现双指针是一套可以复用的框架。5.3 同类型题LeetCode 283——移动零移动零这道题要求把数组里所有0移到末尾同时保持非零元素的相对顺序。其实也可以用双指针做逻辑是先把所有非零元素往前挪然后在剩余位置补0。void moveZeroes(vectorint nums) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; } } while (slow nums.size()) { nums[slow] 0; } }这个题看起来和26题不太一样但内核完全一致快指针负责找“有价值的元素”慢指针负责记录“有价值元素该放的位置”。这类题的共同特点就是用两个指针在一次遍历中完成筛选与整理。5.4 从刷题到面试怎么把一道题讲出深度面试时单纯写出AC代码只能算及格。想拿高分需要在讲题时展现出你的思考层次。我一般按这样的顺序讲第一层讲清楚暴力解以及它为什么不行把时间复杂度的劣势说透。第二层提出双指针优化解释快慢指针各自维护什么不变量。第三层说明边界条件尤其强调空数组和单元素数组。第四层如果能联系到变体题比如“如果允许重复2次呢”“如果不是有序数组呢”就把话题延伸出去。这样讲面试官能感受到你不是在背题而是真的理解了算法背后的设计思想。我在模拟面试时试过这个结构反馈明显比直接背题要好。6. 刷题实操心得从Debug到AC的完整记录6.1 我第一次写错的地方坦白说我第一次提交这道题时并没有一遍过而是在一个很不起眼的地方翻车了。我当时的写法是把循环里赋值的条件写成了nums[fast] ! nums[fast - 1]按理说也能过但在一个自定义用例上出了错输入[1, 1, 2, 2, 3]我的代码输出结果是[1, 2, 3]看起来没问题。但是当我把数组换成[1, 1, 1, 2, 2, 3]时问题出现了。因为在fast3时nums[fast]是2nums[fast - 1]是1判断成立于是把2复制到了nums[2]的位置。此时数组变成了[1, 1, 2, 2, 2, 3]。接着fast4nums[4]是2nums[3]是2判断不成立跳过。fast5nums[5]是3nums[4]是2判断成立复制。最终的[1, 2, 2, 3]不对我慢指针的位置混乱了。你看用nums[fast - 1]做比较一旦前面的值被覆盖过它就不再代表“上一个保留元素”而可能代表“当前数组位置上的值”两者含义完全不同。后来我改成统一用nums[slow]做比较所有用例一把过。这个教训我一直记到现在双指针题里慢指针指向的位置就是标准答案一切判断都以它为准。6.2 使用IDE调试还是直接提交刷题初期很多人习惯直接在LeetCode页面上写代码、点提交错了再看报错。我个人建议在本地IDE里调试尤其是这种涉及指针移动的题用IDE的单步调试功能能让你直观地看到每一步数组的变化。我自己的做法是在本地写一个小测试函数打印每一轮s和f的值以及当前数组状态然后对比运行结果。比如这样void debugRemoveDuplicates(vectorint nums) { int slow 0; cout 初始: slow slow , nums; for (int x : nums) cout x ; cout endl; for (int fast 1; fast nums.size(); fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; cout 更新: slow slow , fast fast , nums; for (int x : nums) cout x ; cout endl; } } }这样你就能看到数组一步步变成最终状态。建立起“指针移动会改变数组值”的直觉之后再做其他变体题会顺畅很多。6.3 时间复杂度的直觉训练刷题量上来之后我发现自己养成了一个习惯看到任意算法先下意识估算一下它的时间复杂度和空间复杂度。这个习惯对面试帮助特别大。回到本题O(n)的时间、O(1)的空间意味着哪怕数组长度是10亿也只需要一次遍历和常数级额外内存。这种“低成本高收益”的特质正是面试官青睐的答案。所谓算法能力本质上就是在这种资源约束下找到最优方案的能力。7. 那些题目之外的事我对刷LeetCode的一点理解很多人问我“这种简单题我刷了有什么用不就是在重复调API吗”我的回答是简单题练的是代码的体感难题练的是思维的边界两者缺一不可。26题虽然只是几分钟就能AC的简单题但它把“原地” “有序” “覆盖”这几个概念全部串在一起。如果你把这道题吃透了后续消灭80题、27题、283题每一道都会轻松很多因为它们本质上是同一套双指针框架的变体。从更实际的角度讲你在面试中遇到的算法题九成以上不会让你设计什么灵光乍现的奇技淫巧而是考察你能否在经典模型之上做出正确的变通。26题就是最经典的双指针模型之一。把它练到肌肉记忆比海量刷题但每道都没吃透要强得多。另外我还有个个人习惯每道题AC之后会在笔记本上写一句话总结这道题的核心考点。比如这道题的笔记是“有序数组原地去重快慢指针慢指针指向已处理区域的最后一个下标”。等过一段时间回头翻笔记复习效率比重新刷一遍高很多。算法这条路没有捷径但可以走得更聪明。希望这篇文章能帮你把26题这个起点踩扎实接下来的路会越走越顺。
返回列表