02复杂度分析

2.1 算法效率评估

在算法设计中,我们先后追求以下两个层面的目标。

  1. 找到问题解法:算法需要在规定的输入范围内可靠地求得问题的正确解。
  2. 寻求最优解法:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。

也就是说,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。

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

2.1.1   实际测试

实际测试具有较大的局限性。存在很多方面干扰,使得计算机不可能每个算法都运行来测试。

2.1.2 理论估值

渐近复杂度分析(asymptotic complexity analysis),简称复杂度分析。

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

2.2   迭代与递归

两种基本的程序控制结构:迭代、递归。

2.2.1   迭代

==迭代(iteration)==是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足。

1.   for 循环

for 循环是最常见的迭代形式
下面基于for实现 1+ 2 + 3 +···+n, res输出结果

int forloop(int n){
	int res = 0;
	for ( int i = 1; i <= n; ++i)
	{
		res += i;
	}
	return res;
}
2. while 循环

与上方问题一致

int whileloop(int n){
	int res = 0;
	int i = 1;
	while (i <= n)
	{
		res += i;
		i++;
	}
	return res;
}

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

例如在以下代码中,条件变量 i 每轮进行两次更新,这种情况就不太方便用 for 循环实现:

int whileLoopII(int n) {
    int res = 0;
    int i = 1;
    while (i <= n) {
        res += i;
        i++;
        i *= 2;
    }
    return res;
}
3.嵌套循环
/* 双层 for 循环 */
string nestedForLoop(int n) {
    ostringstream res;
    // 循环 i = 1, 2, ..., n-1, n
    for (int i = 1; i <= n; ++i) {
        // 循环 j = 1, 2, ..., n-1, n
        for (int j = 1; j <= n; ++j) {
            res << "(" << i << ", " << j << "), ";
        }
    }
    return res.str();
}

![[Pasted image 20240804161517.png]]

2.2.2   递归

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

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

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

  1. 终止条件:用于决定什么时候由“递”转“归”。
  2. 递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
  3. 返回结果:对应“归”,将当前递归层级的结果返回至上一层
int recur(int n){
	if(n == 1)
		return 1;
	int res = recur(n - 1)
	return n + res;
}

![[Pasted image 20240804161809.png]]

虽然从计算角度看,迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范式

  • 迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
  • 递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。

以上述求和函数为例,设问题 f(n)=1+2+⋯+n 。

  • 迭代:在循环中模拟求和过程,从 1 遍历到 n ,每轮执行求和操作,即可求得 f(n) 。
  • 递归:将问题分解为子问题 f(n)=n+f(n−1) ,不断(递归地)分解下去,直至基本情况 f(1)=1 时终止
1.   调用栈

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

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

如图 2-4 所示,在触发终止条件前,同时存在 n 个未返回的递归函数,递归深度为 n 。

递归调用深度

在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。

2.尾递归
int tailRecur(int n, int res)
{
	if (n == 0)
		return res;
	return tailRecur(n - 1, res + n);
}

![[Pasted image 20240804162446.png]]

  • 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
  • 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
3.递归树
int fib(int n)
{
	if(n == 1 || n == 2)
		return n - 1;
	int res = fib(n - 1) + fib(n - 2)
	return res;
}

观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图 2-6 所示,这样不断递归调用下去,最终将产生一棵层数为 n 的递归树(recursion tree)。
![[Pasted image 20240804162811.png]]

2.2.3   两者对比

![[Pasted image 20240804163117.png]]

/* 使用迭代模拟递归 */
int forLoopRecur(int n) {
    // 使用一个显式的栈来模拟系统调用栈
    stack<int> stack;
    int res = 0;
    // 递:递归调用
    for (int i = n; i > 0; i--) {
        // 通过“入栈操作”模拟“递”
        stack.push(i);
    }
    // 归:返回结果
    while (!stack.empty()) {
        // 通过“出栈操作”模拟“归”
        res += stack.top();
        stack.pop();
    }
    // res = 1+2+3+...+n
    return res;
}

用迭代来表示递归

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

2.3 时间复杂度

我们将线性阶的时间复杂度记为 O(n) ,这个数学符号称为大 O 记号(big-O notation),表示函数 T(n) 的渐近上界(asymptotic upper bound)。

`若存在正实数 c 和实数 n0 ,使得对于所有的 n>n0 ,均有 T(n)≤c⋅f(n) ,则可认为 f(n) 给出了 T(n) 的一个渐近上界,记为 T(n)=O(f(n))

2.3.1推算方法

1.   第一步:统计操作数量

针对代码,逐行从上到下计算即可。然而,由于上述 c⋅f(n) 中的常数项 c 可以取任意大小,因此操作数量 T(n) 中的各种系数、常数项都可以忽略。根据此原则,可以总结出以下计数简化技巧。

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

给定一个函数,我们可以用上述技巧来统计操作数量:

void algorithm(int n) {
    int a = 1;  // +0(技巧 1)
    a = a + n;  // +0(技巧 1)
    // +n(技巧 2)
    for (int i = 0; i < 5 * n + 1; i++) {
        cout << 0 << endl;
    }
    // +n*n(技巧 3)
    for (int i = 0; i < 2 * n; i++) {
        for (int j = 0; j < n + 1; j++) {
            cout << 0 << endl;
        }
    }
}

以下公式展示了使用上述技巧前后的统计结果,两者推算出的时间复杂度都为 O(n2) 。

完整统计偷懒统计完整统计偷懒统计
T(n)=2n(n+1)+(5n+1)+2=2n2+7n+3 完整统计
T(n)=n2+n 偷懒统计 (o.O)

2.   第二步:判断渐近上界

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

表 2-2 展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 n 趋于无穷大时,这些常数变得无足轻重。
![[Pasted image 20240804164735.png]]

2.3.2   常见类型

设输入数据大小为 n ,常见的时间复杂度类型如图 2-9 所示(按照从低到高的顺序排列)。

![[Pasted image 20240804164828.png]]
![[Pasted image 20240804164835.png]]

一些例子
1.指数阶
/* 指数阶(循环实现) */
int exponential(int n) {
    int count = 0, base = 1;
    // 细胞每轮一分为二,形成数列 1, 2, 4, 8, ..., 2^(n-1)
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < base; j++) {
            count++;
        }
        base *= 2;
    }
    // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1
    return count;
}

![[Pasted image 20240804165504.png]]
在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过 n 次分裂后停止:

/* 指数阶(递归实现) */
int expRecur(int n) {
    if (n == 1)
        return 1;
    return expRecur(n - 1) + expRecur(n - 1) + 1;
}
2.对数阶

与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 n ,由于每轮缩减到一半,因此循环次数是 log2⁡n ,即 2n 的反函数。

图 2-12 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 O(log2⁡n) ,简记为 O(log⁡n) :

/* 对数阶(循环实现) */
int logarithmic(int n) {
    int count = 0;
    while (n > 1) {
        n = n / 2;
        count++;
    }
    return count;
}

![[Pasted image 20240804165752.png]]
`O(log⁡n) 的底数是多少?

`准确来说,“一分为 m”对应的时间复杂度是 O(logm⁡n) 。而通过对数换底公式,我们可以得到具有不同底数、相等的时间复杂度:

`O(logm⁡n)=O(logk⁡n/logk⁡m)=O(logk⁡n)

也就是说,底数 m 可以在不影响复杂度的前提下转换。因此我们通常会省略底数 m ,将对数阶直接记为 O(log⁡n)

3.   线性对数阶 O(nlog⁡n)

线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 O(log⁡n) 和 O(n) 。相关代码如下:

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

![[Pasted image 20240804165847.png]]

4.   阶乘阶 O(n!)

阶乘阶对应数学上的“全排列”问题。给定 n 个互不重复的元素,求其所有可能的排列方案,方案数量为:

n!=n×(n−1)×(n−2)×⋯×2×1

阶乘通常使用递归实现。如图 2-14 和以下代码所示,第一层分裂出 n 个,第二层分裂出 n−1 个,以此类推,直至第 n 层时停止分裂:

/* 阶乘阶(递归实现) */
int factorialRecur(int n) {
    if (n == 0)
        return 1;
    int count = 0;
    // 从 1 个分裂出 n 个
    for (int i = 0; i < n; i++) {
        count += factorialRecur(n - 1);
    }
    return count;
}

![[Pasted image 20240804165943.png]]

请注意,因为当 n≥4 时恒有 n!>2n ,所以阶乘阶比指数阶增长得更快,在 n 较大时也是不可接受的

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

算法的时间效率往往不是固定的,而是与输入数据的分布有关。假设输入一个长度为 n 的数组 nums ,其中 nums 由从 1 至 n 的数字组成,每个数字只出现一次;但元素顺序是随机打乱的,任务目标是返回元素 1 的索引。我们可以得出以下结论。

  • 当 nums = [?, ?, ..., 1] ,即当末尾元素是 1 时,需要完整遍历数组,达到最差时间复杂度 O(n) 。
  • 当 nums = [1, ?, ?, ...] ,即当首个元素为 1 时,无论数组多长都不需要继续遍历,达到最佳时间复杂度 Ω(1) 。

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

/* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */
vector<int> randomNumbers(int n) {
    vector<int> nums(n);
    // 生成数组 nums = { 1, 2, 3, ..., n }
    for (int i = 0; i < n; i++) {
        nums[i] = i + 1;
    }
    // 使用系统时间生成随机种子
    unsigned seed = chrono::system_clock::now().time_since_epoch().count();
    // 随机打乱数组元素
    shuffle(nums.begin(), nums.end(), default_random_engine(seed));
    return nums;
}

/* 查找数组 nums 中数字 1 所在索引 */
int findOne(vector<int> &nums) {
    for (int i = 0; i < nums.size(); i++) {
        // 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
        // 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
        if (nums[i] == 1)
            return i;
    }
    return -1;
}

2.4空间复杂度

空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。这个概念与时间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”。

2.4.1   算法相关空间

算法在运行过程中使用的内存空间主要包括以下几种。

  • 输入空间:用于存储算法的输入数据。
  • 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
  • 输出空间:用于存储算法的输出数据。

一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。

暂存空间可以进一步划分为三个部分。

  • 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
  • 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
  • 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。

![[Pasted image 20240804171207.png]]

/* 结构体 */
struct Node {
    int val;
    Node *next;
    Node(int x) : val(x), next(nullptr) {}
};

/* 函数 */
int func() {
    // 执行某些操作...
    return 0;
}

int algorithm(int n) {        // 输入数据
    const int a = 0;          // 暂存数据(常量)
    int b = 0;                // 暂存数据(变量)
    Node* node = new Node(0); // 暂存数据(对象)
    int c = func();           // 栈帧空间(调用函数)
    return a + b + c;         // 输出数据
}

2.4.2 推算方法

空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。

而与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。

观察以下代码,最差空间复杂度中的“最差”有两层含义。

  1. 以最差输入数据为准:当 n<10 时,空间复杂度为 O(1) ;但当 n>10 时,初始化的数组 nums 占用 O(n) 空间,因此最差空间复杂度为 O(n) 。
  2. 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 O(1) 空间;当初始化数组 nums 时,程序占用 O(n) 空间,因此最差空间复杂度为 O(n)

函数 loop() 和 recur() 的时间复杂度都为 O(n) ,但空间复杂度不同。

  • 函数 loop() 在循环中调用了 n 次 function() ,每轮中的 function() 都返回并释放了栈帧空间,因此空间复杂度仍为 O(1) 。
  • 递归函数 recur() 在运行过程中会同时存在 n 个未返回的 recur() ,从而占用 O(n) 的栈帧空间
int func() {
    // 执行某些操作
    return 0;
}
/* 循环的空间复杂度为 O(1) */
void loop(int n) {
    for (int i = 0; i < n; i++) {
        func();
    }
}
/* 递归的空间复杂度为 O(n) */
void recur(int n) {
    if (n == 1) return;
    return recur(n - 1);
}

2.5   小结

1.   重点回顾

算法效率评估

  • 时间效率和空间效率是衡量算法优劣的两个主要评价指标。
  • 我们可以通过实际测试来评估算法效率,但难以消除测试环境的影响,且会耗费大量计算资源。
  • 复杂度分析可以消除实际测试的弊端,分析结果适用于所有运行平台,并且能够揭示算法在不同数据规模下的效率。

时间复杂度

  • 时间复杂度用于衡量算法运行时间随数据量增长的趋势,可以有效评估算法效率,但在某些情况下可能失效,如在输入的数据量较小或时间复杂度相同时,无法精确对比算法效率的优劣。
  • 最差时间复杂度使用大 O 符号表示,对应函数渐近上界,反映当 n 趋向正无穷时,操作数量 T(n) 的增长级别。
  • 推算时间复杂度分为两步,首先统计操作数量,然后判断渐近上界。
  • 常见时间复杂度从低到高排列有 O(1)、O(log⁡n)、O(n)、O(nlog⁡n)、O(n2)、O(2n) 和 O(n!) 等。
  • 某些算法的时间复杂度非固定,而是与输入数据的分布有关。时间复杂度分为最差、最佳、平均时间复杂度,最佳时间复杂度几乎不用,因为输入数据一般需要满足严格条件才能达到最佳情况。
  • 平均时间复杂度反映算法在随机数据输入下的运行效率,最接近实际应用中的算法性能。计算平均时间复杂度需要统计输入数据分布以及综合后的数学期望。

空间复杂度

  • 空间复杂度的作用类似于时间复杂度,用于衡量算法占用内存空间随数据量增长的趋势。
  • 算法运行过程中的相关内存空间可分为输入空间、暂存空间、输出空间。通常情况下,输入空间不纳入空间复杂度计算。暂存空间可分为暂存数据、栈帧空间和指令空间,其中栈帧空间通常仅在递归函数中影响空间复杂度。
  • 我们通常只关注最差空间复杂度,即统计算法在最差输入数据和最差运行时刻下的空间复杂度。
  • 常见空间复杂度从低到高排列有 O(1)、O(log⁡n)、O(n)、O(n2) 和 O(2n) 等。

2.   Q & A

Q:尾递归的空间复杂度是 O(1) 吗?

理论上,尾递归函数的空间复杂度可以优化至 O(1) 。不过绝大多数编程语言(例如 Java、Python、C++、Go、C# 等)不支持自动优化尾递归,因此通常认为空间复杂度是 O(n) 。

Q:函数和方法这两个术语的区别是什么?

函数(function)可以被独立执行,所有参数都以显式传递。方法(method)与一个对象关联,被隐式传递给调用它的对象,能够对类的实例中包含的数据进行操作。

下面以几种常见的编程语言为例来说明。

  • C 语言是过程式编程语言,没有面向对象的概念,所以只有函数。但我们可以通过创建结构体(struct)来模拟面向对象编程,与结构体相关联的函数就相当于其他编程语言中的方法。
  • Java 和 C# 是面向对象的编程语言,代码块(方法)通常作为某个类的一部分。静态方法的行为类似于函数,因为它被绑定在类上,不能访问特定的实例变量。
  • C++ 和 Python 既支持过程式编程(函数),也支持面向对象编程(方法)。

Q:图解“常见的空间复杂度类型”反映的是否是占用空间的绝对大小?

不是,该图展示的是空间复杂度,其反映的是增长趋势,而不是占用空间的绝对大小。

假设取 n=8 ,你可能会发现每条曲线的值与函数对应不上。这是因为每条曲线都包含一个常数项,用于将取值范围压缩到一个视觉舒适的范围内。

在实际中,因为我们通常不知道每个方法的“常数项”复杂度是多少,所以一般无法仅凭复杂度来选择 n=8 之下的最优解法。但对于 n=85 就很好选了,这时增长趋势已经占主导了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

tyb333333

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值