Hello!小伙伴们,俺憨人又来了,这次带来点新的东西 — 数据结构,而提到数据结构就不得不提到 — 算法,这两个家伙真是太重要了。那么,我们直接进入正题吧!
目录
一、何为数据结构
在此之前,同学们是不是只会① 敲代码 -> ② 调试、运行 -> ③ 看到代码运行结果 -> ④ 高兴的手舞足蹈/崩溃的抓头发并回到① ,但是大家有没有想过这些数据是怎么存储的呢?如果你想到这一点了,你就大概知道什么是数据结构了。
数据结构:数据结构就是计算机存储、组织数据的方式,是能够产生一种或多种特定关系的数据元素的集合。
二、何为算法
古人云:条条大路通罗马。字面意思是说通往罗马的路有很多(文化水平有限),而我们实现一个编程的结果也有不同的方式,这些方式就统称为算法。
算法:算法就是我们定义良好的计算过程,采取一个或一组输入,从而会产生一个或一组输出。
三、时间复杂度与空间复杂度
既然我们知道了算法,那我们就要知道,同样可以实现预期运行结果的不同的算法也是有优劣性的。如果让我们去选择,我相信 90% 的人都会选择 运行时间短、占用空间小 的代码吧(剩下 10% 的人肯定是因为这里面有自己写的代码才不会选)。那问题来了,我们怎么才能知道哪些代码 运行时间短、占用空间小 呢?别着急,慢慢来。
1.时间复杂度:
大家可能会想,字面理解不就是代码运行时间的复杂程度吗?其实,这样想也没错。但是,如果照着这个思路去理解的话,问题来了 — 我怎么计算代码运行时间的复杂程度?是不是要去运行代码才能知道?那问题又来了(2个) — ①在实现一个程序时,通常会产生很多算法,我们难道都要去运行一遍然后选取最快的那个吗?这就好像我要花最短的时间从北京去上海,难道我要将每一种交通工具都使用一遍才进行选择吗?②每个人、每个群体、每个社会使用的计算机都不同,同样的算法,也许在某一位使用古董计算机的“计算机收藏家”手里就要花很久,也许在某一位使用最先进计算机的“潮流人士”手里1ms就足够。总的来说,运行环境不同,即使算法一样,运行的时间也当然会受到影响,那你怎么能去比较运行时间呢,难道我们要让所有人的运行环境都一样?所以说,我们去计算时间复杂度不可能让每个代码去运行一遍然后比较运行时间,即便是真的运行了,也不能将实际运行的时间进行比较,因为很难保证运行环境相同。
那这就让人头疼了,这个任性的时间复杂度该怎么计算呢?我们都知道在数学中,如果要计算时间的话,只需要知道:时间 * 速度 = 路程 这个公式即可。联想到代码中,执行一次代码的速度几乎是固定的,那么我们只需要计算出算法中基本操作的执行次数是不是就可以知道代码运行的时间了!
所以,时间复杂度实际上就是算法中基本操作的运行次数。
2.空间复杂度:
早期的计算机容量没有那么大,所以人们比较看重空间的使用。所谓的空间复杂度并不是计算一个程序所占的空间大小,而是要计算一个程序在运行中因为临时变量所需要开辟的空间数量,也就是临时占用存储空间大小的量度。
四、计算时间复杂度和空间复杂度
不管是计算时间复杂度还是空间复杂度,都是一个大概、预估的结果。
1.计算时间复杂度:
我们已经知道了,计算时间复杂度就是计算一个算法中基本操作的执行次数,那么如何表示呢?
-----------------------------------------------------------------------------
①大O的渐进表示法:O( “大概的执行次数 – 用N来表示” )
②我们知道,一个算法会包含多个循环,例如下图:
void main(int N,int M)
{
int i = 0;
int count = 0;
for(i = 0;i < N;i++)
{
for(i = 0;i < N;i++)
count++;
}
for(i = 0;i < M;i++)
count++;
return 0;
}
有的小伙伴会说,我知道,计算时间复杂度 = 算法的基本操作执行次数,那这个就是 N*N + M,执行次数确实如此,但是上文我们提到,不管是计算什么复杂度,都是计算一个大概的量,那如果这个程序中再添加一个循环N次、循环M次,循环6次的代码,那是不是要写成 N*N + M + N + M + 6 ?那这个函数刚开始执行两次代码次数是不是也要算进去?答案是否定的,因为我们计算的是大概量,所以针对这个代码,我们只需要找到一个执行次数最大的量就可以了,请注意,找到这个执行次数最大的量需要比较的可不是次数,而是量级。我给大家做个比喻:假如你开了一间公司,这个公司的收益总和最大的单位是亿,当然,肯定不会是整数亿,那如果有人问起你公司收益大概是多少,你肯定会说是 xx 亿,因为不管后面还有多少个千万、多少个百万,这些在亿这个单位量级中都达不到1,连1都达不到,我们在说大概值的时候会将这些说出去吗?当然不会了,我们甚至都不会去看一眼,因为这些对于亿这个单位来说简直是微乎其微。
现在回到这个代码中,这段代码的执行次数实际上就是 N*N + M ,但是对于N的N次方这个量级来说,后面的M就显的微乎其微了,所以,计算时间复杂度就是计算大概的执行次数,我们就可以省略掉这个M了,这段代码的时间复杂度就是O(N*N)。(有点降维打击的意思,后面会有题目给大家练手)
2.空间复杂度:
上文我们已经说过了,空间复杂度计算的实际就是一个算法运行过程中临时开辟空间的个数。表示方法与计算方法与时间复杂度相同。
话不多说,直接上题目,带给你最直观的感受:
①
void Search_Max(int* arr,int N)
{
int n = 0;
int i = 0;
for(i = 0;i < N-1;i++)
{
if(arr[i] > arr[i+1])
n = arr[i];
}
}
look, Q:请计算这段代码的空间复杂度。
A:O(1)
分析:首先看代码内容,这段代码只创建了 n 和 i 这两个临时变量,可能有的人会问,那函数参数 arr数组 和 N 不算吗,因为这两个属于函数的参数,我们知道函数所需的空间是在栈区开辟的,而函数参数又是函数运行需要的信息,因此,函数参数这部分空间在运行之前就已经开辟了,所以它们并不属于函数运行时所开辟的额外空间。
通过上面的介绍和例题,我相信大家已经对时间复杂度和空间复杂度有了初步的认识,现在我们通过实战来进行深入交流吧(手动坏笑)。
五、实战演练:计算时间复杂度和空间复杂度
1.时间复杂度:
①
void Func1(int N,int M)
{
int i = 0;
int count = 0;
for(i = 0;i < 2*N;i++)
{
count++;
}
for(i = 0;i < M;i++)
{
count++;
}
}
答案:O(N+M)
分析:首先,我们可以看到第一个循环执行了 2*N 次,第二个循环执行了 M 次,按道理来说,执行次数是 2*N+M ,为什么答案却把系数 “2”去掉了呢,我们说过,要以最大量级作为时间复杂度,那么很显然,这里的 N 和 M 都是一个量级的,而N前面的系数”2“,我们思考一下,”2“是常数,回想一下开公司的类比,如果你的资产是亿为单位的,那么乘以常数倍后这个单位会变吗,并不会,所以我们应该将它省略掉。
②
void Func2(int M)
{
int i = 0;
int count = 0;
for(i = 0;i < 100;i++)
{
count++;
}
}
答案:O(1)
分析:可能有小伙伴要问了,这个循环明明执行了100次啊,你写个O(1)是把我当傻子还是把我当瞎子。不要紧张,我只想解释一下代码,或者..........
首先,这个循环是执行了100次不假,不管是100次还是10000或者9999999次,是不是都是常数呢,也可以理解为这O(1)里的1代表的就是常数次。
③
const char* strchr(const char* str , int c)
科普时间到!
strchr:返回字符串中第一次出现的想要查找的字符的地址,若查找不到返回空指针NULL。(c – 想要查找的字符 , str – 查找的字符串(查询的区域))
拓展:strstr(const char* str , const char* strcharset) :返回字符串中第一次出现想要查找的字符串的首地址,若查找不到,返回空指针NULL(strcharset – 想要查询的字符串 , str – 查找的字符串(查询的区域))
答案:O(N)
分析:有没有发现这一题有个关键的东西是未知的,对了,就是字符串的长度,这个决定查询次数的数据是未知的,如果是常数次,那时间复杂度不就是O(1)吗?如果是N次才是O(N)啊,那这一题答案不就是不唯一吗(小声嘀咕:你又把我当傻子了)!下面的知识点请注意哦!
如何抉择:如果是上述情况的话就需要进行选择了,试想一下,我们平时说话、做事会不给自己留后路?如果老师说:“我布置了周末作业,周一或者周一之前交来,你啥时候能交?”,我相信你即使周六能完成,你也不会说:“老师,我周六就能交给你(拍着胸脯)。”为什么会这样呢?因为我们总是会最大限度的给自己留有余地,因为未来有太多未知了,我们总要考虑一些有可能发生的意外,所以计算时间复杂度也一样,我们也要考虑执行次数最多的情况。
④
void BubbleSort(int* a,int n)//冒泡排序
{
for(size_t end = n; end > 0; end--)
{
for(size_t i = 1; i < end;i++)
{
int exchange = 0;
if(a[i-1] > a[i])
{
//Swap -- 交换函数,注意形参传的是地址哦,不然改不了实参的值
Swap(&a[i-1],&a[i]);
exchange = 1;
}
}
if(exchange == 0)
break;
}
}
OK,我们来看看答案吧
答案:O(N^2)
分析:这一题乍一看可能要执行好多次,因为我们看到了两层循环,但是我们仔细看一下会发现有个判断循环是否 break 的变量 exchange,①若这个数组已经是排序好的,那么第一次进入第一层循环,并进入第二层循环进行N次循环后就会直接退出循环,此时时间复杂度为O(N)。②若这个数组是乱序的,那就不可能循环N次然后退出循环了,我们可以总结一下规律,第1次进入循环:循环 n-1 次 、 第2次进入循环:循环 n-2 次、..... 第n次进入循环:循环1次,那执行次数是不是就是:(n-1) + (n-2)+ .... +1,按照之前的理解:首先最外层的循环是执行了n次,里面一层的循环一开始是n-1次,然后每次减少1次循环,那这个量级和n这个量级比起来是不是显的微乎其微呢,所以我们直接将常数省略,循环的次数就是 N*N , 即 N^N。如果你觉得这样理解太牵强,那我用数学证明给你看,我们看每一次循环的循环次数,是不是 n-1 、n-2、.... 、1,这些值累加起来是不是就是循环次数(时间复杂度)?再仔细看一下,这个是不是等差数列,等差数列公式:((n-1)+1)*(n-1)*1/2 = n*(n-1)/2 -- ((首项+尾项)*x项数/2),按照前文说的思路,常数这个量级忽略,系数忽略,即N*N,时间复杂度:O(N^2)。
⑤
int Binarysearch(int* a,int n,int x)
{
int begin = 0;
int end = n-1;
int mid = 0;
while(begin <= end)
{
mid = (begin + end)/2;
if(a[mid] > x)
{
end = mid - 1;
}
else if(a[mid] < x)
{
begin = mid + 1;
}
else
return mid;
}
return;
}
答案:O(lgn)
分析:相信这个答案大家并不陌生,也许好多人都知道,经典的二分查找的时间复杂度,但是这个值是怎么算出来的可能就没那么清楚了。 大家系好安全带,要开始加油了!
首先,我们要知道二分查找的运算规则,就是在一组数据中(已从小到大排序)找到中间值,然后与需要查找的值进行比较,这样一次就可以筛选掉整个查找区域的一半,不断的缩小查找区域,若中间值与查找的值相等则查询结束,上文我们已经说过要考虑最坏结果了,因此在这里就不做分类讨论了,我们直接讨论最坏结果,最坏结果是什么呢?我们知道二分查找第1次就是剩余 n/2 个数,第2次就剩余 n/2/2 个数,那最坏是不是就是经过x次查找只剩1个数 – 即 n/(2^x) = 1,那么我们所求的就是 x 的值,换算成表达式就是 lgn(底数没写默认是以2为底)。
⑥
int Fac(int n)
{
if(n == 0)
return 1;
else
return Fac(n-1)*n;
}
答案:O(N)
分析:很多人看到递归可能有点迷糊,我在这里教大家换一个理解方法,其实递归也是循环的一种,以这个代码为例,我们首先看递归结束条件:n=0 时结束递归,其次,我们看到参数初始值:n = n,最后,我们看下一次递归参数:n=n-1。这不就一目了然了吗,翻译成我们自己的话就是,循环开始的参数是 n,然后每次减少1,直到 n=0,那不就是循环n+1次吗,去掉常数,答案显而易见。
2.空间复杂度:
相对于时间复杂度,空间复杂度还是很友好的(感谢空间复杂度,不然码字码的手都要断了)。来人!喂读(者)公子看题!
①
void BubbleSort(int* a,int n)//冒泡排序
{
for(size_t end = n; end > 0; end--)
{
for(size_t i = 1; i < end;i++)
{
int exchange = 0;
if(a[i-1] > a[i])
{
//Swap -- 交换函数,注意形参传的是地址哦,不然改不了实参的值
Swap(&a[i-1],&a[i]);
exchange = 1;
}
}
if(exchange == 0)
break;
}
}
答案:O(1)
分析:初学者看到这个结果大部分人都是诧异的,心想:这个函数不是有循环吗?还是两层循环,空间复杂度怎么会是O(1)呢?再次重申一遍:空间复杂度计算的是申请的额外空间量,我们可以看到这个算法申请了 exchange 这个变量的空间,但是!但是!这是个局部变量,循环一次这个空间就会销毁,再循环就会申请,所以空间复杂度为O(1)。
②
int* Fibonacci(size_t n)//斐波那契数列
{
if(n == 0)
return 0;
int* fibonacci = (int*)malloc((n+1)*sizeof(int*));
fibonacci[0] = 0;
ifbonacci[1] = 1;
for(size_t i = 2;i <= n;i++)
{
fibonacci[i] = fibonacci[i-1]+fibonacci[i-2];
}
return fibonacci;
}
答案:O(N)
分析:这一题我想没什么可说的,函数中额外开辟了动态内存,数量为 n+1,去掉微乎其微的值,所以空间复杂度O(N)。
③
int Fac(size_t N)//递归
{
if(N == 0)
return 0;
return Fac(N-1)*N;
}
答案:O(N)
分析:这一个函数使用的是递归,我们知道递归就是调用的是函数,只不过这个函数是自己本身,而调用函数就要在栈帧开辟空间,所以调用几次就开辟几次。
总结一下计算空间复杂度:别**惦记着循环几次了,看开辟空间的数量就行了!
六、计算时间复杂度与空间复杂度方法总结
我们说过,时间复杂度和空间复杂度计算方法与表达方式相同,所以就不分开总结了。
1.若计算的结果只有常量,那么直接将复杂度记为O(1)。
2.若表达式中含有多阶变量,找最高阶的,其他的直接忽略掉,找最大量级的就可以。
3.将表达式中的常量系数全部去掉,因为常量系数并不会影响量级的变化。
总的来说,把表达式中不会影响最大量级的参数全部去掉,然后常量系数去掉,最后的结果就是复杂度,表达式只有常量时直接记为O(1)。
(计算空间复杂度时别tm惦记那函数循环体循环几次,就计算开辟额外空间的数量)
有收获的兄弟萌给个赞,看到你们的赞我会觉得再累也值得(mua)。