本系列前面两篇讲的都是一些背景知识,从这一篇开始我们正式讲算法,从算法的一些基本概念讲起。
什么是算法
通过上一篇对图灵机原理的讲解,我们知道,一个计算问题描述的是输入/输出之间的关系,如果根据给定的输入能设计一个程序计算出期望的输出,就认为这个问题可解。这个程序的计算过程就是用算法来描述的,通过算法这个工具我们就容易设计出这样的一个程序。
确切地说,算法是有限步骤的计算过程,该过程取某个值或集合作为输入,并产生某个值或集合作为输出。
算法的正确与错误
如果算法对于每个输入都可以正确的停机,则称该算法是正确的,并称正确的算法解决了给定的计算问题。一个不正确的算法对于某个输入可能根本不停机,也可能以不正确的结果停机,比如一个排序问题:
输入:一个长度为n的数组(a1,a2,a3...)
输出:输入数组排序后的一个数组(b1,b2,b3....),满足:b1≤b2≤b3
如果根据这个问题设计出来的算法交给图灵机运行输出的结果与人们的期望相反(比如输出:b3,b2,b1…),那么这个算法就是错误的。
随机访问机模型
针对同一计算问题,可以设计出多种算法,其中有好有坏,我们可以通过预测算法需要的资源来筛选出好的算法。虽然有时我们关心内存、带宽这类硬件资源,但通常我们度量的是运行时间。
度量算法的运行时间,人们通常用的是随机访问机模型(Random-Access Machine, RAM)。在 RAM 模型中:
- 指令一条接着一条执行的,没有并发操作。
- 指令包含了真实计算机的常见指令:算数指令(加法,减法,乘法,除法,取余等)、数据移入指令(装入,存储,复制)和控制命令(条件与无条件转移、子程序调用与返回);
- 每条指令所用的时间均为常量。
RAM 模型假定的观点是,运行每行伪代码所需的时间是一个常量时间,虽然真实计算机执行一行代码与另一行代码需要不同的常量时间。依此,一个算法在特定输入上的运行时间不是指现实意义的时间,而是执行指令的次数。
比如下面这段 foo 函数的代码:
// prettier-ignore
function foo(n) {
for (let i = 0; i < n; i++) { // 2n+1 次
console.log('Hello, World!') // n 次
}
return 0 // 1 次
}
上面的代码需要执行 2n + 1 + n + 1 = 3n + 2 次指令,也就是说执行时间是 3n + 2,如果用一个时间函数来表示,就是 T(n) = 3n + 2。
算法的时间复杂度
根据 RAM 模型,一个算法可以在给定的输入规模 n 下分析出一个运行时间的函数 T(n)。研究 T(n) 常用的一种策略是分析输入规模 n 增大的情况下 T(n) 的变化(如线性增长、指数增长等)。如果用 f(n) 来表示 T(n) 的增长速度,那么 f(n) 和 T(n) 的关系我们约定用一个大 O 来表示,即:
T(n) = O(f(n))
这就是 大 O 表示法。由于输入规模 n 的增长率与 f(n) 的增长率是正相关的,所以称作 渐近时间复杂度(Asymptotic Time Complexity),简称 时间复杂度。相对应的,还有空间复杂度,这里我们不作讨论。
当 n 足够大时或趋于无穷大时,T(n) 的常数部分就变得不重要,我们真正关心的是运行时间的 增长量级 或 增长率。如果用 f(n) 来表示增长数度,上面 foo 示例代码的增长速度可以表示为 f(n) = n,把它代入到 T(n) = O(f(n)) 就是:
T(n) = O(n)
这时,我们称 foo 的时间复杂度为 O(n)。
常见的时间复杂度有:
- 常数阶 O(1),
- 对数阶 O(log2^n),
- 线性阶 O(n),
- 线性对数阶 O(nlog2^n),
- 平方阶 O(n^2),
- 立方阶 O(n^3),
- k 次方阶 O(n^k),
- 指数阶 O(2^n)。
随着问题规模 n 的不断增大,上述时间复杂度不断增大,算法的执行效率也越低。大 O 表示法只是一种估算,当输入规模足够大的时候才有意义。
注意,大 O 表示法考虑的是最坏的情况。比如,从一个长度为 n 的数组中找一个值等于 10 的元素,开始遍历扫描这个数组,有可能第 1 次就扫到了,也有可能是第 n 次才扫到。这里最坏的情况是 n 次,所以时间复杂度就是 O(n)。
大部分情况下你用直觉就可以知道一个算法的大 O 表示。比如,如果用一个循环遍历输入的每个元素,那么这个算法就是 O(n);如果是用循环套循环,那就是 O(n^2),以此类推。
参考:《算法导论,第三版》