JavaScript的算法时间复杂度表示法

900 阅读4分钟

一般我们只关心程序能否以某种算法(或者函数)完成某个功能需求,而很少关心这个算法是否足够优秀。就像自然数的0和1之间,还存在无数个小数,多一个维度观察,会看到不同的世界。日常中之所以很少关心算法,因为处理的数据量比较小。数据的处理时间0.01秒和0.1秒的差距,从相对上相差了10倍,但在绝对上只有0.09秒,使用体验上并不明显。类似,衡量算法除了以时间上维度外,还有从完成算法所需要使用的空间上。例如处理一个数组的排序,默认情况是将数组整个装入内存,然后进行处理。这种情况需要可用的内存空间必须大于被处理的数组长度。如果这个数组有10T(相信大多数独立计算机都不会有10T内存),那么这个算法很难用了(抛开虚拟内存不说)。

本文配合Javascript代码说明几种常见的时间复杂程度的表示法。有助于了解自己开发的程序的算法的时间复杂程度。而了解以及量化时间复杂程度是优化程序的必要方法和过程。

第一步:分析时间复杂度

  1. 最简单和直观的代码:运行n次,所以复杂程度为 O(n)O(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)
}
  1. 典型二分查找算法,虽然数组长度为n但是分解k次,简单来说复杂程度是O(k)O(k)nk的关系是n2k=1\frac{n}{2^k} = 1代数转换一下得到k=log2nk=log_2n复杂程度O(n2)O(n^2)
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
}
  1. 冒泡、选择和插入排序算法长度为n的数组,需要执行n2n^2次,所以复杂程度为O(n2)O(n^2)
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
}
  1. 合并排序算法,分为两个部分:mergeSort数组拆分部分和merge合并部分,我们假设有有数组 [3,5,7,2,1,6],静态代码分析:
递归次数mergeSort 入口说明merge 入口merge 出口说明
0[3,5,7,2,1,6]原始状态,并第一次拆解n/an/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 元素数组,所以不执行 mergen/an/a

可见,合并排序算法由两部分组成:

  1. 数组的拆分部分,复杂度同二分法(中间切开拆分)O(log2n)O(log_2n)
  2. 数组的比较和合并部分,复杂度为O(n)O(n)

两者调用关系(乘法),所以最后的复杂程度为O(nlog2n)O(n*log_2n)

// 合并排序
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
}
  1. 快速排序,同合并算法,有兴趣的同学可以自行分析,时间复杂度也为O(nlog2n)O(n*log_2n)
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
}

小节

如果是多段代码,一步步执行的,时间复杂度为加法关系,例如:O(n)=O(x+y)(nx,y)O(n) = O(x + y) (n \in {x,y})

如果是多段代码,且重复常数量次数的,例如:O(2n2+n+3)O(2n^2+n+3),则取最高阶,并去除系数(类似于求极限算法),所以转换为O(n2)O(n^2)

如果是循环内调用关系的,时间复杂度为乘法关系,例如:O(n)=T(xP(y))nx,yO(n) = T(x*P(y)) , n \in {x,y}O(n)=O(xy)O(n) = O(x*y)如果x=y=nx=y=n,则O(n2)O(n^2)

第二步:绘制复杂公式在二维中表现

上图中,x轴为处理的数据量,y轴为对应的时间消耗。数条黑色实线将时间复杂度分割为不同的数量级,如果是系数甚至指数的有区别,但还是在同一个数量级中,例如:O(n2)O(n^2)O(n3)O(n^3)

不同的数量级之间的耗时差距是巨大的,对于小规模的数据处理也许绝对值影响并不大,例如100毫秒对1毫秒,只有0.1秒的差距,用户体验不明显。随着数据量的增加,原先的99毫秒并非线性放大,而可能是指数放大,例如数据放大100倍,时间增长1万倍,即1000秒对0.1秒的差距。这样的差距是惊人的!

尽可能让自己的时间算法复杂度在绿色部分或者浅黄色部分。处于这个区域的算法,随着数据量的增长,所消耗的时间成线性增长。当超过O(nlog2n)O(n*log_2n) 时,逐渐成指数增长,就会出现数据增长2倍,处理时间大于2倍的情况。