一、算法效率
1.如何衡量一个算法的好坏
对于一个算法,我们想要衡量他的效率
我们有两个方向可以去衡量,时间和空间
但是要注意,这里的时间并不是绝对的时间。因为对于同一个算法,可能会由于硬件设施的不同而导致运行时间的不同。因此不能简单的将绝对时间作为算法效率的衡量标准。对于时间而言,我们衡量的是算法的大概的执行次数
2.算法的复杂度
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
二、时间复杂度
1.时间复杂度概念
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。
即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度
如下面的例子
// 请计算一下Func1中++count语句总共执行了多少次? #include<stdio.h> 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); }
对于这个函数而言,我们不难得出他的运行次数为f(N)=N²+2N+10
但是这个过于繁琐了。我们不需要那么精确,我们抓大头,当n很大的时候,影响最大的一项就是我们最需要关注的一项。即最高次
所以这个运行次数我们简记为N²
为此我们也引出了大O渐进表示法,这个函数的大O渐进表示法为O(N²)
2.大O渐进表示法
大O符号(Big O notation):是用于描述函数渐进行为的数学符号。
推导大O阶方法:
1、用常数1取代运行时间中的所有加法常数。
2、在修改后的运行次数函数中,只保留最高阶项。
3、如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶。
如上题的大O渐进表示法结果为O(N²)
3.关于时间复杂度的一些计算
例一:
// 计算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); }
显然精确的次数为2N+10,但是大O渐进表示形式就为O(N)了
例二:
// 计算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次
所以大O渐进表示形式为O(M+N),如果M>>N,那么可以近似为O(M),反之近似为O(N)
例三:
// 计算Func4的时间复杂度? void Func4(int N) { int count = 0; for (int k = 0; k < 100; ++k) { ++count; } printf("%d\n", count); }
精确次数为100次
他的大O渐进表示为O(1),注意这里的1并不是一次,而是常数次
例四:
// 计算strchr的时间复杂度? const char* strchr(const char* str, int character);
这个其实是一个库函数,他的作用是从字符串中找出对应的字符,他的实现是通过依次遍历来完成的
既然是通过遍历来寻找,那么我们就发现,他有三种特殊情况:最好、最坏和平均
他的最好次数为:O(1),即只需要一次就找到了
最坏情况是:O(N),也就是最后一次才找到
平均情况为:N/2次,即O(N)
对于出现最好最坏的情况,我们一般取最坏情况
例五:
// 计算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; } }
这个也是一种冒牌排序算法,但是要注意这个冒泡排序与我们之前写的有一点不同,我们加了一个break来进行控制,这样的话
对于最好的情况,也就是顺序的情况,他的次数为O(N),因为他有一个exchange来进行控制,如果他是顺序,那么第一次循环的时候,就会由于exchange没有被赋为1,导致外层循环直接被跳出,仅仅只有内层循环执行了N-1次,所以他的时间复杂度为O(N)
最坏情况:也就是彻底的逆序,那么就需要执行N-1 +N-2 +.....+1,即O(N²)
所以他的时间复杂度为O(N²)
但是假如说没有了这个exchange来控制的话,那么无论最好还是最坏都是O(N²)
例六:
// 计算BinarySearch的时间复杂度? 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) >> 1); if (a[mid] < x) begin = mid + 1; else if (a[mid] > x) end = mid - 1; else return mid; } return -1; }
这是一个二分查找
最好情况当然是O(1)了,一次就找到了
最坏情况就是O(logN)次,原因是,二分查找是每次缩小一半去查找的,最后的一个区间长度是1。因此我们逆着来思考,1*2*2....*2=N。就是我们的方程,每查找一次就乘以2,即方程又可化为2^X=N,所以X=log(2,N),意思是以2 为底
为了方便简写,我们一般默认以2为底时可以省略这个底数
例七:
// 计算阶乘递归Fac的时间复杂度? long long Fac(size_t N) { if (0 == N) return 1; return Fac(N - 1) * N; }
这道题从栈帧的角度去理解,他开辟了多少次函数的栈帧,显然调用F(N)就需要调用F(N-1).......一直调用到F(0),所以时间复杂度为O(N)
例八:
// 计算斐波那契递归Fib的时间复杂度? long long Fib(size_t N) { if (N < 3) return 1; return Fib(N - 1) + Fib(N - 2); }
如上图所示,他是一个不断递归调用的过程,那么就需要画出他的图解。第一层是2^0次方,第二层是2^1次方次......,当然后面的圆圈处又一些会提前结束,但是我们可以忽略不记。近似认为是补全的。根据等比数列求和可解得时间复杂度为O(2^N)
三、空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度。空间复杂度不是程序占用了多少bytes的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定
例一:
// 计算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; } }
对于这个冒泡排序,他所额外占用的变量数是3个,所以空间复杂度为O(1)
例二:
// 计算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)
例三:
// 计算阶乘递归Fac的空间复杂度? long long Fac(size_t N) { if(N == 0) return 1; return Fac(N-1)*N; }
如上图所示,是这个函数的栈帧图,显然是O(N)
例四:
// 计算斐波那契递归Fib的空间复杂度? long long Fib(size_t N) { if (N < 3) return 1; return Fib(N - 1) + Fib(N - 2); }
对于这个斐波那契算法的空间复杂度,其实很容易陷入误区,误以为是O(2^N),其实不然。他的空间复杂度是O(N)
这是因为当函数递归到最后一层的时候,又会进行销毁这一层的栈帧,将空间还给操作系统,然后操作系统又将空间给了下一个栈帧。他的栈帧调用图如下所示
他始终只有这O(N)的空间在反复利用
四、常见复杂度对比
如下是一些常见的时间复杂度
五、两道经典的题目
1.消失的数字
题目链接:力扣
法1:排序加二分查找
排序加二分查找
这种方法的时间复杂度其实是不满足条件的
法2:异或
将整个数组进行异或
然后将1到n与这个数组进行异或,最终的结果就是缺失的数字
int missingNumber(int* nums, int numsSize){ int val=0; int i=0; for(i=0;i<numsSize;i++) { val^=nums[i]; } for(i=0;i<=numsSize;i++) { val^=i; } return val; }
法3:等差数列公式
先将整个数组求和,然后将从1~n进行求和
两个和相减即可
2.轮转数组
题目链接:力扣
法1:依次移动每个元素
先写一个可以轮转一次的函数,然后依次轮转k次
时间复杂度为O(K*N)
最坏情况需要轮转N-1次,时间复杂度为O(N²)
当然这种方法的时间复杂度太大了,对于这道题而言是无法通过的
法2:三次逆置
先逆置前n-k个数据
然后逆置后k个数据
最后整体逆置
void reverse(int* a, int begin, int end) { while (begin < end) { int tmp = a[begin]; a[begin] = a[end]; a[end] = tmp; begin++; end--; } } void rotate(int* nums, int numsSize, int k) { if (k >= numsSize) { k = k % numsSize; } reverse(nums, 0, numsSize - 1 - k); reverse(nums, numsSize - k, numsSize - 1); reverse(nums, 0, numsSize - 1); }
他的时间复杂度是O(N)
空间复杂度是O(1)
法3:以空间换时间
这种方法就是将后k个数据拷贝到一个新的数组中
然后将前n-k个数据拷贝到新数组的后面
最后在将新数组的数据整体拷贝到新的数组中
void rotate(int* nums, int numsSize, int k){ k=k%numsSize; int* a=(int*)malloc(sizeof(int)*numsSize); memcpy(a,nums+numsSize-k,k*sizeof(int)); memcpy(a+k,nums,(numsSize-k)*sizeof(int)); memcpy(nums,a,numsSize*sizeof(int)); free(a); }
时间复杂度和空间复杂度都是O(N)
好了本期内容就到这里
如果对你有帮助的话,不要忘记点赞加收藏哦!!!