数据结构与算法打卡(1)复杂度分析方法(总结)


在这里插入图片描述
上篇已经讲过, 复杂度分析非常重要,是数据结构与算法的精髓
这篇就围绕时间、空间复杂度分析的内容展开。

1. 时间复杂度

Why

评估算法的执行效率方法可以分为两大类事后统计法复杂度分析法

但是总不能在任何情况下都把算法跑一遍吧,所以事后统计法存在很大的局限性

  • 测试结果非常依赖测试环境
    测试环境中硬件的不同会对测试结果有很大的影响。所以比较两种算法的执行效率,很有可能出现换一台机器出现不同的结果的情况。

  • 测试结果受数据的影响很大
    这里包含了多个层面,比如数据的排列规则、数据的规模等都会对算法的测试结果造成很大影响,比如排序算法。

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

How

(1)时间复杂度

时间复杂度分析是一种趋势分析,是对算法消耗的时间随数据量级增长趋势的一种分析,或者说粗略估计

那具体如何进行时间复杂度分析?
先看一段代码:

 int cal(int n) {
   int sum = 0;
   int i = 1;
   for (; i <= n; ++i) {
     sum = sum + i;
   }
   return sum;
 }

一个假设

由于只是粗略估计,所以可以假设每行代码执行的时间都一样,为unit_time(单位时间)。

从 CPU 的角度来看,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。其实每行代码对应的 CPU 执行的个数、执行的时间都不一样。

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

 int cal(int n) {
   int sum = 0;             // 花费 1 unit_time 执行时间
   int i = 1;               // 花费 1 unit_time 执行时间
   for (; i <= n; ++i) {    // 花费 n unit_time 执行时间
     sum = sum + i;         // 花费 n unit_time 执行时间
   }
   return sum;
 }

一个规律

通过上面的例子,我可以可以看出来,所有代码的执行时间 T(n) 与每行代码的执行次数 f(n) 成正比

注意,不要小看上面这句话:”每行代码的执行时间与每行代码的执行次数成正比“,已经将代码的执行时间与代码的执行次数绑定了起来,就像美元与石油的绑定,奠定了美帝全球霸权的基础。

一个表示法

将上面的规律总结成一个公式,就得到了大O表示法T(n) = O( f(n) )

其中,O表示代码的执行时间T(n) 与 f(n) 成正比

上面的例子就可以写成 T(n) = O( 2n+2 )

我们大O表示法来表示时间复杂度。但是大O复杂度并不具体表示真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势。

所以,时间复杂度也叫作渐进空间复杂度(asymptotic time complexity),简称时间复杂度

当 n 很大的时候,公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略,所以在最后只需要记录一个最大量级就可以了,比如 T(n) = O( 2n+2 )就可以记为 T(n) = O(n), T(n) = O(2n2+2n+3) 就可以记为 T(n) = O(n2)。

(2)时间复杂度分析的技巧

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

2、加法法则:总复杂度等于量级最大的那段代码的复杂度(我咋觉得应该叫最大法则??)

抽象成公式:如果 T1(n)=O(f(n))T2(n)=O(g(n));那么 T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n)))

大白话理解:多段代码,找复杂度最大的那个
(一般是循环)

示例:


int cal(int n) {
   int sum_1 = 0;
   int p = 1;
   for (; p < 100; ++p) {
     sum_1 = sum_1 + p;
   }

   int sum_2 = 0;
   int q = 1;
   for (; q < n; ++q) {
     sum_2 = sum_2 + q;
   }
 
   int sum_3 = 0;
   int i = 1;
   int 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;
 }

有三段代码,每段的复杂度分别是O(1)、O(n)和O(n2)

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

抽象成公式:
大白话理解:代码中嵌套着代码,复杂度为其乘积
(一般是嵌套循环)

示例:


int cal(int n) {
   int ret = 0; 
   int i = 1;
   for (; i < n; ++i) {
     ret = ret + f(i);
   } 
 } 
 
 int f(int n) {
  int sum = 0;
  int i = 1;
  for (; i < n; ++i) {
    sum = sum + i;
  } 
  return sum;
 }

假设 f() 只是一个普通的操作, cal() 函数的时间复杂度就是 T1(n) = O(n)。但是 f() 包含着循环,它本身的时间复杂度是 T2(n) = O(n),所以整个 cal() 函数的时间复杂度就是,T(n) = T1(n) * T2(n) = O(n*n) = O(n2)。

(3)典型时间复杂度及案例分析

在这里插入图片描述

上面罗列了可以接触的所有代码的复杂度量级,,可以粗略地分为两大类:多项式量级非多项式量级
其中,非多项式量级只有两个:O(2n)和O(n!)。
我们把时间复杂度为非多项式量级的算法问题叫作 NP(Non-Deterministic Polynomial,非确定多项式)问题

我们主要看几种常见的多项式时间复杂度

1、O(1)


 int i = 8;
 int j = 6;
 int sum = i + j;

注意 Tn = O(1),而不是 Tn = O(3)

只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1)。

或者说,一般情况下,只要算法中不存在循环语句递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)

2、O(logn)和O(n logn)


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

Tn = O(log2n),但是记为 Tn = O(logn)

对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度。


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

Tn = O(log3n),但是也记为 Tn = O(logn)

一律表示为Tn = O(logn)的原因是,对数之间可以相互转换,系数为常量。

对于O(n logn),相当于用了一次乘法法则,也就是说多了一个循环嵌套!
很多常见的算法的时间复杂度都是O(n logn),比如归并排序、快速排序。

(现在知道了其时间复杂度,是不是就可以大致猜到这些算法用了大致什么方法?)

3、O(m+n)、O(m*n)


int cal(int m, int n) {
  int sum_1 = 0;
  int i = 1;
  for (; i < m; ++i) {
    sum_1 = sum_1 + i;
  }

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

  return sum_1 + sum_2;
}

如果无法分辨两段代码谁的时间复杂度更高,比如出现了两个不同的数据规模 m 和 n,我们就不能再简单地利用加法法则省略其中一个。而应该是 T1(m) + T2(n) = O(f(m) + g(n)),所以上面代码的总复杂度是O(m+n)。

但是在乘法法则继续有效:T1(m)*T2(n) = O(f(m) * f(n))

2. 空间复杂度

类比时间复杂度,时间复杂度表示算法的存储空间与数据规模之间的增长关系,也叫作渐进空间复杂度。

常见的空间复杂度就是O(1)、O(n)、O(n2),像O(logn)、O(nlogn)这样的对数阶复杂度平时都用不到。

而且空间复杂度分析要比时间复杂度分析要简单得多。

代码示例:


void print(int n) {
  int i = 0;               // 空间复杂度为 O(1)
  int[] a = new int[n];    // 空间复杂度为 O(n)
  for (i; i <n; ++i) {
    a[i] = i * i;
  }

  for (i = n-1; i >= 0; --i) {
    print out a[i]
  }
}

第 2 行代码中,我们申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以我们可以忽略。第 3 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。

3. 最好、最坏、平均、均摊时间复杂度

这里讨论下最好最坏平均均摊时间复杂度。

为了表示代码在不同情况下的不用时间复杂度,我们需要引入最好时间复杂度、最坏时间复杂度和平均时间复杂度。


// n表示数组array的长度
int find(int[] array, int n, int x) {
  int i = 0;
  int pos = -1;
  for (; i < n; ++i) {
    if (array[i] == x) {
       pos = i;
       break;
    }
  }
  return pos;
}

(一段”查找“的代码)

最好和最坏时间复杂度

最好和最坏时间复杂度很好理解:

  • 最好时间复杂度:在理想情况下,执行这段代码的时间复杂度
    例如上面那段代码,最好时间复杂度就是O(1),对应要查找的元素就在数组第一个位置(所以不再需要遍历后面的 n-1 个元素)
  • 最坏时间复杂度:在最糟糕情况下,执行这段代码的时间复杂度
    例如上面那段代码,最好时间复杂度就是O(n),对应数组中不存在要查找的元素(所以需要将数组循环完整一遍)

平均时间复杂度

由于最好和最坏时间复杂度对应的都是最极端情况下的代码复杂度,发生的概率并不大,为了更好地表示平均情况下的复杂度,我们引入平均时间复杂度。

粗略的方法是用普通平均值

平 均 时 间 复 杂 度 ( 普 通 均 值 ) = 所 有 情 况 代 码 运 行 的 次 数 之 和 / 所 有 情 况 ( 数 量 ) 之 和 平均时间复杂度(普通均值) = 所有情况代码运行的次数之和 / 所有情况(数量)之和 =/

(为什么是“代码的运行次数”?前面已经讲过,每行代码的执行次数与每段代码的执行时间成正比

依此,上面的例子的平均时间复杂度计算:

在这里插入图片描述

注意:共有(n+1)种情况,包括了元素不在数组里的情况

但是问题是,每种情况发生的概率并不是一样的,所以这里使用加权平均值或者说期望平均值(而不是最普通的平均值计算):

平 均 时 间 复 杂 度 ( 加 权 均 值 ) = 发 生 的 概 率 ∗ 每 种 情 况 代 码 运 行 的 次 数 平均时间复杂度(加权均值) = 发生的概率 * 每种情况代码运行的次数 =

依此,上面代码的平均时间复杂度计算:
在这里插入图片描述
注意:这里对每种情况发生的概率进行了简化:

  • 假设元素在数组和不在数组中的概率为 1/2
  • 假设元素出现在 0~n-1 这 n 个位置的概率是一样的,为 1/n

均摊时间复杂度

均摊时间复杂度是一个更加高级的概念,它对应的复杂度分析方法叫摊还分析(或者叫平摊分析)。

大部分情况下,我们并不需要区分最好、最坏、平均三种复杂度,平均时间复杂度只在某些特殊性情况下才会用到,而均摊时间复杂度的应用场景比它更加特殊、更加局限。


 // array表示一个长度为n的数组
 // 代码中的array.length就等于n
 int[] array = new int[n];
 int count = 0;
 
 void insert(int val) {
    if (count == array.length) {
       int sum = 0;
       for (int i = 0; i < array.length; ++i) {
          sum = sum + array[i];
       }
       array[0] = sum;
       count = 1;
    }

    array[count] = val;
    ++count;
 }

(一段”插入“的代码)

代码解释:这段代码实现了往一个数组中插入数据的功能。当数组满了之后,用 for 循环进行数组求和,并清空数组,将求和后的 sum 值放到数组的第一个位置,然后再将新的数组插入。但是如何数组一开始就有空闲空间,则直接将数据插入数组。

用之前的三种时间复杂度进行分析

  • 最好时间复杂度:数组中有空间直接插入,则 T(n) = O(1)
  • 最坏时间复杂度:数组中没有空间,需要进行一次数组的遍历求和,此时 T(n) = O(n)
  • 平均时间复杂度
    n 次情况是直接插入,以及一种额外情况
    • 普通均值:T(n) = 所有情况下代码的执行次数之和 / 所有可能的情况数量之和 = f(n+ n / n + 1) = O(1)
    • 加权均值:
      每种情况的概率都为 1 / (n+1)
      T(n) = 1 x 1 / (n+1) + 1 x 1 / (n+1) + … + n x 1 / (n+1) = O(1)

其实针对上面那段代码,都不需要用到平均复杂度的方法,而是可以使用一种更加简单的分析方法:摊还分析法,由它计算出的世界复杂度叫做均摊时间复杂度

为什么呢?
上面的 insert() 函数跟 文章初的 find() 函数有两个不同:

  • 1、find() 函数在极端情况下,复杂度才为O(1);但是 insert() 在大部分情况下,时间复杂度都为O(1),只有个别情况,复杂度才比较高,为O(n)
  • 2、对于 insert() 函数来说,O(1)世界复杂度的插入和O(n)时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系:一般都是一个O(n)插入之后,紧跟着 n-1 个O(1)的插入操作,循环往复。

(1)思路
具体使用摊还分析的大致思路是:
每一次都把耗时多的 O(n) 插入操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续操作的均摊时间复杂度就是O(1)。

(2)应用场景
均摊时间复杂度和摊还分析应用场景比较特殊,所以并不会经常用到。

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度

尽管很多数据结构和算法书籍都花了很大力气来区分平均时间复杂度和均摊时间复杂度,但其实我个人认为,均摊时间复杂度就是一种特殊的平均时间复杂度,我们没必要花太多精力去区分它们。你最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均还是叫均摊,这只是个说法,并不重要。

4. 总结

1、原理

大道至简,关于几种复杂度分析的方法,其实本质都很简单,比如“最好”和“最坏”是人们对于极端情况的一种思考角度——从所有的可能(数据)中,选取最大和最小值来表示这组数据(的边界);而平均分析则是通俗意义上的综合考量——选出一个最能代表这组数据的数值,来代表这组数据,不论是“整除”还是加权平均,也就是为了计算出那一个数值

或者说,是数据分析的角度,将所有可能的代码运行次数或者说代码的执行时间看做成一组数据,时间复杂度分析就是找到一个数值来表示这组数据。

比如 find() 函数就可以理解为这样一串数字:1、2、3、…、n、n+1,其中 n+1 为不在数组内的那种情况;
insert() 函数可以理解为:1、1、1、…、n,其中这里有 n 个 1,n 代表插入位置超过数组空间的情况

2、方法
通过 find 函数来理解最好、最坏和平均时间复杂度的计算;
通过 insert 函数来理解均摊时间复杂度的计算

3、常见复杂度

在这里插入图片描述
O(1):普通程序
O(n):循环
O(n2):嵌套循环
O(logn):“翻倍计次”循环
O(nlogn):嵌套的“翻倍计次”循环

4、学习方法

复杂度分析并不难,关键在于多练。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值