算法的时间复杂度和空间复杂度

算法的时间与空间复杂度

引言

最近看到一个问题,叫做「你们会因为代码烂,而入职两三天选择离职吗?」。
你写的代码有毒!

那么什么是烂代码呢?代码好坏评判标准是什么呢?让我们从算法的角度来分析一番。那么就不得不涉及到几个概念了。


算法(Algorithm)是指用来操作数据、解决程序问题的一组方法。对于同一个问题,使用不同的算法,也许最终得到的结果是一样的,但在过程中消耗的资源和时间却会有很大的区别。

那么我们应该如何去衡量不同算法之间的优劣呢?

主要还是从算法所占用的「时间」和「空间」两个维度去考量。

时间维度:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。
空间维度:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。
因此,评价一个算法的效率主要是看它的时间复杂度和空间复杂度情况。然而,有的时候时间和空间却又是「鱼和熊掌」,不可兼得的,那么我们就需要从中去取一个平衡点。

下面我来分别介绍一下「时间复杂度」和「空间复杂度」的计算方式。

时间复杂度

首先要说的是,时间复杂度的计算并不是计算程序具体运行的时间,而是算法执行语句的次数。
当我们面前有多个算法时,我们可以通过计算时间复杂度,判断出哪一个算法在具体执行时花费时间最多和最少。

我们想要知道一个算法的「时间复杂度」,很多人首先想到的的方法就是把这个算法程序运行一遍,那么它所消耗的时间就自然而然知道了。

这种方式可以吗?当然可以,不过它也有很多弊端。
这种方式非常容易受运行环境的影响,在性能高的机器上跑出来的结果与在性能低的机器上跑的结果相差会很大。而且对测试时使用的数据规模也有很大关系。再者,并我们在写算法的时候,还没有办法完整的去运行呢。

因此,另一种更为通用的方法就出来了:「 大O符号表示法 」,即 T(n) = O(f(n))

我们先来看个例子:

1
2
3
4
5
for(i=1; i<=n; ++i)
{
j = i;
j++;
}

通过「 大O符号表示法 」,这段代码的时间复杂度为:O(n) ,为什么呢?

在 大O符号表示法中,时间复杂度的公式是: T(n) = O( f(n) ),其中f(n) 表示每行代码执行次数之和,而 O 表示正比例关系,这个公式的全称是:算法的渐进时间复杂度。

我们继续看上面的例子,假设每行代码的执行时间都是一样的,我们用 1颗粒时间 来表示,那么这个例子的第一行耗时是1个颗粒时间,第三行的执行时间是 n个颗粒时间,第四行的执行时间也是 n个颗粒时间(第二行和第五行是符号,暂时忽略),那么总时间就是 1颗粒时间 + n颗粒时间 + n颗粒时间 ,即 (1+2n)个颗粒时间,即: T(n) = (1+2n)*颗粒时间,从这个结果可以看出,这个算法的耗时是随着n的变化而变化,因此,我们可以简化的将这个算法的时间复杂度表示为:T(n) = O(n)

为什么可以这么去简化呢,因为大O符号表示法并不是用于来真实代表算法的执行时间的,它是用来表示代码执行时间的增长变化趋势的。

所以上面的例子中,如果n无限大的时候,T(n) = time(1+2n)中的常量1就没有意义了,倍数2也意义不大。因此直接简化为T(n) = O(n) 就可以了。

常见的时间复杂度有:

  • 常数阶O(1),
  • 对数阶O(log2 n),
  • 线性阶O(n),
  • 线性对数阶O(n log2 n),
  • 平方阶O(n^2),
  • 立方阶O(n^3)
  • k次方阶O(n^K),
  • 指数阶O(2^n)。

上面从上至下依次的时间复杂度越来越大,执行的效率越来越低。

计算方法
①选取相对增长最高的项
②最高项系数是都化为1
③若是常数的话用O(1)表示
如f(n)=2*n^3+2n+100
则O(n)=n^3。

通常我们计算时间复杂度都是计算最坏情况

一些计算时间复杂度的例子

让我们举几个例子来看一下

常数阶

1
2
3
int a = 10, sum = 0;	//执行1次
sum = a + 1; //执行1次
printf("sum = %d", sum);//执行1次

这个算法的执行次数函数是f(n)=3。根据我们推导大O阶的方法,第一步就是把常数项3改为1。在保留最高阶项时发现并没有最高阶项,所以这个算法的时间复杂度为O(1)。
如果这个算法中的语句sun = a + 1有10句,即:

1
2
3
4
5
6
7
8
9
10
11
12
int a = 10, sum = 0;	//执行1次
sum = a + 1; //执行第1次
sum = a + 1; //执行第2次
sum = a + 1; //执行第3次
sum = a + 1; //执行第4次
sum = a + 1; //执行第5次
sum = a + 1; //执行第6次
sum = a + 1; //执行第7次
sum = a + 1; //执行第8次
sum = a + 1; //执行第9次
sum = a + 1; //执行第10次
printf("sum = %d", sum);//执行1次

事实上无论n为多少,上面两段代码仅仅只是3次和12次执行的差异。这种与问题的大小无关(n的多少),执行时间恒定的算法,我们称之为具有O(1)的时间复杂度,又叫常数阶。
不管这个常数是多少,都记作O(1),而不能是O(3)、O(12)等其他任何数字。


线性阶

1
2
3
4
5
int sum = 0;//执行1次
for (int i = 0; i < n; i++)//执行n次
{
sum += 10;//执行1次
}

分析算法的复杂度,关键就是要分析循环结构的运行情况
这段代码中的循环语句会被执行n次,随着n的增加或减少,执行次数就会发生变化,就是说循环内语句的执行次数会随着n的变化而变化,所以说这段代码的时间复杂度是O(n)。


线性对数阶O(nlogN)

1
2
3
4
5
6
7
8
for (int i = 0; i < n; i++)
{
int k = 1;
while (k < n)
{
k = k * 2;
}
}

内部while循环我们已经清楚它的时间复杂度为O(log2n),那么代码循环n遍的话那么它的时间复杂度就是O(nlog2n);


平方阶

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

这段代码是一个循环嵌套,内循环刚才分析过是O(n),外层循环每循环一次,内层循环循环n次。所以说对于外层的循环,不过是内部这个时间复杂度为O(n)的语句,再循环n次。也就是 n * n = n^2所以这段代码的时间复杂度为O(n^2);


立方阶

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

注意不一定两层循环时间复杂度就是O(n^2),三层循环就是O(n^3),这里只是简单的举2个例子

空间复杂度

既然时间复杂度不是用来计算程序具体耗时的,那么我也应该明白,空间复杂度也不是用来计算程序实际占用的空间的。

空间复杂度是对一个算法在运行过程中临时占用存储空间大小的一个量度,同样反映的是一个趋势,我们用 S(n) 来定义。

空间复杂度比较常用的有:O(1)、O(n)、O(n²),我们下面来看看:

空间复杂度 O(1)

如果算法执行所需要的临时空间不随着某个变量n的大小而变化,即此算法空间复杂度为一个常量,可表示为 O(1)

1
2
3
4
5
int i = 1;
int j = 2;
++i;
j++;
int m = i + j;

代码中的 i、j、m 所分配的空间都不随着处理数据量变化,因此它的空间复杂度 S(n) = O(1)

空间复杂度 O(n)

1
2
3
4
5
6
int[] m = new int[n]
for(i=1; i<=n; ++i)
{
j = i;
j++;
}

这段代码中,第一行new了一个数组出来,这个数据占用的大小为n,这段代码的2-6行,虽然有循环,但没有再分配新的空间,因此,这段代码的空间复杂度主要看第一行即可,即 S(n) = O(n)

总结

对于一个算法,其时间复杂度和空间复杂度往往是相互影响的,当追求一个较好的时间复杂度时,可能会导致占用较多的存储空间,即可能会使空间复杂度的性能变差,反之亦然。不过,通常情况下,鉴于运算空间较为充足,通常以算法的时间复杂度作为算法优劣的衡量指标。
常见的时间复杂度所耗时间的大小排列我们用一张图来总结
常见的时间复杂度所耗时间大小排序

感谢您的访问与观看!


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!