目录
- 一、栈
- 1 栈的概念及结构
- 2 栈的实现
- 二、队列
- 1 队列的概念及结构
- 2 队列的实现
- 三、栈和队列OJ题
- 1 有效的括号
- 2 用队列实现栈
- 3 用栈实现队列
- 4 循环队列
- 四、概念选择题
一、栈
1 栈的概念及结构
栈:一种特殊的线性表。栈只允许在固定端进行插入和删除操作。进行数据插入和删除操作的一端称为栈顶,另一端称为栈底。栈中的数据元素遵守后进先出LIFO(Last In First Out)的原则。
压栈:栈的插入操作叫做进栈/压栈/入栈,入数据在栈顶。
出栈:栈的删除操作叫做出栈。出数据也在栈顶。
2 栈的实现
栈的实现一般可以使用数组或者链表实现,相对而言顺序结构实现更优一些。因为在尾上插入数据的代价比较小。下面将使用动态数组来实现栈。
需要实现的操作有:栈的创建与销毁,压栈出栈,获取栈顶元素,获取栈中元素个数,栈的判空等。
代码示例:
头文件
#include<stdio.h>
#include<assert.h>// 提供assert函数
#include<stdlib.h>// 提供malloc,realloc等函数
#include<stdbool.h>// 提供bool类型
typedef int STDataType;// 定义栈数据类型
typedef struct Stack
{
STDataType* arr;// 动态数组
int top;// 栈顶的下标
int capacity;// 栈/数组容量
}ST;
//栈的初始化、销毁
void STInit(ST* pst);
void STDestroy(ST* pst);
//入栈、出栈
void STPush(ST* pst, STDataType x);
void STPop(ST* pst);
//获取栈顶数据
STDataType STTop(ST* pst);
//获取数据个数
int STSize(ST* pst);
//判空
bool STEmpty(ST* pst);
源文件
//栈的初始化、销毁
void STInit(ST* pst)
{
assert(pst);
pst->arr = NULL;
pst->top = 0;// 栈顶设置为末尾元素的后一个位置,若直接设为末尾元素则为-1
pst->capacity = 0;
}
void STDestroy(ST* pst)
{
assert(pst);
free(pst->arr);
pst->arr = NULL;
pst->top = pst->capacity = 0;
}
//入栈、出栈
void STPush(ST* pst, STDataType x)
{
assert(pst);
//判断是否满栈决定是否扩容
if (pst->top == pst->capacity)
{
int newcapacity = pst->capacity == 0 ? 4 : 2 * pst->capacity;
STDataType * tmp = (STDataType*)realloc(pst->arr, newcapacity * sizeof(STDataType));
if (!tmp)
{
perror("STPush::realloc fail!");
return;
}
pst->arr = tmp;
pst->capacity = newcapacity;
}
pst->arr[pst->top++] = x;
}
void STPop(ST* pst)
{
assert(pst);
assert(pst->top > 0);
pst->top--;
}
//获取栈顶数据
STDataType STTop(ST* pst)
{
assert(pst);
assert(pst->top > 0);
return pst->arr[pst->top - 1];
}
//获取数据个数
int STSize(ST* pst)
{
assert(pst);
return pst->top;
}
//判空
bool STEmpty(ST* pst)
{
assert(pst);
return pst->top == 0;
}
代码分析:
用动态数组实现栈,代码在栈的结构体定义中设计了3个变量,
arr
是栈数据类型的指针变量,用于记录动态数组;top
是整型变量,用于记录栈顶位置;capacity
也是整型变量,用于记录动态数组的最大容量。
其中top
变量我们需要特别关注一个小问题,即它记录的是最后一个有效元素的下标还是最后一个有效元素后一位置的下标。这虽然是个小问题,但关系到栈的初始化与销毁,以及一系列接口的实现细节。
例如:如果记录的是最后一个有效元素的下标,那么在栈的初始化与销毁时,top
的初始值就是-1
;如果记录的是最后一个有效元素后一位置的下标,那么top
的初始值就是0
。
接着是动态数组的扩容问题,代码示例选择在数据入栈时判断是否需要扩容与进行扩容操作,我们当然可以有另外的选择,例如将容量检查操作与扩容操作单独封装为一个模块。
二、队列
1 队列的概念及结构
队列:只允许在一端进行插入数据操作,在另一端进行删除数据操作的特殊线性表。队列中的数据元素遵守先进先出FIFO(First In First Out) 的原则。
入队列:向队列中插入数据元素的操作叫作入队列,插入数据的一端称为队尾
出队列:在队列中删除数据元素的操作叫作出队列,删除数据的一端称为队头
我们只需要记住队尾入,队头出,先进就先出即可。
2 队列的实现
队列也可以使用数组或链表实现,但链表结构实现更优一些,因为使用数组结构,出队列在数组头上出数据需要不断地将后面数据覆盖前一位置,效率会比较低。
需要实现的操作有:队列的创建与销毁,入队出队,获取队头元素,获取队尾元素,获取队列中元素个数,队列的判空等。
头文件
#include<stdio.h>
#include<assert.h>// 提供assert函数
#include<stdlib.h>// 提供malloc,realloc等函数
#include<stdbool.h>// 提供bool类型
typedef int QDataType;// 定义队列数据类型
// 链式结构:表示队列
typedef struct QListNode// 队列每个数据所在结点
{
struct QListNode* next;
QDataType data;
}QNode;
// 队列的结构
typedef struct Queue
{
QNode* front;// 队头
QNode* rear;// 队尾
int size;// 队长
}Queue;
// 初始化队列
void QueueInit(Queue* pq);
// 销毁队列
void QueueDestroy(Queue* pq);
// 队尾入队列
void QueuePush(Queue* pq, QDataType data);
// 队头出队列
void QueuePop(Queue* pq);
// 获取队列头部元素
QDataType QueueFront(Queue* pq);
// 获取队列队尾元素
QDataType QueueBack(Queue* pq);
// 获取队列中有效元素个数
int QueueSize(Queue* pq);
// 检测队列是否为空,如果为空返回true,如果非空返回false
bool QueueEmpty(Queue* pq);
源文件
// 初始化队列
void QueueInit(Queue* pq)
{
assert(pq);
pq->front = pq->rear = NULL;
pq->size = 0;
}
// 销毁队列
void QueueDestroy(Queue* pq)
{
assert(pq);
while (pq->front)// 销毁释放各个队列结点
{
QNode* pop = pq->front;
pq->front = pq->front->next;
pq->size--;
free(pop);
}
pq->front = pq->rear = NULL;
}
// 队尾入队列
void QueuePush(Queue* pq, QDataType data)
{
assert(pq);
QNode* newnode = (QNode*)malloc(sizeof(QNode));
if (!newnode)
{
perror("QueuePush::malloc fail!");
return;
}
newnode->next = NULL;
newnode->data = data;
if (!pq->front)// 队列为空
{
pq->front = pq->rear = newnode;
}
else// 队列不为空
{
pq->rear->next = newnode;
pq->rear = newnode;
}
pq->size++;
}
// 队头出队列
void QueuePop(Queue* pq)
{
assert(pq);
assert(pq->size > 0);
if (!pq->front->next)
{
//一个结点
free(pq->front);
pq->front = pq->rear = NULL;
}
else
{
//多个结点
QNode* next = pq->front->next;
free(pq->front);
pq->front = next;
}
pq->size--;
}
// 获取队列头部元素
QDataType QueueFront(Queue* pq)
{
assert(pq);
assert(pq->front);
return pq->front->data;
}
// 获取队列队尾元素
QDataType QueueBack(Queue* pq)
{
assert(pq);
assert(pq->rear);
return pq->rear->data;
}
// 获取队列中有效元素个数
int QueueSize(Queue* pq)
{
assert(pq);
return pq->size;
}
// 检测队列是否为空,如果为空返回非零结果,如果非空返回0
bool QueueEmpty(Queue* pq)
{
assert(pq);
return pq->size == 0;
}
代码分析:
用链表实现队列,需要先定义结点的结构体,包含数据域data
与指针域next
。还需要定义队列的结构体,其中有3个变量,front
代表队头指针,即链表的头结点;rear
代表队尾指针,即链表的尾结点;size
代表队长,即链表的有效结点个数。
队尾入数据时需要注意有队列为空与队列不为空两种情况:
如果队列为空,那么front
与rear
都为空,那么新结点入队列时直接将front
与rear
指向新结点,且队长加1即可;如果队列不为空,那么需要将rear
的next
指向新结点并将新结点置为新的尾结点,并队长加1。
队头出数据时需要注意有一个结点和多个结点两种情况:
如果队列有多个结点,需要将头结点front
销毁,设置新的头节点为原头结点下一结点,且队长减1;如果队列只有一个结点,将头结点front
销毁后,就无法设置新的头结点了,因为只有一个结点,下一结点为空,所以需要将front
置为空,同时队列只有一个结点时front
与rear
都指向同一个结点,所以结点被销毁时还需要将rear
也置为空。
三、栈和队列OJ题
1 有效的括号
力扣面试题链接
题目分析:
题目大意为给出一段字符串,字符串只有[] {} ()
三种括号类型,总共6种字符,我们需要判断这一系列括号是否一一对应可以闭合上,且是按正确顺序闭合上的。
例如{ ( [ } { } ] )
。前三个字符都为左括号,暂时没问题。第四个为右括号}
,那么我们需要往前找最近的左括号,看看是否能匹配上,最近的左括号是[
,括号类型不匹配,因此结果为false。
如果改成{ ( [ { } ] )
呢?前四个字符都为左括号,第五个字符为右括号}
,与最近的左括号{
匹配,将匹配的括号去掉。第六个字符是右括号]
,与最近的左括号[
匹配,将匹配的括号去掉。第七个字符是右括号)
,与最近的左括号(
匹配,将匹配的括号去掉。匹配完后,还剩下第一个左括号{
没有右括号匹配,还剩下左/右括号没有对应括号匹配,因此结果为false。
示例代码:
bool isValid(char* s) {
ST st;// 创建栈
STInit(&st);// 初始化栈
while (*s)// 遍历字符串进行匹配
{
if (*s == '(' || *s == '{' || *s == '[')// 左括号入栈
{
STPush(&st, *s);
}
else// 右括号与栈顶元素进行匹配
{
if (STEmpty(&st))
{
STDestroy(&st);
return false;
}
char ch = STTop(&st);
STPop(&st);
if ((*s == ')' && ch != '(')
|| (*s == '}' && ch != '{')
|| (*s == ']' && ch != '['))
{
STDestroy(&st);
return false;
}
}
s++;
}
if (!STEmpty(&st)) return false;// 字符串遍历完后可能栈中还剩余左括号未匹配
return true;
}
代码采取的思路与上述分析类似。由于栈后进先出的特点,我们可以将左括号都入栈,往后遍历遇到右括号时,栈顶元素就是最近的左括号,对两者进行匹配判断即可,如果匹配则继续遍历,不匹配则返回false。
2 用队列实现栈
力扣面试题链接
题目分析:
题目规定我们只能使用入队,出队等操作来实现栈的压栈,入栈等。
使用队列实现栈的关键是如何将先进先出的逻辑转换为后进先出。我们向队列中依次存储了一系列数据1 2 3 4
,其中1是队头,4是队尾,入队从4后入,出队从1出;但如果是栈的话,那么1是栈底,4是栈顶,入栈从4后入,出栈同样从4出。
压栈操作时,我们只能使用入队操作,但直接将5入队并不影响数据序列,因为从队尾入和压栈的栈顶入方向一致,所以同样为1 2 3 4 5
。
出栈操作时,我们需要从栈顶出数据,但栈顶是队尾,出队操作只能从队头出数据,所以我们需要先获取队尾数据。由于题目允许我们使用两个队列,因此我们可以将当前队列的数据导入另一个闲置队列,当导到最后一个数据即队尾数据时,我们直接删除队尾数据即可完成出栈操作。
示例代码:
typedef struct {// 定义两个队列帮助我们完成栈操作
Queue q1;
Queue q2;
} MyStack;
MyStack* myStackCreate() {// 栈的创建,即创建栈空间并初始化两个队列
MyStack* obj = (MyStack*)malloc(sizeof(MyStack));
QueueInit(&obj->q1);
QueueInit(&obj->q2);
return obj;
}
void myStackPush(MyStack* obj, int x) {// 压栈操作,向非闲置队列中入数据即可
if (!QueueEmpty(&obj->q1))
{
QueuePush(&obj->q1, x);
}
else
{
QueuePush(&obj->q2, x);
}
}
int myStackPop(MyStack* obj) {// 出栈操作
Queue* nonEmpty = &obj->q2, *empty = &obj->q1;// 假设法,先假设某队列闲置,再进行判断
if (!QueueEmpty(&obj->q1))
{// 假设的闲置队列不为空,则交换空队列与非空队列
empty = &obj->q2;
nonEmpty = &obj->q1;
}
while (QueueSize(nonEmpty) > 1)// 将数据导入闲置队列
{
QueuePush(empty, QueueFront(nonEmpty));
QueuePop(nonEmpty);
}
QDataType ret = QueueFront(nonEmpty);// 找到了队尾数据
QueuePop(nonEmpty);// 删除队尾数据完成出栈操作
return ret;
}
int myStackTop(MyStack* obj) {// 找栈顶数据操作,即找队尾数据
if (!QueueEmpty(&obj->q1))// 队列1不为空就返回队列1的队尾,否则返回队列2的队尾,题目队返回值是否有效无要求,即使是空栈即两个队列都为空
{
return QueueBack(&obj->q1);
}
else
{
return QueueBack(&obj->q2);
}
}
bool myStackEmpty(MyStack* obj) {// 栈的判空,即判断两个队列是否都为空
return QueueEmpty(&obj->q1) && QueueEmpty(&obj->q2);
}
void myStackFree(MyStack* obj) {// 栈的销毁,即销毁队列,释放栈空间
QueueDestroy(&obj->q1);
QueueDestroy(&obj->q2);
free(obj);
}
3 用栈实现队列
力扣面试题链接
题目分析:
和上一题类似,这一题需要我们使用栈来模拟实现队列的基本操作,同样的这题可以使用两个栈来实现栈后进先出到先进先出的逻辑转换。
同样一组数据1 2 3 4
,1为队头/栈底,4为队尾/栈顶。
入队操作时,我们需要从队尾入,队尾即栈顶,逻辑方向对应,我们直接压栈即可,同样可以得到1 2 3 4 5
。
出队操作时,我们需要从队头出,队头即栈底,逻辑方向不相对应,我们需要先取得栈底元素。同样先将数据导入闲置栈中,但由于栈后进先出的特点,数据的顺序导入后就相反了,变成4 3 2 1
,这时原本的栈底元素4变成了栈顶元素,所以我们直接使用出栈操作即可。
由于这种相反的变化,相较于队列我们可以做一点细节上的变化。将一个栈专门设计为入栈操作进入的栈,另一个栈设计为出栈操作的栈。入栈操作直接压入专门栈的栈顶,出栈时直接从专门栈的栈顶出即可。因为出栈的专门栈的栈顶元素一定为队头,而入栈的专门栈的栈顶一定为队尾。
示例代码:
typedef struct {// 定义两个栈来帮助我们完成队列操作
ST pushst;// 压栈专门栈
ST popst;// 出栈专门栈
} MyQueue;
MyQueue* myQueueCreate() {// 队列的创建,即申请队列空间并初始化两个栈
MyQueue* obj = (MyQueue*)malloc(sizeof(MyQueue));
STInit(&obj->pushst);
STInit(&obj->popst);
return obj;
}
void myQueuePush(MyQueue* obj, int x) {//入队操作,直接入专门栈栈顶即可
STPush(&obj->pushst, x);
}
int myQueuePop(MyQueue* obj) {// 出队操作,直接出专门栈栈顶即可,题目对空栈情况无特别要求,返回值可不做是否无效的判断
STDataType top = myQueuePeek(obj);
STPop(&obj->popst);
return top;
}
int myQueuePeek(MyQueue* obj) {// 获取队头元素
if (STEmpty(&obj->popst))// 出栈专门栈为空,则倒出入栈专门栈的数据
{
while (!STEmpty(&obj->pushst))
{
STDataType tmp = STTop(&obj->pushst);
STPop(&obj->pushst);
STPush(&obj->popst, tmp);
}
}
return STTop(&obj->popst);// 返回出栈专门栈的栈顶元素,即队头元素
}
bool myQueueEmpty(MyQueue* obj) {// 队列的判空,即对两个栈是否都为空判断
return STEmpty(&obj->pushst) && STEmpty(&obj->popst);
}
void myQueueFree(MyQueue* obj) {// 队列的销毁,即两个栈的销毁以及队列空间的释放
STDestroy(&obj->pushst);
STDestroy(&obj->popst);
free(obj);
}
4 循环队列
力扣面试题链接
这里扩展一个概念就是循环队列,和循环链表类似,循环队列的循环主要体现在逻辑上的循环,当队尾的下一个数据元素重新回到队头。循环队列可以是一个连续存储的数组,也可以是互相链接但空间可能不连续的一系列结点组成的链表。但循环队列设计的主要目的是为了解决使用数组,即顺序结构实现队列时的假溢出问题(空间浪费问题),而循环队列的应用场景通常是有限空间循环使用时,例如餐厅排队等桌位或者图书馆排队等座位,我们需要排号获取有限的位子。
题目分析:
该题是前面一般队列实现的进一步延申。要求我们实现循环队列与其的各种基本操作。需要注意循环队列的实现一般都只需要开辟固定的空间即可,即循环队列的队长一般固定,本题也只需要开辟固定空间的队列即可。
我们可以采取的实现方式有很多,链表或者数组,这里采取数组来实现,有兴趣的可以自行尝试用链表进行实现。
示例代码:
typedef int QDataType;// 定义循环队列数据类型
typedef struct {
QDataType* arr;// 动态数组
int front;// 队头
int rear;// 队尾
int capacity;// 数组容量
}MyCircularQueue;
bool myCircularQueueIsEmpty(MyCircularQueue* obj) {// 循环队列的判空
return obj->front == obj->rear;
}
bool myCircularQueueIsFull(MyCircularQueue* obj) {// 循环队列的判满
return (obj->rear + 1) % obj->capacity == obj->front;
}
MyCircularQueue* myCircularQueueCreate(int k) {// 循环队列的创建
MyCircularQueue* obj = (MyCircularQueue*)malloc(sizeof(MyCircularQueue));// 申请队列空间
obj->arr = (QDataType*)malloc(sizeof(QDataType) * (k + 1));// 开辟固定空间
obj->front = obj->rear = 0;
obj->capacity = k + 1;
return obj;
}
bool myCircularQueueEnQueue(MyCircularQueue* obj, int value) {// 循环队列入队
if (myCircularQueueIsFull(obj)) return false;// 判满,若队满则入队失败返回false
obj->arr[obj->rear] = value;// 队列不满则数据直接入队尾
obj->rear = (obj->rear + 1) % obj->capacity;
return true;
}
bool myCircularQueueDeQueue(MyCircularQueue* obj) {// 循环队列出队
if (myCircularQueueIsEmpty(obj)) return false;// 判空,若队空则出队失败返回false
obj->front = (obj->front + 1) % obj->capacity;// 队列不空则直接删除队头数据
return true;
}
int myCircularQueueFront(MyCircularQueue* obj) {// 获取队头元素
if (myCircularQueueIsEmpty(obj)) return -1;// 判空,队列为空则获取失败
return obj->arr[obj->front];
}
int myCircularQueueRear(MyCircularQueue* obj) {// 获取队尾元素
if (myCircularQueueIsEmpty(obj)) return -1;// 判空,队列为空则获取失败
return obj->arr[(obj->rear - 1 + obj->capacity) % obj->capacity];
}
void myCircularQueueFree(MyCircularQueue* obj) {// 销毁队列
free(obj->arr);// 先释放数组空间
free(obj);// 再释放队列空间
}
在循环队列的数组实现方式中,关键是我们如何考虑循环队列的判空与判满。
我们会发现无论队列为空还是队列为满,front
与rear
都会指向同一个位置(当然这也是因为我们选择将最后一个有效元素后一位置的下标设为队尾导致,不同的实现方法会造成一系列截然不同的影响,但并无优劣高下之分)。
对于上述情况我们同样有多种选择,无论是设计一个变量记录队长还是考虑改变队列的实现方式,将最后一个有效元素的下标设为队尾。
示例代码采取的是另一种可行方式,即额外开辟1个空间。
对于这n+1个空间循环队列始终只使用其中n个空间,这样判空与判满就好判断了。
当front == rear
时队列为空,当队尾的下一个为队头时为满。
需要注意rear
加1不一定等于front
,但由于循环的特性,所以对于一个大小为n+1的数组空间,下标是从0到n,而n的下一个是0,可以得出等式rear = (rear + 1) % (n + 1)
,也即当front == (rear + 1) % (n + 1)
时队列为满。
在循环队列的入队操作时,我们需要先对队列判满,如果队列不满,我们就可以直接进行入队操作。入队后我们需要获取下一个下标,即新的队尾下标。相同的,我们只能使用等式rear = (rear + 1) % (n + 1)
得出新下标,因为reaar
加1不一定有效。
在循环队列的出队操作时,我们需要先对队列判空,如果队列不为空,我们就可以删除队头元素。出队操作后我们同样需要获取下一个下标,即新的队头下标,同样使用上面等式获取front = (front + 1) % (n + 1)
。
在获取队头元素操作时我们可以直接返回front
下标位置的元素,但获取队尾元素操作时由于rear
的下标并不是最后一个有效元素,所以rear
需要“减1”,为了保证rear
的有效,我们同样需要使用上面等式来获取下标。可以使用等式rear = (rear - 1 + n + 1) % (n + 1)
,由于直接rear - 1
可能取到负数,所以我们加一个循环数(下标加n+1即循环到同一个下标),这样就一定能得到正数,此时再取余不影响结果。
四、概念选择题
问题:
-
一个栈的初始状态为空。现将元素1、2、3、4、5、A、B、C、D、E依次入栈,然后再依次出栈,则元素出栈的顺序是( )。
A 12345ABCDE
B EDCBA54321
C ABCDE12345
D 54321EDCBA -
若进栈序列为 1,2,3,4 ,进栈过程中可以出栈,则下列不可能的一个出栈序列是( )。
A 1,4,3,2
B 2,3,4,1
C 3,1,4,2
D 3,4,2,1 -
循环队列的存储空间为 Q(1:100) ,初始状态为 front=rear=100 。经过一系列正常的入队与退队操作后, front=rear=99 ,则循环队列中的元素个数为( )。
A 1
B 2
C 99
D 0或者100 -
以下( )不是队列的基本运算?
A 从队尾插入一个新元素
B 从队列中删除第i个元素
C 判断一个队列是否为空
D 读取队头元素的值 -
现有一循环队列,其队头指针为front,队尾指针为rear;循环队列长度为N。其队内有效长度为?(假设队头不存放数据)( )。
A (rear - front + N) % N + 1
B (rear - front + N) % N
C ear - front) % (N + 1)
D (rear - front + N) % (N - 1)
答案:
1.B。依次入栈后,栈内数据为12345ABCDE
,再出栈从E
开始出栈,出栈顺序为EDCBA54321
。
2.C。进栈顺序为1,2,3,4,但出栈顺序不确定。
对于A.1432
,栈内数据变化可能是1
->2 3 4
,1一进栈就出栈,接着2,3,4依次入栈,接着依次出栈,A符合题意。
对于B.2341
,栈内数据变化可能是1 2
->1 3
->1 4
,1,2依次进栈2就出栈,然后3进栈再出栈,最后4入栈,1,4依次出栈,B符合题意。
对于D.3421
,栈内数据变化可能是1 2 3
->1 2 4
,1,2,3依次进栈3就出栈,然后4进栈再出栈,剩下1,2依次出栈,D符合题意。
对于C.3 1 4 2
,栈内数据变化先是1 2 3
,3出栈代表1,2都入栈了,但接着1是无法出栈的,因为此时栈顶元素是2,只有2出了1才能出,C不符合题意。
3.D。队头下标等于队尾下标代表队列为空。
4.B。队尾入,队头出,队列无法删除任意位置的元素。
5.B。根据下标的循环变化0 1 2 3 4...n-2 n-1 n 0 1 2 3 4...n-2 n-1 n 0...
分析计算即可。
本篇文章介绍了栈与队列的概念与结构,并给出了实现栈与队列的代码示例,在代码中特别标注了许多细节,如:栈顶处的下标,栈的扩容问题与入队、出队操作的各种情况等。在初步了解栈与队列后,文章还分享了一些OJ题帮助读者进一步掌握栈与队列的各种操作与特点,并拓展了循环队列的概念与实现。最后提供了几道选择题练习与巩固所有知识。