一、开篇序言
作为一名刚步入大三的计算机科班生,思来想去,还是决定以数据结构作为自己的第一章,原因有很多,由于数据结构是大二学的,现在还有点基础和印象;再者,学计算机就是基础不牢,地动山摇,学好数据结构无论是单纯对计算机的学习或者是升学、找工作都是至关重要的。与君共勉,有错误的地方还请大佬指出🙏
二、数据结构是什么?
下面是摘自由严蔚敏、吴伟民所著作的《数据结构(C语言版)》的定义:
数据 : 是对是对客观事物的符号表示,在计算机科学中是指所有能输入到计算机中并被计算机程序处理的符号的总称。
数据元素 : 是数据的基本单位,在计算机程序中通常作为一个整体进行考虑和处理。
数据对象 : 性质相同的数据元素的集合叫做数据对象,是数据的一个子集。
数据结构 : 是相互之间存在一种或多种特定关系的数据元素的集合。数据元素之间的关系成为“结构”
就我个人的理解,数据结构就是将现实生活中的各种问题,抽象成一种可以用计算机语言来描述其各种关系并解决该问题的一种工具吧,例如:栈、队列、二叉树、红黑树、哈希表等。
数据结构的“结构”通常分为四类基本结构:
1、集合结构
2、线性结构
3、树形结构
4、图状或网状结构
而数据结构中的线性表就是典型的线性结构。
三、线性表
什么是线性结构?什么是线性表?在我看来,简单地说,线性结构你可以抽象为一群人正在排队买饭,每个人都一个前后的顺序关系,这就是一种简单的线性结构,感觉就是在一根线上似的。而线性表则可以理解为具有线性结构的一些数据的一种存储工具。
线性表根据存储方式的不同又可以分为顺序表和链表两种存储形式。顺序表代表了顺序存储,即在逻辑上相邻的元素,其物理地址也是相邻的,例如数组中的元素:data[1]与data[2]在逻辑上是相邻的两个元素即数组下标相邻,他们的物理地址,即内存地址也是相邻的,如图:
代码实现如下:
int data[5] = {0};
cout << "data[1]:" << &data[1] << endl;
cout << "data[2]:" << &data[2] << endl;
输出结果:
data[1]:0x7fffffffde14
data[2]:0x7fffffffde18
可以看到int类型的数据,当数组下标相邻时,其元素在内存中的地址也是相邻的,data[1]是14H,data[2]是18H
而链式存储,顾名思义,就是用链子链起来的存储,它与顺序存储的一个最重要的区别就是:链式存储逻辑相邻的元素,物理不必相邻,这个后续会讲到,本篇主角是“顺序表”
四、顺序表的实现
由于顺序表在形式上与数组很相似,所以我们可以借助数组来实现一个顺序表,但是请注意,顺序表不等于数组!!!这是因为数组一旦创建后便确定,其大小,元素是不可变的,而我们的顺序表是可以进行各种操作的。
1、顺序表的静态分配
下面我们用数组来尝试实现一个顺序表,其结构体可以定义为:
#define MAX_SIZE 10
typedef struct LinearList
{
int data[MAX_SIZE];//借用数组来实现一个顺序表
int length; //顺序表当前表长,用于后续的各种操作,比如判越界等
}SqList;
这种借用数组来实现的顺序表,我们通常称为静态分配的顺序表,其的静态体现在顺序表创建之后大小便不可改动,因为这是借助数组创建的,所以它的大小一定是不可变的,与之对应的叫做“动态分配”他借用C语言中的malloc函数或者C++的new来动态申请内存空间,这样实现的顺序表是可以改变大小的
2、顺序表的动态分配
typedef struct SeqList{
int *data; //指针指向存储数据元素的基地址即首地址
int length; //表示当前顺序表大小
int max_length;//表示当前顺序表最大容量
} SeqList;
由于两种实现方式在大部分操作下代码实现相同,故下述操作将用静态分配的顺序表进行操作,而顺序表的扩容、销毁等相关操作,将在动态分配下进行实现。
五、顺序表的操作
顺序表常用操作总述:
初始化顺序表:InitList(&L)
清空顺序表:ClearList(&L)
顺序表的判空:IsEmpty(L)
求顺序表表长:SumLength(L)
插入元素:InsertList(&L,p,e)
按位查找:PlaceFind(&L,p)
按值查找:DataFind(&L,e)
按位删除:PlaceDelet(&L,p)
按值删除:DataDelet(&L,e)
顺序表扩容:AddList(&L,n)
销毁顺序表:DieList(&L)
接下来按顺序实现上述各种操作:
1、初始化顺序表:InitList(&L)
1.1、静态分配的初始化
由于我们对顺序表的大部分操作一定是根据length来操作的,所以当我们直接给length赋值为0就可以完成初始化了。
// 初始化顺序表
void InitList(SqList &L){
L.length = 0;
}
1.2、动态分配的初始化
动态分配的初始化需要用的malloc函数(如果对于此部分有疑惑的初学者,可以去复习一下C语言的指针、结构体部分)
#define InitSize 10
void InitList(SeqList &L){
L.data = (int *)malloc(sizeof(int) * InitSize);
L.length = 0;
L.max_length = InitSize;
}
malloc函数返回的是一个指针,并且我们的数据元素是int类型,所以还需要用到sizeof函数来计算int类型在内存中的大小。malloc函数申请完内存空间后,我们还是一样的操作,直接给length为0即可,并且需要更新顺序表的最大容量:max_length = InitSize
2、清空顺序表:ClearList(&L)
当我们的顺序表中存在元素时,清空顺序表的本质就是将这些元素清除,也可以理解为这些元素在清除之后将不可被操作,与此同时,顺序表的表长也会归0,但顺序表仍然存在,则我们可以根据初始化的策略,直接令length = 0即可
void ClearList(SqList &L){
L.length = 0;
}
3、顺序表判空IsEmpty(L)
只要顺序表是空的,那么他的大小即长度一定是0
bool IsEmpty(SqList L){
if(L.length == 0){
return true;
}else{
return false;
}
}
是空表返回true,不是空表返回false
4、求顺序表表长:SumLength(L)
返回length即可
void SumLength(SqList L){
if(L.length == 0){
cout << "空表" << endl;
}else{
cout << "表长为:" << L.length << endl;
}
}
5、插入元素:InsertList(&L,p,e)
当插入一个元素时,首先应该判断插入位置是否合理,是否可以插入,假如插入位置大于了顺序表的长度,或者是非正整数,那么插入就是不合法的。再有,当顺序表满时,再进行插入操作时,也是不合法的。当正常插入时,其过程如图:
可以看到,当插入一个元素之后,插入位置以及其之后的元素都将向后移动一个位置,并且顺序表的长度要+1,根据这些,可以得到插入的代码如下:
// 顺序表的按位插入
void InsertList(SqList &L,int p,int e){
//首先判断插入位置的合法性
if(p<1 || p>L.length+1){
cout << "插入位置不正确,插入失败!" << endl;
return;
}
if(L.length+1 > MAX_SIZA){
cout << "顺序表满,插入失败!" << endl;
return;
}
//往后移动元素
for (int i = L.length; i >= p;i--){
L.data[i] = L.data[i-1];
}
L.data[p - 1] = e;
L.length++;
cout << "插入成功!" << endl;
}
其中需要注意的是,在元素后移时一定是从后往前进行的,这样才能腾出来空位置
6、按位查找:PlaceFind(&L,p)
按位查找时,首先同样的一定要判断位置的合法性,之后再进行查找。对于线性表而言,他的按位查找是极其方便的,代码如下:
// 顺序表的按位查找
void PlaceFind(SqList L,int p){
if(p<1 || p>L.length){
cout << "查找位置越界,查找失败!" << endl;
return;
}
cout << p << "位置元素为:" << L.data[p - 1] << endl;
}
7、按值查找:DataFind(&L,e)
按值查找与按位不同,首先他不用判断位置的合法性,但是你需要分情况讨论,即找到了和没找到,所以可以设置一个标志,代表找到怎么样,找不到又怎么样,代码如下:
// 顺序表的按值查找
void dataFind(SqList L,int e){
int i = 0;
int f = 0; //查找的标志
while (i < L.length)//从头查到尾
{
if(L.data[i] == e){
f = 1; //只要有一个元素满足,f置1
cout << "位置" << i + 1 << "上的元素为" << e << endl;
}
i++;
}
if(f == 0){//最后根据f的值来选择进行相关操作
cout << "查找值不存在,查找失败!" << endl;
}
}
8、按位删除:PlaceDelet(&L,p)
按位删除时首先也应该判断位置的合法性,在此不再赘述。在删除某个位置的元素后,其后的所有元素需要前移,并且顺序表表长-1,如图:
代码如下:
// 顺序表的按位删除
void PlaceDelet(SqList &L,int p){
if(IsPout(L,p)){
cout << "删除位置不正确,删除失败!" << endl;
return;
}else{
//元素前移
int e = L.data[p - 1];
for (int i = p; i < L.length; i++)
{
L.data[i - 1] = L.data[i];
}
cout << "成功删除位置" << p << "的元素:" << e << endl;
}
L.length--;
}
这里需要注意的是,与插入操作不同,删除时是元素前移,并且是从删除位置开始操作的,即从前往后依次操作
9、按值删除:DataDelet(&L,e)
按值删除较按位删除稍微复杂一些,他可以采用很多方法去实现,我们将采取效率最高的一种删除方法,即直接在原顺序表上删除,这样所需要的空间是最小的。由于某个值在顺序表中可能多次出现,假如我们挨个删除,删除之后元素前移,则会导致元素覆盖的问题,并且会浪费很多的时间,因此我们可以选择之间挑选符合条件的元素,直接重新构成该顺序表,代码如下:
void dataDeletList(SeqList &L,int e){
int k = 0; //新的顺序表的下标
for (int i = 0; i < L.length; i++)
{
if(L.data[i] != e){ //满足新顺序表的条件
L.data[k] = L.data[i];
k++;//最后执行完最后一次循环,k要+1,所以length = k
}
}
L.length = k;
cout << "删除成功!" << endl;
}
最后要注意,我们的length最后是等于k的,而不是k+1
10、顺序表扩容:AddList(&L,n)
对于顺序表的扩容操作,我们前面提到过,只有对于动态分配的顺序表才可以扩容,所以扩容操作我们只对于动态分配的顺序表进行讨论:
在动态分配的结构体中,我们定义了一个max_length的当前顺序表的最大容量,我们的扩容操作也是针对max_length来进行的,在扩容时还是需要用到malloc函数,因为我们需要申请额外扩容的空间,由于我们无法直接在原有的地址上继续申请n的空间,所以我们需要一口气申请max_length+n的空间,然后赋给L.data,其中又涉及到free函数,代码如下:
void AddList(SqList &L,int n){
if(n<1){
cout << "扩展数量不正确,扩展失败!" << endl;
return;
}
int *fake = (int *)malloc(sizeof(int) * L.length);
for (int i = 0; i < L.length;i++){
fake[i] = L.data[i];
}
free(L.data);
L.data = (int *)malloc(sizeof(int) * (n + L.max_length));
for (int i = 0; i < L.length;i++)
{
L.data[i] = fake[i];
}
L.max_length += n;
free(fake);
cout << "扩展成功!" << endl;
cout << "顺序表最大容量为:" << L.max_length << endl;
}
前面忘记说了,扩容的时候也需要注意n的数值,看是否越界。为了养成一个良好的习惯,最后一定要free掉fake
11、销毁顺序表:DieList(&L)
由于我们静态分配的顺序表,在执行完相关函数之后,所占用的内存空间会自动释放掉,不需要手动释放,而动态分配申请的内存空间需要我们用free函数手动释放掉,代码如下:
void DieList(SqList &L){
L.length = 0;
L.max_length = 0;
free(L.data);
L.data = NULL;
cout << "顺序表已销毁!" << endl;
}
六、结尾
至此,第一种数据结构--线性表的顺序存储方式--顺序表的实现以及相关操作就结束了,顺序表是第一种数据结构,他实现起来较为方便,代码量少,而且通过对顺序表的各种操作我们可以初步体会和了解数据结构,为后续的学习营造了一个良好的开端。