直接上双指针解法之前,先把这道题真正考的东西掰扯清楚。很多朋友刷题刷了十几遍,代码背得滚瓜烂熟,但面试官一换问法立刻卡壳,问题就出在只背了答案,没理解题目背后的数据结构特征。在有序数组上做"去重"这个操作,它的所有巧妙解法都源自数组本身的两条性质:有序和原地操作。这篇就把第12课的内容展开讲透,不仅有代码,还会告诉你为什么必须这么做、面试时候怎么讲、写了二十年代码的老兵是怎么一眼看穿这类题目的。
1. 题目到底在问什么:有序数组去重背后的数据结构本质
1.1 不是让你去重,是让你压缩数组
先看原题描述:给你一个有序数组 nums,请你原地删除重复出现的元素,使每个元素只出现一次,返回删除后数组的新长度。这里有几个关键词必须逐字抠清楚。
第一,"原地"是什么意思?意思是不能新建一个数组来存结果,必须直接在原数组上操作。这个限制一上来就堵死了最直观的做法:遍历原数组,发现不重复的塞进一个新数组。为什么题目要这么苛刻?因为在实际系统里,内存是稀缺资源,尤其是处理超大规模数据时,你不可能每次去重都原地复制一个几GB的数组出来,那系统直接就OutOfMemory了。这道题模拟的就是你在一个巨大的数组上做清理工作,手里只有一把螺丝刀,没有第二个工作台。
第二,"有序数组"给了什么信息?这是整道题最关键的突破口。因为数组有序,所以重复的元素必然是连续出现的,不可能出现 1, 2, 1, 3 这种隔一个再重复的情况。这就意味着当你从左往右扫描时,一旦遇到一个新值,后面所有跟它相同的值都会紧随其后。这个"连续分布"的特性,是所有高效解法的基石。
第三,"返回新长度"而不是"返回新数组",说明题目根本不关心数组后面那部分残留数据长什么样。比如 [1,1,2,2,3],处理完返回 3,数组前三位变成 [1,2,3],后面哪怕是 [2,2] 还是 [999,999] 都无所谓。这个宽容度为原地压缩提供了操作余地。
1.2 无序数组和有序数组的去重难度差了一个量级
很多人会问:那无序数组去重怎么办?如果是无序数组,要求原地去重,最直接的办法是先排序,排序之后就变成这道题了。如果不排序又要原地去重,那就只能暴力双重循环,每遇到一个元素就往前查一遍有没有重复,时间复杂度O(n^2),碰上十万级以上的数据直接卡死。
如果用哈希集合呢?确实能把无序去重做到O(n),但代价是额外空间O(n),并且丧失了"原地"的性质。面试官如果追问一句"能不能O(1)空间?",哈希方案当场作废。
所以有序数组去重之所以能玩出花,靠的就是有序性 + 原定性这两个约束组合出来的特殊解题空间:你既可以用双指针的思想做原地操作,又能保证线性时间复杂度。这类题目在LeetCode上的标签叫"双指针",但双指针只是一个宏观策略,核心在于你能不能发现"数组有序导致重复值连续"这个微观事实。
1.3 这道题在真实系统里的对应场景
说句实话,谁在实际开发里会天天写这种手撕去重?但你想想数据清洗的场景:日志系统里按时间排序的记录,需要去掉同一秒内重复的告警;数据库导出的一列有序ID,要去掉重复项再灌入下游。这些场景的共同点是:数据已经有序,内存有限,不能动辄创建大对象。这道题训练的核心能力就是空间受限条件下的线性扫描思维——你怎么用一个额外的变量,记录下整个扫描过程中的关键状态,达到用时间换空间的效果。这种能力在做流式计算、处理海量日志、设计在线算法时都极其重要。
2. 双指针方案的完整推导:从朴素想法到最优解
2.1 新手第一反应为什么是错的
拿到这道题,大多数人的第一反应是:边遍历边删。用 for 循环,遇到 nums[i] == nums[i-1] 就把 nums[i] 删掉。这个思路在逻辑上没错,但用代码实现时会发现两个问题。
第一个问题是:数组删除元素的成本极高。数组是连续内存空间,删掉中间一个元素,后面所有元素都要往前移动一位,一次删除就是O(n)的操作。最坏情况下,比如一个全是1的数组,要删n-1次,每次移动大量元素,整体复杂度飙升到O(n^2)。在一些在线判题系统里,直接给你一个性能超时的红色警告。
第二个问题是:循环索引的管理容易出错。删除元素后,后面的元素下标会变化,你必须在删完以后把索引回退一位,否则会跳过元素。我见过很多新手写着写着把索引搞乱,最后要么越界,要么漏掉连续重复的元素。
你发现没有,这两个问题的根源都在于"边遍历边删"这个思路,它把"扫描"和"修改"耦合在了一起。错误的根源不是代码写错,而是操作模型选错了。
2.2 逆转思路:扫描和压缩分离
既然边删边遍历代价太大,那换一个角度:我不删,我只把不重复的元素往前面搬,搬完以后返回一个长度,把它当成新数组的边界。
这个思路的转变非常关键。它把"删除"这个破坏性操作,变成了"筛选写入"这个建设性操作。数组的结构没有变,我们只是重新规划了前k个位置应该放什么值。后面的位置不管残留什么,都不影响最终结果。
这里打一个生活化的比方:假设你面前有一排柜子,每个柜子里放着一本书,现在要把重复书名的书清理掉。边删边遍历的做法是:发现两本《三体》,当场抽走一本,后面所有书往前挪一格,挪完还得检查下一格;而筛选写入的做法是:你拿着一个新标签,从头到尾扫一遍柜子,看到一本从没见过的书名,就往柜子最前面的空位放一本,同时给标签上加个计数。整个过程你不用挪动任何书,只需把新书放到前面,最后告诉别人"前k个柜子是不重复的"。哪个快?一目了然。
这个思路落到代码上,就是经典的快慢指针(或者叫读写指针)。慢指针指向"下一个要写入的位置",快指针负责"遍历整个数组寻找新值"。两者各司其职,互不干扰。
2.3 为什么快慢指针的正确性有保证
这可能是整道题最值得花时间想清楚的地方:为什么快指针扫描一遍,慢指针依次写入,结果就一定是对的?
核心在于利用有序性。因为数组有序,所以快指针扫描时,只有遇到一个新值才可能写入。判断"新值"的标准也很简单:nums[fast] != nums[slow - 1](即当前值与上一个已写入的值不同)。因为重复值连续排列,所以这个比较足以判断当前值是不是一个新的数字。
这里要注意一个容易被忽略的点:慢指针指向的位置是"下一个待写入的位置",所以慢指针的前一个位置 slow - 1 存的是"最近一次写入的值"。快指针指向的值只要跟它不同,就一定是新出现的值,因为中间不可能存在别的值(有序性保证所有相同的值都聚在一起)。
举个例子,数组 [1, 1, 1, 2, 3, 3, 4]。慢指针 slow 指向下标1,快指针 fast 遍历:
- fast=1,nums[1]=1 和 nums[slow-1]=nums[0]=1 相同,跳过。
- fast=2,nums[2]=1 和 nums[0]=1 相同,跳过。
- fast=3,nums[3]=2 和 nums[0]=1 不同,把 nums[3] 的值写到 nums[1],数组变成 [1,2,1,2,3,3,4],slow变为2。
- fast=4,nums[4]=3 和 nums[1]=2 不同,写入 nums[2],数组变成 [1,2,3,2,3,3,4],slow变为3。
- fast=5,nums[5]=3 和 nums[2]=3 相同,跳过。
- fast=6,nums[6]=4 和 nums[2]=3 不同,写入 nums[3],数组变成 [1,2,3,4,3,3,4],slow变为4。
最终返回 slow = 4,数组前四位是 [1,2,3,4],完全正确。手动模拟一遍就会发现,这个算法的本质是把数组"前面"变成一块净地,快指针在后面探路,探到宝藏就往净地里搬,而慢指针永远指向净地的边界。
3. 代码实现与边界条件:语言差异和最容易踩的坑
3.1 Java实现:面试最常用
public int removeDuplicates(int[] nums) { if (nums == null || nums.length == 0) { return 0; } // slow 指向下一个不重复元素要写入的位置 // 第一个元素必然保留,所以从 1 开始 int slow = 1; // fast 从第二个元素开始扫描 for (int fast = 1; fast < nums.length; fast++) { // 当前扫描的元素与前一个已保留元素不同 if (nums[fast] != nums[slow - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; }这段代码有四个细节值得专门拎出来讲。
第一个细节是空数组和 null 的处理。如果不加校验,nums.length 直接抛 NullPointerException,空数组则 for 循环根本进不去,slow 返回 1,这明显是错的。所以开头这个 guard clause 不是可有可无的,它是防御性编程的基本素养。面试时写不写这一步,直接影响面试官对你的专业度评分。
第二个细节是 slow 初始值为 1 而不是 0。为什么?因为第一个元素无论如何都会保留,它前面没有元素和它比较,不可能被判定为重复。用 slow=1 可以直接跳过无意义的判断。如果你从 0 开始,代码会变成这样:
int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (fast == 0 || nums[fast] != nums[slow - 1]) { nums[slow++] = nums[fast]; } }也能跑,但每轮循环都要多一次 fast == 0 的判断,性能影响微乎其微,逻辑却不如 slow=1 干净。我推荐 slow=1 的写法,因为它在语义上更精确地表达"第一个元素天然保留"。
第三个细节是为什么拿 nums[fast] 跟 nums[slow - 1] 比较,而不是跟 nums[fast - 1]。这是初学者最容易混淆的地方。跟 nums[fast - 1] 比较的代码是:
int slow = 1; for (int fast = 1; fast < nums.length; fast++) { if (nums[fast] != nums[fast - 1]) { nums[slow++] = nums[fast]; } }这个写法在有序数组下也是对的,因为相邻元素不同就说明新出现了一个值。但它本质上用的是"相邻比较"的判定逻辑,一旦场景变成"每个元素最多保留2个"(后面会讲),你就得改成跟 nums[slow - 1] 比较的写法。从一开始就统一思维模型,后面迁移到变体题时会顺畅得多。
第四个细节是 self-assignment 问题。当 nums[fast] != nums[slow-1] 成立时,理论上你需要把值写入 nums[slow]。但如果 fast 和 slow 是同一个下标呢?比如数组 [1,2,3] 这种完全没有重复的情况,slow 和 fast 同步前进,每一轮都是自己写自己:nums[1] = nums[1],nums[2] = nums[2]。这个操作虽然多余,但无害。你可以加一个判断 fast != slow 来避免,但实际上省略这一步完全没问题,而且代码更简洁。不过如果面试官问你"有没有做多余的写操作",你要能答上来这个点,并说明自己知道但选择不做判断的原因——大多数情况下,一次额外的内存写入成本远低于每次循环加一个分支判断的成本。
3.2 Python实现:Pythonic的写法
def removeDuplicates(nums): if not nums: return 0 slow = 1 for fast in range(1, len(nums)): if nums[fast] != nums[slow - 1]: nums[slow] = nums[fast] slow += 1 return slowPython 的写法在逻辑上和 Java 完全一致,只是少了 null 判断(Python 的 not nums 同时覆盖了空列表和 None 传入的情况,但严格说 None 会抛 TypeError,所以最好还是单独处理)。
这里特别想提醒一个 Python 特有的坑:很多人会写nums = list(set(nums)),然后把返回值设成len(nums)。这在功能上确实能得到正确结果,但完全违反了题目的"原地"限制。set会创建一个全新的哈希集合,还会打乱原有顺序(虽然 Python 的 set 对整数在小型环境下有伪有序性,但这不是语言规范承诺的)。更要命的是,这种写法把面试官想考察的算法能力全跳过了,面试现场这么写等于告诉对方"我没准备过这道题"。
3.3 边界条件汇总:哪些 case 必须测
我在面试别人和陪朋友模拟面试时,必问的测试用例就这几组,你在本地测代码时也建议按这个清单来:
| 场景 | 输入 | 期望输出 | 期望数组前几位 |
|---|---|---|---|
| 空数组 | [] | 0 | 无 |
| 只有一个元素 | [5] | 1 | [5] |
| 全部重复 | [7,7,7,7] | 1 | [7] |
| 无重复 | [1,2,3,4] | 4 | [1,2,3,4] |
| 普通混合 | [1,1,2,2,2,3,4,4] | 4 | [1,2,3,4] |
| 负数和正数混合 | [-3,-3,-2,-1,-1,0,1,1] | 5 | [-3,-2,-1,0,1] |
| 最小/最大边界值 | [-2147483648, 2147483647] | 2 | [-2147483648, 2147483647] |
特别是负数场景,很多人在推导时不注意,但算法的比较逻辑对负数依然成立,因为有序性不区分正负。数组元素是整数的区间是 [−2^31, 2^31−1],这个范围不会影响算法,只是提醒你测试时要覆盖负数。
还有一个边界:数组长度为1。此时 for 循环 fast 从 1 开始,直接不循环,slow=1,返回1。这个数组不用做任何操作,因为它天然不可能有重复项。
3.4 复杂度分析:为什么这是最优解
时间复杂度:快指针遍历一次数组,每次循环内都是O(1)操作,总复杂度O(n)。慢指针最多移动n次,总移动次数也是O(n)。这里的常数因子很小,就是几次比较和赋值。
空间复杂度:只用了 slow 和 fast 两个额外变量,O(1)额外空间,没有新建数组、没有递归调用栈。
这个复杂度已经触及问题的理论下界。为什么?因为你要判断每个元素是否重复,至少必须看一遍每个元素,所以不可能低于O(n)。空间上,只要原地操作,最多就是O(1)个额外变量,再少就没法记录状态了。所以双指针方案在渐进意义上已经是最优解,面试时可以很有底气地说"这个方案的时间和空间复杂度都已经达到最优"。
4. 上头延伸:从"最多保留1个"到"最多保留k个"的通解
4.1 变体:删除有序数组中的重复项 II(LeetCode 80)
LeetCode 80题是这道题最经典的变体:每个元素最多出现两次,还是原地修改,返回新长度。比如 [1,1,1,2,2,3],处理后前5位为 [1,1,2,2,3],返回5。
很多同学拿到60题,第一反应是再加一个计数器,记录每个元素出现了几次。这个思路可行,但实现时容易在重置计数器的时机上出问题。其实用我们前面的统一思维模型,这个变体的解法几乎不用改:
public int removeDuplicates2(int[] nums) { if (nums.length <= 2) { return nums.length; } int slow = 2; for (int fast = 2; fast < nums.length; fast++) { // 核心:当前元素与 slow-2 位置的元素比较 if (nums[fast] != nums[slow - 2]) { nums[slow] = nums[fast]; slow++; } } return slow; }看到没有,只是把比较对象从 slow - 1 换成了 slow - 2,初始值从1改成2,就完成了从"最多1个"到"最多2个"的跨越。为什么这样是对的?因为有序数组里,如果 fast 指向的值和 slow-2 指向的值不同,说明 fast 指向的值在已保留部分中最多只出现了一次(即使 slow-1 等于 fast 的值,那也只有两次;如果 slow-1 不等于 fast 的值,那 fast 是一个全新值,可以放心写入)。如果 fast 的值和 slow-2 的值相同,说明 slow-2、slow-1 已经是两个相同的值了,再写进去就变成三个,必须跳过。
这个写法比计数器方案优雅得多,因为它不依赖额外的状态变量,把约束直接映射到了下标距离上。
4.2 继续抽象:最多保留k个的通用模板
如果你能看懂上面从1到2的跳跃,那就可以一步概括到"每个元素最多保留k个":
public int removeDuplicatesK(int[] nums, int k) { if (nums.length <= k) { return nums.length; } int slow = k; for (int fast = k; fast < nums.length; fast++) { if (nums[fast] != nums[slow - k]) { nums[slow] = nums[fast]; slow++; } } return slow; }k=1时就是原题,k=2时就是80题。写到这里你可能会问:那如果要求"最多保留0个"呢?把重复项全删光?那题目的复杂度就变了,因为head元素的处理逻辑不一样,普通双指针模板需要特殊调整,这里不展开,但说明一点:双指针模板是一个可以举一反三的框架,而不是一道死题的答案。
这个通解在面试中极具杀伤力。面试官问完原题,通常喜欢追加一句"如果要保留两个呢",你直接把代码里的1改成2,同时解释清楚为什么 slow - 2 是对的,面试官基本就能判断你已经完全掌握这类题的本质。
4.3 同家族的其他题目:移动零、移除元素
原题还有一个很值得玩的亲戚:283题移动零。给定数组 [0,1,0,3,12],要求把0全部移到末尾,同时保持非零元素相对顺序。解法其实是同一套双指针思想:
public void moveZeroes(int[] nums) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != 0) { // 交换而非覆盖,因为题目要求保留数组长度 int temp = nums[slow]; nums[slow] = nums[fast]; nums[fast] = temp; slow++; } } }区别在哪里?移动零要求数组长度不变,所以不能简单地用覆盖,必须做交换。而去重题只要返回新长度,数组后面残留什么无所谓,所以可以直接覆盖。这个差异反映出一个小规律:题目是要求"压缩"还是"移动",决定了你是用覆盖还是交换。
再看27题移除元素:给定一个数组和一个值val,原地移除所有等于val的元素,返回新长度。解法几乎和原题一模一样,只是比较条件从 nums[fast] != nums[slow-1] 换成 nums[fast] != val:
public int removeElement(int[] nums, int val) { int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != val) { nums[slow] = nums[fast]; slow++; } } return slow; }注意这里 slow 初始值是0而不是1,因为 val 可能是第一个元素,第一个位置也需要参与筛选。这三道题放在一起看,你会发现它们共享同一个骨架:一个指针负责"写入",一个指针负责"读取",筛选条件不同,输入输出的细节不同,但骨架完全一致。这就是算法题"刷一道会一类"的真正含义,而不是简单把代码背下来。
5. 从刷题到面试:这一课隐藏的考察点
5.1 面试官最想从这道题听到什么
这道题在LeetCode上的难度是简单(Easy),但简单题恰恰是面试中最高频、也最容易暴露问题的题。因为简单题人人都能做对,面试官考察的就不只是"你能不能写出正确代码",更是你思考问题的方式和代码的工程素养。
首先,面试官会看你能不能主动聊清楚"为什么双指针可行"。你如果上来就默写代码,不给任何推导过程,那对不起,在面试官眼里你和背了答案没有区别。正确的做法是先将题目翻译成自己的理解:"这个数组是有序的,所以重复项必然连续,我只要用一个指针维护'已清理区域'的边界,另一个指针去探查新值,就可以在线性时间和常数空间内完成"。
其次,面试官会追问边界条件和细节。比如:slow为什么从1开始?空数组怎么处理?如果数组只有一个元素呢?这些问题考察的是你不是只会在 LeetCode 的测试用例里跑通,而是在任何输入下都能保证正确性和鲁棒性。
最后,面试官大概率会抛出一个变体,比如80题或"最多保留k个"的抽象问题。你如果能当场从原题推导出通解,说明你掌握了规律而不是背了答案,这是面试中最稀缺的能力。
5.2 时间复杂度的另一个视角:最坏情况与平均情况
虽然我们说O(n),但两个实际场景值得细细品味一下。
最坏情况:数组完全没有重复,比如 [1,2,3,4,5]。此时每次循环 fast 的值都和 slow-1 不同,每轮都需要一次比较和一次写入,而且写入的目标位置就是当前位置。一共n-1次无意义的自我赋值。哪来的性能问题?几乎没有,但对超大规模数组来说,这些多余的写操作会消耗内存带宽。
怎么优化?可以加一个判断if (fast != slow)来避免自我赋值。但代价是每轮循环多一次分支判断。分支判断在现代CPU上是有预测成本的。这里就出现了一个经典的性能权衡问题:是少写一次内存(但多判断一次)还是多写一次内存(但省去判断)?我的经验是:对于一般数据规模,省略判断的简洁写法反而更快,因为CPU的分支预测器对nums[fast] != nums[slow-1]这个条件本身已经预测得很准,再加一个分支反而降低IPC。只有当数组特别大(百万级以上)且数据多数没有重复时,才值得用一次额外判断消除自我赋值。
平均情况和最坏情况的区别在于:最坏情况下写入次数是n,平均情况大约 n/2 次写入,但复杂度量级不变。我在面试中通常会主动提一句这个优化点,并说明自己的取舍理由,这会给面试官留下"这人不只会写代码,还懂系统性能"的印象。
5.3 这道题和你后面的算法学习路线
很多初学者刷题有一个误区:按题库顺序从头刷到尾,结果刷到第100题时发现第50题的思路全忘了。我的建议是把每道题当作一个"定理"来学,主动构建知识网络。
这道题属于双指针家族里的"同向双指针"(快慢指针)类别。同向双指针的场景远不止去重,还包括:链表中环的检测(快慢指针一个走一步一个走两步)、有序数组的两数之和(左右夹逼)、滑动窗口(两个指针维护一个区间)等。你在学这道题时如果顺手把这些变体都看一遍,就不只是学会一道题,而是建立了整个双指针知识体系。
另外,这道题还牵出一个重要的思维模式:用"覆盖"代替"删除"。在数组这个线性结构里,删除永远比覆盖贵,因为涉及大批量元素移动。在很多分布式系统、数据库实现中,这种"逻辑删除+物理覆盖"的思维也广泛存在,比如日志系统的 compaction、数据库的 MVCC 清理。
6. 实际动手环节:手撕代码的完整体验
6.1 我建议的练习路径
如果你看这篇文章是为了准备面试,不要直接抄代码跑通就算完。我建议按这个路径练一遍:
第一步,关闭IDE,打开编辑器,憋着自己写一遍完整代码。包括入口函数、边界判断、循环逻辑、返回值。写完再跟标准答案对比。
第二步,把代码改成Python版和Golang版各写一遍。语言不重要,重要的是你能否用不同语法表达同一套逻辑。注意:这其实是在训练"算法和语言解耦"的能力。
第三步,把所有测试用例手打一遍,输入到自己的代码里,观察结果。特别是 [1,1]、[1,2]、[1,1,1] 这种极限短的数组。
第四步,打开一个空白文件,不看任何资料,把80题和27题的代码也写出来。如果写不出来,回头重新看一遍这篇文章的第4节。
第五步,也是最容易忽略的一步:写出代码的时间复杂度、空间复杂度分析,然后对着镜子把解题思路口头讲述一遍。你如果能不看代码把自己的思路给一个完全没做过这题的人讲明白,才算真正掌握了。
6.2 我踩过的坑:一次生产环境的数据清理事故
说了这么多理论,讲个我自己的真实经历让你感受一下这些代码思维的实际价值。
几年前我在做一个数据清洗项目,有一张表存储了按时间排序的用户操作日志,其中同一个用户在1秒内的重复操作需要合并。数据量大概是几千万行。当时团队里有个刚入职的同事接了这个任务,他的第一版方案是:用 Python 的 pandas 把数据读进来,drop_duplicates 之后写回。跑了一个小时没跑完,还差点把服务器的内存打爆。
后来我接手一看,这个需求本质上就是"有序数组去重"的工程版:数据按时间有序,只需要保留每个用户在每秒内的第一条记录。我改成了类似双指针的流式处理思路——用一个变量记录上一条已经保留的记录,边读边写边过滤,内存占用恒定为几个字节,处理完全部数据不到五分钟。
这件事让我特别深刻地理解了一个道理:不要以为LeetCode题只是面试敲门砖,这些基础算法在你处理真实数据时就是救命稻草。只不过在面试题里数组是纯内存结构,在工程里数据可能在磁盘、在消息队列、在分布式存储里,但核心的"线性扫描 + 少量状态变量"这个模式,放之四海而皆准。
6.3 给新手的三个"千万不要"和一个"一定要"
根据我这些年改代码和带新人的经验,给刚接触这道题的朋友三点忠告。
千万不要试图在遍历的时候用nums.remove()或nums.pop()来删除元素。这个操作在Python和Java里都是O(n)的,还要处理索引偏移,写出来的代码既慢又容易出bug。
千万不要在没理解为什么要用双指针的情况下强行记忆代码。题目稍微一变你就懵了,面试官一追问你就慌了,连自己也骗不过去,更别说骗过面试官。
千万不要认为数组去重只能靠哈希表。哈希表虽然通用,但在要求"原地+有序+常数空间"的场景里,快慢指针才是最优解。算法选型三十年河东三十年河西,判断标准永远是题目约束。
一定要手动模拟一遍全过程。哪怕你觉得代码已经写明白了,也在纸上把 [1,1,2,2,3] 走一遍 slow 和 fast 的每一步变化。我担保你手动模拟完以后,对这道题的理解会提升一个量级,而且你会发现一些"读代码时看不到的细节"。
6.4 继续往深处走:如果输入的"数组"是个链表?
最后留一个值得思考的延伸:如果题目换成"删除有序链表中的重复节点",你会怎么做?
链表和数组最大的区别是,链表删除节点是O(1)的操作,因为你只需要改指针。所以链表的去重反而比数组简单——不需要覆盖和搬移,直接截断连接就行:
public ListNode deleteDuplicates(ListNode head) { ListNode cur = head; while (cur != null && cur.next != null) { if (cur.val == cur.next.val) { cur.next = cur.next.next; } else { cur = cur.next; } } return head; }数组和链表这对CP在算法题里经常成对出现,同一道题换容器,解法思路就要跟着调。数组强调"空间连续",所以操作用覆盖;链表强调"节点离散",所以操作靠指针重连。你有没有发现,当数据结构变了,但"利用有序性、线性扫描"这个核心思想没变?这就是算法学习里最重要的"元能力"——从具体容器中抽象出通用规律。
这道题本身很简单,但它像一把钥匙,打开的是双指针、原地操作、有序数据特性、算法复杂度分析这一整扇门。把这节课吃透,后面再遇到滑动窗口、链表去重、有序数组合并,你都会有"这题我见过"的底气,而不是"这题我不会"的慌张。