2016美团研发笔试题解析:数据结构、算法与操作系统高频考点
2026/8/30 17:36:47 网站建设 项目流程

最近重新翻出2016年美团研发工程师笔试题(二),越看越觉得有意思。那年头的校招笔试远没有现在这么多花活,题目是真的硬核,数据结构、算法、操作系统、网络、Java基础,一个都跑不掉。这套题哪怕放到今天,依然是一面很好的“照妖镜”,能很干净地筛掉一部分只会背题的人。如果你正在准备研发岗校招,或者打算跳槽去互联网大厂,完全可以拿它当自测模板,限时两小时做一遍,看看自己到底还能不能拿得下这种老派但扎实的笔试。

这篇文章我不会去逐题报答案,而是从命题思路上拆,把高频考点、编程题实操、答题策略和踩坑经验都过一遍。说得直白一点,题目本身只是素材,真正值钱的是它背后反复出现的那些底层能力要求:边界意识、复杂度分析、代码落地能力。下面直接进入正题。

1. 这套笔试题到底在考什么

1.1 题量与题型构成

2016年美团这类互联网公司的研发笔试,一般还是线下笔试或者早期的在线笔试,题量控制在两小时左右。整套卷子大致由三部分构成:选择题、填空题、编程题。

  • 选择题:覆盖面很广,从Java语法、数据结构到操作系统、网络,一题就是一个知识点,考的是基础知识的准确度。
  • 填空题:往往会让你直接写出某个程序段的运行结果,或者补充某个算法的关键步骤。这种题比选择题更狠,不会就是不会,蒙对概率很低。
  • 编程题:通常是一两道算法题,需要手写完整代码。有的题目还要求写复杂度分析,这一步很多人在考场上容易忽略。

这种结构现在看可能觉得传统,但它其实很科学。选择题考知识面,填空题考推导能力,编程题考代码落地能力,三者缺一不可。如果你只会刷选择题,不练手写代码,到编程题环节大概率当场翻车。

1.2 命题背后的三个隐藏逻辑

第一个逻辑是考“为什么”而不是只考“是什么”。举个例子,题目问“哈希表为什么能实现O(1)查找”,这时候你要是只回答“因为有哈希函数”,基本拿不到分。真正想听的是:哈希函数如何映射、冲突如何解决、负载因子对性能的影响。这些细节才是区分“背过书”和“真懂”的分界线。

第二个逻辑是考“边界意识”。选择题里经常出现数组长度、字符串长度、循环结束条件这些容易被忽略的点。比如二分查找的结束条件到底是left <= right还是left < right,边界差一个位置,结果就完全不同。很多丢分不是因为不会,而是因为没把边界想清楚。

第三个逻辑是考“工程取舍”。同样是排序,什么时候用快排、什么时候用堆排、什么时候用归并,得结合数据规模、稳定性、内存开销来判断。笔试里不直接问“说说排序算法”,而是给你一个具体场景,让你选最合适的排序,考的就是这个判断能力。

2. 高频考点精讲:数据结构与算法

2.1 排序算法:不只背时间空间复杂度

排序是这套笔试题里的绝对主角。常见问法包括:快速排序最坏时间复杂度是多少?堆排序建堆的复杂度是多少?为什么稳定排序很重要?如果只是背结论,很容易掉坑。

先看快排。快排平均时间复杂度是O(nlogn),最坏是O(n^2),最坏情况出现在每次分区都极端不平衡的时候,比如数组已经有序,而pivot每次都选第一个元素。2016年的题目里就喜欢用这种场景做选择题干扰项。解决思路很简单:随机化pivot或者三数取中。实际上工程里的快排不会裸写,C++ STL的sort就是快排加插入排序的混合策略,小数组直接插入排序,能显著减少递归深度。

再看堆排序。容易错的是建堆复杂度,很多人以为是O(nlogn),其实建堆是O(n)。原因是从最后一个非叶子节点开始向下调整,总调整次数加起来是一个等比数列求和,结果收敛于O(n)。这个结论在选择题里很常考,建议动手画一下堆结构推导一遍,比死记硬背可靠。

稳定排序也是一个常考点。稳定的意思是值相等的元素在排序后保持原来的相对顺序。稳定排序包括冒泡、插入、归并;不稳定排序包括快排、堆排、选择排序。为什么要在意稳定性?因为业务排序经常是多重排序,比如先按成绩排序,再按姓名排序,如果第一次排序不稳定,第二次排序的时候结果就乱了。

2.2 链表与树的经典手写题

笔试题里最经典的手写题基本绕不开链表反转、判断链表是否有环、二叉树中序遍历非递归三种。这些题看起来简单,最能在短时间内暴露一个开发者的基本功。

链表反转我建议准备迭代和递归两种写法。考场上优先写迭代,因为不容易爆栈,思路也直观。下面给一段Java迭代实现:

public ListNode reverseList(ListNode head) { ListNode prev = null; ListNode curr = head; while (curr != null) { ListNode next = curr.next; curr.next = prev; prev = curr; curr = next; } return prev; }

这段代码的核心是理解三个指针的推移过程。prev始终指向当前节点的前一个节点,curr指向当前节点,next先暂存下一个节点防止断链。每次循环把当前节点的next指向前一个节点,然后整体移动。笔试时要注意最后返回的是prev,不是curr,因为循环结束的时候curr已经变成null了。

二叉树中序遍历的非递归写法则考察栈的使用。思路是:从根节点开始,一路往左走,把沿途节点全部压栈;弹出一个节点时访问它,然后把指针移到它的右子树,继续往左走。这个“模拟递归”的思想在很多树相关的题里都会用到,值得多练习。

2.3 动态规划:状态定义是关键

动态规划题在当年的笔试题里一般不会出特别变态的题目,但一定会有一道,用来检验你有没有建立“状态”这个概念。比较常见的是最长上升子序列、编辑距离、背包问题里的简化版本。

拿最长上升子序列来说,很多人的第一反应是暴力枚举,然后瞬间发现复杂度爆炸。正确的做法是定义dp[i]为“以第i个元素结尾的最长上升子序列长度”,然后对每个i,往前找所有j < i且nums[j] < nums[i]的位置,更新dp[i] = max(dp[i], dp[j] + 1)。时间复杂度O(n^2),虽然不算最优,但至少说明你理解动态规划的基本套路。

如果你想把这道题答得更出彩,还可以提一嘴O(nlogn)的贪心加二分优化:维护一个tails数组,表示长度为len的上升子序列的最小结尾元素,然后对每个新元素在tails里做二分查找。这种“能往下挖一层”的回答,在面试阶段特别加分。

3. 操作系统与网络的必得分项

3.1 进程线程与并发基础

操作系统这块,笔试里高频出现的就是进程与线程的区别、死锁的四个必要条件、进程间通信方式。这些概念非常基础,但如果不做整理,考场上容易答得零零散散。

进程和线程的区别,每家公司都喜欢考。最核心的一句话是:进程是资源分配的基本单位,线程是CPU调度的基本单位。进程之间内存空间相互独立,线程之间共享进程的内存空间。所以线程切换成本比进程切换低,但同时线程安全问题也就出现了。

死锁的四个必要条件必须背得滚瓜烂熟:互斥、持有并等待、不可剥夺、循环等待。考点不光是背出来,还会要求你说怎么解决。思路也清晰,破坏任意一个条件即可,比如用锁顺序来破坏循环等待,或者用超时机制让锁可以被释放。银行家算法属于更进一步的考点,笔试里不一定要求写代码,但至少要知道它的核心思想是“安全性检查”。

进程间通信方式也是一个高频题。管道、消息队列、共享内存、信号量、Socket,各自适合什么场景,要能说清楚。共享内存最快,因为不需要数据拷贝,但需要自己用信号量做同步;管道适合父子进程之间简单流式通信;消息队列适合不同进程间传递结构化消息。这些优缺点对比在选择题和填空题里反复出现。

3.2 TCP三次握手与可靠传输

网络题里,TCP三次握手和TIME_WAIT基本上是必考。三次握手的核心目的是确认双方的收发能力都正常,所以不是两次也不是四次。两次握手的问题在于,服务端无法确认客户端的接收能力是否正常,容易导致旧连接请求残留造成资源浪费。

具体过程可以这样记:

  • 客户端发送SYN,进入SYN_SENT状态,表示请求建立连接。
  • 服务端收到后回复SYN+ACK,进入SYN_RCVD状态,表示确认了客户端的SYN,同时请求客户端确认。
  • 客户端收到SYN+ACK后回复ACK,进入ESTABLISHED状态,服务端收到ACK后也进入ESTABLISHED状态。

TIME_WAIT状态出现在主动关闭连接的一端,需要等待2MSL。为什么非要等?主要是为了保证最后一个ACK能到达对方,万一ACK丢了,对方重发FIN,这边还能响应;另外一个作用是让旧连接的所有报文在网络中自然消失,避免影响新连接。

TCP和UDP的区别更大题化,但别小看它。只要出现“直播用TCP还是UDP”这种场景题,就不仅要答UDP快、TCP可靠,还要说出直播对实时性要求高,TCP重传机制会导致延迟增大,所以很多场景选择UDP加应用层容错。这就是从书本知识往工程实践迁移的能力。

4. 编程题实操:从题目到AC的完整思考

4.1 题目一:按数字出现频率排序

这是比较典型的自定义排序题,很符合2016年互联网公司的出题口味。题目可以这样描述:给定一个整数数组,请按照数字出现的频率从高到低排序,如果频率相同,则按照数字本身从小到大排序。

思路不难,先统计频率,再排序。关键是能把“统计”和“排序”两个环节的边界处理好。统计用HashMap,key存数字,value存次数。排序的时候对数组里的每个数字,按它的频率和值一起比较。注意这里需要把Integer数组,因为Arrays.sort支持自定义比较器,但基本类型int数组不支持。

Java参考实现:

public Integer[] frequencySort(int[] nums) { Map<Integer, Integer> countMap = new HashMap<>(); for (int num : nums) { countMap.put(num, countMap.getOrDefault(num, 0) + 1); } Integer[] boxed = new Integer[nums.length]; for (int i = 0; i < nums.length; i++) { boxed[i] = nums[i]; } Arrays.sort(boxed, (a, b) -> { int freqA = countMap.get(a); int freqB = countMap.get(b); if (freqA != freqB) { return freqA - freqB; // 频率升序,频率高的在后面,所以最后集合反转或者直接降序 } return a - b; }); // 如果上面是按频率升序,这里需要调整顺序 return boxed; }

这里有个小细节比较容易错:如果直接按频率升序排序,输出结果会是频率从低到高。要实现频率高的在前,可以把比较器改成freqB - freqA,或者排序后反转。这种小坑在笔试里非常容易让人烦躁,建议写的时候就把顺序定义清楚,不要最后再靠反转补救,反转又容易引入新的边界问题。

写完代码后,建议自己在脑子里跑一组测试用例,比如nums = [4, 4, 1, 2, 2, 3],统计结果是4出现2次,2出现2次,1出现1次,3出现1次。频率相同的按大小升序,所以最终结果应该是[1, 3, 2, 2, 4, 4]或[1, 3, 4, 4, 2, 2]里符合顺序的一种。手动模拟一遍,能发现很多逻辑错误。

如果面试官追问内存受限怎么办,这时候可以改成先排序,再遍历统计频率,最后按频率分组输出。时间复杂度从O(nlogn)变成O(nlogn)本身没变,但省掉了HashMap的开销,空间复杂度从O(n)降为O(1)。虽然复杂了一些,但能体现出工程思维的差异化。

4.2 题目二:实现LRU缓存

LRU(Least Recently Used)缓存是2016年互联网公司笔试里的高频题,美团会考不奇怪,因为缓存淘汰策略在真实业务里实在太常见了。题目要求实现get和put两个操作,get和put的时间复杂度都必须是O(1)。

核心数据结构是HashMap加双向链表。HashMap负责O(1)查找,双向链表负责维护访问顺序。每次get一个key,就把对应节点移动到链表头部;每次put一个新key,先判断是否已存在,存在就更新值并移动到头部,不存在就插入头部;如果容量满了,就删除链表尾部节点,同时删除HashMap里的对应key。

Java实现要点如下:

class LRUCache { class Node { int key; int value; Node prev; Node next; Node(int key, int value) { this.key = key; this.value = value; } } private int capacity; private Map<Integer, Node> map; private Node head; private Node tail; public LRUCache(int capacity) { this.capacity = capacity; map = new HashMap<>(); head = new Node(-1, -1); tail = new Node(-1, -1); head.next = tail; tail.prev = head; } public int get(int key) { if (!map.containsKey(key)) { return -1; } Node node = map.get(key); moveToHead(node); return node.value; } public void put(int key, int value) { if (map.containsKey(key)) { Node node = map.get(key); node.value = value; moveToHead(node); } else { Node node = new Node(key, value); map.put(key, node); addToHead(node); if (map.size() > capacity) { Node tailNode = removeTail(); map.remove(tailNode.key); } } } }

这段代码的关键是处理好虚拟头节点和虚拟尾节点。使用虚拟节点能省掉大量判空逻辑,但也要注意,removeTail的时候拿到的tail.prev一定是最后一个真实节点,不会误删虚拟节点。这个细节在笔试手写时很容易出问题,建议先画一画指针指向。

为什么用双向链表而不是单向链表?因为删除一个节点时,需要知道它的前一个节点,单向链表得从头遍历才能找到前驱,就无法保证O(1)时间了。这个“为什么”一定要能脱口而出。

4.3 题目三:二分查找的边界处理

二分查找看起来简单,但2016年笔试题特别爱考它的变体,比如查找第一个等于target的位置、最后一个小于等于target的位置、在旋转数组里找最小值。核心问题在于边界条件。

先给一个稳妥的模板:

public int binarySearch(int[] nums, int target) { int left = 0; int right = nums.length - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] == target) { return mid; } else if (nums[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; }

这里有几个值得展开的点。第一,mid写成left + (right - left) / 2,而不是(left + right) / 2,是为了避免整数溢出。虽然笔试数据未必会触发,但这种写法本身就是一个加分项。第二,while条件用left <= right,意味着区间是左闭右闭,每次更新left或者right都必须跳过mid,否则会出现死循环。第三,返回-1表示找不到,但如果要找第一个大于等于target的位置,返回left就行,left位置天然就是插入点。

旋转数组找最小值是二分查找的进阶版。题目特点:原数组是非递减的,然后从某个点旋转了。思路是拿nums[mid]和nums[right]比较,如果nums[mid] > nums[right],说明最小值在右半部分,移动left;否则最小值在左半部分,移动right。这个题考的是对二分单调性的灵活理解,值得多刷几遍。

5. 答题策略与避坑指南

5.1 时间分配:别在选择题上恋战

两小时笔试,选择题加填空题一般有30到40道,编程题两到三道。我见过太多人在选择题上纠结太久,结果编程题没时间写,这是最亏的。选择题每题分值有限,但编程题一题顶十几道选择题。

建议这样分配:前60分钟搞定选择题和填空题,不会的先标记,直接跳过,不要在一道题上停留超过两分钟。中间40分钟留给编程题,每道题先写思路注释和框架,再补细节。最后20分钟检查编译、扫一遍边界条件。如果编程题真做不完,至少把核心函数骨架和主要思路写出来,阅卷时还能给步骤分。

还有一个容易被忽视的点:笔试题册上经常会有“请在答题纸上写清楚题号”的要求,在线笔试也会有代码运行按钮。实际考试时,一定要留出时间确认输出格式和题目要求,比如有的题要求打印结果,而不是返回对象。格式不对,代码再对也可能零分。

5.2 剑走偏锋:如何用测试用例加分

很多人写完代码就交了,但我当年发现一个很实用的习惯:在代码注释旁边写上你设计的测试用例和预期结果,或者在整个函数下面贴一段简短的main方法,把边界情况跑一遍。这样做有两个好处,一是让自己强制校验逻辑,二是让阅卷人觉得你考虑周全。

拿二分查找举例,至少要测四类用例:数组为空、长度为1且target存在、target不存在于数组中、target比所有数都大。这些用例能暴露大多数边界问题。如果你能在代码注释里写清楚“测试用例:[1,2,3,4], target=2,返回1”这种说明,会给面试官留下很深的印象。

5.3 常见的三个丢分点

  • 主类名写错。在线笔试系统通常要求类名和文件名保持一致,比如Main、Solution,大小写写错直接编译失败。写完第一件事就是检查类名、方法签名和访问修饰符。
  • 数组越界。循环遍历时,习惯用for (int i = 0; i < nums.length; i++),但在删除元素、双指针、二分这种场景,越界概率极高。每次更新索引前,先想一想当前值是否可能等于length。
  • 忽略输入中的特殊情况。很多编程题会有“如果数组为空返回0”这种要求,漏了这种判断,用例跑不过。建议在写主逻辑之前,先把空值、空数组、单元素分支处理掉。

6. 问题排查与复盘:那些年我们一起踩过的坑

6.1 为什么你的快排会栈溢出

笔试现场不会真让你调栈,但面试的时候会被追问。快排使用递归,递归深度在极端情况下会达到n,也就是O(n)的栈空间,数据量一大就会栈溢出。解决思路是:在递归前判断,如果子数组长度小于某个阈值,改用插入排序;或者自己维护一个栈来做非递归快排。后者更适合面试时展示你对系统栈的理解。

还有一种排序场景值得注意:当数据量特别大,无法全部载入内存时,可以使用外部排序。归并排序天然适合外部排序,因为它的合并阶段可以分批读取磁盘数据。笔试如果问“内存只有100M,但数据有10G怎么办”,答案不是快排,而是外部排序加多路归并。

6.2 死锁题目答非所问怎么办

看到死锁相关题目,建议先写四个必要条件,再写破坏条件的方法,最后再谈银行家算法。这个顺序是“由浅入深”的标准范式。

常见错误是直接背了“避免死锁的方法”,结果题目问的是“检测死锁”,答案就对不上了。如果确实不确定,可以先把必要条件都列出来,然后说“死锁检测可以通过资源分配图来实现,检测到环路后执行恢复策略”,这样至少覆盖到了检测层面的关键点,比完全写偏要好。

6.3 编程题编译不过的常见原因

在线笔试的编译器通常比本地IDE严格很多,常见问题包括:导入缺失,比如用了ArrayList但没import java.util.*;中文字符混进代码,比如注释里的分号用了中文符号;变量名拼写不一致,比如前面定义了len,后面写成了leng,编译直接报错。

我的建议是:手写代码或者现场输入的时候,尽量在提交前把代码重新读一遍,从头到尾检查符号和变量名。这个方法很笨,但确实能救回不少分数。再有一个小技巧是,如果在线编译器支持,先编译一次再填答案,能看到错误信息就不要浪费机会。

整理这套题的过程中,我最大的感触是,2016年的研发笔试题虽然老,但核心考点和现在大厂面试的重合度依然非常高。基础的数据结构、算法复杂度、操作系统和网络底层逻辑,这些东西不会因为框架迭代而过时。最后再分享一个很实用的小技巧:备考的时候别只刷题,把每道题背后的“为什么”写在笔记里,比如“为什么需要TIME_WAIT”“为什么LRU用双向链表”“为什么快排要随机化pivot”,这些一句话答案比刷两百道题更值钱。拿这套题做一次全真模拟,你很快就能发现自己到底是在哪个环节掉了链子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询