前一阵我把手头那份2017年春招笔试真题编程题集合翻出来重刷了一遍本来只是想顺手练练手感结果越刷越觉得这批“老题”其实很耐琢磨。那时候的笔试题不像现在有些公司海量题库随机抽题型更集中、更基础也更考验把思路转化成代码的速度。这份集合里的题覆盖了字符串、模拟、搜索、贪心、二分、动态规划基本把大厂春招笔试最常出现的几个方向都点到了。所以这篇复盘主要是想聊聊这批题到底在考什么、我从里面拆出了哪些通用套路、以及刷题过程中我踩过哪些坑希望对正在准备春招笔试的同学有一点参考价值。1. 这份笔试真题集合给了我哪些基础判断1.1 老题集合为什么还值得认真刷很多人觉得2017年的题太老技术栈都换了好几轮了没必要看。但我的看法相反。春招笔试考的不是最新框架而是数据结构、算法、代码实现能力这几块基本功。2017年的题目风格更“朴素”没有太多弯弯绕绕的背景包装反而方便把每个考点看清楚。我刷了一遍之后发现现在很多公司的笔试题里依然能看到这一批题型的影子字符串处理、二维矩阵遍历、日期模拟、最短路、最长上升子序列、二分查找边界等。这种老题集合还有一个好处环境稳定没有实时排名压力。我可以自己掐时间、自己判结果错了就复盘不用担心提交失败带来的挫败感。对于刚准备春招的同学先用这种确定性高的真题集合建立信心比一上来就做随机组卷舒服很多。1.2 拿到题集后我先做了一件事按考点分类我没有从第一题顺序刷到尾而是先把整套题大致扫了一遍按考点做标签。我自己的分法是字符串类回文判断、进制转换、大数处理、字符串匹配。模拟类日期计算、循环队列、逻辑流程模拟。搜索类DFS、BFS、连通分量、迷宫最短路。贪心加二分区间选点、任务调度、lower_bound/upper_bound。动态规划最大子段和、最长递增子序列、背包变体。这个分类做完后面每刷一题都知道它在练哪个能力复盘的时候也方便归因。分类表可以参考我当时的印象考点方向大致占比主要考察点最容易失分的地方字符串两成左右边界处理、字符操作空串和单字符输入模拟两成左右流程拆解、状态转移日期、循环边界搜索两成左右图遍历、连通性、最短路方向数组写错、死循环贪心与二分两成左右排序策略、区间收缩二分终止条件模糊动态规划两成左右状态定义、转移方程初始化错误、漏状态这个占比不一定精确只是为了让自己心里有数。刷题阶段真正重要的不是记住某道题的答案而是知道这类题需要哪套工具。1.3 我给自己定的刷题节奏我发现如果一次刷太多脑子容易糊。所以我的节奏是每次完整做四到五道题时间控制在90分钟左右。先花10分钟读题并手动设计两三个测试用例再花30到40分钟写代码最后留15分钟专门测边界。读题先行是必须养成的习惯。许多题目看起来是“字符串处理”但仔细读完才发现需要用栈或者双指针有些题目开头像动态规划实际贪心就够了。如果拿题就写很容易写半路推翻重来。2. 字符串、模拟和搜索题的实战拆解2.1 字符串题把所有边界条件都在白板上过一遍这套题集里的字符串题不算难但特别验基本功。我印象最深的一类问题是“删除最多一个字符能否变成回文串”。看起来简单其实容易漏掉删除左边还是右边都可行的情况。这类题的核心思想是双指针从两端往中间扫遇到第一个不相等的位置时试一下跳过左边那个或者跳过右边那个只要有一边剩下部分全相等就行。我当时用Python写了一个版本def is_palindrome(s, left, right): while left right: if s[left] ! s[right]: return False left 1 right - 1 return True def valid_palindrome(s): left, right 0, len(s) - 1 while left right: if s[left] ! s[right]: return is_palindrome(s, left 1, right) or is_palindrome(s, left, right - 1) left 1 right - 1 return True这里面的关键点是子函数is_palindrome必须接收当前 left/right而不是自己重新算。否则删除一个字符后的区间就会错。另一个容易忽略的是空串和单字符它们天然是回文串如果忘了处理提交时可能直接数组越界。还有一类是和进制转换、大数相加相关的字符串题。这类题虽然换了个外壳本质都是在考“字符数组逐位运算”。大多数情况下把字符串反转成从低位到高位的形式再逐位相加比直接在原字符串上操作省心很多。笔试环境下只要能正确产出答案用什么顺序处理并不重要。2.2 模拟题先在草稿纸上画状态转移模拟题在2017年的卷子里出现频率不低因为大家笔试题的经验是模拟题能直接反映一个人面对复杂流程时是不是能拆清楚。这类题不考高级算法但考细心和严谨。比如日期问题计算两个日期之间相差多少天或者某天是星期几核心就是“统一到一个基准点”。我自己的做法是定义一个函数把某年某月某日转换成从公元元年开始的累计天数。闰年判断单独写成函数避免在主流程里重复写导致不一致def is_leap(year): return (year % 4 0 and year % 100 ! 0) or year % 400 0 def days_of_month(year, month): month_days [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] if month 2 and is_leap(year): return 29 return month_days[month - 1] def days_from_epoch(year, month, day): total 0 for y in range(1, year): total 366 if is_leap(y) else 365 for m in range(1, month): total days_of_month(year, m) return total day然后两个日期相减取绝对值加1就是相差天数。这个方案的优点是把问题拆成三个独立小函数每个小函数都可以单独测。笔试时间紧张的时候宁可多写几个小函数也不要在一个大函数里堆逻辑否则定位 bug 非常痛苦。模拟题里还有一个高频场景是“循环队列”或“约瑟夫环”。这类题的关键是把下标移动用取模写清楚。很多人一写就乱其实只要记住下一个位置是(cur step - 1) % n移动方向不同时再单独处理。把这一步抽出来整道题就好写了。画状态转移图也很有效我用草稿纸画清楚每个状态之间的跳转再落代码出错率会小很多。2.3 搜索题BFS求最短路DFS判连通这套题集里搜索题的难度分布比较合理有简单的连通分量也有需要 BFS 求最短步数的矩阵题。我自己的经验是先分清 DFS 和 BFS 各自适合的场景。DFS 适合判连通、回溯枚举所有路径BFS 适合求最小步数、无权最短路。因为 BFS 是按层扩展的第一层到达的位置一定是最短路径所在层。拿一个常见的矩阵题目举例给定一个0/1矩阵0表示可走1表示障碍求从左上角到右下角的最少步数。这是最典型的 BFS我当时写的版本是这样from collections import deque def min_steps(grid): if not grid or not grid[0]: return -1 n, m len(grid), len(grid[0]) if grid[0][0] 1 or grid[n-1][m-1] 1: return -1 visited [[False] * m for _ in range(n)] dq deque() dq.append((0, 0, 1)) visited[0][0] True dirs [(1, 0), (-1, 0), (0, 1), (0, -1)] while dq: x, y, step dq.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 dq.append((nx, ny, step 1)) return -1这段代码有几个容易错的细节第一访问数组要在入队的时候标记不能在出队的时候标记否则同一个点可能被多个方向重复入队导致内存膨胀第二步数的初始值是1还是0要看题目定义通常到达起点也算一步所以起点步数为1第三变量名dirs用列表包元组写起来比一堆 if 简洁也不容易漏方向。矩阵边界判断一定是0 nx n这种写法不要写成nx 0 and nx n然后漏掉另一个方向。这些细节看着小笔试时却足以让人反复提交失败。DFS 处理连通分量时我遇到的坑主要是递归深度。如果矩阵特别大递归会栈溢出所以笔试时遇到连通型问题我反而优先想并查集或者显式栈不要依赖系统递归栈的深度。3. 动态规划和二分贪心这几类题的“想清楚再写”原则3.1 动态规划的状态定义比转移方程更关键动态规划在这套题集里属于压轴档出的不算多但出的都比较典型。我刷下来最大的感受是状态定义对了转移方程基本是顺出来的状态定义错了后面再怎么调都不对。比如“最长递增子序列”这个问题状态可以定义为“以第 i 个元素结尾的最长递增子序列长度”转移就是dp[i] max(dp[j] 1)其中j i且nums[j] nums[i]。这样写是 O(n^2)作为暴力版本完全没问题。但如果题目数据范围到了 10^5就必须做优化。我当时用了一个更高效的写法核心思想是维护一个tails数组tails[k]表示长度为k1的递增子序列末尾元素的最小值。可以用二分在tails中找插入位置import bisect def length_of_lis(nums): tails [] for x in nums: idx bisect.bisect_left(tails, x) if idx len(tails): tails.append(x) else: tails[idx] x return len(tails)这段代码看起来很短但背后的“为什么”值得想清楚。二分bisect_left找到的是第一个大于等于x的位置把该位置的值更新成x意味着用更小的末尾元素替换旧值。这样并不会让当前序列长度变长但后续如果出现更大的数就更容易接在后面。这就是贪心加二分的核心思想。笔试里动态规划还有一种高频题是“最大子段和”。这种题适合用 Kadane 算法状态就是“以当前位置结尾的最大子段和”。cur max(cur x, x)同时维护全局最大值。这个状态转移非常短但很多人会忽略全负数组的情况导致初始值设成0之后输出0。正确做法是初始值设为负无穷或者直接让全局最大值等于第一个元素。3.2 二分查找终止条件和区间收缩必须一次性写对二分查找在春招笔试里几乎是必考但很多人出错不是不理解原理而是写的时候没有固定习惯。我自己的固定写法是区间左闭右开也就是[l, r)。用这种写法lower_bound求第一个大于等于target的位置就是这个样子def lower_bound(arr, target): l, r 0, len(arr) while l r: mid (l r) // 2 if arr[mid] target: l mid 1 else: r mid return l这套写法的好处是循环结束时l正好指向第一个满足条件的下标不需要再纠结返回值是谁。如果要求最后一个小于等于 target 的位置可以求lower_bound(target 1) - 1这样复用起来很稳。写二分时还有一个常见问题mid可能因为整数加法溢出。在 C 里建议写int mid l (r - l) / 2;Python 因为有任意精度整数不用过度担心但养成习惯还是稳妥的。另外二分不能只用一个测试用例验证比如空数组、数组只有一个元素、target 小于全部元素、target 大于全部元素、有重复元素这些都要跑一遍。3.3 贪心题证明不出来就先用枚举兜底这套题集里的贪心题大多不复杂像区间调度、任务安排、跳跃游戏。贪心题的难点从来不是代码而是证明当前策略是对的。我当时的习惯是如果20分钟内没法给出清晰的反例就用暴力枚举先验证思路。比如“给定一系列区间选出尽可能多互不相交的区间”这个经典题。贪心策略是按右端点从小到大排序然后尽量选右端点靠前的区间。代码很简短def interval_scheduling(intervals): intervals.sort(keylambda x: x[1]) ans 0 last_end float(-inf) for l, r in intervals: if l last_end: ans 1 last_end r return ans为什么按右端点排序而不是左端点因为结束得越早留给剩余空间的余地越大。这个道理看起来简单但真到笔试现场很多人一紧张就按左端点排结果答案多出不少错误。我的建议是贪心题在写代码前先花一分钟想一个“如果不这么做会不会更好”的反例。如果能造出反例基本说明策略不对如果造不出就大胆写。笔试时间有限证明能省则省但要确保思路经得起反例检验。4. 调试和提交环节最容易翻车的三个地方4.1 输入输出和空数据是隐形扣分点我当时刷题时有一个非常明显的感受思路再对只要输入输出格式错了整个提交就废了。春招笔试环境五花八门有的平台用标准输入有的要控制台输出有的要求多组数据直到 EOF还有的对行尾空格要求严格。多组数据读取是高频坑。比如有的题要求“有多组测试数据每组数据第一行是n……”在 Python 里可以这样处理import sys data sys.stdin.read().strip().split()先把所有数据读进来再按顺序解析能避免input()在空行和 EOF 边缘反复出问题。但用sys.stdin.read()时要注意如果题目要求每行一个字符串而字符串本身可能含空格直接split()会拆错。这时候要按行读取不能一刀切。另一个隐形成分是浮点数比较。笔试中如果答案涉及小数比如求平均值、比率不要直接写if a b要用绝对值差小于1e-9判断。整数题一般没问题但一旦涉及浮点很多莫名奇妙的 WAwrong answer都是精度导致的。4.2 大数溢出和取模时机2017年的笔试题里还没有那么多“超长整数”题但很多题目会把答案限定到10^9 7取模。取模题的坑不在于取模本身而在于取模时机。乘法如果不先取模直接相乘即使在 C 的long long里也可能溢出。我的习惯是加法乘法每算一步都取模减法先加模数再取模避免出现负数。const long long MOD 1e9 7; long long ans 0; for (...) { ans (ans x * y % MOD) % MOD; }Python 玩家虽然不用担溢出但要注意大数的计算速度。纯 Python 处理 10^6 级别的循环加取模性能还是会比 C 差不少所以如果平台对运行时间很苛刻要么优化算法复杂度要么换用 C/Java 提交。我自己平时 Python 写算法原型笔试前还是会练一练 C 的提交模板。4.3 复盘的时候要按错误类型积累刷题复盘不能只把正确答案看一遍就完事。我把自己在这套题集上的错误分成三类第一类是题意理解错第二类是算法复杂度错第三类是代码实现错。每一类需要改进的方向完全不同。题意理解错说明读题时没有抓住边界条件下次要先把题目里的限制条件圈出来。算法复杂度错说明考后需要重新分析数据范围是不是该从 O(n^2) 优化到 O(n log n)。代码实现错比如数组越界、变量名拼错、忘记初始化说明写代码时缺少自查清单。我给自己做了一张简单的复盘表错误类型具体表现我的改进动作题意理解忽略了多组输入、输出格式要求读题时用笔画出关键字算法选择该用二分却写了线性扫描所有题目先看数据范围实现细节边界判断反了、初始化错误每个分支都配一个测试用例这样做的好处是下一套题集出来时我能直接回顾这些高频错误而不是重新踩一遍。5. 这套老题集怎么用最有效5.1 先暴力枚举再考虑优化我刷题时有一个原则实在没思路先写一个逻辑最简单、一定能算对的版本哪怕复杂度很高。这个版本有两个作用第一保证自己对题意的理解是准确的第二可以作为后续优化版的“正确答案生成器”用随机小数据比对两个版本的结果。笔试现场没有评测反馈的时候这一招尤其管用。很多人一上来就想写最优解结果边界错一大堆反而不如先拿暴力版本保证正确性再逐步优化。我还习惯为每道题准备五六组自测数据。尤其是边界数据空数组、只有一个元素、最大值、重复元素。很多时候看似正确的代码一跑边界就露馅。这套老题集里的题边界条件相对规整非常适合训练这种习惯。5.2 不要把真题答案背下来要把“解题习惯”背下来市场上有不少互联网公司真题合集题目本身不难找。但直接背代码意义不大因为今年不可能原题重现。我真正从这套题集里提取的是“遇到某类题先想什么”的习惯模板。比如字符串题先问自己能不能用双指针要不要反转有没有可能涉及栈模拟题先把流程拆成阶段再考虑循环终止条件。矩阵搜索题先判断 DFS 还是 BFS再把方向数组和 visited 数组写好。动态规划题先定义状态再写转移。贪心题先找反例。二分题固定区间写法。这些习惯比记住某道题的模板重要得多。5.3 笔试现场的时间分配建议根据这套题集的练习经验我给自己定的现场策略是拿到全部题目后先花五分钟浏览所有题按难度排序。先把会做的、代码量小的题做掉把动态规划这类容易卡壳的题放到最后。每道题如果连续思考超过20分钟还没有明确思路就果断标记跳过不要死磕。笔试判分通常按通过用例的比例来做对三道简单题的收益往往大于死磕一道压轴题。另外提交前一定要留时间手动检查边界。我见过不少同学样例通过、边界翻车的情况最后就败在那几个隐藏测试用例上。哪怕是老题集也不能因为“看起来很简单”就放松警惕。我重刷这套题集的时候有几道题第一遍写的版本样例全过结果一测空数组就报错这种失误是最可惜的。最后再分享一个小技巧练这套题集的时候我会把每道题的“失败原因”直接写在题号旁边过一段时间再翻看。春招准备是一个长期过程记忆会淡但错误归纳不会。把这些错误反复看几遍比多做十道新题都管用。