阅读 168

前端数据结构---复杂度分析

前端数据结构---复杂度分析

为什么需要复杂度分析

  我们可以把代码跑一遍,然后通过一些工具来统计、监控就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?这种分析方法能比我实实在在跑一遍得到的数据更准确吗?

  首先,肯定的说这种评估算法执行效率的方法是正确的。很多数据结构和算法书籍还给这种方法起了一个名字,叫事后统计法。但是这种统计方法存在一定的局限性。

1、测试结果依赖测试的环境以及数据规模的影响

  比如,我们拿同样一段代码,再不同的机器以及不同的数据规模可能会有不同的结果。

2、掌握复杂度分析,将能编写出性能更优的代码

 

所以我们需要一个不用具体的测试环境、测试数据来测试,就可以粗略地估计算法的执行效率的方法,这就是我们需要的时间、空间复杂度分析方法。

复杂度分析提供了一个粗略的分析模型,与实际的性能测试并不冲突,更不会浪费太多时间,重点在于在编程时,要具有这种复杂度分析的思维,有助于产出效率高的代码。

回到目录

大 O 复杂度表示法

  算法的执行效率,简单的说就是代码执行的时间。但是怎么样在不运行代码的情况下,用“肉眼”得到一段代码的执行时间呢?这里有段非常简单的代码,求 1,2,3...n 的累加和。现在来估算一下这段代码的执行时间。

复制代码

1 function countSum(n) {2    let sum = 0;3    console.log(n)4    for (i = 0; i <= n; ++i) {5      sum = sum + i;6    }7    return sum;8  }

复制代码

每行代码对应的 CPU 执行的个数、执行的时间都不一样,所以只是粗略估计,我们可以假设每行代码执行的时间都一样为 unit_time。在这个假设的基础之上,这段代码的总执行时间是多少呢?

第 2、3 行代码分别需要 1 个 unit_time 的执行时间,第 4、5 行都运行了 n 遍,所以需要 2n * unit_time 的执行时间,所以这段代码总的执行时间就是 (2n+2) * unit_time。

尽管我们不知道 unit_time 的具体值,但是通过代码执行时间的推导过程,我们可以得到一个非常重要的规律,那就是所有代码的执行时间 T(n) 与代码的执行次数 f(n) 成正比

我们可以把这个规律总结成一个公式,这个公式就是数据结构书上说的大O表示法。

我来具体解释一下这个公式:

  • T(n) 表示代码执行的时间

  • n 表示数据规模的大小

  • f(n) 表示代码执行的次数总和

  因为这是一个公式,所以用 f(n) 来表示。公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。

  所以,上面例子中的 T(n) = O(2n+2),这就是大 O 时间复杂度表示法。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。

回到目录

时间复杂度分析

分析大O一般的步骤如下:

  1. 用常数1代替运行中的所有的加法常数 n + 2 + 3 + 4 等于 n + 1

  2. 在修改后的运行次数函数中,只保留最高阶项 如 n^3 + n^2 等于 n^3

  3. 如果最高阶项存在且不为1,则去掉与这个项相乘的常数 如 3n^2 等于 n^2

通过上面三个步骤可以总结出几个方法

1. 只关注循环执行次数最多的一段代码

大 O 这种复杂度表示方法只是表示一种变化趋势。通过上面的公式我们会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级。所以我们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。

2.加法法则:总复杂度等于量级最大的那段代码的复杂度

如果是很长的一个代码段,可以把他们拆分计算时间复杂度,然后再加起来

复制代码

 1 function countSum(n) { 2    let sum_1 = 0; 3    console.log('计算:sum_1') 4    for (let p = 0; p < 100; ++p) { 5      sum_1 = sum_1 + p; 6    } 7  8    let sum_2 = 0; 9    console.log('计算:sum_2')10    for (let q = 0; q < n; ++q) {11      sum_2 = sum_2 + q;12    }13  14    let sum_3 = 0;15    console.log('计算:sum_3')16    for (let i = 0; i <= n; ++i) {17      j = 1; 
18      for (let j = 0; j <= n; ++j) {19        sum_3 = sum_3 +  i * j;20      }21    }22  23    return sum_1 + sum_2 + sum_3;24  }

复制代码

这个代码分为三部分,分别是求 sum_1、sum_2、sum_3。我们可以分别分析每一部分的时间复杂度,然后把相加,再取一个量级最大的作为整段代码的复杂度。

第一段的时间复杂度是多少呢?这段代码循环执行了 100 次,所以是一个常量的执行时间,跟 n 的规模无关。强调一下,即便这段代码循环 10000 次、100000 次,只要是一个已知的数,跟 n 无关,照样也是常量级的执行时间。当 n 无限大的时候,就可以忽略。尽管对代码的执行时间会有很大影响,但是回到时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量的执行时间多大,我们都可以忽略掉。因为它本身对增长趋势并没有影响。

那第二段代码和第三段代码的时间复杂度应该很容易分析出来是 O(n) 和 O(n^2)。

综合这三段代码的时间复杂度,我们取其中最大的量级。所以,整段代码的时间复杂度就为 O(n^2)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度

3.乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

假设有一个嵌套的循环,我们把第一层循环叫T1,那么T1(n)=O(f(n)),第二层循环叫T2,那么T2(n)=O(g(n)),总共时间 T(n)=T1(n)*T2(n) = O(f(n))*O(g(n))= O(f(n) * g(n))

假设 T1(n) = O(n),T2(n) = O(n^2),则 T1(n) * T2(n) = O(n^3)。在具体的代码上,我们可以把乘法法则看成是嵌套循环

如上面计算sum_3的代码段 两个循环为O(n^2)。

回到目录

常见时间复杂度实例分析

O(1)

O(1) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。比如这段代码,即便有 3 行,它的时间复杂度也是 O(1),而不是 O(3)。

 

1 const i = 8; 
2 const j = 6; 
3 const sum = i + j;

只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1)。或者说,一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)

O(logn)

对数阶时间复杂度非常常见,如

1 i=1;2 while (i <= n) {3   i = i * 2; 
4 }

根据说的复杂度分析方法,第三行代码是循环执行次数最多的。所以,我们只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当大于 n 时,循环结束。实际上变量 i 的取值就是一个等比数列。如果我把它一个一个列出来,就应该是这个样子的:

 所以,我们只要知道 x 值是多少,就知道这行代码执行的次数了。通过 2x=n 求解 x 这个问题我们想高中应该就学过了,我就不多说了。x=log2n,所以,这段代码的时间复杂度就是 O(log2n)。

O(n)

O(n)级别有个非常显著的特征就,它会存在一个循环,且循环的次数是和n相关

1 function countSum (n) {2   let sum = 03   for (let i = 0; i < n; i++) {4     sum += i5   }6 }

O(n^2) 

O(n^2)级别的有双重循环

复制代码

function countSum (n) {
  let sum = 0  for (let i = 0; i < n; i++) {
    sum += i    for (let J = 0; J < n; i++) {      // do some thing   }
  }
}

复制代码

不是所有的双重循环都是n^2

复制代码

1 function countSum (n, m) {2   let sum = 03   for (let i = 0; i < n; i++) {4     sum += i5     for (let J = 0; J < m; i++) {6       // do some thing7    }8   }9 }

复制代码

这种是由两个数据规模n、m来决定的时间复杂度,所以是O(n * m),关键还是要分析嵌套的循环跟外面那层循环的关系。

回到目录

时间复杂度耗费时间从小到大依次排列

  O(1) < O(logn) < O(n) < O(nlogn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)

回到目录

常见排序算法对应的时间复杂度

排序方法时间复杂度(平均)时间复杂度(最坏)时间复杂度(最好)空间复杂度稳定性复杂性
直接插入排序O(n2)O(n2)O(n2)O(n2)O(n)O(n)O(1)O(1)稳定简单
希尔排序O(nlog2n)O(nlog2n)O(n2)O(n2)O(n)O(n)O(1)O(1)不稳定较复杂
直接选择排序O(n2)O(n2)O(n2)O(n2)O(n2)O(n2)O(1)O(1)不稳定简单
堆排序O(nlog2n)O(nlog2n)O(nlog2n)O(nlog2n)O(nlog2n)O(nlog2n)O(1)O(1)不稳定较复杂
冒泡排序O(n2)O(n2)O(n2)O(n2)O(n)O(n)O(1)O(1)稳定简单
快速排序O(nlog2n)O(nlog2n)O(n2)O(n2)O(nlog2n)O(nlog2n)O(nlog2n)O(nlog2n)不稳定较复杂
归并排序O(nlog2n)O(nlog2n)O(nlog2n)O(nlog2n)O(nlog2n)O(nlog2n)O(n)O(n)稳定较复杂
基数排序O(d(n+r))O(d(n+r))O(d(n+r))O(d(n+r))O(d(n+r))O(d(n+r))O(n+r)O(n+r)稳定较复杂

回到目录

空间复杂度

 时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。同理,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系,空间复杂度分析跟时间复杂度类似。

复制代码

1 function run (n) {2     let name = 'joel'3     let step= 24     const arr = []5 6     for (let i = 0; i < n; i++) {7       arr.push(i * step)8    }9 }

复制代码

再第4行代码我们初始化一个数组,再第7行代码对这个数组进行push 操作,这个循环是依赖外部的n,所以这个空间复杂度为 O(n),我们常见的空间复杂度就是 O(1)、O(n)、O(n2 


文章分类
后端
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐