目录
引入
顺序表的概念
顺序表是在计算机内存中以数组的形式保存的线性表,线性表的顺序存储是指用一组地址连续的存储单元依次存储线性表中的各个元素、使得线性表中在逻辑结构上相邻的数据元素存储在相邻的物理存储单元中,即通过数据元素物理存储的相邻关系来反映数据元素之间逻辑上的相邻关系,采用顺序存储结构的线性表通常称为顺序表。顺序表是将表中的结点依次存放在计算机内存中一组地址连续的存储单元中。
从上述概念中,我们不难发现顺序表与数组有着难解难分的关系,在某种程度上,我们可以说顺序表就是数组,但是在数组的基础上,它还要求数据是连续存储的,不能跳跃间隔。
顺序表的分类
1、静态顺序表
静态顺序表是在数组的基础上实现的,这更体现了数组与顺序表的关系。
//用SLDataType重命名顺序表内存贮的数据类型,方便后续更改
typedef int SLDataType;
//用宏来定义数组的长度,便于后续修改
#define N 100
struct SequenceList
{
SLDataType data[N];
//记录顺序表内已存储的数据个数
int size;
};
为了后续更改方便,我们可以将顺序表内存储的数据类型用SLDataType来重命名,将数组中的元素个数用一个宏来定义,这样我们需要做相应修改时只需要做少量的修改就能达到修改整个程序的作用。
typedef int SLDataType; //存放int类型数据
typedef double SLDataType; //存放double类型数据
#define N 100 //数组长度为100
#define N 500 //数组长度为500
静态顺序表有着数组的特点,当然也就有着数组的缺陷即数组的大小是固定的,当想存入的数据小于数组长度时会造成内存的浪费,而当想存入的数据大于数组的长度时,那这个顺序表就无法存贮全部数据了。
这样,动态顺序表就应运而生了。
2、动态顺序表
动态顺序表是基于动态内存管理上实现的。动态内存的特性授予了我们根据自己的意愿将顺序表的大小发生改变的能力。这样我们使用顺序表就更加灵活,减少内存的浪费、避免内存的不足,很好地弥补了静态顺序表的缺陷。
typedef struct SequenceList
{
//创建顺序表存储类型的指针作为动态内存的起点,从而实现动态的顺序表
SLDataType* data;
//记录顺序表内已存储的数据个数
int size;
//记录顺序表的容量
int capacity;
}SL;
data:顺序表存储类型的指针,作为动态内存的起点。
size:用来记录顺序表内已存储(有效数据)的个数。
capacity:用来记录顺序表目前的最大容量。
我们还可以将结构体类型重命名,这样使用起来就更加简洁方便。
正文
从上文来看,动态顺序表比静态顺序表更加优秀,动态顺表的应用也更加广泛,接下来我们就来实现动态顺序表的各个接口。
同时为了方便管理,我们采用多文件的方式来完成顺序表。多文件为工作中必备的能力,应把它融入学习过程之中。
注:接口也就是函数。
1、初始化
void SeqListInit(SL* psl)
{
//保证psl不为空指针
assert(psl);
psl->data = NULL;
psl->size = 0;
psl->capacity = 0;
//这个表达式与上面两个表达式的作用相同
//psl->size = psl->capacity = 0;
}
在正式存贮数据之前,我们需要对链表内的数据进行初始化,这样可以避免数据为随机值,造成使用隐患。
2、容量检查
我们对顺序表进行初始化后,发现顺序表的容量为0,还有当顺序表内存贮的数据量等于顺序表的最大容量时,要是我们还想再存数据那该怎么办呢?那我们就要实现接口完成顺序表的初次增容和之后每次容量不够时扩容的功能。
void SeqListCapcityCheck(SL* psl)
{
assert(psl);
if (psl->size == psl->capacity)
{
//设定新的容量
//如果capacity==0,就将newcapacity设定为5
//之后的每一次扩容都为先前一次容量的两倍,如第二次扩容后容量为10,第三次后为20...
int newcapacity = (psl->capacity == 0 ? 5 : psl->capacity * 2);
//当realloc的第一个参数为空指针时,作用效果与malloc相同
SLDataType* tmp = (SLDataType*)realloc(psl->data, newcapacity * sizeof(SLDataType));
if (tmp == NULL)
{
perror("realloc");
exit(-1);
}
psl->data = tmp;
psl->capacity = newcapacity;
tmp = NULL;
}
}
我们发现当顺序表需要初次扩容或增容时,顺序表的size和capacity相等,那我们就可以由这个条件来判断是否需要对容量进行操纵。
int newcapacity = (psl->capacity == 0 ? 5 : psl->capacity * 2);
当容量为0时,我们就设定新容量为5,在其余条件下,每次扩容新容量都是旧容量的两倍。
这样扩容的次数越多,扩容的频率就会越低。
SLDataType* tmp = (SLDataType*)realloc(psl->data, newcapacity * sizeof(SLDataType));
你可能会疑惑,如果这里用realloc,要是是初次增容怎么办呢?这就要我们来看看realloc的用法。
当realloc的第一个参数为空指针时,那么realloc的功能就与malloc相同。 这样是不是就能解决你初次扩容能不能使用realloc的疑问了呢?
且在扩容之后我们要对返回的指针进行检查,如果扩容失败就报错并终止程序,确认扩容成功了才能将新开辟地址的指针赋值给data。
3、从尾部插入数据
void SeqListPushBack(SL* psl, const SLDataType val)
{
assert(psl);
SeqListCapcityCheck(psl);
psl->data[psl->size] = val;
psl->size++;
}
在每次插入数据之前都要调用SeqListCapcityCheck()来对容量进行检查。
存数据的次数 | 应存放到的下标 | 存放时size的大小 |
1 | 0 | 0 |
2 | 1 | 1 |
3 | 2 | 2 |
不难看出,在从尾部存放数据时,数据应存放到的下标和存放时size的大小相等,我们就可以把此时的size当作下标来存贮数据。每次存储后,size都要++。
4、从尾部删除数据
虽然说是要删除数据,但是每次删除数据就要将数据清空,难道不觉得很麻烦吗?我们说过,size是顺序表内存储数据的个数,也代表了顺序表有效数据的个数,那么我们就可以通过size--的方式来让最后一个数据变成无效数据,这样即使不删除最后一个数据,计算机也不会去访问它。这和我们在电脑上删除数据时有着异曲同工之妙。
void SeqListPopBack(SL* psl)
{
assert(psl);
//没有必要将数据清零做到真正的删除
//size代表顺序表内有效数据的个数,只要让size--就代表顺序表的尾部数据不再有效
//从而在对顺序表进行访问时也不会访问最后一个元素,间接完成了从尾部删除数据
//进行删除的前提是有数据可删除
//assert括号内表达式为假就会报警
//如果没有数据可删就报告错误并退出程序
//assert(psl->size > 0);
//psl->size--;
//相比于assert更加温柔的做法,如果没有数据可删就不执行删除操作
if (psl->size > 0)
{
psl->size--;
}
}
5、从头部插入数据
如果直接从数据赋值给data[0],那么当原本data[0]有值时,就会造成数据损失,为了实现头插,我们需要将全部数据全部向后移动一位。
void SeqListPushFront(SL* psl, const SLDataType val)
{
assert(psl);
SeqListCapcityCheck(psl);
//end为从头添加数据前最后一个元素的下标
int end = psl->size - 1;
//从最后一个元素开始,将所有元素一个个的向后移动,空出第一个位置供数据插入
while (end >= 0)
{
psl->data[end + 1] = psl->data[end];
--end;
}
psl->data[0] = val;
psl->size++;
}
从最后一个数据开始,不断地数据向后移动,直到空出第一个数据的位置为止。然后再进行赋值,这样就不会有数据损失了。
6、从头部删除数据
从头部删除数据有着和尾删和头插相同的思想,即通过size--来制造无效数据和数据移动。
void SeqListPopFront(SL* psl)
{
assert(psl);
assert(psl->size > 0);
//从第二个元素开始将数据开始向前移动,达到删除第一个元素即从头部删除数据的目的
int begin = 1;
//最后一个元素的下标为size-1
while (begin <= psl->size-1)
{
psl->data[begin - 1] = psl->data[begin];
++begin;
}
psl->size--;
}
通过移动让第一个数据被覆盖,让多出来的位置变成无效位。达到头删的效果。
7、打印数据
打印数据可以帮助我们来测试顺序表,及时发现出现的BUG。
void SeqListPrint(const SL* psl)
{
assert(psl);
int i = 0;
//++i相比i++的效率较高,但随着CPU的发展,现在来看,二者的效率已经差不多了
for (i = 0; i < psl->size; ++i)
{
//SLDataType发生改变时,printf内的占位符也要发生相应的改变
printf("%d ", psl->data[i]);
}
printf("\n");
}
通过循环的方式依此打印顺序表中的数据。当SLDataType发生改变时,不要忘记将printf中的占位符修改一下。
8、查找数据
查找数据有两种简单形式:
1、可以找重复数据,但没有返回值
void SeqListFind(const SL* psl, const SLDataType val)
{
assert(psl);
//对顺序表进行遍历,如果找到数据则输出该数据的下标
int i = 0;
for (i = 0; i < psl->size; ++i)
{
if (psl->data[i] == val)
{
printf("%d ", i);
}
}
}
2、有返回值,但不能查找重复元素
int SeqListFind(const SL* psl, const SLDataType val)
{
assert(psl);
//对顺序表进行遍历,如果找到数据则返回该数据的下标
int i = 0;
for (i = 0; i < psl->size; ++i)
{
if (psl->data[i] == val)
{
return i;
}
}
//如果遍历没找到数据则说明顺序表内没有该数据,返回EOF代表没找到数据
//EOF==-1
return EOF;
}
两种方式都有各自的用途,但因为第二种可以和另外的接口进行配合,我就第二种方式进行说明。当查找到相应数据时就返回其对应的下标,反之则返回EOF(-1)。
当然你也可以想一想能不能将两种方式组合在一起,让该接口的健壮度更高。
9、在指定位置(下标)插入数据
void SeqListInsert(SL* psl, const int pos, const SLDataType val)
{
assert(psl);
//顺序表要求数据是连续的,保证下标在顺序表所规定的范围内
assert(pos >= 0 && pos <= psl->size);
SeqListCapcityCheck(psl);
//将pos后面的数据整体后移
int end = psl->size - 1;
while (end >= pos)
{
psl->data[end + 1] = psl->data[end];
--end;
}
psl->data[pos] = val;
psl->size++;
}
在进行数据插入前,我们先要判断位置是否合法,当pos==0时为头插,当pos==size时为尾插,二者之间的位置也都是有效位置。
然后和头插一样,将下标为pos和之后的数据后移,为插入数据留出空间。
我们可以将该接口与SeqListFind()接口结合起来,达到在某个数据前插入新数据的效果。
10、在指定位置(下标)删除数据
void SeqListErase(SL* psl, const int pos)
{
assert(psl);
assert(pos >= 0 && pos <= psl->size - 1);
int begin = pos + 1;
while (begin <= psl->size - 1)
{
psl->data[begin - 1] = psl->data[begin];
++begin;
}
psl->size--;
}
数据删除之前也要对位置的合法性进行判断,与指定位置的插入不同的是,pos不能等于size,因为data[size]上并没有数据。
同样的,该接口也可以和SeqListFind()接口结合起来,达到删除某个数据的效果。
11、销毁顺序表
void SeqListDestroy(SL* psl)
{
assert(psl);
free(psl->data);
psl->data = NULL;
psl->size = psl->capacity = 0;
}
对动态内存进行释放,不仅可以归还空间,还可以对我们是否越界使用内存进行检查,如果越界使用,编译器会报警。同时将data赋值为NULL,避免再次使用。
扩展
我们在完成指定位置的插入和删除后,可以发现,头插、尾插,头删,尾删不够是它们的特殊情况罢了,那么我们就可以对这四个接口进行一点改变。
尾插:
void SeqListPushBack(SL* psl, const SLDataType val)
{
SeqListInsert(psl, psl->size, val);
}
头插:
void SeqListPushFront(SL* psl, const SLDataType val)
{
SeqListInsert(psl, 0, val);
}
头删:
void SeqListPopBack(SL* psl)
{
SeqListErase(psl, psl->size-1);
}
尾删:
void SeqListPopFront(SL* psl)
{
SeqListErase(psl, 0);
}
通过接口的复用,可以让程序更加简洁、更具有整体性。
注:我没有写菜单,但是你们可以自行完成菜单,做出一个较为完成的程序。并且菜单应在所有接口完成后再完成,这样方便对各个接口进行测试。但数据结构作为存贮数据的方式,在大部分实际应用上是不需要菜单的。
小细节
1、在程序中我多次使用assert(),assert()函数内部的表达式为真就跳过,为假就会终止程序并报错。我们可以通过他来检查空指针和一些数据的合法性,让程序更加严密。
2、对于一些不用改变的量,我会用const修饰,这样一不小心对不用改变的量进行改变时,编译器会提醒我们,对一些重要的数据更应如此,这能让我们的程序的安全性更高。
3、每完成一个接口我们都应该测试一下,这样就能知道到底是哪个接口出现了问题,而不是全部写完了一起调试,这样出了BUG就需要排查很久。
4、 我们的测试代码不要删除,不需要了就注释掉,这样我们就能知道我们当时写代码的时候的思维。
完整代码
main.c
#define _CRT_SECURE_NO_WARNINGS 1
#pragma warning(disable:6031)
#include "SeqLi.h"
//想删掉不要的语句可以使用注释,这样语句会保留但不会执行,便于后续的测试与回顾
//对各个接口进行测试
//用static修饰可以避免该函数被其他文件调用
static void TestSeqList1(void)
{
SL sl;
SeqListInit(&sl);
//SeqListCapcityCheck(&sl);
//sl.size = 5;
//SeqListCapcityCheck(&sl);
//sl.size = 10;
//SeqListCapcityCheck(&sl);
SeqListPushBack(&sl, 1);
SeqListPushBack(&sl, 2);
SeqListPushBack(&sl, 3);
SeqListPushBack(&sl, 4);
SeqListPushBack(&sl, 5);
SeqListPushBack(&sl, 6);
SeqListPrint(&sl);
SeqListPopFront(&sl);
SeqListPopFront(&sl);
SeqListPopFront(&sl);
SeqListPopFront(&sl);
//printf("\n");
SeqListPrint(&sl);
SeqListPushFront(&sl, 10);
SeqListPushFront(&sl, 20);
SeqListPushFront(&sl, 30);
SeqListPushFront(&sl, 40);
SeqListPushFront(&sl, 50);
SeqListPushFront(&sl, 60);
//printf("\n");
SeqListPrint(&sl);
SeqListPopBack(&sl);
SeqListPopBack(&sl);
SeqListPopBack(&sl);
SeqListPopBack(&sl);
SeqListPrint(&sl);
SeqListDestroy(&sl);
}
static void TestSeqList2(void)
{
SL sl;
SeqListInit(&sl);
SeqListPushBack(&sl, 1);
SeqListPushBack(&sl, 2);
SeqListPushBack(&sl, 3);
SeqListPushBack(&sl, 4);
SeqListPrint(&sl);
//int pos = SeqListFind(&sl, 2);
//printf("%d\n", pos);
//pos = SeqListFind(&sl, 3);
//printf("%d\n", pos);
SeqListPushFront(&sl, 30);
SeqListPushFront(&sl, 40);
SeqListPushFront(&sl, 50);
SeqListPushFront(&sl, 60);
SeqListPushFront(&sl, 70);
SeqListPrint(&sl);
int pos = SeqListFind(&sl, 40);
SeqListInsert(&sl, pos, 100);
SeqListPrint(&sl);
SeqListDestroy(&sl);
}
static void TestSeqList3(void)
{
SL sl;
SeqListInit(&sl);
SeqListPushBack(&sl, 1);
SeqListPushBack(&sl, 2);
SeqListPushBack(&sl, 3);
SeqListPushBack(&sl, 4);
SeqListPrint(&sl);
SeqListErase(&sl, 2);
SeqListPrint(&sl);
SeqListPushFront(&sl,40);
SeqListPushFront(&sl,50);
SeqListPushFront(&sl,60);
SeqListPushFront(&sl,70);
SeqListPushFront(&sl,80);
SeqListPrint(&sl);
int pos = SeqListFind(&sl, 60);
SeqListErase(&sl, pos);
SeqListPrint(&sl);
SeqListDestroy(&sl);
}
int main()
{
//TestSeqList1();
//TestSeqList2();
TestSeqList3();
return 0;
}
SeqLi.c
#define _CRT_SECURE_NO_WARNINGS 1
#pragma warning(disable:6031)
#include "SeqLi.h"
void SeqListInit(SL* psl)
{
//保证psl不为空指针
assert(psl);
psl->data = NULL;
psl->size = 0;
psl->capacity = 0;
//这个表达式与上面两个表达式的作用相同
//psl->size = psl->capacity = 0;
}
void SeqListCapcityCheck(SL* psl)
{
assert(psl);
if (psl->size == psl->capacity)
{
//设定新的容量
//如果capacity==0,就将newcapacity设定为5
//之后的每一次扩容都为先前一次容量的两倍,如第二次扩容后容量为10,第三次后为20...
int newcapacity = (psl->capacity == 0 ? 5 : psl->capacity * 2);
//当realloc的第一个参数为空指针时,作用效果与malloc相同
SLDataType* tmp = (SLDataType*)realloc(psl->data, newcapacity * sizeof(SLDataType));
if (tmp == NULL)
{
perror("realloc");
exit(-1);
}
psl->data = tmp;
psl->capacity = newcapacity;
tmp = NULL;
}
}
void SeqListPushBack(SL* psl, const SLDataType val)
{
assert(psl);
SeqListCapcityCheck(psl);
psl->data[psl->size] = val;
psl->size++;
}
//void SeqListPushBack(SL* psl, const SLDataType val)
//{
// SeqListInsert(psl, psl->size, val);
//}
void SeqListPopBack(SL* psl)
{
assert(psl);
//没有必要将数据清零做到真正的删除
//size代表顺序表内有效数据的个数,只要让size--就代表顺序表的尾部数据不再有效
//从而在对顺序表进行访问时也不会访问最后一个元素,间接完成了从尾部删除数据
//进行删除的前提是有数据可删除
//assert括号内表达式为假就会报警
//如果没有数据可删就报告错误并退出程序
//assert(psl->size > 0);
//psl->size--;
//相比于assert更加温柔的做法,如果没有数据可删就不执行删除操作
if (psl->size > 0)
{
psl->size--;
}
}
//void SeqListPopBack(SL* psl)
//{
// SeqListErase(psl, psl->size-1);
//}
void SeqListPrint(const SL* psl)
{
assert(psl);
int i = 0;
//++i相比i++的效率较高,但随着CPU的发展,现在来看,二者的效率已经差不多了
for (i = 0; i < psl->size; ++i)
{
//SLDataType发生改变时,printf内的占位符也要发生相应的改变
printf("%d ", psl->data[i]);
}
printf("\n");
}
void SeqListDestroy(SL* psl)
{
assert(psl);
free(psl->data);
psl->data = NULL;
psl->size = psl->capacity = 0;
}
void SeqListPushFront(SL* psl, const SLDataType val)
{
assert(psl);
SeqListCapcityCheck(psl);
//end为从头添加数据前最后一个元素的下标
int end = psl->size - 1;
//从最后一个元素开始,将所有元素一个个的向后移动,空出第一个位置供数据插入
while (end >= 0)
{
psl->data[end + 1] = psl->data[end];
--end;
}
psl->data[0] = val;
psl->size++;
}
//void SeqListPushFront(SL* psl, const SLDataType val)
//{
// SeqListInsert(psl, 0, val);
//}
void SeqListPopFront(SL* psl)
{
assert(psl);
assert(psl->size > 0);
//从第二个元素开始将数据开始向前移动,达到删除第一个元素即从头部删除数据的目的
int begin = 1;
//最后一个元素的下标为size-1
while (begin <= psl->size-1)
{
psl->data[begin - 1] = psl->data[begin];
++begin;
}
psl->size--;
}
//void SeqListPopFront(SL* psl)
//{
// SeqListErase(psl, 0);
//}
//void SeqListFind(const SL* psl, const SLDataType val)
//{
// assert(psl);
//
// //对顺序表进行遍历,如果找到数据则输出该数据的下标
// int i = 0;
// for (i = 0; i < psl->size; ++i)
// {
// if (psl->data[i] == val)
// {
// printf("%d ", i);
// }
// }
//}
int SeqListFind(const SL* psl, const SLDataType val)
{
assert(psl);
//对顺序表进行遍历,如果找到数据则返回该数据的下标
int i = 0;
for (i = 0; i < psl->size; ++i)
{
if (psl->data[i] == val)
{
return i;
}
}
//如果遍历没找到数据则说明顺序表内没有该数据,返回EOF代表没找到数据
//EOF==-1
return EOF;
}
void SeqListInsert(SL* psl, const int pos, const SLDataType val)
{
assert(psl);
//顺序表要求数据是连续的,保证下标在顺序表所规定的范围内
assert(pos >= 0 && pos <= psl->size);
SeqListCapcityCheck(psl);
//将pos后面的数据整体后移
int end = psl->size - 1;
while (end >= pos)
{
psl->data[end + 1] = psl->data[end];
--end;
}
psl->data[pos] = val;
psl->size++;
}
void SeqListErase(SL* psl, const int pos)
{
assert(psl);
assert(pos >= 0 && pos <= psl->size - 1);
int begin = pos + 1;
while (begin <= psl->size - 1)
{
psl->data[begin - 1] = psl->data[begin];
++begin;
}
psl->size--;
}
SeqLi.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
//用SLDataType重命名顺序表内存贮的数据类型,方便后续更改
typedef int SLDataType;
//用宏来定义数组的长度,便于后续修改
//#define N 100
//
//struct SequenceList
//{
// SLDataType data[N];
// //记录顺序表内已存储的数据个数
// int size;
//};
typedef struct SequenceList
{
//创建顺序表存储类型的指针作为动态内存的起点,从而实现动态的顺序表
SLDataType* data;
//记录顺序表内已存储的数据个数
int size;
//记录顺序表的容量
int capacity;
}SL;
//对顺序表进行初始化
extern void SeqListInit(SL* psl);
//对顺序表的容量进行检查,不够则扩容
extern void SeqListCapcityCheck(SL* psl);
//从顺序表的尾部插入数据
extern void SeqListPushBack(SL* psl, const SLDataType val);
//从顺序表的尾部删除数据
extern void SeqListPopBack(SL* psl);
//从顺序表的头部开始插入数据
extern void SeqListPushFront(SL* psl, const SLDataType val);
//从顺序表的头部开始删除数据
extern void SeqListPopFront(SL* psl);
//对顺序表进行销毁,回收内存
extern void SeqListDestroy(SL* psl);
//打印顺序表内数据,便于对接口进行测试和将数据呈现在用户面前
extern void SeqListPrint(const SL* psl);
//在线性表内寻找对应的数据,并返回其下标
extern int SeqListFind(const SL* psl, const SLDataType val);
//在顺序表指定位置(下标)插入数据
extern void SeqListInsert(SL* psl, const int pos, const SLDataType val);
//在顺序表的指定位置(下标)删除数据
extern void SeqListErase(SL* psl, const int pos);
小结
顺序表作为数据结构的开始,并不算难,但是需要我们好好学习,了解其思想,熟练指针、结构体和动态内存管理的运用,为之后打好基础。
以上均为个人的思考与看法,希望能帮助大家,如果有所错误还希望各位指正!