1. 问题理解与双指针算法解析
这道题目要求我们对一个已经排序的数组进行原地去重操作,并返回去重后的数组长度。关键在于"原地"二字,意味着我们不能使用额外的存储空间,必须在原数组上直接修改。
题目给出的数组是非严格递增排列的,这意味着数组中可能有重复元素,但整体趋势是递增的。例如[1,1,2,3,3,4]这样的数组就是非严格递增的。
1.1 为什么选择双指针解法
双指针算法是处理数组和链表问题的经典技巧,特别适合需要原地修改的场景。在这个问题中,我们可以使用两个指针:
- 慢指针(slow):指向当前唯一元素的最后一个位置
- 快指针(fast):用于遍历整个数组
这种解法的优势在于:
- 时间复杂度O(n),只需遍历一次数组
- 空间复杂度O(1),不需要额外存储空间
- 保持了元素的原始顺序
提示:在处理有序数组的去重问题时,双指针几乎是标准解法,因为有序性保证了重复元素必然是相邻的。
2. 算法实现细节与代码解析
2.1 完整实现代码
让我们先看完整的Java实现代码,然后再逐步解析:
java复制class Solution {
public int removeDuplicates(int[] nums) {
if (nums.length == 0) return 0;
int slow = 0;
for (int fast = 1; fast < nums.length; fast++) {
if (nums[slow] != nums[fast]) {
slow++;
nums[slow] = nums[fast];
}
}
return slow + 1;
}
}
2.2 代码逐行解析
-
边界条件处理:
java复制if (nums.length == 0) return 0;空数组直接返回0,这是必要的边界条件检查。
-
初始化指针:
java复制int slow = 0;慢指针初始化为0,指向第一个元素(它肯定是唯一的)。
-
快指针遍历:
java复制for (int fast = 1; fast < nums.length; fast++)快指针从第二个元素开始遍历整个数组。
-
发现新元素时的处理:
java复制if (nums[slow] != nums[fast]) { slow++; nums[slow] = nums[fast]; }当快指针指向的元素与慢指针不同时,说明发现了一个新元素。此时:
- 慢指针前移
- 将新元素复制到慢指针位置
-
返回结果:
java复制return slow + 1;因为slow是索引,长度需要+1。
2.3 算法执行过程示例
以nums = [0,0,1,1,1,2,2,3,3,4]为例:
| 步骤 | slow | fast | nums[fast] | 操作后数组状态 |
|---|---|---|---|---|
| 初始 | 0 | 1 | 0 | 无变化 |
| 1 | 0 | 2 | 1 | [0,1,1,1,1,2,2,3,3,4] |
| 2 | 1 | 3 | 1 | 无变化 |
| 3 | 1 | 4 | 1 | 无变化 |
| 4 | 1 | 5 | 2 | [0,1,2,1,1,2,2,3,3,4] |
| 5 | 2 | 6 | 2 | 无变化 |
| 6 | 2 | 7 | 3 | [0,1,2,3,1,2,2,3,3,4] |
| 7 | 3 | 8 | 3 | 无变化 |
| 8 | 3 | 9 | 4 | [0,1,2,3,4,2,2,3,3,4] |
最终返回slow+1=5,前5个元素[0,1,2,3,4]就是去重后的结果。
3. 算法优化与变种思考
3.1 代码优化版本
我们可以将代码写得更加简洁:
java复制class Solution {
public int removeDuplicates(int[] nums) {
int slow = nums.length > 0 ? 1 : 0;
for (int n : nums)
if (n != nums[slow-1])
nums[slow++] = n;
return slow;
}
}
这个版本:
- 使用增强for循环简化遍历
- 合并了slow++和赋值的操作
- 更简洁但可读性稍差
3.2 处理严格递增数组
如果题目保证数组是严格递增的(无重复),我们可以进一步优化:
java复制class Solution {
public int removeDuplicates(int[] nums) {
if (nums.length <= 1) return nums.length;
int slow = 1;
for (int fast = 1; fast < nums.length; fast++) {
if (nums[fast] > nums[slow-1]) {
nums[slow++] = nums[fast];
}
}
return slow;
}
}
3.3 允许最多k个重复元素
这是一个常见的变种问题,比如允许每个元素最多出现2次:
java复制class Solution {
public int removeDuplicates(int[] nums) {
if (nums.length <= 2) return nums.length;
int slow = 2;
for (int fast = 2; fast < nums.length; fast++) {
if (nums[fast] != nums[slow-2]) {
nums[slow++] = nums[fast];
}
}
return slow;
}
}
这个解法的思路是:比较当前元素与slow-2位置的元素,不同时才保留。
4. 常见错误与调试技巧
4.1 常见错误类型
-
边界条件处理不当:
- 忘记处理空数组的情况
- 对于单元素数组返回错误值
-
指针移动逻辑错误:
- 在发现新元素时忘记移动慢指针
- 移动慢指针后忘记赋值
-
返回值计算错误:
- 直接返回slow而忘记+1
- 在循环结束后错误地计算长度
4.2 调试技巧
-
打印中间状态:
在循环中添加打印语句,观察指针移动和数组变化:java复制System.out.println("slow=" + slow + ", fast=" + fast + ", nums=" + Arrays.toString(nums)); -
使用可视化工具:
使用算法可视化工具(如LeetCode的playground)逐步执行代码。 -
测试用例设计:
- 空数组 []
- 单元素数组 [1]
- 全相同数组 [1,1,1]
- 无重复数组 [1,2,3]
- 常规测试用例 [0,0,1,1,1,2,2,3,3,4]
4.3 性能分析
- 时间复杂度:O(n),只需一次遍历
- 空间复杂度:O(1),原地修改
- 在实际面试中,能够分析出这两个复杂度指标是基本要求
5. 实际应用场景与扩展
5.1 实际应用场景
这种去重算法在实际开发中有广泛应用:
- 数据库查询结果去重
- 日志数据分析时去除重复条目
- 大数据处理中的预处理阶段
- 图像处理中去除连续相同的像素点
5.2 类似题目推荐
- 移除元素(LeetCode 27)
- 移动零(LeetCode 283)
- 删除排序链表中的重复元素(LeetCode 83)
- 删除排序链表中的重复元素 II(LeetCode 82)
5.3 多语言实现
Python实现:
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
C++实现:
cpp复制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;
}
6. 个人经验分享
在实际编码和面试中,我有几点心得体会:
-
先考虑边界条件:空数组、单元素数组等特殊情况要先处理,这能避免很多错误。
-
画图辅助理解:在纸上画出指针移动的过程,能帮助理清思路。就像题目中的图示那样,可视化是理解双指针算法的好方法。
-
测试驱动开发:先写出测试用例,再编写代码,最后验证。这种方法能确保代码的正确性。
-
命名要有意义:使用slow/fast而不是i/j这样的泛泛之名,能提高代码可读性。
-
考虑变种问题:掌握基础解法后,思考如何解决变种问题(如允许最多k个重复),这能深化对算法的理解。
这道题看似简单,但它很好地展示了如何利用数组的有序性来优化算法。双指针技巧是面试中的常客,掌握它能帮助你解决一大类数组和链表问题。