引言
教材
- 《数据结构C语言版》严蔚敏版
- 大话数据结构
参考资料
LeetBook
参考视频
王道考研数据结构
数据结构与算法基础-王卓
作者
尘鱼好美
想说的话
这里的作业是引用《数据结构C语言版》严蔚敏版中的作业。
笔记尚未写好,也就是说,后面可能会有很多排版问题,请见谅,这个寒假会更新完毕的!
致谢
第一章 绪论
1.1 什么是数据结构
数据结构是一门研究非数值计算的程序设计问题中计算机操作对象以及它们之间关系和操作的学科。
1.2 基本概念和术语
数据
- 是能输入计算机且能被计算机处理的各种符号的集合
- 是信息的载体
- 是对客观事物符号化的认识
- 能够被计算机识别、存储和加工
包括:
- 数值型的数据:整数、实数等
- 非数值型的数据集:文字、图像、图形、声音等
数据元素:是数据的基本单位(相当于数据库的元组)
-
数据元素在计算机程序中通常作为一个整体进行考虑和处理
-
也简称为元素,或称为
记录
、结点或顶点 -
一个数据元素由若干个数据项组成
在数据库原理中,层次模型采用的就是记录,也就是关系数据库中所说的元组。从中我们可以理解了,某一个类的实例就是数据元素。
数据项:构成数据元素的不可分割的最小单位
相当于数据库中的属性;在数据库中我们也曾叫作数据项。
数据对象:是性质相同的数据元素的集合,是数据的一个子集
在不产生混淆的情况下,我们一般把数据对象简称为数据。
数据结构:数据元素不是孤立存在的,它们之间存在着某种关系,数据元素相互之间的关系称为结构。
在看完以上的概念后,我想我有必要来说几句。
我们可以举生活中的例子来说明以上的概念。假设我们把人看成数据(数据对象)
,而把小红小明看成数据元素
,把小红小明等人的性别、学号、年龄看成数据项
,如果我们要把小红小明等人按照一定的规则放在一起,这个规则就叫数据结构
。
数据结构包含以下三个方面的内容
- 数据元素之间的
逻辑关系
,也称为逻辑结构
。 - 数据元素及其关系在计算机内存中的表示(又称为映像),称为数据的
物理结构
或数据的存储结构
。 - 数据的
运算和实现
,即对数据元素可以施加的操作以及这些操作在相应的存储结构上的实现。
1.2.1 数据结构的两个层次
逻辑结构
- 描述数据元素之间的逻辑关系
- 和数据的存储无关,独立于计算机
- 是从具体问题抽象出来的数学模型
物理结构(存储结构)
- 数据元素及其关系在计算机存储器中的结构(存储方式)
- 是数据结构在计算机中的存储形式
逻辑结构和存储结构的关系
- 存储结构是逻辑关系的映像与元素本身的映像
- 逻辑结构是数据结构的抽象,存储结构是数据结构的实现
- 两者综合起来建立了数据元素之间的结构关系
1.2.2 逻辑结构的种类
划分方法一:
- 线性结构:有且仅有一个开始和一个终端结点,并且所有结点都最多只有一个直接前驱和一个直接后继。例如:线性表,栈,队列,图
- 非线性结构:一个结点可能有多个直接前驱和直接后继。例如:树,图
划分方法二:
- 集合结构:结构中的数据元素直接除了同属于一个集合的关系外,无任何其他关系。
- 线性结构:结构中的数据元素之间存在着一对一的线性关系。
- 树形结构:结构中的数据元素之间存在着一对多的层次关系。
- 图状结构或网状结构:结构中的数据元素之间存在着多对多的任意关系。
1.2.3 四种基本的存储结构
-
顺序存储结构:
用一组连续的存储单元一次存储数据元素,数据元素之间的逻辑关系由元素的存储位置来表示
C语言中用数组来实现顺序存储结构
-
链式存储结构:
用一组任意的存储单元存储数据元素,数据元素之间的逻辑关系用指针来表示。
C语言中用指针来实现链式存储结构
-
索引存储结构:
在存储结点信息的同时,还建立附加的索引表。
-
散列存储结构:
根据结点的关键字直接计算出该结点的存储地址。
从上面的简洁的总结来看,我们如何更好地理解这些数据结构呢?实际上,逻辑结构指的是数据元素之间的关系,如果拿数据库中类比的话,就是你要用层次数据模型还是用关系数据模型来表示数据的区别。而物理结构指的是数据在电脑上面存放的形式,有的是整整齐齐连续排列在内存中,有些是不连续地排列在内存中。
而之所以出现这两种结构,是因为实际业务的需要,你想想看,如果使用连续的内存(顺序存储结构)来存储数据,万一你频繁地删除数据,而且考虑最坏情况,刚好删掉第二个数据元素,那么假设总共由n个数据元素的话,那么后面就有n-2个数据元素需要前移,十分麻烦。
但是如果用不连续存储(链式存储结构)就不一样了,链式存储结构采用的是一种寻址的方式,即数据元素能够寻找上一个数据元素的地址。这样的话如果频繁做更改,拿删除来说,只需要把一个数据元素删除,然后前后的数据元素地址改动一下即可,当然,这些操作都是有小细节的,这里只是大概提一下罢了。
前几年香港有部电影叫无间道 ,大陆还有部电视剧叫潜伏 ,都很火,不知道大家有没有看过 大致说的是,某一方潜伏在敌人的内部,进行一些情报收集工作。为了不暴露每个潜伏人员的真实身份,往往都是单线联系,只有上线知道下线是谁,并且是通过暗号来联络。正常情况下,情报是可以顺利地上传下达的,但是如果某个链条中结点的同志牺牲了,那就麻烦了,因为其他人不知道上线或者下线是谁,后果就很严重了,比如在无间道中,梁朝伟是警方在黑社会中的卧底,一直是与黄秋生扮演的警官联络,可当黄遇害后,梁就无法证明自己是一个警察。所以影片的结尾, 梁朝伟用枪指着刘德华的头说,"对不起,我是警察 ";刘德华马上反问道:“谁知道呢? "是呀,当没有人可以证明你身份的时候,谁知道你是谁呢?影片看到这里,多让人有些唏嘘感慨 。这其实就是链式关系的一个现实样例。
逻辑结构是面向问题的,而物理结构是面向计算机的,其基本的目标就是将数据及其逻辑关系存储到计算机的内存中。
1.2.4 数据类型和抽象数据类型
在使用高级程序设计语言编写程序时,必须对程序中出现的每个变量、常量或表达式,明确说明它们所属的数据类型。
例如,C语言中:
- 提供int,char,float,double等基本数据类型
- 数组、结构、共用体、枚举等构造数据类型
- 还有指针、空类型
- 用户还可用typedef自己定义数据类型
但是上面说的这些仅仅只能够去表示一些简单的数据类型,对于栈、线性表、队列、树这些,很明显表示不了。
高级语言中的数据类型明显地或隐含的规定了在程序执行期间变量和表达的所有可能的取值范围,以及在这些数值范围上所允许进行的操作。
例如:C语言定义变量i为int类型,就表示i是[-min,max]范围的整数,在这个整数集上做加减乘除操作。
所以,什么是数据类型?
数据类型:是一组性质相同的值的集合以及定义在这个值集上的一组操作的总称。
抽象数据类型(Abstract Data Type,ADT):是指一个数学模型以及定义在该模型上的一组操作。
- 由用户定义,从问题抽象出数据模型。(逻辑结构)
- 还包括定义在数据模型上的一组抽象运算。(相关操作)
- 不考虑计算机内的具体存储结构和运算的具体实现算法
抽象数据类型的定义格式
ADT 抽象数据类型名{
数据对象:<数据对象的定义>
数据关系:<数据关系的定义>
基本操作:<基本操作的定义>
}ADT 抽象数据类型名
其中:数据对象、数据关系的定义用伪代码描述。
基本操作的定义格式:
基本操作名<参数表>
初始条件:<初始条件描述>
操作结果:<操作结果描述>
说明:
- 参数表:参数表中引用参数以&开头,表示不仅可以作为参数输入值,还能返回操作后的结果。
- 初始条件:描述操作执行之前数据结构和参数应满足的条件,若不满足,则操作失败,并返回相应的,并返回相应出错信息。若初始条件为空,则省略
- 操作结果:说明操作正常完成之后,数据结构的变化状况和返回的结果。
我又要开始baba了。
对于纯靠背诵的人或复习的人来说,或许上面的总结言简意赅,但是对于初学者来说,我相信理解来龙去脉比记忆要重要许多。
数据类型的出现不是偶然而是必然,如果不规定数据类型的话,那么就会造成资源的浪费。拿C语言来举例,如果你不使用int,long这些基本数据类型的话,那么无端给出一个7,谁知道你的7占几个字节?好比住房子,诶,当然,如果你想提前举手:住房子当然是越大越好啦,那国家可就不同意了,你住那么大,别人住哪里?所以,根据公民的所需来规定每个公民住所的大小,对应到C上,每个数据都应该有自己在内存中所占的对应大小。故数据类型出现了。
那么抽象数据类型又是怎么出现的呢?我们试想,如果要对两个整数做加法,我们关心的是哪两个整数,它们做的操作是不是加法,它们做出来的结果如何。而不关心它们在计算机的时候用到计算机的底层逻辑,比如动到了哪里的内存,CPU做了什么工作,这通通都是我们不关心的,由此抽象数据类型应运而生了。
抽象是指抽取出实物具有的普遍性的性质。它是抽出问题的特征而忽略非本质的细节,是对具体事物的一个概括。抽象是一种思考问题的方式,它隐藏了繁杂的细节,只保留实现目标所需要的信息;如果你学过java,你就知道里面的抽象类深谙此道理。
1.2.5 小结
如果我们将上面所学的知识汇总成图:
1.3 算法的基本概念
在很多教材上,往往只谈数据结构,不谈算法,可是在教材内,又往往充斥着大量的算法,这又是怎么回事呢?
如果只谈数据结构,当然问题不大,但是如果只学数据结构,不学算法,那么我们根本就不知道数据结构怎么应用。
1.3.1 算法和算法分析
如果先不给出算法的定义,对于学数据结构这门课的同学来说,肯定和计算机有着密切的关联,所以经常会在书上看到某某算法。我们如果写一个求和算法出来,如下:
int sum = 0;
for(int i = 1;i<=100;i++)
{
sum = sum + i;
}
cout << "100 = "<<sum<<endl;
上面我想大多数人从上大学第一门课程C语言开始学的就是这个求和算法。可以我们可以看到,这个算法是采用for循环来设计的;在深度学习中,for循环是效率最低的顺序扫描,所以在不得已的情况下,我们不会采用for循环。那我们有更好地求和方法吗?
在高中的时候,我们曾经学过倒序相加法,值得一提的是,倒序相加法现在没几个人记得,为啥?因为它们根本不理解其中的原理,而是靠大量的题目堆出来的记忆,所以在这里,我讲一下倒序相加法的由来。
大名鼎鼎数学家高斯在很小的时候就因为老师***难的一道题很闻名,老师故意***难1加到100要让还在上小学的高斯做出来,而高斯花的时间也不多,三下五就做出来了,他是这么做的:既然要算1加到100,那么我再搞一个1加到100,但是是倒序排放,那么上面的1和下面的100相加刚好为101,2和99相加为101,以此类推总共有100个101,也就是10100,那么除以二就是1加到100的结果,即5050。
如果把100看成n,我们可以写出下列的程序:
int i = 0;
int sum = 0;
int n = 100;
sum = (1+n)*n/2;
cout<<sum<<endl;
由此我们可以发现,好的算法可以起到事半功倍的效果。由此我们引出算法的定义,算法是什么?
算法:是对特定问题求解步骤的一种描述
算法的描述:
自然语言:语言、中文
流程图:传统流程图、NS流程图
伪代码:类语言:类C语言
程序代码:C语言,java语言
算法和程序:
算法是解决问题的一种方法或一个过程,考虑如何将输入转换成输出,一个问题可以有多种算法。
程序是用某种设计语言对算法的具体实现。
算法的重要特性:
- 有穷性:一个算法必须总是在执行有穷步之后结束,且每一步都在有穷时间内完成。
- 确定性:算法中的每一条指令必须有确切的含义,没有二义性,在任何条件下,只有唯一地一条执行路径,即对于相同的输入只能得到相同的输出。
- 可行性:一个算法是能行的,即算法种描述的操作都是可以通过已经实现的基本运算执行有限次来实现的。
- 输入:一个算法可以有零个或多个输入。
- 输出:一个算法可以有一个或多个输出。
在给出上面算法的特性后,我们来避免记忆,给出一个比较简单的故事,假如你的算法是无穷步,那还有意义吗?本来人们就是用来解决问题,不能解决问题要这个算法有什么用。每个算法的每个步骤都是确定的,也就是说不会出现二义性。也就是说,你不能说输出1然后会出来两个数。并且你的算法做出来要可行,如果你的算法开销太大,以现有的机器根本跑不出来,那这个算法也是没有什么意义的。当然,一个算法可以没有输入,但是不能没有输出,就比如我们最简单的每个编程教材最开始都有的一个案例:hello world。
算法设计的要求:
- 正确性:算法应当满足具体问题的需求。
- 可读性:算法要方便人对算法的理解。
- 健壮性(鲁棒性):算法能应对非法情况
- 高效性:要求花费尽量少的时间和尽量低的存储需求
算法分析:
一个好的算法首先要具备正确性,然后是健壮性,可读性,在几个方面都满足的情况下,主要考虑算法的效率,通过算法的效率高低来评判不同算法的优劣程度。
算法效率从以下两个方面来考虑:
时间效率:指的是算法所耗费的时间;
空间效率:指的是算法执行过程中所耗费的存储空间。
时间效率和空间效率有时候是矛盾的。
算法时间效率的度量:
算法时间效率可以用依据该算法编制的程序在计算机上执行所消耗的时间来度量。
两种度量方法:
事后统计:将算法实现,测算其时间和空间开销。
缺点:
- 必须先运行依据算法编制的程序。
- 所得时间的统计量依赖于计算机的硬件、软件等环境因素,有时任意掩盖算法本身的优劣。
- 与编程语言的选择有关。
- 有些算法不能事后统计。
基于事后统计方法有这么多不好的地方,所以我们一般考虑事前分析,拒绝马后炮。下面先给出事前分析的定义:
事前分析:对算法所消耗资源的一种估算方法
经过分析,我们发现一个高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:
- 算法采用的策略、方法
- 编译产生的代码质量
- 问题的输入规模
- 机器执行指令的速度
抛开计算机硬件软件的元素,一个程序的运算时间,依赖于算法的好坏和问题的输入规模。所谓问题输入规模就是指输入量的多少。
如果拿我们前面求和的例子来讲的话:
int sum = 0; //1
for(int i = 1;i<=100;i++) //n+1
{
sum = sum + i;//n
}
cout << "100 = "<<sum<<endl;//1
int i = 0; //1
int sum = 0; //1
int n = 100; //1
sum = (1+n)*n/2; //1
cout<<sum<<endl; //1
纵观两种算法,第一种算法算了1+n+1+n+1 = 2n+3次,而第二种算法算了1+1+1+1+1 = 5次,好坏显而易见。
我们可以根据算法计算所得出的计算步数,来写出n和f(n)的函数,学过高中数学的我们都知道,如果一个函数是指数函数的话,n越大,指数函数会出现“指数爆炸”,也就是说,在趋于n时,每个函数f(n)之间的差距可能会越来越大。
1.3.2 函数的渐近增长
我们在上一小节说过,根据计算步数我们是可以画出图像的,也就是说,很有可能出现以下的场景:
也就是说,随着n的增大,本来f之前的算法是没有g好的,但是随着n的增大,f的算法优胜于g。
再有,在大学我们知道,当n趋于无穷时,我们通常可以忽略常数项和次要项,也就是所谓的抓大头准则
,一般只看最高次数项。
所以,输入规模n在没有限制下,只要超过一个数值N,这个函数就总是大于另一个函数,我们则称函数是渐进增长的。即:给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么,我们说f(n)的增长渐进快于g(n)
。
1.4 算法的时间复杂度
1.4.1 渐进时间复杂度
若有某个辅助函数f(n),使得当n趋于无穷大时,T(n)/f(n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。记作T(n) = O(f(n)),称O(f(n))为算法的渐进时间复杂度(O是数量级的符号),简称时间复杂度。
其中定义里用大写O来体现算法时间复杂度的记法,我们称之为大O记法
。一般情况下,不必计算所有操作的执行次数,而只考虑算法中基本操作执行的次数,它是问题规模n的某个函数,用T(n)
表示。而为了便于比较不同算法的时间效率,我们仅比较它们的数量级。
好了前面说了这么多,最核心的还是一个点,我们如何去推导一个大O阶呢?
1.4.2 推导大O阶方法
我们先给出推导的方法,然后下面一步一步来推导。
推导大O阶
- 用常数1取代运行时间中的所有加法常数
- 在修改后的运行次数函数中,只保留最高阶项
- 如果最高阶存在且不是1,则去除这个项相乘的常数
- 所得结果即为大O阶
示例
int sum = 0,n = 100; //以分号结尾,代码执行一次
sum = (1+n)*n/2 //执行一次
cont<<sum<<endl; //执行一次
运行次数为3,而在上面推导的推导大O阶方法中我们说了,用1代码所有的加法,什么意思呢?我们的3是经过1+1+1算出来的,我们用1代替所有的加法。而这个表达式只有1,没有最高阶项,所以结果1即为大O阶。我们把具有O(1)的时间复杂度的叫做常数阶
。
int i;
for(i = 0;i<n;i++)
{
/*其他常数阶程序代码*/
}
我们可以发现,花括号里的就是常数阶,而for循环循环了n次,也就是说,做了n+常数阶
次执行次数,根据上面推导大O阶方法,我们找到了最高阶项n,舍弃后面常数项,所以我们的时间复杂度为O(n),我们把这类情况称为线性阶
。
顺便一提,一般来说,我们分析算法的时间复杂度,关键就是分析循环结构的运行情况
。
int count = 1;
while(count < n)
{
count = count * 2;
/*其他常数阶程序代码*/
}
从这里的代码我们可以看出,退出循环的条件是count<n,而count是通过自身乘2来更新自我然后跳出循环的。也就是说,设count更新次数为x,其可以写出\(2^{x}=n\)的式子,而我们大O(n)里面的n实际上指的是这里的x,根据高中数学所学的指对互换
,我们可以写出\(x = log_2n\)。所以这个循环的时间复杂度为O(logn),我们把这类情况叫做对数阶
。
int i ,j ;
for (i - 0; i < n; i++) {
for ( j - 0 ; j < n ; j++ )
{
/*时间复杂度为O(1)的程序步骤序列*/
}
}
对于这种就不必多说了,时间复杂度为O\((n^2)\)。我们把这类情况叫做平方阶
。
说完上面所有的情况了,现在我们来几个题来练手。
x = 0;y = 0;
for(int k = 0;k<n;k++){
x++;
}
for(int i = 0;i<n;i++){
for(int j = 0;j<n;j++){
y++;
}
}
分析算法,根据推导大O阶方法,第一行执行1次,第一个循环执行n次,第一个内嵌循环外层n次,内层n次,也就是n的平方。把常数变为1,然后抓大头,最高项系数为1,那么只剩下\(n^2\)。所以该代码的时间复杂度为O\((n^2)\),从完整的代码分析下来我们也可以发现,实际上我们只需要找最复杂的那个循环开始分析就可以了,因为其他的代码所含的时间复杂度最终根据推导大O阶方法都会被省略。下面看一个比较难的例子。
void exam(fload x[][],int m,int n)
{
float sum[];
for(int i = 0;i<m;i++){
sum[i] = 0.0;
for(int j = 0;j<n;j++){
sum[i]+=x[i][j];
}
}
for(i = 0;i<m;i++)
cout<<i<<":"<<sum[i]<<endl;
}
最复杂的就是中间的内嵌循环,外层循环为从0到m,内层循环0到n,所以该时间复杂度应为O(m+n)。
for(i = 1;i<=n;i++)
for(j = 1;j<=n;j++){
c[i][j]=0;
for(k = 1;k<=n;k++)
c[i][j] = c[i][j]+a[i][k]*b[k][j];
}
上面这个是一个N×N矩阵相乘的算法,连续三层循环,第一次执行次数为n,第二层执行次数也为n,第三层执行次数还是n。所以该题算法复杂度为O\((n^3)\)。
for(i = 1;i<=n;i++)
for(j=1;j<=i;j++)
for(k=1;k<=j;k++)
x = x+1;
这里最外层执行次数n,但是最外层执行1次,第二层就循环1次;最外层执行第2次,第二层循环两次;根据等差数列求和公式,即第二层有1+2+3+...+n,即\(\frac{n(1+n)}{2}\)次。当然第三层就不好理解了,所以我们接下来换一种方法。
对于三层循环问题,我们还是直接列出最外层的前几项比较好。在i = 1的时候,内层循环全部加起来只循环一次。在i = 2的时候,第二层循环启动两次循环,所以总共执行1+2,对于i = 3,第二层启动三次循环,第三层也是三次循环。也就是说总共执行1+2+3。如果听不太懂,我们可以用图来表示,即:
所以实际上以上规律是由n来控制的,从上面的图来看的话,根据我们所得规律,i=1里面有一个\(\frac{i(1+i)}{2}\),i = 2里面也有一个\(\frac{i(1+i)}{2}\),以此类推我们可以写出下面的式子:\(\sum^n_{i=1}\frac{i(i+1)}{2}\)。
我们化简一下上面的式子:\(\sum^n_{i=1}(\frac{i^2}{2}+\frac{i}{2}) = \frac1 2\sum^n_{i=1}(i^2-i)\)。
这里用等差求和公式带入求解,即可得出答案\(\frac{n(n+1)(n+2)}{6}\),根据我们前面说的大O阶推导,可以得出本题的时间复杂度为\(n^3\)。
1.4.3 常见的时间复杂度
算法时间效率的比较:
当n取得很大时,指数时间算法和多项式时间算法在所需时间上非常悬殊。
实际上下面的表不用记,只需要利用高数的函数图形去理解就行。
【速记口诀:常对幂指阶】
三种复杂度:
-
最坏时间复杂度:考虑输入数据“最坏”的情况。
-
平均时间复杂度:考虑所有输入数据都等概率出现的情况。
-
最好时间复杂度:考虑输入数据“最好”的情况。
一般总是考虑在最坏情况下的时间复杂度,以保证算法的运行时间不会比它更长。
对于复杂的算法,可以将它分成几个容易估算的部分,然后利用大O加法法则和乘法法则,计算算法的时间复杂度。
加法规则:
T(n) = T1(n)+T2(n) = O(f(n))+O(g(n)) = O(max(f(n),g(n)))
乘法规则:
T(n) = T1(n)T2(n) = O(f(n))O(g(n)) = O(f(n)g(n))
总结:
- 顺序执行的代码只会影响常数项,可以忽略
- 只需要挑循环中的一个基本操作与分析他的执行次数与n的关系即可
- 如果有多层嵌套循环,只需关注最深层循环了几次。
1.5、算法的空间复杂度
1.5.1 渐进空间复杂度
空间复杂度:算法所需存储空间的度量,
记作:S(n) = O(f(n))
其中n为问题的规模(或大小)
算法要占据的空间:
- 算法本身要占据的空间,输出/输出,指令,常数,变量等
- 算法要使用的辅助空间
若额外空间相对于输入数据量来说是常数,则称此算法为原地工作。
1.6 总结回顾
实际上,我们这一章下来基本都是要背的概念啥啥啥的,不过对于考试来说,最重要的还是推导大O阶方法。
对于复杂度来说,我们需要知道的是通常我们指的是时间复杂度
而不是空间复杂度
。对于推导大O阶来说,实际上推法很简单,但是数学功底要很扎实,在给出的例题中我们可以看出三层循环十分考验数学功底,那如果四层循环呢?
为什么这么注重考查时间复杂度呢,在翻阅了书籍后,我得到了答案。现在的程序员,很多都弄不懂算法时间复杂度的估算,这就造成了一种现象,即程序响应时间慢,而它们通常的借口是CPU很快,根本不用考虑算法的优劣,实现功能即可。可实际情况是,我们根据摩尔定律可以知道,电子元件发展一年速度翻一倍,而你的算法稍作改进速度就能翻好几百倍,这不香吗真是的!这拿一个例子来说就是:老式电脑用好算法是现代移山靠技术,现代电脑用垃圾算法是愚公移山靠时间,这肯定是前者更聪明啦!当然这对程序员的头发来说就不友好了。
1.7 作业
一、简述下列术语:数据、数据元素、数据对象、数据结构、存储结构、数据类型和抽象数据类型。
二、试着描述数据结构和抽象数据类型的概念与程序设计语言中数据类型概念的区别。
三、设有数据结构(D,R)其中D={d1,d2,d3,d4},R ={r},r={(d1,d2),(d2,d3),(d3,d4)}。试着按图论中图的画法惯例画出其逻辑结构图。
四、试着仿照三元组的抽象数据类型分别写出抽象数据类型复数和有理数的定义。
第二章 线性表的类型定义
2.1 序幕
知识总览
下面开始我们要学习一种最常用也是最简单也是最不简单的逻辑
结构,也就是线性表。让我们下面举个生活中的例子先来点感觉先。
我们排队为什么要挂号?因为挂号能够使病员井然有序。26号总是排在25号的后面(诶,除非特殊情况,你可别刚啊),线性表就是类似这样的结构。
现在我们引入一个例子:
如果我们想要用程序设计语言表达一元多项式及其运算,应该如何表示呢?在程序设计中,我们要去抓住关键数据,在这个问题中,多项式的关键数据为:
- 多项式项数n
- 各项系数\(a_i\)及指数i
我们可以用数组各元素对应多项式各项:a[i]:项\(x^i\)的系数\(a_i\)
例如:\(f(x) = 4x^5-3x^2+1\)
那我们可以表示成:
也就是说,数组的元素我们放系数,数组的位置作为幂指数。虽然这种表示方法很好,假如有两个多项式相加只需表示为两个数组对应分量相加,但是这样的表示方法也会出现问题,比如我们如何表示多项式:\(x+3x^{2000}\)。
如果还是用上面的方法,那就意味着我们数组必须要有2000个位置。根据多项式来看,有数据的只有数组的1和2000处,其他地方均为0,明显地,上面的表示方法不适合了。
所以我们采取第二种方式来表示多项式相加。
第二种方式是利用顺序存储结构来表示非零项,这样说可能有点迷糊。假设我们现在有\(P_1(x) = 9x^{12}+15x^8+3x^2\),那么在数组中我们是这样存储的:
所以两个多项式相加的时候,我们可以用两个指针一起指向两个装有多项式的数组,然后用幂来比较,幂大的先输出,输出后该数组的指针指向下一位;如果幂相等,那么只需对应系数相加即可。
当然我们也可以用链表结构来存储,但我希望你能在学习后面的知识后,对这里有一个更详细的认识。
2.2 线性表的类型定义
线性表是具有相同数据类型的n个数据元素的有限序列
。其中n为表长,当n=0的时候线性表是一个空表。若用L命名线性表,即:\(L = (a_1,a_2,a_3….,a_n)\)。
这句定义值得我们细细品味,里面的字眼值得我们去扣。
线性表里有很多元素,比如a1,a2。a2相对于a1来说排在后面,所以我们叫做直接后继
。a1相对于a2来说排在前面,所以我们叫他直接前驱
。很显然,倒数第二个元素只有一个直接后继,第二个元素只有一个直接前驱。在非空表里每个数据元素都有自己对应的位置,我们叫做位序。比如在线性表中,a1排在第一位,我们说a1排在线性表中的第一位序。
我们知道线性表是一个序列。也就是说元素之间是有顺序的,若元素存在多个,则第一个元素无前驱,最后一个元素无后继,其他每个元素都有且只有一个前驱和后继。然而,线性表强调有限,计算机的每个概念基本上都是强调离散有限,只有在数学中,我们才喜欢去讨论连续无限。
那么定义里具有相同数据类型是什么意思?假如现在有一个表:
+--------+------------+----------+
| DEPTNO | DNAME | LOC |
+--------+------------+----------+
| 10 | ACCOUNTING | NEW YORK |
| 20 | RESEARCH | DALLAS |
| 30 | SALES | CHICAGO |
| 40 | OPERATIONS | BOSTON |
+--------+------------+----------+
那么我们知道,第二条记录和第一条记录应该是有相同特征的,比如说第二个员工不可能有个特征是性别而第一个员工就没有,不可能出现这种事。
如果用数学语言来进行定义,可以如下所示:
所以线性表元素的个数n定义为线性表的长度
,当n = 0时,称为空表
。
在前面,我直接举相同数据类型的例子可能不够明了,这里再举一个例子。
假如一群同学排队来买演唱会门票,此时排队的人群是不是线性表?答案是,但是此时来了三个同学要插在当中一个同学A的队,说同学A之前拿的三个书包就是用来占位的,书包也算是在排队。如果我们是后面早已来排队的同学,我们肯定不愿意。书包怎么能算排队的人呢?于是把三个人打屎踢出去。
这里用线性表的定义来说,把这三个同学踢出去的原因是啥?嗯,自然就是要相同的数据类型了。
2.3 线性表的抽象数据类型
前面我们给出了线性表的定义,那线性表是用的,那肯定要有一些对应的操作是吧。
对线性表有啥操作?
这里面要扯一个问题了,关于&修饰符的问题。
在C语言里面我们知道,用&可以找到某个变量或其他量的地址。
比如:
#include <stdio.h>
int main()
{
const int a = 10;
//a = 100;
int* p = &a;
*p = 100;
printf("%d",a);
return 0;
}
【说明:在这里面我们可以看到,我们用&调取了常量a的地址,然后把地址赋给了指针,让指针指向这个常量。】
实际上,这个符号在C++也有个功能。他能够把在函数里参数修改的结果带回主函数。
也就是说,要想把主函数的实参带到函数里面修改再带回来,可以在函数引用参数里添加一个&。(C++的引用知识)
2.4、类C语言有关操作补充
在考研必备教材《数据结构C语言版》严蔚敏版中,为了方便同学的学习,采用了类C语言,即C语言C++混用,不在意语法;意在让同学理解内在逻辑而不过分追究编译语法。
在这里的2.4.3、2.4.4都是和你C语言(或C++)的功底结合的。
2.4.1 ElemType的解释
typedf struct{
ElemType data[];
int length;
}SqList;//顺序表类型
这时候我们会觉得很奇怪,从来没有看过ElemType这种类型。实际上我们从英文上可以看出,这里的意思是元素类型,即数据元素类型,比如说你要用什么数组,如果你的数组打算放int,那么你可以把ElemType的位置换成int,或者通过typedef重新定义int为ElemType。
2.4.2 数组定义
typedf struct{
ElemType data[MaxSize];
int length;
}SqList;//顺序表类型
但是有时候也会遇到这种情况
typedf struct{
ElemType *data;
int length;
}SqList;//顺序表类型
实际上,如果是data[MaxSize],那么我们会发现一旦MaxSize确定下来了,那么我们的数组长度也就确定下来了。
但是如果我们用的是* data,那么data是一个指针变量,我们可以通过malloc函数即L.data=(ElemType * )malloc(sizeof(ElemType * )MaxSize),来返回malloc函数申请的一片内存的地址,然后把地址赋给data数组,这样数组的长度由数组元素的字节长度和数组的最大容纳量来确定了。这实际上就是静态数组分配和动态数组分配的区别,后面会讲到。
2.4.3 建立链表相关函数
在C语言中我们常用的是
-
malloc函数,用于开辟m字节长度的地址空间,并且返回这段空间的首地址。
-
sizeof函数,计算变量x的长度
-
free(p)函数,释放指针p所指变量的存储空间,即彻底删除一个变量。
【注:如果要用到以上的函数,需要加载头文件:<stdlib.h>】
而在C++中,我们用的是
- new函数
- delete函数
- sizeof函数
2.4.4 参数传递
函数调用时抄送给形参表的实参必须与形参三个一致,即类型,个数,顺序
2.4.4.1 地址传递
地址传递实际上就是传地址给函数,函数通过指针的解引用互换对应地址中的值;但是这里有个问题,地址不能互换,只有地址上的值能换,这是需要注意的一点。
#include <iostream>
using namespace std;
//地址传递
//定义函数,实现两个数字进行交换
//如果函数不需要不需要返回值,声明的时候可以写一个void
void swap(int* num1, int* num2)
{
cout << "交换前:" << endl;
cout << "num1 = " << *num1 << endl;
cout << "num2 = " << *num2 << endl;
int temp = *num1;
*num1 = *num2;
*num2 = temp;
}
int main()
{
int a = 10;
int b = 20;
cout << "a = " << a << endl;
cout << "b= " << b << endl;
swap(&a, &b);
cout << "a = " << a << endl;
cout << "b= " << b << endl;
system("pause");
return 0;
}
结果:
a = 10
b= 20
交换前:
num1 = 10
num2 = 20
a = 20
b= 10
2.4.4.2 值传递
值传递是把数值传进函数,这样的做法在函数里面的确能够实现应有的功能,但是当函数运行结束时,变量不会做任何改变,因为变量传给函数的是数值,变量所在的地址的数值仍未发生变化。
#include <iostream>
using namespace std;
//值传递
//定义函数,实现两个数字进行交换
//如果函数不需要不需要返回值,声明的时候可以写一个void
void swap(int num1, int num2)
{
cout << "交换前:" << endl;
cout << "num1 = " << num1 << endl;
cout << "num2 = " << num2 << endl;
int temp = num1;
num1 = num2;
num2 = temp;
cout << "交换后:" << endl;
cout << "num1 = " << num1 << endl;
cout << "num2 = " << num2 << endl;
//return; 返回值不需要的时候,可以不写return
}
int main()
{
int a = 10;
int b = 20;
cout << "a = " << a << endl;
cout << "b= " << b << endl;
//当我们函数的形参发生改变的时候,并不会影响实参
swap(a, b);
cout << "a = " << a << endl;
cout << "b= " << b << endl;
system("pause");
return 0;
}
结果:
a = 10
b= 20
交换前:
num1 = 10
num2 = 20
交换后:
num1 = 20
num2 = 10
a = 10
b= 20
2.4.4.3 数组名为参
我们都知道,数组的名字实际上代表着数组中首元素的地址,所以对形参数组所做的任何改变都会反映到实参数组中。
#include <iostream>
using namespace std;
void change(char a[])
{
a[0] = 'a';
}
int main()
{
char arr[] = { '1','2','3','4' };
cout << "改变前的arr[0]:" << arr[0]<<endl;
change(arr);
cout << "改变后的arr[0]:" << arr[0] << endl;
system("pause");
return 0;
}
结果:
改变前的arr[0]:1
改变后的arr[0]:a
2.4.4.4 引用类型做参数
什么是引用?
引用就是他用来给一个对象提供一个替代的名字,可以理解为别名。
引用类型做形参的三点说明
1、传递引用给函数与传递指针的效果是一样的,形参变化实参也发生变化。
2、引用类型作形参,在内存中并没有产生实参的副本,他直接对实参操作;而一般变量作参数,形参与实参就占用不同的存储单元,所以形参变量的值是实参变量的副本。因此,当参数传递的数据量较大时,用引用比用一般变量传递参数的时间和空间效率都好。
3、指针参数虽然能达到和使用引用类型的效果,但在被调函数中需要重复使用“指针变量名”的形式进行计算,这很任意产生错误且程序的阅读性较差;另一方面,在主调函数的调用点处,必须用变量的地址作为实参。
2.5、顺序表
2.5.1 顺序表的定义
说那么多线性表,我们接下来来看看线性表的两种物理结构其中之一:顺序存储结构
。
线性表的顺序表示指的是用一组地址连续的存储单元依次存储线性表的数据元素。
其示意图如下:
这种表示一般被我们叫做顺序存储结构
或者叫做顺序映像
。拥有这种结构的线性表我们叫顺序表
。
2.5.1.1 顺序存储方式
线性表的顺序存储结构,说白了就是在内存中随便找块地,通过占位的形式,把一定内存空间给占了,然后把相同数据类型的数据元素依次存放到这块空地中。这里我们想到,这有点类似于一维数组
吧?一维数组也是这个定义。
这里实际上隐含着另外一个意思,既然线性表的数据元素都是相同数据类型,那么我们知道一个数据元素占几个字节(数据元素的大小),那么线性表连续,就必定有\(LOC(a_ {i+1}) = LOC(a_i)+l\)(这里的loc指的是内存地址,即location)这种情况,其中\(l\)代表他们一个数据元素的大小。比如一维数组a[1],a[2]。如果是整型数组,那么就有如下结果:
和学数学一样,既然有这种相邻关系,就存在\(LOC(a_i) = LOC(a_1)+(i-1)l\) 这样的不相邻的关系。我们把这个关系叫做寻址公式
。
其中,我们把a1的地址称为基地址
;如果拿占座的例子来说,一个人占九个连续的座位,a1就是开始占座的那个人。当我们知道上面的公式后,我们可以推算出任意一个数据元素所在的地址,而且都是相同的时间复杂度,即O(1)。我们通常把具有这一特点的结构叫做随机存取结构
。
2.5.1.2 静态分配方法
我们前面说过可以用一维数组来表示线性表,但是这里要注意一点,线性表长可变,而数组长度是不可动态定义。这时候我们用一个变量(length)来表示顺序表的长度属性。所以单纯用一个数组还不行,还要加一个长度属性。
那如何用C语言去定义顺序表呢?线性表每个节点中都有数据,可是没有说是什么数据类型,可以是基本数据类型也可以是复合数据类型;可以是结构化数据类型也可以是半结构化数据类型,所以,我们通常利用结构体来创建一个线性表。
其中线性表包含数据元素和长度。所以如果写成代码如下:
#define MAXSIZE 100 //定义数组的最大长度
typedef struct{
ElemType data [MAXSIZE]; //用静态的数组存放数据元素
int length; //顺序表当前长度
}SeqList;
在以上的定义中,我们可以发现一件事。如果我们过早的指定一维数组中的MAXSIZE,那么我们很难担保后面能够提供对应的数据元素。就拿上面占位的例子来说,一个人占了九个位置给舍友,这只是一个估计,是死的、理想状态的;而实际上,九个人并不是那么好学,里面有一些人没来放鸽子;还有一种情况就是,九个人有几个还带了女朋友(单身狗留下了泪水),那占的位置不够坐的情况也有可能发生。
说完上面的例子,我们把上面的定义方式叫做静态分配方法
,我们稍后会提出一个动态分配方法
;动态分配方法就是为了随机应变而出现的。
使用静态分配我们想到一个问题,由于静态数组是不可改变的,如果里面的数据元素存满了怎么办?
这时候就可以放弃治疗了。
那有人会说多申请点内存空间不就行了?
2.5.1.3 数据长度和线性表长度区别
前面说完可能有些人没发现细节,使用的时候也会出错,什么细节呢?
#define MAXSIZE 100
typedef struct{
ElemType data [MAXSIZE];
int length;
}SeqList;
我们在这里发现定义顺序表需要三个属性:
- 存储空间的起始位置:数组data
- 线性表的最大存储容量:数组长度MaxSize
- 线性表的当前长度:length
也就是说,你确定了数组长度是吧,数组开辟空间了是吧,最后数组上面选一段作为线性表,有点套娃,如图所示:
也就是说,如果你数组开辟的空间不够多,就会导致顺序表用的空间不够多,也就会导致顺序表的数据元素填不进去。
可能有些人会说,那你说错了,数组的大小有些是可以变的,C,C++,VB都可以用编程手段实现动态分配数组,但是这会带来性能上的损耗的
。
所以综上所述,我们得出如下结论:
- 数组长度是指存放线性表的存储空间的长度,存储分配后这个值是不变的。
- 线性表的长度是线性表中数据元素的个数,随着线性表的插入与删除,这个值是在变换的。
2.5.1.4 动态分配方法
线性表强调元素在逻辑上紧密相邻,所以首先想到用数组存储。但是普通数组有着无法克服的容量限制,在不知道输入有多少的情况下,很难确定出一个合适的容量。对此,一个较好的解决方案就是使用动态分配,即动态数组
。
使用动态数组的方法是用new
申请一块拥有指定初始容量的内存,这块内存用作存储线性表元素,当录入的内容不断增加,以至于超出了初始容量时,就用new
扩展内存容量,这样就做到了既无浪费内存,也可以让线性表容量随输入的增加而自适应大小。
动态分配方法如下:
#include <iostream>
using namespace std;
#define InitSize 10 //默认的最大长度
//顺序表结构体定义
typedef struct
{
//指示动态分配数组的指针
int* data;
//顺序表的最大容量
int Maxsize;
//顺序表的当前长度
int length;
}SeqList;
//初始化顺序表
void InitList(SeqList& L)
{
L.data = new int[InitSize * sizeof(int)];
L.length = 0;
L.Maxsize = InitSize;
}
如果想要增加动态数组的长度,可以编写如下函数:
void IncreaseSize(SeqList& L, int len) { int* p = L.data; L.data = new int[(L.Maxsize + len) * sizeof(int)]; for (int i = 0; i < L.length; i++) { L.data[i] = p[i]; //将数据复制到新区域 } L.Maxsize = L.Maxsize + len; //顺序表最大长度增加len delete(p); //释放老数组的内存空间 }
这里需要一些讲解:
原来老数组是在内存中开辟了一块内存空间,而我们在增加动态数组的长度时,实际上是在内存的其他地方,开了一块更大的空间,然后把老数组上面的数组复制过去新数组。
既然如此,我们就应该把定义一个新指针p,把老数组的指针移交给p后,把data指针指向新数组。此时p指老,data指新,通过循环把p中的每一个元素移交给data即可,移交完成后,要记得把顺序表的最大容量也修改一下,即老顺序表的最大容量加上扩充容量。
经过上面的代码讲解,我们可以知道一件事就是,由于要把数据从老数组复制到新数组,实际上时间开销是非常大的,这也对应了我们2.5.1.3讲解的,一般来说在一些书上是不会讲这个动态分配的事的,只有在考研中才会涉及到这个知识点。
顺序表的特点:
- 随机访问,即可以在O(1)时间内找到第i个元素
- 存储密度高,每个节点只存储数据元素
- 拓展容量不方便(即便采用动态分配的方式实现,拓展长度的时间复杂度也比较高)
- 插入、删除操作不方便,需要移动大量元素
2.5.1.5 小结
2.5.2 线性表的基本操作
实际上,顺序表和单链表的操作基本都是这几个原理。在这里,我们先讲顺序表的基本操作。
在这之前我们需要介绍一下操作算法中用到的预定义常量和类型,在后面的代码中,你经常会看见这些字眼。
//函数结果状态代码
#define TRUE 1
#define FALSE 0
#define OK 1
#define ERROR 0
#define INFEASIBLE -1
#define OVERFLOW -2
//Status 是函数的类型,其值是函数结果状态代码
typedef int Status;
typedef char ElemType;
2.3.2.1 初始化
这里的初始化其实包含了两个动作:对顺序表的初始化和对数组的初始化。在前面我们知道顺序表是用数组表示的,这就意味着数组确定了空间大小后,如果顺序表没用满数组中的内存,就势必有一些内存有脏数据。所有在使用顺序表之前,你需要先把数组内所有元素设为0,并且把顺序表长度设为0。
#include <iostream>
using namespace std;
#define MaxSize 10//定义数组最大长度
typedef struct
{
int data[MaxSize];
int lenght;
}SqList;
//初始化
void InitList(SqList& L)
{
for (int i = 0; i < MaxSize; i++)
{
L.data[i] = 0; //将所有数据元素设置为默认初始值0
}
L.lenght = 0; //顺序表初始长度为0
}
int main()
{
SqList L;
InitList(L);
for (int i = 0; i < MaxSize; i++)
cout << L.data[i] << endl;
return 0;
}
结果:
0
0
0
0
0
0
0
0
0
0
实际上,上面的操作具有一定的违规,因为我们是在用顺序表,而不是在用数组,所以上面打印的条件应该是i<L.length
。所以,这里我们又可以发现,我们只是用顺序表而不用数组的话,实际上是不会访问到脏数据的,所以初始化一般都是初始化顺序表的长度,而不用去初始化数组的值。
然而,抛开上面不谈,实际上我们访问数据的方式也不够好,在测试阶段我们的确可以用这种方式,但是实际做题或者其他应用中,我们还是得用基本操作
去访问数据元素。在下一小节,我们就会讲到这个问题。
2.3.2.2 顺序表的插入
插入删除在顺序表中其实很简单,你可以想象这么一个场景:你们在买火车票,有个人想插队到你前面,一旦你同意,你后面排队的人都得退一步;而如果你们在排队,有个人有事突然走了,那么所有排队的人都可以前进一步。这就是顺序表的插入删除。其中插入的操作有些人俗称加塞
,示意图如下:
ListInsert(&L,I,e):插入操作。在表L中的第i个位置上插入指定元素e。
bool ListInsert(SeqList& L, int i, int e) { if (i<1 || i>L.length + 1)//判断i的范围是否有效 return false; if (L.length >= MAXSIZE)//当前存储空间已满,不能插入 return false; for (int j = L.length; j >= i; j--) L.data[j] = L.data[j - 1]; L.data[i - 1] = e; L.length++; return true; }
注意:在增加元素的时候一定要检查插入之前是否存满了,为此,我们改动了上面的代码。
说明:检查i的合法性判断。好的算法,应该具有“健壮性”。能处理异常情况,并且给使用者反馈。
关于插入操作的时间复杂度
我们在关注时间复杂度的时候,通常都是直接看最内层循环。
-
如果考虑最好情况:新元素插入到表尾,不需要移动元素,i = n+1,循环0次;最好时间复杂度为O(1)
-
最坏情况:新元素插入到表头,需要将原有的n个元素全都向后移动,i = 1,循环n次,最坏时间复杂度 为O(n)
-
平均情况:假设新元素插入每个位置的概率都相等,即p = 1/(n+1),i = 1,循环n次,i = 2,循环n-1次,也就是1+2+3+…+n,根据等差数列求和公式,也就是n(n+1)/2
平均循环概率 = 平均复杂度 = np = n/2 = O(n)
2.3.2.3 顺序表的删除
ListDelete(&L,I,e):删除操作。删除表中第i个位置的元素,并用e返回删除元素的值。
bool ListDelete(SeqList& L, int i, int e) { if (i<1 || i>L.length + 1)//判断i的范围是否有效 return false; e = L.data[i - 1]; for (int j = i; j < L.length; j++) L.data[j - 1] = L.data[j]; L.length--; return true; }
说明:删除方法,有&L,同理,带入L顺序表进去操作后返回,最开始先检查i的合理性,检查成功后,将要删除的值赋给e,然后开始把e这个i位置的元素往前移,把要删除的元素挤掉,然后线性表长度减一,返回成功字样。
关于插入操作的时间复杂度就不细说了,实际上和插入的时间复杂度一模一样,计算方法也大同小异。
2.3.2.4 顺序表的按位查找
GetElem(L, i):按位查找操作,获取表L中第i个位置的元素的值。
int GetElem(SeqList L, int i) { //初始条件:顺序线性表L已存在 if (L.length == 0 || i<1 || i>L.length) return 0; return L.data[i - 1]; }
实际上,动态数组也可以用这种方式访问。
2.3.2.5 顺序表的按值查找
LocateElem(L ,e ):按值查找操作。在表L中查找具有给定关键字值得元素。
//按值查找 int LocateElem(SeqList L, int e) { for (int i = 0; i < L.length; i++) if (L.data[i] == e) return i + 1; return 0; }
需要注意的是,当我们的顺序表里面的元素不是基本类型而是结构类型的时候,按值查找的判定条件==
就不能再用了。
对于结构体类型的数据元素,我们可以采用==
来判定结构体中每个数据类型是否相等。最好的做法是,能做成一个函数
来使用。如果是C++的话,我们还可以对==进行重载
。
不过幸运的是,在考研当中,目标学校更侧重的是你对算法的理解,而不在代码的细节。所以在手写代码的时候无论是基本数据类型还是结构数据类型都是可以直接使用==
。
对于按值查找的时间复杂度来说,和插入操作一样,稍加思考一下就能理解,这里就不过多讲述了。
需要提到的是,按值查找也有技巧可言,并不一定要按照顺序扫描的方式;比如二分查找等查找方法都能提高时间效率,在后面会有更深层次的讲解。
2.5.3 小结
代码示例
#include <iostream>
using namespace std;
#define MAXSIZE 100 //定义数组的最大长度
typedef struct {
int data[MAXSIZE]; //用静态的数组存放数据元素
int length; //顺序表当前长度
}SeqList;
//初始化顺序表
void InitList(SeqList& L)
{
L.length = 0;
}
bool ListInsert(SeqList& L, int i, int e)
{
if (i<1 || i>L.length + 1)//判断i的范围是否有效
return false;
if (L.length >= MAXSIZE)//当前存储空间已满,不能插入
return false;
for (int j = L.length; j >= i; j--)
L.data[j] = L.data[j - 1];
L.data[i - 1] = e;
L.length++;
return true;
}
bool ListDelete(SeqList& L, int i, int e)
{
if (i<1 || i>L.length + 1)//判断i的范围是否有效
return false;
e = L.data[i - 1];
for (int j = i; j < L.length; j++)
L.data[j - 1] = L.data[j];
L.length--;
return true;
}
//按位查找
int GetElem(SeqList L, int i)
{
//初始条件:顺序线性表L已存在
if (L.length == 0 || i<1 || i>L.length)
return 0;
return L.data[i - 1];
}
//按值查找
int LocateElem(SeqList L, int e)
{
for (int i = 0; i < L.length; i++)
if (L.data[i] == e)
return i + 1;
return 0;
}
int main()
{
SeqList L;
InitList(L);
ListInsert(L, 1, 3);
ListInsert(L, 2, 4);
//e可用于保存被删除的元素
int e = -1;
ListDelete(L, 2, e);
int a = GetElem(L, 1);
cout << "第1个元素为:" << a << endl;
int i = LocateElem(L, 3);
cout << "值为3的元素排在" << i << "号位" << endl;
return 0;
}
2.6、单链表
2.6.1 单链表的定义
2.6.1.2 引入
线性表有两种,第一个是我们前面讲到的
顺序表
,对应顺序存储
。第二个是链表
,对应链式存储
。
物理结构 逻辑结构 顺序表 顺序存储 链表 链式存储
要谈论链表,我们就要先谈最简单的链表;所以在这里首先要提出一个单链表
的概念。单链表也叫线性链表
。
线性表的链式存储结构是用一组
任意的存储单元
存储线性表的数据元素。由图可知,每个数据元素\(a_i\)由两部分组成,一部分放数据元素信息,我们叫做数据域
;另外一部分放下一个数据元素地址的信息,我们叫指针域
,两部分加起来合称为结点
。指针域里面放的地址我们叫指针
或者链
。n个结点结成一个链表。因为每个结点只放了一个指针域,所以我们又叫单链表或线性链表。
2.6.1.2 单链表的顺序表的优缺点
顺序表优缺点 | 单链表优缺点 |
---|---|
顺序表优点:可随机存取,存储密度高 | 单链表优点:不要求大片连续空间,改变容量方便 |
缺点:要求大片连续空间,改变容量不方便。 | 缺点:不可随机存取,要耗费一定空间存放指针。 |
2.6.1.3 单链表的代码定义
//定义一个结点 typedef struct LNode{ //定义单链表结点类型 ElemType data; //每个节点存放一个数据元素 struct LNode *next; //指针指向下一个结点 }LNode*, LinkList;
【说明:在这里我们可以看到指针域用了一个结构体嵌套,这是因为下一个结点也是结构体,所以他的地址也会是结构体指针类型。】
【注:LNode*主要是强调他是一个结点,而LinkList主要强调这个结点为整个链表;用了两种命名是为了代码的可读性更强。】
范例:定义一个能存储学生学号、姓名、成绩的单链表
typedef Struct student{
char num[8]; //数据域
char name[8]; //数据域
int score; //数据域
struct student *next; //指针域
}LNode,*LinkList;
【说明:从这个案例我们可以发现数据域不止可以存一个数据。】
然后我们声明定义一个结构体(结点)变量L,然后指向这个链表。
不过为了统一链表的管理,当数据域有多个元素时,我们一般不会像上面那样定义结构体,而是像下面这样定义:
typedef Struct{
char num[8];
char name[8];
int score;
}ElemType;
typedef struct LNode{
ElemType data;//数据域
struct LNode *next;//指针域
}LNode,*LinkList;
【说明:上面的实际上用到了结构体嵌套。】
2.6.2、单链表的初始化
单链表的初始化的概念:即构造一个空表
这里我们要分为两类情况,一类是不带头结点的单链表
,一类是带头结点的单链表
。下面先说说两者的区别:
- 所有的链表都要有个头指针first,带头结点的链表的头指针指向的是头结点,头结点的指针域指向首元结点,不带头结点的头指针直接指向首元结点。
- 两者在操作上有区别:在删除和插入操作中,无论删除和插入的位置如何,带头结点的链表不需要修改头指针的值,而不带头结点的有时候需要。在清空操作中,带头结点的保留头结点,而不带头结点的要销毁。.
- 在结构上,带头结点的单链表,不管链表是否为空,均含有一个头结点,不带头结点的单链表不含头结点。
- 在操作上,带头结点的单链表的初始化为申请一个头结点。无论插入或删除的位置是地第一个结点还是其他结点,算法步骤都相同。不带头结点的单链表,其算法步骤要分别考虑插入或删除的位置是第一个结点还是其他结点。
2.4.2.1 不带头结点的单链表
#include <iostream> using namespace std; //定义链表(不带头结点) typedef struct { //数据域 int data; //指针域 struct LNode* next; }LNode,* LinkList; //初始化链表 bool InitList(LinkList& L) { L = NULL; return true; } int main() { LinkList L; InitList(L); }
由于单链表不带头结点,这就导致了如果初始化表,那就是表全为空。
此时如果要判断单链表是否为空,只需单纯判断L是否为空即可。
//判断单链表是否为空
bool Empty(LinkList L)
{
if (L == NULL)
return true;
else
return false;
}
2.4.2.2 带头结点的单链表
生成新结点作头结点,用头指针L指向头结点。
将头结点的指针域置空,防止内存中有遗留的脏数据。
#include <iostream> using namespace std; //定义链表(不带头结点) typedef struct LNode { //数据域 int data; //指针域 struct LNode* next; }LNode,* LinkList; //初始化链表 bool InitList(LinkList& L) { L = new LNode; if (L == NULL) return false; L->next = NULL; return true; } int main() { LinkList L; InitList(L); }
此时如果想判断单链表是否为空,只需判断头结点中储存的指针域是否为空即可。
//判断单链表是否为空
bool Empty(LinkList L)
{
if (L->next == NULL)
return true;
else
return false;
}
一般来说,我们写的代码都是带头节点的
,用过都说好。
2.4.2.3 小结
2.4.2、单链表简单算法的补充
【补充算法1】——判断链表是否为空
空表:链表中无元素,称为空链表(带头结点的空链表头指针和头结点仍然存在)
【算法思路】判断头结点指针域是否为空
【算法描述】
int ListEmpty(LinkList L){
//若L为空表,则返回1,否则返回0
if(L->next)
return 0;
else
return 1;
}
【补充算法2】——单链表的销毁
销毁:链表销毁后不存在
【算法思路】从头指针开始,依次释放所有结点
我们销毁的思路是:我们还需要另外一个指针变量P,这个指针变量用于结点的操作。若想实现变量P对某结点的操作,首要任务就是让P指向该结点,即把该结点的地址赋给P。那该节点的地址存于头指针L,所以只需p = L即可。当然,当P = L后,不能立马删除p,否则L丢失,链表也跟着丢失;所以我们需要在P = L后,把L移到下一个结点,即L = L->next,然后再释放P(free(P))即可。循环上述操作,即可删除链表。
【注:在C里开辟空间用malloc,释放空间用free;而在C++里,开辟空间用new,释放空间用delete。】
【算法描述】
Status DestroyList_L(LinkList &L){
//销毁单链表L
LNode *p;
while(L){
p = L;
L = L->next;
free(p);
}
return Ok;
}
【补充算法3】——清空链表
清空链表:链表仍然存在,但链表中无元素,成为空链表(头指针和头结点仍然在)
【算法思路】依次释放所有结点,并将头结点指针域设置为空。
先将头指针的指针域赋给指针变量p,这样的话,p就定位了要删除的结点了,但是如果现在直接删除,那么后面的链表就会丢失了。所以这时候我们引入第三个指针变量q,q来保证后面的链表不丢失,当我们q移到p要删除结点的下一个结点后,即q = p->next,我们再去释放p,即free(p)。直到清空列表为止。
【算法描述】
Status ClearList(LinkList &L){
LNode *p,*q;
p = L->next;
while(p){
p=q->next;
free(p);
p = q;
}
L->next = NULL; //头结点指针域为空
return OK;
}
【补充算法4】——求单链表的表长
【算法思路】从首元结点开始,依次计数所有结点
步骤:
【1】首先让某个指针指向首元结点。即L->next。
【2】让p指针移到下一个结点,即p = p->next。
【3】每次移动后都要计数,即表长;如若p->next=!NULL,那么i=i+1。
【算法描述】
int ListLength_L(LinkList L){
LinkList p;
p = L->next;
i = 0;
while(p){
i++;
p = p->next;
}
return i;
}
2.4.3、单链表的基本操作
带头结点的单链表
类型定义
typedef struct LNode{
ElemType data;
struct LNode *next;
}LNode,*LinkList
【说明:在类型定义中,每个结点不仅要存储数据,还要存储下一个结点的地址,即指针域;而指向的下一个结点同样是结构体,所以我们用一个结构体指针去指向,即结构体嵌套】
重要操作
p = L;//p指向头结点
s = L->next;//s指向首元结点
p = p->next;//p指向下一结点
【算法1】取值
取值:取单链表中第i个元素的内容
【算法思路】
从链表的头指针出发,顺着链域next逐个结点往下搜索,直到搜索到第i个结点为止。因此,链表不是随机存取结构。
比如现在要找第三个结点,那么我们只需要指定一个指针变量p,从首元结点开始找,在首元结点时,指定一个整形变量j来计数,初始j = 1;当p移到下一个结点,即p = p->next时,j = j+1。
【算法步骤】
1、从第一个结点(L->next)顺链扫描,用指针p指向当前扫描到的结点,p初值p = L->next。
2、j做计数器,累计当前扫描过的结点数,j初值为1。
3、当p指向扫描到的下一个结点时,计数器j加1。
4、当j==i时,p所指的结点就是要找的第i个结点。
【算法描述】
Status GetElem_L(LinkList L,int i,ElemType &e){
//获取线性表L中的某个数据元素的内容,通过变量e返回
p = L->next;
j=1; //初始化
while(p&&j<1){ //向后扫描,直到p指向第i个元素或p为空
p = p->next;
++j;
}
if(!p||j>1){
return error;//第i个元素不存在
}
e = p->data; //取第i个元素
return OK;
}
【算法2】按值查找
按值查找1:根据指定数据获取该数据所在的位置(地址)
【算法思路】
【算法步骤】
1、从第一个结点起,依次和e相比较。
2、如果找到一个其值与e相等的数据元素,则返回其在链表中的“位置”或地址。
3、如果查遍整个链表都没有找到其值和e相等的元素,则返回0或“NULL”。
【算法描述】
LNode *LocateELem_L(LinkList L,Elemtype e){
//在线性表L中查找值为e的数据元素
//找到,则返回L中值为e的数据元素的地址,查找失败返回NULL
p = L->next;
while(p && p->data!=e){
p = p->next;
}
return p;
}
按值查找2:根据指定数据获取该数据位置序号
【算法描述】
//在线性表L中查找值为e的数据元素的位置序号
int LocateELem_L(LinkList L,Elemtype e){
//返回L中值为e的数据元素的位置序号,查找失败返回0
p = L->next;i = 1;
while(p && p->data!=e){
p = p->next;j++;
}
if(p){
return j;
}
else{
return 0;
}
}
【算法3】插入结点
插入数据:在第i个结点前插入值为e的新结点
【算法思路】
【算法步骤】
1、首先要找到a_{i-1}的存储位置p。
2、生成一个数据域为e的新结点s。
3、插入新结点:
①新结点的指针域指向a_{i},即s->next = p->next
②结点a_{i-1}的指针域指向新结点,即p ->next = s
【注:这里的步骤①和步骤②不能互换,否则会丢失后半部分(即a_i)的链表。】
【算法描述】
//在L中第i个元素之前插入数据元素e
Status ListInsert_L(LinkList &L,int i,ElemType e){
//初始化p指针
p = L,j = 0;
//寻找第i-1个结点,p指向i-1结点
while(p&&j<i-1){
p = p->next;
++j;
}
//i大于表长+1或者小于1,插入位置非法
if(!p||j>i-1){
return ERROR;
}
//生成新结点s,将结点s的数据域置为e
s = new LNode;
s ->data = e;
//将结点s插入L中
s->next = p->next;
p->next = s;
return OK;
}//ListInsert_L
【算法4】删除结点
删除数据:删除第i个结点
【算法思路】
【算法步骤】
1、首先找到\(a_{i-1}\)的存储位置p,保存要删除的a的值。
2、令p->next指向\(a_{i+1}\),即p->next = p->next->next
3、释放结点\(a_i\)的空间
【算法描述】
//将线性表L中的第i个数据元素删除
Status ListDelete_L(LinkList &L,int i,ElemType &e){
p = L;
j = 0;
while(p->next && j<i-1){
//寻找第i个结点,并令p指向其前驱
p = p->next;
++j;
}
//删除位置不合理
if((p->next) = NULL||j>i-1){
return ERROR;
}
//临时保存被删结点的地址以备释放
q = p->next;
//改变删除结点前驱结点的地址以备释放
p->next = q->next;
//保存删除结点的数据域
e = q->data;
//释放删除结点的空间
delete q; //C++写法
return Ok
}//ListDelete_L
【算法时间效率分析】
查找
LNode *LocateELem_L(LinkList L,Elemtype e){
//在线性表L中查找值为e的数据元素
//找到,则返回L中值为e的数据元素的地址,查找失败返回NULL
p = L->next;
while(p && p->data!=e){
p = p->next;
}
return p;
}
因为线性链表只能顺序存取,即在查找时要从头指针找起,查找的时间复杂度为O(n)。
插入和删除
因为线性链表不需要移动元素,只是修改指针,一般情况下时间复杂度为O(1)。
【算法5】建立单链表——头插法
头插法:元素插入在链表头部,也叫 前插法 。
【算法分析】
【算法步骤】
1、从一个空表开始重复读入数据
2、生成新结点,将读入数据存放到新结点的数据域中
3、从最后一个结点开始,依次将各结点插入到链表的前端
【算法描述】
void CreateList_H(LinkList &L,int n){
L = new LNode;
L->next = NULL;
for(i = n;i>0;i--){
p = new LNode;
cin>>p->data;
p->next = L->next;
L->next = p;
}
}//CreateList_H
【注:这里头插法的算法时间复杂度是O(n)】
【算法6】建立单链表——尾插法
尾插法:元素插入在链表尾部,也叫后插法
【算法步骤】
1、从一个空表L开始,将新结点逐个插入到链表的尾部,尾指针r指向链表的尾结点。
2、初始时,r同L均指向头结点。每读入一个数据元素则申请一个新结点,将新结点插入到尾结点后,r指向新结点。
【算法描述】
//正位序输入n个元素的值,建立带表头结点的单链表L
void CreateList_R(LinkList &L,int n){
L = new LNode;
L->next = NULL;
r = L; //尾指针r指向头结点
for(i = 0;i<n;i++){
p = new LNode;
cin>>p->data; //生成新结点,输入元素值
p->next = NULL;
r->next = p; //插入到表尾
r= p; //r指向新的尾结点
}
}//CreateList_R
【注:这里尾插法的时间复杂度是O(n)】
2.5、循环链表
2.5.1、循环链表的概念
循环链表:是一种头尾相接的链表(即:表中最后一个结点的指针域指向头结点,整个链表形成一个环)。
优点:从表中任一结点出发均可找到表中其他结点。
注意:由于循环链表中没有NULL指针,故涉及遍历操作时,其终止条件就不再像非循环链表那样判断p或p->next是否为空,而是判断它们是否等于头指针。
循环条件
单链表:
p! = NULL
p->next != NULL
单循环链表:
p! = L
p->next! = L
【注:表的操作常常是在表的首尾位置上进行。】
2.5.2、两个链表合并
如何将两个带有尾指针的链表合并
思路分析:
操作分析:
//用p存表头结点
p = Ta->next;
//Tb表头连接Ta表尾
Ta -> next = Tb ->next ->next;
//释放Tb表头结点
delete Tb->next;
//修改指针
Tb -> next = p;
具体代码:
LinkList Counnect(LinkList Ta,LinkList Tb){
//假设Ta、Tb都是非空的单循环链表
p = Ta->nextl//p存表头结点
Ta->next = Tb->next->next;//Tb连Ta表尾
delete Tb->next;//释放Tb表头结点
Tb->next = p;/
return Tb;
}
2.6、双向链表
2.6.1、概述
为什么要讨论双向链表?
单链表的概念图如下所示:
单链表->结点->有指示后继的指针域->找后继结点方便;即:查找某结点的后继结点的执行时间为O(1)。
但是这也侧面说明一个问题:无指示前驱的指针域->找前驱结点难,这就意味着要找前驱结点必须从表头开始找,即:查找某结点的前驱结点的执行时间为O(n)。
为了克服这个缺点,我们引入了双向链表这个概念:在单链表的每个结点里面再增加一个指向其直接前驱的指针域prior,这样链表中就形成了有两个方向不同的链,故称为双向链表。
双向链表的定义
typedef struct DuLNode{
Elemtype data;
struct DuNode *prior,*next;
}DuLNode,*DuLinkList;
概念图:
双向循环链表
和单链的循环表类似,双向链表也可以有循环表
- 让头结点的前驱指针指向链表的最后一个结点
- 让最后一个结点的后继指针指向头结点
双向链表结构的对称性
设指针p指向某一结点,则有
p->prior->next = p = p->next->prior
在双向链表中有些操作,因仅设计一个方向的指针,故它们的算法与线性链表的相同。但在插入、删除时,则需同时修改两个方向上的指针,两者操作的时间复杂度均为O(n)。
2.6.2、双向链表的插入
LeetBook大作业
设计链表
设计链表的实现。您可以选择使用单链表或双链表。单链表中的节点应该具有两个属性:val 和 next。val 是当前节点的值,next 是指向下一个节点的指针/引用。如果要使用双向链表,则还需要一个属性 prev 以指示链表中的上一个节点。假设链表中的所有节点都是 0-index 的。
在链表类中实现这些功能:
get(index):获取链表中第 index 个节点的值。如果索引无效,则返回-1。
addAtHead(val):在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。
addAtTail(val):将值为 val 的节点追加到链表的最后一个元素。
addAtIndex(index,val):在链表中的第 index 个节点之前添加值为 val 的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。如果 index 大于链表长度,则不会插入节点。如果index小于0,则在头部插入节点。
deleteAtIndex(index):如果索引 index 有效,则删除链表中的第 index 个节点。
提示:
- 所有
val
值都在[1, 1000]
之内。 - 操作次数将在
[1, 1000]
之内。 - 请不要使用内置的 LinkedList 库。
typedef struct {
int val;
struct MyLinkedList *next;
} MyLinkedList;
//初始化
MyLinkedList* myLinkedListCreate() {
MyLinkedList* L = (MyLinkedList*) malloc(sizeof(MyLinkedList));
L->next = NULL;
return L;
}
//按位查找
int myLinkedListGet(MyLinkedList obj, int index) {
if (index < 0) {
return -1;
}
MyLinkedList * newhead = obj->next;
int i;
for(i=0;i<index;i++){
if(!newhead){
return -1;
}
newhead = newhead->next;
}
if(!newhead){
return -1;
}
else{
return newhead->val;
}
}
//头插法
void myLinkedListAddAtHead(MyLinkedList obj, int val) {
MyLinkedList newNode = (MyLinkedList) malloc(sizeof(MyLinkedList));
newNode->next = obj->next;
obj->next=newNode;
newNode->val=val;
}
//尾插法
void myLinkedListAddAtTail(MyLinkedList obj, int val) {
MyLinkedList* p=obj;
while(p->next){
p=p->next;
}
MyLinkedList newNode = (MyLinkedList) malloc(sizeof(MyLinkedList));
p->next=newNode;
newNode->next=NULL;
newNode->val=val;
}
//插入结点
void myLinkedListAddAtIndex(MyLinkedList obj, int index, int val) {
if(index<=0){
return myLinkedListAddAtHead(obj,val);
}
MyLinkedList * cur=obj;
MyLinkedList * newNode = (MyLinkedList*) malloc(sizeof(MyLinkedList));
newNode->val=val;
for(int i=0;i<index;i++){
if(!cur){
free(newNode);
return;
}
cur=cur->next;
}
newNode->next=cur->next;
cur->next = newNode;
}
//
void myLinkedListDeleteAtIndex(MyLinkedList obj, int index) {
MyLinkedList* current = obj;
for(int i = 0;i<index;i++){
if(!current)
return;
current = current->next;
}
MyLinkedList* delNode = current->next;
if(!delNode){
current->next = NULL;
}
else
current->next = delNode->next;
free(delNode);
}
void myLinkedListFree(MyLinkedList* obj) {
MyLinkedList* current = obj->next;
MyLinkedList* currentNext = NULL;
while(current){
currentNext = current->next;
free(current);
current = currentNext;
}
obj = NULL;
}
/**
Your MyLinkedList struct will be instantiated and called as such:
MyLinkedList* obj = myLinkedListCreate();
int param_1 = myLinkedListGet(obj, index);
myLinkedListAddAtHead(obj, val);
myLinkedListAddAtTail(obj, val);
myLinkedListAddAtIndex(obj, index, val);
myLinkedListDeleteAtIndex(obj, index);
myLinkedListFree(obj);
*/
第三章、栈和队列
3.1、栈和队列的定义和特点
3.1.1、栈和队列介绍
- 栈和队列是两种常用的、重要的数据结构
- 栈和队列是限定插入和删除只能在表的“端点”进行的线性表
栈和队列是线性表的子集(是插入和删除位置受限的线性表)
线性表
insert(L,i,x)
1<=i<=n+1
Delete(L,i)
1<=i<=n
栈
insert(S,n+1,x)
Delete(S,n)
队列
insert(Q,n+1,x)
Delete(Q,1)
栈的形象比喻图
在我放电池时,第一个塞进去的电池塞在最底下,最后一个在顶上;在取电池出来时,最后一个电池先拿出来,第一个塞进去的电池最后一个拿出。
我们把最底下叫做栈底,即表头;我们把最顶上叫做栈顶,即表尾。从以上叙述来看,栈有着后进先出的特点,我们简称(LIFO结构)。
栈的应用
- 数制转换
- 括号匹配的检验
- 行编辑程序
- 迷宫求解
- 表达式求值
- 八皇后问题
- 函数调用
- 递归调用的实现
队列的形象比喻图
就像一个管道一样,第一个进第一个出,只能从表头出,只能从表尾入;即先进先出。
队列的常见应用
由于队列的操作具有先进先出的特性,使得队列成为程序设计中解决类似排队问题的有用工具。
- 脱机打印输出:按申请的先后顺序依次输出
- 多用户系统中,多个用户排成队,分时地循环使用CPU和主存
- 按用户的优先级排成多个队,每个优先级一个队列
- 实时控制系统中,信号按接收的先后顺序依次处理
- 网络电文传输,按到达的时间先后顺序依次进行
3.1.2、栈的定义和特点
-
栈是一个特殊的线性表,是限定仅在一端(通常是表尾)进行插入和删除操作的线性表
-
又称为后进后出的线性表,检查LIFO结构
栈的相关概念
栈是仅在表尾进行插入、删除操作的线性表
表尾(即\(a_n\)端)称为栈顶Top;表头(即\(a_1\)端)称为栈顶Base
插入元素到栈顶(即表尾的操作)称为入栈(有时也叫压栈)。
从栈顶(即表尾)删除最后一个元素的操作,称为出栈(有时也叫弹栈)。
示意图
思考:假设有3个元素a,b,c,入栈的顺序是a,b,c,则它们的出栈顺序有几种可能?
abc一起塞 出栈cba
a塞后拿,接着b塞后拿,接着c塞后拿 出栈abc
ab先塞后拿,接着c再塞后拿 出栈bac
a先塞后拿,接着bc再塞后拿 出栈acb
ab先塞b先拿,再塞c,然后全拿 出栈bca
总结
- 定义:限定只能在表的一端进行插入和删除运算的线性表(只能在栈顶操作)
- 逻辑结构:与同线性表相同,仍为一对一关系
- 存储结构:用顺序栈或链栈存储均可,但以顺序栈更常见
- 运算规则:只能在栈顶运算,且访问结点时依照后进先出的原则
- 实现方式:关键是编写入栈和出栈函数,具体实现依顺序栈或链栈的不同而不同。
3.1.3、队列的定义和特点
队列是一种先进先出的线性表。在表的一端插入(表尾),在另一端(表头)删除
示意图
总结
- 定义:只能在表的一端进入插入运算,在表的另一端进行删除运算的线性表(头删尾插)。
- 逻辑结构:与同线性表相同,仍为一对一关系。
- 存储结构:顺序队或链队,通常以循环顺序队列更常见。
- 运算规则:只能在队首和队尾运算,且访问结点时依照先进先出的原则。
- 实现方式:关键是掌握入队和出队操作,具体实现依顺序队或链队的不同而不同。
3.2、案例引入
3.2.1、进制转换
十进制整数N向其他进制数d(二、八、十六)的转换是计算机实现计算的基本问题。
转换法则:除以d倒取余
该转换法则对应于一个简单算法原理:
n = (n div d)*d + n mod d
其中:div为整除运算,mod为求余运算。