前端应该了解的算法 | 如何度量算法的执行效率

392 阅读9分钟

前言

亲爱的小伙伴,你好!我是 嘟老板。作为一名程序猿,工作中自然少不了接触算法。一段循坏,一次查找排序,都是算法。那什么样的算法才算好算法呢?抛开 正确性可读性健壮性 这些对于编码的基本要求不谈,高效率低存储 是决定算法好坏的关键因素。今天我们就来谈谈算法关键指标之一 - 效率 的度量方法。

程序的执行效率受哪些因素影响

一段程序在计算机上运行受多种因素影响。大致可总结为以下几点:

  • 算法的设计策略

想当初上学的时候,接触一道数学题,计算 1 + 2 +...+ 100 的结果。用程序怎么实现呢?

最简单的思路,for 循环解决:

function sum(n) {
    let result = 0
    for (let i = 1; i <= n; i++) {
        result += i
    }
    return result
}

const result = sum(100)

然而还有以下解决方法,对比看下:

function sum(n) {
    let result = 0
    result = (1 + n) * n / 2
    return result
}

const result = sum(100)

不同的设计,视觉效果、运行效果可能天差地别。从效率方面分析,for 循环方法需要循环 100 次才能得出结果,而第二种方式只需要 1 次执行便可,高下立见。

  • 编译后的代码质量

现在大部分程序都需要软件编译后才能执行,比如 vue,需要编译成 js 文件才能在浏览器运行。因此最终执行的并不是你最初编写的 vue 代码,而是编译之后的 js 代码。编译后的代码质量,直接影响程序执行效率。

  • 程序的输入规模

回顾第 1 点中的 for 循环解法,函数 sum 的参数输入 10010000000,执行时间肯定是差别很大。

  • 硬件性能

现在计算机性能越来越好了,程序运行效率也在加快。同样的程序,如果放在当年爷爷辈的计算机上运行,怕是要等的花都谢了。

度量方法

根据算法效率的度量时机,可以分为:

  • 事后统计法
  • 事前估算法

事后统计法

该方法主要依赖编程语言提供的时间相关 API 计算程序的整体执行时间。前提是 程序 已经写好,输入事先准备的 测试数据 运行程序,输出运行时间。

不过一般不推荐该统计法,有很明显的缺陷:

  • 需要事先写好程序

我程序都写好了,还让我测试性能,然后可能再改、再测、再改、再测....,这本来就不是一个合理的流程。没有经过设计的程序,很难达到最优状态,而在测试之后修修改改,在反复测试,也是相当耗时。

  • 测试数据很难覆盖全场景

上面影响程序运行效率的因素也说了,输入规模的不同,对程序执行效率的影响也不同。

再以 for 循环距离,传入 1100 甚至 10000 可能都执行很快,但不意味着输入 100000000000 也一样快。对于更复杂的算法,准备测试数据的难度也更大。

  • 依赖运行环境和计算机硬件性能

相同的程序,在不同的运行环境下执行效率很大差别。毕竟不能要求所有人的电脑都有相同的性能。

事前估算法

该方法旨在程序编写之前,使用一定的统计方法对算法效率进行估算。

回顾影响程序执行效率的因素,其中 编译后的代码质量 由编译软件决定,硬件性能 由计算机硬件决定。这两个因素都不应该包含在算法效率的客观评判内。因此一段程序的执行效率,主要依赖于 算法的好坏输入规模的大小。因此在估算程序的运行时间时,重要的是将 基本操作的数量输入规模 关联起来。

我们再来引入下上面的两个算法,来表示下基本操作数量与输入规模的关系:

算法一:

function sum(n) {
    let result = 0 /** 执行 1 次 */
    for (let i = 1; i <= n; i++) { /** 执行 n + 1 次 */
        result += i /** 执行 n 次 */
    }
    return result /** 执行 1 次 */
}

算法二:

function sum(n) {
    let result = 0 /** 执行 1 次 */
    result = (1 + n) * n / 2 /** 执行 1 次 */
    return result /** 执行 1 次 */
}

统计下基本操作的执行次数,其中 算法一 执行 2n + 3 次,算法二 执行 3 次。算法一 会随着 输入规模 的增大而增加运行时间,这就是两个算法的效率差距。

接下来,我们来看看估算算法时间复杂度的记法 - 大O 记法

什么是 大 O 记法

假设
T(n) 表示语句的总执行次数,是关于 问题规模 n 的函数,用于分析 T(n)n 的变化情况并确定数量级;
f(n) 是渐进增长率函数,描述算法执行时间随 问题规模 n 增大的不同增长模式,对应不同级别的时间复杂度,如 n, n^2logn 等。
算法的时间复杂度公式可记作:T(n) = O(f(n)),表示随着 问题规模 n 的增大算法执行时间的增长率和 f(n) 的增长率相同。
通常情况下,随着 问题规模 n 的增长,T(n) 增长最慢的算法属于 最优算法

这样使用 O() 来体现算法时间复杂度的记法,叫做 大 O 记法。上面的两个算法可分别表示 O(n)O(1),我们可以分别称之为 线性阶常数阶

拓展
什么是渐进增长率?
渐进增长率 描述的是算法时间复杂度 T(n)n 趋向于无穷大时的 增长情况,反映算法执行时间随问题规模增大的 变化趋势,是评估和比较算法效率的重要标准。

如何推导 大 O 阶

先说方法:

  1. 常数 1 替换所有运行中的所有加法常数
  2. 按照 步骤1 处理后的运行次数函数中,只保留最高阶项
  3. 如果 步骤2 得到的最高阶项存在且不是 1,则去除与其相乘的常数

最终得到的结果就是 大 O 阶

乍一看,好像并不是很清晰,这什么玩意,去除这个去除那个的。不用急,我们结合具体程序分析。

常数阶 O(1)

let result = 0 /** 执行 1 次 */
result = (1 + n) * n / 2 /** 执行 1 次 */
return result /** 执行 1 次 */

以上算法的运行次数 f(n) = 3,按照推导方法顺序处理,先将 常数 3 改为 1,然后保留最高阶,发现并没有最高阶,不需要在向下推导了,最终该算法的时间复杂度为 O(1)

如果我们将程序调整一下,多加几行语句:

let result = 0 /** 执行 1 次 */
result = (1 + n) * n / 2 /** 执行 1 次 */
result = (1 + n) * n / 2 /** 执行 1 次 */
result = (1 + n) * n / 2 /** 执行 1 次 */
result = (1 + n) * n / 2 /** 执行 1 次 */
return result /** 执行 1 次 */

运行次数从 3 次变成了 6 次,那时间复杂度有变化吗?没有。因为其执行次数是固定的,时间复杂度不随 问题规模 n 的变更而变化,都是 O(1),该算法属于 常数阶

线性阶 O(n)

线性阶往往会涉及到循环结构,若要分析时间复杂度,关键是分析循环结构的运行情况。

for (let i = 1; i <= n; i++) {
    result += i /** 执行 n 次 */
}

以上算法运行次数 f(n) = n,按照推导方法顺序处理,没有常数,跳过 步骤1,然后保留最高阶,最高阶为 1,不需要在向下推导了,最终该算法的时间复杂度为 O(n)

平方阶 O(n^2)

平方阶往往会设计嵌套循环。

for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= n; j++) {
        result += j /** 执行 n 次 */
    }
}

已知内部的循环复杂度为 O(n),对于外部循环来说,就是内部的循环再循环 n 次,所以运行次数 f(n) = n^2。按照推导方法顺序处理,没有常数,跳过 步骤1,然后保留最高阶,最高阶为 2,再继续,最高阶项没有乘数,跳过,最终该算法的时间复杂度为 O(n^2)

再来个稍微复杂一点的程序:

for (let i = 1; i <= n; i++) {
    for (let j = i; j <= n; j++) {
        result += j /** 执行 n 次 */
    }
}

算法的运行次数是多少呢?

  • i = 0 时,内部循环执行 n
  • i = 1 时,内部循环执行 n - 1
  • i = 2 时,内部循环执行 n - 2
  • ....
  • i = n - 1 时,内部循环执行 1

综上,运行次数 f(n) = n + (n - 1) + (n - 2) +....1,按照高斯算法计算结果为 (n(n + 1))/2 = n^2/2 + n/2

按照推导方法顺序处理,没有常数,跳过 步骤1,然后保留最高阶,最高阶为 2步骤2 得到 n^2/2,再继续,最高阶项乘数为 1/2,去除,步骤2 得到 n^2,最终该算法的时间复杂度为 O(n^2)

除了以上提到的 常数阶 O(1)线性阶 O(n)平方阶 O(n^2) 之外,还有 对数阶 O(logn)立方阶 O(n^3)指数阶 O(2^n) 等,就不一一列举了。

通常来说,不同阶的算法对应运行时间的大小顺序为:

O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) <O(n^n)

其中 指数阶 及其以后的算法即便 问题规模 n 不大,运行时间也会相当长,慎用。

结语

好啦,今天的内容就到这里。在实际的开发工作中,可能大部分的小伙伴都没有太在意算法的效率问题,但没留意过不代表不重要,不然 Vue 也不会反复改进虚拟 DOMDiff 算法。本文介绍了两种度量算法执行效率的方法 - 事后统计法事前估算法,首推 事前估算法。真心希望每一位小伙伴都能掌握 大 O 阶 的推导。如有疑问,欢迎评论区讨论。

感谢阅读,愿 你我共同进步,谢谢!!!


往期推荐