目录
1-所谓复杂度
1.1-算法是什么
Algorithm——算法,按字面意思来理解就是解决问题的方法,算法其实是由一系列指令构成的,能够对一定规范的输入,在有限时间内获得所需要的输出,这就是算法的本质。
举个栗子,高斯在小时候对于1到100的快速求和,就是一个算法,老师给出输入,1~99这些数字,要求计算它们的和,而高斯给出的首尾相加就是一个算法,可以快速解决这个问题。
一个算法必须具备以下几个特性:
⭐有穷性——算法必须在执行有限个步骤后结束
⭐输入项——算法需要0/N个输入,用来描述需解决问题的那个对象的初始情况
⭐输出项——算法有1/N个输出,用来反映对输入数据进行加工后的结果
⭐确切性——算法当中的每一个step,都是有明确含义的
⭐唯一性——对于一组输入,算法一定会只产生一个结果,而不能有多种情况发生
⭐可行性——算法在执行过程中的任何计算步骤都是能够在有限时间内完成的
总之,在数据结构中,如何通过代码来抽象化一个算法,从而让计算机替代人来进行计算解决问题,就是其关键之处了!
1.2-复杂度是什么
1~99的相加,可以从1开始累加,也可以用高斯的方法首位相加,两个不同的算法得到的是相同的结果,都解决了这个问题,那么不同的算法有优劣之分吗?
很明显,高斯的方法要更快,更好,这里就体现出来了算法是有好坏的!
在程序运行的过程中,由于运行时需要耗费时间,占用内存空间,这两个是由算法导致的最直观的影响,其它的什么cpu发热啊,什么电脑变卡了帧数低了啊,那都是间接影响,不考虑。所以最终人们总结出来,衡量一个算法好不好,一般通过时间、空间(占用内存)两个维度来看。
基于此,人们提出了时间复杂度和空间复杂度的概念,在下文将一一介绍。
1.3-相关数学知识
无穷大的比阶问题是和复杂度息息相关的数学知识,在此简要回顾一下:
不同函数的图像在趋向于+∞时,其速度差异巨大,这将导致阶的不同,拿y=N和y=N*N这两个函数比较,当N→+∞时,你会觉得,y都是趋向正无穷,但是好像,第二个函数要比第一个函数的无穷,更加高级一些,更加趋向无穷,称之为阶的不同,高数中的经典无穷大无穷小比阶在此就不提及了,这个阶的不同在时间复杂度的思想当中有非常重要的应用。
2-时间复杂度
2.1-时间复杂度的概念
首先来思考这么一个问题,按照你的想法,下面这段代码要运行多久?
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
int main()
{
int i = 1;//1
for (i = 1;i < 10;i++)//2
{
int j = 1;//3
for (j = 1;j <= i;j++)//4
{
printf("%d*%d=%d ",i,j,i * j);//5
}
printf("\n");//6
}
return 0;//7
}
如果按照数学的逻辑来看,思路如下:
step1,假设每条语句执行时间是相同的,设为x,按照注释所标注可分为7行(for循环当中的合计);
step2,计算各语句的执行次数,那么//1执行一次,为x;//2执行9次,为9x;//3执行9次,为9x;//4执行45次,为45x;//5执行45次,为45x;//6执行9次,为9x;//7执行1次,为x;
step3,将所有语句执行的时间相加,得到该段代码的执行时间S(x)=119x;
不难发现,似乎循环中的语句,占据了主导作用?很明显,循环的语句会被多次执行,那么理论上程序在跑到循环时,耗费的时间也更多,所以其实时间复杂度,主要就是在跟循环较劲(而循环条件语句通常被忽略,因为循环体才是算法的步骤)!
📕算法当中的基本操作的执行次数,就称之为算法的时间复杂度。
上面代码所描述的问题是可以准确分析的,如果不能准确分析的时候呢?
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++;
}
}
请问上面这段代码当中的count,最后等于多少?不难得出答案,count=N*(N+2),也就是说,count++这句话,在这个函数中被执行了N*(N+2)次!也就是说,计算机需要对count这个变量,进行这么多次的处理,才能完成计算,而相比之下,定义count变量的语句只用执行1次,是不是就显得无关紧要了?
这里的N是一个变量,称之为问题规模,因为N可以直接影响到耗费时间的多少,当N很大时,很明显,程序运行耗费的时间也会随之增加。所以通常用一段程序中其问题规模N的数学表达式来表示时间复杂度。
2.2-神奇的大O(n)表示法
林语堂说过“懒惰使人进步”,仔细想想确实是这么个理:
void test(int N)
{
for (int i = 0;i < N;i++)
{
printf("哈哈\n");
for (int j = 0;j < N;j++)
{
printf("呵呵\n");
for (int k = 0;k < N;k++)
{
printf("嘿嘿\n");
}
}
}
}
很明显,哈哈打印N次,呵呵打印N*N次,嘿嘿打印N*N*N次,那么现在设想一下,如果N足够大,那么立方阶是远远大于平方阶和常数阶的,这在无穷大的比阶当中已经讲过。所以,时间复杂度真的需要考虑所有语句吗?其实不用,在简化后,人们提出了O(n)表示法来表示时间复杂度,这里的时间复杂度为O(n)=N^3。
通过数学中的比阶,将时间复杂度从每条语句所耗费的时间转换到关注耗费时间最多的语句上去,就是O(n)表示法的核心思想!
O(n)表示法不会去关注某个具体程序的所需时间,而更偏向一种概念,比如O(1),代表常数阶的时间复杂度,即这种类型的程序执行起来花费的时间是常数阶的;O(n),则代表是线性函数阶的时间复杂度,这类程序执行起来花费时间就要比O(1)多了。
2.3-常见时间复杂度类别
2.3.1-O(1)类型
此类型的程序,表示其所耗费的时间是常数阶的,也就是其中不存在循环,单纯线性的往下执行,各语句权重相同,在实际当中较少。
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
int main()
{
printf("666\n");
printf("666\n");
return 0;
}
2.3.2-O(n)类型
void test(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,去掉10,时间复杂度应该为2N,系数也舍去,所以该代码是一个O(n)类型的。
注:系数对阶是没有任何影响的,所以在时间复杂度计算最后,会舍去系数,默认为1!
void test(int n,int m)
{
int count = 0;
for (int i = 0;i < m;i++)
{
count++;
}
for (int j = 0;j < n;j++)
{
count++;
}
printf("%d\n", count);
}
这段代码中,与问题规模有关的基本操作次数为m+n,所以时间复杂度就是m+n,也可以归类于O(n)类型,可以发现,当一段程序中最多只含有单层循环时,其时间复杂度为O(n),而于有几个单层循环并无关系。
const char* strchr(const char* str, int character);
strchr函数功能为在一个串中查找给定字符的第一个匹配之处。这个函数的时间复杂度为多少呢?这里就需要综合考虑了:
假设字符串长度为N,
⭐如果我运气好,查找的第一个就是匹配值,那么这个时候就是最好情况,1次完事;
⭐如果我运气一般,找了一半找到了,也就是N/2次完事;
⭐如果我特别非,找完了才找到匹配值,那么这时候是最坏情况,N次完事;
如果将查找次数的权重统一,均记为1/N,表示我一次找到的概率是1/N,两次找到的概率也是1/N,N次找到的概率还是1/N。
那么对这个函数来说,其时间复杂度为:
1/N * (1+2+3+4+....+N),简化为(N^2+N)/2N
舍去常数项以及低阶,时间复杂度为O(n)!
long long Fac(size_t N)
{
if (N == 0)
return 1;
return Fac(N - 1) * N;
}
递归实现阶乘的函数,其时间复杂度也属于O(N)类型,这是因为,虽然这里没有循环,但是递归函数会创建栈帧,传入的是Fac(N),在执行过程中,还要去创建Fac(N-1),Fac(N-2),.....Fac(1),Fac(0)。所以其实这个递归实现的阶乘,其时间复杂度也随传入的N所变化,故其时间复杂度为O(N)。
2.3.3-O(n^2)类型
void BubbleSort(int* arr,int n)//由小到大排
{
assert(arr);
for (size_t end = n;end > 0;end--)//每次排好最后一个
{
int exchange = 0;//记录是否发生交换
for (size_t i = 1;i < end;i++)//此轮要检查是否交换的长度
{
if (arr[i - 1] > arr[i])
{
int tmp = arr[i-1];
arr[i-1] = arr[i];
arr[i] = arr[i-1];
exchange = 1;
}
}
if (exchange == 0)//本轮没有发生交换
{
break;
}
}
}
这是一个优化版的冒泡排序,也应该分情况讨论:
最优情况,如果直接就是好的,不需要交换,时间复杂度为N*1=N;
最坏情况,每一轮都需要去排,那么时间复杂度就应该是N*(N-1)/2,简化后即为N^2 ;
通常在实际中,只会去考虑最坏情况,做好充裕的打算(此处由于有exchange可以中途停止的机制,其实也能够按每次停下相同的权重去计算,在此处就略过),所以冒泡排序的时间复杂的是O(N^2)这个类别的。
2.3.4-O(logN)类型
int BinarySearch(int* arr, int n, int x)
{
assert(arr);
int begin = 0;//下标为0处开始
int end = n - 1;//下标为n-1处结束
while (begin <= end)
{
int mid = begin + ((end - begin)/2);//防溢出
if (arr[mid] < x)
{
begin = mid + 1;
}
else if (arr[mid] > x)
{
end = mid - 1;
}
else
return mid;
}
return -1;
}
直接考虑最坏的情况,在二分查找中最多查找多少次能找到匹配值呢?
在数组长度为N的情况下,假设查找x次后找到了匹配值。那么由于二分查找每一次都是去当前存量的一半中去寻找,所以有2^x = N,x = logN。
即最坏的情况下,要查找logN次,能找到匹配值,所以其时间复杂度为O(logN)。
2.3.5-O(2^N)类型
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
斐波那契数列是一个典型时间复杂度为O(2^N)的程序,同时这也是导致递归实现斐波那契数列是完全不可行的罪魁祸首!
上图展示了斐波那契数列的规律,按照这种形式展开来看,第一层Fib(N)占用一个栈帧;同时会创建两个新栈帧Fib(N-1)和Fib(N-2),第二层就需要两个栈帧了;第三层则是4个栈帧,以此内推,当到了Fib(1)和Fib(2)出现的那一层,该层应该可以有2^(N-1)个栈帧!所以所有栈帧的个数,就是其时间复杂度!
等比数列求和:2^0+2^1+2^2+....+2^(N-1),不难得出,其时间复杂度为O(2^N)!
指数函数的增长速度是非常快的,当N=40的时候,现有的CPU来计算就够呛了,如果这个N只能满足这么小规模的计算,那么这个算法无疑是没有用的。
3-空间复杂度
3.1-空间复杂度在干什么
空间复杂度是指一个算法在运行过程中临时占用存储空间大小的量度。
一个算法在计算机存储器上所占用的存储空间又三个方面组成:
⭐算法本身所占据的存储空间——即实现这个算法的代码
⭐算法的输入输出数据所占空间——根据解决问题的不同来决定
⭐算法在运行过程中额外临时占用的空间——在执行过程中可能需要额外的辅助变量来帮助算法继续运行,如果额外定义一个int变量,那么就说在这个算法运行过程中,需要额外的增加4个字节的内存,而如果额外需要一个一维数组的话,就会跟问题规模N有关了,例如加上一个int arr[N],这里就体现出了空间复杂度的意义!
空间复杂度只是在反映函数在运行过程中显示申请的额外空间的多少,在如今存储量都非常大的情况下,其重要性已经远远不如时间复杂度了。
3.2-常见的空间复杂度
空间复杂度基本上分为O(1)、O(N)、O(N^2)这三种常见的,下面仅介绍前两种:
3.2.1-空间复杂度O(1)
void BubbleSort(int* arr,int n)//由小到大排
{
assert(arr);
for (size_t end = n;end > 0;end--)//每次排好最后一个
{
int exchange = 0;//记录是否发生交换
for (size_t i = 1;i < end;i++)//此轮要检查是否交换的长度
{
if (arr[i - 1] > arr[i])
{
int tmp = arr[i-1];
arr[i-1] = arr[i];
arr[i] = arr[i-1];
exchange = 1;
}
}
if (exchange == 0)//本轮没有发生交换
{
break;
}
}
}
冒泡排序的空间复杂度就为O(1),因为在其运行过程中,除了定义了exchange,然后end和i之外,并没有任何额外随问题规模N变化的数组,所以其空间复杂度为O(1)。
3.2.2-空间复杂度O(N)
第一种常见的空间复杂度为O(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;
}
此处,想通过一个函数来返回斐波那契数列的前n项,那么在实现过程中,就需要在函数当中创建一个堆区数组,用于存放跟问题规模N有关的项,这样就势必会额外占用一部分内存,同时由于与问题规模N线性相关,所以其空间复杂度为O(N)。
第二种则是在函数栈帧的创建与销毁过程中,导致空间复杂度为O(N):
void test(int n)
{
int a = 0;
int* p = (int*)malloc(4 * n);
printf("%p,%p\n", &a, &p);
if (p == NULL)
{
perror("malloc fail\n");
return;
}
free(p);
p = NULL;
}
int main()
{
test(10);
test(10);
return 0;
}
要明确的一点是,时间复杂度是一去不复返的,而内存空间却是可以重复利用的!在上面这段代码中,两次打印的地址是相同的!这表明函数栈帧是重复利用的,而不是每一次都随机去找一块空间,然后进行额外的申请!
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
有了上面的铺垫,来看一看递归实现斐波那契数列的空间复杂度吧!按照时间复杂度来说,其根据问题规模N会创建2^N这个数量级的栈帧,但是对于空间复杂度来说,应该考虑的是,需要多少个栈帧,就够反复利用跑完这个函数了!
首先这个函数会去执行return Fib(N-1)这个部分,而不是同时执行:
对于栈帧来说,Fib(N)执行过程中,首先回去创建Fib(N-1)的栈帧,而Fib(N-1)在执行过程中又马上会去创建Fib(N-2)的栈帧,相当于会一直创建到Fib(1),这个时候,栈帧的数量就已经满足了重复利用的需求,编译器就不会再去创建更多栈帧以免造成空间的浪费了!
所以对于这个递归实现的斐波那契数列函数,其空间复杂度为O(N)!