一般我们只关心程序能否以某种算法(或者函数)完成某个功能需求,而很少关心这个算法是否足够优秀。就像自然数的0和1之间,还存在无数个小数,多一个维度观察,会看到不同的世界。日常中之所以很少关心算法,因为处理的数据量比较小。数据的处理时间0.01秒和0.1秒的差距,从相对上相差了10倍,但在绝对上只有0.09秒,使用体验上并不明显。类似,衡量算法除了以时间上维度外,还有从完成算法所需要使用的空间上。例如处理一个数组的排序,默认情况是将数组整个装入内存,然后进行处理。这种情况需要可用的内存空间必须大于被处理的数组长度。如果这个数组有10T(相信大多数独立计算机都不会有10T内存),那么这个算法很难用了(抛开虚拟内存不说)。
本文配合Javascript代码说明几种常见的时间复杂程度的表示法。有助于了解自己开发的程序的算法的时间复杂程度。而了解以及量化时间复杂程度是优化程序的必要方法和过程。
第一步:分析时间复杂度
- 最简单和直观的代码:运行n次,所以复杂程度为
// 1
for (let i = 0; i < n; i++) {
console.log(i)
}
// 2
let i = 0
while (i < n) {
console.log(i)
i++
}
// 3
function factorial(n) {
if (n === 0) {
return 1
}
return n * factorial(n - 1)
}
// 4
function fibonacci(n) {
if (n <= 1) {
return n
}
return fibonacci(n - 1) + fibonacci(n - 2)
}
- 典型二分查找算法,虽然数组长度为
n
但是分解k
次,简单来说复杂程度是。n
与k
的关系是代数转换一下得到复杂程度
function binarySearch(arr, value) {
let start = 0
let end = arr.length - 1
let middle = Math.floor((start + end) / 2)
while (arr[middle] !== value && start <= end) {
if (value < arr[middle]) {
end = middle - 1
} else {
start = middle + 1
}
middle = Math.floor((start + end) / 2)
}
if (arr[middle] === value) {
return middle
}
return -1
}
- 冒泡、选择和插入排序算法长度为
n
的数组,需要执行次,所以复杂程度为
function bubbleSort(arr) {
for (let i = arr.length; i > 0; i--) {
for (let j = 0; j < i - 1; j++) {
if (arr[j] > arr[j + 1]) {
let temp = arr[j]
arr[j] = arr[j + 1]
arr[j + 1] = temp
}
}
}
return arr
}
function selectionSort(arr) {
for (let i = 0; i < arr.length; i++) {
let lowest = i
for (let j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[lowest]) {
lowest = j
}
}
if (i !== lowest) {
let temp = arr[i]
arr[i] = arr[lowest]
arr[lowest] = temp
}
}
return arr
}
function insertionSort(arr) {
for (let i = 1; i < arr.length; i++) {
let currentVal = arr[i]
for (var j = i - 1; j >= 0 && arr[j] > currentVal; j--) {
arr[j + 1] = arr[j]
}
arr[j + 1] = currentVal
}
return arr
}
- 合并排序算法,分为两个部分:
mergeSort
数组拆分部分和merge
合并部分,我们假设有有数组 [3,5,7,2,1,6],静态代码分析:
递归次数 | mergeSort 入口 | 说明 | merge 入口 | merge 出口 | 说明 |
---|---|---|---|---|---|
0 | [3,5,7,2,1,6] | 原始状态,并第一次拆解 | n/a | n/a | |
└ 1 | {[3,5,7]} , {[2,1,6]} | 拆成 2 组各 3 元素的数组,并递归 | {[3,5,7]},{[1,2,6]} | {[1,3,5,6,7]} | 第二次执行合并 |
└ 2 | {[3]}, {[5,7]} , {[2]}, {[1,6]} | 第 0 和 3 元素唯一,返回,其他拆 | {[3],[5,7]},{[2],[1,6]} | {[3,5,7]},{[1,2,6} | 第一次执行合并 |
└ 3 | {[5]}, {[7]},{[1]},{[6]} | 收到的都是 1 元素数组,所以不执行 merge | n/a | n/a |
可见,合并排序算法由两部分组成:
- 数组的拆分部分,复杂度同二分法(中间切开拆分)
- 数组的比较和合并部分,复杂度为
两者调用关系(乘法),所以最后的复杂程度为
// 合并排序
function mergeSort(arr) {
// 数组切分成两半,直至最后一个元素,执行次数 n / (2^k)
if (arr.length <= 1) return arr
let mid = Math.floor(arr.length / 2)
let left = mergeSort(arr.slice(0, mid))
let right = mergeSort(arr.slice(mid))
return merge(left, right)
}
function merge(left, right) {
let results = []
let i = 0
let j = 0
// 左小于右,则左侧加入结果,左侧指针移动,反之亦然,直至某侧先完成,执行次数为 n
while (i < left.length && j < right.length) {
if (left[i] < right[j]) {
results.push(left[i])
i++
} else {
results.push(right[j])
j++
}
}
while (i < left.length) {
results.push(left[i])
i++
}
while (j < right.length) {
results.push(right[j])
j++
}
return results
}
- 快速排序,同合并算法,有兴趣的同学可以自行分析,时间复杂度也为
function quickSort(arr, left = 0, right = arr.length - 1) {
if (left < right) {
let pivotIndex = pivot(arr, left, right)
quickSort(arr, left, pivotIndex - 1)
quickSort(arr, pivotIndex + 1, right)
}
return arr
}
function pivot(arr, start = 0, end = arr.length + 1) {
let pivot = arr[start]
let swapIdx = start
function swap(array, i, j) {
let temp = array[i]
array[i] = array[j]
array[j] = temp
}
for (let i = start + 1; i < arr.length; i++) {
if (pivot > arr[i]) {
swapIdx++
swap(arr, swapIdx, i)
}
}
swap(arr, start, swapIdx)
return swapIdx
}
小节
如果是多段代码,一步步执行的,时间复杂度为加法关系,例如:。
如果是多段代码,且重复常数量次数的,例如:,则取最高阶,并去除系数(类似于求极限算法),所以转换为。
如果是循环内调用关系的,时间复杂度为乘法关系,例如: 则 如果,则
第二步:绘制复杂公式在二维中表现
上图中,x轴为处理的数据量,y轴为对应的时间消耗。数条黑色实线将时间复杂度分割为不同的数量级,如果是系数甚至指数的有区别,但还是在同一个数量级中,例如:和
不同的数量级之间的耗时差距是巨大的,对于小规模的数据处理也许绝对值影响并不大,例如100毫秒对1毫秒,只有0.1秒的差距,用户体验不明显。随着数据量的增加,原先的99毫秒并非线性放大,而可能是指数放大,例如数据放大100倍,时间增长1万倍,即1000秒对0.1秒的差距。这样的差距是惊人的!
尽可能让自己的时间算法复杂度在绿色部分或者浅黄色部分。处于这个区域的算法,随着数据量的增长,所消耗的时间成线性增长。当超过 时,逐渐成指数增长,就会出现数据增长2倍,处理时间大于2倍的情况。