拓冰建站拓冰建站
首页 / 资讯中心 / 正文

双指针算法精解:从有序数组去重到快慢指针与滑动窗口

如果你在刷算法题时经常被“双指针”这个技巧搞得晕头转向尤其是看到“快慢指针”、“左右指针”这些变种时总觉得它们像魔法一样指针跳来跳去最后就把问题解决了但自己动手写却总是逻辑混乱——那么这篇文章就是为你准备的。很多教程只告诉你双指针的代码模板却很少解释一个最核心的直觉为什么在正确的双指针解法中两个指针都只需要向前移动而永远不需要回头理解这一点你才能真正掌握双指针的精髓而不是死记硬背。本文将用一个经典的“有序数组去重”问题作为主线通过动画般的步骤拆解和代码实现带你彻底搞懂双指针的“不回头”特性并延伸到快慢指针、滑动窗口等场景让你在面试和实战中能一眼识别并优雅地解决这类问题。1. 双指针到底解决了什么问题在开始之前我们先明确双指针的战场。它最擅长处理的是线性数据结构数组、字符串、链表上的问题特别是那些需要在序列中寻找满足某种条件的元素、子数组或者进行原地修改的场景。想象一下这个场景你有一个已经排好序的数组[1, 1, 2, 2, 3, 4, 4, 5]需要你原地删除重复的元素使得每个元素只出现一次并返回新数组的长度。你不能使用额外的数组必须在原数组上操作。传统思路的困境一个朴素的想法是用两层循环。外层循环遍历每个元素内层循环检查后面是否有重复如果有就删除这涉及到数组元素的移动成本很高。这种做法的时间复杂度是 O(n²)在数据量大时效率极低。更重要的是它没有利用“数组有序”这个关键条件。双指针的破局点双指针的精妙之处在于它通过维护两个指针将原本可能需要嵌套循环或复杂逻辑的问题转化为了两个指针在序列上的一次协同遍历。一个指针负责“探索”快指针一个指针负责“建设”慢指针。因为序列的某种特性如有序性我们可以确信已经处理过的区域是“安全”的快指针探索到的新信息足以让慢指针做出决策而无需回头再看。这就是“不回头”的底气所在。接下来我们就以“有序数组去重”这个LeetCode经典题26. 删除有序数组中的重复项为例彻底拆解这个过程。2. 核心概念快慢指针与“不回头”的直觉在双指针的众多变体中快慢指针是最常见的一种。在这个去重问题里慢指针 (slow)指向下一个不重复元素应该放入的位置。它维护着“已处理好的、无重复部分”的边界。快指针 (fast)负责扫描整个数组寻找新的、可能与前面不同的元素。“不回头”的直觉论证为什么fast找到新元素后slow可以直接赋值而不用担心覆盖掉后面还没检查的元素为什么fast自己也不用回头去和更早的元素比较数组有序这是最重要的前提。因为数组是非递减的所以所有相同的数字必然紧挨在一起。slow之前是处理好的slow指针左侧的所有元素已经是去重后的结果且它们互不相同。fast的使命是发现“新群体”fast不断向前当它发现nums[fast] ! nums[fast - 1]时意味着它遇到了一个新的、与前一元素不同的值。由于有序性这个新值肯定也没有在slow之前出现过因为如果出现过它应该和它的同类在一起而fast刚刚才离开那个群体。决策无需历史信息对于slow来说它只需要知道fast当前指向的元素是否是一个“新出现的、不重复的元素”。这个判断完全由fast当前位置与其前一个位置的关系决定不需要查询slow之前的历史。因此两个指针都可以义无反顾地向前走。下面我们通过一步步的“算法动画”来可视化这个过程。3. 环境准备与问题定义我们使用 Python 语言进行演示因为它语法清晰易于理解。你只需要一个能运行 Python 的环境即可如本地Python解释器、Jupyter Notebook或任何在线编程环境。问题正式定义给你一个升序排列的数组nums请你原地删除重复出现的元素使每个元素只出现一次返回删除后数组的新长度。不要使用额外的数组空间你必须在原地修改输入数组 并在使用 O(1) 额外空间的条件下完成。函数签名def removeDuplicates(nums): :type nums: List[int] :rtype: int 我们的目标不仅是实现它更要理解每一步指针移动背后的逻辑。4. 算法流程拆解动画步骤详解让我们假设输入数组为nums [0, 0, 1, 1, 1, 2, 2, 3, 3, 4]。初始状态slow 0fast 1。slow指向第一个位置我们认为第一个元素0已经是新数组的一部分。数组状态[0, 0, 1, 1, 1, 2, 2, 3, 3, 4] ^标注slow*标注fast下同s- 0f- 0步骤1fast 1nums[fast] (0)等于nums[fast - 1] (0)说明是重复元素。fast向前一步寻找新元素。slow不动。状态[0, 0, 1, 1, 1, 2, 2, 3, 3, 4]s- 0f- 0步骤2fast 2nums[fast] (1)不等于nums[fast - 1] (0)发现新元素1。由于slow的下一个位置slow 1应该放这个新元素。所以执行slow 1然后将nums[slow]赋值为nums[fast](即1)。关键理解slow从0变为1。nums[1]原本是重复的0现在被覆盖为1。这安全吗安全因为slow的新位置(1)就是用来存放下一个不重复元素的而原本的nums[1]是多余的0覆盖掉它正是我们“删除”重复项的方式。fast指针已经探索过这个区域我们知道它是重复的。状态[0, 1, 1, 1, 1, 2, 2, 3, 3, 4]注意索引1的值变成了1s- 1f- 1步骤3fast 3nums[fast] (1)等于nums[fast - 1] (1)重复元素。fast向前slow不动。状态[0, 1, 1, 1, 1, 2, 2, 3, 3, 4]s- 1f- 1步骤4fast 4nums[fast] (1)等于nums[fast - 1] (1)重复元素。fast向前slow不动。状态[0, 1, 1, 1, 1, 2, 2, 3, 3, 4]s- 1f- 1步骤5fast 5nums[fast] (2)不等于nums[fast - 1] (1)发现新元素2。slow 1(变为2)nums[slow] nums[fast](即2)。状态[0, 1, 2, 1, 1, 2, 2, 3, 3, 4]索引2变为2s- 2f- 2后续步骤依此类推...最终状态fast遍历完整个数组。slow最终停在了索引 4 的位置。这意味着新数组的有效长度是slow 1 5。数组的前5个元素是[0, 1, 2, 3, 4]后面的元素[2, 2, 3, 3, 4]是什么我们不再关心。最终数组视图[0, 1, 2, 3, 4, 2, 2, 3, 3, 4]s- 4 (最后一个有效元素)整个过程中slow和fast都只从数组开头走到了结尾没有后退过一步。5. 完整代码实现与逐行解析理解了动画步骤代码就非常直观了。from typing import List def removeDuplicates(nums: List[int]) - int: 删除有序数组中的重复项每个元素只保留一个。 返回新数组的长度。 # 边界条件处理如果数组为空直接返回0 if not nums: return 0 # 初始化慢指针 slow从第一个元素开始索引0 # 我们认为第一个元素已经在新数组中了 slow 0 # 快指针 fast 从第二个元素开始索引1遍历整个数组 for fast in range(1, len(nums)): # 核心判断如果 fast 指向的元素不等于 slow 指向的元素 # 注意这里判断 nums[fast] ! nums[slow] 是等价的 # 因为 slow 始终指向当前已去重部分的最后一个元素 if nums[fast] ! nums[slow]: # 发现一个新的不重复元素 # 1. 慢指针向前移动一位指向下一个待填充的位置 slow 1 # 2. 将新元素赋值到 slow 的新位置 nums[slow] nums[fast] # 如果相等fast 继续前进slow 不动相当于跳过了重复项 # 循环会继续直到 fast 找到下一个不同的元素 # 新数组的长度是 slow 的索引 1 return slow 1 # 测试代码 if __name__ __main__: # 测试用例1标准情况 nums1 [0, 0, 1, 1, 1, 2, 2, 3, 3, 4] print(f原始数组: {nums1}) new_length1 removeDuplicates(nums1) print(f去重后长度: {new_length1}) print(f前{new_length1}个元素: {nums1[:new_length1]}) print(- * 30) # 测试用例2无重复 nums2 [1, 2, 3] print(f原始数组: {nums2}) new_length2 removeDuplicates(nums2) print(f去重后长度: {new_length2}) print(f前{new_length2}个元素: {nums2[:new_length2]}) print(- * 30) # 测试用例3全重复 nums3 [7, 7, 7, 7] print(f原始数组: {nums3}) new_length3 removeDuplicates(nums3) print(f去重后长度: {new_length3}) print(f前{new_length3}个元素: {nums3[:new_length3]}) print(- * 30) # 测试用例4空数组 nums4 [] print(f原始数组: {nums4}) new_length4 removeDuplicates(nums4) print(f去重后长度: {new_length4})代码关键点解析边界处理if not nums:处理了输入为空数组的情况这是良好的编程习惯。初始化slow 0是基础因为第一个元素无论如何都会保留。循环条件for fast in range(1, len(nums)):让fast从1遍历到末尾。这里用for循环比while更简洁。核心逻辑if nums[fast] ! nums[slow]:这是判断是否遇到“新”元素的灵魂。为什么是和nums[slow]比因为slow指向的是当前已构建的无重复数组的最后一个元素。如果fast指向的值和它不同那一定是一个全新的值得益于有序性。赋值操作nums[slow] nums[fast]完成了原地修改。它覆盖了slow位置后面原本重复的元素。返回值slow是索引长度需要加1。6. 运行结果与效果验证运行上面的测试代码你会得到如下输出原始数组: [0, 0, 1, 1, 1, 2, 2, 3, 3, 4] 去重后长度: 5 前5个元素: [0, 1, 2, 3, 4] ------------------------------ 原始数组: [1, 2, 3] 去重后长度: 3 前3个元素: [1, 2, 3] ------------------------------ 原始数组: [7, 7, 7, 7] 去重后长度: 1 前1个元素: [7] ------------------------------ 原始数组: [] 去重后长度: 0 ------------------------------如何验证算法正确性功能正确检查返回的长度是否正确以及原数组前length个元素是否已去重且保持原序。原地修改检查函数是否没有返回新数组而是直接修改了输入的nums。你可以打印修改前后的id(nums)会发现是同一个对象。时间复杂度算法只进行了一次线性遍历fast指针走了 n-1 步时间复杂度是O(n)。空间复杂度只使用了slow和fast两个额外变量空间复杂度是O(1)符合题目要求。7. 常见问题与排查思路在实现双指针时新手常会遇到以下几个问题问题现象可能原因排查方式解决方案返回长度正确但数组前几位元素不对指针初始值或比较逻辑错误。例如slow初始化为1或比较nums[fast]和nums[fast-1]时逻辑写反。用一个小数组如[1,1,2]单步调试打印每一步slow,fast和数组状态。确认slow初始为0确认核心判断条件是nums[fast] ! nums[slow]。处理空数组或单元素数组时报错或结果错误没有处理边界条件。在函数开头添加对空数组的判断 (if not nums: return 0)。同时确认循环从fast1开始对于单元素数组循环不会执行直接返回slow11。务必在函数开始处检查输入是否为空。算法在无序数组上运行结果错误双指针快慢指针解法严重依赖数组的有序性。检查题目要求或输入条件。如果数组无序此解法失效。对于无序数组去重应考虑使用哈希集合 (set) 来记录已出现元素但那就不是 O(1) 空间了。感觉理解了但遇到变种题如删除重复项II允许最多重复两次就无从下手没有理解slow指针所维护的“已处理区间”的定义发生了改变。重新定义slow的含义。在本题中slow指向“已去重每个元素唯一数组的末尾”。在变种题中slow可能指向“已处理满足新规则数组的末尾”。将问题抽象化slow维护一个“满足题目要求的、处理好的数组”的边界。fast去探索判断当前元素是否能加入这个“好数组”。判断条件随题目要求变化。8. 双指针的变种与最佳实践掌握了基础模型我们来看看双指针的其他经典应用理解其“不回头”的特性如何在不同场景下体现。8.1 左右指针对撞指针典型问题两数之和 II输入有序数组、反转字符串、验证回文串。核心思想一个指针left从最左开始一个指针right从最右开始向中间移动。它们根据当前指针对应的值来决定移动哪个指针。“不回头”的体现由于数组有序当nums[left] nums[right] target时说明和太大了应该减小。而减小和的最有效方式是让right左移因为right指向当前最大的可用值。right左移后left完全不需要回头因为left左边的数更小与新的right相加只会更小或等于当前和不可能再等于target。决策只依赖于当前两个指针的信息。8.2 滑动窗口快慢指针的拓展典型问题长度最小的子数组、无重复字符的最长子串。核心思想维护一个窗口[left, right]right主动向右扩张left在条件不满足时被动向右收缩。“不回头”的体现当left向右移动时窗口缩小。它为什么不需要回头因为left的移动是由于当前窗口不满足条件如和太小、包含重复字符。left右移是为了尝试让窗口重新满足条件。被left移出窗口的元素在后续right继续扩张时也绝无可能再被需要因为问题通常是求“最小”或“最长”历史更差的解没有保留价值。8.3 双指针用于链表典型问题判断链表是否有环、寻找链表中间节点、寻找链表倒数第K个节点。核心思想快指针每次走两步慢指针每次走一步。“不回头”的体现在链表中指针物理上就无法回头单链表。但逻辑上快指针遍历过的节点慢指针都会在之后遍历到快指针为慢指针“探路”利用了相对速度差来解决问题同样无需回溯。最佳实践总结先画图再编码在纸上画出数组和两个指针的移动轨迹是理解双指针最有效的方法。明确指针定义在动笔前必须清楚每一个指针代表什么含义如slow指向已处理区的末尾left指向窗口左边界。寻找“单调性”双指针高效工作的核心往往依赖于序列的某种“单调性”比如有序、和/积的单调变化、字符出现位置的单调性等。这是“不回头”的数学基础。处理边界总是考虑输入为空、单元素、全相同等边界情况。复杂度分析双指针算法通常能将 O(n²) 的暴力解优化到 O(n)因为它避免了嵌套循环。分析时要确认每个元素是否只被访问了常数次。9. 总结与进阶思考回到我们最初的问题为什么双指针不用回头通过“有序数组去重”的深度剖析我们可以给出一个本质的回答因为问题本身具有的“有序”或“单调”特性保证了当前指针所在位置的信息已经足够做出正确的、面向未来的决策过去的信息不会再影响未来的结果。这种“无后效性”是动态规划的思想在双指针这里以一种更轻量的形式呈现。fast探索到的信息对于slow的决策是充分的slow做出的修改也不会影响fast后续探索的正确性。要真正掌握双指针建议按以下路径练习基础LeetCode 26本文、27移除元素、283移动零。左右指针167两数之和 II、344反转字符串、125验证回文串。滑动窗口209长度最小的子数组、3无重复字符的最长子串、76最小覆盖子串-困难。链表双指针141环形链表、142环形链表 II、876链表的中间结点。下次当你再遇到数组、字符串或链表相关的问题时不妨先问问自己这个问题里的序列有没有某种“单调”的性质我能不能用两个指针一个探索、一个建设在一次遍历中解决它养成这个思维习惯你会发现很多难题的解法都变得清晰起来。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门