时间复杂度

时间复杂度相信了解算法的肯定都听说过,复杂度是用来考量算法优劣的重要依据,时间复杂度也就是对算法的时间维度的考量。本文主要就是讲述时间复杂度的推导过程来帮助对这块不明白的小伙伴快速了解时间复杂度的评估

如何推导对应的时间复杂度

在讲之前先给出几段代码,先来看下这些题对应的时间复杂度是多少,后面再来一步步推导时间复杂度是怎么评估的。

O(logn)

function test(n) {
  let i = 1
  while (i < n) {
    i *= 2
  }
}

O(nlogn)

function test() {
  for (let i = 0; i < n; i += i) {
    for (let j = 0; j < n; j++) {
      console.log('北歌');
    }
  }
}

O(n^2)

function test(n) {
  let sum = 0
  let i = 1
  let j = 1

  for (; i <= n; ++i) {
    j = 1
    for (; j <= n; ++j) {
      sum = sum + i * j
    }
  }
}

后面会一步步推导复杂度的计算过程

算法中的时间复杂度

有循环就是O(n)时间复杂度吗?#yyds干货盘点#

时间复杂度的好坏决定了当前这段代码(算法)的优劣,在工业中使用到的算法一般不会超过O(n^2),所以打上flag的时间复杂度都是不太好的,当数据规模足够大的时候,所实现的算法是非常消耗时间的。最好的是O(1),最差的是。。。没有最差,只有更差

有循环就是O(n)时间复杂度吗?#yyds干货盘点#

评估代码(算法)时间复杂度可以忽略的部分

  • 系数
  • 底数
  • 低阶

不懂什么意思没关系,后面会一一解释 ,只需要记住在评估算法时间复杂度过程中会忽略那些对算法的时间复杂度增长趋势不起绝对性因素的部分

大O表示法

T(n)=O(fn(n))
  • T(n):代码执行的时间
  • n:数据规模的大小
  • f(n): 每行代码执行的次数总和(也就是当前算法的时间复杂度)

这公式表示的意思是:代码的执行时间T(n)与代码的执行次数总和fn(n),用O来表示他两的关系是成正比的意思

此处需要举个栗子,n代表的是数据规模的大小,我们把n代数进去看一下

n = 1000
T(1000) = O(f(1000))

n(100) = O(f(100))

当1000的数据规模在当前这块代码需要的时间就是当前这段代码执行1000次,100次也是同理 ; 所以说他两是成正比的关系,当数据规模越大代码执行的次数也就越多。所以如果你的时间复杂度很高,那你代码的执行时间肯定就会越高,根据数据规模大小导致代码执行时间递增或递减 ,所以时间复杂度的全称叫"渐进式时间复杂度"

看下面这两段代码

function f1(n) {
  while (n--) {
    console.log('北歌')
  }
}

function f2(n) {
  let i = 1
  while (i < n) {
    i *= 2
  }
}
f1(1000)
f2(1000)

哪个更快?相信就算不了解时间复杂度推导的小伙伴也清楚是f2更快,并且数据量越大两者之间的优劣就会越明显。

O(1)「常数阶」

function f() {
  let i = 1 // 1(unit-time)
  let j = 1 // 1(unit-time)
  console.log('北歌') // 1(unit-time)
}
f(100)
T(100) = O(f(100))

这里的数据规模大小对当前代码的时间复杂度有影响吗?

答案肯定是没有,不管传入的数据规模是多大代码中没有循环语句,始终都是3(unit-time)

在评估算法时间复杂度中,不管是赋值语句、输出数据、分支语句都算1(unit-time),简称1,所以这段代码的时间复杂度就是O(3)?

很显然我们并没有见过这样时间的复杂度,在评估时间复杂度中,我们会忽律那些对代码时间复杂度的增长趋势不起绝对性因素的部分,这里O(3)就是O(1), 就算有1000条输出语句也还是O(1)

有循环就一定不是O(1)?

function f() {
  for (let i = 0; i < 100; i++) {
    console.log('前端自学驿站')
  }
}
f(100)
f(10000000)
循环语句: 
    i = 1 // 1
    i < 100 // 100
  i++ // 100
循环体:
     console.log('前端自学驿站') // 100

这段代码中出现了循环语句,但是还是O(1), 为什么?

不管数据规模有多大,当前循环语句并不会受影响,也就是说数据规模的大小并不会影响代码执行时间的增长

O(logn)「对数阶」

这块需要回忆下高中知识,对数(log)函数

有循环就是O(n)时间复杂度吗?#yyds干货盘点#

function test(n) {
  let a = 1 // 1 unit-time
  while (a < n) {
    // 循环体执行多少次?
    /* 
      当a不小于n就跳出循环,假设循环x次,a不小于n;循环x次, a每次都乘以2

      x: 1 => a: 2 * 1 => 2的一次方(2^1)
      x: 2 => a: 2 * 2 => 2的二次方(2^2)
      x: 3 => a: 4 * 2 => 2的三次方(2^3)
      从推导过程得知a^x = n, x = loga^n,最后我们代数进去算一下
      n: 2 => log2^2(log以2为底的2次方) = 1
      打住,并不是为了解题,从推导过程中得出时间复杂度是: log2^n, 前面说过底数、系数、低阶都可以忽略,
      最后得出O(logn)
    */
    a *= 2  // a *= 2 => a = a * 2
  }
}
时间复杂度推导: 1 + log2^n 

在数学中我们是log10^n, 当底数为10的时候忽略底数,在程序中不管底数是多少都可以直接忽略,因为它对时间复杂度的增长趋势不起绝对性作用

忽略底数后: 1 + logn

忽略低价,只保留对时间增长趋势起绝对性作的最高阶部分

忽略低阶后:logn

代数来验证数据规模大小对算法时间复杂度的影响

这是我们最开始推导出来的公式: 1 + log2^n 

T(1000) = O(f(1+log2^1000))
T(1000000) = O(f(1+log2^1000000))

是不是数据规模越大,当前算法的时间复杂度所花费的时间就越多?在当前公式中1就是低阶,数据规模变大,他并没有增长趋势,log2^1000中起绝对性的是1000(真数),并不是底数2, 所以也可以忽略底数

所以下面这段代码的时间复杂度是多少?

function test(n) {
  let i = 1 // 1
  while (i < n) {
    // i += i => i = i + i => i = i * 2
    i += i
  }
}
i = i * 2,跟上面是一样的,假设循环x次后,i不小于n, 那么 i^x = n, x = log(2^n)。
结果也是O(logn)
function test(n) {
  let i = 1 // 1
  while (i < n) {
    i *= 3
  }
}
i = i * 3,假设循环x次后,i不小于n, 那么 i^x = n, x = log(3^n)。
结果也是O(logn)

O(n)「线性阶」

function test(n) {
  /*   
     i = 0 => 1
     i < n => n
     i++ => n
     外层循环执行:1+n+n => 1+2*n => 1+2n
   */
  for (let i = 0; i < n; i++) {

    /* 
      循环的循环体执行多少次取决于什么时候跳出循环, 循环n次,每次都只可能走一个分支也就是
      n
    */
    if (n % 2 === 0) { // n
      console.log('北歌')
    } else { // n
      console.log('前端自学驿站')
    }
  }
}
1+2n(循环) + n(循环体)
   结果: 1+3n
   忽律系数后: 1 + n
   忽律底数后: 没有底数
   忽律低阶后: n
   最终结果: n

线性阶应该是最好推导的吧,没啥好说的😗。

O(nlogn)「线性对数阶」

function test() {
  /*   
     i = 0 => 1
     i < n => log2^n
     i += i => log2^n
     外层循环执行:1+2*log2^n
   */
   // i += i => i = i + i => i = i * 2 => 前面推导出来的log2^n
  for (let i = 0; i < n; i += i) {
    // 外层循环的循环体执行多少次取决于什么时候跳出循环
    // 循环体执行:log2^n

    /* 
       j = 0 => 1
       j < n => n
       j++ => n
       内层循环执行:1+2*n
     */
    for (let j = 0; j < n; j++) {
      // 循环体执行:n
      console.log('北歌');
    }
  }
} 

第一次推导可能比较难,我用图区分了模块,帮助大家理解

有循环就是O(n)时间复杂度吗?#yyds干货盘点#

 /* 
    1+2*log2^n(外循环) + log2^n(循环体)
   1+2*n(内循环) + n(循环体) => 1 + 3*n
    结果 1+2*log2^n+log2^n * 1+3n 
    为什么是乘?因为外层的循环体会执行 log2^n次, 里面的循环语句也会执行这么多次

    忽律系数后: 1+log2^n+log2^n*n
    忽律底数后: logn+log^n*n
    忽律低阶后: logn * n 
    最终结果: nlogn
  */

O(n^2)「平方阶」

function test(n) {
  let sum = 0 // 1
  let i = 1 // 1
  let j = 1 //1 

  /*   
    i <= n => n
    ++i => n
    外层循环执行:2*n
   */
  for (; i <= n; ++i) {
    // 外层循环的循环体执行多少次取决于什么时候跳出循环
    // 循环体执行:n次

    j = 1 // n

    /*   
      j <= n => n
      ++j => n
      内层循环执行:2*n
    */
    for (; j <= n; ++j) {
      // 内层循环的循环体执行多少次取决于什么时候跳出循环
      // 循环体执行:n次
      sum = sum + i * j
    }
  }
}
 /* 
    循环外: 3
    3*n(外循环) + n(循环体)
    2*n(内循环) + n(循环体)
    结果 3 + 3n+n * 3n => (n*3n == 3n^2) =>  3+3n+3n^2
    忽律系数后: 3 + n  + n^2
    忽律底数后: 没有
    忽律低阶后: n^2
    最终结果 n^2
  */

O(n^3) 「立方阶」

 function test(n) {
   for (let i = 0; i < n; i++) {
     for (let j = 0; j < n; j++) {
       for (let k = 0; k < n; k++) {
         console.log('北歌')
       }
     }
   }
 }

3层循环有可能会出现在算法中,但是以O(n^3)的这种代码也不太可能出现在算法中。

推导就是O(n^2) * n => O(n^3)

O(2^n)「指数阶」

O(2^n)指数增长趋势是非常不稳定的,n(数据规模逐渐变大,增长趋势就非常大),时间复杂度跟O(n^2)差不多,当数据规模比较大时会超过0(n^2) 和(n^3)持平

有循环就是O(n)时间复杂度吗?#yyds干货盘点#

function fib(n) {
  if (n <= 1) return n
  return fib(n - 1) + fib(n + 1)
}

看到这段代码有没有很熟悉?这就是用递归实现的斐波那契,它的时间复杂度就是O(2^n),里面涉及到了递归调用,所以他的推导也是比较复杂的

递归调用的函数时间复杂度不但要看当前函数里面的复杂度,还要推导当前函数会被调用多少次

fib(5)

有循环就是O(n)时间复杂度吗?#yyds干货盘点#

0.5*2^n-1
忽律系数后: 2^n-1
忽律低阶后: 2^n

结果就是O(2^n)

有循环就是O(n)时间复杂度吗?#yyds干货盘点#

可以看到当数据规模变大(指数足够大)时,O(2^n)的运行时间肯定是超过了O(n^2),所以当我们用递归实现的斐波那契的数值给到50(看cpu性能)以上就会卡死,可以想象下(50-1, 50+1)这样的一直往下递归直至n<=1, 运行效率是特别慢的,所以一般在算法中并不会是这种赤裸裸的递归,像常见的dfs(深度优先搜索 )都一定会有剪枝的判断,通俗的讲就是递归的过程中把不符合的递归分支给掐断,让其不在递归下去,只让符合条件的分支继续递归。

时间复杂度的各种情况

对于算法的时间复杂度也有类别:

  • 平均时间复杂度
  • 最坏时间复杂度
  • 最好时间复杂度

打个比方,我们写一段程序让机器人在一条10公里的跑道帮忙找自己的手机(手机一定在路上且不会被他人捡到),最好的情况就是手机就在100米处就找到了,最坏的情况无非就是在10公里的最后找到了,这里对应的就是最好情况和最坏情况

对应的就是现在这段代码

function test(n, x) {
  let sum = 0
  for (let i = 0; i <= n; i++) {
    if (i === x) break
    sum += i
  }
  return sum
}
test(100, 100) // O(n)
test(100, 1) // 退化成了O(1)

但在我们平时预估算法时间复杂度只看平均时间复杂度:就是你推导出来的时间复杂度是多少,结果就是多少。

排序算法时间复杂度推导

有循环就是O(n)时间复杂度吗?#yyds干货盘点#

在开始前先解释下什么是稳定,什么是不稳定。

  • 稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。
  • 不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。

那不稳定的排序算法会给我们带来什么影响?

let list = [
  {
    id: 'a',
    sort: 1,
    name: '项目设置',
    path: '~/xx/xx'
  },
  {
    id: 'b',
    sort: 1,
    name: '单元设置',
    path: '~/xx/xx'
  },
]

像上面的这个集合,如果使用的是稳定的排序算法,项目设置会始终是第一个,但如果使用的排序算法是不稳定的单元设置就有可能成为第一个

冒泡排序时间复杂度推导

function bubbleSort(arr) {
    let l = arr.length;
    for (let i = 0; i < l - 1; i++) {
      for (let j = 0; j < l - 1 - i; j++) {
        if (arr[j] > arr[j + 1]) {
          let temp = arr[j + 1];
          arr[j + 1] = arr[j];
          arr[j] = temp;
        }
      }
    }
    return arr;
  }

数据规模大小(arr.length = n), 外层循环会走n次,i每次都递增+1, i=1,n-1; i=2,n-2 ,内部循环每次都会在原有基础上进行n-1,交换两个元素的操作进行(n-1)+(n-2)+(n-3)+……+2+1 => n*(n-1)/2

忽略低阶后: n*n => n^2

这是他的平均情况,如果想要优化当前算法为最好情况的前提是要保证所有序列本身就是排好序的;除此之外也需要修改下算法,如果进行第一次排序的时候就没有交换位置就能够确定当前是排好序的数组,后面就不用进行处理了。

function bubbleSort(arr) {
    let l = arr.length;
    let isDidSwrap = false
    for (let i = 0; i < l - 1; i++) {
      isDidSwrap = false
      for (let j = 0; j < l - 1 - i; j++) {
        if (arr[j] > arr[j + 1]) {
          let temp = arr[j + 1];
          arr[j + 1] = arr[j];
          arr[j] = temp;
          isDidSwrap = true
        }
      }
      if (!isDidSwrap) break;
    }
    return arr;
  }

写在最后

业精于勤,荒于嬉

如果文章中有那块写的不太好或有问题欢迎大家指出,我也会在后面的文章不停修改。也希望自己进步的同时能跟你们一起成长。喜欢我文章的朋友们也可以关注一下,我会很感激第一批关注我的人。此时,年轻的我和你,轻装上阵;而后,富裕的你和我,满载而归。