>> >> >> Reference << << << <<<<<<Ref>>>>>>
Complexity Analysis
Modified: 2025-12-31 | Author:ljf12825

复杂度是计算机科学中非常核心的一个概念,用于衡量一个算法的“好坏”
为什么需要复杂度分析

  1. 脱离硬件依赖:不同的机器(CPU、内存、硬盘)性能不同,同一段代码在不同机器上的运行时间会差异很大。理论分析可以消除这种硬件差异
  2. 脱离数据依赖:如果测试数据规模太小,或者测试数据本身的特点(如是否已排序),可能无法真实反映算法的性能。比如,一个算法对小规模数据很快,对大规模数据极慢
  3. 成本于可行性:在算法设计阶段,往往需要从多个候选方案中选优,不可能每个方案都实现并测试。复杂度分析可以在编码前就进行理论上的评估
  4. 洞察本质:复杂度分析帮助我们理解算法随问题规模增长时,其资源消耗的增长趋势,这是评价算法可扩展性的关键

什么是复杂度
算法的复杂度分为两个维度:

  1. 时间复杂度(Time Complexity):不是计算程序的具体运行时间,而是计算算法执行基本操作(或称语句)的次数。它反映了运行时间随输入数据规模增大的增长趋势
  2. 空间复杂度(Space Complexity):计算算法运行过程中所需的存储空间随输入数据规模增大的增长趋势。这通常指除了输入数据本身所占空间之外,算法运行所需的额外空间

大O表示法(Big O Notation)

复杂度通常用大O表示法来描述。它表示的是算法复杂度的一个上界(Upper Bound),即最坏情况下的复杂度
定义: 如果存在正常数 $c$ 和 $n_o$ ,使得当 $ n \ge n_o$ 时,$T(n) \le c * f(n) $ ,则记为 T(n) = O(f(n)) 通俗理解: 大O表示法抓主要矛盾,忽略常数、系数和低阶项,只关注增长最快的那部分(最高阶项)。因为它对增长趋势的影响最大

示例:

特别的,对数复杂度的底数通常不影响渐进复杂度的表示
根据换底公式:$\log_ax=\frac{\log_bx}{\log_ba}$
也就是说,把对数的底数从a转换为b只会多一个常数因子$\frac{1}{\log_ba}$
在大O表示法下,常数因子是不计入复杂度的\

常见的时间复杂度

按性能从优到劣排列:

复杂度名称代码结构例子说明
$O(1)$常数时间无循环/递归数组按索引查找、哈希表查找最优情况,不随数据规模增长而变化
$O(logn$)对数时间循环变量翻倍/减半二分查找、平衡二叉树搜索树操作性能极佳,几乎与常数时间无异
$O(n)$线性时间单层循环遍历数组、查找链表中的元素性能良好,随数据规模线性增长
$O(nlogn)$线性对数时间分治策略(Divide and Conquer)快速排序、归并排序、堆排序许多高效排序算法的复杂度
$O(n^2)$平方时间双层嵌套循环冒泡排序、选择排序、插入排序两层嵌套循环,性能较差
$O(n^3)$立方时间三重嵌套循环某些矩阵运算性能很差
$O(2^n)$指数时间生成所有子集暴力解决旅行商问题、斐波那契递归性能极差、n稍大就无法计算
$O(n!)$阶乘时间生成全排列暴力解决全排列问题性能最差

直观感受:假设每秒钟能执行 10^8 次操作,不同复杂度算法能处理的数据规模差异巨大

复杂度n = 10n = 20n = 50
$O(1)$瞬间瞬间瞬间
$O(logn$)瞬间瞬间瞬间
$O(n)$瞬间瞬间瞬间
$O(nlogn)$瞬间瞬间瞬间
$O(n^2)$瞬间瞬间0.25秒
$O(2^n)$0.001秒1秒35.7年
$O(n!)$3.62秒77147年

如何分析一段代码的复杂度

核心: 关注循环结构和递归调用

  1. 顺序执行:复杂度为各部分相加,取最大值
// O(1) + O(n) + O(n²) -> O(n²)
do_something(); // O(1)

for (int i = 0; i < n; ++i) { ... } // O(n)

for (int i = 0; i < n; ++i)  // O(n²)
    for (int j = 0; j < n ; ++j) { ... }
  1. 单层循环:循环次数 * 循环体内复杂度
// O(n)
for (int i = 0; i < n; ++i)
    do_something(); // 假设 do_something 是 O(1)
  1. 嵌套循环:各层循环次数的乘积
// O(m * n)
for (int i = 0; i < n; ++i)
    for (int j = 0; j < m ++j)
        do_something(); // O(1)

// O(n²)
for (int i = 0; i < n; ++i)
    for (int j = 0; j < n; ++j)
        do_something(); // O(1)
  1. 对数复杂度:通常出现在循环变量以倍数增长或衰减时
// O(log n)
int i = 1;

while(i < n)
{
    i *= 2; // 每次循环规模减半
    do_something(); // O(1)
}
  1. 递归算法:分析其递归树或使用主定理(Master Theorem)。例如斐波那契数列的递归实现是$O(2^n)$,而归并排序的递归实现是O(n log n)

空间复杂度分析

与分析时间复杂度类似,关注额外开辟的数组、变量、递归调用栈等

  1. O(1)
// 只用了几个固定变量
int algorithm()
{
    int a = 1;
    int b = 2;
    return a + b;
}
  1. O(n)
// 开辟了一个大小为 n 的数组
std::vector<int> algorithm(int n)
{
    std::vector<int> vec;
    for (int i = 0; i < n; ++i)
        vec.push_back(i);

    return vec;
}
  1. O(n²)
// 开辟一个 n * n的二维数组
std::vector<std::vector<int>> algorithm(int n)
{
    std::vector<std::vector<int>> vec(n, std::vector<int>(n));

    for (int i = 0; i < n; ++i)
        for (int j = 0; j < n ++j)
            vec[i][j] = i + j;
    
    return vec;
}
  1. 递归调用栈:递归深度带来的空间消耗
// 空间复杂度O(n)
// 因为最新会递归 n 层,每层调用需要常数空间,所以是O(n)

int factorial(int n)
{
    if (n <= 1)
        return 1;
    return n * factorial(n - 1);
}

最佳、最差、平均情况

通常要关注最坏情况时间复杂度,因为它给出了算法运行时间的上界,保证了性能不会比这更差

其他重要的渐近符号(Asymptotic Notations)

大O表示法(上界)是最常用的,但为了更精确地描述算法行为,计算机科学中定义了一组渐近符号

符号中文名定义通俗理解
Ω(Omega)渐近下界T(n) ≥ c * g(n)“至少这么好“或”最好情况“的量化描述
Θ(Theta)渐近紧确界c₁ * g(n) ≤ T(n) ≤ c₂ * g(n)“确切的”增长率。它同时定义了上界和下界,表示算法的时间复杂度就是这个量级
o(小o)非渐近紧上界T(n) < c * g(n)比大O更严格的上界。例如:n² = O(n²)是紧的,而n = o(n²)是因为n的增长严格慢于 n²
ω (小欧米伽)非渐近紧下界T(n) > c * g(n)比Ω更严格的下界。例如:n² = ω(n)

误区与难点

  1. 不要忽略常数因子(但大O表示法忽略它)
  1. 递归算法的复杂度分析 这是最复杂的一部分。主要有两种方法:
  1. 均摊分析(Amortized Analysis) 有些操作单次看可能很耗时,但一系列操作总起来看平均代价很低
  1. 空间复杂度的隐藏成本

建议

  1. 从里到外分析:从最内层的循环/递归开始分析,逐步向外
  2. 关注最坏情况:除非特别说明,否则默认分析最坏情况复杂度
  3. 理解而不是死记:记住常见算法的复杂度是好的,但更重要的是理解为什么是哪个复杂度
  4. 权衡时空:时间和空间往往不可兼得,这就是所谓的“时空权衡”。有时可以用更多的空间(如查表、缓存)来换取更快的速度,反之亦然。要根据实际场景(如嵌入式设备内存紧张,服务器追求高并发低延迟)做决定
  5. Big O是工具,不是目的:它的目的是帮助选择和理解算法,而不是算法的最终判决书。一个理论复杂度更优的算法可能因为常数因子过大、缓存不友好、并行度差等原因,在实际应用中反而不如一个理论复杂度稍差的算法