.1 衡量算法性能的标准

数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。所以,执行效率是算法一个非常重要的考量指标。那如何来衡量算法代码的执行效率呢?那就用要时间复杂度空间复杂度分析。其实,只要讲到数据结构与算法,就一定离不开时间、空间复杂度分析。

(1)为什么需要复杂度分析?

代码运行时,通过统计、监控,就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?这种分析方法能比运行一遍得到的数据更准确吗?

首先,这种评估算法执行效率的方法是正确的。但是,这种统计方法有非常大的局限性:

  • 测试结果非常依赖测试环境:测试环境中硬件的不同会对测试结果有很大的影响。比如,我们拿同样一段代码,分别用Intel Core i9 处理器和 Intel Core i3 处理器来运行,不用说,i9 处理器要比 i3 处理器执行的速度快很多。还有,比如原本在这台机器上 a 代码执行的速度比 b 代码要快,等我们换到另一台机器上时,可能会有截然相反的结果。
  • 测试结果受数据规模的影响很大:对同一个排序算法,待排序数据的有序度不一样,排序的执行时间就会有很大的差别。极端情况下,如果数据已经是有序的,那排序算法不需要做任何操作,执行时间就会非常短。除此之外,如果测试数据规模太小,测试结果可能无法真实地反应算法的性能。比如,对于小规模的数据排序,插入排序可能反倒会比快速排序要快!

所以,我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法。这就是时间复杂度、空间复杂度分析方法。

(2)大 O 复杂度表示法

复杂度是一个关于输入数据量 n 的函数。假设代码复杂度是 f(n),那么就用个大写字母 O 和括号,把 f(n) 括起来就可以了,即 O(f(n))。例如,O(n) 表示的是,复杂度与计算实例的个数 n 线性相关;O(logn) 表示的是,复杂度与计算实例的个数 n 对数相关,这就是大 O 时间复杂度。

大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度

通常,复杂度的计算方法遵循以下几个原则:

  • 复杂度与具体的常系数无关,例如 O(n) 和 O(2n) 表示的是同样的复杂度。O(2n) 等于 O(n+n),也等于 O(n) + O(n)。也就是说,一段 O(n) 复杂度的代码只是先后执行两遍 O(n),其复杂度是一致的。
  • 多项式级的复杂度相加的时候,选择高者作为结果,例如 O(n²)+O(n) 和 O(n²) 表示的是同样的复杂度。O(n²)+O(n) = O(n²+n)。随着 n 越来越大,二阶多项式的变化率是要比一阶多项式更大的。因此,只需要通过更大变化率的二阶多项式来表征复杂度就可以了。

值得一提的是,O(1) 也是表示一个特殊复杂度,含义为某个任务通过有限可数的资源即可完成。此处有限可数的具体意义是,与输入数据量 n 无关。

下面就分别来看看时间复杂度和空间复杂度是如何衡量的。

2. 时间复杂度分析

一般做算法复杂度分析的时候,遵循下面的技巧:

(1)只关注循环执行次数最多的一段代码

大 O 这种复杂度表示方法只是表示一种变化趋势。我们通常会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级即可。所以,在分析一个算法、一段代码的时间复杂度时,也只关注循环执行次数最多的那一段代码即可。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。

下面来看一个例子:

  1. function cal(n) {
  2. let sum = 0;
  3. for (let i = 0; i <= n; ++i) {
  4. sum = sum + i;
  5. }
  6. return sum;
  7. }

其中第 2 行代码是常量级的执行时间,与 n 的大小无关,所以对于复杂度并没有影响。循环执行次数最多的是第 4 行代码,这行代码被执行了 n 次,所以总的时间复杂度就是 O(n)。

(2)加法法则:总复杂度等于量级最大的那段代码的复杂度

下面来看一个例子:

  1. function cal(n) {
  2. let sum_1 = 0;
  3. for (let p = 1; p < 100; ++p) {
  4. sum_1 = sum_1 + p;
  5. }
  6. let sum_2 = 0;
  7. for (let q = 1; q < n; ++q) {
  8. sum_2 = sum_2 + q;
  9. }
  10. let sum_3 = 0;
  11. for (let i = 1; i <= n; ++i) {
  12. for (let j = 1; j <= n; ++j) {
  13. sum_3 = sum_3 + i * j;
  14. }
  15. }
  16. return sum_1 + sum_2 + sum_3;
  17. }

这个代码分为三部分,分别是求 sum_1、sum_2、sum_3。可以分别分析每一部分的时间复杂度,然后把它们放到一起,再取一个量级最大的作为整段代码的复杂度。

第一段代码循环执行了 100 次,所以是一个常量的执行时间,跟 n 的规模无关。需要说明的是循环的次数只要是一个已知的数,跟 n 无关,照样也是常量级的执行时间。从时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量的执行时间多大,都可以忽略掉。因为它本身对增长趋势并没有影响。

第二段代码和第三段代码的时间复杂度分别是 O(n) 和 O(n2),综合这三段代码的时间复杂度,取其中最大的量级。所以,整段代码的时间复杂度就为 O(n2)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度。将这个规律抽象成公式就是:如果 T1(n)=O(f(n)),T2(n)=O(g(n));那么 T(n)=T1(n)+T2(n)=max(O(f(n)), O(g(n))) =O(max(f(n), g(n)))。

(3)乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

下面看一个例子:

  1. function cal(n) {
  2. let res = 0;
  3. for (let i = 1; i < n; ++i) {
  4. res = res + f(i);
  5. }
  6. }
  7. function f(int n) {
  8. let sum = 0;
  9. for (let i = 1; i < n; ++i) {
  10. sum = sum + i;
  11. }
  12. return sum;
  13. }

可以看到,如果cal方法中还有f函数,所以 cal() 函数的时间复杂度就是,T(n) = T1(n) T2(n) = O(nn) = O(n2)。

乘法法则看成是嵌套循环,将这个规律抽象成公式就是:如果 T1(n)=O(f(n)),T2(n)=O(g(n));那么 T(n)=T1(n)T2(n)=O(f(n))O(g(n))=O(f(n)*g(n))。

3. 常见的时间复杂度

常见时间复杂度:

  • O(1):基本运算 +、-、*、/、%、寻址
  • O(logn):二分查找,跟分治(Divide & Conquer)相关的基本上都是 logn
  • O(n):线性查找
  • O(nlogn):归并排序,快速排序的期望复杂度,基于比较排序的算法下界
  • O(n²):冒泡排序,插入排序,朴素最近点对
  • O(n³):Floyd 最短路,普通矩阵乘法
  • O(2ⁿ):枚举全部子集
  • O(n!):枚举全排列

随着问题规模 n 的不断增大,上述时间复杂度不断增大,算法的执行效率越低。

(1)O(1)

O(1) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。比如下面这段代码,它的时间复杂度也是 O(1):

  1. let i = 8;
  2. let j = 6;
  3. let sum = i + j;

只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度都记作 O(1)。一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)

(2)O(logn)、O(nlogn)

对数阶时间复杂度非常常见,也是最难分析的一种时间复杂度。下面来看一个例子:

  1. let i=1;
  2. while (i <= n) {
  3. i = i * 2;
  4. }

这里第三行代码是循环执行次数最多的。所以,只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。

从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当大于 n 时,循环结束。实际上,变量 i 的取值就是一个等比数列。它们应该是这样子:
image.jpeg
所以,只要知道 x 值是多少,就知道这行代码执行的次数了。通过 2x=n 求解 x,x=log2n。所以,这段代码的时间复杂度就是 O(log2n)。

下面把代码稍微改下:

  1. let i=1;
  2. while (i <= n) {
  3. i = i * 3;
  4. }

这段代码的时间复杂度为 O(log3n)。实际上,不管是以 2 为底、以 3 为底,还是以 10 为底,都可以把所有对数阶的时间复杂度都记为 O(logn)。因为对数之间是可以互相转换的,log3n 就等于 log32 log2n,所以 O(log3n) = O(C log2n),其中 C=log32 是一个常量。基于前面的理论:在采用大 O 标记复杂度的时候,可以忽略系数,即 O(Cf(n)) = O(f(n))。所以,O(log2n) 就等于 O(log3n)。因此,在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为 O(logn)。

如果一段代码的时间复杂度是 O(logn),循环执行 n 遍,时间复杂度就是 O(nlogn) 了。而且,O(nlogn) 也是一种常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)。

(3)O(m+n)、O(m*n)

下面来看一种跟前面都不一样的时间复杂度,代码的复杂度由两个数据的规模来决定:

  1. function cal(m, n) {
  2. let sum_1 = 0;
  3. for (let i = 0; i < m; ++i) {
  4. sum_1 = sum_1 + i;
  5. }
  6. let sum_2 = 0;
  7. for (let j = 0; j < n; ++j) {
  8. sum_2 = sum_2 + j;
  9. }
  10. return sum_1 + sum_2;
  11. }

可以看到,m 和 n 是表示两个数据规模,无法事先评估 m 和 n 谁的量级大,所以在表示复杂度时,就不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是 O(m+n)。

针对这种情况,原来的加法法则就不正确了,需要将加法规则改为:T1(m) + T2(n) = O(f(m) + g(n))。但是乘法法则继续有效:T1(m)T2(n) = O(f(m) f(n))。

4. 时间复杂度分析进阶

上面介绍了最基本的时间复杂度分析的方法,下面来看复杂度分析的进阶知识点:

  • 最好时间复杂度(best case time complexity)
  • 最坏时间复杂度(worst case time complexity)
  • 平均时间复杂度(average case time complexity)
  • 均摊时间复杂度(amortized time complexity)

    (1)最好、最坏时间复杂度

    下面来看一个例子:
    1. // n 表示数组 array 的长度
    2. function find(array, n, x) {
    3. let pos = -1;
    4. for (let i = 0; i < n; ++i) {
    5. if (array[i] == x) pos = i;
    6. }
    7. return pos;
    8. }
    可以看到,这段代码要实现的功能是,在一个无序的数组(array)中,查找变量 x 出现的位置。如果没有找到,就返回 -1。这段代码的复杂度是 O(n),其中,n 代表数组的长度。

在数组中查找一个数据时,并不需要每次都把整个数组都遍历一遍,因为有可能中途找到就可以提前结束循环了。但是,这段代码写得不够高效。可以这样进行优化:

  1. // n 表示数组 array 的长度
  2. function find(array, n, x) {
  3. let pos = -1;
  4. for (let i = 0; i < n; ++i) {
  5. if (array[i] == x){
  6. pos = i;
  7. break;
  8. }
  9. }
  10. return pos;
  11. }

那优化完之后,这段代码的时间复杂度还是 O(n) 吗?很显然,上面的分析方法,解决不了这个问题。

因为,要查找的变量 x 可能出现在数组的任意位置。如果数组中第一个元素正好是要查找的变量 x,那就不需要继续遍历剩下的 n-1 个数据了,那时间复杂度就是 O(1)。但如果数组中不存在变量 x,那就需要把整个数组都遍历一遍,时间复杂度就成了 O(n)。所以,不同的情况下,这段代码的时间复杂度是不一样的。

为了表示代码在不同情况下的不同时间复杂度,需要引入三个概念:最好时间复杂度、最坏时间复杂度和平均时间复杂度。

  • 最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度。在最理想的情况下,要查找的变量 x 正好是数组的第一个元素,这个时候对应的时间复杂度就是最好情况时间复杂度。
  • 最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。如果数组中没有要查找的变量 x,需要把整个数组都遍历一遍才行,所以这种最糟糕情况下对应的时间复杂度就是最坏情况时间复杂度。

    (2)平均时间复杂度

    最好时间复杂度和最坏时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。为了更好地表示平均情况下的复杂度,需要引入另一个概念:平均时间复杂度。

借助上面的例子,要查找的变量 x 在数组中的位置,有 n+1 种情况:在数组的 0~n-1 位置中不在数组中。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1,就可以得到需要遍历的元素个数的平均值,即:
image.jpeg
在时间复杂度的大 O 标记法中,可以省略掉系数、低阶、常量,所以,把这个公式简化之后,得到的平均时间复杂度就是 O(n)。

这个结论虽然是正确的,但是计算过程稍微有点儿问题。这 n+1 种情况,出现的概率并不是一样的。要查找的变量 x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,我们假设在数组中与不在数组中的概率都为 1/2。另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)。

因此,前面的推导过程中存在的最大问题就是,没有将各种情况发生的概率考虑进去。如果把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样:
image.jpeg
这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度

引入概率之后,前面那段代码的加权平均值为 (3n+1)/4。用大 O 表示法来表示,去掉系数和常量,这段代码的加权平均时间复杂度仍然是 O(n)。

实际上,在大多数情况下,并不需要区分最好、最坏、平均情况时间复杂度三种情况。而使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,才会使用这三种复杂度表示法来区分。

(3)均摊时间复杂度

下面来看看均摊时间复杂度的概念,以及它对应的分析方法,摊还分析(或者叫平摊分析)。

均摊时间复杂度,听起来跟平均时间复杂度很像。在大部分情况下,我们并不需要区分最好、最坏、平均三种复杂度。平均复杂度只在某些特殊情况下才会用到,而均摊时间复杂度应用的场景比它更加特殊、更加有限。

下面来看一个例子:

  1. // array 表示一个长度为 n 的数组
  2. // 代码中的 array.length 就等于 n
  3. const array = new Array(n);
  4. let count = 0;
  5. function insert(val) {
  6. if (count == array.length) {
  7. let sum = 0;
  8. for (let i = 0; i < array.length; ++i) {
  9. sum = sum + array[i];
  10. }
  11. array[0] = sum;
  12. count = 1;
  13. }
  14. array[count] = val;
  15. ++count;
  16. }

这段代码实现了一个往数组中插入数据的功能。当数组满了之后,也就是代码中的 count == array.length 时,就用 for 循环遍历数组求和,并清空数组,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。

最理想的情况下,数组中有空闲空间,只需要将数据插入到数组下标为 count 的位置就可以了,所以最好情况时间复杂度为 O(1)。最坏的情况下,数组中没有空闲空间了,需要先做一次数组的遍历求和,然后再将数据插入,所以最坏情况时间复杂度为 O(n)。

平均时间复杂度是 O(1)。可以通过前面讲的概率论的方法来分析:假设数组的长度是 n,根据数据插入的位置的不同,可以分为 n 种情况,每种情况的时间复杂度是 O(1)。除此之外,还有一种“额外”的情况,就是在数组没有空闲空间时插入一个数据,这个时候的时间复杂度是 O(n)。而且,这 n+1 种情况发生的概率一样,都是 1/(n+1)。所以,根据加权平均的计算方法,求得的平均时间复杂度就是:
image.jpeg
这里的平均复杂度分析其实并不需要这么复杂,不需要引入概率论的知识。先来对比一下这个 insert() 的例子和前面那个 find() 的例子,这两者有很大差别。

  • 首先,find() 函数在极端情况下,复杂度才为 O(1)。但 insert() 在大部分情况下,时间复杂度都为 O(1)。只有个别情况下,复杂度才比较高,为 O(n)。这是 insert()第一个区别于 find() 的地方。
  • 第二个不同的地方。对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。

所以,针对这样一种特殊场景的复杂度分析,并不需要像之前讲平均复杂度分析方法那样,找出所有的输入情况及相应的发生概率,然后再计算加权平均值。

针对这种特殊的场景,引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度叫均摊时间复杂度

那如何使用摊还分析法来分析算法的均摊时间复杂度呢?

以数组中插入数据为例。每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)。这就是均摊分析的大致思路。

均摊时间复杂度和摊还分析应用场景比较特殊,所以并不会经常用到。下面来看一下它们的应用场景:

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。

5. 空间复杂度

时间复杂度的全称是渐进时间复杂度表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系

下面来看一个例子:

  1. function print(int n) {
  2. const a = new Array(n);
  3. for (let i = 0; i <n; ++i) {
  4. a[i] = i * i;
  5. }
  6. for (i = n - 1; i >= 0; --i) {
  7. console.log(a[i])
  8. }
  9. }

可以看到,第 3 行代码申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以可以忽略。第 2 行申请了一个大小为 n 的数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。

常见的空间复杂度就是 O(1)、O(n)、O(n2 ),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。

6. 时间转空间复杂度

上面介绍了衡量代码效率的方法。那么,针对这些低效代码,该如何提高它们的效率呢?下面就来看看对于时间复杂度和空间复杂度之间转换的内容,以此来提高代码的效率。

在面试的过程中,遇到考察手写代码的场景,通常面试官会追问:“这段代码的时间复杂度或者空间复杂度,是否还有降低的可能性?”。其实,代码效率优化就是要将可行解提高到更优解,最终目标是:要采用尽可能低的时间复杂度和空间复杂度,去完成一段代码的开发。

(1)时间昂贵、空间廉价

一段代码会消耗计算时间、资源空间,从而产生时间复杂度和空间复杂度,将时间复杂度和空间复杂进行一下对比会发现一个重要的现象。

假设一段代码经过优化后,虽然降低了时间复杂度,但依然需要消耗非常高的空间复杂度。 例如,对于固定数据量的输入,这段代码需要消耗几十 G 的内存空间,很显然普通计算机根本无法完成这样的计算。如果一定要解决的话,一个最简单粗暴的办法就是,购买大量的高性能计算机,来弥补空间性能的不足。

反过来,假设一段代码经过优化后,依然需要消耗非常高的时间复杂度。 例如,对于固定数据量的输入,这段代码需要消耗 1 年的时间去完成计算。如果在跑程序的 1 年时间内,出现了断电、断网或者程序抛出异常等预期范围之外的问题,那很可能造成 1 年时间浪费的惨重后果。很显然,用 1 年的时间去跑一段代码,对开发者和运维者而言都是极不友好的。

这告诉我们一个很现实问题:代码效率的瓶颈可能发生在时间或者空间两个方面。如果是缺少计算空间,花钱买服务器就可以了。这是个花钱就能解决的问题。相反,如果是缺少计算时间,只能投入宝贵的时间去跑程序。即使有再多的钱、再多的服务器,也是毫无用处。相比于空间复杂度,时间复杂度的降低就显得更加重要了。因此会发现这样的结论:空间是廉价的,而时间是昂贵的。

(2)数据结构连接时空

假定在不限制时间、也不限制空间的情况下,可以完成某个任务的代码的开发。这就是暴力解法,更是程序优化的起点。

例如,如果要在 100 以内的正整数中,找到同时满足以下两个条件的最小数字:

  • 能被 3 整除;
  • 除 5 余 2。

最暴力的解法就是,从 1 开始到 100,每个数字都做一次判断。如果这个数字满足了上述两个条件,则返回结果。这是一种不计较任何时间复杂度或空间复杂度的、最直观的暴力解法。当有了最暴力的解法后,就需要评估当前暴力解法的复杂度了。如果复杂度比较低或者可以接受。可如果暴力解法复杂度比较高,那就要考虑采用程序优化的方法去降低复杂度了。

为了降低复杂度,一个直观的思路是:梳理程序,看其流程中是否有无效的计算或者无效的存储。我们需要从时间复杂度和空间复杂度两个维度来考虑。常用的降低时间复杂度的方法有递归、二分法、排序算法、动态规划等。而降低空间复杂度的方法,就要围绕数据结构做文章了。

降低空间复杂度的核心思路就是,能用低复杂度的数据结构能解决问题,就千万不要用高复杂度的数据结构。

经过了剔除无效计算和存储的处理之后,如果程序在时间和空间等方面的性能依然还有瓶颈,又该怎么办呢?如果可以通过某种方式,把时间复杂度转移到空间复杂度,就可以把无价的东西变成有价了。

在程序开发中,连接时间和空间的桥梁就是数据结构。对于一个开发任务,如果能找到一种高效的数据组织方式,采用合理的数据结构的话,那就可以实现时间复杂度的再次降低。同样的,这通常会增加数据的存储量,也就是增加了空间复杂度。

以上就是程序优化的最核心的思路:

  • 第一步,暴力解法。在没有任何时间、空间约束下,完成代码任务的开发。
  • 第二步,无效操作处理。将代码中的无效计算、无效存储剔除,降低时间或空间复杂度。
  • 第三步,时空转换。设计合理数据结构,完成时间复杂度向空间复杂度的转移。

    (3)时间换空间案例

    假设有任意多张面额为 1 元、2 元、10 元的纸币,用它们凑出 100 元,求总共有多少种可能性。暴力解法如下:
    1. function fn() {
    2. let count = 0;
    3. for (let i = 0; i <= (100 / 10); i++) {
    4. for (let j = 0; j <= (100 / 2); j++) {
    5. for (let k = 0; k <= (100 / 1); k++) {
    6. if (i * 10 + j * 5 + k * 1 == 100) {
    7. count += 1;
    8. }
    9. }
    10. }
    11. }
    12. return count;
    13. }
    在这段代码中,使用了 3 层的 for 循环。从结构上来看,是很显然的 O( n³ ) 的时间复杂度。然而,代码中最内层的 for 循环是多余的。因为,当你确定了要用 i 张 10 元和 j 张 5 元时,只需要判断用有限个 1 元能否凑出 100 - 10 i - 5 j 元即可。因此,代码改写如下:
    1. function fn() {
    2. let count = 0;
    3. for (let i = 0; i <= (100 / 10); i++) {
    4. for (let j = 0; j <= (100 / 2); j++) {
    5. if ((100 - i*10 - j*5 >= 0)&&((100 - i*10 - j*5) % 2 == 0)) {
    6. count += 1;
    7. }
    8. }
    9. }
    10. return count;
    11. }
    改造之后,代码的结构由 3 层 for 循环,变成了 2 层 for 循环。很显然,时间复杂度就变成了O(n²) 。这样的代码改造,就是利用了将代码中的无效计算、无效存储剔除,降低时间或空间复杂度。

再看第二个例子。查找一个数组中出现次数最多的那个元素。例如,输入数组 a = [1,2,3,4,5,5,6 ] 中,查找出现次数最多的元素。从数组中可以看出,只有 5 出现了 2 次,其余都是 1 次,所以输出 5。

最笨的方法就是采用两层的 for 循环完成计算。第一层循环,对数组每个元素遍历。第二层循环,则是对第一层遍历的数字,去遍历计算其出现的次数。这样,全局再同时缓存一个出现次数最多的元素即可,代码如下:

  1. function fn() {
  2. let a = [1, 2, 3, 4, 5, 5, 6];
  3. let res = "";
  4. for (let i = 0; i < a.length; i++) {
  5. let count = 0;
  6. for (let j = 0; j < a.length; j++) {
  7. if (a[i] == a[j]) {
  8. count += 1;
  9. }
  10. count > res ? res = a[i] : res = res
  11. }
  12. }
  13. return res;
  14. }

这段代码中采用了两层的 for 循环,很显然时间复杂度就是 O(n²)。而且代码中,几乎没有冗余的无效计算。如果还需要再去优化,就要考虑采用一些数据结构方面的手段,来把时间复杂度转移到空间复杂度了。

我们可以通过一次 for 循环,在循环的过程中,同步记录下每个元素出现的次数。最后,再通过查找次数最大的元素,就得到了结果。

具体而言,定义一个 key-value 结构的字典,用来存放元素-出现次数的 key-value 关系。那么首先通过一次循环,将数组转变为元素-出现次数的一个字典。接下来,再去遍历一遍这个字典,找到出现次数最多的那个元素,就能找到最后的结果了。具体代码如下:

  1. function fn() {
  2. let a = [1, 2, 3, 4, 5, 5, 6];
  3. let map = {}, res = "";
  4. for (let i = 0; i < a.length; i++) {
  5. map[a[i]] ? map[a[i]] += 1 : map[a[i]] = 1;
  6. }
  7. for (let key in map) {
  8. map[key] > res ? res = key : res = res
  9. }
  10. return res;
  11. }

这段代码有两个 for 循环。不过,这两个循环不是嵌套关系,而是顺序执行关系。其中,第一个循环实现了数组转字典的过程,也就是 O(n) 的复杂度。第二个循环再次遍历字典找到出现次数最多的那个元素,也是一个 O(n) 的时间复杂度。

因此,总体的时间复杂度为 O(n) + O(n),就是 O(2n),根据复杂度与具体的常系数无关的原则,也就是O(n) 的复杂度。空间方面,由于定义了 key-value 字典,其字典元素的个数取决于输入数组元素的个数。因此,空间复杂度增加为 O(n)。

这段代码就是通过采用更复杂、高效的数据结构,完成了时空转移,提高了空间复杂度,让时间复杂度再次降低。