时间复杂度以及空间复杂度——程序的性能分析

什么是时间复杂度?


算法的渐进时间复杂度T(n) = O( f(n) ),其中f(n) 表示每行代码执行次数之和,而 O 表示正比例关系

大O符号表示法并不是用于来真实代表算法的执行时间的,它是用来表示代码执行时间的增长变化趋势的。

时间复杂度是一种用于衡量算法执行时间随输入规模增长而变化的度量方法。它描述了算法执行所需的时间与输入规模之间的关系。

时间复杂度通常用大O符号(O)表示,表示算法执行时间的增长速度。常见的时间复杂度包括O(1)、O(log n)、O(n)、O(n log n)、O(n^2)等,其中n表示输入规模。

在时间复杂度中,常数项、低阶项和系数通常被忽略,只关注随着输入规模n增大时的增长趋势。因此,时间复杂度可以帮助我们比较不同算法的效率和性能,并预测算法在大规模数据上的执行时间。

以下是一些常见的时间复杂度:

  • O(1)(常数时间复杂度):算法的执行时间是固定的,不随输入规模变化。
  • O(log n)(对数时间复杂度):算法的执行时间随着输入规模的增加而以对数方式增长。
  • O(n)(线性时间复杂度):算法的执行时间与输入规模成线性关系,随着输入规模的增加线性增长。
  • O(n log n)(线性对数时间复杂度):算法的执行时间随输入规模的增加以较快的速度增长。
  • O(n^2)(平方时间复杂度):算法的执行时间随输入规模的增加而呈平方级增长。
  • O(2^n)(指数时间复杂度):算法的执行时间随输入规模的增加以指数级增长。

需要注意的是,时间复杂度只关注算法的执行时间,而不考虑其他资源消耗(如内存、网络等)。因此,时间复杂度是对算法效率的一个重要指标,但并不是评估算法优劣的唯一标准,还需要考虑其他因素,如空间复杂度、可读性、可维护性等。

什么是时间复杂度?

一个视频教会你算法的时间复杂度_哔哩哔哩_bilibili 视频来源:一个视频教会你算法的时间复杂度_哔哩哔哩_bilibili

 O(logn)——二分查找

 O(nlogn)——排序

时间复杂度的计算

 视频来源:数据结构——时间复杂度计算_哔哩哔哩_bilibili

 

 视频来源:公式法求解时间复杂度之递归篇_哔哩哔哩_bilibili

 归并排序:【算法】排序算法之归并排序 - 知乎

空间复杂度

程序运行时占用内存的大小

S(n)=O(f(n))

变量是常量就是O(1)

变量是数组就是O(N)

递归就是O(N)

什么是空间复杂度?

 

代码随想录

关于时间复杂度,你不知道的都在这里! | 代码随想录

  • 究竟什么是时间复杂度
    • 时间复杂度是一个函数,它定性描述该算法的运行时间
    • 我们在软件开发中,时间复杂度就是用来方便开发者估算出程序运行的答题时间。通常会估算算法的操作单元数量来代表程序消耗的时间,这里默认CPU的每个单元运行消耗的时间都是相同的。
  • 什么是大O
    • 大O用来表示上界的,当用它作为算法的最坏情况运行时间的上界,就是对任意数据输入的运行时间的上界。
    • 拿插入排序来说,插入排序的时间复杂度我们都说是O(n^2) 。

      输入数据的形式对程序运算时间是有很大影响的,在数据本来有序的情况下时间复杂度是O(n),但如果数据是逆序的话,插入排序的时间复杂度就是O(n^2),也就对于所有输入情况来说,最坏是O(n^2) 的时间复杂度,所以称插入排序的时间复杂度为O(n^2)。

      同样的同理再看一下快速排序,都知道快速排序是O(nlogn),但是当数据已经有序情况下,快速排序的时间复杂度是O(n^2) 的,所以严格从大O的定义来讲,快速排序的时间复杂度应该是O(n^2)

      但是我们依然说快速排序是O(nlogn)的时间复杂度,这个就是业内的一个默认规定,这里说的O代表的就是一般情况,而不是严格的上界

    • 面试中说道算法的时间复杂度是多少指的都是一般情况。但是如果面试官和我们深入探讨一个算法的实现以及性能的时候,就要时刻想着数据用例的不一样,时间复杂度也是不同的,这一点是一定要注意的。

  • 不同数据规模的差异
    • 因为大O就是数据量级突破一个点且数据量级非常大的情况下所表现出的时间复杂度,这个数据量也就是常数项系数已经不起决定性作用的数据量
    • 所以我们说的时间复杂度都是省略常数项系数的,是因为一般情况下都是默认数据规模足够的大,基于这样的事实,给出的算法时间复杂的的一个排行如下所示

      O(1)常数阶 < O(logn)对数阶 < O(n)线性阶 < O(nlogn)线性对数阶 < O(n^2)平方阶 < O(n^3)立方阶 < O(2^n)指数阶

      但是也要注意大常数,如果这个常数非常大,例如10^7 ,10^9 ,那么常数就是不得不考虑的因素了。

  • 复杂表达式的化简
    • O(2*n^2 + 10*n + 1000)简化为O(n^2)
  • O(log n)中的log是以什么为底?
    • 平时说这个算法的时间复杂度是logn的,那么一定是log 以2为底n的对数么?

      其实不然,也可以是以10为底n的对数,也可以是以20为底n的对数,但我们统一说 logn,也就是忽略底数的描述

  • 举一个例子
    • 题目描述:找出n个字符串中相同的两个字符串(假设这里只有两个相同的字符串)。
  • O(n)的算法居然超时了,此时的n究竟是多大?

    • 大家在leetcode上练习算法的时候应该都遇到过一种错误是“超时”。

      也就是说程序运行的时间超过了规定的时间,一般OJ(online judge)的超时时间就是1s,也就是用例数据输入后最多要1s内得到结果,暂时还不清楚leetcode的判题规则,下文为了方便讲解,暂定超时时间就是1s。

      如果写出了一个$O(n)$的算法 ,其实可以估算出来n是多大的时候算法的执行时间就会超过1s了。

      如果n的规模已经足够让$O(n)$的算法运行时间超过了1s,就应该考虑log(n)的解法了。

    • 从硬件配置看计算机的性能

      • 计算机的运算速度主要看CPU的配置,以2015年MacPro为例,CPU配置:2.7 GHz Dual-Core Intel Core i5 。

        也就是 2.7 GHz 奔腾双核,i5处理器,GHz是指什么呢,1Hz = 1/s,1Hz 是CPU的一次脉冲(可以理解为一次改变状态,也叫时钟周期),称之为为赫兹,那么1GHz等于多少赫兹呢

      • 1GHz(兆赫)= 1000MHz(兆赫)
      • 1MHz(兆赫)= 1百万赫兹
      • 所以 1GHz = 10亿Hz,表示CPU可以一秒脉冲10亿次(有10亿个时钟周期),这里不要简单理解一个时钟周期就是一次CPU运算。

      • 所以我们的程序在计算机上究竟1s真正能执行多少次操作呢?

      • #完整测试代码
        #include <iostream>
        #include <chrono>
        #include <thread>
        using namespace std;
        using namespace chrono;
        // O(n)
        void function1(long long n) {
            long long k = 0;
            for (long long i = 0; i < n; i++) {
                k++;
            }
        }
        
        // O(n^2)
        void function2(long long n) {
            long long k = 0;
            for (long long i = 0; i < n; i++) {
                for (long j = 0; j < n; j++) {
                    k++;
                }
            }
        
        }
        // O(nlogn)
        void function3(long long n) {
            long long k = 0;
            for (long long i = 0; i < n; i++) {
                for (long long j = 1; j < n; j = j*2) { // 注意这里j=1
                    k++;
                }
            }
        }
        int main() {
            long long n; // 数据规模
            while (1) {
                cout << "输入n:";
                cin >> n;
                milliseconds start_time = duration_cast<milliseconds >(
                    system_clock::now().time_since_epoch()
                );
                function1(n);
        //        function2(n);
        //        function3(n);
                milliseconds end_time = duration_cast<milliseconds >(
                    system_clock::now().time_since_epoch()
                );
                cout << "耗时:" << milliseconds(end_time).count() - milliseconds(start_time).count()
                    <<" ms"<< endl;
            }
        }
        

递归算法的时间复杂度


面试题:求x的n次方

int function1(int x, int n) {
    int result = 1;  // 注意 任何数的0次方等于1
    for (int i = 0; i < n; i++) {
        result = result * x;
    }
    return result;
}
时间复杂度:O(n)
空间复杂度:O(1)

int function3(int x, int n) {
    if (n == 0) return 1;
    if (n == 1) return x;

    if (n % 2 == 1) {
        return function3(x, n / 2) * function3(x, n / 2)*x;
    }
    return function3(x, n / 2) * function3(x, n / 2);
}

当前这棵二叉树就是求x的n次方,n为16的情况,n为16的时候,进行了多少次乘法运算呢?

这棵树上每一个节点就代表着一次递归并进行了一次相乘操作,所以进行了多少次递归的话,就是看这棵树上有多少个节点。

熟悉二叉树话应该知道如何求满二叉树节点数量,这棵满二叉树的节点数量就是2^3 + 2^2 + 2^1 + 2^0 = 15,可以发现:这其实是等比数列的求和公式,这个结论在二叉树相关的面试题里也经常出现。

这么如果是求x的n次方,这个递归树有多少个节点呢,如下图所示:(m为深度,从0开始)

             时间复杂度忽略掉常数项-1之后,这个递归算法的时间复杂度依然是O(n)。对,你没看错,依然是O(n)的时间复杂度!

int function4(int x, int n) {
    if (n == 0) return 1;
    if (n == 1) return x;
    int t = function4(x, n / 2);// 这里相对于function3,是把这个递归操作抽取出来
    if (n % 2 == 1) {
        return t * t * x;
    }
    return t * t;
}

再来看一下现在这份代码时间复杂度是多少呢?

依然还是看他递归了多少次,可以看到这里仅仅有一个递归调用,且每次都是n/2 ,所以这里我们一共调用了log以2为底n的对数次。

每次递归了做都是一次乘法操作,这也是一个常数项的操作,那么这个递归算法的时间复杂度才是真正的O(logn)

什么是空间复杂度呢?

  • 是对一个算法在运行过程中占用内存空间大小的量度,记做S(n)=O(f(n)。

    空间复杂度(Space Complexity)记作S(n) 依然使用大O来表示。利用程序的空间复杂度,可以对程序运行中需要多少内存有个预先估计。

  • 空间复杂度是考虑程序(可执行文件)的大小么?
    • 很多同学都会混淆程序运行时内存大小和程序本身的大小。这里强调一下空间复杂度是考虑程序运行时占用内存的大小,而不是可执行文件的大小。
  • 空间复杂度是准确算出程序运行时所占用的内存么?
    • 不要以为空间复杂度就已经精准的掌握了程序的内存使用大小,很多因素会影响程序真正内存使用大小,例如编译器的内存对齐,编程语言容器的底层实现等等这些都会影响到程序内存的开销。

      所以空间复杂度是预先大体评估程序内存使用的大小。

      说到空间复杂度,我想同学们在OJ(online judge)上应该遇到过这种错误,就是超出内存限制,一般OJ对程序运行时的所消耗的内存都有一个限制。

      为了避免内存超出限制,这也需要我们对算法占用多大的内存有一个大体的预估。

      同样在工程实践中,计算机的内存空间也不是无限的,需要工程师对软件运行时所使用的内存有一个大体评估,这都需要用到算法空间复杂度的分析。

    • int j = 0;
      for (int i = 0; i < n; i++) {
          j++;
      }
      
      

      第一段代码可以看出,随着n的变化,所需开辟的内存空间并不会随着n的变化而变化。即此算法空间复杂度为一个常量,所以表示为大O(1)。

    • 当消耗空间和输入参数n保持线性增长,这样的空间复杂度为O(n)

    • int* a = new int(n);
      for (int i = 0; i < n; i++) {
          a[i] = i;
      }
      

      我们定义了一个数组出来,这个数组占用的大小为n,虽然有一个for循环,但没有再分配新的空间,因此,这段代码的空间复杂度主要看第一行即可,随着n的增大,开辟的内存大小呈线性增长,即 O(n)。

    • 递归的时候,会出现空间复杂度为logn的情况

递归算法的时间与空间复杂度分析


递归求斐波那契数列的性能分析

int fibonacci(int i) {
       if(i <= 0) return 0;
       if(i == 1) return 1;
       return fibonacci(i-1) + fibonacci(i-2);
}

 什么是斐波那契数列?

 

7.6 斐波那契数列通项公式和平衡二叉树查找时间复杂度证明 考研《数据结构C语言版》严蔚敏知识点讲解_哔哩哔哩_bilibili

时间复杂度指的是最坏时间复杂度:平衡二叉树的高度

深度为h有n个结点的平衡二叉树

深度为h平衡二叉树最少结点数

 

 

 

平衡二叉树的调整:

一秒学会 平衡二叉树的调整,非标题党!不简单你打我! (考研数据结构)_哔哩哔哩_bilibili

 什么是平衡二叉树:【数据结构】五分钟告诉你什么是平衡二叉树 ?什么是二叉排序树?平衡因子的计算等等 干货满满!_哔哩哔哩_bilibili

求斐波那契数的递归写法
int fibonacci(int i) {
       if(i <= 0) return 0;
       if(i == 1) return 1;
       return fibonacci(i-1) + fibonacci(i-2);
}
该递归算法的时间复杂度为O(2^n)
所以这种求斐波那契数的算法看似简洁,其实时间复杂度非常高,一般不推荐这样来实现斐波那契。
其实罪魁祸首就是这里的两次递归,导致了时间复杂度以指数上升。
return fibonacci(i-1) + fibonacci(i-2);

// 版本二
int fibonacci(int first, int second, int n) {
    if (n <= 0) {
        return 0;
    }
    if (n < 3) {
        return 1;
    }
    else if (n == 3) {
        return first + second;
    }
    else {
        return fibonacci(second, first + second, n - 1);
    }
}
这里相当于用first和second来记录当前相加的两个数值,此时就不用两次递归了。
因为每次递归的时候n减1,即只是递归了n次,所以时间复杂度是 O(n)。
同理递归的深度依然是n,每次递归所需的空间也是常数,所以空间复杂度依然是O(n)。
代码(版本二)的复杂度如下:
时间复杂度:O(n)
空间复杂度:O(n)

递归算法的时间复杂度本质上是要看: 递归的次数 * 每次递归的时间复杂度。
递归算法的空间复杂度 = 每次递归的空间复杂度 * 递归深度

为什么要求递归的深度呢?

因为每次递归所需的空间都被压到调用栈里(这是内存管理里面的数据结构,和算法里的栈原理是一样的),一次递归结束,这个栈就是就是把本次递归的数据弹出去。所以这个栈最大的长度就是递归的深度。

此时可以分析这段递归的空间复杂度,从代码中可以看出每次递归所需要的空间大小都是一样的,所以每次递归中需要的空间是一个常量,并不会随着n的变化而变化,每次递归的空间复杂度就是$O(1)$。

在看递归的深度是多少呢?如图所示:

递归第n个斐波那契数的话,递归调用栈的深度就是n。

那么每次递归的空间复杂度是O(1), 调用栈深度为n,所以这段递归代码的空间复杂度就是O(n)。

int fibonacci(int i) {
       if(i <= 0) return 0;
       if(i == 1) return 1;
       return fibonacci(i-1) + fibonacci(i-2);
}

 可以看出,求斐波那契数的时候,使用递归算法并不一定是在性能上是最优的,但递归确实简化的代码层面的复杂度。

二分法(递归实现)的性能分析

int binary_search( int arr[], int l, int r, int x) {
    if (r >= l) {
        int mid = l + (r - l) / 2;
        if (arr[mid] == x)
            return mid;
        if (arr[mid] > x)
            return binary_search(arr, l, mid - 1, x);
        return binary_search(arr, mid + 1, r, x);
    }
    return -1;
}

都知道二分查找的时间复杂度是O(logn),那么递归二分查找的空间复杂度是多少呢?

我们依然看 每次递归的空间复杂度和递归的深度

每次递归的空间复杂度可以看出主要就是参数里传入的这个arr数组,但需要注意的是在C/C++中函数传递数组参数,不是整个数组拷贝一份传入函数而是传入的数组首元素地址。

也就是说每一层递归都是公用一块数组地址空间的,所以 每次递归的空间复杂度是常数即:O(1)。

再来看递归的深度,二分查找的递归深度是logn ,递归深度就是调用栈的长度,那么这段代码的空间复杂度为 1 * logn = O(logn)。

大家要注意自己所用的语言在传递函数参数的时,是拷贝整个数值还是拷贝地址,如果是拷贝整个数值那么该二分法的空间复杂度就是O(nlogn)。

代码的内存消耗


  • C/C++这种内存堆空间的申请和释放完全靠自己管理

C++的内存管理

以C++为例来介绍一下编程语言的内存管理。

如果我们写C++的程序,就要知道栈和堆的概念,程序运行时所需的内存空间分为 固定部分,和可变部分,如下:

固定部分的内存消耗 是不会随着代码运行产生变化的, 可变部分则是会产生变化的

更具体一些,一个由C/C++编译的程序占用的内存分为以下几个部分:

  • 栈区(Stack) :由编译器自动分配释放,存放函数的参数值,局部变量的值等,其操作方式类似于数据结构中的栈。
  • 堆区(Heap) :一般由程序员分配释放,若程序员不释放,程序结束时可能由OS收回
  • 未初始化数据区(Uninitialized Data): 存放未初始化的全局变量和静态变量
  • 初始化数据区(Initialized Data):存放已经初始化的全局变量和静态变量
  • 程序代码区(Text):存放函数体的二进制代码

代码区和数据区所占空间都是固定的,而且占用的空间非常小,那么看运行时消耗的内存主要看可变部分。

在可变部分中,栈区间的数据在代码块执行结束之后,系统会自动回收,而堆区间数据是需要程序员自己回收,所以也就是造成内存泄漏的发源地。

如何计算程序占用多大内存

想要算出自己程序会占用多少内存就一定要了解自己定义的数据类型的大小,如下:

注意图中有两个不一样的地方,为什么64位的指针就占用了8个字节,而32位的指针占用4个字节呢?

1个字节占8个比特,那么4个字节就是32个比特,可存放数据的大小为2^32,也就是4G空间的大小,即:可以寻找4G空间大小的内存地址。

大家现在使用的计算机一般都是64位了,所以编译器也都是64位的。

安装64位的操作系统的计算机内存都已经超过了4G,也就是指针大小如果还是4个字节的话,就已经不能寻址全部的内存地址,所以64位编译器使用8个字节的指针才能寻找所有的内存地址。

注意2^64是一个非常巨大的数,对于寻找地址来说已经足够用了。

内存对齐

为什么会有内存对齐?

主要是两个原因

  1. 平台原因:不是所有的硬件平台都能访问任意内存地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。为了同一个程序可以在多平台运行,需要内存对齐。

  2. 硬件原因:经过内存对齐后,CPU访问内存的速度大大提升。

来看一下内存对齐和非内存对齐产生的效果区别。

CPU读取内存不是一次读取单个字节,而是一块一块的来读取内存,块的大小可以是2,4,8,16个字节,具体取多少个字节取决于硬件。

假设CPU把内存划分为4字节大小的块,要读取一个4字节大小的int型数据,来看一下这两种情况下CPU的工作量:

第一种就是内存对齐的情况,如图:

 

char型的数据和int型的数据挨在一起,该int数据从地址1开始,那么CPU想要读这个数据的话来看看需要几步操作:

  1. 因为CPU是四个字节四个字节来寻址,首先CPU读取0,1,2,3处的四个字节数据
  2. CPU读取4,5,6,7处的四个字节数据
  3. 合并地址1,2,3,4处四个字节的数据才是本次操作需要的int数据

此时一共需要两次寻址,一次合并的操作。

大家可能会发现内存对齐岂不是浪费的内存资源么?

是这样的,但事实上,相对来说计算机内存资源一般都是充足的,我们更希望的是提高运行速度。

编译器一般都会做内存对齐的优化操作,也就是说当考虑程序真正占用的内存大小的时候,也需要认识到内存对齐的影响

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值