前面我们讲解了常见的线性数据结构,今天再来学习两种特殊的线性数据结构。
1.栈
栈的概念和结构
栈是一种特殊的线性表,其只允许在固定的一段进行插入和删除元素操作。进行数据插入和删除的一端称为栈顶,另一端称为栈底。栈中的元素遵守后进先出的LIFO(last in first out )的原则。
压栈:栈的插入操作叫做进栈、压栈或者入栈,插入数据都是在栈顶
出栈:栈的删除操作叫做出栈,出数据也是在栈顶。
我们之前也讲到过内存中的栈区,内存中的栈区和我们这里数据结构的栈是完全不同的两个东西,尽管他们的名称相同,但是他们已经是不同学科的完全不同的内容了。但是他们都有同一个性质,后进先出,比如我们函数递归时在栈区创建栈帧,最先销毁的是最后调用的那一层函数栈帧。而数据结构的栈也是后进的元素先弹出,数据结构的栈是在堆区申请空间的,即可以用顺序表的结构实现,也可以用链表的结构实现。注意我们说的后进先出指的是同一时间在栈中的元素,后进来的先被弹出去,函数递归时的栈帧也是同时存在的栈帧后创建的先销毁。
栈也是线性表,线性表一般就是用数组或者链表的形式来实现,而栈的实现显而易见试用动态数组实现更好,因为栈的操作就是入栈压栈,对应顺序表的操作就是尾插和尾删,顺序表的尾插尾删时很方便的,只要让size-1就行了。而用链表实现的话,尾插尾删都是要先找尾节点和或者尾节点的前一个节点,时间复杂度都是O(N),效率没有数组高,而且前面讲到了数组的缓存命中率高,效率更高。
栈的实现
我们用顺序表的结构来实现栈的话可以这样定义
#define INIT_CAPACITY 4
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;//栈顶元素位置
int capacity;
}ST;
首先要有一个指针来接受动态开辟数组的地址,然后top只用来表示栈顶元素的位置的,因为栈的特性,所以我们后面会有一个函数来返回栈顶元素。 capacity则是动态数组的大小。
初始化
栈的初始化有两种实现方式,一种是将top初始化为0,这时候 top 就表示栈顶元素的下一个的下标,
当我们初始化top为0时,那么 压栈操作就是先把数据赋值给下标 top 的位置,然后top++。
当我们初始化为-1时,这时候更精确的表示了栈顶元素的位置,压栈操作则是要先top++,然后再插入数据。
这两种初始化方式其实都能完成栈的操作,具体的函数会有一点区别,都是思路都是差不多的。这里我们采用的版本是将top初始化为-1的版本。
//栈初始化
void STInit(ST* pst)
{
assert(pst);
pst->a=NULL;
pst->capacity = 0;
pst->top = -1;
}
销毁函数
销毁函数就只要释放掉 a 指向的空间就行了,然后将其他值重新初始化。
//销毁
void STDestroy(ST* pst)
{
assert(pst);
free(pst->a);
pst->a = NULL;
pst->top = -1;
pst->capacity = 1;
}
入栈
入栈操作就相当于尾插,我们这种实现方式由于是将top初始化为-1,top的值就是栈顶元素的下标,所以我们要先top++,然后检查是否需要扩容,最后插入数据。
//检查容量
void CheckCapacity(ST* pst)
{
assert(pst);
if(pst->top==pst->capacity)//我们已经在调用函数之前top++
{
int newcapacity = pst->capacity + INIT_CAPACITY;
STDataType* new = (STDataType*)realloc(pst->a, sizeof(STDataType) * newcapacity);
if (new == NULL)
{
perror("realloc fail");
exit(-1);
}
pst->a = new;
pst->capacity = newcapacity;
}
}
//入栈
void StackPush(ST* pst, STDataType x)
{
assert(pst);
pst->top++;
CheckCapacity(pst);
pst->a[pst->top] = x;
}
出栈
出栈操作可以参考顺序表的尾删,在删除之前我们要先判断栈是否为空,所以我们还要写一个判空的函数,直接用top是否大于等于0返回就行。
//判断是否为空
bool StackEmpty(ST* pst)
{
assert(pst);
return pst->top == -1;
}
//出栈
void STPop(ST* pst)
{
assert(pst);
assert(pst->a);
assert(!StackEmpty(pst));
pst->top--;
}
访问栈顶元素
我们实现的栈的栈顶元素就是下标为top的元素,函数先判空,非空再返回栈顶元素。
//返回栈顶元素
STDataType StackTop(ST* pst)
{
assert(pst);
assert(pst->a);
assert(!StackEmpty(pst));
return pst->a[pst->top];
}
求栈的数据个数
栈的数据个数就是栈顶元素下标 top+1;
//求栈元素个数
size_t StackSize(ST* pst)
{
assert(pst);
if (StackEmpty(pst))
{
return 0;
}
else
{
return pst->top + 1;
}
}
栈这种数据结构一般用在一些特殊场景中,比如下面的这一个oj题
看到这个题的时候我们就应该能想到用栈的方式来处理。首先我们要知道括号是如何匹配的,每一个右括号要与相应的左括号匹配,他们之间不能有未匹配的括号。我们这时候就能用栈来接收s中的左括号,当遇到右括号的时候就取出栈顶元素来判断他们是否匹配,不匹配就返回false,匹配就弹出栈顶元素,然后接着遍历。
这种是括号不匹配的问题,然后还有两种特殊情况,一是右括号数量比左括号多,所以每一次比较之前要先判断栈是否为空,如果为空就说明不匹配了,直接返回false,第二种情况就是 当字符串遍历完了之后栈不为空,这就说明左括号数量比右括号多,还是不匹配,返回false,只有满足所有条件才返回true。
我们可以把刚刚实现的栈的代码复制粘贴然后调用接口来实现这个函数,注意复制粘贴之后要先将SYDataType 改为char类型
bool isValid(char* s) {
ST st;
STInit(&st);
while(*s)
{
if(*s=='('
||*s=='{'
||*s=='[')
{
StackPush(&st,*s);//左括号就入栈
}
else
{
if(StackEmpty(&st))//匹配之前先判空
{
return false;
}
else
{
char ch=StackTop(&st);
StackPop(&st);
if(*s==')')
{
if(ch!='(')
return false;
}
if(*s==']')
{
if(ch!='[')
return false;
}
if(*s=='}')
{
if(ch!='{')
return false;
}
}
}
s++;//迭代
}
if(StackEmpty(&st))//遍历完栈为空就匹配
{
return true;
}
return false;
}
2.队列
队列的概念和结构
队列是一种只允许在一端进行数据插入操作,在另一端进行数据删除的特殊线性表,队列具有先进先出FIFO(first in first out)的特点。
入队列:进行插入操作的一端称为队尾
出队列:进行删除操作的一端称为队头
出队列其实就相当于之前我们接触的头删操作,而入队列则是尾插,我们应该选择数组还是链表来实现队列呢?首先数组肯定是不行的,因为数组的头插是要挪动数据的,效率很低。这时候我们就能想到我们之前写的带头双向循环链表了,头删和尾插都很方便,在选择这个复杂链表结构之前,我们要先思考一下单链表能不能实现队列?如果单链表就能够实现了,那我们就没必要选择双向链表了,毕竟每一个节点能节省一点空间。用单链表实现队列的难点只在于如何尾插时间复杂度低,因为单链表的结构限制,如果只有头节点我们要尾插的话就避免不了遍历,遍历的话时间复杂度就高了,其实队列只有尾插,而没有尾删,那我们是不是可能将单链表的头节点和尾节点都传给队列呢,这样的话头删只要把头节点释放并把下一个节点作为新的头节点就行了,而尾插就只要在尾节点后面插入一个节点就行了。
有了front和back指针我们就能在O(1)时间复杂度实现头删和尾插。
队列的实现
队列的定义
typedef int QueueDataType;
//队列节点
typedef struct QueueNode
{
QueueDataType data;
struct QueueNode* next;
}QueueNode;
typedef struct Queue
{
QueueNode* front;
QueueNode* back;
}Queue;
首先我们要定义每个数据节点,然后用一个结构体来管理队列
队列初始化
队列初始化我们要先将两个指针初始化为NULL,防止野指针。
//队列初始化
void QueueInit(Queue* sq)
{
assert(sq);
sq->front = NULL;
sq->back = NULL;
}
其实队列的这种形式也是一种避免使用二级指针的方法,我们只要将头指针和尾指针定义在结构体变量内,那么修改头尾就是在对结构体成员变量进行操作,只要传结构体的一级指针就足够了。
入队列
因为队列初始化将front和back都初始为NULL了,所以入队列要分两种情况,一种是插入第一个数据,这时候要将front和back都改成新节点的地址,而第二种就是正常的插入,这样就只需要在尾节点back进行链接操作就行了。所以我们可以先写一个判空函数,同时因为队列的插入只有这一个接口,所以我们也不用单独写一个函数来创建新节点了。
//队列判空
bool QueueEmpty(Queue* sq)
{
assert(sq);
return sq->front == NULL;
}
//入队列
void QueuePush(Queue* sq, QueueDataType x)
{
assert(sq);
QueueNode* newnode = (QueueNode*)malloc(sizeof(QueueNode));
assert(newnode);
newnode->data = x;
newnode->next = NULL;
//第一个数据插入
if (QueueEmpty(sq))
{
sq->front = newnode;
sq->back = newnode;
}
else
{
sq->back->next = newnode;
sq->back = newnode;
}
}
出队列
出队列的逻辑首先要判空,然后判断是否只有一个节点的时候,这种情况单独讨论,删除之后要置空,第三种就是正常头删,这时候free头节点并且修改头节点就行了。
//出队列
void QueuePop(Queue* sq)
{
assert(sq);
assert(!QueueEmpty(sq));//判空
if (sq->front == sq->back)//只有一个数据节点的时候
{
free(sq->front);
sq->front = NULL;
sq->back = NULL;
}
else
{
QueueNode* del = sq->front;
sq->front = sq->front->next;
free(del);
}
}
取队头数据
取队头数据首先要判空,然后直接返回front的data就行了
//取队头数据
QueueDataType QueueFront(Queue* sq)
{
assert(sq);
assert(sq->front);
assert(!QueueEmpty(sq));
return sq->front->data;
}
取队尾数据
取队尾数据也是先判空,然后返回back的data。
//取队尾数据
QueueDataType QueueBack(Queue* sq)
{
assert(sq);
assert(sq->back);
assert(!QueueEmpty(sq));
return sq->back->data;
}
求数据个数
求数据个数没有便捷的办法了,只能遍历一遍队列。
//求数据个数
size_t QueueSize(Queue* sq)
{
assert(sq);
if (QueueEmpty(sq))
{
return 0;
}
else
{
size_t count = 0;
QueueNode* cur = sq->front;
while (cur)
{
count++;
cur = cur->next;
}
return count;
}
}
如果想要求数据个数方便,那就可以在定义队列的时候加一个size变量专门用来记录数据个数。
下一篇文章解决两个问题:1.如何用两个队列实现栈
2.如何用两个栈实现队列