数据结构与算法(一):概述与复杂度分析

参考引用

  • Hello 算法
  • Github 仓库:hello-algo

1. 初识算法

1.1 算法无处不在

1.1.1 二分查找:查阅字典
  • 在字典里,每个汉字都对应一个拼音,而字典是按照拼音字母顺序排列的。假设我们需要查找一个拼音首字母为 r 的字,通常会按下述步骤实现
    • 翻开字典约一半的页数,查看该页的首字母是什么,假设首字母为 m
    • 由于在拼音字母表中 r 位于 m 之后,所以排除字典前半部分,查找范围缩小到后半部分
    • 不断重复上两步,直至找到拼音首字母为 r 的页码为止
1.1.2 插入排序:整理扑克
  • 在打牌时,每局都需要整理扑克牌,使其从小到大排列,实现流程如下
    • 将扑克牌划分为 “有序” 和 “无序” 两部分,并假设初始状态下最左 1 张扑克牌已经有序
    • 在无序部分抽出一张扑克牌,插入至有序部分的正确位置,完成后最左 2 张扑克已经有序
    • 不断循环步骤 2,每一轮将一张扑克牌从无序部分插入至有序部分,直至所有扑克牌都有序

在这里插入图片描述

1.1.3 贪心算法:货币找零
  • 假设在超市购买了 69 元的商品,给了收银员 100 元,则收银员需要找 31 元,他会很自然地完成下面的思考
    • 可选项是比 (31) 元面值更小的货币,包括 (1) 元、(5) 元、(10) 元、(20) 元。
    • 从可选项中拿出最大的 20 元,剩余 31 - 20 = 11 元
    • 从剩余可选项中拿出最大的 10 元,剩余 11 - 10 = 1 元
    • 从剩余可选项中拿出最大的 1 元,剩余 1 - 1 = 0 元
    • 完成找零,方案为 20 + 10 + 1 = 31 元

    以上每一步都采取当前看来最好的选择(尽可能用大面额的货币),最终得到了可行的找零方案,这种方法本质上是 “贪心” 算法

在这里插入图片描述

1.2 算法是什么

1.2.1 算法定义
  • 算法(algorithm)是在有限时间内解决特定问题的一组指令或操作步骤,它具有以下特性
    • 问题是明确的,包含清晰的输入和输出定义
    • 具有可行性,能够在有限步骤、时间和内存空间下完成
    • 各步骤都有确定的含义,相同的输入和运行条件下,输出始终相同
1.2.2 数据结构定义
  • 数据结构(data structure)是计算机中组织和存储数据的方式,具有以下设计目标

    • 空间占用尽量减少,节省计算机内存
    • 数据操作尽可能快速,涵盖数据访问、添加、删除、更新等
    • 提供简洁的数据表示和逻辑信息,以便使得算法高效运行
  • 数据结构设计是一个充满权衡的过程。如果想要在某方面取得提升,往往需要在另一方面作出妥协,如:

    • 链表相较于数组,在数据添加和删除操作上更加便捷,但牺牲了数据访问速度
    • 图相较于链表,提供了更丰富的逻辑信息,但需要占用更大的内存空间
1.2.3 数据结构与算法的关系
  • 数据结构与算法高度相关、紧密结合,具体表现以下三个方面
    • 数据结构是算法的基石。数据结构为算法提供了结构化存储的数据,以及用于操作数据的方法
    • 算法是数据结构发挥作用的舞台。数据结构本身仅存储数据信息,结合算法才能解决特定问题
    • 算法通常可以基于不同的数据结构进行实现,但执行效率可能相差很大,选择合适的数据结构是关键

    数据结构与算法是独立于编程语言的

在这里插入图片描述

  • 将数据结构与算法类比为积木
    在这里插入图片描述

2. 数据结构

2.1 数据结构分类

  • 常见的数据结构包括数组、链表、栈、队列、哈希表、树、堆、图,它们可以从 “逻辑结构” 和 “物理结构” 两个维度进行分类
2.1.1 逻辑结构:线性与非线性
  • 逻辑结构揭示了数据元素之间的逻辑关系

    • 在数组和链表中,数据按照顺序依次排列,体现了数据之间的线性关系
    • 在树中,数据从顶部向下按层次排列,表现出祖先与后代之间的派生关系
    • 图则由节点和边构成,反映了复杂的网络关系
  • 如下图所示,逻辑结构可被分为 “线性” 和 “非线性” 两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列

    • 线性数据结构:数组、链表、栈、队列、哈希表
    • 非线性数据结构:树、堆、图、哈希表

在这里插入图片描述

非线性数据结构可以进一步被划分为树形结构和网状结构

  • 线性结构:数组、链表、队列、栈、哈希表,元素之间是一对一的顺序关系
  • 树形结构:树、堆、哈希表,元素之间是一对多的关系
  • 网状结构:图,元素之间是多对多的关系
2.1.2 物理结构:连续与分散
  • 在计算机中,内存和硬盘是两种主要的存储硬件设备

    • 内存用于运行程序时暂存数据,速度较快,但容量较小(通常为 GB 级别)
    • 硬盘主要用于长期存储数据,容量较大,但速度较慢(通常可达到 TB 级别)
  • 在算法运行过程中,相关数据都存储在内存中

    • 下图展示了一个计算机内存条,其中每个黑色方块都包含一块内存空间。可以将内存想象成一个巨大的 Excel 表格,其中每个单元格都可以存储一定大小的数据,在算法运行时,所有数据都被存储在这些单元格中
  • 系统通过内存地址来访问目标位置的数据

    • 如下图所示,计算机根据特定规则为表格中的每个单元格分配编号,确保每个内存空间都有唯一的内存地址。有了这些地址,程序便可以访问内存中的数据

在这里插入图片描述

  • 内存是所有程序的共享资源,当某块内存被某个程序占用时,则无法被其他程序同时使用了。因此在数据结构与算法的设计中,内存资源是一个重要的考虑因素
    • 算法所占用的内存峰值不应超过系统剩余空闲内存
    • 如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在分散的内存空间内
  • 如下图,物理结构反映了数据在计算机内存中的存储方式,可分为连续空间存储(数组)和分散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,同时在时间效率和空间效率方面呈现出互补的特点

在这里插入图片描述

  • 所有数据结构都是基于数组、链表或二者的组合实现的。例如,栈和队列既可以使用数组实现,也可以使用链表实现;而哈希表的实现可能同时包含数组和链表
    • 基于数组可实现:栈、队列、哈希表、树、堆、图、矩阵、张量等
      • 基于数组实现的数据结构也被称为 “静态数据结构”,这意味着此类数据结构在初始化后长度不可变
    • 基于链表可实现:栈、队列、哈希表、树、堆、图等
      • 基于链表实现的数据结构被称为 “动态数据结构”,这类数据结构在初始化后,仍可以在程序运行过程中对其长度进行调整

2.2 基本数据结构

  • 基本数据类型是 CPU 可以直接进行运算的类型,在算法中直接被使用,主要包括以下几种类型

    • 整数类型 byte、short、int、long
    • 浮点数类型 float、double,用于表示小数
    • 字符类型 char,用于表示各种语言的字母、标点符号、甚至表情符号等
    • 布尔类型 bool,用于表示 “是” 与 “否” 判断
  • 基本数据类型以二进制的形式存储在计算机中

    • 一个二进制位即为 1 比特。在绝大多数现代系统中,1 字节(byte)由 8 比特(bits)组成

3. 复杂度分析

3.1 算法效率评估

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

    • 时间效率:算法运行速度的快慢
    • 空间效率:算法占用内存空间的大小
  • 效率评估方法主要分为两种:实际测试、理论估算

3.1.1 实际测试
  • 假设有算法 A 和算法 B,它们都能解决同一问题,现在对比这两个算法的效率:最直接的方法是找一台计算机运行这两个算法,并监控记录它们的运行时间和内存占用情况。这种评估方式能够反映真实情况,但也存在较大局限性
    • 一方面,难以排除测试环境的干扰因素。硬件配置会影响算法的性能表现。比如在某台计算机中,算法 A 的运行时间比算法 B 短;但在另一台配置不同的计算机中,可能得到相反的测试结果
    • 另一方面,展开完整测试非常耗费资源。随着输入数据量的变化,算法会表现出不同的效率。例如,在输入数据量较小时,算法 A 的运行时间比算法 B 更少;而输入数据量较大时,测试结果可能恰恰相反
3.1.2 理论估算
  • 可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为:渐近复杂度分析 asymptotic complexity analysis,简称复杂度分析

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

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

3.2 迭代与递归

3.2.1 迭代
  • 迭代(iteration)是一种重复执行某个任务的控制结构
    • 在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足
for 循环
  • for 循环是最常见的迭代形式之一,适合预先知道迭代次数时使用
    • 以下函数基于 for 循环实现了求和 1 + 2 + … + n,求和结果使用变量 res 记录
    int forLoop(int n) {int res = 0;for (int i = 1; i <= n; ++i) {res += i;}return res;
    }
    

在这里插入图片描述

while 循环
  • 与 for 循环类似,while 循环也是一种实现迭代的方法。在 while 循环中,程序每轮都会先检查条件,如果条件为真则继续执行,否则就结束循环
    • while 循环中,初始化和更新条件变量的步骤独立在循环结构之外,因此比 for 循环的自由度更高
    • 下面的条件变量 i 每轮进行了两次更新,这种情况就不太方便用 for 循环实现
    /* while 循环(两次更新) */
    int whileLoopII(int n) {int res = 0;int i = 1; // 初始化条件变量// 循环求和 1, 4, ...while (i <= n) {res += i;// 更新条件变量i++;i *= 2;}return res;
    }
    
嵌套循环
  • 可以在一个循环结构内嵌套另一个循环结构,以 for 循环为例
    /* 双层 for 循环 */
    string nestedForLoop(int n) {ostringstream res;// 循环 i = 1, 2, ..., n-1, nfor (int i = 1; i <= n; ++i) {// 循环 j = 1, 2, ..., n-1, nfor (int j = 1; j <= n; ++j) {res << "(" << i << ", " << j << "), ";}}return res.str();
    }
    

在这里插入图片描述

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

    • 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到 “终止条件”
    • 归:触发 “终止条件” 后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果
  • 从实现的角度看,递归代码主要包含三个要素

    • 终止条件:用于决定什么时候由 “递” 转 “归”
    • 递归调用:对应 “递”,函数调用自身,通常输入更小或更简化的参数
    • 返回结果:对应 “归”,将当前递归层级的结果返回至上一层
  • 只需调用函数 recur(n) ,就可以完成 1 + 2 + … + n 的计算

    int recur(int n) {// 终止条件if (n == 1)return 1;// 递:递归调用int res = recur(n - 1);// 归:返回结果return n + res;
    }
    

在这里插入图片描述

调用栈
  • 递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等,这将导致两方面的结果
    • 函数的上下文数据都存储在称为 “栈帧空间” 的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间
    • 递归调用函数会产生额外的开销,因此递归通常比循环的时间效率更低
  • 上述代码的递归深度为 n,实际的递归深度通常是有限的,过深的递归可能导致栈溢出报错
  • 事实上,“调用栈” 和 “栈帧空间” 这类递归术语已经暗示了递归与栈之间的密切关系
    • 递:当函数被调用时,系统会在 “调用栈” 上为该函数分配新的栈帧,用于存储函数的局部变量、参数、返回地址等数据
    • 归:当函数完成执行并返回时,对应的栈帧会从 “调用栈” 上被移除,恢复之前函数的执行环境
尾递归
  • 如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归(tail recursion)
  • 普通递归
    • 当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文
    • 求和操作是在 “归” 的过程中执行的,每层返回后都要再执行一次求和操作
  • 尾递归
    • 递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无需继续执行其他操作,因此系统无需保存上一层函数的上下文
    • 求和操作是在 “递” 的过程中执行的,“归” 的过程只需层层返回
    int tailRecur(int n, int res) {// 终止条件if (n == 0)return res;// 尾递归调用return tailRecur(n - 1, res + n);
    }
    

在这里插入图片描述

递归树
  • 当处理与 “分治” 相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读
  • 以 “斐波那契数列” 为例,给定一个斐波那契数列 0, 1, 1, 2, 3, 5, 8, 13, … ,求该数列的第 n 个数字
    • 设斐波那契数列的第 n 个数字为 f(n),易得两个结论
      • 数列的前两个数字为 f(1) = 0 和 f(2) = 1
      • 数列中的每个数字是前两个数字的和,即 f(n) = f(n - 1) + f(n - 2)
    • 按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 fib(n) 即可得到斐波那契数列的第 n 个数字
    int fib(int n) {// 终止条件 f(1) = 0, f(2) = 1if (n == 1 || n == 2)return n - 1;// 递归调用 f(n) = f(n-1) + f(n-2)int res = fib(n - 1) + fib(n - 2);// 返回结果 f(n)return res;
    }
    
    • 上述代码在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如下图所示,这样不断递归调用下去,最终将产生一个层数为 n 的递归树(recursion tree)
      在这里插入图片描述
3.2.3 两者对比
  • 迭代:“自下而上” 地解决问题
    • 从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成
    • 从 1 遍历到 n,每轮执行求和操作,即可求得 f(n)
  • 递归:“自上而下” 地解决问题
    • 将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)
    • 将问题分解为子问题 f(n) = n + f(n-1) ,不断(递归地)分解下去,直至基本情况 f(1) = 1 时终止

在这里插入图片描述

3.3 时间复杂度

3.3.1 统计时间增长趋势
  • 时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势
  • 假设输入数据大小为 n ,给定三个算法函数 A、B 和 C
    // 算法 A 的时间复杂度:常数阶
    void algorithm_A(int n) {cout << 0 << endl;
    }
    // 算法 B 的时间复杂度:线性阶
    void algorithm_B(int n) {for (int i = 0; i < n; i++) {cout << 0 << endl;}
    }
    // 算法 C 的时间复杂度:常数阶
    void algorithm_C(int n) {for (int i = 0; i < 1000000; i++) {cout << 0 << endl;}
    }
    
  • 下图展示了以上三个算法函数的时间复杂度
    • 算法 A 只有 1 个打印操作,算法运行时间不随着 n 增大而增长。称此算法的时间复杂度为 “常数阶”
    • 算法 B 中的打印操作需要循环 n 次,算法运行时间随着 n 增大呈线性增长。此算法的时间复杂度被称为 “线性阶”
    • 算法 C 中的打印操作需要循环 1000000 次,虽然运行时间很长,但它与输入数据大小 n 无关。因此 C 的时间复杂度和 A 相同,仍为 “常数阶”

在这里插入图片描述

  • 相较于直接统计算法运行时间,时间复杂度分析有哪些特点?
    • 时间复杂度能够有效评估算法效率
      • 算法 B 的运行时间呈线性增长,在 n > 1 时比算法 A 更慢,在 n > 1000000 时比算法 C 更慢
    • 时间复杂度的推算方法更简便
      • 运行平台和计算操作类型都与算法运行时间的增长趋势无关
    • 时间复杂度也存在一定的局限性
      • 尽管算法 B 的时间复杂度比 C 高,但在输入数据大小 n 较小时,算法 B 明显优于算法 C
3.3.2 函数渐近上界
  • 给定一个输入大小为 n 的函数

    void algorithm(int n) {int a = 1;  // +1a = a + 1;  // +1a = a * 2;  // +1// 循环 n 次for (int i = 0; i < n; i++) { // +1(每轮都执行 i++)cout << 0 << endl;    // +1}
    }
    
  • 设算法的操作数量是一个关于输入数据大小 n 的函数,记为 T(n),则以上函数的的操作数量为:T(n) = 3 + 2n

    • T(n) 是一次函数,说明其运行时间的增长趋势是线性的,因此它的时间复杂度是线性阶
    • 将线性阶的时间复杂度记为 O(n),这个数学符号称为大 O 记号,表示函数 T(n) 的渐近上界
  • 计算渐近上界就是寻找一个函数 f(n),使得当 n 趋向于无穷大时,T(n) 和 f(n) 处于相同的增长级别,仅相差一个常数项 c 的倍数
    在这里插入图片描述

3.3.3 推算方法
  • 确定 f(n) 之后,我们便可得到时间复杂度 O(f(n))。那么如何确定渐近上界 f(n) 呢?总体分为两步
第一步:统计操作数量
  • 针对代码,逐行从上到下计算即可。然而,由于上述 c·f(n) 中的常数项 c 可以取任意大小,因此操作数量 T(n) 中的各种系数、常数项都可以被忽略。根据此原则,可以总结出以下计数简化技巧
    • 1. 忽略 T(n) 中的常数项
      • 因为它们都与 n 无关,所以对时间复杂度不产生影响
    • 2. 省略所有系数
      • 例如,循环 2n 次、5n + 1 次等,都可简记为 n 次,因为 n 前面的系数对时间复杂度没有影响
    • 3. 循环嵌套时使用乘法
      • 总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第 1 点和第 2 点的技巧
    // 时间复杂度:O(n^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;}}
    }
    
第二步:判断渐近上界
  • 时间复杂度由多项式 T(n) 中最高阶的项来决定。这是因为在 n 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略
    • 下表展示了一些例子,其中一些夸张的值是为了强调 “系数无法撼动阶数” 这一结论。当 n 趋于无穷大时,这些常数变得无足轻重
      在这里插入图片描述
3.3.4 常见类型

在这里插入图片描述

常数阶 O(1)
  • 常数阶的操作数量与输入数据大小 n 无关,不随 n 的变化而变化
    int constant(int n) {int count = 0;int size = 100000;for (int i = 0; i < size; i++) {count++;}return count;
    }
    
线性阶 O(n)
  • 线性阶的操作数量相对于输入数据大小 n 以线性级别增长,通常出现在单层循环

    int linear(int n) {int count = 0;for (int i = 0; i < n; i++) {count++;}return count;
    }
    
  • 遍历数组和遍历链表等操作的时间复杂度均为 O(n) ,其中 n 为数组或链表的长度

    int arrayTraversal(vector<int> &nums) {int count = 0;// 循环次数与数组长度成正比for (int num : nums) {count++;}return count;
    }
    
平方阶 O(n^2)
  • 平方阶的操作数量相对于输入数据大小 n 以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为 O(n),因此总体为 O(n^2)
    int quadratic(int n) {int count = 0;// 循环次数与数组长度成平方关系for (int i = 0; i < n; i++) {for (int j = 0; j < n; j++) {count++;}}return count;
    }
    
指数阶 O(2^n)
  • 生物学的 “细胞分裂” 是指数阶增长的典型例子:初始状态为 1 个细胞,分裂一轮后变为 2 个,分裂两轮后变为 4 个,以此类推,分裂 n 轮后有 2^n 个细胞。下图和代码模拟了细胞分裂的过程,时间复杂度为 O(2^n)
    /* 指数阶(循环实现) */
    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 - 1return count;
    }
    

在这里插入图片描述

  • 在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过 n 次分裂后停止
    /* 指数阶(递归实现) */
    int expRecur(int n) {if (n == 1)return 1;return expRecur(n - 1) + expRecur(n - 1) + 1;
    }
    

指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心等算法来解决

对数阶 O(log n)
  • 与指数阶相反,对数阶反映了 “每轮缩减到一半” 的情况。设输入数据大小为 n,由于每轮缩减到一半,因此循环次数是 log_2 n ,即 2^n 的反函数。下图和代码模拟了 “每轮缩减到一半” 的过程,时间复杂度为 O(\log_2 n) ,简记为 O(\log n)
    /* 对数阶(循环实现) */
    int logarithmic(float n) {int count = 0;while (n > 1) {n = n / 2;count++;}return count;
    }
    

在这里插入图片描述

  • 与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一个高度为 log_2 n 的递归树
    /* 对数阶(递归实现) */
    int logRecur(float n) {if (n <= 1)return 0;return logRecur(n / 2) + 1;
    }
    

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

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

    int linearLogRecur(float n) {if (n <= 1)return 1;int count = linearLogRecur(n / 2) + linearLogRecur(n / 2);for (int i = 0; i < n; i++) {count++;}return count;
    }
    
  • 二叉树的每一层操作总数都为 n ,树共有 log_2 n + 1 层,因此时间复杂度为 O(n log n)
    在这里插入图片描述

主流排序算法的时间复杂度通常为 O(n log n),例如快速排序、归并排序、堆排序

阶乘阶 O(n!)
  • 阶乘阶对应数学上的 “全排列” 问题,阶乘通常使用递归实现。如下图和代码所示,第一层分裂出 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;
    }
    

在这里插入图片描述

阶乘阶比指数阶增长得更快,在 n 较大时也是不可接受的

3.3.5 最差、最佳和平均时间复杂度
  • 算法的时间效率往往不是固定的,而是与输入数据的分布有关。假设输入一个长度为 n 的数组 nums,其中 nums 由从 1 至 n 的数字组成,每个数字只出现一次;但元素顺序是随机打乱的,任务目标是返回元素 1 的索引,可以得出
    • 当 nums = [?, ?, …, 1] ,即当末尾元素是 1 时,需要完整遍历数组,达到最差时间复杂度 O(n)
      • “最差时间复杂度” 对应函数渐近上界,使用大 O 记号表示
    • 当 nums = [1, ?, ?, …] ,即当首个元素为 1 时,无论数组多长都不需要继续遍历,达到最佳时间复杂度 Omega(1)
      • “最佳时间复杂度” 对应函数渐近下界,用 Omega 记号表示
  • 在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。而最差时间复杂度更为实用,因为它给出了一个效率安全值

3.4 空间复杂度

  • 空间复杂度用于衡量算法占用内存空间随着数据量变大时的增长趋势
3.4.1 算法相关空间
  • 算法在运行过程中使用的内存空间主要包括以下几种

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

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

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

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

    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;         // 输出数据
    }
    

在这里插入图片描述

3.4.2 推算方法
  • 通常只关注最差空间复杂度,因为内存空间是一项硬性要求,必须确保在所有输入数据下都有足够的内存空间预留

  • 在递归函数中,需要注意统计栈帧空间

    • 函数 loop() 在循环中调用了 n 次 func() ,每轮中的 func() 都返回并释放了栈帧空间,因此空间复杂度仍为 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);
    }
    
3.4.3 常见类型

在这里插入图片描述

常数阶 O(1)
  • 常数阶常见于数量与输入数据大小 n 无关的常量、变量、对象。在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,因此不会累积占用空间,空间复杂度仍为 O(1)
    int func() {// 执行某些操作return 0;
    }void constant(int n) {// 常量、变量、对象占用 O(1) 空间const int a = 0;int b = 0;vector<int> nums(10000);ListNode node(0);// 循环中的变量占用 O(1) 空间for (int i = 0; i < n; i++) {int c = 0;}// 循环中的函数占用 O(1) 空间for (int i = 0; i < n; i++) {func();}
    }
    
线性阶 O(n)
  • 线性阶常见于元素数量与 n 成正比的数组、链表、栈、队列等
    void linear(int n) {// 长度为 n 的数组占用 O(n) 空间vector<int> nums(n);// 长度为 n 的列表占用 O(n) 空间vector<ListNode> nodes;for (int i = 0; i < n; i++) {nodes.push_back(ListNode(i));}// 长度为 n 的哈希表占用 O(n) 空间unordered_map<int, string> map;for (int i = 0; i < n; i++) {map[i] = to_string(i);}
    }
    
  • 如下图所示,此函数的递归深度为 n,即同时存在 n 个未返回的 linear_recur() 函数,使用 O(n) 大小的栈帧空间
    /* 线性阶(递归实现) */
    void linearRecur(int n) {cout << "递归 n = " << n << endl;if (n == 1)return;linearRecur(n - 1);
    }
    

在这里插入图片描述

平方阶 O(n^2)
  • 平方阶常见于矩阵和图,元素数量与 n 成平方关系
    void quadratic(int n) {// 二维列表占用 O(n^2) 空间vector<vector<int>> numMatrix;for (int i = 0; i < n; i++) {vector<int> tmp;for (int j = 0; j < n; j++) {tmp.push_back(0);}numMatrix.push_back(tmp);}
    }
    
  • 如下图所示,该函数的递归深度为 n,在每个递归函数中都初始化了一个数组,长度分别为 n、n-1、…、2、1,平均长度为 n / 2 ,因此总体占用 O(n^2) 空间
    /* 平方阶(递归实现) */
    int quadraticRecur(int n) {if (n <= 0)return 0;vector<int> nums(n);cout << "递归 n = " << n << " 中的 nums 长度 = " << nums.size() << endl;return quadraticRecur(n - 1);
    }
    

在这里插入图片描述

指数阶 O(2^n)
  • 指数阶常见于二叉树。观察下图,高度为 n 的 “满二叉树” 的节点数量为 2^n - 1,占用 O(2^n) 空间
    /* 指数阶(建立满二叉树) */
    TreeNode *buildTree(int n) {if (n == 0)return nullptr;TreeNode *root = new TreeNode(0);root->left = buildTree(n - 1);root->right = buildTree(n - 1);return root;
    }
    

在这里插入图片描述

对数阶 O(log n)
  • 对数阶常见于分治算法
    • 例如归并排序,输入长度为 n 的数组,每轮递归将数组从中点划分为两半,形成高度为 log n 的递归树,使用 O(log n) 栈帧空间
    • 再例如将数字转化为字符串,输入一个正整数 n ,它的位数为 log_{10} n + 1 ,即对应字符串长度为 log_{10} n + 1,因此空间复杂度为 O(log_{10} n + 1) = O(log n)
3.4.4 权衡时间与空间
  • 理想情况下,希望算法的时间复杂度和空间复杂度都能达到最优,但实际非常困难
  • 降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然
    • 将牺牲内存空间来提升算法运行速度的思路称为 “以空间换时间”,反之,则称为 “以时间换空间”
    • 在大多数情况下,时间比空间更宝贵,因此 “以空间换时间” 通常是更常用的策略

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.xdnf.cn/news/149641.html

如若内容造成侵权/违法违规/事实不符,请联系一条长河网进行投诉反馈,一经查实,立即删除!

相关文章

常见的软件脱壳思路

单步跟踪法 1.本方法采用OD载入。 2.跟踪F8&#xff0c;实现向下的跳。 3.遇到程序回跳按F4。 4.绿色线条表示跳转没实现&#xff0c;不用理会&#xff0c;红色线条表示跳转已经实现&#xff01; 5.刚载入程序有一个CALL的&#xff0c;我们就F7跟进去&#xff0c;不然程序很容…

AUTOSAR通信篇 - CAN网络通信(六:CanNm)

文章目录 功能介绍协调算法工作模式网络模式Repeat Message State&#xff08;重复消息状态&#xff09;Normal Operation State&#xff08;正常运行/工作状态&#xff09;Ready Sleep State&#xff08;就绪睡眠状态&#xff09; Prepare Bus Sleep Mode&#xff08;预休眠模…

新款UI动态壁纸头像潮图小程序源码

新款UI动态壁纸头像潮图小程序源码&#xff0c;不需要域名服务器&#xff0c;直接添加合法域名&#xff0c;上传发布就能使用。 可以对接开通流量主&#xff0c;个人也能运营&#xff0c;不需要服务器源码完整。整合头像&#xff0c;动态壁纸&#xff0c;文案功能齐全。 源码…

H5移动端购物商城系统源码 小型商城全新简洁风格全新UI 支持易支付接口

一款比较简单的 H5 移动端购物商城系统源码&#xff0c;比较适合单品商城、小型商城使用。带有易支付接口。 源码下载&#xff1a;https://download.csdn.net/download/m0_66047725/88391704 源码下载2&#xff1a;评论留言或私信留言

微服务的初步使用

环境说明 jdk1.8 maven3.6.3 mysql8 idea2022 spring cloud2022.0.8 微服务案例的搭建 新建父工程 打开IDEA&#xff0c;File->New ->Project&#xff0c;填写Name&#xff08;工程名称&#xff09;和Location&#xff08;工程存储位置&#xff09;&#xff0c;选…

arm代码

RISC精简指令集 长度和执行周期固定 长度为一条机器指令在计算机占用的内存大小 指令周期为CPU执行一条机器指令所发费的时间(时钟周期由CPU工作频率决定) CISC复杂指令集 其架构一般用于PC端 X86和X64都是负载指令集CPU 更注重指令的功能性 指令周期和长度都不固定 ar…

斯坦福数据挖掘教程·第三版》读书笔记(英文版)Chapter 10 Mining Social-Network Graphs

来源&#xff1a;《斯坦福数据挖掘教程第三版》对应的公开英文书和PPT。 Chapter 10 Mining Social-Network Graphs The essential characteristics of a social network are: There is a collection of entities that participate in the network. Typically, these entiti…

Scala第十七章节

Scala第十七章节 scala总目录 文档资料下载 章节目标 了解集合的相关概念掌握Traversable集合的用法掌握随机学生序列案例 1. 集合 1.1 概述 但凡了解过编程的人都知道程序 算法 数据结构这句话, 它是由著名的瑞士计算机科学家尼古拉斯沃斯提出来的, 而他也是1984年图灵…

Linux环境搭建SVN服务器并实现公网访问 - cpolar端口映射

文章目录 前言1. Ubuntu安装SVN服务2. 修改配置文件2.1 修改svnserve.conf文件2.2 修改passwd文件2.3 修改authz文件 3. 启动svn服务4. 内网穿透4.1 安装cpolar内网穿透4.2 创建隧道映射本地端口 5. 测试公网访问6. 配置固定公网TCP端口地址6.1 保留一个固定的公网TCP端口地址6…

@ConfigurationProperties配置绑定~

ConfigurationProperties注解是Spring Boot中的一个注解&#xff0c;用于将配置文件中的属性值绑定到Java类中的字段上。 ConfigurationProperties注解的作用包括&#xff1a; 实现配置文件属性和Java类字段的映射&#xff0c;简化了读取配置文件的操作。 可以指定配置文件中…

Elasticsearch:什么时候应该考虑在 Elasticsearch 中添加协调节点?

仅协调节点&#xff08;coordinating only nodes&#xff09;充当智能负载均衡器。 仅协调节点的这种特殊角色通过减轻数据和主节点的协调责任&#xff0c;为广泛的集群提供了优势。 加入集群后&#xff0c;这些节点与任何其他节点类似&#xff0c;都会获取完整的集群状态&…

全志ARM926 Melis2.0系统的开发指引⑤

全志ARM926 Melis2.0系统的开发指引⑤ 编写目的8. 固件修改工具(ImageModify)使用8.1.界面说明8.2.操作步骤8.2.1. 配置平台8.2.2. 选择固件8.2.3. 选择要替换的文件8.2.4. 替换文件8.2.5. 保存固件 8.3.注意事项8.4.增加固件修改权限设置8.4.1. 概述8.4.2. 操作说明8.4.2.1.打…

【C语言经典100例题-70】求一个字符串的长度(指针)

代码 使用指针来遍历字符串&#xff0c;直到遇到字符串结尾的空字符\0为止&#xff0c;统计字符数量即为字符串长度。 #include<stdio.h> #define n 20 int getlength(char *a) {int len 0;while(*a!\0){len;a;}return len; } int main() {char *arr[n] { 0 };int l…

Stable diffusion的架构解读(本博客还是以unet架构为主)

博客只是简单的记录一下自己学的&#xff0c;基于自己的一些情况&#xff0c;所以简单了一些只是将来忘记&#xff0c;用来回顾用。 论文的大体框架 unet结构位于 unet会接受prompt特征、latent特征、和t时间步特征&#xff0c;最后生成新一轮的特征 可以参考知乎大佬htt…

计算机竞赛 深度学习实现行人重识别 - python opencv yolo Reid

文章目录 0 前言1 课题背景2 效果展示3 行人检测4 行人重识别5 其他工具6 最后 0 前言 &#x1f525; 优质竞赛项目系列&#xff0c;今天要分享的是 &#x1f6a9; **基于深度学习的行人重识别算法研究与实现 ** 该项目较为新颖&#xff0c;适合作为竞赛课题方向&#xff0c…

全志ARM926 Melis2.0系统的开发指引⑦

全志ARM926 Melis2.0系统的开发指引⑦ 编写目的11. 调屏11.1. 调屏步骤简介11.1.1. 判断屏接口。11.1.2. 确定硬件连接。11.1.3. 配置显示部分 sys_config.fex11.1.3.1. 配置屏相关 IO 11.1.4. Lcd_panel_cfg.c 初始化文件中配置屏参数11.1.4.1. LCD_cfg_panel_info11.1.4.2. L…

虚拟机联网

桥接 桥接模式就是虚拟机与你的电脑平起平做&#xff0c;都有同样的IP&#xff0c;且与你的电脑在同一网段下&#xff0c;就能够上网。 电脑的IP的地址 虚拟机的ip地址 设置vm1的ip地址与网关与电脑相同 如果出现ssh连接虚拟机不成功的问题&#xff0c;无其他问题时&#xff0…

Next.js 入门笔记

前言 之前初步体验了 React 的魅力, 又看文档理解了一下 useState 和 useEffect, 目前初步理解的概念是: useState 用来声明在组件中使用并且需要修改的变量 useEffect 用来对 useState 声明的变量进行初始化赋值 可能理解的不太准确, 不过大概差不多是这么个意思. 但是再往后…

React项目部署 - Nginx配置

写在前面&#xff1a;博主是一只经过实战开发历练后投身培训事业的“小山猪”&#xff0c;昵称取自动画片《狮子王》中的“彭彭”&#xff0c;总是以乐观、积极的心态对待周边的事物。本人的技术路线从Java全栈工程师一路奔向大数据开发、数据挖掘领域&#xff0c;如今终有小成…

012-第二代硬件选型

第二代硬件选型 文章目录 第二代硬件选型项目介绍重新换平台缘由X86 && Arm 架构切换 ARM Linux 硬件选型系统确定Qt 版本确定总结一下 关键字&#xff1a; Qt、 Qml、 Arm、 X86、 linux 项目介绍 欢迎来到我们的 QML & C 项目&#xff01;这个项目结合了 QM…