本篇博客详细总结数据结构中的第一种结构:线性表之不定长顺序表,主要从以下几个方面梳理:线性表的定义、顺序表的概念、顺序表的基本操作:增删改查的基本思想及代码实现、基本操作的算法效率分析(时间复杂度和空间复杂度)、顺序表的优缺点适用场合,以及常见的面试题。
目录
一、线性表的定义
线性表(linear list)是n个具有相同特性的数据元素的有限序列。 线性表是一种在实际中广泛使用的数据结构,常见的线性表:顺序表、链表、栈、队列、字符串... 线性表在逻辑上是线性结构,也就说是连续的一条直线。但是在物理结构上并不一定是连续的, 线性表在物理上存储时,通常以数组和链式结构的形式存储。因此,线性表按照存储结构又分为:顺序表和链表。
二、顺序表之不定长顺序表(动态顺序表)
2.1 顺序表的基本概念及结构
顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,只要确定好了存储线性表的起始位置,线性表中任一数据元素都可以随机存取,所以线性表的顺序存储结构是一种随机存取的储存结构,因为高级语言中的数组类型也是有随机存取的特性,所以通常我们都使用数组来描述数据结构中的顺序储存结构,即一般情况下采用数组存储。在数组上完成数据的增删查改。因此可以把顺序表就理解成数组。
顺序表是线性数据结构。顺序表和数组的区别:顺序表的底层结构是数组,对数组的封装,实现了常用的增删改查等接口。
2.2 顺序表的分类
顺序表一般可以分为:
- 静态顺序表(也称为定长顺序表):使用定长数组存储元素。:【缺陷:空间给少了不够用,给多了造成空间浪费】
- 动态顺序表(也称为不定长顺序表):使用动态开辟的数组存储。
静态顺序表只适用于确定知道需要存多少数据的场景。静态顺序表的定长数组导致N定大了,空间开多了浪费,开少了不够用。所以现实中基本都是使用动态顺序表,根据需要动态的分配空间大小,所以下面我们实现动态顺序表。
从下图可以看出:动态顺序表本质上是一个数组,数组所占的内存空间由堆区进行申请,在栈区定义一个顺序表类型,其中包含:指向动态数组的指针,记录动态数组有效元素个数的变量size,记录数组总的容量大小的capacity变量。数组的访问方式本质上就是通过指针访问数组即p[i],只不过这里的p换成了:plist->data[i],这里的plist->data就是指向这个动态数组的指针!
2.2 接口函数实现
顺序表的主要操作为增删改查,这里详细展示各个操作的思想和代码实现以及算法效率分析,主要接口函数如下所示,注意传入结构体指针,在函数内部便可以修改和访问变量及数据!在函数内部通过结构体指针访问结构体成员,使用指向符访问即可!
//引入头文件
#include "SeqList.h"
#include<assert.h>
#include<stdlib.h>
//接口函数的设计
//1.初始化顺序表
void InitSeqList(SeqList* plist);
//2.销毁顺序表
void DestorySeqList(SeqList* plist);
//3.清空顺序表
void ClearSeqList(SeqList* plist);
//4.打印顺序表
void ShowSeqList(SeqList* plist);
//5.顺序表判满
Status IsFullSeqList(SeqList* plist);
//6.顺序表扩容
Status GrowSeqList(SeqList* plist); // bool GrowSeqList(SeqList* plist);
//7.顺序表判空
Status IsEmptySeqList(SeqList* plist);
//8.头插操作
Status InsertHead(SeqList* plist, ElemType val);
//9.尾插操作
Status InsertTail(SeqList* plist, ElemType val);
//10.指定位置插入 posindex 插入的下标
Status InsertPosVal(SeqList* plist, int posindex, ElemType val);
//11.头删
Status DeleteHead(SeqList* plist);
//12.尾删
Status DeleteTail(SeqList* plist);
//13.指定位置删除
Status DeletePos(SeqList* plist, int posindex);
//14.删除指定元素(全部删除)
Status DeleteVal(SeqList* plist, ElemType val);
//15.查找某个元素,返回下标
int SearchVal(SeqList* plist, ElemType val);
//16.冒泡排序
void BubbleSort(SeqList* plist);
//17.二分查找(前提:数组完全有序) 非递归和递归实现
int BinarySearch0(SeqList* plist, ElemType val);
int BinarySearch1(SeqList* plist, ElemType val);
//18.将两个有序顺序表合并为一个有序顺序表
void MergeList(const SeqList* pla, const SeqList* plb, SeqList* plc);
2.2.1 宏定义和类型重命名
为方便代码复用和实际开发效率,通常会引入一些宏定义和类型重命名,如下代码所示:
#define TRUE 1
#define FALSE 0
#define OVERFLOW -1
#define LIST_INIT_CAPACITY 10 //动态顺序表的初始容量大小
#define LIST_GROW 2 //动态顺序表的扩容倍数
typedef int Status; //状态 TRUE FALSE 给c语言加入bool类型的方式
typedef int ElemType; //数组元素类型,
2.2.2 顺序表的设计
顺序表本质上是动态数组的一种封装,封装成结构体,内部主要有3个成员构成,当申请的内存空间不够用时,考虑要扩容,因此,需要加入变量size和capacity,判断二者相等,则需要进行扩容,同时size这个变量又在编程时,具有应用意义:即size既可以代表有效元素个数,又可以代表数组最后一个元素的下一个元素的下标。
//顺序表类型的设计
typedef struct SeqList
{
ElemType* data; //指向动态数组的指针,指针的类型取决于指针所指向的数据的类型
size_t size; //顺序表有效数据个数
size_t capacity; //顺序表的总容量
}SeqList;
2.2.3 顺序表的初始化
顺序表在创建以后必须要进行初始化,否则内部为随机值,无法使用。顺序表的初始化主要是对结构体成员赋初值。
//1.初始化顺序表
void InitSeqList(SeqList* plist) //参数设计为结构体指针,结构体指针访问成员用指向符
{
//第0步:对传入的指针进行参数检测
assert(plist != NULL);//或者assert(plist)
if(plist == NULL)
{
return;
}
//第二步:让指针data通过malloc指向一块堆空间
plist->data = (ElemType*)malloc(sizeof(ElemType) * LIST_INIT_CAPACITY);
assert(plist->data != NULL);
//第三步:给size有效数组元素个数赋值
plist->size = 0;
//第四步:给capacity当前总的容量大小赋值
plist->capacity = LIST_INIT_CAPACITY;
}
2.2.4 销毁顺序表
因为顺序表是动态内存分配的,是在堆区利用
realloc函数
分配的空间,不销毁会有内存泄漏的风险。因此,使用完毕必须要释放,同时释放完后要将指针置空,防止出现野指针!
//2.销毁顺序表 释放堆内存
void DestorySeqList(SeqList* plist)
{
assert(plist != NULL);
free(plist->data);
plist->data = NULL; //防止出现野指针
}
2.2.5 清空顺序表
顺序表的有效数据元素个数,是通过size进行记录的,即使数组容量有10个,只要将size清0,就代表数组为空。 虽然这十个单元格子是存在内存空间的,但是编程者认为没有有效的数据,这便是清空顺序表!
//3.清空顺序表 清除有效数据
void ClearSeqList(SeqList* plist)
{
assert(plist != NULL);
plist->size = 0;
}
2.2.5 打印顺序表
打印数组元素,其实和之前用指针方式访问数组一样,for循环遍历通过下标即可,
plist->data[i] 。
//4.打印顺序表
void ShowSeqList(SeqList* plist)
{
assert(plist != NULL);
for (int i = 0; i < plist->size; i++)
{
printf("%d ", plist->data[i]);
}
printf("\n");
}
当顺序表的容量已满,此时在插入数据元素,便存不下,此时便需要进行扩容,因此插入操作之前需要对顺序表进行判满操作,如何判满?比较plist->size和plist->capacity是否相等即可。
当顺序表为空时,此时进行删除操作,便会出现错误,因此删除操作之前必须要进行判空操作,如何判空?比较plist->size是否等于0即可。
总结:插入元素之前必须进行判满操作,删除元素之前必须要进行判空操作!!!
2.2.6 顺序表判满
判满操作很简单,只需要比较size和capacity是否相等即可,若相等,表明顺序表已满,此时要进行扩容操作!
//5.顺序表判满
Status IsFullSeqList(SeqList* plist)
{
assert(plist != NULL);
return plist->size == plist->capacity;
}
2.2.7 顺序表扩容
当我们插入数据,若空间不够,将要扩容操作:使用
realloc
函数。
//6.顺序表扩容
Status GrowSeqList(SeqList* plist) // bool GrowSeqList(SeqList* plist);
{
assert(plist != NULL);
if (plist == NULL)
return FALSE;
int newcapacity = plist->capacity * LIST_GROW; //新的容量大小
//注意借助临时变量防止出现内存泄漏!
ElemType* tmp = (ElemType*)realloc(plist->data, newcapacity * sizeof(ElemType));
if (tmp == NULL)
return OVERFLOW;
plist->data = tmp; //最后在赋值给最初的指针,使得统一使用同一个指针
//更新顺序表的容量大小
plist->capacity = newcapacity;
return TRUE;
}
2.2.8 顺序表判空
判空操作很简单,只需要比较size是否等于0即可,若相等,则无法进行删除操作!!
//7.顺序表判空
Status IsEmptySeqList(SeqList* plist)
{
assert(plist != NULL);
return plist->size == 0;
}
2.2.9 求当前顺序表有效数据元素个数
这个操作很简单,只需要返回结构体的size成员即可!
返回表中数据元素的个数
int GetSize(SeqList* plist)
{
return plist->size;
}
2.2.10 求当前顺序表的容量大小
这个操作也很简单,只需要返回结构体的capacity成员即可!
//4.返回表的容量
int GetCapacity(SeqList* plist)
{
return plist->capacity;
}
2.2.11 头插
注意:插入元素操作,必须是从最后一个元素开始挪动,不能从前往后移动,否则发生数据覆盖!
头插数据的主要思路如下:
第0步:指针参数断言;
第1步:进行判满操作,同时如果顺序表已满,则要进行扩容,同时要保证扩容成功;
第2步:开始挪动数据元素:挪动现有元素,从最后一个元素开始,依次向后挪动,直到第一个元素,下标为0;
第3步:将需要插入的数据val放进空出来的头部格子;
第4步:更新顺序表的有效元素个数变量,plist->size++。
//8.头插操作 时间复杂度:O(n)
Status InsertHead(SeqList* plist, ElemType val)
{
第0步:参数检测
assert(plist != NULL);
第1步:判满操作
if (IsFullSeqList(plist) && GrowSeqList(plist) != TRUE)
{
return OVERFLOW;
}
第2步:移动数据
for (int i = plist->size-1; i >=0; i--)
{
plist->data[i + 1] = plist->data[i];
}
第3步:将下标为0的格子放入需要插入的数据
plist->data[0] = val;
第4步:更新有效数据元素个数变量
plist->size++;
return TRUE;
}
2.2.12 尾插
尾插数据的主要思路如下:
第0步:指针参数断言;
第1步:进行判满操作,同时如果顺序表已满,则要进行扩容,同时要保证扩容成功;
第2步:尾插不需要移动数据,只需要在最后一个元素的下一个格子放入一个元素
第3步:更新顺序表的有效元素个数变量,plist->size++。
//9.尾插操作 时间复杂度:O(1)
Status InsertTail(SeqList* plist, ElemType val)
{
第0步:指针参数断言;
assert(plist != NULL);
第1步:进行判满操作
if (IsFullSeqList(plist) && GrowSeqList(plist) != TRUE)
{
return OVERFLOW;
}
第2步:尾插不需要移动数据,直接放入数据
plist->data[plist->size] = val;
第3步:更新顺序表的有效元素个数变量
plist->size++;
return TRUE;
}
2.2.13 指定位置插入
指定位置插入数据(按位置插入)的主要思路如下:
第0步:指针参数和插入的位置断言;
第1步:进行判满操作,同时如果顺序表已满,则要进行扩容,同时要保证扩容成功;
第2步:挪动现有元素的一部分,插入位置的下一个元素开始,统一从最后一个元素开始,依次向后挪动一个格子;
第3步:将数据放入空出的格子内;
第4步:更新顺序表的有效元素个数变量,plist->size++。
//10.指定位置插入 posindex 插入的下标 时间复杂度:O(n)
Status InsertPosVal(SeqList* plist, int posindex, ElemType val)
{
第0步:指针参数和插入的位置断言;
assert(plist != NULL && posindex>=0 && pos<=plist->size);
第1步:进行判满操作
if (IsFullSeqList(plist) && GrowSeqList(plist) != TRUE)
{
return OVERFLOW;
}
第2步:挪动现有元素的一部分
for (int i = plist->size - 1; i >= posindex; i--)
{
plist->data[i + 1] = plist->data[i];
}
第3步:将数据放入空出的格子内;
plist->data[posindex] = val;
第4步:更新顺序表的有效元素个数变量
plist->size++;
return TRUE;
}
2.2.14 头删
注意:删除元素操作,必须是从第一个元素开始挪动,不能从后往前移动,否则发生数据覆盖!
头部删除数据的主要思路如下:
第0步:指针参数断言;
第1步:进行判空操作,同时如果顺序表为空,无法删除,结束程序;
第2步:从第2个元素开始,一直到最后一个元素,向前移动覆盖数据;
第3步:更新顺序表的有效元素个数变量,plist->size--。
//11.头删 时间复杂度:O(n)
Status DeleteHead(SeqList* plist)
{
第0步:指针参数断言;
assert(plist != NULL);
第1步:进行判空操作,同时如果顺序表为空,无法删除,结束程序;
if (IsEmptySeqList(plist))
{
return FALSE;
}
第2步:从第2个元素开始,一直到最后一个元素,向前移动覆盖数据;
for (int i = 1; i <= plist->size-1; i++)
{
plist->data[i - 1] = plist->data[i];
}
第3步:更新顺序表的有效元素个数变量,plist->size--。
plist->size--;
return TRUE;
}
2.2.15 尾删
尾删数据的主要思路如下:
第0步:指针参数断言;
第1步:进行判空操作,同时如果顺序表为空,无法删除,结束程序;
第2步:尾删不需要移动数据;
第3步:更新顺序表的有效元素个数变量,plist->size--。
只要把最后一个元素数量减掉,最后一个数据去掉,
size--
,并不影响查找等功能。
//12.尾删 时间复杂度:O(1)
Status DeleteTail(SeqList* plist)
{
第0步:指针参数断言;
assert(plist != NULL);
第1步:进行判空操作,同时如果顺序表为空,无法删除,结束程序;
if (IsEmptySeqList(plist))
{
return FALSE;
}
第2步:尾删不需要移动数据;
plist->size--; //存在局限性
return TRUE;
}
2.2.16 指定位置删除
指定位置删除数据(按位置删除)的主要思路如下:
第0步:指针参数和删除的位置断言;
第1步:进行判空操作,同时如果顺序表为空,无法删除,结束程序;
第2步:挪动现有元素的一部分,删除位置的下一个元素开始,统一从这个元素开始,依次向前挪动一个格子;(依次向前覆盖,覆盖时左边先动)
第3步:更新顺序表的有效元素个数变量,plist->size--。
//13.指定位置删除 时间复杂度:O(n)
Status DeletePos(SeqList* plist, int posindex)
{
第0步:指针参数和删除的位置断言;
assert(plist != NULL && posindex>=0 && pos<=plist->size);
第1步:进行判空操作,同时如果顺序表为空,无法删除,结束程序;
if(IsEmptySeqList(plist)==FALSE)
{
return;
}
第2步:挪动现有元素的一部分,删除位置的下一个元素开始,统一从这个元素开始,依次向前挪动一个
格子;(依次向前覆盖,覆盖时左边先动)
for (int i = posindex+1; i <= plist->size-1; i++)
{
plist->data[i-1] = plist->data[i];
}
第3步:更新顺序表的有效元素个数变量,plist->size--。
plist->size--;
return TRUE;
}
2.2.17 删除指定元素
//14.删除指定元素(全部删除) 时间复杂度:O(n^2)
Status DeleteVal(SeqList* plist, ElemType val)
{
if (plist->size == 0)
{
return FALSE;
}
else
{
int flag = FALSE;
for (int i = 0; i <plist->size; )
{
if (plist->data[i] == val)
{
flag = TRUE;
for (int j = i + 1; j < plist->size; j++)
{
plist->data[j - 1] = plist->data[j];
}
plist->size--;
}
else
{
i++;
}
}
return flag;
}
}
//补充练习Leetcode:成对出现数据,只有一个数据单独出现一次,
返回这个单独出现一次的数据的下标
/*解题思路:异或运算可以巧妙地解决出现一次的问题,
异或运算是位运算,计算效率更高,它是将两个数的二进制数按位进行异或,不同为1,相同为0
0与任何数异或结果为这个数,两个相同的数异或的结果是0,
出现偶数次的数异或两次必会被抵消,并且异或满足交换律,因此它与数组中的数字的顺序无关*/
int SearchOnlyOne(SeqList* plist)
{
int res = 0; //0异或一个数得到结果为这个数
for (int i = 0; i < plist->size; i++)
{
res = res ^ plist->data[i];
}
return res;
}
2.2.18 查找某个元素
查询数据元素,返回下标,本质就是遍历数组元素,依次比较。
//15.查找某个元素,返回下标 时间复杂度:O(n)
int SearchVal(SeqList* plist, ElemType val)
{
assert(plist != NULL);
int index = -1;
for (int i = 0; i < plist->size; i++)
{
if (plist->data[i] == val)
{
index = i;
break;
}
}
return index;
}
2.2.19 冒泡排序
//16.冒泡排序 时间复杂度:O(n^2)
void BubbleSort(SeqList* plist)
{
assert(plist != NULL);
if (plist->size == 0)
return;
bool flag = false;
for (int i = 0; i < plist->size; i++)
{
for (int j = 0; j < plist ->size-i - 1; j++)
{
if (plist->data[j] > plist->data[j + 1])
{
ElemType tmp = plist->data[j];
plist->data[j] = plist->data[j + 1];
plist->data[j + 1] = tmp;
flag = true;
}
}
if (flag == false)
{
break;
}
}
}
2.2.20 二分查找
//17.二分查找(前提:数组完全有序) 非递归:时间复杂度O(logn),空间复杂度:O(1) 和递归实现:时间复杂度是O(logn),空间复杂度:O(n)
int BinarySearch0(SeqList* plist, ElemType val)
{
assert(plist != NULL);
if (plist->size == 0)
return;
int left = 0, right = plist->size - 1;
int index = -1;
while (left <= right)
{
int midindex = (left + right) / 2;
//int midindex = (left + right) >> 1;
//int midindex=(right-left)/2+left;
//int midindex=(right-left)>>1+left;
if (plist->data[midindex] = val)
{
index = midindex;
}
else if (plist->data[midindex] > val)
{
right = midindex - 1;
}
else if(plist->data[midindex] < val)
{
left = midindex + 1;
}
return index;
}
}
int SearchSection(SeqList* plist, int left, int right, ElemType val)
{
if (left > right)
{
return -1;
}
int midindex = (left + right) / 2;
if (plist->data[midindex] == val)
{
return midindex;
}
if (plist->data[midindex] > val)
{
return SearchSection(plist, left, midindex - 1, val);
}
else if (plist->data[midindex] < val)
{
return SearchSection(plist, midindex+1, right, val);
}
}
int BinarySearch1(SeqList* plist, ElemType val)
{
assert(plist != NULL);
return SearchSection(plist, 0, plist->size - 1, val);
}
2.2.21 合并有序顺序表
//18.将两个有序顺序表合并为一个有序顺序表:思想:谁小谁下来,谁++ 时间复杂度O(m+n),空间复杂度:O(n)
void MergeList(const SeqList* pla, const SeqList* plb, SeqList* plc)
{
assert(pla != NULL && plb != NULL && plc != NULL);
int i=0, j=0, z=0;
while (i < pla->size && j < plb->size)
{
if (pla->data[i] < plb->data[j])
{
//plc->data[z++] = pla->data[i++];
InsertTail(plc, pla->data[i++]);//考虑 扩容问题
}
else
{
//plc->data[z++] = plb->data[j++];
InsertTail(plc, plb->data[j++]);//考虑 扩容问题
}
}
if (i >= pla->size)
{
while (j < plb->size)
{
//plc->data[z++] = plb->data[j++];
InsertTail(plc, plb->data[j++]);//考虑 扩容问题
}
}
if (j > plb->size)
{
while(i < pla->size)
{
//plc->data[z++] = pla->data[i++];
InsertTail(plc, pla->data[i++]);//考虑 扩容问题
}
}
}
2.3 顺序表优缺点总结
顺序表
1、可动态增长的数组
2、数据在数组中存储时必须是连续的
缺点:
1、中间或者头部的插入删除很慢,需要挪动数据。时间复杂度是0(N)2、空间不够时,增容会有一定消耗和空间浪费。
优点:
1、随机访问。
2、缓存利用率比较高
2.4 顺序表的问题及思考
问题:
1. 中间/头部的插入删除,时间复杂度为O(N)
2. 增容需要申请新空间,拷贝数据,释放旧空间。会有不小的消耗。
3. 增容一般是呈2倍的增长,势必会有一定的空间浪费。例如当前容量为100,满了以后增容到 200,我们再继续插入了5个数据,后面没有数据插入了,那么就浪费了95个数据空间。
思考:如何解决以上问题呢?链表解决!!!
三、 数组相关面试题
1. 原地移除数组中所有的元素val,要求时间复杂度为O(N),空间复杂度为O(1)。
2. 删除排序数组中的重复项。
3. 合并两个有序数组。