算法性能分析

181 阅读8分钟

1 时间复杂度分析

1.1 什么是时间复杂度

时间复杂度是一个函数,它定性描述该算法的运行时间。 假设算法的问题规模为n,那么操作单元数量便用函数f(n)来表示,随着数据规模n的增大,算法执行时间的增长率和f(n)的增长率相同,这称作为算法的渐近时间复杂度,简称时间复杂度,记为 O(f(n))。

1.2 什么是大O

大家都知道O(n),O(n^2),却说不清什么是大O。 算法导论给出的解释:大O用来表示上界的,当用它作为算法的最坏情况运行时间的上界,就是对任意数据输入的运行时间的上界。

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

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

我们主要关心的还是一般情况下的数据形式。

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

1.3 不同数据规模的差异

不同算法的时间复杂度在不同数据输入规模下的差异。

image.png

计算时间复杂度的时候要忽略常数项系数呢?

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

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

1.4 复杂表达式的化简

eg.

O(2*n^2 + 10*n + 1000)
  1. 去掉运行时间中的加法常数项 (因为常数项并不会因为n的增大而增加计算机的操作次数)。O(2 * n^2 + 10 * n)
  2. 去掉运行时间中的加法常数项 (因为常数项并不会因为n的增大而增加计算机的操作次数)。O(n^2 + n)
  3. 只保留保留最高项,去掉数量级小一级的n (因为n^2 的数据规模远大于n),最终简化成 O(n^2)

1.5 O(logn)中的log是以什么为底?

一定是log 以2为底n的对数么?

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

1.6 举一个例子

找出n个字符串中相同的两个字符串(假设这里只有两个相同的字符串)。 🍕如果是暴力枚举的话,时间复杂度是多少呢,是O(n^2)么? 忽略了字符串比较的时间消耗 除了n^2 次的遍历次数外,字符串比较依然要消耗m次操作(m也就是字母串的长度),所以时间复杂度是O(m × n × n)。

🍕其他解题思路 先排对n个字符串按字典序来排序,排序后n个字符串就是有序的,意味着两个相同的字符串就是挨在一起,然后在遍历一遍n个字符串,这样就找到两个相同的字符串了。

快速排序时间复杂度为O(nlogn),依然要考虑字符串的长度是m,那么快速排序每次的比较都要有m次的字符比较的操作,就是O(m × n × log n) 。 之后还要遍历一遍这n个字符串找出两个相同的字符串,别忘了遍历的时候依然要比较字符串,所以总共的时间复杂度是 O(m × n × logn + n × m)。简化后时间复杂度是 O(m × n × log n)。 很明显O(m × n × logn) 要优于O(m × n × n)! 当然这不是这道题目的最优解,只是举例算时间复杂度。

2 算法为什么会超时

2.1 超时是怎么回事

一般OJ(online judge)的超时时间就是1s,也就是用例数据输入后最多要1s内得到结果

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

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

计算机的运算速度主要看CPU的配置 而且计算机的cpu也不会只运行我们自己写的程序上,同时cpu也要执行计算机的各种进程任务等等,我们的程序仅仅是其中的一个进程而已。

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

3 递归算法的时间复杂度

通过一道面试题,一个面试场景,来好好分析一下如何求递归算法的时间复杂度。

🍕 面试题:求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),此时面试官会说,有没有效率更好的算法呢。

如果此时没有思路,不要说:我不会,我不知道了等等。 可以和面试官探讨一下,询问:“可不可以给点提示”。面试官提示:“考虑一下递归算法”。

int function2(int x, int n) {
    if (n == 0) {
        return 1; // return 1 同样是因为0次方是等于1的
    }
    return function2(x, n - 1) * x;
}

可能一看到递归就想到了O(log n),其实并不是这样,递归算法的时间复杂度本质上是要看: 递归的次数 * 每次递归中的操作次数

每次n-1,递归了n次时间复杂度是O(n),每次进行了一个乘法操作,乘法操作的时间复杂度一个常数项O(1),所以这份代码的时间复杂度是 n × 1 = O(n)。

这个时间复杂度就没有达到面试官的预期。于是又写出了如下的递归算法的代码:

int function3(int x, int n) {
    if (n == 0) {
        return 1;
    }
    // 分 n 奇数偶数
    if (n % 2 == 1) {
        return function3(x, n / 2) * function3(x, n / 2)*x;
    }
    return function3(x, n / 2) * function3(x, n / 2);
}

分析一下,首先看递归了多少次呢,可以把递归抽象出一棵满二叉树。刚刚同学写的这个算法,可以用一棵满二叉树来表示(为了方便表示,选择n为偶数16),如图:

递归算法的时间复杂度

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

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

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

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

递归求时间复杂度

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

此时面试官就会说:“这个递归的算法依然还是O(n)啊”, 很明显没有达到面试官的预期。

那么O(logn)的递归算法应该怎么写呢?

想一想刚刚给出的那份递归算法的代码,是不是有哪里比较冗余呢,其实有重复计算的部分。

int function4(int x, int n) {
    if (n == 0) {
        return 1;
    }
    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)

补充: O(logn)的本质:输入规模翻倍,操作次数只增加一,每操作一次,需要处理的规模就小一半的

4 空间复杂度分析

什么是空间复杂度呢?

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

  1. 空间复杂度是考虑程序运行时占用内存的大小,而不是可执行文件的大小。
  2. 不要以为空间复杂度就已经精准的掌握了程序的内存使用大小,很多因素会影响程序真正内存使用大小

在OJ(online judge)上应该遇到过这种错误,就是超出内存限制,一般OJ对程序运行时的所消耗的内存都有一个限制。 同样在工程实践中,计算机的内存空间也不是无限的,需要工程师对软件运行时所使用的内存有一个大体评估,这都需要用到算法空间复杂度的分析。

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

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

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

其他的 O(n^2), O(n^3) 我想大家应该都可以以此例举出来了,那么思考一下 什么时候空间复杂度是 O(logn)呢?

空间复杂度是logn的情况确实有些特殊,其实是在递归的时候,会出现空间复杂度为logn的情况

5 递归算法的性能分析

通过求斐波那契数列和二分法再来深入分析一波递归算法的时间和空间复杂度 F (0)=0 F (1)=1 F (n)= F (n - 1)+ F (n - 2)( n ≥ 2, n ∈ N*)

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

5.1 时间复杂度分析

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

上面的代码每次递归都是O(1)的操作。再来看递归了多少次,这里将i为5作为输入的递归过程 抽象成一棵递归树,如图:

20210305093200104.png

从图中,可以看出f(5)是由f(4)和f(3)相加而来,那么f(4)是由f(3)和f(2)相加而来 以此类推。

在这棵二叉树中每一个节点都是一次递归,那么这棵树有多少个节点呢?

我们之前也有说到,一棵深度(按根节点深度为1)为k的二叉树最多可以有 2^k - 1 个节点。

所以该递归算法的时间复杂度为O(2^n),这个复杂度是非常大的,随着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)。

5.2 空间复杂度分析

递归算法的空间复杂度 = 每次递归的空间复杂度 * 递归深度 为什么要求递归的深度呢?

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

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

最后对各种求斐波那契数列方法的性能做一下分析,如题:

递归的空间复杂度分析

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

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

二分查找的递归实现

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/cpp中函数传递数组参数,不是整个数组拷贝一份传入而是传入的数组首元素地址也就是说每一层递归都是公用一块数组地址空间的,所以 每次递归的空间复杂度是常数即:O(1)。 再来看递归的深度,二分查找的递归深度是logn ,递归深度就是调用栈的长度,那么这段代码的空间复杂度为 1 * logn = O(logn)。

6 代码的内存消耗

6.1 不同语言的内存管理

  • C/C++这种内存堆空间的申请和释放完全靠自己管理
  • Java 依赖JVM来做内存管理,不了解jvm内存管理的机制,很可能会因一些错误的代码写法而导致内存泄漏或内存溢出
  • Python内存管理是由私有堆空间管理的,所有的python对象和数据结构都存储在私有堆空间中。程序员没有访问堆的权限,只有解释器才能操作。
  • JS中没有专门的内存管理接口,所有的内存管理都是自动的。

由于我主要是用js所以这里没有多看

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

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

C++数据类型的大小

为什么64位的指针就占用了8个字节,而32位的指针占用4个字节呢?

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

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

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

6.3 内存对齐

不要以为只有C/C++才会有内存对齐,只要可以跨平台的编程语言都需要做内存对齐,Java、Python都是一样的

什么是内存对齐。 元素是按照定义顺序一个一个放到内存中去的,但并不是紧密排列的。 从结构体存储的首地址开始,每个元素放置到内存中时,它都会认为内存是按照自己的大小(通常它为4或8)来划分的,因此元素放置的位置一定会在自己宽度的整数倍上开始,这就是所谓的内存对齐。

面试中面试官非常喜欢问到的问题,就是:为什么会有内存对齐?

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

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

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

内存对齐

一字节的char占用了四个字节,空了三个字节的内存地址,int数据从地址4开始。

此时,直接将地址4,5,6,7处的四个字节数据读取到即可。

第二种是没有内存对齐的情况如图:

非内存对齐

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

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

此时一共需要两次寻址,一次合并的操作。 大家可能会发现内存对齐岂不是浪费的内存资源么?

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

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

补充: JavaScript 内存管理 1 简介 像 C 语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()free()。相反,JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。

2 内存生命周期

不管什么程序语言,内存生命周期基本是一致的:

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放\归还

3 JavaScript 的内存分配

值的初始化:为了不让程序员费心分配内存,JavaScript 在定义变量时就完成了内存分配。

var n = 123; // 给数值变量分配内存
var s = "azerty"; // 给字符串分配内存

var o = {
  a: 1,
  b: null
}; // 给对象及其包含的值分配内存

通过函数调用分配内存:有些函数调用结果是分配对象内存

var d = new Date(); // 分配一个 Date 对象

有些方法分配新变量或者新对象:

var s = "azerty";
var s2 = s.substr(0, 3); // s2 是一个新的字符串