算法基础篇(一)——算法时间和空间复杂度

算法基础篇(一)——算法时间和空间复杂度

1.阅读这篇文章我能学到什么?
你可能想知道一些计算算法时间或空间复杂度的基本方法,那么就请阅读这篇文章。这篇文章例举了非常简单的例子带你一步步得出复杂度结果,将会简单又轻松。

——如果您觉得这是一篇不错的博文,希望您能给一个小小的赞,感谢您的支持。

几年前还在学校学习编程相关知识时,对算法的复杂度有过一段时间基本的了解,到了工作后虽实际用的不多,偶尔涉及到会上网查查。一直想找个时间去系统的学习下,弄懂它。
针对我们要解决的问题,如何选择一个最适合的算法是一个问题,我们需要综合考略很多因素,其中算法的时间和空间复杂度就是很重要的一个衡量标准。我们的计算机有两个宝贵的资源,一个是运行的时间,另一个就是空间的开销。学会衡量算法的时间空间复杂度将有利于我们写出或选择最适合的解决问题方法。

1. 时间复杂度

1.1 时间复杂度概念

时间复杂度反应执行一个算法对时间的开销程度。复杂度越低表明算法效率越高,一般能花更少的时间完成任务。这里我用了一般这个词,很多书上都以语句执行的次数来衡量两个算法谁花费的时间更多,还引入了一个概念叫时间频度T(n)。实际这个概念是有条件的,100次int类型的加法花费的时间是会比50次int类型加法多,但是在我们嵌入式芯片进行运算时,除了运算的次数还需要考虑运算的类型,对于一些CPU 50次浮点运算花费的时间很可能会远大于200次整数运算。单条语句的执行时间也是有区别的。我们看书的时候一定要结合实际不可盲目信书,开发中选择解决问题的算法要综合考虑多种因素。
如果我们用n表示问题的规模。一个算法花费的时间与算法中语句的执行次数成正比,我们将一个算法的语句执行次数称为时间频度T(n),它是关于n的函数。一般算法的执行次数与相关执行参数有关,如果用T(n)来比较两个算法谁的计算时间短,那必须先确定参数才能计算出次数进行比较。为了更好的描述算法的效率,我们一般用T(n)随n变化的数量级来作为算法的时间度量,我们引入大O来描述算法执行时间和规模n的这种数量级变化关系。
一般情况下,n规模增大而T(n)增长最慢的算法我们认为其时间复杂度更低。

1.2 几种常见的算法时间复杂度

O ( 1 ) < O ( log ⁡ 2 2 n ) < O ( n ) < O ( n log ⁡ 2 2 n ) < O ( n 2 ) < O ( n 3 ) < … < O ( 2 n ) < O ( n ! ) Ο(1)<Ο(\log_2{2n})<Ο(n)<Ο(n\log_2{2n})<Ο(n^2)<Ο(n^3)<…<Ο(2^n)<Ο(n!) O(1)O(log22n)O(n)O(nlog22n)O(n2)O(n3)O(2n)O(n!)

  • O(1)——常数阶
  • O(n)——线性阶
  • O(log2n)——对数阶
  • O(nlog2n)——线性对数阶
  • O(n^2)——平方阶

提示:计算机领域对数默认是以2为底,所以看到log省略底数时它表示2为底。

在这里插入图片描述

python3绘图代码:

import numpy as np
import math
import matplotlib.pyplot as plt

n = np.arange(1, 40, 1)

y1 = [1 for m in n]                                  #O(1)
y2 = [math.log(2 * m, 2) for m in n]                 #O(log2n)
y3 = n                                               #O(n)
y4 = [m * math.log(2 * m, 2) for m in n]             #O(nlog2n)
y5 = [math.pow(m, 2) for m in n]                     #O(n^2)
y6 = [math.pow(m, 3) for m in n]                     #O(n^3)
y7 = [math.pow(2, m) for m in n]                     #O(2^n)
y8 = [math.factorial(m) for m in n]                  #O(n!)

plt.plot(n, y1, color = "red", label = "O(1)")
plt.plot(n, y2, color = "orange", label = "O(log2n)")
plt.plot(n, y3, color = "yellow", label = "O(n)")
plt.plot(n, y4, color = "green", label = "O(nlog2n)")
plt.plot(n, y5, color = "blue", label = "O(n^2)")
plt.plot(n, y6, color = "violet", label = "O(n^3)")
plt.plot(n, y7, color = "coral", label = "O(2^n)")
plt.plot(n, y8, color = "cyan", label = "O(n!)")
plt.xlabel("n")
plt.ylabel("T(n)")
plt.title("Complexity")
plt.ylim(0, 300)
plt.xlim(0, 40)
plt.legend()
plt.show()

1.3 如何计算时间复杂度

通过前面的学习我们已经知道大O表示法表示的算法时间复杂度,实质是表示的运算执行次数的数量级数。
如何去计算时间复杂度呢?我们可以分四种情况:

1.3.1 不含循环

算法的执行次数就是算法的时间频度。这类算法的时间复杂度是O(1)。

//交换a和b的值
temp = a;         //执行1次
a = b;            //执行1次
b = temp;         //执行1次

这段代码总共执行了3次运算,确定的常数次运算算法的复杂度是O(1)。

1.3.2 含有循环

这种情况算法的时间复杂度取决于最内层循环执行的次数。

1.3.2.1 只含一个循环的情况:
//查找数组a中值等于target的
for(i = 0; i < n; i++)            //执行1~n次
{
    if(a[i] == target)            //执行1~n次
    {
        break;
    }
}

//判断查询结果
if(i < n)                         //执行1次
{
    Ret = i                       //执行0~1次
}
else
{
    Ret = -1                      //执行0~1次
}

这段代码最糟糕的情况下执行了 n + n + 1 + 1 n+n+1+1 n+n+1+1即频度 T ( n ) = 2 n + 2 T(n)=2n+2 T(n)=2n+2次,对于时间复杂度我们关注的是数量级, 2 n + 2 2n+2 2n+2属于什么数量级呢?可以按下面两条法则进行求解数量级:

  • 运算次数如果是常数则变1。
  • 运算次数如果关于n,则去除常数项,只保留高阶项且去掉高阶项的系数。
    我们说的某算法的复杂度是按其最坏情况下执行的次数求得,所以上面示例代码的时间复杂度为O(n)。
1.3.2.2 循环嵌套的情况
for(i = 0; i < n; i++)                      //执行n次
{
    for(j = 0; j < n; j++)                  //执行n次
    {
      a++;                                  //执行1次
    }
}

不管这段代码有啥实际用途。我们先来计算其频度。先考虑循环的最内层printf每次循环只会执行一次,但如果嵌套在循环j内则将执行 1 × n 1\times n 1×n次,是乘积关系,而循环j也执行了n次(严格来说for循环里是三个子句,n次循环里j = 0执行了1次,j < n比较了 n + 1 n+1 n+1次,j++执行了n次,为了便于分析问题我们可以假设for循环每次执行都只算一次,因为这不会影响最终的时间复杂度的数量级),所以第内层循环总共执行了 1 × n + n = 2 n 1\times n+n=2n 1×n+n=2n次。由于外层i循环有n,总执行次数为 n × 2 n = 2 n 2 n\times 2n=2n^2 n×2n=2n2。其频度为 2 n 2 2n^2 2n2,则算法时间复杂度为 O ( n 2 ) O(n^2) O(n2)。如果把内外循环分开来看,当循环嵌套时,其复杂度通常是内外循环复杂度的乘积。
再看一个例子:

//冒泡排序
for(i = 0; i < n - 1; i++)                  //执行n-1次
{
    for(j = 0; j < n - i - 1; j++)          //执行n - i - 1次
    {
        if(a[j] > a[j + 1])                 //执行1次
        {
            temp = a[j];                    //执行0~1次
            a[j] = a[j + 1];                //执行0~1次
            a[j + 1] = temp;                //执行0~1次
        }
    }
}

内层循环内的变量值交换需要满足一定条件才会执行,我们一般分析复杂度时按最坏的情况(除非刻意求最好的情况或平均情况)。最坏情况下内存循环里的语句每次都会执行,1个比较和3次赋值共4次运算(这里也是忽略j + 1运算,因为这也不会影响最终的时间复杂度数量级,为了便于分析和描述问题后文默认for循环括号内每次执行算一次)。更复杂的情况来了,内层j循环的次数并不确定,与外层i循环有关。i值从0到 n − 2 n-2 n2 n − 1 n-1 n1次,j值从 n − i − 2 n-i-2 ni2到0即 n − i − 1 n-i-1 ni1次,实际嵌套后的循环次数就构成了级数, ( n − 1 ) + ( n − 2 ) + ( n − 3 ) + . . . + 2 + 1 = ∑ n = 1 n − 1 a n = n × ( n − 1 ) 2 (n-1)+(n-2)+(n-3)+...+2+1=\sum_{n=1}^{n-1}a_n=\frac{n\times (n-1)}{2} (n1)+(n2)+(n3)+...+2+1=n=1n1an=2n×(n1),由于内层循环内还有4个运算要执行因此还要 × 4 \times 4 ×4才是频度,则算法时间复杂度为 O ( n 2 ) O(n^2) O(n2)

1.3.2.3 循环并列的情况
for(i = 0; i < n; i++)                      //执行n次
{
    a++;                                    //执行1次
}

for(j = 0; j < n; j++)                      //执行n次
{
    a++;                                    //执行1次
}

不管这段代码有没有实际用途(也不是完全没用,事实上单片机编程中可以用来用作粗略的Delay函数)。先分析i循环,a++放在n次循环内总共执行了 n × 1 = n n\times 1=n n×1=n次,而for循环括号内算一次的话也执行了n次,所以i循环的频度为 n + n = 2 n n+n=2n n+n=2n次,同理j循环频度也是2n次。并列的循环总频度等于各个循环频度相加,那么这段并列循环代码的总频度就是 2 n + 2 n = 4 n 2n+2n=4n 2n+2n=4n次,算法的时间复杂度为 O ( n ) O(n) O(n)

以上就是分析时间复杂度的最基本的思路,分析更复杂算法的时间复杂度需要关键就是分析好频度的级数。要是把循环换成递归也是同样的分析思路。

2. 空间复杂度

2.1 空间复杂度概念

时间复杂度和空间复杂度是衡量算法优劣的两个重要维度。空间复杂度反应算法所耗费存储空间的程度,它也是规模n的函数。算法的空间复杂度是算法在运行过程中占用存储空间大小的度量,而算法在执行过程中占用的存储空间可以分为三类:

    1. 算法输入输出数据所占用的存储空间 。这部分空间由所要解决的问题决定,与用什么算法无关。
    1. 存储算法本身所占用的存储空间 。就和任何文本数据一样都需要空间来存储这部分代码,显然越简短的算法一般会占用更少的存储空间。
    1. 算法在运行过程中占用的临时存储空间 ,一些算法的临时存储空间为常数且不随问题的规模变化,我们称这类算法是就地进行的,另一类算法的临时存储空间随着问题规模n而变化。
      类似前面的时间复杂度,空间复杂度是关注空间开销的级数,而不是具体个数。空间复杂度也是用大O表示法。以上三种算法的空间开销,我们主要关注的是第三种运行过程中算法占用的临时存储空间。

2.2 如何计算空间复杂度

  • 当占用的存储空间为常数时(不随问题规模n变化)时,空间复杂度为O(1)。
  • 与时间复杂度不同,空间复杂度循环时和递归时是不同的情况,因为递归会消耗空间(尾调用除外)。使用递归时空间复杂度=递归深度N*单次递归占用的空间。

_为了方便描述,下面都假设变量是int类型,占用2字节

2.2.1 占用常数空间
int a = 1;                      //创建a占用2Bytes
int b = 2;                      //创建b占用2Bytes
int temp = 0;                   //创建temp占用2Bytes

a = temp;
temp = b;
b = a;

总共出现了3个变量,共占用常数空间6Bytes,所以空间复杂度是O(1)。

2.2.2 含有循环时的空间复杂度计算
2.2.2.1 变量定义在循环外
int a = 0;                      //创建a变量占用2Bytes
for(i = 0; i < n; i++)          //创建i变量占用2Bytes
{
    a++;
}

总共出现了2个变量,共占用常数空间4Bytes,所以空间复杂度是O(1)。

2.2.2.2 变量定义在循环内
for(i = 0; i < n; i++)          //创建i变量占用2Bytes
{
    int a = 0;                  //创建a变量占用2Bytes,但是创建了n次
    a++;
}

我们知道内存的分配是在变量定义的时候,局部变量从定义后保持存在知道局部的作用域结束时将其释放,而将变量定义在循环内,每次循环都会分配一次变量空间,释放需要等到循环完全结束。所以这段代码的空间复杂度为:O(n)。我们应该避免在循环内定义变量。

2.2.3 含递归(不考虑尾调用)时的空间复杂度计算
void fun(int a)                 //创建a变量占用2Bytes,递归了n次
{
    int sum  = 13;              创建a变量占用2Bytes,递归了n次

    if(a >= sum)
    {
        return a;
    }
    else
    {
        a++;
        return fun(a);
    }

这段代码使用了递归,每次执行递归函数都要创建两个变量,占用4Bytes空间,假设递归了n次则空间复杂度为O(n)。我们知道函数是需要是要消耗栈空间的,调用了n次递归函数还需要消耗n次的栈空间。因尽量避免在递归函数内定义变量,sum可以用宏或静态变量代替。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值