概要
数据结构三要素——逻辑结构(定义)、数据的运算(基本操作)、存储结构(存储/物理结构不同,运算的实现方式不同)
一、栈的概念和性质
1.1 栈的概念
栈(Stack) 是 只允许在一端进行插入或删除操作的线性表
术语 | 解释 |
---|---|
栈顶 | 允许插入和删除的一端 |
栈底 | 不允许插入和删除的一端 |
空栈 |
1.2 栈的性质
- 栈是一种操作受限的线性表,数据元素之间呈线性关系
- 后进先出Last In First Out(LIFO)
- n个不同元素进栈,出栈元素不同排列的个数为[1/(n+1)]*Cn2n,该式子称为卡特兰数
1.3 栈的存储结构
1.3.1 顺序存储
在内存中可看成倒着的栈,data[9]为栈底元素,data[0]为栈顶元素
优缺点:
- 栈的大小不可变
代码实现:
#define MAXSIZE 10
//静态数组实现:
typedef struct{
ElemType data[MAXSIZE];
int top; //栈顶指针,指向栈顶元素
}SqStack;
共享栈:
两个栈共享同一片内存空间,两个栈从两边往中间增长
1.3.2 链式存储
同样分为带头结点的实现和不带头结点的实现
代码实现:
//链栈的结点结构、栈类型定义
typedef struct LinkNode{
ElemType data;
struct LinkNode* next;
}*LiStack;
二、栈的基本操作
2.1 创销赋清、增删改查
创销赋清 | 解释 |
---|---|
InitStack(&S) | 初始化:构造一个空栈S,分配内存空间 |
DestroyStack(&S) | 销毁:销毁并释放栈S所占用的内存空间 |
赋 | |
清 |
增删改查 | 解释 |
---|---|
Push(&S,x) | 进栈:若栈S未满,则将x加入使之成为新栈顶 |
Pop(&S,&x) | 出栈:若栈S非空,则弹出栈顶元素,并用x返回 |
改 | |
GetTop(S, &x) | 读栈顶元素:若栈S非空,则用x返回栈顶元素 |
2.2 其他操作
其他操作 | 解释 |
---|---|
StackEmpty(S) | 判空操作:若S为空栈,则返回TRUE,否则返回FALSE |
2.3 用顺序栈实现基本运算
#define MAXSIZE 10
//静态数组实现:
typedef struct{
ElemType data[MAXSIZE];
int top; //栈顶指针,指向栈顶元素
}SqStack;
2.3.1 创
只需初始化栈顶指针
top也可以指向下一个可以插入的位置,初始化时S.top = 0,其他操作也有所变化
代码实现:
void InitStack(SqStack &S){
S.top = -1; //初始化栈顶指针
}
销毁:只需要逻辑上的清空,不用手动释放内存(开辟在栈区,函数运行结束后系统自动回收内存)
2.3.2 增
代码实现:
健壮性:栈是否已满
bool Push(SqStack &S, ElemType x){
//1.健壮性
if(S.top == MaxSize-1)
return false;
//2.入栈
S.data[++S.top] = x; //指针先加1,新元素再入栈
return true;
}
2.3.3 删
代码实现:
健壮性:栈是否为空
bool Pop(SqStack &S, ElemType &x){
//1.健壮性
if(S.top == -1)
return false;
//2.出栈
x = S.data[S.top--]; //栈顶元素先出栈,指针再减1
return true;
}
2.3.4 查
代码实现:
健壮性:栈是否为空
bool Pop(SqStack &S, ElemType &x){
//1.健壮性
if(S.top == -1)
return false;
//2.查
x = S.data[S.top];
return true;
}
2.3.5 判空
代码实现:
bool StackEmpty(SqStack S){
if(S.top == -1)
return true;
else
return false;
}
2.4 用链式栈实现基本运算
与单链表基本相同,区别在于进栈/出栈都只能在栈顶一端进行(链头作为栈顶)
2.4.1 创
2.4.2 增
2.4.3 删
三、队列的概念和性质
3.1 队列的概念
队列(Queue)是只允许在一端进行插入,在另一端删除的线性表
术语 | 解释 |
---|---|
队头 | 允许删除的一端 |
队尾 | 允许插入的一端 |
空队列 |
3.2 队列的性质
- 队列是一种操作受限的线性表,数据元素之间呈线性关系
- 先进先出First In First Out(FIFO)
3.3 队列的存储结构
3.3.1 顺序存储
Q.front指向队头元素;
Q.rear 指向下一次将要插入的位置(即队尾元素+1)% MAXSIZE
代码实现:
#define MAXSIZE 10
//静态数组实现:
typedef struct{
ElemType data[MAXSIZE];
int front,rear; //队头指针和队尾指针
}SqQueue;
3.3.2 链式存储
同样分为带头结点的实现和不带头结点的实现
Q.front指向头结点,队头结点为Q.front->next;
Q.rear指向队尾元素(区别顺序队列)
代码实现:
//链队列的结点结构
typedef struct LinkNode{
ElemType data;
struct LinkNode* next;
}*LinkNode;
//链队列数据类型定义
typedef struct{
LinkNode *front, *rear;
}LinkQueue;
3.3.3 双端队列
不同队列 | 解释 |
---|---|
双端队列 | 只允许从两端插入、两端删除的线性表 |
输入受限的双端队列 | 只允许从一端插入、两端删除的线性表 |
输出受限的双端队列 | 只允许从两端插入、一端删除的线性表 |
对于双端队列,若只使用其中一端的插入、删除操作,则效果等同于栈
在栈中合法的输出序列,在双端队列中必定合法(卡特兰数)
四、队列的基本操作
4.1 创销赋清、增删改查
创销赋清 | 解释 |
---|---|
InitQueue(&Q) | 初始化:构造一个空队列Q |
DestroyQueue(&Q): | 销毁:销毁并释放队列Q所占用的内存空间 |
赋 | |
清 |
增删改查 | 解释 |
---|---|
EnQueue(&Q,x) | 入队:若队列Q未满,将x加入,使之成为新的队尾 |
DeQueue(&Q,&x) | 出队:若队列Q非空,删除队头元素,并用x返回 |
改 | |
GetHead(Q,&x) | 读队头元素:若队列Q非空,则将队头元素赋值给x |
4.2 其他操作
其他操作 | 解释 |
---|---|
QueueEmpty(Q) | 判空操作:若队列Q为空返回true,否则返回false |
4.3 用顺序队列实现基本运算
#define MAXSIZE 10
//静态数组实现顺序队列的数据类型定义
typedef struct{
ElemType data[MAXSIZE];
int front,rear; //队头指针和队尾指针
}SqQueue;
4.3.1 创
代码实现:
//初始化时,队头、队尾指针指向0
void InitQueue(SqQueue &Q){
Q.rear = Q.front = 0;
}
4.3.2 增
入队操作(只能从队尾入队)
代码实现:
健壮性:队列是否已满的条件:队尾指针的再下一个位置是队头,即(Q.rear+1)%MaxSize==Q.front (即牺牲一个存储单元在队头的前面,当队尾指针指向该存储空间则队列满。区分队列空的条件
bool EnQueue(SqQueue &Q, ElemType x){
//1. 健壮性
if((Q.rear+1) % MAXSIZE == Q.front)
return false;
//2.插入到队尾
Q.data[Q.rear] = x; //新元素插入队尾
Q.rear = (Q.rear+1) % MAXSIZE; //队尾指针加1取模
return true;
}
将存储空间在逻辑上变成了环状,模运算将无限的整数域映射到有限的整数集合{0, 1, 2, …, MaxSize-1}
4.3.3 删
出队操作(从队头出)
代码实现:
健壮性:队列是否空
bool DeQueue(SqQueue &Q, ElemType &x){
//1. 健壮性
if(Q.rear == Q.front)
return false;
//2.出队
x = Q.data[Q.front];
Q.front = (Q.front-1) % MAXSIZE;
return true;
}
4.3.4 查
获得队头元素
代码实现:
健壮性:队列是否空
bool GetHead(SqQueue Q, ElemType &x){
//1. 健壮性
if(Q.rear == Q.front)
return false;
//2.出队
x = Q.data[Q.front];
return true;
}
4.3.5 判空
if(Q.rear == Q.front)
Q.front指向队头元素,Q.rear 指向下一次将要插入的位置(即队尾元素+1)
4.4 用链式队列实现基本运算
统一用带头结点的实现
//链队列的结点结构
typedef struct LinkNode{
ElemType data;
struct LinkNode* next;
}LinkNode;
//链队列数据类型定义
typedef struct{
LinkNode *front, *rear;
}LinkQueue;
4.4.1 创
代码实现:
//初始化时,队头、队尾指针指向头结点
void InitQueue(LinkQueue &Q){
Q.rear = Q.front = new LinkNode;
Q.front->next = NULL;
//若不带头结点,Q.front = NULL, Q.rear = NULL,判空Q.front == NULL
}
4.4.2 增
入队
代码实现:
无健壮性,链式队列不会满
void EnQueue(LinkQueue &Q, ElemType x){
//1. 创建新的结点存入数据x
LinkNode *s = new LinkNode;
s->data = x;
s->next = NULL;
//2.插入到队尾
Q.rear->next = s;
//3.更新队尾结点
Q.rear = s;
return true;
}
4.4.3 删
出队操作
代码实现:
健壮性:链式队列会空;
若队尾指针指向队头结点时,要更新队尾指针指向头结点
bool DeQueue(LinkQueue &Q, ElemType &x){
//1. 健壮性1
if(Q.rear == Q.front)
return false;
//2.用p指向队头结点(方便出队、更新队头结点、释放delete)
LinkNode *p = Q.front->next;
//3.出队
x = p->data;
//4.更新队头结点(非头结点)
Q.front->next = p->next;
//健壮性2
if(Q.rear == p)
Q.rear = Q.front;
//5. 释放p结点
delete p;
return true;
}
4.4.4 查
队头元素:Q.front->next
记得判空
4.4.5 判空
if(Q.rear == Q.front)
五、栈的应用
5.1 括号匹配问题
用栈实现括号匹配:
依次扫描所有字符,遇到左括号入栈,遇到右括号则弹出栈顶元素检
查是否匹配。
5.1.1 流程
匹配失败情况:
①左括号单身②右括号单身③左右括号不匹配
5.1.2 代码实现
健壮性:1.左括号单身 2.右括号单身
bool bracketCheck(char str[], int length){
//1.声明初始化一个栈(这里用顺序存储实现)
SqStack S;
InitStack(S);
//2.依次遍历表达式的所有字符
for(int i = 0; i < length; ++i)
{
//左括号:入栈
if(str[i] == '(' || str[i] == '[' || str[i] == '{')
{
Push(S,str[i]);
}
//右括号:弹出栈顶左括号进行匹配
else
{
//健壮性1
if(StackEmpty(S))
return false;
char topElem;
Pop(S,topElem);
if(str[i] == ')' && topElem != '(')
return false;
if(str[i] == ']' && topElem != '[')
return false;
if(str[i] == '}' && topElem != '{')
return false;
}
}
//健壮性2
return StackEmpty(S);
}
5.2 表达式求值中的应用
5.2.1 三种表达式
算数表达式由三个部分组成:操作数、运算符、界限符
后缀表达式又称逆波兰表达式,前缀表达式又称波兰表达式, 可以不用界限符也能无歧义地表达运算顺序
5.2.2 中缀表达式转后缀(或前缀)表达式
这里只介绍中缀转后缀的手算和机算方法,若是中缀转前缀则同理,区别在于按照「运算符 左操作数 右操作数」的方式组合,并遵循“右优先”原则
1. 中缀转后缀的手算方法:
①确定中缀表达式中各个运算符的运算顺序
②选择下一个运算符,按照「左操作数 右操作数 运算符」的方式组合成一个新的操作数
PS:运算顺序不唯一,因此对应的后缀表达式也不唯一
③如果还有运算符没被处理,就继续②
“左优先”原则:只要左边的运算符能先计算,就优先算左边的,保证手算和机算结果相同,即保证运算顺序唯一
2. 中缀转后缀的机算方法:
①初始化一个栈,用于保存暂时还不能确定运算顺序的运算符。
②从左到右处理各个元素,直到末尾。可能遇到三种情况:
- 遇到操作数:直接加入后缀表达式
- 遇到界限符:遇到 “(” 直接入栈;遇到 “)” 则依次弹出栈内运算符并加入后缀表达式,直到弹出“(”为止。
注意:“(” 和 “)” 不加入后缀表达式
- 遇到运算符:依次弹出栈中优先级高于或等于当前运算符的所有运算符并加入后缀表达式,碰到“(”或栈空则停止;同时把当前运算符入栈
③处理完所有字符后,将栈中剩余运算符依次弹出并加入后缀表达式
5.2.3 后缀(前缀)表达式结果的计算
这里只介绍后缀表达式的手算和机算方法,若是前缀表达式方法类似,区别在于从右往左扫描,且先出栈的是栈顶元素“左操作数”
1. 后缀表达式的手算方法:
从左往右扫描,每遇到一个运算符,就让运算符前面最近的两个操作数执行对应运算,合体为一个操作数
特点:最后出现的操作数先被运算--栈
2. 后缀表达式的机算方法:(用栈实现后缀表达式的计算)
①从左往右扫描下一个元素,直到处理完所有元素
②若扫描到操作数则压入栈,并回到①;否则执行③
③若扫描到运算符,则弹出两个栈顶元素,执行相应运算,运算结果压回栈顶,回到①
注意:先出栈的是栈顶元素“右操作数”
④若表达式合法,则最后栈中只会留下一个元素,就是最终结果
5.2.4 中缀转后缀+后缀表达式求值实现中缀表达式的计算
用栈实现中缀表达式的计算:
①初始化两个栈,操作数栈和运算符栈
②若扫描到操作数,压入操作数栈
③若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈(期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应运算,运算结果再压回操作数栈)
④处理完所有字符后,将栈中剩余运算符依次弹出并与操作数栈的两个栈顶元素执行相应运算再压回操作数栈
5.3 递归中的应用
5.3.1 函数调用
递归调用时,函数调用栈可称为“递归工作栈”
每进入一层递归,就将递归调用所需信息压入栈顶
每退出一层递归,就从栈顶弹出相应信息
(函数调用时,需要用一个栈存储:①调用返回地址 ②实参 ③局部变量)
特点:最后被调用的函数最先执行结束(LIFO)
缺点:
- 太多层递归可能会导致栈溢出(可以自定义栈将递归算法改造成非递归算法)
- 可能包含很多重复计算
5.3.2 递归应用
适合用“递归”算法:可以把原始问题转换为属性相同,但规模较小的问题(如计算正整数的阶乘n!、斐波那契数列等)