栈
栈的类型定义
ADTStack⎩⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎧数据对象:D={ai∣ai∈ElemSet,i=1,2,...,n,n≥0}数据关系:R1={<ai−1,ai>∣ai−1,ai∈D,i=1,2,...,n}约定an端为栈顶,a1端为栈底基本操作:InitStack(&S)DestroyStack(&S)StackLength(S)StackEmpty(S)GetTop(S,&e)ClaerStack(&S)Push(&S,e)Pop(&S,&e)StackTravers(S,visit())
栈的基本操作
Push(&S,e)
- 初始条件:栈S已存在
- 操作结果:插入元素e为新的栈顶元素。
Pop(&S,&e)
- 初始条件:栈已存在且非空
- 操作结果:删除S的栈顶元素,并用e返回其值。
栈的应用举例
例1. 数制转换
算法原理:
N=(N div d)×d+N mod d
例如:
(1348)10=(2504)8
其运算过程如下:
N1348168212N div 81682120N mod 84052
计算顺序由上到下,输出顺序由下至上。计算顺序和输出顺序正好相反。把计算顺序的结果全部进栈,再全部出栈,就能得到一个计算顺序的逆序,即输出顺序。
void conversion(){
InitStack(S);
scanf("%d",N);
while(N){
Push(S,N%8);
N=N/8;
}
while(!StackEmpty(S)){
Pop(S,e);
printf("%d",e);
}
}
例2. 括号匹配的检验
假设在表达式中
( [ ] () )
或 [ ( [ ] [ ] ) ]
等为正确的格式,
[ ( ] )
或( [ () )
或( () ] )
均为不正确的格式。
检验括号是否匹配的方法可用期待的急迫程度这个概念来描述。
分析可能出现的不匹配的情况:
- 到来的右括弧不是“所期待”的;
- 到来的是“不速之客”;
- 直到结束,也没有到来所期待的。
算法的设计思想:
- 凡是出现左括弧,则进栈
- 凡是出现右括弧,首先检查栈是否为空:
- 若栈空,则表明该“右括弧”多余;
- 否则和栈顶元素比较:
- 表达式检验结束时,若栈空,则表明表达式中匹配正确,否则表明“左括弧”有余。
void match(char exp[]){
initStack(S);
char c;int i=0;b=1;
while(exp[i]!='\0'&&b=1){
if(exp[i]=='(' ) push(S,exp[i]);
else if(exp[i]==')'){
c=Pop(S);if(c!='(') b=0;
}
i++;
}
return (b&&StackEmpty(S));
}
例3. 行编辑程序问题
最简单的思想是对用户每输入一个字符存储一次,但是这样并不恰当。
在用户输入一行的过程中,应允许用户输入出现差错,并在发现有误时可以及时更正。
合理的做法是:
设立一个输入缓冲区,用以接受用户输入的一行字符,然后逐行存入用户数据区;并假设"#“为退格符,”@"为退行符(整行删除)。
例子:
终端输入:
whli##ilr#e(s#*s)
outcha@putchar(*s=#++);
则有效的是下列两行:
while(*s)
putchar(*s++)
代码:
while(ch!=EOF){
while(ch!=EOF&&ch!='\n'){
switch(ch){
case '#':Pop(S,c);break;
case '@':ClearStack(S);break;
default:Push(S,ch);break;
}
ch=getchar();
}
ClearStack(S);
if(ch!=EOF) ch=getchar();
}
例4. 表达式求值
限于二元运算符的表达式定义:表达式::=(操作数)+(运算符)+(操作数)操作数::=简单变量∣表达式简单变量::=标识符∣无符号整数表达式的三种标识方法:设Exp=S1+OP+S2则称OP+S1+S2为前缀表示法S1+OP+S2为中缀表示法S1+S2+OP为后缀表示法例如:Exp=a×b+(c−d/e)×f前缀式:+×ab×−c/def中缀式:a×b+c−d/e×f后缀式:ab×cde/−f×+结论:1)操作数之间的相对次序不变;2)运算符的相对次序不同;3)中缀式丢失了括弧信息,致使运算的次序不确定;4)前缀式的运算规则为:连续出现的两个操作数和在它们之前且紧靠它们的运算符构成一个最小表达式;5)后缀式的运算规则为:运算符在式中出现的顺序恰为表达式的运算顺序(这一条非常有优势!);每个运算符和在它之前出现且紧靠它的两个操作数构成一个最小表达式;综上,三种标识方法中后缀式是最好的选择。现在需要解决两个问题:1)如何从后缀式求值?2)怎么把表达式转换成后缀式?如何从后缀式求值?思路:先遍历寻找运算符,再找操作数。具体地,遍历后缀表达式,遇到操作数就进行压栈,遇到运算符时出栈两次,得到第二个和第一个操作数,进行计算,然后再将计算结果压栈,直到遍历完整个后缀表达式,栈中剩下的元素就是计算结果。如何从原表达式获得后缀表达式?每个运算符的运算次序要由它之后的一个运算符来定,在后缀式中,优先数高的运算符领先于优先数低的运算符。从原表达式求得后缀式的规律为:1.设立暂存运算符的栈;2.假设表达式的结束符为"#",于是,预设运算符栈的栈底为"#"3.若当前字符时操作数,则直接发送给后缀式;4.若当前运算符的优先数高于栈顶运算符,则进栈;5.若当前运算符的优先数低于或等于栈顶运算符,推出栈顶运算符发送给后缀式;6."("对它的前后的运算符起隔离作用,")"可视为从对应的左括弧开始的子表达式的结束符。
例5. 实现递归
什么是递归?
递归就是直接或间接的来调用自身。
递归算法:
if 递归终止条件
return 递归终止时的值
else
递归公式
为什么要递归?
- 递归求解比迭代要方便。
- 本身就是递归问题。
- 二叉树等本身就是递归定义的数据结构,其基本操作都是用递归操作来实现的。
栈与递归的关系。
如果使用递归来解决问题,系统会自动定义好栈。
递归算法本身很简单,但是有时考虑到效率的问题,希望用非递归算法来解决。有的问题可以用迭代来解决,但有的问题需要程序员自己定义栈来实现一个递归问题的非递归算法(这个知识点在复习二叉树的会好好研究)。
当一个函数在运行期间调用另一个函数时,在运行该被调用函数之前,需要先完成三项任务:
- 将所有的实在参数、返回地址等信息传递给被调用函数保存;
- 为被调用函数的局部变量分配存储区;
- 将控制权转移到被调用函数的入口。
从被调用函数返回调用函数之前,应该完成下列三项任务:
- 保存被调函数的计算结果;
- 释放被调函数的数据区;
- 依照被调函数保存的返回地址将控制转移到调用函数。
多个函数嵌套调用的规则是:
后调用先返回!
此时的内存管理实行栈式管理
例如:
void main(){
...
a();
...
}
void a(){
...
b();
...
}
void b(){
...
}
栈:
函数b的数据区函数a的数据区main数据区
栈类型的实现
按存储结构分类:
- 顺序栈
- 链栈
顺序栈
顺序栈类似于线性表的顺序映象实现,指向表尾的指针可以作为栈顶指针。
#define STACK_INIT_SIZE 100
typedef struct stack{
SElemType *base;
SElemType* top;
int stacksize;
}SqStack;
注意,非空栈时top始终在栈顶元素的下一个位置。
Status InitStack(SqStack &S,int maxsize){
S.base = new ElemType[maxsize];
if(!S.base) exit(OVERFLOW);
S.top = S.base;
S.stacksize = maxsize;
return OK;
}
Status Push(SqStack &S,SElemType e)
{
if(S.top-S.base>=S.stacksize)
return OVERFLOW;
*S.top++=e;
return OK;
}
Status Pop(SqStack &S,SElemType &e){
if(S.top == S.base) return ERROR;
e=*--S.top;
return OK;
}
链栈
不带头结点的链表,栈顶指针就是头指针。
为什么不设置头结点?
设置头结点的目的是为了将对于第一个位置操作和对于其它位置操作统一起来。但是对于栈来说只能对第一个元素进行操作,没有设置头结点的必要了。
typedef struct node{
SElemType data;
struct node* next;
}StackNode,*LinkStack;
LinkStack top;
链栈的压栈和出栈就是单链表的第一个元素之前插入和删除第一个元素,这里就不放代码了。
队列
队列的类型定义
队列是一个特殊的线性表,只能在队尾进行插入,队头进行删除,是一种先进先出的线性表。
ADT Queue⎩⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎪⎧数据对象:D=ai∣ai∈ElemSet,i=1,2,...,n,n≥0数据关系:R1=<ai−1,ai>∣ai−1,ai∈D,i=1,2,...,n约定其中a1端为队列头,an端为队列尾基本操作:InitQueue(&Q)DestroyQueue(&Q)QueueLength(Q)QueueEmpty(Q)GetHead(Q,&e)ClaerQueue(&Q)EnQueue(&Q,e)DeQueue(&Q,&e)QueueTravers(Q,visit())
队列类型的实现
介绍两种存储方式:
链队列–链式映象
采用链式存储结构的队列称为链队列。
typedef struct QNode{
QElemType data;
struct QNode *next;
}QNode,*QueuePtr;
typedef struct{
QueuePtr front;
QueuePtr rear;
}LinkQueue;
对于带头结点的队列,若队列为空,则队头指针和队尾指针都指向头结点。
Status InitQueue(LinkQueue &Q){
Q.front = Q.rear = new QNode;
if(!Q.front) exit(OVERFLOW);
Q.front->next=NULL;
return OK;
}
入队操作
Status EnQueue(LinkQueue &Q,QElemType e){
p = new QNode;
if(!p) exit(OVERFLOW);
p->data = e;p->next=NULL;
Q.rear->next = P;
Q.rear = p;
return OK;
}
出队操作
Status DeQueue(LinkQueue &Q,QElemType &e){
if(Q.front==Q.rear) return ERROR;
p = Q.front->next;
e = p->data;
Q.front->next = p->next;
if(Q.rear==p) Q.rear = Q.front;
delete(p);
return OK;
}
循环队列–队列的顺序表示和实现
注:
- 循环队列也可以用链式存储来实现,但没有特殊强调的话,一般都是指顺序存储的循环队列。
- 不要把循环队列和循环链表弄混。
循环队列的特点
- 队列的顺序存储结构中用一组地址连续的存储单元依次存放从队头到队尾的元素。
- 附设两个指针front和rear分别指示队头元素的位置和队尾元素的下一个位置。
- 初始化建空对列时令front=rear=0,插入新的队尾元素时尾指针增1,删除队头元素时头指针增1。
- 因为在对队列的操作中,头尾指针只增加不减小,导致被删除元素的空间无法重新利用。尽管队列中实际的元素个数可能远远小于存储空间的规模,但仍不能做入队列操作,该现象称为“假上溢”。
- 克服“假上溢”现象的方法是将顺序队列想象为一个首尾相接的圆环,称之为循环队列。
- 循环队列中无法通过Q.front=Q.rear来判断队列是空还是满。解决此问题的方法至少有三种:
- 另设一个标志位区别队列的空和满。
- 使用一个计数器记录队列中元素的总数(实际上是队列长度)。
- 少用一个元素的空间,约定以“队列头指针在队尾指针的下一个位置(指环状的下一位置)上”作为队列呈满状态的标志。(在后续算法中我们使用这种方法)
循环队列的类型定义
#define MAXSIZE 100
typedef struct SqQueue{
QElemType *base;
int front;
int rear;
int queuesize;
}SqQueue;
初始化操作
Status InitQueue(SqQueue &Q,int maxsize){
Q.base = new ElemType[maxsize];
if(!Q.base) exit(OVERFLOW);
Q.queuesize = maxsize;
Q,front = Q.rear = 0;
return OK;
}
入队操作
Status EnQueue(SqQueue &Q,ElemType e){
if((Q.rear+1)%Q.queuesize==Q.front)
return ERROR;
Q.base[Q.rear]=e;
Q.rear=(Q.rear+1)%Q.queuesize;
return OK;
}
循环队列中需要注意的几点:
- 如果
Q.front == Q.rear
,则可以判断循环队列维空
- 如果
(Q.rear + 1)%MAXSIZE == Q.front
,则可以判断循环队列为满
- 无论是对循环队列进行插入或删除元素时,均可能涉及到尾指针或头指针的调整(非简单地对指针进行+1操作),即
Q.rear = (Q.rear + 1)%MAXSIZE
或
Q.front = (Q.front + 1)%MAXSIZE
- 如何理解
(Q.rear - Q.front + MAXSIZE)%MAXSIZE
即为循环队列的长度
当Q.rear > Q.front
时,循环队列长度=Q.rear - Q.front
当Q.rear < Q.front
时,循环队列长度=Q.rear - Q.front + MAXSIZE
综合以上两种情况,循环队列长度(任何情况下)=( Q.rear - Q.front + MAXSIZE ) % MAXSIZE
队列与循环链表
- 队列(包括循环队列)是一个逻辑概念。而链表是一个存储概念。一个队列是否是循环队列,不取决于它将采用何种存储结构,根据实际的需要,循环队列可以采用顺序存储结构,也可以采用链式存储结构,包括采用循环链表作为存储结构。
- 一串数据依次通过一个栈,可以产生多种出栈序列。可能的不同出栈序列数目的计算可利用Catalan函数算出。(Cataland函数,占坑)
一串数据通过一个栈后的次序由每个数据之间的入栈、出栈操作序列决定,只有当“所有数据全部入栈后再出栈”才能使数据倒置。事实上,存在一种操作序列“入栈、出栈、入栈、出栈、…”可以使数据通过栈后仍然保持不变。
- 一串数据通过一个队列,只有一种出队列顺序,就是其入队列顺序。
几种特殊的队列:
- 双端队列:可以在双端进行插入和删除操作的线性表。
- 输入受限的双端队列:线性表的两端都可以输出元素,但是只能在一端输入数据元素。
- 输出受限的双端队列:线性表的两端都可以输入元素,但是只能在一端输出元素。
数组
二维数组的定义:
⎩⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎧数据对象:D={ai,j∣0≤i≤b1−1,0≤j≤b2−1}数据关系:R={ROW,COL}ROW={<ai,j,ai+1,j>∣0≤i≤b1−2,0≤j≤b2−1}COL ={<ai,j,ai,j+1>∣0≤i≤b1−1,0≤j≤b2−2}
数组的顺序表示和实现
数组只有顺序存储结构。
类型特点:
- 只有引用型操作,没有加工型操作;
- 数组是多维的结构,而存储空间是一个一维的结构。
有两种顺序映象的方式:
- 以行序为主序(低下标优先);
- 以列序为主序(高下标优先);
以行序为主序的存储映象
例如:
二维数组A如下:
a0,0a1,0a0,1a1,1a0,2a1,2
行优先存储:
a0,0a0,1a0,2a1,0a1,1a1,2
二维数组A中任意元素
ai,j的存储位置
LOC(i,j)=LOC(0,0)+(b2×i+j)×L
LOC(0,0)为基地址
特殊矩阵的压缩存储
什么是稀疏矩阵?
假设m行n列的矩阵含t个非零元素,则称
δ=m×nt
为稀疏因子。
通常认为
δ≤0.05的矩阵为稀疏矩阵。
这个0.05并不是绝对的,只是概念上需要设置一个稀疏因子。
以常规方法,即以二维数组表示高阶的稀疏矩阵时产生的问题:
- 零值元素占了很大空间;
- 计算中进行了很多和零值的运算,遇到除法,还需判别除数是否为零;
解决问题的原则:
- 尽可能少存或不存零值元素;
- 尽可能减少没有实际意义的运算;
- 操作方便,即:能尽可能快地找到与下标值
(i,j)对应的元素;能尽可能快地找到同一行或同一列的非零值元;
有两类矩阵:
- 特殊矩阵
非零元在矩阵中的分布有一定规则。例如:对称矩阵,对角矩阵,三角矩阵,对角矩阵
- 随机稀疏矩阵
非零元在矩阵中随机出现。
对称矩阵
在一个
n阶方阵A中,若元素满足下述性质:
ai,j=aj,i,0≤i,j≤n−1
则称A为对称矩阵。
对称矩阵中的元素关于主对角线对称,故只需要存储矩阵中上三角或下三角中的元素,让每两个对称的元素共享一个存储空间,这样,能节约近一半的存储空间。不失一般性,按行优先顺序存储主对角线(包括对角线)以下的元素,其存储形式如图所示:
在这个下三角矩阵中,第i行恰有i+1个元素,元素总数为:
(i+1)=n(n+1)/2
因此,将这些元素存放在一个向量
sa[0,...,n(n+1)/2]中。为了便于访问对称矩阵A中的元素,必须在
ai,j和
sa[k]之间找一个对应关系。
- 若
i≥j,则
ai,j在下三角中。
ai,j之前的
i行(从第
0行到第
i−1行)一共有
1+2+...+i=i(i+1)/2个元素,在第i行上,
ai,j之前恰有j个元素(即
ai,0,ai,1,ai,2,...,ai,j−1),因此有:
k=i×(i+1)/2+j,0≤k<n(n+1)/2
- 若
i<j,则
ai,j是在上三角中,因为
ai,j=
aj,i,所以只要交换上述对应关系式中的
i和
j即可得到:
k=j×(j+1)/2+i,0≤k<n(n+1)/2
- 令I=max(i,j),J=min(i,j),则
k和
i,j的对应关系可统一为:
k=I×(I+1)/2+J,0≤k<n(n+1)/2
因此,
ai,j的地址可用下列式计算:
LOC(ai,j)=LOC(sa[k])=LOC(sa[0])+k×d=LOC(sa[0]+[I∗(I+1)/2+J]×d)
- 对于任意给定一组下标
(i,j),均可在
sa[k]中找到矩阵元素
ai,j,反之,对所有的
k=0,1,2,...,n(n−1)/2−1,都能确定sa[k]中的元素在矩阵中的位置(i,j)。
例如
a2,1和
a1,2均存储在
sa[4]中,这是因为
k=I×(I+1)/2+J=2×(2+1)/2+1=4
三角矩阵
以主对角线划分,三角矩阵有上三角和下三角两种,上三角矩阵的下三角(不包括对角线)中的元素均为常数。下三角矩阵正好相反,它的主对角线上方均为常数。在大多数情况下,三角矩阵常数为零。
- 三角矩阵中的重复元素c可共享一个存储空间,其余的元素正好有
n(n+1)/2个,因此,三角矩阵可压缩存储到向量
sa[0,..,n(n+1)/2]中,其中c存放在向量的最后一个分量中。
- 上三角矩阵中,主对角线之上的第
p行
(0≤p<n)恰好有
n−p个元素,按行优先顺序存放上三角矩阵中的元素
ai,j时,
ai,j之前的
i行(
0,...,i−1行)一共有
(n−p)=i(n+(n−(i−1)))/2=i(2n−i+1)/2个元素,在第
i行上,
ai,j前恰好有
j−i个元素:
ai,i,...,ai,j−1。
- 因此,
sa[k]和
ai,j的对应关系是:
k={i(2n−i+1)/2+j−i,n(n+1)/2,当i≤j当i>j
下三角矩阵的存储和对称矩阵类似,
sa[k]和
ai,j对应关系是:
k={i(i+1)/2+j,n(n+1)/2,i≥ji>j
对角矩阵
对角矩阵中,所有的非零元素集中在以主对角线为中心的带状区域中,即除了主对角线和主对角线相邻两侧的若干条对角线上的元素之外,其余元素皆为零。
上图为三对角矩阵。
对于三对角矩阵,非零元素仅出现在主对角(
ai,i,0≤i≤n−1)上,紧邻主对角线上边的那条对角线上(
ai,i+1,0≤i≤n−2)和紧邻主对角线下边的那条对角线上(
ai+1,i,0≤i≤n−2)。显然,当
∣i−j∣>1时,元素
ai,j=0。
由此可知,一个
k对角矩阵(
k为奇数)A是满足下述条件的矩阵:若
∣i−j∣>(k−1)/2,则元素
ai,j=0。
对角矩阵可按行优先顺序或对角线的顺序,将其压缩存储到一个向量中,并且也能找到每个非零元素和向量下标的对应关系。
LOC(i,j)=LOC(0,0)+[3×i−1+(j−i+1)]×d=LOC(0,0)+(2i+j)×d
上例中,
a3,4对应着
sa[10]。
k=2×i+j=2×3+4=10
a2,1对应着
sa[5]
k=2×2+1=5
由此,我们称
sa[0,...,3×n−2]是三阶对角带状矩阵A的压缩存储表示。
上述的各种特殊矩阵,其非零元素的分布都是有规律的,因此总能找到一种方法将它们压缩存储到一个向量中,并且一般都能找到矩阵中的元素与该向量的对应关系,通过这个关系,仍能对矩阵的元素进行随机存取。
随机稀疏矩阵的压缩存储方法
三元组顺序表
用三元组存储非零元素和其所在的行列值
#define MAXSIZE 12500
typedef struct Triple{
int i,j;
ElemType e;
}Triple;
typedef union{
Triple data[MAXSIZE+1];
int mu,nu,tu;
}TSMatrix;
三元组法存放的稀疏矩阵完成矩阵的倒置
占坑
归纳总结
- 两个对称矩阵相加,结果是对称矩阵;两个对称矩阵相乘,结果可能不对称,除非两个矩阵相同。
- 两个三对角矩阵相加,结果是三对角矩阵;两个三对角矩阵相乘,结果不再是三对角矩阵。
- 两个稀疏矩阵相加,还是稀疏矩阵(可能0元素个数有变化);两个稀疏矩阵相乘,结果不一定稀疏。