题目描述
给你一个非严格递增排列的数组 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 已按非严格递增排列
解题思路
由于数组已经排序,重复的元素必然相邻。我们可以利用这一特性来解决问题。
我们可以将解法分为三类:
- 算法本质实现:展示算法核心思想,不依赖STL库函数
- STL应用实现:利用STL库函数简化代码实现
- 其他特殊解法:展示不同的编程思想和技巧
解法比较与选择
算法本质实现 vs STL应用实现
-
算法本质实现:
- 优点:有助于理解算法核心思想,不依赖外部库,便于移植到其他语言
- 缺点:代码相对复杂,需要手动处理更多细节
- 适用场景:学习算法原理、面试手写代码、嵌入式等受限环境
-
STL应用实现:
- 优点:代码简洁,开发效率高,经过充分测试
- 缺点:依赖特定库,可能隐藏实现细节
- 适用场景:实际项目开发、快速原型开发
推荐解法
在实际应用中,推荐使用方法一(双指针法),理由如下:
- 逻辑直观,易于理解和实现
- 时间复杂度O(n),空间复杂度O(1),均为最优
- 只需要一次遍历,效率高
- 无边界漏洞,适用于各种边界情况
对于追求代码简洁性的场景,可以考虑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] 说明:测试解法对题目提示中边界值的处理能力
测试建议
- 对于每种解法,都应该使用以上所有测试用例进行验证
- 特别关注空数组和单元素数组这两种边界情况
- 对于递归解法,要注意测试大规模数据是否会引发栈溢出
- 对于STL相关解法,要验证其在极端输入下的行为是否符合预期
- 对于需要移动元素的解法(如方法四和方法十),要注意其在大量重复元素情况下的性能表现
算法本质实现
这类解法展示了算法的核心思想,不依赖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指针。
算法详解:
- 初始化slow指针为1(因为第一个元素肯定不重复)
- fast指针从索引1开始遍历数组
- 比较fast指针指向的元素与前一个元素(fast-1)是否相同
- 如果不同,说明是新元素,将其复制到slow指针位置,然后slow指针前移
- 最终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] 时:
- slow初始为1,fast从1开始
- fast=1时,比较nums[1]和nums[0],都是1,不执行if语句
- fast=2时,循环条件
fast < nums.size()-1即2 < 2为false,循环结束 - 最后一个元素nums[2]=2没有被处理
- 返回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] 时:
- slow初始为1,fast从1开始
- fast=1时,比较nums[1]和nums[0],都是1,不执行if语句
- fast=2时,循环条件
fast < nums.size()即2 < 3为true,继续执行 - 比较nums[2]和nums[1],分别是2和1,不相等,执行if语句
- nums[1] = nums[2],即nums[1] = 2,slow增加到2
- fast=3时,循环条件
fast < nums.size()即3 < 3为false,循环结束 - 返回slow=2,结果正确
调试建议:
- 在编写循环时,仔细考虑边界条件
- 使用具体的小例子手动执行代码,验证逻辑正确性
- 注意数组索引从0开始的特性
- 对于涉及数组大小减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)
使用双指针,但判断逻辑不同。遍历数组时,如果当前元素与前一个不重复元素不同,则为新元素。
与方法一的具体差异:
- 方法一是fast指针与fast-1位置元素比较(相邻元素比较)
- 方法二是当前元素(i)与index-1位置元素比较(与已确定的不重复元素比较)
- 两种方法虽然时间复杂度相同,但比较的参照物不同
算法详解:
- index指针指向下一个不重复元素应放置的位置(初始为1)
- 从索引1开始遍历数组
- 比较当前元素(i)与index-1位置的元素(即前一个不重复元素)是否相同
- 如果不同,说明是新元素,将其复制到index位置,然后index指针前移
- 最终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)
通过计数的方式判断重复元素,维护不重复元素的计数。
算法详解:
- count变量记录不重复元素的个数(初始为1,因为第一个元素肯定不重复)
- 从索引1开始遍历数组
- 比较当前元素与前一个元素是否相同
- 如果不同,说明是新元素,将其复制到count位置,然后count加1
- 最终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) - 递归栈空间
使用递归的方式处理数组,每次处理一个元素。
算法详解:
- 递归函数有两个参数:index(当前不重复元素的位置)和next(下一个待检查元素的位置)
- 终止条件:当next >= nums.size()时,说明已处理完所有元素,返回index + 1
- 递归体:
- 如果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算法。这种方法展示了算法的本质实现。
算法详解:
- writeIndex指针指向下一个写入位置(初始为1)
- readIndex指针从索引1开始遍历数组
- 比较readIndex指向的元素与writeIndex-1位置的元素是否相同
- 如果不同,说明是新元素,将其复制到writeIndex位置,然后writeIndex前移
- 最终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)
从后向前遍历,记录重复元素个数,然后统一向前移动元素。这种方法虽然不是最优的,但展示了另一种思路。
算法详解:
- duplicates变量记录重复元素的个数
- 从后向前遍历数组
- 如果当前元素与前一个元素相同,duplicates加1
- 如果不同,将当前元素向前移动duplicates位(即跳过前面所有重复元素的位置)
- 最终不重复元素个数为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)
使用单指针遍历数组,通过计数重复元素的方式处理重复项。当遇到不同元素时,根据重复计数确定放置位置。
算法详解:
- writePos指针指向下一个不重复元素应放置的位置(初始为1)
- duplicateCount变量记录已遇到的重复元素数量
- 从索引1开始遍历数组
- 如果当前元素与前一个元素相同,增加duplicateCount计数
- 如果当前元素与前一个元素不同,说明是新元素,将其写入writePos位置,然后writePos前移
- 最终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)
使用滑动窗口的思想,维护一个只包含不重复元素的窗口。窗口右边界不断扩展,左边界根据重复情况调整。
算法详解:
- left指针作为窗口的左边界,指向当前已处理的最后一个不重复元素
- right指针作为窗口的右边界,遍历整个数组
- 当right指向的元素与left指向的元素不同时,说明是新元素
- 将新元素复制到left+1位置,并移动left指针扩展窗口
- 最终窗口大小(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)
使用迭代器遍历容器,手动删除重复元素。这种方法虽然直观,但效率较低。
算法详解:
- 使用迭代器it遍历数组
- 比较当前元素与下一个元素是否相同
- 如果相同,使用erase函数删除下一个元素,erase函数返回指向下一个元素的迭代器
- 如果不同,移动迭代器到下一个元素
- 最终返回数组大小
关键易错点:
- 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)
函数说明:
- std::unique函数不会真正删除元素,而是将不重复的元素移到容器的前端,返回指向超出不重复元素范围的下一个位置的迭代器。
- std::distance函数计算两个迭代器之间的距离。
- vector的erase函数可以删除指定范围的元素。
核心用法详解:
- std::unique(nums.begin(), nums.end()) - 将不重复元素移到前端,返回指向重复元素开始位置的迭代器
- 配合distance或减法操作可以获取不重复元素的个数
- 配合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应用实现两种不同的思路:
-
算法本质实现侧重于展示算法的核心思想,不依赖特定库函数,有助于深入理解算法原理。这类实现通常更通用,便于移植到其他编程语言或受限环境中。
-
STL应用实现利用现代C++标准库提供的功能,可以大大简化代码实现,提高开发效率。这类实现在实际项目中更常见,因为它们经过了充分测试且性能良好。
通过对比这两种实现方式,我们可以更好地理解算法的本质,同时掌握如何在实际项目中高效地使用标准库。这种双重视角对于成为一名优秀的程序员非常重要。