【本节目标】
1.什么是时间复杂度和空间复杂度?
2.如何计算常见算法的时间复杂度和空间复杂度?
3.有复杂度要求的算法题练习
正文开始
1.什么是时间复杂度和空间复杂度?
1.1算法效率
算法效率分析分为两种:
第一种是时间效率,第二种是空间效率。
时间效率被称为时间复杂度, 而空间效率被称作空间复杂度。
时间复杂度主要衡量的是一个算法的运行速度,而空间复杂度主要衡量一个算法所需要的额外空间,在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。 所以我们如今已经不需要再特别关注一个算法的空间复杂度。
1.2 时间复杂度的概念
时间复杂度的定义:
在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比,算法中的基本操作的执行次数,为算法的时间复杂度。
1.3 空间复杂度的概念
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。空间复杂度不是程序占用了多少bytes(字节)的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。
2.如何计算常见算法的时间复杂度
其实这个时间复杂度跟你的电脑配置(硬件)有一定关系:一般来说配置高的时间复杂度就会相对而言小一些,而配置低的时间复杂度就会大一些;因此,我们在计算时间复杂度的时候,计算的是程序运行的次数,这样我们就可以通过代码运行次数间接的比较时间复杂度的大小。
2.1大O的渐进表示法
// 请计算一下Func1基本操作执行了多少次?
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N; ++i)
{
for (int j = 0; j < N; ++j)
{
++count;
}
}
for (int k = 0; k < 2 * N; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
Func1 执行的基本操作次数 :
N^2、2*N、10分别表示这三条循环语句的循环次数。
当N增大时:
N = F(10N) = 130
N = F(100N) = 10210
N = F(1000N) = 1002010
实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法。
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O阶的方法:
1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
时间复杂度是一个估算,是去看表达式中影响最大的那一项,随着N的增大,N^2对F(N)表达式中的影响是最大的,因此Func1的时间复杂度为:
N = F(10N) = 100
N = F(100N) = 10000
N = F(1000N) = 1000000
通过上面我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)【O(N)】
平均情况:任意输入规模的期望运行次数(平均)【O(N/2)】
最好情况:任意输入规模的最小运行次数(下界)【O(1)】
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N / 2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
2.2常见时间复杂度计算举例
实例1
例题1:计算Func2的时间复杂度
// 计算Func2的时间复杂度?
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);
}
这么大致一看,Func2的时间复杂度貌似是O(2N+10),但从括号里面的式子可以看出,N前面的常数2以及后面的常数10对最后的结果影响并不大,且N为最高阶,前面的项数不是1,根据推导大O阶的方法可以得知,常数2和常数10就要去掉,因此最后Func1的时间复杂度为:
实例2
例题2:计算Func3的时间复杂度
// 计算Func3的时间复杂度?
void Func3(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阶的方法,我们可以得知Func3的时间复杂度为:
因为N跟M都是最高阶,而且前面的常数为1,N和M的值都随着本身的增大而增大。
但如果题目给出了M>>N(M远大于N)这个条件的话,Func3的时间复杂度就会变成:
因为M对M+N这个式子的影响更大,而N对这个式子的影响就可以忽略了(M远大于N)。
实例3
例题3:计算Func4的时间复杂度
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
Func4的时间复杂度为:
为什么是O(1)?
因为k是一个确定的常数次(最大为100),改变N的值对k没有任何影响,因此通过这道题我们可以得出一个结论:
只要操作的次数是一个确定的常数次,不管这个常数的大小是多少,时间复杂度统统都算O(1)。
实例4
例题4:计算strchr的时间复杂度
// 计算strchr的时间复杂度?
const char* strchr(const char* str, char character)
{
while (*str != '\0')
{
if (*str == 's')
//若str找到了想在字符串中找到的字符,则返回str当前的值
return str;
++str;
//str指向下一个字符
}
return NULL;
}
这道题的时间复杂度就要分情况来考虑了:
当我们想寻找字符串中的某个字符时,可能在第一次找到,也可能在最后一次找到,还可能在中间或者其他什么地方找到,因此就要分为最好、最坏和平均三种情况。如果我们假设字符串的长度为N的话,其时间复杂度就有O(1)、O(N)、O(N/2)三种情况,但是我们规定,当一个算法的时间复杂度存在这三种情况时,我们关注的就是其最坏的情况,也就是O(N),因此strchr的时间复杂度为:
这里我们可以简单的归纳一下O(N)、O(1)这两个时间复杂度的区别在哪儿:
- O(1) :说明该算法的效率不变
- O(N) :说明该算法的效率随着N的值变化而变化(最坏的打算)
实例5
例题5: 计算BubbleSort的时间复杂度
// 计算BubbleSort的时间复杂度?
//冒泡排序(从小到大)
void BubbleSort(int* a, int n)
{
//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;
}
}
这道题就要涉及到次数的计算了:
第一次排序:N-1次(交换的次数)
第二次排序:N-2次
第三次排序:N-3次
…
第n次排序:1次
因此需要排序的次数为一个等差数列,所以总共要排[(N-1+1)*N]/2
等于(N^2)/2
次,因此最后BubbleSort的时间复杂度为:
因此我们在计算时间复杂度的时候要注意:
不是一层循环就是O(N),两层循环就是O(N^2),具体要看程序(具体情况具体分析).
实例6
例题6:计算阶乘递归Factorial的时间复杂度
// 计算阶乘递归Factorial的时间复杂度?
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N - 1) * N;
}
因为我们时间复杂度计算的是算法的运算次数,由例题6可知,假设我们要计算N的阶乘,就要调用函数N次,因此时间复杂度就为:
还有一个计算二分查找的时间复杂度的例题,我将放在另一篇博客里面细讲,链接如下:
2.3常见时间复杂度的对比
由图可知,O(1)随着元素数量(长度)的增加,其时间复杂度根本不变,因此时间复杂度为O(1)的算法为最优。
3.如何计算常见算法的空间复杂度
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。
3.1常见空间复杂度计算举例
实例7
例题7:计算冒泡法排序的空间复杂度
// 计算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;
}
}
由实例1我们看到,其创建的变量一共有5个(函数的形参a和b,无字符型变量end,i和整型变量exchange),也就是开辟了5个额外空间,因此变量的个数为一个常数,所以空间复杂度为O(1)。
可能这里有人会问,不是每循环一次就会开辟一个空间吗?这里我们要记住一个点:
时间会累计,空间不会累计。
什么意思呢,比如说for循环要循环N次,因此时间复杂度就是O(N),因为每循环一次,时间都会被消耗,而空间是可以重复利用的,一个变量使用完一块空间后可以接着给另一个变量来使用,所以空间复杂就是O(1)。
实例8
例题8:计算斐波那契数的空间复杂度
// 计算Fibonacci的空间复杂度?
long long* Fibonacci(size_t n)
{
if (n == 0)
return NULL;
long long* fibArray = (long long*)malloc((n + 1) * sizeof(long long));
//malloc()括号里面为多少就意味开辟多少空间
fibArray[0] = 0;
fibArray[1] = 1;
for (int i = 2; i <= n; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray[i - 2];
}
return fibArray;
}
不清楚malloc函数的朋友可以看看我的另一篇博客,里面对malloc等动态开辟内存函数都做了详细的介绍,链接如下:
[https://juejin.cn/post/7067937445114806308#heading-3]
乍一看,例题2里面好像只有5个变量,即size_t n
、long long* fibArry
、fibArry[0]
、fibArry[1]
和int i
,但是我们要注意到,malloc函数又开辟了N+1块空间,因此例题2总共额外开辟了N+1+5=N+6
块空间,因此空间复杂度就是O(N+6),但又因为随着N的增大,常数6对空间复杂度的大小影响会变得越来越小,所以常数6就要舍弃,因此例题6的空间复杂度就为O(N)
实例9
例题9:计算阶乘递归Factorial的空间复杂度
// 计算阶乘递归Factorial的空间复杂度?
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N - 1) * N;
}
计算一个数的阶乘,如果用递归来写的话,比如说计算的N阶乘,这个递归就要运行N次,每递归一次都要开辟一个额外空间:
递归调用N次时,每次调用都要建立一个栈帧,而每个栈帧都使用了常数个空间->空间复杂度为O(1),N个栈帧就使用了N个空间->空间复杂度为O(N),虽然这些空间在建立完以后会从后到前逐个销毁,但在建立时毕竟还是用过了这么多空间,而空间复杂度计算的就是使用过的空间的个数,因此例题9的空间复杂度为O(N)
4.有复杂度要求的算法题练习
最后还有两道经典的面试题,都有时间复杂度和空间复杂度的要求,这里就不做太多描述了,详情请见如下链接:
经典面试题(2):消失的数字(规定时间复杂度)
经典面试题(3):旋转数组(规定空间复杂度)
二分查找的介绍及其时间复杂度问题
本篇的内容到这里就结束了,如果你觉得对你多少有点帮助的话可以点赞支持一波哦,欢迎大佬在评论区批评指正,咱们下次再见!