前端视角下的时间复杂度和空间复杂度

851 阅读4分钟

本文是站在前端的角度来看时间复杂度和空间复杂度,所以阐述的都很基础。

时间复杂度

时间复杂度:定性描述算法的运行时间。只是定性,而不是定量的描述算法的运算时间。

它用一个函数O表示,比如O(1), O(n), O(logn)...

记住下面这个图: image.png

O(1)

表示不管你输入的是多少,里面的代码只会执行一次。一般是没有循环的代码,复杂度就是O(1)。

let i = 0;
i++

O(n)

与传入的数据量成正比,你输入多少,我就执行多少次

for(let i = 0; i < n; i++) {
    console.log(i)
}

把两个时间复杂度放在一起执行,总的时间复杂度就是增长趋势更快的那个时间复杂度。下面的代码中当n足够大的时候,O(1)就可以忽略: O(1)+ O(n) = O(n)

let i = 0;
i++
for(let i = 0; i < n; i++) {
    console.log(i)
}

O(n^2)

O(n) * O(n) = O(n^2),这是一种非常糟糕的算法,随着数据量的增大,运算时间会增加的非常快,可以看前面的那个图。在工作中尽量少使用,除非你能确定你最大的数据量是多少。

for(let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {}
}

log(n)

最典型的是二分法,每一次查找就砍一半。这种时间复杂度是非常好的,随着数据量的增加,运算时间增长的并不是很大,这种是工作中追求的一种时间复杂度。

let i = 0;
while(i < n) {
    console.log(i)
    i *= 2
}

nlog(n)

在一个循环里面嵌套一个二分法

function(n) { 
    for(let i = 0; i < n; i ++) { 
    // 或者来一个二分查找 
        while(i < n) { 
            i *= 2 
        } 
    } 
}

空间复杂度

空间复杂度:算法在运行过程中临时占用存储空间大小的度量。

它用一个函数O表示,比如O(1), O(n), O(logn)...

O(1)

let i = 0;
i++

这里只声明了单个变量,单个变量的空间复杂度是O(1)

O(n)

const list = []
for(let i = 0; i < n; i++) {
    list.push(i)
}

空间复杂度是o(n),因为声明了一个list数据,数组里面有n个值,需要占据内存空间n个内存单元。

O(n^2)

O(n^2)的典型代表就是矩阵,而矩阵在数组中就是一个二维数组。

const matrix = []
for (let i = 0; i < n; i++) {
    matrix.push([])
    for (let j = 0; j < n; j++) {
        matrix[i].push(j)
    }
}

前端开发更注重时间复杂度

如果你没有复杂度的概念和敏感度,写程序是非常危险的,比如代码功能都是正常的,但是一旦数据量大了之后,程序就崩溃了。

对于前端开发来说尤其是时间复杂度,毕竟页面是离用户最近的,如果页面经常卡死,那么没人会使用。所以,我们常说的前端性能优化就是说的是时间复杂度。

对于一个算法来说,O(n^2)基本上是不可用的,一般是要控制在O(n),最好是O(logn)。

从一个例子来实践时间复杂度和空间复杂度

算法题:将一个数组旋转k步

  • 输入一个数组[1,2,3,4,5,6,7]
  • k = 3,即旋转3步
  • 输出[5,6,7,1,2,3,4]

两种思路

思路一:把末尾的元素挨个pop,然后unshift到数组前面

export function rotate1(arr: number[], k: number): number[] {
  const length = arr.length
  if (!k || length === 0) return arr
  // 如果k是负值,或者k大于length,这里做了处理
  const step = Math.abs(k % length) // abs 取绝对值

  // 时间复杂度是O(n^2) 空间复杂度O(1)
  for (let i = 0; i < step; i++) {
    const n = arr.pop()
    if (n != null) {
      arr.unshift(n) // 数组是一个有序结构,unshift 操作非常慢!!! O(n)
    }
  }
  return arr
}

思路二:把数组拆分,最后concat到一起

export function rotate2(arr: number[], k: number): number[] {
  const length = arr.length
  if (!k || length === 0) return arr
  const step = Math.abs(k % length) // abs 取绝对值

  // 时间复杂度 O(1) 空间复杂度是O(n)
  const part1 = arr.slice(-step) // O(1)
  const part2 = arr.slice(0, length - step)
  const part3 = part1.concat(part2)
  return part3
}

上面两种方法的时间和空间复杂度如何呢?

  • 思路一:时间复杂度为O(n^2),空间复杂度为O(1)
  • 思路二:时间复杂度为O(1), 空间复杂度为O(n)

性能测试

const arr1 = []
for (let i = 0; i < 10 * 10000; i++) {
  arr1.push(i)
}
console.time('rotate1')
rotate1(arr1, 9 * 10000)
console.timeEnd('rotate1') // 968ms O(n^2)

const arr2 = []
for (let i = 0; i < 10 * 10000; i++) {
  arr2.push(i)
}
console.time('rotate2')
rotate2(arr2, 9 * 10000)
console.timeEnd('rotate2') // 1ms O(1)

可以看到思路一的运行时间是思路二的几百倍。这是因为有这么一段代码:arr.unshift(n)。

数组是一个有序的集合,当你往数组前面插入一个元素的时候,其余的元素都要往后移动一位,也就是O(n),所以思路一的时间复杂度就是O(n^2)。

数组中的unshift, shift, splice的时间复杂度都是O(n),因为当它们操作时,都要改变数组中所有元素的位置。但是,pop和push就不会,所以这两个的时间复杂度是O(1)。

可见,如果你能掌握了算法,那么你就能写出好的代码。