栈和队列

定义

(stack) 是只允许一端进行插入或删除操作的线性表(是同一端).

重要术语

  • 栈顶
  • 栈底
  • 空栈

特点:LIFO(Last In First Out)后进的先出

基本操作

  • InitStack(& S):初始化栈。构造一个空栈S,分配内存空间。
  • DestroyStack(& S):销毁栈。销毁并释放栈S所占的内存空间。
  • Push(&S, x):进栈,若栈S未满,则将x加入使之成为新栈顶
  • Pop(&S, &x):出栈,若栈S非空,则弹出栈顶元素,并用x返回
  • GetTop(S, &x):读栈顶元素。若栈S非空,则用x返回栈顶元素
    • 栈的差一般只访问栈顶元素
  • StackEmpty(S):判断一个栈S是否为空,若为空,则返回true,否则返回false。

常考题型:判断合法的出栈的顺序(要满足先进后出,后进先出)

n个不同元素进栈,出栈元素不同排列的个数为 $\dfrac{1}{n+1}C_{2n}^{n}$。
上述公式成为卡特兰(Catalan)数,可用数学归纳法法证明。
例如:有3个数据元素abc,有 $\dfrac{1}{4}\cdot\dfrac{6*5*4}{1*2*3}=5$ 种可能
a进a出b进b出c进c出
ab进b出c进c出
abc进cba出
a进a出bc进cb出
ab进ba出c进c出

顺序栈

定义

#define MaxSize 10 // 定义栈中元素的最大个数
typedef struct{
  ElemType data[MaxSize]; // 静态数组存放栈中元素
  int top; // 栈顶指针
}SqStack;

初始化栈:

void InitStack(SqStack &S){
  S.top = -1; // 初始化栈顶指针 
}

判断栈空

bool StackEmpty(SqStack S){
  if(S.top == -1)
    return true;
  else
    return false;
}

顺序栈-进栈

bool Push(SqStack &S, ElemType x){
  if(S.top == MaxSize -1)
    return false;
  S.top++; // 指针加一
  S.data[S.top]=x;
  // 上面这两句可以合成一句
  // S.data[++S.top] = x;
  return true;
}

顺序表-出栈

返回了pop出来的元素

bool Pop(SqStack &S, ElemType &x){
  if(S.top == -1)
    return false;
  x = S.data[S.top];
  S.top--;
  // 上面这两句可以合成一句
  // x = S.data[S.top--];
  return true;
}

顺序栈-读栈

获得栈顶元素

bool GetTop(SqStack S, ElemType &x){
  if(S.top == -1)
    return fasle;
  x = S.data[S.top];
  return true;
}

top如果一开始设计为-1,那么top表示指向的栈顶元素,top一开始如果设计为零,那么top指向的位置是栈顶元素的下一个,也表示当前栈的元素个数

顺序栈-共享栈

提高内存空间的利用率

#define MaxSize 10
typedef struct{
  ElemType data[MaxSize];
  int top0; // 0号栈栈顶指针
  int top1; // 1号栈栈顶指针
}ShStack;

初始化栈

void InitStack(ShStack &S){
  S.top0=-1; // 初始化栈顶指针
  S.top1=MaxSize;
}

相当于一个空间,两个栈分别把内容推到两头

栈的链式实现

感觉和头插法差不多,单链表只能操作头下一个结点

链头就是栈顶

typedef struct Linknode{
  ElemType data;
  struct Linknode *next;
}*LiStack;

比较简单

队列

队列(Queue) 是只允许在一端进行插入,在另一端删除的线性表

特点:先进队列的元素先出FIFO(First In First Out)
重要术语

  • 队头:允许删除的一端
  • 队尾:允许插入的一端
  • 空队列

基本操作

  • InitQueue(&Q):初始化队列,构造一个空队列Q
  • DestroyQueue(&Q):销毁队列,销毁并释放队列Q所占的内存空间
  • EnQueue(&Q, x):入队,若队列Q未满,将x加入,使之成为新的队尾
  • DeQueue(&Q, &x):出队,若队列Q非空,删除对头元素,并用x返回
  • GetHead(Q, &x):读队头元素,若队列Q非空,则将对头元素赋值给x
  • QueueEmpty(Q):判断队列是否为空,若队列Q为空返回true,否则返回false

DeQueue 和 GetHead 的区别在于GetHead不删除对头元素

队列和栈都是操作受限的线性表

队列-顺序存储

#define MaxiSize 10
typedef struct{
  ElemType data[MaxSize]; // 用静态数组存放队列元素
  int front, rear; // 队头指针和队尾指针
}SqQueue;

顺序存储是指物理空间上的连续
队尾指针是指向下一个存储的位置

初始化队列

void InitQueue(SqQueue &Q){
  // 初始时 对头、队尾指针指向0
  Q.rear = Q.front = 0;
}

判断队列是否为空

bool QueueEmpty(SqQueue Q){
  if(Q.rear == Q.front)
      return true;
  else
      return false;
}

队列-入队

bool EnQueue(SqQueue &Q, ElemType x){
  if((Q.rear+1)%MaxSize == Q.front)
    return false;
  Q.data[Q.rear] = x;
  Q,rear=(Q.rear+1)%MaxSize;
  return true;
}

用这种方式实现的队列成为循环队列,用模运算将存储空间在逻辑上变成了“环状”,这样会浪费一个空间位置,可以通过给SqQueue增加一个size元素,来存储队列中元素的个数,来判断队列已满还是为空
或者增加一个tag元素,用来表示最近一次的操作是插入还是删除,因为判空是通过front == rear 判断的,因此存了MaxSize个数据后,front会等于rear,因此有三种方法

  • 只存MaxSize-1个数据,这样只有为空的时候才会front==rear
  • 队列结构里增加一个size元素表示队列里元素的个数
  • 队列结构里增加一个tag元素表示最近一次操作,如果front== rear,那么tag表示删除则为空,tag表示插入则为满

队列-出队

删除一个对头元素,并用x返回

bool DeQueue(SqQueue &Q, ElemType &x){
  if(Q.rear == Q.front)
    return false;
  x = Q.data[Q.front];
  Q.front = (Q.front+1)%MaxSize;
  return true;
}

获得对头元素的值,用x返回

bool GetHead(SqQueue Q, ElemType &x){
  if(Q.rear == Q.front)
    return false;
  x = Q.data[Q.front];
  return true;
}

队列元素个数

int Length(SqQueue Q){
  return (Q.rear+MaxSize-front)%MaxSize;
}

之前讲的都是队尾指针指向下一个存储的地址,也可以队尾指针指向队尾元素,此时的判空方法

(rear+1)%MaxSize == front

但是同样的需要牺牲一个存储单元来避免两种状态(满,空)都会识别为空,解决方法和之前相同

  • 牺牲一个存储单元
  • 增加辅助变量

队列的链式实现

// 链式队列结点 
typedef struct LinkNode{
  ElemType data;
  struct LinkNode *next;
}LinkNode;

// 链式队列
typedef struct{
  LinkNode *front, *rear;
}LinkQueue;

同样分为带头结点和不带头结点

初始化(带头结点)

void InitQueue(LinkQueue &Q){
  // 初始化时 front、rear都指向头结点
  Q.front = Q.rear = (LinkNode *)malloc(sizeof(LinkNode));
  Q.front->next = NULL;
}

判断队列是否为空

bool IsEmpty(LinkQueue Q){
  if(Q.front == Q.rear)
    return true;
  else
    return false;
}

入队(带头结点)

void EnQueue(LinkQueue &Q, ElemType x){
  LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode));
  if(s == NULL)
    return false;
  s->data = x;
  s->next= NULL;
  Q.rear->next = s; // 新节点插入到rear之后
  Q.rear = s;       // 修改表尾指针
}

出队(带头结点)

bool DeQueue(LinkQueue &Q, ElemType &x){
  if(Q.front == Q.rear)
    return false;
  LinkNode *p = Q.front->next;
  x = p->data; // 用变量x返回队头元素
  Q.front->next = p->next;
  if(Q.rear == p){ // 如果此次时最后一个结点出队
    Q.rear = Q.front; // 修改rear指针
  }
  free(p);  // 释放结点空间
  return true;
}

初始化(不带头结点)

void InitQueue(LinkQueue &Q){
  // 初始化时 front、rear 都指向NULL
  Q.front = NULL;
  Q.rear = NULL;
}

判断队列是否为空(不带头结点)

bool IsEmpty(LinkQueue Q){
  if(Q.front == NULL)
    return true;
  else
    return false;
}

入队(不带头结点)

void EnQueue(LinkQueue &Q, ElemType x){
  LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode));
  s->data = x;
  s->next =NULL;
  if(Q.front == NULL){// 对第一个元素特殊处理
    Q.front = s;
    Q.rear=s;
  } else {
    Q.rear->next = s;
    Q.rear = s;
  }
}

出队(不带头结点)

bool DeQueue(LinkQueue &Q, ElemType &x){
  if(Q.front == NULL)
    return false; // 空队
  LinkNode *p = Q.front;
  x = p->data;
  Q.front = p->next;
  if(Q.rear == p){
    Q.front = NULL;
    Q.rear = NULL;
  }
  free(p);
  return true;
}

双端队列

双端队列是允许从两端插入、两端删除的线性表。
输入受限的双端队列是只允许从一段插入,两端删除的线性表。
输出受限的双端队列是只允许两端插入,一端删除的线性表。

常考输出序列的合法性,主要是自己分析orz

栈-括号匹配

// 初始化栈
void InitStack(SqStack &S)

// 判断栈是否为空
bool StackEmpty(SqStack S)

// 新元素入栈
bool Push(SqStack &S, char x)

// 栈顶元素出栈,用x返回
bool Pop(SqStack &S, char &x)

bool brancketCheck(char str[], int length){
  SqStack S;
  InitStack(S);
  for (int i =0; i<length; i++){
    if(str[i] == '(' || str[i] == '[' || str[i] = '{'){
      Push(S, str[i]); // 扫描到左括号,入栈
    } else {
      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;
    }
    return StackEmpty(S); // 检索完全部括号后,说明匹配成功
  }
}

栈-表达式求值

原理

Reverse Polish notation 逆波兰表达式=后缀表达式
Polish notation 波兰表达式=前缀表达式

中缀表达式就是操作符在两个操作数中间,比如1 + 1
后缀表达式就是操作符在两个操作数后面,比如1 1 + 前缀表达式就是操作符在两个操作数前面,比如+ 1 1

a+b-c 的后缀表达式是 a b + c -
a+b-c 的前缀表达式是 - + a b c
一个表达式的后缀表达式和前缀表达式是不唯一的
a+b-c*d 的后缀表达式是 a b+ c d* -
a+b-c*d 的前缀缀表达式是 -+a b * c d

中缀表达式转后缀表达式的方法:

  • 确定中缀表达式中各个运算符的运算顺序
  • 确定下一个运算符,按照【左操作数 右操作数 运算符】 的方式组成一个新的操作数
  • 如果还有运算符没被处理,就继续上一步

因为运算顺序不唯一,因此对应的后缀表达式也不唯一

“左优先”原则:只要左边的运算符能计算,那就优先计算最左边的,这样可以保证运算顺序的唯一性

比如:

A+B-C*D/E+F 转化为
A B + C D * E / - F +

我们要怎么样计算这样一个后缀表达式呢?
以上面的例子为例:
先将A,B压入栈中,读到一个操作符,则弹出两次栈顶元素,进行计算,再压入栈中,即:

  • 压入A
  • 压入B
  • 读到+号
  • 弹出B
  • 弹出A
  • 计算A+B,并压入栈中
  • 压入C
  • 压入D
  • 读到*号
  • 弹出D
  • 弹出C
  • 计算C*D,并压入栈中
  • 压入E
  • 读到/号
  • 弹出E
  • 弹出C*D
  • 计算C*D/E,并压入栈中
  • 读到-号
  • 弹出C*D/E
  • 弹出A+B
  • 计算(A+B)-(C*D/E),并压入栈中
  • 压入F
  • 读到+号
  • 弹出F
  • 弹出(A+B)-(C*D/E)
  • 计算(A+B)-(C*D/E)+F并压入栈中
  • 扫描结束,得到最后结果(A+B)-(C*D/E)+F

中缀表达式转换成前缀表达式:

  • 确定中缀表达式中各个运算符的运算顺序
  • 选择下一个运算符,按照【运算符 左操作数 右操作数】 的方式组合成一个新的操作数
  • 如果还有运算符没有被处理,就继续上一步

“右优先”原则:只要右边的运算符能先计算,就优先计算右边的

比如

A+B*(C-D)-E/F 转化为

  • A - * B- C D / E F
  • 从右往左扫描,直到处理完所有的元素
  • 若扫描到操作数则压入栈,并回到上一步,否则执行下一步
  • 若扫描到运算符,则弹出两个栈顶元素,指向相应运算,运算结果压会栈中,返回第一步

基本和后缀表达式一致

代码

中缀表达式转后缀表达式

  • 初始化一个栈,用于保存暂时还不能确定运算顺序的运算符
  • 从左到右处理各个元素,直到末尾,可能遇到三种情况
    • 遇到操作数,直接加入后缀表达式
    • 遇到界限夫。遇到“(” 直接入栈;遇到")" 则依次弹出栈内运算符并加入后缀表达式,直到弹出"(".注意 “(” 不加入后缀表达式
    • 遇到运算符。依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到“(” 或栈空则停止。之后再把当前运算符入栈。

按上述方法处理完所有字符后,将栈中剩余运算符依次弹出,并加入后缀表达式。

例子:

A+B-C*D/E+F
开始扫描
A 加入后缀表达式
 后缀表达式:A
 栈:空
扫描到+号,由于栈空,于是把+号压入栈中
 后缀表达式:A
 栈:+
扫描到B,加入后缀表达式
 后缀表达式:A B
 栈:+
扫描到-号,弹出+号加入后缀表达式,压入-号
 后缀表达式:A B +
 栈:-
扫描到C,压入栈中
 后缀表达式:A B + C
 栈:-
扫描到 * 号,-号比、*号优先级低,*直接压入栈中
 后缀表达式:A B + C
 栈:- *
扫描到D,加入后缀表达式
 后缀表达式:A B + C D
 栈:- *
扫描到/,弹出*,弹出-,把、压入栈中
 后缀表达式:A B + C D *
 栈:- /
扫描到E,加入后缀表达式
 后缀表达式:A B + C D * E
 栈:- /
扫描到+,弹出/,弹出-,压入 +
 后缀表达式:A B + C D * E / -  栈:+
扫描到F,加入后缀表达式
 后缀表达式:A B + C D * E / - F  栈:+
扫描结束,弹出+,加入后缀表达式
 后缀表达式:A B + C D * E / - F +  栈:空

大概这样一个过程,得到后缀表达式

带有括号的例子

A + B*(C-D)-E/F
跳过前面类似的几步
扫描到(,压入栈中
 后缀表达式:A B
 栈:+ * (
扫描到C,加入后缀表达式
 后缀表达式:A B C
 栈:+ * (
扫描到-,压入栈中,遇到(,直接压入栈
 后缀表达式:A B C
 栈:+ * ( -
扫描到D,加入后缀表达式
 后缀表达式:A B C D
 栈:+ * ( -
扫描到),弹出-,加入后缀表达式。弹出(
 后缀表达式:A B C D -
 栈:+ *
扫描到-,弹出*,弹出-,压入-
 后缀表达式:A B C D - * +
 栈:-
扫描到E,加入后缀表达式
 后缀表达式:A B C D - E
 栈:-
扫描到/,压入栈中
 后缀表达式:A B C D - E
 栈:- /
扫描到F,加入后缀表达式
 后缀表达式:A B C D - E F
 栈:- /
扫描结束,弹出/, 弹出-
 后缀表达式:A B C D - E F / -
 栈:空

这样我们就通过栈得到了由中缀表达式得到后缀表达式的算法

在生成后缀表达式的同时可以计算值

栈的应用-递归

函数调用的特点:最后被调用的函数最先执行结束(LIFO)

函数调用时,需要用一个栈存储:

  • 调用返回地址
  • 实参
  • 局部变量

适合用“递归”算法解决的:可以把原问题转换为属性相同,但规模较小的问题:

  • 计算正整数的阶乘
  • 计算斐波那契数列

递归调用时,函数调用栈可称为“递归工作栈”
没进入一层递归,就将递归调用所需信息压入栈顶
每退出一层递归,就从栈顶弹出相应信息

太多层递归可能会导致栈溢出,可能会包含很多的重复运算

队列的应用

  • 树的层次遍历
    到“树”之后还会再提到,这里只做了解
  • 图的广度优先遍历
  • 在操作系统中的应用

多个进程争抢着使用有限的系统资源时,FCFS(First Come First Service,先进来先服务)是一种常用策略。

特殊矩阵的压缩存储

特殊矩阵:

  • 对称矩阵
  • 三角矩阵
  • 三对角矩阵
  • 稀疏矩阵

二维数组的存储策略:行优先存储、列优先存储

一个2×2的矩阵 行优先存储:(0,0)、(0,1)、(1,0)、(1,1)
列优先存储:(0,0)、(1,0)、(0,1)、(1,1)

对称矩阵

对称矩阵的压缩存储,由于对阵矩阵的特殊性,因此只需要存储上三角区域即可,按行优先原则将各元素存入一维数组B中即可
基于行优先:

B[0] B[1] B[2] B[n-1] B[n]
a11 a12 a13 a1n a22

这样只需要存n(n+1)/2个元素即可,具体访问的时候可以做一个映射

三角矩阵

三角矩阵是下三角矩阵为任意数,上三角区域除去对角线为相同的常数,因此我们可以在对称矩阵的基础上加一个位置,存放这个常量,其他都一样

三对角矩阵

三对角矩阵又被叫做带状矩阵,除了主对角线左边和右边以外的元素都为0。按行优先原则,只存储带状部分

稀疏矩阵

非零元素远远小于矩阵的元素的个数
顺序存储:通过三元组存储<行,列,值>,

链式存储:十字链表法
存了五个数据:行、列、值、指向同列下一个元素、下指向同行下一个元素