算法与数据结构——复杂度分析

news/2024/9/17 8:02:05/文章来源:https://www.cnblogs.com/1873cy/p/18366749

复杂度分析

算法效率评估

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

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

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

  • 时间效率:算法运行速度的快慢。
  • 空间效率:算法占用内存空间的大小。

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

迭代与递归

在算法中,重复执行某个任务是很常见的,它与复杂度分析息息相关。因此,在介绍时间复杂度和空间复杂度之前,先了解如何在程序中实现重复执行任务,即来那个黄总基本的程序控制结构:迭代、递归

迭代

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

1.for循环

for循环是最常见的迭代形式之一,适合在预先知道迭代次数时使用

以下函数基于for循环实现了求和1+2+3+...+n,求和结果使用res记录。

int forLoop(int n){int res = 0;// 循环求和 1,2,3,4...,nfor (int i = 1; i <= n; i++){res += i;}return res;
}

流程图:

此求和函数的操作与输入数据大小n成正比,或者说成“线性关系”。实际上,时间复杂度描述的就是这个“线性关系”

2.while循环

与for循环类似,while循环也是一种实现迭代的方法。在while循环中,每轮都会先检查条件,如果条件为真,则继续执行否则就结束循环。

/*while 循环*/
int whileLoop(int n){int res = 0;int i = 1;while (i <= n){res += i;i++;}return res;
}

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

3.嵌套循环

我们可以在一个循环结构内嵌套另一个循环结构,下面以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();
}

嵌套循环流程框图:

 在这种情况下,函数的操作数量与n2成正比,或者说算法运行时间和输入数据大小n成“平方关系”。我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使其时间复杂度提高至“立方关系”“四次关系”,以此类推。

递归

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

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

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

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

观察以下代码,我们只需要调用函数recur(n)就可以完成1+2+3+...+n的计算

/*递归调用*/
int recur(int n){if (n == 1){return 1;}return n + recur(n - 1);
}

递归过程:

 

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

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

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

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

 

1.调用栈

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

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

在上述递归过程中,在触发终止条件前,同时存在n个未返回的递归函数,递归深度为n。

2.尾递归

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

  • 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文
  • 尾递归:递归调用时函数返回前的最后一个操作,这意味着函数返回到上一层级后,无需继续执行其他操作,因此系统无需保存上一层函数的上下文。

以计算1+2+...+n为例,我们将结果设为函数参数res,从而实现尾递归:

/*尾递归*/
int tailRecur(int n, int res){// 终止条件if (n == 0)return res;// 尾递归调用return tailRecur(n - 1, res + n);
}

普通递归:求和操作是在“归”的过程中执行的,每层返回后都要执行一次求和操作。

尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。

尾递归过程:

注意:许多编译器或解释器并不支持尾递归优化。例如Python、Java、C++等默认不支持尾递归优化。

3.递归树

当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例:

给定一个斐波那契数列 0,1,1,2,3,5,8,13,...,求该数列的第n个数字。

设斐波那契数列的第n个数字为 𝑓(𝑛) ,易得两个结论。

  • 数列的前两个数字为 𝑓(1) = 0 和 𝑓(2) = 1。
  • 数列中的每个数字是前两个数字的和,即 𝑓(𝑛)  =  𝑓(𝑛 - 1) +  𝑓(𝑛 - 2)。    

按照地推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用fib(n)即可得到斐波那契数列的第n个数字:

/*斐波那契数列的第n个数字  0,1,1,2,3,5,8......  */
int fib(int n){if (n == 2 || n == 1)return n - 1;return fib(n - 1) + fib(n - 2);
}

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

从本质上看,递归体现了“将问题分解为更小的子问题”的思维范式,这种分治策略至关重要。

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

 

两者对比

  迭代   递归
实现方式 循环结构 函数调用自身
时间效率 效率通常较高,无函数调用开销 每次函数调用都会产生开销
内存使用 通常使用固定大小的内存空间 累计函数调用可能使用大量的栈帧空间
适用问题 适用于简单循环任务,代码直观、可读性好 适用于子问题分解,如树、图、分治、回溯等,代码结构简洁、清晰。

以上述递归函数为例,求和操作在递归的“归”阶段进行,这意味着最初被调用的函数实际上是最后完成其求和操作的,这种工作机制与栈的“先入后出”原则异曲同工。

事实上,“调用栈”和“栈帧空间”这类递归术语已经暗示了递归与栈之间的密切关系。

  • 递:当函数被调用时,系统会在“调用栈”上为该函数分配新的栈帧,用于存储函数的局部变量、参数、返回地址等数据
  • 归:当函数完成执行并返回时,对应的栈帧会被从“调用栈”上移除,恢复之前函数的执行环境。

总之,选择迭代还是递归取决于特定问题的性质。在编程实践中,权衡两者的优劣并根据情景选择合适的方法至关重要。

时间复杂度

运行时间可以直观且准确地反映算法的效率。要准确预估一段代码的运行时间,应该进行如下操作。

  • 确定运行平台,包括硬件配置、编程语言、系统环境等,这些因素都会影响代码的运行效率。
  • 评估各种计算操作的运行时间,例如加法操作需要1ns,乘法操作需要10ns,打印操作需要5ns等。
  • 统计代码中所有的计算操作,并将所有操作的执行时间求和,从而得到运行时间。
// 在某运行平台下
void algorithm(int n) {int a = 2; // 1 nsa = a + 1; // 1 nsa = a * 2; // 10 ns// 循环 n 次for (int i = 0; i < n; i++) { // 1 ns ,每轮都要执行 i++std::cout << 0 << std::endl; // 5 ns}
}

根据以上方法,可以得到算法的运行时间为(6n+12)ns

但时间上,统计算法的运行时间既不合理也不现实。首先,我们不希望将预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。

统计时间增长趋势

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

“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为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只有一个打印操作,算法运行时间不随着n增大而增大。我们称此算法的时间复杂度为“常数阶”。
  • 算法B中的打印操作需要循环n次,算法运行时间随着n增大而线性增加。此算法的时间复杂度被称为“线性阶”。
  • 算法C中的打印操作需要循环1000000次,虽然运行时间很长,但是它与输入数据大小n无关。因此C的时间复杂度与A相同,均为“常数阶”。

函数渐近上界

给定一个输入大小为n的函数:

void algorithm(int n) {int a = 2; // +1 a = a + 1; // +1 a = a * 2; // +1// 循环 n 次for (int i = 0; i < n; i++) { // +1  ,每轮都要执行 i++std::cout << 0 << std::endl; // +1}
}

设算法的操作数量是一个关于输入数据大小n的函数,记为 𝑇(𝑛),则以上函数的操作数量为:

 𝑇(𝑛) = 3 + 2𝑛

 𝑇(𝑛) 是一次函数,说明其运行时间的增长趋势是线性的,因此它的时间复杂度是线性阶。

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

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

推算方法

总体上分为两步:首先统计操作数量,然后判断渐近上界。

第一步:统计操作数量

针对代码,逐行从上到下计算即可。操作数量𝑇(𝑛)中的各种系数、常数项都可以忽略。根据此原则,可总结出一下计数简化技巧。

  • 忽略𝑇(𝑛)中的常数项
  • 省略所有系数
  • 嵌套循环时使用乘法。总操作数量等于外层循环和内层循环操作数量之积。
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;}}
}

 以下公式展示了使用上述技巧前后的统计结果,两者推算出的时间复杂度都为 𝑂(𝑛2
) 。
𝑇(𝑛) = 2𝑛(𝑛 + 1) + (5𝑛 + 1) + 2 完整统计 (‑.‑|||)
= 2n2 + 7𝑛 + 3
𝑇(𝑛) = 𝑛2 + 𝑛 偷懒统计 (o.O) 

第二步:判断渐近上界

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

下表展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。

常见类型

设输入数据大小为n,常见的时间复杂度类型如图所示:

1.常数阶 𝑂(1) 

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

2.线性阶 𝑂(n) 

线性阶的操作数量相对于数据大小n以线性级别增长。通常出现在单层循环中。遍历数组和遍历链表等操作的时间复杂度均为 𝑂(𝑛) ,其中n为数组或链表的长度。

3.平方阶 𝑂(n2

平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为 𝑂(𝑛) ,因此总体的时间复杂度为 𝑂(𝑛2) 。

以冒泡排序为例,外层循环执行n-1次,内层循环执行n-1、n-2、n-3、...、2、1次,平均n/2次,因此时间复杂度为 𝑂((𝑛 − 1)𝑛/2) = 𝑂(𝑛2)

/*平方阶-冒泡排序*/
int bubbleSort(vector<int> &nums){/*for (int i = 0; i < nums.size(); i++){for (int j = i + 1; j < nums.size(); j++){if (nums[i] > nums[j]){int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}}}*/for (int i = 0; i < nums.size(); i++){for (int j = 0; j < nums.size() - i - 1; j++){if (nums[j] > nums[j + 1]){int temp = nums[j];nums[j] = nums[j + 1];nums[j + 1] = temp;}}}
}

4.指数阶 𝑂(2n

生物学的“细胞分裂”是指数阶增长的典型例子:初识状态为1个细胞,分裂一轮后变为4个,以此类推,n轮分裂后有2n个细胞。

在实际算法中,指数阶常出现于递归函数中。

 

/* 指数阶(循环实现) */
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;
}
/* 指数阶(递归实现) */
int expRecur(int n) {if (n == 1)return 1;return expRecur(n - 1) + expRecur(n - 1) + 1;
}

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

5.对数阶 𝑂(log 𝑛) 

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

与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一棵高度为log2n的递归树:

/* 对数阶(递归实现) */
int logRecur(int n) {if (n <= 1)return 0;return logRecur(n / 2) + 1;
}

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

6.线性对数阶 𝑂(𝑛 log 𝑛) 

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

如图展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为n,树共有 log2𝑛 + 1 层,因此时间复杂度为𝑂(𝑛 log 𝑛) 。

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

7.阶乘阶𝑂(𝑛!)

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

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

阶乘通常使用递归实现:

/* 阶乘阶(递归实现) */
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;
}

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

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

AI大模型快速生成题库-助力业务人效提升10+倍

一 现状问题 1、培训考核涉及的文件数量较多 当前,京东航空公司维修部门面临着人员规模的快速增长和持续的培训需求。根据民航局的规定,维修培训必须确保所有维修人员都能够完成对飞机维修相关文件的学习,这包括维修方案、维修工程管理手册、工作程序手册等共计12本手册以及…

存储系列之 Linux ext2 概述

来自:https://www.cnblogs.com/orange-CC/p/12673052.html存储系列之 Linux ext2 概述引言:学习经典永不过时。我们之前介绍过存储介质主要是磁盘,先介绍过物理的,后又介绍了虚拟的。保存在磁盘上的信息一般采用文件(file)为单位,磁盘上的文件必须是持久的,同时文件是通…

存储系列之 从ext2到ext3、ext4 的变化与区别

来自:https://www.cnblogs.com/orange-CC/p/12673073.html存储系列之 从ext2到ext3、ext4 的变化与区别引言:ext3 和 ext4 对 ext2 进行了增强,但是其核心设计并没有发生变化。所以建议先查看上上篇的《存储系列之 Linux ext2 概述 》,有了ext2的基础,看这篇就是so easy了…

【YashanDB知识库】生成迁移报告失败,报错未知类型错误异常:

【标题】YMP迁移 【问题分类】迁移报告 【关键字】迁移报告、未知类型错误异常 【问题描述】下载迁移报告时报错“未知类型错误异常:”,一长串英文日志报错:【问题原因分析】java版本不对,ymp仅支持java 8和11版本,用户环境用的21版本。【解决/规避方法】将java版本更新为8…

Visual Studio 2013 自定义动态库dll文件lib存放路径

前言全局说明Visual Studio 2013 自定义lib存放路径一、说明 环境: Windows 7 旗舰版 Visual Studio 2013二、设置说明 在一个功能比较全的项目中,有可能会引入第三方库来完成某些功能, 为了让目录结构、文件,清晰,会将引入的dll文件,放置到一个独立目录里。 这样方便管理…

7.路由器配置及使用

9.1 路由器的结构内存只读内存随机存储器ARP:广播信息 非易失随机存储器闪存9.2 路由器的工作原理9.3 路由表 注意,分两种路由表 路由器的路由表(基于端口的)三层交换机的路由表(基于vlan的)9.4 路由器的工作模式9-5 路由器的基本配置及常见命令 路由的配置方式*重要9-6 …

dedecms 两个常见漏洞的复现

dedecms系统的部分复现,还有更多的漏洞没有去复现和发现。侵权声明 本文章中的所有内容(包括但不限于文字、图像和其他媒体)仅供教育和参考目的。如果在本文章中使用了任何受版权保护的材料,我们满怀敬意地承认该内容的版权归原作者所有。 如果您是版权持有人,并且认为您的…

过滤器与拦截器

过滤器 与拦截器 参考https://www.cnblogs.com/Black-Ice/p/16248535.html过滤器 Filter Filter 基本介绍 过滤器 Filter 是 Sun 公司在 Servlet 2.3 规范中添加的新功能,其作用是对客户端发送给 Servlet 的请求以及对 Servlet 返回给客户端的响应做一些定制化的处理,例如校验…

独立站是什么?独立站的优势是什么?为什么要做独立站?一键三问

独立站是指一个完全独立的网站,由公司自主搭建和运营,包括独立的服务器、网站程序和单独的域名等等,完全不依赖于任何第三方平台。它最初用于区分与亚马逊、eBay、速卖通等各种第三方电商平台的区别。市面上有三种建站系统:全自主开发、基于开源软件建设的独立站和基于SaaS…

AP9196 DC-DC 输入3-40V 6A升压恒流电源管理芯 太阳能路灯方案

产品说明 AP9196 是一系列外围电路简洁的宽调光比升压调光恒流驱动器,适用于3-40V输入电压范围的LED照明领域。 AP9196 采用我司专利算法,可以实现高精度的恒流效果,输出电流恒流精度≤3%,电压工作范围为5-40V,可以轻松满足锂电池及中低压的应用需求,输出耐压仅由MOS 耐…

Camera MIPI 协议理解

D-PHY 1、传输模式 1.LP(Low-Power) 模式:用于传输控制信号,最高速率 10 MHz HS(High-Speed)模式:用于高速传输数据,速率范围 [80 Mbps, 1Gbps] per Lane 传输的最小单元为 1 个字节,采用小端(低位字节放到内存的低地址端,高位字节放到内存的高地址端)的方式及 LS…

nvm---安装

安装流程:https://blog.csdn.net/qq_22182989/article/details/125387145 第一步:下载安装 nvm