【数据结构与算法】2、复杂度分析

只要讲到数据结构和算法,就必定离不开时间、空间复杂度分析。复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半。算法

为何须要复杂度分析?

过后分析法:把代码跑一遍,经过统计、监控、就能获得算法执行的时间、占用的内存大小,可是这种统计方法具备很大的局限性数组

一、测试结果依赖测试环境

测试环境中,硬件的不一样会对测试结果有很大的影响,好比 i9 的运算速度会比 i3 快,或者在 A 计算机上执行某个代码块a 的速度比另外一个代码块 b 的速度要快,放到 B 计算机上可能又会获得不一样的结果。数据结构

二、测试结果受测试数据的规模的影响很大

若是是在测试排序算法,测试数据的有序性极可能会影响不一样排序算法的执行时间;若是测试数据规模过小,测试结果有可能没法真实地反映算法的性能。数据结构和算法

咱们须要一个不须要用具体的测试数据来测试,就能够粗略的估计算法的执行效率的方法。这就是时间、空间复杂度分析。函数

大 O 复杂度表示法

算法的执行效率,粗略地讲,就是算法的执行时间。可是如何在不运行代码的状况下,用“肉眼”获得一段代码的执行时间呢?性能

这里有一段很是简单的代码例 1,求 1,2,3...n 的累加和:学习

例 1:  
1  int cal(int n){  
2    int sum = 0;  
3    int i = 1;  
4    for(; i <= n; i ++){  
5       sum = sum + i;  
6    }  
7   
8    return sum;  
9  }

从 CPU 的角度来看,例 1 的代码的每一行都执行着相似的操做:读数据 -- 运算 -- 写数据。尽管每一行代码对应的 CPU 执行的个数、执行时间都不同,可是,咱们这里只是粗略估计,因此能够假设每行代码执行的时间都为 unit_time测试

例 1 中,第 二、三、8 行代码须要 3 * unit_time 的执行时间;第 四、5 行都运行了 n 遍,须要 2n * unit_time 的执行时间;总的执行时间就是T(n) = ( 2n + 3 ) * unit_time 。能够看出,全部代码的执行时间 T(n) 与每行代码的执行次数成正比spa

再看例 2:code

例 2:  
1  int cal(int n){  
2    int sum = 0;  
3    int i = 1;  
4    int j = 1;  
5    for(; i <= n; i ++){  
6      j = 1;  
7      for(; j <= n; j ++){  
8        sum = sum + i;  
9      }  
10   }  
11   
12   return sum;  
13  }

例 2 中,第 二、三、四、12 行代码须要 4 * unit_time 的执行时间;第 五、6 行代码须要 2n * unit_time 的执行时间;第 七、8 行代码循环执行了 n² 遍,须要 2 * n² * unit_time 的执行时间;总的执行时间就是 T(n) = ( 2n² + 2n + 4 ) * unit_time 。

根据例1 和例 2 的推导过程,能够获得一个很是重要的规律:全部的代码的执行时间 T(n) 与每行代码的执行次数 n 成正比

咱们把这个规律总结成一个公式,就是大 O 复杂度表示法

公式0.png

其中,T(n) 表示代码执行的时间,n 表示数据规模的大小,f(n) 表示每行代码执行的次数总和;O 表示代码的执行时间 T(n) 与代码的执行次数 f(n) 成正比。

大 O 复杂度表示法来表达时间复杂度,例 1 为 T(n) = O( 2n + 3),例 2 为 T(n) = O( 2n² + 2n + 4 )。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增加的变化趋势,因此也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度

当 n 很大时,公式中的低阶、常量、系数三部分并不左右增加趋势,因此都能够忽略,只须要记录一个最大量级就能够了。例 一、例 2 的时间复杂度,就分别能够记为: T(n) = O( n );T(n) = O( n² )。

时间复杂度分析

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

大 O 复杂度表示方法,只是表示一种变化趋势,咱们一般会忽略掉公式中的常量、低阶、系数,只需记录一个最大阶的量级就能够了。

咱们在分析一个算法、一段代码的时间复杂度的时候,只关注循环执行次数最多的那一段代码就能够了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。

仍以例 1 为例:

例 1:  
1  int cal(int n){  
2    int sum = 0;  
3    int i = 1;  
4    for(; i <= n; i ++){  
5      sum = sum + i;  
6    }  
7   
8    return sum;  
9  }

其中第 二、三、8 行代码都是常量级的执行时间,与 n 的大小无关,因此对于时间复杂度并没有影响。循环执行次数最多的是第 四、5 行代码,因此这块代码要重点分析。因为这两行代码被执行了 n 次,因此总的时间复杂度就是 O( n )。

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

下面你们看例 3:

例 3:  
1  int cal(int n){  
2    int sum_1 = 0;  
3    int p = 1;  
4    for(; p < 100; p ++){  
5      sum_1 = sum_1 + p;  
6    }  
7   
8    int sum_2 = 0;  
9    int q = 1;  
10   for(; q < n; q ++){  
11     sum_2 = sum_2 + q;  
12   }  
13  
14   int sum_3 = 0;  
15   int i = 1;  
16   int j = 1;  
17   for(; i <= n; i ++){  
18     j = 1;  
19     for(; j <= n; j ++){  
20         sum_3 = sum_3 + i * j;  
21     }  
22   }  
23   
24   return sum_1 + sum_2 + sum_3;  
25 }

例 3 的代码分为三部分,分别求 sum_1 、sum_2 、sum_3 。

  1. 第一段代码循环执行了 100 次,因此是一个常量的执行时间,跟 n 的规模无关;
  2. 第二段代码的 十、11 一共执行了 2n 次,因此时间复杂度 T(n) = O( n ) ;
  3. 第三段代码的 1九、20 行代码一共执行了 2n² 次,因此时间复杂度是 T(n) = O( n² )。

综合这三段代码的时间复杂度,咱们取其中最大的量级,因此整段代码的时间复杂度为 T(n) = O( n² )。也就是说,总的时间复杂度,等于量级最大的那段代码的时间复杂度。

将这个规律抽象成公式,以下:

若是:

公式1.png

那么:

公式2.png

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

有以下代码例 4:

例 4:  
1  int cal(int n){  
2    int ret = 0;  
3    int i = 1;  
4    for(; i < n; i ++){  
5      ret = ret + f(i);  
6    }  
7   
8    return ret;  
9  }  
10  
11 int f(int n){  
12   int sum = 0;  
13   int i = 1;  
14   for(; i < n; i ++){  
15       sum = sum + i;  
16   }  
17   
18   return sum;  
19 }

例 4 的代码为嵌套循环代码。假设 f() 只是一个普通的操做,那 四、5 行的时间复杂度就是T1(n) = O( n )。可是,f() 函数自己不是一个简单的操做,它的时间复杂度为T2(n) = O( n )。因此,例 4 中整个 cal() 函数的时间复杂度就是T(n) = T1(n) * T2(n) = O( n * n ) = O( n² )。

也就是说,总的时间复杂度,等于循环调用代码的时间复杂度的乘积。

将这个规律抽象成公式,以下:

若是:

公式3.png

那么:

公式4.png

几种常见的时间复杂度

常见的复杂度量级并很少,粗略的分为两类:多项式量级非多项式量级

多项式量级:

  • 常量阶 O(1)
  • 对数阶 O(㏒n)
  • 线性阶 O(n)
  • 线性对数阶 O(n ㏒n)
  • 平方阶 O(n²)、立方阶 O(n³) ... k次方阶 O(nᵏ)

非多项式量级:

  • 指数阶 O(2ⁿ)
  • 阶乘阶 O( n! )

咱们把时间复杂度为非多项式量级的算法问题叫作 NP(Non-Deterministic Polynomial,非肯定多项式)问题。

当数据规模愈来愈大时,非多项式量级算法的执行时间和急剧增长,求解问题的执行时间会无线增加。因此,非多项式时间复杂度的算法实际上是很是低效的算法。了解几种常见的多项式时间复杂度便可:

一、O(1)

二、O(㏒n)、O(n ㏒n)

三、O(m+n)、O(m\*n)

空间复杂度分析

前面提到,时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增加关系。

类比一下,空间复杂度全称就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增加关系。

相对时间复杂度来讲,空间复杂度分析要简单得多。

下面看一下例 5:

例 5:  
1  void print(int n){  
2    int i = 0;  
3    int[]a= new int[n];  
4    for(; i < n; i ++){  
5      a[i] = i * i;  
6    }  
7   
8    for(i = n - 1; i >= 0; i --){  
9      System.out.println(a[i]);  
10   }  
11 }

跟时间复杂度分析同样,能够看到,第 2 行代码中,咱们申请了一个空间存储变量 i,可是它是常量阶的,与数据规模 n 无关,因此能够忽略;第 3 行申请了一个大小为 n 的 int 类型数组;除此以外,剩下的代码都没有占用更多的空间,因此整段代码的空间复杂度就是 O( n )。

咱们常见的空间复杂度就是O( 1 )、O( n )、O( n² )。

最好、最坏状况时间复杂度

例 6:

例 6:  
1  int find(int[]array, 
   /*n 表示数组 array 长度*/int n, 
   /*x 表示须要寻找的数字*/int x){  
2    int i = 0;  
3    int pos = -1;  
4    for(; i < n; i ++){  
5      if(array[i] == x){  
6        pos = i;  
7      }  
8    }  
9   
10   return pos;  
11 }

例 6 要实现的功能,是在一个无序的数组中,查找变量 x 出现的位置,若是没有找到,就返回 -1;按照大 O 表示法,例 6 的时间复杂度是 O( n ),其中,n 表明数组长度。

可是,咱们在数组中查找某一个数字,不用所有都遍历一遍,有可能半途中就找到了,就能够提早结束循环了。

将例 6 改进后,获得例 7:

例 7:  
1  int find(int[]array, 
   /*n 表示数组 array 长度*/int n, 
   /*x 表示须要寻找的数字*/int x){  
2    int i = 0;  
3    int pos = -1;  
4    for(; i < n; i ++){  
5      if(array[i] == x){  
6        pos = i;  
7        break;  
8      }  
9    }  
10   
11   return pos;  
12 }

不一样的状况下,例 7 的时间复杂度是不同的:

  • 若是数组中的第一个数字恰好是 x,那例 7 的时间复杂度就是 O( 1 )
  • 若是数组中的最后一个数字才是 x,那例 7 的时间复杂度就是 O( n )

下面引入三个概念,用来表示代码在不一样状况下的不一样时间复杂度

一、最好状况时间复杂度

最好状况时间复杂度,就是在最理想的状况下,执行这段代码的时间复杂度。

例 7 中,若是数组中第一个数字就是 x,那这时候的时间复杂度就是最好状况时间复杂度。

二、最坏状况时间复杂度

最坏状况时间复杂度,就是在最不理想的状况下,执行这段代码的时间复杂度。

例 7 中,若是数组中 x 是最后一个数字,那这时候的时间复杂度就是最坏状况时间复杂度。

三、平均状况时间复杂度

最好状况时间复杂度和最坏状况时间复杂度都是出如今极端环境下的代码复杂度,发生的几率并不大。为了更好地表示平均状况下的复杂度,咱们须要分析平均状况时间复杂度,简称平均时间复杂度。

平均时间复杂度

继续看例 7:

例 7:  
1  int find(int[]array, 
   /*n 表示数组 array 长度*/int n, 
   /*x 表示须要寻找的数字*/int x){  
2    int i = 0;  
3    int pos = -1;  
4    for(; i < n; i ++){  
5      if(array[i] == x){  
6        pos = i;  
7        break;  
8      }  
9    }  
10   
11   return pos;  
12 }

在例 7 中,咱们要查找的 x 所在的位置有 n+1 种状况:

  • 前 n 种状况:x 在数组的 0 ~ n-1 的位置中
  • 第 n+1 种状况:不在数组中

咱们把每种状况下,查找须要遍历的元素个数累加起来,而后除以 n+1,就能够获得须要遍历的元素个数的平均值:

  • 假如 x 在第 0 位,须要遍历 1 个元素(1)
  • 假如 x 在第 1 位,须要遍历 2 个元素(2)
  • 假如 x 在第 2 位,须要遍历 3 个元素(3)
  • 假如 x 在第 n-1 位,须要遍历 n 个元素(n)
  • 假如 x 不在数组中,一样须要遍历 n 个元素(n)

因此,总共的遍历个数为:

公式5.png

再除以总共的遍历方法 n + 1,即:

公式6.png

在大 O 表示法中,能够省略掉系数、低阶、常量,平均复杂度为 O( n )。

可是有一个问题,就是这 n+1 种状况,并非等几率的。

  1. 首先,变量 x要么在数组里,要么不在数组里。这两种几率统计起来比较麻烦,为了方便理解,假设 x 在数组中和不在数组中的几率都为 1/2
  2. 另外,要查找的变量 x,在 0 ~ n-1 这 n 个位置上的几率是同样的,均为 1/n ,因此总体上,要查找的 x 在 0 ~ n-1 这 n 个位置上的的几率为 1/2n

将几率因素考虑进去,平均时间复杂度为:

公式7.png

这样的计算结果叫作加权平均值,也叫作指望值,因此平均时间复杂的的其实是加权平均时间复杂度指望时间复杂度

舍掉系数和常量,获得的平均时间复杂度为 O( n )。

在大多数状况下,咱们并不须要区分最好、最坏、平均时间复杂度。只有同一块代码在不一样状况下,时间复杂度出现了量级的差距,才会使用这三种时间复杂度来表示。

均摊时间复杂度

大部分状况下,并不须要区分最好、最坏、平均时间复杂度,平均时间复杂度只在某些特殊场景有使用, 均摊时间复杂度的应用场景比平均时间复杂度更特殊、更有限。

事实上,均摊时间复杂度就是一种特殊的平均时间复杂度

例 8:

例 8:  
1  int[]array = new int[n];  // 长度为 n 的数组  
2  int count = 0;  
3   
4  void insert(int val){  
5    if(count == array.length){  
6      int sum = 0;  
7      for(int i = 0; i < array.length; i ++){  
8        sum = sum + array[i];  
9      }  
10     array[0] = sum;  
11     count = 1;  
12   }  
13   array[count] = val;  
14   count ++;  
15 }

例 8 实现了往数组中插入数据的功能:

  1. 当数组满了以后(也就是代码中 count == array.length 的时候),用 for 循环遍历数组求和,并清空数组,将求和以后的 sum 值放到数组的第一个位置,而后再将新的数据插入
  2. 若是数组一开始就有空闲时间,则直接将数据插入数组

因此例 8 的时间复杂度:

  • 最理想的状况下,数组中有空闲时间,咱们只须要将数据插入到数组下标为 count 的位置就好了,因此最好状况时间复杂度为 O( 1 )
  • 最坏的状况下,数组中没有空闲空间了,咱们须要先作一次数组的遍历求和,而后再将数据插入,因此最坏状况时间复杂度为 O( n )

最后经过几率计算一下平均时间复杂度:假设数组长度是 n,根据数据插入位置的不一样,能够分为 n 种状况,每种状况的时间复杂度为 O( 1 );另外还有 1 种状况,假如数组中没有空闲空间时,插入一个数据的时间复杂度为O( n )。并且,这 n+1 种状况发生的几率同样,都是 1/(n+1) 。根据加权平均的计算方法,能够求得平均时间复杂度:

公式8.png

使用大 O 表示法,舍掉常量、系数、低阶,平均时间复杂度为 O( 1 )。

可是例 8 中的平均复杂度分析其实并不须要这么麻烦。再将例 7 和例 8 的代码放在一块儿看一下:

例 7:  
1  int find(int[]array, 
   /*n 表示数组 array 长度*/int n, 
   /*x 表示须要寻找的数字*/int x){  
2    int i = 0;  
3    int pos = -1;  
4    for(; i < n; i ++){  
5      if(array[i] == x){  
6        pos = i;  
7        break;  
8      }  
9    }  
10   
11   return pos;  
12 }
例 8:  
1  int[]array = new int[n];  // 长度为 n 的数组  
2  int count = 0;  
3   
4  void insert(int val){  
5    if(count == array.length){  
6      int sum = 0;  
7      for(int i = 0; i < array.length; i ++){  
8        sum = sum + array[i];  
9      }  
10     array[0] = sum;  
11     count = 1;  
12   }  
13   array[count] = val;  
14   count ++;  
15 }
  1. 如前所述,例 7 中的 find() 函数在极端状况下才会出现复杂度为 O(1)的状况;可是例 8 中的 insert() 函数在大部分状况下,时间复杂度都是 O(1),只有个别状况下,复杂度才为 O(n)。这是二者之间的第一个差别
  2. 第二个差别,对于 insert() 函数来讲,O(1) 时间复杂度和 O(n) 时间复杂度,出现的频率是很是有规律的,并且有必定的先后时序关系,通常都是一个 O(n) 插入以后,紧跟着 n-1 个 O(1)插入,循环往复

因此,针对这样一种特殊场景的复杂度分析,并不须要以前的计算平均时间复杂度那样,引入几率计算加权平均值。由一种更加简单的分析方法:摊还分析法。经过摊还分析获得的时间复杂度,叫作均摊时间复杂度。

例 8 中的 insert() 函数,每一次 O(n) 插入操做后,都会跟着 n-1 次的 O(1) 插入操做,因此把耗时多的那次操做均摊到接下来的 n-1 次耗时少的操做上,均摊下来,这一组连续的操做的均摊时间复杂度就是 O(1)。

均摊时间复杂度和摊还分析的应用场景比较特殊,因此咱们不会常常用到。它们的应用场景通常以下:

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

思考分析

有以下代码例 9 ,试着分析一下 add() 函数的时间复杂度:

例 9:  
1  int[]array = new int[10];  
2  int len = 10;  
3  int i = 0;  
4   
5  void add(int element){  
6    if(i >= len){  
7      int[]new_array = new int[len * 2];  
8      for(int j = 0; j < len; j ++){  
9        new_array[j] = array[j];  
10     }  
11     array = new_array;  
12     len = len * 2;  
13   }  
14   array[i] = element;  
15   i ++;  
16 }

解答:

函数 add() 的做用,有两点:

  1. 代码 1四、15 行,将给定的数据 element 按顺序放入数组 array 中,这个过程当中的时间复杂度为 O(1)
  2. 代码 6-13 行,假如数组 array 已经存满,将数组 array 的容量扩充到原来容量的 2 倍,而后将原数组 array 元素赋值过去,再将给定的数据 element 按顺序放入数组 array 中,这个过程当中的时间复杂度为O(n),n 为数组 array 扩容前的长度。

由上述可知,最好时间复杂度为O(1),最坏时间复杂度为O(n)

通过观察,能够发现,1 、 2两步是有规律执行的。没有扩容时,先执行 len 次第 1 步,再执行 1 次第 2 步,此时完成一次扩容;而后执行 2*len-1 次第 1 步,再执行 1 次第 2 步,此时完成二次扩容;概括可得,执行的规律应该为 n 次 O(1),1 次 O(n),2*n-1 次 O(1),1 次 O(2n),4*n-1 次 O(1),1 次 O(4n)...

因此均摊复杂度应为O(1)

相关文章
相关标签/搜索