![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/bd211d98acf8c5dd7cfd6fa2f02e2714.png)
数据结构的基本概念
基本概念和术语
-
数据结构在学什么?
如何用程序代码把现实世界的问题信息化
如何用计算机高效地处理这些信息从而创造价值 -
如何在计算机中表示这些信息?
我们可以定义一个浮点型变量(float)来表示小数,可以定义一个数组表示多个数据等。 -
什么是数据?
对于音乐家,会用音符描述世界;对于画家,会用画笔描述世界;而对于我们计算机学者而言,则会用数据描述这个世界,由此可以给数据下一个定义:
数据是信息的载体,是描述客观事物属性的数、字符以及所有能输入到计算机中并被计算机程序识别和处理的符号集合,数据是计算机程序加工的原料。 -
早期计算机处理的数据
世界上第一台计算机被发明出来并不是用来打游戏,看电影的。世界上第一台通用计算机ENIAC其实是战争的产物,当时美国军方要求自己的科学家团队制造这样的一台机器,用于快速地计算弹道轨迹以及计算原子弹爆炸方程等这些复杂的数学计算问题。所以刚开始发明的计算机,是用来处理纯数值的问题,也即是整数或者小数这些加减乘除等等。但随着计算机技术的发展,我们的计算机被应用于生活的方方面面,现在的计算机会用于处理很多非数值型的问题。对于我们熟知的排名,我们可以用数值的大小进行排序,但是数值与数值之间排序的先后关系,这并不是纯数值的问题。而人与人之间的这种个体好友关系,也不是数值的问题。因此,现在我们处理的非数值型问题当中,我们通常会关注这样的两点。
对于非数值型的问题: -
关注每个个体的具体信息
-
关注每个个体之间的关系
我们如何表示每一个个体的具体信息?
我们如何描述这些个体之间的相互逻辑关系呢? -
数据元素——描述一个个体
排序中的每一个序列就是一个独立的个体,一个个体会有各类特征,也就是各种各样的信息,对于计算机来说,我们可以用一个数据元素来对应这样的一个独立个体。另一个序列也可以用相同这样的一些属性来进行描述。每一个细分的属性,我们可以把它称为一个数据项。 -
数据元素:
数据元素是数据的基本单位,通常作为一个整体进行考虑和处理 -
数据项:
一个数据元素可由若干个数据项组成,数据项是构成数据元素不可分割的最小单位。 -
什么是数据对象?
当我们把数据输入到计算机当中的时候,我们通常会用一个数据元素来对应现实世界当中的某一个逻辑个体,而我们对这个个体的描述可以拆分为一个一个的数据项。如果我们把属性相同的这些数据元素看作一个整体的话,这个整体就可以组成一个所谓的数据对象。数据对象是具有相同性质的数据元素的集合,是数据的一个子集,而数据结构是相互之间存在一种或多种特定关系的数据元素的集合。 -
什么是数据结构?
数据结构强调的是数据元素之间一定要有某种关系,而数据对象只强调数据元素之间只要有相同的性质就可以从属于一个数据对象。同样的数据元素,可以根据不一样的逻辑关系组成不同的数据结构。数据结构更加强调数据元素之间的关系,而数据对象更加强调每一个数据结构个体里边的性质要相同。
数据结构三要素
- 逻辑结构
- 集合:各个元素同属于一个集合,别无其它关系。如世界前500强企业便是一个集合。
- 线性结构:数据元素之间是一对一的关系。除了第一个元素,所有元素都有唯一前驱;除了最后一个元素,所有元素都有唯一后继。如就餐时顾客的等待位置队列。
- 树形结构:数据元素之间是一对多的关系。如思维导图。
- 图状结构:数据元素之间是多对多的关系。如描述各个地点之间道路信息的时候。
- 数据的运算
针对于某种逻辑结构,结合实际需求,定义基本运算。
- 查找第i个数据元素,如查找世界前500强企业中排名第一的企业。
- 在第i个位置插入新的数据元素,如将新晋企业插入到世界前500强企业的排名中。
- 删除第i个位置的数据元素,如世界前500强企业发生变更,则需要删除原排名中相应的企业。
因此,当我们确定了一种逻辑结构,并且基于这种逻辑结构定义了某一些基本操作之后,我们就相当于定义好了一个数据结构。
- 物理结构(存储结构)
如何用计算机实现这种数据结构?
- 顺序存储:把逻辑上相邻的元素存储在物理位置上也相邻的存储单元中,元素之间的关系由存储单元的邻接关系来体现。
- 链式存储:逻辑上相邻的元素在物理位置上可以不相邻,借助指示元素存储地址的指针来表示元素之间的逻辑关系。
- 索引存储:在存储元素信息的同时,还建立附加的索引表。索引表中的每项称为索引项,索引项的一般形式为(关键字,地址)。
- 散列存储:根据元素的关键字直接计算出该元素的存储地址,又称哈希(Hash)存储。
其中,顺序存储要求各个数据元素在物理上是相邻存放的。链式存储、链式存储、散列存储可以统称为非顺序存储或离散存储。
在绪论这个部分,为了简化问题,我们就以线性的这种逻辑结构来探讨如何用计算机来表示这种线性关系。
- 数据结构的三要素
- 若采用顺序存储,则各个数据元素在物理上必须是连续的;若采用非顺序存储,则各个数据元素在物理上可以是离散的。
- 数据的存储结构会影响存储空间分配的方便程度。
- 数据的存储结构会影响对数据运算的速度。
- 数据类型与抽象数据类型
数据类型是一个值的集合和定义在此结合上的一组操作的总称。
- 原子类型,其值不可再分割的数据类型,如bool类型、int类型。
- 结构类型,其值可以再分解为若干成分的数据类型,如坐标。
抽象数据类型(Abstract Data Type,ADT)是抽象数据组织及与之相关的操作。
定义了一个ADT,就是在“定义”一种数据结构,确定了ADT的存储结构,才能“实现”这种数据结构。
算法和算法评价
算法的基本概念
- 什么是算法?
程序=数据结构+算法
数据结构:如何用数据正确地描述现实世界地问题,并存入计算机。
算法:如何高效地处理这些数据,以解决实际问题。
算法(Algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中的每条指令表示一个或多个操作。
- 算法的特性
- 有穷性:一个算法必须总在执行有穷步之后结束,且每一步都可在有穷时间内完成。
- 确定性:算法中每条指令必须有确切的含义,对于相同的输入只能得出相同的输出。
- 可行性:算法中描述的操作都可以通过已经实现的基本运算执行有限次来实现。
- 输入:一个算法有零个或多个输入,这些输入取自于某个特定的对象集合。
- 输出:一个算法有一个或多个输出,这些输出是与输入有着某种特定关系的量。
- “好”算法的特质
- 正确性:算法应能够正确地解决求解问题。
- 可读性:算法应具有良好的可读性,以帮助人们理解。
- 健壮性:输入非法数据时,算法能适当地做出反应或进行处理,而不会产生莫名其妙的输出结果。
- 高效率与低存储需求。
算法效率的度量
时间复杂度
空间复杂度
如何评估算法时间开销?
让算法先运行,事后统计运行时间?
这存在什么问题?
- 和机器性能有关,如:超级计算机与单片机
- 和编程语言有关,越高级的语言执行效率越低
- 和编译程序产生的机器指令质量有关
- 有些算法是不能事后再统计的,如:导弹控制算法
- 算法时间复杂度
事先预估算法时间开销T(n)与问题规模n之间的关系(T表示“Time”)
我们来看这样一段代码:
#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之间的关系,它的时间复杂度的表达式相对来说还是比较简单的,而如果换一个算法,它的表达式可能会很复杂,我们就很难从这个表达式当中看出这个算法到底是好算法还是坏算法。所以接下来要探讨的第一个问题是我们是否可以忽略这些表达式当中的某些部分,或者说是否可以把这个时间复杂度的表达式给简化呢。第二个要探讨的问题是如果一个算法,它有好几千行代码,我们如果还用刚才那种一行一行来数的方式统计的话,显然这种思路是不切实际的。
- 问题1:是否可以忽略表达式某些部分?
时间开销与问题规模n的关系:
T1(n)=3n+3
T2(n)=n^ 2+3n+1000
T3(n)=n^ 3+n^ 2+3n+1000000
对于以上三种关系,可以看出,当问题规模足够大的时候,我们可以忽略掉这些表达式当中更低阶的部分,甚至还可以忽略的前面的系数。对于时间复杂度的度量,我们只需要关注它的表达式当中最高阶的那一项,并且把它的系数化为1,然后在前面添O就可以了,用这样的方式来表示一个算法的时间开销应该是什么数量级的。也可以用数学语言来描述刚才我们提到的过程:
加法规则:
T(n)=T1(n)+T2(n)=O(f(n))+O(g(n))=O(max(f(n),g(n)))
多项相加,只保留最高阶的项,且系数变为1。
乘法规则:
T(n)=T1(n)×T2(n)=O(f(n))×O(g(n))=O(f(n)×g(n))
多项相乘,都保留。
因此,当我们描述一个算法的时间复杂度的时候,一般来说,我们只需要关注它的时间复杂度是什么样的一个数量级就可以了,可以用O表示法来表示。
T1(n)=O(n)
T2(n)=O(n^ 2)
T3(n)=O(n^ 3)
时间复杂度各数量级比较:
- 问题2:如果代码行数过多,如何对算法的时间复杂度进行计算?
如果我们在前面的void函数中的第1行代码前又插入了1000行顺序执行的代码,那么T(3000)=1000+1+3001+2*3000+1
由问题1可知,我们只需要关注时间开销的数量级就可以了,也就是用O表示法来表示。虽然后面的常数项看起来大了很多,但是如果用O表示法来记的话,其实它们都是O(n)的数量级。因此得出结论,顺序执行的代码只会影响常数项,可以忽略。只需挑循环中的一个基本操作分析它的执行次数与n的关系即可。 - 既然我们只需要关注代码当中循环的部分,那如果说有嵌套的循环,那这种代码应该怎么拆分呢?
#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)。
如果有多层嵌套循环,只需关注最深层循环循环了几次。
最坏时间复杂度:最坏情况下算法的时间复杂度。
平均时间复杂度:所有输入示例等概率出现的情况下,算法的期望运行时间。
最好时间复杂度:最好情况下算法的时间复杂度。
对于最好时间复杂度,其实参考的意义不大,因为当我们在评价一个算法的时候,我们主要是想看一下这个算法在一些不好的情况下,是否会出现运行时间过长的问题。因此,当我们评价一个算法的时候,一般只考虑最坏和平均复杂度。
- 空间复杂度
空间开销(内存开销)与问题规模n之间的关系
在一个程序运行的时候,需要哪些内存空间?
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)。