赞
踩
数据结构在学什么?
如何用程序代码把现实世界的问题信息化
如何用计算机高效地处理这些信息从而创造价值
如何在计算机中表示这些信息?
我们可以定义一个浮点型变量(float)来表示小数,可以定义一个数组表示多个数据等。
什么是数据?
对于音乐家,会用音符描述世界;对于画家,会用画笔描述世界;而对于我们计算机学者而言,则会用数据描述这个世界,由此可以给数据下一个定义:
数据是信息的载体,是描述客观事物属性的数、字符以及所有能输入到计算机中并被计算机程序识别和处理的符号集合,数据是计算机程序加工的原料。
早期计算机处理的数据
世界上第一台计算机被发明出来并不是用来打游戏,看电影的。世界上第一台通用计算机ENIAC其实是战争的产物,当时美国军方要求自己的科学家团队制造这样的一台机器,用于快速地计算弹道轨迹以及计算原子弹爆炸方程等这些复杂的数学计算问题。所以刚开始发明的计算机,是用来处理纯数值的问题,也即是整数或者小数这些加减乘除等等。但随着计算机技术的发展,我们的计算机被应用于生活的方方面面,现在的计算机会用于处理很多非数值型的问题。对于我们熟知的排名,我们可以用数值的大小进行排序,但是数值与数值之间排序的先后关系,这并不是纯数值的问题。而人与人之间的这种个体好友关系,也不是数值的问题。因此,现在我们处理的非数值型问题当中,我们通常会关注这样的两点。
对于非数值型的问题:
关注每个个体的具体信息
关注每个个体之间的关系
我们如何表示每一个个体的具体信息?
我们如何描述这些个体之间的相互逻辑关系呢?
数据元素——描述一个个体
排序中的每一个序列就是一个独立的个体,一个个体会有各类特征,也就是各种各样的信息,对于计算机来说,我们可以用一个数据元素来对应这样的一个独立个体。另一个序列也可以用相同这样的一些属性来进行描述。每一个细分的属性,我们可以把它称为一个数据项。
数据元素:
数据元素是数据的基本单位,通常作为一个整体进行考虑和处理
数据项:
一个数据元素可由若干个数据项组成,数据项是构成数据元素不可分割的最小单位。
什么是数据对象?
当我们把数据输入到计算机当中的时候,我们通常会用一个数据元素来对应现实世界当中的某一个逻辑个体,而我们对这个个体的描述可以拆分为一个一个的数据项。如果我们把属性相同的这些数据元素看作一个整体的话,这个整体就可以组成一个所谓的数据对象。数据对象是具有相同性质的数据元素的集合,是数据的一个子集,而数据结构是相互之间存在一种或多种特定关系的数据元素的集合。
什么是数据结构?
数据结构强调的是数据元素之间一定要有某种关系,而数据对象只强调数据元素之间只要有相同的性质就可以从属于一个数据对象。同样的数据元素,可以根据不一样的逻辑关系组成不同的数据结构。数据结构更加强调数据元素之间的关系,而数据对象更加强调每一个数据结构个体里边的性质要相同。
因此,当我们确定了一种逻辑结构,并且基于这种逻辑结构定义了某一些基本操作之后,我们就相当于定义好了一个数据结构。
其中,顺序存储要求各个数据元素在物理上是相邻存放的。链式存储、链式存储、散列存储可以统称为非顺序存储或离散存储。
在绪论这个部分,为了简化问题,我们就以线性的这种逻辑结构来探讨如何用计算机来表示这种线性关系。
数据类型是一个值的集合和定义在此结合上的一组操作的总称。
抽象数据类型(Abstract Data Type,ADT)是抽象数据组织及与之相关的操作。
定义了一个ADT,就是在“定义”一种数据结构,确定了ADT的存储结构,才能“实现”这种数据结构。
算法(Algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中的每条指令表示一个或多个操作。
时间复杂度
空间复杂度
如何评估算法时间开销?
让算法先运行,事后统计运行时间?
这存在什么问题?
我们来看这样一段代码:
#include <stdio.h>
void Count(int n) {
int i=0;
while(i<=n) {
i++;
printf("Count %d\n", i);
}
printf("CountAdd %d\n", n);
}
int main() {
Count(3000);
}
运行结果:
我们在main函数里调用计数的函数,传入n的值为3000。这里的n其实就是问题的规模,传入参数n越大的话,那么这个循环的次数就会越多,也会导致这个算法的运行时间会越长。
在void函数中,我们将这5行代码进行编号,从1到5。我们默认执行这样任意一行代码所花的时间是相同的,虽然实际上它们执行时间肯定不一样,但是如果考虑太多因素的话,我们就没有办法来评价这个算法了,所以我们把问题简化。可以看出,第1行代码只执行了1次,第2行代码,它是一个while循环,做了一个条件判断,这个循环里边的代码总共会执行3000次,但是这个循环条件的判断,它需要执行3001次,因为执行了第3000次以后,还需要进行1次判断,这个时候i的值为3001,它才跳出循环接着执行第5句。所以条件判断这一句应该是执行了3001次,而里面3、4这两句分别执行了3000次,第五句则执行了1次。所以当问题规模为3000的时候,这个算法的执行就应该花费T(10)=1+3001+2*3000+1单位的时间。如果把3000换成n的话,我们也可以得到时间开销T与n之间的一个表达式关系。
时间开销与问题规模n的关系:
T(n)=3n+3
从这个式子当中我们可以看到,问题规模n和算法的时间开销T之间的关系,它的时间复杂度的表达式相对来说还是比较简单的,而如果换一个算法,它的表达式可能会很复杂,我们就很难从这个表达式当中看出这个算法到底是好算法还是坏算法。所以接下来要探讨的第一个问题是我们是否可以忽略这些表达式当中的某些部分,或者说是否可以把这个时间复杂度的表达式给简化呢。第二个要探讨的问题是如果一个算法,它有好几千行代码,我们如果还用刚才那种一行一行来数的方式统计的话,显然这种思路是不切实际的。
时间复杂度各数量级比较:
#include <stdio.h>
void Count(int n) {
int i=0;
while(i<=n) {
i++;
printf("Count %d\n", i);
for (int j=1; j<=n; j++) {
printf("Test");
}
}
printf("CountAdd %d\n", n);
}
我们在while循环里面又加了一个for循环,这个for循环会循环n次,外面一层的while循环也会循环n次,因此,里面的for循环总共会执行n*n,也就是n ^2次。考虑时间开销的话,外层循环的这些语句,它们的时间开销应该是O(n)这个数量级的,因为它总共执行n次,而内层循环的这些语句,它的执行次数就应该是n ^2的数量级。根据之前提到的加法规则,我们只需要保留更高阶的部分,也即是保留O(n ^2)。
如果有多层嵌套循环,只需关注最深层循环循环了几次。
最坏时间复杂度:最坏情况下算法的时间复杂度。
平均时间复杂度:所有输入示例等概率出现的情况下,算法的期望运行时间。
最好时间复杂度:最好情况下算法的时间复杂度。
对于最好时间复杂度,其实参考的意义不大,因为当我们在评价一个算法的时候,我们主要是想看一下这个算法在一些不好的情况下,是否会出现运行时间过长的问题。因此,当我们评价一个算法的时候,一般只考虑最坏和平均复杂度。
void Count(int n) {
int i=0;
while(i<=n) {
i++;
printf("Count %d\n", i);
}
printf("CountAdd %d\n", n);
}
当以上算法运行之前,需要把这个程序相关的程序代码放到内存当中,CPU便可以开始执行一行一行的代码。同时,在调用以上算法的时候,会传入一个int型的参数,需要定义一个int型的变量,当这段代码运行的时候,内存中还需要有一小片区域用于存放和它相关的一系列局部变量,参数信息。其中的i和n都是int型的变量,所以至少还需要8个字节用于存放和它相关的数据。在这个算法中,n是问题规模,但是不管n的值怎么变化,这个算法在执行的过程中,它所需要的这个内存空间大小都是固定不变的一个常数值,所以这个算法的空间复杂度就是常数阶的。空间复杂度为:
S(n)=O(1)
S表示“Space”
如果算法所需要的内存空间和问题规模n并没有关系,也即是它的空间复杂度是常数阶的话,那么我们就可以称这种算法,它可以原地工作。
算法原地工作:算法所需内存空间为常数。
void test(int n) {
int flag[n]; //声明一个长度为n的数组
int i;
//...省略代码
}
在以上算法中,我们定义了一个int型的数组,这个数组的长度为n,n为问题规模。所以在这个算法当中,它运行的过程中所需要的内存空间大小就会和问题规模n有联系。假设一个int型的变量占4个字节,那么存放参数n就需要4个字节。对于算法中的int数组,每个数组元素都会占4个字节,总共有n个元素,所以这个数组总共会占4n的空间,i这个变量又会占4个字节,所以加起来就是4n+8的空间,这个算法所需要的空间就和问题规模n有关系了。和时间复杂度类似,当我们在谈论一个算法的空间复杂度的时候,其实我们只需要关注它所需要消耗的这个空间是什么数量级,也即是什么阶数就可以了,所以我们同样是用O表示法来表示。对于4n+8这个表达式,它的阶数其实就是等于O(n),因此这个算法的空间复杂度就是O(n)的数量级。
如果说在函数当中,定义了某些变量,但是这个变量它所占的空间和问题规模n没有关系的话,那么这个类型的变量最多也就会在表达式当中增加一个常数项。但是由于我们最终都是要转换成O表示法,也即是只关心它的阶数,所以这种常数项对我们最终的结果不会产生任何影响。因此,当我们在分析一个算法的空间复杂度的时候,其实我们只需要关注它所需要的存储空间大小和问题规模相关的这些变量就可以了。
void test(int n) {
int flag[n][n]; //声明n*n的二维数组
int i;
// ...省略代码
}
以上算法中,我们定义了一个二维数组,它的大小为n*n。根据刚才的分析,我们知道,像n这个参数,还有i这个变量,并不需要把这两个变量考虑进去,因为存储这两个变量最多也就增加一个常数项,而唯一和问题规模n相关的变量是flag这个数组。当问题规模为n的时候,存储这个数组所需要的内存空间应该是4n ^2 ,因为一个int型的变量是四个字节。所以这个算法的空间复杂度应该是n ^2这样的一个数量级:S(n)=O(n ^2)
void test(int n) {
int flag[n][n]; //声明n*n的二维数组
int other[n]; //声明一个长度为n的数组
int i;
//...省略代码
}
再看以上算法,这个例子当中,我们定义了一个二维数组,它的大小为n*n,然后一个一维数组,它的长度为n。所以当这个算法运行的时候,内存当中需要存这样的两个数组,同时也需要存其它的一些变量。存储flag这个数组所需要的内存空间大小,它的数量级应该是n ^2,存储一维数组,它所需要的内存空间开销应该是O(n)这样的一个量级,然后存储其它的变量所需要的内存空间大小应该是一个常量,也即是O(1)这样的量级:S(n)=O(n ^2)+O(n)+O(1)=O(n ^2)
之前我们提到过,当多个O相加的时候,我们只需要保留阶数最高的那个O,也即是n ^2这一项就可以了。之前我们提到的这些例子当中,导致一个算法的空间复杂度变化的主要原因是这个算法当中定义的某些变量,存储这些变量需要一些内存空间的开销,但是还有一种情况也会导致内存空间开销的变化,就是函数调用的时候,也会导致内存开销的增加。
#include <stdio.h>
void Count(int n) { //n为问题规模
int a,b,c; //声明一系列局部变量
//...省略代码
if (n>1) {
Count(n-1);
}
printf("CountAdd %d\n", n);
}
int main() {
Count(5);
}
在这个函数当中定义了三个局部变量,根据之前的分析我们知道这些变量它所占的空间大小应该是一个常量。当参数n大于1的时候,它会递归地调用自己的函数,让传入的参数减1,最后会进行一个打印输出当前这个函数n的值。我们在main函数里对Count函数进行调用,传入的参数为5,最终运行结果如下:
我们来分析一下这个递归调用的过程,刚开始,这段程序要运行,需要把它的程序代码放入内存当中,这片空间大小是固定的,与问题规模无关。接下来,main函数调用了Count函数,然后传入的参数是5,这个函数开始运行。然后声明了三个变量a,b,c。和我们之前分析的那些函数一样,也需要把这个函数运行过程中所涉及到的参数还有变量存放到内存里。继续往后运行,发现此次n的值是大于1的,所以这个函数又会调用它自身,但是这个它传入的参数应该是5-1,也即是传入4。在这次调用中n的值是4,同时在这次调用当中还会再声明一次a,b,c。所以这次函数调用相关的这些参数还有变量,同样也需要在内存中开辟一片空间来存放。在第一层调用当中,n的值等于5,然后里面有a,b,c这些变量。然后在第二层调用当中,n的值其实是4,虽然它们都是n,但是在内存当中其实是两份不一样的数据。同时在第二层调用当中,它也会有a,b,c这几个变量,同样的,虽然名字看起来一样,但其实在内存当中这几个变量都是存放在不同的区域的。再往后的几层调用都是一样,每一层的调用都需要把这层调用当中的参数和a,b,c这些局部变量用一片专门的内存空间来存储。当调用到第5层的时候,n的值已经变成了1,条件已经不满足,这次的调用会直接跳过if语句,然后执行printf语句,此时n的值是1。接下来函数调用结束,返回之后就可以把这次函数相关的信息数据给删除了。当返回到这一层的时候,系统会根据内存当中保存的信息来恢复函数相应的执行环境。每一级的函数调用肯定都需要k个字节,k为常数。当n为5的时候,总共发生了5层的递归调用。因此,这个递归调用的层数和我们的问题规模n刚好是相等的。所以当问题规模为n的时候,它所需要的内存空间大小就应该是kn个字节。如果用O表示法只关注它的阶数的话,那么可以把k给去掉。所以这个程序,它的空间复杂度就应该是O(n)这样的一个数量级:S(n)=O(n)。空间复杂度=递归调用的深度,因为每一层的递归调用所需要的这个内存空间大小都是常量k个字节。但其实也会有一些算法,它每一层的递归调用所需要的内存空间大小是不一样的。比如我们把刚才那个递归调用程序给稍微改一下,每一层的递归都会定义一个int型的数组,这个数组的长度和这一级递归的参数n是相同的。也即是第一层调用的时候,n的值为5,那么这个数组的长度就是5,接下来由于n大于1,所以下一级的调用这个n的值,传入的参数就变成了4,因此下一级的调用当中,这个数组的长度也为4,以此类推。总之,由于声明的数组长度和这级调用所传入的参数n是有关的,每一级的函数调用当中,用于存放这些变量所需要的空间大小肯定也是不一样的,最下面一级调用数组的长度为1,然后第二级的调用数组的长度为2,第3级的调用数组的长度为3,以此类推,第n级的调用数组长度就应该是n。所以综合来看,各级递归调用所需要的存储和flag数组的空间大小就应该是1/2n ^2+1/2n。显然在这个算法当中,它的空间复杂度S(n)就应该是O(n ^2),S(n)=O(n ^2)。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。