post / 2023.12.12

数据结构实验笔记

数据结构实验笔记

本文合并了原先的八篇数据结构上机实验报告,去掉了重复的套话和完整源码,只保留每个实验的核心思路、关键数据结构和算法要点,作为复习提纲使用。代码片段为 C++。

一、递归与栈:斐波那契数列

问题

用递归求第 $n$ 个 Fibonacci 数。

递归与栈的关系

递归能不能正确返回,取决于两件事:参数如何传递、返回到哪里。以 Fib(5) 为例:

  1. 每递归一层,都要给该层的形参重新分配空间;
  2. 还要把"本次调用结束后该回到哪一行"这个返回地址保存下来。

这两样东西合起来构成一条工作记录(活动记录),由系统压入函数调用栈。调用时压栈,返回时从栈顶弹出,于是就能准确回到上一层。

以 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)$ 即可。递归版本的价值在于演示栈的工作过程。

二、划分子集问题

问题

把集合划分为若干互不相交的子集,使得同一子集内的元素两两无冲突,并且子集数量尽量少。(典型应用:排课、日程安排。)

算法:循环筛选法

  1. 先用一个二维数组(冲突矩阵)描述冲突关系:$conflict[i][j] = 1$ 表示 $i$ 与 $j$ 冲突,$0$ 表示不冲突。
  2. 从第一个未分组元素开始,把它放入当前组。
  3. 依次检查其余未分组元素:如果它与当前组里的每一个元素都不冲突,就并入当前组;否则跳过,留给下一轮。
  4. 一轮扫描结束后,若还有元素未分组,就开一个新组,重复第 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}$,但只累加两边都非零的项:

  1. 遍历 $A$ 的每一个非零元素 $(i, k, a)$;
  2. 在 $B$ 中寻找行号等于 $k$ 的非零元素 $(k, j, b)$;
  3. 把 $a \times b$ 累加到结果矩阵的 $(i, j)$ 位置。

结果矩阵的非零元素动态生成,若累加后为 0 则不存入。

要点

  • 比朴素三重循环快得多,因为跳过了大量零元素相乘。
  • 想进一步提速,可以给三元组表加行索引(行逻辑链接顺序表),使第 2 步不必每次从头扫描 $B$。
  • 必须校验:$A$ 的列数是否等于 $B$ 的行数,以及输入的行列下标是否越界。

四、KMP 字符串匹配

问题

在文本串中查找模式串首次出现的位置。朴素匹配在失配时把模式串整体右移一位、文本指针回退,最坏情况是 $O(nm)$;KMP 把它优化到 $O(n+m)$。

核心:LPS 数组

LPS(Longest Prefix Suffix)记录模式串每个位置之前的子串中,“最长且相等的前缀和后缀"的长度。它告诉我们在失配时模式串可以安全地向右滑动多少,从而不必回退文本指针。

以模式串 ABABCABAB 为例,其 LPS 数组为:

下标012345678
字符ABABCABAB
LPS001201234

算法

// 构造 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 起步,但中序必须从整棵树最左下的节点开始,否则会漏掉左子树的左半边。

由遍历序列反推二叉树

  • 前序 + 中序、后序 + 中序、层序 + 中序,都可以唯一确定一棵二叉树。
  • 但前序 + 后序不行,无法区分只有一个孩子时它挂在左边还是右边。

六、哈夫曼树与编码

问题

对字符按出现频率编码,使编码后的总长度最短,并且任意编码都不是另一个编码的前缀(前缀码,保证能唯一译码)。

算法

  1. 统计每个字符的出现频率。
  2. 每个字符建一个叶子节点,全部放入最小堆。
  3. 循环:取出频率最小的两个节点,合并为一个新节点(频率为两者之和),把新节点放回堆中。
  4. 堆中只剩一个节点时,它就是哈夫曼树的根。
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 与最小生成树

问题

在带权连通无向图中找一棵总权重最小的生成树。

算法(贪心)

  1. 把所有边按权重升序排序。
  2. 依次考察每条边:如果它的两个端点还不在同一个集合中,就把它加入生成树,并合并这两个集合;否则说明会形成环,直接丢弃。
  3. 选够 $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$ 到其余所有顶点的最短距离。

算法(贪心 + 优先队列)

  1. 把起点距离设为 0,其余全部设为无穷大。
  2. 把 (距离, 顶点) 放入优先队列,每次取出距离最小的顶点 $u$——它的最短距离此刻已经确定。
  3. 松弛:对 $u$ 的每个邻居 $v$,若 $dist[u] + w(u,v) < dist[v]$ 则更新 $dist[v]$,并把新的 (dist[v], v) 入队。
  4. 重复 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函数调用栈递归靠栈保存参数与返回地址
划分子集冲突矩阵 + 循环队列与组内所有元素比较,贪心分组
稀疏矩阵乘法三元组表只累加两边都非零的项
KMPLPS 数组文本指针永不回退
二叉树数组 / 链式结构顺序存储的父子下标关系;三种 DFS + 一种 BFS
哈夫曼最小堆每次合并频率最小的两个节点
排序二叉树二叉链表删除分三种情况;中序得递增序列
线索化带标记位的二叉链表用 ltag/rtag 区分孩子与线索
图的存储邻接矩阵 / 邻接表稠密用矩阵,稀疏用表
Kruskal边集 + 并查集排序取边,判环靠并查集
Dijkstra优先队列贪心松弛,不支持负权边
关键路径有向无环图 + 拓扑序正推 ES、反推 LS,余量为 0 即关键

本文作者: 孤筝

本文标题: 数据结构实验笔记

本文发布时间: 2023-12-12

本文更新时间: 2026-10-05

本文版权: CC BY-NC-SA 4.0

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

搜索