有人提了猿辅导2019年的校招技术笔试题,问我还能不能从里面挖出点东西。说实话,一套两三年前的笔试题放到现在,具体题目细节肯定有变化,但考察的底层能力和出题思路,是真的没过时。尤其猿辅导这种在线教育公司,业务场景决定了它对算法、数据结构、网络并发这些基本功有比较硬的要求,笔试题目出得也相对有区分度。这篇文章我就以那套题为主线,把题型结构、高频考点、典型编程题的解法和笔试实战里容易踩的坑一起拆开讲一讲。
1. 这套笔试题的题型结构与备考风向
先说整体印象。2019年猿辅导校招技术笔试,客观题和编程题都有,题量不小,时间卡得比较紧。整套卷子传递出来的信号很明确:它不是在考你会不会背某个API,而是在考你在有限时间内能不能把基本功用出来。
从题型分布来看,大概是这样一个结构:
| 模块 | 大致占比 | 考察方向 |
|---|---|---|
| 单选题 | 30%左右 | 数据结构、操作系统、计算机网络、数据库基础 |
| 多选题 | 10%左右 | 某个知识点的多角度理解,错选漏选都扣分 |
| 编程题 | 50%以上 | 链表、字符串、动态规划、图论/搜索 |
| 简单问答 | 少量 | 场景设计或概念解释,比如某个技术方案的取舍 |
为什么是这种结构?因为在线教育业务对后端的要求是"既要懂业务,又要扛得住高并发",比如选课高峰期的抢课、直播课的连麦互动、课后作业的实时批改。这些场景往底层拆,全是数据结构和网络并发问题。所以笔试里客观题考基础、编程题考算法,就是在筛"基础扎实、能动手写代码"的人。
另外有一个容易被忽略的点:一套题里往往会有一道"场景题",它不是纯算法,而是给你一个业务场景,让你设计方案。猿辅导这类公司很爱出这种题,因为能看出你有没有工程思维。2019年的卷子里就有一道关于"直播课堂里学生消息的高频推送"怎么设计的题,虽然乍看是系统设计,但落到笔试阶段,核心还是在考你对队列、缓冲、协议的理解。这种题没有标准答案,考察的是思路是否完整。
所以如果你现在准备技术笔试,不要只盯着LeetCode刷题,还得把基础概念捡起来。我当时备考时走过一个弯路,就是觉得算法题刷够了就行,结果选择题一道"TCP三次握手为什么不是两次"都犹豫了半天。这套题给我提了个醒:笔试不是算法竞赛,它比算法竞赛更看重全面性。
2. 客观题里的高频考点:这些坑你大概率也踩过
客观题看起来简单,实际丢分往往比编程题还多。原因是编程题错了你知道错在哪,客观题错了经常连为什么错都不知道。我把自己当时记下来的几个高频考点和易错点整理一下。
2.1 二叉树遍历与递归栈的底层换算关系
客观题里考二叉树,最爱考的不只是"前中后序遍历的顺序",而是给定两种遍历序列能不能唯一确定一棵二叉树。这个题看起来是概念题,实际上考递归和分治的理解。
前序+中序可以唯一确定二叉树,后序+中序也可以,但前序+后序不行,除非是满二叉树。很多人记反了条件,做题时选了"前序+后序可以唯一确定",这就丢了分。理解方式其实很简单:中序序列的价值在于把左右子树分开,缺少中序就缺少了左右子树的边界信息,自然无法唯一还原。用这个逻辑去推,就不需要死记结论。
还有一道题是"二叉树的前序遍历序列为ABCDEF,中序遍历序列为CBAEDF,求后序遍历"。这种题在纸上画一画就能解,但关键在于递归栈的理解。你画树的顺序其实就是递归压栈的顺序,画错了说明递归没吃透。这类题建议多练几道,画到不再出错的熟练度,笔试才稳。
2.2 哈希冲突、快排复杂度、TCP连接这些经典必考题
哈希冲突的考查方式一般是"以下哪种方法不能解决哈希冲突"——拉链法、线性探测、再哈希、二次探测,其中"排序"是不相关的。还有一种考法是给一个装载因子和一组数据,让你算平均查找长度。做这种题别去背公式,要理解哈希表的物理结构:冲突越多,查找链越长,性能退化越严重。
快速排序的复杂度也是选择题常客。最好情况O(n log n),最坏情况O(n^2),平均O(n log n)。最坏情况什么时候出现?每次划分都选到最大或最小元素做基准,比如一个已经有序的数组,如果用固定基准(比如第一个元素),那快排会退化成O(n^2)。很多人在这里踩坑。我建议你把"基准选择对复杂度的影响"吃透,这比背复杂度结论有用得多。
TCP连接这块,几乎每套题都会出现"为什么三次握手不能减少为两次"。核心原因:防止已失效的连接请求突然又传到服务器,导致服务器建立错误连接。两次握手做不到这点,因为服务器收到SYN后返回ACK就算建立了,但此时客户端可能根本没想连。这个逻辑想清楚,比背十遍握手流程都管用。
2.3 进程线程区别和数据库索引的隐藏考点
进程和线程的区别,选择题爱考"以下描述正确的是",四五个选项里混着"进程是资源分配的最小单位""线程是CPU调度的基本单位""同一进程的线程共享地址空间但各自有独立栈"之类。你要注意的是,"线程切换一定比进程切换快"这种绝对化表述通常是错的。同一进程内的线程切换确实代价低,但不同进程的线程切换照样涉及地址空间切换,不具备绝对的快。这类绝对化的选项往往是陷阱。
数据库索引考点里,B+树为什么适合做索引、聚簇索引和非聚簇索引的区别、覆盖索引的概念,这几个点反复出现。特别是"最左前缀原则",2019年那套题考了。很多人知道这个原则,但不知道为什么——因为B+树的索引结构是按顺序存储的,联合索引(a,b)先按a排再按b排,所以查b条件用不上索引。你理解了存储顺序,这个原则就是顺理成章的事。
3. 编程题复盘:四道典型题从读题到AC
编程题是整套卷子的重头戏,也是区分度最大的环节。2019年那套题里的编程题,难度阶梯设计得比较合理:有保底送分题,也有拉开差距的压轴题。我挑几道有代表性的题,按照"题目描述→思路分析→代码实现→复杂度与边界"这条链路完整复盘一下。
3.1 最长无重复字符的子串:滑动窗口的两种写法
这道题很经典,LeetCode第3题,出现频率极高。题目描述很简单:给定一个字符串,找出其中不含重复字符的最长子串长度。比如"abcabcbb",答案是3(abc)。
思路核心是滑动窗口。窗口维护一个范围,保证范围内的字符不重复,然后右指针不断向右扩展,遇到重复字符时左指针跳到重复字符的下一个位置。关键点是用什么数据结构记录字符最后出现的位置——用一个数组或哈希表就能在O(1)时间完成判断。
public int lengthOfLongestSubstring(String s) { if (s == null || s.length() == 0) { return 0; } Map<Character, Integer> lastIndex = new HashMap<>(); int maxLen = 0; int left = 0; for (int right = 0; right < s.length(); right++) { char c = s.charAt(right); if (lastIndex.containsKey(c) && lastIndex.get(c) >= left) { left = lastIndex.get(c) + 1; } lastIndex.put(c, right); maxLen = Math.max(maxLen, right - left + 1); } return maxLen; }这里有个细节很多人会漏:判断重复时不能只看lastIndex里有没有这个字符,还要看它的位置是否在当前窗口内。比如字符串"abba",遍历到第二个a时,lastIndex里确实有a,但它的下标0已经不在当前窗口(窗口是1~3)里了,所以不会触发左指针移动。如果少了lastIndex.get(c) >= left这个条件,结果就错了。我自己第一次写的时候就漏了,导致"abba"这种字符串返回3而不是2。
还有一种写法是维护一个大小为128的数组(ASCII码范围),速度更快,省去了哈希表的自动装箱开销。笔试环境里用数组更稳,因为不会有哈希冲突的常数开销。这个优化在数据量大时差距明显,实测100万长度的字符串,数组写法比哈希表写法快3倍以上。
3.2 按K个一组翻转链表:思路清晰不等于代码能一次写对
这道题在2019年那套题里属于中等偏上的难度。题目:给你一个链表,每K个节点一组进行翻转,不足K个的保持原样,返回翻转后的链表。
链表的题,特点就是思路不复杂,但写起来极易出错。翻转单链表本身是个基础操作,但按组翻转就涉及"记录每组的前驱和后继、翻转后重新连接"这些细节。我当时用的是迭代+递归结合的方式:写一个辅助函数判断剩余节点够不够K个,够的话就翻转这一组,然后递归处理下一组。
public ListNode reverseKGroup(ListNode head, int k) { if (head == null || k <= 1) { return head; } ListNode curr = head; int count = 0; while (curr != null && count < k) { curr = curr.next; count++; } if (count < k) { return head; // 不足K个,直接返回 } // 现在curr指向第K+1个节点,先翻转前K个 ListNode prev = null; ListNode node = head; while (node != curr) { ListNode next = node.next; node.next = prev; prev = node; node = next; } // 翻转之后,head变成了这一组的尾节点,它的next要接到下一组的翻转结果上 head.next = reverseKGroup(curr, k); return prev; }这个解法的核心是递归思路:每次只翻转当前这一组,翻转后把尾节点的next指向下一组的翻转结果。递归的出口是"剩余节点不足K个"。理解了这个思路,代码其实是好写的,难的是翻转过程中指针的重新指向。我建议你在白纸上把"prev→node→next"三步指针移动画一遍,画熟了再写代码,正确率会高很多。
另一种纯迭代写法需要维护一个dummy节点,用prevTail记录上一组的尾节点,代码更繁琐,但避免了递归栈的额外空间。笔试时我推荐递归写法,因为逻辑更清晰,调试成本低。关于空间复杂度,这里递归深度是n/k,并不大,所以不用太担心。
3.3 找出数组里最大的K个数:不是所有时候都该用排序
笔试里除了链表题,还常考这种"找最大K个数"的题。题目描述:给一个无序整数数组和一个整数K,返回最大的K个数,顺序不限。
大多数人第一反应是排序然后取前K个,时间复杂度O(n log n),数据量小时没问题。但面试官想看到的通常不是这个解法,而是堆或者快速选择。
堆的写法:维护一个大小为K的最小堆,遍历数组,如果堆没满就入堆,如果堆满了且当前元素比堆顶大,就弹出堆顶再入堆。最后堆里就是最大的K个数。时间复杂度O(n log K),空间复杂度O(K)。
public int[] findTopK(int[] nums, int k) { if (nums == null || nums.length == 0 || k <= 0) { return new int[0]; } PriorityQueue<Integer> minHeap = new PriorityQueue<>(k); for (int num : nums) { if (minHeap.size() < k) { minHeap.offer(num); } else if (num > minHeap.peek()) { minHeap.poll(); minHeap.offer(num); } } int[] result = new int[minHeap.size()]; int i = 0; for (int val : minHeap) { result[i++] = val; } return result; }这里有个容易误解的点:为什么用最小堆而不是最大堆。最小堆的堆顶是堆里最小的元素,当你想知道"这个新元素能不能挤进TopK"时,只需和堆顶比。如果用最大堆,堆顶是最大的元素,你没法快速判断新元素是否应该淘汰掉当前的某个元素。这是一个典型的"反直觉但正确"的设计,理解了原理就不会选错。
关于快速选择(QuickSelect),平均时间复杂度是O(n),最坏O(n^2),在数据量极大时比堆更快。但笔试里我建议用堆,因为快速选择有个麻烦:它是部分排序,返回的K个元素是有序的,如果题目要求"按从大到小返回",用快速选择后还得再排一次序,反而多一道工序。
还有一个重要注意点:K的大小和n的关系。如果k接近n,TopK问题就变成了"排序问题",堆的O(n log n)并不比直接排序快多少。笔试里如果题目描述没有特别说明数据规模,堆是通用解,但如果明确说了n特别大、K特别小,堆的方案才是最优解。
3.4 岛屿数量:图搜索的经典入口题
这道题我会特别拿出来说,是因为它在校招笔试里出现频率极高,而且能一下子看出来一个人是不是真的理解DFS/BFS。题目描述:给一个二维网格,'1'表示陆地,'0'表示水域,问有多少个岛屿。相邻的陆地(上下左右)算同一个岛屿。
这道题的思想很简单:遍历所有格子,遇到没访问过的'1'就计数加一,然后从这个格子开始做DFS或BFS,把整块连通区域都标记为已访问。核心问题是标记方式。一种方式是维护一个visited数组,另一种是直接把访问过的'1'改成'0'(沉没法)。笔试里我推荐直接改值,省空间,代码也更简洁。
public int numIslands(char[][] grid) { if (grid == null || grid.length == 0) { return 0; } int rows = grid.length; int cols = grid[0].length; int count = 0; for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { if (grid[i][j] == '1') { count++; dfs(grid, i, j); } } } return count; } private void dfs(char[][] grid, int i, int j) { if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length || grid[i][j] == '0') { return; } grid[i][j] = '0'; // 沉没当前格子 dfs(grid, i - 1, j); dfs(grid, i + 1, j); dfs(grid, i, j - 1); dfs(grid, i, j + 1); }递归DFS的缺点是极端情况下(整个网格都是'1')递归栈会非常深,可能栈溢出。所以有的笔试环境我会用BFS或显式栈的DFS。BFS用队列模拟层级扩散,代码稍长但肯定不会栈溢出。面试官在笔试后评论这道题时常说:能把DFS写成BFS且不重不漏的,基础通常比较扎实。
另外,这道题还有一个变种——"被围绕的区域"(LeetCode 130),做法是从边界上的'O'开始反向往内搜索,和岛屿数量正好是逆向思维。刷题时把这两个题连着做,对DFS/BFS的理解会深不少。
4. 笔试实战里的极端边界:这些点能让你多拿不少分
编程题不是能跑通用例就万事大吉。笔试系统判分的时候,除了隐藏的测试用例,还会看代码的边界处理能力。如果你的代码在多个测试集上有较好的边界表现,往往会比只过了示例用例的人分高。下面是几个我当时踩过之后总结出来的点。
4.1 输入边界:空值、极端值、溢出
几乎每一道编程题都要考虑空输入。字符串要有null的判断和空字符串的判断,数组要考虑长度为0的情况,链表要考虑null。这些不是锦上添花,而是保命的。
另一个容易踩的坑是整数溢出。2019年那道"字符串转整数"题,要求写一个atoi函数,输入字符串"2147483648",也就是Integer.MAX_VALUE + 1。如果你在累加过程中直接用一个int存结果,一累加就溢出了,返回值就是错的。正确做法是在累加前判断当前值是否已经大于(Integer.MAX_VALUE - digit) / 10。这个判断在LeetCode第8题里有标准解法,笔试里也经常原题变形出现。
类似地,回文数判断、反转数字、二分查找里的mid = (left + right) / 2,当left和right都很大时可能溢出。更安全的写法是mid = left + (right - left) / 2。这个细节在二分查找的变种题里非常重要。
4.2 时间复杂度优化:从能用到够用
笔试的测试数据规模往往会比示例大很多。示例里给一个长度100的数组,隐藏测试可能给你长度10^6的。所以"能跑通示例"不等于"能AC"。我备考时见过最遗憾的一幕,是一个同学写了两层循环求最长回文子串,示例用例没问题,但隐藏用例超时,一分都没拿到。
判断自己的解法会不会超时,可以用一个经验法则:1秒运算量大约在10^8左右(Java/C++),Python要再降一个数量级。如果算法复杂度是O(n^2)且n是10^5,那运算量是10^10,必超时。这时候要么优化成O(n log n),要么换思路。
以"最长回文子串"为例,暴力法是两重循环枚举所有子串再判断回文,复杂度O(n^3)或O(n^2)。如果你用中心扩展法,每个中心点向两边扩展,总复杂度O(n^2),n=10^4勉强能过;如果你用Manacher算法,O(n)就能解决。笔试中遇到"最长回文类"的题,建议直接上中心扩展,因为Manacher的代码复杂且容易写错,中心扩展已经足够应付大多数情况。
4.3 输出格式:白丢分的重灾区
输出格式这件事看着无关紧要,实际却能白丢分。笔试系统判题通常是严格比对输出结果,多一个空格、少一个换行、末尾多了个逗号,都可能判错。
我见过最典型的丢分场景是:题目要求输出用空格分隔的一组数字,但没说行末不能有空格。考生在循环里每个元素后面都输出了一个空格,结果最后一位后面也有空格。有些判题系统对行末空格不敏感,但有些严格比对,直接判错。稳妥的做法是先拼成一个字符串,输出时统一处理,或者用System.out.print的条件判断控制分隔符。
另一个场景是浮点数的精度。题目要求保留两位小数,你用System.out.println(value)直接输出,可能输出一堆小数位,判题系统按字符串比对就错了。正确做法是用String.format("%.2f", value)或DecimalFormat。
5. 这套题留给我的备考心得:刷题之外的三个经验
下面这部分可能有点非主流,但我觉得比多刷两道题更有用。都是我真实吃过亏之后总结出来的。
第一个经验是做真题的时间分配要提前演练。2019年那套题我记得时间大概是120分钟,题目量决定了你不可能每道编程题都花30分钟去仔细打磨。我那时候的策略是:拿到卷子先花5分钟把所有题目过一遍,把编程题按"容易→中等→难"排序,先做容易的保底,再啃中等,最后剩下的时间死磕难题。这个策略帮我避免过"一道题卡了一个小时,后面的送分题都没来得及写"的悲剧。
第二个经验是编程题要写注释和清晰的变量名。如果你以为笔试只看结果不看代码,那就错了。很多公司的笔试系统会有"人工复核"环节,特别是编程题,面试官会去看你的代码风格。变量名叫a、b、c,和变量名叫left、right、current,给人的印象完全不同。我见过有人TopK问题代码写对了,但变量名全是a1、a2、a3,阅读体验极差,面试官评论区直接写了"可读性差"。这不是能力问题,是习惯问题。
第三个经验可能有点鸡汤,但真的是我体会最深的:基础概念和算法题要并行复习,不要偏废。我第一年备考就是只顾着刷LeetCode,结果客观题丢分严重,总分没达到面试线。后来我调整策略,每天先花半小时过基础概念,再花两小时刷题,效果明显好很多。基础概念是"压舱石",算法题是"加分项",二者缺一不可。
另外,笔试和面试是两回事。笔试考的是"你会不会",面试考的是"你懂不懂"。笔试里你只要把题AC了,哪怕思路有点绕,也是满分;但面试中你需要讲清楚为什么这么写、有没有更优解。所以笔试可以追求速度和正确率,面试前一定要再补一轮"思路讲解"的练习。我见过好几个同学笔试分数很高,但面试时讲不清自己写的代码,最后倒在了技术面。
最后说一句关于刷题数量的话。很多人纠结"我刷了200题够不够""刷500题是不是稳了"。我的观点是,数量本身不是关键,关键是你有没有把每道题背后的方法论吃透。滑动窗口、双指针、DFS、BFS、DP、贪心,这些套路每个方向刷透十道题,比囫囵吞枣刷两百道题有用得多。比如你理解了滑动窗口的"窗口什么时候收缩、什么时候扩展"这个核心,最长无重复子串、最小覆盖子串、字符串排列这些题就都通了。我用这套方法备考下来,面对笔试的心态比海量刷题时稳不少。