【前言】时间复杂度和空间复杂度非常重要,是数据结构必须要了解的部分,不仅是学校考试,大厂笔试,甚至于和人描述你的算法优异,都可以从复杂度上入手。
目录
1.时间复杂度
算法效率有两种,一种叫时间效率,一种叫空间效率。时间效率被称为时间复杂度,空间效率被称为空间复杂度。
算法中基本操作的执行次数,为算法的时间复杂度。
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", count);
}
那么这个函数基本操作执行了多少次呢?
F(N)=N的平方+2*N+10
但是随着N的增大,我们可以发现这个表达式中N的平方对结果的影响是最大的。
时间复杂度是一个估算,是去看表达式中影响最大的那一项。
大O的渐进表示法:
1.用常数1取代运行时间中所有加法常数。
2.在修改后的运行次数函数中,只保留最高阶项。
3.如果最高阶项存在且不是1,则去除与这个项目相乘的常数,得到结果就是大O阶了。
可以得到O(N的平方)
再来看一个例子
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;
}
}
其时间复杂度为O(N+M),因为其中的N与M是未知的。
若假设M远大于N,则为O(M)。
若假设M与N差不多大,则为O(M)或者O(N)。因为此时相当于2倍的M或者2倍的N。按照渐进法就可以取掉2倍了。
若NM给出了具体数值,则遵循渐进法第一条,确定的常数次其时间复杂度为O(1)。
还有些算法需要分情况计算。
存在着最好,平均,与最坏的情况。
最坏:任意输入规模的最大运行次数 O(N)
平均:任意输入规模的期望运行次数 O(N/2)
最好:任意输入规模的最小运行次数 O(1)
例如:在长度为N中的数组寻找一个数据x,可能会在任意的位置找到。所以针对这个情况会有如上的三种复杂度。
如果长度不变,则为O(1)
如果长度线性变长 则为O(N)
但是当三种情况都存在,应当考虑最坏的情况
可以思考一下冒泡排序法的时间复杂度,情况都是答案为O(N的平方)
第一趟冒泡,随着指针向后推移,会进行N次比较
第二趟冒泡,随着指针向后推移,会进行N-1次比较
以此反复,会进行总数为1到N的一个等差数列求和的次数的比较。再通过大O阶算法就可以得到结果了。
二分查找法
假设有一个有序的数组,其值为0,1,2,3,4,5,6,7,8,9
那么我们先从中间找,取到4,如果4就是我们要的值,那么O(1),此为最好的情况。
如果我们要找的值比4更大,就可以缩放区间,将区间定位为5-9这个位置。然后再取5-9的中间,如此反复,进行折半查找。
那么假设找了X次,每一次折半都会使区间变化,第一次为n(假设n个元素),折半后为n/2,X次折半后为(n/2)^X ,在最坏的情况下,最后区间为1,即有(n/2)^X=1 ,
即得到了X=log2n(格式有误,2为底数,n为真数)
X也就是二分查找的时间复杂度,可以得到O(logn)(n为真数,底数省略,也可以不省略)
再看一道题
long long factorial(size_t N)
{
return N < 2 ? N : factorial(N - 1) * N;
}
比如我们输入的N为5,则通过三目运算返回factorial(4)*5,其实质就是一个递归调用,此时需要取计算factorial(4)的值,就又如此反复的循环起来了。
下一个得到的就是factorial(3)*4,直到factorial(2)*3,,factorial(1)*2 ,这样递归也就结束了,那么开始逐层退出,运算factorial(1)的值,可以算出为1,有了factorial(1)的值,就可以返回计算factorial(1)*2的值,同理往上就可以得到factorial(4)*5的值。
逐层进入(递归)
层次 | 实参 | 调用形式 | 需要计算的表达式 | 需要等待的结果 |
---|---|---|---|---|
1 | n=5 | factorial(5) | factorial(4) * 5 | factorial(4) 的结果 |
2 | n=4 | factorial(4) | factorial(3) * 4 | factorial(3) 的结果 |
3 | n=3 | factorial(3) | factorial(2) * 3 | factorial(2) 的结果 |
4 | n=2 | factorial(2) | factorial(1) * 2 | factorial(1) 的结果 |
5 | n=1 | factorial(1) | 1 | 无 |
逐层退出
层次 | 调用形式 | 需要计算的表达式 | 返回值 | 表达式的值 |
---|---|---|---|---|
5 | factorial(1) | 1 | 无 | 1 |
4 | factorial(2) | factorial(1) * 2 | factorial(1) 的返回值,也就是 1 | 2 |
3 | factorial(3) | factorial(2) * 3 | factorial(2) 的返回值,也就是 2 | 6 |
2 | factorial(4) | factorial(3) * 4 | factorial(3) 的返回值,也就是 6 | 24 |
1 | factorial(5) | factorial(4) * 5 | factorial(4) 的返回值,也就是 24 | 120 |
这样就得到了5!。
如果计算的是N!,那么递归调用了N次,每次递归运算了3次(本题是三目操作),那么得到O(3N)
通过大O阶得到O(N)
到这里基本上常见的时间复杂度就这些了。
通过计算可以发现logn这个时间复杂度非常nice,就拿他和O(n)进行对比,当n等于10亿的时候,O(logn)也不过等于30,非常小。所以可见二分查找的优异非常明显。其缺点也非常明显,需要有序才能进行二分查找。
2.空间复杂度
空间复杂度不算空间,而是对变量的个数进行计算。
空间复杂度也使用大O阶渐进表示法。
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 == 1)
{
break;
}
}
}
其变量个数为5,也遵循大O阶,所以为O(1)
就算是循环,在第一次时开辟了空间,循环了N次那么空间也用了N次,但需要记住的是
时间是累积的,但空间不累计。就算出了{},也就是作用域,空间被销毁,下一次进入时也仍然复辟并沿用此空间。感兴趣的可以了解一下栈帧。
long long* fibarray =(long long)malloc(n+1) *sizeof(long long)
空间复杂度为O(N),因为存在malloc函数。这里的malloc开辟了一个数组,长度为n+1
再来看之前的例子,计算其空间复杂度
long long factorial(size_t N)
{
return N < 2 ? N : factorial(N - 1) * N;
}
从factorial(10)到factorial(1)*2,每一层函数调用都要开辟栈帧。
递归调用了N层,那么每次调用建立一个栈帧(每个栈帧独立使用),每个栈帧使用了常数个空间,即每一层都是O(1)。并且我们有N个栈帧,那么就可以得到空间复杂度为O(N)。
虽然空间在使用完后都会销毁,但空间复杂度本质上是在寻找最坏的情况下同时需要多少空间。
并且在调用时建立栈帧,返回时进行销毁。
3.刷题练习
题1 消失的数字
数组nums包含从0到n的所有整数,但缺了一个。请编写代码找出缺失整数。在O(n)内完成。
思路1:
先排序,再遍历,看是否后面一个数比前面大1
但是不符合题要求。哪怕是最快的排序也是O(N*logN)
思路2:
将数组中所以数加到一起,其结果记为ret1,再将0到n所有数加到一起为ret2,两者相减即得到需要寻找的数字。
思路3:异或。
比如2与3的异或,先写出2与3的二进制
2: 10
3: 11 异或的结果是01
异或的特点为相同为0,相异为1,按位异或。(二进制)
那么我们可以看出,相同的数字在异或之后,将会得到0
所以我们将待测数组的值依次跟0-n的所有数进行异或,剩下的值就是没有的那个数字。
而且不要求有序进行。
例如:
1 3 1三个数进行异或,不管是什么顺序,最后得到的都是11,也就是3这个值。
也就是说不管两个1之间有多少其他值,最终两个1都会异或没。
#include<stdio.h>
int main()
{
int a = 0;
int num[] = { 0,1,2,3,4,6,7,8,9};
//a先与数组中的数进行异或。
for (int i = 0; i < sizeof(num)/sizeof(num[1]); i++)
{
a ^= num[i];
}
// a再与0-n的所有数异或。
for (int j = 0; j < sizeof(num) / sizeof(num[1]) + 1; j++)
{
a ^= j;
}
printf("%d", a);
}
其实总结下来也就是不带进位的加法啦。
题2 旋转数组
输入nums=【1,2,3,4,5,6,7】,k=3
输出【5,6,7,1,2,3,4】
解释:
第一次旋转【7,1,2,3,4,5,6】
第二次旋转【6,7,1,2,3,4,5】
第三次旋转【5,6,7,1,2,3,4】
思路1
最直接的进行旋转
函数部分:
void rotate(int* nums, int numsize, int k)
{
while (k--)
{
int temp = nums[numsize - 1];
for (int i = numsize - 2; i >= 0; --i)
{
nums[i + 1] = nums[i];
}
nums[0] = temp;
}
}
但如果给出的数组过大,可能效率十分低下。
思路2
空间换时间。进行一个整体移动。
例如k=3,将5,6,7放到一个新数组的前位,将1,2,3,4放到新数组的后面。
思路3
将后k个逆置,前n-k个逆置,再整体逆置。
void reverse(int *nums,int left,int right)
{
int temp;
while (left < right)
{
temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
++left;
--right;
}
}
void rotate(int* nums, int numsize, int k)
{
reverse(nums, numsize - k, nums - 1);
reverse(nums, 0, numsize -k- 1);
reverse(nums, 0, numsize - 1);
}
但仍然会有问题。可能出现数组越界。
例如k=13,使得出现负数。
但换个思路,旋转13次等于旋转13%7,为6次,所以就相当于旋转了6次。
if (k >= numsize)
{
k %= numsize;
}
添加如上的限制就可以解决。