2019年秋招那个节点,我印象里最深的不是群面,也不是面试官追问,而是打开快手游戏研发A卷的那一瞬间。第一页不是普通互联网公司那种“三题算法走天下”的套路,而是混合了大量C++、图形学、网络同步内容的综合试卷。当时我盯着屏幕大概愣了十秒钟,脑子里只有一个念头:这张卷子不是在筛“会写代码的人”,它是在筛“真的能去做游戏研发的人”。虽然现在距离这份卷子已经过去很久,但里面很多题目和考点我依然记得很清楚,也经常在指导学弟学妹校招时拿出来当案例讲。这篇文章就做一次完整的复盘,把那份A卷的考察逻辑、核心题目、解题思路以及我踩过的坑都拆开说清楚。
如果你正在准备游戏研发方向的校招,或者只是好奇这类岗位笔试到底考什么,这份复盘应该能帮你省掉很多自己摸索的时间。我会按“卷面结构—算法与数据结构—C++与图形学—网络同步与架构设计—备考策略”这条线来讲,中间会穿插我当时真实的答题状态和考后反思,尽量把为什么这么考、怎么答才不丢分这件事讲透。
1. 拿到A卷时的第一反应:题型分布和考察逻辑
1.1 快手的游戏研发笔试到底在筛什么人
先聊一个很多同学容易忽略的点:不同公司、不同岗位的笔试题,背后都有一套“岗位画像”。快手游戏研发A卷给我的感觉是,它希望筛选出的人至少具备三方面的能力:第一,扎实的编程基本功和算法思维,这是所有技术岗位的底线;第二,对游戏引擎和底层机制有真实的理解,而不是只会在Unity里拖拽预制体;第三,对多人游戏里的网络同步、性能优化这些“进阶但核心”的话题有自己的思考。
为什么这么说?因为卷子里的题目分布非常有意思。纯算法题只占一部分,剩下的大头给了C++内存布局、图形学坐标系变换、渲染管线、网络同步方案设计。这种组合在当年的互联网大厂校招里并不算主流,更像是在模拟一个游戏研发工程师日常工作中会遇到的真实问题。你如果只刷LeetCode,不碰引擎底层,可能编程题能做出来,但简答题和选择题会非常难受。
1.2 卷面结构一览(凭记忆复盘)
我尽量还原一下当时卷子的结构,具体题号顺序可能有出入,但题型分布大概是这样的:
| 题型 | 题量 | 分值占比 | 考察方向 |
|---|---|---|---|
| 单选题 | 15题左右 | 约30% | C++语法与内存、数据结构、操作系统、图形学基础 |
| 多选题 | 5题左右 | 约15% | 计网、同步方案、并发、Shader相关 |
| 编程题 | 2题 | 约30% | 数据结构与算法 |
| 简答/设计题 | 2题 | 约25% | 网络同步、游戏逻辑架构 |
从分值上就能看出来,它不是“算法定生死”的卷子。选择题和简答题加起来占了一半以上,而且这些题几乎没有标准八股文答案,更多是考察你对概念的理解深度。比如有一道多选题问的是“帧同步和状态同步的区别”,选项里混杂着“确定性”“服务器权威”“断线重连复杂度”“流量消耗”这些关键词,如果你只是背过定义,没有真正理解两种方案在实战中的取舍,很容易多选或少选。
这里给一个很实用的建议:拿到卷子先花两分钟扫一遍所有题目,心里对整张卷子的难度分布和分值分布有个底,再决定答题顺序。我当时就是吃了这个亏,前面选择题抠得太细,导致后面编程题时间略紧。后面第五部分我会专门讲时间分配。
2. 算法题复盘:最容易超时的是“觉得简单”的题
2.1 数组与字符串:双指针的隐蔽坑
编程题第一道,如果我没记错的话,是一道字符串相关的题,大致要求是:给定一个字符串,要求去掉所有重复字符,并且保持第一次出现的顺序。看起来非常简单,很多人第一反应是用一个哈希集合记录已出现字符,然后遍历字符串,没出现过就加入结果,出现过就跳过。这种做法的时间复杂度是O(n),空间复杂度也是O(n),本身没问题,但它考察的点其实藏在题目隐含条件里。
题目里如果加了“原地修改”“不能开辟额外数组”或者“只能遍历一次”这种限制,解法就完全不一样了。我当时遇到的版本是允许用额外空间,但要求输出顺序稳定,所以哈希集合方案直接可行。不过我在写代码的时候踩了一个小坑:忘记了字符范围可能是ASCII扩展字符还是Unicode字符,直接用vector<bool>去当哈希表用,导致越界。笔试环境里编译器不一定报错,但运行时会出问题。
正确的做法是直接用unordered_set<char>或者set<char>,或者先用ASCII 128的长度做标记。如果是Unicode场景,unordered_set更稳妥。我当时还刻意写了一个“避免集合删除操作”的版本,因为有人会用set.find加set.erase去维护顺序,但那会退化到O(n·k)。真正的考点就是:你能不能识别出这是一个“判重+保序”问题,并且用对数据结构。
代码层面,核心就是:
string removeDuplicates(string s) { unordered_set<char> seen; string res; res.reserve(s.size()); for (char c : s) { if (!seen.count(c)) { seen.insert(c); res.push_back(c); } } return res; }这道题给我们的启示是:笔试里的“简单题”往往不是考你会不会某种奇技淫巧,而是考你在有复杂度约束和边界条件的情况下,能不能快速写出无bug的代码。很多同学刷题时只追求“思路对”,忽略了边界检查,但笔试判题是看全部测试用例的,一个字符集的坑就能让你这道题拿不到满分。
2.2 树与图:递归改非递归的考察点
第二道编程题比第一题上了一个台阶,我记得和二叉树遍历相关,但不是简单的输出前序/中序/后序,而是有额外条件:要求用非递归方式实现某种遍历,并且在遍历过程中完成一个统计任务。这种题在游戏研发笔试里出现的频率很高,因为游戏里的场景管理、寻路、UI树更新经常会遇到树的遍历,而实际工程中递归可能导致栈溢出,所以“手写非递归”是加分项。
非递归遍历的核心是用显式栈模拟系统调用栈。比如中序遍历,你需要先把左子树一路压栈,然后弹出访问,再处理右子树。代码其实不复杂:
vector<int> inorderTraversal(TreeNode* root) { vector<int> res; stack<TreeNode*> st; TreeNode* cur = root; while (cur || !st.empty()) { while (cur) { st.push(cur); cur = cur->left; } cur = st.top(); st.pop(); res.push_back(cur->val); cur = cur->right; } return res; }但注意,如果题目要求“只允许O(1)空间”,那就得用Morris遍历,这是另一个层次的问题。我当年复习的时候只准备了递归和栈模拟,没有深入Morris,幸好那道题没卡空间复杂度,只要求非递归,不然我就凉了。后来想想,这种“不加解释的约束条件”其实在暗示你对每种遍历的掌握程度。如果你能在答案里写一句“如果是O(1)空间,可以改用Morris遍历”,会是一个很亮眼的加分项。
除了二叉树,图相关的搜索题几乎必考。A卷里虽然没有出现大型图搜索编程题,但选择题里有一道关于BFS求最短路径的变体,在二维网格上从起点到终点,有障碍物,问最少步数。这道题看似是模板题,但选项里暗藏了两个坑:一是队列里到底该存坐标还是存“坐标+步数”,二是访问标记的时机。很多人习惯在出队的时候标记 visited,这在普通BFS里不会出问题,但在某些变体里会导致同一个节点被重复入队很多次,最坏情况下复杂度退化。正确做法是在入队时直接标记 visited。这是一个非常细节但很关键的点,游戏里的寻路如果处理不好,也会出现大量重复计算。所以建议后辈们在复习BFS时,把“入队时标记”当成默认习惯,而不是“出队时标记”。
2.3 动态规划:状态定义比转移方程更重要
动态规划那道题,我记得跟“背包”有点关系,但加了一个限制:每种物品的数量是有限的,不是无限背包,也不是01背包,而是多重背包。经典解法是二进制拆分优化,或者直接用三重循环,但数据范围决定你能否用三重循环。我当时的判断是,数据范围不大,三重循环能过,所以先写了朴素版本,再特意提了一句“如果数据范围扩大,可以用单调队列优化到O(n·V)”。这种“先拿稳分,再展示深度”的策略在校招笔试里非常实用。
不过我想说的重点不在多重背包本身,而是动态规划题的一个通用原则:状态定义比转移方程更重要。如果你把状态定义对了,转移方程基本是顺水推舟;如果状态定义错了,后面所有推导都是白费。比如这道题,如果你定义dp[i][j]为“前i个物品中选出总重量不超过j的最大价值”,那转移就是标准的背包;但如果你定义成“总重量恰好为j”,初始化、转移方向、最终答案都会变。
我当时做完之后复盘,发现自己最开始差点把“恰好”和“不超过”搞混,因为题目里有一句话描述得比较绕。这种情况在校招笔试里太常见了,命题人会在题目描述里埋一些歧义,考察你能不能从业务描述里提炼出准确的数学定义。我的建议是:读题时把限制条件用下划线标出来,尤其注意“最多”“恰好”“至少”这三类词,它们的DP初始化完全不同。
// 多重背包朴素版核心代码 vector<int> dp(V + 1, 0); for (int i = 0; i < n; i++) { for (int j = V; j >= 0; j--) { for (int k = 1; k <= cnt[i] && k * w[i] <= j; k++) { dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]); } } }3. C++与图形学部分:游戏研发特有的送分与送命
3.1 内存对齐、虚函数表与智能指针
选择题里C++占比不低,而且考法非常“工程化”,不是直接问你“虚函数是什么”,而是给出一个结构体,让你计算sizeof,或者给出一段多继承代码问对象内存布局。我印象最深的一道题是内存对齐:
struct A { char a; int b; char c; }; struct B { char a; char c; int b; };问sizeof(A)和sizeof(B)分别是多少。答案是12和8。原因在于默认对齐规则下,每个成员的对齐数是它自身大小和编译器默认对齐数中的较小值,int是4字节对齐,所以A里char后面要填充3字节,c后面再补充3字节凑到int的倍数;而B里两个char连着放,总共占2字节,后面再补2字节给int对齐,整体就是8字节。这道题考的不是你会不会背规则,而是你是否真的理解内存布局,因为游戏引擎里大量使用结构体打包顶点数据、Uniform Buffer,内存对齐没弄好,轻则浪费显存,重则出现奇怪的渲染Bug。
虚函数表那道题也很有代表性。题目大概是给了一个基类和一个派生类,各有一个虚函数和一个普通成员变量,问sizeof(基类)在32位和64位平台上分别是多少。这题考察的知识点是:类里有虚函数时,对象内存里会多一个指向虚函数表的指针,也就是vptr,所以32位下多4字节,64位下多8字节。但要注意,如果编译器开启了空基类优化,某些特殊情况会有细微差别。更进阶的问法是“派生类对象的虚函数表里包含了哪些条目”,这就涉及菱形继承、虚继承下的布局了。
还有一个必考点是智能指针。快手这道题不是问shared_ptr和unique_ptr的区别,而是给出一个场景:一个对象被多个系统持有,其中一个系统注册了回调,另一个系统在异步线程里访问它,问用哪种智能指针管理最安全。这个场景在实际游戏开发中特别常见,比如战斗逻辑里的技能对象、UI系统里的资源对象。正确方向通常是shared_ptr保证生命周期,再配合weak_ptr来打破循环引用,同时还要考虑线程安全,比如shared_ptr的控制块是线程安全的,但对象本身的读写不一定是。这道题没有标准完美答案,面试官想看到的是你能否分辨“所有权归属”和“访问安全性”是两件事,我在答案里把这两层分开写,得分应该不低。
3.2 坐标系变换:把“左手系”顺清楚
图形学部分的选择题和简答题是A卷的重头戏。有一道选择题问的是:在左手坐标系中,绕Y轴正方向顺时针旋转,旋转矩阵长什么样?这题如果只是背过右手系的旋转矩阵,非常容易选错,因为左右手系的旋转正方向定义是相反的。
先说结论:左手坐标系下,绕Y轴正方向看过去,顺时针旋转对应的是正角度旋转;而右手系里“正方向旋转”是逆时针。很多教材默认右手系,导致Unity里用左手系的时候很多人搞混。我当时在现场用了一个笨但很稳的办法:把坐标轴比划出来,左手拇指指向Y轴正方向,四指弯曲的方向就是旋转的正方向,然后手动乘一下基向量验证。
这种题在笔试里其实不是考你计算能力,而是考你有没有真正在引擎里处理过坐标系。你在Unity里定义一个旋转,Quaternion.Euler(0, 90, 0)代表的旋转方向和矩阵里的RotationY是同一个东西吗?如果没亲手做过坐标变换,很容易栽在符号上。
另外一道印象很深的题是法线变换。给了一个非均匀缩放的模型矩阵,问世界空间下的法线应该怎么变换。标准答案是不能直接用模型矩阵变换法线,而要使用模型矩阵的逆转置矩阵。原因是非均匀缩放会改变法线方向,只有用逆转置矩阵变换,才能保证法线仍然垂直于切平面。这道题我记得是选择题里错误率最高的一道之一,因为它需要你用数学推导来验证,而不是靠“我记得好像是……”这种模糊记忆。
大家可以把这个推导自己动手算一遍,过程很简单:切向量T经过模型矩阵M变换后是M·T,法线N变换后的向量如果仍然垂直于M·T,那么应该有(MN')·(MT) = 0,推出N'^T M^T M T = 0,如果M^T M = I(正交矩阵),那N'直接用M变换就行;但一般情况下M^T M不是单位阵,所以要让N' = (M^{-1})^T N。这个推导过程本身就是一道完美的笔试解答题,写清楚比单纯记结论有价值得多。
3.3 渲染管线:一次draw call的前半生
简答题里有一道让我到现在还记忆犹新的题:请描述一个物体从CPU提交到GPU屏幕上显示的完整流程,并说明其中哪些环节会影响性能。这道题分值不低,而且非常开放,考的是你对渲染管线的整体理解。
我当时的回答分成了几个阶段:首先是CPU侧的场景剔除,包括视锥体剔除和遮挡剔除,这是减少draw call的第一步;然后是把可见物体的顶点数据、索引数据、材质参数、变换矩阵等整理到缓冲区中,绑定顶点缓冲区和索引缓冲区;接下来是设置渲染状态,比如深度测试、混合模式、Shader、纹理等;提交draw call之后,GPU进入顶点着色器阶段,把模型空间坐标变换到裁剪空间;然后是光栅化,把图元变成像素碎片;之后是片段着色器,计算颜色、光照、阴影;最后经过深度测试、模板测试和混合,写入帧缓冲区,等待呈现到屏幕。
每到一个环节,我都会补充一句“这里可能出现的性能瓶颈是什么”。比如CPU侧频繁切换渲染状态导致draw call无法合批;顶点数据量过大导致顶点着色器压力上升;过度绘制导致片段着色器执行次数爆炸;纹理采样带宽受限等等。这种“流程+瓶颈”的回答方式,既展示了你对整个管线的理解,又表明你有性能意识,而性能意识恰恰是游戏研发笔试里最想看到的东西。
现在回头看,这道题其实是一个典型的“知识框架型”问题,它不会只考你某一行代码,而是考察你脑子里有没有一张完整的渲染地图。如果你只会用引擎API,但不知道背后的管线流程,这道题很难拿高分。
4. 网络同步与架构题:这部分决定了你的上限
4.1 状态同步与帧同步:一道题背后的架构理念
A卷的简答题第二道,几乎是游戏研发岗位的保留题目:请对比状态同步和帧同步的优缺点,并说明在什么场景下你会选择哪一种。这道题如果只看表面,就是两个概念的对比,但想拿高分,你需要把它上升到“网络模型与游戏逻辑耦合度”的层面。
先梳理一下基础定义。状态同步的核心是服务器维护权威状态,客户端发送操作请求,服务器计算完结果后把新的状态广播给所有客户端。这种方案安全性高、防作弊能力强,逻辑也好扩展,但缺点是流量大、响应有延迟感,而且服务器压力集中。帧同步的核心是所有客户端同步执行同一份输入指令,各自计算同样的逻辑,服务器只负责收集和广播输入。这种方案流量小、逻辑表现一致,适合格斗游戏、RTS这种对帧率一致性要求高的类型,但缺点是对确定性要求极高,任何浮点运算差异都可能导致不同步。
我当时在答案里除了列出对比,还写了一个实际案例:MOBA类游戏通常更偏向状态同步,因为需要服务器做伤害判定和防作弊,但对技能释放的手感要求又很高,所以客户端会做表现层插值和预测;而像《王者荣耀》这种大规模同屏的玩法,也考虑过帧同步的方案,因为它能在相同带宽下支持更多人同时在线,但需要处理断线重连、加速外挂这些问题。这个案例加分的原因在于,它说明我不是在背概念,而是真的知道这两种方案在商业项目里是怎么取舍的。
如果让我再说一个笔试里的隐藏考点,那就是“确定性”这个词。帧同步方案里,不止要保证所有客户端输入顺序一致,还要保证浮点运算的一致,所以很多引擎会规定不能用不同平台的数学库,或者统一用定点数。这道题里我特意强调了一句“逻辑层与表现层分离是实现帧同步的前提”,因为只有把战斗逻辑里的随机数、时间、物理都做成可复现的,多个客户端才能跑到同一个状态。
4.2 延迟补偿与快照插值的计算题
除了简答题,选择题里还有一道关于网络同步的具体计算题,当时让我犹豫了好一会儿。题目大致是:客户端每隔100ms收到一个服务器的快照,某个物体在快照A的位置是(0, 0),在快照B的位置是(10, 0),当前渲染时间戳位于两个快照之间,且已经过去了60ms,请问对该物体进行线性插值后的位置是多少。
这题的数学非常简单,插值位置 = 0 + (10 - 0) * (60 / 100) = 6。但选项里故意给了一些干扰项,比如“10”“4”“0”,对应的是插值方向搞反、时间比例算错、直接使用上一个快照位置这些常见错误。考完后我复盘时想明白了一个点:这道题看似在考“lerp公式”,其实在考你是否理解“渲染帧率”和“网络快照频率”是不一致的。如果快照频率是10Hz,而渲染帧率是60FPS,那么渲染层必须在两个快照之间通过插值生成平滑的中间帧,否则画面就会出现肉眼可见的卡顿和跳变。
再往深一层说,如果题目加上“客户端也发送操作给服务器”的条件,那还可以升级成客户端预测+服务器回滚的经典方案。客户端在发送操作后不等服务器确认,直接开始移动,服务器发现位置不对时,用权威快照纠正客户端。这里就引入了“延迟补偿”的概念,服务器在判定命中时,会把玩家拉回到某个历史时间点去判断,而不是用当前状态。这其实是一整套面试官非常喜欢继续追问的内容,笔试里用一道小计算题做引子,面试时就会不断加码。
5. 考后复盘:如果重来一次我会怎么复习
5.1 时间分配上的教训
说实话,如果把整张A卷的难度做一条曲线,它不是从易到难,而是“前半段选择题平稳,中段编程题波动,最后简答题突然拔高”。我当时在选择题上花了将近45分钟,因为很多多选题都拿不准,反复权衡。结果到了最后简答题,时间只剩不到30分钟,导致状态同步那道题虽然会写,但写得很仓促,没有充分展开。现在回想起来,这是一个非常典型的失误。
理想的时间分配应该是:选择题控制在25到30分钟,不会的先标记跳过;编程题每道20到25分钟,满分是60分钟左右;简答题预留至少40分钟,因为这类题需要组织语言、画对比表、写关键代码片段。如果选择题遇到读了30秒还没思路的,直接凭第一感觉选一个并标记,后面如果有多余时间再来纠结。考试不追求每道题都对,而是追求总分最大化,把时间花在有把握拿分的题目上永远是第一原则。
5.2 对后来者的具体建议
结合这张A卷,我给准备游戏研发校招的同学几条具体建议:
第一,LeetCode还是要刷,但不用只刷困难题,中频题才是主力。重点覆盖字符串、双指针、二叉树遍历、BFS/DFS、DFS回溯、简单DP、图的最短路。游戏研发笔试的算法题通常不会太偏门,但会在边界条件上设坑,所以平时做题强迫自己把所有边界想清楚,比追求题数更重要。
第二,C++不能停留在语法层面,要往内存模型和对象模型上补。虚函数表、内存对齐、智能指针、move语义、const correctness,这些是选择题高产区。推荐看《深度探索C++对象模型》和《Effective Modern C++》里智能指针那几章,不用全看,针对笔试考点看就行。
第三,图形学这块,至少要理解MVP矩阵的推导、坐标系的区别、渲染管线的完整流程、光照模型的基本原理。不要求手写一个光栅化器,但要有能力在白板上画出流程并标明瓶颈。如果对渲染管线的认识还停留在“调用DrawPrimitive”,那笔试的时候会非常被动。
第四,网络同步是很多人的盲区,因为它平时写业务代码很难接触到。建议自己搭一个简单的帧同步Demo,哪怕只是在局域网里跑两个客户端,用相同的随机种子和输入序列模拟同步逻辑,这个过程能帮你真正理解确定性和状态复现。然后再看一些商业游戏的同步方案分享,网上公开的技术博客很多,做笔记的时候把“场景—方案—取舍”三个维度连起来记。
第五,笔试前最好做一两次完整的模拟。找一份往年真题或者自己给自己出一份混合卷,严格按考试时间走一遍。模拟的时候用和实际考试一样的节奏,不许中途刷手机,这样能让你提前适应时间压力,也能暴露自己哪类题最容易超时。我后来给学弟学妹做模拟时发现,大多数人超时都超在选择判断上,原因就是“每个选项都想搞清楚”,但考试不需要你当学术研究,只需要你用最少时间拿最多分。
还有一个小技巧,是我考完才顿悟的:简答题里如果有“对比”类问题,不管题目有没有要求,都可以画一张表。状态同步和帧同步、AABB和OBB、RTTI和反射,这些对比类知识用表格呈现,阅卷人一眼就能看到你的思路结构,比整页文字更容易拿分。别用太花哨的排版,就是Markdown表格那种对齐方式,清晰最重要。
现在再看2019年这套快手游戏研发A卷,其实它的风格和后来的很多游戏公司笔试题是一脉相承的:不追求偏题怪题,而是用常规考点组合出贴近真实工作的场景。它真正筛掉的,是那些只在搜索引擎里见过“游戏研发”这个词,却没有真正尝试去理解引擎、渲染、同步这些底层知识的候选人。如果你现在也在准备这一方向的校招,我建议你放下“背题”的念头,多去问自己一个为什么:为什么状态同步需要服务器权威,为什么法线变换要用逆转置矩阵,为什么一次draw call会有性能开销。把这些为什么都弄明白了,你再去打开任何一份游戏研发笔试题,心态都会完全不一样。