一、栈和队列的定义和特点
1、栈的定义和特点
栈 (stack) 是限定仅在表尾进行插入或删除操作的线性表
因此,对栈来说,表尾端有其特殊含义,称为栈顶 (top), 相应地, 表头端称为栈底 (bottom)不含元素的空表称为空栈
栈的修改是按后进先出的原则进行的,因此,栈又称为后进先出 (Last In First Out,LIFO) 的线性表
2、队列的定义和特点
队列(queue) 是一种先进先出(First In First Out, FIFO)的线性表
只允许在表的一端进行插入,而在另一端删除元素
在队列中,允许插入的一端称为队尾 (rear), 允许 删除的一端则称为队头 (front)
二、栈的表示和操作的实现
1、栈的类型定义
栈也有两种存储表示方法,分别称为顺序栈和链栈
2、顺序栈的表示和实现
顺序栈是指利用顺序存储结构实现的栈,即利用一组地址连续的存储单元依次存放自栈底
到栈顶的数据元素,同时附设指针 top 指示栈顶元素在顺序栈中的位置
通常习惯的做法是:
以 top = 0 表示空栈,鉴于 C 语言中数组的下标约定从 0 开始,则当以 C 语言作描述语言时,如此设定会带 来很大不便,因此另设指针 base 指示栈底元素在顺序栈中的位置。当 top 和 base 的值相等时,表示空栈。
顺序栈的存储结构:
#define MAXSIZE 10 // 顺序栈存储空间的初始分配量
typedef struct{
SElemType *base; // 栈底指针
SElemType *top; // 栈顶指针
int stackszie; // 栈可用的最大容量
}SqStack;
base 为栈底指针,初始化完成后,栈底指针 base 始终指向栈底的位置,若 base 的值为
NULL, 则表明栈结构不存在。top 为栈顶指针,其初值指向栈底。每当插入新的栈顶元素
时,指针 top 增 1; 删除栈顶元素时, 指针 top 减 1
栈空时,top 和 base 的值相等,都指向栈底栈非空时, top 始终指向栈顶元素的上一个位置
由于顺序栈和顺序表一样,受到最大空间容量的限制,虽然可以在“满员 ”时重新分配空
间扩大容量,但工作量较大,应该尽量避免
Ⅰ、初始化
顺序栈的初始化操作就是为顺序栈动态分配一个预定义大小的数组空间
步骤:
- 为顺序栈动态分配一个最大容量为 MAXSIZE 的数组空间,是 base 指向这段空间的基地址,即栈底
- 栈顶指针 top 初始为 base,表示栈为空
- stacksize 置为栈的最大容量 MAXSIZE
Status InitStack(SqStack &S){
// 构造一个空栈 S
S.base = new SElemType[MAXSIZE]; //为顺序栈动态分配一个最大容量为 MAXSIZE 的数组空间
if(!S.base)
exit(OVERFLOW); //存储分配失败
S.top = S.base; //top 初始为 base,空栈
S.stacksize = MAXSIZE; // stacksize置为栈的最大容量 MAXSIZE
return OK;
}
Ⅱ、入栈
入栈操作是指在栈顶插入一个新的元素
步骤:
- 判断栈是否满,若满则返回 ERROR
- 将新元素压入栈顶,栈顶指针加 1
Status Push(SqStack &S,ElemType e){
// 插入元素 e 为新的栈顶元素
if(S.top - S.base == S.stacksize)
return ERROR; // 栈满
*S.top++ = e; // 元素 e 压入栈顶,栈顶指针加 1
return OK;
}
Ⅲ、出栈
出栈操作是将栈顶元素删除
步骤:
- 判断栈是否为空,若空则返回 ERROR
- 栈顶指针减 1,栈顶元素出栈
Status Pop(SqStack &S,SElemType &e){
// 删除 S 的栈顶元素,用 e 返回其值
if(S.top == S.base)
return ERROR; // 栈空
e = *--S.top; // 栈顶指针减 1,将栈顶元素赋给 e
return OK;
}
Ⅳ、取栈顶元素
当栈非空时, 此操作返回当前栈顶元素的值, 栈顶指针保待不变
SElemType GetTop(SqStack S){
// 返回 S 的栈顶元素,不修改栈顶指针
if(S.top != S.base){ // 栈非空
return *(S.top - 1); // 返回栈顶元素的值,栈顶指针不变
}
}
3、链表的表示和实现
链栈是指采用链式存储结构实现的栈
通常链栈用单链表来表示
链栈的存储结构:
typedef struct StackNode{
ElemType data;
struct StackNode *next;
} StackNode,*LinkStack;
Ⅰ、初始化
链栈的初始化操作就是构造一个空栈, 因为没必要设头结点, 所以直接将栈顶指针置空即可
Status IniyStack(LinkStack &S){
// 构造一个空栈 S,栈顶指针置空
S = NULL;
return OK;
}
Ⅱ、入栈
链栈在入栈前不需要判断栈是否满,只需要为入栈元素动态分配一个结点空间
步骤:
- 为入栈元素 e 分配空间,用指针 p 指向
- 将新结点数据置为 e
- 将新结点插入栈顶
- 修改栈顶指针为 p
Statuc Push(LinkStack &S,SElemType e){
// 在栈顶插入元素 e
p = new StackNode; // 生成新结点
p->data = e; // 将新结点数据域置为 e
p->next = S; // 将新结点插入栈顶
S = p; // 修改栈顶指针为 p
return OK;
}
Ⅲ、出栈
链栈在出栈前也需要判断栈是否为空,在出栈后需要释放出栈元素的栈顶空间
步骤:
- 判断栈是否为空,若空则返回 ERROR
- 将栈顶元素赋给 e
- 临时保存栈顶元素的空间,以备释放
- 修改栈顶指针,指向新的栈顶元素
- 释放原来栈顶元素的空间
Status Pop(LinkStack &S,SELemType &e){
// 删除 S 的栈顶元素,用 e 返回其值
if(S == NULL)
return ERROR; // 栈空
e = S->data; // 将栈顶元素赋给 e
p = S; // 用 p 临时保存栈顶元素空间,以备释放
S = S->next; // 修改栈顶指针
delete p; // 释放原栈顶元素的空间
return OK;
}
Ⅳ、取栈顶元素
当栈非空时,此操作返回当前栈顶元素的值,栈顶指针S保持不变
SElemType GetTop(LinkStack S){
// 返回 S 的栈顶元素,不修改栈顶指针
if(S != NULL) // 栈非空
return S->data; // 返回栈顶元素的值,栈顶指针不变
}
三、栈与递归
1、采用递归算法解决问题
所谓递归是指,若在一个函数、过程或者数据结构定义的内部又直接(或间接)出现定义本身的应用,则称它们是递归的,或者是递归定义的
1.1、定义是递归的
有很多数学函数是递归定义的 , 如大家熟悉的阶乘函数
二阶 Fibonacci 数列
long Fact(long n){
if(n == 0)
return 1;
else
return n * Fact(n - 1);
}
long Fib(long n){
if(n == 1 || n == 2)
return 1;
else
return Fib(n - 1) + Fib(n - 2);
}
类似这种的复杂问题,若能够分解成几个相对简单且解法相同或类似的子问题来求解,便称作递归求解
这种分解-求解的策略叫做“分治法”
采用“分治法”进行递归求解的问题需要满足三个条件:
- 能将一个问题转变成一个新问题,而新问题与原问题的解法相同或类同,不同的仅是处理的对象,并且这些处理对象更小且变化有规律
- 可以通过上述转化而使问题简化
- 必须有一个明确的递归出口,或称递归的边界
“分治法” 求解递归问题算法的一般形式:
void p(参数表){
if(递归结束条件成立) // 递归终止条件
可直接求解;
else
p(较小的参数); // 递归步骤
}
1.2、数据结构是递归的
某些数据结构本身具有递归的特性,则它们的操作可递归地描述
遍历输出链表中各个结点的递归算法
步骤:
- 如果 p 为 NULL,递归结束返回
- 否则输出 p->data,p 指向后继结点继续递归
void TraverseList(LinkList p){
if(p == NULL)
return;
else{
cout << p->data << end;
TraverseList(p->next);
}
}
简化:
void TraverseList(LinkList p){
if(p){
cout<<p->data<<end;
TraverseList(p->next);
}
}
1.3、问题的解法是递归的
还有一类问题,虽然问题本身没有明显的递归结构,但用递归求解比迭代求解更简单,如 Hanoi 塔问题、八皇后问题、迷宫问题等
n 阶 Hanoi 塔问题
步骤:
- 如果 n = 1,则直接将编号为 1 的圆盘从 A 移到 C,递归结束
- 否则
- 递归,将 A 上编号为 1 至 n - 1 的圆盘移到 B,C 做辅助塔
- 直接将编号为 n 的圆盘从 A 移到 C
- 递归,将 B 上编号为 1 至 n - 1 的圆盘移到 C,A 做辅助塔
void Hanoi(int n,char A,char B,char C){
// 将塔座 A 上的 n 个圆盘按规则搬到 C 上,B 做辅助塔
if(n == 1)
move(A,1,C); // 将编号为 1 的圆盘从 A 移到 C
else{
Hanoi(n - 1,A,C,B); // 将 A 上编号为 1 至 n - 1 的圆盘移到 B,C 做辅助塔
move(A,n,C); // 将编号为 n 的圆盘从 A 移到 C
Hanoi(n - 1,B,A,C); // 将 B 上编号为 1 至 n - 1 的圆盘移到 C,A 做辅助塔
}
}
2、递归过程和递归工作栈
一个递归函数的运行过程类似于多个函数的嵌套调用,只是调用函数和被询用函数是同一个函数
为了保证递归函数正确执行,系统需设立一个“递归工作栈”作为整个递归函数运行期间使用的数据存储区
每一层递归所需信息构成一个工作记录,其中包括所有的实参、所有的局部变量,以及上一层的返回地址
每进入一层递归,就产生一个新的工作记录压入栈顶
每退出一层递归, 就从栈顶弹出一个工作记录,则当前执行层的工作记录必是递归工作栈栈顶的工作记录,称这 个记录为“活动记录”
3、递归算法的效率分析
3.1、时间复杂度的分析
递归计算 Fibonacci 数列和 Hanoi 塔问题递归算法的时间复杂度均为
3.2、空间复杂度的分析
对于递归算法,空间复杂度
其中,f(n) 为 "递归工作栈" 中工作记录的个数与问题规模 n 的函数关系
递归解决阶乘间题、 Fibonacci 数列问题、 Hanoi 塔问题的递归算法的空间复杂度均为O(n)
4、队列的表示和操作的实现
4.1、循环队列——队列的顺序表示和实现
队列的顺序存储结构
#define MAXSIZE 100 // 队列可能达到的最大长度
typedef struct{
QElemType *base; // 存储空间的基地址
int front; // 头指针
int rear; // 尾指针
} SqQueue;
初始化创建空队列时,令 front = rear = 0 , 每当插入新的队列尾元素时,尾指针 rear 增 1; 每当删除队列头元素时,头指针 front 增 1。因此,在非空队列 中,头指针始终指向队列头元素,而尾指针始终指向队列尾元素的下一个位置
以上方式可能会导致“假溢出”现象。解决方法:循环队列
头、 尾指针以及队列元素之间的关系不变 ,只是在循环队列中,头、 尾指针“依环状增
1”的操作可用“模”运算来实现。通过取模,头指针和尾指针就可以在顺序表空间内以头
尾衔接的方式“循环”移动
Ⅰ、初始化
循环队列的初始化操作就是动态分配一个预定义大小为 MAXSIZE 的数组空间
步骤:
- 为队列分配一个最大容量为 MAXSIZE 的数组空间,base 指向数组空间的首地址
- 头指针和尾指针置为零,表示队列为空
Status InitQueue(SqQueue &Q){
// 构造一个空队列 Q
Q.base = new QElemType[MAXSIZE];// 为队列分配一个最大容量为 MAXSIZE 的数组空间
if(!Q.front)
exit(OVERFLOW); // 存储分配失败
Q.front = Q.rear = 0; // 头指针和尾指针为零,队列为空
return OK;
}
Ⅱ、求队列长度
对于非循环队列,尾指针和头指针的差值便是队列长度,而对于循环队列,差值可能为负
数,所以需要将差值加上 MAXSIZE, 然后与 MAXSIZE 求余
int QueueLength(SqQueue Q){
// 返回 Q 的元素个数,即队列的长度
return (Q.rear - Q.front + MAXSIZE) % MAXSIZE;
}
Ⅲ、入队
入队操作是指在队尾插入一个新的元素
步骤:
- 判断队列是否满,若满则返回 ERROR
- 将新元素插入队尾
- 队尾指针加 1
Status EnQueue(SqQueue &Q,QElemType e){
// 插入元素 e 为 Q 的新的队尾元素
if((Q.rear + 1) % MAXSIZE == Q.front) // 尾指针在循环意义上加 1 后等于头指针,队满
return ERROR;
Q.base[Q.rear] = e; // 新元素插入队尾
Q.rear = (Q.rear + 1) % MAXSIZE; // 队尾指针加 1
return OK;
}
Ⅳ、出队
出队操作是将队头元素删除
步骤:
- 判断队列是否为空,若空则返回 ERROR
- 保存队头元素
- 队头指针加 1
Status DeQueue(SqQueue &Q,QElemType &e){
// 删除 Q 的队头元素,用 e 返回其值
if(Q.front == Q.rear) // 队空
return ERROR;
e = Q.base[Q.front]; // 保存队头元素
Q.front = (Q.front + 1) % MAXSIZE; // 队头指针加 1
return OK;
}
Ⅴ、取队头元素
当队列非空时,此操作返回当前队头元素的值,队头指针保持不变
SElemeType GetHead(SqQueue Q){
// 返回 Q 的队头元素,不修改队头指针
if(Q.front != Q.rear) // 队列非空
return Q.base[Q.front]; // 返回队头元素的值,队头指针不变
}
4.2、链队——队列的链式表示和实现
链队是指采用链式存储结构实现的队列
一个链队显然需要两个分别指示队头和队尾的指针(分别称为头指针和尾指针)才能唯一
确定
队列的链式存储结构
typedef struct QNode{
QElemType data;
struct QNode *next;
} QNode,*QueuePtr;
typedef struct{
QueuePtr front; // 队头指针
QueuePtr rear; // 队尾指针
} LinkQueue;
Ⅰ、初始化
链队的初始化操作就是构造一个只有一个头结点的空队
步骤:
- 生成新的结点作为头结点,队头和队尾指针指向此结点
- 头结点的指针域置空
Status InitQueue(LinkQueue &Q){
// 构造一个空队列 Q
Q.front = Q.rear = new QNode; // 生成新结点作为头结点,队头和队尾指针指向此结点
Q.front->next = NULL; // 头结点的指针域置空
return OK;
}
Ⅱ、入队
链队在入队前不需要判断队是否满,需要为入队元素动态分配一个结点空间
步骤:
- 为入队元素分配结点空间,用指针 p 指向
- 将新结点数据域置为 e
- 将新结点插入到队尾
- 修改队尾指针为 p
Status EnQueue(LinkQueue &Q,QElemType e){
// 插入元素 e 为 Q 的新的队尾元素
p = new QNode; // 为入队元素跟陪结点空间,用指针 p 指向
p->data = e; // 将新结点数据域置为 e
p->next = NULL;
Q.rear->next = p; // 将新结点插入到队尾
Q.rear = p; // 修改队尾指针
return OK;
}
Ⅲ、出队
链队在出队前也需要判断队列是否为空,在出队后需要释放出队头元素的所占空间
步骤:
- 判断队列是否为空,若空则返回 ERROR
- 临时保存队头元素的空间,以备释放
- 修改队头指针,指向下一个结点
- 判断出队元素是否为最后一个元素,若是,则将队尾指针重新赋值,指向头结点
- 释放原队头元素的空间
Status DeQueue(LinkQueue &Q,QElemType &e){
// 删除 Q 的队头元素,用 e 返回其值
if(Q.front == Q.rear) // 若队列为空,则返回 ERROR
return ERROR;
p = Q.front->next; // p 指向队头元素
e = p->data; // e 保存队头元素的值
Q.front->next = p->next; // 修改头指针
if(Q.rear == p)
Q.rear = Q.front; // 最后一个元素被删,队尾指针指向头结点
delete p; // 释放原队头元素的空间
return OK;
}
Ⅳ、取队头元素
当队列非空时,返回当前队头元素的值,队头指针保持不变
SElemType GetHead(LinkQueue Q){
// 返回 Q 的队头元素,不修改队头指针
if(Q.front != Q.rear) // 队列非空
return Q.front->next->data; // 返回队头元素的值,队头指针不变
}
参考资料:《数据结构 C语言版 严蔚敏 第2版》