目录
第一部分、算法效率的度量方法概述
1、事后统计方法
主要是通过设计好的测试程序和数据,利用计算机计时器对不用算法编制的程序的运行时间进行比较,从而确定算法效率的高低。但是这个方法有很大的缺陷,就是在测试之前,必须完成测试程序。试想一下:辛辛苦苦写完了程序,结果测试后发现这是个糟糕的算法,那么前面的工作不是功亏一篑了?而且不同的测试环境的差别不是一般的大。
2、事前分析估算方法
在计算机程序编写之前,我们可以依据统计方法对算法进行估算。我们通过总结可以发现,一个高级语言编写的程序在计算机上运行所消耗的时间取决于下列因素:
—算法采用的策略、方案
—编译产生的代码质量
—问题的输入规模(输入量的多少)
—机器执行指令的速度
所以我们可以发现,抛开与计算机硬件、软件(编译器)有关的因素,一个程序的运行时间依赖于算法的好坏和输入问题的规模。所以所谓的事前估算法,就是在撇开这些与计算机硬件、软件有关的因素,仅考虑算法本身的效率高低,可以认为一个特定算法的“运行工作量”的大小只依赖与问题的规模,或者说算法的执行时间是问题规模的函数。
注意:我们在分析一个算法的运行时间时,重要的是把基本操作的数量和输入模式关联起来。
第二部分:算法的时间复杂度
一、算法时间复杂度的定义
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间量度,记作T(n)=O(f(n))它表示随着问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度,简称时间复杂度。其中f(n)是问题规模n的某个函数。
二、关于时间复杂度定义的一些解释
(1)这里我们讲的是增长率,并不是讲某一个具体的函数值,因为比较某一个函数值没有意义,我们要看的是一个算法的潜力而不是他当前的数据值,举个例子:当n=100时,的值域为1000,但是增长率是100;的值虽然只有800,但是增长率是1000,那么当n+1后,变成了1100,变成了1800,复杂度发生了变化,所以我们比的是增长率,不是比函数值。
(2)关键我们要知道一件事:函数执行次数==时间(以代码执行一次作为一个单位时间)
(3)像用大O()体现算法复杂度的记法,我们称之为大O记法
(4)一般情况下,随着输入规模n的增大,T(n)增长最慢的算法为最优算法。
三、推导大O阶方法(分析时间复杂度的方法)
(1)用常数1取代运行时间中的所有加法常数。
(2)在修改后的运行次数函数中,只保留最高项(高数抓大头)
(3)如果最高阶项存在且不是1,则去除与这个项相乘的常数。例如,我们只要认即可。
(4)得到最后的结果就是大O阶
四、几个实例
(1)常数阶
int sum = 0,n = 100;
printf("hello,world!\n");
printf("hello,world!\n");
printf("hello,world!\n");
printf("hello,world!\n");
printf("hello,world!\n");
printf("hello,world!\n");
sum = (1+n)*n/2;
请问这段代码的大O是多少呢?
很多初学者都会认为多少条语句,程序执行了多少次,大O就是多少,所以会觉得这是O(8);但是实际上,根据概念T(n)是关于问题规模的函数,显然无论打印多少次都不会因为n的改变而改变,与问题规模无关,所以这段代码的大O应该是O(1),因为只有一条质量与n有关。
(2)线性阶
一般含有非嵌套循环涉及线性阶,线性阶就是随着问题规模n的扩大,对应计算次数呈直线增长。
int i, n = 100, sum =0;
for(i=0;i<n;i++)
{
sum = sum + i;
}
上面这套代码,它的时间复杂度就是O(n),因为循环体中的代码需要执行n次。
(3)平方阶
刚才是单个循环结构,那么嵌套呢?
int i,j,n=100;
for(i = 0;i<n;i++)
{
for(j=0;j<n;j++)
{
printf("hello,world!"\n);
}
}
那么这套代码的时间复杂度就是,如果有三个嵌套,那么就是.很容易总结出,循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数。
那么如果是下面这段代码呢?
int i,j,n = 100;
for(i = 0;i<n;i++)
{
for(j=i;j<n;j++)
{
printf("hello,world!\n")
}
}
与上面代码不同的是,内层循环中的起始条件j不再是等于0,而是会随着i的变化而变化;分析一下,当i=0时,内循环执行了n次,当i=1时,内循环执行了1次,所以总的执行次数应该是:;这不就是我们上一篇提到的高斯的算法嘛,那么可以有;用我们推导大O阶的方法,因为没有常数相加,所以第一条忽略。第二条只保留最高项,所以去掉。第三条,去除与最高项相乘的常数,最终的
(4)对数阶
我们看一下这个程序:
int i = 1,n = 100;
while(i<n)
{
i=i*2;
}
由于每次i*2之后,就距离n更近一步,假设有x个2相乘后大于或等于n,则会退出循环。于是由得到,所以这个循环的时间复杂度
这个时候我们来总结一下了,推导大O不算难,难的是对数列的一些运算(就像高斯的算法),如果想要考研,那么就需要强化一下数学尤其是数列方面的知识。
五、函数调用的时间复杂度分析
先看下下面的例子:
int main()
{
int i,j;
for(i=0;i<n;i++)
{
function(i);
}
}
void function(int count)
{
printf("%d",count);
}
我们来分析一下,函数体是打印这个参数。function函数的时间复杂度是O(1),所以整体的事件复杂度就是循环的次数O(n)
假设function是下面这样,那又会是如何呢?
void function(int count)
{
int j;
for(j = count;j<n;j++)
{
printf("%d",j);
}
}
那么这样子就和上文提到的平方阶的时候举的第二个例子一样:function内部的循环次数随着count的增加(接近n)而减少,所以根据求大O阶的方法得出算法的时间复杂度是
上面的例子在之前都有所涉及到,那么现在让大家分析一下一个比较复杂的例子,看看下面这套代码的时间复杂度是多少?
int main()
{
n++;
function(n);
for(i=0;i<n;i++)
{
function(i);
}
for(i=0;i<n;i++)
{
for(j=i;j<n;j++)
{
printf("%d",j);
}
}
}
void function(int count)
{
printf("%d",count);
}
第一行 n++执行了1次是O(1),function 执行了n次 是O(n),第三行for之前分析过, 下一个for是;因为都是并列的关系,所以我们都给加起来,得到的结果就是
给两张图总结一下吧。
六、常见的时间复杂度
常用的时间复杂度所耗费时间从小到大的排序:
而像之后的算法,由于n值的增大都会使得结果大得难以想象,基本没有人会用这种算法,所以我们没必要去专门讨论他。
七、最坏情况与平均情况
我们在生活中会对一些事情做一些预期,就比如我们大部分人都会经历的高考,有的人预期比较高,想着考C9,结果考了个普通的985,那么他得到的挫败感会更强。相反有的人对自己的预期很低,作着最坏的打算,想着考上本科就好了,假如最后的结果比他预料的要好,那么他也会更快乐,能更好地接受结局。
算法的分析也是类似,我们查找一个有n个随机数字数组中的某个数字,最好的情况是第一个数字就是我们要找的,那么时间复杂度就是O(1),但也有可能这个数字在数组的最后,那么时间复杂度就是O(n)
而平均运行时间就是期望的运行时间。
其中最坏运行时间是一种保证。在应用中,这是一种最重要的要求,通常除了特别指定,我们提到的运行时间都是最坏情况的运行时间。
第三部分:算法的空间复杂度
空间复杂度相对于时间复杂度,对我们写代码来说不是特别重要。我们在写代码的时候,完全可以用空间来换时间。
举个例子,要判断某年是不是闰年,你可能会花一点心思来写一个算法,没给一个年份,就可以通过这个算法计算得到是不是闰年的结果。另外一种方法是,事先建立一个由2050个元素的数组,然后把所有年份按下标的数字对应,如果是闰年,则此组元素的值是1,如果不是元素的值是0.这样,所谓的判断某一年是否为闰年就变成了查找这个数组某一个元素的值的问题。
第一种方法相对第二种方法来说明显节省空间,但每一次查询都需要经过一系列的计算才能知道是否为闰年。第二种方法虽然需要在内存里存储2050个元素的数组,但是每次查询只需要一次索引判断即可。这就是用空间换时间的小技巧,具体哪种方法好,需要我们结合实际情况来看。
1、算法空间复杂度的概念
算法的空间复杂度通过计算算法所需的存储空间实现,算法的空间复杂度的计算公式记作,其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。
通常我们都是用“时间复杂度”来指运行时间的需求,用“空间复杂度”指空间需求。
当直接让我们求“复杂度”时,通常指的是时间复杂度。
(本节完)
参考资料:
1、《数据结构教程》李春葆主编-清华大学出版社-2022.7
2、时间复杂度和空间复杂度1_哔哩哔哩_bilibili 鱼C小甲鱼