数据结构_顺序表专题

何为数据结构?

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

数据结构介绍

  • 准确概念

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

  • 概念分析

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

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

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

概念解释清楚了,再看一眼概念:相信同学们已经把概念刻进了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: 数据结构是做什么的? 计算机存储、组织数据的方式

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值