1.算法效率
想必大家都写了有一段时间的代码了,在往后写代码的过程中我们都会越来越注重算法的效率问题,那么如何衡量一个算法的好坏呢?
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。因此:
衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度
- 时间复杂度主要衡量一个算法的运行快慢
- 空间复杂度主要衡量一个算法运行所需要的额外空间
在计算机发展的早期,计算机的存储容量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储容量已经达到了很高的程度。所以我们如今已经不需要再特别关注一个算法的空间复杂度。
2.时间复杂度
2.1时间复杂度的概念
时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度
也就是说我们找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度
举个栗子:请计算一下Test1中count++语句总共执行了多少次?
// 请计算一下Test1中count++语句总共执行了多少次? void Test1() { int N = 0; int count = 0; scanf("%d", &N ); for (int i = 0; i < N ; ++i) { for (int j = 0; j < N ; ++j) { count++; } } int k = 10; while (k--) { count++; } printf("%d", count); }
图解:
以上的计算还算简单吧,实际中我们计算时间复杂度时,好多都是很麻烦的,所以我们其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法来计算复杂度
2.2大O的渐进表示法
大O符号(Big O notation):是用于描述函数渐进行为的数学符号
使用规则:
1.用常数1取代运行时间中的所有加法常数
- 比如:2N^2+N+100,这里首先就需要去掉常数项100
2.在修改后的运行次数函数中,只保留最高阶项
- 接着以上的步骤2N^2+N,显然2N ^ 2远大于N,所以只保留2N ^ 2这个最高项
3.如果最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的结果就是大O阶
- 此时只剩最高项2N^2,最高项是2对时间复杂度影响不大,应该去掉,所以最终的时间复杂度就是O(N ^ 2)
再比如使用大O的渐进表示法以后,Test1的时间复杂度就为O(N^2)
通过上面的例子我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
比如:在一个长度为N数组中搜索一个数据k
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)
只看这点还是不太好理解的,下面我们来通过一些计算时间复杂度的经典例题来进一步感受一下
2.3时间复杂度计算例题
例题一:
// 计算Func1的时间复杂度? void Func1(int N) { int count = 0; for (int k = 0; k < 2 * N; ++k) { ++count; } int M = 10; while (M--) { ++count; } printf("%d\n", count); }
题解:
这里for循环执行2N次,再加上while循环执行M=10次所以一共执行2N+10次,根据大O的渐进表示法,时间复杂度就为O(N)
例题二:
// 计算Func2的时间复杂度? void Func2(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); }
题解
这里两个for循环分别执行M次和N次,M、N都是未知数无法区分谁是最高项,因此两个都取出,所以根据大O的渐进表示法,时间复杂度就为O(M+N)
例题三:
// 计算Func3的时间复杂度? void Func3(int N) { int count = 0; for (int k = 0; k < 100; ++k) { ++count; } printf("%d\n", count); }
题解:
这里循环执行了100次,为常数次,由大O的渐进表示法第一条,时间复杂度就为O(1),这里就算执行一万次、一亿次也都是常数次,时间复杂度依然是O(1)
例题四:
// 计算strchr的时间复杂度? const char* strchr(const char* str, int character)
题解:
解释:strchr是一个字符串查找函数,功能是在一个字符串str中查找目标字符character
所以这里的找到有三种情况:
- 最好情况:一次就找到,此时时间复杂度为O(1)
- 最坏情况:把整个字符串都遍历了一遍依旧没找到目标字符,此时时间复杂度为O(N)
- 平均情况:在字符串中间找到了,此时时间时间复杂度为O(N/2)
- 我们在上面说过在实际中一般情况关注的是算法的最坏运行情况,所以strchr的时间复杂度为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; } }
题解:
之前我们介绍过冒泡排序,如果数据有N个那么它会执行N-1趟,而且随着趟数的每次+1,每趟比较的次数会 -1,所以比较的次数就为(N-1) + (N-2) + … + 1 = (N-1) * N / 2,根据大O的渐进表示法,时间复杂度就为O(N^2)
例题六:
// 计算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; }
题解:
折半查找是一个效率很牛逼的算法,每查找一次,查找区间就缩小一半,查找多少次就除以2多少次(N/2/2/2/2…/2 = 1),假设有2N个数,找到目标数需要查找k次
- k = 1 查找区间:N = 1
- k = 2 查找区间:N = 1*2
- k = 3 查找区间:N = 122
- …
- 查找k次 查找区间:N = 1 * 2 * 2 * 2 * 2 * 2…(k个2) = 2 ^ k
- 由以上规律我们可以得到N=2^k所以k=logN,即折半查找的时间复杂度就为**O(logN)**在计算时间复杂度时有这样一个规定:在计算时间复杂度时,当底数为2时可以忽略不写,但是其他的底数是不能忽略的,不过这是很少见的
- 例题七:
// 计算阶乘递归Fac的时间复杂度? long long Fac(size_t N) { if(0 == N) return 1; return Fac(N-1)*N; }
题解:
递归的时间复杂度是每次递归调用执行次数的累加
这里的阶乘递归每次调用都会相乘一次,调用N次就是N个1相加,所以时间复杂度为O(N)
例题八:
// 计算斐波那契递归Fib的时间复杂度? long long Fib(size_t N) { if (N < 3) return 1; return Fib(N - 1) + Fib(N - 2); }
题解:
可见求斐波那契数列的递归算法是有非常多的重复运算的,所以我们一般使用迭代的方法来求解
3.空间复杂度
3.1空间复杂度的概念
空间复杂度也是一个数学表达式,是对**一个算法在运行过程中临时占用存储空间大小的量度 **
简单来说空间复杂度算的就是变量的个数,因为变量一旦声明就会在内存中开辟空间
注意:
函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定
空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法,大多数情况算法的空间复杂度是 O(1) 或 O(N)
说了这么多大家可能还是有点不理解,下面我们还是直接来拿例题来感受
2.2空间复杂度计算例题
例题一:
// 计算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的渐进表示法,这里的空间复杂度为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; }
题解:
这是斐波那契数列的迭代实现,申请的空间为fibArray和一个变量i,前者大大小为8(n+1),后者为常数直接去掉,再根据大O的渐进表示法,所以空间复杂度为O(N)
例题三
// 计算阶乘递归Fac的空间复杂度? long long Fac(size_t N) { if(N == 0) return 1; return Fac(N-1)*N; }
题解:
递归的空间复杂度是每次递归调用的变量个数累加
这里递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间,所以空间复杂度为O(N)
4.常见复杂度对比
一般算法常见的复杂度:
1314 | O(1) | 常数阶 |
2n+1 | O(n) | 线性阶 |
3n^2+4n+5 | O(n^2) | 平方阶 |
3log(2n+4) | O(logn) | 对数阶 |
2n+3log(2n)+12 | O(nlogn) | nlogn阶 |
n^ 2+2n^3+4n+8 | O(n^3) | 立方阶 |
2^n | O(2^n) | 指数阶 |
图表:
好了以上就是时间复杂度和空间复杂度的全部介绍了,期待大佬们的三连!你们的支持是我最大的动力!
文章有写的不足或是错误的地方,欢迎评论或私信指出,我会在第一时间改正