在学习具体的数据结构和算法之前,每一位初学者都要掌握一个技能,即善于运用时间复杂度和空间复杂度来衡量一个算法的运行效率。所谓算法,即解决问题的方法。同一个问题,使用不同的算法,虽然得到的结果相同,但耗费的时间和资源肯定有所差异。这也就意味着,如果解决问题的算法有多种,我们就需要从中选出最好的那一个。那么,怎么判断哪个算法更好(或者更优)呢?在满足准确性和健壮性的基础上,还有一个重要的筛选条件,即通过算法所编写出的程序的运行效率。程序的运行效率具体可以从 2 个方面衡量,分别为:程序的运行时间和程序运送所需的内存空间大小。本篇博客详细总结如何计算一个算法的时间复杂度和空间复杂度,根据代码的编写形式分为:非递归式(循环)和递归代码的时间复杂度,空间复杂度计算,根据出题的形式又可分为:给定代码计算时间复杂度和空间复杂度,或者给定题目请设计算法同时给出时间复杂度和空间复杂度;后者作为笔试面试的重点考查方式,需要重点掌握!
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。 时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外内存空间。根据算法编写出的程序,运行时间更短,运行期间占用的内存更少,该算法的运行效率就更高,算法也就更好。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计 算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。记作:T(n)=O(f(n)),其中f(n)是问题规模n的某个函数。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。O(语句总的执行次数),即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
理解概念的两个关键点:
1. 用计算语句总的执行次数,来度量时间的;
2. 把握好问题的规模这个概念,抓住问题规模;
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O阶方法:
- 用常数1取代运行时间中的所有加法常数。
- 在修改后的运行次数函数中,只保留最高阶项。
- 如果最高阶项存在且不是1,则去除与这个项相乘的常数。得到的结果就是大O阶。
通过上面我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。 另外有些算法的时间复杂度存在最好、平均和最坏情况:
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
- 最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x 最好情况:1次找到 最坏情况:N次找到平均情况:N/2次找到在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)。
总结:O(执行次数) ----> O(阶次/数量级)
举个栗子:
实现getsum函数求和1+2+3+...+n
int getsum(int n)
{
int result=0; 执行1次
for(int i=1;i<=n;i++) 执行次数为:1+n+n
{
result+=i; 执行次数为:n
}
return result; 执行1次
}
分析:
问题规模体现在形参n上,当n不同,计算求和的项数就不同,计算总的执行次数为f(n)=1+1+n+n+n+1=3n+3; 经过化简可知:O(n) ,可以总结出:像定义变量、循环初始条件、循环判断条件、循环自增变量等这些都不会影响最终的结果(当n较大时),在计算总的执行次数时可以不计算它们!只需要看循环内部语句总的执行次数。
#include
int main()
{
int res=0,n=100; 执行1次
res=(1+n)*n/2; 执行1次
printf("%d",res); 执行1次
return 0; 执行1次
}
分析:这个算法的运行次数函数是f(n)=4。根据我们推导大O阶的方法,第一步就是把常数项 4改为1。在保留最高阶项时发现,它根本没有最高阶项,所以这个算法的时间复杂度为O(1)
#include
int main()
{
int res=0,n=100; 执行1次
res=(1+n)*n/2; 执行第1次
res=(1+n)*n/2; 执行第2次
res=(1+n)*n/2; 执行第3次
res=(1+n)*n/2; 执行第4次
res=(1+n)*n/2; 执行第5次
res=(1+n)*n/2; 执行第6次
res=(1+n)*n/2; 执行第7次
res=(1+n)*n/2; 执行第8次
res=(1+n)*n/2; 执行第9次
res=(1+n)*n/2; 执行第10次
printf("%d",res); 执行1次
return 0; 执行1次
}
事实上无论n为多少,上面的两段代码就是4次和13次执行的差异。这种与问题的大小无关(n的多少),即没有问题规模,执行时间恒定的算法,我们称之为具有 0(1)的时间复杂度,又叫常数阶。O(K)=O(1)
对于分支结构而言,无论是真,还是假,执行的次数都是恒定的,不会随着 n的变大而发生变化,所以单纯的分支结构 (不包含在循环结构中),其时间复杂度也是0(1)。
线性阶的循环结构会复杂很多。要确定某个算法的阶次,我们常常需要确定某个特定语句或某个语句集运行的次数。(关键是要分析O(1)语句执行的总次数)。因此,我们要分析算法的复杂度,关键就是要分析循环结构的运行情况。
示例一:
#include
int main()
{
int i=0;
for(i=0;i
上面这段代码,它的循环的时间复杂度为 O(n),因为循环体中的代码须要执行 n次。
总结:对于单层for循环,循环条件与问题规模n有关,时间复杂度一般都是O(n)
实例1:
#include
int main()
{
int count=1;
while (count
int main()
{
int num;
while (num!=0)
{
num/=2;
/*时间复杂度为O(1)的程序步骤序列*/
}
return 0;
}
示例1:
由于每次 count 乘以2之后,就距离n 更近了一分。也就是说,有多少个2相乘后大于 n,则会退出循环。由2^x=n 得到 x=log2n(x也代表次数)。所以这个循环的时间复杂度为0(logn)。
实例2:
由于每次num除以2之后,就距离0 更近了一分。也就是说,有多少个2相除后等于0,则会退出循环。由n/2^x=1 ,得到 x=log2n(x也代表次数)。所以这个循环的时间复杂度为0(logn)。
实例1:
#include
int main()
{
int i,j;
for(i=0;i
int main()
{
int i,j;
for(i=0;i
int main()
{
int i,j;
for(i=0;i
实例1:
是一个循环嵌套,它的内循环刚才我们已经分析过,时间复杂度为O(n)。而对于外层的循环,不过是内部这个时间复杂度为 O(n)的语句,再循环n次。所以这段代码的时间复杂度为O(n^2)。
实例2:
如果外循环的循环次数改为了m,时间复杂度就变为O(mxn)。
实例3:
由于当i=0,内层循环执行1次,当i=1,内层循环执行2次,当i=2,内层循环执行3次......当i=n-1,内层循环执行n次,因此总的执行次数为:1+2+3+4+...+n即等差数列求和问题:(1+n)*n/2=n/2+n^2=O(n^2)
总结:总结:对于双层for循环,内外循环条件与问题规模n有关,时间复杂度一般都是O(n^2)
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度 空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。 空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。 注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因 此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
空间复杂度计算的是与问题规模有关的额外开辟的内存空间!!!比如:临时定义的变量与问题规模无关不能算进去。
1.非递归方式
int getsum(int n)
{
int res=0;
for(int i=1;i<=n;i++)
{
res+=i;
}
return res;
}
2.递归方式
int getsum(int n)
{
if(n==1)
{
return 1;
}
else
{
return getsum(n-1)+n;
}
}
分析:非递归方式:问题规模体现在形参上n
时间复杂度:单层for循环,语句执行次数为n,所以时间复杂度为:O(n)
空间复杂度:没有与问题规模相关额外开辟的内存 ,空间复杂度为O(1)
注:临时变量res和i都与问题规模无关,无论求和项数是多少,都只开这两个O(2)-----O(1)
递归总结分析:
重点:递归就是函数自己调用自己,发生函数调用就会发生函数栈帧的开辟,每进行一次函数调用就会在内存上开辟一块内存,且需要注意,只有当函数调用完毕(函数语句执行完毕,得到确切的值,返回给被调函数,这块内存才会被释放!!!)
递归的时间复杂度:函数的调用次数
递归的空间复杂度:递归的深度(二叉树的深度)
分析:递归方式:问题规模体现在形参上n
在这个问题上,函数调用次数与递归深度都相同,都与问题规模有关,因此,时间复杂度和空间复杂度都是O(n)
1.非递归的方式
int Fib(int n)
{
int a = 1;
int b = 1;
int c = 1;
while(n>=3)
{
c = a + b;
a = b;
b = c;
}
return c;
}
2.递归的方式
int Fib(int n)
{
if(n<=2)
return 1;
else
return Fib(n - 1) + Fib(n - 2);
}
分析:非递归方式:问题规模体现在形参上n
时间复杂度:单层while循环,语句执行次数为n-3+1=n-2,所以时间复杂度为:O(n)
空间复杂度:没有与问题规模相关额外开辟的内存 ,空间复杂度为O(1)
分析:递归方式:问题规模体现在形参上n
递归的时间复杂度:计算函数的调用总的次数
递归的空间复杂度:递归的深度(二叉树的深度)
注意:每一层的递归调用都会在栈上分配一块内存,有多少层递归调用就分配多少块相似的内存,所有内存加起来就是开辟的总的内存大小,同层占用一块内存!!
1.非递归方式
int binarysearch(int* arr, int len, int val)
{
assert(arr != NULL && len > 0); //参数检验
int left = 0, right = len - 1;
while (left <= right) //查找的条件
{
int midindex = (left + right) / 2;
//面试进行优化,利用位运算更偏底层运行速度更快。
//优化一:midindex = (left + right) >> 1;
// 提示:若: 数据量很大 [0,200] left=150,right = 160; -> 310, 有可能超出范围如何解决? 算术运算符操作的数据范围只能在基本数据类型的范围之内
//优化二:midindex = ((right - left)>>1) + left;
if (arr[midindex] == val)
{
return midindex;
}
else if (arr[midindex] < val)
{
left = midindex + 1;
}
else
{
right = midindex - 1;
}
}
return -1;
}
2.递归方式
int binarysearch0(int* arr, int len, int value, int left, int right)
{
if (left > right)
return -1;
int midindex = (left + right) / 2;
if (arr[midindex] == value)
{
return midindex;
}
else if (arr[midindex] > value)
{
return binarysearch0(arr, len, value, left, midindex - 1);
}
else
{
return binarysearch0(arr, len, value, midindex + 1, right);
}
}
int binarysearch(int* arr, int len, int value)
{
assert(arr != NULL);
return binarysearch0(arr, len, value, 0, len - 1);
}
分析:非递归方式:问题规模体现在形参上len,数组长度不同,问题规模就不同
时间复杂度:每次查找查找区间减半,所以减了多少次,代表语句执行的总次数,logn
空间复杂度:没有与问题规模相关额外开辟的内存 ,空间复杂度为O(1)
分析:递归方式:问题规模体现在形参上查找的区间上,封装成函数。
时间复杂度:每次查找查找区间减半,所以减了多少次,代表语句执行的总次数,logn
空间复杂度:没有与问题规模相关额外开辟的内存 ,空间复杂度为O(1)
这种题型求解思路如下:
时间复杂度:访问问题规模中的元素总个数
空间复杂度:与问题规模相关额外开辟的内存空间,没有额外开辟跟问题规模相关的内存O(1)
以上便是我为大家带来的算法效率分析的内容,若有不足,望各位大佬在评论区指出,谢谢大家!可以留下你们点赞、收藏和关注,这是对我极大的鼓励,我也会更加努力创作更优质的作品。再次感谢大家!