一、顺序表
1.概念
顺序表保存数据的方法通常就是以数组的形式保存,这意味着数据的存储是连续的,不仅仅是逻辑上连续,在空间上也是连续的。优点是查找数据的时间复杂度是O(1),通常只需要一个下标就查到了,而缺点就是当在顺序表中间插入数据和删除数据时,由于其数据的连续存储,会使得进行这些操作时后面的数据都需要进行移动,最坏情况下时间复杂度就是O(N)。
使用动态内存开辟的顺序表在添加数据时,会对顺序表是否还有空间进行检查,若空间不足会进行扩容。这里就有两个缺点,
第一个缺点是扩容会对性能有一定的消耗,
realloc调整内存空间的两种情况:
1.当原有空间之后有足够大的空间时,则直接在ptr后面追加空间,然后返回ptr。
2.如果原有空间之后没有足够大的空间时。则找一块新的空间。并把原来内存的数据拷贝至新空间中,自动释放旧空间并返回新空间地址。
如果只是第一种情况并不会消耗多少性能,但第二种消耗的性能就会大大增加,所以这也是顺序表的一个缺点。
第二个缺点是可能会对空间造成一定的浪费,顺序表的扩容方式一般是双倍扩容,假如原来有100个字节空间,双倍扩容后就有200个字节空间来存储数据,这种扩容方式很容易就造成空间的浪费。
2.顺序表的实现
(1)顺序表的创建
typedef int SLDataType;
typedef struct SeqList
{
SLDataType* a;
int size;
int capacity;
}SL;
这里选择动态顺序表,使用动态开辟的空间存储顺序表的数据。
a表示该顺序表的起始地址,size表示实时存储数据的个数,capacity表示顺序表能容纳数据的个数
(2)顺序表的初始化
//初始化顺序表
void SLInit(SL* ps1)
{
assert(ps1);
ps1->a = NULL;
ps1->size = 0;
ps1->capacity = 0;
}
顺序表使用之前的初始化
(3)顺序表空间的检查
//检查空间是否足够
void SLCheckCapacity(SL* ps1)
{
assert(ps1);
if (ps1->size == ps1->capacity)
{
int NewCapacity = ps1->capacity == 0 ? 4 : 2 * ps1->capacity;
SLDataType* tmp = (SL*)realloc(ps1->a, sizeof(SL) * NewCapacity);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
ps1->a = tmp;
ps1->capacity = NewCapacity;
}
}
对空间的检查将被应用于所有插入的场景,头插,尾插,或在指定地址插入都需要进行空间的检查,防止出现空间不足的情况
创建tmp的原因就是防止扩容失败,如果直接将realloc返回的地址直接赋给ps1->a,可能就会出现扩容失败而导致ps1->a直接变为空而导致直接失去以前的数据且无法对原来空间进行释放的问题
(4)头尾的插入与删除
//尾插
void SLPushBack(SL* ps1, SLDataType x)
{
assert(ps1);
SLCheckCapacity(ps1);
ps1->a[ps1->size] = x;
ps1->size++;
}
//头插
void SLPushFront(SL* ps1, SLDataType x)
{
assert(ps1);
SLCheckCapacity(ps1);
int end = ps1->size;
while (end > 0)
{
ps1->a[end] = ps1->a[end-1];
end--;
}
ps1->a[0] = x;
ps1->size++;
}
//尾删
void SLDelBack(SL* ps1)
{
assert(ps1);
SLCheckCapacity(ps1);
温柔的检查
//if (ps1->size == 0)
//{
// return;
//}
//暴力的检查
assert(ps1->size > 0);
ps1->size--;
}
//头删
void SLDelFront(SL* ps1)
{
assert(ps1);
SLCheckCapacity(ps1);
温柔的检查
//if (ps1->size == 0)
//{
// return;
//}
//暴力的检查
assert(ps1->size > 0);
for (int i = 0; i < ps1->size-1; i++)
{
ps1->a[i] = ps1->a[i + 1];
}
ps1->size--;
}
删除时需要考虑顺序表是否为空的问题
(5)任意位置的插入和删除
//任意下标的插入
void SLInsert(SL* ps1, int pos, SLDataType x)
{
assert(ps1);
assert(pos > 0 && pos <= ps1->size);
SLCheckCapacity(ps1);
int end = ps1->size - 1;
while (end >= pos)
{
ps1->a[end + 1] = ps1->a[end];
end--;
}
ps1->a[pos] = x;
ps1->size++;
}
//任意下标的删除
void SLErase(SL* ps1, int pos)
{
assert(ps1);
assert(pos > 0 && pos < ps1->size);
while (pos < ps1->size - 1)
{
ps1->a[pos] = ps1->a[pos + 1];
pos++;
}
ps1->size--;
}
(6)数据下标的寻找
//找寻下标
int SLSearch(SL* ps1, SLDataType x)
{
assert(ps1);
for (int i = 0; i < ps1->size; i++)
{
if (ps1->a[i] == x)
{
return i;
}
}
return -1;
}
顺序表中数据下标的寻找时间复杂度只有O(1),这也是顺序表的一个优势
(7)销毁顺序表
//销毁顺序表
void SLDestroy(SL* ps1)
{
assert(ps1);
if (ps1->a != NULL)
{
free(ps1->a);
SLInit(ps1);
}
}
二、链表
1.概念
链表的概念与顺序表有所不同,链表在内存中的存储不是连续的,链表只是在逻辑上可以被看作连续,因为每一个数据都是通过上一个数据或者下一个数据找到的,链表不需要为所有数据整体开辟空间,而是每添加一个数据元素时都为这一个元素单独开辟一个空间,听起来可能很麻烦,但它解决了顺序表的很多缺点,不需要扩容,且在链表中的插入和删除的时间复杂度只是O(1)。
总体来说,顺序表与链表各有优点,不同的场景可能要用到的线性表种类不同,那种更符合场景就用哪个。
2.链表的实现
链表的实现这里以无头单向链表为例,无头单向链表相比与其他类型的链表思维量更大,要考虑多种情况。
(1)链表的创建
typedef int SLNDataType;
//Single List单链表
typedef struct SListNode //单链表结点
{
SLNDataType val;
struct SListNode* next;//下一个结点地址
}SLNode;
(2)链表的插入和删除
//创建新结点
SLNode* CreateNode(SLNDataType x)
{
SLNode* newnode = (SLNode*)malloc(sizeof(SLNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);//直接终止总程序
}
newnode->val = x;
newnode->next = NULL;
return newnode;
}
//尾插
void SLTPushBack(SLNode** pphead, SLNDataType x)
{
assert(pphead);
SLNode* newnode = CreateNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
//找尾
SLNode* tail = *pphead;
while (tail->next != NULL)//如果这里写成了tail !=NULL 会导致链表链接失败,tail->next 不会改变,且malloc之后,因为tail是一个局部变量,出了作用域tail销毁,会出现内存泄露的问题
{
tail = tail->next;
}
tail->next = newnode;
}
}
//头插
void SLTPushFront(SLNode** pphead, SLNDataType x)
{
assert(pphead);
SLNode* newnode = CreateNode(x);
if (*pphead == NULL)
{
*pphead = newnode;
}
else
{
SLNode* tmp = *pphead;
*pphead = newnode;
(*pphead)->next = tmp;
}
}
//尾删
void SLTDelBack(SLNode** pphead)
{
assert(pphead);
assert(*pphead);
if ((*pphead)->next == NULL)
{
free(*pphead);
*pphead = NULL;
}
else
{
//找尾
SLNode* tail = *pphead;
while (tail->next->next != NULL)
{
tail = tail->next;
}
free(tail->next);
tail->next = NULL;
}
}
//头删
void SLTDelFront(SLNode** pphead)
{
assert(pphead);
assert(*pphead);
SLNode* tmp = (*pphead)->next;
free(*pphead);
*pphead = tmp;
}
无头单向链表的插入与删除一般需要特别注意链表没有成员的情况。
通过这段代码也可以注意到无头单向链表的尾插与尾删都是很麻烦的,这也是无头单向链表的一个缺点,不过若是双向循环链表就可以解决这些问题,因为可以通过第一个结点或头结点来找出最后一个结点的地址从而完成尾插或尾删
(3)寻找结点pos及在pos前后进行插入删除操作
//找结点
SLNode* SLTFind(SLNode** pphead, SLNDataType x)
{
assert(pphead);
assert(*pphead);
SLNode* tmp = *pphead;
while (tmp->val != x)
{
tmp = tmp->next;
if (tmp->next == NULL)
{
return NULL;
}
}
return tmp;
}
//在pos前插入一个结点(通常配合SLTFind使用)
void SLTInsert(SLNode** pphead, SLNode* pos, SLNDataType x)
{
assert(pphead);
assert((!pos && !(*pphead)) || (pos && *pphead));
if (pos == *pphead)
{
SLTPushFront(pphead, x);
return;
}
SLNode* newnode = CreateNode(x);
SLNode* left = *pphead;
while (pos != left->next)
{
left = left->next;
}
newnode->next = pos;
left->next = newnode;
}
//删除pos结点(通常配合SLTFind使用)
void SLTErase(SLNode** pphead, SLNode* pos)
{
assert(pphead);
assert(*pphead);
assert(pos);
SLNode* left = *pphead;
if (left == pos)
{
SLTDelFront(pphead);
return;
}
while (left->next != pos)
{
left = left->next;
}
left->next = pos->next;
free(pos);
}
//在pos之后插入
void SLTInsertAfter(SLNode* pos, SLNDataType x)
{
assert(pos);
SLNode* newnode = CreateNode(x);
newnode->next = pos->next;
pos->next = newnode;
}
//删除pos之后的结点
void SLTEraseAfter(SLNode* pos)
{
assert(pos);
assert(pos->next);
SLNode* tmp = pos->next;
pos->next = pos->next->next;
free(tmp);
tmp = NULL;
}
//只在pos结点之后插入或删除的原因就是只传入pos结点地址的情况下,由于单链表的单向性无法得到前一个结点的地址为多少
//虽然可以用特殊的方法在逻辑上在pos之前插入或删除,但相比于向后插入还是要麻烦一点
需特别注意这几个函数的传参,也需了解为什么这样传参
(4)销毁单链表
//销毁单链表
void SLTDeatroy(SLNode** pphead)
{
assert(pphead);
assert(*pphead);
SLNode* cur = (*pphead)->next;
while (cur != NULL)
{
free(*pphead);
*pphead = cur;
}
*pphead = NULL;
}
三、栈
1.概念
首先要明确栈的特点,这个栈是数据结构里的栈,还有一种栈是语言/操作系统内存区域中的栈,清楚区别即可。
栈是一种特殊的线性表,其只允许在固定的一端进行插入和删除操作,进行插入和删除的一端被称为栈顶,另一端称为栈底,栈中的数据元素遵守后进先出的原则。栈的两种操作都有名称,插入叫压栈,删除叫出栈
2.栈的实现
栈的种类有数组栈和链式栈
对于双向链表,栈顶可以是尾,也可以是头
对于单链表,栈顶只能是头。但对于栈的实现,最佳方案还是使用数组实现
这里就用数组实现栈
(1)栈的创建
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top; //指向栈顶元素
int capacity;
}ST;
(2)栈的初始化
d STInit(ST* pst)
{
assert(pst);
pst->a = NULL;
pst->capacity = 0;
//栈顶的写一个元素
pst->top = 0;
}
这一块与顺序表的创建类似。
(3)压栈与出栈
//栈顶插入删除
void STPush(ST* pst, STDataType x)
{
assert(pst);
if (pst->top == pst->capacity)
{
int newcapacity = pst->capacity == 0 ? 4 : pst->capacity * 2;
STDataType* tmp = (STDataType*)realloc(pst->a, sizeof(ST) * newcapacity);
if (tmp == NULL)
{
perror("realloc fail");
return;
}
pst->a = tmp;
pst->capacity = newcapacity;
}
pst->a[pst->top] = x;
pst->top++;
}
void STPop(ST* pst)
{
assert(pst);
assert(pst->top > 0);
pst->top--;
}
(4)获取栈顶元素
//获取栈顶元素
STDataType StackTop(ST* pst)
{
assert(pst);
return pst->a[pst->top - 1];
}
(5)获取栈中有效元素个数
// 获取栈中有效元素个数
int STSize(ST* pst)
{
assert(pst);
return pst->top;
}
(6)检测栈是否为空
// 检测栈是否为空,如果为空返回非零结果,如果不为空返回0
bool STEmpty(ST* pst)
{
assert(pst);
return pst->top == 0;
}
(7)销毁栈
// 销毁栈
void STDestroy(ST* pst)
{
assert(pst);
free(pst->a);
STInit(pst);
}
四、队列
1.概念
队列:先进先出,即入队是怎样的,出队就是怎样的
想要实现队列可以很多种方法,数组、单链表、双链表都可以
但最节省空间的还是单链表,只是需要再创建一个结构体来存储头结点和尾结点的地址
2.队列的实现
这里使用单链表实现队列
(1)队列的创建
typedef int QDataType;
typedef struct QueueNode
{
QDataType val;
struct QueueNode* next;
}QNode;
typedef struct Queue
{
QNode* qhead;
QNode* tail;
int size;
}Queue;
注意队列的特殊性,想要用单链表实现队列需要再创建一个结构体来存储头结点和尾结点的地址,因为队列先进先出的特点,每次想要删除队列中的元素都需要对队列进行尾删,而尾删对于单链表会比较麻烦,所以保存头尾结点的地址就有必要了
(2)队列的插入和删除
//队列的插入和删除(队列只支持尾插和头删)
void QPush(Queue* qt, QDataType x)
{
assert(qt);
QNode* newnode = (QNode*)malloc(sizeof(QNode));
if (newnode == NULL)
{
perror("malloc fail");
return;
}
newnode->val = x;
newnode->next = NULL;
if (qt->qhead == NULL)
{
qt->qhead = qt->tail = newnode;
}
else
{
qt->tail->next = newnode;
qt->tail = newnode;
}
qt->size++;
}
void QPop(Queue* qt)
{
assert(qt);
assert(qt->qhead);
QNode* tmp = qt->qhead;
qt->qhead = qt->qhead->next;
free(tmp);
tmp = NULL;
if (qt->qhead == NULL)
{
qt->tail = NULL;
}
qt->size--;
}
队列的插入和删除说白了就是头插和尾删,想要实现这两个功能也需要注意队列为空的情况
(3)查找队列头尾
//查找队列头尾
QDataType QFront(Queue* qt)
{
assert(qt);
assert(qt->qhead);
return qt->qhead->val;
}
QDataType QBack(Queue* qt)
{
assert(qt);
assert(qt->qhead);
return qt->tail->val;
}
(4)获取队列中有效元素个数
//队列成员个数
int QSize(Queue* qt)
{
assert(qt);
return qt->size;
}
(5)检测队列是否为空
//检查队列是否为空
bool QEmpty(Queue* qt)
{
assert(qt);
return qt->qhead == NULL;
}
(6)销毁队列
// 销毁队列
void QDestroy(Queue* qt)
{
assert(qt);
QNode* cur = qt->qhead;
while (cur)
{
QNode* next = cur->next;
free(cur);
cur = next;
}
qt->qhead = NULL;
qt->tail = NULL;
}
五、线性表总结
总的来说,线性表是一种常见的数据结构。线性表就是由n(n>=0)个相同类型的数据元素组成的有序序列。
线性表的特点就是:
- 集合中的数据元素具有线性关系,即元素之间有前后顺序关系。
- 除了第一个数据元素外,每一个数据元素只有一个直接前驱。
- 除了最后一个数据元素外,每一个数据元素只有一个直接后继。
满足这些特点的集合就可以被称为线性表,而在这之后学习的树,图等数据结构都有其各自的特点,这些数据结构都有其适合的应用场景,在工作中都会起到不可或缺的作用,所以当前的学习需要对这些数据结构的实现掌握牢固。