LeetCode / JavaScript No.06 过滤数组中的元素
🗿🗿🗿本系列文档在 LeetCode 30 天 JavaScript 挑战 完成后可看到题解,本人只转载方便自看
问题描述
给定一个整数数组 arr 和一个过滤函数 fn,并返回一个过滤后的数组 filteredArr 。
fn 函数接受一个或两个参数:
- arr[i] - arr 中的数字
- i - arr[i] 的索引
filteredArr 应该只包含使表达式 fn(arr[i], i) 的值为 「真值」 的 arr 中的元素。「真值」 是指 Boolean(value) 返回参数为 true 的值。
在不使用内置的 Array.filter 方法的情况下解决该问题。
真值和伪值
在 LeetCode 过滤数组中的元素 问题中,要求从数组中移除所有不是 「真值」 的值(即移除所有 「伪值」)。JavaScript
有两个布尔值 true
和 false
。但实际上可以将任何值放在 if
语句中。该值将根据其「真值」被强制转换为布尔值。
所有值都被认为是真值,除了以下几种情况:
false
- 所有形式的零,包括
0
、-0
(0/-1
的输出)和0n
(BigInt(0)
的输出) NaN
(“Not a Number”,可以通过0/0
获得)""
(空字符串)null
undefined
存在这样的语言特性,简单理解是它很方便。比如下面这样:
if (userInput !== null && userInput !== "") {
...
}
// 上面代码可以简化为
if (userInput) {
...
}
但是,如果不仔细思考、不明确哪些值是有效的,就会容易出现 bug。例如,零或空字符串可能是完全有效的输入,上述代码将导致 bug。
真值和逻辑运算符
const stringVal = textInput || "Default Value";
为什么逻辑运算符会返回一个字符串?
在 JavaScript
中,逻辑运算符并不返回布尔值,它会返回它提供的两个操作数中的一个。可以编写非常简洁的代码。
- OR 运算符
||
如果第一个值是真值(在不计算第二个值的情况下),它将返回第一个值。否则,它将返回第二个值。 - AND 运算符
&&
如果第一个值是假值(在不计算第二个值的情况下),它将返回第一个值。否则,它将返回第二个值。 - Nullish 合并 运算符
??
与||
相同,只是它只将null
和undefined
视为假值。
一个简单判断的方法是记住逻辑运算符将返回它需要评估的最后一个值。例如,如果第一个值是 true
,那么 OR 运算符将立即返回 true
,因此它仅在第一个值为真值时才返回第一个值。
这种方法很精巧,因为对于真正的布尔值,这个算法实际上的工作方式与期望的完全一样。也可以使用它们来编写非布尔操作的简短代码。
常见用例之一是从列表中选择第一个真值:
let val;
if (a) {
val = a;
} else if (b) {
val = b;
} else {
val = c;
}
// 上面代码可以替换为
const val = a || b || c;
还可以有条件地执行一些代码:
if (a && b) {
func();
}
// 上面代码可以替换为
a && b && func();
内置 Array.filter
LeetCode 过滤数组中的元素 问题要求重新实现 Array.filter
方法,它是 JavaScript 中最常用的数组方法之一。
Array.filter
是数组原型上的方法。这个实现是一个函数,接受数组作为第一个参数。- 传递给
Array.filter
的回调函数具有对原始数组的引用,作为第三个参数传递。这个实现的回调函数只接受两个参数。 Array.filter
可选地允许传递一个thisArg
作为第二个参数。如果提供了thisArg
,传递的回调将绑定到该上下文(假设回调不是箭头函数,因为它们不能绑定)。Array.filter
处理稀疏数组。例如,如果你编写代码let arr = Array(100); arr[1]= 10;
,Array.filter
只会查看索引1
,并自动过滤掉空索引。
LeetCode 过滤数组中的元素 问题解答方法
方法1:将值推入新数组
创建一个新数组,将满足 fn(arr[i], i)
返回真值的所有值都推入其中。通过迭代原始数组中的每个元素来完成。
var filter = function(arr, fn) {
const newArr = [];
for (let i = 0; i < arr.length; ++i) {
if (fn(arr[i], i)) {
newArr.push(arr[i]);
}
}
return newArr;
};
方法2:使用 For...in
循环
For...in
循环更常用于遍历对象的键。但是也可以用于遍历数组的索引。这种方法之所以引人注目,是因为它优先稀疏数组,省略空索引。
这是最接近内置 Array.filter
工作方式的方法。因为 Array.filter
需要处理稀疏数组,所以通常比一个假设数组不稀疏的最佳自定义实现更慢。另一个需要注意的是,由于 For...in
循环包括对象原型上的键,因此通常最好使用 Object.keys()
。
var filter = function(arr, fn) {
const newArr = [];
for (const stringIndex in arr) {
const i = Number(stringIndex);
if (fn(arr[i], i)) {
newArr.push(arr[i]);
}
}
return newArr;
};
方法3:预分配内存
将元素推入数组可能是一个慢操作。这是因为数组可能没有足够的空间来存储新元素,因此需要调整大小。通过使用 new Array(size)
初始化数组,可以避免这些高代价的调整大小操作。最后,通过从数组末尾弹出来删除空元素。注意,当原始数组中移除的元素较少时,此算法的性能最佳。
var filter = function(arr, fn) {
let size = 0;
const newArr = new Array(arr.length);
for (let i = 0; i < arr.length; ++i) {
if (fn(arr[i], i)) {
newArr[size] = arr[i];
size++;
}
}
// 确保新数组的长度为 size
while (newArr.length > size) {
newArr.pop();
}
return newArr
};
方法4:原地执行操作
这个方法类似于方法 3 ,但利用了输入数组的内存,避免了创建新数组的成本。需要注意的是,尽管这个解决方案是有效的,但通常不建议修改传递给函数的参数。这是因为函数的用户可能不希望他们的数组被修改,这可能导致 bug。注意,内置的 Array.filter
不会修改输入数组。
var filter = function(arr, fn) {
let size = 0;
for (let i = 0; i < arr.length; ++i) {
if (fn(arr[i], i)) {
arr[size] = arr[i];
size++;
}
}
// 确保新数组的长度为 size
while (arr.length > size) {
arr.pop();
}
return arr
};
方法5:标准库
使用内置的 Array.filter
方法。
var filter = function(arr, fn) {
return arr.filter(fn);
};
性能分析
对这些方法的非正式分析,通过过滤包含 200,000
个值的 Math.random()
数组 30 次来完成。被移除的比例通过改变回调函数来进行调整。例如,x => x > 0.2
将导致删除 20% 的元素。
可以得出一些合理的结论:
- 方法 2(
for...in
) 和 方法 5(内置) 最慢,因为它们有处理稀疏数组的情况。 - 方法 1(推入) 在大多数元素被删除时是最快的。这是因为在这些情况下,昂贵的推入操作很少发生。
- 方法 3(预分配内存) 和 方法 4(原地执行) 在删除很少的元素时是最快的。这是因为在这些情况下,弹出操作很少发生。方法 4 比方法 3 更快,因为不需要创建初始数组。
尽管可以通过选择最佳的筛选方法来优化代码,但为了简洁性和可读性,应该使用内置的 Array.filter
方法。例外的情况是如果编写一个高性能的库或处理非常大的数组,性能的提升才会变得有意义。