2019年秋招季,小米的软件开发笔试题A卷,是不少计算机专业应届生投递简历后的第一道坎。当时各大求职讨论区里关于这套题的帖子能翻好几页,有人吐槽选择题考得太细,有人说编程题看起来不难但一提交就超时,还有人在求多选答案。现在回头看,这套题其实非常典型:它考察的不是某一套花哨的技术栈,而是大学四年计算机核心专业课的实际掌握程度,以及拿到一个问题之后能不能快速写出正确且高效的代码。
这套试卷适合谁来参考?如果你是大二大三的学生,可以通过它提前感受大厂笔试的难度和范围;如果你是正在准备校招的应届生,它是一份值得反复刷的基础题集;就算你已经做了几年开发,回头再做一遍也会发现,很多平时"凭感觉"写出来的代码,底层原理早就在这套卷子里出现过。笔试和面试最大的不同在于,面试可以聊项目、讲故事,笔试只能拼基础和算法的熟练度,会就会,不会就不会,这是大厂在简历筛选之后做的第二道机器化过滤。
1. 试卷整体画像:三个板块外加一份考点地图
1.1 卷面结构:选择题为主,编程题压轴
A卷的整体结构和大厂校招笔试的主流模式一致:客观题(单选加多选)占大头,最后安排一到两道编程题。时间一般控制在90分钟左右,有些考场会放宽到120分钟,但大多数人感受到的依然是"题量大、时间紧"。当年考完后很多人的第一反应不是"题太难",而是"再给我十分钟我还能多做对两道选择"。
客观题覆盖C/C++或Java语言、数据结构、操作系统、计算机网络、数据库,偶尔还会出现一两道Linux命令或设计模式相关的题。编程题则集中在字符串处理、链表操作和动态规划这几类常见的出题范畴。分值上,编程题往往占30%到40%,是决定能否进入面试环节的关键。
为什么笔试要这样设计?核心原因是海量简历需要高效筛选。选择题可以自动判分,投递系统几秒钟就能给出一份成绩排序;编程题需要在线评测系统跑测试用例,能有效过滤“简历写得漂亮但代码能力欠佳”的候选人。所以这套卷子筛选的其实是两个维度的能力:知识储备的广度,以及代码实现的基础功。
1.2 考点地图:把大学四年专业课浓缩成一张表
我根据自己刷过的多套同类笔试题,把这类试卷的考点整理成一张表。做套题之前,建议先扫一遍这张表,心里有个谱:
| 知识模块 | 题量参考(按40题计) | 常见出题形式 | 核心考点示例 |
|---|---|---|---|
| C/C++/Java语言 | 8-10 | 阅读代码写出输出、概念辨析 | 虚函数机制、引用与指针、集合线程安全 |
| 数据结构与算法 | 8-10 | 复杂度计算、二叉树遍历、排序原理 | 时间空间复杂度、哈希冲突、链表操作 |
| 操作系统 | 4-6 | 进程线程、死锁、内存管理 | 虚拟内存、死锁四条件、进程调度 |
| 计算机网络 | 4-6 | TCP/UDP、HTTP、网络设备 | 三次握手、状态码、DNS查询流程 |
| 数据库 | 2-4 | 索引、事务、SQL语句 | B+树、ACID、聚簇索引与非聚簇索引 |
| Linux与设计模式 | 2-3 | 命令含义、模式识别 | grep参数、单例与工厂模式 |
这张表不是猜题,而是从历年大厂校招笔试的公开面经里统计出来的高频分布。用一个不严谨但直观的说法:如果把这张表的每个知识点吃透,一套笔试卷子至少能拿到六成以上的分数。剩下的四成,靠的是刷题量和临场状态。
为什么这套卷子值得反复做?因为它的考点足够“正”。它没有偏题怪题,考察的都是工程师日常工作中真正会产生影响的计算机基础。这种命题思路,代表了大厂对校招生的期望:你可以没有丰富的项目经验,但底层知识必须扎实,因为后面所有的业务开发、系统设计、线上问题排查,都建立在这些基础之上。
2. 选择题高频考点:每个知识点背后都有真实工作场景
2.1 语言基础:从语法规则考到内存级机制
语言题是选择题里最让人头疼的部分。我印象很深的一类题是给出一段C++代码,让你判断虚函数调用的输出。这里涉及的不只是知道“虚函数存在”,而是要理解它到底怎么实现。编译器会把含有虚函数的类变成一个虚函数表,表中存的是该类的虚函数地址,每个对象内存布局的最前面会有一个虚指针指向这张表。当通过基类指针调用虚函数时,程序会顺着虚指针找到虚表,再定位到实际函数地址,从而实现运行时多态。
这道题的常见坑是:构造函数里调用虚函数,不会发生动态绑定。原因是对象构造期间,虚表指针还处于初始化阶段,编译器会把它当作当前类的调用处理。很多人在这里答错,说明平时只看语法书、没有动手看汇编或调试过内存布局。同理,析构函数为什么建议声明为虚函数?因为如果基类析构函数不是虚的,通过基类指针delete派生类对象时,只会执行基类的析构逻辑,派生类里申请的资源就会泄漏。这是一个极其真实的工程问题,不是笔试造出来的概念题。
Java方向的题也有类似的套路。Integer在-128到127之间会走缓存池,所以用==比较两个Integer时,在这个范围内可能返回true,超过范围反而要equals,稍微绕一下就容易掉坑。HashMap不是线程安全的,多线程写会丢数据甚至造成CPU飙升;HashTable虽然安全但是全局锁,性能差;ConcurrentHashMap在JDK8之后取消了分段锁,改用CAS加synchronized锁桶的方式。这些知识点没有一项是“背下来就能加分”的,全都对应着真实场景,比如缓存服务为什么内存占用异常、高并发下HashMap为什么会把机器拖垮。
2.2 数据结构与算法:手感和数学敏感度都要有
选择题里的数据结构题,最常考的是时间复杂度和经典结构特性。比如二分查找为什么是O(log n):因为每一轮都把搜索区间缩小一半;归并排序为什么稳定的同时还要O(n)的额外空间,因为它合并时需要一个辅助数组。这种题不需要死记结论,关键看能不能画出递归树或者写出递推公式。
二叉树遍历是另一个高频区。前序、中序、后序的递归写法大多数人都能默写,但考场上经常出的是“已知前序和中序,求后序”或者“判断某序列是不是合法的二叉搜索树前序遍历”。这类题其实在考察对遍历过程的本质理解:前序第一个节点是根,中序里根把左右子树切开,递归套用就能还原整棵树。如果只是背了“递归三步走”,遇到变形题就容易卡壳。
哈希冲突解决方式也需要分清。链地址法是每个桶后面挂一个链表,同样的散列结果排在同一个桶的链表里;开放定址法是在冲突位置往后探测空位。理解这两者的区别在实际中也有用,比如Redis的字典就用了链地址法,Java的HashMap在链表过长时会转成红黑树来保证查询效率。考到这类题时,如果能把原理和工程实现联系起来,答案会非常清晰,而不是靠猜。
2.3 操作系统与网络:排查线上问题离不开的语言
操作系统题里,进程和线程的区分几乎年年出现。进程是资源分配的最小单位,每个进程有独立的地址空间;线程是CPU调度的最小单位,同一进程内的线程共享地址空间和资源。因为线程共享内存,所以线程间的通信成本比进程间低很多,但也正因为共享,才需要加锁,才容易死锁。
死锁的四个必要条件——互斥、持有并等待、不可剥夺、循环等待——在笔试里很常见。对应的破解方向也固定:让资源可共享来破坏互斥、一次性申请所有资源来破坏持有并等待、允许抢占来破坏不可剥夺、按固定顺序申请资源来破坏循环等待。这套理论在分布式系统里已经被扩展成“分布式锁顺序问题”。我印象很深的一次线上故障,两个服务互相等待对方的锁,日志里全是超时告警,当时第一个想到的就是循环等待,顺着这个思路去梳理调用链,很快就定位到了问题节点。
计算机网络题同样是送分题和送命题并存。TCP三次握手的过程要理解到每一个标志位的含义:第一次握手客户端发送SYN,表明请求建立连接并携带初始序列号;第二次握手服务端发送SYN+ACK,表示收到客户端序列号,同时确认自己的序列号;第三次握手客户端发送ACK,告知服务端连接建立。为什么不是两次?因为如果只有两次握手,服务端无法确认客户端是否收到了自己的SYN+ACK,万一客户端因为网络问题没收到,服务端会一直维护一个半连接资源,浪费系统资源。这个场景放到现在看,恰好对应着SYN Flood攻击的防护逻辑。
HTTP状态码也是高频考点。301是永久重定向,302是临时重定向,304表示资源未修改可继续使用缓存,404是请求资源不存在,500是服务器内部错误,503是服务暂时不可用。这些状态码在实际开发里天天见,排查接口报错时,先看状态码基本就能判断是客户端问题还是服务端问题。
2.4 数据库与Linux:后端工程师的日常积累
数据库部分的重点在索引和事务。B+树索引的结构要理解到位:非叶子节点只存放键值和指针,叶子节点存放真实数据,而且叶子节点之间通过链表相连,非常适合范围查询和顺序访问。聚簇索引的叶子节点直接存储整行数据,一张表只能有一个聚簇索引;非聚簇索引的叶子节点存储的是主键值,查询列不在索引里时需要回表。理解了回表,就能理解为什么建立联合索引时“最左前缀”原则那么重要。
事务ACID四个性质里,一致性是最终目标,原子性、隔离性、持久性都是为实现一致性服务的。隔离性又引出四种隔离级别:读未提交、读已提交、可重复读、串行化。MySQL默认是可重复读,并通过MVCC和间隙锁解决幻读问题。笔试里常出“某个隔离级别下会出现什么问题”这类题,本质上是在考对隔离级别演变脉络的理解。
Linux相关的题占比不高,但很实际。比如grep -r做递归搜索,ps -ef看进程,top看系统负载,netstat查端口占用。有人觉得这些是运维的活,但作为软件开发,排查线上环境时,第一件事就是登录服务器看进程和日志,不会这些命令会非常被动。
3. 编程题实战复盘:三道典型题从读题到完整代码
编程题是笔试的重头戏。A卷的编程题在题型上偏好基础题,但会刻意增加边界条件和数据规模的问题。我按考后圈子里复现讨论的常见版本,整理成下面三道经典题型,题型、难度和考点基本对齐。
3.1 滑动窗口解决最长无重复子串
题目描述:给定一个字符串,找出其中不含有重复字符的最长子串的长度。
暴力解法是枚举所有子串,再用一个哈希表判断是否有重复字符,整体时间复杂度O(n^2)。当字符串长度达到十万级别时,这个复杂度直接超时。正确解法是滑动窗口加哈希表。
核心思路是维护一个窗口,窗口左边界left,右边界i,遍历字符串时把当前字符作为右边界。用哈希表记录每个字符最近一次出现的位置。当遇到重复字符,并且该字符上次出现的位置在left右侧时,把left跳到上次出现位置的下一位。每一步都更新窗口长度最大值。
#include <string> #include <unordered_map> #include <algorithm> using namespace std; int lengthOfLongestSubstring(string s) { unordered_map<char, int> lastPos; int left = 0, ans = 0; for (int i = 0; i < s.size(); i++) { char c = s[i]; if (lastPos.count(c) && lastPos[c] >= left) { left = lastPos[c] + 1; } lastPos[c] = i; ans = max(ans, i - left + 1); } return ans; }需要注意两个细节。第一,判断条件里必须带lastPos[c] >= left,否则有可能把窗口之外的旧位置也纳入比较,导致left错误回退。第二,遇到重复字符时,left更新为上次出现位置加一,不需要做额外再检查,因为当前i作为右边界,窗口内如果还有重复,会在后续遍历中继续更新。这段代码的时间复杂度是O(n),空间复杂度O(min(字符集大小, n))。
笔试时这种题一定要当场自测几个边界用例:空串返回0,单字符返回1,全重复字符串比如“aaaa”返回1,前面重复后面不重复的字符串比如“abba”返回2。这些用例能帮你发现很多隐性问题,比如全重复字符串里left会一路往后跳,但ans不会变,逻辑依然正确。
3.2 反转链表:两种写法都要能在五分钟内默写
题目描述:反转一个单链表。
反转链表是链表题里最基础的题,但也是现场出错率最高的一道。迭代法的核心是三个指针。pre指向已经反转好的链表的头节点,curr指向当前要反转的节点,next保存curr的下一个节点,防止断链。每次循环把curr的next指向pre,然后pre、curr、next都向后移动一位。
struct ListNode { int val; ListNode *next; ListNode(int x) : val(x), next(nullptr) {} }; ListNode* reverseList(ListNode* head) { ListNode* pre = nullptr; ListNode* curr = head; while (curr != nullptr) { ListNode* next = curr->next; curr->next = pre; pre = curr; curr = next; } return pre; }递归写法理解起来稍微难一些,但代码更短。递归的终止条件是head为空或head->next为空。每层递归先反转head之后的部分,拿到新的头节点,然后把head自己放到链表尾部。
ListNode* reverseListRecursive(ListNode* head) { if (head == nullptr || head->next == nullptr) { return head; } ListNode* newHead = reverseListRecursive(head->next); head->next->next = head; head->next = nullptr; return newHead; }递归写法的关键在于“相信函数定义”:reverseListRecursive(head->next)返回的是以head->next为头节点的子链表反转后的新头部。拿到这个新头后,只需要把当前head接在后面。最后返回的是newHead,而不是head。很多人递归写错,就是把head->next->next = head这一步想反了。
笔试时如果时间紧张,写迭代法更稳妥,递归法容易因为对栈理解不够而出现思路混乱。建议两种都练到能默写的程度,因为面试时面试官很可能会追一句“用递归再写一版”。
3.3 动态规划解决最长递增子序列
题目描述:给定一个无序的整数数组,找到其中最长递增子序列的长度。
这是一道非常经典的动态规划题。先讲朴素DP。定义dp[i]表示以nums[i]结尾的最长递增子序列长度,初始值为1。对于每个i,遍历之前所有j,如果nums[j] < nums[i],说明可以接在nums[j]后面,dp[i]更新为max(dp[i], dp[j] + 1)。遍历结束后答案取dp数组的最大值。
#include <vector> using namespace std; int lengthOfLIS(vector<int>& nums) { int n = nums.size(); if (n == 0) return 0; vector<int> dp(n, 1); int ans = 1; for (int i = 1; i < n; i++) { for (int j = 0; j < i; j++) { if (nums[j] < nums[i]) { dp[i] = max(dp[i], dp[j] + 1); } } ans = max(ans, dp[i]); } return ans; }朴素DP的时间复杂度是O(n^2),当数组长度到一万以上就会比较吃力。若题目要求更优解,可以用贪心加二分,维护一个tails数组,tails[k]表示长度为k+1的递增子序列的末尾元素的最小值。遍历每个数字x,在tails里查找第一个大于等于x的位置并替换;如果不存在,说明x比所有末尾元素都大,可以扩展子序列长度。
#include <vector> #include <algorithm> using namespace std; int lengthOfLIS(vector<int>& nums) { vector<int> tails; for (int x : nums) { auto it = lower_bound(tails.begin(), tails.end(), x); if (it == tails.end()) { tails.push_back(x); } else { *it = x; } } return tails.size(); }这个做法的时间复杂度降到O(n log n),但理解难度也上来了。我建议初学者先把O(n^2)的DP练熟,因为动态规划的思路是通用的,很多变种题都是在这个基础上加条件。O(n log n)的做法能理解证明过程最好,实在有困难也可以先记住模板,大多数笔试的数据规模用O(n^2)也能通过。
4. 答题节奏与应试策略:90分钟到底该怎么分配
4.1 选择题:30到40分钟必须收住
笔试最怕的不是不会,而是会做的题没时间做。选择题如果做得太慢,后面的编程题就只能草草提交。我给自己定的节奏是:单选控制在30秒到1分钟一题,多选1到2分钟一题,35道左右的选择题加起来不超过40分钟。遇到读两遍还没思路的题,立刻跳过,先标记一下,写完编程题再回来看。
这里有个实际经验:很多人觉得多选难拿分,因为漏选和错选都不得分,所以一纠结就耗过去好几分钟。我的建议是,对于没有把握的多选,选一个最有把握的选项就好。虽然不能保证拿满分,但至少能保住一部分分数。从整个卷面的收益来看,把犹豫的时间花在编程题上更划算,因为编程题一题的分值能顶好几道选择。
4.2 编程题:先保第一题稳定提交
编程题通常有两道,难度略有差异。我的策略是先把最有把握的题完整写出来,确保通过尽可能多的测试用例,再回头攻难题。不要死磕一道题超过20分钟,尤其是当第二道题明显需要更复杂的数据结构时。
在线评测系统有一个特点:编译错误和运行时错误都不给分。写完代码后一定要自己过一遍逻辑,确认没有数组越界、没有递归出口写错、没有把变量名搞混。很多时候因为一个分号或者一个大小写问题,整道题直接零分,这是最亏的。我在笔试时养成一个习惯,写完核心逻辑后,先在草稿纸上推两个简单用例,手工模拟一下过程,再点提交,能避免大量低级错误。
4.3 拿到题目先想清楚再动手
编程题常见的低级错误是用错数据类型导致溢出。比如题目给的数据范围是10^9,用int就会在中间计算时爆掉,结果全错。建议看到题目先看一眼数据范围的提示,凡是涉及大数的地方,直接用long long。还有一个很常见的问题是读题不仔细,题目要求升序,代码里写的是降序,这种错误没有任何技巧可以弥补,只能靠多花30秒把题读清楚。
动态规划题如果状态定义对了,递推公式就水到渠成;如果状态设计错了,写出来的代码再长也没有意义。所以在动笔之前,先把状态定义、初始化、转移方程、答案位置这四个要素写在草稿纸上。我每次笔试都会提醒自己:宁可多花三分钟想清楚,也不要边写边改,后者才是真正的浪费时间。
5. 复盘之后的长期价值:从应付笔试到理解工程师的底层能力
5.1 当年的高频考点,今天依然是面试必问
如今再看这套A卷,会发现一个很有意思的现象:当年选择题里考的那些点,在后续的面试中被反复深挖。虚函数机制变成了“讲讲多态在内存里是怎么实现的”,TCP三次握手变成了“出现大量TIME_WAIT连接是为什么、该怎么处理”,B+树索引变成了“联合索引为什么最左匹配,使用索引时怎么避免回表”。
这说明笔试并不是孤立的关卡,它是整个校招流程的知识底座。面试官默认你笔试时已经掌握了这些基础概念,所以面试时不再问“是什么”,而是直接问“为什么”“怎么办”。如果只是背了答案通过了笔试,后面面试一定会露馅。反过来,如果认真研究了这些考点背后的原理,面试中不管话题怎么延伸都能接得住。
5.2 基础知识的掌握程度,决定了工作能走多深
做过几年开发之后再回头看这些题,我对“基础知识”这个词有了完全不同的理解。写业务代码时,确实不需要每天手写红黑树,但当你需要排查一个线上接口为什么时不时抖动时,网络、操作系统、数据库的知识会一起发挥作用:先看TCP连接是否异常,再看内存使用情况,然后查SQL有没有走到索引。任何一个环节的缺失,都可能导致问题定位到一半就卡住。
这也是为什么我建议还在学校的读者不要只刷题,每做完一套卷子,把错题对应的教材章节拿出来读一遍。笔试题的价值不在于“考完就忘了”,而在于它像一面镜子,照出你知识体系里真正薄弱的地方。把每个考点想明白,后面遇到的很多真实问题都会变得有迹可循。
5.3 给准备校招的朋友几条实在建议
第一,至少刷200道分类题,字符串、链表、二叉树、动态规划、回溯这些高频类型都要覆盖到。第二,操作系统、网络、数据库这三门课,不要只看资料,尽量结合真实场景去理解,比如你现在用的这台电脑上,打开一个网页到底发生了什么,把这条链路里的每一步都用学过的知识解释出来。第三,不要只刷题不总结,建议做一个错题本,把每次笔试面试中暴露的知识盲区记录下来,隔一段时间集中回顾。
我自己的做法是,把每套刷过的卷子按考点拆成一个个小卡片,正面写一个问题,背面写原理和典型场景。考前翻卡片比重新刷题效率高得多,因为那是针对自己薄弱点的定向复习,而不是把时间浪费在已经会的内容上。
最后分享一个我踩过几次坑之后的小习惯:笔试前把常用数据结构和算法的模板代码提前准备好,不需要背整段,但至少知道到考场上翻哪个模板。往年我总在现场写HashMap遍历时忘了怎么取键值对,或者把二分查找的边界写错,后来我在本地维护了一个模板文件,考试前花十分钟过一遍,效果非常明显。这套A卷可能只是你求职路上众多试卷中的一套,但如果你能从中真正提取出自己的薄弱点,它的价值会远超一场笔试的分数。