数组去重有很多种方法,但是哪一种更快更好呢?
时间复杂度
算法的时间复杂度,用来度量算法的运行时间,记作: T(n) = O(f(n))。它表示随着 输入大小n 的增大,算法执行需要的时间的增长速度可以用 f(n) 来描述。
- 我们知道常数项对函数的增长速度影响并不大,所以当 T(n) = c,c 为一个常数的时候,我们说这个算法的时间复杂度为 O(1);如果 T(n) 不等于一个常数项时,直接将常数项省略。
function add(void) {
console.log("Hello, World!"); // 需要执行 1 次
return 0; // 需要执行 1 次
}
上面这个算法T(n) = O(f(n)),而f(n)=2,所以,这个方法的时间复杂度为O(1)
- 我们知道高次项对于函数的增长速度的影响是最大的。n^3 的增长速度是远超 n^2 的,同时 n^2 的增长速度是远超 n 的。同时因为要求的精度不高,所以我们直接忽略低此项。
比如
T(n) = n^3 + n^2 + 29,此时时间复杂度为 O(n^3)。
- 因为函数的阶数对函数的增长速度的影响是最显著的,所以我们忽略与最高阶相乘的常数。
比如
T(n) = 3n^3,此时时间复杂度为 O(n^3)。
综合起来:如果一个算法的执行次数是 T(n),那么只保留最高次项,同时忽略最高项的系数后得到函数 f(n),此时算法的时间复杂度就是 O(f(n))。为了方便描述,下文称此为 大O推导法。
例子
1.对于一个循环,假设循环体的时间复杂度为 O(n),循环次数为 m,则这个 循环的时间复杂度为 O(n×m)。
function add(void) {
let sum = 0
for(let i = 0; i < n; i++) // 循环次数为n
{
sum = sum + i // 循环体时间复杂度为O(1)
}
}
此时时间复杂度为 O(n × 1),即 O(n)。
2.对于多个循环,假设循环体的时间复杂度为 O(n),各个循环的循环次数分别是a, b, c...,则这个循环的时间复杂度为 O(n×a×b×c...)。分析的时候应该由里向外分析这些循环。
function add(void) {
let sum = 0
for(let i = 0; i < n; i++) { // 循环次数为n
for(let j = 0; j < n; j++) {// 循环次数为n
sum = sum + i + j // 循环体时间复杂度为O(1)
}
}
}
此时时间复杂度为 O(n × n × 1),即 O(n^2)。
3.对于顺序执行的语句或者算法,总的时间复杂度等于其中最大的时间复杂度。
function add(void) {
let sum = 0
// 第一部分时间复杂度为 O(n^2)
for(let i = 0; i < n; i++) { // 循环次数为n
for(let j = 0; j < n; j++) {// 循环次数为n
sum = sum + i + j // 循环体时间复杂度为O(1)
}
}
// 第一部分时间复杂度为 O(n^2)
for(let i = 0; i < n; i++) { // 循环次数为n
sum = sum + i + j // 循环体时间复杂度为O(1)
}
}
此时时间复杂度为 max(O(n^2), O(n)),即 O(n^2)。
4.对于条件判断语句,总的时间复杂度等于其中 时间复杂度最大的路径 的时间复杂度。
function add(void) {
let sum = 0
if(n > =0){
// 第一部分时间复杂度为 O(n^2)
for(let i = 0; i < n; i++) { // 循环次数为n
for(let j = 0; j < n; j++) {// 循环次数为n
sum = sum + i + j // 循环体时间复杂度为O(1)
}
}
} else {
// 第一部分时间复杂度为 O(n^2)
for(let i = 0; i < n; i++) { // 循环次数为n
sum = sum + i + j // 循环体时间复杂度为O(1)
}
}
}
此时时间复杂度为 max(O(n^2), O(n)),即 O(n^2)。
时间复杂度分析的基本策略是:从内向外分析,从最深层开始分析。如果遇到函数调用,要深入函数进行分析。
高阶
function aFunc(n) {
if (n <= 1) {
return 1;
} else {
return aFunc(n - 1) + aFunc(n - 2);
}
}
参考答案:为 O(2^n)
可见这个方法所需的运行时间是以指数的速度增长的。
数组去重
讲完了时间复杂度,再来说一下数组去重。
定义一个要去重的数组:
// let arr = [1,1,1,2,2,3,4,4,5,6]
let arr = [1,1,1,'true','true',true,true,15,15,false,false, undefined,undefined, NaN, NaN,null,null, 'NaN', 0, 0, 'a', 'a',{},{}]
双重for循环( O(n^2) )
1.开辟新空间,使用indexOf辅助
let newArr = []
for(let i = 0;i < arr.length;i++){
if(newArr.indexOf(arr[i]) === -1){
newArr.push(arr[i])
}
}
原理:判断新数组中是否存在该比较值,如果不存在,则添加。
缺点:新增了内存空间,时间复杂度高,NaN、函数无法去重
2.不开辟新空间,使用splice辅助
for(var i=0; i<arr.length; i++){
for(var j=i+1; j<arr.length; j++){
if(arr[i]==arr[j]){ //第一个等同于第二个,splice方法删除第二个
arr.splice(j,1);
j--;
}
}
}
原理:内层循环的下标始终大于外层循环的下标,当外层循环的值等于内层循环的值时,则删去内层循环的这个值,同时,因为删掉了一个元素,所以需要把下标j往前移动一步。
为什么删内层?下标比较往后,避免影响之后的循环。
缺点:时间复杂度高,NaN、函数无法去重
优点:没有新增内存,但是,splice影响原数组。
注意:如果在比较内外层数值时,使用==,那么在判断undefined和null时,认定是为true,就会把null删除,所以,再判断去重时,最好可以用===
3.利用includes
let newArr = []
for(var i=0; i<arr.length; i++){
if(!newArr.includes(arr[i])){
newArr.push(arr[i])
}
}
includes 检测数组是否有某个值
缺点:新增了内存空间,时间复杂度高,函数无法去重
利用filter + hasOwnProperty
function que(arr){
var obj = {};
return arr.filter(function(item, index, arr){
return obj.hasOwnProperty(typeof item + item) ? false : (obj[typeof item + item] = true)
})
}
利用hasOwnProperty 判断是否存在对象属性
优点:所有类型都可以去重
缺点:时间复杂度高,需要新增对象内存
利用filter + indexOf
function que(arr){
return arr.filter(function(item, index, arr){
return arr.indexOf(item, 0) === index;
})
}
原理:在原始数组中的第一个索引==当前索引值,返回当前元素
缺点:时间复杂度高,数组中的函数,NaN无法去重(并且NaN是直接消失)
map数据结构去重
function que(arr){
let map = new Map();
let newArr = new Array(); // 数组用于返回结果
for (let i = 0; i < arr.length; i++) {
if(map .has(arr[i])) { // 如果有该key值
map .set(arr[i], true);
} else {
map .set(arr[i], false); // 如果没有该key值
newArr .push(arr[i]);
}
}
return newArr ;
}
原理:创建一个空Map数据结构,遍历需要去重的数组,把数组的每一个元素作为key存到Map中。由于Map中不会出现相同的key值,所以最终得到的就是去重后的结果。
缺点:数组中的函数,NaN无法去重
sort排序后,时间复杂度( O(n*logN) )
sort排序是快速排序:时间复杂度为O(n*logN),且sort排序是按照字符编码的顺序进行排序
利用sort+splice
arr = arr.sort()
/// 开辟新空间,将不重复的数值添加入新数组。
var newArr= [arr[0]];
for (var i = 1; i < arr.length; i++) {
if (arr[i] !== arr[i-1]) {
newArr.push(arr[i]);//不相等,则把i指向的数值添加入新数组
}
}
/// 不开辟新空间,使用splice辅助
for(var i=1; i<arr.length; i++){
if (arr[i] === arr[i-1]) {
arr.splice(i,1);//如果相等就删除后一个元素
i--;
}
}
原理:经过排序后,数组从小到大排列,我们只需要判断相邻元素是否相等。
缺点:数组中的函数,NaN无法去重
优点:相较于双重for循环,时间复杂度减少了
利用指针+排序+splice(或slice)
function fast(arr) {
arr.sort()
let slow = 0,fast=1
while(fast < arr.length){
if(arr[slow] !== arr[fast]){
++slow
arr[slow] = arr[fast]
}
fast++
}
return arr.splice(0,slow+1)
}
缺点:无法对函数,NaN去重
优点:效率高。因为数组频繁的插入和删除,是一件对性能不好的操作,所以对于一般处理数组的算法问题,我们要尽可能只对数组尾部的元素进行操作,以避免额外的时间复杂度。这个方法,可以集中的一次性提取去重过后的数组。
时间复杂度 O(n)
利用对象属性去重
function que(arr){
let obj = {}
let newArr = []
for(let i = 0;i< arr.length;i++){
if(!obj[arr[i]]){
obj[arr[i]]=1
newArr.push(arr[i])
} else {
obj[arr[i]]++
}
}
return newArr;
}
对象的属性是唯一的。
缺点:因为对象的属性是一个字符串,所以在比较的时候,都会转换为字符串,会将boolean类型的false,和字符串类型的"false",看做是一样的。特别注意的是,如果数组中包含boolean类型的true,那么运算if时,会进入else这个步骤,导致true无法被push进入数组。所以,这个方法虽然高效,但是不能用于数组中有boolean和字符串相同以及number类型和字符串相同的比较。
优点:效率快。
set
Set类似于数组,但是成员的值都是唯一的,没有重复的值,也没有索引。用set.size表示伪数组长度
var newArr = [...new Set(arr)]
缺点:函数无法去重。
优点:效率快。