
第三弹了我决定换一个更“实用主义”的角度来写C/C数据结构与算法。之前我习惯把每个知识点按“定义—实现—复杂度”的顺序拆开讲后来发现不少读者反馈单独看懂了做题还是卡住写实验报告也没思路。原因很简单把单个容器和算法背熟和能把它们串起来解决具体问题中间隔着一条很长的实战距离。所以这一篇我打算以“从暴力到高效”作为主线把暴力枚举、剪枝、KMP、双端队列、排序与分治这几组高频考点放到同一个框架里讲。这篇文章适合三类人正在准备蓝桥杯或算法面试的C/C选手期末复习数据结构、打算动手写实验报告的同学以及刚在VS Code里配好C/C环境、想找一个能真正练手的切入点的初学者。篇幅会比较长建议配合代码一起看最好打开编译器跟着敲一遍。1. 暴力枚举没你想的那么“笨”状态空间、剪枝和复杂度边界1.1 暴力枚举的适用边界什么时候可以硬来先聊一个很多人看不起、但所有高手都不敢轻视的话题暴力枚举。为什么因为它是确保正确性的底线。面试里我见过太多选手一上来就背了个高级模板结果边界漏掉、下标搞错、复杂度根本分析不清楚。真正的解题流程应该先从暴力开始把状态空间画清楚然后才谈优化。暴力枚举的本质是把问题所有可能的状态全部遍历一遍。打个比方找钥匙的时候聪明人会根据钥匙形状排除一部分抽屉但暴力做法是每个抽屉都拉开看一眼。这个策略看起来很蠢但它的正确性几乎不需要证明不会因为分析漏了某个分支而翻车。关键问题是什么时候能“硬来”判断标准只有一个运行时间。C在普通竞赛和面试环境下一秒钟大概能承受10的7次方到10的8次方量级的简单操作。如果枚举的状态总数在这个范围内暴力就是合理方案。比如 n 是 20 左右的子集枚举2的20次方约等于100万完全可行n 到 24 时2的24次方是1600多万C还能勉强跑但已经开始有压力一旦 n 到 30就必须换思路比如折半搜索把问题拆成两半分别枚举再合并答案。这个“先算状态总数再决定方案”的习惯比背多少模板都重要。很多蓝桥杯的填空题和简单题本质就是让你判断“这题能不能直接枚举”能就直接写别犹豫。1.2 三种高频枚举形态全排列、子集、区间点对我整理了刷题里最常见的三种枚举形态每一种都有对应的固定套路。第一种全排列枚举。C里最省事的是用std::next_permutation但它有个前提必须先排序否则它只会在当前序列之后的字典序区间里生成排列会漏掉前面的情况。手写回溯全排列也是高频考点核心是一个标记数组加路径容器#include bits/stdc.h using namespace std; vectorint path; vectorbool used(10, false); void dfs(int n, int depth) { if (depth n) { for (int x : path) cout x ; cout \n; return; } for (int i 1; i n; i) { if (used[i]) continue; used[i] true; path.push_back(i); dfs(n, depth 1); path.pop_back(); used[i] false; } } int main() { dfs(3, 0); return 0; }第二种子集枚举。最常见的是二进制枚举把 mask 的每一位当成“选或不选”。n 个元素的集合映射成 0 到 (1n)-1 的整数每一位代表一个元素是否出现在子集中。for (int mask 0; mask (1 n); mask) { int sum 0; for (int i 0; i n; i) { if (mask (1 i)) { sum a[i]; } } if (sum target) { // 记录 mask 对应的子集 } }第三种区间和点对枚举。两个下标 i 和 j 构成一个子数组或一个点对两重循环扫一遍。这个形态本身很简单但它往往是优化的起点比如用前缀和把子数组求和从 O(n) 降到 O(1)整体复杂度从 O(n^3) 直接降到 O(n^2)。1.3 剪枝的三个方向可行性、最优性与搜索顺序暴力枚举的进阶形态是搜索搜索的灵魂是剪枝。剪枝的本质是在DFS深入之前提前判断某条分支不可能产生答案直接返回。剪枝有三个主要方向我分别说。可行性剪枝判断当前路径继续走下去是否还有可能满足约束。比如走迷宫时剩余步数不够到达终点直接放弃。最优性剪枝用当前结果和理论上可能达到的最好结果比较如果已经没有希望超过已知最优解就剪掉。这个在求最大值的背包问题里特别常用。搜索顺序剪枝优先搜索那些更容易让答案逼近最优解的分支这样最优性剪枝条件会更早被触发整体搜索树会明显缩小。我举个例子0/1背包的深度优先搜索加最优性剪枝。先把物品按单位价值排序预处理后缀总价值然后搜的时候判断“当前价值加上剩余物品的最大理论价值”是否已经小于已知最优解struct Item { int w, v; double ratio; }; int n, capacity, best 0; vectorItem items; vectorint suffixMax; void dfs(int idx, int curW, int curV) { best max(best, curV); if (idx n) return; // 最优性剪枝curV 加上后面所有物品的理论最大价值也追不上 best if (curV suffixMax[idx] best) return; // 选当前物品 if (curW items[idx].w capacity) { dfs(idx 1, curW items[idx].w, curV items[idx].v); } // 不选当前物品 dfs(idx 1, curW, curV); }这里suffixMax[idx]表示从 idx 到 n-1 所有物品价值之和。注意剪枝条件里的等号如果相等时追不上已知最优说明这条路再走下去也没有意义。但剪枝条件一定要分析清楚宁可少剪也不能把可行解剪掉这是新手最容易踩的坑。1.4 从枚举到记忆化搜索一个自然的进阶讲完剪枝我想多说一句记忆化搜索就是“枚举 状态去重”它是动态规划的前身。如果你发现同一组参数在递归中被反复计算用一个数组把结果存下来下次直接返回这就是记忆化。它能处理的规模往往比纯枚举高好几个数量级因为去重之后每个状态只访问一次。所以我始终觉得学算法不一定要按“数据结构先学完再学算法”的顺序来。从枚举入手然后学会分析重复子问题再引入记忆化这个路径对新手来说比直接上DP要友好得多。2. KMP 的 next 数组别背模板试着从手推开始2.1 朴素匹配慢在哪里i 的无效回退字符串匹配也是数据结构里的“劝退点”尤其是 KMP。很多人能背出代码但问他 next 数组是怎么算出来的就露出半吊子水平了。先看朴素匹配为什么慢。主串 s 和模式串 p两个循环i 从主串头开始j 从模式串头开始一旦失配i 要回退到这次匹配起点的下一个位置j 归零整个过程的时间复杂度是 O(n*m)。慢的根源在于 i 的线性回退。举个例子主串是aaaaab模式串是aaab。朴素匹配在 i 从 0 开始匹配到前三个 a 之后在第四个位置发现 b 和 a 不相等然后 i 回到 1重新开始比较。这个过程重复了多次而实际上模式串内部的连续aaa结构完全可以让我们少做很多无用功。能不能让 i 不回退只让 j 跳到一个合理的位置继续比较这就是 KMP 的核心思想。2.2 next 数组的本质最长相等真前后缀KMP 的一切秘密都在 next 数组里。我这里采用的版本next[i] 表示模式串前 i1 个字符组成的子串里最长相等“真前后缀”的长度。所谓真前后缀就是前缀和后缀不能等于整个子串本身。举个例子模式串ababc。i0 时子串是a真前后缀只有一个字符但长度为 1 的“真”前后缀既包含前缀 a 又包含后缀 a等于整个子串所以不算next[0] 等于 0。i1 时子串是ab前缀 a后缀 b不相等next[1] 等于 0。i2 时子串是aba前缀 a后缀 a相等且长度为 1。长度 2 时前缀 ab后缀 ba不相等所以 next[2] 等于 1。i3 时子串是abab前缀 ab后缀 ab相等且长度为 2所以 next[3] 等于 2。i4 时子串是ababc前缀 a、ab、aba、abab后缀 c、bc、abc、babc没有任何相等next[4] 等于 0。所以ababc的 next 数组是[0, 0, 1, 2, 0]。再算一个容易出错的例子aaaaab前面五个 a 连续next 依次是 0、1、2、3、4到了最后 b因为 b 和前面的 a 不匹配会一路回退到 0所以最终 next 是[0, 1, 2, 3, 4, 0]。2.3 部分匹配表构建与匹配主过程求 next 的递推逻辑可以这样理解假设已经知道 next[i-1]要求 next[i] 时先把 j 定位到 next[i-1]比较 p[i] 和 p[j]。如果相等那 next[i] 就是 j1如果不相等就需要循环回退让 j next[j-1]继续比较直到 j 为 0 或者找到匹配。代码写成这样vectorint buildNext(const string p) { int m p.size(); vectorint next(m, 0); int j 0; for (int i 1; i m; i) { while (j 0 p[i] ! p[j]) { j next[j - 1]; } if (p[i] p[j]) { j; } next[i] j; } return next; }匹配主过程也很对称。i 遍历主串j 维护模式串当前位置。失配时不动 i只让 j 跳回next[j-1]这也是为什么很多人容易写错既然 next 存的是长度那失配时应该看 j-1 位置的 next 值而不是第 j 个位置。int kmpMatch(const string s, const string p) { int n s.size(), m p.size(); vectorint next buildNext(p); int j 0; for (int i 0; i n; i) { while (j 0 s[i] ! p[j]) { j next[j - 1]; } if (s[i] p[j]) { j; } if (j m) { cout match at i - m 1 endl; j next[j - 1]; // 继续寻找下一个匹配位置 } } return -1; }2.4 一个完整模拟abababcabab 中找 ababc理论说多了容易晕我手动模拟一遍。主串abababcabab模式串ababcnext 数组是[0, 0, 1, 2, 0]。i0s[0]ap[0]a匹配j 变成 1。 i1s[1]bp[1]b匹配j2。 i2s[2]ap[2]a匹配j3。 i3s[3]bp[3]b匹配j4。 i4s[4]ap[4]c失配。j 跳到 next[3]2也就是模式串的第 2 个字符 a 重新开始。此时 s[4] 恰好等于 a所以 j 变成 3。 i5s[5]bp[3]b匹配j4。 i6s[6]cp[4]c匹配j 变成 5等于模式串长度说明在主串下标 2 到 6 的位置找到了ababc。注意整个过程 i 从 0 到 6 没有回退过这就是 KMP 能保持 O(nm) 的原因。原理上可以理解为i 只增不减j 虽然有回退但回退的总次数不会超过之前匹配成功的总次数所以整体是线性的。另外提一嘴网上还有种从 -1 开始的 next 版本两种写法都能用但面试时最容易翻车的是两种混着写。我的建议是认准一种这里用的“长度模式”配合next[j-1]回退思路统一不容易出边界问题。至于 nextval 优化核心思想是如果失配后跳过去的字符和当前失配字符相同那这次跳转是无效的应该继续往前跳它能进一步减少无意义的比较但实际工程中收益有限考试时知道这个概念就够了。3. 双端队列STL 的 deque 底层是怎么兼顾两端插入的3.1 为什么需要 dequevector 和 list 的两难普通队列是 FIFO队头出、队尾入。双端队列就是头尾两边都可以进出C 标准库里的std::deque就是这个角色。为什么需要它因为很多场景下vector和list都让人觉得别扭。vector尾部插入很快但头部插入要把所有元素往后搬O(n)list两端插入都能做到 O(1)但随机访问是 O(n)而且每个节点都要分配独立内存缓存命中率差。deque恰好补在这个空档头尾插入删除都是均摊 O(1)随机访问也是 O(1)。它非常适合用在滑动窗口、某些 BFS 变体、树的锯齿形层次遍历这类场景里。接口上就是push_back、push_front、pop_back、pop_front还支持operator[]用起来像数组一样方便。3.2 deque 的底层中控 map 与缓冲区面试里如果问deque为什么能做到两端 O(1)很多人答不上来。它和vector不是一个套路vector是连续大数组deque是一个“中控数组”加一堆小缓冲区。我尽量讲得直白一些。deque内部有一个指针数组叫中控 map里面每个元素指向一块固定大小的缓冲区通常每块缓冲存几百个元素。往头部插入时如果当前头部缓冲区满了就在中控 map 的前面再挂一块新缓冲区往尾部插入同理只不过挂在后面。元素本身分布在多个不连续的小数组里但因为中控 map 记录了每块缓冲的地址随机访问时可以先通过下标定位到是第几块再算缓冲内偏移本质上还是两次指针跳转所以复杂度是 O(1)。这个设计换来了两端插入的均摊 O(1)代价是比vector多一次间接寻址缓存命中率也不如vector好。所以我的经验是需要频繁在头部操作时选deque纯粹尾部追加加随机访问vector仍然优先需要在中间任意位置插入删除才轮到list。容器push_frontpush_back随机访问缓存命中率适用场景vectorO(n)O(1) 均摊O(1)高尾部操作多dequeO(1)O(1)O(1)中两端操作多listO(1)O(1)O(n)低频繁中间插入删除3.3 环形数组手写双端队列为了真正理解双端队列我建议你手写一个。最常见的实现是基于环形数组。维护head_表示队头下标size_表示元素个数容量固定时队尾下标就是(head_ size_) % cap_这样不用单独维护 tail也避免了 head 和 tail 相等时是空还是满的歧义。template typename T class MyDeque { public: MyDeque(size_t cap 8) : cap_(cap), data_(cap), head_(0), size_(0) {} bool empty() const { return size_ 0; } bool full() const { return size_ cap_; } void push_back(const T v) { if (full()) resize(); int idx (head_ size_) % cap_; data_[idx] v; size_; } void push_front(const T v) { if (full()) resize(); head_ (head_ - 1 cap_) % cap_; data_[head_] v; size_; } void pop_front() { head_ (head_ 1) % cap_; --size_; } void pop_back() { --size_; } T front() { return data_[head_]; } T back() { return data_[(head_ size_ - 1) % cap_]; } private: size_t cap_; vectorT data_; size_t head_; size_t size_; void resize() { vectorT newData(cap_ * 2); for (size_t i 0; i size_; i) { newData[i] data_[(head_ i) % cap_]; } head_ 0; data_.swap(newData); cap_ * 2; } };这个实现里push_front和push_back在不满时都是 O(1)。扩容时重新分配一块更大的内存把元素从head_开始按逻辑顺序搬到新数组然后重置head_为 0逻辑顺序完全不变。这也是 STLdeque扩容思路的简化版只是它不用整体搬迁只搬中控 map因此扩容成本更低。写这个代码时最容易犯的错是pop_back有人会去移动 head 或者清零尾部元素其实不用只要--size_这个元素逻辑上已经不存在了后续插入会自然覆盖它。3.4 滑动窗口最大值单调队列的经典应用双端队列最经典的实战题目就是滑动窗口最大值。给定一个数组和窗口大小 k求每个窗口的最大值。普通做法是每滑一次扫一遍窗口O(n*k)用单调双端队列可以把总复杂度压到 O(n)。核心思想是队列里存下标队头到队尾对应的元素值单调递减。窗口每次右移时做三件事先弹出已经滑出窗口的队头然后把新元素从队尾往前弹出所有比它小的下标因为那些元素在窗口里已经不可能再成为最大值了最后把新下标压入队尾。这样队头永远是当前窗口的最大值。vectorint maxSlidingWindow(vectorint nums, int k) { dequeint q; // 存下标 vectorint res; for (int i 0; i (int)nums.size(); i) { while (!q.empty() q.front() i - k) q.pop_front(); while (!q.empty() nums[q.back()] nums[i]) q.pop_back(); q.push_back(i); if (i k - 1) res.push_back(nums[q.front()]); } return res; }我第一次写这个题时犯过一个错压入队列前忘了把小于等于新元素的值都弹出去导致队头不是严格最大。后来意识到单调队列里的“等于”也要弹出否则相同的最大值会留在队尾很久虽然不影响正确性但会让队列不够精简理解上也容易混乱。4. 快排和堆排里的分治思维从一份排序对照表说起4.1 分治的三板斧拆分、递归、合并分治是数据结构与算法里最重要的元思想之一排序是它最好的教学载体。分治思路只有三步拆分把原问题拆成结构相同的子问题递归对子问题分别求解合并把子问题的结果组装回原问题答案。递归三要素也对应着这三步终止条件通常是规模足够小直接解决递推关系描述怎么把大问题化成小问题合并操作决定递归完怎么组装结果。如果理解不了递归建议先从打印斐波那契数列开始画一下调用栈然后再看排序。复杂度上有个很短的主定理直觉如果问题被分成 a 份每份大小是原来的 b 分之一合并代价是 O(n^d)那么复杂度大概遵循常见形式。快排和归并的 O(n log n) 来自“每次规模减半共 log n 层每层合计 O(n)”堆排也同样把这棵树画出来就容易理解了。4.2 快速排序的写法与退化问题快排的核心是 partition 操作选一个基准值 pivot把数组排成左边小于 pivot右边大于 pivot然后递归处理左右两边。最不容易写错的版本是双指针相向逼近。两个指针从左右同时出发左边找比 pivot 大的右边找比 pivot 小的找到就交换最后 pivot 落在正确位置。为了方便我通常直接取中间元素当 pivot避免在边界上纠结。void quickSort(vectorint a, int l, int r) { if (l r) return; int i l, j r; int pivot a[l (r - l) / 2]; while (i j) { while (a[i] pivot) i; while (a[j] pivot) --j; if (i j) { swap(a[i], a[j]); i; --j; } } quickSort(a, l, j); quickSort(a, i, r); }这个版本思路很清晰但有一个隐患如果数组原本有序而 pivot 每次取到的是边界值那么递归退化成每次只把数组分成长度 1 和长度 n-1 的两部分时间复杂度退化成 O(n^2)。所以很多工程实现会随机选 pivot或者用三数取中取左、中、右三个位置的中位数当 pivot。数据量小到一定程度时直接切到插入排序也能避免递归开销。快排不稳定这一点也常被问到。所谓不稳定就是相同值的相对顺序可能被打乱原因在 partition 的交换过程中相同的元素会被移动。工程上如果要求稳定排序一般直接选归并。4.3 堆排序在数组上模拟完全二叉树堆排序是另一种典型的分治思想但它没有显式地递归拆分问题而是利用完全二叉树在数组里的位置关系下标 i 的左孩子是 2i1右孩子是 2i2父节点是 (i-1)/2。这个映射关系就是“数组即树”的核心。建堆的过程从最后一个非叶节点开始逐个向下调整。向下调整的意思是如果当前节点比它的左右孩子中较大的那个小就交换然后继续往下调整直到满足堆的性质。void siftDown(vectorint a, int i, int n) { while (true) { int largest i; int l 2 * i 1; int r 2 * i 2; if (l n a[l] a[largest]) largest l; if (r n a[r] a[largest]) largest r; if (largest i) break; swap(a[i], a[largest]); i largest; } } void heapSort(vectorint a) { int n a.size(); for (int i n / 2 - 1; i 0; --i) { siftDown(a, i, n); } for (int i n - 1; i 0; --i) { swap(a[0], a[i]); siftDown(a, 0, i); } }建堆过程看起来像 O(n log n)因为每个节点都要下沉 log n 层但实际上底层节点数量多但下沉次数少经过级数求和之后建堆总代价是 O(n)。这个结论我当时推导了很久才接受建议你自己也动笔算一次越靠下的节点虽然多但每次下沉到叶子只需要 1 次或 2 次比较而根节点虽然只有一个但可能下沉 log n 层。各项累加起来是一个收敛的等比级数。排序阶段才是严格 O(n log n)因为每次把堆顶最大值和最后一个元素交换堆规模减一再对新的堆顶做一次下沉。4.4 考研与面试常用的排序剖析我整理了一张简化版的排序对照表覆盖了期末复习和面试里最常用到的七种排序算法平均时间复杂度最坏时间复杂度空间复杂度稳定性冒泡排序O(n^2)O(n^2)O(1)稳定插入排序O(n^2)O(n^2)O(1)稳定选择排序O(n^2)O(n^2)O(1)不稳定希尔排序O(n^1.3~2)O(n^2)O(1)不稳定归并排序O(n log n)O(n log n)O(n)稳定快速排序O(n log n)O(n^2)O(log n)不稳定堆排序O(n log n)O(n log n)O(1)不稳定这张表不用死记理解两个关键记忆点就能推出来稳定性上相邻交换的排序基本都是稳定的跳跃交换的基本不稳定复杂度上比较排序的极限是 O(n log n)归并在最坏情况下也能达到快排和堆排平均是 O(n log n)但快排的最坏情况可以通过随机化来规避堆排则天然没有最坏情况退化的烦恼。面试里如果被问到“空间复杂度最小的稳定排序”答案是归并排序的原地版本但实际工程里还是用非原地的归并更靠谱。这个点可以作为延伸考试一般不会深挖。5. 跑起来才算学会VS Code 配置、单步调试和数据实验报告5.1 在 VS Code 里把 C/C 编译调试跑通算法这个东西光看不练等于白看。所以最后一部分我讲讲环境搭建以及怎么用 VS Code 把 C/C 的编译和调试配置起来。第一步是装编译器。Windows 上我建议装 MinGW-w64注意要选带 posix 线程模型和 seh 异常模型的那个版本装完后把 bin 目录加到系统 PATH。macOS 上装 Apple 的 clang执行xcode-select --install就能拿到命令行工具。Linux 上sudo apt install g之类一条命令解决。装完验证一下打开终端执行g --version能看到版本信息才算成功。很多初学者卡在这一步明明装了命令行里还是提示找不到 g十有八九是 PATH 没配好或者终端没重新打开。第二步是在 VS Code 里装扩展。必须装的是微软官方 C/C 扩展也就是ms-vscode.cpptools如果要偷懒跑单文件可以再装一个 Code Runner。第三步按 F5 调试时会提示创建配置VS Code 会生成.vscode/tasks.json和.vscode/launch.json两个文件。tasks.json负责编译核心逻辑是调 g 把当前打开的源文件编译成同目录下的 exe{ version: 2.0.0, tasks: [ { label: C/C Build, type: cppbuild, command: g, args: [-g, ${file}, -o, ${fileDirname}/${fileBasenameNoExtension}.exe], group: {kind: build, isDefault: true}, problemMatcher: [$gcc] } ] }launch.json负责调用 gdb 启动调试关键是program字段要指向刚才编译出来的 exe并且加一个preLaunchTask让它在调试前自动编译{ version: 0.2.0, configurations: [ { name: C/C Debug, type: cppdbg, request: launch, program: ${fileDirname}/${fileBasenameNoExtension}.exe, args: [], stopAtEntry: false, cwd: ${workspaceFolder}, environment: [], externalConsole: false, MIMode: gdb, miDebuggerPath: gdb, setupCommands: [ { description: Enable pretty printing for gdb, text: -enable-pretty-printing, ignoreFailures: true } ], preLaunchTask: C/C Build } ] }这里最容易踩的坑有两个。一是工作区路径带中文gdb 在某些环境下会乱码甚至崩我建议所有练习目录都用英文命名。二是 Windows 下控制台输出中文乱码这通常是因为源文件是 UTF-8而控制台代码页是 GBK最简单的办法是代码里先不用中文输出等配置熟练了再处理编码。顺带提一句MinGW-w64 不只是 VS Code 在用很多科学计算软件在编译 C/C 接口时也会依赖它比如 MATLAB 里配置 mex 编译工具链就是同一个工具链。5.2 用断点和观察窗口对着算法“看”配置调试环境不是为了装样子是为了真的能看到程序运行过程的中间状态。我学 KMP 时收获最大的一次不是在视频里看别人推导而是自己在 buildNext 函数里打了一个断点单步走了一遍。方法很简单在next[i] j;这一行打断点然后按 F5 调试左侧变量窗口里能看到 i、j、p 和 next 数组的实时变化。每次循环结束时盯着 next 数组里新填进去的值和纸上手推的结果比对一遍。一旦不一致问题很快就能暴露出来要么是回退逻辑写错了要么是边界条件判断反了。这种“对着变量看算法”的习惯能帮你把抽象的递归和回溯过程变成具体的画面。特别是看快排的递归调用直接单步一层层进去观察 partition 之后数组的变化比任何人用嘴解释都直观。我强烈建议每个学数据结构的人都花半天时间学会用调试器看数组这半天的投入比多看十个小时视频都管用。5.3 数据结构实验报告的写法最后聊一个期末经常遇到的实际问题数据结构实验报告怎么写。很多同学写实验报告就是贴一大段代码然后写几句“测试通过”这其实完全没有达到实验报告的目的。实验报告的核心是展示你“为什么这么设计”而不是“写了什么代码”。我常用的结构是这样需求分析用一两句话说明问题背景和输入输出数据结构设计说明你选了哪种存储结构为什么这么选比如双端队列为什么用环形数组而不是链表算法描述可以用自然语言加伪代码描述核心流程不用贴完整源码复杂度分析分析时间复杂度和空间复杂度这步是老师最在意的测试结果列一张表写清楚每个测试用例的输入、预期输出、实际输出再补一个异常场景。最后写小结复盘你在实现过程中遇到的问题和解决思路。举个实际的例子如果实验题目是“用双端队列实现滑动窗口最大值”需求分析是给定数组和窗口大小输出每个窗口的最大值。数据结构设计部分对比 vector、list、deque 的优劣说明选 deque 的原因队头需要弹出过期元素队尾需要弹出小元素正好用双端操作。算法描述写清楚单调队列的三步逻辑。复杂度分析给出 O(n) 时间、O(k) 空间的结论。这样一份报告即使代码量不多也大概率能拿到一个不错的分数。我个人的经验是实验报告别等到最后一晚才写边写代码边记下关键设计决策最后整理起来会非常轻松顺手还能把这些记录变成复习笔记。