同一个问题可以用不同的算法实现,而算法是有优劣之分的。我们经常需要对算法进行分析,以便于选择合适的算法和改进算法。
通常我们从两个维度来描述算法的优劣:程序代码的执行时间和代码占用的内存空间。两者分别叫做算法的时间复杂度和算法的空间复杂度,合称算法的复杂度。
时间复杂度和空间复杂度可以反映出算法的效率。
时间复杂度
时间复杂度用来衡量算法的执行时间,用 O 表示。
事实上,代码执行时所耗费的时间,只有在机器上运行后才能知道,从理论上是不能算出来的。为了方便,我们用执行到的语句数量来表示执行的时间。语句执行次数越多,代码所耗费的时间越长。
举些栗子。
function isEven(num) {
let isEven = num % 2 === 0
return isEven
}
这是一个判断一个数是否为偶数的函数,运行时执行到了两条语句,它的时间复杂度为 O(2)。
function sum(arr) {
let total = 0
for (let i = 0; i < arr.length; i++) {
total += arr[i]
}
return total
}
这是一个求和的函数,它的时间复杂度取决于参数数组的大小。算法的执行时间往往取决于要处理的数据的大小,通常我们把要处理的数据的大小叫做问题的规模,用 n 表示。在这个示例中,n 就是数组的长度。
所以,这个求和算法的复杂度为 O(3n + 3)。
说实话,计算这个复杂度还挺麻烦的。很多时候,我们不需要计算得那么精确,我们只需要知道算法的大致时间就好了。对于计算机来说,多执行几条命令在时间上效率并没有提高多少。
为了方便计算和比较不同的时间复杂度,我们需要对结果去掉低阶项,去掉常数项,去掉高阶项的常参。这话涉及到多项式的知识,可能比较难理解,可以看下面示例。
O(3) = O(99999) = O(1)
O(2n + 4) = O(n + 999) = O(n)
O(2n^2) = O(3n^2 + 8) = O(8n^2 + 4n + 7) = O(n^2)
O(n^3 + 2n^2) = O(n^3)
这样的话,计算时间复杂度就方便很多了。
如果一个算法的时间复杂度是个常数,即随着问题的规模(n)的增大,它的时间复杂度不变,那么算法的时间复杂度为 O(1)。
let sum = 0
for (let i = 1; i <= 100; i++) {
sum += sum
}
比如这个求 1 + 2 + 3 + ... + 100
的算法,它的时间复杂度是 O(1)。因为它的时间复杂度是个常数,大概 300 多,我们不需要知道具体的值是多少。
function sort(arr) {
for (let out = 0; out < arr.length - 1; out++) {
for (let j = 0; j < arr.length - out - 1; j++) {
if (arr[j] > arr[j + 1]) {
let tmp = arr[j]
arr[j] = arr[j + 1]
arr[j + 1] = tmp
}
}
}
return arr
}
这是冒泡排序法,用到了二重循环,每重循环的次数大概为 n(arr.length),因此它的时间复杂度为 O(n^2)。
一个简单的判断时间复杂度的方法就是,如果算法中只用到了一重循环,并且循环的次数大致为 n,那么算法的时间复杂度为 O(n);如果算法中用到了二重循环,每重循环的次数大概为 n,因此它的时间复杂度为 O(n^2);以此类推。
我们再来看一个函数。
function find(arr, num) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === num) {
return true
}
}
return false
}
这是一个判断数组中是否存在一个目标数的函数。它的执行时间更是不确定的。如果要查找的数在数组的第一个,那么它只需要执行几条语句能完成了。如果目标数是数组的最后一个,或者在数组中不存在,那么要执行的时间就很久了。通常我们在讨论算法的时间复杂度时,指的是在最坏的情况下,算法的时间复杂度。因此,这个算法的时间复杂度是 O(n)。
我们再来看一个例子:
for (let i = 1; i <= n; i *= 2) {
console.log(i)
}
在这个示例中,i
是指数增长的,我们假设执行的次数为 m,那么 2^m = n
,即 m = logx2(n)
。因此,时间复杂度为 log2(n)。
常见的时间复杂度
常见的时间复杂度有下面这些(按数量级递增排列):
常数阶O(1) -> 对数阶O(log2n) -> 线性阶O(n) -> 线性对数阶O(nlog2n) -> 平方阶O(n^2) -> 立方阶O(n^3) -> k次方阶O(n^k) -> 指数阶O(2^n)。
空间复杂度
空间复杂度用来表示算法的执行时所需存储空间的度量。
计算的方法和时间复杂度类似,这里不再赘述。
比如上面的冒泡排序法,空间复杂度为 O(1)。
应用
前面说过,我们经常对算法进行分析,以便于选择合适的算法和改进算法。
在改进算法方面,如果程序注重运行时间,有时我们会选择牺牲空间复杂度的方式来换取算法的时间复杂度。
比如 LeetCode 的第一道算法题(有兴趣自行百度 LeetCode Two Sum),一般情况下我们采用双重循环来做,时间复杂度为 O(n),这样的话代码的执行时间就会超出限制的时间。所以只好采用一重循环 + Map 的思路来做。这是一个典型的“以空间换时间的”的例子。
熟悉算法复杂度的概念,也可以帮助我们选择适合的算法。
比如我们知道了冒泡排序法的平均时间复杂度为 O(n^2),空间复杂度为 O(1),快速排序法是的平均时间复杂度为 O(log2(n)),空间复杂度为 O(1)。那么很显然,当数据量比较大的时候,快速排序法明显会比冒泡排序法更加高效。
当然,算法复杂度并不是衡量算法时唯一考虑的因素。很多时候,我们还需要考虑算法是否容易实现、代码可读性等等。
就以上面的排序算法来说。快速排序算法不是稳定的,而冒泡排序是稳定的算法,稳定性也是选择排序算法考虑的因素之一。