第四范式2019校招后端笔试题,我刷完后的复盘和思考
2019年秋招季,我投了第四范式的后端开发岗。那时候AI公司风头正劲,第四范式以“机器学习平台”“AutoML”被大家熟知,我当时想着,后端笔试肯定不只是普通的Java/Go八股文,多少会沾点算法、系统设计甚至一点机器学习基础。等真拿到题目,我发现这判断对了一半,它对基础工程能力的考察远比我想象的“扎实”,有些题甚至让我当场冒汗。这篇复盘整理了题型分布、核心考点、几道典型题的完整解题思路,还有我踩过的坑,给后面准备AI公司后端岗的同学做个参照。
这套题适合两类人看:一类是像我一样准备AI公司后端实习/校招的,想提前了解考察方向和难度;另一类是已经在做后端开发、想对照自检一下自己基础知识是否经得起“笔试题”这种压力测试的。整份卷子的风格不是让你死记硬背,而是把知识点放到具体场景里,考察你在限定时间内能不能快速给出可落地的方案。
1. 整体印象:这卷子不是“刷题就能过”的类型
先说整体体感。整套题大致分了几个模块:选择题/填空题、算法编程题、简答题/方案设计题。如果只看算法题,难度其实不算变态,LC中等偏上一点;但你如果以为把LeetCode刷熟就稳了,那就错了。真正拉开差距的是后面的方案设计题和与机器学习场景结合的考察点,这些题没有标准答案,靠的是你平时写代码时有没有形成一套系统的思考框架。
我印象很深的一点是:试卷里反复出现“海量数据”“高并发”“缓存一致性”这些关键词,比一般互联网公司后端题更侧重数据处理和模型服务的场景。后来查了下面试资料,第四范式主要做企业级AI平台,后端需要支撑模型训练、模型上线、在线推理这些链路,所以它的后端工程师,本质上是在“AI基础设施”上做工程。这直接决定了笔试题会往“系统设计+基础原理+算法基础”三个方向出。
另外,时间安排上要提醒一下:总分值不算吓人,但题量不小。我当时拿到卷子,先扫了一遍,发现编程题有3道,每道都不是5分钟能写完的,还有两道设计题需要写很多文字。如果你按顺序硬做,很容易在中间消耗太久,最后设计题只能草草写两句。合理策略是先把容易拿分的选择题和简单题快速过掉,再集中火力攻克编程题和设计题。
2. 题型拆解:考点分布与考察意图
那次笔试总共大概9到10道大题,细分下来可以归纳成几条线。我按记忆整理了一下,各部分的倾向性大概是这个感觉:
| 考察方向 | 典型内容 | 占比感受 | 为什么这么考 |
|---|---|---|---|
| 数据结构与算法 | 排序、二分、DP、链表、二叉树、堆 | 35% | 校招基础筛选线,确保候选人有一定编码功底 |
| 操作系统与网络 | 进程线程、内存管理、TCP/UDP、IO模型 | 20% | 后端要面对高并发和网络IO,底层原理必须通透 |
| 数据库与缓存 | 索引原理、事务隔离、Redis数据结构与淘汰策略 | 15% | 存储是后端核心,尤其要处理海量训练/推理数据 |
| 机器学习基础 | 评价指标、过拟合、特征工程、模型上线中的工程点 | 15% | 公司基因,后端也要能和算法团队同频沟通 |
| 场景设计题 | 缓存设计、任务调度、分布式链路、QPS估算 | 15% | 考察真正的系统设计潜力和工程落地思维 |
这个分布很能说明问题:它对“算法功底”和“工程深度”是并重的。普通的后端笔试更偏“你能不能在LeetCode上写出题”,而这份卷子还希望你回答“为什么这样设计”“数据库底层是怎么实现的”“模型上线时精度和延迟怎么权衡”。
我当时就栽在一道和“模型上线”相关的选择题上:它问的是模型在线推理服务需要重点关注的性能指标。选项里有AUC、QPS、延迟、召回率这类,我直接选了AUC,后来复习时才发现,面向线上推理,最核心的是延迟(Latency)和吞吐(QPS),AUC是离线评估指标。这说明一个点:如果你是纯后端背景,最好提前了解一下机器学习的基本概念,哪怕只是知道“离线指标”和“在线指标”的区别,也能避开这种基础送分题的坑。
3. 核心知识点深度拆解:光刷题不够,要懂底牌
我在复习阶段最大的感受是,光看面经和刷题,很容易陷入“背答案”的状态。但第四范式的笔试题,尤其是选择题和简答题,经常换一个场景问同一个原理,考察你是不是真的理解。下面我把考察频率最高的几个知识点拆开来说,这部分也是我认为整份卷子最值得研究的地方。
3.1 数据结构与算法:不是最难的,但最容易失分
算法题大概在LC中等难度,但会结合工程场景出题。比如有一道近似“实现一个带过期时间的LRU缓存”,虽然核心是“HashMap+双向链表”,但加了“过期时间”和“并发访问”两个约束,瞬间就变了很难。只背过LRU基本模板的话,很容易在“过期清理”和“线程安全”上踩坑。
我当时用double check locking写的,加了一个后台线程做过期清理,交卷后复盘,发现如果要求“读操作也更新过期时间”,我的实现就错了,因为我在get的时候没有维护每个key的最后访问时间。
另一道比较有代表性的题是海量数据topK:从100亿个整数中找出最大的1000个。看到“100亿”,先别慌,这题考点不是分布式,而是“局部最优”和“堆”这两个点。
主体思路是:维护一个大小为K=1000的小顶堆。每来一个数,如果比堆顶大,就替换堆顶,然后向下调整;如果比堆顶小,直接丢弃。因为小顶堆堆顶是堆里最小的元素,如果当前数字连堆顶都打不过,说明它一定不在最大的K个数里。这样处理一个数的时间是O(logK),总复杂度是O(n logK),对100亿数据来说,就是“读一遍 + 每个数做几次比较”。
这个思路和大数据平台里map端combiner的局部聚合很像:每个节点先算局部topN,再汇总到主节点算全局topN。所以与其说这题考算法,不如说考你“有没有大数据处理的分而治之意识”。
3.2 操作系统与网络:笔试里的“隐形大坑”
操作系统和网络在选择题和简答题里占了不少分,而且考察点非常基础,但越是基础越容易忽略。比如多线程、进程线程区别、用户态与内核态切换、零拷贝、epoll和select的区别,这些概念看似大家都会背,但是换个方式问就很容易含糊。
有一道题大概是问:多线程并发访问同一个变量,存在什么问题?解释下volatile和原子类的区别。这种题目在校招里真的一抓一大把,因为后端工程师写高并发代码,离不开线程安全。但很多人答题时就只写“volatile保证可见性,不保证原子性”,然后没下文了,这道题分拿不高。要拿高分,你需要进一步展开:volatile底层是基于内存屏障实现的,线程写变量时会强制刷新到主内存,读变量时从主内存拉取。它解决的是可见性,但没解决“read-modify-write”这种复合操作的原子性。而AtomicInteger是基于CAS+自旋实现的,它通过比较并交换的方式保证复合操作原子性。如果你能顺便写一句“CAS在竞争激烈时可能导致大量自旋,所以高并发下可以考虑LongAdder”,那这道题的区分度就出来了。
网络方面,TCP三次握手、四次挥手是必然考的,但第四范式比较偏爱的考点是TCP和UDP的区别 + 为什么在线推理服务推荐用HTTP/2而不是HTTP/1.1?我当时看到这题有点懵,因为它不是纯背八股,而是要把网络知识结合到实际业务里。后来想明白了:HTTP/2支持多路复用,一个TCP连接里可以并行多个请求/响应,不会像HTTP/1.1那样前面一个慢请求堵住后面所有请求。这对于在线推理服务很关键,因为推理服务往往有几十毫秒甚至上百毫秒的响应时间,如果配置为“一个连接同时只能处理一个请求”,QPS就会被严重限制。
3.3 数据库与缓存:你必须懂索引,不能只看面试题
数据库这块近几年越来越热门,笔试不考写SQL,而是考索引原理。比如:为什么用B+树做索引,而不用红黑树或哈希表?这题算基础中的基础,但想答得有深度也不容易。
哈希索引适合等值查询,但遇到范围查询就废了;红黑树虽然能范围查询,但在磁盘场景下树太高,一个节点一个磁盘块,查找时磁盘IO次数太多。B+树把多个键值放在同一个节点里,每个节点对应一个磁盘页大小,通过增加每个节点的“扇出”,把树高控制在三层左右,一般三次磁盘IO就能从根节点走到叶子节点。而且B+树的叶子节点用双向链表串起来,查“age between 20 and 30”这种范围查询时,只要先找到20,然后顺着链表向后遍历就行。
我当时在卷子上写“B+树叶子节点串成链表,适合范围查询”后,又补了一句“所以在建普通索引时,要尽量让索引列有顺序,比如自增ID就比UUID更合适,这样能减少索引页的随机IO和页分裂”。后面这句是我做题时的临场发挥,但我觉得正是这类“原理延伸到实践”的点,才是面试官想看到的。
缓存方面,Redis几乎算是后端笔试标配了,重点考察Redis的过期删除策略、内存淘汰策略、持久化机制。有一题问的是Redis的key过期了为什么内存没有马上释放,答案是Redis过期删除采用“惰性删除+定期删除”结合:当key被访问时检查是否过期,过期则删除;同时后台定时任务抽样删除部分过期key。如果你只答“惰性删除”,说明你没看过Redis源码,定期删除这个机制很多人会漏掉。
还有一道设计题:如何设计一个缓存,避免缓存雪崩、穿透、击穿。这三兄弟是后端系统设计题里的“钉子户”。我当时分别写的解决方案是:
- 穿透:布隆过滤器拦截不存在的key;或者缓存空值(比如缓存一个特殊标记,设置短过期时间)
- 击穿:使用互斥锁,只有一个线程回源数据库,其他线程等待;或者用“逻辑过期”策略
- 雪崩:缓存过期时间加随机扰动,避免大量key同一时间过期;或者集群部署,避免单点失效
如果能把雪崩和穿透区分开,再给出“为什么布隆过滤器适合解决穿透”,这道题基本就稳了。
3.4 机器学习基础:后端为什么也要懂这些
第四范式的笔试题里出现机器学习概念,我一开始是有点意外的,但仔细想想又很合理。后端工程师要写的并不只是“CRUD API”,而是要支撑模型全生命周期:包括数据集的读取与校验、特征处理流程的编排、模型训练任务的调度、模型上线后的在线推理服务。如果后端对机器学习的基本概念一无所知,很难和算法工程师协作。
这部分考得不算深,基本都是概念理解。比如过拟合的解决办法,在卷子上很常见:正则化、增加数据量、dropout、早停、交叉验证。再比如评价指标的选择:二分类模型在正负样本极不平衡时,用准确率(Accuracy)是没有意义的,因为全部预测为负样本可能也有99%的正确率,这时候更应该看AUC、F1、召回率、精确率这类指标。
我记得当时还考了一个很实际的问题:模型训练完成后,部署上线时如何评估模型性能变化?选项里有“A/B测试”“灰度发布”“回滚机制”这些。答案是“A/B测试”,因为它能在真实流量中对比新老模型的效果指标,从而判断是否应该全量上线。这已经偏MLOps了。如果你只懂传统后端,根本没接触过这些词,很可能选错。我当时选对了,但更多是靠直觉,而不是系统性判断。
所以给纯后端背景的同学一个建议:准备第四范式这类公司,不用啃完整个机器学习教材,但至少要把以下概念过一遍:监督/非监督学习、特征工程、过拟合/欠拟合、准确率/精确率/召回率/F1/AUC、训练集/验证集/测试集、模型上线中的A/B测试和灰度发布。这部分花一天时间就能补齐,回报率非常高。
4. 实操环节:三道典型题从读题到AC的完整思路
前面讲了知识点,这节我用三道我印象最深的题,完整还原一下从读题到解题的思考过程。我会把代码也用伪代码/代码片段写出来,尽量还原当时的思路,而不是直接给答案。
4.1 算法题:带过期时间的LRU缓存
题目大意:实现一个带过期时间的LRU缓存,支持get(key)和put(key, value, expireTime),要求在平均时间复杂度O(1)内完成。
读题时我脑子里立刻浮现出一串问题:O(1)的查找怎么实现?HashMap可以。O(1)的淘汰怎么实现?双向链表可以,链表末尾就是最久未使用的节点。过期时间怎么处理?key-value扩展为key-node,node里存value和expireTime。
几个关键设计点:
- 双向链表中每个节点除了存key、value,还要存expireTime
- get时,如果当前时间超过expireTime,直接删除并返回不存在
- get时,如果命中且没过期,需要把该节点移动到链表头部
- put时,如果key已存在,更新value和expireTime,移动到头部;如果不存在,插入头部,如果容量超了,删除尾部节点
初次实现比较简单,但还是要注意几个工程细节:
- 用“当前时间戳”作为判断依据,而不是存“剩余有效期”,因为存剩余有效期的话你还要在每次访问时做减法,处理方式更绕
- 清理过期时间的时候,不能只清理“一个节点”,理论上可以惰性,在访问时发现过期再删,这样避免后台线程额外消耗
- 但如果不做主动清理,缓存里可能会堆很多已经过期的key,内存浪费
如果只考算法,惰性删除就够了。但如果考到并发,建议用ConcurrentHashMap + 自己加锁的双向链表,或者直接使用Caffeine的guava实现做参考。当时时间有限,我就用一个简单哈希表+双向链表实现,没有加并发控制,把“线程安全”这点写在了注释里,表示我知道这题还有这个维度。
代码骨架大概是:
struct Node { int key, val, expireTime; Node* prev; Node* next; }; class LRUCacheWithExpire { private: unordered_map<int, Node*> mp; Node* head; Node* tail; int capacity; public: int get(int key) { auto it = mp.find(key); if (it == mp.end()) return -1; Node* node = it->second; if (node->expireTime < currentTime()) { removeNode(node); mp.erase(key); return -1; } moveToHead(node); return node->val; } // put 类似,记得在插入前先检查容量和过期 };真的把它跑通之后,你会发现这类题的核心不是LRU的“双向链表”本身,而是你怎么把“过期时间”和“访问顺序”这两个维度融合在一个数据结构里。后来我在写业务代码时,也用到了这个思路:给缓存key加了一个“业务维度的逻辑过期时间”,用来防止旧特征数据被重复使用。
4.2 算法题:求数组中的逆序对数量
题目大意:给定一个数组,求逆序对的数量。逆序对的定义是:对于数组中的两个下标i < j,如果arr[i] > arr[j],则它们构成一个逆序对。
这题如果只知道暴力“双重循环”,时间复杂度O(n^2),在大数据量下必然超时。正解是利用归并排序:在merge的过程中,如果左半边当前元素大于右半边当前元素,说明左半边从当前元素到结束,都和右半边这个元素形成逆序对,直接累加。
我在草稿纸上模拟了整个过程。比如[7, 5, 6, 4],归并排序会先拆成[7,5]和[6,4],然后继续拆成单个元素。合并[7]和[5]时,发现7>5,逆序对+1,变成[5,7];合并[6]和[4],逆序对+1,变成[4,6];合并[5,7]和[4,6]时,因为5>4,所以[5,7]里的两个元素都和4构成逆序对,逆序对+2,然后7>6,逆序对+1,总数就是1+1+2+1=5。
代码实现上,归并排序框架不变,只需在合并时加上计数:
int count = 0; public void mergeSort(int[] arr, int left, int right) { if (left >= right) return; int mid = left + (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); } private void merge(int[] arr, int left, int mid, int right) { int[] temp = new int[right - left + 1]; int i = left, j = mid + 1, k = 0; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { temp[k++] = arr[i++]; } else { count += (mid - i + 1); temp[k++] = arr[j++]; } } while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; System.arraycopy(temp, 0, arr, left, temp.length); }这题看似基础,但它的工程意义在于:任何需要“统计顺序关系”的场景都能用到归并排序的merge阶段。比如在实时特征计算中,有排序特征的需求,逆序对数量可以作为一个特征衡量序列的混乱程度,所以这类题不是白考的。
还有个小建议:遇到数字类题目,先用小样例手动演算一遍,再用代码实现,尤其在笔试场景下,可以避免因为“边界条件处理错误”导致AC不了。
4.3 设计题(简答):设计一个支持海量任务的异步处理系统
题目大意:系统每天要处理上千万个异步任务(比如模型推理任务),每个任务需要调用外部服务,耗时不定,偶尔失败需要重试,要求设计一个高吞吐、可扩展的异步任务处理方案。
这题没有标准代码,但你需要给出清晰的架构和关键组件的选型理由。我当时分了几层来写:
第一层是生产者。调用方通过消息队列的API提交任务,不要直接阻塞等待结果。消息队列选择上,我写的Kafka,因为它的吞吐量非常高,适合大批量任务的导入。如果任务量中等但要求投递可靠,RocketMQ也是一个选择,它的事务消息和重试队列更完善。
第二层是消费者/Worker。下游worker集群从消息队列拉取任务,执行具体的模型推理或外部调用。这里要注意几点:
- worker要做幂等,因为消息队列是“至少一次”投递语义,可能重复消费
- 消费速度要可控,不能一次性拉太多任务导致内存打爆
- worker失败后要重试,但重试要有上限,并且超过上限后进入死信队列,方便人工排查
第三层是结果存储。任务执行完的结果写入一个高性能的存储系统(比如Redis或者单独的MySQL表),调用方通过轮询或者回调拿到结果。
第四层是监控与告警。每类任务的执行时间、失败率、堆积数量都要有指标,因为异步系统“积压”是最隐蔽的故障。我写了一个“堆积延迟”作为核心监控项,意思是队列里最老的消息等待了多久,而不是简单看队列长度,因为队列长度和队列消费速度未必成正比。
这个架构思路其实很通用,很多公司在做异步任务处理时都是这个套路,差别在于消息队列选型、重试策略、以及是否使用分布式调度平台。准备这类设计题时,建议多看几篇“消息队列(Kafka/RocketMQ)实战”的文章,把消费幂等、重试机制、顺序消息这三个点吃透。
5. 实际踩过的坑与复盘总结
这节说点真实经验,有些真的是考场上踩了才能记住的坑。
5.1 时间分配:别在算法题上死磕满分
我当时花在第二道算法题上的时间太久了,一直想在O(n)里解出来,结果浪费了半个小时,回头看如果用归并排序早就写完了。笔试不比竞赛,不需要你提出最优的最优,能保证通过所有测试用例、代码清晰可读,就已经是很好的答案。遇到一道题卡了15分钟以上,就应该果断标记一下,先去做后面的题,回头有时间再优化。
5.2 边界条件:数组为空、只有一个元素、输入超大
我复盘时发现,自己很多失分不是不会做,而是边界条件没处理好。比如快排没有处理数组为空的情况,LRU缓存没有处理容量为0的情况。在笔试环境里,测试用例往往故意包含这些极端情况。解决方法是写代码前先想清楚:空输入、单元素输入、重复元素、最大数值范围,这些边界是否都覆盖了。
5.3 设计题别只说方案,要带“为什么”
很多同学和我初期的状态一样,写设计题只会堆名词:用Redis做缓存、用MQ做解耦、用分库分表。但你得问自己一个问题:为什么选Redis而不是本地内存?为什么选Kafka而不是ActiveMQ?为什么分库分表的键要这么分?
比如:Redis适合高并发读场景,因为单线程模型避免了锁竞争,但在一致性要求极高的场景就未必合适,因为它默认无法保证强一致。如果你能主动点出“这里可以接受短暂不一致所以用Redis缓存”,面试官就会知道你是真的懂。
5.4 代码风格:模拟面试环境,不要依赖IDE提示
很多人在IDE里写代码很顺,一旦到了纯文本框的笔试环境,连括号匹配都费劲。我建议平时练习时,直接在网页端的编辑器里刷题,不开代码补全、不点“智能提示”,完全手写。这个过程很痛苦,但非常有效,尤其能锻炼你组织代码块段的能力。你在试卷上每写一个for循环都能少纠结一次“该用i还是j”,就是一种进步。
5.5 操作系统/网络概念,不要只背结论
比如“epoll和select的区别”,在网上看到了很多面经,每次都觉得自己懂了,但如果你不去实践一下写个echo server,其实根本无法深层次体会它为什么减少了系统调用。笔试可能只考一个选择,但如果你能把“select是轮询所有的fd集合,epoll是事件驱动、只返回就绪的fd、且不需要每次重新传入fd集合”这个机制说清楚,你就比大多数人强。
6. 从这套题反推:AI公司后端到底在招什么人
复习和复盘这套题时,我一直在想一个问题:第四范式的后端笔试到底想筛选出什么样的人?后来我得出的结论是:它想找的不是“纯刷题机器”,也不是“只会写业务CRUD的码农”,而是那种“基础扎实、能思考、能落地”的技术人。
基础扎实,体现在数据结构、操作系统、网络、数据库这些底层原理的掌握是否真正通透而非死记硬背。能思考,体现在设计题里能否给出方案,并且有条有理地讲清楚取舍。能落地,体现在代码是否考虑边界条件、异常场景、并发安全这些“工程细节”。
你自己也可以对照这个标准自测一下:如果你现在写一个带token校验的HTTP服务,能立刻想到需要处理并发访问问题吗?如果你设计的缓存系统,能明确说出它在不同一致性要求下的应对方案吗?如果你的算法题都能过,但设计题聊聊就卡壳,那说明你离“真正的后端工程师”还有一段距离。
最后分享一个我自己从这次笔试中学到的小技巧:在校招准备阶段,做任何一道题,都先问自己三个问题——这个知识点在真实工程中解决什么问题?如果数据量扩大100倍会怎样?如果要和其他系统协作,接口要怎么设计?练到后面,你会发现自己不只是在“答题”,而是在“做一个系统”,这种状态对笔试和未来工作都有帮助。