数据结构(顺序表)(1)
本文思路:
- 本文呢,讲解顺序表。那本文大概是什么思路呢?我会根据我上课所学的知识,按照我的记忆和理解来给大家梳理好顺序表,一步一步来,一步步遇到问题解决问题。
- 同时本文会顺带讲讲代码习惯,代码风格。本文函数命名长,且这么命名的原因主要是:
- 名字不在乎长,长一点没事,主要是要让别人一下子能看懂你的代码
- 另外后面我们会学到C++的STL,里面的函数名就和我们这个写法差不多.
1.顺序表的类别:
顺序表其实就是我们的数组,它的样式就是数组这个模样的。所以顺序表主要就是分为两类顺序表,一种是静态顺序表,一种是动态顺序表。
静态顺序表:静态,顾名思义,不动的。也就是说静态顺序表的大小是定了的,一开始你给它初始化的时候给了多少空间,它就只能用多少空间。就像一开始就定义了一个10个整型的数组一样,你只能用这10个整型的空间。
所以静态顺序表就十分笨拙。你初始化的时候空间给多了,造成空间浪费。给少了,又不够用。
而且静态顺序表在于处理数据的增加,删除,查找,和修改的时候,维护起来也十分的笨拙,不方便。
所以本文,主要是以动态顺序表为主要的讲解内容。
2.正式开始顺序表的代码讲解
- 这部分我会一步一步按照“写一个顺序表”的思路,一步步给大家铺开来讲解。最终的顺序表代码会在下一节完整展现。
- 首先我们定义一个顺序表:(先是静态的)
#pragma once #include<stdio.h>//这些都是我们等会要用的头文件 #include<assert.h>//断言 #include<stdlib.h>//动态开辟空间 //定义一个静态顺序表 //咱们可以通过这个宏来设定一个N,这样更换数组大小的时候,改N就行. #define N 100 //typedef的目的和宏也是一样,如果想要替换顺序表里面的数据类型,改这里就可以. typedef int SLDataType; struct Seqlist { //那这里就可以看到,这个顺序表是静态的,大小是固定不变的. SLDataType a[N]; //这里是表示顺序表里面有效数据的个数. int size; }; //上文也说了静态顺序表不合适,这里仅做一种演示.
- 动态顺序表:
//动态顺序表: //原因同上 typedef int SLDataType; //这里也是,由于顺序表名称长,可以typedef更换一下名字 typedef struct Seqlist { SLDataType* a;//指向动态开辟空间的首元素地址 int size;//顺序表有效的元素个数 int capacity;//顺序表的容量,空间大小. }SL;
- 定义完顺序表的结构之后,就要写相应的函数来实现相应的功能。那要实现什么功能呢?那就得看看顺序表得用到什么应用上面。最简单的就是通讯录,也是咱们初阶数据结构前面最常遇到的。
- 那实现通讯录,是不是得要实现增加联系人,删除联系人,查找联系人,和修改联系人信息。那么咱们一开始实现顺序表的时候就按照这个模版来实现,不过一开始我们先用整型元素来替代联系人。(当然,别的类型也是可以的,比如double,char,等等)
- 这样就变成了,增加整型元素,删除整型元素,查找整型元素和修改整型元素。
- 那么增加,删除,查找,修改,又可以细致的分为头部增加数据,尾部增加数据,头部删除数据,尾部删除数据…
- 但是在执行这些操作之前咱们是不是得初始化一下我们的顺序表,这样才能更好的在顺序表上进行操作。同时还有当程序结束之后,我们得将我们创建的顺序表处理一下,free掉我们开辟的空间,把指针置空。
- OK,那开始第一阶段的代码 (咱们先把我们要实现的部分函数功能先在头文件声明好) :(所以这是头文件代码)
- 当然,这也是部分的头文件代码,后续会根据本文节奏一点点讲解:
typedef int SLDataType; //这里也是,由于顺序表名称长,可以typedef更换一下名字 typedef struct Seqlist { SLDataType* a;//指向动态开辟空间的首元素地址 int size;//顺序表有效的元素个数 int capacity;//顺序表的容量,空间大小. }SL; //初始化顺序表 void SLInit(SL* ps); //程序结束销毁顺序表 void SLDestroy(SL* ps); //打印顺序表内容(这个函数主要是为了方便测试) void SLPrint(SL* ps); //尾部插入数据 void SLPushBack(SL* ps, SLDataType x); //尾部删除数据 void SLpopback(SL* ps); //头部插入数据 void SLpushFront(SL* ps, SLDataType x); //头部删除数据 void SLpopFront(SL* ps);
- 来到这里之后,就要给大家讲解一下这里的这些函数
void SLInit(SL* ps);
里面的形参为什么是结构体指针,而不是直接传结构体过去呢。有时候有同学会犯这样的错误。- 这是因为我们这些函数的功能是要对结构体里面的内容要进行修改的,而形参只是实参的拷贝,如果你传了结构体过去,函数功能改变的只是拷贝出来的形参,而实参却没有改变。所以这里就要穿指针过去,这样才能真正的修改结构体里的内容。
- 然后
void SLPrint(SL* ps);
这个函数主要是为了打印顺序表的,为后面我们测试代码的时候能更直观的观察结果。- 那又有同学问了,这里是print函数没有说要改变结构体里面的内容昂,为什么还要传指针呢?
- 这里就涉及到了代码习惯的问题,这样写最主要就是和其他函数参数达成一致性,不用说所有的函数都传的指针,就一个函数传结构体,主要就是为了一致性的问题。这样也更好一些。
- OK,竟然头文件里,函数我们已经定义好了,那就要去到“.c”文件里面去将我们的代码功能一步步的补充完整。
- 这部分的节奏就是写完一个函数就测试一个函数。这个也是大家从c语言学习转到数据结构学习的大家需要做的一个改变
- 主要就是原本c语言的代码量不多,全部写完也就是70多行多的,就算最后运行出了问题也可以很快的找到问题所在。
- 但是数据结构不同,因为数据结构初阶我们是使用c语言来写,代码量很多,动辄几百行,所以当你写完全部功能之后再去调试,很可能就会很痛苦了。
- 所以这阶段我们完成一个函数功能就调试测试一次,尽可能的把问题解决了,这样也好发现问题。
- 第一个函数:
//初始化顺序表 void SLInit(SL* ps) { //咱们可以先动态开辟一段空间,这段空间 //想开多少就开多少,咱们这边就先给4个整型空间 //因为这时候SLDataType是int的别名 ps->a = (SLDataType*)malloc(sizeof(SLDataType) * 4); //为了代码的健壮性,咱们可以判断一下指针 //空的话,直接整个程序结束 if (ps->a == NULL) { perror("malloc failed"); exit(-1); } //这里就是初始有效数据是0 ps->size = 0; //这里就按照我们刚刚开辟的4个空间给 ps->capacity = 4; }
- 这里可能同学对函数里面的那个
exit(-1)
和return -1
的不同不了解。这里我来说一下。
- 其实也很简单,return就是单纯的这个函数结束,然后返回-1,然后程序继续往下走。函数里的
return
不影响其他程序继续运行.- 而
exit(-1)
则不同,如果函数运行到这句话,整个程序都会终止。所以就是这些方面的差别。
- OK,那咱们看一下这个代码能不能行:
- OK的,那就来到第二个函数:
//程序结束销毁顺序表 void SLDestroy(SL* ps) { //先释放空间 free(ps->a); //指针置空 ps->a = NULL; //有效数据个数和容量置为0 ps->capacity = ps->size = 0; }
- 这个销毁函数,就很简单了,这个没什么好说的。
- 第三个函数:
//打印顺序表内容(这个函数主要是为了方便测试) void SLPrint(SL* ps) { for (int i = 0; i < ps->size; i++) { printf("%d ", ps->a[i]); } printf("\n"); }
- 这个函数也很简单,没什么可以说的。
- 第四个函数(尾部插入数据),这边咱们先分析一波思路先:
- 通过这波分析,咱们是不是遇到了两种情况:
- 一种的空间足够下的尾部插入
- 一种是空间有不足下的尾部插入
- 那这时候,我们是不是得联想到头部插入和头部删除也可能会出现空间不足的问题。那这么是不是就可以写一个函数来判断数组空间是否足够?
- 而且数组空间满了是不是还得扩容,这样才可以继续插入数据.
- 所以我们这边先写上这么个函数先:
//判断顺序表空间是否足够 void SLCheckCapacity(SL* ps) { //满了就要扩容 if (ps->size == ps->capacity) { //为了代码健壮性,我们先用临时变量tmp接受 SLDataType* tmp = (SLDataType*)realloc(ps->a, ps->capacity * 2 * sizeof(SLDataType)); if (tmp == NULL) { perror("relloc failed"); exit(-1); } //判断是否为空之后,再赋值给ps->a ps->a = tmp; //一般情况来说,我们扩容都是两倍两倍的扩容 ps->capacity *= 2; } }
- 这里relloc函数的作用是扩大到多少,而不是增加多少,具体的函数作用和声明,可以去cplusplus官网查看。
- 然后还有涉及到的就是这个扩容,有两种方式扩容。一种是原地扩容,一种是异地扩容。
- 原地扩容就是,先查看这原来的空间周围,有没有足够的空间去扩容,因为顺序表,数组它是一个物理空间上连续的一个数据结构。如果周围的空间够,那就直接在原地扩容了,扩到要求的大小,返回的地址还是原来ps->a的地址。
- 异地扩容就是,周围的空间不够了,就要重新再去找一段连续的满足扩容之后的大小的空间,然后再返回新的地址。这就是异地扩容,这个其实很麻烦的。
- 举个例子,一家公司出去团建,一开始本来只有5个人,出去租酒店,5个人的房间是连续相邻的。后来又来了5个人。然后公司要求酒店说,咱们的人的房间得是连续相邻的,一个接着一个的。这时候酒店就要先看看这原本5个人的房间周围有没有多余的5个房间,如果有就住上了。如果没有,酒店就要重新的在他们的房间里面找连续相邻的10个房间重新分配。这样原本的5个人就要重新整理行李到新房间。
- 意思就是这个意思,所以还是很麻烦的一件事,所以后面就会提到“链表”这种数据结构来解决这些个问题。
- OK,那接下来就是尾部插入函数了:
//尾部插入数据 void SLPushBack(SL* ps, SLDataType x) { //先判断空间是否足够 SLCheckCapacity(ps); //插入数据 ps->a[ps->size] = x; //插入数据之后记得,有效数据个数size要加一. ps->size++; }
- 这里大家可能对
ps->a[ps->size] = x;
比较陌生,为什么下标是ps->size,其实也很简单,我画个图给大家看看,就能理解了,不过这个一开始大家可能没那么快适应,看多了就习惯了。
- OK,那我们是不是得测试一波代码了:
可以看到,尾部插入数据,这个功能咱们已经搞定了。
那么接下来就写一下尾部删除数据这个函数:
同样,我们先分析一波先:(为什么先分析呢,因为如果按部就班的用错误例子讲解的话,太费劲了,所以就先分析一波,把为什么这么写讲清楚)
//尾部删除数据 void SLpopback(SL* ps) { //温柔版检查 //if (ps->size == 0) // return; //暴力检查,这就是如果出错了就直接报错,退出程序, assert(ps->size > 0); ps->size--; }
- 温柔版检查它就不会直接报错,而是函数结束,接着运行别的代码,就显得温柔些。暴力版检查就暴力点,出错了就直接报错,告诉你出错了,直接终止程序。
- 测试一波:
- OK,那么就来到头部插入数据了。说实话这个方法效率很低,因为要向后挪动数据,时间复杂度是O(N)。
- 同样先分析一波:
//头部插入数据 void SLpushFront(SL* ps, SLDataType x) { //先判断空间是否足够 SLCheckCapacity(&ps); //挪动数据 int end = ps->size - 1; for(int i = end; i >= 0;i--) { ps->a[i + 1] = ps->a[i]; } //赋值 ps->a[0] = x; //记得有效数据要加一 ps->size++; }
- 测试一波:
- OK,那就到了头部删除数据了,同样先分析一波:
//头部删除数据 void SLpopFront(SL* ps) { //断言一下,防止为空 assert(ps); //判断有效数据个数 assert(ps->size > 0); //挪数据 int begin = 1; while (begin < ps->size) { ps->a[begin - 1] = ps->a[begin]; begin++; } ps->size--; }
- 测试一波: