这是一个全新的专栏,内容主要是用C语言学习数据结构的初阶内容,一是复习,二是希望能带给大家一些帮助。
从今天开始,我们进入数据结构与算法的学习,在进入今天的内容之前,我们先了解一下什么是数据结构,什么是算法。
定义解释
数据结构: 数据结构是计算机存储、组织数据的方式,是存在一种或多种特定关系的数据元素的集合。
举个例子:比如我们在校学生的信息,要知道他是哪个班,哪个专业,哪个系,是大几的,这时候我们就可以用一个结构体把他们储存起来,然后再使用一些数据结构,描述他们的关系,例如数组,链表,树,图这样的数据结构,把学生信息按照一种特殊的方式存储起来,方便查找,节省空间和时间。
算法:
算法是良好的计算过程,是通过一系列的计算步骤,解决你的问题,把输入数据变成我们想要的输出结果的一种方法。
既然是方法那就有好也有坏,我们怎么评估一个算法是好还是坏呢?这就要进入我们今天的重点,复杂度:
算法的复杂度
一个算法在编写成可执行程序后,运行时会耗费时间资源和内存资源,我们衡量一个算法的好坏,就从时间和空间两个维度开始衡量,这也就是时间复杂度和空间复杂度。
时间复杂度是衡量算法运行快慢,空间复杂度是衡量算法运行所需要的额外空间。我们也知道现在计算机的存储容量已经很大了,我们在关注的时候,更加关注一个算法的时间复杂度,他的效率如何。 下面来介绍一下时间复杂度及其求法:
时间复杂度
在计算机科学中,算法的时间复杂度是一个函数(数学函数表达式),他定量的描述了算法的运行时间,但我们仔细的想一下,一个算法换做不同的电脑,不同的编译器,甚至是相同电脑的不同时间,运行时间都是不一样的所以我们对时间复杂度的计算并不是一个精确的值,而是一种比例,这种比例是通用的,是与算法中语句的执行次数成比例的,那么一个算法中的语句有很多,我们又该选取哪一条呢?在这里先不直接给大家,先举一个例子,大家通过例子来感受一下:
#include<stdio.h>
int main()
{
int cnt = 0;
int cmt = 0;
for(int i=0;i<10;++i)
{
cmt++;
for(int j=0;j<10;++j)
{
cnt++;
}
}
return 0;
}
我们看这样一个算法,我们衡量算法的时间复杂度,要的是找一条基础性的语句,算法每执行一次,他都会执行的语句,这样的语句才有代表性,就例如上面嵌套循环的代码 里面有在外层循环的cmt++; ,也有在内层循环的cnt++; ,究竟那一条才是基础语句呢?我们可以分析一下 : cmt++在外层循环,也就是他执行了10次,cnt++在内层循环,他执行了100次。在这里注意,我们衡量一个算法的时间复杂度,一定是要根据其执行次数最多的语句来进行的,后面也会提到,我们看的是算法执行时间最长的情况作为时间复杂度,这也很好理解,这样的话情况只会更好,不会更坏,所以在这个算法中,时间复杂度就是O(100),在后面的介绍中我们会对这样的复杂度进行简化。 按照这样的思路,我们提高一下难度再进行计算: 我们看这样一串代码,来计算他的时间复杂度,毫无疑问,基础语句是cnt++,因为只有cnt++才是每一次循环都执行的语句,第一个嵌套循环,100次,这里就不多说了,第二个for循环 cnt++执行了2*N次,第三个for循环cnt++执行了 N*N = N^2次 ,加起来就是 N^2+2*N+100 次,时间复杂度就是
O(N^2+2*N+100)
这式子未免也太长了点,要是在多几十个循环,岂不是电脑屏幕都装不下了?不用慌,我们有大O渐进表示法,这个表示法只考虑最高阶,忽略低阶函数对复杂度的影响,可以想一下当N越大时 N^2就越大,而其他的项相当于最高阶项就越小,当N = 10000000时,有没有那个100,意义就不大了。所以我们就可以把上面的表达式直接写成O(N^2) 就可以了,那如果函数表达式时一个常数,如O(100),O(109),我们就可以直接把他写成O(1)表示算法的复杂度会改变,是常数阶,也是最优的时间复杂度。 在这里我们把大O渐进表示法的三条规则给大家罗列出来,方便记忆:
1.用常数1取代算法中所有的加法常数
2.在函数中只保留最高阶项
3.如果最高阶存在而且系数不为1,把系数改为1
在进一步了解算法的时间复杂度之前,我想问大家一个问题,假如我们有一个查找函数,我们是取其最优情况,一次就找到的情况为时间复杂度,还是取其最差的情况,还是取其平均情况呢?答案是:取其最差情况,这样就只有更好,没有更差了。
下面我们看几种常见算法的时间复杂度计算:
冒泡排序
如果有不熟悉冒泡排序的,没关系,在这里我们回忆一下: 像这样,第一趟把第一大的数据放到末尾,第二趟不再对末尾进行比较,从末尾-1开始比较(因为末尾已经是最大的了,不需要比较),把第二大的放在末尾-1的位置…一直到不需要交换,那排序就完成了,像这样最大的数依次向后放的排序就叫冒泡排序。 那么他的时间复杂度应该如何计算呢 ,基础语句肯定就是if判断,判断了多少次,就是函数执行了多少次。第一趟执行N-1次,第二趟执行 N-2次 第三趟执行 N-3次,一直执行到1次为止(只判断一次且不交换),这样看起来是一个公差为1的等差数列,那也就是 N*(N-1)/2 ,我们再用大O渐进表示法进行简化,那就是O(N^2)。
二分查找
int BinarySearch(int *a,int n,int x)
{
assert(a);
int begin = 0;
int end = n-1;
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;
}
二分查找相信大家都不陌生了,但要是让大家算算它的时间复杂度呢? 想要算出它的时间复杂度,那我们先要知道它的基础语句是什么,显然,每次循环的时候都会执行的语句是int mid = begin + ((end-begin)>>1); ,他就是我们的基础语句,那只需要搞明白他会执行多少次就可以了,我们先回忆一下二分查找的原理:
假设有一串有序(从小到大排列)的整数,让我们查找其中的某一个数,由于是有序的,我们把要查找的数字(key)和区间中点数字进行比较,如果key大于区间中点的值,我们就抛弃左半区间,再取中点,重复步骤一直到key == a[mid]为止,这就是二分查找,假设我们要查找的数字是区间最左端的数据,画图如下:
一直到区间只有一个元素,也就是第一个元素的时候,我们查找就算完成了。 取这种情况还有一个作用:我们在前面的学习提到过,算法的时间复杂度,求的是最差的形况,那么对于二分查找的情况来说,最好的情况是一开始要找的数据就在中点,这样一次就出来了,最坏的情况就是我们已经分到不能再分了,只剩下一个元素了,也就是图示的这种情况。
所以我们要针对这种情况求时间复杂度,基础语句是int mid = begin + ((end-begin)>>1); ,假设要查找的数组中有N个数字,那基础语句执行的步骤可以表示为:N/2/2…/2 = 1,N除以2的次数也就是我们要求的时间复杂度,那怎么表示这个次数呢?我们可以把所有的/2都乘过去 N = 12222… 假设算法执行了M次,那也就是 N = 2M,紧接着对两边取对数 M = log2N,也算是一种逆向思维,从1个元素开始还原整个数组,这样,我们就把二分查找的时间复杂度求出来了。
递归算法
递归的思想就是大事化小,小事化了,再把过程往回反馈的过程,我们先来看一个简单的求阶乘的递归函数:
long long Fac(size_t N)
{
if(N==0)
return 1;
return Fac(N-1)*N;
}
我们知道,递归函数都是一层一层的调用,所以递归的复杂度计算公式事:单次执行循环的次数*递归的层数,我们画图来求一下(假设求4的阶乘): 大概就是这样,我们先找到算法中的基础语句,肯定就是那个 return 了,在每个方法中都执行1次,递归的层数是N次,那这样 N*1 ,这个递归算法的复杂度就是O(N)。
下面再看看经典的斐波那契数列
long long Fac(size_t N)
{
if(N==0)
return 1;
return Fac(N-1)*N;
}
这个就留给大家来练练手,给点提示,加起来是一个等比数列,简化是O(2N)。 留个图好理解:
小结
对于常见算法的时间复杂度分析就到此结束,相信大家对于时间复杂度也有了一定了了解,想要深入了解的可以把自己了解的算法都算一遍,加深自己的印象。
|