春招那阵子我投了不少算法岗,旷视的笔试算是印象比较深的一场。不说别的,单是“研发工程师”这个岗位的笔试范围就比想象中广,算法、概率、机器学习基础、甚至一点工程细节都会涉及。虽然这是2019年的题,但从面试角度来说,它考察的核心逻辑到现在也没怎么变:基础扎不扎实、代码能不能写利索、思路会不会转弯。这篇文章就结合当年的笔试经历,把整套题目的考察逻辑、重要考点、以及我当时踩过的坑完整拆一遍,给准备类似岗位笔试的同学一个参考。
1. 笔试整体设计与思路拆解
1.1 为什么笔试会这么出题
旷视做的是计算机视觉方向,但这不意味着笔试开篇就考卷积神经网络。2019年春招这场研发工程师笔试,整体来看还是以计算机基础为主,算法题占大头,机器学习相关的题作为拉开差距的部分。这个出题思路其实很现实:视觉算法工程师首先得是合格的工程师,代码底子和逻辑能力不过关,后面做模型训练、部署、数据管线都容易出问题。
整张卷子的结构大致可以分成四块:不定项选择、算法编程题、简答/推导题、以及一个小型系统设计题。选择部分涵盖数据结构、操作系统、网络、概率统计,题目不算难,但面铺得很开。编程题通常是两道,一道偏数据结构,一道偏动态规划或搜索。简答题考机器学习基础概念,比如正则化、过拟合、交叉验证这些,偶尔会让你推一下某个损失函数的梯度。最后的系统设计题是拉分项,给一个场景让你设计解决方案。
这套设计思路其实是多数AI公司技术笔试的标准套路,核心目标是在两个小时内快速筛选出“基础过关、能动手写代码、有一定系统思维”的人。所以准备这类笔试,不需要去刷特别偏难怪的题,但计算机核心课程的地基必须打得足够稳。
1.2 从岗位要求反推笔试重点
研发工程师在旷视的语境里,更偏算法落地方向。也就是说,光会调模型不行,你得能处理数据、写训练脚本、调参、部署服务,甚至要会优化推理速度。这决定了对工程能力的考察不会少。
笔试中体现得很明显:编程题不是LeetCode那种纯刷题模式,而是带着实际场景味道,比如在二维矩阵里找最长的满足某种性质的路径,或者在资源受限的情况下做任务调度。这类题背后考察的不只是语法熟练度,更是问题建模能力。另外像概率题出现在选择里,也说明他们希望你有扎实的数理基础,毕竟做模型评估、A/B测试、采样策略这些事情,不懂概率是玩不转的。
2. 核心考点解析与实操要点
2.1 算法题重点:LeetCode中等题为主,不要死磕难题
从实际题目的难度分布来看,笔试编程题的难度基本集中在LeetCode中等题,偶尔会出现一道偏简单的困难题。涵盖的题型无非就是数组操作、链表、二叉树遍历、动态规划、回溯搜索、双指针、哈希表。这些类别看起来很多,但高频考点是非常集中的。
我当时遇到的编程题,一道是“给定一个整数矩阵,从左上角走到右下角,每次只能向右或向下,求路径上数字之和最大的路线”的变体——多加了一个条件,某些格子不能走。这就是典型的动态规划问题,但是加入了障碍物后,需要初始化的时候特别注意边界情况。另一道是“实现一个LRU缓存”,要求get和put都是O(1)时间复杂度。这题看着简单,实际坑不少,需要同时用哈希表和双向链表,而且链表节点的前后指针操作特别容易绕晕。
这类题目的准备思路,我建议把常见的数据结构操作练成肌肉记忆,比如链表反转、二叉树的前中后序遍历、快速排序和归并排序的手写、二分查找的边界处理、 HashMap的底层原理。这些基本功扎实了,笔试中遇到的大部分题都能找到思路。
2.2 机器学习基础:不只是背概念,要能推导
简答和选择里涉及的机器学习内容,主要集中在几个方向:正则化的作用和原理(L1为什么会产生稀疏解)、过拟合的判别与应对方法、交叉验证的流程、梯度下降的几种变体(SGD、Momentum、Adam的区别)、损失函数的选择、样本不均衡的处理方式。
表面上是概念题,但如果你只看书没有自己推过一遍,很容易在“L1正则化为什么会产生稀疏解”这种题上卡住。这个问题的核心在于L1正则化的约束区域是菱形(在二维情形下),最优解更容易落在坐标轴上,从而实现稀疏。但用文字回答和用公式推导完全是两回事,笔试的简答题要求你写出数学表达式和推导过程,平时不动手推,到考场上很难写出完整答案。
我记得当时有一道题是“写出逻辑回归的损失函数,并推导梯度”,这题其实不难,但如果平时只是背结论,推导过程中很容易在sigmoid函数求导那一步出错。sigmoid的一个重要性质是σ′(x)=σ(x)(1−σ(x)),利用这个性质可以让梯度表达式变得很简洁,这个技巧我建议提前练熟。
2.3 数学与概率统计:不要忽视的基础分
笔试选择里混着几道概率统计题,看起来不起眼,但往往是区分度很高的部分。常见考法有:给你一个随机变量的分布函数,求期望和方差;或者给你一个贝叶斯公式的场景题,让你计算后验概率。
举个例子,一类很经典的题是这样的:某疾病的患病率为1%,检测方法的灵敏度是99%,特异度是95%,如果一个人检测结果为阳性,问实际患病的概率是多少。这题直接用贝叶斯公式算,答案其实只有不到17%,很多凭直觉选的人都会选错。这类题的分很好拿,考前把条件概率、贝叶斯公式、常见分布(正态分布、二项分布、泊松分布)的均值和方差公式过一遍,基本就能拿到分。
2.4 系统设计题:考察的是工程思维
最后那道系统设计题,考的是“给一个人脸识别门禁系统设计整体架构,要求说明各个模块的职责、可能的瓶颈和优化方案”。这种题不是让你写出代码,而是看你有没有能力把一个实际业务问题拆解成可实现的组件。
答题思路大概是这样的:第一,先明确系统的核心流程——人脸检测、特征提取、特征比对、结果输出;第二,在每个流程上说明清楚选什么技术方案,比如人脸检测用MTCNN还是RetinaFace,特征提取用ResNet50还是MobileNet系列,这里需要考虑精度的同时也要评估部署资源;第三,一定要提到性能优化,比如模型量化剪枝、推理引擎选型、缓存策略等;第四,说明数据存储方案,比如特征向量库用faiss这类向量检索工具,而不需要提具体的数据库品牌,关键是说明为什么它适合这个场景。
这类题没有标准答案,但能看出一个人是不是真实做过项目。如果完全没接触过工程化部署,写出来的方案很容易停留在理论层面,所以在准备这类岗位时,一定要自己去跑一遍完整的模型部署流程,哪怕在本地跑通一个简化版也很有帮助。
3. 实操过程与核心环节实现
3.1 动态规划题:从暴力递归到状态压缩的完整演进
笔试遇到动态规划题,最怕的不是不会做,而是上来就写了一个错误的贪心。我建议在平时练习时就养成一套固定的解题节奏:先明确状态定义,再写状态转移方程,然后初始化边界,最后考虑能否优化空间复杂度。
以“带障碍物的二维矩阵最大路径和”为例,我们一步步来推演:
第一步,定义状态。设 dp[i][j] 表示从起点走到 (i,j) 位置时的最大路径和。这个定义非常直观,也是大多数二维动态规划问题的通用状态。
第二步,写状态转移方程。因为只能向右或向下走,所以 (i,j) 位置的路径只可能来自上方 (i−1,j) 或左方 (i,j−1),转移方程为: dp[i][j] = grid[i][j] + max(dp[i−1][j], dp[i][j−1])
第三步,初始化边界。第一行的格子只能从左边走过来,第一列的格子只能从上面走过来。但这里需要特别注意一个坑:如果某个格子是障碍物,那么不仅这个格子本身不可达,它后面的一整行或一整列也不可达了。这就是笔试里容易忽略的地方。
第四步,空间优化。仔细观察转移方程可以发现,dp[i][j] 只依赖上一行的数据,所以可以用一维数组来滚动更新:
vector<int> dp(cols, 0); for (int i = 0; i < rows; ++i) { for (int j = 0; j < cols; ++j) { if (grid[i][j] == -1) { dp[j] = -1; // 障碍物标记 } else { int left = (j > 0 && dp[j-1] != -1) ? dp[j-1] : INT_MIN; int up = (dp[j] != -1) ? dp[j] : INT_MIN; if (i == 0 && j == 0) dp[j] = grid[0][0]; else if (left == INT_MIN && up == INT_MIN) dp[j] = INT_MIN; else dp[j] = grid[i][j] + max(left, up); } } }这题的核心难点在于状态定义,只要想清楚“走到当前位置的最大路径和”,后面写代码只是水到渠成。笔试现场不需要追求写出最优的滚动数组版本,二维dp能AC(通过全部测试用例)就已经能拿大部分分数了。
3.2 LRU缓存:哈希表加双向链表的标准解法
LRU缓存机制是经典面试题,笔试里如果出现,通常要求实现 get 和 put 两个操作,时间复杂度为O(1)。判断一个候选人是不是真的理解这题,关键看他能不能解释清楚“为什么要哈希表+双向链表”而不只是背代码。
哈希表负责实现O(1)的查找,而双向链表负责维护访问顺序。链表头表示最近访问的节点,链表尾表示最久未访问的节点。每次 get 一个 key,就把对应节点移到链表头部;每次 put 一个新 key,也插入到头部;如果缓存超过容量,就删除尾部节点,并同步删除哈希表中的记录。
这里有个非常容易踩的坑:如果用单链表,删除尾部节点需要遍历链表才能找到它的前驱节点,时间复杂度就变成O(n)了。双向链表则可以直接通过节点的 prev 指针找到前驱,达到O(1)删除。这个细节非常关键,有些同学在面试官追问为什么不用单链表时答不上来,就是没有理解数据结构选型的本质。
我当时笔试里写的大致代码结构是这样的:
class LRUCache { private: struct Node { int key, value; Node* prev; Node* next; Node(int k, int v) : key(k), value(v), prev(nullptr), next(nullptr) {} }; int capacity; Node* head; Node* tail; unordered_map<int, Node*> mp; void removeNode(Node* node) { node->prev->next = node->next; node->next->prev = node->prev; } void addToHead(Node* node) { node->next = head->next; node->prev = head; head->next->prev = node; head->next = node; } public: LRUCache(int capacity) { this->capacity = capacity; head = new Node(0, 0); tail = new Node(0, 0); head->next = tail; tail->prev = head; } int get(int key) { if (mp.find(key) == mp.end()) return -1; Node* node = mp[key]; removeNode(node); addToHead(node); return node->value; } void put(int key, int value) { if (mp.find(key) != mp.end()) { Node* node = mp[key]; node->value = value; removeNode(node); addToHead(node); } else { if (mp.size() == capacity) { Node* last = tail->prev; removeNode(last); mp.erase(last->key); delete last; } Node* node = new Node(key, value); mp[key] = node; addToHead(node); } } };代码本身并不长,但链表操作非常容易绕晕。我当时的做法是:全程在纸上画节点图,先画出 head、tail 和实际节点之间的连接关系,再在图上模拟一次插入和删除,最后再去写代码。这招在笔试现场非常管用,能减少很多低级错误。
3.3 逻辑回归梯度推导:公式推导的拿分技巧
简答题里那道“写出逻辑回归的损失函数并推导梯度”,我在这里把完整过程走一遍,你会发现它没有想象的那么复杂。
逻辑回归中,对于单个样本 (x,y),预测概率为 hθ(x)=P(y=1|x;θ)=σ(θ^T x),其中σ是sigmoid函数。用极大似然估计,单个样本的损失可以写成: L(θ) = −[ y log(hθ(x)) + (1−y) log(1−hθ(x)) ]
这是二分类交叉熵的单个样本形式。接下来求参数θ的梯度,关键是利用sigmoid函数的导数性质:σ′(z)=σ(z)(1−σ(z))。
先看对log(hθ(x))求梯度: ∂ log(hθ(x)) / ∂θ = (1/hθ(x)) * hθ(x)(1−hθ(x)) * x = (1−hθ(x)) * x
再看对log(1−hθ(x))求梯度: ∂ log(1−hθ(x)) / ∂θ = −(1/(1−hθ(x))) * hθ(x)(1−hθ(x)) * x = −hθ(x) * x
把两部分代入损失函数的梯度表达式: ∂L/∂θ = −[ y(1−hθ(x))x − (1−y)hθ(x)x ] = [ y + hθ(x) − yhθ(x) − hθ(x) + yhθ(x) ] * x = (hθ(x) − y) * x
最终梯度就是预测值与真实标签的差再乘以特征向量,这个形式简洁到令人惊讶。如果在推导过程中利用好sigmoid的导数性质,整个过程非常顺畅。如果硬算sigmoid的导数而不做化简,很容易在代数运算中出错。这道题拿到满分的关键,不是记住最终结果,而是把中间步骤写清楚,尤其是sigmoid求导那一步。
4. 常见问题与排查技巧实录
4.1 编程题常见失误:边界条件与输入输出
笔试编程题最常见的失误其实不是逻辑错误,而是边界条件处理不完整。数组越界、空数组、只有一行或只有一列的矩阵、整数溢出,这些场景都是测试用例中一定会出现的情况。
以动态规划题为例,如果矩阵只有一行,那么dp数组的初始化就要考虑到“只能向右走”;如果没有障碍物,问题退化为普通路径问题,但你的代码仍然要能正确处理。这些都是非常细节的地方,但在笔试的线上测试环境里,几乎全部都会作为隐藏测试用例。
另外一个容易出问题的地方是输入输出格式。有些笔试平台会要求你处理循环输入,有些是一次性读入全部数据。我当时的建议是:提前去了解笔试平台常用的输入方式,在本地练习时就用标准输入输出。千万别在考场上花时间研究怎么从标准输入读取数据,那是白白浪费时间。
4.2 简答题常见误区:只写结论不写过程
简答题的判分方式通常按步骤给分,即使最终结果错误,中间的推导步骤正确也能拿到不少分。但很多同学的习惯是直接写一个最终结论,过程一句带过甚至完全省略,这在笔试中非常吃亏。
比如让你推导L1正则化产生稀疏解的原因,正确的答题方式应该是:先写出L1正则化后的损失函数,再画出约束区域和等值线的几何解释,最后说明凸优化中尖角导致的稀疏性。每一步都有分,而仅仅写一句“L1能得到稀疏解”是拿不到分的。
每道简答题的答题结构大致是:先给出结论,再用公式或图形说明原因,最后补充一个例子或特殊情况。这套结构在笔试和面试中都非常实用,建议平时多练习。
4.3 笔试时间分配策略:分值优先,不要死磕
整张卷子两个小时,编程题通常给的时间最多,但不要一上来就钻进编程题。我的分配策略是先花10分钟左右浏览全部题目,心里快速判断每道题的难度和预计时间。选择填空题能快速拿分的先做,简答题的关键词先写在草稿纸上,编程题如果超过20分钟没有思路,先跳过做后面的题目,最后再回头想。
这里想强调一个很现实的技巧:在线上笔试环境中,编程题只要逻辑正确,不要求代码风格多优雅,甚至不用考虑代码重复和内存效率,能AC就是胜利。但简答题如果空着,就真的是零分。安排好时间,确保每道题都有回答,是笔试的基本策略。
4.4 关于智商题和偏题的准备
有些人会担心笔试里出现“奇怪的智商题”或者纯脑筋急转弯式的题目。从我实际体验来看,这类题即使偶尔出现,占比也极低,核心考察还是基础和思维逻辑。与其花大量时间准备偏题怪题,不如把计算机基础课重新过一遍:数据结构、操作系统、计算机网络、概率统计。
操作系统和网络的基础知识在选择题里经常出现,比如进程和线程的区别、死锁产生的必要条件、TCP三次握手的过程、IP数据包的分片规则等。这些内容虽然不直接关联视觉算法,但作为一名研发工程师,它们是通用常识。把这些基础打牢,选择题能多拿不少分。
5. 笔试之外的个人体会
我后来复盘这段笔试经历时发现,准备过程和最终结果之间并没有那么强的线性关系。真正有用的其实是笔试前的系统复习,它逼着我把大学期间的知识点重新串了一遍,也让我意识到工程能力在算法岗位中的权重越来越高。如果你正在准备类似岗位的笔试,我个人最想强调的就三点:第一,算法题做熟练不等于会做,一定要理解每个数据结构选型背后的原因;第二,推导题要动手写,不要只在脑子里想;第三,平时项目训练中尽量多走一遍从数据到部署的完整流程,这个经历在笔试和面试里都会无形中帮到你。
这些内容放在今天来看依然不会过时。笔试只是求职的一小步,但它考察的东西,恰恰是研发工程师日常工作中最需要的底层能力。希望这篇复盘对你有用。