通常情况下,一个问题可能对应有多种解决方案,每种解决方案都是一种算法。因此,我们可能经常需要做一件事:从众多算法中挑选出一个最好的算法。所谓“最好”的算法,即最适合当前场景使用的算法。
不同的应用场景,挑选算法的侧重点也有所不同。多数情况下,挑选算法主要考虑以下两个因素:
- 算法的执行效率:一个算法的执行时间越短,执行效率就越高;
- 占用存储空间的大小:有些机器的存储空间很少,因此挑选算法时,应优先选择所需存储空间小的算法。
在适合特定场景的前提下,算法的执行效率越高、占用的存储空间越小,算法就越好。
判断算法的“好坏”,有以下两种方式:
- 事后统计:将各个算法都编写成可运行的程序,然后交给计算机执行,记录程序的运行时间和占用的存储空间的实际大小,从而挑选出最好的算法;
- 预先估算:根据算法包含的各个步骤,估算出算法的运行时间和占用存储空间的大小。通过比较各个算法的估算值,挑选出最好的算法。
大多数情况下,我们选择第 2 种方式。原因很简单,将众多算法一一实现的工作量太大,得不偿失。此外,不同计算机的软、硬件环境不同,即便使用同一台计算机,不同时间段的运行环境也不相同,事后统计的结果会受到各种因素的影响,不一定准确。
我们习惯用时间复杂度表示一个算法的运行时间,用空间复杂度表示算法占用存储空间的大小。
时间复杂度
时间复杂度用来表示算法运行所需要的时间。
计算一个算法的时间复杂度,需要经历以下几个步骤:
1) 统计算法中各个步骤的执行次数
我们知道,算法是有限的执行步骤的集合,每个算法都可以用伪代码来表示。因此,一个算法的执行时间可以用伪代码中所有指令执行次数的总和来表示。
举个例子,计算从 1 加到 n 的和并输出。如下用伪代码的形式描述了一个可以解决此问题的算法:
sum <- 0 // 将 0 赋值给 sum,执行 1 次
for i <- 1 to n+1: // i 从 1 循环到 n+1(i<n+1),执行 n+1 次
sum <- sum+i; // 每次循环将 sum+i 的值赋值给 sum,执行 n 次
print sum // 输出 sum 的值,执行 1 次
整段伪代码共有 4 条指令,它们的执行次数分别为 1 次、n+1 次、n 次、1 次,总的执行次数为 2*n+3 次。因此,我们可以用 2*n+3 表示该算法的执行时间,其中 n 是一个变量。这意味着,算法的执行时间和 n 的大小有直接的关系。
显然,通过此方法统计得到的算法的执行时间,并不是固定值,更多时候得到的是类似 2*n+3、n2+2*n+3 这样的表达式。这就产生一个问题,如何通过比较不同的表达式挑选出效率最高的算法呢?
2) 大 O 记法比较表达式的大小
某个问题对应有 3 种算法,它们各自的执行时间分别为 10、2*n+3、n2+2*n+3,如何比较它们的大小呢?
很简单,我们只需要确定 n 的值,就可以轻松比较出它们的大小。例如当 n =1 时,它们的大小关系是 10 > n2+2*n+3 > 2*n+3,因此 2*n+3 对应算法的执行效率最高;再比如 n =10 时,它们的大小关系为 n2+2*n+3 > 2*n+3 > 10,因此 10 对应算法的执行效率最高。
大多数场景下,我们都遵循这样的比较原则:假设 n 的值无限大,比较各个表达式的大小,从而找到执行效率最高的算法。仍以 10、2*n+3、n2+2*n+3 为例,当 n 的值趋于无限大时,显然 10 对应算法的执行效率最高。
对于累加项个数较少的表达式,我们很容易就能比较它们的大小,但如果算法的执行时间是类似 3*n+2*n2+4+logn+... 这样的表达式,比较起来会有些困难。针对这种情况,我们可以依照如下规则对表达式进行简化:
- 去掉表达式中所有的加法常数项,比如 3*n2+2*n+3 简化为 3*n2+2*n。当 n 值无限大时,常数项对整个表达式的值的影响可以忽略不计;
- 去掉表达式中指数较低的加法项,例如 3*n2+2*n 简化为 3*n2。当 n 无限大时,n2 的值远远大于 n,此时 n 值的变化对 n2 的影响可以忽略不计。
- 去掉 n 的系数,例如 3*n2 简化为 n2。当 n 无限大时,系数对整个表达式值的影响可以忽略不计。
根据此规则,我们就将 3*n2+2*n+3 简化为了 n2。同理,我们可以将 3*n+2*n2+4+logn 简化为 n2。通过简化表达式,降低了表达式的复杂度,便于我们比较各个表达式的大小关系。
此外,为了避免人们随意使用 a、b、c 等字符来表示算法的运行时间,大家逐渐达成了一种共识,即采用大 O 记法(注意,是大写的字母 O,不是数字 0)表示算法(程序)的运行时间,又称为算法的时间复杂度。
发展至今,大 O 记法已为大多数人所采纳,表示方法也很简单,格式如下:
O(频度)
其中,频度就是简化之后的表达式。注意,对于执行时间为常数的(例如上面的 10),算法的时间复杂度用 O(1) 表示。
例如,n2+2*n+3 对应算法的时间复杂度为 O(n2)。如下列举了常用的几种时间复杂度,以及它们之间的大小关系(值越小,算法的运行效率越高):
O(1)常数阶 < O(logn)对数阶 < O(n)线性阶 < O(n2)平方阶 < O(n3)(立方阶) < O(2n) (指数阶)
空间复杂度
算法的空间复杂度,指的是该算法执行过程占用的存储空间的大小。
根据算法编写出的程序,执行过程中占用的存储空间大致可分为以下几部分:
- 程序代码本身所占用的存储空间;
- 程序中如果需要输入输出数据,也会占用一定的存储空间;
- 程序在运行过程中,可能会临时申请额外的存储空间。
程序自身所占用的存储空间取决于其包含的代码量,如果要压缩这部分存储空间,只需要精简程序代码即可。程序运行过程中输入输出的数据往往由实际问题而定,也就是说,程序输入输出所占用的存储空间和选用的算法之间,没有必然的联系。
因此,比较算法占用的存储空间,针对的往往是算法执行过程中额外申请的存储空间的大小。不同的算法,其执行时额外申请的存储空间的大小也有所不同。
举个例子,如下是一段伪代码:
输入 n // 输入 n 值
for i<-1 to n // 将从 1 到 n 的值存储在 A 数组中
A[i] <- i // 将 i 存储在 A 数组中第 i 个元素的位置
可以看到,根据 n 的值,伪代码中会额外申请可存放 n 个元素的存储空间。
和时间复杂度一样,算法的空间复杂度也采用大 O 记法表示:
- 如果算法中额外申请的存储空间和输入值无关,则算法的空间复杂度就为 O(1);
- 如果随着输入值 n 的增大,算法申请的存储空间成线性增长,则程序的空间复杂度用 O(n) 表示;
- 如果随着输入值 n 的增大,程序申请的存储空间成 n2 关系增长,则程序的空间复杂度用 O(n2) 表示;
- 如果随着输入值 n 的增大,程序申请的存储空间成 n3 关系增长,则程序的空间复杂度用 O(n3) 表示;
- 等等。
以上面的这段伪代码为例,随着 n 值的增大,A[1 ... n] 申请的存储空间也会增加,n 增大的速率和额外申请空间的速率之间呈线性关系,因此这部分伪代码的空间复杂度为 O(n)。
多数实际场景中,一个好的算法往往更注重的是时间效率,空间复杂度只要在一个合理的范围内即可。