何为数据结构?
咱今天也来说道说道......
数据结构介绍
- 准确概念
数据结构就是计算机存储、组织数据的方式
- 概念分析
从上句分析,数据结构是一种方式。一种管理数据的方式。为了做什么?为的就是计算机存储数据,组织数据。
存储数据好理解,就是把数据导进计算机。
组织数据:组织二字:就好比将军带兵。计算机无疑就是将军,数据就是征入军营的士兵们,数据现在还没真正成为士兵,得靠将军对它们进行管理、操练。比如增加能人志士,淘汰不合格的士兵,平时点卯(对士兵进行点名),对士兵在刀剑、骑射方面进行能力的增强。
概念解释清楚了,再看一眼概念:相信同学们已经把概念刻进了DNA。
数据结构就是计算机存储、组织数据的方式
将军之所以能为将帅之才,那自然是依靠它们善带兵之道,识人善用——即针对不同基础的士兵,独有一套招纳、组织方式。
今日,我们就看“队伍”之一——名为:顺序表这支军队里面平时如何招纳、组织数据。
先来看看这支队伍里面的士兵是为何种数据?
数据篇—顺序表里的数据
开门见山,核心一句话:顺序表的底层就是数组。
什么意思?且听我一言:既然是军队,那必然有队列阵型,每一个人站位何处都是定好了的。总不能这立一个,那躺一个,无组织无纪律,这明显和数据结构所达目的背道而驰。
数组为何能成为顺序表的底层?数组,是⼀组相同类型元素的集合。听听,集合二字,可见它的组织性;相同类型元素,则暗示它浑然天成能打造一支基本素质不低的军队。说白了,顺序表要的就是数组这种凝聚力。
- 阵型排列
具体阵型,简单看看:
地址我随便意思了一下,重点是什么? 这个顺序表和数组一个样,数组里面的元素数据类型是int型,每个数据拥有4个字节的空间,在数组里元素和元素之间地址连续。
好了,如果本段的解释,让你明白:顺序表的底层就是数组,这一小节儿的重点就把握了。
- 数据类型
照例,先给出核心:顺序表里要求的数据的类型较为广泛,上到内置类型(比如:int,float,double),下到自定义类型(结构体),再到大杂烩(基于内置类型与自定义类型定义的各种数据,比如,int a[ ],char b[ ]。
可以说,数据类型在顺序表里不作重点,简单了解到类型涵盖广泛就足够。
结构篇—顺序表里存储、组织数据
先来看,顺序表是如何招纳(存储)数据的,俗话说“巧妇难为无米之炊”,先得有数据才能管理、组织数据。
存储数据
顺序表在招纳数据的时候,为了满足顺序表本身是数组的特点,数组结构要求有二:类型与大小。
类型我们在前面已讨论—每个数据类型统一且数据类型范围较为广泛。
那么大小呢?在建立“这支队伍”之初,将军似乎不能预估士兵的多少,符合要求的数据到底有多少我们也未知,后续还会需要新的数据,空间不够怎么办?
谁来决定数组的大小?似乎成了问题,“韩信点兵,多多益善”,随着将军带兵之道不断精进,能带善于带更多的士兵(需要的数据不断引进),我们要以数组的形式的排列数据,但是又得灵活地开辟空间,大小是无法一口气拍定的。所以,顺序表里使用动态内存开辟空间(动态数组)对数据进行存储
这里就开始写代码了,我们分成三个文件:SeqList.h(顺序表头文件),SeqList.c(顺序表函数实现文件)以及test.c(测试文件)
//头文件SeqList.h 包含顺序表类型的定义(即到底是一支由什么数据组成的,有多少数据,现在空间大小几何的数组) 以及相关组织数据方法声明
typedef int SLDatatype;//此处int只为举例
struct SeqList
{
SLDataType* arr;
int size;//表示顺序表里有效数据个数
int capacity;//表示当前顺序表里已开辟空间大小,表示现有capacity个数据的空间,为了后续开辟空间有依据可言
}
//既然是动态数组,我们无法在开辟数组空间无法得知需要的空间大小,但是又得用数组,我们使用指针(数组首元素地址即数组名),表示数组。同时,对于不同数组招募的数据不同,我们直接对int进行重命名,最后如果需要的数据类型不同,可以把typedef后的int改成所需的数据。
定义好顺序表的雏形后,可以预见,在今后代码创建顺序表时,我们的代码无疑就是创建一个结构体。
提醒一下“创建顺序表即创建一个结构体”,顺序表本质还是数组,实现出来的样子就是数组。只是在组织数据时需要size和capacity这个变量。
怎么理解呢? 好理解。 这军队里打仗上战场的是数组(军队);这把握局势,统揽全局,出谋划策、有助带兵的是谁?当然是军师(size和capacity)。在刚刚那张图里虽然看不见size和capacity,但是对于组织军队,起到重要作用的便是这俩。
所以定义顺序表,包含数组和重要的两个变量
//test.c文件
int main()
{
struct SeqList sl;//名字很长啊,为了方便定义,我们在顺序表定义对结构体重命名。
return 0;
}
故最后的顺序表定义代码:
typedef int SLDataType;
//定义一个动态顺序表
typedef struct SeqList
{
SLDataType* arr;
int size;//数组里有效数据个数—元素个数
int capacity;//数组当前大小
}SL;
综上,为了方便存储不同类型的数据,灵活地调整存储数据的空间,我们分别使用重命名typedef成SLDataType 和 动态开辟内存(先只给一个指针表示数组和两个变量方便后续操作)
组织数据
现在有了存储数据的方式,在没有新来数据时,我们得做些准备工作(初始化)。
一、顺序表(动态数组)的初始化
//SeqlList.c
void SLInit(SL sl)
{
sl.arr = NULL;
sl.size = sl.capacity = 0;
}
//test.c来检验测试写的实现方法是否有bug
#include "SeqList.h"
int main()
{
SL sl;//这里使用SeqList.h定义的类型,不包含就找不到这个顺序表,后续一些函数也无法调用
SLInit(sl);
return 0;
}
易错:传值调用和传址调用
经测试,如果这里传SL sl,会报错:使用了未初始化的局部变量sl
解:顺序表在SLInit方法里,形参拷贝了一份sl的值,但是传过去的sl本身就没有值,更不能实现拷贝值一说。这就是为何报错说使用了未初始化的局部变量。
再者,使用了这样一份拷贝的顺序表,在SLInit方法里就算初始化成功,最后被初始化的复印件也会被删除,再看原件,毫无变化——故也没能达到初始化我们原本创建顺序表的目的。
所以,使用传址调用,地址在计算机里,具有唯一性。从不同维度解决问题,精准找到对的顺序表,而不是长得像的,临时的顺序表。
故顺序表的初始化方法代码:
void SLInit(SL* psl)
{
psl->arr = NULL;
psl->capacity = psl->size = 0;
}
二、顺序表的销毁
顺序表的销毁,对应我们的将士带兵论即为,在必要情况下,将士将每位士兵遣散回家,这遣散过程还包括扎营驻寨的空间的归还,军师也得衣锦还乡不是?。
拢共三个方面,那代码实现:
void SLDestroy(SL* psl)
{
if(psl->arr != NULL)
{
//既然数组不为空,意味着存着这一支军队
free(psl->arr);//可见数组的统一,不光组织起来方便,解散也方便
}
//数组为空||释放了数组后,说明数组已经不存在
psl->arr = NULL;//这个数组已经不存在,不能再存地址了
psl->size = psl->capacity = 0;//有效数据个数也为0,空间大小也为0
}
释放顺序表,考察的是对于各个部分安排是否妥当,切记考虑全面。
三、顺序表的增删查改
这才是真正体现组织数据技术含量的部分,不同的数据结构对于实现:增删查改,有着其独到之处。顺序表的增删查改。
增
两种基本增加数据方法,头插和尾插。头插即在数组下标为0位置处插入我们安排的数据,原来的数据都往后面挪一位(这位新来的数据,可见实力不小,能排在首位,后面的都得挪窝)
尾插即在原来数组的最后一位有效数字后添加我们安排的数据,原来的数据不用挪位置。(这位新来的数据被安排到最后一位)
这数据招进来,类型统一是统一,可别忘了顺序表
还有size和capacity两个变量。新来一个数据,size++就好,capacity呢?万一空间不够了就安排不进了,这就是在增之前,我们需要考虑的——是否需要此时动态开辟内存
- 是否需要动态开辟内存
代码:
void SLCheckCapacity(SL* psl)
{
if(psl->size == psl->capacity)//“扩容”就在此刻
{
//说明有效数字已经满了数组已经开辟的空间,也满足刚开始size和capacity同为0的情况
//扩容操作如下:
SLDataType* tmp = realloc(psl->arr,2 * psl->capacity * sizeof(SLDataType));//这句为什么用reallo? 因为我们是扩容,realloc函数就是实现在原有空间上继续开辟空间,有增容的概念。第一个参数是你所指定的空间,第二个参数是你所申请开辟的空间大小,因为以存储空间(内存)以字节为单位,故第二个参数要乘上一个数据的字节大小,2*capacity意思是2倍:我们常常2倍增容,这样到后期增容速率会变慢,且2倍增容浪费空间数不会太多。
//左边为什么用tmp? tmp(暂时的),realloc函数的返回类型是一个数组首地址(指针),因为申请空间可能不成功,一旦不成功,返回NULL,那原有的数据就会丢失,保险起见,先用一个tmp接收。
if(tmp == NULL)
{
// 意味着没申请成功
perror("realloc:failed");//报错,提醒自己
}
}
}
realloc函数有些细节以及2倍增容真正数学原理,我没细说,不过这里的解释倒是够用。有兴趣的同学可以去详细了解。
void SLCheckCapacity(SL* psl)
{
if(psl->size == psl->capacity)
{
SLDataType* tmp = realloc(psl->arr,2 * psl->capacity * sizeof(SLDataType)); //注意这里有个坑,先往后看,马上就说
if(tmp == NULL)
{
// 意味着没申请成功
perror("realloc:failed");//报错,提醒自己
}
else
{
// 申请成功了
psl->arr = tmp;
psl->capacity = 2 * psl->capacity;
}
}
}
易错:psl->capacity==0时,开辟了0个空间
所以以后写一长串参数运算时,注意一下参数为0的情况。
真正的代码实现:
void SLCheckCapacity(SL* psl)
{
if(psl->size == psl->capacity)
{
int NewCapacity = psl->capacity == 0 ? 4 : 2 * psl->capacity//创建一个新变量,判断原空间大小:如果为0,默认给它4个空间成为新空间,如果不为0,则在原有基础上2倍成为新空间数
SLDataType* tmp = realloc(psl->arr,NewCapacity * sizeof(SLDataType));
if(tmp == NULL)
{
// 意味着没申请成功
perror("realloc:failed");//报错,提醒自己
}
else
{
// 申请成功了
psl->arr = tmp;
psl->capacity = NewCapacity;
}
}
}
- 头插
判断完空间需要扩容后,现在的空间大小已经满足要求。我们开始实现插入数据,最后再size++。
先来看头插:根据刚刚的描述,安排首位为新数据,后面的都要向后移一位。真正逻辑是,先有得首位空了,才能安排新数据;但是要让首位空,所有数据都得先后移一位。故先循环,让后面的数据先移位,循环结束后,下标为0的元素就赋上新数据,再size++;
void SLPushFront(SL* psl,SLDataType x)
{
assert(psl);//断言:文章最后一部分有解释
SLCheckCapacity(psl);
for(int i = psl->size;i ;i--)//移位得从后往前,好好想想:如果从前往后移,首先0下标数字一移就把下标为1的数字就覆盖了,这一改就再也找不到了。及时止损,换个思路:从后往前挪,后面的先后挪总会留下一个空位,画个图就明白了
{
psl->arr[i] = psl->arr[i - 1];//循环的最后,就是arr[1] = arr[0];由此得出i的取值范围:i > 0
}
psl->arr[0] = x;
psl->size++;
}
- 尾插
代码思路:先照例检查是否有足够空间,再尾插,这个尾插,插在有效数字的最后,不用移位那也用不上for循环。核心就一句arr[ ] = x
void SLPushBack(SL* psl,SLDataType x)
{
assert(psl);
SLCheckCapacity(psl);
psl->arr[] = x;
psl->size++;
}
那【】中到底是什么?
size本来记录的就是数组里面有效的数字,还是随着数组元素增加依次增加,数组下标从0开始,到size下标时,刚好对应没有数组元素。
补全合并代码,最终的尾插方法:
void SLPushBack(SL* psl,SLDataType x)
{
assert(psl);
SLCheckCapacity(psl);
psl->arr[psl->size++] = x;//size后++,对于安排数字不影响,优雅
}
删
和“增”一样,删这种组织数据的方法,也分头删和尾删
- 头删
模拟一下头删场景,借此来窥探代码思路。
头删,头就是下标为0的元素。
“好图不嫌多用”,我们在删除此图的26时,最后达成的效果是
对比很鲜明啊,最明显的也是在刚开始写代码容易忽略的就是size--。下标为1及后面的元素都往前了一位。无疑是有循环的,被我们忽略的还有一个26,我们需要单独对它做什么吗?似乎看来,所有数据覆盖后,就达成了这个效果,单独操作不合理也费劲
代码:
void SLPopFront(SL* psl)
{
assert(psl);
for(int i = 0;i < psl->size - 1;i++)
{
psl->arr[i] = psl->arr[i + 1];//最后一次就是arr[size - 2] = arr[size - 1];由此确定i < size - 1(看移动前那张图分析)
}
psl->size--;
}
- 尾删
同样模拟一下尾删场景,借此来摸索代码思路。
尾删,就是删掉当前数组的最后一位有效数字。在这里就是3
删完后,就是这个效果:
无疑size--,元素并没有发生移位,不用for循环。
代码:
void SLPopBack(SL* psl)
{
assert(psl);
psl->size--;//没错,size--就可以,不用对3进行什么操作
}
//为什么?size--后,我们在增加数字时,(头插、尾插),3都会被覆盖,不影响;在删除数字时(头删,我们for循环也只会关心size下标内的数据,3不在有效范围内,不影响;尾删不影响,继续size--),也只关心size内的数据。查找,修改数据更是,不然我们刚开始为何要定义一个变量叫当前数组的有效个数,因为我们只关心下标size之前的数据。
查
我们这里的查,是根据已给的数据在原数组里判断是否存在。
思路很简单,将数组元素遍历,每个元素与x比较,如果相等就找到了;反之,循环结束后若还没找到,说明当前数组不存在该数据
代码:
int FindByX(SL* psl,SLDataType x)
{
assert(psl);
for(int i = 0;i < psl->size;i++)
{
if(psl->arr[i] == x)
{
return i;//这里先不考虑x有多个的情况,先掌握思路
}
}
return -1;//都直到循环结束,也没找到对应的下标,说明数组里面是没有x这个元素的,故返回一个无效下标
}
//test.c
int main()
{
//...
// 测试“查”方法
int find = FindByX(psl,x)
if(find < 0)
{
printf("没找到\n");
}
else{
printf("找到了,下标为%d\n",find);
}
//...
return 0;
}
改
两种情况:一种指定下标更改:一步到位arr[指定下标] = x;
还有一种情况,改数组里面的某个数字,那第一步得找到那个数字的对应下标不是?再加上arr[find] = x;
这个没什么坑,直接上代码:
void SLModify(SL* psl,SLDataType x,SLDataType Newx)//为了方便,在参数里面我直接规定了新数据,其实还可以用scanf通过键盘录入新数据
{
assert(psl);
int find = FindByX(psl,x);
if(find < 0)
{
}
else
{
psl->arr[find] = Newx;
}
}
//情况二
void SLModify2(SL* psl,int pos,SLDataType Newx)
{
psl->arr[pos] = Newx
}
四、特殊的增和删
在增加或者删除数据时,我们只介绍了头增和尾增、头删和尾删;不具有增加的任意性:万一所增的数据偏偏非头非尾,所删的数据也在中间某一个位置。
顺序表的指定位置之前插入数据
我们还是通过那张“好图”来模拟场景实现,借此摸索代码思路。
给出这样一个顺序表,现在我们要在数组下标为pos的地方即“1”之前增加一个数据“55”。最后这个顺序表长这样:
上下对比,先看数组外,变量size++了(增加一个数据,有效数据个数可不就得++),再来看数组内元素是否发生移位:发生了,发生的范围在pos及pos后面的所有数据,arr[pos] = 1数据都发生了移位;我们因此能确定使用for循环;并且i的起点应该是pos。
按照之前的理解,思路都是先移位,让arr[pos]空出来,最后arr[pos] = 55,size++,同理可得代码:
void SLInsert(SL* psl,int pos,SLDataType x)
{
assert(psl);
for(int i = size;i > pos;i--)
{
psl->arr[i] = psl->arr[i - 1];//可以看到这里仍然是从后向前移动数据,循环结束后达到在目的位置为空
}
pal->arr[pos] = x;
psl->size++;
}
//发现没,与头插代码相比,变的就是循环的元素个数(与pos有关)
你会发现,这个方法里,一旦pos==0 就是我们写的头插,pos==size就是我们写的尾插;简而言之,我们对下标任意化了,这就是前文所说的任意性。
顺序表的指定位置删除数据
同样,数据结构要的就是一个代码“跃然纸上”的效果。
这次我们要删除arr[pos] = 1这个数据了。最后的效果:
size变没有?变了——size--;元素移位没有?移了,for循环。有了pos,可以确定循环变量的范围。
代码实现:
void SLDel(SL* psl,int pos)
{
assert(psl);
for(int i = pos;i < psl->size - 1;i++)
{
psl->arr[i] = psl->arr[i - 1];//最后一步是arr[size - 2] = arr[size - 1]
}
psl->size--;
}
你会发现,这个方法里,一旦pos==0 就是我们写的头删,pos==size就是我们写的尾删;简而言之,我们对下标任意化了,这也对应前文所说的任意性。
代码优化——断言处理:
参数只说了传指针,没说不能传空。
比如:
调试结果:
究竟原因其实是:对空指针进行解引用,为何不能对空指针进行解引用:操作系统和硬件通常不会将物理内存地址0分配给任何实际的内存块,因此尝试访问这个地址会导致硬件异常,比如段错误或访问违规。操作系统捕获到这种异常后,通常会终止程序的执行。
空指针往往被分配的是NULL代表着0x00,也同时意味着这是个抽象地址,既然底层没有任何物理内存支持,尝试访问是违法的,况且里面也不能存储什么有效数据。(我知道有些小伙伴会忘记之前学过的指针知识,特意再详说)
其实在传的参数为指针时,当用户传来无效指针(空指针)野指针,代码里一旦对空指针解引用(例如:psl->arr[i],psl->size,其中psl是NULL,而->是对指针的解引用),程序就直接崩溃了;为了提高代码的健壮性,我们在函数的第一步对传过来的指针进行断言assert(指针),如果为空,程序会直接终止,报错;并提醒我们发生何处断言错误。——可见,检验传参(指针)有效性,是很有必要的。
总结
Q&A: 数据结构是做什么的? 计算机存储、组织数据的方式
为什么有顺序表?为了方便管理一堆相同类型、数量且不一定的数据。
方便体现在何处?动态数组和一些现成(现在方法都得自己敲出来实现,才能叫现成)的“增删查改”方法;与以前定长的数组相比,简单灵活得多