重学数据结构与算法系列:时间复杂度与空间复杂度

引言

相信大家都听说过程序的本质就是算法加数据结构的说法,因此如果我们想程序跑的速度既快又非常节省资源。那么就需要好好设计程序的执行算法以及数据结构。这就涉及到一个问题,我们在写代码的时候怎么去评判一段代码到底是不是跑得快又省资源的呢?这个时候我们就需要借助于时间复杂度以及空间复杂度分析来进行分析了。

码代码为什么要进行复杂度分析?

有的同学会说,代码能够跑出结果实现功能不就行了嘛,为什么还要进行复杂度分析呢?实际上代码能实现指定的功能,是对程序的最低的要求。无论是我们平常的算法刷题或者实际项目中,都需要对于具体的代码的执行性能有一定的要求,希望代码能够实现既能跑的快,又节省硬件资源,实现最优处理。

实际上我们可以通过一些工具、数据以及环境来进行程序的复杂度分析,包括耗时以及资源占用情况。但是这种评价方法依赖于准备的测试数据以及测试环境,不同规模的测试数据以及不同配置的测试环境,得到的测试结果具有不确定性。因此我们需要一种大致的估算方法,在代码写出来之后可以粗略的估算代码的复杂度,这样有助于我们及时的调整代码结构或者数据存储结构。这个粗略的程序复杂度估算方法就是我们今天所要介绍的时间复杂度分析法以及空间复杂度分析法。

Big O复杂度表示法

在介绍时间复杂度以及空间复杂度之前,我们先来看下Big O复杂度表示法。还记得第一次基础到Big O表示法是在网易公开课上的MIT算法导论课程上,感兴趣的同学可以去看下。

假设有这样的定义f(n)=O(g(n)),存在常数c、以及n_0,使得0\leqslant f(n) \leqslant cf(n)。举个栗子, 6n^3 = O(n^3),这其中实际上就是一种渐进的趋向,所以忽略了常数项。

上面阐述了对于Big O的数学定义,接下来我们就看下在代码方面是符合进行使用的。假设有如下的代码:

1 public static int calculator(int n) {
2   int sum = 0;
3   for (int i = 1; i <= n; ++i) {
4     for (int j = 1; j <= n; ++j) {
5     sum = sum + j;
6           }
7   }
8   return sum;
9 }

这段代码很简单,主要实现的了数字累加,这里我们假设每行代码执行的时间都时一个固定值为一个execution_time。那么上面的代码我们可以看出,第2行以及第8行代码分别执行了一个execution_time,而第3行代码由于在循环体中,所以执行了n个execution_time。另外第4行代码以及第5行代码分别执行了n^2次,因此这段代码总的执行时间就是(2n^2+n+2)个execution_time。

可以看得出来代码的执行时间和实际的代码的执行次数是成正比的。结合上文中的对于Big O的公式,可以得出代码的时间复杂度表达式即为:T(n) = O(2n^2 + n +2),其中n就表示为数据的规模,而T即为代码的执行时间,而O就表示一种渐进的关系。相信大家都学过微积分,其中的很重要的思想就是无限趋近后用主要公式项,代替次要公式项。所以此处的常数项、低阶项在n无穷大的时候都可以忽略掉。因此这段代码的时间复杂度我们可以认为为T(n) = O(n^2)


时间复杂度分析

从上文的分析中,我们学会了如何使用Big O表示法来分析代码的的时间复杂度。上文中的例子只是常见的一种,接下来我们再看看其他几种常见的代码结构如何进行是时间复杂度分析以及一些复杂度分析技巧。

1、O(1)

首先确认下O(1)表达的意思是并不是说代码的执行时间复杂度为1,而是说代码执行的时间复杂度是常数项。如下的代码,按照上文分析的,每次执行代码就是就是1个execution_time,那么三行代码就是3个execution_time,对应的时间复杂度是O(1),并不是O(3)。

1 int a = 1;
2 int b = 2;
3 int result = a * b;

因此代码的时间复杂度并不与数据规模n正相关,即便是有1W行代码,它的时间复杂度也还是O(1)。

2、O(n) 

在如下的代码中,随着n的增大,对应的3、4行代码的执行次数也随着逐渐增大。借助于Big O表示法,我们可以分析出该段代码的时间复杂度为O(n),其中第2、6行代码执行实际为常数项,随着数据规模的增大,对最终结果的影响逐渐减小。因此我们在分析代码时间复杂度的时候,重点关注影响因子最大的代码块,此处循环体就是影响因子最大的代码块。

1 public static int calculator(int n) {
2  int sum = 0;
3  for (int i = 1; i <= n; ++i) {
4    sum = sum + i;
5  }
6  return sum;
7 }


3、O(n²)

如上文Big O表示法中的分析可知,以下代码的总的执行时间为(2n²+n+2)个execution_time,但是最后得出的时间复杂度为O(n²)。因此在此处我们也要说明下分析技巧,实际上还是使用的微积分的思想,当n趋近无穷大的时候,参数项、低阶项n以及常数项2在n²面前都是弟弟,因此n²是这段代码最耗时的部分,也就是说我们根据代码分析时间复杂度的时候应该抓大放小,重点关注那些循环次数多的代码块,然后运用趋近无穷大的方法,确定最终的时间复杂度。

 public static int calculator(int n) {
   int sum = 0;
   for (int i = 1; i <= n; ++i) {
     for (int j = 1; j <= n; ++j) {
     sum = sum + j;
           }
   }
   return sum;
 }

4、O(logn)

这类时间复杂度还是比较常见的,我们还是先来看下代码:

1 public static int calculator(int n) { 
2  i=1;
3  while (i <= n) { 
4      i = i * 2; 
5  }
6  return i;
7 }

我们可以很明显的看出来,第4行代码执行的次数最多,所以我们得先搞清楚这里的代码到底执行了多少次,我们才能确定这段代码的时间复杂度。实际上它是个等比数列,我们都知道如果想获取执行次数x,我们只需要进行取对处理就可以。

因此此处的x=log2n,那么该段代码的时间复杂度就为O(log2n),在之前的大O表示法中,我们知道常数项是可以忽略掉的,因此代码的复杂度实际就是O(logn)。


空间复杂度分析

在前文中,我们分析了代码如何通过Big O表示法来确定时间复杂度。接下来我们再看看空间复杂度是怎样的分析的,实际上空间复杂度分析主要考虑的是代码实现所需要的空间规模与数据规模之间的趋势关系。看下如下的代码:

1 public static void save(int n) {
2    Set<Integer> ds = new HashSet<>(n);
3    for (int i = 0; i < n; i++) {
4            ds.add(i)
5    }
6 }

上述代码中,在代码的第二行申请了大小为n的set集合,其他代码并没有再申请新的存储空间,因此这段代码的空间复杂度就是O(n)。相对于时间复杂度来说,空间复杂度相对来说没有太多的分类,分析的规则实际和时间复杂度优点类似,只不过空间复杂度关注有没有新的存储空间申请。同样的,空间复杂度分析也遵循抓大放小的原则,关注趋近无穷大的时候,真正起作用的因子。

总结

本文主要对于为什么要对程序进行复杂度分析以及如何对程序进行时间复杂度分析和空间复杂度分析,这也是我们在写出代码后如何评价代码是否执行高效的评价基础。实际上我们常见的复杂度也就是O(1)、O(logn)、O(n)、O(nlogn)、O(n²),他们的复杂度依次从小到大。

 (图片来源于网络)

猜你喜欢

转载自blog.csdn.net/Diamond_Tao/article/details/122781272