0.渐进时间复杂度

849 阅读5分钟

1.时间复杂度的意义

举个栗子,用于实现同一个功能的a、b两份代码,其中

  • a执行需要100ms,内存占用10MB
  • b执行需要100s,内存占用100MB

很明显能看出,代码a优于代码b。由此可见,衡量代码优劣的两个指标:运行时间占用内存空间

这种通过执行代码,并统计得到代码执行时间和占用内存大小的方式,叫作事后统计法

这种统计方式是完全正确的,但是这种统计方式有很大的局限性,比如:

1.我在不同配置的机器(如i3、i9、r9等)上运行,由于CPU的差异,得出的结果肯定有偏差,甚至可能导致代码b的执行时间好于代码a;

2.同一段代码,对于不同的输入参数(如数据规模大小、有序度等),导致执行结果出现偏差。

所以我们需要一个不需要具体测试数据来执行测试代码,就可以粗略的估计出代码执行效率的方法

基于这个需求,我们看一段代码:

public int calculate(int n) {
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum = sum + i;
    }
    return sum;
}

我们假设每行代码的执行时间都一样,为unit_time,基于这个假设,计算这段代码的总执行时间?

第2行需要执行1个unit_time,

第3、4行需要执行n次,需要执行2nunit_time,,

所有这个段代码的总执行时间是(2n + 1)*unit_time,可以看出代码的执行时间与每行代码的执行次数成正比。

使用函数来表示就是:代码的执行时间T(n)与每行代码的执行次数f(n)成正比,其中,T(n)是执行时间,f(n)是代码执行次数总和,n表示数据规模大小。

2.操作执行次数

关于代码的操作执行次数,我们用小学时做过的水池问题,来做一下比喻:

案例1: 一个水管2天可以注满一水池的水,注满10个水池需要多少天?

自然是:2 * 10 = 20天。

如果需要注满n个水池,需要 2 * n = 2n天。

使用一个函数计算这个相对时间就是:T(n) = 2n

案例2: 第一天放满1个水池,第二天放满2个,第三天放满4个...第几天能放满32个水池(不是演唱会)?

我们使用简单的代码来解释一下:

//记每天放水里为count,第一天为1
//总的放水量为n,n为32
int count = 1;
while(count <= n) {
    count = count * 2;
}

代码中可以看出,变量count的值从1开始,每次循环乘2,使用高中的对数来解决,每个循环count的值为:

2º 2¹ 2² 2³ ... 2ⁱ = n

我们需要计算循环代码块的执行次数,使用2ⁱ = n求解,i=logn,也就是T(n) = logn。

带入案例里测试一下

天数循环次数水池个数
101
212
324
438
5416
6532

第6天的时候,可以放满32池水

案例3: 有水厂A和水厂B,水厂A每天可以放满一池水,水厂B每2天可以放满一池水,问水厂B放满一池水需要几天?

额,2天,跟A木有关系,T(n) = 2

案例4: 第一天放满1个水池,第二天放满2个,第三天放满3个,每天比前一天多放满1个,到第10天的时候,总共放满了多少池的水?

答:1+2+3+4+...+10=55

那么第n天的时候如何计算,这时候又用到了高中学过的累加求和公式:1+2+3+...+n=n(n+1)/2=½(n²+n),

T(n) = ½(n²+n)

上面的案例分别对应了几种执行方式:

案例1:T(n) = 2n,执行次数是呈线性增长的;

案例2:T(n) = logn,执行次数是呈对数增长的;

案例3:T(n) = 2,执行次数是呈常数增长的;

案例4:T(n) = ½(n²+n),执行次数是呈次数增长的;

3.渐进时间复杂度

在获取到基本操作的执行次数T(n)后,对执行时间的计算还是不够清晰,如上案例1和4,T(n) = 2n,T(n) = ½(n²+n),n的取值会影响最终的结果,比如取一个更明显的T(n) = 233n,T(n) = 4n²,n<50时,前面的值更大,n>60时,后面的值更大,,最终T(n)的值需要看n的取值。

当n取值很大,接近于无穷大时,公示中的常量、低阶的值、系数等不会影响到最终结果的增长趋势,可以被忽略,这种方式叫作渐进时间复杂度,简称时间复杂度,用大O表示。它描述的是代码随数据规模增加的趋势,而不是真正的执行时间。

在分析一个算法的时间复杂度时,我们只关注执行次数最多的一段代码,其中,常量的时间复杂度为O(1),我们分析一下上述4个案例的时间复杂度:

案例1:2n,忽略系数后,T(n) = O(n);

案例2:logn,不用忽略,T(n) = O(logn);

案例3:2,常量的时间复杂度为O(1),T(n) = O(1);

案例4:½(n²+n)即½n²+½n,忽略低阶剩余½n²,忽略系数,T(n) = O(n²);

我们通过图表来看一下时间复杂度的趋势: 案例1 线性增长趋势 案例2 对数增长趋势 案例3 常量增长趋势 案例4 平方增长趋势

4.补充:空间复杂度

时间复杂度描述的是算法的执行时间与数据规模的增长关系趋势,同样的,空间复杂度表示,算法的存储空间占用与数据规模的增长关系趋势。

举个栗子,将一个数组反序:

public int[] reverse(int[] a) {
    int index = 0;
    int[] b = new int[a.length];
    for (int i = a.length; i > 0; i--) {
        b[index++] = a[i];
    }
    return b;
}

代码中声明了一个变量index,占用一个空间单位的存储,之后申请了一个大小为n的int数组b,使用空间为n,后续没有新的空间占用,总结一下:index占用空间为常量,可以忽略,数组b占用空间为n,最终空间复杂度为O(n)。

5.总结

衡量代码优劣的两个指标:运行时间占用内存空间,也就是这一篇所讲的时间复杂度和空间复杂度。

时间复杂度和空间复杂度用于分析算法执行效率与数据规模直接的增长趋势,可以粗略概况为:越高阶的算法,执行效率越低。

时间复杂度趋势图

今天的内容也是在后续刷题过程中,需要多关注的点,算是基础内容,后续还会有针对某种数据结构或算法的分析,随着多刷题,相信会越来越熟练。