一、引言
在算法题"删除有序数组中的重复项"中,一个非常优雅的解法是使用"快慢指针"。仅需一次遍历,就能在原地去除重复元素并返回新数组的长度。本文将围绕一段经典的快慢指针代码,深入剖析其背后的原理,同时总结涉及的知识点------包括双指针思想、有序数组特性、原地修改、返回新长度等,并在此基础上拓展到更一般的数组操作技巧。
二、原代码与核心思路
class Solution {
public int removeDuplicates(int[] nums) {
int n = nums.length; // 数组长度
int fast = 1, slow = 1; // 快慢指针都从1开始
while (fast < n) {
// 当快指针发现一个不同于前一个元素的"新"值时
if (nums[fast] != nums[fast - 1]) {
nums[slow] = nums[fast]; // 把这个值写到慢指针的位置
slow++; // 慢指针前进,指向下一个待写入位置
}
fast++; // 快指针一直前进,扫描整个数组
}
return slow; // 慢指针的值恰好是不重复元素的个数
}
}
核心逻辑 :利用"有序数组"中重复元素必然相邻的性质,用 fast 指针遍历数组,用 slow 指针记录下一个不重复元素应该存放的位置。当 fast 发现一个与上一个值不同的元素时,就把它复制到 slow 处,然后 slow 后移。最终 slow 就是去重后的数组长度。
三、知识点详细拆解
3.1 有序数组与去重策略
| 知识点 | 说明 |
|---|---|
| 有序数组的特性 | 所有相等的元素一定连续排列,不存在前后分散的相同值 |
| 如何去重 | 只保留每个连续相同段的第一个元素 |
| 条件判断 | nums[fast] != nums[fast-1] 判断是否遇到了"新值" |
| 保留第一个 | 快指针从 1 开始,slow 也从 1 开始,隐式保留了 nums[0](第一个元素) |
代码实例与注释:
// 一般的暴力去重:需要额外空间
int[] arr = {1, 1, 2};
List<Integer> uniqueList = new ArrayList<>();
for (int num : arr) {
if (uniqueList.isEmpty() || uniqueList.get(uniqueList.size() - 1) != num) {
uniqueList.add(num); // 遇到新值才加
}
}
// 但这需要额外空间 O(n),不是原地算法
拓展:如果不是有序数组,需要先排序或使用哈希表记录出现过的元素。
3.2 双指针(快慢指针)技术
双指针是一种常用于数组/链表线性扫描的通用技巧。这里的"快慢指针"特指两个指针从同侧出发,一快一慢,不同职责。
| 知识点 | 说明 |
|---|---|
| fast 指针 | 遍历者,扫描每一个元素,不停向前 |
| slow 指针 | 写入者,指向下一个"合格元素"应当放置的位置 |
| 循环条件 | fast < n,当快指针越界时遍历结束 |
| 指针初始值 | 均从 1 开始,跳过首个元素(首个元素必保留) |
| 指针移动规则 | fast 每次都增加;slow 仅在写入新值后增加 |
| 时间复杂度 | O(n),每个元素被 fast 访问一次 |
| 空间复杂度 | O(1),只使用了两个额外变量 |
变体示例(保留最多 k 个重复项):
public int removeDuplicates(int[] nums, int k) {
// 保留每个数字最多出现 k 次
int slow = 0;
for (int fast = 0; fast < nums.length; fast++) {
// 前 k 个直接放,或者当前元素不等于 slow - k 位置的元素时写入
if (slow < k || nums[fast] != nums[slow - k]) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
// 当 k=1 时,就是普通的去重(每个元素只保留一次)
3.3 原地修改数组(In-place Modification)
原地修改要求不使用额外的大数组,直接修改输入数组并返回部分有效区域。
| 知识点 | 说明 |
|---|---|
| 定义 | 直接操作输入数组的内存空间,O(1) 额外空间 |
| 优点 | 节约内存,符合很多算法题要求 |
| 返回新长度 | 调用者根据返回的 slow 读取前 slow 个元素即为有效数据 |
| 注意事项 | 数组本身会被改变,调用者需理解这种设计 |
对比:需要额外空间的做法
// 非原地:使用新数组
int[] newArr = new int[nums.length];
int idx = 1;
newArr[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
if (nums[i] != nums[i - 1]) {
newArr[idx++] = nums[i];
}
}
// newArr 前 idx 项是去重结果,但空间 O(n)
3.4 返回新长度的设计模式
在 LeetCode 等平台,原地修改数组的题目通常要求返回"新长度",以便后台校验。
| 知识点 | 说明 |
|---|---|
| 作用 | 告诉调用方有效数据范围是 [0, returnedLength - 1] |
| 典型返回值 | slow 指针的最终值 |
| 计算方式 | slow 从 1 开始,每遇到不重复元素就 +1,因此正好是不重复元素个数 |
| 边界情况 | 空数组时,n=0,fast=1 不满足 fast < n,直接返回 slow=1?实际上有 bug,见下文 |
⚠️ 原代码的隐藏 bug:空数组情况
如果 nums 长度为 0,则 n = 0,fast = 1, slow = 1,while(fast < 0) 不执行,返回 1,这显然是错误的(应该返回 0)。原代码缺少对空数组的特判。这在题目中通常不会出现,但作为健壮代码应补齐:
if (nums.length == 0) return 0;
3.5 循环结构与条件判断
代码中使用了 while 循环和 if 判断,涉及基本控制流。
| 知识点 | 说明 |
|---|---|
while 循环 |
当条件满足时执行,适用于不确定循环次数但条件明确的情况;此处也可用 for 更紧凑 |
if 判断 |
基于相邻元素比较决定是否执行写入操作 |
fast++ 放在循环末尾 |
无论是否写入,快指针都递增,保证遍历所有元素 |
slow++ 的简洁写法 |
nums[slow++] = nums[fast]; 先赋值再自增,一行搞定 |
转换为 for 循环的等价写法:
for (int fast = 1; fast < nums.length; fast++) {
if (nums[fast] != nums[fast - 1]) {
nums[slow++] = nums[fast];
}
}
3.6 Java 数组与索引操作
| 知识点 | 说明 |
|---|---|
| 数组声明与初始化 | int[] nums,固定长度 |
| 索引从 0 开始 | nums[0] 是第一个元素 |
| 数组长度 | nums.length 属性,不可变 |
| 越界风险 | fast < n 防止访问 nums[n] 导致 ArrayIndexOutOfBoundsException |
fast - 1 的安全性 |
由于 fast 从 1 开始,fast-1 最小为 0,不会越界 |
四、深入拓展:双指针家族
双指针除了快慢指针,还有对撞指针、分离指针等形式。
对撞指针:两数之和 II
public int[] twoSum(int[] numbers, int target) {
int left = 0, right = numbers.length - 1;
while (left < right) {
int sum = numbers[left] + numbers[right];
if (sum == target) {
return new int[]{left + 1, right + 1};
} else if (sum < target) {
left++; // 和太小,左指针右移
} else {
right--; // 和太大,右指针左移
}
}
return new int[]{-1, -1};
}
分离指针:合并两个有序数组
public void merge(int[] nums1, int m, int[] nums2, int n) {
int p1 = m - 1, p2 = n - 1, tail = m + n - 1;
while (p1 >= 0 && p2 >= 0) {
nums1[tail--] = (nums1[p1] > nums2[p2]) ? nums1[p1--] : nums2[p2--];
}
while (p2 >= 0) {
nums1[tail--] = nums2[p2--];
}
}
五、知识点汇总总表
| 模块 | 核心知识点 | 关键细节 |
|---|---|---|
| 有序数组去重 | 利用有序性,相邻比较 | nums[fast] != nums[fast-1] 判断新值 |
| 快慢指针 | 双指针中的"读写分离" | slow 负责写,fast 负责读 |
| 原地修改 | 不增加额外存储,直接改输入 | O(1) 空间,返回新长度 |
| 算法健壮性 | 处理空数组 | if (n == 0) return 0; |
| 循环控制 | while / for 的选择 | for 循环更紧凑且不易忘记递增 |
| 索引安全 | 防止数组越界 | fast < n 和 fast-1 >= 0 均保证合法 |
| 简洁写法 | nums[slow++] = nums[fast] |
赋值与自增结合,代码更清爽 |
| 双指针泛化 | 对撞指针、分离指针、快慢指针 | 解决不同场景:有序和、合并、去重、找环等 |
六、总结
从一段简洁的"删除有序数组重复项"代码中,我们窥见了算法设计的精巧:利用有序数组的性质,将双指针技术运用得淋漓尽致,用 O(n) 时间和 O(1) 空间就完成了任务。同时,代码中也隐藏着边界处理、索引安全、原地修改等工程细节。理解和记住这个模板之后,可以轻松迁移到"保留最多 k 个重复项"等变体问题。希望这篇文章能让你对双指针和数组操作有更立体的认识,不仅会写,还写得健壮、优雅。