数据结构与算法(一) 时间复杂度、空间复杂度计算

时间:2024-02-17 19:59:14

一、时间复杂度计算

1、 时间复杂度的意义

复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半

  1. 测试结果非常依赖测试环境
  2. 测试结果受数据规模的影响很大

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

2、大 O 复杂度表示法

1)、 可以将计算时间复杂度的方式和计算代码执行次数来进行类别


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

第 2、3 行代码分别需要 1 个 unit_time 的执行时间,第 4、5 行都运行了 n 遍,所以需要 2n * unit_time 的执行时间,所以这段代码总的执行时间就是 (2n+2) * unit_time。可以看出来,所有代码的执行时间 T(n) 与每行代码的执行次数成正比

2)、 复杂一点的计算


int cal(int n) {              ----1
int sum = 0;                  ----2
int i = 1;                    ----3
int j = 1;                    ----4
for (; i <= n; ++i) {         ----5
  j = 1;                      ----6
  for (; j <= n; ++j) {       ----7
    sum = sum +  i * j;       ----8
  }                           ----9
}                             ----10
}                             ----11

T(n) = (2n^2+2n+3)unit_time

T(n)=O(f(n))

大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度

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))).
  3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

    ​ T(n) = T1(n) * T2(n) = O(n*n) = O(n2)

3、 常见的是时间复杂度

复杂度量级(递增)排列 公式
常量阶 O(1)
对数阶 O(logn)
线性阶 O(n)
线性对数阶 O(nlogn)
平方阶、立方阶...K次方阶 O(n2),O(n3),O(n^k)
指数阶 O(2^n)
阶乘阶 O(n!)

①. O(1):代码的执行时间和n没有关系,一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1);

②. O(logn)、O(nlogn)

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

通过 2x=n 求解 x 这个问题我们想高中应该就学过了,我就不多说了。x=log2n,所以,这段代码的时间复杂度就是 O(log2n)。

当底数为其他方式的时候,可以根据换底公式对去掉底数通过前面的学习,我们知道常量系数对于时间复杂度是没有影响的,所以可以统一换算为以10位底的对数,及来O(logn);

根据前面的乘法法则,执行n遍时间复杂度为O(logn)的代码段,即可以得到时间整个总的时间复杂度为O(nlogn);

③. 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;
}

针对这种情况,原来的加法法则就不正确了,我们需要将加法规则改为:

T1(m) + T2(n) = O(f(m) + g(n))

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

二、空间复杂度分析

1、定义:时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系

2、 例子:

 void print(int n) {
  int i = 0;
  int[] a = new int[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、 常见空间复杂度:O(1)、O(n)、O(n2), O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到;

4、 课后习题

有人说,我们项目之前都会进行性能测试,再做代码的时间复杂度、空间复杂度分析,是不是多此一举呢?而且,每段代码都分析一下时间复杂度、空间复杂度,是不是很浪费时间呢?你怎么看待这个问题呢?

答案肯定是否定的,性能测试很依赖于测试环境,环境的优劣影响着测试结果,另外,数据的测试规模也对结果有很大的影响,利用时间复杂度和空间复杂度的分析,可以排除环境原因,在理论上对程序的运行时间和占用空间做分析,均衡这两个点进行算法的选择

精选留言:

我不认为是多此一举,渐进时间,空间复杂度分析为我们提供了一个很好的理论分析的方向,并且它是宿主平台无关的,能够让我们对我们的程序或算法有一个大致的认识,让我们知道,比如在最坏的情况下程序的执行效率如何,同时也为我们交流提供了一个不错的桥梁,我们可以说,算法1的时间复杂度是O(n),算法2的时间复杂度是O(logN),这样我们立刻就对不同的算法有了一个“效率”上的感性认识。

当然,渐进式时间,空间复杂度分析只是一个理论模型,只能提供给粗略的估计分析,我们不能直接断定就觉得O(logN)的算法一定优于O(n), 针对不同的宿主环境,不同的数据集,不同的数据量的大小,在实际应用上面可能真正的性能会不同,个人觉得,针对不同的实际情况,进而进行一定的性能基准测试是很有必要的,比如在统一一批手机上(同样的硬件,系统等等)进行横向基准测试,进而选择适合特定应用场景下的最有算法。

综上所述,渐进式时间,空间复杂度分析与性能基准测试并不冲突,而是相辅相成的,但是一个低阶的时间复杂度程序有极大的可能性会优于一个高阶的时间复杂度程序,所以在实际编程中,时刻关心理论时间,空间度模型是有助于产出效率高的程序的,同时,因为渐进式时间,空间复杂度分析只是提供一个粗略的分析模型,因此也不会浪费太多时间,重点在于在编程时,要具有这种复杂度分析的思维
一、什么是复杂度分析?
1.数据结构和算法解决是“如何让计算机更快时间、更省空间的解决问题”。
2.因此需从执行时间和占用空间两个维度来评估数据结构和算法的性能。
3.分别用时间复杂度和空间复杂度两个概念来描述性能问题,二者统称为复杂度。
4.复杂度描述的是算法执行时间(或占用空间)与数据规模的增长关系。
二、为什么要进行复杂度分析?
1.和性能测试相比,复杂度分析有不依赖执行环境、成本低、效率高、易操作、指导性强的特点。
2.掌握复杂度分析,将能编写出性能更优的代码,有利于降低系统开发和维护成本。
三、如何进行复杂度分析?
1.大O表示法
1)来源
算法的执行时间与每行代码的执行次数成正比,用T(n) = O(f(n))表示,其中T(n)表示算法执行总时间,f(n)表示每行代码执行总次数,而n往往表示数据的规模。
2)特点
以时间复杂度为例,由于时间复杂度描述的是算法执行时间与数据规模的增长变化趋势,所以常量阶、低阶以及系数实际上对这种增长趋势不产决定性影响,所以在做时间复杂度分析时忽略这些项。
2.复杂度分析法则
1)单段代码看高频:比如循环。
2)多段代码取最大:比如一段代码中有单循环和多重循环,那么取多重循环的复杂度。
3)嵌套代码求乘积:比如递归、多重循环等
4)多个规模求加法:比如方法有两个参数控制两个循环的次数,那么这时就取二者复杂度相加。
四、常用的复杂度级别?
多项式阶:随着数据规模的增长,算法的执行时间和空间占用,按照多项式的比例增长。包括,
O(1)(常数阶)、O(logn)(对数阶)、O(n)(线性阶)、O(nlogn)(线性对数阶)、O(n^2)(平方阶)、O(n^3)(立方阶)
非多项式阶:随着数据规模的增长,算法的执行时间和空间占用暴增,这类算法性能极差。包括,
O(2^n)(指数阶)、O(n!)(阶乘阶)
五、如何掌握好复杂度分析方法?
复杂度分析关键在于多练,所谓孰能生巧。

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

  • 理解:最好情况时间复杂度、最坏情况时间复杂度、平均情况时间复杂度、均摊时间复杂度 相应的概念和区别
  • 学习四种时间复杂度的出现情况以及对应情况下时间复杂度的简单计算方法
  • 区别平均时间复杂度和均摊时间复杂度的相同点和不同点(了解)
1、最好、最坏情况时间复杂度
  • 顾明思意,及在最好情况下和最差的情况下,程序的时间复杂度

  • 先看一段程序

    
    // 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;
    }
    

    上面这段程序是查找数据所在数组的下标,假设数组中不存在重复元素,当所要查找的元素在第一个位置时,第一次执行for循环当中的语句即退出,程序的时间复杂度为O(1),当数组中不存在索要查找的元素时,则时间复杂度上升到O(n);所以:

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

    最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度

2、 平均情况时间复杂度
  • 仍然是上面那段程序,元素可以出现在任何位置,且出现几率相同,要查找的变量 x 在数组中的位置,有 n+1 种情况:在数组的 0~n-1 位置中和不在数组中,我们把每种情况下,查找遍历元素的次数加起来,除以n+1,得到平均每次需要遍历的个数

    化简后,去掉系数,则为O(n);

  • 上面的这种计算方法是有一定问题的,及:我们假设每个位置出现的查找元素的概率是相同的,实际上,是不同的

  • 我们知道,要查找的变量 x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便你理解,我们假设在数组中与不在数组中的概率都为 1/2。另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n),

  • 因此,前面的推导过程中存在的最大问题就是,没有将各种情况发生的概率考虑进去。如果我们把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样:

这个计算出来的值,叫做加权平均值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度;

3、均摊时间复杂度(了解:可看作为一种特殊的平均时间复杂度)

实际上,在大多数情况下,我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。像我们上一节课举的那些例子那样,很多时候,我们使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。

4、课后习题

// 全局变量,大小为10的数组array,长度len,下标i。
int array[] = new int[10]; 
int len = 10;
int i = 0;

// 往数组中添加一个元素
void add(int element) {
   if (i >= len) { // 数组空间不够了
     // 重新申请一个2倍大小的数组空间
     int new_array[] = new int[len*2];
     // 把原来array数组中的数据依次copy到new_array
     for (int j = 0; j < len; ++j) {
       new_array[j] = array[j];
     }
     // new_array复制给array,array现在大小就是2倍len了
     array = new_array;
     len = 2 * len;
   }
   // 将element放到下标为i的位置,下标i加一
   array[i] = element;
   ++i;
}

答案:最好:O(1);最坏:O(n);平均O(1);

当i < len时, 即 i = 0,1,2,...,n-1的时候,for循环不走,所以这n次的时间复杂度都是O(1);
当i >= len时, 即 i = n的时候,for循环进行数组的copy,所以只有这1次的时间复杂度是O(n);
由此可知:
该算法的最好情况时间复杂度(best case time complexity)为O(1);
最坏情况时间复杂度(worst case time complexity)为O(n);
平均情况时间复杂度(average case time complexity),
第一种计算方式: (1+1+...+1+n)/(n+1) = 2n/(n+1) 【注: 式子中1+1+...+1中有n个1】,所以平均复杂度为O(1);
第二种计算方式(加权平均法,又称期望): 1*(1/n+1)+1*(1/n+1)+...+1*(1/n+1)+n*(1/(n+1))=1,所以加权平均时间复杂度为O(1);
第三种计算方式(均摊时间复杂度): 前n个操作复杂度都是O(1),第n+1次操作的复杂度是O(n),所以把最后一次的复杂度分摊到前n次上,那么均摊下来每次操作的复杂度为O(1)