2017年牛客模考(三模)的编程题集合,到今天再看依然是一套很典型的校招笔试训练题。那会儿我还在忙着刷题找工作,牛客模考每次都会卡着时间做一遍,三模这套题的难度曲线我记得很清楚:前两道基本是送分题,最后一道能拉开差距。最近用 Python 把它们重新做了一遍,顺便把思路和踩坑整理出来,希望对准备技术笔试的同学有帮助。这套题适合两类人看:一类是正在备战校招、想在笔试前找实战感觉的同学;另一类是已经工作一段时间,想重新捡起算法基本功的开发者。读完之后,你至少能收获三样东西:常见题型的识别方法、经典算法的模板写法,以及一套在线笔试环境下排查问题的思路。
1. 从三模看校招笔试:这套题到底想考什么
1.1 模考定位与题目梯度设计
牛客的模考并不是单纯给几道题让你刷,它是在模拟真实在线笔试环境:有限的时间、看不到实时提交反馈、代码要自己本地验证之后粘贴上去。2017年的这场三模,编程题集合按难度递进,基本是“一道热身的字符串题 + 一道数据结构题 + 一道综合算法题”的配置。这种设计很老练,它想让笔试成绩有区分度,同时也给不同水平的选手稳定的得分机会。
基础题考验的是“能不能顺畅地写代码”,也就是基本功;综合题考验的是“在时间压力下,能不能把问题抽象成算法模型”。所以你会发现,这套题不是考冷门技巧,而是反复在考同一批高频考点:字符串处理、栈和队列、动态规划、搜索。这其实给后来人一个很明显的信号:校招笔试不追求偏题怪题,核心是把经典问题的解法练到肌肉记忆。
1.2 题目背后的四项核心能力
我做完这三道题之后复盘,发现它们考察的能力可以拆成四层。第一层是读题抽象能力,能不能把一段描述转成明确的输入输出和数据模型;第二层是算法设计能力,根据数据范围选择合适的算法,而不是看到“最短路”就直接写 Floyd;第三层是实现与调试能力,能不能把思路落成边界正确的代码;第四层是复杂度意识,会不会预估最坏情况下的运行时间。
以第三层为例,很多人在本地测对了,一提交就 Runtime Error,原因往往是数组越界或空输入没处理。2017年的题集里就有一个字符串题,输入行可能包含多个空格,用 input().split() 和不处理空串的方法结果完全不一样。这就是典型的基础不牢。所以这套题虽然年代早了点,但考点一点都不过时。
2. 核心考点拆解:四个高频题型套路
2.1 字符串处理:绝不只是“遍历一遍”
字符串题看起来简单,但笔试里它是翻车重灾区。2017年三模里有一道单词反转的题目,要求保持单词顺序,把每个单词内部反转。很多人第一反应是先用 split() 按空格切分,再逐个反转,最后 join。思路没问题,但如果题目要求保留多余空格,事情就变得有点麻烦。
我建议这类题一定要先确认两件事:一是输入是否可能包含多余空格、换行符、制表符;二是输出要求是否严格匹配分隔符。如果允许用高级语言特性,Python 的切片很容易完成单次反转;但如果要求写 C++ 或 Java,双指针是更通用的方案。刷题的时候不要只满足于“能过样例”,要想想如果不让你用 split,你还能不能写出来。
2.2 栈与队列:括号匹配和单调栈的两种用法
括号匹配是栈的经典应用,思路很简单:遇到左括号入栈,遇到右括号时看栈顶是否匹配,匹配就弹出,不匹配就返回错误。但真正写代码时,很多人会忘记处理“栈为空但遇到右括号”的情况,或者遍历结束后栈里还有剩余左括号。
2017年的题目里,括号相关的题不光要求判断合法性,还要求计算最长合法括号子串的长度。这就要用到栈的另一个技巧:栈底保存“最后一个未匹配的右括号位置”。一旦遇到合法括号对,当前位置减去栈底位置就是当前合法子串长度。这个思路不是原始思路,但非常实用,很多后续题目都换了件马甲继续考。如果你对栈不熟,建议把“括号匹配 + 最长匹配”这两个模板都背下来。
2.3 动态规划:从二维 DP 到空间压缩
三模的压轴题里,动态规划占了不少分量,典型如最小编辑距离。这类题的套路很固定:定义 dp[i][j] 为第一个字符串前 i 个字符变成第二个字符串前 j 个字符的最小操作次数,然后按“插入、删除、替换”三种操作写状态转移。
初学 DP 的同学容易盯着递推公式看半天,却忽略了初始化的正确性。编辑距离里,dp[0][j] 应该等于 j,因为空串变成 j 个字符只能插入 j 次;dp[i][0] 应该等于 i,同理。这个要是写错,整个表都废了。等二维表写顺了,再考虑空间压缩:其实每一行只依赖上一行,所以可以用两个一维数组滚动更新,把空间降到 O(n)。校招笔试虽然一般不管空间,但能写出空间优化版本,在面试里是加分项。
2.4 搜索:BFS 与 DFS 怎么选
搜索题是综合题的常客。网格类题目里,求最短路径优先用 BFS,因为 BFS 第一次到达终点时的步数一定是最短的;而判断是否存在路径、求解连通区域数量,DFS 或 BFS 都可以。
2017年的题目中有一道迷宫题,数据范围不大,DFS 也能过,但从稳健角度我更喜欢 BFS。BFS 的模板非常固定:队列 + visited 数组,入队时标记,出队时扩展四个方向。这里有一个特别容易被忽略的点:如果出队时才标记 visited,同一个节点可能被多个邻居重复入队,虽然结果可能正确,但性能和内存上会有问题。正确的做法是“入队即标记”,在做提前就把它锁死,避免重复。
3. 实战模拟:代表题的完整题解
这套题里有几道代表性能拉满的题目,我把题干大意和完整解题过程复述一下,代码用 Python 写,尽量做到可以直接跑。
3.1 题解一:单词反转(简单)
题面:输入一个英文句子,单词之间用空格分隔,可能包含连续空格。输出每个单词内部字符都反转后的句子,单词顺序保持不变。
这道题考察字符串切割与拼接。最简单的做法是 split 后逐词反转:
s = input().strip() words = s.split() res = [w[::-1] for w in words] print(' '.join(res))如果你不想用 split,也可以双指针从后往前找单词边界。需要注意的地方有两个:第一,strip() 要去掉首尾多余空格,否则空字符串可能混进结果;第二,如果题目要求保留连续空格,上面的代码会丢失格式。这种题不能想当然,一定要根据题目输出要求决定是否保留。
3.2 题解二:最长合法括号子串(中等)
题面:给定一个只包含 '(' 和 ')' 的字符串,求最长连续合法括号子串的长度。
栈的经典扩展。常规做法是维护一个栈,遇到 '(' 入栈,遇到 ')' 出栈;但单纯这样只能判断合法,求不了最长长度。需要把栈底留作一个“哨兵”位置:
def longest_valid_parentheses(s): stack = [-1] max_len = 0 for i, ch in enumerate(s): if ch == '(': stack.append(i) else: stack.pop() if not stack: stack.append(i) else: max_len = max(max_len, i - stack[-1]) return max_len解释一下:遍历到右括号时先弹出一个左括号索引;如果弹完之后栈空了,说明这之前的括号无法配对,就把当前位置压入栈底作为新的哨兵;如果栈不为空,用当前位置减去栈顶元素,得到以当前右括号结尾的合法子串长度。实测下来这个方法非常稳定,代码短,且不容易漏边界。
3.3 题解三:最长无重复字符子串(中等)
题面:给定一个字符串,找出其中不含有重复字符的最长子串长度。
这是滑动窗口的经典题。维护一个窗口,用哈希表记录字符最新出现的位置。遍历字符串时,窗口右端不断右移;如果下一个字符之前出现过,就把窗口左端移动到上次出现位置的下一个位置。
def length_of_longest_substring(s): char_index = {} left = 0 max_len = 0 for right, ch in enumerate(s): if ch in char_index and char_index[ch] >= left: left = char_index[ch] + 1 char_index[ch] = right max_len = max(max_len, right - left + 1) return max_len这里最关键的是判断条件char_index[ch] >= left。如果不加这个条件,可能把左边界往左拉。举个例子,字符串 "abba",当遍历到最后一个 'a' 时,虽然 'a' 之前出现过,但它在窗口外,所以不应该移动 left。这个坑我在笔试里踩过一次,印象特别深。
3.4 题解四:最小编辑距离(较难)
题面:有两个字符串 word1 和 word2,允许插入、删除、替换一个字符,计算将 word1 变成 word2 所需的最小操作次数。
经典 DP。定义 dp[i][j] 表示 word1 前 i 个字符转换到 word2 前 j 个字符的最小步数。初始化首行首列后,双循环遍历:
def min_distance(word1, word2): m, n = len(word1), len(word2) dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(m + 1): dp[i][0] = i for j in range(n + 1): dp[0][j] = j for i in range(1, m + 1): for j in range(1, n + 1): if word1[i - 1] == word2[j - 1]: dp[i][j] = dp[i - 1][j - 1] else: dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1) return dp[m][n]注意状态转移的语义:dp[i-1][j] + 1表示删除 word1 的第 i 个字符;dp[i][j-1] + 1表示插入一个字符到 word2;dp[i-1][j-1] + 1表示替换。笔试时建议先用二维表把思路理顺,再视情况优化一维空间。如果直接一维,写错了不好排查。
3.5 题解五:迷宫最短路(综合)
题面:给定一个 n 行 m 列的网格,0 表示可走,1 表示障碍,从 (0,0) 出发,每次可以上下左右移动一步,求到达 (n-1, m-1) 的最少步数,如果不可达返回 -1。
BFS 模板题:
from collections import deque def min_steps(grid): if not grid or grid[0][0] == 1: return -1 n, m = len(grid), len(grid[0]) visited = [[False] * m for _ in range(n)] q = deque() q.append((0, 0, 0)) visited[0][0] = True dirs = [(1, 0), (-1, 0), (0, 1), (0, -1)] while q: x, y, step = q.popleft() if x == n - 1 and y == m - 1: return step for dx, dy in dirs: nx, ny = x + dx, y + dy if 0 <= nx < n and 0 <= ny < m and not visited[nx][ny] and grid[nx][ny] == 0: visited[nx][ny] = True q.append((nx, ny, step + 1)) return -1这段代码有三个关键点:起点可能被障碍堵住;方向数组要能覆盖上下左右;visited 标记一定要在入队时做。如果你的代码把visited[nx][ny] = True放在了 pop 之后,数据一大就会队列爆炸。这也是在线笔试中很常见的性能陷阱。
4. 答题过程中的常见问题与排查技巧实录
4.1 我踩过的五个坑
| 常见问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 输入读取错误 | 数据只有一半,或输出完全不同 | 用 input() 读一行,但输入实际有多行 | 用 sys.stdin.read().split() 统一读取 |
| 数组越界 | 报 IndexError | 访问网格邻居时没判断边界 | 先判断 0<=x<n,再访问 |
| DP 初始化错误 | 答案偏大或偏小 | dp[0][j]、dp[i][0] 没赋初值 | 初始化首行首列为递增序列 |
| 栈空未处理 | 括号匹配误判 | 见右括号直接 pop,栈已空 | 先判断栈是否为空 |
| 超时无优化 | 大样例卡死 | 穷举所有子串,O(n^2) | 改成滑动窗口 O(n) |
这些坑看着简单,但每一条都对应着真实的失分现场。尤其是输入读取,牛客的测试数据往往比本地练习更复杂,不一次性读入,可能会因为换行符导致数据错位。
4.2 在线笔试环境下如何自测
在线笔试不像本地 IDE,你可以一边调试一边看变量。很多测评系统只给你一个“答案错误”的反馈,所以自测策略很重要。
我个人的做法是三步走。第一步,跑一遍题目自带的示例,确保基本流程正确。第二步,构造边界数据:空字符串、单字符、全障碍网格、重复字符、连续空格等,这一轮能过滤掉大部分隐藏错误。第三步,估算数据规模,如果最高复杂度可能达到 10^8 级别,就要考虑换算法或剪枝。
还有一个小技巧:在本地准备一个随机测试脚本,针对输入输出可以生成随机数据,然后和暴力解法对比。比如编辑距离题,可以写一个 BFS 的暴力版做对照,随机小数据比对结果,虽然笔试现场没时间这样做,但在家刷题时非常好用。
5. 基于这套题延伸的备考建议
5.1 刷题策略:从“做对”到“做快”
刷题阶段很多人只追求把题做出来,但校招笔试真正拉开差距的,是“做快”和“做稳”。一道题如果你需要 50 分钟才能写出来,那考试时基本等于没做。我建议按知识点分类刷题:字符串一组、栈和队列一组、DP 一组、搜索一组,每个知识点刷到能不看模板默写核心代码。
每道题做完之后,都花 10 分钟复盘:这题考的是哪个模板?我当时卡在哪一步?以后遇到相似题第一反应应该是什么?把这些内容记到自己的模板库里,比毫无目的地刷两百道题更有效。刷题数量当然重要,但数量必须建立在每次都有沉淀的基础上。
5.2 Python 写算法题的三个实用技巧
如果你打算用 Python 参加笔试,有三个技巧非常实用,我最近在做这套题时也一直在用。
第一个是collections.deque。BFS 时不要用 list 模拟队列,因为 pop(0) 是 O(n) 操作,数据量一大就卡死,deque 的 popleft() 是 O(1)。
第二个是快速读取输入。多行输入时,推荐:
import sys data = sys.stdin.read().split()这样一次读入所有内容,再按下标取数,既快又不容易漏行。很多遇到“本地能跑,在线超时”的同学,问题往往就出在输入输出上。
第三个是functools.lru_cache。解决递归型 DP 时,加一行装饰器就能自动记忆化,比如跳台阶、斐波那契、递归子序列问题都可以用。不过要注意,如果递归深度很大,建议还是改成显式 DP,避免栈溢出。
5.3 后续扩展方向
这套题里涉及的题型都有很多扩展。字符串题可以延伸到 KMP 和字典树;括号题可以继续思考如何生成所有合法括号组合;最长不重复子串再往前走就是滑动窗口做最小覆盖子串;编辑距离可以扩展到编辑代价不同的变种;迷宫 BFS 可以推广到多源 BFS、带权最短路。
如果你把这套题里的每个模板都吃透,再去刷其他真题,会发现很多题都是“旧瓶装新酒”。比如看到最长合法括号,你可以联想到栈;看到迷宫,你马上想到 BFS 模板;看到两个字符串求最小代价,你意识到要设计 DP 状态。这种条件反射,才是刷题训练最值钱的结果。
我个人在实际操作中的体会是:2017年的题目放在今天依然有练习价值,不是因为题目有多难,而是它把校招笔试最常考的题型都浓缩进去了。重新做一遍的时候,我仍然会犯一些低级错误,比如栈空判断漏掉、滑动窗口边界写错。所以别嫌题老,基础模板的熟练度,永远是笔试提分最快的一条路。