概要
参考《大话数据结构》,把常用的基本数据结构梳理一下。
本节介绍栈与队列。
栈
栈:栈是限定仅在表尾进行插入和删除操作的线性表。
我们把允许插入和删除的一端称为栈顶(top),另一端称为栈底(bottom),不含任何数据元素的栈称为空栈。栈又称为后进先出(Last in First Out)的线性表,简称 LIFO 结构。
栈的插入操作,叫作进栈,也称压栈、入栈。类似子弹入弹夹。
栈的删除操作,叫做出栈,也有的叫作弹栈。如同弹夹中的子弹出夹。
栈的顺序存储结构
既然栈是线性表的特例,那么栈的顺序存储其实也就是线性表顺序存储的简化,我们简称为顺序栈。
下面我们直接利用完整的代码解释相关操作。
#include<iostream>
using namespace std;
typedef int SElemType; // SElwmType 类型根据实际情况而定,这里假设为 int
const int MAXSIZE = 5;
//栈的顺序存储结构
struct SqStack
{
SElemType data[MAXSIZE];
int top; //用于栈顶指针,当存在一个元素时,top = 1,通常把空栈的判定条件定为 top = -1
};
//初始化一个空栈
void InitStack(SqStack* S )
{
S->top = -1;
}
//进栈操作
void EnStack(SqStack* S, SElemType e)
{
if(S->top+1 == MAXSIZE) //栈满
cout<<"栈已经满了,禁止插入"<<endl;
else
{
S->top++; //栈顶指针增 1
S->data[S->top] = e; //将元素 e 赋值给栈顶
}
}
//出栈操作
//若栈不空,则删除 S 中栈顶元素,用 e 返回其值
void DeStack(SqStack* S, SElemType *e)
{
if(S->top == -1) //栈为空的判断
cout<<"栈为空"<<endl;
else
{
*e = S->data[S->top]; //将栈顶元素赋值给 e
S->top--; // 栈顶指针减 1
}
}
int main()
{
SqStack* myS = new SqStack; // 不加动态内存会运行错误
InitStack(myS); //初始化空栈
SElemType e = 3;
EnStack(myS, e); //入栈操作
cout<<myS->data[myS->top]<<endl; //输出 3
SElemType *f = new SElemType;
DeStack(myS, f);
cout<<*f<<endl; //输出 3
return 0;
}
进栈入栈操作没有涉及到任何循环语句,因此时间复杂度均是 (O(1)).
栈的链式存储结构
栈的链式存储结构简称链栈。
栈只是栈顶来做插入和删除操作,栈顶放在链表的头部还是尾部呢?由于单链表有头指针,而栈顶指针也是必须的,那干吗不让它合二为一呢,所以比较好的办法是把栈顶放在单链表的头部。另外,都已经有了栈顶在头部了,单链表中比较常用的头结点也就失去了意义,通常对于链栈来说,是不需要头结点的。
对于链栈来说,基本不存在栈满的情况,除非内存已经没有可以使用的空间。但对于空栈来说,链表原定义是头指针指向空,那么链栈的空其实就是 top=NILL 的时候。
下面我们直接利用完整的代码解释相关操作。
#include<iostream>
using namespace std;
typedef int SElemType; // SElwmType 类型根据实际情况而定,这里假设为 int
//const int MAXSIZE = 5;
//栈的链式存储结构
struct StackNode
{
SElemType data;
StackNode* next; //从栈顶的方向指向栈底
};
struct LinkStack
{
StackNode* top; //用于栈顶指针
int counts; // 记录栈中元素个数
};
//初始化一个空栈
void InitStack(LinkStack* S )
{
S->counts = 0;
}
//进栈操作
void EnStack(LinkStack* S, SElemType e)
{
StackNode* s = new StackNode;
s->data = e;
s->next = S->top; //把当前的栈顶元素赋值给新结点的直接后继
S->counts++; //栈顶指针增 1
S->top = s; //将新的结点 s 赋值给栈顶指针
}
//出栈操作
//若栈不空,则删除 S 中栈顶元素,用 e 返回其值
void DeStack(LinkStack* S, SElemType *e)
{
if(S->counts == 0) //栈为空的判断
cout<<"栈为空"<<endl;
else
{
*e = S->top->data; //将栈顶元素赋值给 e
S->top = S->top->next; // 栈顶指针减 1
S->counts--; //元素数减一
}
}
int main()
{
LinkStack* myS = new LinkStack; // 不加动态内存会运行错误
InitStack(myS); //初始化空栈
SElemType e = 3;
EnStack(myS, e); //入栈操作
cout<<myS->top->data<<endl; //输出 3
SElemType *f = new SElemType;
DeStack(myS, f);
cout<<*f<<endl; //输出 3
return 0;
}
进栈入栈操作没有涉及到任何循环语句,因此时间复杂度均是 (O(1)). 如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么最好是用链栈,反之,如果它的变化在可控范围内,建议使用顺序栈更好一些。
栈的应用---递归
栈有一个很重要的应用:有程序设计语言中实现了递归。
用递归实现斐波那契数列。代码如下
int Fbi(int i)
{
if(i == 0 || i == 1)
return i;
if (i > 1)
{
return Fbi(i-1)+Fbi(i-2); //调用自己
}
}
int main()
{
cout<< Fbi(5);
return 0;
}
为了理解,我们可以不要把一个递归函数中调用自己的函数看作是在调用自己,而就当它是在调用“另一个”函数,只不过,这个函数和自己长得一样而已。我们来模拟代码中 Fbi(i) 函数当 i=5 的执行过程,如下图
前面我们看到递归是如何执行它的前行和退回阶段的。递归过程退回的顺序是它前行顺序的逆序。在退回过程中,可能要执行某些动作,包括恢复前行过程中存储起来的某些数据,这种存储某些数据,并在后面又以存储的逆序恢复这些数据,以提供之后使用的需求,显然 很符合栈这样的数据结构,因此,编译器使用栈实现递归就没什么好惊讶的了。简单地说,就是在前行阶段,寻每一层递归,函数的局部变量、参数值以及返回地址都被压入栈中。在退回阶段,位于栈顶的局部变量、参数值和返回地址被弹出,用于返回调用层次中执行代码的其余部分,也就是恢复了调用的状态。
队列
队列:是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。
我们把允许插入的一端称为队尾,允许删除的一端称为队头。队列是一种先进先出(First in First Out)的线性表,简称 FIFO 结构。
队列的顺序存储结构及实现
我们假设一个队列按数组的形式存储,当队列进行插入和删除操作时,队列中的所有元素都得向前移动,以保证队列的队头。也就是下标为 0 的位置不为空,此时时间复杂度为 (O(n)),这像现实中大家排队买票,前面的人买好了离开,后面的人就是全部向前一步,补上空位,似乎没什么不好。可想想,如果不去限制队列的元素必须存储在数组的前 (n) 个单元这一条件,出队的性能就会大大增加。也就是说,队头不需要一定在下标为 0 的位置。
为了避免当只有一个元素时,队头和队尾重合使处理变得麻烦,所以引入两个指针, front 指针指向队头元素,rear 指针指向队尾元素的下一个位置,这样当 front 等于 rear 时,此队列不是还剩一个元素,而是空队列。
队列的整体移动会面临一个问题,就是有可能跑到数组之外。假设一个队列的总个数不超过 5 个,但目前总共 3 个元素已经移动到数组末尾,再进行插入操作的话,就会产生数组越界的错误,可实际上,队列在下标为 0 和 1 的地方还是空闲的。这种现象叫做“假溢出”。 为了解决这个问题,就再从头开始,也就是头尾相接的循环。我们把队列的头尾相接的顺序存储结构称为循环队列。
下面我们直接利用完整的代码解释相关操作。
#include<iostream>
using namespace std;
typedef int QElemType; // QElwmType 类型根据实际情况而定,这里假设为 int
const int MAXSIZE = 5;
//循环队列的顺序存储结构
struct SqQueue
{
QElemType data[MAXSIZE];
int fro; //头指针
int rear; //尾指针,若队列不空,指向队尾元素的下一个位置
};
//初始化一个空队列
void InitQueue(SqQueue* Q )
{
Q->fro = 0;
Q->rear = 0;
}
//循环队列求队列长度代码如下
int QueueLength(SqQueue Q)
{
return(Q.rear - Q.fro + MAXSIZE) % MAXSIZE;
}
//循环队列的入队操作
void EnQueue(SqQueue* Q, QElemType e)
{
//为了避免当 rear = fon 时,不知道队列是空还是满,故总留一个空位置,所以下面判断有 +1
if((Q->rear+1)%MAXSIZE == Q->fro) //队列满的判断
cout<<"队列已经满了,禁止插入"<<endl;
else
{
Q->data[Q->rear] = e; //将元素 e 赋值给队尾
Q->rear = (Q->rear+1) % MAXSIZE; // rear 指针后移一个位置,若到最后则转到数组头部
}
}
//循环队列的出队操作
//若队列不空,则删除 Q 中队头元素,用 e 返回其值
void DeQueue(SqQueue* Q, QElemType *e)
{
if(Q->fro == Q->rear) //队列空的判断
cout<<"队列为空"<<endl;
else
{
*e = Q->data[Q->fro]; //将队头元素赋值给 e
Q->fro = (Q->fro+1) % MAXSIZE; // fro 指针向后移动一位,若到最后则转到数组头部
}
}
int main()
{
SqQueue* myQ = new SqQueue; // 不加动态内存会运行错误
InitQueue(myQ); //初始化队列
QElemType e = 3;
EnQueue(myQ, e); //入队操作
cout<<myQ->data[myQ->fro]<<endl; //输出 3
QElemType *f = new QElemType;
DeQueue(myQ, f);
cout<<*f<<endl; //输出 3
return 0;
}
下面研究不需要担心队列长度的链式存储结构。
队列的链式存储结构及实现
队列的链式存储结构,其实就是线性表的单链表,只不过它只能尾进头出而已,我们把它简称为链队列。为了操作上的方便,我们将队头指针指向链队列的头结点,而队尾指针指向终端结点。
下面我们直接利用完整的代码解释相关操作。
#include<iostream>
using namespace std;
typedef int QElemType; // SElwmType 类型根据实际情况而定,这里假设为 int
//const int MAXSIZE = 5;
//队的链式存储结构
struct QueueNode
{
QElemType data;
QueueNode* next; //指向队尾的方向
};
struct LinkQueue
{
QueueNode* fro; //指向队头
QueueNode* rear; //指向队尾
//int counts; // 记录队中元素个数
};
void InitQueue(LinkQueue *Q) //构造一个空的队列
{
QueueNode *q = new QueueNode; //申请一个结点的空间
q->next = nullptr; //当作头结点
//队首与队尾指针都指向这个结点,指针域为NULL
Q->rear = q;
Q->fro = Q->rear;
}
//进队操作
void EnQueue(LinkQueue* Q, QElemType e)
{
QueueNode* q = new QueueNode;
q->data = e;
q->next = nullptr; //最后一个元素指向空指针
Q->rear->next = q; //把原先队尾指针指向新添加的节点
Q->rear = q; //把当前的 s 设置为队尾
}
//出队操作
//若队不空,则删除 Q 中栈顶元素,用 e 返回其值
void DeQueue(LinkQueue* Q, QElemType *e)
{
if(Q->fro == Q->rear)
cout<<"空队"<<endl;
else
{
*e = Q->fro->next->data; //将队头元素赋值给 e. Q->fro 是头结点,但是不存数据,所以得有 next
Q->fro = Q->fro->next; //队头指针减 1
}
}
int main()
{
LinkQueue* myQ = new LinkQueue; // 不加动态内存会运行错误
InitQueue(myQ);
QElemType e = 3;
EnQueue(myQ, e); //入栈操作
cout<<myQ->rear->data<<endl; //输出 3
QElemType *f = new QElemType;
DeQueue(myQ, f);
cout<<*f<<endl; //输出 3
if(myQ->rear == myQ->fro)
cout<<"目前是空队列"<<endl;
return 0;
}
总结
栈和队列都是特殊的线性表,只不过对插入和删除操作做了限制。
- 栈是限定仅在表尾进行插入和删除操作的线性表
- 队列是只允许在一端进行插入操作,而在另一端进行删除操作的线性表
它们都可以用线性表的顺序存储结构来实现,但都存在着顺序存储的一些弊端。对于栈来说,如果是两个相同数据类型的栈,则可以用数组的两端作栈底的方法来让两个栈共享数据,这就可以最大化地利用数组的空间。对于队列来说,为了避免数组插入和删除时需要移动数据,于是就引入了循环队列,使得队头和队尾可以在数组中循环变化。
它们也都可以通过链式存储结构来实现,实现原则上与线性表基本相同。