ARTICLE DETAIL

资讯详情

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

双指针原地算法:力扣26/80题有序数组去重模板全解析

双指针原地算法:力扣26/80题有序数组去重模板全解析 刷力扣的人早晚都会撞上这道题。26题“删除有序数组中的重复项”是面试里出现频率极高的基础题而80题“删除有序数组中的重复项 II”则是它的直接变体把“每个元素最多出现一次”改成“最多出现两次”。两道题放在一起刷其实是在练同一个核心能力在有序数组上用双指针原地修改数组。这篇文章就把这两道题彻底拆开从思路、写法到调试坑点全部过一遍。先明确一下这两道题到底在问什么。26题给一个升序排列的数组要求原地删除重复元素使每个元素只出现一次然后返回新长度且不需要考虑数组中超出新长度后面的元素。80题规则几乎一样区别只是每个元素最多保留两次。举个例子[1,1,1,2,2,3]26题要求变成[1,2,3,...]返回380题要求变成[1,1,2,2,3,...]返回5。这类题适合谁来刷准备校招社招的应届生、跳槽想补算法基础的开发、想把双指针套路练熟的人都建议把这两道放在同一天做。因为它们共用同一套模板做一道等于做了两道还能顺便理解“通用性”和“参数化”在算法题里是怎么回事。而且这两题在力扣热题100里都有一席之地属于不刷会亏的类型。1. 整体设计思路为什么这两道题必须一起刷1.1 两道题背后的同一个核心模型先别看代码把模型想清楚。对于有序数组所有重复元素一定聚在一起形成一段一段的“连续相同值区间”。我们要做的是“就地压缩”这些区间把需要的元素提到数组前面后面多余的直接弃掉。这本质上就是一个流式处理问题从左往右扫描数组当前元素如果“有资格保留”就写到结果区的下一个位置没资格就跳过。维持一个“慢指针”指向已处理区域的末尾一个“快指针”遍历整个数组。快慢指针之间正好隔开“待处理区域”这就是双指针原地算法的经典形态。26题和80题的区别只在于“有资格保留”的判断标准26题当前元素不等于前一个已保留元素即首次出现保留。80题当前元素出现次数小于2时可保留也就是跟前两个已保留位置进行比较或者用计数器记录当前连续相同值的出现次数超过2则跳过。这个差别看起来很小但写法上有两种截然不同的分支面试时候很多人在这里栽跟头。后面第3节会详细对比这两种分支。1.2 为什么“有序数组”这个条件如此关键如果数组无序这题难度直接上升一个档次。无序数组要去重你没法只用双指针线性扫描要么排序破坏原顺序要么用哈希表记录出现过的值额外O(n)空间。而题目明确说了是“有序数组”就是为了让你能用O(n)时间和O(1)空间完成。这个“有序”到底省了什么省掉了回头查询。因为相同元素全部相邻你永远不需要回头和更早的元素比较只需和最近保留的那一个或两个比较即可。用个生活类比在按首字母排好序的电话簿里找重名的人你只需要和紧挨着你的几个人比不必翻完整本电话簿。面试时如果没说有序直接默认用哈希表做不要硬套双指针。这是另一个常见坑。1.3 原地修改的“物理意义”题目要求原地删除不能new一个新数组返回然后让调用者“看前k个元素”。这意味着你必须真正改变传入的数组内容把有效元素搬到数组前部。实现时有一个细节很多人容易忽略数组尾部多出来的旧值不影响结果。比如[1,1,2,2,3]处理完数组实际变成[1,2,3,2,3]只要返回3评测就判定通过。这一点可以放心不需要置零尾部元素。但如果你在本地调试时打印整个数组看到尾部残留会心里发毛我建议调试时只打印前k个元素就不会误判自己写错了。2. 核心细节解析双指针的两种分支写法与边界条件2.1 快慢指针的分工与不变量先定一个不变量写起来才不会乱slow下一个有效元素要写入的位置也是当前已确认保留的结果区的长度。fast当前扫描到的原数组元素下标。slow和fast的物理含义是区间[0, slow)是已经确认合法的输出区区间[slow, fast)是已经被扫描过但不保留的废弃区区间[fast, n)是还没看过的未来区。每次迭代fast前进一位判断nums[fast]是否要放入nums[slow]。如果要放nums[slow] nums[fast]然后slow如果不要直接让fast往下走。这个不变量适用于26和80两道题区别只体现在“要不要放”的判断上。2.2 26题的判断逻辑和上一个保留值比较26题里当前元素nums[fast]能保留的条件是它和上一个已写入的元素nums[slow - 1]不同。因为数组有序重复值都连着如果相同说明当前fast所指的是已经在输出区出现过的值如果不同说明它是一个新值首次出现。代码可以这样写def removeDuplicates(nums): n len(nums) if n 0: return 0 slow 1 # 第一个元素天然保留从下标1开始写 for fast in range(1, n): if nums[fast] ! nums[slow - 1]: nums[slow] nums[fast] slow 1 return slow这里一个重要选择是slow初始值设为1并且跳过nums[0]不处理。因为第一个元素永远不需要被覆盖它一定是输出区的第一个成员。这样写会少一个边界判断。2.3 80题的两种竞争写法80题是“最多保留两个相同元素”。有两个写法流派各有拥趸。写法A比较nums[slow - 2]后置条件法这是效率最高、代码最短的写法def removeDuplicates(nums): n len(nums) if n 2: return n slow 2 for fast in range(2, n): if nums[fast] ! nums[slow - 2]: nums[slow] nums[fast] slow 1 return slow思路来源既然每个元素最多保留两个那么新元素能写入的条件是它和前两个保留位至少有一个不同。为什么看slow - 2而不是slow - 1因为这个元素如果能写就相当于在输出区里允许与前面最多一个重复一旦nums[fast] nums[slow - 2]说明前面已经保留了两个同样的值当前元素就是第三个必须跳过。这个方法好理解但有个容易绕晕的地方slow - 2这个位置可能被覆盖过多次不一定是原数组的相邻位置但它永远代表“输出区中当前这个值的第一处或第二处”。仔细走一遍例子[1,1,1,2,2,3]slow2, fast2, nums[2]1nums[slow-2]nums[0]1相等跳过。fast3, nums[3]2nums[0]1不等写入nums[slow]nums[3]2slow3。fast4, nums[4]2nums[slow-2]nums[1]1不等写入nums[slow]nums[4]2slow4。fast5, nums[5]3nums[slow-2]nums[2]2不等写入nums[slow]nums[5]3slow5。结果正确。写法B计数法显式记录次数另一种更“直白”的写法是用一个count变量记录当前连续重复的个数这样更符合人脑直觉def removeDuplicates(nums): n len(nums) if n 0: return 0 slow 0 count 1 for fast in range(n): if fast 0 and nums[fast] nums[fast - 1]: count 1 else: count 1 if count 2: nums[slow] nums[fast] slow 1 return slow计数法写起来更啰嗦但逻辑更透明特别适合在面试时边说边写不容易被slow - 2绕晕。缺点是count的更新和重置要在循环里维护容易在else分支上写错。这两种写法我建议都掌握。面试官问80题先用计数法讲一遍思路再补一句“还可以用固定窗口长度的写法比较slow-2”会显得你理解更深属于加分操作。2.4 边界条件空数组、单元素、全相同元素边界条件才是这类题真正的分水岭。空数组返回0。单元素数组26题返回180题返回1都没问题。全相同元素比如[5,5,5,5,5]26题返回1数组变成[5,...]80题返回2数组变成[5,5,...]。一个常见处理方式是在函数开头做长度判断26题if n 0: return 0然后slow1开始。80题if n 2: return n然后slow2开始。这样能保证在进入循环前slow - 1或slow - 2不会越界。别小看这几行力扣的判题器对越界非常敏感漏掉一个长度分支直接报错。3. 实操过程从暴力解到最优解的全流程演进3.1 第一步先写出“直接复制到新数组”的版本面试时不要一上来就写双指针。先把最直观的解法讲清楚也就是允许额外空间的话怎么做def removeDuplicates_with_extra_space(nums): if not nums: return 0 res [nums[0]] for x in nums[1:]: if x ! res[-1]: res.append(x) # 最后把res复制回nums前len(res)个位置 for i in range(len(res)): nums[i] res[i] return len(res)这个版本的作用是验证对题意的理解是否准确同时给后续原地算法提供一个对照基准。写完这个版本再解释“题目要求原地所以不能申请额外数组需要把res压缩掉”然后自然地过渡到双指针。实际面试时面试官更想看到的是这个“先说朴素思路、再优化”而不是直接甩出最优解虽然最优解也就五行。前者能体现工程思维。3.2 第二步把“复制到新数组”改造成“覆盖旧数组”对比一下就会发现朴素版本的核心操作是如果当前值 ≠ res的最后一个值就加入res。这个逻辑放到原地版里就是把res[-1]替换成nums[slow - 1]把“加入res”替换成“写入nums[slow]并把slow加一”。这一步改造可以用一个很形象的比喻新数组res其实一直藏在原数组的前半部分slow就是res的下标截止到slow之前的内容全部是“最终产物”。“新数组不再需要单独申请空间”因为它就住在原数组里。改造后的26题代码就是上一节给的版本。我自己刷题时最喜欢这样记忆slow是“答案的结尾”。fast是“原始数据的探路者”。nums[fast]与nums[slow - 1]比较本质是“当前元素和答案的最后一个元素是否相同”。这个视角一旦建立80题只是把“答案的最后一个元素”换成“答案的倒数第二个元素”其余全部照搬。整个知识迁移只需要两三分钟这是高效刷题的关键不要刷一道会一道要总结出“模板迁移”的规律。3.3 第三步抽象出“通用模板”顺带解决“最多保留K个重复”既然26是“最多保留1个”80是“最多保留2个”那“最多保留K个”呢直接用同样的套路把slow初始值设为K判断条件改成nums[fast] ! nums[slow - K]def removeDuplicates_k(nums, k): n len(nums) if n k: return n slow k for fast in range(k, n): if nums[fast] ! nums[slow - k]: nums[slow] nums[fast] slow 1 return slow我用这个模板把26题K1和80题K2一起AC后才真正理解力扣把这题放到“热题100”里的意义。它考的不是你会不会去重而是你会不会把一个具体的解题模式参数化。面试如果遇到“最多保留3个”或者“保留M个”的变体直接把K传进去3分钟就能写完。3.4 各种写法的复杂度对比与取舍解法时间复杂度空间复杂度适用场景哈希表去重无序数组O(n)O(n)无序数组或要求保留原顺序但允许额外空间双指针原地法O(n)O(1)有序数组原地要求严格时计数法双指针O(n)O(1)想让逻辑更直观时适合讲解两者性能上没差异都是单趟扫描、常数空间。区别在于代码可读性和面试表达。追求效率选“慢指针前比较K个”的模板追求讲清楚选计数法。如果你是在力扣上刷题两个都必须能一眼写出来。4. 常见问题与排查技巧实录4.1 问题1明明写对了提交后却数组越界这是做26题最容易遇见的报错。常见原因有两个一是slow从0开始初始化比较nums[fast]和nums[slow - 1]时slow - 1等于-1索引越界。正确的做法要么让slow从1开始要么在循环里加一个slow 0的判断。不加判断直接用nums[-1]在Python里不会报错但会拿到最后一个元素逻辑完全错乱。二是完全没有做长度判断空数组直接进循环n0时报错。建议先写if n 0: return 0这样后续逻辑才能放心用nums[0]。4.2 问题280题用“慢指针减2比较”时结果多出来一个重复值这类问题的典型样例是[0,0,0,0,0,0]。如果你用“比较nums[slow - 2]”的写法并不会出错因为slow - 2永远指向“当前值第一次出现的位置”值相等则说明第3次出现不保留。但如果把条件误写成nums[fast] ! nums[slow - 1]这个样例就会出错结果会留下3个以上。遇到这类问题我的排查习惯是不要只在力扣上跑自己在草稿纸上走一遍完整的双指针轨迹把每一轮的slow、fast、被比较的下标和值写出来马上就能定位到是“比对对象选错”还是“slow更新时机错误”。4.3 问题3本地打印数组发现尾部脏数据怀疑写错了很多人在本地把整个数组打印出来看到[1,2,3,3,3]就会懵以为自己没删干净。其实题目只要求“前k个元素是答案”后面无论是什么都不影响判题。这个属于题目约定不是bug。调试时建议只打印nums[:slow]可以避免自我怀疑。如果你非要看到数组被“清干净”可以自己额外写个循环把后半段置为0但提交前记得删掉否则会影响空间复杂度的评价甚至被误判为O(n)。4.4 问题4把26题和80题的“保留数量”搞混力扣的题号会变但这两道题的难度和内容多年未变很多人刷完26过一周刷80会下意识写出slow 1。记住一个锚点26留1个80留2个所以slow的初值分别是1和2判断条件分别比较slow-1和slow-2。这个规律可以写成一行口诀“留几个就从第几个开始写比较时看前几个位置”。很土但真的管用。4.5 问题5面试时被追问“如果数组没有序怎么办”这是这两道题最常见的追问。如果你已经写完双指针面试官问“无序数组怎么处理”其实是考察你是否知道“有序”这个前提的价值。标准回答是无序数组没有“重复值全部相邻”的性质双指针没法线性判断重复所以要么先排序时间O(nlogn)空间O(1)或O(n)要么用哈希表记录出现次数时间O(n)空间O(n)。如果要求稳定保留首次出现的顺序且不能排序哈希表是合理选择。如果面试官还要求最多保留两个且无序哈希表记录次数即可写起来比有序版还简单。这个追问能答好说明你不是背代码而是真懂模型。5. 扩展思考从两道题延伸到整个双指针体系5.1 双指针三兄弟快慢、左右、滑动窗口做完26和80你其实已经掌握了双指针里最重要的一个子类——同向快慢指针。在这个大类里常见题还有27题“移除元素”保留不等于val的元素。283题“移动零”把所有0移到末尾等价于“保留非零元素”。19题“删除链表的倒数第N个节点”链表版快慢指针。这些题和26/80的骨架高度相似都是“一个指针往前走一个指针在原地写”。我建议把27和283放在同一天刷掉你会惊喜地发现代码几乎不用改只改判断条件。5.2 为什么刷题要有“并题”策略单刷一道题记忆留存率很低把相似题放到一起横向对比能沉淀出“题型模板”。26和80是最典型的“并题”组合因为80只是26加了一个参数K。把K抽象出来之后这两道题的代码各只需5行且能解决一大类“保留有限重复”的变体。我在实际刷题中体会最深的不是某道题的解法而是这种“用一个模板横扫一族题”的感觉。面对一个新题先判断它属于哪个模板再套用、改条件、调边界比每次从零开始想快得多。5.3 这类题在真实面试中的权重说实话双指针在互联网大厂算法面中属于“入门必考”多数不会作为压轴题但经常出现在第一轮的电面和笔试里。它的变体形式很多而26/80又是最基础的两个所以非常适合作为“每天刷第一题”的热身题。如果你已经刷穿了这两道可以顺手把“最多保留K个”的模板写到自己的代码仓库里面试前过一遍比临时翻题解安心得多。6. 实操过程中的体会与提醒最后分享几个我个人刷题和面试时的实际体会。首先这两道题千万不要背答案。我见过太多人能把26题代码默写出来但面试时被改成80题就卡住就是因为不理解slow和fast背后的不变量。自己动手把[1,1,1,2,2,3,3,3]在纸上跑一遍双指针比默写十遍都有效。其次用Python刷题有个细节值得注意Python的列表负索引不会越界报错nums[-1]能取到最后一个元素但你的算法逻辑大概率是错的。最好在写比较条件之前先想清楚要用slow - 1还是slow - 2不要依赖语言特性兜底。另外提交代码前一定要想清楚“返回长度”和“数组实际内容”的关系。力扣只检查返回长度和数组前k个值哪怕输出区后面残留了旧值也不会报错但如果你在本地用pytest做断言记得只断言前k个。如果你准备面试建议把这两道题当成“必须60秒内写出无bug代码”的题目来练。它们足够简单不应该在考场上浪费太多时间。练到条件反射级别之后再去刷27、283、19等兄弟题你会明显感觉到同类题的通吃能力。多说一嘴我记得有一位前辈总结过刷算法题的价值不在于把每道题做对而在于把一类题的套路内化成思考方式。26和80就是练“原地快慢指针”最好的入门组合。把这5行模板焊死在脑子里以后遇到任何“必须保留若干重复”的变体你都能第一时间反应过来这比多刷几百道题更值钱。
返回列表