3月中旬,手机弹出一封邮件——蚂蚁集团2024春招算法岗第一批笔试通知。考试时间定在周六上午10点,全程2小时,在线监控。我当时正在刷LeetCode的贪心题,看到邮件第一反应是“终于来了”,第二反应是“第一批笔试,题型到底什么样”。说实话,算法岗这种岗位的笔试,在没有面经参考的情况下准备起来非常没有方向感,因为你不确定它考的是纯算法题、机器学习基础,还是两者混着来。等真正做完整场笔试,再和同批次的朋友们复盘了一圈,我才算把这场笔试的套路摸清楚。这篇就当是给后面准备蚂蚁算法岗的同学一份参考,把我这批次遇到的真题回忆版、考点拆解和踩坑细节都写出来,能帮一个是一个。
1. 这场笔试的组织形式与题型分布
1.1 在线考试平台与考试环境
进考场前先说说平台。蚂蚁这批用的是牛客网的企业笔试系统,考前半小时开放登录,登录之后系统会让你做环境检测,包括摄像头、麦克风和浏览器版本。这里有个细节容易被忽略:系统明确要求使用Chrome浏览器,而且开着原生无痕模式反而可能触发警告。我当时用的Chrome,提前调试了摄像头权限,整个过程没出问题。但同批次有个朋友用的Edge,进入考试后画面黑屏,折腾了好几分钟才恢复正常,白白浪费了宝贵的答题时间。
考试全程是有监考的,浏览器会记录切出页面的次数和时长,如果频繁切到别的标签页,后台会标记异常。我的建议是考前把所有辅助工具放在另一台设备或者打印出来,别在考试电脑上开任何无关页面,哪怕是查资料也不要冒险。远程笔试这东西,宁可保守到极致,也不要拿诚信记录开玩笑。
1.2 题量与时间分配预判
这批次我的真实题量是:15道选择题(单选+多选混合)+ 2道编程题,总分100,时长120分钟。选择题每题大概1分多一点,编程题一道是中等难度(大概LeetCode Medium偏下),一道是偏难的(涉及状态压缩DP,后面详细讲)。
这个题量看起来不算多,两个半小时足够写完,但实际做下来会发现时间非常紧张。选择题涉及的面很广,从数据结构、排序算法到机器学习基础都有,很多题不是一眼能看出答案的。编程题又要读题、设计算法、调边界条件、处理输入输出,如果前面选择题磨磨蹭蹭,后面编程题很容易崩盘。关于时间分配,我个人的建议是:选择题尽量控制在35分钟内完成,不会的题先标记跳过,不要恋战;两道编程题分别预留40分钟和30分钟,最后15分钟统一检查选择题和补漏。我这个顺序后面吃了亏,第5章详细复盘。
1.3 计分规则和筛选逻辑
牛客网的笔试系统对编程题有部分分机制,通过一部分测试用例会拿到对应的比例分,不是只有AC和非AC两个结果。这一点容易被低估——很多人看一道题没有完整思路就放弃,实际上用暴力方法拿部分分,比空着不写强得多。蚂蚁的算法岗笔试刷人比例确实不低,从同批次考生的反馈看,进面的基本条件应该是选择题正确率较高且至少完整AC一道编程题。但也不是说一定要两道全对,选择题全对、编程题AC一道并有一定部分分的同学,有不少也进了面。
另外有个信息值得留意:蚂蚁算法岗的笔试并不区分具体团队,不管是投AI平台、OceanBase还是其他算法方向,这一轮笔试用的是同一套题。真正分方向是笔试之后的事,面试官会根据你的简历和意向团队重新评估。所以准备阶段不要被“我是做AI平台的,是不是要多看深度学习”这种想法带偏,这一轮笔试考的就是通用的算法基本功。
2. 选择题考点复盘:机器学习与数据结构各占多少
2.1 机器学习/深度学习相关题
选择题里机器学习相关的题大概占了5~6道,这是算法岗笔试的特色,也是区别于纯后端开发笔试的地方。记忆比较深的一道题是:给了一个模型在训练集上AUC 0.99、测试集上AUC 0.72的对比,问最合理的改善手段是什么,选项有降低模型复杂度、增加L2正则化、增大训练数据、换一个更复杂的模型。这题考的是过拟合的基本判断——训练集极高、测试集低,典型的过拟合,答案肯定是正则化、降低复杂度、补充数据这一类,选更复杂的模型反而是火上浇油。这类题目本身不难,难在这是一道多选题,少选一个就扣分,需要有清晰的边界判断。
还有一道和L1/L2正则化相关:问L1正则化为什么能让模型参数变得稀疏。答案的核心在于L1的惩罚项在0点处不可导,优化过程中参数很容易被压到精确的0,而L2的梯度在参数接近0时会变小,参数只是趋向0但不会成为严格的0。这道题不是死记结论就能答对的,需要理解两种正则化对梯度下降过程的影响差异,我在准备阶段刷过相关概念,还算顺利。
另一道印象深刻的题考的是聚类算法。题目大致是:K-means算法在迭代过程中,如果某个簇在分配步骤后没有样本点,应该怎么处理。选项包括重新初始化该簇中心、直接保持原中心、将该簇删除并减少簇数、随机将其他样本点分到该簇。正确做法是重新初始化该簇中心,这是K-means实现中很经典的空簇处理问题,但在实际面试中反而很少有人会专门准备这个细节。
2.2 数据结构与算法基础题
数据结构算法这块的选择题有4~5道,几乎覆盖了最常见的考点。有一道题考KMP算法的next数组,模式串是"abacaba",问其next数组(前缀函数形式)是多少。计算过程是这样的:
| 前缀 | 最长相等前后缀长度 |
|---|---|
| a | 0 |
| ab | 0 |
| aba | 1(前缀a,后缀a) |
| abac | 0 |
| abaca | 1(前缀a,后缀a) |
| abacab | 2(前缀ab,后缀ab) |
| abacaba | 3(前缀aba,后缀aba) |
所以答案就是 [0, 0, 1, 0, 1, 2, 3]。这里要注意,不同教材对next数组的定义有差异,有的是整体左移一位,有的在末尾补-1,但牛客网的题明确说了用前缀函数定义。看到这类题不要慌,在草稿纸上老老实实画一遍就能算出来,最怕的就是凭记忆背答案,把几种定义搞混。
排序算法稳定性的题几乎每场笔试都会出现。这道题问的是:以下哪些排序算法是稳定的?冒泡排序、快速排序、归并排序、堆排序。答案是冒泡和归并。快速排序因为分区操作是跨距离交换元素的,会打乱相等元素的相对顺序;堆排序同理,建堆和堆调整的过程涉及一定范围外的交换,稳定性无从保证。这些知识点属于八股,但笔试还真会考,复习的时候不能只看时间复杂度,稳定性、空间复杂度、适用场景都得一起过。
2.3 概率统计和工程场景题
剩下几道题分布在概率统计和工程场景上。有一道概率题是这样的:甲命中率为0.8,乙命中率为0.7,两人各自射击一次,问至少有一人命中的概率。答案是1 - 0.2 * 0.3 = 0.94,解法很直接,考的是对立事件的转化。
还有一道题结合了哈希表和数据库索引的工程场景,问在什么情况下哈希索引会比B+树索引更适合。选项包括范围查询、等值查询、模糊查询、排序查询。答案是等值查询。哈希索引的底层结构决定了它在等值查询上时间复杂度接近O(1),但无法支持范围扫描和排序。这道题对算法岗来说有点超纲,但因为蚂蚁有OceanBase这类数据库背景,笔试里出现数据库索引相关的题也不算意外。
最后一道印象比较深的是关于AUC的题:随机取一个正样本和一个负样本,模型对正样本的预测分数大于对负样本预测分数的概率是多少?答案就是AUC。这题考的是对评估指标的深层理解,而不是单纯的公式记忆,我在准备机器学习面试的时候看过AUC的几何意义和概率解释,做起来比较从容。
3. 编程题第一道:看似贪心,实则经典区间调度
3.1 题目复述与思路推导
编程题第一道题设不长,大概是这样:
"某平台有n场直播,每场直播有固定的开始时间和结束时间,同一个观众在同一时刻只能观看一场完整直播。给定n场直播的开始时间和结束时间,问一个观众最多能完整观看多少场直播。n最大为10^5,时间范围为0到10^9。"
看到这题的第一反应就是区间调度问题,经典解法是按结束时间排序,然后贪心选择最早结束且不与已选区间冲突的区间。但笔试现场要的不是背答案,而是要把为什么按结束时间排序说清楚,因为你的代码是按这个逻辑写的,如果思路本身有漏洞,边界情况处理错了,AC率会很难看。
为什么按开始时间排序不行?我举个例子:区间A[1, 10],区间B[2, 3],区间C[4, 5]。按开始时间排序,A最早开始,选了A之后B和C都冲突,答案只能看1场;但按结束时间排序,B最早结束(3点),选B,再选C(4到5),答案能到2场。贪心的核心逻辑是:结束时间越早的区间,给后面的区间留下的余量越大,所以每一步“选择结束最早的可行区间”都不会让最优解变差——这就是区间调度问题里的替换法证明思路。写代码的时候我把这个逻辑在注释里写了一遍,也算是对自己思路的一种确认。
3.2 完整代码与边界条件
def max_watched_shows(n, shows): # shows: list of (start, end) shows.sort(key=lambda x: x[1]) count = 0 current_end = -1 for start, end in shows: if start >= current_end: count += 1 current_end = end return count这个代码逻辑很简单,但边界条件有几个坑。第一个坑是时间范围很大,时间戳可以是0,所以current_end初始化为-1而不是0,不然第一个区间从0开始的直播会被漏掉。第二个坑是区间左闭右开还是闭区间——题目说的是结束时间和开始时间相同的两场直播能不能连着看。那场笔试的表述是“相邻场次之间无缝隙即可”,也就是start >= current_end就可以选,这个细节如果不仔细读题,很多人的解法会用成start > current_end,导致边界用例错一片。
3.3 这道题的延伸思考
第一题做完其实还有时间富余,我顺手在草稿纸上想了想变体:如果每场直播的收益不一样,要求的是最大收益而不是最大场次,那这个问题就不能用贪心解决了,需要动态规划。按结束时间排序之后,dp[i]表示前i场直播能获得的最大收益,转移的时候需要二分查找最后一场不冲突的直播。这个变体在后续的面试里被面试官问到了,笔试虽然没考,但我建议准备笔试的同学把这类经典问题的所有变体都过一遍,因为你永远不知道面试官会不会顺着笔试题往下追问。
4. 编程题第二道:状态压缩动态规划,考验基本功
4.1 题目的识别与状态设计
第二道编程题明显难度上去一个档次。题目大意是:有n个促销任务,每个任务有前置任务约束、耗时(都是1天)和收益,现在总共有limit天,问能获得的最大收益。n的取值范围是1到20,limit不超过15。
看到n ≤ 20,第一反应就是状态压缩DP。为什么?因为20这个数字非常典型:2^20约等于100万,这个规模可以在合理时间内枚举所有状态。如果用一般的搜索或者递归,复杂度会呈指数爆炸,而n=20的规模恰好是状压DP能handle的极限。
状态定义我觉得很直观:dp[mask] 表示已经完成了mask这个任务集合时能获得的最大收益。这里mask是一个二进制数,第i位为1表示第i个任务已经完成。关键在于转移的时候如何判断前置任务是否都完成了——维护一个pre数组,pre[j]是一个bitmask,表示任务j的所有前置任务。那么任务j可以被加入当前状态mask的条件是:(pre[j] & mask) == pre[j],含义是任务j的前置任务全部包含在mask中。
4.2 完整代码实现
def max_profit(n, limit, profits, pre): # profits: list of int, pre[j]: bitmask of prerequisites of task j size = 1 << n dp = [-1] * size dp[0] = 0 ans = 0 for mask in range(size): if dp[mask] < 0: continue # mask 中 1 的个数就是已经使用的天数(每个任务耗时1天) if mask.bit_count() > limit: continue ans = max(ans, dp[mask]) for j in range(n): if (mask >> j) & 1: continue if (pre[j] & mask) == pre[j]: next_mask = mask | (1 << j) dp[next_mask] = max(dp[next_mask], dp[mask] + profits[j]) return ans这里有个容易忽略的点:dp不是简单地把所有可达状态都设为正数,而是要逐步取最大值。因为同一个next_mask可能从多个不同的mask转移过来,如果不用max,可能丢掉最优路径。另外,遍历mask从0到size-1的顺序是成立的,因为每次转移都是往next_mask = mask | (1 << j)走,这个值一定大于mask,所以从小到大遍历天然满足无后效性。
4.3 我在考场上的实际处理
这道题我大概花了25分钟左右才完整调通。最开始我的代码没有检查mask.bit_count() > limit这个条件,导致算出来的结果可以通过小样例,但在某些case里把超过limit天的方案也算进去了。后来在本地模拟数据的时候发现,假设limit=2,n=3,如果三个任务互相没有前置关系,理论上最多只能做两个任务,但我的dp会把三个任务全做完的结果算出来。这个bug不是靠看代码能发现的,必须构造一个规模很小的反例去验证逻辑。
笔试做这种偏难的题,我的心得是:先写出能跑通小样例的朴素版,再逐步优化,而不是一上来就想一步到位。状态压缩DP本身就容易出错,如果一开始就想着加各种剪枝和优化,很容易把状态转移写乱。我在草稿纸上先画了一个n=3的小例子,把所有状态转移路径走了一遍,确认没有遗漏之后才改写成代码提交。虽然花的时间长了一点,但一次通过所有测试用例,比反复交反复改要高效得多。
5. 失败与教训:我在时间分配上犯的错
5.1 选择题上浪费了太多时间
考完之后复盘,我最大的失误就是选择题部分消耗过多时间。前面说过我的计划是选择题控制在35分钟内,但实际上我在前10道选择题上就花了近30分钟,原因是有几道题我不太确定,反复在几个选项之间纠结。比如那道K-means空簇处理的题,我当时在“重新初始化中心”和“保持原中心不变”之间犹豫了很久,因为我记得某个库的实现是保持原中心,但题目问的是理论上最合理的处理方式,这个纠结本身就说明知识掌握得不够扎实。
等到我开始做编程题时,时间已经过去接近50分钟。第一题虽然顺利通过,但第二题的状态压缩DP花了几分钟才想起来状态该怎么定义。如果选择题能再果断一点,把不确定的题先用标记功能记录下来、最后再回头检查,编程题的思考时间会充裕很多。
5.2 读题不仔细导致的低级返工
另一个浪费时间的点是第一道编程题。题目里“结束时间和开始时间相同即可看连续场次”这个细节,我一开始没注意到,代码用成了严格大于,结果提交后没过第一个样例。回头重读题目才发现问题,改一行就通过了。笔试时平台只提供最简单的编辑器,没有本地IDE那种边写边跑的能力,改完代码还要自己模拟输入去验证,这个过程非常消耗时间。所以我能给的建议就是:读题至少读两遍,把时间区间边界的处理方式圈出来,再动笔。很多时候不是题目难,而是细节没看清。
5.3 复盘后的改进策略
笔试结束当晚,我把所有不确定的题重新整理了一遍。对于选择题,我把机器学习、数据结构和概率统计三块分别列了一个错题集,发现薄弱点主要集中在机器学习的模型评估和细节处理上,而不是纯数据结构。于是我后面两周的复习重心就放在了偏差方差、正则化、各类评估指标、聚类算法细节上,这个方向和纯刷LeetCode是完全不同的准备路径。
时间分配我也重新做了规划。如果再有类似的笔试,我会给自己定一个硬性规则:每道选择题最多2分钟,超时先标记跳过;编程题每个题先读两遍题,再花5分钟在草稿纸上理思路,不能一上来就写代码。这两条规则看似简单,但实际操作中非常考验执行力。很多人笔试发挥不好,不是因为实力不够,而是因为时间节奏失控。
第二天系统出了各题得分,第一道编程题是满分,第二道拿到了大部分测试用例的分数,选择题具体错了几道没显示,但最终结果是收到了后续面试通知。回头看这一轮笔试,难度分布其实挺合理的,没有偏题怪题,考的都算是算法岗该有的基本功。唯一需要注意的是题量虽然不大,但覆盖面广,复习的时候不要只顾着刷题,机器学习和概率统计的基础概念一定要过一遍。接下来我应该还会再写写面试那一轮的复盘,先把笔试这部分记录在这里,给后面准备的同学做个参考。