第三章:栈、队列和数组

栈的基本概念

栈实际就是一种特殊的线性表,可以看做是一种操作受限的线性表,对于普通的线性表,我们可以在任意的地方插入和删除,但是对于栈来说我们只能在线性表的一端进行。
接下来是一些基本概念
空栈:没有任何数据元素的栈就是空栈
栈顶:允许进行插入和删除操作的一端
栈底:不允许进行插入和删除操作的一端
1695189335.jpg
由于栈的进出只能从一端,所以栈有一个很显著的特点就是后进先出,例如该栈是按照1,2,3,4,5的顺序依次入栈,那么出栈顺序就是5,4,3,2,1
接下来是栈相关的基本操作

  • InitList(&L):初始化表。构造一个空的线性表L,分配内存空间。
  • DestroyList(&L):销毁操作。销毁线性表,并释放线性表L所占用的内存空间。
  • ListInsert(&L,i,e):插入操作。在表L中的第i个位置上插入指定元素e。
  • ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。
  • LocateElem(L,e):按值查找操作。在表L中查找具有给定关键字值的元素。
  • GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值。
  • Length(L):求表长。返回线性表L的长度,即L中数据元素的个数。
  • PrintList(L):输出操作。按前后顺序输出线性表L的所有元素值。
  • Empty(L):判空操作。若L为空表,则返回true,否则返回false。

getTop操作只会读取栈顶元素的值,不会删除栈顶元素
对于栈来说,大多数使用场景下都只会获取栈顶元素,一般不会涉及到按位序查找这种操作

栈这一节最常见的考题是给一个入栈顺序,要你给出可能的出栈顺序,或者反过来考
例如:入栈顺序为a,b,c,d,e,问你出栈顺序有哪些
这种题可以在草稿纸上演算

n个不同的元素入栈,出栈元素的不同排序的个数有 1 n + 1 C 2 n n \frac{1}{n+1}C_{2n}^{n} n+11C2nn

顺序栈的实现

前面说过,栈其实就是一个操作受限的线性表,换句话说,栈也是一个线性表,并且由于他操作受限,实现起来的难度也不如线性表。

#define MAXSIZE 100
typedef struct{
	int data[MAXSIZE];//元素
	int top;//栈顶指针
} Stack;

以上代码就是一个顺序栈的结构体定义,难度不算大,初始化也只需要修改top的值即可,但是这里要稍加注意,top可以让他指向两个位置,第一个位置是直接指向栈顶元素,第二个位置是指向栈顶元素的下一个位置,如果我要让他直接指向栈顶元素,那么初始化时就应该设为-1,如果要让他指向栈顶元素的下一个位置,那就应该初始化为0,这个没有硬性要求,全凭自己喜好,我们这里让他指向-1的位置,这里给出栈的初始化以及判空操作

void init(Stack &s){
	s.top = -1;
}
bool isEmpty(Stack s){
	return s.top == -1;
}
bool isFull(Stack s){
	return s.top == MAXSIZE-1;
}

接下来我们来看增删改查的操作,一般来说栈只允许操作栈顶位置, 下面给出入栈操作的代码

bool push(Stack &s,int e){
	if(isFull(s)){
		return false;
	}
	s.top++;
	s.data[s.top] = e;//若top指向的是栈顶元素的下一个元素,则需要交换一下位置
	return true;
}

出栈操作和入栈操作差不多,可以看做是入栈的逆操作,下面直接给出代码

bool pop(Stack &s,int &e){
	if(isEmpty(s)){
		return false;
	}
	e = s.data[s.top];
	s.top--;
	return true;
}

接下来就是查找操作了,一般来说对于栈的操作都只会涉及到查找栈顶元素,获取栈顶元素几乎等价于出栈操作,以下给出代码

bool getTop(Stack s,int &e){
	if(isEmpty(s)){
		return false;
	}
	e = s.data[s.top];
	return true;
}
#include <iostream>
using namespace std;
#define MAXSIZE 100
typedef struct{
	int data[MAXSIZE];//元素
	int top;//栈顶下标
} Stack;
void init(Stack &s){
	s.top = -1;
}
bool isEmpty(Stack s){
	return s.top == -1;
}
bool isFull(Stack s){
	return s.top == MAXSIZE-1;
}
bool push(Stack &s,int e){
	if(isFull(s)){
		return false;
	}
	s.top++;
	s.data[s.top] = e;//若top指向的是栈顶元素的下一个元素,则需要交换一下位置
	return true;
}
bool pop(Stack &s,int &e){
	if(isEmpty(s)){
		return false;
	}
	e = s.data[s.top];
	s.top--;
	return true;
}
bool getTop(Stack s,int &e){
	if(isEmpty(s)){
		return false;
	}
	e = s.data[s.top];
	return true;
}
void print(Stack s){
	for(int i = 0;i<s.top+1;i++){
		cout << s.data[s.top-i]<<endl;
	}
}
int main(void){
	Stack s;
	init(s);
	push(s,1);
	push(s,3);
	push(s,2);
	int e;
	pop(s,e);
	print(s);
	return 0;
}

接下来介绍一种比较特别的栈,共享栈,共享栈就是指两个栈共享同一片存储空间,共享栈定义如下

#define MAXSIZE 100
typedef struct{
	int top1;
	int top2;
	int data[MAXSIZE];
} Stack;

一个共享栈有两个栈顶,一个在上面一个在下面,可以从两头同时插入数据,操作逻辑和一般的栈大差不差,只不过在判满操作上略有不同,接下来给出初始化代码以及判满操作,我们假设top1为下面的栈

bool isFull(Stack s){
	return s.top1+1 == s.top2;
}

其余代码逻辑和普通栈没有区别,相关代码逻辑读者可以自行实现

栈的增删改查操作均可以在O(1)的时间复杂度内完成,对于一些比较简单的操作,例如栈清空操作,本节并没有给出,读者感兴趣可以自行实现

链栈

链栈也就是基于链式存储的栈结构,实际上如果我们对链表进行限制,让其只能在第一个元素的位置插入和删除,实际上就已经是一个栈了,下面给出具体代码

#include <iostream>
using namespace std;
typedef struct Node{
	int data;
	Node *next;
} Node,*Stack;
void init(Stack &s){
	s = new Node;//头结点
	s -> next = NULL;
}
bool isEmpty(Stack s){
	return s -> next == NULL;
}
bool push(Stack &s,int e){
	Node *newNode = new Node;
	newNode->data = e;
	newNode->next = s -> next;
	s -> next = newNode;
	return true;
}
bool pop(Stack &s,int &e){
	if(isEmpty(s)){
		return false;
	}
	Node *oldNode = s -> next;
	e = oldNode -> data;
	s ->next = oldNode -> next;
	return true;
}
int getTop(Stack s){
	if(isEmpty(s)){
		return false;
	}
	return s -> next -> data;
}
void print(Stack s){
	Node *temp = s->next;
	while(temp != NULL){
		cout << temp->data <<endl;
		temp = temp -> next;
	}
}
int main(void){
	Stack s;
	init(s);
	push(s,1);
	push(s,2);
	push(s,3);
	int e;
	pop(s,e);
	print(s);
	return 0;
}

队列

队列的基本概念

队列和栈类似,也是一种操作受限的线性表,前面我们学到的栈,不管是入栈还是出栈都从同一个方向,而队列则不同,队列要求从一头进,另一头出,即队头进,队尾出
对队列的插入操作称为入队,删除操作称为出队
1695282747.jpg
队列没有栈那么抽象难理解,在现实生活中有非常多的队列,比如就是排队打饭这个例子,你要去打饭肯定是去队尾排,你打完饭后肯定是从队头出来,队列的特点就是先进入队列的元素先出队,后进入的元素后出队
队头就是删除的那一端,队尾就是插入的那一端,空队列就是没有元素的队列,队列的特点就是先进先出,也就是先入队的会先出队。
接下来是队列的一些基本操作

  • 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。

队列的静态实现

由于队列只是一个操作受限的线性表,所以其结构体也和线性表类似

#define MAXSIZE 100
typedef struct {
	int data[MAXSIZE];
	int front,rear;
} Queue;

由于队列的操作是受限的,所以我们只能从对头插入元素,从队尾删除元素,因此就需要设置两个变量front和rear来分别跟踪队头和队尾的位置。
一般来说我们front指向队头元素所在的位置,而rear则指向队尾元素的下一个位置。
下面给出初始化和判空操作

void init(Queue &q){
	q.front = 0;
	q.rear = 0;
}
bool isEmpty(Queue q){
	return q.rear == q.front;
}

在说出队和入队操作之前,应该先了解一下循环队列,我们用静态数组来存放队列的这种形式肯定是要使用循环队列的,否则我们插入的元素个数就是有限的,比如这个队列有100个元素,我插99个元素,rear指向第100个元素,front指向第0个元素,我再删除这99个元素,那么front和rear两个指针都指向第100个元素,这就会产生一个问题,我的front和rear指针回不去了,也就是说我的队列容量现在相当于只剩1了,这显然是我们不想看到的,我们希望在这种情况下,第100个元素插入之后,第101个元素可以插入到空闲的第一个元素的位置,将空间充分利用起来,要实现这个功能,就需要用到取余运算。
取余运算有一个很美好的性质,我们写这样一个算法

for(int i = 0;i<100;i++){
		cout << i%5<<endl;
	}

观察期输出结果,会发现是0,1,2,3,4依次循环输出的,而我们也是希望当rear指针到达队尾时跳到第一个元素的位置,和这个过程有异曲同工之妙,我们只需要加一后对MAXSIZE进行取模,就可以循环往复地使用队列。
1695284900.jpg
接下来看这个队列的插入操作

bool isFull(Queue q){
	return (q.rear+1)%MAXSIZE == q.front;
}
bool enqueue(Queue &q,int e){
	if(isFull(q)){
		return false;
	}
	q.data[q.rear] = e;
	q.rear = (q.rear+1)%MAXSIZE;
	return true;
}

可以看到,对于循环队列来说,每一次更改下标都需要对MAXSIZE进行一次取模,以实现循环的功能

我们这种判断出队和入队操作是通过rear和front两个指针的,从图中也可以看出来,这种算法必然会浪费一个数据元素的空间,我们也可以在数据结构中设置一个size属性来记录队列内元素的个数,每次出队和入队都改变一下size属性,通过size属性来判断队满和队空即可

下面来看出队操作

bool dequeue(Queue &q,int &e){
	if(isEmpty(q)){
		return false;
	}
	e = q.data[q.front];
	q.front = (q.front+1)%MAXSIZE;
	return true;
}

出队和入队都是很类似的
接下来我们给出一个稍微难理解的算法,即求队列长度

int getLen(Queue q){
	return (q.rear-q.front+MAXSIZE)%MAXSIZE;
}

这个算法只有一句话,但是很难理解,这里解释一下,首先,我们有两种情况,第一种就是q.rear-q.front减出来是正数,也就是rear在front之后,这个时候是我们很好理解的,长度就是直接相减,如下图所示
1695293379.jpg
但是问题就是,rear指针有可能在front指针之前,这个时候就不好理解了,如果rear在front之前,就是下面这种情况,如下图所示
1695293414.jpg
此时我们如果使用rear-front,实际得到的就是-(front-rear),也就是空闲块个数的相反数,这一点务必理解清楚,实际上我们要求的长度,就是总块数-空白块数,也就是总块数+空白块个数的相反数,所以我们在(rear-front)的基础上加上总块数就可以得到长度了。
对于取值运算,队列的取值运算一般只会取队头元素的值,相当于是出队操作的简化,这里我们给出具体代码

bool getHead(Queue q,int &e){
	if(isEmpty(q)){
		return false;
	}
	e = q.data[q.front];
	return true;
}

本节中,我们都是让队尾指针指向队尾元素的下一个位置,但也可以让其指向队尾元素的位置,这种情况入队和出队操作只需要修改一下更改指针和修改数值的操作顺序即可,但初始化的时候需要将rear指向MAXSIZE-1的位置

队列的链式实现

前面说了顺序实现,接下来介绍链式存储的队列,由于队列是单链表的阉割版,所以实现队列的也可以参考单链表的方式,队列分为带头结点的版本和不带头结点的版本,接下来看链队列的定义

typedef struct Node{
	int data;
	struct Node *next;
} Node;
typedef struct{
	Node *front,*rear;
} Queue;

我们可以使用带头节点和不带头结点两种方式的链队列,我们这里采用带头节点的方式,需要注意的是,在链队列中,front始终指向头结点,而rear始终指向最后一个节点。

void init(Queue &q){
	Node *p = new Node;
	p -> next = NULL;
	q.front = p;
	q.rear = p;
}

根据这个定义,我们就可以想明白判空操作,由于这种方式不存在判满操作,理论上只要内存足够就不会满,所以这里只有判空操作

bool isEmpty(Queue q){
	return q.front == q.rear;
    //return q.front -> next == NULL;
}

这里给出了两个方式判空,使用哪种随意
有了前面的学习,入队和出队操作都异常简单

bool enqueue(Queue &q,int e){
	Node *p = new Node;//建立新节点
	if(p == NULL){
		return false;
	}
	p -> next = NULL;
	p -> data = e;
	q.rear -> next = p;
	q.rear = p;
	return true;
}
bool dequeue(Queue &q,int &e){
	if(isEmpty(q)){
		return false;
	}
	Node *p = q.front -> next;//要出队的节点
	e = p -> data;
	q.front -> next = p -> next;
	if(q.rear == p){
		q.rear = q.front;
	}
	delete p;
	return true;
}

如果不带头结点的话,对于空队列的入队操作需要单独处理,对于仅剩一个元素的队列的出队操作也需要单独处理,所以这里推荐使用带头节点的元素。
对于链队列的删除操作,一定要注意对最后一个元素的删除需要进行单独处理,删除最后一个元素时需要修改rear的值。
最后就是求队长的操作,链式存储求队长的操作就比较耗时,只有从front指针开始依次往后找,下面给出代码

int getLen(Queue q){
	int num = 0;//队长计数器
	Node *p = q.front;//p指向头结点
	while(p->next != NULL){
		num++;
		p = p->next;//计数器+1,p指向下一个节点
	}
	return num;//循环结束返回num
}

这种实现方式的时间复杂度为O(n),但实际上队长这个信息是我们经常需要用到的,所以我们完全可以在结构体中直接加入一个size变量用来存储长度,并且我们也就可以通过size来对长度进行判断,一举两得。
数据结构其实是非常灵活的,切勿教条化,本课只能介绍一些常见数据结构以及他们的常见实现方式,实际使用中我们为了方便可以根据自己的思路进行改造,甚至可以自己创造。

双端队列

双端队列允许从两端插入,也允许从两端删除,如下图所示
1695298691.jpg
由此还可以引出两个变种,分别是输入受限的双端队列和输出受限的双端队列,如下图所示
1695298743.jpg
相信读者也有能力去实现一个双端队列,无非就是多加一个或两个指针,并且在入队和出队的时候需要指明方向,实际上双端队列并不会考察代码题,主要考察点给你一个输入序列,让你判断输出序列是否合法,下面看例题
数据元素的输入序列为1,2,3,4,则哪些输出顺序是合法的,哪些是非法的,这种题难度不是很大,主要是画图分析,可以多做题来训练。

栈和队列的应用

括号匹配

括号匹配问题就是判断表达式中的括号是否合法,我们写的括号都是成双成对出现的,所以如果出现了不是成双成对出现的括号,我们就认为这个表达式不合法。
举个例子,**()[()()] 这就是一个合法的括号表达式,而(()[)()] **这就是一个括号不合法的表达式,因为他不是成双成对出现的,思路如下:

  • 扫描到的是左括号,直接压入栈
  • 扫描到的是右括号,栈为空,则匹配失败
  • 扫描到的是右括号,栈顶元素无法匹配,则匹配失败
  • 扫描到的是右括号,栈顶元素可以匹配,弹出栈顶元素
  • 扫描完毕,栈内还存在元素,匹配失败,否则匹配成功

括号匹配是栈的经典应用,接下来给出具体代码

#include <iostream>
using namespace std;
#define MAXSIZE 100
typedef struct{
	char data[MAXSIZE];
	int top;//栈顶指针
} Stack;
void init(Stack &s){
	s.top = -1;//栈顶指向-1
}
bool isEmpty(Stack s){
	return s.top == -1;
}
bool isFull(Stack s){
	return s.top == MAXSIZE - 1;
}
bool push(Stack &s,char e){
	if(isFull(s)){
		return false;
	}
	s.top++;
	s.data[s.top] = e;
	return true;
}
bool pop(Stack &s,char &e){
	if(isEmpty(s)){
		return false;
	}
	e = s.data[s.top];
	s.top--;
	return true;
}
bool getTop(Stack &s,char &e){
	if(isEmpty(s)){
		return false;
	}
	e = s.data[s.top];
	return true;
}
bool isLeft(char s){
	//判断当前字符是不是左括号
	return s=='['||s=='('||s=='{';
}
bool isRight(char s){
	//判断当前字符是不是右括号
	return s=='}'||s==']'||s==')';
}
bool isMatch(char left,char right){
	//左右括号是否可以匹配
	if(!(isRight(right) && isLeft(left))){//保证输入正确性
		return false;
	}
	switch(right){
	case ']':
		if(left == '[')
			return true;
		break;
	case ')':
		if(left == '(')
			return true;
		break;
	case '}':
		if(left == '{')
			return true;
		break;
	default:
		return false;
	}
	//不对则返回false
	return false;
}
bool match(Stack &s,char *str,int len){//匹配str中的括号是否匹配,使用s作为辅助栈,括号长度为len
	for(int i;i<len;i++){
		//获取到每一个字符
		char t = str[i];//当前读取到的字符
		//如果当前字符是左括号,则一定压入栈
		if(isLeft(t)){
			push(s,t);
		}else{
			//如果当前字符是右括号,并且栈为空,则一定匹配失败
			if(isRight(t) && isEmpty(s)){
				return false;
			}
			
			//如果当前字符是右括号,栈顶元素匹配则出栈,栈顶元素不匹配则一定匹配失败
			char e;//栈顶元素
			getTop(s,e);
			if(isRight(t) && isMatch(e,t)){//栈顶元素匹配
				pop(s,e);
			}else{
				//栈顶元素不匹配,一定匹配失败
				return false;
			}
		}
		
	
	}
	if(isEmpty(s)){//完成后,如果栈内为空,则匹配成功,否则匹配失败
		return true;
	}else{
		return false;
	}
}
int main(void){
	Stack s;
	init(s);
	//栈初始化结束
	char str[MAXSIZE];//录入括号字符串
	char temp;//用于录入的临时字符串
	cout << "输入括号表达式(输入#结束):";
	cin >> temp;
	int len = 0;//长度
	while(temp != '#'){
		str[len] = temp;
		cin >> temp;
		len++;
	}
	//录入结束
	if(match(s,str,len)){
		cout << "合法括号";
	}else{
		cout << "非法括号";
	}
	//
	return 0;
}

表达式求值

表达式求值顾名思义就是用来给表达式求值,再给表达式求值之前,我们需要了解三种表达式,分别是前缀,中缀,后缀表达式,重点掌握后缀表达式。
中缀表达式就是我们最常用的,比如这种表达式:(15÷(7-(1+1))×3)-(2+(1+1),这种算术表达式一共有三个部分组成,分别是操作数,运算符和界限符,其中操作数就是数字,运算符就是参与运算的符号,界限符就是括号,用于界定优先级。
在中缀表达式中,界限符是必要的,如果没有界限符,那么表达式就会产生运算顺序的问题,而前缀表达式和后缀表达式则是一种不需要括号也可以无歧义地表示表达式的方式,后缀表达式也可以成为逆波兰表达式,前缀表达式也可以称为波兰表达式。
中缀表达式就是操作符在运算符中间,而后缀表达式则是操作符在运算符右边,即中缀表达式a+b写成后缀表达式ab+,前缀表达式则为+ab,这里要强调的是,a和b的顺序是不能发生改变的,尽管在加法中发生改变也不会影响什么,但除法和减法则没有交换律,所以我们索性全都不改变顺序。
中缀表达式a+b-c写成后缀表达式就是ab+c-,写成前缀表达式就是-+abc

对于一个中缀表达式,其 前缀/后缀 表达式可能不唯一

再看一个带乘法的例子,中缀表达式a+b-cd转为前缀表达式为-+abcd,转为后缀表达式为ab+cd*-
最后再看最开始的例子:(15÷(7-(1+1))×3)-(2+(1+1)
这个例子转为后缀表达式为15 7 1 1 + - ÷ 3 * 2 1 1 + + -
读者可以观测以下后缀表达式和中缀表达式运算的先后顺序,中缀表达式中从左往右,符号出现的先后顺序,就是中缀表达式中的运算顺序,前缀表达式反之,由于前缀表达式和后缀表达式运算顺序可以用出现顺序来决定,所以尽管不用括号来控制运算顺序,也可以无歧义地表示表达式。
但是前面说过,由于我们可以人为确定运算顺序,比如a+b+c我可以先让b+c计算,也可以先让a+b计算,正因如此,后缀表达式其实可以不唯一,但对于一个算法而言不允许这样不唯一的情况出现,所以我们必须按照一个固定的算法去确定运算顺序。
我们规定运算顺序采用左优先原则,也就是只要有靠左边的运算符可以先计算,那就计算左边的运算符,下面给出具体实例,序号表示生效顺序:
1695383592.jpg
接下来通过下图来演示如何手算后缀表达式
1695383716.jpg
简而言之,从左向右依次扫描,扫到运算符之后,就把前面两个数按该运算符进行运算,然后将其运算结果看成新的数,接着继续根据以上算法向后扫描,直到扫描完毕。
如果我们将数字看为入栈序列会更好理解,15 7 1 1之后扫描到了+,这是一个运算符,就把最后入栈的两个数,即1 1进行加法运算,得到2,我在压入栈,此时栈内就是15 7 2,这个时候扫描到减法,又是一个操作符我又把7 2出栈运算,运算结果入栈,以此类推,扫描到运算符就弹出两个运算后入栈,扫描到数字就直接入栈,这也是该算法的基本思想。
深入理解了该手算过程后,对于后面计算后缀表达式的程序编写有很大帮助。
前缀表达式和后缀表达式类似但相反,比如后缀表达式的扫描顺序是从左往右,那么前缀表达式的扫描顺序就是从右往左,并且前缀表达式弹出的第一个数应该是左操作数,而后缀表达式弹出的第一个数应该是右操作数。
接下来我们来用计算机实现两个算法,中缀表达式转后缀表达式以及后缀表达式的计算,实现了这两个功能,我们也就实现了对中缀表达式的求值运算。
首先来看中缀表达式转后缀表达式,这里直接给出实现思路:
初始化一个栈,用于保存暂时还不能确定运算顺序的运算符,从左往右依次扫描中缀表达式。

  • 遇到操作数。直接加入后缀表达式。
  • 遇到界限符。遇到“(”直接入栈:遇到“)”则依次弹出栈内运算符并加入后缀表达式,直到弹出“(”为止。注意:“(”不加入后缀表达式。
  • 遇到运算符。依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式,若碰到“(”或栈空则停止。之后再把当前运算符入栈。

按上述方法处理完所有字符后,将栈中剩余运算符依次弹出,并加入后缀表达式。
446168964c6040a7a38007df98e4e065-1.gif
以下用流程图表示

我这里尝试来分析以下该算法的底层逻辑
首先读者可以去看一下前面的例子,都有一个规律,那就是后缀表达式和前缀表达式的数字出现顺序是一样的,例如如下例子

我们可以结合中缀转后缀以及后缀表达式的求值,从而实现中缀表达式的求值

  • 初始化两个栈,操作数栈和运算符栈
  • 若扫描到操作数,压入操作数栈
  • 若扫描到运算符或界限符,则按照“中缀转后缀”相同的逻辑压入运算符栈(期间也会弹出运算符,每当弹出一个运算符时,就需要再弹出两个操作数栈的栈顶元素并执行相应运算,运算结果再压回操作数栈)

递归

递归的过程就是函数自我调用的过程,在函数调用的时候有一个特点,最后调用的函数是最先结束的,实际上函数调用就是通过栈实现的,每当有一个函数被调用,就会将函数调用的信息压入函数调用栈,包括了调用应该返回的返回地址,外部传入的实参,以及局部变量,在函数运行过程中定义以及使用的变量都会保存在该层栈内,
1695392127.jpg
当我们在func1中修改a和b时,修改的其实是func1那一层的a和b,并不会影响main函数中的a和b,这也就是常说的局部变量,同时,在func1函数内部定义的局部变量x其实也是放在这一层的栈内的,当fun1执行完毕之后,就可以把信息弹出栈,这就是函数调用发生的事情。
递归可以帮助我们解决很多问题,对于任何通过递推公式给出的问题我们均可以很方便地写出其递归代码,我们只要有了递归表达式和边界条件就可以直接写出来,但递归的算法复杂度是不太好的,并且很容易导致栈溢出。
当我们了解了递归算法本质就是用栈来实现的,我们完全可以用栈来讲递归算法改写为非递归算法。

队列的应用

队列可以用于树的层次遍历,算法思想如下

  1. 根节点入队列
  2. 将队头元素的孩子节点入队
  3. 队头元素出队(输出)
  4. 如果队列中还有节点,跳转到第二步

队列也可以实现对图的广度优先遍历,实现思想和树的层次遍历几乎相同
队列在OS中可以用来实现先来先服务的调度策略

数组和特殊矩阵

矩阵可以很方便的使用二维数组来存储,首先看一维数组,一维数组在内存中的表示也很好理解,就是开辟了一个连续的内存空间,如图所示
1695393556.jpg
由于每个元素的大小都是相同的,所以我们只需要知道第一个元素的起始地址,我们就可以很方便地知道任何一个元素的起始地址,计算公式为LOC+i单个元素大小 (i从0开始)
由于内存是线性的,二维数组就只有降维成一维来存储,如下图所示
1695393941.jpg
二维数组可以横着存,也就是行有限,也可以竖着存,也就是列优先,这样也可以实现随机存取,当我们需要访问b[i][j]的时候,我们假设以行优先的策略来存储,那么地址就是 LOC+ i
每行元素个数单个元素大小 + j单个元素大小
本质上就是看他前面有多少个元素,抓住了这一点就很好理解了。
矩阵一般就是存放在二维数组里,一般来说矩阵下标从1开始,但数组下标从0开始,这一点务必记住
对于一些特殊的矩阵,我们可以用一些算法来压缩存储空间
首先是对称矩阵,对称矩阵就是一个关于主对角线对称的矩阵,对于这类矩阵,我们只需要存储下面的三角或者上面的三角,我们这里存储下三角区,并且以行优先来存储
1695394423.jpg
在实际内存中就是下图所示
1695394448.jpg
我们可以通过等差数列求和公式来算出我们应该设置的数组大小
S n = n 2 ⋅ ( a 1 + a n ) S n = n a 1 + n ( n − 1 ) 2 d S_{n} = \frac{n}{2}\cdot (a_{1}+a_{n})\\ S_{n} = na_{1}+\frac{n(n-1)}{2}d Sn=2n(a1+an)Sn=na1+2n(n1)d
我们可以根据行号和列号来访问数组的元素,当列号小于行号时,我们可以把行号列号交换来取值,这是对称矩阵的特性,即a[i][j]=a[j][i],当行号大于列号时,我们可以通过一些算法算出该数据的存储位置,原理和我们前面提到的类似,就是算其前面有多少个元素,只不过需要用等差数列求和公式而已。

数组下标为几,就说明前面有几个元素,注意下标和位序的转换

三角矩阵的压缩存储和对称矩阵的压缩存储几乎完全一样,这里不做过多追溯
1695395070.jpg
在三角矩阵中,我们需要的存储单元会比对称矩阵多一个。
三对角矩阵如下图所示
1695395154.jpg
找到规律其实也很简单,我们只需要按照行依次把有数据的元素存入即可,这种矩阵除了第一行和最后一行是两个元素以外,其余各行都是三个元素,并且我们可以用行标减去列标的方式来判断是否有值,如果行标减列表的绝对值大于1,则表示一定没有值,否则就可以计算其存储位置,计算也非常简单,不过多赘述。
我们这里还需要掌握的是根据数组下标来找到元素在矩阵中的坐标,我们可以反推,如下图所示
1695395562.jpg
我们知道有了i和j如何算出k,我们现在有了i和k,也就完全可以解出j

接下来是最后一种稀疏矩阵,稀疏矩阵就是非0元素个数非常少的矩阵,如果为此定义一个二维数组,那么很多都是没有必要的,大多数都是0,我们可以用顺序存储的方式存储一个三元对,每一个对表示行,列,值,三个信息,如下图所示
1695395826.jpg
也可以用十字链表法来表示,如下图所示
1695395936.jpg

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值