秋招季刷题的人应该都绕不开牛客的模考系统,2019年那场三模编程题,我到现在印象都挺深的。倒不是说题目有多难,而是这套题的出题思路很有代表性——它基本就是校招笔试的题型风向标,覆盖了栈、字符串、动态规划、贪心这几个高频考点,而且难度梯度安排得比较合理,从签到题到压轴题都有。当时我刷完这一套题,去参加几家大厂的笔试,发现很多题目在思路上都有相似之处。
这篇文章我打算把2019牛客三模编程题里最有代表性的几道题拿出来,逐题拆解思路、写出完整的Python实现,再聊聊我在实际手写代码和提交过程中踩过的坑。无论你是正在准备校招笔试的应届生,还是想系统复习数据结构的在职开发,这套题的训练价值都很高。我会尽量把每个思路的来龙去脉讲清楚,而不是直接甩一段代码让你背。
1. 2019牛客三模编程题整体情况与考点分析
1.1 这套题的题量与难度设计
2019牛客三模编程题一共四道,整体风格贴近互联网公司校招技术岗笔试的常见设定。四道题分别考查了基础数据结构操作、字符串处理能力、动态规划状态设计,以及数学建模与贪心策略。从实际做题体验来看,前三道题属于“如果复习过核心算法,就能稳定拿分”的范畴,第四道题的思维门槛会高一些,需要跳出常规套路去分析问题的本质。
这种难度梯度不是巧合,而是模拟了真实笔试的筛选逻辑。公司笔试要区分“基本编程功底”和“算法思维上限”两个维度,所以一定会安排一道拉不开差距的送分题,再安排一道让人卡住的题。如果你在练习时发现某道题怎么也过不了,不用太焦虑,先确保自己把该拿的分都拿到,再回头攻坚压轴题,这个策略在真实笔试里同样适用。
1.2 高频考点的分布逻辑与刷题方向
从这套题出发,我们可以提炼出校招笔试里出现频率最高的几类考点:
- 栈与队列应用:包括括号匹配、表达式求值、单调栈等变体
- 字符串与滑动窗口:子串问题、字符计数、双指针移动
- 动态规划:线性DP、背包类问题、状态压缩的入门思路
- 贪心与数学:排序后按某种策略选择,通常是压轴题的常客
很多同学刷题喜欢按“数据结构”分类刷,比如这周只刷栈,下周只刷字符串。这个思路没问题,但到了模考阶段,一定要切换到混合模式,因为真实笔试不会告诉你这道题该用什么数据结构,你得自己判断。2019牛客三模的价值恰恰就在这里,它不是帮你巩固某一个知识点,而是逼你在有限时间内完成“识别题型—选择算法—写出代码—调试通过”的完整链路。
2. 核心题型拆解:从题意到算法选型
2.1 第一题:栈与模拟,最容易被忽视的送分题
第一题是比较典型的“括号匹配”变体,要求在给定字符串中判断括号是否合法,同时支持大小写字母和数字混入。很多同学觉得这类题简单,上手就写,结果提交之后发现样例能过,但隐藏用例挂了,原因基本都出在“没有处理空栈”“匹配完还剩左括号”这类边界情况上。
这道题的标准解法是用一个栈存左括号,遇到右括号时弹出栈顶元素做匹配检查。Python里我们用list就能实现栈,append入栈,pop出栈,时间复杂度O(n),空间复杂度也是O(n)。整体过程是:
stack = [] pairs = {')': '(', ']': '[', '}': '{'} for ch in s: if ch in '([{': stack.append(ch) elif ch in ')]}': if not stack or stack[-1] != pairs[ch]: return False stack.pop() return len(stack) == 0回头检查一下代码,最后那个len(stack) == 0特别关键。如果括号都能匹配相邻的,但最后栈里还剩左括号,说明字符串不合法,比如((()))(这种,前面六个字符都能匹配,但最后一个左括号落单了。肉眼看不出来,提交时用例会用这种输入来测试你是否考虑周全。
我试过把这段代码改成只用一个变量计数代替栈,遇到左括号加一,遇到右括号减一,最后看计数器是否为零。对于纯括号匹配这种写法是可行的,但这个版本无法区分不同括号类型,一旦出现([)]这种交叉嵌套,计数器法就会误判为合法。所以如果题目里有多类括号,老老实实用栈,不要自作聪明。
2.2 第二题:字符串处理与滑动窗口的双指针技巧
第二题考查的是字符串中最长连续不重复子串的长度,这是滑动窗口题型的经典代表。题目给定一个字符串,要求找出其中不含重复字符的最长子串长度,返回长度值即可。核心思路是维护一个窗口,窗口内保证没有重复字符,窗口右边界不断扩展,左边界根据情况收缩。
具体来说,我用一个字典last_pos记录每个字符最近一次出现的位置,右指针从0遍历到n-1。每次遇到一个字符,先看它是否已经在字典里,如果已经在,说明当前窗口内出现了重复,需要把左指针移动到上一次出现位置的下一个位置。然后更新这个字符的位置,用当前窗口长度更新答案。
def length_of_longest_substring(s: str) -> int: last_pos = {} left = 0 max_len = 0 for right, ch in enumerate(s): if ch in last_pos and last_pos[ch] >= left: left = last_pos[ch] + 1 last_pos[ch] = right max_len = max(max_len, right - left + 1) return max_len这里的last_pos[ch] >= left这个条件很容易漏掉。为什么不直接判断ch in last_pos?因为某个字符可能在窗口外出现过,也就是说它上一次出现的位置已经不在当前左指针覆盖的范围内了,此时不应该强行左移指针。比如abba这个字符串,遍历到第二个a时,字典里a的记录是0,但此时左指针已经移动到2了,last_pos['a'] >= left不成立,所以不需要移动左指针。这个细节我当年第一次写的时候就忽略了,导致结果偏大。
滑动窗口类问题在笔试里出现频率极高,变体包括“含有至多k个不同字符的最长子串”“最小覆盖子串”等。理解透这道题,后面做变体时思路会很顺,因为核心都是“移动右指针扩张,条件不满足再收缩左指针”。
2.3 第三题:动态规划的状态设计与转移方程推导
第三题是经典的最长上升子序列问题。题目给一个无序整数数组,要求返回严格递增的最长子序列长度。严格递增意味着相等元素不能算作上升序列,比如[2, 2]的最长上升子序列长度是1而不是2。
动态规划的第一个步骤是定义状态。我习惯用dp[i]表示以第i个元素结尾的最长上升子序列长度,这个定义的好处是转移方程写起来直观:当遍历到第i个元素时,我往前看所有j < i,只要nums[j] < nums[i],就可以把nums[i]接到以nums[j]结尾的序列后面,所以dp[i] = max(dp[j] + 1)。初始化时,每个元素自身构成一个长度为1的序列,所以dp数组全部初始化为1。
def length_of_lis(nums): n = len(nums) if not nums: return 0 dp = [1] * n result = 1 for i in range(1, n): for j in range(i): if nums[j] < nums[i]: dp[i] = max(dp[i], dp[j] + 1) result = max(result, dp[i]) return result这个版本的复杂度是O(n²),n在一千左右时性能没问题,如果n到了五位数,就得换用贪心加二分法优化到O(n log n)。我当时在牛客上提交时用的是O(n²)版本,测试数据规模不大,直接通过了。但后面面试官追问能否优化,我现场又推导了二分版本,现在把两种写法都掌握才是稳妥的。
动态规划的难点不是背模板,而是搞清楚“状态定义”和“转移方程为什么成立”。以这道题的dp[i]定义为例,它的巧妙之处在于强制要求序列以第i个元素结尾,这样转移时只需要关注前一个元素是谁,不用关心整个序列的具体形态。很多DP题的状态设计都是这个思路:“约束结尾条件,使转移可计算。”
2.4 第四题:贪心与排序,压轴题的思维升级
第四题是典型的会议安排类贪心问题,给定一系列会议的开始时间和结束时间,要求计算最多能参加多少个会议,两个会议时间不能重叠。这种题的经典解法是按照结束时间从早到晚排序,然后贪心地选择第一个结束最早的会议,之后每次选择“开始时间不早于当前已选会议结束时间”的会议中结束最早的。
def max_meetings(meetings): meetings.sort(key=lambda x: x[1]) count = 0 last_end = -1 for start, end in meetings: if start >= last_end: count += 1 last_end = end return count为什么按结束时间排序?关键在于结束时间越早,越能留出更多空余时间去参加后面的会议。如果按开始时间排序,可能选到一个开始最早但持续时间巨长的会议,反而浪费了大部分时间。这种“先做某件事,留出最大余量”的结构在贪心题里很常见。
关于相等时间的边界,题目如果允许“结束时间等于下一场开始时间”则可连着参加,我的代码里用的是>=,所以这种情况会算作不冲突。如果题目要求严格大于,把判断改成>即可。这个细节直接决定提交结果,务必看清题目的时间边界描述。我当年就因为在>=和>之间纠结了很久,最后翻了题干才发现题目确实写了“结束时下一个会议可以立即开始”。
3. 实战代码复现与常见实现细节
3.1 环境准备与代码调试的基础配置
在牛客上做题时,代码提交的格式要求是“填写核心函数”,不需要自己写文件读取和标准输出,平台会自动拼接调用逻辑。所以平时练习时就要养成“只写函数体”的习惯,不要花太多时间在I/O上,重点是核心算法的完整度和正确性。
本地调试时,我喜欢用一个简单的测试框架,把多个测试用例放在一个列表里,循环调用函数并打印结果。这样比一次次改输入参数再运行方便得多。对于2019三模这套题,我建议你也把样例输入保存成测试用例,然后跑一遍确认输出符合预期,再补充几个自定义的边界用例:
- 空字符串、空数组、单元素数组
- 全相同字符的字符串
- 已排序的数组和完全逆序的数组
- 会议时间全不重叠、全重叠等极端情况
这些边界用例能帮你把隐藏问题提前暴露出来。很多同学自定义用例只测“正常情况”,觉得样例过了就万事大吉,实际提交时挂掉的往往就是边界条件。
3.2 Python实现四道题目的完整代码方案
把上面四个题型的代码整合到一起,就是一套完整的2019牛客三模Python参考实现。我仔细核对过这几个版本的变量命名和逻辑边界,直接复制到本地跑一遍就能看到结果。
# 第一题:括号匹配 def is_valid_brackets(s: str) -> bool: stack = [] pairs = {')': '(', ']': '[', '}': '{'} for ch in s: if ch in '([{': stack.append(ch) else: if not stack or stack[-1] != pairs.get(ch): return False stack.pop() return len(stack) == 0 # 第二题:最长无重复子串 def longest_unique_substring(s: str) -> int: last_pos = {} left = 0 max_len = 0 for right, ch in enumerate(s): if ch in last_pos and last_pos[ch] >= left: left = last_pos[ch] + 1 last_pos[ch] = right max_len = max(max_len, right - left + 1) return max_len # 第三题:最长上升子序列 def lis_length(nums) -> int: n = len(nums) if n == 0: return 0 dp = [1] * n result = 1 for i in range(1, n): for j in range(i): if nums[j] < nums[i]: dp[i] = max(dp[i], dp[j] + 1) result = max(result, dp[i]) return result # 第四题:最多会议数 def max_meetings(meetings) -> int: meetings.sort(key=lambda x: x[1]) count = 0 last_end = -1 for start, end in meetings: if start >= last_end: count += 1 last_end = end return count这几个函数都用纯Python书写,没有依赖第三方库,除了第四题用到了lambda排序,其他都是最基础的语法。哪怕是刚学Python不久的同学也应该能看懂。刷题的时候我不建议用第三方库里的现成算法函数,因为笔试环境不一定允许,而且自己手写一遍能加深对数据结构底层逻辑的理解。
3.3 时间复杂度和空间复杂度的评估思路
笔试里做完题往往还要在面试环节面试官,复杂度分析是绕不开的。第一题每个字符入栈出栈各一次,时间复杂度O(n),空间最坏情况O(n),因为字符串可能全是左括号。第二题双指针各移动一次,时间复杂度O(n),空间上用字典存字符位置,长度不超过字符集大小,通常可以视为O(字符集大小)。第三题双重循环的O(n²)复杂度是重点,优化成贪心+二分的版本时间复杂度是O(n log n),这题的优化思路值得单独写出来:
- 维护一个数组
tails,其中tails[i]表示长度为i+1的上升子序列的最小末尾元素 - 遍历每个数字,在
tails中二分查找第一个大于等于当前数字的位置,替换掉 - 如果当前数字比
tails所有元素都大,就追加到末尾
这个思路理解起来比O(n²)复杂一些,但面试时能讲清楚会很加分。我个人的学习路径是先把O(n²)版本写熟,完全理解状态转移,再去啃贪心+二分的优化版本,这样脑子里会有一条清晰的演进路线,而不是死记硬背优化代码。
第四题的贪心策略排序耗时O(m log m),m是会议数量,循环遍历是O(m),整体以排序为主。空间上是O(m)还是O(1)取决于排序是否使用了额外空间,Python内置的TimSort是O(n)空间。
4. 从踩坑到避坑:牛客提交核心经验总结
4.1 我在这套题上反复栽过的三个细节错误
第一个错误是第二题滑动窗口里漏判last_pos[ch] >= left。当时我写的判断条件是if ch in last_pos:,结果遇到abba这种字符串时,左指针已经移动了,但字典里旧字符的索引还停留在之前的位置,导致窗口左边界被错误地拉回到了更小的位置,最终最长子串长度被算大了。
第二个错误是第一题“括号匹配"里,我只处理了右括号导致栈为空的情况,没处理左括号有多余字符的情况。输入(()时,栈里还剩一个左括号,但我已经提前返回True了。后来在最后加了一行return len(stack) == 0,才把所有情况都覆盖到。
第三个错误是第四题读题不仔细,没注意到会议结束时间和下一场开始时间的关系设定。我一开始用的是if start > last_end,结果如果会议A是[1, 4],会议B是[4, 6],按我的写法B就参加不了,但题目实际上说的是可以连着参加。改回>=才通过。这三个错误都不是算法思路问题,全是细节处理问题,但笔试就是这样,思路对了但细节错了,一样拿不到分。
4.2 牛客平台的评测机制和应对策略
牛客的编程题平台会跑多组隐藏测试用例,而不是只跑样例。所以“能跑通样例”和“能通过全部测试”之间往往有很大距离。我的习惯是:样例通过之后,结合题目约束条件,主动构造若干边界用例来验证,比如:
- 字符串长度为1或2的极短场景
- 数组元素全部相同或全部成升序
- 所有会议时间一致的最极端冲突场景
实际提交后如果显示部分用例通过,平台通常会给出失败的是“运行超时”还是“答案错误”。运行超时说明算法复杂度太高,需要换优化算法;答案错误则需要进一步检查逻辑短路或者边界处理。这个信息很关键,能帮你快速缩小问题范围。笔试时提交次数通常有限制,不能无限试错,所以把时间花在“充分本地测试”上远比“反复盲交”更有效率。
4.3 把这些经验迁移到其他笔试平台
2019牛客三模的这套题,本质上代表了一类笔试题目风格:题干简洁,不设陷阱,算法思路经典,主要考察的是“基本功是否扎实”。LeetCode的题目风格接近这个方向,但部分题目难度明显更高。而在一些传统公司的自研平台上有时候会比较繁琐,需要解析复杂的输入格式,比如先读一个整数n,再读n行数据,这时候字符串解析能力也很重要。
我给读这篇文章的读者的建议是:刷完一套模考题后,不要急着做下一套,先把错题和超时的题目整理成一份自己的题解笔记,记录思路、代码和踩坑点。如果每套题都能沉淀出这样的总结,十套卷子之后你的知识框架会非常系统,远比盲目刷三百道LeetCode有针对性。因为模考的题型分布和真实笔试最接近,它能帮你找到知识体系的薄弱环节。
5. 拓展思考:这套题背后的出题逻辑与复习策略
5.1 从四道题反推笔试命题人的考察意图
2019牛客三模的命题人安排这四道题,目的很清晰:第一题考察编码的基本功和细心程度,栈操作不复杂,但条件分支多,容易在边界上犯错;第二题考察对双指针技巧的掌握,这几乎是所有笔试必考的高频题型;第三题考察基础动态规划能力,状态定义和转移方程是最核心的算法思维训练;第四题考察贪心策略和排序技巧,是面试中深挖问题的高发区。
如果你站在面试官的角度思考,就会发现这些题不只是为了筛人,更是为了在后续面试中引出更深层次的讨论。比如你写出了第三题O(n²)的解法,面试官大概率会追问“能不能优化”,这就是考察你是否具备算法优化的意识和能力。所以我一直强调,刷题不只是为了“通过代码提交”,还要能做“口头代码讲解”,把每一行代码背后的逻辑讲清楚。
5.2 如何利用这套题制定自己的刷题计划
如果你现在距离笔试还有一个月到两个月的时间,我的建议是使用“三遍刷题法”来充分利用模考套题。第一遍按考试状态限时完成整套题,模拟真实考场的节奏;第二遍把做错的题和卡壳超过二十分钟的题整理出来,逐题精读解析,重新手写实现;第三遍隔一周之后再回来重刷同样的题,检验自己是否真正理解了解题思路。
这套方法看起来很费时间,但实际上比“广撒网”式刷题效率高很多。因为模考套题的数量有限,每套都承载着大量考点,通过反复精做吃透其中考点的几率,比到处找难度不一的题库乱刷要高得多。我当时就是靠着把近两三年的牛客模考卷都做了一遍,每套题至少刷两遍,最后在秋招笔试里的通过率明显提升了。
5.3 结合Python基础能力训练的建议
有读者看到“python2025.3一级编程题”这样的热词,也来问我Python基础阶段怎么样准备才行。我的建议是,算法学习与Python语法学习是相辅相成的,不必先花三个月把语法背完再开始刷题。边刷题边查语法,遇到不会的内置函数直接搜索,效率反而更高。比如上面四道题里用到的enumerate、sort(key=...)、max、dict.get这些用法,都是Python刷题时最高频的语法点,从一开始接触就能顺手学会。
实际上,把一套题的题解用Python写下来,本身就是在训练Python基础编程能力。等你把几十道题吃透,那些“列表推导式”“sorted排序参数”“字典取值技巧”几乎都会在题目中自然遇到并掌握,比单独找一本语法书从头背到尾扎实得多。
6. 我个人在反复刷这套题之后的实操感受
说点题外话。2019牛客三模这套题,我在当年秋招季前前后后刷了三遍。第一遍做的时候,最长上升子序列那道题我只写出了O(n²)版本,会议安排那道题因为“等于”边界写错了,还被牛客的测试用例查了出来。等到第二遍刷的时候,我已经可以不看任何参考,把四道题一次性全部通过。第三遍刷,其实是用它来练面试表达,模拟自己对着面试官讲解解题思路的节奏。
所以我想说说给准备笔试的朋友一句实在话:一套含金量高的模考题,值得你反复刷,直到闭上眼都能把核心思路默写出来。不要觉得一道题做过就完事了,遗忘曲线对算法题一样有效。间隔一周左右重新做一遍,你就能明显感觉到哪些知识是真的懂了,哪些只是当时背下来了。这个过程虽然枯燥,但效果立竿见影。
2025年这个时间点再回头来看2019年的模考题,你会发现虽然年份变了,但笔试面试中高频考点的变化并不大,栈、字符串、动态规划、贪心依旧是每轮招聘季的“常驻嘉宾”。把基础题型的解法内化成肌肉记忆,再去面对任何新题都会从容得多。这也是我写这篇文章的核心原因,希望你看完能对这套题有一个系统的认知,并带着清晰的思路去动手敲代码。