[26] 删除排序数组中的重复项

题目描述

给你一个非严格递增排列的数组 nums,请你原地删除重复出现的元素,使每个元素只出现一次,返回删除后数组的新长度。

元素的相对顺序应保持一致。然后返回 nums 中唯一元素的个数。

判题标准

系统会用以下代码来测试你的题解:

cpp 复制代码
int[] nums = [...]; // 输入数组
int[] expectedNums = [...]; // 长度正确的期望答案
int k = removeDuplicates(nums); // 调用
assert k == expectedNums.length;
for (int i = 0; i < k; i++) {
    assert nums[i] == expectedNums[i];
}

示例

示例 1:

ini 复制代码
输入:nums = [1,1,2]
输出:2, nums = [1,2,_]

示例 2:

ini 复制代码
输入:nums = [0,0,1,1,1,2,2,3,3,4]
输出:5, nums = [0,1,2,3,4]

提示

  • 1 <= nums.length <= 3 * 10^4
  • -100 <= nums[i] <= 100
  • nums 已按非严格递增排列

解题思路

由于数组已经排序,重复的元素必然相邻。我们可以利用这一特性来解决问题。

我们可以将解法分为三类:

  1. 算法本质实现:展示算法核心思想,不依赖STL库函数
  2. STL应用实现:利用STL库函数简化代码实现
  3. 其他特殊解法:展示不同的编程思想和技巧

解法比较与选择

算法本质实现 vs STL应用实现

  1. 算法本质实现

    • 优点:有助于理解算法核心思想,不依赖外部库,便于移植到其他语言
    • 缺点:代码相对复杂,需要手动处理更多细节
    • 适用场景:学习算法原理、面试手写代码、嵌入式等受限环境
  2. STL应用实现

    • 优点:代码简洁,开发效率高,经过充分测试
    • 缺点:依赖特定库,可能隐藏实现细节
    • 适用场景:实际项目开发、快速原型开发

推荐解法

在实际应用中,推荐使用方法一(双指针法),理由如下:

  1. 逻辑直观,易于理解和实现
  2. 时间复杂度O(n),空间复杂度O(1),均为最优
  3. 只需要一次遍历,效率高
  4. 无边界漏洞,适用于各种边界情况

对于追求代码简洁性的场景,可以考虑STL unique函数的核心用法(方法十一),这是STL的标准用法。

极端测试用例

为了验证各种解法在边界条件下的正确性和鲁棒性,我们整理了以下极端测试用例:

1. 空数组

输入:[] 预期输出:0 说明:测试解法对空数组的处理能力

2. 单元素数组

输入:[1] 预期输出:1, nums = [1] 说明:测试解法对只有一个元素的数组处理能力

3. 两元素相同数组

输入:[1,1] 预期输出:1, nums = [1] 说明:测试解法对全部重复元素的处理能力

4. 两元素不同数组

输入:[1,2] 预期输出:2, nums = [1,2] 说明:测试解法对无重复元素的处理能力

5. 全部重复元素数组

输入:[1,1,1,1,1,1,1,1,1,1] 预期输出:1, nums = [1,1,1,1,1,1,1,1,1,1] 说明:测试解法对所有元素都相同的情况处理能力

6. 无重复元素数组

输入:[1,2,3,4,5,6,7,8,9,10] 预期输出:10, nums = [1,2,3,4,5,6,7,8,9,10] 说明:测试解法对无重复元素的情况处理能力

7. 交替重复元素数组

输入:[1,1,2,2,3,3,4,4,5,5] 预期输出:5, nums = [1,2,3,4,5,2,2,4,4,5] 说明:测试解法对规律性重复元素的处理能力

8. 最大数组长度测试

输入:长度为 3 * 10^4 的数组,所有元素相同 预期输出:1 说明:测试解法在最大输入规模下的性能和稳定性

9. 最大数组长度测试(无重复)

输入:长度为 3 * 10^4 的数组,所有元素各不相同 预期输出:3 * 10^4 说明:测试解法在最大输入规模下处理无重复元素的性能

10. 边界值测试

输入:[-100,-100,-50,0,50,100,100] 预期输出:5, nums = [-100,-50,0,50,100,100,100] 说明:测试解法对题目提示中边界值的处理能力

测试建议

  1. 对于每种解法,都应该使用以上所有测试用例进行验证
  2. 特别关注空数组和单元素数组这两种边界情况
  3. 对于递归解法,要注意测试大规模数据是否会引发栈溢出
  4. 对于STL相关解法,要验证其在极端输入下的行为是否符合预期
  5. 对于需要移动元素的解法(如方法四和方法十),要注意其在大量重复元素情况下的性能表现

算法本质实现

这类解法展示了算法的核心思想,不依赖STL库函数,有助于理解算法本质。

方法一:双指针法(快慢指针)- 推荐解法

cpp 复制代码
int removeDuplicates(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int slow = 1;
    for (int fast = 1; fast < nums.size(); fast++) {
        if (nums[fast] != nums[fast - 1]) {
            nums[slow] = nums[fast];
            slow++;
            // 先移动 slow 指针(执行 ++slow),将 nums[fast] 赋值给 nums[slow],确保不重复区域连续
        }
    }
    return slow;
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

使用两个指针,slow指针指向不重复元素的位置,fast指针遍历整个数组。当fast指针遇到新元素时,将其复制到slow指针位置并移动slow指针。

算法详解

  1. 初始化slow指针为1(因为第一个元素肯定不重复)
  2. fast指针从索引1开始遍历数组
  3. 比较fast指针指向的元素与前一个元素(fast-1)是否相同
  4. 如果不同,说明是新元素,将其复制到slow指针位置,然后slow指针前移
  5. 最终slow指针的位置就是不重复元素的个数

关键易错点

  • slow指针初始值为1,因为第一个元素肯定不重复
  • 必须先赋值再移动slow指针,如果顺序颠倒会导致第一个元素被覆盖
  • fast指针从索引1开始,避免访问nums[-1]

推荐理由

  • 逻辑直观,易于理解
  • 只需要一次遍历,效率高
  • 原地修改,空间复杂度最优
  • 无边界漏洞,适用于各种边界情况

调试案例:双指针循环条件错误

新手在实现双指针法时,常常会犯一个错误:将循环条件写成 fast < nums.size()-1 而不是 fast < nums.size()。这种错误会导致无法正确处理最后一个元素。

错误代码示例

cpp 复制代码
// 错误实现:循环条件错误
int removeDuplicatesWrong(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int slow = 1;
    // 错误的循环条件:fast < nums.size()-1
    for (int fast = 1; fast < nums.size()-1; fast++) {
        if (nums[fast] != nums[fast - 1]) {
            nums[slow] = nums[fast];
            slow++;
        }
    }
    return slow;
}

错误分析 : 当输入数组为 [1,1,2] 时:

  1. slow初始为1,fast从1开始
  2. fast=1时,比较nums[1]和nums[0],都是1,不执行if语句
  3. fast=2时,循环条件 fast < nums.size()-12 < 2 为false,循环结束
  4. 最后一个元素nums[2]=2没有被处理
  5. 返回slow=1,结果错误,以测试用例 [1,1,2] 验证时,返回结果为 1(正确结果应为 2)

正确代码

cpp 复制代码
// 正确实现:循环条件正确
int removeDuplicates(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int slow = 1;
    // 正确的循环条件:fast < nums.size()
    for (int fast = 1; fast < nums.size(); fast++) {
        if (nums[fast] != nums[fast - 1]) {
            nums[slow] = nums[fast];
            slow++;
        }
    }
    return slow;
}

正确分析 : 当输入数组为 [1,1,2] 时:

  1. slow初始为1,fast从1开始
  2. fast=1时,比较nums[1]和nums[0],都是1,不执行if语句
  3. fast=2时,循环条件 fast < nums.size()2 < 3 为true,继续执行
  4. 比较nums[2]和nums[1],分别是2和1,不相等,执行if语句
  5. nums[1] = nums[2],即nums[1] = 2,slow增加到2
  6. fast=3时,循环条件 fast < nums.size()3 < 3 为false,循环结束
  7. 返回slow=2,结果正确

调试建议

  1. 在编写循环时,仔细考虑边界条件
  2. 使用具体的小例子手动执行代码,验证逻辑正确性
  3. 注意数组索引从0开始的特性
  4. 对于涉及数组大小减1的运算,特别小心边界情况

方法二:基于距离的双指针法

cpp 复制代码
int removeDuplicates2(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int index = 1;  // 指向下一个不重复元素应放置的位置
    for (int i = 1; i < nums.size(); i++) {
        // 如果当前元素与前一个不重复元素不同
        if (nums[i] != nums[index - 1]) {
            nums[index] = nums[i];
            index++;
            // 先移动 index 指针(执行 ++index),将 nums[i] 赋值给 nums[index],确保不重复区域连续
        }
    }
    return index;
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

使用双指针,但判断逻辑不同。遍历数组时,如果当前元素与前一个不重复元素不同,则为新元素。

与方法一的具体差异

  1. 方法一是fast指针与fast-1位置元素比较(相邻元素比较)
  2. 方法二是当前元素(i)与index-1位置元素比较(与已确定的不重复元素比较)
  3. 两种方法虽然时间复杂度相同,但比较的参照物不同

算法详解

  1. index指针指向下一个不重复元素应放置的位置(初始为1)
  2. 从索引1开始遍历数组
  3. 比较当前元素(i)与index-1位置的元素(即前一个不重复元素)是否相同
  4. 如果不同,说明是新元素,将其复制到index位置,然后index指针前移
  5. 最终index的值就是不重复元素的个数

关键易错点

  • "前一个不重复元素"指的是index-1位置的元素,而不是i-1位置的元素
  • index初始值为1,因为第一个元素肯定不重复

推荐理由

  • 与方法一类似,但提供了不同的思考角度
  • 逻辑清晰,易于实现

方法三:计数法

cpp 复制代码
int removeDuplicates3(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int count = 1;  // 不重复元素的个数
    for (int i = 1; i < nums.size(); i++) {
        if (nums[i] != nums[i-1]) {
            nums[count] = nums[i];
            count++;
            // 先移动 count 指针(执行 ++count),将 nums[i] 赋值给 nums[count],确保不重复区域连续
        }
    }
    return count;
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

通过计数的方式判断重复元素,维护不重复元素的计数。

算法详解

  1. count变量记录不重复元素的个数(初始为1,因为第一个元素肯定不重复)
  2. 从索引1开始遍历数组
  3. 比较当前元素与前一个元素是否相同
  4. 如果不同,说明是新元素,将其复制到count位置,然后count加1
  5. 最终count的值就是不重复元素的个数

关键易错点

  • count初始值为1,因为第一个元素肯定不重复
  • 复制元素到nums[count]位置后再增加count

推荐理由

  • 思路简单直接,易于理解
  • 代码简洁,不易出错

方法四:从后向前遍历删除

cpp 复制代码
int removeDuplicates4(std::vector<int>& nums) {
    for (int i = nums.size() - 1; i > 0; i--) {
        if (nums[i] == nums[i - 1]) {
            // 手动实现删除操作,不使用STL
            for (int j = i; j < nums.size() - 1; j++) {
                nums[j] = nums[j + 1];
            }
            nums.pop_back(); // 或者 nums.resize(nums.size() - 1);
        }
    }
    return nums.size();
}
  • 所需头文件<vector>
  • 时间复杂度:O(n²) 最坏情况(所有元素都相同)
  • 空间复杂度:O(1)

从后向前遍历数组,当发现相邻元素相等时删除后面一个。这种方法虽然直观,但效率较低。

复杂度详解: 删除数组元素需要移动后续所有元素。在最坏情况下(所有元素都相同),需要进行n-1次删除操作:

  • 第一次删除需要移动n-1个元素
  • 第二次删除需要移动n-2个元素
  • ...
  • 最后一次删除需要移动1个元素 总共移动次数为:(n-1) + (n-2) + ... + 1 = n(n-1)/2 = O(n²)

关键易错点

  • 从后向前遍历避免了删除元素后索引失效的问题
  • 手动实现元素前移时要注意边界条件

不推荐理由

  • 时间复杂度较高O(n²)
  • 频繁的元素移动操作影响性能
  • 仅适用于小规模数据集

方法五:递归解法

cpp 复制代码
int removeDuplicates5(std::vector<int>& nums) {
    if (nums.size() <= 1) return nums.size();
    return removeDuplicatesRecursive(nums, 0, 1);
}

int removeDuplicatesRecursive(std::vector<int>& nums, int index, int next) {
    // 基本情况:已处理完所有元素
    if (next >= nums.size()) {
        return index + 1;
    }
    
    // 如果找到新元素
    if (nums[next] != nums[index]) {
        nums[index + 1] = nums[next];
        return removeDuplicatesRecursive(nums, index + 1, next + 1);
    } else {
        // 继续查找下一个可能的不重复元素
        return removeDuplicatesRecursive(nums, index, next + 1);
    }
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(n) - 递归栈空间

使用递归的方式处理数组,每次处理一个元素。

算法详解

  1. 递归函数有两个参数:index(当前不重复元素的位置)和next(下一个待检查元素的位置)
  2. 终止条件:当next >= nums.size()时,说明已处理完所有元素,返回index + 1
  3. 递归体:
    • 如果nums[next] != nums[index],说明是新元素,将其复制到index+1位置,然后递归处理next+1元素
    • 否则,继续查找下一个可能的不重复元素

关键易错点

  • 递归终止条件是next >= nums.size()
  • 递归体中要注意区分找到新元素和未找到新元素的情况
  • 递归会增加空间复杂度

递归深度风险

  • 递归深度与数组长度成正比,最深可达O(n)
  • 当数组长度接近或超过系统栈深度时(如题目提示中的3×10⁴上限),会触发栈溢出,导致程序崩溃
  • 在实际应用中,对于大规模数据,应避免使用递归解法
  • 这是递归解法的主要限制,也是不推荐在生产环境中使用的原因

方法六:纯手工实现unique功能

cpp 复制代码
int removeDuplicates6(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int writeIndex = 1;  // 写入位置
    
    // 从第二个元素开始遍历
    for (int readIndex = 1; readIndex < nums.size(); readIndex++) {
        // 如果当前读取的元素与前一个不重复元素不同
        if (nums[readIndex] != nums[writeIndex - 1]) {
            nums[writeIndex] = nums[readIndex];
            writeIndex++;
            // 先移动 writeIndex 指针(执行 ++writeIndex),将 nums[readIndex] 赋值给 nums[writeIndex],确保不重复区域连续
        }
    }
    
    return writeIndex;
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

手工实现类似STL unique函数的功能,不依赖任何STL算法。这种方法展示了算法的本质实现。

算法详解

  1. writeIndex指针指向下一个写入位置(初始为1)
  2. readIndex指针从索引1开始遍历数组
  3. 比较readIndex指向的元素与writeIndex-1位置的元素是否相同
  4. 如果不同,说明是新元素,将其复制到writeIndex位置,然后writeIndex前移
  5. 最终writeIndex的值就是不重复元素的个数

关键易错点

  • writeIndex初始值为1,因为第一个元素肯定不重复
  • 比较的是readIndex元素与writeIndex-1位置的元素

方法七:反向思维解法

cpp 复制代码
int removeDuplicates7(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int duplicates = 0;
    int n = nums.size();
    
    // 从后向前遍历
    for (int i = n - 1; i > 0; i--) {
        if (nums[i] == nums[i - 1]) {
            duplicates++;
        } else {
            // 将元素向前移动duplicates位
            nums[i - duplicates] = nums[i];
        }
    }
    
    // 处理第一个元素
    if (n > 0) {
        nums[0] = nums[0];  // 第一个元素始终保留在位置0
    }
    
    return n - duplicates;
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

从后向前遍历,记录重复元素个数,然后统一向前移动元素。这种方法虽然不是最优的,但展示了另一种思路。

算法详解

  1. duplicates变量记录重复元素的个数
  2. 从后向前遍历数组
  3. 如果当前元素与前一个元素相同,duplicates加1
  4. 如果不同,将当前元素向前移动duplicates位(即跳过前面所有重复元素的位置)
  5. 最终不重复元素个数为n - duplicates

关键易错点

  • duplicates记录的是重复元素个数,不是不重复元素个数
  • 第一个元素始终保留在位置0
  • 元素向前移动的步长等于已发现的重复元素个数

方法八:重复计数单指针法

cpp 复制代码
int removeDuplicates8(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int writePos = 1;     // 写入位置
    int duplicateCount = 0; // 重复元素计数
    
    for (int i = 1; i < nums.size(); i++) {
        if (nums[i] == nums[i - 1]) {
            // 发现重复元素,增加计数
            duplicateCount++;
        } else {
            // 发现新元素,将其写入正确位置
            nums[writePos] = nums[i];
            writePos++;
            // 先移动 writePos 指针(执行 ++writePos),将 nums[i] 赋值给 nums[writePos],确保不重复区域连续
        }
    }
    
    return writePos;
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

使用单指针遍历数组,通过计数重复元素的方式处理重复项。当遇到不同元素时,根据重复计数确定放置位置。

算法详解

  1. writePos指针指向下一个不重复元素应放置的位置(初始为1)
  2. duplicateCount变量记录已遇到的重复元素数量
  3. 从索引1开始遍历数组
  4. 如果当前元素与前一个元素相同,增加duplicateCount计数
  5. 如果当前元素与前一个元素不同,说明是新元素,将其写入writePos位置,然后writePos前移
  6. 最终writePos的值就是不重复元素的个数

关键易错点

  • writePos初始值为1,因为第一个元素肯定不重复
  • duplicateCount虽然记录了重复元素数量,但在算法中并未直接使用(可以用于其他目的)
  • 重点是发现新元素时的处理逻辑

推荐理由

  • 提供了另一种思考问题的角度
  • 通过计数的方式可以扩展用于其他相关问题

方法九:滑动窗口法

cpp 复制代码
int removeDuplicates9(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    int left = 0;  // 窗口左边界,指向最后一个不重复元素
    
    // 滑动窗口的右边界遍历整个数组
    for (int right = 1; right < nums.size(); right++) {
        // 如果右边界元素与窗口内最后一个不重复元素不同
        if (nums[right] != nums[left]) {
            // 扩展窗口,将新元素加入窗口
            left++;
            nums[left] = nums[right];
            // 先移动 left 指针(执行 ++left),将 nums[right] 赋值给 nums[left],确保不重复区域连续
        }
        // 如果相同,则窗口不扩展,继续移动右边界
    }
    
    // 窗口大小即为不重复元素的个数
    return left + 1;
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

使用滑动窗口的思想,维护一个只包含不重复元素的窗口。窗口右边界不断扩展,左边界根据重复情况调整。

算法详解

  1. left指针作为窗口的左边界,指向当前已处理的最后一个不重复元素
  2. right指针作为窗口的右边界,遍历整个数组
  3. 当right指向的元素与left指向的元素不同时,说明是新元素
  4. 将新元素复制到left+1位置,并移动left指针扩展窗口
  5. 最终窗口大小(left+1)即为不重复元素的个数

关键易错点

  • left初始值为0,指向第一个元素
  • 只有发现新元素时才移动left指针
  • 窗口大小是left+1,不是left

推荐理由

  • 引入了滑动窗口的思想,这是一种重要的算法技巧
  • 与双指针法类似,但视角不同,有助于理解窗口类问题

方法十:迭代器手动删除法

cpp 复制代码
int removeDuplicates10(std::vector<int>& nums) {
    if (nums.size() <= 1) return nums.size();
    
    auto it = nums.begin();
    while (it != nums.end() && (it + 1) != nums.end()) {
        // 比较当前元素与下一个元素
        if (*it == *(it + 1)) {
            // 删除下一个重复元素
            it = nums.erase(it + 1);
            // 注意:erase后it仍然指向有效元素,不需要移动
        } else {
            // 移动到下一个元素
            ++it;
        }
    }
    
    return nums.size();
}
  • 所需头文件<vector>
  • 时间复杂度:O(n²) 最坏情况
  • 空间复杂度:O(1)

使用迭代器遍历容器,手动删除重复元素。这种方法虽然直观,但效率较低。

算法详解

  1. 使用迭代器it遍历数组
  2. 比较当前元素与下一个元素是否相同
  3. 如果相同,使用erase函数删除下一个元素,erase函数返回指向下一个元素的迭代器
  4. 如果不同,移动迭代器到下一个元素
  5. 最终返回数组大小

关键易错点

  • erase函数会返回指向被删除元素后一个元素的迭代器
  • 需要正确处理迭代器,避免访问无效位置
  • 边界条件处理,确保(it + 1)不越界

不推荐理由

  • 时间复杂度较高O(n²),因为erase操作需要移动后续所有元素
  • 频繁的元素移动操作影响性能
  • 仅适用于小规模数据集

STL应用实现

这类解法利用STL库函数简化代码实现,适合在实际项目中快速解决问题。

方法十一:STL unique函数的核心用法

STL的unique函数和erase函数配合使用是解决这类问题的标准方法。unique函数将相邻的重复元素移到数组末尾,但不真正删除它们,需要配合erase函数才能真正删除。

cpp 复制代码
// 方法1: 使用unique获取不重复元素的长度
int removeDuplicates11(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    auto it = std::unique(nums.begin(), nums.end());
    return it - nums.begin();
}

// 方法2: 使用distance和unique获取长度
int removeDuplicates12(std::vector<int>& nums) {
    return std::distance(nums.begin(), std::unique(nums.begin(), nums.end()));
}

// 方法3: 使用unique和erase真正删除重复元素
int removeDuplicates13(std::vector<int>& nums) {
    nums.erase(std::unique(nums.begin(), nums.end()), nums.end());
    return nums.size();
}
  • 所需头文件<vector>, <algorithm>
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

函数说明

  1. std::unique函数不会真正删除元素,而是将不重复的元素移到容器的前端,返回指向超出不重复元素范围的下一个位置的迭代器。
  2. std::distance函数计算两个迭代器之间的距离。
  3. vector的erase函数可以删除指定范围的元素。

核心用法详解

  1. std::unique(nums.begin(), nums.end()) - 将不重复元素移到前端,返回指向重复元素开始位置的迭代器
  2. 配合distance或减法操作可以获取不重复元素的个数
  3. 配合erase函数可以真正删除重复元素

关键易错点

  • std::unique只移动元素,不真正删除元素
  • 返回的是迭代器,需要正确使用才能得到长度
  • 必须配合erase才能真正删除重复元素

常见误区

  • 误以为std::unique会真正删除重复元素
  • 忘记减去begin()导致返回错误的长度
  • 使用unique后忘记调用erase导致数组长度未改变

时间复杂度详细分析: 虽然整体时间复杂度是O(n),但具体分析如下:

  • std::unique函数遍历数组一次,时间复杂度为O(n)
  • vector::erase函数需要移动被删除元素后面的所有元素,最坏情况下时间复杂度也是O(n)
  • 但因为unique已经将重复元素移到末尾,erase只需要移动一次,所以整体仍是O(n)
  • 相比纯双指针法,unique+erase多了元素移动的开销,但在小规模数组中这种差异可以忽略

实际应用建议

  • 项目开发中:unique+erase组合以'代码简洁性'为核心优势,适合测试环境临时数据处理(如小规模配置文件去重)
  • 面试场景中:更推荐双指针法,因为它展示了算法思维且没有额外的元素移动开销
  • 性能敏感场景:对于大规模数据处理,双指针法通常有更好的性能表现

推荐理由

  • 代码简洁,利用STL标准函数,稳定可靠
  • 时间和空间复杂度都是最优的
  • 是实际项目中的推荐做法

其他特殊解法

这些解法展示了不同的编程思想和技巧。

方法十二:使用set去重(不推荐)

cpp 复制代码
int removeDuplicates14(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    std::set<int> uniqueSet(nums.begin(), nums.end());
    int i = 0;
    for (int num : uniqueSet) {
        nums[i++] = num;
    }
    return uniqueSet.size();
}
  • 所需头文件<vector>, <set>
  • 时间复杂度:O(n log n)
  • 空间复杂度:O(n)

使用set自动去重的特性,然后复制回原数组。注意:这种方法不满足题目"原地"修改的要求,仅作学习参考。

复杂度详解: set的插入操作时间复杂度为O(log n),总共需要插入n个元素,所以总时间复杂度为O(n log n)。

关键易错点

  • set会自动排序,可能改变元素的原始顺序
  • 不满足题目"原地修改"的要求(原地修改指空间复杂度O(1))

不推荐理由

  • 时间复杂度不是最优的O(n log n)
  • 空间复杂度不是O(1)
  • 会改变元素原有顺序
  • 违反题目要求的"原地"修改

方法十三:使用栈模拟

cpp 复制代码
int removeDuplicates15(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    std::vector<int> stack;
    stack.push_back(nums[0]);
    
    for (int i = 1; i < nums.size(); i++) {
        if (nums[i] != stack.back()) {
            stack.push_back(nums[i]);
        }
    }
    
    // 将栈中元素复制回原数组
    for (int i = 0; i < stack.size(); i++) {
        nums[i] = stack[i];
    }
    
    return stack.size();
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(n) - 栈空间

使用栈来存储不重复的元素,最后复制回原数组。这种方法虽然不是最优的,但提供了一种不同的思路。

关键易错点

  • 需要额外的O(n)空间存储栈
  • 栈中元素是按顺序存储的,复制回原数组时要注意索引

方法十四:使用位运算标记(适用于特定范围的整数)

cpp 复制代码
int removeDuplicates16(std::vector<int>& nums) {
    if (nums.empty()) return 0;
    
    // 由于数值范围是[-100, 100],调整为[0, 200]
    std::vector<bool> seen(201, false);
    int index = 0;
    
    for (int i = 0; i < nums.size(); i++) {
        int adjustedNum = nums[i] + 100;  // 调整到非负范围,偏移量为100
        if (!seen[adjustedNum]) {
            seen[adjustedNum] = true;
            nums[index++] = nums[i];
            // 先移动 index 指针(执行 ++index),将 nums[i] 赋值给 nums[index],确保不重复区域连续
        }
    }
    
    return index;
}
  • 所需头文件<vector>
  • 时间复杂度:O(n)
  • 空间复杂度:O(1) - 但需要额外的位图空间

使用位图标记已出现的元素(需要调整数值范围到非负数)。注意:这种方法受限于数值范围,仅适用于特定场景。

适用场景说明: 由于题目中给出数值范围为[-100, 100],我们可以将其调整为[0, 200]来使用位图标记。调整方法是将原数值加上偏移量100。

关键易错点

  • 需要将数值范围调整到非负数,偏移量等于负数范围的绝对值
  • 受限于数值范围,不适用于所有情况
  • 需要预先知道数值范围

解法综合对比分析

为了更好地理解各种解法的特点和适用场景,我们对所有解法进行综合对比分析:

解法编号 解法名称 时间复杂度 空间复杂度 所需头文件 优缺点 适用场景
方法一 双指针法(快慢指针) O(n) O(1) <vector> 优点:逻辑清晰,效率高,无额外开销 缺点:需要手动实现 面试、学习、生产环境(大规模数据如10万条日志去重,追求低内存开销)
方法二 基于距离的双指针法 O(n) O(1) <vector> 优点:逻辑清晰,效率高 缺点:需要手动实现 面试、学习
方法三 计数法 O(n) O(1) <vector> 优点:思路简单,代码简洁 缺点:需要手动实现 学习、面试
方法四 从后向前遍历删除 O(n²) O(1) <vector> 优点:思路直观 缺点:时间复杂度高,效率低 小数据集
方法五 递归解法 O(n) O(n) <vector> 优点:代码简洁 缺点:递归深度风险,空间开销大 小数据集、学习
方法六 纯手工实现unique功能 O(n) O(1) <vector> 优点:逻辑清晰,效率高 缺点:需要手动实现 面试、学习
方法七 反向思维解法 O(n) O(1) <vector> 优点:思路独特 缺点:需要额外变量记录状态 学习
方法八 重复计数单指针法 O(n) O(1) <vector> 优点:思路清晰 缺点:需要手动实现 学习、面试
方法九 滑动窗口法 O(n) O(1) <vector> 优点:引入窗口思想 缺点:需要手动实现 学习、面试
方法十 迭代器手动删除法 O(n²) O(1) <vector> 优点:使用STL迭代器 缺点:时间复杂度高,效率低 小数据集
方法十一 STL unique函数 O(n) O(1) <vector>, <algorithm> 优点:代码简洁,标准库实现 缺点:有额外元素移动开销 生产环境(中小规模数据处理)
方法十二 使用set去重 O(n log n) O(n) <vector>, <set> 优点:自动去重 缺点:不满足原地修改要求,复杂度高 学习
方法十三 使用栈模拟 O(n) O(n) <vector> 优点:思路独特 缺点:需要额外空间 学习
方法十四 位运算标记 O(n) O(1)* <vector> 优点:效率高 缺点:受限于数值范围 特定场景(已知数值范围的数据去重)

*注:方法十四虽然标注空间复杂度为O(1),但实际上需要额外的位图空间,与数值范围相关。

常见错误总结

在实现这些解法的过程中,新手容易犯一些常见错误,以下是主要的错误类型和避免方法:

1. 循环边界错误

错误示例

cpp 复制代码
// 错误:循环条件写成 nums.size()-1,导致漏掉最后一个元素
for (int i = 1; i < nums.size()-1; i++) {
    if (nums[i] != nums[i-1]) {
        // 处理逻辑
    }
}

调试过程: 用测试用例 [1,1,2] 调试时,会发现最后一个元素 2 未被判断,返回结果比预期少 1。

正确做法

cpp 复制代码
// 正确:循环到 nums.size()
for (int i = 1; i < nums.size(); i++) {
    if (nums[i] != nums[i-1]) {
        // 处理逻辑
    }
}

避免方法

  • 使用具体例子手动执行代码验证边界条件
  • 注意数组索引从0开始的特性
  • 对于涉及数组大小减1的运算,特别小心边界情况

2. 指针操作顺序错误

错误示例

cpp 复制代码
// 错误:先移动指针再赋值,可能导致覆盖
int slow = 0;
for (int fast = 1; fast < nums.size(); fast++) {
    if (nums[fast] != nums[fast-1]) {
        slow++;  // 先移动指针
        nums[slow] = nums[fast];  // 再赋值
    }
}

调试过程: 使用测试用例 [1,2,3] 调试时,发现结果数组的第一个元素被错误覆盖,返回结果不正确。

正确做法

cpp 复制代码
// 正确:先赋值再移动指针
int slow = 1;
for (int fast = 1; fast < nums.size(); fast++) {
    if (nums[fast] != nums[fast-1]) {
        nums[slow] = nums[fast];  // 先赋值
        slow++;  // 再移动指针
    }
}

避免方法

  • 明确各指针的含义和作用
  • 理解指针移动和赋值操作的逻辑关系
  • 通过小例子验证指针操作顺序

3. STL函数使用误区

错误示例

cpp 复制代码
// 错误:误以为unique会真正删除元素
auto it = std::unique(nums.begin(), nums.end());
int k = it - nums.begin();  // 只是获得了长度,但数组并未真正改变

调试过程: 使用测试用例 [1,1,2] 调试时,发现数组长度未改变,仍然包含重复元素。

正确做法

cpp 复制代码
// 正确:配合erase函数真正删除重复元素
auto it = std::unique(nums.begin(), nums.end());
nums.erase(it, nums.end());  // 真正删除重复元素
int k = nums.size();

避免方法

  • 深入理解STL函数的实际行为
  • 查阅官方文档确认函数功能
  • 通过实际测试验证函数效果

4. 递归深度风险

潜在问题: 对于大规模数据(如题目提示中的3×10⁴上限),递归解法可能导致栈溢出。

调试过程: 在处理大规模数据时,程序出现栈溢出错误或异常终止。

避免方法

  • 评估递归深度与输入规模的关系
  • 对于大规模数据,优先考虑迭代解法
  • 在生产环境中谨慎使用递归处理大规模数据

5. 特殊输入处理错误

常见错误: 未正确处理空数组、单元素数组等边界情况。

调试过程: 在测试空数组或单元素数组时,程序出现崩溃或返回错误结果。

正确做法

cpp 复制代码
// 在函数开始处处理边界条件
if (nums.empty()) return 0;
if (nums.size() == 1) return 1;

避免方法

  • 养成先处理边界条件的习惯
  • 仔细阅读题目要求和提示
  • 使用多种边界情况测试代码

总结

在解决数组去重问题时,我们需要区分算法本质实现和STL应用实现两种不同的思路:

  1. 算法本质实现侧重于展示算法的核心思想,不依赖特定库函数,有助于深入理解算法原理。这类实现通常更通用,便于移植到其他编程语言或受限环境中。

  2. STL应用实现利用现代C++标准库提供的功能,可以大大简化代码实现,提高开发效率。这类实现在实际项目中更常见,因为它们经过了充分测试且性能良好。

通过对比这两种实现方式,我们可以更好地理解算法的本质,同时掌握如何在实际项目中高效地使用标准库。这种双重视角对于成为一名优秀的程序员非常重要。

相关推荐
THGML4 小时前
排序算法解析
数据结构·算法·排序算法
周杰伦_Jay4 小时前
【计算机网络核心】TCP/IP模型与网页解析全流程详解
网络·网络协议·tcp/ip·计算机网络·算法·架构·1024程序员节
额呃呃4 小时前
对信号的理解
linux·运维·算法
OKkankan4 小时前
模板的进阶
开发语言·数据结构·c++·算法
RTC老炮5 小时前
webrtc弱网-PccBitrateController类源码分析与算法原理
网络·算法·webrtc
和芯星通unicore5 小时前
扩展RTCM消息
人工智能·算法
草莓熊Lotso5 小时前
《算法闯关指南:优选算法--前缀和》--25.【模板】前缀和,26.【模板】二维前缀和
开发语言·c++·算法
hetao17338375 小时前
[CSP-S 2024] 超速检测
c++·算法
熬了夜的程序员5 小时前
【LeetCode】88. 合并两个有序数组
数据结构·算法·leetcode·职场和发展·深度优先