一.时间复杂度
1.1什么是时间复杂度
时间复杂度是一个函数,它定性描述该算法的运行时间。
假设算法的问题规模为n,那么操作单元数量便用函数f(n)来表示,随着数据规模n的增大,算法执行时间的增长率和f(n)的增长率相同,这称作为算法的渐近时间复杂度,简称时间复杂度,记为 O(f(n))。
1.2什么是大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代表的就是一般情况,而不是严格的上界。
![](https://i-blog.csdnimg.cn/blog_migrate/f4ee98fea6257c9f40830d079f9462d4.png)
面试中说道算法的时间复杂度是多少指的都是一般情况。但是如果面试官和我们深入探讨一个算法的实现以及性能的时候,就要时刻想着数据用例的不一样,时间复杂度也是不同的,这一点是一定要注意的。
1.3时间复杂度排行
因为大O就是数据量级突破一个点且数据量级非常大的情况下所表现出的时间复杂度,这个数据量也就是常数项系数已经不起决定性作用的数据量。
所以我们说的时间复杂度都是省略常数项系数的,是因为一般情况下都是默认数据规模足够的大,基于这样的事实,给出的算法时间复杂的的一个排行如下所示:
O(1)常数阶<O(logn)对数阶< O(n)线性阶<线性对数阶O(nlogn)<O(n^2)平方阶< O(2^n)指数阶
1.4时间复杂度表达式的简化
①去掉运行时间中的加法常数项 (因为常数项并不会因为n的增大而增加计算机的操作次数)
②去掉常数系数(上文中已经详细讲过为什么可以去掉常数项的原因)
③只保留保留最高项,去掉数量级小一级的n (因为n^2 的数据规模远大于n)
例如将O(n^2 + n)简化为O(n^2)
或者可以提取n,变成O(n(n+1)) ,省略加法常数项后也就别变成了:O(n^2)
1.5 O(logn)中的log是以什么为底?
平时说这个算法的时间复杂度是logn的,那么一定是log 以2为底n的对数么?
其实不然,也可以是以10为底n的对数,也可以是以20为底n的对数,但我们统一说 logn,也就是忽略底数的描述。
为什么可以这么做呢?如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/5146667c4d6e6c13cfb5dc3196dc94df.png)
在上文已经讲述了我们计算时间复杂度是忽略常数项系数的。抽象一下就是在时间复杂度的计算过程中,log以i为底n的对数等于log 以j为底n的对数,所以忽略了i,直接说是log n。
1.6递归算法的时间复杂度分析
假设有一个面试题:求x的n次方
想一下这么简单的一道题目,代码应该如何写呢。最直观的方式应该就是,一个for循环求出结果,代码如下:
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;
}
面试官问:“那么这个代码的时间复杂度是多少?”。
递归算法的时间复杂度本质上是要看: 递归的次数 * 每次递归中的操作次数。
每次n-1,递归了n次时间复杂度是O(n),每次进行了一个乘法操作,乘法操作的时间复杂度一个常数项O(1),所以这份代码的时间复杂度是 n × 1 = O(n)。
这个时间复杂度就没有达到面试官的预期。于是又写出了如下的递归算法的代码:
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;//2个递归调用
}
return function3(x, n / 2) * function3(x, n / 2);
}
我们来分析一下这份代码的时间复杂度,首先看递归了多少次呢,可以把递归抽象出一棵满二叉树。刚刚同学写的这个算法,可以用一棵满二叉树来表示(为了方便表示,选择n为偶数16),如图:
![](https://i-blog.csdnimg.cn/blog_migrate/79ce652a01792e3cb552d317bb7788c0.png)
这棵树上每一个节点就代表着一次递归并进行了一次相乘操作,所以进行了多少次递归的话,就是看这棵树上有多少个节点。
熟悉二叉树话应该知道如何求满二叉树节点数量,这棵满二叉树的节点数量就是2^3 + 2^2 + 2^1 + 2^0 = 15,可以发现:这其实是等比数列的求和公式,这个结论在二叉树相关的面试题里也经常出现。
这么如果是求x的n次方,这个递归树有多少个节点呢,如下图所示:(m为深度,从0开始)
![](https://i-blog.csdnimg.cn/blog_migrate/72a1d4630b3ac1a95b272ea0ad2f2135.png)
时间复杂度忽略掉常数项-1之后,这个递归算法的时间复杂度依然是O(n)。此时面试官就会说:“这个递归的算法依然还是O(n)啊”, 很明显没有达到面试官的预期。
于是又写出如下递归算法的代码:
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)。
二.空间复杂度
2.1什么是空间复杂度
空间复杂度是对一个算法在运行过程中占用内存空间大小的度量,记做S(n)=O(f(n))
空间复杂度(Space Complexity)记作S(n) 依然使用大O来表示。利用程序的空间复杂度,可以对程序运行中需要多少内存有个预先估计。
注:这里强调一下空间复杂度是考虑程序运行时占用内存的大小,而不是可执行文件的大小。
空间复杂度O(1)举例:
int j = 0;
for (int i = 0; i < n; i++) {
j++;
}
随着n的变化,所需开辟的内存空间并不会随着n的变化而变化。即此算法空间复杂度为一个常量,所以表示为大O(1)。
空间复杂度O(n)举例:
int* a = new int(n);
for (int i = 0; i < n; i++) {
a[i] = i;
}
当消耗空间和输入参数n保持线性增长,这样的空间复杂度为O(n)。
空间复杂度是O(log n)的情况确实有些特殊,在递归的时候,会出现空间复杂度为O(log n)的情况。
2.2递归算法的空间复杂度分析
递归求斐波那契数列的性能分析
先来看一下求斐波那契数的递归写法。
版本一:递归算法
int fibonacci(int i) {
if(i <= 0) return 0;
if(i == 1) return 1;
return fibonacci(i-1) + fibonacci(i-2);
}
在讲解递归时间复杂度的时候,我们提到了递归算法的时间复杂度本质上是要看: 递归的次数 * 每次递归的时间复杂度。
可以看出上面的代码每次递归都是O(1)的操作。再来看递归了多少次,这里将i为5作为输入的递归过程 抽象成一棵递归树,如图:
![](https://i-blog.csdnimg.cn/blog_migrate/f9ff63725e3b0344842dc5e7b17c457f.png)
在这棵二叉树中每一个节点都是一次递归,我们之前也有说到,一棵深度(按根节点深度为1)为k的二叉树最多可以有 2^k - 1 个节点。所以该递归算法的时间复杂度为O(2^n),这个复杂度是非常大的,随着n的增大,耗时是指数上升的。
说完了这段递归代码的时间复杂度,再看看如何求其空间复杂度呢
递归算法的空间复杂度 = 每次递归的空间复杂度 * 递归深度
因为每次递归所需的空间都被压到调用栈里(这是内存管理里面的数据结构,和算法里的栈原理是一样的),一次递归结束,这个栈就是就是把本次递归的数据弹出去。所以这个栈最大的长度就是递归的深度。
此时可以分析这段递归的空间复杂度,从代码中可以看出每次递归所需要的空间大小都是一样的,所以每次递归中需要的空间是一个常量,并不会随着n的变化而变化,每次递归的空间复杂度就是O(1)。
递归的深度是多少呢?如图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/74e6050beb96d79c2045a306c565cdc1.png)
递归第n个斐波那契数的话,递归调用栈的深度就是n。
那么每次递归的空间复杂度是O(1), 调用栈深度为n,所以这段递归代码的空间复杂度就是O(n)。
可不可以优化一下这个递归算法呢?优化的方向 主要是减少递归的调用次数。
版本二:优化递归算法
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)。
最后对各种求斐波那契数列方法的性能做一下分析,如图:
![](https://i-blog.csdnimg.cn/blog_migrate/fd5f39b8bc013b4297957b112f7528f4.png)
可以看出,求斐波那契数的时候,使用递归算法并不一定是在性能上是最优的,但递归确实简化的代码层面的复杂度。
二分法(递归实现)的性能分析
带大家再分析一段二分查找的递归实现。
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)。
三.以空间换时间是常见的优化思路
直白地说就是:消耗更多的内存来让程序更快地计算出结果。
在哈希法中,使用数组、set、map等容器无一例外都基于以空间换时间的思路,时间复杂度一般都是O(1)级别。先将集合中的数据放进容器,然后通过哈希索引的方式快速找到某个元素是否出现在这个集合中。