1.算法效率
1.1 如何衡量一个算法的好坏
如何衡量一个算法的好坏?比如下面的斐波那契数列:
long long Fib(int N) { if(N < 3) return 1; return Fib(N-1) + Fib(N-2); }
斐波那契数列的递归实现方式十分简洁,但是简洁就一定号码?那该如何衡量好与坏呢?
1.2 算法的复杂度
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
2.时间复杂度
2.1 时间复杂度的概念
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度
// 请计算一下Func1中++count语句总共执行了多少次? 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); }
很容易得出,上述++count语句共执行了N^2+2*N+10次 ,那它的时间复杂度函数式就是:F(N)=N^2+2*N+10,这是基本操作执行次数,那我们就可以说这就是上述代码的时间复杂度吗?
不能。实际上我们在计算时间复杂度时,不一定要计算精确的执行次数,而只需要大概的执行次数,那么这里我们就要用到大O渐进表示法。
2.2 大O渐进表示法
大O符号:是用于描述函数渐进行为的数学符号。
推导大O阶方法:
1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项
3、如果最高接项存在且不是1,则去除这个项目相乘的常数。得到的结果就是大O阶
所以使用大O的渐进表示法,只保留最高项,上文中的代码的时间复杂度就是N^2。
通过上面,我们会发现大O的渐进表示法去掉了那些对结果影响不太大的项,简洁明了的表示出了执行次数。
另外,有些算法的时间复杂度存在最好、平均和最坏的情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N的数组中找一个数x
最好情况:1次找到。
平均情况:N/2此找到。
最坏情况:N次找到。
而我们在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索的数据时间复杂度是O(N)
2.3 常见时间复杂度计算举例
实例1:
// 计算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); }
该代码的基本操作执行次数是:2*N+10次 ,根据推导大O阶的方法,只保留最高项并去除最高项的系数,得到时间复杂度是O(N)
实例2:
// 计算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); }
该代码的基本操作次数是:M+N次,M和N是两个未知数,所以都不能省略,所以时间复杂度是O(M+N)
实例3:
// 计算Func4的时间复杂度? void Func4(int N) { int count = 0; for (int k = 0; k < 100; ++ k) { ++count; } printf("%d\n", count); }
该代码的基本操作执行次数是:100次,根据推导大O阶方法,常数次用常数1代替,所以时间复杂度就是O(1)
注意:O(1)代表的不是1次,是常数次。
实例4:
// 计算strchr的时间复杂度? const char * strchr ( const char * str, int character );
strchr函数的功能可以在cplusplus.com网站中搜到:
它的功能是:查找字符串中第一个出现的字符
那在字符串中查找第一个出现的字符,要执行的次数就不确定了,有可能1次就找到了,这是最好的情况,有可能将整个字符串都遍历了一遍后才找到,这是最坏的情况,而时间复杂度关注的是最坏的情况,所以它的时间复杂度是O(N)。
实例5:
// 计算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; } }
以上代码就是冒泡排序,关于冒泡排序我们在之前C语言中详细讲解过,这里不再赘述,想要了解的,请看:C语言初阶-数组-CSDN博客
我们知道,对n个数字冒泡排序需要排n-1趟,每一趟内部都在比较、交换数据,第一趟需要比较交换N-1次,往后依次是N-2、N-3、N-4......4、3、2、1、0,是个等差数列,那通过计算可知,基本操作执行次数是:N*(N-1)/2次 ,所以它的时间复杂度应该是O(N^2)
实例6:
// 计算BinarySearch的时间复杂度? int BinarySearch(int* a, int n, int x) { assert(a); int begin = 0; int end = n-1; while (begin < end) { int mid = begin + ((end-begin)>>1); if (a[mid] < x) begin = mid+1; else if (a[mid] > x) end = mid; else return mid; } return -1; }
以上代码是二分查找法,这个之前在C语言中我们也详细讲过,请看:C语言-折半查找(二分查找)算法详解_成屿的博客-CSDN博客
二分查找法每次缩小一半的查找范围,考虑最坏的情况,N/2/2/2/2/2/.....=1,假设找了x次,相当于除了x次2,2^x=N,则 x=logN,(log N在算法分析中代表底数为2,对数为N),所以时间复杂度是O(log N)
实例7:
// 计算阶乘递归Fac的时间复杂度? long long Fac(size_t N) { if(0 == N) return 1; return Fac(N-1)*N; }
上述递归的时间复杂度是O(N),下面我们分析一下:
实例8:
// 计算阶乘递归Fac的时间复杂度? long long Fac(size_t N) { if(0 == N) return 1; for(size_t i=0; i < N; i++) { ; } return Fac(N-1)*N; }
这段代码的时间复杂度是O(N^2),它每次调用里面可以当做以个等差数列,调用了N次,全部加起来,时间复杂多就是O(N^2)。
实例9:
// 计算斐波那契递归Fib的时间复杂度? long long Fib(size_t N) { if(N < 3) return 1; return Fib(N-1) + Fib(N-2); }
上述代码的时间复杂度是O(2^N),下图是分析过程:
3.空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度
空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。
空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定。
3.1 常见空间复杂度计算举例
实例1:
// 计算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; } }
空间复杂度是O(1),因为它使用了常数个空间。
实例2:
// 计算Fibonacci的空间复杂度? // 返回斐波那契数列的前n项 long long* Fibonacci(size_t n) { if(n==0) return NULL; long long * fibArray = (long long *)malloc((n+1) * sizeof(long long)); fibArray[0] = 0; fibArray[1] = 1; for (int i = 2; i <= n ; ++i) { fibArray[i] = fibArray[i - 1] + fibArray [i - 2]; } return fibArray; }
空间复杂度是O(N),因为它额外开辟了N个空间。
实例3:
// 计算阶乘递归Fac的空间复杂度? long long Fac(size_t N) { if(N == 0) return 1; return Fac(N-1)*N; }
它的空间复杂度是O(N)。为什么是O(N)呢?
因为递归时,每次调用创建一个栈帧,调用了N次,一共创建了N个栈帧,每个栈帧使用常数个空间,所以空间复杂度是O(N)。
实例4:
// 计算斐波那契递归Fib的空间复杂度? long long Fib(size_t N) { if(N < 3) return 1; return Fib(N-1) + Fib(N-2); }
有人认为这个和上面的时间复杂度一样都是O(2^N),对吗?
不对。实际上是O(N),为什么呢?
我们知道函数栈帧的创建是从上往下的,而在栈区,从上往下是从高地址到低地址,当回归时,Fib(2)的栈帧空间会销毁,Fib(1)在Fib(2)的原有位置上开辟空间,这就相当于它们复用了同一块空间。注意:空间的销毁是指把空间的使用权还给操作系统,而不是真的将这块空间销毁。
以上就是关于时间复杂度和空间复杂度的例题。
关于复杂度我们可以记住一下两句话:
1. 时间是一去不复返的,时间是累积计算的。
2. 空间是可以重复利用的,空间不累积计算。
4.常见的复杂度对比
5.复杂度的oj练习
5.1 消失的数字
链接:面试题 17.04. 消失的数字 - 力扣(LeetCode)
注意这道题要求我们在O(n)的时间内完成。
如果不考虑时间复杂度的话,这道题有三道解法:
法一:
排序,依次查找,如果下一个数不是上一个数+1,那么上一个数+1就是缺失的数字。
该方法时间复杂度是O(N*log N)。
法一不做详细讲解。
法二:
异或,两个相同的数相异为0,0和任何数异或为该数本身,那么我们将本身没有缺失数据的数组与缺失了数字的数组的所有元素相异或,就得到了缺失的数字。
该方法的时间复杂度是O(N)。
代码如下:
int missingNumber(int* nums, int numsSize){ int x=0; int i=0; for(i=0;i<numsSize;i++) { x^=nums[i]; } for(i=0;i<numsSize+1;i++) { x^=i; } return x; }
法三:
对0~N等差数列求和,然后减去数组所有元素,就可以得到缺失的数字。
该方法的时间复杂度是O(N)。
代码如下:
int missingNumber(int* nums, int numsSize){ int x=(0+numsSize)*(numsSize+1)/2; for(int i=0;i<numsSize;i++) { x-=nums[i]; } return x; }
5.2 轮转数组
这道题也有三道解法:
法一:
暴力求解:每次旋转一个,直到旋转k次,可以先将最后一个数据保存起来,然后从倒数第2个往后覆盖。
这种方法的时间复杂度是O(N^2),空间复杂度是O(1),效率太低,不符合题目要求。
暴力求解很简单,这里不详细讲解。下面再看一种方法:
法二:
三段逆置:
逆置前n-k个元素:4 3 2 1 5 6 7
逆置后k个元素: 4 3 2 1 7 6 5
整体逆置: 5 6 7 1 2 3 4
这个方法的时间复杂度是O(N),空间复杂度是O(1)。
代码如下:
//逆置函数 void reverse(int*a,int left,int right) { while(left<right) { int tmp=a[left]; a[left]=a[right]; a[right]=tmp; left++; right--; } } void rotate(int* nums, int numsSize, int k){ if(k>numsSize) k%=numsSize; //前n-k个逆置 reverse(nums,0,numsSize-k-1); //后k个逆置 reverse(nums,numsSize-k,numsSize-1); //整体逆置 reverse(nums,0,numsSize-1); }
法三:
空间换时间:可以创建一个空间tmp,把nums的前n-k个元素拷贝到tmp的后面,再把nums的后k个元素拷贝到tmp的前面。最后再将tmp中的内容拷贝到nums中去。
该方法的时间复杂度是O(N),空间复杂度是O(N)。
代码如下:
void rotate(int* nums, int numsSize, int k){ if(k>numsSize) k%=numsSize; int*tmp=(int*)malloc(sizeof(int)*numsSize); if(tmp==NULL) { perror("malloc"); return; } memcpy(tmp+k,nums,sizeof(int)*(numsSize-k)); memcpy(tmp,nums+numsSize-k,sizeof(int)*(k)); memcpy(nums,tmp,sizeof(int)*(numsSize)); free(tmp); tmp=NULL; }
以上就是我们今天学的关于复杂度的全部内容了,未完待续。。。