Leetcode 26.删除有序数组中的重复项 双指针巧解有序数组去重:从快慢指针到原地修改算法的精髓

一、引言

在算法题"删除有序数组中的重复项"中,一个非常优雅的解法是使用"快慢指针"。仅需一次遍历,就能在原地去除重复元素并返回新数组的长度。本文将围绕一段经典的快慢指针代码,深入剖析其背后的原理,同时总结涉及的知识点------包括双指针思想、有序数组特性、原地修改、返回新长度等,并在此基础上拓展到更一般的数组操作技巧。


二、原代码与核心思路

复制代码
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=0fast=1 不满足 fast < n,直接返回 slow=1?实际上有 bug,见下文

⚠️ 原代码的隐藏 bug:空数组情况

如果 nums 长度为 0,则 n = 0fast = 1, slow = 1while(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 < nfast-1 >= 0 均保证合法
简洁写法 nums[slow++] = nums[fast] 赋值与自增结合,代码更清爽
双指针泛化 对撞指针、分离指针、快慢指针 解决不同场景:有序和、合并、去重、找环等

六、总结

从一段简洁的"删除有序数组重复项"代码中,我们窥见了算法设计的精巧:利用有序数组的性质,将双指针技术运用得淋漓尽致,用 O(n) 时间和 O(1) 空间就完成了任务。同时,代码中也隐藏着边界处理、索引安全、原地修改等工程细节。理解和记住这个模板之后,可以轻松迁移到"保留最多 k 个重复项"等变体问题。希望这篇文章能让你对双指针和数组操作有更立体的认识,不仅会写,还写得健壮、优雅。

相关推荐
承渊政道1 小时前
【动态规划算法】(斐波那契数列模型详解)
数据结构·c++·学习·算法·leetcode·macos·动态规划
ch.ju1 小时前
Java程序设计(第3版)第二章——循环结构4
java
笨笨饿2 小时前
# 67_MCU的几大分区
数据结构·单片机·嵌入式硬件·算法·机器人·线性回归·个人开发
knight_9___2 小时前
RAG面试篇11
java·面试·职场和发展·agent·rag·智能体
念越2 小时前
Java 文件操作与IO流详解(File类 + 字节流 + 字符流全总结)
java·开发语言·io
6Hzlia2 小时前
【Hot 100 刷题计划】 LeetCode 230. 二叉搜索树中第 K 小的元素 | C++ 栈迭代中序遍历
c++·算法·leetcode
大熊背2 小时前
ISP Pipeline中Lv实现方式探究之六--lv值计算再优化
网络·算法·自动曝光·lv
RTC老炮2 小时前
WebRTC下FlexFEC算法架构及原理
网络·算法·音视频·webrtc
xin_nai2 小时前
LeetCode热题100(Java)(2)双指针
算法·leetcode·职场和发展