评估一个算法的好坏,有两个指标:执行时间和内存消耗。它们对应的概念分别是:时间复杂度与空间复杂度。这两个概念需要在我们做题的过程中慢慢去理解,所以不一定要在这一节消化掉。
对于时间和空间,通常情况下我们更关注时间性能,这是因为用户更在意程序的响应速度,而空间成本现在越来越低廉,并且程序使用完的空间还可以回收,而时间一去就再也回不来了。
我们在上一节也向大家提到过,时间性能和空间性能通常来说不可能同时最优,因此优化的思路一般而言是「空间换时间」,「空间换时间」这个思想会一直伴随着我们学习算法与数据结的过程,请大家留意。
我们先说时间复杂度,提到时间复杂度就不得不说一下大 记号。大
记号的括号里面通常来说是一个相对简单的,关于输入规模 N 的表达式,我们这里用
表示,输入规模
可以理解为输入数据的个数。
整体就表示了我们设计的算法的执行的操作有多少步,请大家注意,它表示的是一个上界,表达了我们是考虑了最坏的情况,这一点我们放在后面说。使用大 记号是一种化繁为简的思想,我们用一个简易的表达式,加上一个记号,代表了一个相对复杂的表达式。
我们没有必要真正地计算我们所设计的算法的具体的执行操作步数。这是因为计算真实的操作数可能比较复杂,很复杂的数学表达式不便于比较和程序员之间的交流。
时间复杂度是一个抓大放小的结果;我们真正关心的是:随着输入的增加,运行时间将以什么样的速度增加。
现在大家看到的是计算 1 到 的和的两个算法。左边这个程序就是老老实实地从 1 加到
,需要执行的操作数和这个数
相关,并且是线性相关。我们就说这个算法的时间复杂度是
,而事实上,每一次循环,
要加 1 ,还要做判断是不是加到头了,并且还要把结果加到 sum 这个变量里,总的操作数是 3 倍的
,我们简记成
,这是有一定道理的,我们等会再说。
而右边的这个程序只需要执行常数次,使用了等差数列的前 n 项和公式:首项 + 末项的和乘以项数 除以 2,不管 n 是多少,只需要执行 3 次,就完成计算了,这个算法执行的次数与 n 无关,我们记为 。
为什么不写
这样更具体的表示式呢。这是因为对于计算机来说,它们没有差别,若干次数的操作不是造成程序性能差异的主要原因。
那么时间复杂度有没有计算规则呢?事实上是有的,我们为大家简单归纳几点:
如果一个算法执行的操作数与输入数据 的关系是多项式形式:
例如:我们这个例子以 二次多项式为例 :aN^2 + bN + c
在计算复杂度的时候, 第 1,忽略加法常数项; 第 2,只保留最高次幂项 第 3,且最高次幂项的乘法系数看做 1
忽略加法常数项,我们刚刚说了,3 次操作,乃至 300 次操作,在程序看来都是差不多的; 只保留最高次幂项,可以这样理解,算法的总操作数,是由这个最高次幂的项主导的,在 N 成倍增加的时候,这一项整体的增加是由 aN^2 决定的。 并且最高次幂项的乘法系数看做 1,这一点可能不太好理解,依然是我们刚才说的,需要以动态的观点去理解它,等会儿我们介绍完时间复杂度的定义以后,就容易理解了。
什么样的算法,时间复杂度是 方呢?我们在第三章要学习的:选择排序、冒泡排序、插入排序的时间复杂度都是
。
接下来,我们来解释为什么需要「动态」去理解「时间复杂度」。 首先谈「时间复杂度」只有在输入规模特别大的时候才有意义。
我们这里画出了 5 个函数的图像,横轴表示输入规模,纵轴表示输入规模对应的函数值。
- 这是在 100 以内,它们的函数图像,我们看到 1 是最平缓的;
- 其次是以 2 为底 n 的对数,它是相对平缓的;
- 然后就是 n 它处在对角线的位置,表示随着 n 的增加,相应地函数值的增量也是相同的;
- 接着是
,最陡峭的是
,在 10 的附近,我们这个坐标系就显示不下了。
但是我们更关注的是输入规模特别大的时候,输入规模成倍增加的时候,函数值成倍增加了多少,我反复说了这句话好几次,就是为了加深大家对「动态」理解「时间复杂度」这个概念的印象。
依然是前面的 5 个函数表达式,我们将对数的底由 2 换成 10,并且在 n 方这个函数除以 10000,好让这 5 个函数的图像显示在一个坐标系当中。
我们看从 40000 到 8000 , 1 和 log 以 10 为底 n 的对数,在函数图像上重合,几乎看不出差别,说明数据规模的变化,对于这两类算法不会产生很大的影响;
n log 以 10 为底 n 的对数,虽然在 40000 这个位置高于了 n 方除以 10000; 但是在 80000 这个位置,它在 n 方除以 10000 的下面,于是我们认为 n log 以 10 为底 n 的对数的时间复杂度更低。
函数值越大,不同的类型函数的值的差异才会体现出来。我们在实际工程中,处理数据的级别也多半是在大数据这个概念下。
下面我们来看一下时间复杂度的定义: 我们看到这样的一个表达式很像极限的定义,其中 g(n) 是我们说的,大欧记号里那个相对简单的表达式,作为整体,表示了一个复杂的函数表达式 f(n) f(n) 和 g(n) 的关系是什么,存在两个正常量,使得当 输入规模 n 抄了某个自然数的临界值 n0 以后,f(n) 的图像都在 g(n) 的某个常数倍数的下面。 因此,大 O 复杂度的表达式,我们考虑的是最坏的情况,最坏的情况的情况都能接收,我们设计的算法才有意义。 这个定义像极了《微积分》里极限的定义。我们来看一个更形象的图示。
在 n 趋向于无穷的时候,g(n) 倍数的趋向和 f(n) 相当。也就是在某一个正整数以后,g(n) 的常数倍,会在 f(n) 的上方。 我们就用 big O g(n) 来表示 f(n) 的上界,这就是时间复杂度的数学定义的极限形式。
因此我们说,时间复杂度就是估算一个简易的函数表达式,并且这种估算是保守估算,简易的函数表达式的倍数可以在这个真正的函数表达式的上方,这样的估算是有意义的。
空间复杂度可以类似地去理解,也就是我们接解决一个问题的过程中 ,使用了多少空间,这个空间与问题规模的函数关系。
空间复杂度表示了在处理问题的过程中,使用的额外的空间,也使用大 O 记号。
接下来我们来谈一下引入 大 O 记号的意义
化繁为简的表达式,便于不同算法进行比较,把差不多的算法归为一类,便于工程师之间的交流; 而计算复杂度这个过程,也有利于在程序员在编码的过程中进行自我检查和优化。有的时候,我们就是看一下时间复杂度和空间复杂度的表达式,时间复杂度过高,空间复杂度很低。我们就知道,在我们编写的算法中没有记住一些信息,应该使用空间换时间。
下面我们简单谈一谈对于「时间复杂度」与「空间复杂度」的误解
第一,「时间复杂度」不等于程序的执行时间,我们不停地强调「时间复杂度」需要动态地理解,在数据规模特别大的时候,「时间复杂度」才有意义。所以大家在刷题的过程中,看到明明我的算法复杂度比较低,为什么排名还是靠后呢。 这里原因可能是,我们的算法复杂度确实是低的,但是这个复杂度的表达式前面的乘法系数很大,还有可能是测试数据的规模比较小,并且还有测量不准确的问题。 把时间复杂度和程序的执行时间划等号,是不对的。
第二,需要尽可能地优化算法。这一条的意思不是说不需要精益求精,而是我们在对一个知识点没有理解透彻的情况下,有些优化可能是过度的,会产生一些副作用。我们在文字部分有向大家举了个例子。结论是:特别细小的优化可以不做,因为这样的优化有可能在性能上收效甚微、破坏了可读性,在工程中这一点是得不偿失的。希望大家能理解这种工程化的思维,理论上最优,但实际操作的时候,我们会有一些取舍。所以才会有「最佳实践」这样的概念。
第三,我们之前也向大家多次提到过,时间与空间很多时候不可调和,「优化空间」有可能导致时间复杂度增加。绝大多数情况下,一个算法,时间复杂度最优,才是我们编写算法和使用数据结构的目的。
最后我们总结一下:
时间复杂度和空间复杂度是需要我们在做题的过程中慢慢总结的,应该要养成习惯,做完一个问题,马上分析时间复杂度; 官方题解的时间复杂度是很好的参考资料,面试的时候能够分析到官方题解的程度就可以了。 而《算法导论》这一类书籍的时间复杂度分析过于复杂,如果是不是专门的科研人员,在阅读的时候可以跳过; 时间复杂度还有别的记号,但是在工程领域,我们只谈大 O 记号,这是因为我们考虑最坏的情况是更有意义的; 关于时间复杂度的其它话题,「均摊复杂度分析」和「震荡时间复杂度分析」,我们暂时不做介绍,大家可以在互联网上搜索相关资料进行学习; 另外,时间复杂度分析还有一个有利的工具,叫主定理,我们会在后序章节合适的地方向大家介绍。