目录
- 算法的时空复杂度
- 算法效率
- 时间复杂度
- 概念
- 常见的时间复杂度
- 大O的渐进表示方法
- 推导大O阶的方法:
- 最好、最坏、平均复杂度
- 冒泡排序的时间复杂度
- 二分法查找的时间复杂度
- 递归的时间复杂度
- 递归法求斐波那契数的时间复杂度
- 空间复杂度
- 冒泡排序的空间复杂度
- 递归的空间复杂度
- 递归法求斐波那契数的空间复杂度
算法的时空复杂度
算法效率
衡量一个算法的好坏,看的不是代码的简洁程度,而是算法的效率,即算法的复杂度
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。
在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度
时间复杂度
概念
在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间
注意:这里的函数不是C语言里学的函数,是数学中带未知数的函数式
一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了下面的时间复杂度这个分析方式。
一个算法所花费的时间与其中语句的执行次数成正比例,所以时间复杂度最终计算的是算法中的基本操作的执行次数
常见的时间复杂度
O(1)<O(logN)<O(N)<O(N*logN)<O(N2)<O(2N)<O(N!)
这里对logN的写法做一些说明:
通常,如果底数为2时,2可以省略即写成logN,但底数为其他的时候则不可以省略
注意:有些地方也会将log2N写成lgN,但不建议这样写,容易和数学上的搞混
在数据结构的时间复杂度计算中,只有以2为底的,可以忽略底数。其他均不可以,但一般很少出现其他底数
大O的渐进表示方法
实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法
所谓渐进就是估算,是计算大概的执行次数所属的量级
推导大O阶的方法:
- 用常数1取代运行时间中的所有加法常数。
- 在修改后的运行次数函数中,只保留最高阶项。
为什么只保留最高阶呢?因为其他项对结果的影响不大。特别是随着执行次数N趋于无穷时
- 如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶
针对规则1,看下面这段代码:
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
这里的时间复杂度为O(1)
其实循环100次和循环100000000次消耗的时间几乎差不多
我们可以使用clock( )这个函数来验证一下
clock函数
作用:程序跑到当前代码行所用的时间
单位为毫秒,头文件为time.h
上面还是在debug版本下,没有经过优化的
如果在release版本下,则用时会更短
针对规则2、3,看下面这段代码:
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
这里的时间复杂度原本为:2N+10
按照规则2、3,最终时间时间复杂度为:O(N)
最好、最坏、平均复杂度
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
冒泡排序的时间复杂度
其时间复杂度为O(N2)
其实质上是一个等差数列求和
N-1+N-2+……+2+1=N*(N-1)/2
二分法查找的时间复杂度
其时间复杂度为O(logN)
二分查找,每次查找,查找的区间就缩小一半,所以查找了多少次就除以多少个2
即原来有N个数,查找一次除2,最终只剩下一个数了,这也就是最后一次查找
N/2/2/2……/2=1
假设查找了x次
N=1 * 2 * 2 * 2……* 2
N=2x
x=log2N
这里计算的是最坏时间复杂度
递归的时间复杂度
计算技巧:每次递归调用中的执行次数的累加
例如阶乘的递归,调用自身n次,每次调用执行次数为常数次,所以最终时间复杂度为O(N)
递归法求斐波那契数的时间复杂度
时间复杂度为:O(N2)
斐波那契数每次递归时,一个函数都会产生两个函数,如同细胞分裂一样,所以总的递归次数是等比求和,公比为2。而每次递归的执行次数都是常数次,所以最终时间复杂度为O(2N)
根据这个时间复杂度,可见递归算法求斐波那契数是一个效率很低的算法,没有什么实际意义。
因此,我们还有一个方法求斐波那契数:迭代算法。这个算法的时间复杂度为O(N)
空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度
空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义
所以空间复杂度最终计算的是因算法需要而额外开辟的空间
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了
但在递归函数中存在一些不同,这个后面会说到
冒泡排序的空间复杂度
其空间复杂度是O(1)
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(1)
递归的空间复杂度
技巧:每次递归调用的变量个数的累加
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
上述代码的空间复杂度为:O(N)
递归调用了N次,开辟了N个函数栈帧,每个栈帧使用了常数个空间。
而且这里和重复创建多个变量不同,那个变量是先销毁之前的,再创建新的,所以空间复杂度是O(1)。而这里的函数栈帧并不会因为新的函数栈帧的开辟而销毁上一次开辟的,因为每一次递归,两个函数栈帧之间是有联系的,如果将某层栈帧销毁后,那么下面的函数将无法完成“归”的操作。
尽管这里没有创建变量,但其实这里的形参就可以看做是变量,因为每次函数栈帧的创建都需要传这个参数
普通调用不需要计算形参的空间,但递归调用需要
递归法求斐波那契数的空间复杂度
空间复杂度为:O(N)
为什么会和时间复杂度不同呢?因为时间是累积的,一去不复返;而空间是可以重复利用的
当左侧的向下的递归调用全部结束后,函数栈帧由下往上一层一层销毁,再开始进行右侧的递归调用。但其实右侧的递归调用所开辟的函数栈帧是与左侧的函数栈帧是相同的,也就是说两侧所用的空间是相同的。
所以斐波那契数从N到2,会经历N-1次递归调用,那么就会产生N-1个函数栈帧,所以最终空间复杂度为O(N)
这里对于空间的重复使用,我们通过一段代码来直观看一下: