前言
博主希望以一系列学习博客的方式来记录自己学习算法的经历并便于自己复习,欢迎各位看官一起交流学习。
1.时间复杂度
算法效率中,时间效率现在更加被看重
1.1总述
算法的时间复杂度是一个函数,定量描述了算法运行时间
时间复杂度不是计算具体多少秒,因为:每一次程序的运行与电脑硬件(cpu和内存)有关系 ,用具体时间不能很好的衡量算法的优劣。虽然我们可以通过大量实验来测试计量具体时间,但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻 烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比 例,算法中的基本操作的执行次数,为算法的时间复杂度。
1.2如何计算执行次数
int sum=0;//执行一次
sum=(1+n)*n/2; //执行一次
printf("%d",sum);//执行一次
一共执行三次,每一条基本语句就算执行一次。
for(int a=1;a<=n;a++){//执行n次
pritnf("hello world");
}
该循环执行n次。
1.3 大O(big o notation)的渐进表示法(估算)
一个例子(FUN1):
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N ; ++ i)
{
for (int j = 0; j < N ; ++ j)
{
++count;
}
}//n*n次
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}//n*2次
int M = 10;//一次
while (M--)//十次
{
++count;
}
printf("%d\n", count);
}
关于次数的函数f(n)=n^2+2n+11。但是我们的最终表现结果是O(N^2),原因如下:
各位都是数学小天才,当n--->无穷时,大小几乎只和n^2有关,因此没有必要算很仔细的次数,时间复杂度是一个估算,找“老大”,影响最大的那一项
规则如下:
可以理解为:这是一个逐渐简化计算次数的推导方法 。
1.4部分特殊情况的时间复杂度
a. 时间复杂度不一定只有一个未知数
void Func3(int N, int M)
{
int count = 0;//1
for (int k = 0; k < M; ++ k)
{
++count;
}//m
for (int k = 0; k < N ; ++ k)
{
++count;
}//n
printf("%d\n", count);//1
}
如上例子,两个未知数m,n, 答案应该是o(m+n),
但是若有条件 m>>n,则为o(m);
若有条件m与n差不多大小,则为o(n)或o(m),因为o(2m)就是o(m); 这样关于未知数的一次线性表示方式也被称为线性阶。
确定的常数次都是o(1),也被称为常数阶,例如最开始给出的高斯求和的代码,一共三次运算,但是我们程序员不写成O(3),而是写成O(1),一方面是上文蓝字中的规则第一步就是常数合并为1,另一方面这个问题的执行次数与n值无关,我们将这种确定次数的算法都视作O(1)时间复杂度。
b.对于下例会分情况的算法
const char * strchr ( const char * str, char character )
{
while(*str != '\0')
{
if(*str == character)
return str;
++str;
}
return NULL;
}
这样的算法要分情况:
最坏情况(最后一次找到或者找不到),平均情况(中途找到),最好情况(一来就找到)。平均运行时间是最有意义的,是期望的运行时间,一般是通过运行一定量的实验数据后估算出来的,较贴近于实际使用情况,但不是竞赛、考研等方向的考察对象,此处不作研究。
一般关注的是算法的最坏运行情况。
这是一种悲观的预期,做最坏的打算, 但是也是最靠谱的。
在一个字符串中找对应的字母,如果字符串变长(也就是输入体量变大):
最好情况的时间复杂度o(1)则表示时间复杂度不变,最坏情况o(n)认为时间复杂度变长。
因此选择最坏时间复杂度。
再来个例子
例1,冒泡排序的时间复杂度
复杂度与n的几次方没有绝对关系,具体应该看思想 ,如冒泡排序:
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
for (size_t i = 1; i < end; ++i)
{
if (a[i-1] > a[i])
{
Swap(&a[i-1], &a[i]);//默认swap函数是复杂度为O(1)的函数
}
}
}
此时你应该能大体估算,不用写出具体表达式,脑子里应该想:每轮都循环n次左右,共循环2次,固为O().
例2,二分查找的时间复杂度
类似于折纸举例,每次缩小一半,缩小到最后一个单位区间的时候
就能一定能找到或一定找不到了。
一个区间长度为n,如果一共折了x次,
那么这一个单位区间区间1*(2^x)就能变回原来的长度
也就是
折了x次就是找了x次,那么就是x=
所以说二分查找的时间复杂度就是O(logn)
但由于不方便写底数,我们在算法中习惯忽略底数从而写作O(logN)
O(logN)都表示底数为2,但有的参考资料与书籍在不够严谨的情况下也会写成O(lgN)。
例3 ,递归阶乘
long long Factorial(int n){
return n<2 ? n:FactOrial(n-1);
}
递归调用了n次,每次递归运算常数次(O(1))
一共就是O(n)
例4 斐波那契数列
斐波那契的递归算法,时间复杂度是o(2^n)
void fib(n){
if(n<=2){
return 1;}
else return fib(n-1)+fib(n-2);
}
例如计算f(8),每一次按照树形打开
(图像来源于网络)
因此为2^n
斐波那契的循环算法,时间复杂度是o(n)
void fib(n){
int* arr=(int *)malloc(10000*sizeof(int));
arr[0]=1;arr[1]=1;
for(int a=2;a<n;a++){
arr[a]=arr[a-1]+arr[a-2];//共计算n次
}
}
1.5时间复杂度的宏观感知
2^10=1024,我们程序员就将1024看做1000.
2的十次方是1024,程序员默认为1024为1000.
我们一起来默念并记忆:2的三十次方是十亿,2的二十次方是一百万,二的十次是一千.........
logn 与 n就是很厉害的算法
则O(1)就是最牛逼的算法,没有之一。
一般来说:O(1)<O(logn)<O(n)<O(nlogn)<O()<O(
)<O(
)<O(n!)<O(
),这些也是比较常见的复杂度表示方式。
2.空间复杂度
一般不强调空间复杂度。
浅显的讲,空间复杂度一般指变量的个数。
(但是空间复杂度并不是不算参数中的变量,具体还是要看思想!!!)
空间复杂度也使用大O渐进表示法 。
时间复杂度不算时间,算次数;
空间复杂度不算空间,算变量数;
我们任然使用几个例子来说明空间复杂度
1.冒泡排序
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个空间,但是整个算法只要我们开辟额外的常数个变量,因此也为O(1)
只看这一个函数新使用的空间,传进来的不计数。
2.递归阶乘
long long Factorial(size_t N)
{
return N < 2 ? N : Factorial(N-1)*N;
}
阶乘的空间复杂度是O(N)
3.斐波那契数列
long long Fib(int n){
if(n<3){
return 1;
}
return Fib(n-1)+Fib(n-2);
}
空间复杂度是O(n) !
对于递归的调用,假设n=5,那么这时就会去找Fib(5-1)的值,然后去找Fib(3),然后是Fib(2),得到这个值之后才会去找第一个Fib(N-2)也就是Fib(3)中的Fib(1),所以递归是先找完一侧的,再一次一次返回找另一侧的。
并且,1 2两函数开辟的函数栈帧属于同一空间。
当2的使用结束后,函数栈帧会立刻返回给操作系统,接着系统又会把这块空间交给Fiber(1)
又由于是同样的函数,使用的空间是一样大的,所以右侧全部是复用左侧的空间。
总的来说,也就是:
空间是可以重复使用的
时间是一去不复返的