1. 先搞清楚这道题到底在问什么
1.1 题目描述逐句拆解
题目编号 26,题名“删除有序数组中的重复项”,是我见过最适合零基础入门“双指针法”的一道题,没有之一。它不涉及高深的数据结构,不考复杂的数学推导,核心逻辑用两句话就能讲完:给你一个按非递减顺序排列的数组,你需要原地删除重复出现的元素,让每个元素只出现一次,然后返回新的数组长度。
注意几个关键词,一个都不能漏。
第一个是“有序”。数组已经排好序了,意味着所有相同的值会连在一起。这一点直接决定了我们能用非常轻量的方式去重,而不是动用哈希表之类的额外结构。
第二个是“原地”。题目原话是不会使用额外数组空间,且必须通过修改输入数组来实现。这不仅是这道题的限制,也是面试里常考的工程意识:能在原数组上操作就不要新开空间,因为实际工作中,数据的搬移和内存开销往往比逻辑本身更值钱。
第三个是“返回新长度”。它不是让你输出整个去重后的数组,而是让你返回去重后还剩多少个元素。最后平台验证时会读取原数组前新长度个元素,检查是否和预期一致。也就是说,数组的前半段必须是无重复的,后半段残留什么值都不重要。
举个例子。输入数组是[1,1,2],去重后你应该把数组改成[1,2,2]或者[1,2,1]都可以,只要前 2 个位置是1,2,返回值是2,就算通过。很多新手不知道这一点,非要把数组后面多余的清掉,其实完全没必要。
1.2 为什么“有序”两个字是灵魂
如果数组无序,比如[2,1,2,1],你没法靠相邻比较来去重。要想高效去重,常规做法是拿哈希集合记录出现过的元素,遍历一遍,没见过的就保留。这样时间复杂度也是 O(n),但需要 O(n) 的额外空间,而且最终结果会天然变成“首次出现顺序”,不再是原来的相对顺序。
这道题刻意给了“有序”,本质上是把难度降了一大截。因为值相等的元素一定排在相邻位置,所以当你在数组中从左往右走的时候,只需要时刻盯着“上一个保留的值”和“当前看到的值”是否相等,就能判断当前值是不是重复项。这就像你在车站排队,队伍里只有按身高从矮到高排好的人才可能相邻出现相同身高;如果队伍乱站,想找出相同身高就得来回比对。
有序,是双指针解法能够成立的前提。没有这个前提,双指针就要换一种玩法,或者干脆用其他数据结构。所以做题时不要把“有序”当成理所当然,它是出题人埋下的最大提示。
1.3 空间复杂度 O(1) 意味着什么
常见的时间复杂度大家比较敏感,空间复杂度经常被忽略。空间复杂度 O(1) 意思是除了必要的几个变量(比如循环索引、计数器),不能随着输入规模变大而申请额外的存储。你不能新建一个数组,不能新建一个哈希表,甚至连一个“新数组再复制回去”的缓冲都不行。
那是不是除了输入数组本身,什么都不能动?也不是。你可以在原数组上自由修改,可以申请几个固定大小的临时变量。所以最朴素的想法——“把不重复的元素挑出来放进新数组”——从根上就被拒绝了。
这就倒逼我们想一个问题:能不能让自己维护的“不重复区域”和原来的数组共用同一块内存?当然可以,这也是双指针法的精髓所在:一个指针负责“写”,一个指针负责“读”,二者在原数组上前后脚移动,写指针永远不会超过读指针,所以覆盖掉的位置都是已经读过、不再需要的旧值,安全又节省空间。
2. 双指针思路是怎么一步步推出来的
2.1 从暴力解法到双指针的演进
很多零基础的同学看到题目第一反应是:那我用两个循环,遇到重复就删掉一个,把后面的元素往前移。这个思路能通,但时间复杂度是 O(n²),因为删除一个元素需要把后面的所有元素整体搬一次。如果数组有十万个元素且大部分是重复的,操作次数非常恐怖。
稍微好一点的做法是边遍历边把不重复元素往前面放。比如我准备一个变量k,表示“当前已经确认的不重复元素个数”。从头开始扫描,每遇到一个和上一个不重复的元素,就把它放到数组第k个位置,然后k加一。这不就是双指针吗?
是的,这就是双指针。只不过很多初学者没有意识到,所谓的双指针并不玄乎:一个指针用来扫描原始数组,我们可以叫它“快指针”或者fast;另一个指针用来标记不重复区间的下一个写入位置,我们可以叫它“慢指针”或者slow。两个指针都是从左往右移动,所以整体只需要遍历一遍。
这里有一个很关键的直觉:如果数组有序,那么“上一个不重复的元素”其实就是数组里已经在慢指针位置及其之前保存好的最后一个元素。因为我们每发现一个新值都会把它写到慢指针位置,所以只要比较nums[fast]和nums[slow]是否相等,就能知道当前值是不是已经出现过的重复项。注意,这里的比较是“当前扫描值”和“最后一个保留值”比,而不是和它前一个原始位置比。
2.2 快慢指针的职责划分
用一句话给两个指针划清职责:
slow:指向结果数组的“下一个空位”。同时,nums[slow]也代表当前已保留的最后一个不重复元素(这个描述在初始状态下有点特殊,需要结合代码理解)。fast:遍历原始数组,负责发现“新的、和之前不同的元素”。
更严谨的写法通常是这样:
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初始化时slow = 0,意思是第一个元素一定保留,不需要比较它自己。从fast = 1开始扫描。当nums[fast]和nums[slow]相等时,说明当前元素和最后一个保留元素重复,什么都不做,fast继续前进。当它们不相等时,说明发现了一个新元素,先把slow向后移一位,再把当前值写进去。
这里建议初学者先不要盯着slow和fast的位置死记硬背,而是想象两个人在跑操:slow是“成果验收员”,只站到已经确认无重复的区域最后一个位置;fast是“侦察兵”,不断向前看新面孔,看到新面孔就喊一声,验收员往前挪一步,把他收到队伍里。重复值再多,验收员也纹丝不动。
2.3 通过例子走一遍全过程
纸上谈兵不够,我们手动走一遍。假设输入是[0,0,1,1,1,2,2,3,3,4]。
初始状态:slow = 0,指向0;fast = 1,指向第二个0。
nums[1] == nums[0],相等,跳过。fast = 2。nums[2] == 1,和nums[0] = 0不等。slow变成1,将nums[1]赋值为1。此时数组变成[0,1,1,1,1,2,2,3,3,4]。注意,原数组第二个位置的1被覆盖成了1,没变化,但位置1现在代表结果区的第二个元素。fast = 3,nums[3] == 1,和nums[1] = 1相等,跳过。fast = 4,nums[4] == 1,和nums[1] = 1相等,跳过。fast = 5,nums[5] == 2,和nums[1] = 1不等。slow变成2,nums[2]被赋值2。- 以此类推,最后
slow停在9?让我们数一下:不重复元素是0,1,2,3,4共 5 个,slow从 0 开始,经历了 4 次不同写入,slow最终等于4(最后一个元素下标),返回值是slow + 1 = 5。
细心的同学会发现,slow指向的位置在循环中一直是“最后一个保留元素”,但是当slow等于fast时,赋值nums[slow] = nums[fast]相当于自己给自己赋值,完全无害。这也是双指针写法里最常见的隐蔽细节:不要担心覆盖会破坏还没扫描到的数据,因为slow永远小于等于fast,它所覆盖的位置,fast早就扫过了。
3. 手写代码:Python / C++ / Java 三种实现
3.1 Python:最贴近思路的写法
Python 写这种题最直观,因为你不需要纠结数组索引的类型声明,也不需要手动管理内存。上面第 2 节的代码可以直接用,但是我个人更喜欢另一种稍微泛化一点的写法,因为它能顺带解决“保留 K 个元素”的进阶题,后面会讲。
def removeDuplicates(nums): if len(nums) <= 1: return len(nums) slow = 1 for fast in range(1, len(nums)): if nums[fast] != nums[slow - 1]: nums[slow] = nums[fast] slow += 1 return slow这里slow初始化为1,表示“已经保留一个元素”,比较对象是nums[slow - 1]。逻辑和前面等价,但是初学阶段我个人更推荐第一种slow = 0的写法,因为nums[slow]就是“最后一个保留值”,不需要多一个减一操作,更容易理解。
不过要注意,如果nums是空列表,第一种写法必须先判断if not nums;第二种写法也要判断长度。边界条件永远不能丢。
3.2 C++:注意边界与索引细节
C++ 写法和 Python 几乎一一对应,但有一些小细节值得警惕。
class Solution { public: 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]) { ++slow; nums[slow] = nums[fast]; } } return slow + 1; } };这里最容易忽略的是nums.size()的返回类型是size_t,无符号类型。如果nums为空,nums.size() - 1就会变成巨大的数。所以在 C++ 里一定要先判空,再谈后面的逻辑。另外fast < nums.size()这个条件会隐式把fast转成无符号类型比较,一般没问题,但如果你不小心把fast声明成int且nums.size()是 0,循环边界就会失控。稳妥做法是直接写for (int fast = 1; fast < (int)nums.size(); ++fast),或者开头判空后size一定大于等于 1,其实不转也安全,但养成显式转换的习惯可以少踩一些隐蔽的坑。
还有一点,C++ 的 vector 是引用传入,你修改nums就是直接改原数组,符合“原地”要求。如果你误把参数写成值传递,那就等于复制了一份,改了半天原数组没变,平台判题直接失败。这是我见过不少新手在 C++ 里犯的低级错误。
3.3 Java:从语言角度看同样逻辑
Java 的数组自带length属性,不需要函数调用,写法上比 C++ 清爽些。
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[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; } }Java 和 C++ 的代码几乎一样,因为核心逻辑就是数组下标操作。唯一要注意的可能是slow++和++slow在赋值场景下的区别。上面代码是先slow++,再nums[slow] = nums[fast],如果你写成nums[slow++] = nums[fast],那么赋值用的下标是旧值slow,写完后slow才加一。这两种写法效果不同,但都能工作?我们来验证一下:如果slow是 0,nums[slow++] = nums[fast]会把值写到下标 0,然后slow变成 1。但第一个元素本来应该保留在 0,如果当前fast扫描到的是第二个元素并且它和nums[0]不同,你把它写到nums[0],就把原第一个元素覆盖了,结果完全错了。所以先移动slow再写入是正确的,先写后移只适用于slow表示“下一个写入位置”的写法。这两种风格初学者必须分清,不然很容易写出逻辑相反的 bug。
3.4 复杂度分析
双指针解法的时空复杂度非常优秀。
- 时间复杂度:O(n)。
fast指针从数组头走到尾,每个元素只被访问一次;slow指针虽然也会移动,但它最多移动 n 次,两个指针整体是线性扫描。 - 空间复杂度:O(1)。只用了常数个变量,没有和 n 相关的额外空间。
这种 O(n) 时间、O(1) 空间的解法,已经是这道题的最优解。面试时你说出复杂度,再解释清楚为什么不能更快,基本就能过关。为什么时间不可能低于 O(n)?因为至少要看一遍所有元素才能知道哪些重复,就像不把整本书翻一遍,你不可能知道哪些句子是抄的。
4. 新手最容易踩的四个坑
4.1 忘了处理空数组和长度为 1 的输入
很多人在for循环里用了nums[slow],当nums为空时直接越界。比如写成:
slow = 0 for fast in range(1, len(nums)): ...如果nums = [],len(nums)是 0,range(1, 0)不会执行,函数最后返回slow + 1 = 1。原数组长度为 0,你返回 1,必然错误。所以要在一开始就判断:
if len(nums) == 0: return 0对于长度 1 的数组,range(1, 1)不执行,返回slow + 1 = 1,正确,所以不需要单独判断len(nums) == 1,但如果你使用的是slow = 1的写法,空数组依然要处理。总之一句话:入口先判空,永远不亏。
4.2 覆盖顺序写反导致结果错乱
这是最常见、也最隐蔽的一个坑。上面 Java 部分提到过nums[slow++] = nums[fast]和先slow++再nums[slow] = nums[fast]的区别。再举一个具体例子。
假设数组是[1,2,3],我们希望保留全部。初始slow = 0,fast = 1,发现2 != 1。如果写成nums[slow++] = nums[fast],会把2写到nums[0],数组变成[2,2,3],slow变成 1。接着fast = 2,发现3和nums[slow] = nums[1] = 2不等,再nums[slow++] = nums[fast],把 3 写到nums[1],数组变成[2,3,3],返回slow + 1 = 3。答案长度是 3,但数组前三个元素是2,3,3,丢了原来的 1,判题就会失败。所以记住:先把慢指针向后挪一挪,把新元素放到空位上,而不是覆盖掉当前最后一个保留值。
4.3 把 nums[fast] 和 nums[fast - 1] 比较
有些同学会觉得,既然数组有序,那直接判断当前元素和前一个元素相不相等不就行了?我承认,在这个具体的“删除所有重复项且保留一个”的场景下,用nums[fast] != nums[fast - 1]配合slow移动也能通过。但它有一个前提:你比较的是原始数组中相邻元素,而数组的前半部分可能已经被覆盖了。如果慢指针的写入位置正好落在fast - 1上,就会用新值覆盖旧值,导致后续比较出错。
我不建议新手用这种“比较相邻”的写法。不是因为不能通过,而是因为它破坏了双指针的通用性,而且你很难解释清楚为什么在边界条件下依然正确。相比之下,nums[fast] != nums[slow]的语义是“当前元素是否和已保留的最后一个元素相同”,无论数组前部分被覆盖成什么样,都不影响判断。这才是可迁移的思路。
为了让你彻底放心,我们看一眼覆盖前后的状态:slow永远指向“结果数组的末尾”,它前面的位置都已经是不重复序列了。fast指向“待检查元素”。两个指针之间可能夹着很多已经被跳过的重复元素,也可能紧挨着。慢指针写入的位置一定在fast之前或者等于fast,不可能覆盖掉fast还没看的数据。这是双指针解法的安全基石。
4.4 返回值写错:长度不是索引
因为slow是最后一个保留元素的下标,所以返回值是slow + 1。我见过有人直接返回slow,结果所有测试用例都差 1。这个错误在样例[1,1,2]里特别容易发现:slow最后等于 1,返回 1 显然是错的,应该是 2。还有人会写成slow + 2,把下标和长度混得更乱。建议你在纸上推演样例时,刻意数一下结果数组里有多少个元素,再回看slow的数字,把“下标”和“长度”的换算刻在脑子里:长度为 n 的数组,最后一个元素下标是 n-1;不重复元素个数 = 最后一个不重复元素下标 + 1。
4.5 不知道如何验证答案
在刷题平台上,你提交后平台会自动验证。但本地调试时,怎么确认自己改对了?这里分享一个我自己常用的验证方法:先调用函数得到返回值len,然后打印数组的前len个元素,再手动检查是否无重复且有序。
nums = [0,0,1,1,1,2,2,3,3,4] new_len = removeDuplicates(nums) print(new_len) # 5 print(nums[:new_len]) # [0, 1, 2, 3, 4]如果打印结果不是[0, 1, 2, 3, 4],说明某个环节出了问题。另一个办法是用断言做自动化验证:
assert new_len == 5 assert nums[:new_len] == [0, 1, 2, 3, 4]养成写断言的习惯,以后做更复杂的题也能快速定位问题。
5. 进阶:从“删除重复项”到“最多保留 K 个元素”
5.1 通用解法的推导
这道题还有一种更通用的双指针写法,可以应对“有序数组中每个元素最多保留 k 个”的系列题目。核心思想是:对于当前扫描到的元素nums[fast],只要它不等于位置slow - k上的元素,就说明它在已保留序列中出现的次数还不到 k 个,可以保留。
以 k=1 为例,上面的写法就是nums[fast] != nums[slow - 1]。当 k=2,也就是“删除有序数组中的重复项 II”(某平台第 80 题),题目要求每个元素最多出现两次,这时候只需要把比较对象从slow - 1改成slow - 2:
def removeDuplicatesK(nums, k): if len(nums) <= k: return len(nums) slow = k for fast in range(k, len(nums)): if nums[fast] != nums[slow - k]: nums[slow] = nums[fast] slow += 1 return slow注意这里的slow初始化成k,表示前 k 个元素天然保留。比如 k=2,数组前两个元素哪怕都是同一个值,也允许保留。从第 3 个元素开始,如果它和往前数第 2 个位置(即slow - 2)的值相同,说明相同元素已经有 2 个了,当前这个就要跳过;否则就保留。
这个通用模板非常实用。面试官让你先做第 26 题,往往下一问就是第 80 题。如果你能当场把这个模板写出来,并在复杂度分析后解释清楚为什么slow - k比较可以保证最多保留 k 个,会是很强的加分项。
5.2 第 80 题的思路延伸
具体到“每个元素最多保留 2 次”,我们走个例子[1,1,1,2,2,3]。k=2,slow=2,fast=2,nums[2]=1,nums[slow-2]=nums[0]=1,相等,说明 1 已经出现两次,跳过。fast=3,nums[3]=2,nums[0]=1,不等,保留:nums[2]=2,slow=3。fast=4,nums[4]=2,nums[slow-2]=nums[1]=1,不等,保留:nums[3]=2,slow=4。fast=5,nums[5]=3,nums[2]=2,不等,保留:nums[4]=3,slow=5。返回 5,去重后前五个元素是[1,1,2,2,3],正确。
你会发现,掌握了这个模板,等于同时拿下了两道题。这也是为什么我强烈建议零基础同学不要只背代码,而是理解slow和fast之间那个“距离”的含义:slow - k指向的是结果序列中“从末尾往前数第 k 个”位置。这种面向区间边界的思考方式,比死记硬背结论更有价值。
5.3 双指针在真实场景中的映射
你可能觉得这种数组去重题只存在于刷题平台,实际工作里用不上。其实不然。很多工程场景都在做类似的事情:比如数据清洗时,需要从已排序的日志序列中剔除重复的id;视频剪辑软件里,需要把连续重复的关键帧去掉;数据库的索引去重、流式数据处理里的“只保留最新值”等等,本质上都是在一个可变的区间内维护“有效数据”的边界。
双指针法特别适合那些“不能占用大量额外内存”的嵌入式环境或高并发服务。你不可能每来一条数据就新建一个结构去存,而是要在原缓冲区上原地整理,把有效数据往前压。这道题练的就是这个基础能力。
我自己在写一个日志清理工具时,就遇到过一个类似场景:内存里有一个按时间排序的缓存数组,需要把同一秒内重复的日志级别过滤掉,只保留第一条。当时第一反应就是用双指针,一个指针读,一个指针写,几行代码搞定,既不用开辟新数组,也不用list.remove这种 O(n) 操作反复搬移数据。刷题的价值,正是在这种时刻体现出来的。
最后再分享两个实战小技巧
第一个技巧:如果你在本地调试时总是分不清slow和fast的关系,可以在循环里临时打印中间状态。比如:
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] print(f"fast={fast}, slow={slow}, nums_slice={nums[:slow+1]}") return slow + 1看到每一步数组前缀的变化,会比盯着代码想一百遍更管用。等完全理解了再删掉打印语句。
第二个技巧:做这道题之前,先自己把“数组”和“数组下标”这两个概念彻底搞清楚。很多零基础同学卡住,不是因为双指针难,而是不知道nums[slow]和slow是两回事。slow只是一个整数,nums[slow]是这个整数对应位置的值。把它们的关系理清,双指针就成功了一半。
这道 26 题是我建议所有初学者在刷题早期就做的题目。它难度不高,却把“原地修改”“指针思想”“边界处理”这三个最重要的基本功都覆盖了。认真吃透它,后面再碰滑动窗口、链表快慢指针、或者更复杂的双指针题目,你会发现很多思路都是相通的。希望这篇带刷笔记能帮你迈出第一步,并且走得稳一点。