post / 2023.12.12
数据结构实验笔记
本文合并了原先的八篇数据结构上机实验报告,去掉了重复的套话和完整源码,只保留每个实验的核心思路、关键数据结构和算法要点,作为复习提纲使用。代码片段为 C++。
一、递归与栈:斐波那契数列
问题
用递归求第 $n$ 个 Fibonacci 数。
递归与栈的关系
递归能不能正确返回,取决于两件事:参数如何传递、返回到哪里。以 Fib(5) 为例:
- 每递归一层,都要给该层的形参重新分配空间;
- 还要把"本次调用结束后该回到哪一行"这个返回地址保存下来。
这两样东西合起来构成一条工作记录(活动记录),由系统压入函数调用栈。调用时压栈,返回时从栈顶弹出,于是就能准确回到上一层。
以 Fib(3) = Fib(2) + Fib(1) 为例,调用链是:
Fib(3)
├── Fib(2)
│ ├── Fib(1) → 返回 1
│ └── Fib(0) → 返回 0
│ 得 1
└── Fib(1) → 返回 1
得 2
算法
int Fib(int n) {
if (n == 0) return 0;
if (n == 1) return 1;
return Fib(n - 1) + Fib(n - 2);
}
这版递归每次都会重复计算大量子问题,时间复杂度是指数级的 $O(2^n)$。要高效求解应改用递推或记忆化,$O(n)$ 即可。递归版本的价值在于演示栈的工作过程。
二、划分子集问题
问题
把集合划分为若干互不相交的子集,使得同一子集内的元素两两无冲突,并且子集数量尽量少。(典型应用:排课、日程安排。)
算法:循环筛选法
- 先用一个二维数组(冲突矩阵)描述冲突关系:$conflict[i][j] = 1$ 表示 $i$ 与 $j$ 冲突,$0$ 表示不冲突。
- 从第一个未分组元素开始,把它放入当前组。
- 依次检查其余未分组元素:如果它与当前组里的每一个元素都不冲突,就并入当前组;否则跳过,留给下一轮。
- 一轮扫描结束后,若还有元素未分组,就开一个新组,重复第 2 步,直到所有元素都分完。
数据结构
用循环队列存放待处理元素。每扫描一轮,元素在队列中轮转一圈,不需要额外开辟存储空间。
要点
- 判断"能否并入当前组"必须和组里所有已有元素都比较,只看其中一个会出错。
- 这是贪心策略——能压进当前组就压,因此得到的是较优解而非严格最优解。
三、稀疏矩阵乘法
问题
矩阵中非零元素很少时,用完整的二维数组存储会浪费大量空间,改用三元组只存非零元素,并实现两个稀疏矩阵的乘法。
数据结构
struct Node { // 一个非零元素
int i, j; // 行号、列号
int data; // 元素值
};
struct Matrix {
int m, n; // 总行数、总列数
int L; // 非零元素个数
Node* Ma[MAX]; // 非零元素数组
};
算法
按定义 $C_{ij} = \sum_k A_{ik} \cdot B_{kj}$,但只累加两边都非零的项:
- 遍历 $A$ 的每一个非零元素 $(i, k, a)$;
- 在 $B$ 中寻找行号等于 $k$ 的非零元素 $(k, j, b)$;
- 把 $a \times b$ 累加到结果矩阵的 $(i, j)$ 位置。
结果矩阵的非零元素动态生成,若累加后为 0 则不存入。
要点
- 比朴素三重循环快得多,因为跳过了大量零元素相乘。
- 想进一步提速,可以给三元组表加行索引(行逻辑链接顺序表),使第 2 步不必每次从头扫描 $B$。
- 必须校验:$A$ 的列数是否等于 $B$ 的行数,以及输入的行列下标是否越界。
四、KMP 字符串匹配
问题
在文本串中查找模式串首次出现的位置。朴素匹配在失配时把模式串整体右移一位、文本指针回退,最坏情况是 $O(nm)$;KMP 把它优化到 $O(n+m)$。
核心:LPS 数组
LPS(Longest Prefix Suffix)记录模式串每个位置之前的子串中,“最长且相等的前缀和后缀"的长度。它告诉我们在失配时模式串可以安全地向右滑动多少,从而不必回退文本指针。
以模式串 ABABCABAB 为例,其 LPS 数组为:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 字符 | A | B | A | B | C | A | B | A | B |
| LPS | 0 | 0 | 1 | 2 | 0 | 1 | 2 | 3 | 4 |
算法
// 构造 LPS 数组
void computeLPS(const string& pat, vector<int>& lps) {
int len = 0; // 当前最长前后缀匹配长度
lps[0] = 0;
int i = 1;
while (i < (int)pat.size()) {
if (pat[i] == pat[len]) {
lps[i++] = ++len;
} else if (len != 0) {
len = lps[len - 1]; // 回退到次长前后缀,继续比较
} else {
lps[i++] = 0;
}
}
}
// 在 text 中查找 pat
void KMPSearch(const string& text, const string& pat) {
int m = pat.size(), n = text.size();
vector<int> lps(m);
computeLPS(pat, lps);
int i = 0, j = 0; // i 走文本,j 走模式串
while (i < n) {
if (pat[j] == text[i]) { i++; j++; }
if (j == m) {
cout << "在索引 " << i - j << " 处找到匹配串" << endl;
j = lps[j - 1]; // 继续找下一次匹配
} else if (i < n && pat[j] != text[i]) {
if (j != 0) j = lps[j - 1];
else i++; // j 已为 0,无法再回退,只能让文本指针前进
}
}
}
要点
- 文本指针
i永不回退,这是 KMP 效率的来源。 - 失配时利用
lps[j-1]回退模式指针,而不是把j清零。 - 算
lps的那段代码自身也是"模式串匹配自己”,思路与主循环一致。 - 示例:文本
ABABDABACDABABCABAB,模式ABABCABAB,匹配位置为索引 10。
五、二叉树的存储与遍历
问题
给定一串按**层序(BFS 顺序)**输入的字符,’@’ 表示空节点,构建二叉树;再分别实现顺序存储与链式存储下的遍历。
两种存储方式
顺序存储:用数组按下标存放,父子关系由下标算出:
- 父节点下标为 $i$ → 左孩子 $2i+1$,右孩子 $2i+2$;
- 反过来:节点 $i$ 的父节点是 $\lfloor (i-1)/2 \rfloor$。
适合完全二叉树;节点稀疏时会浪费大量数组空间。
链式存储:用结构体加指针,用队列辅助构建。
struct TreeNode {
char data;
TreeNode* left;
TreeNode* right;
};
构建流程:根节点入队 → 每从队头取出一个节点,就依次为它挂上左、右孩子(输入为 ‘@’ 则该侧为空),并把新孩子入队。想改成按前序输入构建,只需把队列换成栈。
三种深度优先遍历
void preOrder(TreeNode* p) { // 前序:根 → 左 → 右
if (!p) return;
visit(p);
preOrder(p->left);
preOrder(p->right);
}
void inOrder(TreeNode* p) { // 中序:左 → 根 → 右
if (!p) return;
inOrder(p->left);
visit(p);
inOrder(p->right);
}
void postOrder(TreeNode* p) { // 后序:左 → 右 → 根
if (!p) return;
postOrder(p->left);
postOrder(p->right);
visit(p);
}
广度优先遍历
用队列从根开始逐层处理:访问一个节点时把它的左右孩子依次入队。
void levelOrder(TreeNode* root) {
if (!root) return;
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode* p = q.front(); q.pop();
visit(p);
if (p->left) q.push(p->left);
if (p->right) q.push(p->right);
}
}
完整示例
输入 abc@def@@@@(层序,’@’ 为空),对应的二叉树是:
a
/ \
b c
/ \ / \
@ d e f
/ \ / \ / \
@ @ @ @ @ @
| 遍历 | 结果 |
|---|---|
| 前序(根左右) | a b d c e f |
| 中序(左根右) | b d a e c f |
| 后序(左右根) | d b e f c a |
| 层序(BFS) | a b c d e f |
顺序存储下中序遍历的起点要仔细处理:前序可以从下标 0 起步,但中序必须从整棵树最左下的节点开始,否则会漏掉左子树的左半边。
由遍历序列反推二叉树
- 前序 + 中序、后序 + 中序、层序 + 中序,都可以唯一确定一棵二叉树。
- 但前序 + 后序不行,无法区分只有一个孩子时它挂在左边还是右边。
六、哈夫曼树与编码
问题
对字符按出现频率编码,使编码后的总长度最短,并且任意编码都不是另一个编码的前缀(前缀码,保证能唯一译码)。
算法
- 统计每个字符的出现频率。
- 每个字符建一个叶子节点,全部放入最小堆。
- 循环:取出频率最小的两个节点,合并为一个新节点(频率为两者之和),把新节点放回堆中。
- 堆中只剩一个节点时,它就是哈夫曼树的根。
while (minHeap.size() > 1) {
auto left = minHeap.top(); minHeap.pop();
auto right = minHeap.top(); minHeap.pop();
auto parent = new HuffmanNode('$', left->frequency + right->frequency);
parent->left = left;
parent->right = right;
minHeap.push(parent);
}
生成编码与译码
- 生成编码:从根出发递归遍历,往左走记
0,往右走记1,走到叶子节点时把当前路径串记为该字符的编码。 - 编码:把原文每个字符替换成它的编码串。
- 译码:从根出发,遇到
0走左、遇到1走右,走到叶子就输出该字符并回到根重新开始。
要点
- 频率越高的字符离根越近,编码越短——这就是压缩的原理。
- 因为编码都在叶子节点上,所以不存在"某个编码是另一个前缀"的情况,译码不会歧义。
- 用最小堆而不是反复排序,可以把构建复杂度降到 $O(n \log n)$。
- 出现频率相同的字符,谁先谁后不影响编码长度,只影响具体分配到的 0/1 串。
七、排序二叉树与节点删除
问题
构建一棵排序二叉树(BST),支持插入、中序遍历和删除节点。
插入
递归比较:待插值小于当前节点就往左走,大于就往右走;走到空位就新建节点挂上。
TreeNode* insert(TreeNode* root, int val) {
if (!root) return new TreeNode(val);
if (val < root->val) root->left = insert(root->left, val);
else if (val > root->val) root->right = insert(root->right, val);
return root; // 相等时不插入,避免重复
}
中序遍历
中序遍历 BST 会得到递增序列,这是检验 BST 是否构建正确最直接的办法。
删除节点
删除是最容易写错的一步,分三种情况:
| 情况 | 处理方式 |
|---|---|
| 叶子节点(无孩子) | 直接删除 |
| 只有一个孩子 | 用这个孩子顶替自己的位置 |
| 有两个孩子 | 用右子树中的最小节点(中序后继)替换自己的值,再递归删除那个后继节点 |
TreeNode* remove(TreeNode* root, int val) {
if (!root) return nullptr;
if (val < root->val) root->left = remove(root->left, val);
else if (val > root->val) root->right = remove(root->right, val);
else {
if (!root->left) { auto t = root->right; delete root; return t; }
if (!root->right) { auto t = root->left; delete root; return t; }
// 两个孩子:找右子树最小节点
TreeNode* succ = root->right;
while (succ->left) succ = succ->left;
root->val = succ->val;
root->right = remove(root->right, succ->val);
}
return root;
}
也可以用左子树的最大节点(中序前驱)来替换,效果等价。
删除整棵树
后序遍历每个节点依次释放,不能先释放父节点再找孩子。
八、二叉树线索化
问题
$n$ 个节点的二叉树共有 $2n$ 个指针域,其中实际使用的只有 $n-1$ 条边,剩下 $n+1$ 个是空指针,浪费明显。线索化就是用这些空指针存放遍历次序下的前驱/后继,让遍历不必再依赖栈或递归。
数据结构
struct Node {
int data;
Node *left, *right;
int ltag, rtag; // 0 表示指向孩子,1 表示是线索(指向前驱/后继)
};
算法(以中序线索化为例)
用递归中序遍历,同时维护一个全局的 pre 指针记录上一个访问的节点:
Node* pre = nullptr;
void thread(Node* p) {
if (!p) return;
thread(p->left); // 先处理左子树
if (!p->left) { // 左孩子为空,左指针改指向前驱
p->left = pre;
p->ltag = 1;
}
if (pre && !pre->right) { // 前驱的右孩子为空,右指针改指向后继
pre->right = p;
pre->rtag = 1;
}
pre = p; // 更新前驱
thread(p->right); // 再处理右子树
}
要点
left/right指针的语义是双重的,必须靠ltag/rtag区分,否则会把线索当成孩子指针用,导致死循环。- 中序线索化要先递归左子树,再处理当前节点,最后递归右子树;
pre = p的位置决定了线索指向谁。 - 前序、后序线索化的思路相同,区别在于处理当前节点的时机与
pre的更新顺序。 - 线索化后的树可以用空间 $O(1)$ 完成遍历,不需要栈,也不必递归。
九、图的存储:邻接矩阵与邻接表
邻接矩阵
用二维数组 adj[V][V] 存边:adj[i][j] = 1(或边的权重)表示 $i \to j$ 有边。
- 优点:判断两点间是否有边是 $O(1)$;实现简单。
- 缺点:空间固定为 $O(V^2)$,稀疏图浪费严重;遍历某点的所有邻居要扫描一整行,$O(V)$。
- 无向图的矩阵沿主对角线对称。
邻接表
用「数组 + 链表」:adj[i] 存放所有与节点 $i$ 相邻的节点。
vector<int> adj[MAX]; // 每个元素是一个链表/动态数组
void addEdge(int u, int v) {
adj[u].push_back(v);
adj[v].push_back(u); // 无向图要加反向边
}
- 优点:空间 $O(V + E)$,稀疏图友好;遍历某点的邻居只访问真实存在的边。
- 缺点:判断两点间是否有边要遍历链表,最坏 $O(V)$。
- 有向图中,
adj[u]存的是 $u$ 的出边;求入度需要额外遍历或另建逆邻接表。
怎么选
| 场景 | 选择 |
|---|---|
| 稠密图、需要频繁查询两点是否相邻 | 邻接矩阵 |
| 稀疏图、需要频繁遍历某点的所有邻居 | 邻接表 |
| 点数极大(如 $10^5$) | 邻接表(矩阵根本开不下) |
建图时的检查
读入边 $(u, v)$ 后要校验 $0 \le u, v < V$,避免越界;无权图的两点间不应重复加边,否则会影响遍历与度数统计。
十、Kruskal 与最小生成树
问题
在带权连通无向图中找一棵总权重最小的生成树。
算法(贪心)
- 把所有边按权重升序排序。
- 依次考察每条边:如果它的两个端点还不在同一个集合中,就把它加入生成树,并合并这两个集合;否则说明会形成环,直接丢弃。
- 选够 $V-1$ 条边时结束。
sort(edges.begin(), edges.end(),
[](const Edge& a, const Edge& b) { return a.weight < b.weight; });
UnionFind uf(numNodes);
vector<Edge> result;
for (const Edge& e : edges) {
if (uf.find(e.start) != uf.find(e.end)) { // 不连通 → 加入不会成环
result.push_back(e);
uf.unite(e.start, e.end);
}
}
并查集(Union-Find)
Kruskal 的效率关键在"判断两个点是否已连通",这正是并查集的强项。它支持两种操作:
- Find(x):找到 $x$ 所在集合的根节点,顺带判断两点是否同集合。
- Union(a, b):把两个集合合并。
两种优化配合使用可让均摊复杂度接近常数:
| 优化 | 做法 | 效果 |
|---|---|---|
| 路径压缩 | Find 时把路径上所有节点直接挂到根上 | 大幅降低树高 |
| 按秩合并 | 把矮树挂到高树下面 | 避免树长歪 |
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // 路径压缩
return parent[x];
}
void unite(int a, int b) {
a = find(a); b = find(b);
if (a == b) return;
if (rank[a] < rank[b]) swap(a, b); // 按秩合并
parent[b] = a;
if (rank[a] == rank[b]) rank[a]++;
}
要点
- 时间复杂度 $O(E \log E)$,主要花在排序上。
- 适合稀疏图。稠密图通常用 Prim 算法($O(V^2)$ 或配堆的 $O(E \log V)$)更快。
- 边的排序、并查集的判环,是 Kruskal 的两个核心动作。
十一、Dijkstra 与单源最短路径
问题
给定带权图和起点 $s$,求 $s$ 到其余所有顶点的最短距离。
算法(贪心 + 优先队列)
- 把起点距离设为 0,其余全部设为无穷大。
- 把
(距离, 顶点)放入优先队列,每次取出距离最小的顶点 $u$——它的最短距离此刻已经确定。 - 松弛:对 $u$ 的每个邻居 $v$,若 $dist[u] + w(u,v) < dist[v]$ 则更新 $dist[v]$,并把新的
(dist[v], v)入队。 - 重复 2–3,直到队列为空。
vector<int> dijkstra(const vector<vector<int>>& g, int src) {
int n = g.size();
vector<int> dist(n, INF);
// 小顶堆:队首是距离最小的元素
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
dist[src] = 0;
pq.push({0, src});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue; // 过期条目,跳过
for (int v = 0; v < n; v++) {
if (g[u][v] == 0) continue; // 0 表示无直接连接
if (dist[u] + g[u][v] < dist[v]) {
dist[v] = dist[u] + g[u][v];
pq.push({dist[v], v});
}
}
}
return dist;
}
要点
- 不能处理负权边。Dijkstra 的正确性依赖"已取出的点距离不会再变小",负权边会打破这个前提;含负权时改用 Bellman-Ford 或 SPFA。
- 用邻接矩阵表示图时,内层遍历是 $O(V)$,总复杂度 $O(V^2)$;换成邻接表配优先队列可降到 $O(E \log V)$。
- 同一个点可能被多次入队,所以出队时要判断
d > dist[u]跳过过期条目。 - 用
0表示"无边"是一种约定;若图中允许权重为 0 的边,需要另设标记或改用邻接表。
十二、关键路径
问题
在 AON(活动在顶点上)网络中,每个节点表示一个活动、带有持续时间和先后依赖,找出决定项目总工期的关键活动序列。
- 最早开始时间 ES:该活动最早能什么时候开工。
- 最晚开始时间 LS:在不拖延总工期的前提下,最晚必须什么时候开工。
- 时间余量:$LS - ES$。余量为 0 的活动就是关键活动,它们连起来构成关键路径。
正向计算 ES
按拓扑序从头事件往后推:
$$ES[v] = \max_{u \in pred(v)} \big( ES[u] + duration[u] \big)$$没有前置活动的头事件 $ES = 0$。项目总工期 $T = \max_v (ES[v] + duration[v])$。
void calcEarliest() {
// 必须先按拓扑序处理,否则前驱的 ES 还没算好
for (int u : topoOrder) {
for (int v : activities[u].next) {
earliest[v] = max(earliest[v], earliest[u] + activities[u].duration);
}
}
}
反向计算 LS
按逆拓扑序从末事件往回推:
$$LS[u] = \min_{v \in succ(u)} \big( LS[v] \big) - duration[u]$$没有后继的末事件,$LS = ES = T - duration$。(等价地,令 $LF[\cdot] = T$,则 $LS = LF - duration$,$LF[u] = \min_{v \in succ(u)} LS[v]$。)
void calcLatest() {
for (int u : reverseTopoOrder) {
for (int v : activities[u].next) {
latest[u] = min(latest[u], latest[v] - activities[u].duration);
}
}
}
要点
- 两个方向都必须严格按拓扑序推进,否则用到的前驱/后继还没算好。
- 判断头事件不能看"邻接表是否为空"——邻接表为空的是末事件(出度为 0)。头事件的判定依据是入度为 0,这个方向搞反会导致整个计算失空。
- 关键路径可能不止一条,全部 $\text{余量} = 0$ 的活动都要标记出来。
- 想要缩短总工期,只能压缩关键活动;压缩非关键活动没有意义。
小结
| 实验 | 核心数据结构 | 关键点 |
|---|---|---|
| Fibonacci | 函数调用栈 | 递归靠栈保存参数与返回地址 |
| 划分子集 | 冲突矩阵 + 循环队列 | 与组内所有元素比较,贪心分组 |
| 稀疏矩阵乘法 | 三元组表 | 只累加两边都非零的项 |
| KMP | LPS 数组 | 文本指针永不回退 |
| 二叉树 | 数组 / 链式结构 | 顺序存储的父子下标关系;三种 DFS + 一种 BFS |
| 哈夫曼 | 最小堆 | 每次合并频率最小的两个节点 |
| 排序二叉树 | 二叉链表 | 删除分三种情况;中序得递增序列 |
| 线索化 | 带标记位的二叉链表 | 用 ltag/rtag 区分孩子与线索 |
| 图的存储 | 邻接矩阵 / 邻接表 | 稠密用矩阵,稀疏用表 |
| Kruskal | 边集 + 并查集 | 排序取边,判环靠并查集 |
| Dijkstra | 优先队列 | 贪心松弛,不支持负权边 |
| 关键路径 | 有向无环图 + 拓扑序 | 正推 ES、反推 LS,余量为 0 即关键 |
赞助 / 我一个人打车六七十回家!


何时一樽酒,重与细论文。