数据结构与算法的重温之旅(一)——复杂度分析

本系列所有文章的代码都是用JavaScript实现,之所以用JavaScript实现是因为它可以直接在浏览器宿主中运行代码,即在浏览器中按f12打开控制台,选择console按钮,在下面空白的文本框把本例的代码黏贴上去回车即可运行。方便各位同学学习和调试。

最近刷leetCode刷到后面medium级别的题目的时候就越力不从心,于是乎去极客时间那里买了一门数据结构与算法的课来学习一下,本刊是记录自己在这门课程的笔记,如有错误,劳烦勘正。

在讲解复杂度分析之前,我们先要知道为什么我们写程序的时候需要算法与数据结构。可能有些人觉得自己随便撸一段代码,保证业务流畅运行不报错就可以了,其实这样的认知是十分肤浅的。你目前的解决方法只是解决了你目前测试当中所遇到的问题,当在复杂的生存环境中,有可能遇到的数据十分的庞大,一些你原本以为完美的代码可能在这复杂的环境中挤占太多的服务器资源或客户端资源,设置直接导致服务器宕机或者客户端挂掉,所以我们有必要用算法与数据结构来优化我们的代码。那什么是算法,什么是数据结构呢?按照我的理解:数据结构是数据的存储状态,算法则是对数据的操作方法。认知这一点很重要,很多人以为算法就是数据结构,数据结构就是算法,其实他们两个不是相等关系,而是相关联的关系,作用于他们之间的其实就是数据,数据结构和算法谁也不能脱离谁单独使用。

讲到这里我们就进入正体,我们为什么要分析一个算法与数据结构的复杂度呢,我们之所以用算法与数据结构是为了提高程序的性能,程序的哪方面的性能得到提升呢?答案是他的所用时间和所占空间(也就是所占的服务资源),换句话来说,我们用算法与数据结构的目的是要更快更省。

那我们如何分析一个算法是否更快更省呢,传统上有一个方法是事后统计法,即通过统计和监控得到算法执行所用的时间和占用的内存大小,但是这种方法十分的不准确,哪些因素会影响呢,主要是下面这些情况:

1.测试结果非常依赖测试环境

在测试的过程中如果你的机器硬件设备很好的话,测试得出的数据可能很好看,但是如果是换了一个老旧的机器的话,可能得出的数据跟好的机器得出的数据差别很大。

2.测试结果受数据规模的影响很大

有些数据如果数量很少用算法是看不出来不同算法之间有什么优势区别。在排序算法中,在同等情况下,比如数据是有序的情况下,用冒泡排序所用的时间设置比用快速排序的时间还要少。

经过上面的例子,所以我们必须通过一个科学的方法来比较各个算法之间的复杂度,这种方法我们称之为大O复杂度表示法。下面我们来分析一个算法的时间复杂度。

算法的执行效率其实粗略的讲是代码执行所要花费的时间,那我们应该如何看出一个算法所花的时间呢,下面贴个代码来举例子:

function test (val) {
    let sum = 0;
    let a = 1
    for (; a < val; a++) {
        sum = sum + 1
    }
    return sum
}复制代码

我们在估算程序每个步骤所用的时间时,假设每一步所用的时间都是相等的,都为unit_time,在这个假设的基础上,我们来计算一下该程序所用的时间。第 2、3 行代码分别需要 1 个 unit_time 的执行时间,第 4、5 行都运行了 n 遍,所以需要 2n*unit_time 的执行时间,所以这段代码总的执行时间就是 (2n+2)*unit_time。可以看出来,所有代码的执行时间 T(n) 与每行代码的执行次数成正比。

同理,按照这个思路,我们在拿一个程序来举例:

function test (val) {
    var sum = 0
    var i = 1
    var j = 1
    for (; i <= val; i++) {
        j = 1
        for (; j <= val; j++) {
            sum = sum + i * j
        }
    }
}复制代码

运用刚才的思路,第 2、3、4 行代码,每行都需要 1 个 unit_time 的执行时间,第 5、6 行代码循环执行了 n 遍,需要 2n * unit_time 的执行时间,第 7、8 行代码循环执行了 遍,所以需要 2n* unit_time 的执行时间。所以,整段代码总的执行时间 unit_time。尽管我们不知道 unit_time 的具体值,但是通过这两段代码执行时间的推导过程,我们可以得到一个非常重要的规律,那就是,所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比。也就是下面所要说的大O表示法:。

我来具体的解释一下这个公式:其中,T(n) 我们已经讲过了,它表示代码执行的时间;n 表示数据规模的大小;f(n) 表示每行代码执行的次数总和。因为这是一个公式,所以用 f(n) 来表示。公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。所以第一个例子中的和第二个例子中的,这就是大O时间复杂度表示法。。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以这也叫渐进时间复杂度(asymptotic time complexity),简称时间复杂度。当我们的变量n很大的时候,其实在公式中低阶、常量和系数三个部分并不会左右整个增长趋势,所以我们可以把他们忽略,记录最大的量级就可以了,所以上面两个实例的时间复杂度为:和。

现在我们按照理论,进一步的说明比较实用判断时间复杂度的方法:

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

刚刚有讲,大 O 这种复杂度表示方法只是表示一种变化趋势。我们通常会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级就可以了。所以,我们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。如第一个代码例子里,第2、3行都是常量级的运行时间,与n无关,我们可以忽略他们的用时,而第4、5行则是与n相关,整个程序当中执行次数最多的代码,这两行代码在上面有说过执行了n次,所以时间复杂度为O(n)。

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

如下面的这段代码:

function test(n) {
   let sum_1 = 0;
   let p = 1;
   for (; p < 100; ++p) {
     sum_1 = sum_1 + p;
   }

   let sum_2 = 0;
   let q = 1;
   for (; q < n; ++q) {
     sum_2 = sum_2 + q;
   }
 
   let sum_3 = 0;
   let i = 1;
   let j = 1;
   for (; i <= n; ++i) {
     j = 1; 
     for (; j <= n; ++j) {
       sum_3 = sum_3 +  i * j;
     }
   }
 
   return sum_1 + sum_2 + sum_3;
 }
复制代码

这个代码其实分成了三份,分别是求sum_1、sum_2和sum_3,我们把他们的时间复杂度放在一起比较,然后再取时间复杂度最大的作为整个代码的复杂度。按照代码来分析,首先第一块sum_1是进行100次的循环运算,这个只是一个常量级的运算时间,和n无关。第二块代码则进行了n次的循环运算,则时间复杂度为O(n)。第三块代码则进行了n的平方次循环运算,得到的时间复杂度为,所以通过上面的比较,我们可以得到该程序的时间复杂度为,也就是说总的时间复杂度等于量级最大的那段代码的时间复杂度。抽线成具体公式则是:

如果,则

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

讲完了加法法则,其实大家可以通过发散思维猜到乘法法则的公式是什么样的,乘法法则的公式如下所示:,则。按照公式,如果f(n)等于n,g(n)等于n的平方的话,那最后的T(n)则等于n的立方。下面举例来佐证:

function test(n) {
   let ret = 0; 
   let i = 1;
   for (; i < n; ++i) {
     ret = ret + f(i);
   } 
 } 
 
 function f(n) {
  let sum = 0;
  let i = 1;
  for (; i < n; ++i) {
    sum = sum + i;
  } 
  return sum;
 }
复制代码

我们单独看 test() 函数。假设 f() 只是一个普通的操作,那第 4~6 行的时间复杂度就是,T1(n) = O(n)。但 f() 函数本身不是一个简单的操作,它的时间复杂度是 T2(n) = O(n),所以,整个 cal() 函数的时间复杂度就是,T(n) = T1(n) * T2(n) = O(n*n) = 。

其实上面的这三种方法不需要死记硬背,只要多运用多实践就可以记到心中。时间复杂度分为多项式和非多项式,非多项式只有和,多项式有很多种,下面来详细的讲一下常见的时间复杂度:

1.O(1)时间复杂度

O(1)表示的是n是个常量,并不是说代码只有一行,而是指代码里面没有递归、循环语句的时候,即使代码有成千上万行,时间复杂度仍然是个常量,和n无关。

2.O(logn)和O(nlogn)时间复杂度

这两个时间复杂度表示的是一个对数阶,比较常见这种对象阶的时间复杂度出在二分查找那里,下面以一个简单的例子来举例O(logn)时间复杂度

 var i = 1
 var n = 10
 while (i <= n)  {
   i = i * 2;
 }
复制代码

在这个例子当中,这个循环只需运行三次即可,这个代码有点像我们高中时候学的等比数列,i每次都乘以2,这样的话我们就得到公式,利用高中学过的对数只是,我们得到,所以这里的时间复杂度是。不过在这里我们就有疑问了,为什么不是时间复杂度,而是logn的时间复杂度呢。在数学上,我们可以对对数提取公因式,比如我们现在有一个的数,提取公因式得:,我们在上面有说过常数是可以省略的,所以得到的是logn。而nlog则更简单,它是通过在上面再加一层循环,利用上面说到的乘法原则所得而成。

3.O(n+m)和O(n*m)时间复杂度

当一个代码块里面有两个循环体,并且两个循环体都是相当于不同的变量的时候,这个时间复杂度就由两个数据来决定的了。例子如下:

function test(m, n) {
  var sum_1 = 0;
  var i = 1;
  for (; i < m; ++i) {
    sum_1 = sum_1 + i;
  }

  var sum_2 = 0;
  var j = 1;
  for (; j < n; ++j) {
    sum_2 = sum_2 + j;
  }

  return sum_1 + sum_2;
}
复制代码

在这里,两个代码块里的循环是分别相对于m和n的,所以我们这里要将上面的加法原则进一步的修改,原来的加法原则是建立在循环都是对应同一个n的时候取时间复杂度最大的这一个,而这里由于无法判断哪一个的时间复杂度最大所以只能让他们相加:。而O(n*m)是利用乘法原则,所以结论没变。

4.O(n!)和时间复杂度

这两个时间复杂度区别与之前提过的时间复杂度,这里的时间复杂度都是非多项式,由于时间消耗太大,一般比较少用到这些的时间复杂度。

在讲完了时间复杂度之后,下面将空间复杂度就简单很多。空间复杂度的全称是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系。如下例子:

function print(n) {
  var i = 0;
  var a = new Array(n);
  for (i; i <n; ++i) {
    a[i] = i * i;
  }

  for (i = n-1; i >= 0; --i) {
    console.log(a[i])
  }
}
复制代码

跟时间复杂度分析一样,我们可以看到,第 2 行代码中,我们申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以我们可以忽略。第 3 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。空间复杂度的常见类型就O(1)、O(n)和,像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。而且,空间复杂度分析比时间复杂度分析要简单很多。所以,对于空间复杂度,掌握刚我说的这些内容已经足够了。

最后以一幅时间复杂度的图来总结一下常见的时间复杂度:

点此进入下一篇文章的学习:数据结构与算法的重温之旅(二)——复杂度进阶分析​​​​​​​


转载于:https://juejin.im/post/5ce2e339e51d4550bf1ae7b3

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值