《大话数据结构》--时间复杂度与空间复杂度

384 阅读11分钟

时间复杂度与空间复杂度

  • 时间复杂度:用于评估执行程序所消耗的时间,可以估算出程序对处理器的使用程度。
  • 空间复杂度:用于评估执行程序所占用的内存空间,可以估算出程序对计算机内存的使用程度。

在实践中或面试中,我们不仅要能够写出具体的算法来,还要了解算法的时间复杂度和空间复杂度,这样才能够评估出算法的优劣。当时间复杂度和空间复杂度无法同时满足时,还需要从中选取一个平衡点。

一个算法通常存在最好、平均、最坏三种情况,我们一般关注的是最坏情况。最坏情况是算法运行时间的上界,对于某些算法来说,最坏情况出现的比较频繁,也意味着平均情况和最坏情况一样差。

通常,时间复杂度要比空间复杂度更容易出问题,更多研究的是时间复杂度,面试中如果没有特殊说明,讲的也是时间复杂度

算法时间复杂度的定义

在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间量度,记做:T(n) = O(f(n)).它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度,简称为时间复杂度。其中f(n)是问题规模n的某个函数。

问题规模n和执行次数T(n)

一般随着n增大,T(n)增长最慢的算法被称之为最优算法。

常见的时间复杂度有:O(1)常数型;O(log n)对数型,O(n)线性型,O(nlog n)线性对数型,O(n2)平方型,O(n3)立方型,O(nk)k次方型,O(2n)指数型。

image.png

上图为不同类型的函数的增长趋势图,随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行效率越低。

常见的算法时间复杂度由小到大依次为:Ο(1)<Ο(log n)<Ο(n)<Ο(nlog n)<Ο(n2)<Ο(n3)<…<Ο(2^n)<Ο(n!)。

值得留意的是,算法复杂度只是描述算法的增长趋势,并不能说一个算法一定比另外一个算法高效。这要添加上问题规模n的范围,在一定问题规范范围之前某一算法比另外一算法高效,而过了一个阈值之后,情况可能就相反了,通过上图我们可以明显看到这一点。这也就是为什么我们在实践的过程中得出的结论可能上面算法的排序相反的原因。

一个程序的运行时间,依赖于算法的好坏和问题的输入规模。所谓问题输入规模是指输入量的多少。

计算1+2+3+······+100的结果

两个算法的第一行和最后一行语句一样,所以我们关注中间部分。我们把循环看做整体,忽略头尾循环判断的开销,那么这两个算法其实就是n次和1次的差别。

在上述算法中,

第一种n=100,T(n) = 100;

第二种n=100,T(n) = 1;

运行时间和有消耗的基本操作的执行次数成正比

如何推导时间复杂度

上面我们了解了时间复杂度的基本概念及表达式,那么实践中我们怎么样才能通过代码获得对应的表达式呢?这就涉及到求解算法复杂度。

求解算法复杂度一般分以下几个步骤:

  • 找出算法中的基本语句:算法中执行次数最多的语句就是基本语句,通常是最内层循环的循环体。
  • 计算基本语句的执行次数的数量级:只需计算基本语句执行次数的数量级,即只要保证函数中的最高次幂正确即可,可以忽略所有低次幂和最高次幂的系数。这样能够简化算法分析,使注意力集中在最重要的一点上:增长率。
  • 用大Ο表示算法的时间性能:将基本语句执行次数的数量级放入大Ο记号中。

其中用大O表示法通常有三种规则:

  • 用常数1取代运行时间中的所有加法常数;
  • 只保留时间函数中的最高阶项;
  • 如果最高阶项存在,则省去最高阶项前面的系数;

下面通过具体的实例来说明以上的推断步骤和规则。

大O记法

用大写O()来体现算法时间复杂度的记法,称之大O记法。O(1)叫做常数阶、O(n)叫做线性阶、O(n2)称作平方阶。

推导大O阶方法

  1. 使用常数1取代运行时间中的所有加法常数。
  2. 在修改后的运行次数函数中,只保留最高阶项
  3. 如果最高阶项存在且不是1,则取出与这个项相乘的常数,得到的结果就是大O阶

时间复杂度实例

常数阶O(1)

无论代码执行了多少行,只要是没有循环等复杂结构,那这个代码的时间复杂度就都是O(1),如:

int i = 1;
int j = 2;
int k = 1 + 2;

上述代码执行时,单个语句的频度均为1,不会随着问题规模n的变化而变化。因此,算法时间复杂度为常数阶,记作T(n)=O(1)。这里我们需要注意的是,即便上述代码有成千上万行,只要执行算法的时间不会随着问题规模n的增长而增长,那么执行时间只不过是一个比较大的常数而已。此类算法的时间复杂度均为O(1)。

int n = 100, sum = 0;  // 执行1次
sum = (1 + n) * n / 2;//执行1次
System.out.println(sum); //执行1次

运行函数f(n) = 3。根据推导大O阶方法,

  1. 把常数项的3改为1
  2. 没有最高阶项,所以算法复杂度为O(1).

对数阶O(log n)

int i = 1;
while(i < n) {    
    i = i * 2; // 时间复杂度为O(1)的程序步骤
}

从上面代码可以看到,在while循环里面,每次都将 i 乘以 2,乘完之后,i 距离 n 就越来越近了。我们试着求解一下,假设循环x次之后,i 就大于 2 了,此时这个循环就退出了,也就是说 2 的 x 次方等于 n,那么 x = log2^n
也就是说当循环 log2^n 次以后,这个代码就结束了。因此这个代码的时间复杂度为:O(logn)

线性阶O(n)

示例代码:

int j = 0; // ①
for (int i = 0; i < n; i++) { // ②
   j = i; // ③
   j++; // ④
}

上述代码中,语句①的频度为1,②的频度为n,③的频度为n-1,④的频度为n-1,因此整个算法可以用公式T(n)=1+n+(n-1)+(n-1)来表示。进而可以推到T(n)=1+n+(n-1)+(n-1)=3n-1,即O(n)=3n-1,去掉低次幂和系数即O(n)=n,因此T(n)=O(n)。

在上述代码中for循环中的代码会执行n遍,因此它消耗的时间是随着n的变化而成线性变化的,因此这类算法都可以用O(n)来表示时间复杂度。

for (int i = 0; i < n; i++) {     
    // 时间复杂度为O(1)的程序步骤
}

分析算法的复杂度,关键是分析循环体结构的运行情况。由于循环体中代码需要执行n次,所以时间复杂度为O(n)

线性对数阶O(nlogN)

示例代码:

for (int m = 1; m < n; m++) {
   int i = 1; // ①
   while (i <= n) {
      i = i * 2; // ②
   }
}

线性对数阶要对照对数阶 O(log n)来进行理解。上述代码中for循环内部的代码便是上面讲到对数阶,只不过在对数阶的外面套了一个n次的循环,当然,它的时间复杂度就是n*O(log n)了,于是记作O(nlog n)。

平方阶O(n²)

for (int i = 0; i < n; i++) {    
    for (int j = 0; j < n; j++) {        
        // 时间复杂度为O(1)的程序步骤    
    }
}

内循环的时间复杂度为O(n),再循环n次,时间复杂度为O(n2)。如果外循环的次数改为m,时间复杂度为O(m*n)。

for (int i = 0; i < n; i++) {    
    for (int j = i; j < n; j++) {        
        // 时间复杂度为O(1)的程序步骤    
    }
}

当i=0时,内循环循环n次,i=1时,循环n-1次·······,当i=n-1时,执行了1次。 执行次数 = n + (n-1) + (n-2) + ····· + 1 = n(n-1)/2 = n2/2 + n/2.

  1. 没有加法常数,不予考虑。
  2. 只保留最高项。因此为 n2/2
  3. 去除这个项相乘的常数,也就是1/2。

最终复杂度为O(n2)

static void function(int count) {    
    for (int i = 0; i < n; i++) {        
        // 时间复杂度为O(1)的程序步骤    
    }
}

function(n);    // 执行次数 n次
for (int i = 0; i < n; i++) {    // 执行次数 n*n次
    function(n);
}    
for (int i = 0; i < n; i++) {     // 执行次数 n*(n+1)/2次
    for (int j = i; j < n; j++) {        
        // 时间复杂度为O(1)的程序步骤    
    }
}

执行次数f(n) = n + nn + n(n+1)/2 = 3n2/2 + 3n/2,根据推导大O阶方法,最终复杂度也为O(n2)

立方阶O(n³)、K次方阶O(n^k)

参考上面的O(n²) 去理解就好了,O(n³)相当于三层n循环,其它的类似。

除此之外,其实还有 平均时间复杂度、均摊时间复杂度、最坏时间复杂度、最好时间复杂度 的分析方法,有点复杂,这里就不展开了。

排序算法对比

上面介绍了各种示例算法的时间复杂度推理过程,对照上面的时间复杂度以及算法效率的大小,来看一下我们常见的针对排序的几种算法的时间复杂度对比。

image.png

最坏情况和平均情况

一般我们查找1-n中的某个数字,最好结果是放在第一个位置,时间复杂度为O(1)。最坏就是在最后的位置,时间复杂度为O(n)。

最坏情况运行时间是一种保证,就是运行时间将不会再坏了。通常提到的运行时间都是最坏情况的运行时间。

平均时间是所有情况中最有意义的,因为他是期望的运行时间,上述问题平均查找时间是n/2。

对算法的分析,一种是计算所有情况的平均值,称为平均时间复杂度。一种是计算最坏情况下的时间复杂度,称为最坏时间复杂度。一般没有特殊说明,都是指最坏时间复杂度。

空间复杂度

最后,我们再了解一下空间复杂度。空间复杂度主要指执行算法所需内存的大小,用于对程序运行过程中所需要的临时存储空间的度量,这里的空间复杂度同样是预估的。

程序执行除了需要存储空间、指令、常数、变量和输入数据外,还包括对数据进行操作的工作单元和存储计算所需信息的辅助空间。存储空间通常包括:指令空间(即代码空间)、数据空间(常量、简单变量)等所占的固定部分和动态分配、递归栈所需的可变空间。其中可变空间与算法有关。

一个算法所需的存储空间用f(n)表示。S(n)=O(f(n))其中n为问题的规模,S(n)表示空间复杂度。

下面看两个常见的空间复杂度示例:空间复杂度O(1)和O(n)。

空间复杂度 O(1)

空间复杂度为O(1)的情况的示例代码与时间复杂度为O(1)的实例代码一致:

int i = 1;
int j = 2;
int k = 1 + 2;

上述代码中临时空间并不会随着n的变化而变化,因此空间复杂度为O(1)。总结一下就是:如果算法执行所需要的临时空间不随着某个变量n的大小而变化,此算法空间复杂度为一个常量,可表示为 O(1),即 S(n) = O(1)。

空间复杂度 O(n)

示例代码:

int j = 0;
int[] m = new int[n];
for (int i = 1; i <= n; ++i) {
   j = i;
   j++;
}

上述代码中,只有创建int数组分配空间时与n的大小有关,而for循环内没有再分配新的空间,因此,对应的空间复杂度为S(n) = O(n)。