时间复杂度实战:从线性阶、平方阶到对数阶全解析

75 阅读14分钟

强烈建议有时间的同学看下:

GitHub - trekhleb/javascript-algorithms: 📝 Algorithms and data structures implemented in JavaScript

复杂度分析是整个算法学习的精髓,只要掌握了它,数据结构和算法的内容基本上就掌握了一半了。

什么是复杂度分析

数据结构和算法解决是 “如何让计算机更快时间、更省空间的解决问题”。因此需从执行时间和占用空间两个维度来评估数据结构和算法的性能。分别用时间复杂度和空间复杂度两个概念来描述性能问题,二者统称为复杂度。

复杂度描述的是算法执行时间(或占用空间)与数据规模的增长关系。

为什么要进行复杂度分析

和性能测试相比,复杂度分析有不依赖执行环境、成本低、效率高、易操作、指导性强的特点。掌握复杂度分析,将能编写出性能更优的代码,有利于降低系统开发和维护成本。

如何进行复杂度分析

算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。

  • 时间效率:算法运行时间的长短。
  • 空间效率:算法占用内存空间的大小。

实际测试

  • 难以排除测试环境的干扰因素
  • 展开完整测试非常耗费资源

理论估算

通过一些计算来评估算法的效率。这种估算方法被称为渐近复杂度分析,简称复杂度分析。

复杂度分析能够体现算法运行所需的时间和空间资源与输入数据大小之间的关系。它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势。

  • 时间和空间资源分别对应时间复杂度和空间复杂度
  • 随着输入数据大小的增加意味着复杂度反映了算法运行效率与输入数据体量之间的关系
  • 时间和空间的增长趋势表示复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间增长的快慢

大 O 表示法

算法的执行时间与每行代码的执行次数成正比,用 T(n) = O(f(n)) 表示,其中 T(n) 表示算法执行总时间,f(n) 表示每行代码执行总次数,而 n 往往表示数据的规模。这就是大 O 时间复杂度表示法。

迭代与递归

迭代

for 循环

适合在预先知道迭代次数时使用

while 循环

while 循环比 for 循环的自由度更高。在 while 循环中,我们可以自由地设计条件变量的初始化和更新步骤。

嵌套循环

每一次嵌套都是一次升维

递归

递归是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。

  • 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到终止条件。
  • 归:触发终止条件后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。

而从实现的角度看,递归代码主要包含三个要素。

  • 终止条件:用于决定什么时候由递转归。
  • 递归调用:对应递,函数调用自身,通常输入更小或更简化的参数。
  • 返回结果:对应归,将当前递归层级的结果返回至上一层。

迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范式。

  • 迭代:自下而上地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
  • 递归:自上而下地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止。
/* 递归 */
function recur(n) {
    // 终止条件
    if (n === 1) return 1;
    // 递:递归调用
    const res = recur(n - 1);
    // 归:返回结果
    return n + res;
}

调用栈

递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。

  • 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间。
  • 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。

尾递归

如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归(tail recursion)。

  • 普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
  • 尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
/* 尾递归 */
function tailRecur(n, res) {
    // 终止条件
    if (n === 0) return res;
    // 尾递归调用
    return tailRecur(n - 1, res + n);
}

递归树

当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。

  • 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
  • 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。

 两者对比

迭代递归
实现方式循环结构函数调用自身
时间效率效率通常较高,无函数调用开销每次函数调用都会产生开销
内存使用通常使用固定大小的内存空间累积函数调用可能使用大量的栈帧空间
适用问题适用于简单循环任务,代码直观、可读性好适用于子问题分解,如树、图、分治、回溯等,代码结构简洁、清晰
/* 使用迭代模拟递归 */
function forLoopRecur(n) {
    // 使用一个显式的栈来模拟系统调用栈
    const stack = [];
    let res = 0;
    // 递:递归调用
    for (let i = n; i > 0; i--) {
        // 通过“入栈操作”模拟“递”
        stack.push(i);
    }
    // 归:返回结果
    while (stack.length) {
        // 通过“出栈操作”模拟“归”
        res += stack.pop();
    }
    // res = 1+2+3+...+n
    return res;
}

当递归转化为迭代后,代码变得更加复杂了。尽管迭代和递归在很多情况下可以互相转化,但不一定值得这样做,有以下两点原因。

  • 转化后的代码可能更加难以理解,可读性更差。
  • 对于某些复杂问题,模拟系统调用栈的行为可能非常困难。

时间复杂度

算法的时间复杂度,也就是算法的时间量度。

统计时间增长趋势

时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势

  • 时间复杂度能够有效评估算法效率
  • 时间复杂度的推算方法更简便
  • 时间复杂度也存在一定的局限性

函数渐近上界

OO 记号,表示函数 T(n)T(n) 的渐近上界。

时间复杂度分析本质上是计算操作数量 T(n)T(n) 的渐近上界,它具有明确的数学定义。

若存在正实数 cc 和实数 n0n_0,使得对于所有的 n>n0n>n_0,均有 T(n)cf(n)T(n) \leq c \cdot f(n),则可认为 f(n)f(n) 给出了 T(n)T(n) 的一个渐近上界,记为 T(n)T(n)

计算渐近上界就是寻找一个函数 f(n)f(n),使得当 nn 趋向于无穷大时,T(n)T(n) 和 f(n)f(n) 处于相同的增长级别,仅相差一个常数项 cc 的倍数。

推算方法

  • 只关注循环执行次数最多的一段代码
// 时间复杂度为 O(n)
function cal(n) { 
   let sum = 0;
   let i = 1;
   for (; i <= n; ++i) {
     sum = sum + i;
   }
   return sum;
 }
  • 加法法则:总复杂度等于量级最大的那段代码的复杂度
function cal(n) {
   let sum_1 = 0;
   let p = 1;
   for (; p < 100; ++p) {
     sum_1 = sum_1 + p;
   }

   let sum_2 = 0;
   let q = 1;
   for (; q < n; ++q) {
     sum_2 = sum_2 + q;
   }
 
   let sum_3 = 0;
   let i = 1;
   let j = 1;
   for (; i <= n; ++i) {
     j = 1; 
     for (; j <= n; ++j) {
       sum_3 = sum_3 +  i * j;
     }
   }
 
   return sum_1 + sum_2 + sum_3;
 }
  • 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

嵌套代码求乘积:比如递归、多重循环等。

// T(n) = T1(n) * T2(n) = O(n*n) = O(n(2)) 
function cal(n) {
   let ret = 0; 
   let i = 1;
   for (; i < n; ++i) {
     ret = ret + f(i); // 重点为  f(i)
   } 
 } 
 
function f(n) {
  let sum = 0;
  let i = 1;
  for (; i < n; ++i) {
    sum = sum + i;
  } 
  return sum;
 }
  • 多个规模求加法:比如方法有两个参数控制两个循环的次数,那么这时就取二者复杂度相加
// T1(m) + T2(n) = O(f(m) + g(n))
function cal(m, n) {
  let sum_1 = 0;
  let i = 1;
  for (; i < m; ++i) {
    sum_1 = sum_1 + i;
  }

  let sum_2 = 0;
  let j = 1;
  for (; j < n; ++j) {
    sum_2 = sum_2 + j;
  }

  return sum_1 + sum_2;
}
  • 多个规模求乘法:比如方法有两个参数控制两个循环的次数,那么这时就取二者复杂度相乘
// T1(m) * T2(n) = O(f(m) * g(n))
function cal(m, n) {
  let sum_3 = 0;
   let i = 1;
   let j = 1;
   for (; i <= m; ++i) {
     j = 1; 
     for (; j <= n; ++j) {
       sum_3 = sum_3 +  i * j;
     }
   }
}

统计操作数量

由于上述 T(n)cf(n)T(n) \leq c \cdot f(n) 中的常数项 cc 可以取任意大小,因此操作数量 T(n)T(n) 中的各种系数、常数项都可以忽略

  1. 忽略 T(n)T(n) 中的常数项。因为它们都与 nn 无关,所以对时间复杂度不产生影响。
  2. 省略所有系数。例如,循环 2n2n 次、 5n+15n+1 次等,都可以简化记为 nn 次,因为 nn 前面的系数对时间复杂度没有影响。
  3. 循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第 1 点和第 2 点的技巧。

例子1:

function aFun() {
    console.log("Hello, World!");      //  需要执行 1 次
    return 0;       // 需要执行 1 次
}

那么这个方法需要执行 2 次运算。

例子 2:

function bFun(n) {
    for(let i = 0; i < n; i++) {         // 需要执行 (n + 1) 次
        console.log("Hello, World!");      // 需要执行 n 次
    }
    return 0;       // 需要执行 1 次
}

那么这个方法需要执行 ( n + 1 + n + 1 ) = 2n +2 次运算。

例子 3:

 function cal(n) {
   let sum = 0; // 1 次
   let i = 1; // 1 次
   let j = 1; // 1 次
   for (; i <= n; ++i) {  // n 次
     j = 1;  // n 次
     for (; j <= n; ++j) {  // n * n ,也即是  n平方次
       sum = sum +  i * j;  // n * n ,也即是  n平方次
     }
   }
 }

注意,这里是二层 for 循环,所以第二层执行的是 n * n = n(2) 次,而且这里的循环是 ++i,和例子 2 的是 i++,是不同的,是先加与后加的区别。

那么这个方法需要执行 ( n(2) + n(2) + n + n + 1 + 1 +1 ) = 2n(2) +2n + 3 。

判断渐近上界

时间复杂度由 T(n)T(n) 中最高阶的项来决定。这是因为在 nn 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。

image.png

最差、最佳、平均时间复杂度

算法的时间效率往往不是固定的,而是与输入数据的分布有关

“最差时间复杂度”对应函数渐近上界,使用大 OO 记号表示。相应地,“最佳时间复杂度”对应函数渐近下界,用 Ω\Omega 记号表示

最差时间复杂度和最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,平均时间复杂度可以体现算法在随机输入数据下的运行效率,用 Θ\Theta 记号来表示。

// n 表示数组 array 的长度
function find(array, n, x) {
  let i = 0;
  let pos = -1;
  for (; i < n; ++i) {
    if (array[i] == x) {
      pos = i; 
      break;
    }
  }
  return pos;
}

find 函数实现的功能是在一个数组中找到值等于 x 的项,并返回索引值,如果没找到就返回 -1 。

最好情况时间复杂度,最坏情况时间复杂度

如果数组中第一个值就等于 x,那么时间复杂度为 O(1),如果数组中不存在变量 x,那我们就需要把整个数组都遍历一遍,时间复杂度就成了 O(n)。所以,不同的情况下,这段代码的时间复杂度是不一样的。

所以上面代码的 最好情况时间复杂度为 O(1),最坏情况时间复杂度为 O(n)。

平均情况时间复杂度

如何分析平均时间复杂度 ?代码在不同情况下复杂度出现量级差别,则用代码所有可能情况下执行次数的加权平均值表示。

要查找的变量 x 在数组中的位置,有 n+1 种情况:在数组的 0~n-1 位置中和不在数组中。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1,就可以得到需要遍历的元素个数的平均值,即:

省略掉系数、低阶、常量,所以,这个公式简化之后,得到的平均时间复杂度就是 O(n)。

我们知道,要查找的变量 x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,我们假设在数组中与不在数组中的概率都为 1/2。另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1/(2n)。

因此,前面的推导过程中存在的最大问题就是,没有将各种情况发生的概率考虑进去。如果我们把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样:

这个值就是概率论中的 加权平均值,也叫 期望值,所以平均时间复杂度的全称应该叫 加权平均时间复杂度 或者 期望时间复杂度

所以,根据上面结论推导出,得到的 平均时间复杂度 仍然是 O(n)。

常见类型

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

常数阶

常数阶 O(1)O(1) 的操作数量与输入数据大小 nn 无关,即不随着 nn 的变化而变化。

线性阶

线性阶 O(n)O(n) 的操作数量相对于输入数据大小 nn 以线性级别增长。线性阶通常出现在单层循环中

遍历数组和遍历链表等操作的时间复杂度均为 O(n)O(n) ,其中 nn 为数组或链表的长度

平方阶

平方阶的操作数量相对于输入数据大小 nn 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为 O(n)O(n),因此总体的时间复杂度为 O(n2)O(n^2)

image.png

指数阶

生物学的“细胞分裂”是指数阶增长的典型例子 O(2n)O(2^n)

image.png

aFunc(n) {
    if (n <= 1) {
        return 1;
    } else {
        return aFunc(n - 1) + aFunc(n - 2);
    }
}

对数阶

与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 nn,由于每轮缩减到一半,因此循环次数是 O(log2n)O(log_2n)  ,即 2n2^n 的反函数, 通常会省略底数 mm,将对数阶直接记为 O(logn)O(logn)

/* 对数阶(递归实现) */
function logRecur(n) {
    if (n <= 1) return 0;
    return logRecur(n / 2) + 1;
}
let i=1;
while (i <= n)  {
   i = i * 2;
}
// T(n) = T1(logn) * T2(n) = O(logn*n) = O(nlogn)
function aFun(n){
  let i = 1;
  while (i <= n)  {
     i = i * 2;
  }
  return i
}

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

对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。

image.png

线性对数阶

线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 O(logn)O(logn) 和 O(n)O(n)

主流排序算法的时间复杂度通常为  ,例如快速排序、归并排序、堆排序等。

/* 线性对数阶 */
function linearLogRecur(n) {
    if (n <= 1) return 1;
    let count = linearLogRecur(n / 2) + linearLogRecur(n / 2);
    for (let i = 0; i < n; i++) {
        count++;
    }
    return count;
}

image.png

阶乘阶

阶乘 O(n!)O(n!) 通常使用递归实现。

image.png