数据结构与算法学习之复杂度分析

目录

复杂度介绍

在正式学习数据结构与算法之前,我们需要知道一个概念复杂度(时间复杂度和空间复杂度),它是干啥的? 它是学习数据结构与算法的基础,掌握了它就掌握了一半的数据结构与算法.

我们在平常开发或者代码review,程序压测,都需要进行评测代码执行效率和占用空间的大小.

以往我们在这方面的工作是,采用的是通过生产环境/预生产环境,进行程序压测,得到压测结果(包含代码执行时间和占用空间),用来评测该程序是否能在极端情况下正常运行.

这种测试方法叫做事后统计法,即根据硬件,网络设备,数据库,运行的程序环境等软硬件得到的压测结果.

这种测试方法的优点是:上手简单快速,易出结果.但是缺点也很明显,就是受制于软硬件,运行的程序环境的影响很大.不同的软硬件,程序运行环境所得到的压测结果也不同,也比较耗时财力

所以,为了能够得到一个较为准确的程序执行效率和占用空间的结果,我们引入了复杂度这一概念.

复杂度描述的是算法执行时间(或占用空间)与数据规模的增长关系。

复杂度

时间复杂度

就是指代码执行的效率问题,如以下代码

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

当前代码的第2,3行,都会执行一次,这个执行一次的时间叫做unit_time,第4,5行是个循环,它会循环n次,即执行的就是2nunit_time,那么该程序执行的时间效率为(时间复杂度):T(n) = (2nunit_time)+(2*unit_time).

按照现在的思路,再来分析一个例子

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

当前代码的第2,3,4行,都会执行一次,这个执行一次的时间叫做unit_time,第5,6行是个循环,它会循环n次,即执行的就是2n*unit_time,第7,8行也是个循环,那么他的执行时间2n^2 * unit_time

那么该程序执行的时间效率为(时间复杂度):T(n) = (2nunit_time+2n^2 * unit_time)+(3unit_time)转化一下(2n^2 + 2n+3)*unit_time.

根据上面两个例子的推导,我们虽然不知道unit_time,但是可以发现一个规律,就是所有代码的执行时间 T(n) 与每行代码的执行次数 f(n) 成正比。

大O表示法

在第一个例子中的 T(n) = O(2n+2),在第二个例子中的 T(n) = O(2n2+2n+3)。这就是大 O 时间复杂度表示法。
大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。

大O的法则

获取最大量级复杂度(一段代码)

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

加法法则(只有一种数据规模)

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

加法法则(两种或者多种数据规模)

需要将两种或者多种复杂度量级相加(即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;
}

乘法法则

嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

常见的复杂度量级

多项式阶

随着数据规模的增长,算法的执行时间和空间占用,按照多项式的比例增长。包括,O(1)(常数阶)、O(logn)(对数阶)、O(n)(线性阶)、O(nlogn)(线性对数阶)、O(n^2) (平方阶)、O(n^3) (立方阶)

非多项式阶

随着数据规模的增长,算法的执行时间和空间占用暴增,这类算法性能极差。包括,O(2^n)(指数阶)、O(n!)(阶乘阶)

几种量级的复杂度执行效率图

数据结构与算法学习之复杂度分析

空间复杂度

表示算法的存储空间与数据规模之间的增长关系(就是指代码执行时所占用空间的关系)


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)。

我们常见的空间复杂度就是 O(1)、O(n)、O(n2 ),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。

而且,空间复杂度分析比时间复杂度分析要简单很多。所以,对于空间复杂度,掌握刚我说的这些内容已经足够了。

最好情况时间复杂度

指的是在理想状态下代码执行的复杂度,例子

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

根据上面的代码,我们可以知道当前最好情况复杂度为o(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;
}

根据上面的代码,我们可以知道当前最坏情况复杂度为o(n)

平均时间复杂度

我们都知道,最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。

为了更好地表示平均情况下的复杂度,我们需要引入另一个概念:平均情况时间复杂度,后面我简称为平均时间复杂度。

如下例子

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

该平均时间情况复杂度为o(n)

应用场景

只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。

均摊时间复杂度(摊还分析)

就是特殊的平均情况时间复杂度分析

如下例子

 // array表示一个长度为n的数组
 // 代码中的array.length就等于n
 int[] array = new int[n];
 int count = 0;
 
 void insert(int val) {
    if (count == array.length) {
       int sum = 0;
       for (int i = 0; i < array.length; ++i) {
          sum = sum + array[i];
       }
       array[0] = sum;
       count = 1;
    }

    array[count] = val;
    ++count;
 }

该均摊时间复杂度为o(1)

应用场景

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。

而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。

学习数据结构与算法的目的

升值加薪;夯实基础;职业生涯能更远一些

参考资料

  1. 数据结构与算法之美
  2. 小学生也能看懂的时间复杂度(大概吧)
上一篇:C# 实例解释面向对象编程中的开闭原则


下一篇:何夕:泛零售企业如何构建核心数智化能力 | 数智泛零售01课回顾