学习思维导图:
# 栈、队列和数组
## 栈和队列基本概念
## 栈和队列的顺序存储结构
## 栈和队列的链式存储结构
## 多维数组的存储
## 特殊矩阵的压缩矩阵
## 栈、队列和数据的应用
学习思维导图:
# 栈、队列和数组
## 栈和队列基本概念
## 栈和队列的顺序存储结构
## 栈和队列的链式存储结构
## 多维数组的存储
## 特殊矩阵的压缩矩阵
## 栈、队列和数据的应用
栈是一个元素的集合,加入元素的操作叫做“压栈”(push),而移除元素的操作叫做“出栈”(pop)。它遵循后进先出(LIFO, Last In First Out)的原则。这意味着最后被压入栈的元素是第一个被弹出的元素。
push(element)
: 将元素添加到栈的顶部。pop()
: 移除并返回栈顶的元素。如果栈为空,这个操作可能会抛出一个错误或返回特定的值(例如null或undefined),具体取决于实现。peek()
或 top()
: 返回栈顶的元素但不移除它。这只是一个查看操作,栈的内容不会改变。如果栈为空,这个操作可能会抛出一个错误或返回特定的值。isEmpty()
: 判断栈是否为空。如果栈为空,返回true;否则返回false。size()
或 length()
: 返回栈中元素的数量。栈的实现方式有两种,顺序栈和链式栈。 顺序栈是在顺序表的基础上实现栈结构, 链式栈是在链表的基础上实现栈结构。
顺序栈是使用数组来实现的栈,利用数组的索引来模拟栈的操作。 与普通的线性表不同,栈的操作被限制在表的一端进行,这一端被称为栈顶 (Top),另一端被称为栈底 (Bottom)。
顺序栈的关键在于栈顶指针,栈顶指针是一个整数变量,用于指示栈顶元素在数组中的位置。其值的变化直接反映了栈中元素的变化。 当栈空时,一般将栈顶指针设置为 1,当栈满时,栈顶指针指向数组中的最后一个元素。
当元素入栈时,将栈顶指针向后移动一个位置、然后放置新元素即可。当元素出栈时,需要将栈顶指针向前移动一个位置。 当然,入栈和出栈时需要保证数组不满或者不空。
#define MAX_SIZE 100 // 定义栈的最大容量
// 定义顺序栈的结构
typedef struct {
int data[MAX_SIZE]; // 使用数组存储数据
int top; // 栈顶指针
} SeqStack;
// 初始化栈
SeqStack* initStack() {
SeqStack* stack = (SeqStack*)malloc(sizeof(SeqStack));
if(!stack) {
printf("Failed to allocate memory for stack\n");
exit(1);
}
// 栈顶指针初始化为-1,表示栈为空
stack->top = -1;
return stack;
}
// 入栈操作
bool push(SeqStack* stack, int value) {
if (isFull(stack)) {
printf("Stack is full!\n");
return false;
}
// 先移动栈顶指针,再存放元素
stack->data[++stack->top] = value;
return true;
}
// 出栈操作
bool pop(SeqStack* stack, int* value) {
if (isEmpty(stack)) {
printf("Stack is empty!\n");
return false;
}
// 先取出元素,再移动栈顶指针
*value = stack->data[stack->top--];
return true;
}
// 获取栈顶元素
bool peek(SeqStack* stack, int* value) {
if (isEmpty(stack)) {
printf("Stack is empty!\n");
return false;
}
*value = stack->data[stack->top];
return true;
}
// 判断栈是否为空
// 当栈中没有元素时,栈顶指针通常指向一个特殊的位置,例如 -1。
bool isEmpty(SeqStack* stack) {
return stack->top == -1;
}
// 判断栈是否已满
// 当栈顶指针指向数组中的最后一个元素时,说明栈已经满了
bool isFull(SeqStack* stack) {
return stack->top == MAX_SIZE - 1;
}
栈的链式存储结构利用单链表来实现栈的功能。为了保证入栈(Push)和出栈(Pop)操作的时间复杂度为 O(1),通常将链表的头部作为栈顶。
链式栈的栈顶指针指向链表的首部,如果栈为空的话,栈顶指针指向 NULL。
// 定义链式栈的节点结构
typedef struct Node {
int data;
struct Node* next;
} Node;
// 定义链式栈的结构
typedef struct {
Node* top;
} LinkedStack;
// 初始化栈
LinkedStack* initStack() {
LinkedStack* stack = (LinkedStack*)malloc(sizeof(LinkedStack));
if(!stack) {
printf("Failed to allocate memory for stack\n");
exit(1);
}
stack->top = NULL; // 栈顶指针初始化为NULL,表示栈为空
return stack;
}
// 入栈操作
void push(LinkedStack* stack, int value) {
Node* newNode = (Node*)malloc(sizeof(Node));
if(!newNode) {
printf("Failed to allocate memory for new node\n");
exit(1);
}
newNode->data = value;
newNode->next = stack->top;
stack->top = newNode;
}
// 出栈操作
bool pop(LinkedStack* stack, int* value) {
if (isEmpty(stack)) {
printf("Stack is empty!\n");
return false;
}
Node* temp = stack->top;
*value = temp->data;
stack->top = temp->next;
free(temp);
return true;
}
// 获取栈顶元素
bool peek(LinkedStack* stack, int* value) {
if (isEmpty(stack)) {
printf("Stack is empty!\n");
return false;
}
*value = stack->top->data;
return true;
}
// 判断栈是否为空
bool isEmpty(LinkedStack* stack) {
return stack->top == NULL;
}
队列是一种遵循先入先出 (FIFO, First In First Out) 原则的线性数据结构。元素在队尾添加,在队首删除。
enqueue(element)
: 将元素添加到队列的尾部。dequeue()
: 从队列的头部移除并返回元素。如果队列为空,此操作可能会返回特定的值或引发错误。front()
或 peek()
: 返回队首的元素但不移除它。isEmpty()
: 判断队列是否为空。size()
: 返回队列中元素的数量。顺序队列通常是指使用固定大小的数组来存储队列中的元素。在顺序队列中,通常有两个指标:一个是队头(front),另一个是队尾(rear)。当插入(入队)或删除(出队)元素时,这两个指标会移动。
顺序队列有一个明显的问题:随着时间的推移,队列中的元素可能向数组的末尾移动,即使队列并不满,也可能无法再插入新的元素,因为队尾已经达到了数组的末尾。这种现象称为"假溢出"。
为了解决上述问题,可以使用循环队列(也称为环形队列)。循环队列是顺序队列的一个变种,它把数组视为一个循环的结构。当队尾指标达到数组的最后一个位置并且还需要进一步移动时,它会回到数组的起始位置。
需要注意的是,在循环队列中需要牺牲一个存储单元以区分队空和队满的情况。
front == rear
时,队列为空(rear + 1) % size == front
时,队列为满#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int front, rear;
} CircularQueue;
// 初始化队列
void initQueue(CircularQueue* q) {
q->front = q->rear = 0;
}
// 入队操作
bool enqueue(CircularQueue* q, int value) {
if (isFull(q)) return false;
q->data[q->rear] = value;
q->rear = (q->rear + 1) % MAX_SIZE;
return true;
}
// 出队操作
bool dequeue(CircularQueue* q, int* value) {
if (isEmpty(q)) return false;
*value = q->data[q->front];
q->front = (q->front + 1) % MAX_SIZE;
return true;
}
// 获取队首元素
bool front(CircularQueue* q, int* value) {
if (isEmpty(q)) return false;
*value = q->data[q->front];
return true;
}
// 获取队尾元素
bool rear(CircularQueue* q, int* value) {
if (isEmpty(q)) return false;
*value = q->data[(q->rear - 1 + MAX_SIZE) % MAX_SIZE];
return true;
}
// 判断队列是否为空
bool isEmpty(CircularQueue* q) {
return q->front == q->rear;
}
// 判断队列是否满
bool isFull(CircularQueue* q) {
return (q->rear + 1) % MAX_SIZE == q->front;
}
链式队列是使用链表结构来实现的队列。它充分利用了链表的动态性质,允许队列在运行时动态地增长或缩小。链式队列通常有两个指针:一个指向队头,另一个指向队尾。
链式队列通常使用一个简单的单链表来实现,但与普通的单链表不同的是,链式队列有两个指针:一个指向链表的头部(队头),另一个指向链表的尾部(队尾)。
typedef struct Node {
int data;
struct Node* next;
} Node;
typedef struct {
Node* front, *rear;
} LinkedQueue;
// 初始化队列
void initLinkedQueue(LinkedQueue* q) {
q->front = q->rear = NULL;
}
// 入队操作
void enqueueLinked(LinkedQueue* q, int value) {
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->data = value;
newNode->next = NULL;
if(isEmptyLinked(q)) {
q->front = q->rear = newNode;
} else {
q->rear->next = newNode;
q->rear = newNode;
}
}
// 出队操作
bool dequeueLinked(LinkedQueue* q, int* value) {
if (isEmptyLinked(q)) return false;
Node* temp = q->front;
*value = temp->data;
q->front = q->front->next;
if (q->front == NULL) q->rear = NULL; // 当队列中最后一个元素被出队后
free(temp);
return true;
}
// 获取队首元素
bool frontLinked(LinkedQueue* q, int* value) {
if (isEmptyLinked(q)) return false;
*value = q->front->data;
return true;
}
// 判断队列是否为空
bool isEmptyLinked(LinkedQueue* q) {
return q->front == NULL && q->rear == NULL;
}
前序表达式(Preorder,也叫做波兰表示法)、中序表达式(Infix)和后序表达式(Postorder, 也称为逆波兰表示法)都是二叉树的遍历策略,通常用于算术表达式的表示。不同的表示方法对操作符和操作数的顺序有不同的规定。
表达式类型 | 描述 | 示例表达式 | 对应的公式 |
---|---|---|---|
前序 | 操作符位于其操作数之前 | * + A B C | (A + B) * C |
中序 | 操作符位于其两个操作数之间,最常见的表示法 | (A + B) * C | (A + B) * C |
后序 | 操作符位于其操作数之后 | A B + C * | (A + B) * C |
上图是表达式 (A + B) * C
对应的二叉树,前序、后序表达式分别是对这棵树的 前序遍历和后序遍历。
中序表达式求值涉及到两个主要的数据结构:操作符栈与操作数栈。其核心思路是逐个读取中序表达式的字符,根据字符的类型(操作数、操作符、括号)进行不同的处理,最终得到表达式的值。
+、-、*、/
等):以表达式 3 + (5 * 2 - 8)
说明计算过程:
步骤 | 输入字符 | 操作 | 操作符栈 | 数据栈 |
---|---|---|---|---|
1 | 3 | 压入数据栈 | 3 | |
2 | + | 压入操作符栈 | + | 3 |
3 | ( | 压入操作符栈 | +, ( | 3 |
4 | 5 | 压入数据栈 | +, ( | 3, 5 |
5 | * | 压入操作符栈 | +, (, * | 3, 5 |
6 | 2 | 压入数据栈 | +, (, * | 3, 5, 2 |
7 | - | * 比 - 优先级高,* 出栈计算后,将 - 入栈 | +, (, - | 3, 10 |
8 | 8 | 压入数据栈 | +, (, - | 3, 10, 8 |
9 | ) | 处理到(之前的所有操作符 | + | 3, 2 |
10 | 用 + 运算符计算 | 5 |
后序表达式求值主要依赖一个操作数栈。其核心思路是逐个读取后序表达式的元素,并根据元素的类型(操作数或操作符)进行处理。
以表达式 3 5 2 * 8 - +
说明计算过程:
步骤 | 输入字符 | 操作 | 操作数栈 |
---|---|---|---|
1 | 3 | 压入操作数栈 | 3 |
2 | 5 | 压入操作数栈 | 3, 5 |
3 | 2 | 压入操作数栈 | 3, 5, 2 |
4 | * | 弹出两个操作数并相乘,结果压入操作数栈 | 3, 10 |
5 | 8 | 压入操作数栈 | 3, 10, 8 |
6 | - | 弹出两个操作数并相减,结果压入操作数栈 | 3, 2 |
7 | + | 弹出两个操作数并相加,结果压入操作数栈 | 5 |
将中缀表达式转换为后缀表达式的算法思想如下:
初始化一个操作符栈,从左向右开始扫描中缀表达式;
以中序表达式 a/b+(c*d-e*f)/g
为例,可以通过如下步骤将其转换为后续表达式:
待处理序列 | 栈 | 后缀表达式 | 当前扫描元素 | 动作 |
---|---|---|---|---|
a/b+(c*d-e*f)/g | a | a 加入后缀表达式 | ||
/b+(c*d-e*t)/g | a | / | / 入栈 | |
b+(c*d-e*f)/g | / | a | b | b 加入后缀表达式 |
+(c*d-e*f)/g | / | ab | + | + 优先级低千栈顶的 / , 弹出 / |
+(c*d-e*f)/g | ab/ | + | + 入栈 | |
(c*d-e*f)/g | + | ab/ | ( | ( 入栈 |
c*d-e*f)/g | +( | ab/ | c | c 加入后缀表达式 |
*d-e*f)/g | +( | ab/c | * | 栈顶为 ( ,* 入栈 |
d-e*t)/g | +(* | ab/c | d | d 加入后缀表达式 |
-e*f)/g | +(* | ab/cd | - | - 先级低于栈顶的 * ,弹出 * |
-e*f)/g | +( | ab/cd* | - | 栈顶为 ( ,- 入栈 |
e*t)/g | +(- | ab/cd* | e | e 加入后缀表达式 |
*f)/g | +(- | ab/cd*e | * | * 优先级高于栈顶的 - , * 入栈 |
f)/g | +(-* | ab/cd*e | f | f 加入后缀表达式 |
)/g | +(-* | ab/cd*ef | ) | 把栈中 ( 之前的符号加入表达式 |
/g | + | ab/cd*ef*- | / | / 优先级高于栈顶的 + , / 入栈 |
g | +/ | ab/cd*ef*- | g | g 加入后缀表达式 |
+/ | ab/cd*ef*-g | 扫描完毕,运算符依次退栈加入表达式 | ||
ab/cd*ef*-g/+ | 完成 |
入栈出栈是常考的问题,这类问题的核心可以被总结为如下范式:
给定一个元素入栈序列,判断哪些出栈序列是不可能的?
给定一个入栈序列,其出栈序列可能有很多种,因为一个元素的出栈时间比较灵活,比如它可以一入栈马上出栈,也可以等一会再出栈。要辨别不可能的出栈系列,需要抓住关键规则:只有栈顶元素能够最先出栈。
也就是说,对于下图这种情况,A 被 B 压住了,在出栈序列中,A 一定在 B 的后面。如果某个出栈序列中 A 在 B 的前面,则这种出栈序列是不可能出现的。
如果数组的第一个元素位于内存地址 $A$,且每个元素占用 $B$ 个字节,行列总数分别为 $R$ 和 $C$,那么数组的 $(i, j)$ 元素(其中 $i$ 是行号,$j$ 是列号)的内存地址可以计算为 $A + (i \times C + j) * B$。
例子:
在C语言中定义定义如下多维数组
int a[2][3] = {{1, 2, 3}, {4, 5, 6}};
这个数组的内存布局可以视为一个一维数组,如下所示:
+---------+---------+---------+---------+---------+---------+
| a[0][0] | a[0][1] | a[0][2] | a[1][0] | a[1][1] | a[1][2] |
+---------+---------+---------+---------+---------+---------+
0x100 0x104 0x108 0x10C 0x110 0x114
什么是对称矩阵:对于矩阵$A$中的任意一个元素$a_{i, j}$都有 $a_{i, j} = a_{j ,i}$,
所以为了节省存储空间,可以使用一位数组$B$进行存储
如何计算对称矩阵中元素的下标
$A$ 中的元素 $a_{i, j}$ 在数组 $B$ 的下标
$$k = 1 + 2 + \cdots + (i-1) + j - 1 = i(i-1)/2+j-1$$
元素为
如何计算三角矩阵中元素的下标
以上三角矩阵$A$为例,上半部分元素首先按序存储在一位数组$B$中,在最后一个位置添加一个元素,用于存储下三角位置对应的元素
$A$ 中的元素 $a_{i, j}$ 在数组 $B$ 的下标
$$k = n + (n+1) _ \cdots + (n-i+2) + (j-i+1) - 1 = (i-1)(2n-i+2)/2 + (j-i)$$
元素为
三对角矩阵可以使用压缩存储,将3条对角线上的元素按照行优先的方式存放在一位数组B中。
稀疏矩阵中非零元素的个数相比矩阵元素的总个数非常少,通常,稀疏矩阵中的非零元素的分布是没有规律的。因此,非零元素可以用一个三元组来存储(行,列,值),使用三元组组成的线性表来存储稀疏矩阵中南欧给所有的元素。
矩阵