每年三四月份,牛客网的模考一出来,群里就炸锅。2017年那次一模我印象特别深,那会儿我还在学校,刚刷完剑指offer觉得自己行了,结果被一模编程题教做人。现在回头看,那套题其实一点也不偏,反而特别能反映笔试编程题的核心套路——字符串处理、贪心、动态规划、模拟,全是这些东西。这几年我帮学弟学妹改简历、做模拟面试,发现大家刷题有个通病:光顾着刷量,不研究题目背后的设计逻辑。所以今天想借2017牛客模考(一模)编程题集合这个经典样本,把笔试编程题的解题思路完整拆一遍,包括题目考点、常见陷阱、代码实现,以及我当时踩过的坑和总结出的应考节奏。
这套题适合谁?两类人。第一类是准备秋招春招的应届生,尤其是投后端、算法、测试开发这些要考编程题的岗位;第二类是刚刷完基础题、想检验自己水平的大二大三学生。如果你已经工作但想跳槽,也可以拿它当热身。文章不会只给答案,我会把每道题的思考过程、为什么这么解、换一道类似的题怎么识别考点都讲清楚。
1. 2017一模整体的题目设计思路
1.1 题型分布与难度曲线
先看整体盘子。牛客模考编程题通常四道左右,2017年一模的难度分布大致是:一道简单字符串、一道中等模拟、一道贪心、一道动态规划。这个配置几乎成了之后几年校招笔试的模板,到现在很多公司的笔试题还是这个比例,只不过难度整体抬高了。
为什么出题人这么安排?很简单,考查覆盖面。字符串是基础编码能力的试金石,模拟题考代码组织能力和状态梳理能力,贪心考思维敏锐度,动态规划考算法功底。四道题分别对应不同维度,能把候选人的水平拉开层次。如果你只擅长某一种题,大概率会被卡住。
再具体一点,难度曲线是前松后紧。第一道题基本是送分题,保证大部分人能上手写;第二道题稍微绕一点,需要理清逻辑;第三道和第四道是分水岭,决定了你能不能进下一轮面试。很多人栽在第三道贪心题上,不是不会,而是没看出来这是个贪心——这是最可惜的失分方式。
我建议拿到题先花两分钟扫一遍所有题目,不要从第一题按顺序做。先把送分题做了,再跳到你最有把握的中等题,最后啃难题。这个策略我在后面章节会详细讲。
1.2 为什么这套题现在刷依然不过时
有人可能觉得2017年的题太老了,现在面试都不考这些。这话对了一半。新技术框架确实日新月异,但笔试编程题考的是算法和数据结构基础,这东西十年二十年不会变。2017年考最长上升子序列,2025年还在考;2017年考区间调度贪心,现在依然是大厂高频题。
而且2017年这套题有个好处:它处在"牛客模考"这个体系的早期,题目风格还没被各种培训机构研究透,所以题目更纯粹,没有太多偏题怪题。现在的笔试题反而经常出现一些为了难而难的题目,动不动就上后缀自动机、树链剖分,对校招生来说反而失去了选拔意义。所以拿这套题打基础,性价比很高。
我每年带新人刷题,都会让他们先把这套题做一遍,目的不是让他们背答案,而是让他们感受一下"一个正常难度的笔试是什么样"。做完这套题,再去刷那些偏难怪题,心里就有底了。
1.3 一道题的完整估值模型
刷题不能傻刷,你得知道每道题大概花多长时间是合理的。我自己的标准是:第一道简单题控制在5分钟以内;中等题10到15分钟;难题如果20分钟还没有思路,果断放弃或者写个暴力解法先拿部分分。
这个时间估值基于一个简单的公式:笔试总时长除以题目数量,再考虑难度加权。比如一共90分钟四道题,平均每道22.5分钟,但简单题不应该用满这个时间,省下来的时间得补给难题。很多人栽就栽在简单题上死磕最优解,结果难题连暴力分都没拿到。
2017年一模我当时就犯了这错误。第一道字符串题明明用最基本的遍历就能AC,我非要优化成O(n)空间复杂度的花活,结果折腾了二十分钟,后面贪心题只能草草写个错误答案交上去。笔试不是给你炫技的,是让你拿分的。
2. 高频考点拆解:字符串与模拟题
2.1 回文串判定的三种写法
字符串题是笔试的常客,2017一模第一道题就是回文串相关。题目大致是:给定一个字符串,判断它是否是回文串,忽略空格和标点,且不区分大小写。听起来很简单对吧?但越简单的题越容易暴露出编码习惯问题。
最稳妥的写法是双指针——一个指向开头,一个指向结尾,跳过非字母数字字符,然后比较。这个方法时间复杂度O(n),空间复杂度O(1),既不依赖额外的数据结构,也不容易出错。
def is_palindrome(s: str) -> bool: left, right = 0, len(s) - 1 while left < right: while left < right and not s[left].isalnum(): left += 1 while left < right and not s[right].isalnum(): right -= 1 if s[left].lower() != s[right].lower(): return False left += 1 right -= 1 return True第二种写法是逆序比较,先把字符串清洗干净,再反转,然后逐位比较。这个思维最直白,但多了一次字符串拷贝,空间复杂度O(n)。第三种写法是递归,不推荐在笔试里用,容易栈溢出而且代码还长。
这里有个小坑很多人会踩:if s[left].lower() != s[right].lower(),注意是用lower()统一大小写,而不是直接比较。还有内层跳过非字母数字的while循环要加left < right条件,不然可能越界。这些细节在IDE里有提示,但在牛客这种不帮你检查越界的在线编辑器里,可能直接报错。
2.2 字符串压缩与解压的边界处理
另一道高频字符串题是压缩:给定一个字符串,把连续重复的字符压缩成"字符+次数"的形式,比如aaabbc压缩成a3b2c1。这题考的是对连续区间的处理,跟"统计词频"是同一类思路。
def compress(s: str) -> str: if not s: return "" res = [] count = 1 for i in range(1, len(s)): if s[i] == s[i - 1]: count += 1 else: res.append(s[i - 1] + str(count)) count = 1 res.append(s[-1] + str(count)) return "".join(res)这题有两个边界必须处理好。第一个是空串输入,直接返回空串,不然s[-1]会越界;第二个是最后一个字符的统计——很多人循环里只处理了"前后字符不同"的情况,忘了把最后一组追加进去。这两个问题我每次改卷子都能看到,说明不是个例,是普遍习惯问题。
还有一个优化的点:如果压缩后的字符串不比原串短,应返回原串。这个要求源自LeetCode 443的变体,牛客的题也常这样出。加上这个判断后,代码要多一层逻辑:
def compress(s: str) -> str: if not s: return "" res = [] count = 1 for i in range(1, len(s)): if s[i] == s[i - 1]: count += 1 else: res.append(s[i - 1] + str(count)) count = 1 res.append(s[-1] + str(count)) compressed = "".join(res) return compressed if len(compressed) < len(s) else s这种题本身不难,但要拿满分,边界条件一个都不能漏。我强调这些是因为笔试判分的时候,很多case就是针对边界条件设计的——你功能逻辑全对,但空串没处理,照样WA(Wrong Answer)。
2.3 模拟题的通用思路:状态机思维
模拟题是2017一模的第二道,也是很多人的噩梦。那道题大概是模拟一个简化版计算器,输入一个只包含数字、+、-、*、/的表达式,输出计算结果。这种题不考算法,考的是对过程的拆解能力。
我做模拟题有个固定套路:先画状态机。状态就是"当前正在读什么"——可能是数字、可能是运算符、可能是操作符之后的下一个数字。每一次读入一个字符,根据当前状态决定下一步动作。把这个状态流转画清楚,代码就是状态机的直译。
比如计算器表达式求值,核心是处理优先级。经典做法是用两个栈:一个操作数栈,一个运算符栈。遇到运算符时,如果栈顶运算符优先级不低于当前运算符,就先弹出运算再把当前运算符压栈。这个"弹栈计算"的过程,就是状态机里"读到运算符时进入结算状态"的落地。
def calculate(s: str) -> int: stack = [] num = 0 sign = '+' for i, ch in enumerate(s): if ch.isdigit(): num = num * 10 + int(ch) if ch in '+-*/' or i == len(s) - 1: if sign == '+': stack.append(num) elif sign == '-': stack.append(-num) elif sign == '*': stack.append(stack.pop() * num) elif sign == '/': stack.append(int(stack.pop() / num)) sign = ch num = 0 return sum(stack)这里有个Python特有的坑:int(stack.pop() / num)和stack.pop() // num结果不一样。比如-3 // 2在Python里等于-2,因为Python的整除是向下取整,而题目通常想要的是向零取整。所以必须写成int(-3 / 2),得-1。这种语言层面的细节,笔试中不会有编译器提醒你,只能靠平时积累。
模拟题拿高分的核心就一句话:先把规则梳理成清晰的流程,再写代码。我见过太多人上手就写,写到一半发现少处理一种情况,又回头改结构,最后代码跟意大利面一样——能跑,但没人敢保证它是对的。花三分钟整理流程,能省下三十分钟改bug的时间。
3. 贪心与动态规划:那两道分水岭题目
3.1 区间调度贪心的证明思路
2017一模的第三道题是会议室安排问题变种:给定一组区间,找出最多能选择多少个互不重叠的区间。这题是贪心算法的经典入门题,也是面试官最爱问的题之一——因为它表面上是"安排",实际上是考你是否理解贪心策略背后的选择逻辑。
最经典的解法是:按区间结束时间排序,然后依次选择,只要当前区间开始时间不早于上一个选中区间的结束时间,就选中它。排序复杂度O(n log n),选择过程O(n)。
def max_non_overlapping(intervals): if not intervals: return 0 intervals.sort(key=lambda x: x[1]) count = 1 end = intervals[0][1] for i in range(1, len(intervals)): if intervals[i][0] >= end: count += 1 end = intervals[i][1] return count很多人会问:为什么按结束时间排序,而不是按开始时间或者区间长度?这个问题的答案才是面试官真正想听的。按结束时间排序保证了每次选择都"给后面留下尽可能大的剩余空间",这是贪心选择性质的直观理解。形式化证明是交换论证法:假设最优解的第一个区间不是结束时间最早的,可以把最优解的第一个区间替换成结束时间最早的区间,其余部分不受影响,因此存在一个包含结束时间最早区间的最优解。
这个证明思路我建议背下来,因为很多贪心题的证明套路都长一个样。笔试虽然不要求写证明,但理解证明能帮你在面对变形题的时候判断"这题是不是贪心"。
3.2 最长上升子序列的DP推导
最后一道题是经典的动态规划——最长上升子序列(LIS)。题目:给一个无序数组,求最长严格递增子序列的长度。子序列不要求连续,但要保持原数组顺序。
看到"最长"加"子序列"这两个词,第一反应就应该是DP。状态定义:dp[i]表示以nums[i]结尾的最长上升子序列长度。转移方程:dp[i] = max(dp[j] + 1),其中j < i且nums[j] < nums[i]。初始状态dp全为1(每个元素自身构成一个长度为1的子序列)。
def length_of_lis(nums): if not nums: return 0 dp = [1] * len(nums) for i in range(len(nums)): for j in range(i): if nums[j] < nums[i]: dp[i] = max(dp[i], dp[j] + 1) return max(dp)O(n^2)的解法是最容易实现也最不容易出错的。但面试或笔试如果限时较紧,可能会希望你会O(n log n)的优化版——用贪心加二分,维护一个tails数组,其中tails[k]表示长度为k+1的上升子序列末尾元素的最小值。具体不展开,网上很多资料。我想强调的是:先写对,再写快。如果O(n^2)的思路清晰,就按O(n^2)写,不要为了炫技写二分然后因为边界条件出错。笔试是看AC的题数,不看复杂度多漂亮。
3.3 怎么快速判断一道题该用贪心还是DP
这是个困扰许多人的问题。我的判断标准是:贪心是每一步做局部最优选择,DP是考虑所有可能状态并取最优。如果题目有"每一步可以做出一个选择,选择后整个问题变成一个更小的同类型子问题"的结构,很可能贪心;如果题目有"多种方式组合成答案,需要把所有情况都考虑到"的结构,那是DP。
举个例子:区间调度按结束时间排序是贪心,因为每选一个区间后,问题就缩小成"从当前结束时间之后找最大数量区间",而且选择结束时间最早的区间永远不比选择其他区间差。而LIS不是贪心——你不能说"选最小的那个数一定最优",因为子序列的构成要考虑顺序,所以必须枚举所有可能性,用DP。
还有一个典型的区分题:找零钱问题。如果用无限量硬币凑出某个金额,求最少硬币数,先用最大面值硬币不一定最优(比如面值1、3、4凑6,先拿4再拿1+1是3枚,但3+3只要2枚),所以这题不能用贪心,得用DP。但如果是面值1、5、10、25美分,贪心反而最优。原因是这些面值满足贪心选择性质。所以判断标准不是"看起来能不能贪",而是"局部最优是否真的能推出全局最优"。
4. 真题实战:两道完整的解题过程
4.1 题目:最小覆盖子串的滑窗实现
虽然不是2017一模的原题,但滑动窗口这类题在牛客模拟中反复出现,一模也有一道变种。我拿一道典型题来讲完整流程:给定一个字符串S和一个模式串T,在S中找到包含T所有字符的最短子串。这题是滑窗的经典场景。
思路分五步走。第一步,用字典need记录T中每个字符的需求量;第二步,用两个指针left和right表示窗口边界;第三步,移动right扩展窗口,同时更新窗口内字符计数;第四步,当窗口内已包含T全部字符时,尝试移动left收缩窗口,记录最短长度;第五步,重复直到right遍历完整个S。
def min_window(S: str, T: str) -> str: from collections import Counter if not S or not T: return "" need = Counter(T) remain = len(T) left = 0 min_len = float('inf') min_left = 0 for right, ch in enumerate(S): if need[ch] > 0: remain -= 1 need[ch] -= 1 while remain == 0: if right - left + 1 < min_len: min_len = right - left + 1 min_left = left left_ch = S[left] if need[left_ch] == 0: remain += 1 need[left_ch] += 1 left += 1 return "" if min_len == float('inf') else S[min_left:min_left + min_len]这个代码里最关键的是remain这个变量。它表示"还没满足的T中字符个数",只有当窗口内某个字符是T需要的且当前数量不足时,remain才会减一。这个设计比每次都比较两个Counter要高效得多,也避免了重复计算。
我实战中常犯的错误是在收缩窗口时忘了恢复need计数。每次移动left,必须把对应字符的需求量加回来,否则窗口内字符计数会越来越小,导致误判。这个bug特别隐蔽,因为小数据量可能测不出来,但大数据量就会出现"明明包含T的字符,却显示不包含"的诡异现象。
4.2 题目:最大连续子数组和的两种解法
另一道常考的题是最大连续子数组和,也就是LeetCode 53。题目很简短:给定一个整数数组,找出一个具有最大和的连续子数组,返回其最大和。看似简单,但它既可以用DP解,也可以用分治法解,是考察基本功的好题。
Kadane算法是这题的最优解:cur = max(num, cur + num),result = max(result, cur)。一维DP的压缩版,空间O(1),时间O(n)。
def max_subarray(nums): cur = nums[0] result = nums[0] for num in nums[1:]: cur = max(num, cur + num) result = max(result, cur) return result理解这个算法的关键是cur的语义:以当前位置为结尾的最大连续子数组和。为什么是max(num, cur + num)?因为要么从当前元素重新开始,要么接着前面的连续段。很多人在这一步纠结"如果前面的连续段本身就小于0怎么办"——max(num, cur + num)已经处理了这个情况:如果cur是负数,加上num反而不如直接取num大,所以自动选择重新开始。
分治法解法也值得掌握:把数组分成两半,最大子数组要么完全在左半,要么完全在右半,要么跨越中点。前两种情况递归解决,跨越中点的情况需要从中点向两边扩展找最大和,然后三取一。复杂度O(n log n),虽然不如Kadane,但分治思想在很多进阶题里都会用到,建议写一遍加深理解。
4.3 完整调试与自测的实操记录
来说说我当年在牛客上做这类题的真实过程。写完之后我不会直接提交,先自己构造几组测试用例跑一遍。第一组是最简单的正常情况,比如[1, 2, 3];第二组是全负数,比如[-1, -2, -3],这组最容易暴露初始值没设好的问题;第三组是混合正负,比如[-2, 1, -3, 4, -1, 2, 1, -5, 4]。每组用例都要在纸上先算好预期输出,再跑到代码里验证。
全负数这个用例特别关键。很多人Kadane算法初始化cur = 0,结果全负数数组会错误地输出0。正确做法是初始化cur = nums[0],或者cur = float('-inf')再遍历。我在牛客上见过太多人因为这一个小问题,写对了80%的逻辑但提交直接WA。
自测的时候还有一个技巧:故意加一个大规模随机数组来压测。不是检查正确性,而是看时间复杂度是否够快。如果O(n^2)的解法跑到10万数据量会卡住,那就趁早换思路。笔试环境一般有性能监控,超时也算错。
5. 牛客笔试中的失分点与排查技巧
5.1 输入输出格式的坑
牛客笔试和LeetCode最大的区别就是:牛客要自己处理输入输出,LeetCode只需要实现函数。这个差异让很多人吃了大亏。最常见的问题是读入数据时类型不对——比如题目说第一行一个整数n,第二行n个整数,你按字符串读进来忘了转int,自然全错。
牛客输入模板我建议形成肌肉记忆。整数数组:data = list(map(int, sys.stdin.readline().split()))。多行输入,以EOF结束:for line in sys.stdin: ...。字符串:s = sys.stdin.readline().strip()。这些模板不花什么技术含量,但能避免大量低级错误。
还有一个很隐蔽的坑:输出格式。要求每个结果占一行,你正确输出了结果但忘了换行——这种情况通常不会判错,但如果要求用空格分隔而你在末尾多打了一个空格,有些严格的判题系统会报Presentation Error。虽然不算WA,但零分和全分之间,就差了这一个空格,很冤。
5.2 边界条件速查表
我总结了一个笔试前必看的边界条件清单,每个题目类型对应几个必测的边界:
| 题目类型 | 必须测试的边界条件 |
|---|---|
| 数组类 | 空数组、单元素数组、全相同元素、全负数 |
| 字符串类 | 空串、单字符串、全空格串、大小写混合 |
| 二叉树类 | 空树、只有左子树、只有右子树、单节点 |
| 动态规划 | n=0、n=1、n=2、目标值等于边界值 |
| 数学类 | 零、负数、最大整数、溢出场景 |
别觉得这些是废话。我在牛客上看到的最多报错就是"数组越界"和"空指针异常",全是边界条件没处理干净。特别是递归和DP类题目,n=0的时候dp数组初始化为[0] * n,输出时越界;n=1时循环根本不执行,有些变量没被赋值——这些问题在提交前自己先测一遍就能发现。
5.3 时间复杂度的经验判断
笔试经常会碰到"题目说数据范围n<=10^5,你的算法跑了O(n^2)"的情况。一套下来肯定超时。我平时判断能否通过,有一个粗略经验表:数据量10^5,O(n log n)大概是1秒上下,O(n^2)直接是分钟级别;数据量10^4,O(n^2)勉强能过,但O(n^3)就别想了;数据量10^3,O(n^2)很轻松,O(n^3)可能危险。
如果时间不够优化,至少写个暴力解拿部分分。有些题目的判分规则是多个测试点,每个测试点有一定分值,暴力解能过其中一部分小的case。千万别空着,空着连同情分都没有。我曾亲眼见过有人四道题只AC两道,但第三题写了个暴力拿了40%的分数,最后总分比三道题满分的人都高——因为难度越大的题,别人越可能完全做不出来。
5.4 笔试现场的调试策略
在线笔试通常没有调试器,你只能用print大法。但print不是随便打的,要有技巧。我自己的习惯是:先打印最关键的状态变量——循环的起点、终点、中间结果;然后再打印循环内部每个分支的走向。
比如DP题,我会在每次状态转移之后把dp数组打印出来看变化趋势;如果某个值不对,根据dp的变化能立刻定位是转移方程写错还是初始化写错。比盲目打印所有变量高效得多。
还有一个小技巧:不要把print留在最终提交的代码里。很多人调试完忘了删,结果满屏调试输出,直接判错。我习惯在提交前用Ctrl+F搜一下"print",确认没有调试语句才提交。
6. 基于2017一模的备考建议与刷题节奏
6.1 三轮刷题法
如果离笔试还有一段时间,我建议用三轮刷题法来准备。第一轮是分类刷,把所有高频考点各刷10道左右,目标是形成条件反射——看到"最长"想DP,看到区间想贪心,看到字符串匹配想滑窗;第二轮刷整套模拟卷,每周一套,目标是训练时间分配和手感;第三轮是回顾错题,重点是把自己反复错的题目类型重做一遍。
三轮之间不是递进关系,而是有交叉的。分类刷的时候也可以偶尔抽一套完整卷子来检验;做整套卷子的时候遇到不会的题,回归到对应知识点去补课。这样周而复始,效果比闷头刷题好得多。
2017一模就是很适合做第二轮刷题练手的卷子。因为它难度适中,题型分布均匀,不会像一些大厂真题那样一开始就被难题劝退。用它来检测自己哪一类考点还没掌握,再针对性地去补模块,性价比最高。
6.2 如何高效整理错题本
我不推荐手抄错题,太费时间。推荐用电子表格或者笔记软件,每道错题记录六要素:题目链接、考点标签、错误原因、正确思路、代码实现、复盘时间。重点是"错误原因"这一栏,要具体到"边界条件没处理"还是"状态转移方程写错了"还是"压根没想到这个考点"。
整理错题不是记完就完了,要定期回顾。我会在每周末把本周错题重新做一遍,做对两遍以上的才标记为"已掌握"。这个"重复做对两遍"的标准很重要,因为第一遍看答案做对的题,过两周大概率还是会忘。只有完全凭自己写出来、跑通,才算真正掌握。
我在帮别人复盘时发现一个规律:大部分人的错误类型不超过三种。有的永远在边界条件上翻车,有的永远卡在状态转移,有的是一到模拟题就逻辑混乱。找到自己的固定短板,集中突破,比什么都题都平均用力有效得多。
6.3 笔试时间分配的具体建议
最后说说考场上到底怎么分配时间。假设一共四道题,我习惯这样安排:前5分钟快速浏览全部题目,标注每道题的难度和擅长程度;然后先做最擅长的那道,把确定能拿的分先拿到;接着做最简单的送分题,再处理中等题;最后剩下的时间全部投入难题,如果难题15分钟还没有完整思路,直接写暴力过小数据case。
这样的安排能保证一个下限:至少做对两道题,可能三道。如果按顺序死磕,很可能第一道简单题做完,第二道中等题卡了半小时,后面两道题连看都没来得及看。我2017年一模就是这样,第一道题做了太久,第三道贪心题基本没时间思考,草草写了个错误解法——那次模考我考完就知道问题出在哪,之后调整了做题顺序和节奏,秋招笔试顺利多了。
还有个小建议:平时练习时尽量使用和笔试相同的编程环境。牛客网有模拟笔试功能,完全复刻真实考试界面和判题方式。多用这个功能做全真模拟,到真实笔试时环境适应成本就很小。环境不熟悉导致的紧张,在编程笔试里是很亏的。
7. 从一道模考题看校招笔试的出题趋势
7.1 考察重点从"会不会"转向"熟不熟"
对比2017年和现在的笔试题目,我发现一个明显变化:现在的题目越来越"卷",但核心考点的考察方式反而更偏向熟练度和准确率。出题人已经不太用偏题怪题来筛人了,而是在经典题型上增加信息量,让题目看起来复杂,但确定能解出来。
这是什么意思呢?比如LIS,2017年可能就直接给数组求最长上升子序列;现在可能给一堆点的坐标,让你先排序再求LIS,或者给每个数字加上额外属性,需要自定义排序规则再套LIS。说白了,题目的外衣越来越多,内核不变。这就更要求你把基础算法的推导过程吃透,而不是死记硬背模板。模板背得再熟,遇到披了新外衣的题,认不出来一样白搭。
所以我带人的时候,从来不让直接背Kadane算法或者LIS模板,而是要求他们能从状态定义开始,自己推导出转移方程。这个过程走一遍,比刷十道同类题更有用。笔试的时候忘了一个边角语法,可以通过推导再确认,而不是依赖死记硬背的代码。
7.2 数据范围增大带来的复杂度要求
另一个趋势是数据范围越来越大。早年模板题可能n=100,O(n^3)都能过;现在很多中等题n=10^5,O(n^2)就是超时。这就要求你在写代码之前先估算复杂度,再决定用哪种算法。
我建议在草稿纸上养成一个习惯:读完题先看数据范围,标记出可能的时间复杂度上限,再根据这个上限倒推算法。比如n=10^5,最坏允许O(n log n),那你能用的算法就限定在排序、二分、堆、并查集这些里面;DP数组如果是一维O(n)可以,二维O(n^2)就不可行,要优化或换思路。
这个"先看数据范围再定算法"的习惯,我在2017年那会儿还没有,是后来吃了亏才养成的。有一次模拟笔试,题目给的是n=10^5,我上来就写了个二维DP,写完还在得意状态转移方程很巧妙,一提交直接超时。从那以后,我拿到题第一件事就是盯数据范围。
7.3 面试中的算法延伸提问
笔试做完了,题目本身还没结束。面试官经常会拿你笔试里做过的题来深挖,比如问你"这道题还有没有更优解""你刚才这个解法空间复杂度还能不能降""如果输入是流式数据,你怎么改"。如果你笔试时只是背模板AC了,这些追问很容易露馅。
针对一套模考题,面试前可以自己准备几个追问的答案。比如LIS,能说出O(n log n)的二分优化和证明思路;区间调度,能说出贪心选择性质的交换证明;最大子数组和,能说出分治解法和Kadane算法的区别与联系。准备这些不是为了背答案,而是通过思考这些问题,把题目理解得更深。笔试时你只是完成代码,面试时你需要展示思维深度。
这也是为什么我把2017一模这道题翻来覆去地讲——它足够经典,可以往各个方向延伸。把一道经典题的上下游都打通,比囫囵吞枣刷十道新题收获更大。
我从2017年那个被模考打击到的学生,到后来帮别人准备校招笔试,中间最大的变化就是明白了:笔试考的不是你遇到过多少题,而是你在有限时间内,把核心算法应用到一个新场景里的能力。牛客模考也好、公司笔试题也好,都是这个逻辑的外化。这套题的每道题都值得反复咀嚼,直到你不仅能写出代码,还能讲清楚每一步为什么这么走。