从现在开始,我就要对初阶数据结构的知识点进行讲解。在这篇文章中,我先介绍时间复杂度和空间复杂度,我先抛出时间复杂度和空间复杂度的概念,然后通过题目来讲解,保证对时间复杂度和空间复杂度理解的深度。
数据结构的概念
数据结构是计算机存储、组织数据的方式。数据结构是指相互之间存在一种或多种特定关系的数据元素的集合。
----来自百度
算法的概念
算法就是定义良好的计算过程,它取一个或一组的值为输入,并产生出一个或一组值作为输出。简单来说,算法就是一系列的计算步骤,用来计算输入的数据。
算法的时间复杂度和空间复杂度
如何衡量算法的好坏
对于算法的好坏,都是通过比较算法的时间复杂度和空间复杂度来判断。因为算法在编写成可执行程序后,运行时需要消耗时间资源和空间(内存)资源,如果哪个算法消耗的时间资源或者空间资源小,那么这个算法就更优化。
时间复杂度
时间复杂度是衡量一个算法的运行快慢的方法,它并不是程序的运行时间,因为不可能每个程序都要通过运行来得到运行时间,再进行比较。时间复杂度是一个函数,定量描述了函数的运行时间。
空间复杂度
空间复杂度也不是程序占用了多少字节的空间,空间复杂度算的是变量的个数,空间复杂度也是一个函数,是对一个算法在运行过程中临时占用存储空间大小的量度。
函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器的信息等)在编译期间已经确定好了,因此,空间复杂度主要通过函数在运行时显式申请的额外空间来确定。
注意,在计算机的快速发展下,内存已经变得非常大了,所以人们慢慢从以前的关心空间复杂度变为了现在的关心时间复杂度,比如,在一个算法中,出现了以空间换取时间的做法。
大O渐进表示法
大O渐进表示法适合于时间复杂度和空间复杂度。它规定了时间复杂度和空间复杂度的表示。
推导大O阶方法:
1.在表达式只有常数的情况下,用常数1进行替代。
2.在修改后的运行次数函数中,只保留最高阶项。
3.如果最高阶项存在且系数不是1,则去除与这个项目相乘的常数,得到的结果就是大O阶。
有些算法时间存在最好,平均和最坏的情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最坏情况:任意输入规模的最小运行次数(下界)
在实际中一般关注的是算法的最坏运行情况。
计算时间复杂度的例子
1.计算Func1的时间复杂度
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);
}
我顺着代码往下寻找,在第一个循环嵌套着另外一个循环。
一次循环进行N次,第一个循环要进行几次呢?答案是N^2次。
接下来,观察第二个循环,发现它进行了2*N次。
最后,再次观察第三次循环,发现他进行了10次。
所以,程序里面的循环总共有N^2+2*N+10次。
接下来,我用推导大O阶方法,来计算该程序的时间复杂度。
推导大O阶方法的第二条,在修改后的运行次数函数中,只保留最高阶项,在上面的表达式中,N^2是最高项式,所以只保留 N^2。
最后,该代码的时间复杂度是O(N^2)。
2.计算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次,第二个循环M次,那么循环总共有2 *N+M次。
接下来,我用推导大O阶方法,来计算该程序的时间复杂度。
推导大O阶方法第二条,在修改后的运行次数函数中,只保留最高阶项。2 * N是表达式2*N+M的最高项,所以只留下了2 * N。
推导大O阶方法第三条,如果最高阶项存在且系数不是1,则去除与这个项目相乘的常数,得到的结果就是大O阶。那么,上面的表达式就剩下了N。
所以该程序的时间复杂度是: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次,第二次循环执行M次,总共执行M+N次。
在推导大O阶方法,这条表达式没有什么需要进行化简,所以该程序的时间复杂度是O(M+N)。
4.计算Func4的时间复杂度
void Func4(int N)
{
int count = 0;
for(int k = 0; k < 100; k++)
{
count++;
}
printf("%d\n",count);
}
在这串代码中,程序将循环100次。
在推导大O阶方法的第一条规律中,在表达式只有常数的情况下,用常数1进行替代,所以Func4的时间复杂度是O(1)。
5.计算strchr的时间复杂度
const char* strchr(const char* str,int character)
strchr是一个字符串查找函数。
假设在下面的字符串中查找某个字符。
a b c d e f r e e w q s s s a x \0
最好的情况,查找的是a,在第一个元素找到,时间复杂度是O(1)。
最坏的情况,查找的是x,在最后一个元素找到,时间复杂度是O(N)。
上面已经提到,一般关注的算法最坏的情况,所以,该程序的时间复杂度是:O(N)。
6.计算Bubblesort的时间复杂度
void Bubblesort(int* a, int n)
{
assert(a);
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n - 1 - i; j++)
{
if (a[j] > a[j + 1])
{
int tmp = a[j];
a[j] = a[j + 1];
a[j + 1] = tmp;
}
}
}
}
当i等于0时,j最大等于n-2,j从0变为了n-2,遍历了n-1次。
当i等于1时,j最大等于n-3,j从0变为了n-3,遍历了n-2次。
当i等于2时,j最大等于n-4,j从0变为了n-4,遍历了n-3次。
……
……
……
当i等于n-2时,j最大等于2,j从0变成了2,遍历了2次。
当i等于n-1时,j最大等于1,j从0变成了1,遍历了1次。
所以两个循环合起来是1+2+3+……+(n-2)+(n-1)次,总共为n(n-1)/2。
在推导大O阶方法的第二条,在修改后的运行次数函数中,只保留最高阶项,在这里的最高项是(n ^ 2)/2,所以只保存(n ^2)/2。
在推导大O阶方法,如果最高阶项存在且系数不是1,则去除与这个项目相乘的常数,得到的结果就是大O阶,在这里1/2直接换成了1。
该程序的大O表示法是:O(N^2)。
7.计算BinarySearch的时间复杂度
int BinarySearch(int* pc,int sz,int n)
{
int start = 0;
int end = n - 1;
int tmp = 0;
while(start <= end)
{
tmp = start + ((end - start)>>1);
if(pc[tmp] > n)
{
end = tmp - 1;
}
else if(pc[tmp] < n)
{
start = tmp + 1;
}
else
{
return tmp;
}
}
return -1;
}
在这一次求该程序的时间复杂度时,我可以利用所要查找的值的范围来推断该程序进行了多少次。
n元素是传参过来的。
在第一次查找中,我需要在N个元素内查找n元素。
在第二次查找中,我需要在N/2个元素内查找n元素。
在第三次查找中,我需要在N/2/2个元素内查找n元素。
……
……
……
在第x次查找时,我需要在N/(2^x) = 1时找到了该元素。
所以,总共次数x可以利用等式N/(2^x) = 1求出。
最后x等于以2为底整数N的对数。
因为在计算复杂度时,以2为底的对数,里面的2是省略掉的,其他底数不省略。
该程序的时间复杂度是:O(logN)。
8.计算阶乘递归Fac的时间复杂度
long long Fac(size_t N)
{
if(N == 1)
{
return 1;
}
return Fac(N-1) * N;
}
N到1,总共递归了N次。
该程序的时间复杂度是:O(N)。
9.计算Fac的时间复杂度
long long Fac(size_t N)
{
if(N == 0)
return 1;
for(size_t i = 0; i < N; i++)
{
//………
}
return Fac(N-1) * N;
}
函数每递归一次,就进行N次循环。
总共递归N次,所以总共有N*N次的循环。
该程序的时间复杂度是:O(N^2)。
10.计算斐波那契递归的时间复杂度
long long Fab(size_t n)
{
if(n < 3)
return 1;
else
return Fab(n-1) + Fab(n-2);
}
如图,第一趟总调用1次,第二次总调用2次,第三次总调用4次,依次计算下去,最后一次调用是2^(N-2)次。
总共为2 ^ 0 + 2 ^ 1 +2 ^ 3 + …… + 2 ^ (N -2)
计算为2^(n-1)-1。
根据推导大O阶方法可得,该程序的时间复杂度是:O(2^N)。
计算空间复杂度的例子
1.计算Func4的空间复杂度
void Func4(int N)
{
int count = 0;
for(int k = 0; k < 100; k++)
{
count++;
}
printf("%d\n",count);
}
变量N作为函数参数,它的空间不算显式申请的额外空间,总共有变量count和变量k两个变量,为常数个,所以该程序的空间复杂度是:O(1)。
2.计算Bubblesort的空间复杂度
int BinarySearch(int* pc,int sz,int n)
{
int start = 0;
int end = n - 1;
int tmp = 0;
while(start <= end)
{
tmp = start + ((end - start)>>1);
if(pc[tmp] > n)
{
end = tmp - 1;
}
else if(pc[tmp] < n)
{
start = tmp + 1;
}
else
{
return tmp;
}
}
return -1;
}
变量a和变量n作为函数的参数,它们的空间不算额外开辟,额外开辟空间的变量有start、end、tmp三个变量,为常数个,所以该程序的空间复杂度是:O(1)。
3.计算Fibonacci的空间复杂度
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;
}
变量n作为函数参数,它的空间不算显式额外开辟的空间,额外开辟空间有变量i和动态开辟的(n+1)个变量,所以总共开辟了(n+2)个变量,根据推导大O阶方法可知,该程序的空间复杂度是:O(N)。
4.计算阶乘递归Fac的空间复杂度
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
如图,递归每调用一次,开辟一个栈帧,总共开辟看N个栈帧,并且只有在所有的递归调用结束后,栈帧才会释放。(栈帧存有变量)
该程序的空间复杂度是:O(N)。
5.计算斐波那契数递归的空间复杂度
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
因为斐波那契数递归的调用中,出现了空间重复利用的情况,如下图的两个Fac(N-2)就重复利用一块空间,所以斐波那契递归的开辟的栈帧是(N-2)个。
根据推导大O阶方法可知,该程序的空间复杂度是:O(N)。
6.观察Func1函数和Func2函数的空间关系
#include<stdio.h>
void Func1()
{
int a = 0;
printf("%p\n",&a);
}
void Func2()
{
int b = 0;
printf("%p\n",&b);
}
int main()
{
Func1();
Func2();
return 0;
}
运行结果如下:
由运行结果可以得知,两个函数里面的变量地址竟然是一样的,所以可以证明,两个函数使用同一块空间。
原因是函数Func1和函数Fanc2里面的变量是一样的,所以在main函数调用Func1时,开辟了栈帧,结束调用Func1函数后,并没有直接释放掉这个栈帧,而是给函数Func2使用,所以才出现了两个函数使用的空间是一样的。
今天的讲解就到此为止,关注点一点,下期更精彩。