ARTICLE DETAIL

资讯详情

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

LeetCode 26与27:一文学透快慢指针原地删除数组元素

LeetCode 26与27:一文学透快慢指针原地删除数组元素 LeetCode 第26题和第27题是我带新人练快慢指针时几乎固定要摆在一起的组合。不管你是从“力扣热题100”里看到它们还是在各种刷题攻略里被反复安利“双指针模板”这两道题大概率都会在某个晚上出现在你的提交记录里。名字都叫“删除”但真正考的其实不是删而是如何在原数组上完成一次不额外占空间的筛选。第26题要求原地删除有序数组里的重复项只保留每个数字第一次出现的位置第27题要求原地移除所有等于给定值 val 的元素。两道题都不允许用额外数组判题时会根据你返回的长度截断数组只检查前 k 个位置。也就是说真正让你写的是“整理逻辑”和“有效长度”而不是调用语言自带的 remove 接口。对刚入门双指针的朋友来说用这两道题练手再合适不过题干短、结论清晰、难度友好几乎一遍就能摸清快慢指针的骨架。适合谁呢刚开始刷数组题的初学者、准备面试想在双指针上多拿分的同学以及想快速复习“原地数组操作”这批模板题的选手都可以把这两题作为一次小专题来刷。把它们合在一起看比单独刷一道题划算得多——因为它们的代码结构相似但初始化和比较对象刚好有一点差异这一点差异恰恰是快慢指针最容易翻车的地方。1. 两道题为什么值得放在一起刷1.1 题干与约束对比这两道题放在同一个专题里特别合适因为它们长得实在太像了。项目第26题第27题输入有序数组 nums数组 nums 和指定值 val要求原地删除重复项每数保留一个原地移除所有等于 val 的元素返回值新长度新长度空间限制O(1)O(1)比较对象数组内部相邻的保留值固定的外部值 val第26题额外强调“有序数组”这是它能用最简写法处理的关键重复元素必然相邻所以只需要判断当前元素和上一个保留元素是否相同。第27题没有有序条件元素的排列顺序是任意的因此它不能依赖“相邻相同”这种性质只能老老实实拿每个元素和 val 比较。先理解平台是怎么判题的。LeetCode 的几个删除类题目都不是物理上把数组截短而是检查“返回长度范围内”的元素是否符合要求。比如第26题返回 5判题器只看 nums[0..4]第27题返回 4判题器只看 nums[0..3]。这也意味着你的代码不需要清理数组后半截的“垃圾数据”你只需要把该留的元素整理到数组最前面然后告诉判题器有效长度是多少。理解这一点很重要。很多人一开始会纠结“删除后数组长度到底变没变”从而去调用语言内置的删除方法结果要么空间超限要么逻辑绕进了死胡同。数组在底层就是一个连续内存块它的“删除”本质是覆盖写配合一个语义上的有效长度。想明白这个快慢指针的整个思路就顺了。1.2 为什么这道题就该用快慢指针如果只是要求“返回新长度”用额外列表把符合条件的元素收集起来再复制回去也能做到但空间是 O(n)不符合要求。如果硬要在原数组上暴力删除比如找到一个重复项就把后面所有元素整体前移时间复杂度会到 O(n^2)一旦数组长度上万跑起来就很痛苦。快慢指针的思路用一个生活类比就能说清你面前有一长串等候排队的人快指针像一个安检员从头到尾检查每个人要不要慢指针则站在队伍前面划出下一个“可以站人的位置”。被留下的乘客直接走到慢指针指的位置站好然后慢指针往后挪一格被丢弃的乘客直接跳过没人管他原本站在哪。这样一整条队伍只需要被安检员从头到尾看一遍就能完成筛选不需要反复拖动整条队伍。放到数组操作里快指针负责读原始数组的每一个元素慢指针负责确定“该写到哪里”。两者不会冲突因为快指针永远走在慢指针前面或者和慢指针停留在同一位置慢指针要写入的位置一定是快指针已经读过、可以安全覆盖的位置。这就是快慢指针能原地工作、一次遍历完成的原因。1.3 两题的微小差异就是核心考点快慢指针的代码模板看起来非常类似但有两处不能乱改初始化时慢指针从几开始比较时到底拿当前元素和谁做对比。第26题里数组是有序的第一个元素永远会保留下来因为它是数组中第一次出现的值。所以慢指针可以直接从 1 开始比较对象是“上一个已保留的元素”也就是 nums[slow-1]。第27题里数组无序第一个元素也可能恰好等于 val所以慢指针必须从 0 开始比较对象是固定的 val。这个差异很细小但特别能看出来一个人是不是真的理解了快慢指针。我见过不少同学把第26题的代码背下来直接套到第27题慢指针从 1 开始结果数组第一个元素等于 val 时根本没被处理白白丢了一个删除目标。这两道题放在一起写就是为了把这种“背模板”造成的坑一次踩明白。2. 第26题有序数组去重实战2.1 暴力思路到底慢在哪拿到第26题第一反应可能是这样从左往右扫如果发现当前元素和上一个元素相同就把后面的所有元素往前移动一位同时把数组的“有效长度”减一。比如数组 [1,1,1,2,3]在第二个 1 那里就要把 1,2,3 整体前移在第三个 1 那里又要前移一次数据被反复搬动效率很低。更麻烦的是移动完元素之后当前下标要不要回退不回退可能漏掉连续相同的元素回退又容易处理不干净。我曾经见过一个暴力实现为了处理 [1,1,1,1] 这种全重复用例在循环里加了大量边界判断最后代码又长又脆换一组测试用例就出问题。暴力法的本质问题是它在“删除元素”这件事上做了太多物理移动。可实际上我们需要的只是把不重复的元素按顺序摆到数组开头其他位置是什么根本不重要。既然目标变了算法也就有更优解了。2.2 快慢指针的逐步推导第26题的解法可以直接推导出来而不是靠记忆。首先因为数组有序第一个元素一定保留所以慢指针初始化为 1表示“下一个不重复元素要写入的位置”。快指针从 1 开始扫描一直走到数组末尾。每一步做什么比较 nums[fast] 和 nums[slow-1]。这里的 nums[slow-1] 表示当前已经整理好的无重复前缀区间的最后一个数值也就是“上一个被保留的数字”。如果它们相等说明 fast 指向的数字已经出现过了跳过fast 继续往前走。如果不相等说明 fast 找到了一个新数字把它写到 nums[slow]然后 slow 加一。举个例子假设输入是nums [0,0,1,1,1,2,2,3,3,4]我们来手动走一遍fast1nums[1]0nums[slow-1]nums[0]0相等跳过。fast2nums[2]1nums[0]0不相等nums[1]1slow 变为 2。fast3nums[3]1nums[slow-1]nums[1]1相等跳过。fast4nums[4]1nums[1]1相等跳过。fast5nums[5]2nums[1]1不相等nums[2]2slow 变为 3。fast6nums[6]2nums[2]2相等跳过。fast7nums[7]3nums[2]2不相等nums[3]3slow 变为 4。fast8nums[8]3nums[3]3相等跳过。fast9nums[9]4nums[3]3不相等nums[4]4slow 变为 5。最终返回 5。数组的前 5 位被改成了 [0,1,2,3,4]后面的脏数据不用管。整个过程只扫描了一遍数组里每个元素最多被读一次、写一次。这里有一个理解关键为什么比较对象是 nums[slow-1]而不是 nums[slow]因为 slow 指向的是“待写入位置”这个位置上的旧值可能是任意残留数据拿它做比较没有意义。真正代表结果区间状态的是 slow-1 位置上的值它是当前唯一区间的最后一个元素。很多第一次写这道题的人在这里栽跟头后面我会专门说这个问题。2.3 完整代码与边界检查第26题的 Python 参考实现如下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边界情况可以逐个确认空数组直接返回 0。数组只有一个元素for 循环不执行直接返回 1符合预期。数组元素全部相同比如 [5,5,5]fast 所有步都跳过slow 一直是 1返回 1。数组元素全部不同比如 [1,2,3]fast 每一步都发现新值slow 和 fast 同步前进返回 3数组也没发生变化。时间复杂度是 O(n)空间复杂度是 O(1)。这个实现还有一个好处因为有序数组的第一个元素必被保留代码里不需要额外的 if 特判逻辑非常干净。补充一点如果看到网上有另一种写法慢指针也从 0 开始那么通常会在循环里加一个if slow 0 or nums[fast] ! nums[slow - 1]的特判。这种写法也不是不行只是多一层判断。我个人更喜欢 slow 从 1 开始因为语义更直接——写代码前先想清楚“哪几个元素天生就该在结果里”。3. 第27题移除元素3.1 题目结构几乎一样但比较对象变了第27题和第26题放在一起几乎就是同一套模板的两种形态。唯一显眼的区别是第26题的“目标值”动态来自数组本身的上一个保留元素第27题的“目标值”在题目输入里写死了叫 val。因为目标值固定了所以循环逻辑简化成只要 nums[fast] 不等于 val就把它写到慢指针位置否则直接跳过。保留条件不再是“和上一个元素不同”而是“不等于给定的 val”。但有一个坑是初学者很容易踩的慢指针初始值。第26题可以写 slow1因为第一个元素必然保留第27题不行比如输入nums [3,2,2,3], val 3第一个元素 3 是要被删除的如果你还像第26题那样从 1 开始第一个 3 会在结果区间里赖着不走。所以第27题的慢指针必须从 0 开始表示结果区间的下一个写入位置同时也是当前有效长度。手动模拟一下这个例子fast0nums[0]3等于 val跳过slow 保持 0。fast1nums[1]2不等于 valnums[0]2slow 变为 1。fast2nums[2]2不等于 valnums[1]2slow 变为 2。fast3nums[3]3等于 val跳过。最终返回 2数组前两位是 [2,2]。可以看到第一个元素顺利被处理掉就是因为 slow 从 0 开始。3.2 标准实现与搬砖原则第27题的标准快慢指针版本非常短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有些面试场景会更喜欢 while 版本方便现场口头推导class Solution: def removeElement(self, nums: List[int], val: int) - int: slow 0 fast 0 while fast len(nums): if nums[fast] ! val: nums[slow] nums[fast] slow 1 fast 1 return slow所有等于 val 的元素都不是我们要的所以它们不会进入结果区间所有不等于 val 的元素都会被按顺序搬到前面。快指针负责“看”慢指针负责“留”这就是全部规则。为什么这样写不会破坏还没读到的数据因为 fast 永远不小于 slow。当 fast 等于 slow 时写入就是写回当前元素没影响当 fast 大于 slow 时写入的位置是已经扫描过的位置根本不会覆盖未来数据。这个性质是快慢指针能原地搬数据的根基。有人可能会问如果我不需要保持元素的相对顺序有没有更快的办法有的。可以用左右双指针把等于 val 的元素和数组末尾的元素交换然后把右边界左移。这种做法在某些场景下元素移动次数更少但会打乱原顺序。如果你后续还要根据原顺序处理数据就不要用它。第27题本身只要求返回长度不要求保持顺序所以两种都能过但快慢指针版是更通用的模板。3.3 复杂度与边界情况时间上快指针完整遍历一次数组慢指针最多也走固定长度总体时间复杂度 O(n)空间上全程只用了几个整型变量O(1)。几个典型边界val 不在数组里比如nums[1,2,3], val4fast 扫描时每个元素都满足保留条件slow 最终等于 3数组原样返回。数组里全是 val比如nums[7,7,7], val7fast 每个元素都跳过slow 一直是 0返回 0判题器看前 0 个位置自然为空。空数组for 循环不执行返回 0。val 紧挨着出现比如[1,1,2,1]val1快指针跳过多余的 1最后保留 [2]返回 1。这个过程不会因为连续删除而出错因为快指针只是不执行写入慢指针不会越界。这里再强调一下快慢指针版天然保持原数组的相对顺序。也就是说所有不等于 val 的元素它们在结果数组里的前后关系和它们在原数组里的关系一模一样。如果题目再往下问“移动零”“把奇数排在偶数前”这类变体这套思想可以直接复用。4. 常见问题与避坑实录4.1 返回值到底是长度还是下标很多人第一次写第27题时会困惑slow 最后到底是长度还是下一个写入位置事实上在 slow 从 0 开始的版本里它两个都是。slow 表示的是“已经写入的元素个数”每写入一个就加一所以最终值就是新数组长度。同时它也是下一个空位的下标两者恰好重合因为数组下标从 0 开始写入 count 个元素后下一空位下标就是 count。第26题 slow 从 1 开始为什么返回 slow 也是长度因为第一个元素天然占据结果区间的第 0 位slow 从 1 起步相当于已经提前计数了。之后每写一个新元素 slow 加一最终 slow 就是保留元素的总个数。这个位置关系如果面试时能主动讲清楚会显得你对数组语义很扎实。4.2 照搬模板导致越界的几种写法有一种错误的写法是在 26 题里用if nums[fast] ! nums[slow]做比较而不写nums[slow-1]。比如数组 [1,2,2]slow 初始为 1fast1 时 nums[1]2和 nums[slow] 也就是 nums[1] 比较发现“相等”于是判定为重复直接把元素 2 跳过了结果数组丢失了一个本应保留的元素。这里的问题就出在拿待写入位置的旧值当参照物而不是拿结果区间最后一个元素当参照物。还有一种情况是有人为了追求“原地删除”直接在 for 循环里调用 pop、remove 等操作。这样会导致数组长度变化进而让后续索引错位要么越界要么漏元素。快慢指针的价值恰恰在于不修改数组长度只覆盖值这样索引关系始终稳定。另一个容易出错的点是 while 循环里忘记在相等分支让 fast 前进。比如这样while fast len(nums): if nums[fast] ! val: nums[slow] nums[fast] slow 1看起来没什么问题但如果把 fast 递增的语句放在 if 外面且 while 条件检查的是 fast len(nums)只要某个元素等于 valfast 就会被跳过而继续循环导致死循环。用 for 循环可以天然避免这个问题所以我更推荐新手先用 for 版本。4.3 快慢指针和左右双指针到底怎么选刷题时经常会看到两种双指针快慢指针和左右对撞指针。它们容易混淆我列一个简单的区分表对比点快慢指针左右指针指针方向同向移动fast 在前相向移动left 从左right 从右典型场景原地筛选、去重、移动元素有序数组两数之和、反转、分区是否能保持顺序能通常不能典型题目26、27、283 移动零167 两数之和 II、344 反转字符串判断方法很简单如果题目要求“原地整理数组且尽量保持原相对顺序”优先想快慢指针如果题目只是让找某两个位置或允许随意交换元素位置可以考虑左右指针。第26题和第27题都属于前者。4.4 常见问题速查表症状可能原因修正方式数组第一个元素等于 val 时没被删除第27题慢指针初始化为 1第27题 slow 从 0 开始结果数组多一个重复元素26题比较对象写成了 nums[slow]改成 nums[slow-1]出现数组越界空数组没判断或 while 快指针没停止先判空或使用 for range死循环while 循环里 fast 没有前进使用 for 循环或在所有分支里保证 fast 递增结果顺序乱了使用了左右交换法并期望保序保序需求下换回快慢指针4.5 面试里怎么答才加分如果面试官让你现场写第27题别上来就直接甩代码。我会这样组织回答先说暴力思路“我可以从前往后扫遇到等于 val 的元素就把它后面的所有元素前移时间复杂度 O(n^2)不太好。”然后过渡到优化“实际上我们不需要物理删除只需要把不等于 val 的元素往前写。用一个慢指针记录写入位置一个快指针负责扫描原数组遇到不等于 val 的值就写进慢指针位置最后慢指针就是新长度。”接着写代码、跑一个用例最后补充边界情况。之所以要讲这个顺序是因为面试官想看的不是你会不会背这道题而是你能不能解释清楚为什么这样写是对的。快慢指针的核心说到底是读和写分离慢指针的位置代表“结果区的边界”这个定义在整段代码里保持一致思路就无懈可击。我自己刚开始刷题时这两道题也是先背下来的。后来把 26 和 27 放一起对着一看才发现 slow 初始值的差异里全是门道26题从 1 开始因为第一个元素天然保留27题从 0 开始因为第一个元素也可能被干掉26题比较的是“上一个保留值”27题比较的是外部 val。一句话总结我的个人体会快慢指针模板不难背但比背模板更重要的是搞清楚慢指针到底指向什么想清楚这个以后遇到变体题心里就有底。下一步建议直接继续刷 LeetCode 80题删除有序数组中的重复项 II和 283题移动零都是同一个套路的小变形。趁着手感还热连着刷三题快慢指针基本就牢牢长在脑子里了。
返回列表