本文的所有代码均由C++编写
如果你已经看完这篇杂谈,可以前往下一篇→数据结构杂谈(三)_尘鱼好美的小屋-CSDN博客
文章目录
- 2 顺序表
- 2.1 线性表的类型定义
- 2.2 类C语言有关操作补充
- 2.2.1 ElemType的解释
- 2.2.2 数组定义
- 2.2.3 建立链表可能会用到的函数
- 2.2.4 参数传递问题
- 2.2.4.1 地址传递
- 2.2.4.2 值传递
- 2.2.4.3 数组名为参
- 2.3 顺序表
- 2.3.1 顺序表的定义
- 2.3.1.1 顺序存储方式
- 2.3.1.2 静态分配方法
- 2.3.1.3 数据长度和线性表长度区别
- 2.3.1.4 动态分配方法
- 2.3.1.5 顺序表的特点
- 2.3.2 顺序表的基本操作
- 2.3.2.1 初始化
- 2.3.2.2 顺序表的插入
- 2.3.2.3 顺序表的删除
- 2.3.2.4 顺序表的按位查找
- 2.3.2.5 顺序表的按值查找
2 顺序表
下面开始我们要学习一种最常用也是最简单也是最不简单的逻辑
结构,也就是线性表。
为了更直观地体现线性表,我们举几个生活中的例子。
第一个例子是食堂排队打饭,这种排队通常都是一个接着一个,第一个同学打完就轮到下一个同学打饭,如果队伍中间一个人有事走了,那么其他人都会前进一步,而如果前面有人仗着有朋友而插队,那么所有人都得后退一步,这种情况在线性表中的体现即为
顺序表
。第二个例子是医院挂号,这种挂号一般是按顺序挂号,挂完号的人就去等待区候着而不用在那里排队,到了你的号数就去看病,与前一种方式不同,它不会因为你站的位置来决定你的看病的先后顺序,而是看你手中的挂号牌,这种情况在线性表中的体现即为
单链表
。
2.1 线性表的类型定义
接下来我们要讲的东西是针对线性表,即单链表
和顺序表
都属于此范畴。
线性表是具有相同数据类型的n个数据元素的有限序列
。其中n为表长,当n=0的时候线性表是一个空表。若用L命名线性表,即:L=(a1,a2,a3….,an)L = (a_1,a_2,a_3….,a_n)L=(a1,a2,a3….,an)。
关于上面的L表示法,其中有几个术语是需要我们知道的。
a2相对于a1来说排在后面,所以我们叫做
直接后继
。a1相对于a2来说排在前面,所以我们叫他直接前驱
。很显然,倒数第二个元素只有一个直接后继,第二个元素只有一个直接前驱。在非空表里每个数据元素都有自己对应的位置,我们叫做位序
。比如在线性表中,a1排在第一位,我们说a1排在线性表中的第一位序
。位序和索引要明确,线性表中第一个元素即为第一位序,且为第0个元素。
2.2 类C语言有关操作补充
在考研必备教材《数据结构C语言版》严蔚敏版中,为了方便同学的学习,采用了类C语言,即C语言C++混用,不在意语法;意在让同学理解内在逻辑而不过分追究编译语法。
2.2.1 ElemType的解释
我们给出一个线性表中顺序表的定义,这里我们只是简单了解下,看不懂没关系。
typedf struct{ElemType data[];int length;
}SqList;//顺序表类型
这时候我们会觉得很奇怪,从来没有看过ElemType这种类型。实际上我们从英文上可以看出,这里的意思是元素类型,即数据元素类型,比如说你要用什么数组,如果你的数组打算放int,那么你可以把ElemType的位置换成int。
2.2.2 数组定义
typedf struct{ElemType data[MaxSize];int length;
}SqList;//顺序表类型
从上面的代码来看,很明显使用数组来表示顺序表,用长度来记录表长。但是它确有另外一种表示方法。
typedf struct{ElemType *data;int length;
}SqList;//顺序表类型
实际上,如果是data[MaxSize],那么我们会发现一旦MaxSize确定下来了,那么我们的数组长度也就确定下来了。
但是如果我们用的是* data,那么data是一个指针变量,我们可以通过new函数
来申请一片内存的地址,然后把地址赋给data数组,这样数组的长度由数组元素的字节长度和数组的最大容纳量来确定了。这实际上就是静态数组分配和动态数组分配的区别,后面会讲到。
2.2.3 建立链表可能会用到的函数
在C语言中我们常用的是
-
malloc函数,用于开辟m字节长度的地址空间,并且返回这段空间的首地址。
-
sizeof函数,计算变量x的长度
-
free§函数,释放指针p所指变量的存储空间,即彻底删除一个变量。
需要注意的是,如果要用到以上的函数,需要加载头文件:<stdlib.h>
而在C++中,我们用的是
- new函数
- delete函数
- sizeof函数
2.2.4 参数传递问题
2.2.4.1 地址传递
地址传递实际上就是传地址给函数,函数通过指针的解引用互换对应地址中的值;但是这里有个问题,地址不能互换,只有地址上的值能换,这是需要注意的一点。
对于C语言来说,通常喜欢用指针来修改传入的参数并返回对应的结果,而对于C++来说,用引用&会更方便一些。
在C++中,引用相当于一个别名,比如说int a = 1,此时你用引用可以给1再起一个别名,比如起个别名叫b,那么对b中的1修改为2后,a的1也会编程2,这样相当于是用两个名字去操纵同一个数据。
引用类型作形参,在内存中并没有产生实参的副本,他直接对实参操作;而一般变量作参数,形参与实参就占用不同的存储单元,所以形参变量的值是实参变量的副本。因此,当参数传递的数据量较大时,用引用比用一般变量传递参数的时间和空间效率都好。
指针参数虽然能达到和使用引用类型的效果,但在被调函数中需要重复使用“指针变量名”的形式进行计算,这很任意产生错误且程序的阅读性较差;另一方面,在主调函数的调用点处,必须用变量的地址作为实参。
2.2.4.2 值传递
值传递是把数值传进函数,这样的做法在函数里面的确能够实现应有的功能,但是当函数运行结束时,变量不会做任何改变,因为变量传给函数的是数值,变量所在的地址的数值仍未发生变化。
2.2.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.3 顺序表
说那么多线性表,我们接下来来看看线性表的两种物理结构其中之一:顺序存储结构
。
2.3.1 顺序表的定义
线性表的顺序表示
指的是用一组地址连续的存储单元依次存储线性表的数据元素。
其示意图如下:
这种表示一般被我们叫做顺序存储结构
或者叫做顺序映像
。拥有这种结构的线性表我们叫顺序表
。
2.3.1.1 顺序存储方式
线性表的顺序存储结构,说白了就是在内存中随便找块地,通过占位的形式,把一定内存空间给占了,然后把相同数据类型的数据元素依次存放到这块空地中。这里我们想到,这有点类似于一维数组
吧?一维数组也是这个定义。
这里实际上隐含着另外一个意思,既然线性表的数据元素都是相同数据类型,那么我们知道一个数据元素占几个字节(数据元素的大小),那么线性表连续,就必定有LOC(ai+1)=LOC(ai)+lLOC(a_ {i+1}) = LOC(a_i)+lLOC(ai+1)=LOC(ai)+l(这里的loc指的是内存地址,即location)这种情况,其中lll代表他们一个数据元素的大小。比如一维数组a[1],a[2]。如果是整型数组,那么就有如下结果:
至于lll所代表的数据元素的大小我们从何而知呢?就是利用我们在2.2.3讲到的sizeof函数。
2.3.1.2 静态分配方法
在2.2.2时我们曾经提到两种顺序表的实现方法,即动态分配
和静态分配
,在下面我们会依次讲解这两种方法。
我们前面说过可以用一维数组来表示线性表,但是这里要注意一点,线性表长可变,而数组长度是不可动态定义。这时候我们用一个变量(length)来表示顺序表的长度属性。所以单纯用一个数组还不行,还要加一个长度属性。
那如何用C语言去定义顺序表呢?线性表每个节点中都有数据,可是没有说是什么数据类型,可以是基本数据类型也可以是复合数据类型;可以是结构化数据类型也可以是半结构化数据类型,所以,我们通常利用结构体来创建一个线性表,其中线性表包含数据元素和长度。所以如果写成代码如下:
#define MAXSIZE 100 //定义数组的最大长度
typedef struct{ElemType data [MAXSIZE]; //用静态的数组存放数据元素int length; //顺序表当前长度
}SeqList;
在以上的定义中,我们可以发现一件事。如果我们过早的指定一维数组中的MAXSIZE,那么我们很难担保后面能够提供足够多的数据元素。
就拿占位的例子来说,一个人占了九个位置给舍友,这只是一个估计,是死的、理想状态的;而实际上,九个人并不是那么好学,里面有一些人没来放鸽子;还有一种情况就是,九个人有几个还带了女朋友(单身狗留下了泪水),那占的位置不够坐的情况也有可能发生。
在上一段话中提到线性表数据元素不足和数据元素存满的情况,数据元素不足只是浪费了内存,如果是存满这时候就可以放弃治疗了。
2.3.1.3 数据长度和线性表长度区别
对于上面的讲解可能还有些人会有疑惑,length和MAXSIZE的区别不是很清楚,在这一小节我们详细阐述一下。
静态分配如下所示:
#define MAXSIZE 100
typedef struct{ElemType data [MAXSIZE];int length;
}SeqList;
我们在这里发现定义顺序表需要三个属性:
- 存储空间的起始位置:数组data
- 线性表的最大存储容量:数组长度MaxSize
- 线性表的当前长度:length
也就是说,你确定了数组长度是吧,数组开辟空间了是吧,最后数组上面选一段作为线性表,有点套娃,如图所示:
也就是说,如果你数组开辟的空间不够多,就会导致顺序表用的空间不够多,也就会导致顺序表的数据元素填不进去。
所以综上所述,我们得出如下结论:
- 数组长度是指存放线性表的存储空间的长度,存储分配后这个值是不变的。
- 线性表的长度是线性表中数据元素的个数,随着线性表的插入与删除,这个值是在变换的。
2.3.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; //顺序表最大长度增加lendelete(p); //释放老数组的内存空间
}
原来老数组是在内存中开辟了一块内存空间,而我们在增加动态数组的长度时,实际上是在内存的其他地方,开了一块更大的空间,然后把老数组上面的数组复制过去新数组。
既然如此,我们就应该把定义一个新指针p,把老数组的指针移交给p后,把data指针指向新数组。此时p指老,data指新,通过循环把p中的每一个元素移交给data即可,移交完成后,要记得把顺序表的最大容量也修改一下,即老顺序表的最大容量加上扩充容量。
经过上面的代码讲解,我们可以知道一件事就是,由于要把数据从老数组复制到新数组,实际上时间开销是非常大的,这也对应了我们2.3.1.3讲解的,一般来说在一些书上是不会讲这个动态分配的事的,只有在
考研
中才会涉及到这个知识点。
2.3.1.5 顺序表的特点
结果上面的讲解,我们可以总结顺序表具有如下特点:
- 随机访问,即可以在O(1)时间内找到第i个元素
- 存储密度高,每个节点只存储数据元素
- 拓展容量不方便(即便采用动态分配的方式实现,拓展长度的时间复杂度也比较高)
- 插入、删除操作不方便,需要移动大量元素
2.3.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;
}
实际上,上面的操作具有一定的违规,因为我们是在用顺序表,而不是在用数组,所以上面打印的条件应该是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++的话,我们还可以对==进行重载
。
不过幸运的是,在考研当中,目标学校更侧重的是你对算法的理解,而不在代码的细节。所以在手写代码的时候无论是基本数据类型还是结构数据类型都是可以直接使用==
。
对于按值查找的时间复杂度来说,和插入操作一样,稍加思考一下就能理解,这里就不过多讲述了。
需要提到的是,按值查找也有技巧可言,并不一定要按照顺序扫描的方式;比如二分查找等查找方法都能提高时间效率,在后面会有更深层次的讲解。