草庐IT

数据结构 | 时间复杂度与空间复杂度

北 海 2024-05-10 原文

🌳🌲🌱本文已收录至:数据结构 | C语言
更多知识尽在此专栏中!

🎉🎉🎉欢迎点赞、收藏、关注 🎉🎉🎉

文章目录


🌳前言

复杂度是衡量一个算法好坏的标准,可以从 时间空间 两个维度进行比较。可能你之前听说某个算法的时间复杂度是O(N),空间复杂度是O(1),知道这是一个还不错的算法,那么你知道这些复杂度是如何计算出来的吗?本文将会揭开它们神秘的面纱,让你拥有一把衡量算法好坏的度量衡。

🌳正文

先说结论

  • 时间复杂度主要是衡量一个算法的运行快慢,通常由循环决定
  • 空间复杂度主要是衡量一个算法运行所需的额外空间,通常由开辟的空间大小决定

常见错误理解

  • 时间复杂度是就是一段代码实际运行运行所花的时间。这种理解是错误的,因为环境的不同会导致代码运行的快慢,比如将一个大型程序放在你电脑上运行,和放在神威·太湖之光上运行所花的时间是肯定不同的,为了统一评判,我们将算法中基本操作的执行次数,称为算法的时间复杂度
  • 空间复杂度和代码长度有关,代码码越长越复杂。错误,假如我们只创建了常数个变量,纵使代码写的再长,这个算法的空间复杂度也是O(1),在程序中创建的变量个数(在内存中申请的空间大小),称为空间复杂度,创建的变量数越多,算法所占空间就越复杂

当然这只是最基本的知识,关于时间&空间复杂度的更多知识可以往下看


🌲时间复杂度

🌱先说概念

在计算机科学中,算法的时间复杂度是一个函数,它定量地描述了该算法的运行时间

同大多数读者一样,我也不喜欢冗长复杂的官方解释,通俗来说,算法中基本操作的执行次数(循环部分),就是代表了该算法的时间复杂度,比如下面这段代码

//请问这段代码的时间复杂度是多少?
int main()
{
    int N = 0;
    scanf("%d", &N);
    int count = 0;
    int i = 0;
    for (i = 0; i < N; i++)
    {
        count++;
    }
    return 0;
}

直接看循环部分,可以看到这个算法会循环N次,N是可变的,因此这个算法的时间复杂度就是N,简单吧,当然这只是一个最简单的例子,真实的程序循环比这复杂得多,此时就需要一个工具:大O渐进表示法,来帮助我们计算出算法的时间复杂度

🌱大O渐进表示法

O符号:是用来描述函数渐进行为的数学符号,这个符号有点像数学中取极限
大O渐进表示法 的推导步骤:

  1. 去掉已求出时间中的常数项。如果都是常数项,就用常数1代表时间复杂度
    • 比如时间为 2N ^ 2 + 2N + 100,需要去掉常数项100
  2. 取出其中的最高阶项。比如 N、2N与N ^ 2,最高阶项为N^2
    • 接着上面的推导 2N ^ 2 + 2N,显而易见 2N ^ 2 要大于 2N2N ^ 2就是这里的最高阶项
  3. 如果存在常数项 * 最高阶项的情况,就要去除常数项。比如2N,最终复杂度为N
    • 最后在对最高阶项进行处理 2N ^ 2常数项 2 对整体时间复杂度影响是不大的,应该去除

以上就是通过 大O渐进表示法时间复杂度的步骤,当然示例中的时间复杂度最终为O(N ^ 2)

大O渐进表示法 这样表示,是否合理呢?
答:很合理,尤其是放在计算机上使用

首先假设存在这么一个时间复杂度(不用 大O渐进表示法 版): F(N) = N^2 + 2 * N + 10
经过 大O渐进表示法 处理后,变成 F(N) = O(N^2),让我们来测试一组数据:

NF(N) = N^2+2*N+10F(N) = O(N^2)相差率
10100+20+10 = 13010023%
10010000+200+10 = 10210100002%
100001000200101000000000.02%

显然,随着数据的不断增大,二者间的差距会越来越小,而经过 大O渐进表示法 计算后的时间复杂度,是更容易计算的,除非追求精确的数据,否则用 大O渐进表示法 是很合理的~
大O渐进表示法 的核心作用就是去除那些对结果影响不大的项

🌱示例

时间复杂度这一块有几个比较经典的题目需要掌握一下,学会使用 大O渐进表示法 求出时间复杂度

🪴题目一

// 计算Func1的时间复杂度?
void Func1(int N, int M)
{
    int count = 0;
    for (int k = 0; k < M; ++k)
    {
        ++count;
    }
    for (int k = 0; k < N; ++k)
    {
        ++count;
    }
    printf("%d\n", count);
}

答案:O(N + M)
因为这里有两次循环,并且 NM 都是未知数,无法区分出谁是最高阶项,因此两个都取出,都没有带常数项,不做去除操作。综上 Func1 的时间复杂度就是 O(N + M)

🪴题目二

// 计算Func2的时间复杂度?
void Func2(int N)
{
    int count = 0;
    for (int k = 0; k < 100; ++k)
    {
        ++count;
    }
    printf("%d\n", count);
}

答案: O(1)
显然,这里需要循环100次,都是常数项,直接遵循推导步骤一,时间复杂度O(1)
这里只循环了100次,即使循环1k次、1w次,也都是常数项,一样是 O(1)

🪴题目三

//计算strchr的时间复杂度?
const char* strchr(const char* str, int character);

答案: O(N)
说明:strchr 是一个字符串寻找函数,作用是在字符串str中查找目标字符character
有三种情况:

  1. 最好的情况,只找一次,此时的时间复杂度O(1)
  2. 最坏的情况,没有目标字符,需要把整个字符串找一遍,时间复杂度为 O(N)
  3. 平均的情况,在中间就找到了,时间复杂度O(N / 2)

面对这种多分支情况,我们要做预期管理用最悲观的态度来判断程序,这样做的好处是预期值低,结果出来时不会有很大落差,生活中也可以像这样,做好准备。言归正传,这里选择最坏的情况,即 O(N),当然这种情况比较特殊,值得注意一下

🪴题目四

//计算BubbleSort的时间复杂度?
//冒泡排序
void BubbleSort(int* a, int n)
{
    assert(a);
    for (size_t end = n; end > 0; --end)
    {
        int exchange = 0;
        for (size_t i = 1; i < end; ++i)
        {
            if (a[i - 1] > a[i])
            {
                Swap(&a[i - 1], &a[i]);
                exchange = 1;
            }
        }
        if (exchange == 0)
            break;
    }
}

答案: O(N ^ 2)
冒泡排序是一个神奇的算法,每次冒泡比较的趟数都不同,可以这样推导

  • 第一趟:比较 N - 1
  • 第二趟:比较 N - 2
  • 第三趟:比较 N - 3
  • …………
  • 最后一趟: 比较 1

总共比较的次数,就是时间复杂度,即 (N - 1) + (N - 2) + …… + 1,显然这是一个首项为 N - 1,尾项为 1 的等差数列,并且共有 N - 1 项,把高中学的知识用起来,N - 1 项和为 (N - 1) * N / 2 ,通过 大O渐进表示法 进行计算,最终结果为 O(N ^ 2)

🪴题目五

//计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
    assert(a);
    int begin = 0;
    int end = n - 1;
    // [begin, end]:begin和end是左闭右闭区间,因此有=号
    while (begin <= end)
    {
        int mid = begin + ((end - begin) >> 1);
        if (a[mid] < x)
            begin = mid + 1;
        else if (a[mid] > x)
            end = mid - 1;
        else
            return mid;
    }
    return -1;
}

答案: O(logN)
折半查找,一个站在巨人肩膀上的算法,假如我们想在世界范围内找一个人(假设有70亿人,且数据已排序),通过二分查找,最多只需要查找33次,就能找出这个人,厉害吧?下面是二分查找的推导过程
假设需要查找的次数为 k 次,那么可以这样写

  1. N / 2 -> N / 2 ^ 1
  2. N / 4 -> N / 2 ^ 2
  3. N / 8 -> N / 2 ^ 3
  • …………

其中,左边的序号就是查找的次数 k ,可得出式子 N = 2 ^ k ,稍微变换下,得到 k = logN,其中第二个式子就是二分查找的时间复杂度,可能细心的人能发现,我没有写底数 2,不是不写,而是不好写,除非文本编辑器支持插入数学式,否则这个是很难表示的,鉴于这个东西应用的广泛性,有这样一个规定:在底数为 2 时,可以不写底数;如果底数为其他数,就需要写出来,其他底数都很少见的。 在有的地方,会把 lgN 看作 logN,第一种方法是有歧义的,和以 10 为底的对数表示形式一致,这是不太好的,但如果我们看到了,要明白 lgN 也是一个以 2 为底的对数
二分查找为何厉害?因为二分查找在计算时,每次都是对半查找,即 2 ^ k,是一个指数爆炸级查找,因此很快就能找到目标数,听说过一张纸对折64次就能碰到月球的故事吧?指数爆炸是个很庞大的数据

🪴题目六(递归)

//计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
    if (N < 3)
        return 1;
    return Fib(N - 1) + Fib(N - 2);
}

答案: O(2 ^ N)
递归本来就是一个很麻烦的东西,更何况这是计算斐波那契数列

  • 递归中的时间复杂度,计算的是每次递归中执行次数的累加

我们可以将递归斐波那契数列水平展开,即 1+2+4+8+16+32+……+2^N
根据 大O渐进表示法 ,去除影响小的常数项,最终结果为 O(2 ^ N)

10.24更正
O(2 ^ N) 是斐波那契数列的时间复杂度有些不准确,因为将斐波那契数列递归求值展开成一颗二叉树后,会发现这是一颗普通二叉树,部分节点有缺失,O(2 ^ N) 用来描述满二叉树合理些。斐波那契数列的详细时间复杂度为 O(1.618 ^ N) ,更详细的是一个无理数的指数级,想了解的可以看看这篇文章 递归求解斐波那契数列的时间复杂度问题

相信你看完这些经典例题后,能对 大O渐进表示法 有一个新的认识,加油,你会越来越强的!


🌲空间复杂度

🌱照例,先说概念

算法的空间复杂度是指临时占用储存空间大小的量度

简单理解,空间复杂度是算法中变量的个数(申请的空间大小)。因为变量在使用前,要先声明,而声明会在内存中开辟空间,无论是在堆上还是栈上,都会造成内存损耗,损耗越大,空间复杂度就越高 ,先看代码:

//空间复杂度
int main()
{
	int a = 1;
	int b = 2;
	int c = 3;
	return 0;
}

看变量个数,有a、b、c三个变量,属于常数次,所以这个程序的空间复杂度O(1)空间复杂度也遵循大O渐进表示法,这里就不再介绍了,忘记了的同学可以往上翻翻

  • 当出现函数调用时,形参部分空间不计入空间复杂度的计算,递归除外,递归会建立额外的函数栈帧
    • 函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定
  • 大多数情况下,算法的空间复杂度是 O(1)O(N)

眼看千遍,不如手过一遍,下面跟着我一起来看看试题,练练手吧!

🌱示例

🪴题目一

//计算Fibonacci的空间复杂度?
//返回斐波那契数列的前n项
long long* Fibonacci(size_t n)
{
    if (n == 0)
        return NULL;
    long long* fibArray = (long long*)malloc((n + 1) * sizeof(long long));
    fibArray[0] = 0;
    fibArray[1] = 1;
    for (int i = 2; i <= n; ++i)
    {
        fibArray[i] = fibArray[i - 1] + fibArray[i - 2];
    }
    return fibArray;
}

答案: O(N)
先看题目,这是一个迭代实现的斐波那契数列,没有使用递归,直接看看创建的变量个数:

  1. fibArray
  2. (n+1) * sizeof(long long)
  3. i

共计申请了三块空间,其中第二块空间最大,为最高阶项,即 n + 1,去除常数项,最终结果为 O(N)

🪴题目二(递归)

//计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
    if (N == 0)
        return 1;
    return Fac(N - 1) * N;
}

答案: O(N)
递归的规则是 先递出,再回归,如果中途遇到递归,继续递出,因此在计算递归空间复杂度时,计算的是每次递归调用的变量个数相加(所开辟的空间),也可以看作递归的深度
显然这里的递归深度是 N,开辟了N个栈帧,每个栈帧使用了常数个空间,空间复杂度自然就是 O(N)

你学会了吗?是不是感觉空间复杂度要比时间复杂度简单些?毕竟现在是大容量时代,内存都变得不值钱了,于是对空间的要求自然而然的变低了。


🌲各种复杂度量级展示

一般算法的常见复杂度类型如图所示


  • 这是各种复杂度的关系图
    可以看到二分查找 logN 是真的强!

🌲相关题目推荐

有些题目中,对时间复杂度和空间复杂度是有要求的,比如下面这两个简单题,用来练手就很不错,动起来吧!关于这两题的题解,后续会发布的

10.25更新
两道题目的题解文章已发布,欢迎查看
C语言题解 | 消失的数字 &轮转数组

  • 题目一:消失的数字 1

  • 题目二:旋转数组 2


🌳总结

以上就是本次关于时间复杂度空间复杂度的全部内容了,作为数据结构中的第一课,算是比较偏向于理论的部分,学起来也还比较简单,开胃菜嘛,等后面手撕顺序表、链表、二叉树就爽了

如果你觉得本文写的还不错的话,期待留下一个小小的赞👍,你的支持是我分享的最大动力!

如果本文有不足或错误的地方,随时欢迎指出,我会在第一时间改正

相关文章推荐
你真的了解时间复杂度吗
如何计算时间复杂度
时间复杂度计算 例题合集

有关数据结构 | 时间复杂度与空间复杂度的更多相关文章

  1. ruby - 使用 ruby​​ 将 HTML 转换为纯文本并维护结构/格式 - 2

    我想将html转换为纯文本。不过,我不想只删除标签,我想智能地保留尽可能多的格式。为插入换行符标签,检测段落并格式化它们等。输入非常简单,通常是格式良好的html(不是整个文档,只是一堆内容,通常没有anchor或图像)。我可以将几个正则表达式放在一起,让我达到80%,但我认为可能有一些现有的解决方案更智能。 最佳答案 首先,不要尝试为此使用正则表达式。很有可能你会想出一个脆弱/脆弱的解决方案,它会随着HTML的变化而崩溃,或者很难管理和维护。您可以使用Nokogiri快速解析HTML并提取文本:require'nokogiri'h

  2. ruby - 解析 RDFa、微数据等的最佳方式是什么,使用统一的模式/词汇(例如 schema.org)存储和显示信息 - 2

    我主要使用Ruby来执行此操作,但到目前为止我的攻击计划如下:使用gemsrdf、rdf-rdfa和rdf-microdata或mida来解析给定任何URI的数据。我认为最好映射到像schema.org这样的统一模式,例如使用这个yaml文件,它试图描述数据词汇表和opengraph到schema.org之间的转换:#SchemaXtoschema.orgconversion#data-vocabularyDV:name:namestreet-address:streetAddressregion:addressRegionlocality:addressLocalityphoto:i

  3. ruby-on-rails - Ruby 检查日期时间是否为 iso8601 并保存 - 2

    我需要检查DateTime是否采用有效的ISO8601格式。喜欢:#iso8601?我检查了ruby​​是否有特定方法,但没有找到。目前我正在使用date.iso8601==date来检查这个。有什么好的方法吗?编辑解释我的环境,并改变问题的范围。因此,我的项目将使用jsapiFullCalendar,这就是我需要iso8601字符串格式的原因。我想知道更好或正确的方法是什么,以正确的格式将日期保存在数据库中,或者让ActiveRecord完成它们的工作并在我需要时间信息时对其进行操作。 最佳答案 我不太明白你的问题。我假设您想检查

  4. ruby - Ruby 有 `Pair` 数据类型吗? - 2

    有时我需要处理键/值数据。我不喜欢使用数组,因为它们在大小上没有限制(很容易不小心添加超过2个项目,而且您最终需要稍后验证大小)。此外,0和1的索引变成了魔数(MagicNumber),并且在传达含义方面做得很差(“当我说0时,我的意思是head...”)。散列也不合适,因为可能会不小心添加额外的条目。我写了下面的类来解决这个问题:classPairattr_accessor:head,:taildefinitialize(h,t)@head,@tail=h,tendend它工作得很好并且解决了问题,但我很想知道:Ruby标准库是否已经带有这样一个类? 最佳

  5. ruby-on-rails - 将 Ruby 中的日期/时间格式化为 YYYY-MM-DD HH :MM:SS - 2

    这个问题在这里已经有了答案:Railsformattingdate(4个答案)关闭4年前。我想格式化Time.Now函数以显示YYYY-MM-DDHH:MM:SS而不是:“2018-03-0909:47:19+0000”该函数需要放在时间中.现在功能。require‘roo’require‘roo-xls’require‘byebug’file_name=ARGV.first||“Template.xlsx”excel_file=Roo::Spreadsheet.open(“./#{file_name}“,extension::xlsx)xml=Nokogiri::XML::Build

  6. ruby - 查找字符串中的内容类型(数字、日期、时间、字符串等) - 2

    我正在尝试解析一个CSV文件并使用SQL命令自动为其创建一个表。CSV中的第一行给出了列标题。但我需要推断每个列的类型。Ruby中是否有任何函数可以找到每个字段中内容的类型。例如,CSV行:"12012","Test","1233.22","12:21:22","10/10/2009"应该产生像这样的类型['integer','string','float','time','date']谢谢! 最佳答案 require'time'defto_something(str)if(num=Integer(str)rescueFloat(s

  7. ruby - 是否有用于序列化和反序列化各种格式的对象层次结构的模式? - 2

    给定一个复杂的对象层次结构,幸运的是它不包含循环引用,我如何实现支持各种格式的序列化?我不是来讨论实际实现的。相反,我正在寻找可能会派上用场的设计模式提示。更准确地说:我正在使用Ruby,我想解析XML和JSON数据以构建复杂的对象层次结构。此外,应该可以将该层次结构序列化为JSON、XML和可能的HTML。我可以为此使用Builder模式吗?在任何提到的情况下,我都有某种结构化数据-无论是在内存中还是文本中-我想用它来构建其他东西。我认为将序列化逻辑与实际业务逻辑分开会很好,这样我以后就可以轻松支持多种XML格式。 最佳答案 我最

  8. ruby - 我如何添加二进制数据来遏制 POST - 2

    我正在尝试使用Curbgem执行以下POST以解析云curl-XPOST\-H"X-Parse-Application-Id:PARSE_APP_ID"\-H"X-Parse-REST-API-Key:PARSE_API_KEY"\-H"Content-Type:image/jpeg"\--data-binary'@myPicture.jpg'\https://api.parse.com/1/files/pic.jpg用这个:curl=Curl::Easy.new("https://api.parse.com/1/files/lion.jpg")curl.multipart_form_

  9. 世界前沿3D开发引擎HOOPS全面讲解——集3D数据读取、3D图形渲染、3D数据发布于一体的全新3D应用开发工具 - 2

    无论您是想搭建桌面端、WEB端或者移动端APP应用,HOOPSPlatform组件都可以为您提供弹性的3D集成架构,同时,由工业领域3D技术专家组成的HOOPS技术团队也能为您提供技术支持服务。如果您的客户期望有一种在多个平台(桌面/WEB/APP,而且某些客户端是“瘦”客户端)快速、方便地将数据接入到3D应用系统的解决方案,并且当访问数据时,在各个平台上的性能和用户体验保持一致,HOOPSPlatform将帮助您完成。利用HOOPSPlatform,您可以开发在任何环境下的3D基础应用架构。HOOPSPlatform可以帮您打造3D创新型产品,HOOPSSDK包含的技术有:快速且准确的CAD

  10. FOHEART H1数据手套驱动Optitrack光学动捕双手运动(Unity3D) - 2

    本教程将在Unity3D中混合Optitrack与数据手套的数据流,在人体运动的基础上,添加双手手指部分的运动。双手手背的角度仍由Optitrack提供,数据手套提供双手手指的角度。 01  客户端软件分别安装MotiveBody与MotionVenus并校准人体与数据手套。MotiveBodyMotionVenus数据手套使用、校准流程参照:https://gitee.com/foheart_1/foheart-h1-data-summary.git02  数据转发打开MotiveBody软件的Streaming,开始向Unity3D广播数据;MotionVenus中设置->选项选择Unit

随机推荐