LeetCode双指针技巧:原地删除有序数组重复项

LeetCode双指针技巧:原地删除有序数组重复项

1. 题目解析与核心思路

这道LeetCode经典题目要求我们在原地修改有序数组,删除重复出现的元素,并返回新数组的长度。题目看似简单,但考察了以下几个关键点:

  1. 数组操作的基本功
  2. 双指针技巧的灵活运用
  3. 边界条件的处理能力

1.1 题目具体要求

给定一个升序排列的数组nums,我们需要:

  • 原地删除重复出现的元素
  • 使每个元素只出现一次
  • 返回删除后数组的新长度
  • 必须使用O(1)的额外空间

注意:题目要求原地修改数组,这意味着不能使用额外的数组来存储结果,这也是这道题的难点所在。

1.2 双指针解法原理

双指针法是解决这类数组操作问题的利器。具体思路是:

  • 使用一个慢指针(slow)指向当前不重复序列的末尾
  • 使用一个快指针(fast)遍历整个数组
  • 当发现nums[fast] ≠ nums[slow]时,将nums[fast]复制到nums[slow+1]
  • 最后返回slow+1即为新数组长度

这种方法的精妙之处在于:

  • 时间复杂度O(n),只需遍历一次数组
  • 空间复杂度O(1),没有使用额外空间
  • 保持了数组元素的原始顺序

2. 详细实现与代码解析

2.1 C++实现版本

int removeDuplicates(vector<int>& nums) { if(nums.empty()) return 0; int slow = 0; for(int fast = 1; fast < nums.size(); ++fast) { if(nums[fast] != nums[slow]) { nums[++slow] = nums[fast]; } } return slow + 1; }

关键点解析:

  1. 首先处理空数组的特殊情况
  2. slow初始化为0,fast从1开始遍历
  3. 当发现不相等元素时,slow先自增再赋值
  4. 最终返回slow+1是因为数组索引从0开始

2.2 Python实现版本

def removeDuplicates(nums): 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

Python版本与C++逻辑完全一致,只是语法上的差异。注意Python中列表是可变的,可以直接修改。

2.3 边界条件处理

在实际编码中,有几个边界条件需要特别注意:

  1. 空数组输入:直接返回0
  2. 单元素数组:无需处理,直接返回1
  3. 所有元素相同:只需要保留第一个元素
  4. 大数组测试:确保算法效率

3. 复杂度分析与优化

3.1 时间复杂度分析

  • 最佳情况:O(1)(空数组或单元素数组)
  • 最坏情况:O(n)(需要完整遍历数组)
  • 平均情况:O(n)

由于我们只遍历数组一次,没有嵌套循环,时间复杂度是线性的。

3.2 空间复杂度分析

  • 额外空间使用:O(1)
  • 只使用了常数个额外变量(slow和fast)

这完全符合题目要求的原地修改条件。

3.3 可能的优化方向

虽然标准解法已经很高效,但仍有微优化空间:

  1. 提前终止:当fast到达数组末尾时可以提前结束循环
  2. 减少赋值操作:当fast - slow > 1时才进行赋值
  3. 使用while循环:某些情况下可能比for循环更高效

不过这些优化带来的性能提升通常很小,在LeetCode评测系统中可能看不出明显差异。

4. 常见错误与调试技巧

4.1 新手常见错误

  1. 忘记处理空数组情况
  2. slow指针初始化为1(应该是0)
  3. 返回slow而不是slow+1
  4. 使用额外数组存储结果(违反题目要求)
  5. 比较nums[fast]和nums[fast-1](逻辑错误)

4.2 调试技巧

当你的代码不能通过测试用例时:

  1. 打印指针位置和数组状态:
print(f"fast={fast}, slow={slow}, nums={nums}")
  1. 使用小型测试用例手动模拟:
  • 输入:[1,1,2]
  • 预期输出:2(数组变为[1,2,_])
  1. 检查边界条件:
  • 空数组[]
  • 单元素数组[1]
  • 全相同数组[1,1,1]

4.3 单元测试用例推荐

完善的测试用例应该包含:

test_cases = [ ([], 0), ([1], 1), ([1,1,2], 2), ([0,0,1,1,1,2,2,3,3,4], 5), ([1,1,1,1,1], 1), ([1,2,3,4,5], 5) ]

5. 双指针技巧的扩展应用

这道题展示的双指针技巧可以应用于许多类似场景:

5.1 类似题目推荐

  1. LeetCode 27. 移除元素
  2. LeetCode 80. 删除有序数组中的重复项 II
  3. LeetCode 283. 移动零
  4. LeetCode 844. 比较含退格的字符串

5.2 双指针模式总结

双指针主要有以下几种使用模式:

  1. 前后指针:一个从头部开始,一个从尾部开始
  2. 快慢指针:以不同速度遍历
  3. 滑动窗口:维护一个满足条件的窗口

本题属于快慢指针的典型应用,掌握这种模式可以解决一大类数组操作问题。

5.3 实际工程应用

虽然这是一道算法题,但类似的思路在实际工程中也有应用:

  1. 日志去重处理
  2. 数据库记录清理
  3. 大数据集的流式处理
  4. 内存优化场景下的数据整理

6. 不同语言实现的注意事项

6.1 Java实现

public int removeDuplicates(int[] nums) { if(nums.length == 0) return 0; int slow = 0; for(int fast = 1; fast < nums.length; fast++) { if(nums[fast] != nums[slow]) { nums[++slow] = nums[fast]; } } return slow + 1; }

Java注意事项:

  • 数组长度使用nums.length
  • 注意数组越界问题
  • 方法签名要正确

6.2 JavaScript实现

function removeDuplicates(nums) { if(nums.length === 0) return 0; let slow = 0; for(let fast = 1; fast < nums.length; fast++) { if(nums[fast] !== nums[slow]) { nums[++slow] = nums[fast]; } } return slow + 1; }

JS注意事项:

  • 使用严格相等运算符!==
  • 变量声明使用let/const
  • 数组是对象,可以修改

6.3 Go实现

func removeDuplicates(nums []int) int { if len(nums) == 0 { return 0 } slow := 0 for fast := 1; fast < len(nums); fast++ { if nums[fast] != nums[slow] { slow++ nums[slow] = nums[fast] } } return slow + 1 }

Go注意事项:

  • 切片是引用类型
  • 使用len()获取长度
  • 语法简洁,没有++slow这种写法

7. 算法可视化与理解

为了更好理解双指针的工作方式,我们可以用以下例子演示:

初始数组:[0,0,1,1,1,2,2,3,3,4]

执行过程:

  1. slow=0, fast=1: 0=0 → 不操作
  2. slow=0, fast=2: 0≠1 → nums[1]=1 → [0,1,1,1,1,2,2,3,3,4]
  3. slow=1, fast=3: 1=1 → 不操作
  4. slow=1, fast=4: 1=1 → 不操作
  5. slow=1, fast=5: 1≠2 → nums[2]=2 → [0,1,2,1,1,2,2,3,3,4]
  6. slow=2, fast=6: 2=2 → 不操作
  7. slow=2, fast=7: 2≠3 → nums[3]=3 → [0,1,2,3,1,2,2,3,3,4]
  8. slow=3, fast=8: 3=3 → 不操作
  9. slow=3, fast=9: 3≠4 → nums[4]=4 → [0,1,2,3,4,2,2,3,3,4]

最终返回slow+1=5,前5个元素[0,1,2,3,4]就是去重后的结果。

8. 进阶思考与扩展

8.1 如果数组未排序怎么办?

对于无序数组,去重需要不同的方法:

  1. 先排序再使用双指针(O(nlogn)时间)
  2. 使用哈希表记录已出现元素(O(n)时间但需要额外空间)

8.2 允许最多保留k个重复项

这是LeetCode 80题的变种,解法思路类似:

  • 比较nums[fast]和nums[slow-k]
  • 当不相等时才进行赋值操作

8.3 并行化处理的可能性

对于超大数组,可以考虑:

  1. 分段处理
  2. 多线程/多进程并行
  3. MapReduce模式

不过这些方法通常需要额外空间,不符合本题要求。

9. 面试中的考察点

这道题在面试中经常出现,面试官可能关注:

  1. 能否正确理解题目要求(特别是原地修改)
  2. 双指针思路的清晰表达
  3. 边界条件的处理
  4. 代码的简洁性和可读性
  5. 时间/空间复杂度的分析能力

建议在面试中:

  • 先确认理解题意
  • 举例说明思路
  • 写出代码后主动检查边界条件
  • 讨论可能的优化

10. 个人实战经验分享

在实际刷题过程中,我发现以下几点特别重要:

  1. 初始条件设置:slow从0开始还是1开始容易混淆
  2. 赋值时机:是先++slow还是先赋值要清楚
  3. 返回值:记住数组长度是索引+1
  4. 测试用例:一定要测试全相同和全不同的极端情况

一个容易忽略的细节是,当数组已经无重复时,我们的算法仍然会进行不必要的赋值操作。虽然不影响正确性,但在性能敏感的场景可能需要优化。