Bootstrap

数据结构初阶---顺序表

一、线性表---LinearList

线性表是n个具有相同特性的数据元素的有限序列,是一种在实际生活中广泛使用到的数据结构。

主要的线性表有:顺序表、链表、栈、队列、字符串。

线性表在逻辑上是线性结构---连续的一条直线,但是在物理上不一定连续,在物理上存储时,通常以数组和链式结构形式来存储。

今天主要了解顺序表。

二、顺序表---SequenceList

顺序表是用一段连续物理地址的存储单元进行数据存储以及数据的增删改查的线性结构,一般采用数组的形式存储。

也就是说,线性表中的顺序表,在物理上是连续的。

顺序表的分类

顺序表可以分为静态和动态顺序表。

静态即表示数组的空间是固定的,存储的数据量是固定的,容易出现浪费空间或者空间不够用的情况,我们在实际中很少使用到静态的顺序表:

#define N 100
#define SLDataType int//数据类型
typedef struct SeqList
{
	SLDataType a[N];//定长数组
	int size;//存储的数据个数
}SL;

动态表示数组的空间在堆上、通过malloc、calloc函数进行动态开辟的,可以使用realloc函数进行扩充空间,最后使用free函数释放空间,动态内存空间开辟返回首地址,因此我们创建成员变量的类型应该是数据的指针类型。

#define SLDataType int
typedef struct SeqList
{
	SLDataType* a;//指向动态开辟的数组
	int size;//数据个数
	int capacity;//空间容量
}SL;

动态顺序表的接口实现

由于每个接口函数传入的参数都有结构体变量指针,在函数中需要对该指针解引用,因此传入的这个指针不能为空NULL!所以需要使用断言assert对每个接口函数进行判断!

传址而不能传值,因为函数形参是实参的一份临时拷贝,额外开辟的形参空间中的数据修改最终都会销毁、空间回收,无法修改实参的任何数据。

1.初始化SeqListInit

void SeqListInit(SL* psl);

①初始化阶段开辟空间

我们可以在初始化阶段就通过malloc与calloc来开辟空间:

//初始化
void SeqListInit(SL* psl)
{
    //1.初始化开辟空间的方式,假设初始容量4
	psl->size = 0;
	psl->capacity = 4;
	SLDataType* tmp = (SLDataType*)calloc(psl->capacity, sizeof(SLDataType));
	if (tmp == NULL)
		perror("calloc fail");
	psl->a = tmp;
}

calloc就直接顺便初始化了空间;malloc相反,不会初始化空间,而且两者传入参数也稍微不同,需要注意。

②初始化阶段不开辟空间(置NULL)
void SeqListInit(SL* psl)
{
	assert(psl);
	psl->a = NULL;//数据空间初始化为NULL
	//这里初始化也可以开辟空间
	psl->size = 0;
	psl->capacity = 0;
}

这里就选择不开辟空间的方式,后续在检查空间容量的接口上会进行空间的开辟操作。

2.打印SeqListPrint

void SeqListPrint(SL* psl);

void SeqListPrint(SL* psl)
{
	assert(psl);
	for (int i = 0; i < psl->size; i++)
	{
		printf("%d ", psl->a[i]);
	}
	printf("\n");
}

打印就很简单了,和一般的数组元素打印其实是相同的。

3.销毁SeqListDestroy

void SeqListDestroy(SL* psl);

void SeqListDestroy(SL* psl)
{
	assert(psl);
	if(psl->a!=NULL)
	{
		free(psl->a);
		//free在释放空间的同时,会检查你有没有越界,大概率会检查出来有没有越界
		psl->a = NULL;
		psl->capacity = 0;
		psl->size = 0;
	}
}

销毁操作硬要说的话就是对动态开辟的空间的释放,使用free函数即可。

同样需要注意:free函数对于空间的释放是一次性的全部释放,只能传入起始地址对全部空间释放!不支持分多次释放一次开辟的空间! 

在释放后,同样的操作我们将数据指针a置空,容量capacity与数据个数size置0。

4.检查空间容量SeqListCheckMemory

void SeqListCheckMemory(SL* psl);

当我们的数据个数size与空间容量capacity相等时,这个时候就需要进行扩容,然后我们在初始化阶段SeqListInit并没有开辟空间,且capacity与size均为0,那么我们首要的步骤其实是开辟空间,假设我们每一次扩充原来的1倍(即乘以2),可以使用三目操作符 ? :进行巧妙的操作:

psl -> capacity == 0 ? 4 : psl->capacity * 2 ,当空间容量为0时,4赋给NewCapacity,后面再将NewCapacity值传回psl->capacity;同理如果空间容量不为0,那么就直接翻倍(乘以2)即可。

然后就是空间扩充操作:

void SeqListCheckMemory(SL* psl)
{
	assert(psl);
	if (psl->size == psl->capacity)
	{
		//开始容量是0--->开4空间容量;开始不是0--->开原空间两倍容量
		int NewCapacity = psl->capacity == 0 ? 4 : psl->capacity * 2;
		SLDataType* tmp = (SLDataType*)realloc(psl->a, NewCapacity * sizeof(SLDataType));
		if (tmp == NULL)
		{
			perror("realloc fail");
			return;
		}
		psl->a = tmp;
		psl->capacity = NewCapacity;
	}
}

为什么不直接将扩充的空间给数据指针psl->a呢?因为存在风险:如果扩充失败,返回NULL,如果返回值NULL直接给psl->a,就会连之前的数据也全部丢失了。

因此使用一个tmp的中间量来过渡,无误再赋给psl->a。当然最后不要忘记将NewCapacity值重新给到psl->capacity。

每次扩容的量, 取决于实际情况,数据结构并没有标准规定要扩容多少,按照一定的比例进行扩容即可!

下面是头尾部的插入删除数据操作:

5.头插SeqListPushFront

void SeqListPushFront(SL* psl, SLDataType x);

//头插---时间复杂度O(N),头插N个数据时间复杂度O(N^2)--等差数列求和
void SeqListPushFront(SL* psl, SLDataType x)
{
	assert(psl);
	SeqListCheckMemory(psl);
	int end = psl->size - 1;
	//for (int i = end; i >= 0; i--)//前移然后首位填充x
	//{
	//	psl->a[i + 1] = psl->a[i];
	//}
    //上下循环都行
	while (end >= 0)
	{
		psl->a[end + 1] = psl->a[end];
		end--;
	}
	psl->a[0] = x;
	psl->size++;
}

首先断言,然后插入数据,同理需要先进行容量检查,再执行插入数据操作。

在首位插入数据,那么我们直接将当前所有数据向后进行挪动一个位置,然后将首位放入插入的数据即可。在挪动时需要注意,我们需要从最后开始向后移,从前开始移会导致数据丢失! 

顺序表头插的时间复杂度为O(N),插入N个数据的时间复杂度为O(N^2)。

6.尾插SeqListPushBack

void SeqListPushBack(SL* psl, SLDataType x);

//尾插---时间复杂度O(1),尾插N个数据时间复杂度O(N)
void SeqListPushBack(SL* psl, SLDataType x)
{
	assert(psl);
	SeqListCheckMemory(psl);
	psl->a[psl->size] = x;
	psl->size++;
}

我们在插入的接口函数的操作前,除了对于传入参数的断言,还有对于容量空间的检查也是必不可缺的,因为插入就意味着增加数据,就意味着空间数据个数变多,可能会导致越界,因此必须要先进行容量检查。当进行容量检查后,容量不够会扩容,我们直接将需要插入的x,放入下标为size的空间中即可。同时放入后size需要自增1,因为数据个数多了一个。

顺序表尾插的时间复杂度为O(1),尾插N个数据的时间复杂度为O(N)。

7.头删SeqListPopFront

void SeqListPopFront(SL* psl);

//头删---时间复杂度O(N),头删N个数据时间复杂度O(N^2)
void SeqListPopFront(SL* psl)
{
	assert(psl);
	//将其后的所有数据前移,覆盖首位的元素
	int begin = 0;
	for (int i = begin; i < psl->size - 1; i++)
	{
		psl->a[i] = psl->a[i+1];
	}
	//上下为两种思路,都是为了防止数组越界
	//int begin = 1;
	//for (int i = begin; i < psl->size ; i++)
	//{
	//	psl->a[begin-1] = psl->a[begin];
	//}

	psl->size--;
}

删除首位数据,由于删除,就不需要进行容量的检查,我们在断言后直接进行删除操作即可,如何删除呢?将首位数据之后的所有数据向前移一位即可,那么下标1处的数据会将首位数据覆盖掉,最后也不要忘记将数据个数size减1。对于向前的挪动覆盖,需要从前面数据开始进行,否则会丢失数据!

头删的时间复杂度为O(N),头删N个数据的时间复杂度为O(N^2) 。

8.尾删SeqListPopBack

void SeqListPopBack(SL* psl);

尾删,直接数据个数减1即可,不需要对该值进行任何修改。但是需要注意的是,数据个数不能为负!否则会导致插入数据或者删除的不正确! 因此为了解决这一点,我们使用if的条件判断温柔解决或者使用断言assert进行暴力解决即可。

尾删的时间复杂度O(1),尾删N个数据的时间复杂度为O(N)。

9.任意位置插入SeqListInsert

这个任意位置并不是真正的任意位置,而是在满足顺序表的规则下任意位置插入,顺序表要求数据都是连续存储的,存储在连续的地址当中。那么我们无法隔一个或多个没有存储数据的空间来插入数据,这是不满足顺序表要求的。

void SeqListInsert(SL* psl, int pos, SLDataType x);

pos指下标,也就是在pos的下标位置插入一个数据x,那么整体思路就是断言+判断空间容量+判断pos的数值是否超过了数据个数+pos下标及之后数据的整体后移一位。那么同理为了防止移位导致的数据丢失,我们从后向前将数据向后挪位。

pos由于代表着下标,为了防止越界访问,我们需要对pos的值进行一个判断,可以使用if条件判断,也可以使用assert断言,当pos=size时,其实就是一个尾插。因此pos取值区间[0,size]。

//允许的任意位置处的插入
void SeqListInsert(SL* psl, int pos, SLDataType x)
{
	assert(psl);
	//对于下标pos参数是有要求的---[0,size],pos=size时其实就是尾插
	//但是pos不能>size,因为顺序表中数据是在连续地址上连续存储的,如果>size就不是连续存储!
	assert(pos >= 0 && pos <= psl->size);
	//插入所以需要检查空间容量
	SeqListCheckMemory(psl);
	int end = psl->size - 1;
	while (end >= pos)
	{
		psl->a[end + 1] = psl->a[end];
		end--;
	}
	psl->a[pos] = x;
	psl->size++;
}

任意位置插入的时间复杂度为O(N)。

10.任意位置删除SeqListErase

void SeqListErase(SL* psl, int pos);

删除在pos下标处的数据,断言判断+pos数值判断+删除操作,删除操作而言,pos就不能够与size相等了,因为size下标处本身就没有数据。在删除操作中,依旧是老一套的挪位覆盖,将pos下标处之后的所有数据,向前挪动一位(优先挪动前面的数据以避免数据丢失)。

//允许的任意位置处的删除
void SeqListErase(SL* psl, int pos)
{
	assert(psl);
	assert(pos >= 0 && pos < psl->size);//这里删除不能删除没有数据处的数据,与Insert有所不同
	//int begin = pos;
	//while (begin < psl->size - 1)
	//{
	//	psl->a[begin + 1] = psl->a[begin];
	//	begin++;
	//}
	//等效于下面的方式
	int begin = pos+1;
	while (begin < psl->size)
	{
		psl->a[begin-1] = psl->a[begin];
		begin++;
	}
	psl->size--;
}

那么对于删除操作所创建的begin下标而言,为了不越界访问,我们需要小心谨慎地设置循环条件,就如同上面讲到的头删的操作一样。

任意位置删除的时间复杂度为O(N) 。

11.数据查找SeqListSearchData

int SeqListSearchData(SL* psl, SLDataType x);

函数返回查找数据所在下标。

//数据搜索
int SeqListSearchData(SL* psl, SLDataType x)
{
	assert(psl);
	int i = 0;
	for (; i < psl->size; i++)
	{
		if (psl->a[i] == x)
			return i;//找到返回下标
	}
	return -1;//未找到
}

Extra---有关数组的越界与数组的下标

数组越界读取一般不会报错,数组越界写入数据可能报错。

1.数组越界读取数据

如上图并没有显示错误。

2.数组越界写入数据

但是如果越界写入的位置距离数组空间很远,还是可能不报错的。

这是因为,编译器检查是否越界是一种抽查行为,它将数组空间以后的几位拿出来标记然后当做检查位,如果这些位置上的数据遭到了更改,那么编译器就会认为我们对于数组越界访问了。

读取不报错是因为读取的时候并没有对于越界空间中的数据进行任何值的修改,编译器并没有检测出越界。

写入可能报错因为我们可能对于抽查位上的数据进行越界写入,从而编译器发现我们的越界行为,因此报错。

3.数组下标为什么从0开始

由于数组的本质是指针,存在着arr[ i ]= * ( parr + i )。数组的下标从0开始实际上是数组与指针之间的一种逻辑自洽。

顺序表的优缺点

顺序表在进行尾插和尾删操作时,效率不错,对单个数据的操作的时间复杂度为O(1)。

但是顺序表进行中间位置的插入删除以及头部的插入删除时,由于需要进行数据的挪动移位,对于单个数据的这些操作的时间复杂度都为O(N),因此效率低下。

顺序表的另一个缺点是,顺序表的扩容依旧是存在会空间浪费的情况!

我们按照某种规模进行扩容,如果规模过大--->会导致部分空间的浪费!如果规模过小--->会导致多次扩容,而每次扩容会伴随一定的消耗!

因此对于顺序表的扩容而言,我们很难去抓住这个扩容的规模,基本上都会导致扩容的消耗与空间的浪费。

但是对于链表(LinkedList)而言,链表的每一块空间都是单独开辟的,使用指针进行连接,因此我们需要多少个数据就开辟多少个空间,需要插入一个数据就再单独开辟一块空间,是没有空间的浪费的。

;