数据结构与算法 - 动态顺序表 --概念+实现


前言

路漫漫其修远兮,吾将上下而求索;


一、数据结构

什么是数据结构(Data Structure)

我们在写一些项目的时候,需要向内存申请空间来对数据进行存储、管理;而存储、管理数据需要数据结构,不同的数据结构有其独特的价值,例如,有些数据结构仅仅知识拿来存放数据,有些数据结构适用于搜索……

eg.

  • 利用数组存放数据优点:放数据方便,并且可以利用下标进行随机访问;缺点:如果是静态的数组,就会出现要么空间不够要么空间浪费的情况;如果是动态数组,空间不够了便要进行扩容的操作,而扩容又是一种消耗资源(异地扩容,找空间、拷贝旧空间中的数据到新空间,释放旧空间,返回新空间的地址)的操作;总之,麻烦并且有不小的消耗
  • 链表:如果你要存放一个数据便要申请一块空间,每存放一个数据便要申请一块空间……利用指针将这些空间链接起来;存放数据比较方便并且不存在巨大的消耗,因为链表无需像顺序表在插入、删除数据时还要一个一个地挪动数据,只需要改变结点之间的链接关系即可;
  • 而倘若你不单单只是利用数据结构来存放数据,还要对这些数据进行查找、排序等,链表便不再适用,此时便会利用到树(eg. 红黑树)

数据结构的种类非常多,其中最简单的数据结构便是线性表

什么是线性表?


二、算法

算法(Algorithm) :定义良好的计算过程,他取一个或一组的值为输入,并产生出一个或者一组值作为输出。简单来说就是一系列的计算步骤,用来将输入数据转换成输出的结果;

常见的算法有排序、查找、去重等;


三、线性表

线性表(Liner List) 是n 个具有相同特性的数据元素的有限序列。线性表是一种在实际中广泛使用的数据结构,常见的线性表有:顺序表、链表、栈、队列、字符串……

线性表在逻辑上一定是线性结构,即逻辑上呈现出连贯的“线”性,但是在物理结构上不一定是连续的,物理上的连续性体现在存储数据时通常采用数组的形式;线性表通常采用数组或者链式结构来存储数据;

以数组为底层原理的线性表称之为顺序表,是在数组的基础上增加了一些功能以实现对数据的存放、管理;同时,相较于数组,数组并没有要求其数据必须连续存放,而顺序表中的数据必须连续存放;

除了线性表还有什么结构呢?

  • 树形结构、哈希结构……

四、顺序表

(一)概念及其结构

顺序表用一段物理地址连续的内存单元依次存储数据元素的线性结构,一般情况下采用数组存储,即在数据的基础上增加增删查改的功能以实现对于数据的管理;

注:顺序表底层用来存放数据的结构就是数组,但是在数组的基础上,它还要求数据是从头开始连续存储的,并不能跳跃间隔;

顺序表一般可以分为静态顺序表与动态顺序表(与通讯录大体相似)

1、静态顺序表

类型声明:

分析:当我们拥有了一个可以存放100个int 类型数据的静态顺序表,即一个可以存放100个int 类型数据的数组的时候,我们如何得知此数组是否还能存放下数据?需要利用一个计数器size 来专门记录在此数组存放的有效数据的个数;

2、动态顺序表

类型声明:

注:

在结构体类型声明中并未直接创建一个数组成员,而是放了一个指向数组空间的指针(可以理解为数组首元素的地址)。因为此处并不知道会用多少空间,需要利用到realloc 进行动态开辟,所以在维护这个顺序表的结构体类型声明中放一个数组首元素地址便可;

利用typedef 将类型进行重定义:1、重定义int ,利于修改,修改一处而达到多处得到修改的效果; 2、将结构体类型进行重定义,便于书写


五、动态顺序表的实现

动态顺序表的实现,本博主将其分为以下四个步骤:

  • 定义动态顺序表的类型
  • 初始化顺序表
  • 功能接口函数的实现(增删查改打印)
  • 一些细节的接口函数(销毁动态开辟的空间)

注:什么是接口函数?

字面意思,跟人进行接口的函数即称之为接口函数;

接口函数有什么用?

  • 当你想要独立实现了一个顺序表,回忆之前的写通讯录,此时你可能会想如何在顺序表中存放数据,而这些数组又是存放它的哪个位置?尾上插入数据、中间插入数据、头上插入数据又是如何完成的?针对这些,我们会将功能分开写函数来实现,即调用相对应的接口函数以达到相应的功能;

(一)、SeqList.h 

所要使用到的接口函数如下:

//初始化
void SeqListInit(SL* ps);

//接口函数 增删
void SeqListPushBack(SL* ps, SLDataType x);//尾插
void SeqListPopBack(SL* ps);//尾删
void SeqListPushFront(SL* ps,SLDataType x);//头插
void SeqListPopFront(SL* ps);//头删

//查找
int SeqListFind(SL* ps, SLDataType x);

//打印
void SeqListPrint(SL* ps);

//任意pos 插入数据
void SeqListInsert(SL* ps, int pos, SLDataType x);

//任意pos 位置删除
void SeqListErase(SL* ps, int pos);

//细节接口函数--销毁
void SeqListDestroy(SL* ps);

注:参数的传递采用的是传址调用,为什么不能使用传值调用?

1、传值调用,形参是对实参的临时拷贝;修改形参并不会影响实参;在这些接口函数中,有些接口函数是需要修改实参的内容的,例如:初始化、头插、尾插、头删、尾删等;故而要使用传址调用

2、并不知道此链表的长度;如果此链表很长,数据非常多,那么进行传值调用,会产生巨大的消耗;入栈、数据的拷贝……相较于传址调用,在时间和空间上具有非常大的消耗;故而在对结构体变量进行传参的时候,尽量使用传址调用;

(二)、SeqList.c 

接口函数的具体实现

1、初始化

//初始化
void SeqListInit(SL* ps)
{
	assert(ps);

	ps->arr = NULL;
	ps->size = ps->capacity = 0;
}

注:初始化指针ps->arr , 以避免野指针;

初始化可以分成两种:

  • 一是,在初始化函数中利用malloc 开辟结构体的空间,然后以返回值的形式带出;
  • 二是,在外面创建结构体变量,然后传参的形式带入;

此处采用的是方式二;

结构体成员arr 的空间分配也有两种方式:

  • 一是初始化时便分配一定的空间,例如可以存放下4个数据的空间,然后再对封装的对顺序表的结构体进行初始化处理
  • 二是,直接对结构体进行初始化,将空间的开辟全交给“扩容”,在其中多增加一个判断即可

此处采用的是方式二;

2、扩容

//扩容-
void BuySeqListCheckCapacity(SL* ps)
{
	//先判断是顺序表是否为初次开辟;
	//为避免频繁扩容,此处采用倍数的形式来增长空间
	if (ps->size == ps->capacity)
	{
		int newcapacity = ps->capacity == 0 ? 4 : 2 * (ps->capacity);
		SLDataType* parr = (SLDataType*)realloc(ps->arr ,sizeof(SLDataType) * newcapacity);
		if (parr == NULL)
		{
			perror("BuySeqListCheckCapacity");
			exit(-1);
		}
		//处理
		ps->arr = parr;
		ps->capacity = newcapacity;
	}
}

注:

对于数据的插入,空间存在三种情况:

  • 一是,空间足够,数据便直接存放便可;
  • 二是,还未存放数据即从未开辟空间,所以先开辟能存放下4个数据的空间;判断的依据,ps->size == 0; 
  • 三是,空间不足需要对其进行扩容处理,于是便会按照原来空间的两倍来进行扩容;

其中,二三均为空间不足够的情况,故而在“扩容”的这个函数中,需要判断空间是否足够,然后再判断空间不足够的原因(空间不足够的具体情况);

为什么要按照是原来空间的2倍扩容?

  • 并不是非要两倍进行扩容,可以是3倍、4倍等;只不过按照原来空间的两倍进行扩容是一种比较适中的方式;频繁地扩容会打断操作系统地执行而导致效率下降,倘若一次性开辟地空间太多,便会存在浪费空间的可能性;

此处还非常巧妙地应用到了realloc 进行处理;realloc 的第一个参数为NULL的时候,其作用与malloc 别无二致;当realloc 的第一个参数不为NULL的时候便会对此地址上的空间进行调整;

3、尾插入

//尾插
void SeqListPushBack(SL* ps, SLDataType x)
{
	assert(ps);
	//尾插--插入数据--判断空间是否足够
	BuySeqListCheckCapacity(ps);
	//放入数据,size++
	ps->arr[ps->size] = x;
	ps->size++;
}

注:

尾插 - 在顺序表的尾部插入数据,既为插入数据那么首先要保证空间足够,于是乎先调用函数BuySeqListCheckCapacity , 然后再放入数据即可;别忘了调整 ps->size;

4、尾删

//尾删
void SeqListPopBack(SL* ps)
{
	assert(ps);
	//删除的前提是顺序表中有数据可以被删除
	assert(ps->size);
	//直接size--即可,不用对空间的数据进行处理
	ps->size--;
}

注:删除数据的前提是空间中有数据可删,所以需要assert 判断一下顺序表中有无数据;

此处无需对空间中的数据进行处理,只需要调整ps->size 让其减小 1 即可,因为下一次在这个空间填充数据的时候会覆盖这些数据;

5、头插

//头插
void SeqListPushFront(SL* ps, SLDataType x)
{
	assert(ps);
	//插入数据首先是判断空间是否足够
	BuySeqListCheckCapacity(ps);
	//先将数据向后挪动(线挪动后面的数据),空出第一个位置上的空间;size++
	/*int i = 0;
	for (i = ps->size ; i > 0; i--)
	{
		ps->arr[i] = ps->arr[i - 1];
	}*/

	int end = ps->size ;
	while (end > 0)
	{
		ps->arr[end] = ps->arr[end - 1];
		--end;
	}

	ps->arr[0] = x;
	ps->size++;
}

注:

同理,无论是怎样的插入,只要是插入数据便会使用到空间,所以第一件事便是对空间进行判断;

其次,头插,即在数组中下标为0的位置上放置数据;故而应该先将数据向后挪动将第一个空间给空出来,然后再将数据放进去,然后size++;

如上图所示,需要利用循环将数据一个一个地往后挪动一个数据的空间;交换数据的次数便是数据的个数,即size, 当size 等于0时循环便停止;

6、头删

//头删
void SeqListPopFront(SL* ps)
{
	//删除的前提是有数据可以被删除
	assert(ps);
	assert(ps->size);
	//删除的本质就是让后面的数据覆盖第一个位置空间中的数据,然后size--
	//将数据从前往后进行挪动
	/*int i = 0;
	for (i = 0; i < ps->size - 1; i++)
	{
		ps->arr[i] = ps->arr[i + 1];
	}*/

	int begin = 0;
	while (begin < ps->size - 1)
	{
		ps->arr[begin] = ps->arr[begin + 1];
		++begin;
	}
	ps->size--;
}

删除的前提是有数据可以删除,故而首先应该是对顺序表中的数据个数进行断言;

其次,头删,让后面的数据将下标为0的空间中数据给覆盖掉便可;循环size-1 次,记得size--;

7、查找


//查找
int SeqListFind(SL* ps, SLDataType x)
{
	//查找的前提也是有数据可以进行查找
	assert(ps);
	assert(ps->size);
	//查找的原理是比对,然后返回下标
	int i = 0;
	for (i = 0; i < ps->size; i++)
	{
		if (ps->arr[i] == x)
			return i;
	 }
	//没有找到
	return -1;
}

查找的原理便是利用循环对顺序表进行遍历,然后将所要查找的数据与顺序表中的数据进行一一比对,不相同便查找下一个空间的数据,相同便返回此数据在顺序表中的下标;倘若将整个顺序表均遍历完了还没有返回便代表着没有找到,即返回-1;

注:当在顺序表中找不到数据的时候不一定非要返回-1,只要是个负数你知道如何使用便可;

为什么会是负数?

  • 因为此处的查找返回的是对应数据的下标,众所周知,数组下标是从0开始的,不可能为负数,故而返回负数以代表找不到;

8、打印

//打印
void SeqListPrint(SL* ps)
{
	assert(ps);
	int i = 0;
	for (i = 0; i < ps->size; i++)
	{
		printf("%d ", ps->arr[i]);
	}
	printf("\n");//换行
}

9、指定pos位置上的插入

//指定pos 插入数据
void SeqListInsert(SL* ps, int pos, SLDataType x)
{
	assert(ps);
	//pos的范围要合理
	assert(pos >= 0 && pos <= ps->size);//当pos等于ps->size 便为尾插
	//插入,空间的检查,size++
	BuySeqListCheckCapacity(ps);

	//将pos 位置后面的数据向后挪动,将下标为pos 的空间给空出来,
	//先挪动后面的数据
	//int i = 0;
	//for (i = ps->size; i > pos; i--)
	//{
	//	ps->arr[i] = ps->arr[i - 1];
	// }
	int end = ps->size;
	while (end > pos)
	{
		ps->arr[end] = ps->arr[end - 1];
		--end;
	}

	ps->arr[pos] = x;
	ps->size++;

}

向任意pos前插入数据,首先要判断pos 是否合法;先考虑一下极端情况,当pos为0时,即向下标为0的空间前插入数据,相当于头插;当pos 为size(数组元素的个数),因为顺序表尾数据的下标为size-1,所以当pos 为size 的时候相当于尾插;综上,pos的合法范围为 0~size;

插入数据的前提是顺序表中有空间可以放数据,故而要对空间进行检查;

顺序表的关键特征是数据是从头开始连续存储的,并不能跳跃间隔;与之前写的头插函数一样,需要将pos位置上的数据以及pos 后面的数据均向后挪动一位;

最后不要忘记 size++;

10、指定pos 位置上的删除

//指定pos 位置删除
void SeqListErase(SL* ps, int pos)
{
	//删除的前提--有数据可以删除
	//删除的本质就是将pos 位置上的数据进行覆盖,让pos 后面的数据向前挪动,覆盖pos 上的空间
	//size--
	assert(ps);
	assert(ps->size);
    //判断pos 是否合法
    assert(pos>=0 && pos <= ps->size-1);
	/*int i = 0;
	for (i = pos; i < ps->size - 1; i++)
	{
		ps->arr[i] = ps->arr[i + 1];
	 }*/

	int begin = pos + 1;
	while (begin < ps->size)
	{
		ps->arr[begin - 1] = ps->arr[begin];
		++begin;
	}
	ps->size--;
}

删除数据的前提是空间中有数据可删,所以需要assert 判断一下顺序表中有无数据;

其次是pos 需要在合法范围内,我们分析一下pos 的极端情况:当pos 为0时,相当于头删,因为顺序表中尾数据的下标为size-1, 故而pos 的范围为 0~size-1

此处为删除指定pos 位置上的数据,只要将pos 空间的数据被覆盖了,即将pos 位置之后的数据向前挪动一个位置;

不要忘记 size--;

11、销毁

//细节接口函数--销毁
void SeqListDestroy(SL* ps)
{
	assert(ps);
	//释放动态开辟的空间
	//将一些值置为0
	free(ps->arr);
	ps->arr = NULL;
	ps->size = ps->capacity = 0;
}

需要手动释放动态开辟的空间,并且将变量置空,置初始值;

12、复用

头插和尾插可以复用指定pos位置的插入

头删和尾删可以复用指定pos位置的删除

不再赘述;


总结

1、线性表在逻辑结构上呈现线性,其物理结构不一定呈现线性;

线性表包括:顺序表、链表、栈、队列、串等;

2、顺序表就是数组,但是在数组的基础上,它还要求数据是从头开始连续存储的,并不能跳跃间隔;并且顺序表作为线性表的一种,其逻辑结构与物理结构均呈现线性;

3、线性表的实现

  • 定义动态顺序表的类型
  • 初始化顺序表
  • 功能接口函数的实现(增删查改打印)
  • 一些细节的接口函数(销毁动态开辟的空间)

小技巧,倘若想迅速搓出一个顺序表,可以先写指定pos位置的插入、指定pos位置的删除这两个函数,头插、尾插、头删、尾删复用这两个函数即可;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值