给定一个数组和一个值 val,原地移除数组中所有等于 val 的元素,返回剩下的元素个数 k;前 k 个位置放剩下的元素,顺序可以任意,后面的内容不重要。这是双指针的入门题:一个指针 i 扫描,一个指针 k 指向下一个要写入的位置。本文除了给出五种语言的实现,还比较了另一种"用最后一个元素补位"的做法,用真实的写入次数展示两种做法在不同数据上各有优劣,并把双指针做成分步演示。
完整题目见官方页面(链接在文末)。评测只检查返回的 k 和前 k 个元素(不要求顺序),本文的用例输出是 [k, 前 k 个元素],比较时不区分前 k 个元素的先后。基准解是列表推导式过滤,与原地覆盖是两种独立的写法。一共 10 组用例,覆盖:官方示例、空数组、单个元素、没有元素等于 val、全部等于 val、val 在头或尾:
输入 [[3,2,2,3],3] 输出 [2,[2,2]] 输入 [[0,1,2,2,3,0,4,2],2] 输出 [5,[0,1,3,0,4]] 输入 [[],0] 输出 [0,[]] 输入 [[1],1] 输出 [0,[]] 输入 [[1],2] 输出 [1,[1]] 输入 [[2,2,2],2] 输出 [0,[]]
k 指向下一个要写入的位置。从左到右扫描,遇到不等于 val 的元素就写到 nums[k] 并让 k 加一,等于 val 的直接跳过。扫描结束时 nums[0..k) 就是所有保留的元素,并且保留了它们原来的相对顺序。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。
Python · 解法代码def solve(nums, val): k = 0 # nums[0:k] 是已经留下的、不等于 val 的元素 for x in nums: if x != val: nums[k] = x k += 1 return [k, nums[:k]]
$ python3 solution.py # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON [[3,2,2,3],3] → [2,[2,2]] [[0,1,2,2,3,0,4,2],2] → [5,[0,1,3,0,4]] [[],0] → [0,[]] [[1],1] → [0,[]] [[1],2] → [1,[1]] [[2,2,2],2] → [0,[]]
Java · 解法代码class Solution { public int removeElement(int[] nums, int val) { int k = 0; // nums[0..k) 是已经留下的、不等于 val 的元素 for (int x : nums) { if (x != val) nums[k++] = x; } return k; } }
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl [[3,2,2,3],3] → [2,[2,2]] [[0,1,2,2,3,0,4,2],2] → [5,[0,1,3,0,4]] [[],0] → [0,[]] [[1],1] → [0,[]] [[1],2] → [1,[1]] [[2,2,2],2] → [0,[]]
Go · 解法代码func removeElement(nums []int, val int) int { k := 0 // nums[0:k] 是已经留下的、不等于 val 的元素 for _, x := range nums { if x != val { nums[k] = x k++ } } return k }
$ go build -o bin . && ./bin < cases.jsonl [[3,2,2,3],3] → [2,[2,2]] [[0,1,2,2,3,0,4,2],2] → [5,[0,1,3,0,4]] [[],0] → [0,[]] [[1],1] → [0,[]] [[1],2] → [1,[1]] [[2,2,2],2] → [0,[]]
JavaScript · 解法代码function removeElement(nums, val) { let k = 0; // nums[0..k) 是已经留下的、不等于 val 的元素 for (const x of nums) { if (x !== val) nums[k++] = x; } return k; }
$ node run.js < cases.jsonl [[3,2,2,3],3] → [2,[2,2]] [[0,1,2,2,3,0,4,2],2] → [5,[0,1,3,0,4]] [[],0] → [0,[]] [[1],1] → [0,[]] [[1],2] → [1,[1]] [[2,2,2],2] → [0,[]]
C++ · 解法代码#include <vector> using namespace std; class Solution { public: int removeElement(vector<int>& nums, int val) { int k = 0; // nums[0..k) 是已经留下的、不等于 val 的元素 for (int x : nums) { if (x != val) nums[k++] = x; } return k; } };
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl [[3,2,2,3],3] → [2,[2,2]] [[0,1,2,2,3,0,4,2],2] → [5,[0,1,3,0,4]] [[],0] → [0,[]] [[1],1] → [0,[]] [[1],2] → [1,[1]] [[2,2,2],2] → [0,[]]
共 10 组用例(含边界),以暴力解的结果为基准逐组比对: Python 通过 10/10 Java 通过 10/10 Go 通过 10/10 JavaScript 通过 10/10 C++ 通过 10/10
题目允许改变元素顺序,所以还有另一种做法:遇到等于 val 的元素,就用数组最后一个元素覆盖它,然后数组长度减一,并且这个位置要再检查一次(因为换过来的元素也可能等于 val)。这种做法在 val 很少的时候只需要很少的写入。下面是长度 100000 的数组,val 占不同比例时,两种做法真实的写入次数:
写入次数对比:长度 100000,val 在数组里占不同比例 val 占 1%: 正向覆盖写入 99000 次,从后往前补位写入 1000 次,保留个数 99000/99000 val 占 10%: 正向覆盖写入 90000 次,从后往前补位写入 10000 次,保留个数 90000/90000 val 占 50%: 正向覆盖写入 50000 次,从后往前补位写入 50000 次,保留个数 50000/50000 val 占 99%: 正向覆盖写入 1000 次,从后往前补位写入 99000 次,保留个数 1000/1000
val 占 1% 时,正向覆盖写了 99000 次,从后往前补位只写了 1000 次;val 占 50% 时两者相同(都是 50000 次);val 占 99% 时反过来:正向覆盖只写 1000 次,从后往前补位写了 99000 次。哪种更省写入取决于 val 在数据中的比例。补位法的代价是会改变保留元素的相对顺序,而正向覆盖保持顺序。这个实验只数了写入次数,没有测量实际耗时。
Python 版本另外和列表推导式做了随机对拍,比较个数和前 k 个元素(不区分顺序):
随机对拍 10000 组(长度 0 到 15、取值 -4 到 4 的随机数组,val 取 -4 到 4;比较返回的个数和前 k 个元素(不区分顺序)),solve 与 brute 的结果全部一致。
另外四种语言只在上面 10 组固定用例上验证过,没有做大规模随机对拍。这道题是一次线性扫描,所以没有做规模对比实验。
下面的演示用 [0, 1, 2, 2, 3, 0, 4, 2] 和 val = 2。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。淡色的是等于 val、被跳过的位置,绿色是已经保留的部分。