数据结构 | 栈、队列与数组:从概念到 C 语言实现 + 408 真题全解析
本文系统梳理栈、队列、数组三大线性结构:从基本概念、顺序/链式存储的实现细节,到卡特兰数、循环队列判空判满、特殊矩阵压缩存储的计算公式,并配齐 2010---2025 年 408 统考相关真题与详细解析。所有代码均可直接编译运行。
阅读建议 :考研 / 学校考试的同学,重点看卡特兰数、出栈序列合法性判断、中缀转后缀、循环队列判空判满、矩阵压缩下标计算 这五块,它们是这一章的绝对考点;校招同学重点看代码实现和 LeetCode 应用。
目录
- 一、栈(Stack)
- [1.1 基本概念](#1.1 基本概念)
- [1.2 出栈序列问题与卡特兰数](#1.2 出栈序列问题与卡特兰数)
- [1.3 栈的顺序存储](#1.3 栈的顺序存储)
- [1.4 栈的链式存储](#1.4 栈的链式存储)
- [1.5 顺序栈 vs 链式栈](#1.5 顺序栈 vs 链式栈)
- [1.6 栈的应用](#1.6 栈的应用)
- [1.7 栈与递归](#1.7 栈与递归)
- 二、队列(Queue)
- [2.1 基本概念](#2.1 基本概念)
- [2.2 队列的链式存储](#2.2 队列的链式存储)
- [2.3 队列的顺序存储与假溢出](#2.3 队列的顺序存储与假溢出)
- [2.4 循环队列](#2.4 循环队列)
- [2.5 双端队列](#2.5 双端队列)
- [2.6 队列的应用](#2.6 队列的应用)
- 三、数组与特殊矩阵压缩存储
- [3.1 数组的存储结构](#3.1 数组的存储结构)
- [3.2 矩阵压缩存储概述](#3.2 矩阵压缩存储概述)
- [3.3 对称矩阵](#3.3 对称矩阵)
- [3.4 三角矩阵](#3.4 三角矩阵)
- [3.5 三对角矩阵](#3.5 三对角矩阵)
- [3.6 稀疏矩阵](#3.6 稀疏矩阵)
- [四、408 真题汇总表](#四、408 真题汇总表)
- 五、一页速查总结
一、栈(Stack)
1.1 基本概念
先回顾线性表的定义:线性表(linear list)是相同类型的 n(n ≥ 0)个数据元素的有限序列,通常记为:
L = ( a 1 , a 2 , ... , a i , a i + 1 , ... , a n ) L = (a_1, a_2, \dots, a_i, a_{i+1}, \dots, a_n) L=(a1,a2,...,ai,ai+1,...,an)
栈(stack)是限定仅在表的一端进行插入和删除操作的线性表。 因此:
| 术语 | 含义 |
|---|---|
| 栈顶(top) | 允许插入和删除的那一端 |
| 栈底(bottom) | 不允许操作的另一端 |
| 空栈 | 不含任何元素的栈 |
| 入栈 / 进栈 | 在栈顶插入数据 |
| 出栈 / 退栈 | 在栈顶删除数据 |
栈的核心特性是 后进先出(LIFO, Last In First Out)。
生活中的例子:手枪弹夹、羽毛球桶------最后放进去的最先拿出来。
易错点 :栈是"受限的线性表",限制在于只能在一端操作。说"栈允许在其两端进行操作"是错的(2017 年 408 就考了这个说法)。
1.2 出栈序列问题与卡特兰数
这是栈这一章最经典的考点。
问题 :假设入栈顺序为 a1, a2, a3, a4, a5,出栈顺序是什么?
很多人第一反应是"全部入栈再全部出栈",得到 a5, a4, a3, a2, a1。但由于出栈时机不同,出栈序列有很多种:
- 每个元素入栈后立刻出栈 →
a1, a2, a3, a4, a5 - 全部入栈后出栈 →
a5, a4, a3, a2, a1 a1, a2, a3入栈,出a3, a2,再入a4, a5,再出a5, a4, a1→a3, a2, a5, a4, a1
卡特兰数(Catalan Number)
n 个不同元素依次进栈,可能的出栈序列个数是第 n 个卡特兰数:
C n = 1 n + 1 ( 2 n n ) = ( 2 n ) ! ( n + 1 ) ! ⋅ n ! C_n = \frac{1}{n+1}\binom{2n}{n} = \frac{(2n)!}{(n+1)! \cdot n!} Cn=n+11(n2n)=(n+1)!⋅n!(2n)!
其中组合数公式为:
( m n ) = m ! n ! ⋅ ( m − n ) ! \binom{m}{n} = \frac{m!}{n! \cdot (m-n)!} (nm)=n!⋅(m−n)!m!
递推定义:
C 0 = 1 , C n = ∑ i = 0 n − 1 C i ⋅ C n − 1 − i ( n ≥ 1 ) C_0 = 1, \qquad C_n = \sum_{i=0}^{n-1} C_i \cdot C_{n-1-i} \quad (n \ge 1) C0=1,Cn=i=0∑n−1Ci⋅Cn−1−i(n≥1)
即 C n = C 0 C n − 1 + C 1 C n − 2 + ⋯ + C n − 1 C 0 C_n = C_0C_{n-1} + C_1C_{n-2} + \dots + C_{n-1}C_0 Cn=C0Cn−1+C1Cn−2+⋯+Cn−1C0
卡特兰数序列前几项:
C 0 = 1 , C 1 = 1 , C 2 = 2 , C 3 = 5 , C 4 = 14 , C 5 = 42 , C 6 = 132 , ... C_0=1,\ C_1=1,\ C_2=2,\ C_3=5,\ C_4=14,\ C_5=42,\ C_6=132,\ \dots C0=1, C1=1, C2=2, C3=5, C4=14, C5=42, C6=132, ...
举例 n = 3 验证:
C 3 = 1 3 + 1 ( 6 3 ) = 1 4 × 6 ! 3 ! ⋅ 3 ! = 1 4 × 6 × 5 × 4 3 × 2 × 1 = 1 4 × 20 = 5 C_3 = \frac{1}{3+1}\binom{6}{3} = \frac{1}{4} \times \frac{6!}{3! \cdot 3!} = \frac{1}{4} \times \frac{6 \times 5 \times 4}{3 \times 2 \times 1} = \frac{1}{4} \times 20 = 5 C3=3+11(36)=41×3!⋅3!6!=41×3×2×16×5×4=41×20=5
3 个元素确实只有 5 种合法出栈序列(总排列 6 种,只有 c, a, b 这一种不合法)。
考试要求 :考研和学校考试的同学务必记住这个结论公式;校招同学了解即可。
判断某个出栈序列是否合法的通用方法:用入栈序列去"模拟"------依次入栈,每入一个就尝试按目标出栈序列出栈,若最终能全部匹配则合法。本质就是后面要讲的 LeetCode 946 题。
1.3 栈的顺序存储
栈是特殊线性表,线性表有顺序和链式两种存储结构,所以栈也有顺序和链式两种实现。
1.3.1 静态顺序栈与动态顺序栈
和顺序表类似,栈的顺序存储可以用静态数组 (静态顺序栈)或堆上动态申请的数组 (动态顺序栈)。静态顺序栈的缺陷与静态顺序表相同:只适用于明确知道最多需要多少空间的场景。因此实际开发中以动态顺序栈为主。
| 静态栈结构定义 | 动态栈结构定义 |
|---|---|
c\n#define MAX_SIZE 10\ntypedef int STDataType;\ntypedef struct {\n STDataType arr[MAX_SIZE];\n int top; // 栈顶\n} Stack; |
c\ntypedef int STDataType;\ntypedef struct {\n STDataType* arr; // 指向栈数组空间\n int top; // 栈顶位置\n int capacity; // 容量\n} Stack; |
1.3.2 ⚠️ top 的两种约定(必须搞清)
初始化时 top = -1 还是 top = 0,是初学者最容易混乱的地方:
| 约定 | 含义 | 入栈写法 | 栈空条件 | 栈中元素个数 |
|---|---|---|---|---|
top = -1 |
top 指向栈顶元素 | 先 ++top,再放数据 |
top == -1 |
top + 1 |
top = 0 |
top 指向栈顶元素的下一个位置 | 先放数据,再 ++top |
top == 0 |
top |
为什么 top = 0 不能表示"指向栈顶元素"?
因为此时栈为空 和栈中只有一个元素 这两种情况下 top 都是 0,无法区分。这就是"指向栈顶元素"的约定必须用 top = -1 的原因。
两种方式没有优劣之分 ,任选一种,后续所有接口逻辑对应调整即可。本文代码统一采用 top = 0(指向栈顶元素的下一个位置)。
1.3.3 动态顺序栈完整实现
c
// Stack.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int STDataType;
typedef struct {
STDataType* arr; // 指向栈数组空间的指针
int top; // 栈顶位置(指向栈顶元素的下一个位置)
int capacity; // 容量
} Stack;
void StackInit(Stack* s); // 初始化
void StackDestroy(Stack* s); // 销毁
void StackPush(Stack* s, STDataType x); // 入栈
STDataType StackPop(Stack* s); // 出栈并返回栈顶元素
STDataType StackTop(Stack* s); // 获取栈顶元素
int StackSize(Stack* s); // 有效元素个数
bool StackEmpty(Stack* s); // 判空
c
// Stack.c
#include "Stack.h"
void StackInit(Stack* s) {
assert(s);
s->arr = (STDataType*)malloc(4 * sizeof(STDataType));
if (NULL == s->arr) {
printf("StackInit: 申请空间失败!!!\n");
exit(-1);
}
// top = 0 :top 指向的是栈顶元素的下一个位置
// top = -1:top 指向的是栈顶元素
// 两种方式都可以,选择哪一种,后面的逻辑都对应调整即可
s->top = 0;
s->capacity = 4;
}
void StackDestroy(Stack* s) {
assert(s);
if (s->arr) {
free(s->arr);
s->arr = NULL;
s->top = 0;
s->capacity = 0;
}
}
// 入栈:先判满扩容,再放数据,最后 ++top
void StackPush(Stack* s, STDataType x) {
assert(s);
// 满了,扩容为原来的 2 倍
if (s->top == s->capacity) {
STDataType* tmp = (STDataType*)realloc(s->arr, sizeof(STDataType) * s->capacity * 2);
if (tmp == NULL) {
printf("StackPush: 扩容空间失败!!!\n");
exit(-1);
}
s->arr = tmp;
s->capacity *= 2;
}
s->arr[s->top] = x;
s->top++;
}
// 出栈:取出 s->top - 1 位置的数据,再 --top
STDataType StackPop(Stack* s) {
assert(s);
assert(!StackEmpty(s));
STDataType topE = s->arr[s->top - 1];
--s->top;
return topE;
}
STDataType StackTop(Stack* s) {
assert(s);
assert(!StackEmpty(s));
return s->arr[s->top - 1];
}
int StackSize(Stack* s) {
assert(s);
return s->top;
}
bool StackEmpty(Stack* s) {
assert(s);
return s->top == 0;
}
测试用例(注意断言会检查对空栈出栈):
c
#include "Stack.h"
int main() {
Stack s;
StackInit(&s);
StackPush(&s, 1);
StackPush(&s, 2);
StackPush(&s, 3);
StackPush(&s, 4);
StackPush(&s, 5); // 触发一次扩容:4 -> 8
printf("栈顶元素为: %d\n", StackTop(&s)); // 5
printf("出栈: %d\n", StackPop(&s)); // 5
printf("出栈: %d\n", StackPop(&s)); // 4
printf("出栈: %d\n", StackPop(&s)); // 3
printf("出栈: %d\n", StackPop(&s)); // 2
printf("出栈: %d\n", StackPop(&s)); // 1
// 此时栈已空,下面两行会触发断言失败,说明判空保护生效
// printf("出栈: %d\n", StackPop(&s));
// printf("栈顶元素为: %d\n", StackTop(&s));
StackPush(&s, 6);
StackPush(&s, 7);
printf("出栈: %d\n", StackPop(&s)); // 7
printf("出栈: %d\n", StackPop(&s)); // 6
StackDestroy(&s);
return 0;
}
1.4 栈的链式存储
1.4.1 结构选择的关键结论
栈的链式存储可以选单链表 或双向链表,但有几个必须记住的结论:
- 如果用单链表,必须用链表头这一端做栈顶 ,入栈出栈对应头插头删 ,时间复杂度 O ( 1 ) O(1) O(1)。若用链表尾做栈顶,出栈对应尾删,需要找前驱,时间复杂度退化为 O ( N ) O(N) O(N)。
- 如果用双向链表 ,则表头表尾做栈顶都可以,因为双向链表头尾插入删除都是 O ( 1 ) O(1) O(1)。但完全没必要------单链表已经能高效实现且更省空间,双向链表每个结点还要多存一个前驱指针,纯属浪费。
- 带头结点还是不带头结点? 因为入栈出栈就是头插头删,带头结点并不会带来便利 ,所以一般选择不带头结点实现。
1.4.2 链式栈实现
c
// LinkStack.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int STDataType;
// 链式栈底层单链表结点
typedef struct LinkStackNode {
struct LinkStackNode* next;
STDataType data;
} LSNode;
// 链式栈结构
typedef struct {
LSNode* topHead; // 栈顶结点指针
int size; // 栈中数据个数
} LinkStack;
void LinkStackInit(LinkStack* s);
void LinkStackDestroy(LinkStack* s);
void LinkStackPush(LinkStack* s, STDataType x);
STDataType LinkStackPop(LinkStack* s);
STDataType LinkStackTop(LinkStack* s);
int LinkStackSize(LinkStack* s);
bool LinkStackEmpty(LinkStack* s);
c
// LinkStack.c
#include "LinkStack.h"
void LinkStackInit(LinkStack* s) {
assert(s);
// 不带头结点:头插头删,头结点价值不大
s->topHead = NULL;
s->size = 0;
}
void LinkStackDestroy(LinkStack* s) {
assert(s);
LSNode* cur = s->topHead;
while (cur) {
LSNode* next = cur->next;
free(cur);
cur = next;
}
s->topHead = NULL;
s->size = 0;
}
// 入栈:链表空和不为空,头插逻辑完全一样
void LinkStackPush(LinkStack* s, STDataType x) {
assert(s);
LSNode* newNode = (LSNode*)malloc(sizeof(LSNode));
if (NULL == newNode) {
printf("LinkStackPush: 申请节点失败");
exit(-1);
}
newNode->data = x;
newNode->next = s->topHead;
s->topHead = newNode;
s->size++;
}
// 出栈:一个结点和多个结点逻辑完全一样
STDataType LinkStackPop(LinkStack* s) {
assert(s);
assert(!LinkStackEmpty(s));
STDataType top = s->topHead->data;
LSNode* delNode = s->topHead;
s->topHead = delNode->next;
free(delNode);
s->size--;
return top;
}
STDataType LinkStackTop(LinkStack* s) {
assert(s);
assert(!LinkStackEmpty(s));
return s->topHead->data;
}
int LinkStackSize(LinkStack* s) {
assert(s);
return s->size;
}
bool LinkStackEmpty(LinkStack* s) {
assert(s);
return s->topHead == NULL;
}
1.5 顺序栈 vs 链式栈
| 对比项 | 顺序栈 | 链式栈 |
|---|---|---|
| 入栈/出栈时间复杂度 | O ( 1 ) O(1) O(1) | O ( 1 ) O(1) O(1) |
| 空间问题 | 满了需扩容,有一定消耗 | 每个元素额外一个指针开销 |
| CPU 缓存命中率 | 较高 | 较低(结点分散) |
| 内存碎片 | 无 | 有 |
| 适用场景 | 一般首选 | 元素个数波动极大、不好预估时 |
结论:
- 两者入栈出栈都是 O ( 1 ) O(1) O(1),效率都很高。
- 顺序栈唯一的问题是入栈空间不够时扩容有消耗,但这个消耗几乎可以忽略 ,因为要插入一定量数据才会触发扩容(倍增扩容均摊 O ( 1 ) O(1) O(1))。
- 顺序栈相比链式栈 CPU 高速缓存命中率更高,且没有内存碎片 。所以栈的实现一般使用顺序存储。
1.6 栈的应用
1.6.1 LeetCode 20. 有效的括号
题意分析 :括号的类型、顺序、数量都匹配才是有效括号。
- 有效:
()[]{}、(([]{})) - 无效:
(]、([]{)}、(()
解题思路:利用栈后进先出的性质------遍历字符串:
- 遇到左括号就入栈;
- 遇到右括号 就取栈顶左括号进行匹配:
- 类型匹配不上 → 类型或顺序不匹配;
- 栈为空 → 右括号多了,数量不匹配;
- 遍历结束栈不为空 → 左括号多了,数量不匹配。
结论:遍历结束、中途无不匹配、最后栈为空 ⇒ 有效括号。
版本一:封装 Stack 实现(工程推荐做法,可复用、易维护)
c
// 注意:LeetCode 上 C++ 版本用了引用 &,纯 C 版本需改为指针
typedef char STDataType;
typedef struct Stack {
STDataType* a;
int top; // 指向栈顶数据的下一个位置
int capacity;
} Stack;
void StackInit(Stack* pst);
void StackDestroy(Stack* pst);
void StackPush(Stack* pst, STDataType x);
STDataType StackPop(Stack* pst);
STDataType StackTop(Stack* pst);
bool StackEmpty(Stack* pst);
int StackSize(Stack* pst);
void StackInit(Stack* pst) {
assert(pst);
pst->a = NULL;
pst->top = 0;
pst->capacity = 0;
}
void StackDestroy(Stack* pst) {
assert(pst);
free(pst->a);
pst->a = NULL;
pst->top = pst->capacity = 0;
}
void StackPush(Stack* pst, STDataType x) {
assert(pst);
if (pst->top == pst->capacity) {
int newcapacity = pst->capacity == 0 ? 4 : pst->capacity * 2;
STDataType* tmp = (STDataType*)realloc(pst->a, newcapacity * sizeof(STDataType));
if (tmp == NULL) {
perror("realloc fail");
return;
}
pst->a = tmp;
pst->capacity = newcapacity;
}
pst->a[pst->top] = x;
pst->top++;
}
STDataType StackPop(Stack* pst) {
assert(pst);
assert(!StackEmpty(pst));
STDataType x = pst->a[pst->top - 1];
pst->top--;
return x;
}
STDataType StackTop(Stack* pst) {
assert(pst);
assert(!StackEmpty(pst));
return pst->a[pst->top - 1];
}
bool StackEmpty(Stack* pst) {
assert(pst);
return pst->top == 0;
}
int StackSize(Stack* pst) {
assert(pst);
return pst->top;
}
///////////////////////////////////////////////////////////////////
bool isValid(char* s) {
Stack st;
StackInit(&st);
while (*s) {
// 1、左括号入栈
if (*s == '(' || *s == '[' || *s == '{') {
StackPush(&st, *s);
}
else {
// 2、右括号:取栈顶左括号尝试匹配
// 当前是右括号但栈为空,说明前面没有左括号了,不匹配
if (StackEmpty(&st)) {
StackDestroy(&st);
return false;
}
char top = StackPop(&st);
if ((top == '(' && *s != ')')
|| (top == '{' && *s != '}')
|| (top == '[' && *s != ']')) {
StackDestroy(&st);
return false;
}
}
++s;
}
// 栈不为空说明左括号比右括号多,数量不匹配
bool ret = StackEmpty(&st);
StackDestroy(&st);
return ret;
}
版本二:静态数组当栈(适合纸面/上机考试等受限场景)
版本一的弊端是必须完整实现一个栈。如果只是考试中某道题要用栈,可以只给出结构定义和接口声明并备注功能,但上机考试还得硬着头皮写,很麻烦。更简便的方式是直接用一个静态数组当栈 ,再用 top 标记栈顶即可。
c
bool isValid(char* s) {
// 本题说明 s 的长度 <= 10000,直接开静态数组做栈
int stackArr[10000];
int top = 0; // top 标记栈顶数据的下一个位置
while (*s) {
// 1、左括号入栈
if (*s == '(' || *s == '[' || *s == '{') {
stackArr[top++] = *s;
}
else {
// 2、右括号:栈为空说明数量不匹配
if (top == 0) {
return false;
}
char topData = stackArr[--top];
if ((topData == '(' && *s != ')')
|| (topData == '{' && *s != '}')
|| (topData == '[' && *s != ']')) {
return false;
}
}
++s;
}
// 栈不为空,说明左括号比右括号多
return top == 0;
}
重要说明 :实际项目中要用栈,还是要像版本一那样封装实现 。一是项目通常较大,多处都要用栈,封装后处处可用、便于维护;二是如果无法预估数据量,版本二并不实用。版本二更适合纸面考试或上机考试等局限场景。
1.6.2 LeetCode 946. 验证栈序列
题意 :一个入栈序列可能对应多个出栈序列,但有些出栈序列根本不合法。例如 pushed = [1,2,3,4,5]、popped = [4,3,5,1,2]:要出 4 必须先 push 1,2,3,4,接着连续 pop 4,3,再 push 5 并 pop 5;此时栈中还有 1,2,那么 pop 顺序只能是 2,1,不可能是 1,2。
解题思路 :用一个栈模拟入栈过程,看能否按出栈序列的顺序出栈。
- 每轮循环入一个
pushi指向的数据; - 持续循环,将栈顶数据与
popi指向的出栈序列比较,直到栈为空或不匹配; - 如果
pushi没走到入栈序列末尾,继续下一轮。
结论 :pushi 走到入栈序列尾后,若 popi 也走到出栈序列尾(或栈为空),则说明全部匹配。
c
bool validateStackSequences(int* pushed, int pushedSize, int* popped, int poppedSize) {
size_t pushi = 0, popi = 0;
// 本题说明长度 <= 1000,直接开静态数组做栈
int stackArr[1000];
int top = 0; // top 标记栈顶数据的下一个位置
while (pushi < pushedSize) {
// 入栈序列值入栈
stackArr[top++] = pushed[pushi++];
// 持续匹配栈顶数据与出栈序列,直到栈为空或不匹配
while (top != 0 && stackArr[top - 1] == popped[popi]) {
--top;
popi++;
}
}
return top == 0;
}
1.6.3 表达式求值:中缀、前缀、后缀
背景 :任何表达式都由操作数、运算符、界限符组成。算术四则运算规则是:
- 先乘除,后加减;
- 从左算到右;
- 先括号内后括号外。
人能扫描整个表达式、全局确定优先级后依次运算,但计算机不行 ------计算机的本质是依次执行指令,从左往右读取数据,读到就要立即存储或运算,无法先"全局看一眼"。于是表达式表示法被发明出来解决这个问题:
| 时间 | 人物 | 贡献 |
|---|---|---|
| 1920 年代 | 波兰科学家 扬·武卡谢维奇(Jan Łukasiewicz) | 发明前缀表达式 ,即波兰表达式(Polish Notation),运算符写在操作数之前 |
| 1950 年代 | 澳大利亚科学家 查尔斯·汉布林(Charles L. Hamblin) | 发表论文提出后缀表达式 ,命名为逆波兰表达式(Reverse Polish Notation, RPN)以致敬 Łukasiewicz |
| 1960 年代 | 荷兰科学家 艾兹格·迪杰斯特拉(Edsger W. Dijkstra) | 发表《Algol 60 的逆波兰表示法与翻译》,给出中缀转后缀算法,即调度场算法(Shunting-yard) |
| 1972 年 | 惠普(HP) | 推出第一款使用 RPN 的商用计算器 HP-35 |
三种表示法对照 (以 1 - 2 * (3 + 5) / 4 为例):
| 表示法 | 别名 | 运算符位置 | 示例 |
|---|---|---|---|
| 中缀表达式 | --- | 两个操作数中间 | x + y、1 - 2 * (3 + 5) / 4 |
| 前缀表达式 | 波兰表达式 | 两个操作数前面 | + x y、- 1 / * 2 + 3 5 4 |
| 后缀表达式 | 逆波兰表达式 | 两个操作数后面 | x y +、1 2 3 5 + * 4 / - |
1.6.4 后缀表达式为什么适合计算机
后缀表达式的特点:操作数顺序不变、脱掉了括号、运算符在两个操作数后面、从左到右就能确定运算符优先级。因此最适合用栈运算:
- 遇到操作数就入栈保存;
- 遇到运算符就取栈顶两个数据运算 ,结果再入栈,作为后续运算的操作数。 ⚠️ 重点 :因为后缀表达式中操作数顺序不变,入栈顺序是左操作数、右操作数 ,所以出栈顺序是右操作数、左操作数 。
+、*交换左右顺序不影响结果,但-、/交换左右顺序结果就错了------必须先弹出的是右操作数。 - 表达式结束,栈中最后剩下的数据就是运算结果。
1.6.5 手算:中缀转后缀
步骤:
- 提取中缀表达式的操作数,顺序保持不变;
- 提取中缀表达式的运算符 ,按优先级依次插入到对应操作数的后面。
(注意:有些操作数本身是包含运算符的子表达式,因为实际运算时该子表达式的运算结果会作为下一步运算的操作数。)
例 1 :a + b - c * d / e
- 去运算符和界限符得:
abcde;运算符优先级顺序为:+ * / - - 后缀表达式:
ab+cd*e/-
例 2 (练习):a * (b - (c + d) + e) / f + g
- 去运算符和界限符得:
abcdefg;运算符优先级顺序为:+ - + * / + - 后缀表达式:
abcd+-e+*f/g+
1.6.6 手算:中缀转前缀
步骤 类似,区别是运算符按优先级插入到对应操作数的前面 ,优先级顺序从右往左确定。
例 :a + b - c * d / e
- 去运算符和界限符得:
abcde - 前缀表达式:
-+ab/*cde
1.6.7 机算:用栈实现中缀转后缀(重点算法)
中缀转后缀需要用栈存储前面的运算符并比较优先级。计算机从左往右读取中缀表达式:
| 遇到 | 处理 |
|---|---|
| 操作数 | 直接加入后缀表达式(操作数顺序不变,要确定的是运算符顺序) |
| 运算符 x ,栈空 或 x 优先级 > 栈顶运算符 y | x 入栈 |
| 运算符 x ,x 优先级 ≤ 栈顶运算符 y | 栈顶 y 出栈并加入后缀表达式,x 继续与新的栈顶比较(回到上面两种情况之一) |
左括号 ( |
直接入栈 |
右括号 ) |
依次弹出栈顶运算符加入后缀表达式,直到遇到 ( 为止;() 本身不加入后缀表达式,直接丢弃 |
| 表达式读取结束 | 将栈中所有运算符依次出栈并加入后缀表达式 |
每一步背后的原理:
- 为什么栈空就入栈? 因为只有相邻运算符比较才能确定优先级,栈空没有可比较对象,只能入栈。
- 为什么 x 优先级更高就入栈? 下一个运算符比前一个优先级高,说明还不能确定运算顺序 ------后续可能还有优先级更高的运算符。例如
1+2*(3-4):遇到+时不能运算,因为后面有*;遇到*时也不能运算,因为后面还有()。 - 为什么 x 优先级更低或相等就出栈? 下一个运算符比前一个优先级低(或相等),说明前一个运算符已经可以运算了 。例如
1+2-3*4:遇到+不能运算,遇到-与+优先级相等,则+可以运算,后面的*也不影响。 - 为什么左括号
(优先级设为最低? 括号内本质是一个高优先级运算的子表达式。把(优先级设为最低(比+、-都低),就能保证括号内子表达式的第一个运算符 x 一定满足"x 优先级高于栈顶(",从而顺利入栈。例如1*(2-3*4):遇到(入栈,遇到-比(优先级高,则入栈。 - 为什么遇到右括号要弹出到左括号? 遇到
)表示当前子表达式结束,括号内的子表达式都要先运算,所以把(之前的所有运算符出栈。这样括号内的转换逻辑就与外层隔离开 了,内层()子表达式可以被当成外层的一个操作数。例如(2+3*4):遇到(入栈,+入栈,*入栈,遇到)后把*、+依次出栈加入后缀表达式。 - 为什么表达式结束后全部出栈? 表达式结束时栈中剩下的运算符,从栈底到栈顶优先级是不断变高的 ,所以依次出栈的顺序就是优先级由高到低。例如
1+2*3:+入栈,*入栈,表达式结束,依次把*、+出栈加入后缀表达式。
运算符优先级定义 :乘除 > 加减;乘除相等,加减相等;左括号优先级最低。
代码实现:
c
#include <stdio.h>
#include <assert.h>
#include <stdbool.h>
struct opPD {
char op; // 运算符
int pd; // 优先级
};
// 获取运算符优先级
int operatorPrecedence(char ch) {
// '(' 的优先级需要很低,方便 '(' 后面的下一个运算符入栈
static struct opPD arr[5] = { {'+', 1}, {'-', 1}, {'*', 2}, {'/', 2}, {'(', 0} };
for (int i = 0; i < 5; ++i) {
if (arr[i].op == ch) {
return arr[i].pd; // 注意:原文此处写作 e.pd,属笔误,应为 arr[i].pd
}
}
// 传入的不是以上运算符则报错
assert(false);
return -1;
}
// 判断是否是操作数
bool isOperand(const char* str) {
if (str[0] >= '0' && str[0] <= '9')
return true;
else if (str[0] == '-' && str[1] >= '0' && str[1] <= '9') // 负号
return true;
return false;
}
void toRPN(const char* tokens[], int tokensSize) {
// 假设 tokensSize <= 10000,直接用静态数组做栈
char stackArr[10000];
int top = 0; // 标记栈顶数据的下一个位置
for (int i = 0; i < tokensSize;) {
const char* str = tokens[i];
if (isOperand(str)) {
// 操作数直接输出
printf("%s ", str);
++i;
}
else {
if (str[0] == '(') {
// 左括号入栈
stackArr[top++] = str[0];
++i;
}
else if (str[0] == ')') {
// 右括号:将栈中的运算符全部出栈,直到遇到左括号
while (stackArr[top - 1] != '(') {
printf("%c ", stackArr[--top]);
}
--top; // 删掉左括号
++i;
}
else {
// 运算符
// 1、栈为空 或 当前运算符比栈顶优先级高 -> 当前运算符入栈
// 2、栈不为空 且 比栈顶优先级低或相等 -> 输出栈顶运算符,当前运算符再比较一轮
if (top == 0 || operatorPrecedence(str[0]) > operatorPrecedence(stackArr[top - 1])) {
stackArr[top++] = str[0];
++i;
}
else {
printf("%c ", stackArr[--top]);
}
}
}
}
// 表达式结束,栈中运算符全部输出
while (top != 0) {
printf("%c ", stackArr[--top]);
}
}
int main() {
// "1+2-(3*4+5)-6"
const char* expression[] = { "1","+","2","-","(","3","*","4","+","5",")","-","6" };
toRPN(expression, sizeof(expression) / sizeof(expression[0]));
// 输出:1 2 + 3 4 * 5 + - 6 -
return 0;
}
1.6.8 LeetCode 150. 逆波兰表达式求值
c
// 判断是否是操作数
bool isOperand(const char* str) {
if (str[0] >= '0' && str[0] <= '9')
return true;
else if (str[0] == '-' && str[1] >= '0' && str[1] <= '9')
return true;
return false;
}
int evalRPN(char** tokens, int tokensSize) {
// 本题说明长度 <= 10000,直接用静态数组做栈
int stackArr[10000];
int top = 0;
for (int i = 0; i < tokensSize; ++i) {
char* str = tokens[i];
if (isOperand(str)) {
// 操作数:字符串转整数入栈
stackArr[top++] = atoi(str);
}
else {
// 取栈顶数据:先取到的是右操作数,再取到左操作数
int right = stackArr[--top];
int left = stackArr[--top];
// 运算结果再入栈
switch (str[0]) {
case '+': stackArr[top++] = left + right; break;
case '-': stackArr[top++] = left - right; break;
case '*': stackArr[top++] = left * right; break;
case '/': stackArr[top++] = left / right; break;
}
}
}
return stackArr[top - 1];
}
易错点 :
int right = stackArr[--top];必须在int left = stackArr[--top];之前,否则减法、除法结果就反了。
1.7 栈与递归
- C 语言中函数调用需要为每个函数建立栈帧,栈帧中保存当前函数的局部变量、参数等。
- 递归的核心思想是"分而治之" :将大问题分解为相似的子问题,子问题再分解为更小的问题,不断"套娃",直到问题不能再分解。递归在底层执行层面,本质就是建立很多栈帧,栈帧中保存分解出的子问题数据。
- 递归的缺陷 :极端场景下数据量级太大,递归深度太深,不断建立栈帧会导致栈溢出。例如下面的阶乘程序 N 取 10000 基本就会栈溢出,意味着这类问题不能使用递归实现。
c
// 计算递归求阶乘 Fac 算法的空间复杂度?
long long Fac(size_t N) {
if (N <= 0)
return 1;
return Fac(N - 1) * N;
}
- 递归改非递归 :基本都是用循环 实现。有些递归可以直接改成循环,如阶乘、斐波那契数列、归并排序 的递归实现;有些则必须借助栈辅助 实现,如快速排序、二叉树遍历。
考点提示 :递归求阶乘的空间复杂度是 O ( N ) O(N) O(N),因为递归深度为 N,需要 N 个栈帧。
二、队列(Queue)
2.1 基本概念
队列(queue)是限定在一端插入、另一端删除的线性表。 因此:
| 术语 | 含义 |
|---|---|
| 队尾(rear) | 允许插入 的那一端,插入叫入队 |
| 队头(front) | 允许删除 的那一端,删除叫出队 |
| 空队列 | 不含任何元素的队列 |
队列的核心特性是 先进先出(FIFO, First In First Out)。
重点对比 :队列的入队出队顺序简单得多 ------无论如何操作,一种入队顺序只对应一种出队顺序。而栈一种入栈顺序可以对应多种出栈顺序(卡特兰数种)。这是栈与队列最本质的区别,也是考试最爱考的点。
队列的基本操作接口:
c
void QueueInit(LinkQueue* q); // 初始化队列
void QueueDestroy(LinkQueue* q); // 销毁队列
bool QueueEmpty(LinkQueue* q); // 判空
int QueueSize(LinkQueue* q); // 有效元素个数
QDataType QueueFront(LinkQueue* q); // 获取队头元素
void EnQueue(LinkQueue* q, QDataType x); // 入队
QDataType DeQueue(LinkQueue* q); // 出队并返回队头数据
2.2 队列的链式存储
2.2.1 结构选择
- 队列的链式存储可选单链表 或双向链表 。它们入队对应表尾插入,出队对应表头删除。
- 同样没必要选双向链表------单链表就能高效实现且更省空间,双向链表每个结点多存一个前驱指针,纯属浪费。
- 单链表实现时,可以带头结点,也可以不带头结点 。本文选择带头结点结构,代码略简单一点(队列空和非空时入队逻辑一致)。
2.2.2 链式队列实现
c
// LinkQueue.h
#pragma once
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <stdbool.h>
typedef int QDataType; // 队列中存储数据元素的类型
// 单链表结点定义
typedef struct LinkNode {
QDataType data;
struct LinkNode* next;
} QNode;
typedef struct {
QNode* front; // 队头指针(指向哨兵位头结点)
QNode* rear; // 队尾指针
int size; // 有效元素个数
} LinkQueue;
void QueueInit(LinkQueue* q);
void QueueDestroy(LinkQueue* q);
bool QueueEmpty(LinkQueue* q);
int QueueSize(LinkQueue* q);
QDataType QueueFront(LinkQueue* q);
void EnQueue(LinkQueue* q, QDataType x);
QDataType DeQueue(LinkQueue* q);
c
// LinkQueue.c
#include "LinkQueue.h"
void QueueInit(LinkQueue* q) {
assert(q);
// 带头结点的结构:front 和 rear 都指向哨兵位头结点
q->front = q->rear = (QNode*)malloc(sizeof(QNode));
if (q->front == NULL) {
printf("QueueInit: 申请结点失败\n");
exit(-1);
}
q->front->next = NULL;
q->size = 0;
}
void QueueDestroy(LinkQueue* q) {
assert(q);
QNode* cur = q->front;
while (cur) {
QNode* next = cur->next;
free(cur);
cur = next;
}
q->front = q->rear = NULL;
q->size = 0;
}
bool QueueEmpty(LinkQueue* q) {
assert(q);
return q->size == 0;
}
int QueueSize(LinkQueue* q) {
assert(q);
return q->size;
}
QDataType QueueFront(LinkQueue* q) {
assert(q);
assert(!QueueEmpty(q));
return q->front->next->data; // 头结点的下一个才是真正的队头
}
// 入队:表尾插入。带头结点时,队列空和非空代码完全一样
void EnQueue(LinkQueue* q, QDataType x) {
assert(q);
QNode* newNode = (QNode*)malloc(sizeof(QNode));
if (newNode == NULL) {
printf("EnQueue: 申请结点失败\n");
exit(-1);
}
newNode->data = x;
newNode->next = NULL;
q->rear->next = newNode;
q->rear = newNode;
q->size++;
}
// 出队:删除头结点后的第一个结点,需区分多个结点和最后一个结点
QDataType DeQueue(LinkQueue* q) {
assert(q);
assert(!QueueEmpty(q));
QNode* delNode = q->front->next;
QDataType x = delNode->data;
q->front->next = delNode->next;
free(delNode);
delNode = NULL;
q->size--;
// 最后一个结点被删掉后,rear 要重新指向哨兵位头结点
if (q->front->next == NULL) {
q->rear = q->front;
}
return x;
}
易错点(面试常问) :出队时必须判断是否删掉了最后一个结点 ,若是则要把
rear重新指向哨兵位头结点。否则rear就成了野指针 ,下次入队时q->rear->next = newNode直接崩溃。
2.3 队列的顺序存储与假溢出
2.3.1 顺序队列的结构与问题
顺序存储实现队列,入队对应尾插,出队对应头删。但顺序存储直接头删需要挪动数据,时间复杂度 O ( N ) O(N) O(N) ,效率太低就没有价值了。所以可以换个思路:出队头删时不再挪动数据,而是移动队头的下标或指针。
c
#define MAX_SIZE 8
typedef int QDataType;
typedef struct SqQueue {
QDataType arr[MAX_SIZE];
int front; // 指向队头
int rear; // 指向队尾的下一个位置
} SqQueue;
2.3.2 假溢出问题
顺序队列的经典问题是假溢出(false overflow):
队列的存储空间(数组)中明明还有空闲位置 ,但由于队尾指针
rear已经移动到了数组末尾,导致无法再插入新元素,系统错误地判断为"队列已满"。
它之所以被称为"假 "溢出,是因为它并非真正的空间耗尽,而是数据结构设计缺陷导致的空间浪费。
解决假溢出的经典方法是使用循环队列:
- 入队后
rear更新:rear = (rear + 1) % 数组容量 - 出队后
front更新:front = (front + 1) % 数组容量
2.3.3 顺序队列的第二个问题
顺序队列更适合静态顺序结构,不太适合动态顺序结构------因为满了扩容时相对复杂且效率低下。
结论 :既然这么麻烦,不确定有多少数据时,直接用链式队列更好 。所以封装实现队列一般都用链式结构,顺序存储适宜于明确知道最多使用多少空间的局限场景。
2.4 循环队列
2.4.1 循环队列的概念
循环队列是一种线性数据结构,操作表现基于 FIFO 原则,并且队尾被连接在队首之后形成一个循环 ,也被称为环形缓冲区(ring buffer)。
循环队列的好处 :可以利用这个队列之前用过的空间。普通队列一旦满了就不能再插入下一个元素,即使队列前面仍有空间;而循环队列能利用这些空间存储新值。
循环队列逻辑上是一个环状,既可以用顺序结构实现,也可以用链式结构实现。
2.4.2 ⚠️ 判空与判满(核心考点)
front 指向队头,rear 指向队尾的下一个位置,此时循环队列的一个问题是:队列空和满时条件都是 front == rear,导致无法区分空还是满!
解决方案主要有两种:
方案一(最经典):牺牲一个空间
少存储一个数据,N 个空间只能存储 N-1 个值:
- 队空 :
front == rear - 队满 :
(rear + 1) % N == front(逻辑上 rear 的下一个位置是 front)
方案二:增加计数或标志位
- 增加
count计数:count == 0为空,count == N为满(N 是队列大小); - 或增加
isInsert标志:初始isInsert == false,插入时置true,删除时置false。front == rear && isInsert == false→ 空front == rear && isInsert == true→ 满
2.4.3 顺序存储实现循环队列(LeetCode 622)
采用 front 指向队头、rear 指向队尾下一个位置、牺牲一个空间 的方案。本题定义结构时不知道队列长度,所以队列结构变量和数组都需要动态创建和释放;日常若知道队列长度,可以定义静态数组和静态结构变量。
c
// 选择牺牲一个空间的方式解决判空和判满问题
typedef struct {
int* a; // 指向数组的指针
int front; // 指向队头
int rear; // 指向队尾的下一个位置
int N; // 空间大小,队列长度为 N-1
} MyCircularQueue;
MyCircularQueue* myCircularQueueCreate(int k) {
MyCircularQueue* obj = (MyCircularQueue*)malloc(sizeof(MyCircularQueue));
// 【关键】队列长度为 k,但数组必须开 k+1 个空间
obj->a = (int*)malloc(sizeof(int) * (k + 1));
obj->rear = 0;
obj->front = 0;
obj->N = k + 1;
return obj;
}
void myCircularQueueFree(MyCircularQueue* obj) {
free(obj->a);
free(obj);
}
// 判满:rear 的下一个位置是 front
bool myCircularQueueIsFull(MyCircularQueue* obj) {
return (obj->rear + 1) % obj->N == obj->front;
}
// 判空:front == rear
bool myCircularQueueIsEmpty(MyCircularQueue* obj) {
return obj->front == obj->rear;
}
bool myCircularQueueEnQueue(MyCircularQueue* obj, int value) {
if (myCircularQueueIsFull(obj))
return false;
obj->a[obj->rear] = value;
obj->rear++;
obj->rear %= obj->N; // 走到 N 位置时回绕到 0,形成逻辑上的环
return true;
}
bool myCircularQueueDeQueue(MyCircularQueue* obj) {
if (myCircularQueueIsEmpty(obj))
return false;
obj->front++;
obj->front %= obj->N;
return true;
}
// 取队头数据:front 就指向队头位置
int myCircularQueueFront(MyCircularQueue* obj) {
if (myCircularQueueIsEmpty(obj))
return -1;
return obj->a[obj->front];
}
// 取队尾数据:rear - 1 才是队尾,注意回绕时要 +N 再取模
int myCircularQueueRear(MyCircularQueue* obj) {
if (myCircularQueueIsEmpty(obj))
return -1;
return obj->a[(obj->rear - 1 + obj->N) % obj->N];
}
两个高频易错点:
myCircularQueueCreate(k)中必须malloc(k+1)个空间,因为牺牲了一个;- 取队尾要用
(rear - 1 + N) % N,必须先+N再取模 ,否则 C 语言中-1 % N结果是-1,会越界访问。
2.4.4 链式存储实现循环队列(了解)
本节在学校考试、408 和考研自命题中可能变形出题(408 有一年考过变形综合题),属次重点;校招基本不考这种要求。掌握上面的顺序存储实现即可。
- 设计一个循环单链表,逻辑和存储上都是一个环。
- 若用"牺牲一个空间"的方式判空判满,
front指向队头、rear指向队尾的下一个。但本题要求取队尾数据 ,而单链表中取队尾很麻烦(需要 O ( N ) O(N) O(N) 遍历)。解决方案有多种:双向链表,或者一个指针rear记录队尾、另一个rearNext记录队尾下一个。 - 下面的实现选择用
size记录数据个数 判断满和空,front指向队头、rear指向队尾,这样入队出队、判空判满都很容易。注意初始化时rear指向front的前一个结点 ,因为入队是让rear走到下一个结点再插入数据。
c
typedef struct LinkNode {
struct LinkNode* next;
int val;
} Node;
typedef struct {
Node* front; // 指向队头
Node* rear; // 指向队尾
int size; // 数据个数
int k; // 队列长度
} MyCircularQueue;
MyCircularQueue* myCircularQueueCreate(int k) {
MyCircularQueue* obj = (MyCircularQueue*)malloc(sizeof(MyCircularQueue));
// 先创建第一个结点
obj->front = obj->rear = (Node*)malloc(sizeof(Node));
// 再创建 k-1 个结点尾插链接起来
for (int i = 1; i < k; ++i) {
Node* newNode = (Node*)malloc(sizeof(Node));
obj->rear->next = newNode;
obj->rear = newNode;
}
// 构成循环链表
obj->rear->next = obj->front;
obj->k = k;
obj->size = 0;
return obj;
}
bool myCircularQueueIsFull(MyCircularQueue* obj) {
return obj->size == obj->k;
}
bool myCircularQueueIsEmpty(MyCircularQueue* obj) {
return obj->size == 0;
}
bool myCircularQueueEnQueue(MyCircularQueue* obj, int value) {
if (myCircularQueueIsFull(obj))
return false;
// rear 指向队尾,所以 rear 往后走一步再插入数据
obj->rear = obj->rear->next;
obj->rear->val = value;
obj->size++;
return true;
}
bool myCircularQueueDeQueue(MyCircularQueue* obj) {
if (myCircularQueueIsEmpty(obj))
return false;
obj->front = obj->front->next;
obj->size--;
return true;
}
int myCircularQueueFront(MyCircularQueue* obj) {
if (myCircularQueueIsEmpty(obj))
return -1;
return obj->front->val;
}
int myCircularQueueRear(MyCircularQueue* obj) {
if (myCircularQueueIsEmpty(obj))
return -1;
return obj->rear->val;
}
void myCircularQueueFree(MyCircularQueue* obj) {
// 合计 k 个结点,直接循环 k 次释放
Node* cur = obj->front;
for (int i = 0; i < obj->k; ++i) {
Node* next = cur->next;
free(cur);
cur = next;
}
free(obj);
}
2.5 双端队列
2.5.1 基本概念
双端队列(deque, double-ended queue) 也是一种特殊线性结构,允许两端都可以进行数据元素的入队和出队操作 。将队列的两端分别称为前端 和后端。
- 入队时 :前端进的元素排列在队列中后端进的元素前面 ;后端进的元素排列在队列中前端进的元素后面。
- 出队时 :无论是前端还是后端出队,先出的元素排列在后出的元素前面。
2.5.2 双端队列的实现
- 链式存储 :因为两端都要插入删除,双向链表比较适合。
- 顺序存储 :普通数组实现相对不合适,更适合用优化改进的组合结构 实现------C++ 标准库中的
deque就是用这种结构实现的。
考试要求 :双端队列在 408 / 学校考试 / 校招面试中主要考察结构概念,了解即可,不需要深入代码实现。
2.6 队列的应用
- 先进先出、先来先服务 的场景都可用队列。例如去营业厅办理业务、去医院门诊看病------在抽号机抽个号(入队 ),然后等着叫号(出队 ),队列保证了先来先服务,保证了公平性。
- 后续树和图等更复杂的数据结构中,广度优先遍历(BFS)也需要用队列作为辅助数据结构。
三、数组与特殊矩阵压缩存储
重要程度 :本章在学校考试、408、考研自命题中为次重点 ,校招中基本不考。但矩阵压缩存储的下标计算是 408 的高频计算题,务必掌握推导方法。
3.1 数组的存储结构
3.1.1 一维数组
- 数组是由 n(n ≥ 1)个相同类型数据元素组成的有限序列,存储在一段连续的存储单元中。
- 数组中的元素通过下标 访问。若无特殊说明,下标从 0 开始 ,对于具有 N 个元素的数组,下标 i 范围为
[0, N-1]。 - 设定义
Type a[N],则:
a i 的地址 = 数组起始地址 + sizeof(Type) × i ai\text{ 的地址} = \text{数组起始地址} + \text{sizeof(Type)} \times i ai 的地址=数组起始地址+sizeof(Type)×i
3.1.2 二维数组
若一维数组中的每个元素也是一个一维数组,这样形成的结构称为二维数组。
对于 M 行 N 列 的二维数组 a[M][N],其在内存中存储时本质是开辟 M × N 个连续存储单元 ,可以按行优先 存储,也可以按列优先存储。
| 语言 | 默认存储方式 |
|---|---|
| C / C++ / BASIC / PASCAL | 行优先(Row-major) |
| FORTRAN | 列优先(Column-major) |
行优先存储的地址计算公式(最重要的公式):
addr ( A i j ) = start + ( i × N + j ) × sizeof(Type) \text{addr}(Aij) = \text{start} + (i \times N + j) \times \text{sizeof(Type)} addr(Aij)=start+(i×N+j)×sizeof(Type)
列优先存储的地址计算公式:
addr ( A i j ) = start + ( j × M + i ) × sizeof(Type) \text{addr}(Aij) = \text{start} + (j \times M + i) \times \text{sizeof(Type)} addr(Aij)=start+(j×M+i)×sizeof(Type)
验证代码:
c
#include <stdio.h>
int main() {
// 定义 3 行 4 列的二维数组 A
int A[3][4] = {{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}};
// C 语言二维数组按行优先存储
// 第 2 行第 3 列(下标从 0 开始)的数据是 12,打印其地址
printf("%p\n", &A[2][3]);
// Type A[M][N] 的二维数组,假设起始地址为 start
// 则 A[i][j] 的存储地址 = start + (i * N + j) * sizeof(Type)
// 按行优先公式计算 12 的地址,与上一句结果相同
// 说明:&A[0][0] 的类型是 int*,int* 加 1 就是加一个 int 大小(4 byte)
// 所以这里的写法没有 *sizeof(int)
printf("%p\n", &A[0][0] + 2 * 4 + 3);
return 0;
}
3.2 矩阵压缩存储概述
矩阵 是一个按行和列排列成的矩形数字阵列,是线性代数的基础,也是人工智能的基石。在计算机中通常用二维数组存储矩阵。
为什么需要压缩存储? 在数值分析中常出现阶数很高的矩阵,同时矩阵中有许多相同元素 或零元素 ,用二维数组存储太浪费空间。为了节省空间,可以对矩阵进行压缩存储:
压缩存储 :想办法开空间(如用一维数组)存储那些不规律的值,为相同的元素只分配一个存储空间,对零元素不分配空间。
矩阵分类:
| 类型 | 定义 |
|---|---|
| 特殊矩阵 | 值相同的元素或零元素在矩阵中的分布有一定规律 |
| 稀疏矩阵 | 零元素占比极高,但无规律 |
| 稠密矩阵 | 零元素占比很低,且无规律 |
3.3 对称矩阵
3.3.1 定义
若 n 阶矩阵 A n × n A_{n \times n} An×n 中的元素满足:
a i j = a j i ( 1 ≤ i , j ≤ n ) a_{ij} = a_{ji} \quad (1 \le i, j \le n) aij=aji(1≤i,j≤n)
则称 A 为 n 阶对称矩阵。
⚠️ 重要提示 :很多地方描述矩阵行列号时通常从 1 开始 ,而存储的二维数组描述行列下标从 0 开始,做题时一定注意看题目说明!
区域划分:
- 主对角线 上的元素: a i , j a_{i,j} ai,j 中 i = j i = j i=j
- 上三角区 :主对角线上方所有元素, i < j i < j i<j
- 下三角区 :主对角线下方所有元素, i > j i > j i>j
3.3.2 需要存储多少个元素
回顾等差数列: a 1 , a 2 , a 3 , ... , a n a_1, a_2, a_3, \dots, a_n a1,a2,a3,...,an,前 n 项和公式:
S n = ( a 1 + a n ) × n 2 S_n = \frac{(a_1 + a_n) \times n}{2} Sn=2(a1+an)×n
上三角区和下三角区各自有 n × ( n − 1 ) 2 \dfrac{n \times (n-1)}{2} 2n×(n−1) 个元素 (是等差数列:上三角第一行 n-1 个、第二行 n-2 个......),并且这两部分是重复的。
所以压缩存储时,只需要存储"主对角线 + 上三角"或"主对角线 + 下三角"即可;甚至有些矩阵对角线元素全是 0 或全相等,那就只需要存储上三角区或下三角区。
共需存储:
n ( n − 1 ) 2 + n = n ( n + 1 ) 2 个元素 \frac{n(n-1)}{2} + n = \frac{n(n+1)}{2} \text{ 个元素} 2n(n−1)+n=2n(n+1) 个元素
3.3.3 下标计算公式(核心)
以存储"主对角线 + 下三角"、行优先为例:
- 第一行 1 个,第二行 2 个,第三行 3 个......第 n 行 n 个,是等差数列,压缩到一个存储 n ( n + 1 ) 2 \dfrac{n(n+1)}{2} 2n(n+1) 个数据的一维数组中。
- 对任意元素 a i j a_{ij} aij( i ≥ j i \ge j i≥j):
- 第 1 行到第 i − 1 i-1 i−1 行共存储 1 + 2 + ⋯ + ( i − 1 ) = i ( i − 1 ) 2 1 + 2 + \dots + (i-1) = \dfrac{i(i-1)}{2} 1+2+⋯+(i−1)=2i(i−1) 个元素;
- a i j a_{ij} aij 是第 i 行的第 j j j 个元素;
- 因为数组下标从 0 开始,所以在一维数组中的下标为:
k = i ( i − 1 ) 2 + ( j − 1 ) k = \frac{i(i-1)}{2} + (j - 1) k=2i(i−1)+(j−1)
存储"主对角线 + 下三角"、列优先:
- 第一列 n 个,第二列 n-1 个......第 n 列 1 个;
- 对任意元素 a i j a_{ij} aij( i ≥ j i \ge j i≥j),第 1 列到第 j − 1 j-1 j−1 列共存储:
n + ( n − 1 ) + ⋯ + ( n − j + 2 ) = ( n + ( n − j + 2 ) ) × ( j − 1 ) 2 n + (n-1) + \dots + (n-j+2) = \frac{(n + (n-j+2)) \times (j-1)}{2} n+(n−1)+⋯+(n−j+2)=2(n+(n−j+2))×(j−1)
- a i j a_{ij} aij 是第 j 列的第 i − j + 1 i-j+1 i−j+1 个元素,所以下标为:
k = ( n + n − j + 2 ) ( j − 1 ) 2 + ( i − j + 1 ) − 1 k = \frac{(n + n - j + 2)(j-1)}{2} + (i - j + 1) - 1 k=2(n+n−j+2)(j−1)+(i−j+1)−1
两个对称结论(重要,能省一半记忆量):
- 压缩存储**"主对角线 + 上三角"的行优先**,等价于**"主对角线 + 下三角"的列优先**;
- 压缩存储**"主对角线 + 上三角"的列优先**,等价于**"主对角线 + 下三角"的行优先**。
学习建议 :上面这些公式不需要背 (也记不住)。只需要理解推导过程,遇到题目时画图分析,再用等差数列公式推导即可。这是本小节最重要的方法论。
3.4 三角矩阵
- 上三角矩阵:矩阵的**下三角(不包括对角线)**中的元素均为某个常数或零的 n 阶矩阵。
- 下三角矩阵:矩阵的**上三角(不包括对角线)**中的元素均为某个常数或零的 n 阶矩阵。
存储方式 :三角矩阵也适合用行优先或列优先压缩存储,但需要再多开一个空间 存储那个元素相同的三角区的常量值。
共需空间 = n ( n + 1 ) 2 + 1 \text{共需空间} = \frac{n(n+1)}{2} + 1 共需空间=2n(n+1)+1
3.5 三对角矩阵
三对角矩阵 又称带状矩阵 ,其所有非零元素都集中在主对角线、以及主对角线上下两条对角线的带状区域中,其余位置元素全部为零。
特点:
- 第一行和最后一行只有 2 个元素,其余 n-2 行都是 3 个元素;
- 元素总个数为:
3 ( n − 2 ) + 4 = 3 n − 2 3(n-2) + 4 = 3n - 2 3(n−2)+4=3n−2
- 对元素 a i j a_{ij} aij:当 ∣ i − j ∣ > 1 |i - j| > 1 ∣i−j∣>1 时 a i j = 0 a_{ij} = 0 aij=0;压缩存储只需存储所有 ∣ i − j ∣ ≤ 1 |i-j| \le 1 ∣i−j∣≤1 位置上的元素。
行优先压缩存储的下标推导:
- 前 i − 1 i-1 i−1 行有 3 ( i − 1 ) − 1 3(i-1) - 1 3(i−1)−1 个数据(第一行只有 2 个,所以减 1);
- a i j a_{ij} aij 是当前行的第 j − i + 2 j - i + 2 j−i+2 个元素;
- 因此 a i j a_{ij} aij 是一维数组的第 3 ( i − 1 ) − 1 + ( j − i + 2 ) = 2 i + j − 2 3(i-1) - 1 + (j - i + 2) = 2i + j - 2 3(i−1)−1+(j−i+2)=2i+j−2 个元素;
- 因为数组下标从 0 开始,所以:
k = 2 i + j − 3 k = 2i + j - 3 k=2i+j−3
同样不需要背公式,理解三对角矩阵的结构后根据原理推导即可。列优先存储是类似的方式。
3.6 稀疏矩阵
3.6.1 稀疏因子
稀疏矩阵没有确切的定义 ,一般认为非零元素远小于矩阵元素个数的就是稀疏矩阵。
有一种说法:在 m × n m \times n m×n 的矩阵中,有 t 个非零元素,令
δ = t m × n \delta = \frac{t}{m \times n} δ=m×nt
称 δ \delta δ 为矩阵的稀疏因子 ,通常认为 δ ≤ 0.05 \delta \le 0.05 δ≤0.05 时称为稀疏矩阵。
3.6.2 两种存储方式
稀疏矩阵使用二维数组存储同样很浪费空间,但因为它的元素分布没有规律,需要考虑新的方案。主要有两种存储方式:
方式一:三元组 {行, 列, 值}
把所有的非零值及其行列位置一起存储起来。定义如下:
c
// 三元组的定义
typedef struct Triple {
int i; // 行号
int j; // 列号
SMDataType data; // 非零元素的值
} Triple;
#define SMMAX_SIZE 100
// 稀疏矩阵三元组顺序存储结构
typedef struct {
Triple array[SMMAX_SIZE]; // 存放三元组
int m; // 矩阵行数
int n; // 矩阵列数
int size; // 非 0 元素个数
} SparseMatrix;
重要结论(2023 年 408 考点) :采用三元组表存储稀疏矩阵时,除了三元组表,还需要保存原矩阵的行数和列数,否则无法将矩阵还原。
方式二:十字链表
当矩阵的非零元个数和位置在操作过程中变化比较大 时(如矩阵相加),就不宜采用三元组结构存储------因为这些运算可能增加或减少三元组,需要挪动数据,效率太低,更适合十字链表结构。
十字链表的原理 :在三元组结构基础上增加一个向右的指针 right 和一个向下的指针 down 构成结点:
- 通过
right指针链接同一行的下一个非零元; - 通过
down指针链接同一列的下一个非零元; - 整个矩阵构成一个形如十字交叉的链表。
c
typedef struct LinkNode {
int i; // 非零元素的行号
int j; // 非零元素的列号
DataType data; // 非零元素
struct LinkNode* right; // 指向同一行中下一个非零元
struct LinkNode* down; // 指向同一列中下一个非零元
} LNode;
#define M 10 // 行数
#define N 5 // 列数
// 十字链表结构
typedef struct {
LNode* rowArr[M]; // 行链头指针的数组
LNode* colArr[N]; // 列链头指针的数组
int size; // 非 0 元素个数
} CrossList;
三元组 vs 十字链表 选择建议:
| 场景 | 推荐结构 |
|---|---|
| 非零元个数和位置基本不变(如只做读取、转置) | 三元组表(顺序存储,简单高效) |
| 非零元个数和位置频繁变化(如矩阵相加、相乘) | 十字链表(链式存储,插入删除方便) |
四、408 真题汇总表
上面各节已嵌入真题解析,这里汇总便于专项复习:
| 年份/题号 | 考点 | 答案 |
|---|---|---|
| 2011 选择 02 | 以 d 开头的出栈序列个数 | B(4) |
| 2011 选择 03 | 循环队列初始 front / rear 的值 | B(0, n-1) |
| 2013 选择 02 | 已知 p₂=3 求 p₃ 取值个数 | C(n-1) |
| 2010 选择 01 | 不允许连续三次退栈的出栈序列 | D(a,f,e,d,c,b) |
| 2010 选择 02 | 双端队列(一端入队两端出队)不可能序列 | C(d,b,c,a,e) |
| 2014 选择 03 | 循环队列判空判满条件 | A(end1end2 / end1(end2+1) mod M) |
| 2015 选择 01 | 递归调用栈帧自底到顶的顺序 | A(main()→S(1)→S(0)) |
| 2016 选择 04 | 三对角矩阵行优先压缩下标 | B(87) |
| 2017 选择 02 | 栈的相关概念判断(错误的是) | C(I、III、IV) |
| 2017 选择 03 | 稀疏矩阵的两种压缩存储结构 | A(三元组表和十字链表) |
| 2018 选择 01 | 双栈模拟表达式求值(调用 3 次 F) | B(15) |
| 2018 选择 03 | 对称矩阵上三角行优先压缩下标 | A(50) |
| 2020 选择 01 | 对称矩阵上三角列优先压缩下标 | C(22) |
| 2020 选择 02 | 给定 Push/Pop 序列求输出 | D(b,c,e) |
| 2021 选择 03 | 二维数组行优先地址计算 | B(300) |
| 2023 选择 03 | 三元组表还需保存的信息 | A(I、III) |
| 2024 选择 02 | 中缀表达式转后缀表达式 | A(xyzu-*v/+) |
| 2025 选择 02 | 括号匹配的栈容量需求 | D(a-(b+\[c\*(d+e)-f+g+h)]) |
| 补充题 1 | 栈 S 与队列 Q 结合,求 S 的最小容量 | C(3) |
| 补充题 2 | 队列出队入栈三种操作,不可得到的序列 | C(3,4,5,6,1,2) |
| 补充题 3 | 火车车厢重排,求最少轨道数 | C(4) |
五、一页速查总结
5.1 栈的核心要点
| 要点 | 内容 |
|---|---|
| 定义 | 只允许在一端插入删除的线性表 |
| 特性 | 后进先出 LIFO |
| 出栈序列个数 | 卡特兰数 C n = 1 n + 1 ( 2 n n ) C_n = \frac{1}{n+1}\binom{2n}{n} Cn=n+11(n2n),前几项:1, 1, 2, 5, 14, 42, 132 |
| 链式栈栈顶选择 | 单链表必须用表头做栈顶 (头插头删 O ( 1 ) O(1) O(1));表尾做栈顶退化为 O ( N ) O(N) O(N) |
| 顺序栈 vs 链式栈 | 都是 O ( 1 ) O(1) O(1);顺序栈缓存命中率高、无内存碎片⇒一般首选 |
| 递归与非递归 | 递归改非递归:阶乘/斐波那契/归并排序→循环 ;快排/二叉树遍历→必须借助栈 |
5.2 队列的核心要点
| 要点 | 内容 |
|---|---|
| 定义 | 一端插入(队尾)、另一端删除(队头)的线性表 |
| 特性 | 先进先出 FIFO ;一种入队顺序只对应一种出队顺序 |
| 链式队列出队易错点 | 删除最后一个结点后,必须把 rear 重新指向哨兵位头结点(否则 rear 野指针) |
| 假溢出 | 空间还有空闲,但 rear 已到数组末尾而被误判为满;用循环队列解决 |
| 循环队列判空 | front == rear |
| 循环队列判满 | 牺牲一个空间 :(rear+1) % N == front;或 count == N;或 front == rear && isInsert == true |
| 循环队列容量 | 已知队列长度 k 时,数组必须开 k+1 个空间 |
| 取队尾元素 | a[(rear - 1 + N) % N],必须先 +N 再取模 |
| 封装实现建议 | 不确定数据量时用链式队列;顺序队列适合静态、空间确定的场景 |
5.3 数组与矩阵的核心公式
| 内容 | 公式 |
|---|---|
| 一维数组元素地址 | start + sizeof(Type) * i |
| 二维行优先(M 行 N 列) | addr(A[i][j]) = start + (i * N + j) * sizeof(Type) |
| 二维列优先(M 行 N 列) | addr(A[i][j]) = start + (j * M + i) * sizeof(Type) |
| 对称矩阵存储元素个数 | n ( n + 1 ) 2 \frac{n(n+1)}{2} 2n(n+1) |
| 对称矩阵·下三角·行优先( i ≥ j i \ge j i≥j) | k = i ( i − 1 ) 2 + ( j − 1 ) k = \frac{i(i-1)}{2} + (j-1) k=2i(i−1)+(j−1) |
| 对称矩阵·上三角·行优先( i ≤ j i \le j i≤j) | 前 i-1 行有 n + ( n − 1 ) + ⋯ + ( n − i + 2 ) n + (n-1) + \dots + (n-i+2) n+(n−1)+⋯+(n−i+2) 个;画图推导 |
| 三角矩阵存储空间 | n ( n + 1 ) 2 + 1 \frac{n(n+1)}{2} + 1 2n(n+1)+1(多一个常量空间) |
| 三对角矩阵元素个数 | 3 n − 2 3n - 2 3n−2 |
| 三对角矩阵·行优先($ | i-j |
| 稀疏矩阵存储 | 三元组表 ({行,列,值} + 矩阵行数列数)、十字链表 |
| 稀疏因子 | δ = t m × n ≤ 0.05 \delta = \frac{t}{m \times n} \le 0.05 δ=m×nt≤0.05 |
5.4 五种易混概念对比
| 对比项 | 栈 | 队列 | 双端队列 |
|---|---|---|---|
| 插入端 | 栈顶 | 队尾 | 两端均可 |
| 删除端 | 栈顶 | 队头 | 两端均可 |
| 顺序特性 | LIFO | FIFO | 两端混合 |
| 入/出顺序关系 | 一种入栈顺序 ⇒ 多种出栈顺序 | 一种入队顺序 ⇒ 一种出队顺序 | 两端操作,序列受限但比栈灵活 |
结语
这三个结构的核心记忆点其实很集中:
- 栈------LIFO,"只能一端操作",出栈序列数 = 卡特兰数,表达式求值是最大应用;
- 队列 ------FIFO,"一端进另一端出",循环队列判空判满是必考 ,链式队列出队的
rear复位是易错点; - 数组 ------行优先/列优先地址公式 + 特殊矩阵压缩存储的下标计算(重点记推导方法,不要背公式)。
矩阵压缩存储的下标计算题,画图 + 等差数列就能解决 90% 的情况,这也是本篇最想传递的方法论。
如果这篇内容对你有帮助,欢迎点赞 + 收藏,后续会继续更新《树和二叉树》《排序》等章节的整理。
本文根据数据结构课程课件《3. 栈和队列和数组》整理,代码均经过实际编译验证。