一. 了解顺序表
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储.在数组上完成数据的增删查改.
1.1区分
用简单的语言来描述顺序表,其实就是数组,但也有与数组不同的地方.
对于数组,只需要找到对应的下标,就能将数据存到想要存储的位置,也就是在不越界的前提下,可以随意存储到任意位置.
对于顺序表,就不能随意存储,需要严格遵守顺序,必须从表的头部开始依次向后插入数据,才符合顺序表的定义.
1.2顺序表的类型
1.2.1静态顺序表
使用定长数组存储元素.
#define N 8 //宏定义变量
typedef int SLDataType;
typedef struct SeqList
{
SLDataType arr[N]; //定长数组
size_t size; //有效数据个数
}SeqList;
使用宏来定义一个全局变量,作为顺序表数据的存储空间.在结构体中创建数组存储数据,size记录有效的数据个数.
那么对于实践中,静态顺序表并不实用,有诸多问题.当知道需要多少个空间来存储数据,可以将N定义为准确的大小.而对于现实生活中,很多时候并不知道需要多少空间,当N定义小了,则会浪费;N大了则会浪费.
所以静态顺序表就用于了解.
1.2.2动态顺序表
使用动态开辟的数组存储
typedef int SLDataType;
typedef struct SeqList
{
SLDataType* arr; //指向动态开辟的数组
size_t size; //有效数据个数
size_t capacity //容量空间的大小
}SeqList;
动态能够使用malloc和realloc来根据需求去申请空间空间,也可以随着数据的不断插入来进行扩容.所以相比于静态,动态的优势明显大于静态,应用的场景也更广.
二 .顺序表的实现
2.1 结构体的创建
在顺序表中,创建结构体是为了更好的去管理数据,包括数组和其他变量.
typedef int SLDataType;
typedef struct SeqList
{
SLDataType* arr; //指向动态开辟的数组
size_t size; //有效数据个数
size_t capacity //容量空间的大小
}SL;
在这里引入了size和capacity变量:size记录有效数据的个数,是最后一个数据的下一个位置;而capacity用于是否扩容的判断条件,一次扩容多少是个问题,扩容是会付出一定程度的代价,每次一个还是多少?所以可以一次多扩一点.那么一次扩大多少呢,在数据结构中并没有严格的规定,遵循的是具体情况具体分析,但一般情况下会2倍扩容或者1.5倍
2.2常见函数实现
在这一般创建3个文件来进行实现顺序表:
SeqList.h的头文件,用于声明函数以及结构体.
SeqList.c的源文件,用于实现函数的功能.
test.c的源文件,用于调用和测试.
2.2.1初始化
首先来看一个经典的反面教材
void TestSL1()
{
SL sl;
SLInit(sl);
}
void SLInit(SL s)
{
s.a = NULL;
//函数的功能实现
}
这里TestSL1函数中传给SLInit的实参,与SLInit接受的形参,是一个拷贝的关系,也就是形参是实参的拷贝,并不会改变实参.所以传值不行那就传址.
void TestSL1
{
SL sl;
SLInit(&sl); //取地址
}
void SLInit(SL* psl)
{
psl->a = NULL;
//功能实现
}
1.创建顺序表
void SLInit(SL* psl)
{
psl->a = NULL; //先将创建的数组指向NULL
psl->size = 0; //数据个数设为0
psl->capacity = 0; //容量为0
}
2.销毁顺序表
void SLDestory(SL* psl)
{
if(psl->a != NULL)
{
free(psl->a); //手动释放空间
psl->a = NULL ;
psl->size = 0 ;
psl->capacity = 0 ;
}
}
2.2.2功能实现-头尾的插入删除
1.尾插
当空间足够的时候可以直接放入数据,然后让size自增.代码如下:
void SLPushBack(SL* psl , SLDataType x)
{
SLCheckCapacity(psl) //扩容函数,后续的代码也将使用此函数来进行扩容
psl->a[psl->size] = x; //在a数组中下标为size的地方赋值为x
psl->size++; //自增
}
那么真正的问题是,当空间不足的时候该如何继续进行尾插呢?所以需要进行扩容,扩容之前也要进行判断:空间是否不足.但初始化时,将空间容量设为了0,这里运用三目运算符,来应对初始为0,和不为0和不为0的情况.为了代码更加的简洁,可以将实现扩容的代码进行封装成一个函数,来供每个功能调用.
void SLCheckCapacity(SL* psl)
{
if (psl->size == psl->capacity) //判断空间是否已满
{
int NewCapacity = psl->capacity == 0 ? 4 : psl->capacity * 2;
//三目运算符来解决初始化为0和不为0的情况,
SLDataType* tmp = (SLDataType*)realloc(psl->a, sizeof(SLDataType) * NewCapacity);
//用中间变量来接收新开辟的地址,以免出现当开辟失败后返回空指针的情况
//realloc的两个参数 : 旧地址 , 扩容到多大
if (tmp == NULL) //显示出错信息
{
perror("realloc file");
return;
}
psl->a = tmp;
psl->capacity = NewCapacity;
}
}
接下来扩容可以使用realloc来进行扩容,realloc有原地扩容和异地扩容,前者会判断空间后续的空间有无分配给其他,否则会使用后者,后者将找到一块足够的空间,把已经存储的数据拷贝过去,释放原来的空间,最后返回新地址,需要注意的是,前者的效率高于后者.可以对比两者的返回值,也就是地址是否相同来判断.
判断realloc的扩容方式
int* p1 = (int*)malloc(40);
printf("%p\n", p1);
int* p2 = (int*)realloc(p1, 800); //realloc的参数为 : 旧地址 , 扩容到多大
printf("%p\n", p2);
2.头插
对于头插进行扩容时.是不能向前扩容,只能向后扩容.所以头插只能将已有的数据向后移动,再将需要头插的数据放入.
void SLPushFront(SL* psl, SLDataType x)
{
SLCheckCapacity(psl);
int end = psl->size - 1; //定位到最后一个数据
while (end>=0)
{
psl->a[end + 1] = psl->a[end]; //向后挪动数据
--end;
}
psl->a[0] = x;
psl->size++;
}
需要注意的是,不需要频繁的调用头插.当需要头插n个数据时,所使用的时间复杂度是O(n²),因为n的时候,需要向后挪动数据的次数是一个等差数列.而尾插插入n个则是O(n).所以能不用头插就不用.
3.尾删
对于尾删,顾名思义就是删除最后一个数据,所以,只需要将size减1,就能完成.
void SLPopBack(SL* psl)
{
psl->size--;
}
可能会有这样的疑问: 只进行自减,将size前移,但最后一个数据并没有完全被删除,会对之后的插入有影响吗?
答案是不会.因为当size前移之后,有效数据的个数减少了一个,也就是只能访问到size前的数据,而后续进行头尾插入时,会将数据进行覆盖,不会产生任何影响.同时也不能将空间释放,因为向内存申请空间是不能分期去还的,只能一次性付清
当size一直自减,超出了顺序表.变为了-1(甚至更小),其实也不会报错,因为程序执行的时候并没有去访问它.在这种情况下,继续尾插4个数据,再打印出来,会发现只有3个数据打印.原因是size为-1,顺序表的有效数据是从下标为0开始到size的前一个数据,在插入数据时,会将size为-1的位置存放第一个插入的数据,然后自增,再存放数据,且在最终打印时不会访问size为-1的数据,所以程序不会报错.
此过程可自己进行调试,观察size的变化,和数据是如何存储的.
上述情况是在顺序表已经删空了之后继续删除导致size为负数,那么该如何避免呢?这里有两种方法来判断,在函数中写入.
温柔的检查:
//温柔的检查
if(psl->size == 0)
{
return;
}
暴力的检查:
#include<assert.h>
assert(psl->size > 0); //断言
4.头删
头删只需要将后面的数据往前移动,把需要删除的数据覆盖,再把size自减.需要注意的是,挪动数据不能从后往前,这样会把所有数据覆盖为最后一个数据,所以只能从前往后.
void SLPopFront(SL* psl)
{
assert(psl->size > 0);
int begin = 1;
while (begin < psl->size)
{
psl->a[begin - 1] = psl->a[begin]; //挪动数据
++begin;
}
psl->size--;
}
2.2.3任意下标位置的插入删除
1.插入
这里在pos的位置处插入,但要注意的是,按照顺序表的要求,保证数据的连续,就不能越过size.且挪动数据的范围是,从pos到size前一个数据,也可以等于size,这样就不用挪动数据,变为尾插数据了.
void SLInsert(SL* psl, int pos, SLDataType x)
{
assert(psl); //断言指针不为空
assert(pos > 0 && pos <= psl->size);
SLCheckCapacity(psl); //检查是否需要扩容
//挪动数据
int end = psl->size - 1;
while (end >= pos)
{
psl->a[end + 1] = psl->a[end];
--end;
}
psl->a[pos] = x;
psl->size++;
}
2.删除
与插入不同,这里的pos不能等于size,因为size处是一个无效的位置.所以与头删逻辑相同,需要从前往后挪动覆盖.
void SLErase(SL* psl, int pos)
{
assert(psl);
assert(pos > 0 && pos < psl->size);
//挪动覆盖
int begin = pos + 1;
while (begin < psl->size)
{
psl->a[begin - 1] = psl->a[begin];
begin++;
}
psl->size--;
}
2.3常见问题
越界读基本不会报错,而越界写可能会报错.因为越界的检查是一种抽查行为,就像查酒驾.它会在数组后设计一些标记位,去检查这些位置有无被修改.
三 .小结
对于程序员来说,十分清楚程序是如何使用的,但给到了别人使用,可能不清楚是如何去操作和传参.当使用者看到函数的参数是一个指针,可能会传入一个空指针,这样就会导致程序遇到空指针异常结束,所以可以在每个函数开始前来进行断言psl不为空指针,可以有效的避免传错.
这里解释一下为什么数组要从0开始作为第一个数据的下标而不是从1开始.因为数组访问的本质是指针,数组名是首元素的地址.
int a[10];
//数组名a,是首元素地址.
a[i] == *(a+i);
a[0] == *(a+0);
顺序表的讲解就到此结束,欢迎各位到评论区留言纠错