携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第15天,点击查看活动详情
前言
我们知道,学好数据结构和算法的一个前提就是搞懂复杂度的概念和分析。
本文就来分享一波作者对复杂度的学习心得与见解。本篇属于第一篇,主要介绍时间复杂度的一些内容。
笔者水平有限,难免存在纰漏,欢迎指正交流。
时间复杂度
定义
在进行算法分析时,语句总的执行次数T(n)是关于问题输入规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。
算法的时间复杂度,也就是算法的时间量度,和T(n)成正比,记作T(n) = O(f(n))。这表示随n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中f(n)是问题输入规模n的函数。
这样用大写O( )来体现时间复杂度的记法称为大O记法。
一般情况下,随着n增大,T(n)增长最慢的算法为最优算法。
如何推导大O阶
推导方法
1.用常数1取代所有加法常数
2.只保留最高阶项,其他的去掉
3.如果最高阶项存在且其系数不为1,则去除其系数
要注意,这里的阶不是只看指数的,而是看f(n)的增长率的,增长率高的就为高阶,低的为低阶。 所以一些数学函数的增长率关系必须得清楚。
实际上,低阶项并不是说毫无影响,只是说相对于高阶项而言影响微乎其微到可以忽略,因为我们要看的是量级蜕变,就比如n和n^2 ,不是一个量级的,差距显著,而n和2n仍处在一个量级。
常见大O阶的说明
常数阶
int main()
{
int sum = 0;//执行1次
int i = 0;//执行1次
for(i = 0; i < 10; i++)//执行11次
{
sum += i;//执行10次
}
printf("%d", sum);//执行1次
return 0;
}
上面代码的执行次数是固定的常数,与n无关,我们称之为具有O(1)的时间复杂度,又叫常数阶。
无论常数是多少,我们都记作O(1)。
线性阶
分析算法的复杂度,其中的一个关键就是要分析循环结构的运行情况。
int add(int n)
{
int i = 0;
int sum = 0;
for(i = 0; i < n; i++)
{
sum += i;
}
return sum;
}
循环体中代码要执行n次,最高阶项就是n,所以易知时间复杂度为O(n)。
对数阶
void mul(int n)
{
int cnt = 1;
while(cnt < n)
{
cnt *= 2;
}
}
cnt只要小于n就会一直进入循环乘以2,直到比n要大为止,在这个过程中,我们假设乘了x个2,那么就有2x =n,也就是x=log2n。所以时间复杂度为O(logn)。要注意的是,logn是对以2为底n的对数的简写。
平方阶
下面是一个很简单的例子,对于时间复杂度为O(n2 )。
for(i = 0; i < n; i++)
{
for(j = 0; j < n; j++)
{
//执行O(1)的操作
}
}
一般而言,循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数。
for(i = 0; i < n; i++)
{
for(j = 0; j < m; j++)
{
//执行O(1)的操作
}
}
这里时间复杂度是不是就是O(m*n)呢?不一定,得看m和n的相对大小:
若是m>>n,则时间复杂度为O(m^2 )
若是n>>m,则时间复杂度为O(n^2)
若是m和n差不多大,则时间复杂度就为O(m*n)
再看看这个例子:
for(i = 0; i < n; i++)
{
for(j = 0; j <= i; j++)
{
//执行O(1)的操作
}
}
i=0时,内循环执行1次,i=1时,内循环执行2次......i=n-1时,内循环执行n次,也就是说,总的执行次数为1+2+3+...+n,求和得(n+1)n/2也就是1/2n^2 +1/2n,时间复杂度为O(n^2 )。
来个调用函数相关的例子:
void fun(int cnt)
{
int j = 0;
for(j = cnt; j < n; j++)
{
//执行O(1)的操作
}
}
int main()
{
int i = 0;
int j = 0;
int n = 0;
scanf("%d", &n);
fun(n);//执行次数为n
for(i = 0; i < n; i++)
{
fun(i);//执行次数为n*n
}
for(i = 0; i < n; i++)//执行次数为n(n+1)/2
{
for(j = i; j < n; j++)
{
//执行O(1)的操作
}
}
return 0;
}
乍一看是不是还有点复杂的说,其实还是平方阶的,我们分析一波。
常数次的就不看了,第一个fun(n)的,调用一次fun函数,里面执行了n次,第一个for循环,循环里面调用了fun函数,调用一次就是n次,总共调用了n次,所以是n*n次。
而对于第二个for循环,是个嵌套循环,注意内循环的初始化条件为j=i,当i=0时,内循环执行n次,i=1时,内循环执行n-1次......当i=n-2时,内循环执行2次,i=n-1时,内循环执行1次,总的执行次数为n+n-1+n-2+...+2+1,求和得到n(n+1)/2。
所以程序的执行次数大概为3/2n^2 +3/2n,时间复杂度就为O(n^2)。
常见的时间复杂度
从低阶到高阶排序:
O(1) < O(logn) < O(n) < O(nlongn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)
我们最常讨论的其实也就以下几个时间复杂度
最坏情况和平均情况
三种情况
我们前面讲的例子其实执行次数是可以预估出来的,因为较为固定,但是有一些算法它的执行次数时不确定的,这时候就有几种可能情况了。
- 最好情况:任意输入规模的最小运行次数(下界)
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
例如:在一个长度为n数组中搜索一个数据x
最好情况:1次找到
最坏情况:n次找到
平均情况:n/2次找到
在实际中,一般情况下关注的是算法的最坏运行情况,也就是所谓的时间复杂度指的是最坏情况下的时间复杂度,比如上面例子中数组中搜索数据时间复杂度为O(n)。
实例练习
二分查找
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n-1;
// [begin, end]:begin和end是左闭右闭区间,因此有=号
while (begin <= end)
{
int mid = begin + ((end-begin)/2);
if (a[mid] < x)
begin = mid+1;
else if (a[mid] > x)
end = mid-1;
else
return mid;
}
return -1
}
二分查找查找次数其实是不确定的,最好情况下1次就能找到,最坏情况下要一直二分直到left>right,有n个数字,每次二分都会截断一半的数字,最后截断到只剩下一个数字,其中假设截断了x次,每次截断n都要除以2,所以最后有n/2x =1,也就是执行次数x=logn,时间复杂度就为O(logn)。
阶乘递归
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if(0 == N)
return 1;
else
return Fac(N-1)*N;
}
前面好像没有讲过递归欸,那你觉得时间复杂度会是多少呢?
每次进入函数都会调用一次N-1代入后的函数,直到N的值变为0,所以实际上执行了f(N-1)、f(N-2)......f(3)、f(2)、f(1)、f(0)这么多函数,执行次数为N,时间复杂度为O(n)。
斐波那契递归
// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
if(N < 3)
return 1;
else
return Fib(N-1) + Fib(N-2);
}
这个就比较复杂了,最好知道什么是二叉树(后面才学),简单来说二叉树就是每次分支最多只有两个分支的树形结构,如图
二叉树和斐波那契递归又有什么关系呢?斐波那契递归的函数调用过程其实可以用二叉树来表示,如图
这样一看就很直观了,明显发现这种算法有很多重复计算,所以随着n的增大,算法效率应该很低,那到底怎样计算时间复杂度呢?
注意看图,每一层的执行次数可以数这一层有多少函数调用来计算,而且还有一定规律:都是2的指数倍,比如第一层是2^0 ,第二层是2^1 ,第三层是2^2 ,以此类推,第n层就是2^(n-1) ,这下时间复杂度不就出来了吗——O(2^n)。
以上就是本文全部内容了,感谢观看,你的支持就是对我最大的鼓励~