ARTICLE DETAIL

资讯详情

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

从删除有序数组重复项到双指针原地算法通用模板——力扣26题精讲

从删除有序数组重复项到双指针原地算法通用模板——力扣26题精讲 不是我夸张这题我前前后后刷了三遍每次重刷都会发现一点新东西。力扣第 26 题“删除有序数组中的重复项”看起来只有一句话实际上把原地算法、双指针、边界处理和“返回值语义”四件事全考了。很多新手看完题直奔 set 去了结果空间复杂度直接爆表还有人写对了逻辑却在返回长度时多写了 1整个提交功亏一篑。更麻烦的是网上题解版本太多有的 slow 初始化为 0有的初始化为 1比较对象一会儿是nums[slow-1]一会儿是nums[fast-1]新手很容易被绕晕。这篇文章我不打算只贴一个 AC 代码而是把推导过程、两种指针语义、边界测试、以及从 26 题延伸到“保留 k 个重复值”通用模板的过程全都讲透顺便把我自己踩过的坑和推荐的刷题顺序一并交代清楚。1. 题目到底在考什么别被“去重”两个字带偏了1.1 需求拆解题目里藏着的三个硬性要求题目原文是给你一个非严格递增排列的数组nums请你原地删除重复出现的元素使每个元素只出现一次返回删除后数组的新长度。元素的相对顺序应该保持一致。这里面有三处容易被忽略但恰恰是整道题的命门第一原地修改。你不能声明一个新的数组把不重复的元素收集起来再返回。任何这种思路不管代码写得多漂亮空间复杂度都是 O(n)不符合题目要求。题目要求额外空间只允许 O(1)。第二返回的是新长度不是数组本身。力扣的判题逻辑是拿到你返回的length之后把nums数组的前length个元素取出来和期望结果逐位比较。也就是说你根本不需要把数组尾部的“残留数据”清理干净那些数据也不会影响判分。这一点疏通了后面看测试用例时心态会稳很多。第三输入本身就是有序数组。很多人在读题时直接把“有序”二字划过去了好像它只是背景设定。实际上“有序”是本题唯一能使用 O(1) 空间完成去重的根本原因。如果数组完全无序重复元素散落在各个角落你就必须用哈希表记住所有出现过的值而有序数组保证重复元素全部相邻于是“相邻比较”就能完成全部去重。1.2 为什么 set 和“新建数组”在这里行不通我见过不止一个初学者写出这样的代码class Solution: def removeDuplicates(self, nums: list[int]) - int: nums list(set(nums)) return len(nums)这段代码在本地跑甚至能正确打印出去重后的数组看起来好像没毛病。但它在力扣上是错的错在两个层面。第一个层面是空间复杂度。set(nums)在最坏情况下数组所有元素都不同要存下全部 n 个值额外的哈希表开销是 O(n)题目明确要求 O(1)这就不合格。第二个层面更隐蔽也更容易让 Python 新手翻车函数内的nums ...只是把局部变量nums重新绑定到一个新列表根本没有修改调用方传入的那个原数组对象。力扣判题时检查的还是原来的那块内存你把局部变量指向别处判题器看都不会看。想要原地改写一个 Python 列表你应该用切片赋值nums[:] ...但即便如此set 去重后也不保证保持原有元素顺序稳定所以这条路从头到尾都不该走。1.3 “有序”这个前提是全场最重要的隐藏条件打个比方如果把数组想象成按姓氏笔画排好队的一排人你现在要找出所有重复的姓氏。因为队伍已经有序你只需要看“前面的那个姓”和“当前这个人的姓”是否相同如果队伍是乱的你就得拿小本子不停地记录。有序就是那个让你可以扔掉小本子、只用一双眼和一个指头的条件。对应到代码里这个“指头”就是快慢指针。因为数组有序所有相同的值必然彼此相邻。你从数组头部开始扫描每遇到一个新的值就把它往数组前面搬一次同一个值后面的重复项全部跳过。这个过程不需要额外记忆任何“之前出现过什么”因为新的值一定比之前所有的保留值都大你只要和“最近一个保留值”比一比就够了。2. 双指针推导为什么“快读慢写”是这类题的骨架2.1 从暴力删除到原地覆盖的思维转变新手最容易想到的暴力做法是从头遍历一旦发现nums[i] nums[i-1]就调用删除方法把它删掉。Python 里是nums.pop(i)C 里是vector.eraseJava 里得手动移动元素。问题在于数组这种数据结构的删除操作不是 O(1)。删除任意位置的一个元素后面所有元素都要往前挪一位。最坏情况下数组全部是同一个值你得删 n-1 次每次都要移动剩余元素总复杂度逼近 O(n²)。虽然这道题 n 最大只有三万硬写也能跑完但显然不是算法题想要的答案。正确的做法是假删除。既然不让用额外空间那我们就不真正删除元素而是用覆盖一个快指针负责“读”一个慢指针负责“写”把该保留的元素依次覆盖到数组的头部前缀。数组尾部留存了什么垃圾数据根本不需要关心。这种思想在计算机领域有个专门名词in-place algorithm原地算法。2.2 一个具体例子走完整个流程以nums [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]为例走一遍标准的“快读慢写”流程。初始化时让slow 1含义是“下标 0 这个位置已经确定保留无需再比较下一个允许写入的位置是下标 1”。为什么下标 0 天然可以保留因为不管数组怎么重复第一个位置上的元素永远不可能需要被删除。fast1nums[1]0和 nums[0]0 相等是重复值跳过。fast2nums[2]1和 nums[0]0 不等说明遇到了新元素。写入nums[1]1slow 前进到 2。fast3nums[3]1和 nums[1]1 相等跳过。fast4nums[4]1和 nums[1]1 相等跳过。fast5nums[5]2和 nums[1]1 不等写入nums[2]2slow 前进到 3。fast6nums[6]2和 nums[2]2 相等跳过。fast7nums[7]3和 nums[2]2 不等写入nums[3]3slow 前进到 4。fast8nums[8]3和 nums[3]3 相等跳过。fast9nums[9]4和 nums[3]3 不等写入nums[4]4slow 前进到 5。最终返回slow 5数组前 5 个元素是 [0, 1, 2, 3, 4]尾部残留的 [3, 4] 等元素完全不用理会。2.3 为什么覆盖操作永远不会弄丢还没扫描的数据有个问题我第一次看双指针时也困惑过nums[slow] nums[fast]这一写会不会把还没扫描到的数据覆盖掉答案是不会因为整个过程中始终满足slow fast。slow 代表“已经整理好的去重前缀的长度”fast 代表“当前扫描指针的位置”。两个指针要么一起前进要么 fast 单独前进、slow 原地等待slow 永远不可能超过 fast。因此写入位置要么就是 fast 自己所在的原位要么位于 fast 左侧的已扫描区域绝无可能越过 fast 去碰右边那些还没被读取的元素。这就是原地双指针为什么安全的核心保证。2.4 慢指针的两种语义先统一口径再谈代码我后来发现网上同一声称“正确”的题解代码存在明显差异根源在于大家对slow的定义根本没对齐。常见的定义有两种语义 Aslow 指向“下一个应该被写入的位置”比较对象是nums[slow-1]最终返回值是 slow。语义 Bslow 指向“最后一个已经确定的保留元素”比较对象是nums[slow]每次写入前先让 slow 加 1最终返回值是 slow 1。这两种语义写出来的代码长得一模一样但初始化、比较条件、返回值全都不一样。如果脑子里语义不清写出来就是一个看起来能跑但细想全是 bug 的版本。下一章用两套完整代码分别对照你就能彻底看清差异在哪。3. slow 指针的两种初始化最容易写错的一段逻辑3.1 写法一slow 指向“下一个写入位置”这是我在实际刷题和面试中使用频率最高的一套模板class Solution: def removeDuplicates(self, nums: list[int]) - int: 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解释就三句话slow1表示下标 0 已经确定保留fast从 1 开始扫描只要nums[fast]和nums[slow-1]不相等就说明遇到了一个“去重前缀里没见过”的新值把它写到slow指向的位置然后slow加 1。空数组的处理必须放在最前面。如果不加if not nums: return 0nums[slow - 1]在空数组上会直接越界。3.2 写法二slow 指向“最后一个已保留元素”另一种同样正确的写法是这样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 1这种写法里slow 指向的是“队尾元素”也就是去重前缀里最后一个已经被确认保留的值。当fast遇到新值时先让 slow 加 1 腾出一个空位再执行写入。因为 slow 从 0 开始最后返回长度时需要记得加 1。它其实比写法一更贴近人类直觉你去重后的数组当前最后一个元素是谁新的数字跟它比就完事了。但问题在于返回时的slow 1以及每次写入前的slow 1这两处是最容易出 off-by-one 错误的地方。新手写着写着就会把返回值写成 slow然后对着错误结果发呆。3.3 Java 版的对照写法这套逻辑跟语言绑定很弱Java 写出来是同一副骨架class Solution { public int removeDuplicates(int[] nums) { if (nums.length 0) { return 0; } int slow 1; for (int fast 1; fast nums.length; fast) { if (nums[fast] ! nums[slow - 1]) { nums[slow] nums[fast]; slow; } } return slow; } }任何语言都是这三个要点比较上一保留元素、写入 slow 位置、返回 slow。只要你把 slow 的语义定义清楚不照着别人的代码硬背换多少个语言都不怕。3.4 比较对象为什么是 slow-1而不是 fast-1可能有读者会想数组是有序的那我直接用nums[fast] ! nums[fast-1]判断相邻重复不是更省事吗表面上它在很多用例下都能得到正确结果但这里有一个隐患nums[fast-1]不一定是“去重前缀的最后一个保留元素”。因为数组一直被原地覆盖某些靠前的位置早就被改写过了。比较fast-1时你实际上在拿当前元素和“原始数组中前一个位置的当前残留值”做比较这个值的语义是漂移的——它可能是某个早期的保留值而不是你真正想比较的“最新保留值”。万一这个残留值小于当前元素而当前元素实际上已经出现在前缀里就存在误判为“新值”的可能。标准写法之所以坚持比较nums[slow-1]是因为 slow-1 的位置语义永远稳定它是去重后前缀的最后一个元素。把所有比较都锚定在去重前缀上整个算法的正确性论证才经得起追问。而且这种写法还有一个巨大优势——下一章会看到它几乎不用改就能扩展到“允许保留 k 个重复值”的通用模板而fast-1那套完全做不到。3.5 我推荐的书写顺序我个人习惯是先写一行注释再写代码# slow 是下一个写入位置也是去重后数组的长度 # fast 负责扫描全数组遇到新值时写入 nums[slow] 并令 slow 1先把这两句注释落在纸上循环体基本不可能写错。刷题不是表演默写注释不是写给考官看的是写给你自己防呆的。4. 边界条件与完整测试把力扣的判题逻辑彻底搞明白4.1 必测的六组用例写完核心逻辑我建议你用下面这六组用例做一遍自查它们覆盖了几乎所有边界情况输入数组期望返回长度处理后 nums 前 length 位[]0[][1]1[1][1,1,1,1]1[1][1,2,3,4,5]5[1,2,3,4,5][0,0,1,1,1,2,2,3,3,4]5[0,1,2,3,4][-1,-1,0,0,0,1]3[-1,0,1]前两条分别是空数组和单元素数组专门用来检验你是否漏写了空数组特判。第三条是“全部相同”此时 slow 全程不会前进返回值必须停在 1。第四条是“全部不同”此时每个元素都自成一个新值slow 会一直前进到数组末尾。最后两条是混合场景用来验证整体流程。还有一类用例容易忘负数。很多人在本地测试时只用正数结果数组里一旦出现负数nums[fast] ! nums[slow-1]本身依然成立逻辑上没问题但你至少应该确认一遍。4.2 判题器到底在检查什么力扣对“数组类”题目的验证方式可以近似理解成下面这段伪代码if len(nums[:length]) ! len(expected): return False for i in range(length): if nums[i] ! expected[i]: return False return True注意它只检查你返回的length范围内的元素超出这个范围的内容哪怕乱七八糟判题器也不会多看一眼。这就是为什么 4.1 的表格里只写了“前 length 位”而不是整段数组。明白这一点后有些本地调试时的困惑就迎刃而解了。你在 IDE 里打印整个数组发现去重后尾部长这样[0, 1, 2, 3, 4, 3, 4]后面尾巴还在别慌。这不是你写错了而是“原地删除”本来就不负责清理尾部。你打印时应该只打印前removeDuplicates(nums)个元素。4.3 Python 原地修改的隐藏陷阱Python 里有个特别容易踩的坑值得单独拎出来说。你辛辛苦苦写了一个去重逻辑最后写成下面这样nums nums[:length] # 错这不是原地修改 return length这样写在本地 Python 脚本里可能看起来是对的因为局部变量 nums 指向了新列表后面你打印 nums 也确实是去重后的结果。但在力扣的类方法里调用方持有的原列表对象根本没有变化判题器检查的依然是旧对象结果就是“逻辑正确但报错”。正确的原地截断写法是nums[:] nums[:length] # 对原对象做切片赋值不过在 26 题里你根本不需要截断直接返回 slow 就完事了判题器只关心前 slow 个元素。我也见过一些同学多此一举地截断数组虽然切片赋值本身不会扣分但没必要。5. 一题百用把 26 题抽象成“保留最多 k 个”的通用模板5.1 先看变式题 80允许重复两次力扣第 80 题“删除有序数组中的重复项 II”是 26 题最直接的变式要求每个元素最多保留两次超过两次的重复项全部删掉。看完 26 题再看 80 题很多人的第一反应是“那我加个计数器不就行了”。加计数器当然可以但不够优雅而且容易在边界上翻车。更好的做法是直接沿用双指针只改两个地方class Solution: def removeDuplicates(self, nums: list[int]) - int: if not nums: return 0 slow 2 for fast in range(2, len(nums)): if nums[fast] ! nums[slow - 2]: nums[slow] nums[fast] slow 1 return slow对比 26 题的代码slow初始值从 1 变成 2比较对象从nums[slow-1]变成nums[slow-2]。就这两行整套题就解出来了。是不是很像魔法这里面其实有严格的数学原理下一小节拆开讲。5.2 通用模板的原理为什么比较对象是 slow-k把 26 题和 80 题统一起来可以得到一个“最多保留 k 个”的通用模板def removeDuplicatesK(nums, k): if not nums: return 0 slow k for fast in range(k, len(nums)): if nums[fast] ! nums[slow - k]: nums[slow] nums[fast] slow 1 return slow关键就一句话当前元素 nums[fast] 只和“去重前缀中倒数第 k 个元素”比较。为什么这个位置是 slow-k而不是 slow-1 或者 fast-k推导过程是数组有序因此去重前缀中最后 k 个元素如果都等于同一个值 x那么nums[fast] nums[slow-k]意味着在去重前缀的末尾已经存在 k 个 x此时如果再把 nums[fast] 写进去就会出现 k1 个 x违反了“最多保留 k 个”的限制因此必须跳过。反过来如果nums[fast] ! nums[slow-k]那说明 nums[fast] 要么比去重前缀末尾的最大值还要大是一个真正的新值要么虽然等于前缀最大值但前缀末尾的 k 个元素不可能全等于它——否则 slow-k 位置也应该是它产生矛盾。所以这种情况下写入一定安全。当 k1 时这就是 26 题比较的是最后一个保留元素当 k2 时这就是 80 题比较的是倒数第二个保留元素。你可以把 k 改成任何正整数这个模板都成立时间复杂度始终是 O(n)空间复杂度始终是 O(1)。5.3 通用模板的适用范围与注意点这个模板有一个大前提数组必须有序而且允许重复的上限 k 是一个固定常数。如果题目改成“最多保留数组总长度的一半”或者加一个“每个数字只能出现一次但输入无序”这套模板就失效了需要换哈希表或排序的思路。另外要注意这个模板的slow k意味着前 k 个元素默认全部保留。这个默认值是合理的因为一个元素从第 1 次到第 k 次出现都属于“允许出现”的范围内只有从第 k1 次开始才需要被过滤。把前 k 个位置无脑保留逻辑上和 26 题把第一个元素无脑保留是一致的。我建议你刷完 26 题之后当天立刻刷一遍 80 题用 5.2 的模板改两个数字把这个迁移过程变成肌肉记忆。这种“一题生两题”的练习方式比盲目刷题列表高效得多。6. 刷完这道题我留下的一些体会与避坑经验6.1 和 27 题“移除元素”对照着刷效果更好力扣第 27 题“移除元素”和 26 题是孪生兄弟给定一个值val原地删除所有等于val的元素返回新长度。代码几乎一模一样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 slow26 题的比较对象是“上一个保留元素”是动态的27 题的比较对象是固定的val是静态的。把这两题放在同一天刷你就能体会到双指针的两种形态一种用于“按条件筛选”另一种用于“数组内去重”。很多同类题比如移动零、压缩字符串本质上都是同一个模板的变形。6.2 面试时怎么把这道题讲出亮点算法面试遇到这题时大部分候选人会写代码但很少有人能讲清楚。我的建议是走四条线每一条都能让面试官点头第一条是复杂度线。先承认暴力方案的问题数组删除是 O(n) 操作最坏情况下整体 O(n²)空间上新建数组要 O(n)。然后表明你明白优化目标就是 O(n) 时间和 O(1) 空间。第二条是前提线。强调“有序”这个条件说明因为有序所以重复元素相邻才让单指针比较成为可能。没有这个前提双指针方案不成立。第三条是正确性线。解释 slow 代表什么、fast 代表什么以及为什么slow fast保证覆盖安全为什么比较nums[slow-1]而不是nums[fast-1]。第四条是扩展线。主动提出“这题可以推广到最多保留 k 个重复值只需要把比较对象改成nums[slow-k]”然后现场改写代码。这一下就能把普通题解和深度理解拉开差距。6.3 我的个人习惯先写注释再写循环最后分享一个让我少踩无数坑的习惯写任何双指针题第一件事不是敲循环而是在函数体第一行写下你对两个指针的定义。# slow下一个写入位置也就是去重后数组的长度 # fast扫描整个数组的指针负责寻找新值这行注释写完之后slow的初始值、比较下标、返回值几乎是跟着注释自动蹦出来的。做题最怕的不是不会而是思路还很模糊就急着敲键盘结果越敲越乱。把语义定死在注释里代码只是照着翻译而已。就这道题而言我自己的体会是慢指针的意义绝不只是“去重后的长度计数器”它其实代表了另一个维度——你已经整理好的秩序边界。fast 每往前走一步都在和这个秩序边界做比较决定边界要不要向前推进一格。想明白这一层双指针类的题目就不再是一堆死记硬背的模板而是一种可以迁移的思维方式。后续不管是刷 80 题还是碰压缩字符串、移动零你会发现它们都在同一个框架下区别只是比较对象和移动规则稍作变化而已。
返回列表