当前位置:   article > 正文

数据结构_顺序表专题

数据结构_顺序表专题

何为数据结构

咱今天也来说道说道......

数据结构介绍

  • 准确概念

数据结构就是计算机存储、组织数据的方式

  • 概念分析

从上句分析,数据结构是一种方式。一种管理数据的方式。为了做什么?为的就是计算机存储数据,组织数据。

存储数据好理解,就是把数据导进计算机。

组织数据:组织二字:就好比将军带兵。计算机无疑就是将军,数据就是征入军营的士兵们,数据现在还没真正成为士兵,得靠将军对它们进行管理、操练。比如增加能人志士,淘汰不合格的士兵,平时点卯(对士兵进行点名),对士兵在刀剑、骑射方面进行能力的增强。

概念解释清楚了,再看一眼概念:相信同学们已经把概念刻进了DNA。

数据结构就是计算机存储、组织数据的方式

将军之所以能为将帅之才,那自然是依靠它们善带兵之道,识人善用——即针对不同基础的士兵,独有一套招纳、组织方式。

今日,我们就看“队伍”之一——名为:顺序表这支军队里面平时如何招纳、组织数据。

先来看看这支队伍里面的士兵是为何种数据?

数据篇—顺序表里的数据

开门见山,核心一句话:顺序表的底层就是数组

什么意思且听我一言:既然是军队,那必然有队列阵型,每一个人站位何处都是定好了的。总不能这立一个,那躺一个,无组织无纪律,这明显和数据结构所达目的背道而驰。

数组为何能成为顺序表的底层数组,是⼀组相同类型元素的集合。听听,集合二字,可见它的组织性;相同类型元素,则暗示它浑然天成能打造一支基本素质不低的军队。说白了,顺序表要的就是数组这种凝聚力。

  • 阵型排列

具体阵型,简单看看:

地址我随便意思了一下,重点是什么? 这个顺序表和数组一个样,数组里面的元素数据类型是int型,每个数据拥有4个字节的空间,在数组里元素和元素之间地址连续

好了,如果本段的解释,让你明白:顺序表的底层就是数组,这一小节儿的重点就把握了。

  • 数据类型

照例,先给出核心:顺序表里要求的数据的类型较为广泛,上到内置类型(比如:int,float,double),下到自定义类型(结构体),再到大杂烩(基于内置类型与自定义类型定义的各种数据,比如,int a[ ],char b[ ]。

可以说,数据类型在顺序表里不作重点,简单了解到类型涵盖广泛就足够。

结构篇—顺序表里存储、组织数据

先来看,顺序表是如何招纳(存储)数据的,俗话说“巧妇难为无米之炊”,先得有数据才能管理、组织数据。

存储数据

顺序表在招纳数据的时候,为了满足顺序表本身是数组的特点,数组结构要求有二:类型与大小。

类型我们在前面已讨论—每个数据类型统一且数据类型范围较为广泛。

那么大小呢?在建立“这支队伍”之初,将军似乎不能预估士兵的多少,符合要求的数据到底有多少我们也未知,后续还会需要新的数据,空间不够怎么办?

谁来决定数组的大小?似乎成了问题,“韩信点兵,多多益善”,随着将军带兵之道不断精进,能带善于带更多的士兵(需要的数据不断引进),我们要以数组的形式的排列数据,但是又得灵活地开辟空间,大小是无法一口气拍定的。所以,顺序表里使用动态内存开辟空间(动态数组)对数据进行存储

这里就开始写代码了,我们分成三个文件:SeqList.h(顺序表头文件),SeqList.c(顺序表函数实现文件)以及test.c(测试文件)

  1. //头文件SeqList.h 包含顺序表类型的定义(即到底是一支由什么数据组成的,有多少数据,现在空间大小几何的数组) 以及相关组织数据方法声明
  2. typedef int SLDatatype;//此处int只为举例
  3. struct SeqList
  4. {
  5. SLDataType* arr;
  6. int size;//表示顺序表里有效数据个数
  7. int capacity;//表示当前顺序表里已开辟空间大小,表示现有capacity个数据的空间,为了后续开辟空间有依据可言
  8. }
  9. //既然是动态数组,我们无法在开辟数组空间无法得知需要的空间大小,但是又得用数组,我们使用指针(数组首元素地址即数组名),表示数组。同时,对于不同数组招募的数据不同,我们直接对int进行重命名,最后如果需要的数据类型不同,可以把typedef后的int改成所需的数据。

 定义好顺序表的雏形后,可以预见,在今后代码创建顺序表时,我们的代码无疑就是创建一个结构体。

提醒一下“创建顺序表即创建一个结构体”,顺序表本质还是数组,实现出来的样子就是数组。只是在组织数据时需要size和capacity这个变量。

怎么理解呢? 好理解。 这军队里打仗上战场的是数组(军队);这把握局势,统揽全局,出谋划策、有助带兵的是谁?当然是军师(size和capacity)。在刚刚那张图里虽然看不见size和capacity,但是对于组织军队,起到重要作用的便是这俩。

所以定义顺序表,包含数组和重要的两个变量

  1. //test.c文件
  2. int main()
  3. {
  4. struct SeqList sl;//名字很长啊,为了方便定义,我们在顺序表定义对结构体重命名。
  5. return 0;
  6. }

故最后的顺序表定义代码:

  1. typedef int SLDataType;
  2. //定义一个动态顺序表
  3. typedef struct SeqList
  4. {
  5. SLDataType* arr;
  6. int size;//数组里有效数据个数—元素个数
  7. int capacity;//数组当前大小
  8. }SL;

综上,为了方便存储不同类型的数据,灵活地调整存储数据的空间,我们分别使用重命名typedef成SLDataType 和 动态开辟内存先只给一个指针表示数组和两个变量方便后续操作) 

组织数据

现在有了存储数据的方式,在没有新来数据时,我们得做些准备工作(初始化)。

一、顺序表(动态数组)的初始化 

  1. //SeqlList.c
  2. void SLInit(SL sl)
  3. {
  4. sl.arr = NULL;
  5. sl.size = sl.capacity = 0;
  6. }
  7. //test.c来检验测试写的实现方法是否有bug
  8. #include "SeqList.h"
  9. int main()
  10. {
  11. SL sl;//这里使用SeqList.h定义的类型,不包含就找不到这个顺序表,后续一些函数也无法调用
  12. SLInit(sl);
  13. return 0;
  14. }

易错:传值调用和传址调用

经测试,如果这里传SL sl,会报错:使用了未初始化的局部变量sl

解:顺序表在SLInit方法里,形参拷贝了一份sl的值,但是传过去的sl本身就没有值,更不能实现拷贝值一说。这就是为何报错说使用了未初始化的局部变量。

再者,使用了这样一份拷贝的顺序表,在SLInit方法里就算初始化成功,最后被初始化的复印件也会被删除,再看原件,毫无变化——故也没能达到初始化我们原本创建顺序表的目的。

所以,使用传址调用,地址在计算机里,具有唯一性。从不同维度解决问题,精准找到对的顺序表,而不是长得像的,临时的顺序表。

故顺序表的初始化方法代码:

  1. void SLInit(SL* psl)
  2. {
  3. psl->arr = NULL;
  4. psl->capacity = psl->size = 0;
  5. }

二、顺序表的销毁

顺序表的销毁,对应我们的将士带兵论即为,在必要情况下,将士将每位士兵遣散回家,这遣散过程还包括扎营驻寨的空间的归还,军师也得衣锦还乡不是?。

拢共三个方面,那代码实现:

  1. void SLDestroy(SL* psl)
  2. {
  3. if(psl->arr != NULL)
  4. {
  5. //既然数组不为空,意味着存着这一支军队
  6. free(psl->arr);//可见数组的统一,不光组织起来方便,解散也方便
  7. }
  8. //数组为空||释放了数组后,说明数组已经不存在
  9. psl->arr = NULL;//这个数组已经不存在,不能再存地址了
  10. psl->size = psl->capacity = 0;//有效数据个数也为0,空间大小也为0
  11. }

释放顺序表,考察的是对于各个部分安排是否妥当,切记考虑全面。

三、顺序表的增删查改

这才是真正体现组织数据技术含量的部分,不同的数据结构对于实现:增删查改,有着其独到之处。顺序表的增删查改。

两种基本增加数据方法,头插和尾插。头插即在数组下标为0位置处插入我们安排的数据,原来的数据都往后面挪一位(这位新来的数据,可见实力不小,能排在首位,后面的都得挪窝) 

尾插即在原来数组的最后一位有效数字后添加我们安排的数据,原来的数据不用挪位置。(这位新来的数据被安排到最后一位)

这数据招进来,类型统一是统一,可别忘了顺序表

还有size和capacity两个变量。新来一个数据,size++就好,capacity呢?万一空间不够了就安排不进了,这就是在增之前,我们需要考虑的——是否需要此时动态开辟内存

  • 是否需要动态开辟内存 

代码:

  1. void SLCheckCapacity(SL* psl)
  2. {
  3. if(psl->size == psl->capacity)//“扩容”就在此刻
  4. {
  5. //说明有效数字已经满了数组已经开辟的空间,也满足刚开始size和capacity同为0的情况
  6. //扩容操作如下:
  7. SLDataType* tmp = realloc(psl->arr,2 * psl->capacity * sizeof(SLDataType));//这句为什么用reallo? 因为我们是扩容,realloc函数就是实现在原有空间上继续开辟空间,有增容的概念。第一个参数是你所指定的空间,第二个参数是你所申请开辟的空间大小,因为以存储空间(内存)以字节为单位,故第二个参数要乘上一个数据的字节大小,2*capacity意思是2倍:我们常常2倍增容,这样到后期增容速率会变慢,且2倍增容浪费空间数不会太多。
  8. //左边为什么用tmp? tmp(暂时的),realloc函数的返回类型是一个数组首地址(指针),因为申请空间可能不成功,一旦不成功,返回NULL,那原有的数据就会丢失,保险起见,先用一个tmp接收。
  9. if(tmp == NULL)
  10. {
  11. // 意味着没申请成功
  12. perror("realloc:failed");//报错,提醒自己
  13. }
  14. }
  15. }

 realloc函数有些细节以及2倍增容真正数学原理,我没细说,不过这里的解释倒是够用。有兴趣的同学可以去详细了解。

  1. void SLCheckCapacity(SL* psl)
  2. {
  3. if(psl->size == psl->capacity)
  4. {
  5. SLDataType* tmp = realloc(psl->arr,2 * psl->capacity * sizeof(SLDataType)); //注意这里有个坑,先往后看,马上就说
  6. if(tmp == NULL)
  7. {
  8. // 意味着没申请成功
  9. perror("realloc:failed");//报错,提醒自己
  10. }
  11. else
  12. {
  13. // 申请成功了
  14. psl->arr = tmp;
  15. psl->capacity = 2 * psl->capacity;
  16. }
  17. }
  18. }

 易错:psl->capacity==0时,开辟了0个空间

所以以后写一长串参数运算时,注意一下参数为0的情况。

真正的代码实现:

  1. void SLCheckCapacity(SL* psl)
  2. {
  3. if(psl->size == psl->capacity)
  4. {
  5. int NewCapacity = psl->capacity == 0 ? 4 : 2 * psl->capacity//创建一个新变量,判断原空间大小:如果为0,默认给它4个空间成为新空间,如果不为0,则在原有基础上2倍成为新空间数
  6. SLDataType* tmp = realloc(psl->arr,NewCapacity * sizeof(SLDataType));
  7. if(tmp == NULL)
  8. {
  9. // 意味着没申请成功
  10. perror("realloc:failed");//报错,提醒自己
  11. }
  12. else
  13. {
  14. // 申请成功了
  15. psl->arr = tmp;
  16. psl->capacity = NewCapacity;
  17. }
  18. }
  19. }
  • 头插

判断完空间需要扩容后,现在的空间大小已经满足要求。我们开始实现插入数据,最后再size++。

先来看头插:根据刚刚的描述,安排首位为新数据,后面的都要向后移一位。真正逻辑是,先有得首位空了,才能安排新数据;但是要让首位空,所有数据都得先后移一位。故先循环,让后面的数据先移位,循环结束后,下标为0的元素就赋上新数据,再size++;

  1. void SLPushFront(SL* psl,SLDataType x)
  2. {
  3. assert(psl);//断言:文章最后一部分有解释
  4. SLCheckCapacity(psl);
  5. for(int i = psl->size;i ;i--)//移位得从后往前,好好想想:如果从前往后移,首先0下标数字一移就把下标为1的数字就覆盖了,这一改就再也找不到了。及时止损,换个思路:从后往前挪,后面的先后挪总会留下一个空位,画个图就明白了
  6. {
  7. psl->arr[i] = psl->arr[i - 1];//循环的最后,就是arr[1] = arr[0];由此得出i的取值范围:i > 0
  8. }
  9. psl->arr[0] = x;
  10. psl->size++;
  11. }
  •  尾插

代码思路:先照例检查是否有足够空间,再尾插,这个尾插,插在有效数字的最后,不用移位那也用不上for循环。核心就一句arr[ ] = x

  1. void SLPushBack(SL* psl,SLDataType x)
  2. {
  3. assert(psl);
  4. SLCheckCapacity(psl);
  5. psl->arr[] = x;
  6. psl->size++;
  7. }

 那【】中到底是什么?

 size本来记录的就是数组里面有效的数字,还是随着数组元素增加依次增加,数组下标从0开始,到size下标时,刚好对应没有数组元素。

补全合并代码,最终的尾插方法:

  1. void SLPushBack(SL* psl,SLDataType x)
  2. {
  3. assert(psl);
  4. SLCheckCapacity(psl);
  5. psl->arr[psl->size++] = x;//size后++,对于安排数字不影响,优雅
  6. }

和“增”一样,删这种组织数据的方法,也分头删和尾删  

  • 头删 

模拟一下头删场景,借此来窥探代码思路。

头删,头就是下标为0的元素。

 

“好图不嫌多用”,我们在删除此图的26时,最后达成的效果是

对比很鲜明啊,最明显的也是在刚开始写代码容易忽略的就是size--。下标为1及后面的元素都往前了一位。无疑是有循环的,被我们忽略的还有一个26,我们需要单独对它做什么吗?似乎看来,所有数据覆盖后,就达成了这个效果,单独操作不合理也费劲

代码:

  1. void SLPopFront(SL* psl)
  2. {
  3. assert(psl);
  4. for(int i = 0;i < psl->size - 1;i++)
  5. {
  6. psl->arr[i] = psl->arr[i + 1];//最后一次就是arr[size - 2] = arr[size - 1];由此确定i < size - 1(看移动前那张图分析)
  7. }
  8. psl->size--;
  9. }
  •  尾删

 同样模拟一下尾删场景,借此来摸索代码思路。

尾删,就是删掉当前数组的最后一位有效数字。在这里就是3

删完后,就是这个效果:

无疑size--,元素并没有发生移位,不用for循环。

代码:

  1. void SLPopBack(SL* psl)
  2. {
  3. assert(psl);
  4. psl->size--;//没错,size--就可以,不用对3进行什么操作
  5. }
  6. //为什么?size--后,我们在增加数字时,(头插、尾插),3都会被覆盖,不影响;在删除数字时(头删,我们for循环也只会关心size下标内的数据,3不在有效范围内,不影响;尾删不影响,继续size--),也只关心size内的数据。查找,修改数据更是,不然我们刚开始为何要定义一个变量叫当前数组的有效个数,因为我们只关心下标size之前的数据。
 查

我们这里的查,是根据已给的数据在原数组里判断是否存在。

思路很简单,将数组元素遍历,每个元素与x比较,如果相等就找到了;反之,循环结束后若还没找到,说明当前数组不存在该数据

代码:

  1. int FindByX(SL* psl,SLDataType x)
  2. {
  3. assert(psl);
  4. for(int i = 0;i < psl->size;i++)
  5. {
  6. if(psl->arr[i] == x)
  7. {
  8. return i;//这里先不考虑x有多个的情况,先掌握思路
  9. }
  10. }
  11. return -1;//都直到循环结束,也没找到对应的下标,说明数组里面是没有x这个元素的,故返回一个无效下标
  12. }
  13. //test.c
  14. int main()
  15. {
  16. //...
  17. // 测试“查”方法
  18. int find = FindByX(psl,x)
  19. if(find < 0)
  20. {
  21. printf("没找到\n");
  22. }
  23. else{
  24. printf("找到了,下标为%d\n",find);
  25. }
  26. //...
  27. return 0;
  28. }

两种情况:一种指定下标更改:一步到位arr[指定下标] = x;

还有一种情况,改数组里面的某个数字,那第一步得找到那个数字的对应下标不是?再加上arr[find] = x;

这个没什么坑,直接上代码:

  1. void SLModify(SL* psl,SLDataType x,SLDataType Newx)//为了方便,在参数里面我直接规定了新数据,其实还可以用scanf通过键盘录入新数据
  2. {
  3. assert(psl);
  4. int find = FindByX(psl,x);
  5. if(find < 0)
  6. {
  7. }
  8. else
  9. {
  10. psl->arr[find] = Newx;
  11. }
  12. }
  13. //情况二
  14. void SLModify2(SL* psl,int pos,SLDataType Newx)
  15. {
  16. psl->arr[pos] = Newx
  17. }

四、特殊的增和删

在增加或者删除数据时,我们只介绍了头增和尾增、头删和尾删;不具有增加的任意性:万一所增的数据偏偏非头非尾,所删的数据也在中间某一个位置。

顺序表的指定位置之前插入数据

我们还是通过那张“好图”来模拟场景实现,借此摸索代码思路。

给出这样一个顺序表,现在我们要在数组下标为pos的地方即“1”之前增加一个数据“55”。最后这个顺序表长这样:

上下对比,先看数组外,变量size++了(增加一个数据,有效数据个数可不就得++),再来看数组内元素是否发生移位:发生了,发生的范围在pos及pos后面的所有数据,arr[pos] = 1数据都发生了移位;我们因此能确定使用for循环;并且i的起点应该是pos。

按照之前的理解,思路都是先移位,让arr[pos]空出来,最后arr[pos] = 55,size++,同理可得代码:

  1. void SLInsert(SL* psl,int pos,SLDataType x)
  2. {
  3. assert(psl);
  4. for(int i = size;i > pos;i--)
  5. {
  6. psl->arr[i] = psl->arr[i - 1];//可以看到这里仍然是从后向前移动数据,循环结束后达到在目的位置为空
  7. }
  8. pal->arr[pos] = x;
  9. psl->size++;
  10. }
  11. //发现没,与头插代码相比,变的就是循环的元素个数(与pos有关)

你会发现,这个方法里,一旦pos==0 就是我们写的头插,pos==size就是我们写的尾插;简而言之,我们对下标任意化了,这就是前文所说的任意性。

顺序表的指定位置删除数据

同样,数据结构要的就是一个代码“跃然纸上”的效果。

这次我们要删除arr[pos] = 1这个数据了。最后的效果:

size变没有?变了——size--;元素移位没有?移了,for循环。有了pos,可以确定循环变量的范围。

代码实现:

  1. void SLDel(SL* psl,int pos)
  2. {
  3. assert(psl);
  4. for(int i = pos;i < psl->size - 1;i++)
  5. {
  6. psl->arr[i] = psl->arr[i - 1];//最后一步是arr[size - 2] = arr[size - 1]
  7. }
  8. psl->size--;
  9. }

 你会发现,这个方法里,一旦pos==0 就是我们写的头删,pos==size就是我们写的尾删;简而言之,我们对下标任意化了,这也对应前文所说的任意性。

代码优化——断言处理:

参数只说了传指针,没说不能传空。

比如:

调试结果:

 究竟原因其实是:对空指针进行解引用,为何不能对空指针进行解引用:操作系统和硬件通常不会将物理内存地址0分配给任何实际的内存块,因此尝试访问这个地址会导致硬件异常,比如段错误或访问违规。操作系统捕获到这种异常后,通常会终止程序的执行。

空指针往往被分配的是NULL代表着0x00,也同时意味着这是个抽象地址,既然底层没有任何物理内存支持,尝试访问是违法的,况且里面也不能存储什么有效数据。(我知道有些小伙伴会忘记之前学过的指针知识,特意再详说)

其实在传的参数为指针时,当用户传来无效指针(空指针)野指针,代码里一旦对空指针解引用(例如:psl->arr[i],psl->size,其中psl是NULL,而->是对指针的解引用),程序就直接崩溃了;为了提高代码的健壮性,我们在函数的第一步对传过来的指针进行断言assert(指针),如果为空,程序会直接终止,报错;并提醒我们发生何处断言错误。——可见,检验传参(指针)有效性,是很有必要的。

总结

Q&A: 数据结构是做什么的? 计算机存储、组织数据的方式

            为什么有顺序表?为了方便管理一堆相同类型、数量且不一定的数据。

            方便体现在何处?动态数组和一些现成(现在方法都得自己敲出来实现,才能叫现成)的“增删查改”方法;与以前定长的数组相比,简单灵活得多

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/知新_RL/article/detail/852857
推荐阅读
相关标签
  

闽ICP备14008679号