给定一个升序数组,原地删除重复元素,使每个值最多出现两次,返回处理后的长度 k,数组前 k 个位置放保留下来的元素,后面的内容不重要。这是第 26 题的推广:那一题每个值保留 1 个,这一题保留 2 个。做法是双指针,但比较的对象变了:k 是已保留部分的长度,对于当前元素 x,如果 k < 2(前两个直接保留),或者 x != nums[k - 2](x 不等于已保留部分的倒数第二个),说明 x 目前最多出现了一次,保留它。这个写法可以推广到"每个值最多保留 m 个":只要把 2 换成 m。本文用真实数据展示"与原数组里前两个位置比较"这个看起来相似的写法为什么错,给出五种语言的实现,并把双指针做成分步演示。
完整题目见官方页面(链接在文末)。评测只检查返回的 k 和前 k 个元素,本文的用例输出是 [k, 前 k 个元素]。基准解用字典记录每个值已经保留了几次,与双指针"看已保留部分的倒数第二个"是两种独立的写法。一共 12 组用例,覆盖:官方两个示例、单个元素、每个值恰好 2 个、每个值出现 3 个或更多、全部相同:
输入 [[1,1,1,2,2,3]] 输出 [5,[1,1,2,2,3]] 输入 [[0,0,1,1,1,1,2,3,3]] 输出 [7,[0,0,1,1,2,3,3]] 输入 [[1]] 输出 [1,[1]] 输入 [[1,1]] 输出 [2,[1,1]] 输入 [[1,1,1]] 输出 [2,[1,1]] 输入 [[1,2,3]] 输出 [3,[1,2,3]]
一个很容易想到的写法是拿 nums[i] 与 nums[i - 2] 比较:如果相同,说明连续出现了三次,跳过。但数组在扫描过程中已经被覆盖了(保留的元素被写到了前面),nums[i - 2] 不再是原数组里的那个位置,也不一定是"已经保留下来"的元素,比较就没有意义。正确的比较对象是已保留部分的倒数第二个,也就是 nums[k - 2]。下面是错误写法与 solve 的真实结果:
# 拿当前元素与原数组里前两个位置比较的写法(数组会被覆盖,比较的对象已经被改过) [1, 1, 1, 2, 2, 3]: 该写法 -> [4, [1, 1, 2, 3]] solve -> [5, [1, 1, 2, 2, 3]] [0, 0, 1, 1, 1, 1, 2, 3, 3]: 该写法 -> [7, [0, 0, 1, 1, 2, 3, 3]] solve -> [7, [0, 0, 1, 1, 2, 3, 3]] [1, 1, 1, 1]: 该写法 -> [2, [1, 1]] solve -> [2, [1, 1]] [1, 2, 2, 2, 3]: 该写法 -> [4, [1, 2, 2, 3]] solve -> [4, [1, 2, 2, 3]] 随机 20000 个数组:该写法出错的有 5363 个(26.8%)
官方示例 [1, 1, 1, 2, 2, 3] 应该保留 5 个([1, 1, 2, 2, 3]),错误写法只保留了 4 个,把第二个 2 也丢了;[1, 2, 2, 2, 3] 也丢了一个。第二组和第三组两种写法碰巧一致。在随机的 20000 个数组里,这种写法出错的有 5363 个,占 26.8%。
k 是已保留部分的长度。对每个元素 x(按顺序读取):如果 k < 2,直接保留;否则如果 x != nums[k - 2],说明保留部分的最后两个里至少有一个与 x 不同,也就是 x 至多已经出现了一次,保留它:写到 nums[k],k 加一。否则(x 等于 nums[k - 2],而数组有序,所以 nums[k - 1] 也等于 x)说明 x 已经保留了两个,跳过。因为数组升序,同一个值一定连续出现,所以只需要看已保留部分的倒数第二个。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。
Python · 解法代码def solve(nums): k = 0 # nums[0:k] 是已经处理好的部分:每个值最多出现 2 次 for x in nums: if k < 2 or x != nums[k - 2]: # 前两个直接保留;之后只要 x 不等于"倒数第二个保留的数",就说明 x 至多出现了 1 次 nums[k] = x k += 1 return [k, nums[:k]]
$ python3 solution.py # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON [[1,1,1,2,2,3]] → [5,[1,1,2,2,3]] [[0,0,1,1,1,1,2,3,3]] → [7,[0,0,1,1,2,3,3]] [[1]] → [1,[1]] [[1,1]] → [2,[1,1]] [[1,1,1]] → [2,[1,1]] [[1,2,3]] → [3,[1,2,3]]
Java · 解法代码class Solution { public int removeDuplicates(int[] nums) { int k = 0; // nums[0..k) 是已经处理好的部分:每个值最多出现 2 次 for (int x : nums) { if (k < 2 || x != nums[k - 2]) { // 前两个直接保留;之后只要 x 不等于"倒数第二个保留的数",就说明 x 至多出现了 1 次 nums[k++] = x; } } return k; } }
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl [[1,1,1,2,2,3]] → [5,[1,1,2,2,3]] [[0,0,1,1,1,1,2,3,3]] → [7,[0,0,1,1,2,3,3]] [[1]] → [1,[1]] [[1,1]] → [2,[1,1]] [[1,1,1]] → [2,[1,1]] [[1,2,3]] → [3,[1,2,3]]
Go · 解法代码func removeDuplicates(nums []int) int { k := 0 // nums[0:k] 是已经处理好的部分:每个值最多出现 2 次 for _, x := range nums { if k < 2 || x != nums[k-2] { // 前两个直接保留;之后只要 x 不等于"倒数第二个保留的数",就说明 x 至多出现了 1 次 nums[k] = x k++ } } return k }
$ go build -o bin . && ./bin < cases.jsonl [[1,1,1,2,2,3]] → [5,[1,1,2,2,3]] [[0,0,1,1,1,1,2,3,3]] → [7,[0,0,1,1,2,3,3]] [[1]] → [1,[1]] [[1,1]] → [2,[1,1]] [[1,1,1]] → [2,[1,1]] [[1,2,3]] → [3,[1,2,3]]
JavaScript · 解法代码function removeDuplicates(nums) { let k = 0; // nums[0..k) 是已经处理好的部分:每个值最多出现 2 次 for (const x of nums) { if (k < 2 || x !== nums[k - 2]) { // 前两个直接保留;之后只要 x 不等于"倒数第二个保留的数",就说明 x 至多出现了 1 次 nums[k++] = x; } } return k; }
$ node run.js < cases.jsonl [[1,1,1,2,2,3]] → [5,[1,1,2,2,3]] [[0,0,1,1,1,1,2,3,3]] → [7,[0,0,1,1,2,3,3]] [[1]] → [1,[1]] [[1,1]] → [2,[1,1]] [[1,1,1]] → [2,[1,1]] [[1,2,3]] → [3,[1,2,3]]
C++ · 解法代码#include <vector> using namespace std; class Solution { public: int removeDuplicates(vector<int>& nums) { int k = 0; // nums[0..k) 是已经处理好的部分:每个值最多出现 2 次 for (int x : nums) { if (k < 2 || x != nums[k - 2]) { // 前两个直接保留;之后只要 x 不等于"倒数第二个保留的数",就说明 x 至多出现了 1 次 nums[k++] = x; } } return k; } };
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl [[1,1,1,2,2,3]] → [5,[1,1,2,2,3]] [[0,0,1,1,1,1,2,3,3]] → [7,[0,0,1,1,2,3,3]] [[1]] → [1,[1]] [[1,1]] → [2,[1,1]] [[1,1,1]] → [2,[1,1]] [[1,2,3]] → [3,[1,2,3]]
共 12 组用例(含边界),以暴力解的结果为基准逐组比对: Python 通过 12/12 Java 通过 12/12 Go 通过 12/12 JavaScript 通过 12/12 C++ 通过 12/12
数组长度分别为 1 万、10 万、100 万,每个值出现 5 次,每个规模取三次中最快的一次,基准是用字典记录次数的做法:
数组长度 n(每个值出现 5 次) | 暴力解 | 本文解法
10000 | 0.526 ms | 0.307 ms
100000 | 5.022 ms | 3.043 ms
1000000 | 78.970 ms | 32.276 ms双指针是线性的:从 10 万到 100 万(10 倍)耗时增长约 10.6 倍;字典做法也是线性,但每个元素要查两次字典,100 万时 79.0ms,双指针 32.3ms,约 2.4 倍。这里只测了 Python。
Python 版本另外和字典做法做了随机对拍,同时比较返回的个数和前 k 个元素:长度 1 到 16、取值 -4 到 4,重复值很多:
随机对拍 20000 组(长度 1 到 16、取值 -4 到 4 的有序数组(重复值很多);比较返回的个数和前 k 个元素),solve 与 brute 的结果全部一致。
另外四种语言只在上面 12 组固定用例上验证过,没有做大规模随机对拍。
下面的演示用一个每个值最多出现 4 次的数组。前两个 0 直接保留;1 与 nums[0](0)不同,保留,第二个 1 与 nums[1](0)不同,保留;第三个 1 与 nums[2](1)相同,说明已经保留了两个,跳过;第四个 1 同理跳过;之后的 2、3、3 都保留。最后 k = 7。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。