牛客一模算法笔试复盘:KMP、Dijkstra与动态规划实战解析
2026/8/31 4:31:59 网站建设 项目流程

考过牛客模考(一模)算法笔试的同学,应该都有一种明显的感受:这套题不追求“偏难怪”,而是非常贴近真实校招笔试的调性——选择题考基础扎实不扎实,编程题考你在有限时间里能不能把思路快速转成正确代码。我完整跟过一轮牛客模考,一模这套卷子做完之后最大的感想是:它能暴露出来的问题,比闷头刷十套LeetCode还多,尤其是那些你以为会、但一上考场就卡壳的知识点。

牛客的模考系统大家都不陌生,它模拟的是主流互联网公司笔试环境的整套流程。不同公司用牛客笔试时的题量和难度差别不小,但一模这套卷子基本是“标准版”:约20道选择题加2到4道编程题,时长120分钟。选择题覆盖数据结构、算法、语言基础、操作系统和计算机网络;编程题主要集中在字符串处理、图论最短路、动态规划和贪心策略上。这个结构和很多公司校招技术岗的笔试高度重合,所以无论你面后端、前端还是算法岗,参加一次模考的参考价值都很大。

下面是我复盘这套卷子时的完整拆解,包括选择题高频考点的推导过程、编程题的思路和可运行代码,以及牛客笔试系统本身的注意事项。如果你正准备下一轮校招,或者正处于刷题阶段,这篇文章应该能帮你少走不少弯路。

1. 先把这套卷子看明白:模考形式和考点分布

1.1 牛客模考的试卷构成与时间分配

先说试卷形式。我参加的一模算法笔试,前半小时基本都在跟选择题较劲。选择题中,数据结构相关的一般占7到9道,算法设计类5到6道,剩下的分散在C++/Java语法、计算机网络和操作系统里。千万别小看这些选择题,很多题不是你会不会,而是“你能不能在规定时间内快速做出判断”。模考总时长通常是120分钟,如果选择题磨蹭太久,后面编程题大概率做不完,所以我习惯把选择题控制在40分钟以内,遇到拿不准的先标记,回头再处理。

编程题一般是2到4道,难度呈梯度上升。第一题往往是简单模拟或字符串处理,属于“送分题”,但只要有边界条件没考虑清楚,就可能卡很久。中间一到两题考察经典算法,比如最短路、DP、贪心,这部分是区分度最高的。最后一题如果出现,通常是综合应用或偏优化的题,拿到部分分也比空着强。

1.2 一模的核心考点清单

把整套卷子做完后,我统计了一下考点分布,整理成下面这张表供大家参考:

考点类别具体知识点一模出现频率备注重难点
数据结构数组、链表、栈、队列、哈希表链表反转、哈希冲突处理
树与二叉树遍历、二叉树性质、堆前中后序遍历转换、堆调整
字符串KMP、模式匹配中高next数组推导、匹配计数
排序算法快排、堆排、归并、冒泡复杂度、稳定性、手写实现
经典算法二分、贪心、DP二分答案、状态转移设计
图论Dijkstra、拓扑排序、并查集优先队列优化、路径打印
数学与杂项快速幂、位运算、STL使用溢出处理、取模运算

有同学会问,像粒子群算法、模拟退火、卡尔曼滤波、机器学习、深度学习这些热词里的算法,笔试会不会考?以我的经验,传统算法笔试很少直接让你手写粒子群或者SVM,这些更多出现在算法岗的专业方向笔试或者面试问答里。但选择题有可能会给你一段“群体迭代寻优”的描述,让你判断这是哪种算法。所以备考时至少要能区分常见算法的基本思想,而不是只会背名字。

另外,不管你是走后端、前端还是算法方向,这套卷子里的选择题都值得认真对待。因为这代表的是计算机基础素养,很多公司即使招非算法岗,也喜欢用这类题快速筛人。

2. 选择题高频考点拆解:从KMP到排序

2.1 KMP算法:next数组推导实战

先说KMP。热词里有一个非常典型的例子:模式串 p="abacaba",求其next数组。这个问题在一模乃至正式笔试中出现的频率都不低,因为它考的不是你能不能背出代码,而是有没有真正理解“前缀函数”的意义。

我先把模式串的字符和下标列出来,然后手动推导一遍:

下标 i0123456
字符abacaba
next[i]0010123

推导过程是这样的:

  • next[0] = 0,因为长度为1的子串没有真前缀和真后缀。
  • i=1,字符是b,子串"ab"的公共前后缀长度为0,所以next[1]=0。
  • i=2,字符是a,子串"aba"中前缀a和后缀a相同,长度为1,所以next[2]=1。
  • i=3,字符是c,子串"abac",最长公共前后缀为0,所以next[3]=0。
  • i=4,字符是a,子串"abaca",前缀a和后缀a相同,所以next[4]=1。
  • i=5,字符是b,子串"abacab",最长公共前后缀是"ab",长度为2,所以next[5]=2。
  • i=6,字符是a,子串"abacaba",最长公共前后缀是"aba",长度为3,所以next[6]=3。

这个推导过程其实就是KMP求next数组的暴力逻辑,笔试中常见考法有两种:一种是给出几个next数组选项让你选,另一种是让你判断某一步匹配失败后模式串应该怎么移动。

代码实现上,我习惯写下面这个版本,它在很多OJ上都验证过:

void getNext(const string& p, vector<int>& next) { int n = p.size(); next.resize(n); next[0] = 0; int j = 0; for (int i = 1; i < n; i++) { while (j > 0 && p[i] != p[j]) { j = next[j - 1]; } if (p[i] == p[j]) { j++; } next[i] = j; } }

这里有个特别容易搞混的点:不同教材对next数组的定义不一样。有的定义为“最长公共前后缀长度”,有的定义为“失配后跳转的下标位置”,两者之间往往差1。笔试时如果题目给了明确公式,一定以题目为准;如果没有给出,我一般直接按“最长公共前后缀长度”的版本推导。

2.2 排序算法复杂度与稳定性对照

排序算法是选择题的重灾区,因为可考的点太琐碎:平均时间复杂度、最坏时间复杂度、空间复杂度、是否稳定、一趟排序后的序列长什么样。我用一张表把常见考点汇总一下:

排序算法平均时间复杂度最坏时间复杂度空间复杂度稳定性
冒泡排序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 log n)~O(n^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+k)O(n+k)O(k)稳定

记忆技巧可以这样:排序算法是否稳定,核心看是“相邻元素交换”还是“远距离跳跃交换”。冒泡、插入、归并都是相邻或分段相邻的操作,相对稳定;选择、快排、堆排都涉及跨越多个位置的交换,容易把相等元素的相对顺序打乱,所以不稳定。快排最坏情况退化到O(n^2)的原因也要记清:当每次分区都极端不平衡,比如序列已经有序且每次选最左端元素当基准时,递归深度会变成n,这时复杂度就是O(n^2),优化方法是随机选基准或三数取中。

模考选择题里很爱考一类题:“对某序列进行某排序后,前几趟结果是什么”。这类题考的是你对排序过程的理解,不是死背复杂度。比如快速排序每一趟都会让基准元素落位,堆排序每一趟会把当前堆顶换到末尾。做这类题,拿小例子手推一遍比背结论可靠。

2.3 贪心、二分与“看似搜索”的题

选择题里还有一类题不是单纯考某个算法,而是给一个场景,让你判断最优策略。比如区间调度问题,问你按什么顺序贪心才能选最多的不相交区间。正确策略是按结束时间升序排列,每次选结束最早且与已选区间不冲突的区间。为什么不是按开始时间或区间长度?因为一个区间结束得越早,留给后面的空间就越大,这一步的贪心选择不会影响后续最优解。这就是贪心算法最核心的“局部最优能推出全局最优”的证明思路。

二分也是常客。很多同学对二分的印象还停留在“有序数组里找某个数”,但实际上笔试更爱考“二分答案”:在一个单调的取值范围内,判断某个值是否可行。比如给定若干包裹和载重量,求能按时运完的最小船载量。这种题只要写出一个O(n)的check函数,外面套二分,复杂度就是O(n log W),同时覆盖了“搜索”和“优化”两大概念,非常经典。

热词里提到的粒子群、模拟退火、蚁群这类元启发式算法,笔试选择题偶尔会以小场景形式出现。比如“一群候选解根据个体最优和全局最优迭代更新”,你要能识别出这是粒子群算法。备考时不需要深入推导公式,但要知道每个算法的核心机制:粒子群靠个体极值与全局极值驱动,模拟退火靠温度控制的概率接受劣解跳出局部最优,遗传算法靠选择、交叉和变异。这就足够了。

3. 编程题实战拆解:三道最具代表性的题

3.1 字符串处理题:KMP的活学活用

一模的第一道编程题通常是字符串处理,难度不大但很考基本功。我印象比较深的一道题是这样的:给定一个文本串s和一个模式串p,统计模式串p在文本串s中出现的次数,允许重叠。

看到“允许重叠”几个字,直接用string::find循环查找就会出问题,因为find默认从左往右找到一个就跳过整个匹配部分。比如s="aaaa",p="aa",按find做法只能找到1次,但实际重叠匹配能数出3次。所以这道题的正解就是KMP。

完整代码如下:

#include <bits/stdc++.h> using namespace std; vector<int> getNext(const string& p) { int n = p.size(); vector<int> next(n, 0); int j = 0; for (int i = 1; i < n; i++) { while (j > 0 && p[i] != p[j]) { j = next[j - 1]; } if (p[i] == p[j]) { j++; } next[i] = j; } return next; } int kmpCount(const string& s, const string& p) { if (p.empty()) return 0; vector<int> next = getNext(p); int n = s.size(), m = p.size(); int j = 0, ans = 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) { ans++; j = next[j - 1]; // 允许重叠匹配的关键 } } return ans; } int main() { string s, p; while (cin >> s >> p) { cout << kmpCount(s, p) << endl; } return 0; }

这个代码里有几个点要说明。第一,匹配成功时用“j = next[j - 1]”而不是“j = 0”,目的是利用已匹配部分的信息,让重叠匹配不重不漏。第二,主循环里用了while(cin >> s >> p),这是牛客笔试环境的标准写法,因为题目可能包含多组测试数据,系统不会告诉你一共有几组,只能通过读入EOF判断输入结束。

3.2 图论题:优先队列优化Dijkstra

第二道编程题经常是图论。这次一模出现了一道很典型的最短路题:n个城市、m条双向道路,每条道路有长度,求从起点1到终点n的最短路径长度。n和m的数据范围到了10的5次方量级,这就要求算法复杂度不能高于O(m log n),也就是必须用优先队列优化的Dijkstra。

朴素Dijkstra每次找最小dist时需要扫描全部节点,总复杂度O(n^2),在n=10^5时完全跑不动。用优先队列维护“当前dist最小的未确定节点”,每次弹出并用它松弛邻居,每个节点最多入队多次,总复杂度降到O(m log m),就能通过。

完整代码:

#include <bits/stdc++.h> using namespace std; typedef long long ll; typedef pair<ll, int> PII; const ll INF = 0x3f3f3f3f3f3f3f3f; int n, m; vector<vector<pair<int, ll>>> g; vector<ll> dist; void dijkstra(int s) { dist.assign(n + 1, INF); vector<bool> vis(n + 1, false); priority_queue<PII, vector<PII>, greater<PII>> pq; dist[s] = 0; pq.push({0, s}); while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); if (vis[u]) continue; vis[u] = true; for (auto &e : g[u]) { int v = e.first; ll w = e.second; if (dist[v] > dist[u] + w) { dist[v] = dist[u] + w; pq.push({dist[v], v}); } } } } int main() { while (cin >> n >> m) { g.assign(n + 1, {}); for (int i = 0; i < m; i++) { int u, v; ll w; cin >> u >> v >> w; g[u].push_back({v, w}); g[v].push_back({u, w}); } dijkstra(1); cout << (dist[n] == INF ? -1 : dist[n]) << endl; } return 0; }

上面代码用了C++17的结构化绑定,牛客的编译器通常支持。如果担心环境兼容性,可以把auto [d, u]换成传统的pq.top().first和pq.top().second再pop。

这里有两个容易踩的坑。第一个坑是重边,题目如果保证没有重边还好,如果有重边,更新时要取最小值。不过Dijkstra配合优先队列时,即使有重边也不影响最终结果,因为更小的dist会先弹出,后弹出的较大dist会被vis标记直接跳过。第二个坑是数据范围,边的权值和答案都可能超过int范围,一定要用long long,INF也要开得足够大,我习惯用0x3f3f3f3f3f3f3f3f。

3.3 动态规划题:经典状态转移

最后一类高频编程题就是动态规划。一模的DP题我遇到的是一道类似“最长不下降子序列”的变种:给定一个序列,求最长的子序列,使得相邻元素之差不小于k。n的量级在10^5,所以经典的O(n^2)DP会超时,需要用贪心+二分把复杂度压到O(n log n)。

核心思路是维护一个数组d,d[i]表示长度为i的子序列中,末尾元素的最小值。遍历原序列每个元素x时,在d中二分查找最后一个满足“与当前元素差至少为k”的位置,然后更新。这个思路本质上是LIS问题的推广。

代码可以这样写:

#include <bits/stdc++.h> using namespace std; int main() { int n, k; while (cin >> n >> k) { vector<long long> a(n); for (int i = 0; i < n; i++) cin >> a[i]; vector<long long> d; for (long long x : a) { // 在d中找最后一个 <= x-k 的位置 int l = 0, r = d.size(); while (l < r) { int mid = (l + r) >> 1; if (d[mid] <= x - k) l = mid + 1; else r = mid; } int pos = l; if (pos == (int)d.size()) d.push_back(x); else d[pos] = min(d[pos], x); } cout << d.size() << endl; } return 0; }

这个DP的细节比较多,我实际调试时也卡过一会儿。关键在于二分边界:我们希望找到一个尽量长的子序列,其末尾元素可以接上当前x且满足差值条件。d数组是单调递增的,所以二分找的是“最后一个 d[mid] <= x-k 的位置”,也就是当前元素能插入的位置。更新时取min是为了让d数组保持“更小末尾优先”,这样后续元素才有更大机会接上。

如果k=0,这道题就退化成普通的最长不下降子序列,二分条件变成d[mid] <= x,代码逻辑一样。笔试中遇到这种变体题,先想清楚“我能不能把它映射到学过的经典问题上”,能映射就成功了一大半。

4. 牛客笔试系统的使用技巧与避坑指南

4.1 ACM模式输入输出处理

很多第一次参加牛客笔试的同学会挂在输入输出上。牛客系统通常要求你自己处理标准输入输出,也就是所谓的ACM模式。别不当回事,我见过不少人在LeetCode上刷题如鱼得水,一到牛客笔试反而连完整main函数都写不出来。

最基础的三类输入要熟练掌握。第一类是固定数量的输入,比如先给一个n,然后给n个数,直接cin >> n再循环读取即可。第二类是未知数量的多组输入,系统不告诉你有几组,只在每组内部以特定格式提供数据,处理方式就是while(cin >> a >> b),一直读到EOF,上面几道题都是这个写法。第三类是含空格的字符串输入,cin >> s遇到空格会停,如果一行里需要读取完整句子,就要用getline(cin, s)。但要注意,在getline之前如果用过cin >> n,缓冲区会残留一个换行符,不先用getchar()把换行符吃掉,第一行getline会读到空串。这个坑我踩过不止一次。

4.2 复杂度估算与数据范围判断

编程题写完后,必须快速估算自己的算法能不能过。我常用的判断标准是:1秒内,普通C++代码能执行约10^8次简单操作。如果n是10^5,那么O(n^2)就是10^10,肯定超时,必须想办法优化到O(n log n)或O(n)。反过来,如果n是10^3,那O(n^2)完全没问题,不需要硬上更复杂的算法。

空间也类似。一个int数组vector a(10^7)大约占40MB,在牛客笔试常见的256MB内存限制下还可以,但再来几个类似数组就危险了。开数组前先算一算,别等段错误了才慌。还有一个隐性技巧:如果题目给的数据范围特别大,往往意味着必须用更优算法;如果给的数据范围很小,反而是个信号——可能可以用状态压缩、暴搜或者O(n^3)的Floyd直接过。

4.3 做题顺序与时间分配

关于做题顺序,我的建议是倒着做,不是正着做。先花两三分钟把全部题目扫一遍,判断难度,挑最有把握的先写。一模的编程题通常第一题最简单,但有时候第二题反而是模拟题,第三题才是真DP。看到题目先别急着打字,先在草稿纸上把样例推一遍,确认理解题意。

选择题的分配也别忘了。我给自己定的规则是:选择题每题最多2分钟,超时就标记跳过,最后如果有时间再回头。因为一道选择题的分值通常低于一道编程题的部分分,为了选择题放弃编程题很不划算。编程题即使拿不到满分,用暴力解法过掉部分测试用例,得分也往往比空着强。

5. 模考复盘与常见问题速查

5.1 模考暴露出的三类典型问题

复盘一模这套卷子,我发现多数人的问题集中在三类。

第一类是基础不牢。比如KMP的next数组定义含糊、排序稳定性记反、Dijkstra的vis标记位置写错,这些都属于“见过但没真正掌握”。建议针对这类问题回到基础,把每种经典算法自己手写两遍,直到不看书也能在纸上推出来。

第二类是代码实现速度慢。一道会做的题,从想清楚思路到通过所有用例,用了40分钟,这在笔试里等于失败。解决方法是平时刷题时用带计时的模式练习,每道题给自己设一个时限,模拟真实笔试的紧张感。

第三类是心态问题。看到某道题没思路,心里就开始慌,后面的题也没心思做。我在模考时也遇到过这种状态,后来总结出一条原则:先拿能拿的分。如果一道题没有AC思路,立刻把暴力写法写上,至少拿部分分,然后再去想优化。

5.2 高频报错与排查方法速查表

我把这次模考以及平时帮学弟学妹调试时遇到的高频问题整理成一张速查表,笔试前可以快速翻阅:

报错/现象可能原因排查思路
运行超时算法复杂度过高、死循环把n的范围套进复杂度估算,检查循环跳出条件
答案错误边界条件遗漏、未取模构造极端数据:空输入、最大n、全是相同元素
段错误访问越界、栈溢出检查数组下标范围,递归改循环
输出格式错误多打印空格/换行严格按照样例输出,行末不要留多余空格
编译错误头文件缺失、变量重名本地编译一次再提交,优先用万能头

5.3 一模之后的复习路线建议

一模结束后的复习,不建议再盲目刷题,而是按“查漏补缺—专题训练—模拟冲刺”三步走。

查漏补缺阶段,把模考中做错的题、蒙对的题全部整理出来,分析是哪个知识点薄弱。专题训练阶段,针对薄弱点集中刷30到50道同类型题目,比如数据结构薄弱就集中刷链表和二叉树,图论薄弱就集中刷最短路和拓扑排序。模拟冲刺阶段,考前一周每天做一套牛客模考或真题,重点训练时间分配和临场心态。

如果时间还充裕,可以适当了解一些扩展算法,比如粒子群、模拟退火、卡尔曼滤波、BM25、PID控制等。它们的原理不复杂,但在面试聊项目时能体现知识广度。

最后说一点我自己的体会。参加一次牛客模考(一模)算法笔试,最大的价值不在于分数,而在于让你在真正上考场前,把所有容易犯的错都提前犯一遍。我当年就是一模做得稀烂,才下定决心把KMP、Dijkstra、DP这些经典算法逐一手写了一遍,后来二模三模成绩明显提升。所以,如果你一模没考好,别灰心,把它当成一次免费的实战演练。问题暴露得越早,你能补救的时间就越多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询