数组去重与时间复杂度分析

1,196 阅读8分钟

数组去重有很多种方法,但是哪一种更快更好呢?

时间复杂度

算法的时间复杂度,用来度量算法的运行时间,记作: 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)]

缺点:函数无法去重。

优点:效率快。