ARTICLE DETAIL

资讯详情

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

LeetCode 26题详解:双指针原地去重有序数组的算法思维

LeetCode 26题详解:双指针原地去重有序数组的算法思维 1. 题目拆解与核心思路转换做算法题最怕一上来就埋头写代码先花两分钟把题目读懂比什么都重要。LeetCode 第 26 题删除有序数组中的重复项题面非常短给你一个升序排列的数组请在原地删除重复出现的元素使每个元素只出现一次返回删除后数组的新长度。注意两个关键词——升序排列和原地。升序意味着重复元素一定紧挨在一起这为双指针解法提供了天然的便利原地则意味着不能新建数组必须直接修改原数组这对空间复杂度提出了硬性要求。很多第一次接触这道题的人会本能地想到用 set 去重再转回 list在写业务代码时这完全合理但在算法题里这就踩了红线set 会打乱数组中的相对顺序而且开辟了额外存储空间空间复杂度是 O(n)不符合题目约束。我们需要的是 O(1) 空间复杂度的原地算法于是快慢双指针就成了最优解。快慢双指针的思路并不复杂但理解它需要一个状态视角的转换。想象你有两根指针一根叫 slow一根叫 fastfast 负责在前面探路一根一根地往后扫slow 则负责维护已经处理好的那段区域的尾部。每次 fast 发现了一个和 slow 指向的值不同的新元素就把它挪到 slow 的下一个位置然后 slow 前进一步。这样 slow 走过的区域始终是不含重复项的而 fast 则把整个数组完整过了一遍。我见过不少人第一次看到这段代码时一脸茫然为什么是 if 比较而不是 while 循环其实这是一个非常精彩的工程化思路因为数组是有序的fast 一路往后走它遇到的每个值要么和当前有效区域的最后一个元素相同要么不同。相同就跳过不同就收编仅此而已。这个不同才操作的惰性策略让两根指针都只往一个方向移动并且永远不会回头整体时间复杂度被压到了 O(n)。如果你用生活化的方式来理解这个算法可以想象一条传送带上的包裹它们按重量从小到大排列有些重量会重复。你作为质检员手里拿着一张标签纸slow 就相当于你最后一张贴好的标签的位置。每来一个包裹如果重量和上一张贴纸相同直接放行不管如果不同就把它放到 slow 的下一个空位贴上新标签然后 slow 前进一步。整个过程你不回头不用第二张桌子原地就把去重完成了。说到底这题考的不是某个冷门数据结构而是你有没有不看到全貌也能高效处理有序数据的思维。这是算法面试里的经典素质考察理解了这层逻辑这道题的代码反而成了最不重要的部分。2. 双指针实现的核心原理详解2.1 指针初始化的零特判艺术拿到这道题第一个要决策的问题是慢指针从 0 开始还是从 1 开始。两种方案都有大量的人在用但对我来说从 1 开始是更优雅的写法因为它天然地避开了空数组和单元素数组的边界特判。具体来说我习惯把 slow 初始化为 1fast 从 1 开始向后扫描。这里的关键判断是不管数组什么样第一个元素一定保留因为它是去重后的第一个元素。所以 slow 从 1 开始表示从索引 1 开始逐位覆盖新的不重复值。从 1 开始还有一个隐形的好处不需要单独检查数组长度是否为 0。如果数组为空循环条件 fast len(nums) 根本不会进入直接返回 0如果数组只有一个元素返回 1完全正确。这套写法一行特判都不用写简洁利落。而如果 slow 从 0 开始你就需要先判断一下数组长度是否为 0否则当快慢指针都指向同一个越界位置时会出问题。虽然多一行 if 也无伤大雅但在面试的手写代码环节能少写一行就少一分出错风险。2.2 比较对象是 nums[fast] 与 nums[slow-1] 还是别的有了 slow 初始化为 1 的基础我们就要确定快慢指针的比较对象了。我习惯用nums[fast] ! nums[slow - 1]作为判断条件。为什么是和nums[slow - 1]比而不是和nums[slow]比因为 slow 指向的是下一个待覆盖的位置这个位置上的值可能还是旧数据没有意义而slow - 1是有效去重序列的最后一个元素也就是最后一个被收编的值。我们真正关心的是fast 现在探到的这个新值和有效序列末尾是否相同。相同就跳过不同就覆盖。举个例子数组是[0, 0, 1, 1, 1, 2, 2, 3, 3, 4]。当 slow 1fast 2 时nums[fast] 1nums[slow-1] nums[0] 0两者不同所以 nums[1] 1slow 变成 2。此时有效序列变成了[0, 1]慢指针指向索引 2也就是接下来要覆盖的位置。这种写法的好处在逻辑上非常直观你永远只保留上一次见过的值一旦发现新值出现就更新保存。很多类似的题目比如移除指定元素、移动零都可以沿用这套保留有效区 覆盖新元素的框架只是判断条件从 是否等于 变成了 是否等于目标值 等等。2.3 为什么不需要 while 循环这是我在初学阶段困惑了很久的一个问题。很多人写完判断条件后想重复的元素可能会连续出现好几个难道不应该用 while 循环跳过所有重复项吗但实际答案是完全不需要。原因很简单if 分支每次只处理一个元素如果 fast 连续碰到多个重复值那么这些重复值会被一个一个地跳过。注意slow 在这个过程中完全没有移动也就是说即使有连续十个重复值slow 也在原地等待只有 fast 一步步往后挪。这个慢等待、快探索的模式本身就已经覆盖了跳过所有重复项的需求不需要额外的 while 来跳过一段区间。如果换成 while 循环反而会增加复杂度你需要小心处理 fast 越界还要后退一步配合循环体之外的逻辑稍有不慎就会写出索引越界的 bug。if 写法虽然移动次数多一些但每次循环做的工作少逻辑简单不易出错在 O(n) 的时间复杂度下没有任何性能问题。2.4 一句话理解快慢指针的本质你可以把整个算法理解为这样一件事slow 负责圈出一块干净区域fast 负责在原始数组里探索每发现一个新元素就把它搬到干净区域的末尾并把区域的边界往后推一格。干净区域之外的地方即使有旧数据残留也无所谓因为我们最终只读取前 len(nums) 的长度。这也是为什么题目只要求返回新长度、不要求修剪数组的原因——LeetCode 只检查前新长度的元素是否符合预期后面的元素是历史遗留数据不影响判题。但在实际工程中如果需要真的截断数组可以在函数返回时使用nums[:slow]来获得一个仅含有效元素的数组如果调用方需要的话。这个细节也体现了一种偷懒但合理的工程思维我们花费最小代价获得正确结果剩下的交给后续操作。3. 完整实现与复杂度剖析3.1 标准快慢指针代码逐行解析直接上代码这是我在 LeetCode 上提交并通过的版本实测在 Python 3 环境下可以稳定通过所有测试用例。def removeDuplicates(nums: List[int]) - int: # 慢指针从 1 开始因为 nums[0] 必然保留 slow 1 # 快指针从 1 开始向后探索 for fast in range(1, len(nums)): # 发现新值与有效序列末尾不同 if nums[fast] ! nums[slow - 1]: nums[slow] nums[fast] slow 1 # 有效序列长度为 slow return slow逐行拆解一下这个代码的逻辑链条第一行slow 1是整个算法的基石。它声明了从索引 1 开始有效序列的末尾就是slow-1位置上的元素。不管数组是否为空、是否有重复第一个元素总是独一无二的不需要处理。第二行for fast in range(1, len(nums))负责从第二个元素开始逐一访问整个数组。为什么从 1 开始因为索引 0 已经在 slow 1 时被默认保留了再比较索引 0 和索引 0 没有意义。第三行是整个算法的核心判断nums[fast] ! nums[slow - 1]。如果快指针发现了一个和有效序列末尾不同的元素说明这是一个新的不重复元素需要被保存。注意这里是slow-1不是slow因为 slow 指向的是待覆盖的位置它的值可能是旧的、无意义的。第四行和第五行执行覆盖和推进把nums[fast]的值赋给nums[slow]然后把 slow 加一更新有效序列的末尾位置。这是整个算法的写入环节fast 的值可以放心被覆盖因为它已经在循环中被访问过了后续不再需要原始值。第六行返回 slow 本身。因为 slow 始终指向下一个待写入的位置所以它天然就是去重后数组的长度不需要再做slow1之类的修正。3.2 复杂度分析与空间优势对比时间复杂度是 O(n)因为 fast 指针每一个元素都只被访问一次slow 指针也只向右移动两个指针加起来最多走 2n 步常数系数直接忽略。空间复杂度是 O(1)除了输入数组本身之外只使用了两个整型变量。为了更直观地感受这个算法的优势我整理了一个常见解法的复杂度对比表解法方案时间复杂度空间复杂度是否改变相对顺序是否符合题目要求set 去重再转列表O(n)O(n)不保证不符合新建列表筛选O(n)O(n)是不符合暴力逐个覆盖O(n^2)O(1)是不符合快慢双指针O(n)O(1)是符合set 方案虽然是很多 Python 工程师的第一反应但在算法题竞赛场景里空间复杂度注定是硬伤。而快慢双指针不仅满足题目约束而且在工程场景下也非常实用——比如你正在处理一个超大规模的有序数组上千万个数字想原地去重又不想占用额外内存这套算法就是最理想的方案。3.3 手动推演一遍完整流程文字解释再多也不如手动推演来得清楚我们拿一个典型的重复数组走一遍流程数组为[0, 0, 1, 1, 1, 2, 2, 3, 3, 4]。初始状态slow 1fast 从 1 开始。fast 1nums[1] 0nums[slow-1] nums[0] 0。两者相等跳过slow 不变。fast 2nums[2] 1nums[0] 0。两者不等执行 nums[1] 1slow 变为 2。此时数组前部变为[0, 1]。fast 3nums[3] 1nums[1] 1。两者相等跳过。fast 4nums[4] 1nums[1] 1。两者相等跳过。fast 5nums[5] 2nums[1] 1。两者不等执行 nums[2] 2slow 变为 3。数组前部变为[0, 1, 2]。fast 6nums[6] 2nums[2] 2。两者相等跳过。fast 7nums[7] 3nums[2] 2。两者不等执行 nums[3] 3slow 变为 4。数组前部变为[0, 1, 2, 3]。fast 8nums[8] 3nums[3] 3。两者相等跳过。fast 9nums[9] 4nums[3] 3。两者不等执行 nums[4] 4slow 变为 5。循环结束返回 slow 5去重后数组的前 5 个元素为[0, 1, 2, 3, 4]完全正确。你可以把这个推演过程拿到白板上走一遍比看任何解释都有用。走通一次之后你就会发现这个算法的快慢本质慢指针用来指示有效序列的边界快指针用来探索全数组。3.4 等价的 Python 写法与风格考量除了上面的写法Python 社区还有一种更计数思维的写法使用nums[fast] ! nums[fast - 1]作为判断条件。这种写法不需要借用 slow-1 的概念而是基于有序数组中如果当前元素和前一个元素不同那它就是新元素的思路。def removeDuplicates(nums: List[int]) - int: slow 0 for fast in range(len(nums)): if fast 0 or nums[fast] ! nums[fast - 1]: nums[slow] nums[fast] slow 1 return slow这两种写法在结果上完全等价但个人觉得第一种写法slow - 1比较法在语义上更贴近维护有效序列的概念面试时讲起来也更顺畅。第二种写法引入了fast - 1的比较虽然也很直观但需要额外处理fast 0时避免越界的特判多了一个分支。至于写成for循环还是while循环完全取决于个人习惯。for循环的取值范围更明确不需要手动维护 fast 的递增while循环的写法自由度更高但容易在边界条件上出错。我用for循环比较多因为它在语义上更安全——循环次数是固定的少一个忘记加步长的 bug 隐患。4. 常见陷阱、典型错误与排查思维4.1 初学阶段的三大典型错误我在从 LeetCode 评论区、以及给朋友 review 代码的过程中发现这道题的错误集中在三个地方每一个都值得单独拿出来说。第一个错误是慢指针从 0 开始却没有处理空数组。如果数组是空的slow 0循环条件fast len(nums)直接不成立返回 0 看似正确但如果你在循环内部直接访问nums[slow]或nums[slow-1]就会触发IndexError: list index out of range。很多人在数组长度为 0 的测试用例上栽了跟头。如果是慢指针从 1 开始这类问题会在起始条件就规避掉。第二个错误是判断条件写成了nums[fast] ! nums[slow]。表面上看slow 指向有效序列的尾部但慢指针的含义是下一个待覆盖的位置该位置上可能还残留着旧值直接用这个值做比较是不可靠的。正确写法是nums[fast] ! nums[slow - 1]和有效序列的末尾比较。第三个错误是覆盖值之后忘记对 slow 做加法。虽然看起来是小失误但它会导致每个新元素都被覆盖到同一个位置最终数组变成只有最后一个元素的结果长度始终是 1。这类 bug 在 LeetCode 上的报错信息会比较隐晦判题器显示的结果是输出的数组长度是 1但期望是 5如果没有意识到是 slow 没有递增排查起来会非常头疼。如果你也遇到了类似的报错我建议在循环内部加一个 print打印每次 fast、slow、nums[slow]、nums[slow-1] 的值马上就能定位是哪个环节出了问题。LeetCode 的 Playground 功能可以让你直接看到每一步的数组状态比在脑子里空想要省事得多。4.2 边界测试用例的完整清单无论代码写得再顺畅边界用例是躲不掉的。我每次刷题都会整理一份边界用例清单这道题至少需要覆盖以下四类第一类是空数组[]期望输出 0。这一类主要验证的是代码在没有任何元素时是否安全返回有没有访问非法索引。第二类是单元素数组[5]期望输出 1。这个用例验证的是单个元素本身就是不重复的任何去重逻辑都不能把它丢掉。第三类是全重复数组[7, 7, 7, 7, 7]期望输出 1。这验证了跳过所有相同值的循环路径确认最终只剩一个元素。第四类是完全没有重复的数组[1, 2, 3, 4, 5]期望输出 5。这验证了每个元素都是新值的路径此时 fast 和 slow 应该同步前进每个元素都被覆盖到自己原来的位置上。这四个用例基本覆盖了所有分支路径。我用 Java 和 Python 两套语言在 LeetCode 上测试过都能通过。注意数组输入在 LeetCode 里可以直接写nums []或nums [7, 7, 7, 7]然后调用一下函数看输出即可。4.3 从这道题延伸的双指针家族快慢双指针不是这一道题的专属解法把它吃透之后你会发现很多数组题目都是同一个套路的不同变体。先说 LeetCode 第 27 题移除元素题目要求原地删除所有等于 val 的元素。核心思路完全一致只是判断条件从是否等于前一个有效值变成了是否等于目标值。你只需要把if nums[fast] ! nums[slow - 1]改成if nums[fast] ! val然后nums[slow] nums[fast]slow 加一几乎可以无缝切换。再说 LeetCode 第 283 题移动零要求把数组里的所有 0 移到末尾同时保持非零元素的相对顺序。这道题同样可以用双指针解决不过更合适的双指针方案是非零元素往前覆盖末尾补零的变体。思路是快指针逢零跳过逢非零写到 slow 位置等快指针走完再把 slow 到数组末尾的部分全部填成 0。本质上还是快指针负责探索慢指针负责维护有效区。最后提一下 LeetCode 第 80 题删除有序数组中的重复项 II它允许每个元素最多出现两次。这道题把判断条件从和 slow-1 比较改成了和 slow-2 比较。为什么因为在最多保留两个重复值的规则下新元素能否加入不取决于和前一个元素是否相同而是取决于它和有效序列的最后两个元素是否会产生三个重复值。所以判断条件变成了nums[fast] ! nums[slow - 2]其余逻辑完全一样。把第 26 题理解通透之后第 80 题就是两分钟的事。4.4 实际工程中的相似场景与经验我最初刷这道题的时候觉得它是典型的面试题跟实际工作关系不大。后来做数据处理才意识到这种原地压缩有序数据的需求在日志清洗、数据采集去重、缓存更新等场景下非常多见。比如你在处理一组按时间排序的用户行为日志每条日志有用户 ID 和行为类型按时间戳升序排列你需要在原始列表上原地去重只保留每个用户的第一条日志。闰年也好、并发也好处理逻辑本质上和 LeetCode 26 一模一样只是比较的字段从整个元素变成了某个字段的值。另外一个很实用的工程化经验是在 Python 中如果不需要原地操作直接使用dict.fromkeys(nums)去重并保序也很快捷但如果数据量是百万级且内存可用空间有限快慢双指针的原地方案可以显著降低内存峰值。很多数据管道都要求内存稳定而非速度极快这时候用双指针思路去改造代码价值就体现出来了。5. 代码演进、变体思路与进阶讨论5.1 从双指针到单指针的思维跃迁快慢双指针还有一个非常有趣的观察点一旦你想通了 slow 的含义你甚至可以理解为只有一根指针在工作。fast 就是for循环里的循环变量slow 则是有效区的边界。在很多代码简洁性至上的 Python 圈子里这种通过循环变量i直接引用数组的写法很常见。本质上我们不是真的维护了两根指针而是把数组自身的迭代和有效区的构建融合在了一起。你应该记住这种用一个变量维护状态用循环天然推进另一个变量的手法它在很多算法题里都能大幅简化代码。如果你想挑战自己可以尝试把这段代码改写成 Java、C 或 Go 语言语言不同但逻辑完全一致。我用 Java 写过一版除了List与数组的类型差异之外核心逻辑和 Python 几乎逐字对应。这种跨语言的迁移能力也是刷题过程中值得刻意训练的。5.2 通用压缩算法的思路启发第 26 题与数组原地压缩的概念息息相关。你可能听说过字符串压缩算法、RLE游程编码等它们做的事情本质上是把连续重复的信息转换成更紧凑的形态。快慢双指针虽然只是删除重复项但它体现的只保留必要信息丢弃冗余数据的思想和 RLE 的记录字符和次数有异曲同工之妙。如果你是在做嵌入式开发或网络协议解析内存空间极其有限这类原地压缩的手法会非常有用。不需要新建一个缓冲数组而是在原数组上通过双指针自由挪动数据这在内存受限的场景中是一项性价比极高的技能。5.3 当数组不再有序快慢双指针还成立吗这是进阶学习者经常提出的一个好问题。如果题目变成删除无序数组中的重复项快慢双指针还需要借助别的数据结构吗答案是如果要求保留相对顺序且原地操作快指针的探索方式不变但比较逻辑就失效了因为无序数组中相同元素不一定紧挨着nums[fast] ! nums[slow-1]的判定就会误判。此时如果仍然要求 O(n) 时间复杂度就必须借助哈希表记录已经出现过的元素。这样一来空间复杂度从 O(1) 变成了 O(k)k 为不同元素的数量本质上是用空间换时间。LeetCode 也有一道类似的无序去重问题考察的正是这种时间空间权衡的决策能力。我建议你将这道变体也亲手写一遍体会一下有序条件下空间可以做到 O(1)、无序条件下必须引入哈希表的差异这对理解双指针思想会有更深层的帮助。5.4 更高效的缓存友好性从计算机体系结构的角度看快慢双指针还有一个优势它能保证良好的缓存局部性。因为 fast 是顺序扫描数组的CPU 的 prefetch 机制可以高效预取后续元素slow 虽然可能在原地写覆盖但它的移动也是顺序的、向前的。相比随机访问和跳跃式索引这种顺序读写模式对现代 CPU 的缓存结构非常友好在数据规模极大时性能差异会更加明显。如果你的数组数据量达到千万级别这个算法几乎可以跑在内存带宽的上限。我在做大数据量的性能验证时实测过 1000 万元素的排序数组去重耗时大约在几十毫秒级别几乎没有性能瓶颈。这也是为什么我强烈推荐用这道题练手——不仅是因为面试会问更是因为它在真实场景中真的很实用。6. 面试答题策略与实战经验6.1 面试官想考察的三个核心能力如果你是在准备算法面试这道题的得分点不仅在于代码写对更在于你能不能讲清楚思路和权衡。面试官通常会从三个维度评估答案。第一是问题拆解能力。你需要明确指出数组是有序的、原地删除、空间复杂度为 O(1)这三条信息直接推导出快慢双指针方案。能否在 30 秒内提炼出有序这个最关键的性质决定了你的思路是否清晰。第二是边界条件意识。能否主动和面试官确认空数组怎么处理单元素数组怎么处理这些问题是区分刷题机器和真正理解代码质量的分水岭。我会在动笔之前快速列出这些边界条件并确认算法对它们都能安全处理。第三是优化意识。在你自己提出双指针方案后能否主动比较它在时间和空间上与 set 方案的优劣会被很多面试官当作加分项。你不一定要说得非常专业但至少要让面试官知道你明白 O(1) 空间在内存受限场景下的意义。6.2 如何在 5 分钟内把思路讲得清晰我建议你练成一个固定的讲题框架按题目归类、约束提取、方案提出、复杂度分析、代码实现、测试验证的顺序来讲。先说你认为这道题属于数组遍历 原地修改的范畴核心约束有两条原地操作和有序数组。然后自然提出有序环境下重复元素必然相邻因此可以用双指针中的快慢指针来实现。再介绍复杂度O(n) 时间、O(1) 空间最后强调一下边界条件的处理方式。实际面试中最好在白板上同步画一下指针运动的过程。写一个数组 [1, 1, 2]画两个箭头分别指向 slow 和 fast手动推演两步面试官立刻就明白你的思路了。切忌只给结论不给过程很多面试官更看重你的推演能力而不是最终的代码。6.3 踩过坑之后的调试技巧分享在实际刷题的过程中我有一次连续提交了三次都报 IndexError最后发现是数组长度为 1 时nums[slow]的索引越界了。排查这类问题最快的办法是在函数入口处加一个简单的if not nums: return 0但更根本的思路是调整 slow 的初始值。另一次我在写第 80 题的变体时因为把slow - 2写成了slow - 1导致所有元素被保留答案错误。这种错误用肉眼很难看出来但如果你打印每次循环的数组状态马上就会发现为什么没有一个重复元素被删掉。LeetCode 的 Playground 非常适合做这类单步调试建议你充分利用。还有个常见的经验Python 的切片操作在某些题解里会被用来直接去掉数组末尾但这会创建新的列表对象本质上还是 O(n) 空间的拷贝不符合本题的原意。如果你的代码里出现了nums[:slow]这样的赋值操作注意它只是生成一个新列表并不会真正修改原数组。这点务必想清楚不然会被面试官追问到哑口无言。6.4 题目常见的追问方向面试官在答完这道题之后经常紧跟两个追问。第一个追问是如果每个元素允许出现两次你怎么改这就是我之前提到的第 80 题判断条件从slow-1改成slow-2即可但你需要把为什么改成这个条件的逻辑讲清楚允许出现两次时能不能加入取决于当前值和有效序列倒数第二个元素是否相同而不是前一个。第二个追问是你能不能用别的解法跟我解释一下面对这种问题我一般先提到哈希表方案说明它可以支持无序数组去重但空间复杂度是 O(n)然后提到排序后去重但时间复杂度是 O(n log n)。最后再强调一下在当前题目的有序约束下双指针是时间空间最优解。这样你的回答既有层次感又显得你对各种权衡了然于胸。7. 我对这道题的最终经验总结回到最开始那句话LeetCode 第 26 题的价值不在于它有多难而在于它是一个极好的算法思维体操锚点。我从它身上学到的并不是快慢双指针这五个字而是三个更底层的思考方式。第一有序这个前提条件值得被无限放大。题目一旦给出有序数组很多原本复杂的问题去重、查找、合并都会变得简单。面试时遇到数组第一反应应该是它有序吗这个信息能用吗有序是白给的但很多人看不到导致选错算法。这道题就是一个把有序用到极致的经典例子。第二原地操作往往意味着要接受数据原地覆盖允许旧数据在数组尾部残留。很多从业务代码转型算法的人最初都不太适应这种看似邋遢的风格但这就是高性能系统里常见的空间节约策略。你只是在逻辑上不读尾部数据而不是真的去清零或删除它这种思维本身就是一种工程智慧。第三快慢双指针是一个母题它可以演化出移除元素、移动零、有序数组最多保留两个重复项等一整套高效方案。把母题吃透比盲目刷几十道题更有效率。我认识的一些竞赛选手都是靠一类题吃透一道代表题的方法用较少的题目覆盖大多数考点这就是高效备考的底层逻辑。最后再分享一个个人练习建议每当你学完一道新题试着去 LeetCode 搜索可以参考相同思路的题目把三四道相关题放在一起对比着做收获往往比单独刷三倍数量的题还要大。算法的精髓从来不是记住答案而是理解答案为什么长这样以及换一个条件之后它会怎么变形。
返回列表