目录
0.前言
1.线性表
2.顺序表
2.1什么是顺序表
2.2 顺序表结构体设计
2.3 顺序表的初始化
2.4 顺序表的销毁
2.5* 顺序表的尾插
2.6* 顺序表的尾删
2.7 顺序表的头插
2.8 顺序表的头删
2.9 顺序表的查找
2.10 顺序表的插入(任意位置)
2.11顺序表的删除(任意位置)
2.12 复用大说明
2.13测试大说明(应用上可以有查找删除面)
3.* 未来写数据结构注意
0.前言
本文顺序表代码以及相关思维导图文件都已上传至gitee,可自取
2顺序表/动态顺序表实现 · onlookerzy123456qwq/data_structure_practice_primer - 码云 - 开源中国 (gitee.com)https://gitee.com/onlookerzy123456qwq/data_structure_practice_primer/tree/master/2%E9%A1%BA%E5%BA%8F%E8%A1%A8/%E5%8A%A8%E6%80%81%E9%A1%BA%E5%BA%8F%E8%A1%A8%E5%AE%9E%E7%8E%B0
1.线性表
线性表,英文名linera list,是n个具有相同特性的数据元素排成的有限线性序列。线性表是一种在实际中广泛使用的数据结构,常见的线性表有:顺序表,链表,字符串,栈和队列等等。
线性表在 逻辑上是 线性结构,也就说是连续的一条直线。但是在 物理结构上并 不一定是连续的,线性表在 物理上存储时,通常以 数组和链式结构的形式存储。
2.顺序表
2.1什么是顺序表
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储,在数组上完成增删查改。
说人话就是,顺序表就是一个数组,但是在数组的基础上,它要求数据必须是连续存储的,不能有跳跃和间隔。
下面我具体实现一个顺序表。
2.2 顺序表结构体设计
首先顺序表是有两类的,一种是可以动态扩容的,一种则是静态的。静态的顺序表的特点:如果满了,就不允许插入了。动态的顺序表是空间不够可以增容,从而继续存储。第一种的缺点是很大的,因为静态的顺序表从一开始其容量就已经决定了,后续不能扩容,很难确定在一开始给这个顺序表多大的空间。例如我想插入1001个数据,但是静态顺序表只能存1000个,这最后1个数据你就无法插入。而如果你只改下只想插入存储10个数据,那1000的大小的静态顺序表就会有900+的空间是浪费的。N给小了不够用,N给大了会浪费,就好纠结!
所以我们选用动态的顺序表进行实现。
我们说C语言中如何用一个struct,来描述表示一个完整的顺序表呢?
首先要开辟出一个数组实体,堆区的空间远大于栈区,所以选择在堆区存储该数组,所以我们就要在结构体中存储一个指针,指向存储在堆区的该数组。
然后还需要分别描述该数组以及顺序表的大小。因为我们开辟出一段空间,但是并不意味着这个数组上每个元素都存储着有效数据,数组的大小是顺序表的纯物理意义上的大小,而实际有效元素的个数才是真正的该顺序表的大小,这是两个概念。所以需要定义一个整形变量作为容量,代表的是现在数组的大小,即现在数组能够存储元素的个数,但并不意味着现在每个地方都存储着有效数据。然后还需要定义一个整形变量作为大小,代表的是现在顺序表的大小,即现在数组中有效插入数据元素的个数。
typedef struct SeqList {
int* _a; //指向堆区数组实体
int _size; //顺序表大小
int _capacity; //顺序表容量
}SeqList;
可是我们就可以发现一个问题,这个顺序表只能存储int类型的元素,我们要存储别的类型的元素如char,如long long,甚至是我们自己定义的结构体类型元素struct Student,都必须直接在SeqList结构体内部修改,为了方便后续修改存储类型,即我们这个SeqList可以有通用的一个类型,所以我们使用typedef一个通用类型的方式,进行定义,后续直接修改通用类型的代表类型就可以了。
//作为顺序表数据的通用类型
typedef int SLDataType;
typedef struct SeqList {
SLDataType* _a;
int _size;
int _capacity;
}SeqList;
2.3 顺序表的初始化
我们要对一个定义出的顺序表struct SeqList对象进行初始化。例如SLDataType* _a指针一开始总不能是一个野指针,int _size和_capacity总不能是随机值吧。所以定义出来就要对这个顺序表初始化,这点是我们必须要做的,不初始化的后果是很严重的,例如野指针非法访问,顺序表大小和容量紊乱。
然后是传参问题,我们不能在SeqListInit接口上直接传入SeqList s,因为我们知道C语言上的函数传参,函数中的参数是对传入数据的一份拷贝,应该传入的是SeqList* ps。如何体会这一点,我们进行如下实验:
//wrong example
void SeqListInit(SeqList s)
{
s._a = NULL;
s._size = s._capacity = 0;
}
如果直接传SeqList对象,在内存空间上就会如上图呈现,则我们在SeqList当中的逻辑,修改赋值的是s那一块空间的0xABCE,randomval1和randomval2。SeqListInit函数栈帧销毁之后我们发现原来sq的值还是没有修改。
所以我们应该传入sq的地址。
void SeqListInit(SeqList* ps)
{
ps->_a = NULL;
ps->_size = ps->_capacity = 0;
}
这样修改的就是sq本体成员的值了。同时,我们之后的所有接口都是需要传入sq的地址的,因为只有这样才能操作到sq实体!否则操作到的都是sq的拷贝。
2.4 顺序表的销毁
我们知道我们的顺序表使用的是堆区的空间,如果我们使用完这个顺序表,直到进程最后退出都不释放这块顺序表的堆区空间的话,那就会造成严重的内存泄漏问题。同时我们也可以看到如果不把sq内部的_a指针置空,就会发生野指针的问题。
所以无论如何,当用完一个顺序表,我们必须要销毁顺序表。
void SeqListDestroy(SeqList* ps)
{
//释放堆区空间(在此时检查越界)
free(ps->_a);
//避免野指针
ps->_a = NULL;
ps->_capacity = ps->_size = 0;
}
2.5* 顺序表的尾插
不要麻痹大意,简单的往往不简单!1.首先画图,2.然后思考各种情况,3.再上手写代码,4.写完代码须思考还有何纰漏,5.最后还要写代码测试。
就像尾插,如果直接在数组实体的尾部插入数据,只写这一句代码就完成这个函数的实现,这其实非常非常错误的。
你是否考虑过,这个顺序表的容量是否已经满了,再插入就要增容的情况,你是否考虑过,一开始这个顺序表是指向NULL的,我们需要对之开辟一段空间,然后才能对之插入。你是否考虑过,插入之后就需要更新struct SeqList对象内部的顺序大小_size这个成员变量,因为我们插入之后,顺序表存储的有效数据数量确实是增加了一个。这些都应该是我们应该考虑的事情。
事实上无论是尾插PushBack,还是头插PushFront,还是任意位置插入Insert,只要插入成功,都是需要考虑上述的增容检查问题以及顺序表大小_size更新问题。
那这样我们就设计检测增容为一个通用接口,因为我们在设计涉及所有有关插入接口的时候,都需要检查增容。
void CheckSLCapacity(SeqList* ps)
{
if (ps->_size == ps->_capacity)
{
int next_cap = (ps->_capacity == 0) ? 4 : ps->_capacity * 2;
SLDataType* ptmp = (SLDataType*)realloc(ps->_a, sizeof(SeqList) * next_cap);
if (ptmp == NULL)
{
//申请空间失败
perror("realloc");
exit(1);
}
ps->_a = ptmp;
ps->_capacity = next_cap;
}
}
void SeqListPushBack(SeqList* ps, SLDataType x)
{
//检查扩容
CheckSLCapacity(ps);
//尾部插入
ps->_a[ps->_size] = x;
//更新顺序表
ps->_size++;
}
只要这样才是完整的尾插。
2.6* 顺序表的尾删
首先我们需要明白,衡量这个SeqList对象有效数据多少的是,是成员变量_size的大小,所以我们其实只需要让ps->_size--,就可以达到尾删的效果,并不需要我们对尾部的数据赋值成0,-1或者是什么值,在下一次的插入的时候,这个刚刚尾删的数据单元,会被覆盖的,这个数据是什么改不改并不重要。
ps->_size--确实可以达到尾部删除的效果,可是我们在所有时候的删除都要让ps->_size--吗?我们测试如下代码
void SeqListPopBack(SeqList* ps)
{
//减少有效数据个数即可删除
ps->_size--;
}
可是你是否考虑过,删除的时候,如果此时_size大小,即顺序表有效数据的数目,已经是0的话,这时候我们还能减吗?如果不加以约束,_size依旧无脑减一,那后面我们就会发现,进程崩溃!
void SeqListTest5()
{
SeqList sq;
SeqListInit(&sq);
SeqListPushBack(&sq, 10);
SeqListPushBack(&sq, 20);
//如果不加以在删除函数内部约束,这样删除会使得_size:2->-3
SeqListPopBack(&sq);
SeqListPopBack(&sq);
SeqListPopBack(&sq);
SeqListPopBack(&sq);
SeqListPopBack(&sq);
//接下来插入会发生数组的越界访问
SeqListPushBack(&sq, 30);//_a[-3]=30
SeqListPushBack(&sq, 40);//a[-2]=40
SeqListDestroy(&sq);//在free的时候,会检查出数组的越界错误
}
int main()
{
SeqListTest5();
return 0;
}
原因也很简单,那就是我们看到,如果无脑的过度删除,那_size就会变成负数。然后后续我们再插入的时候,执行到这一句代码的时候:ps->_a[ps->_size] = x;就有可能变成ps->_a[-1]=x;ps->_a[-2]=x;等等,就会发生越界访问的问题!这就是问题!不过我们还是需要知道一件事,这个数组越界问题的报错并不是在访问修改的时候就及时报错,而是会在后面的free的时候才会进行越界的检查与报错,所以我们说数组越界访问的问题的报错是在最后才会呈现出来的。
所以我们应该允许有有效数据,即_size>0的时候,才进行删除,否则就是无效删除,在调用删除的时候,就什么都不干。
void SeqListPopBack(SeqList* ps)
{
//在有有效数据的情况下删,没有则不删
if (ps->_size > 0)
{
//减少有效数据个数即可删除
ps->_size--;
}
}
2.7 顺序表的头插
首先画图,其次考虑各种情况,然后再写代码,最后考虑代码的纰漏。
最后还需要更新ps->_size++。
void SeqListPushFront(SeqList* ps, SLDataType x)
{
//检查扩容
CheckSLCapacity(ps);
//往后移动
int end = ps->_size - 1;
while (end >= 0)
{
//a[end]代表去赋值者
ps->_a[end + 1] = ps->_a[end];
--end;
}
ps->_a[0] = x;
//更新顺序表
ps->_size++;
}
2.8 顺序表的头删
删除我们要知道,必须是_size>0的时候才能进行删除。
void SeqListPopFront(SeqList* ps)
{
//在有有效数据时才删除
if (ps->_size > 0)
{
//处理数据:所有的前挪动
//a[beg]是被赋值者
int beg = 0;
while (beg <= ps->_size - 2)
{
ps->_a[beg] = ps->_a[beg + 1];
++beg;
}
//更新顺序表有效数据
ps->_size--;
}
}
2.9 顺序表的查找
我们要在一个顺序表中,对所有有效数据进行遍历,找到目标要寻找元素的下标,如果寻找失败,则返回-1。
int SeqListFind(SeqList* ps, SLDataType x)
{
int pos = 0;
while (pos < ps->_size)
{
if (ps->_a[pos] == x)
{
return pos;
}
++ pos;
}
return -1;
}
2.10 顺序表的插入(任意位置)
我们设计一个接口Insert,传入要插入的位置pos,然后在这个位置进行插入,同时pos位置及其之后的元素都要被挤到后面去,完成一次插入。当然我们插入的位置是有要求的,我们可以在_a[0]到_a[_size]进行插入,在[0]位置的插入就是头插,在[_size]位置的插入就是尾插,在0<pos<_size插入就是在中间插入。在其他的位置插入都是非法的,因为顺序表(线性表)的要求就是数据必须连续存储,不能有任何间隔的数组。如果允许跳跃存储的话,那就会违反顺序表的基本性质。
如果检查插入的位置是合法的,那就要火速进行检查扩容,防止存储的位置不够。
逻辑就是在_a[0]---_a[pos-1]的元素不变,然后_a[pos]---a[_size-1]整体往后挪动,最后我们在_a[pos]位置上赋值插入用户想插入的值。
当然要注意在最后要更新_size。
void SeqListInsert(SeqList* ps, int pos, SLDataType x)
{
//在pos位置插入
//检查插入位置是否合法
assert(pos >= 0 && pos <= ps->_size);
//检查扩容
CheckSLCapacity(ps);
//[0,pos-1]不动,[pos,ps->_size-1]集体往后挪
int end = ps->_size - 1;
while (end >= pos)
{
ps->_a[end + 1] = ps->_a[end];
--end;
}
//给pos位置赋值
ps->_a[pos] = x;
//更新size大小
ps->_size++;
}
2.11顺序表的删除(任意位置)
首先我们要意识到,删除必须是在合法的位置删除才有意义,不合法的位置删除是没有意义的。比如说,我们可以删除从_a[0]---_a[_size-1]的任意一个元素。如果要删除的元素是[_size-1],那就是尾删,如果删除的元素下标位置是0<pos<_size-1,此时_a[0]---_a[pos-1]的元素不变,同时_a[pos+1]---a[_size-1]整体往前挪动覆盖,就可以达到删除的效果。而如果不是[0,_size-1]位置的元素要进行删除,那其实是不允许的。
最后不要忘记_size--,这才是真正宣告顺序表少了一个元素。
void SeqListErase(SeqList* ps, int pos)
{
//删除位置有效才可以,非有效直接报错
assert(pos >= 0 && pos < ps->_size);
if (ps->_size > 0)
{
//[0]--[pos-1]:保留不变,[pos]--[ps-_size-1]前挪删除
int beg = pos;
while (beg <= ps->_size - 2)
{
ps->_a[beg] = ps->_a[beg + 1];
++beg;
}
//更新有效数据大小(其实这才是删除)
ps->_size-- ;
}
}
2.12 复用大说明
其实很多的接口代码都是不用从头到尾一行行的实现,我们可以复用已经实现的函数接口,例如,如果我们已经实现了SeqListInsert,从这个角度上说,其实头插SeqListPushFront(ps,x)就是SeqListInsert(ps,0,x),尾插SeqListPushBack(ps,x)就是SeqListInsert(ps,ps->_size,x)。同理尾删和头删也可以用SeqListErase这个通用接口实现。
所以我们可以复用,不用再去实现尾插和头插了,代码如下所示。
//如果我们先实现出来 SeqListInsert和SeqListErase 这两个接口
void SeqListPushFront(SeqList* ps, SLDataType x)
{
SeqListInsert(ps,0,x);
}
void SeqListPopFront(SeqList* ps)
{
SeqListErase(ps,0);
}
void SeqListPushBack(SeqList* ps, SLDataType x)
{
SeqListInsert(ps,ps->_size,x);
}
void SeqListPopBack(SeqList* ps)
{
SeqListErase(ps,ps->_size-1);
}
2.13测试大说明(应用上可以有查找删除面)
我们应该对每一个接口进行测试,就是说写一个接口就要测试一个接口写的正确,因为写一点点,就测试一点点,我们很容易就发现问题出在哪里,而如果写了几百行代码再调试,会有许多的错误夹在在一起,这时候更加难办!所以其实写一点点,测试一点点,这其实在节约我们的时间!一定要写一点点,就测试一点点!
测试的时候我们采用单元测试,具体的形式是,不直接写代码到main函数当中,而是封装几个Test函数,分别在main函数中跑,这样非常方便我们进行测试,代码也更加清晰规范:
void SeqListTest4()
{
SeqList sq;
SeqListInit(&sq);
SeqListInsert(&sq, 0, 55);
SeqListInsert(&sq, 0, 66);
SeqListInsert(&sq, 0, 77);
SeqListInsert(&sq, 0, 88);
SeqListInsert(&sq, 0, 99);
SeqListPrint(&sq);
SeqListDestroy(&sq);
}
int main()
{
/*SeqListTest1();*/
/*SeqListTest2();*/
/*SeqListTest3();*/
//SeqListTest4();*/
SeqListTest5();
return 0;
}
3.* 未来写数据结构注意
一定要按照这个逻辑步骤来,细致的思考与画图后,再进行书写,可以减少很多不必要的麻烦。