如果你正在准备校招,或者已经过了校招季正在准备跳槽,那你大概率被“牛客”这两个字支配过。牛客模考这东西,说白了就是模拟大厂笔试的一套完整流程:限时、在线判题、看不到实时排名,交卷之后给你一个通过率的百分比。我当年刷这套题的时候,一道题写错排序规则,愣是卡了四十分钟,那种想砸键盘的感觉,现在想起来还特别真实。
【2018】牛客模考(一模)编程题集合(B),是牛客网在2018年春季招聘季推出的第一轮模拟考的三套卷之一。一模的题目不是随手从题库里捞几道题塞进去,而是按照当年各厂笔试题的命题风格、难度曲线、考点分布去设计的。三套卷里,B卷的位置很微妙:A卷偏基础语法,C卷压轴题扎堆,B卷则处在中间偏上的位置,最适合拿来当完整的自测卷用。无论你是刚开始刷题的小白,还是刷了几个月LeetCode但没实际限时做过题的人,这套卷都值得认真做一遍——它考的不只是你会不会写代码,而是你在“限时+陌生环境+在线评测”的压力下,能不能把自己的真实水平稳定输出出来。
1. 一模B卷到底在考什么:整体设计与思路拆解
1.1 三套卷的难度定位,B卷为什么最适合自测
我先说一下当年一模三套卷的定位。A卷的目标是通过率大概在六成左右,题目以语法题、简单模拟、字符串基础操作为主,适合刚学完语言、准备首次参加笔试的人用来找感觉;C卷的目标通过率大概只有两成上下,线段树、贪心、复杂动态规划都是常客,适合已经刷了大量题、想冲击大厂核心岗位的人去挑战;B卷夹在中间,目标通过率大概在三成五到四成五之间,考点覆盖的是笔试中最高频的几类问题:字符串处理、排序思想、基础动态规划。
这也就是说,B卷不会让你在题目阅读上花太多时间,但每道题都需要你真正想明白再动手。我用一个比较直观的说法:如果A卷是在考你“会不会用语言”,B卷是在考你“能不能在笔试现场用最短时间把问题转化成代码”,C卷则是在考你“对高级算法的熟练度和临场推导能力”。绝大多数人的校招目标岗位,落到笔试环节,差不多就是B卷这个难度。所以这套卷的参考价值远不止“一套模拟题”,它基本是那几年大厂笔试的缩影。
1.2 一套模拟卷覆盖了哪些核心考点
拿B卷的编程题来说,常见的出题方向其实是可以总结的。字符串拼接、排序变体、数组内求最值、背包类动态规划,这四个方向在当年的笔试里出现频率非常高。字符串处理考的是你对底层字典序规则的理解;排序变体考的是你能不能打破“直接sort一下就行”的惯性思维;数组求最值表面上考算法,实际上考的是时间复杂度的权衡;背包类动态规划则是笔试里的“定海神针”,几乎每家公司的笔试题都绕不开它。
除此之外,这套卷还隐性地考了两个能力。第一个是输入输出处理,牛客的判题系统采用的是标准输入输出,不会帮你写好读入和输出,所有数据都得你自己解析,这个环节就能筛掉一批人;第二个是边界条件处理,空数组、重复元素、极端数值范围,这些用例不会出现在题目描述里,但评测机里一定有。能把这种隐性考点纳入统筹的模拟卷,才是真正值得刷的模考。
1.3 建议的做题顺序与时间分配
我自己做题的时候,习惯先花三到五分钟把所有题目通读一遍,把每道题的预估难度标一下。B卷这种结构,通常是一道简单题、一道中等偏难题、一道基础DP,做题顺序建议从简单题开始,把能拿的分先落袋,再啃中等题,最后做DP题。如果一道题二十到三十分钟还没有明确思路,就先跳过,把后面能拿的分拿到,回头再补。这套思路在牛客真实的笔试里非常管用,因为很多笔试不是按点给分,而是按通过的测试用例比例算分,你做了一半的题,也有部分分数。死磕一道题导致后面全空,是最亏的。
时间分配上,我建议用整块的一个半小时来模拟:前五分钟读题和定策略,前四十分钟做掉前两题,中间四十分钟做第三题,最后留十分钟检查边界和读入输出格式。如果中间卡住了,先输出样例看看结果,排除低级错误再继续。这套时间策略,我后来实际笔试时也一直沿用,很稳。
2. 牛客OJ的裁判规则:读题前先读懂判题系统
2.1 输入输出是笔试的第一道题
很多第一次用牛客做笔试的人,代码逻辑明明没问题,提交却一直报错,后来发现是卡在输入输出上。牛客使用的是标准输入输出,也就是通过标准输入流读取数据,通过标准输出流打印结果。它和力扣这种“函数式答卷”最大的区别,就是所有数据格式、读取顺序、输出格式都要你自己处理。评测机拿到你的输出之后,是逐字符和标准答案比对的,多一个空格、多一个换行、多打一行调试信息,都会判错。
所以做题的第一步,不是写算法,而是先确认输入格式。比如题目说第一行是一个整数n,接下来n行每行一个字符串,那你就需要一个能完整读入这些数据的模板。我用Python比较多,给出一个最常用的读入模板:
import sys def main(): data = sys.stdin.read().split() if not data: return # data 是一个列表,已经按空白字符切好 # 手动从 data 中按顺序取数 pass if __name__ == "__main__": main()这里用sys.stdin.read().split()比一行一行input()稍微快一些,尤其在读大量数据时优势明显。用.split()会自动把空格、换行、Tab都切掉,也不需要手动处理换行符。需要注意,如果题目里的字符串可能包含空格(比如地名、句子),就不能无脑用split(),得按行读取,这一点要读题时想清楚。
2.2 语言版本与编译环境要提前摸清
2018年前后,牛客在线评测对Python的支持已经比较成熟了,但很多考场同时提供Python2和Python3,默认版本可能不一样。现在牛客基本都以Python3为主流,大家直接选Python3就行。但如果你当年刷这套B卷,或者现在用的还是旧缓存页面,一定要确认自己写的代码在当前Python版本下能跑。最典型的就是print语句,Python2里print "ok"能运行,Python3会直接报语法错误。
C/C++用户需要注意编译器版本和STL的可用性,牛客多数情况下C++环境是支持C++11的,unordered_map、auto这些语法可以放心用。Java用户则需要把类名写成Main,否则评测机会报“找不到主类”。这些细节看上去不起眼,但在真实笔试现场,任何一个编译错误都会让你心态崩掉。我的建议是正式做题前,先在牛客的练习环境里提交一道最基础的A+B题目,验证自己选的语言环境是否正常,顺手把输入输出模板跑通,能省去后面很多麻烦。
2.3 多组测试用例与评测机制
牛客的笔试编程题,通常会有很多组测试用例,评测机对你的代码会跑多组输入。这意味着代码里不能写“只处理一组数据就结束”的逻辑。有些题面会明确说明“输入包含多组测试用例”,这时你的读入模板就不能只读一行,而是要循环读到没有数据为止。
多组数据处理的常用模板如下:
import sys def solve(): data = sys.stdin.read().split() # 使用指针或迭代器依次读取 i = 0 while i < len(data): n = int(data[i]) i += 1 # 处理每组数据 if i >= len(data): break if __name__ == "__main__": solve()另外要知道牛客的判题规则里有“部分通过”的概念。如果你的代码只过了60%的测试用例,页面会显示“通过率60%”,不会直接给你0分。所以在笔试时,哪怕你只能写出暴力解法,也一定要交上去,暴力至少能拿一部分分,空着才是真的0分。这也是我认为模考比刷零散题目更有价值的原因之一:它能帮你提前摸清这套评分机制,不至于正式笔试时手忙脚乱。
3. 真题逐题拆解:三道题的完整题解与代码实现
当年B卷的编程题,题型大致可以归为三类。我把三道典型题按原卷的题面风格复现出来,每一道都配上完整的输入输出、思路推导和代码实现,大家边看边跟着写,效果最好。
3.1 字符串拼接:直接sort一定会错的题
题面描述大致是这样:给定n个由小写字母组成的字符串,要求将它们按某个顺序首尾连接成一个字符串,使得最终得到的字符串字典序最小。求这个最小字典序字符串。
输入描述:第一行一个整数n,表示字符串数量;接下来n行,每行一个字符串。输出描述:一行,拼接后的最小字典序字符串。
样例输入:
3 b ba bc样例输出:
babbc这道题最容易踩的坑,就是直接对所有字符串按字典序排序,然后拼接输出。看起来很有道理,因为字典序最小的字符串自然应该放在前面。但这是错的。反例就是样例里的b和ba:"b"的字典序比"ba"小,如果按普通排序,"b"排在"ba"前面,拼接结果是"bba";但"ba" + "b" = "bab","bab"的字典序比"bba"小,所以正确的顺序应该是"ba"在前。
正确的比较规则是:两个字符串a和b,如果a+b < b+a,那么a就应该排在b前面。这个规则的本质是,在两个字符串的局部顺序影响全局结果时,不能孤立的比较单个字符串的字典序,而是要比拼接后的结果。为什么这个规则成立?因为任意一个合法的最终排列,如果存在相邻两个字符串的顺序违反了这个规则,那么交换这两个相邻字符串的位置,整个结果的字典序一定会变得更小。反复交换,直到所有相邻位置都满足规则,得到的排列就是全局最优。这个思路在算法上叫“基于交换的贪心证明”,是字符串排序类问题的核心。
代码实现时,Python3里可以用functools.cmp_to_key把自定义比较函数转换成排序的key:
import sys from functools import cmp_to_key def compare(a, b): if a + b < b + a: return -1 if a + b > b + a: return 1 return 0 def main(): data = sys.stdin.read().split() if not data: return n = int(data[0]) strs = data[1:1 + n] strs.sort(key=cmp_to_key(compare)) sys.stdout.write("".join(strs)) if __name__ == "__main__": main()时间复杂度方面,排序过程最多比较O(n log n)次,每次比较拼接后的字符串长度是O(L1 + L2),所以整体复杂度是O(n log n * L),其中L是字符串的平均长度。对笔试数据范围来说完全够用。空间复杂度是O(n),主要是存储输入字符串。
这道题的经验在于:遇到“把若干个元素排成某个顺序使得结果最优”的题,先想一想局部交换能否优化结果,如果相邻元素顺序错误会导致整个结果变差,那往往就是自定义排序规则解决了。
3.2 最大相邻差值:为什么不能直接排序
第二道题也是一个经典中的经典:给定一个长度为n的数组,要求求出这个数组排序后,相邻两个数之间差值的最大值。题目还加了一个条件:算法的时间复杂度要求为O(n)。
输入描述:第一行一个整数n,第二行n个整数。输出描述:一个整数,表示排序后相邻两数的最大差值。
样例输入:
5 7 1 3 2 6样例输出:
3排序后的数组是[1, 2, 3, 6, 7],相邻差值分别是1、1、3、1,最大差值是3。
如果你第一反应是“先把数组排个序,然后遍历一遍求差值”,说明你的思路方向是对的,但没满足题目要求的复杂度。常规排序O(n log n),数据范围一大就会超时。这题的正确解法是用“桶”的思想,把排序的复杂度降到O(n)。
核心思路是这样的:先找到数组的最小值min_val和最大值max_val。如果最大值等于最小值,说明所有数都一样,答案直接是0。然后我们把min_val到max_val这个区间平均分成n个桶,每个桶的宽度是(max_val - min_val) / (n - 1)。把n个数放进n个桶之后,第0个桶和第n-1个桶一定分别包含最小值和最大值,中间至少存在一个空桶。关键结论是:最大差值不可能来自同一个桶内的两个数,因为同一个桶内的数间距一定小于桶宽;最大差值只可能来自相邻两个非空桶之间,也就是前一个桶的最大值和后一个桶的最小值的差。
每个桶里我们只需要保存这个桶内元素的最小值和最大值,其他信息都不需要。然后从左往右扫一遍所有非空桶,计算相邻非空桶之间前桶最大值与后桶最小值的差值,取最大值即可。这个过程的复杂度是遍历一遍数组O(n),再扫一遍桶O(n),总时间复杂度O(n),空间复杂度O(n)。
具体实现时要注意一个细节:桶宽要避免浮点数计算,否则会有精度问题。我习惯用整数运算来处理,代码里这样写:
import sys def max_gap(nums): n = len(nums) if n < 2: return 0 min_val = min(nums) max_val = max(nums) if min_val == max_val: return 0 bucket_size = max(1, (max_val - min_val) // (n - 1)) bucket_num = (max_val - min_val) // bucket_size + 1 bucket_min = [None] * bucket_num bucket_max = [None] * bucket_num for num in nums: idx = (num - min_val) // bucket_size if bucket_min[idx] is None or num < bucket_min[idx]: bucket_min[idx] = num if bucket_max[idx] is None or num > bucket_max[idx]: bucket_max[idx] = num prev = bucket_max[0] ans = 0 for i in range(1, bucket_num): if bucket_min[i] is None: continue gap = bucket_min[i] - prev if gap > ans: ans = gap prev = bucket_max[i] return ans def main(): data = sys.stdin.buffer.read().split() if not data: return n = int(data[0]) nums = list(map(int, data[1:1 + n])) sys.stdout.write(str(max_gap(nums))) if __name__ == "__main__": main()为什么最大差值不会出现在同一个桶内?因为每个桶的宽度是严格小于等于全局平均间隔的,而n个元素分布在n个桶里,同一个桶内的任意两个元素差值一定小于桶宽。如果答案出现在桶内,那它一定小于某个相邻非空桶之间的间隔,所以不可能是最大值。这个证明可能有些抽象,但属于“先记住结论,用几次就理解了”的典型内容。实际笔试时,就算你记不住证明,只要按这个模板写,也能拿到满分。
3.3 购物券背包:基础动态规划定海神针
第三题是一个背包类动态规划题,我按当年的常见题面风格整理如下:小明有一张价值m元的购物券,商场里有n件商品,每件商品有一个价格p和一个重要度v,小明最多使用购物券购买一次(每件商品只能买一件)。他想在购物券可支付的范围内,让自己买到的商品总价值最大。其中单件商品的价值定义为“价格×重要度”。求最大总价值。
输入描述:第一行两个整数n和m,分别表示商品数量和购物券总额;接下来n行,每行两个整数p和v。输出描述:一个整数,表示最大总价值。
样例输入:
4 10 3 2 4 3 5 4 6 5样例输出:
42这里解释一下样例:第2件商品价格4、重要度3,价值为12;第4件商品价格6、重要度5,价值为30,两件总价10元,总价值42。这个组合是全局最优的。
这类题的经典解法是01背包动态规划。定义dp[j]表示购物券已花费不超过j元时能获得的最大总价值。初始化时所有dp[j]都为0。然后依次处理每件商品,对于当前商品,如果价格是p,价值是value,我们就从后往前更新所有dp[j]:
dp[j] = max(dp[j], dp[j - p] + value)
这里为什么要从后往前更新?因为每件商品只能用一次。如果从前往后更新,dp[j - p]可能已经在同一轮里被当前商品更新过,就会出现“同一件商品被买两次”的效果。从后往前更新,dp[j - p]还是上一轮的值,就能保证每件商品最多被选一次。这是背包问题里特别容易翻车的细节,我之前就栽过。
代码实现如下:
import sys def main(): data = sys.stdin.read().split() if not data: return it = iter(data) n = int(next(it)) m = int(next(it)) dp = [0] * (m + 1) for _ in range(n): p = int(next(it)) v = int(next(it)) value = p * v for j in range(m, p - 1, -1): if dp[j - p] + value > dp[j]: dp[j] = dp[j - p] + value sys.stdout.write(str(dp[m])) if __name__ == "__main__": main()时间复杂度是O(n * m),空间复杂度是O(m)。如果商品数量大,m作为购物券总额也不会大到离谱,这个复杂度在牛客的评测机上是能过的。如果你遇到的是“每件商品能买多件”的变体,那就是完全背包,内层循环改成从前往后就行;如果数量有限,比如每件商品最多k件,那就先把k件展开成单独的商品,再用01背包处理,这一套扩展对笔试来说已经覆盖大部分场景。
这道题背后的价值在于:动态规划的核心不是背模板,而是理解状态定义和转移方向。dp[j]从“不超过j元”到“不一定刚好花完”,这个状态设计可以灵活迁移到很多现实场景里。我当时做完这道题,顺手把牛客上其他几道基础背包题都刷了一遍,性价比很高。
4. 高频失误与排查技巧实录
4.1 一套题做下来最容易翻车的五个点
第一,字符串拼接题直接sort。这是很多人第一次做这道题时的通病,以为是纯字典序排序,结果几个特殊用例直接打回原形。记住一点:拼接类求最优的题目,优先考虑自定义排序规则,而不是直接套用默认排序。
第二,桶排序题里用了浮点数计算。桶宽如果用浮点数,会出现桶内元素落错桶、空桶判断错误、边界溢出这些问题。我用整数运算重构一次之后,所有边界用例都干净了。实测下来,整数除法加max(1, ...)的处理方式最稳妥。
第三,背包题内层循环方向写反。01背包从后往前,完全背包从前往后,这个口诀一定要刻在脑子里。我见过不少同学,状态转移方程写得完全正确,就是循环方向反了,样例能过,但隐藏用例全错。
第四,读入时没有处理多组数据。有些题目数据不止一组,代码写得再好,只处理一组就退出,等于白写。用sys.stdin.read()配合迭代器或索引,是最稳的通用做法。
第五,调试输出忘删除。写题时用print打印中间结果很正常,但交卷前忘了删,输出结果里就会混入调试信息,评测机判定答案错误。这种失误在真实笔试中特别冤。我现在的习惯是,提交前用快捷键全选代码,搜一遍print(确认没有多余输出。
4.2 在线笔试应该怎么调试
牛客笔试环境通常没有调试器,你不能打断点,也不能单步执行。这时候最有效的调试手段就是“对拍”,这个概念很多人听说过但没落实到习惯里。写一个暴力解法,再写一个自以为高效的正确解法,用随机生成的小规模数据同时跑这两份代码,对比结果是否一致。不一致就缩小数据范围,定位是哪组数据出错。
比如字符串拼接那道题,暴力解法就是全排列所有可能性,取字典序最小的结果。高效解法是自定义排序。对拍时随机生成3到5个小写字母组成的字符串,跑上千组,如果全部一致,基本可以确信高效解法的正确性。这个过程看着麻烦,实际上能省下大量在评测机上试错的时间。我刷B卷的时候,第二道桶排序题就是靠对拍发现了一个边界错误:当数组长度为2且两个元素差距为1时,桶数量计算会出问题。这个用例极其隐蔽,但用对拍一秒就能暴露。
对拍的Python脚本我给出一个简化的模板,思路是生成随机输入,分别调用两份代码逻辑,比对输出:
import random def brute(nums): # 暴力解法,正确性优先 pass def solution(nums): # 高效解法 pass for _ in range(10000): n = random.randint(1, 8) nums = [random.randint(1, 100) for _ in range(n)] if brute(nums) != solution(nums): print("发现反例:", nums) break笔试时不需要把对拍脚本写到提交代码里,它就是你在本地编辑器里的一个验证工具。熟练之后,写一个对拍脚本只需要几分钟,但能帮你提前拦截掉绝大多数逻辑错误。
4.3 常见问题速查表
我把刷这套题时最容易遇到的问题整理成了一张表,方便你做到一半卡住时快速对照:
| 现象 | 常见原因 | 排查思路 |
|---|---|---|
| 提交后显示编译错误 | Python选错版本,或Java类名不是Main | 确认评测环境版本,检查类名和语法 |
| 输出比答案多一行 | 调试print没删,或多打印了换行 | 全局搜索print,删掉所有调试输出 |
| 样例能过,但通过率0% | 输入格式没按多组数据处理 | 检查读入代码,用sys.stdin.read()重写 |
| 字符串排序结果不对 | 直接按字典序排序,没有用a+b<b+a规则 | 改用cmp_to_key自定义比较 |
| 桶排序答案偏小 | 用了浮点数计算桶宽,边界元素落错桶 | 改用整数除法计算桶宽 |
| 背包答案偏大 | 内层循环方向写反,同一商品被多次选择 | 01背包必须从后往前遍历j |
这张表既是刷题避坑指南,也可以当成正式笔试前的检查清单。我后来每次参加线上笔试之前,都会把这张表过一遍,尤其是“调试print没删”和“类名不是Main”这两条,几乎每次都能拦住一个低级失误。
4.4 关于时间和心态的一些经验
刷模考题和刷LeetCode有一个很大的区别:模考题是限时的,但LeetCode没有时间压力,你可以慢慢想一天。很多人在牛客上做题,不是算法不会,而是时间压力下读题读歪了、边界没考虑全、低级错误反复犯。B这套卷的限时练习,恰恰能锻炼这种“在时间压力下保持代码稳定性”的能力。
我自己做题的时候,给自己定了一个很简单的规矩:每道题先花两分钟把输入输出格式和数据范围看清楚,再花两分钟想清楚算法,剩下的时间全部用来实现和检查。数据范围特别重要,如果n是10^5级别,基本排除了O(n^2)算法;如果n是10^3级别,暴力就可能过。很多题不用把最优算法想出来,先判断数据范围能不能让暴力通过,能过就直接写暴力,省下来的时间用来检查其他题。
有一道题我当时用暴力方法过了,而同一个考场很多人在纠结最优解法,最后反而超时没写完。这说明笔试现场的“最优解”不一定是最好的选择,在限定时间内“能拿到分的解法”才是好解法。模考的价值,就是用低成本的试错帮你建立起这种考场直觉。做B卷的时候多体验几次“暴力过题”和“卡在最优解导致没写出来”的对比,正式笔试时你会感谢这段经历。