腾讯音乐移动客户端笔试复盘:操作系统、网络与算法全解析
腾讯音乐的笔试安排在周六下午,形式是牛客网在线笔试,双机位监控,全程摄像头开启,手机扫码作为第二机位放在侧后方。整套卷子满分100分,考试时间120分钟,题型分为三块:第一部分是20道选择题,每题2分,共40分;第二部分是2道编程题,每题20分,共40分;第三部分是2道简答题,每题10分,共20分。这个分值分布很关键——选择题和简答题加起来60分,编程题只有40分,所以笔试能不能过,不光看算法写没写出来,基础知识的扎实程度同样重要,甚至更重要。
移动客户端岗位的笔试不像后台开发那样侧重高并发和分布式,它有自己的考察侧重点:操作系统、网络、Java/Kotlin基础、Android/iOS框架原理、数据结构与算法都会覆盖到。题型虽然传统,但有不少值得复盘的地方,这篇文章我按题型逐块拆解,把整套卷子涉及到的核心考点、我当时怎么答的、哪些地方踩了坑,以及如果你是下一批考生该按什么思路去准备,都讲清楚。
1. 笔试整体设计与考察逻辑
1.1 岗位技能模型决定考察范畴
先聊一个容易被忽略的问题:为什么移动客户端岗位的笔试会考操作系统和网络,而不是像前端那样侧重浏览器原理和JavaScript?
原因是移动客户端的日常工作内容决定了技能栈。你开发一个App,从网络请求发出到数据落地,中间要经历DNS解析、TCP连接、TLS握手、HTTP报文传输,到了客户端这边还要处理JSON解析、图片解码、数据库存储,这些环节底层全部依赖操作系统提供的线程调度、内存管理、文件系统能力。你不可能在不懂进程线程模型的情况下写出不卡顿的列表,也不可能在不了解TCP拥塞控制的情况下处理好弱网环境下的请求重试机制。所以笔试考操作系统和网络,本质上是在筛选基本功扎实的人,而不是背了多少框架API的人。
腾讯音乐的这套选择题,覆盖范围就在这个逻辑框架内:操作系统考了进程间通信方式和死锁产生的必要条件,网络考了TCP三次握手和HTTP状态码的含义,数据结构和算法考了二叉树遍历、排序算法时间复杂度和哈希冲突的解决方式,语言基础考了Java的垃圾回收机制、Kotlin的协程原理、String和StringBuilder的区别,还有几道Android专属题,涉及Activity启动模式和Handler消息机制。
1.2 整卷难度分布与淘汰逻辑
从题目难度梯度来看,这套卷子设计得比较有层次。选择题中大约有7-8道属于“背过就能答”的记忆型题目,比如TCP和UDP的区别、TCP三次握手过程、HashMap的默认负载因子是多少,这类题考察的是你的基础是否系统。另有8-9道属于“理解了才能答”的推理型题目,比如给你一段代码问输出结果、给出四个进程调度策略问哪个可能导致饥饿,这类题考察的是你对知识点有没有真正吃透。还有3-4道属于“需要经验才能答”的工程型题目,比如Handler的postDelay是精确延时吗、如何避免内存泄漏,这类题没有标准教科书答案,需要你对实际开发有认知。
编程题一道easy一道medium偏难,第一道是模拟题,第二道是滑动窗口加贪心的综合题。简答题第一道是设计一个图片加载库需要考虑哪些因素,第二道是描述一次线上崩溃排查的全过程。整体难度不低,尤其是简答题,没有标准答案,考察的是你的工程思维和问题分析能力。
我个人的判断是,这套笔试的淘汰逻辑是三层过滤:第一层过滤掉基础不扎实的人,通过选择题的准确率筛掉知识体系有硬伤的同学;第二层过滤掉代码能力不达标的人,通过编程题的AC情况和部分用例通过情况筛掉写不出完整代码的同学;第三层过滤掉工程经验不足的人,通过简答题的回答质量筛掉只刷题不做项目、对真实开发场景没有感知的同学。三种能力缺一种,都有可能在某一层被筛掉。
2. 选择题高频考点深度拆解
2.1 计算机基础模块:操作系统与网络
操作系统部分有一道题印象很深:问的是“下列哪些属于进程间通信方式”,给了多个选项,包括共享内存、消息队列、Socket、管道和信号量。这道题本身不难,IPC的几种经典方式都属于必背内容,但容易在“Socket算不算进程间通信”这个选项上犹豫。从定义上看,Socket确实可以用于本机进程间通信,只是它更多被用于网络通信,所以不能被排除。另一个容易漏掉的是信号量,信号量本身是一种同步机制,它不是用来传递数据的,但它经常和共享内存配合使用来实现进程同步,所以如果把它归入通信方式也没错。这类题目考的是你对概念边界的把握,闷头背八股的人很容易在这个地方丢分。
关于死锁那道题,考察的是死锁产生的四个必要条件:互斥、持有并等待、不可剥夺、循环等待。选项给出了“资源一次性分配”“银行家算法”“资源有序分配法”“破坏互斥条件”,让你判断哪些是死锁的预防策略而不是必要条件。这里需要区分清楚一个概念:必要条件是死锁发生的前提,而预防策略是针对必要条件去破坏其中任意一个。银行家算法属于避免死锁,不是预防死锁,因为它在资源分配之前先判断系统是否处于安全状态,本质上是动态地避免系统进入不安全状态,而不是从结构上消除必要条件。理解了这一层区别,这道题就不会答错。
网络模块考了TCP三次握手的序列号变化细节,问的是第二次握手时SYN和ACK标志位以及序列号分别是什么状态。很多同学背过“三次握手”这个结论,但到了具体的标志位和seq、ack数值变化就含糊了。答案是第二次握手时,服务器返回SYN=1、ACK=1,确认号是客户端的初始序列号加1,同时携带服务器自己的初始序列号。这类题目没有技巧,就是在纸上把三次握手的报文流画一遍,把每一次的标志位、seq、ack都标清楚,画过三遍自然就记住了。
HTTP状态码那题考了502的含义,选项包括Bad Gateway、Gateway Timeout、Not Found、Internal Server Error。502的含义是网关从上游服务器收到了无效响应,而504才是网关超时,这两个状态码在面试中也经常被拿出来对比。做客户端开发的同学应该对4xx和5xx的状态码比较敏感,因为请求失败时要根据状态码判断是客户端问题还是服务端问题,这个基本功不能丢。
2.2 语言基础模块:Java与Kotlin
Java部分的题目围绕JVM和集合类展开。有一道题问“以下哪种情况不会触发垃圾回收”,给了几个选项,包括系统调用System.gc、新生代内存不足、老年代内存不足、程序正常退出。答案是程序正常退出时不会触发一次完整的GC流程,因为JVM进程已经结束,回收堆内存已经没有意义了。这道题考察的是对GC触发机制的理解,而不是停留在“GC会回收垃圾”这种表面认知上。
HashMap相关题目也出现了,问的是“当HashMap的容量超过负载因子设定的阈值时会发生什么”。答案是触发扩容,容量翻倍,并且重新计算每个元素的位置。这里有个容易被忽略的细节:JDK 8之后HashMap在链表长度超过8且数组长度大于64时,会把链表转成红黑树来降低查询时间复杂度。这个细节在选择题里作为干扰项出现过,如果对HashMap的底层结构理解不到位,容易跟扩容机制混淆。
Kotlin协程考了一道关于调度器的题目:Dispatchers.Main、Dispatchers.IO和Dispatchers.Default的区别。这是个高频考点,Main用于UI操作,运行在主线程;IO用于网络请求和磁盘读写,底层是IO线程池;Default用于CPU密集型任务,底层是共享线程池。这题的难点在于IO和Default虽然底层线程池有重叠,但在设计语义上有明确区分。
关于协程,我还想多说一点:协程并不是Kotlin独有的概念,它是广义上的一种并发设计模式,Kotlin的实现方式是通过编译器将挂起函数的状态机化,把代码转换成状态机来管理不同的挂起点,从而实现非阻塞的并发。笔试不会考到这个层面的实现细节,但如果你理解了这层原理,很多关于协程的题目都能推断出答案,而不是靠死记硬背。
2.3 Android专属模块:框架原理与组件机制
Android题有一道关于Activity启动模式的题目,考察的是singleTask模式下的回调顺序。假设一个Activity A启动了同一个任务栈中的singleTask模式的Activity B,B被再次启动时,系统会调用B的onNewIntent方法,同时会清掉B之上所有的Activity,并且最终会回调到onResume。这题的迷糊点在于,很多人会以为再次启动时会重新走完整的生命周期,但实际上由于singleTask模式下实例已经存在,走的是一条全新的分发路径。
Handler的消息机制也考了一道流程题,问的是Looper、Handler、MessageQueue三者之间的关系。标准的答案是:Handler通过sendMessage将消息放入MessageQueue中,Looper通过loop方法不断从MessageQueue中取出消息,再分发给Handler的handleMessage处理。这道题还附带了一个衍生考点:Handler是线程安全的吗?答案是Handler本身不是线程安全的,它的安全性依赖于Looper内部的消息队列的锁机制。另外还有一道关于内存泄漏的变体题,问的是在Activity中使用非静态内部类形式的Handler为什么会导致内存泄漏,原因是非静态内部类持有外部类的引用,而Handler中的消息可能延迟处理,导致Activity无法被回收。在笔试里遇到Handler相关的题,主线程Looper的循环机制和非静态内部类持有外部类引用导致的泄漏,是最高频的两个考察点。
3. 编程题实战复盘
3.1 第一道编程题:字符串模拟与状态机思维
第一道编程题是一个字符串题,大意是:给定一个只包含字符(、)和?的字符串,其中?可以被替换成(或),要求判断是否存在一种替换方案,使得最终的字符串是一个合法的括号序列,并且任意前缀中(的数量不小于)的数量。如果存在,输出任意一种合法替换结果。
这类题属于括号序列的经典变种,分析思路可以分几步。
先判断基本条件:如果字符串长度是奇数,直接不可能构造成合法的括号序列,因为左右括号数量无法相等。如果整个字符串中(的数量和)的数量都超过了字符串长度的一半,也不可能构造成功,因为合法括号序列中左右括号数量必须严格相等。
接下来是替换策略:统计出固定(的数量固定左和固定)的数量固定右,剩余的就是?的数量。若固定左已经大于n/2或者固定右已经大于n/2,直接输出"不可能"。
关键点在于替换的分配策略。优先把前面的?替换成(,把后面的?替换成),这样可以保证在尽量靠前的位置累积左括号,使得整个序列更容易满足前缀合法性。这一步的贪心逻辑是通过交换论证:如果存在一个合法替换方案,那么把所有(尽量前移的方案也一定合法,所以按照这种策略构造不会漏掉可行解。
最后再对构造出的字符串做一次线性扫描验证前缀和是否合法,如果验证通过就输出结果,否则就输出"不可能"。这最后的验证是必要的,它能兜住任何边界条件下策略失误的情况,在笔试中能少丢很多不该丢的用例分。
完整代码如下,语言用的C++:
#include <bits/stdc++.h> using namespace std; int main() { string s; cin >> s; int n = s.size(); if (n % 2 == 1) { cout << "impossible" << endl; return 0; } int fixedLeft = 0, fixedRight = 0; for (char c : s) { if (c == '(') fixedLeft++; if (c == ')') fixedRight++; } int question = n - fixedLeft - fixedRight; int needLeft = n / 2 - fixedLeft; int needRight = n / 2 - fixedRight; if (needLeft < 0 || needRight < 0) { cout << "impossible" << endl; return 0; } string res = s; int curLeft = 0; for (int i = 0; i < n; i++) { if (res[i] == '?') { if (curLeft < needLeft) { res[i] = '('; curLeft++; } else { res[i] = ')'; } } } int balance = 0; bool ok = true; for (int i = 0; i < n; i++) { if (res[i] == '(') balance++; else balance--; if (balance < 0) { ok = false; break; } } if (ok && balance == 0) { cout << res << endl; } else { cout << "impossible" << endl; } return 0; }笔试里的编程题不要求你一次性AC,但至少要保证拿部分分。对于这种模拟题,最重要的就是别急着写代码,先把所有边界情况在纸上列一遍,比如输入长度为1、长度为2、全问号这种极端情况,然后再动手。边界情况处理好了,样例用例的分就能稳拿。
3.2 第二道编程题:滑动窗口与哈希计数
第二道编程题是一个数组题,大意是:给定一个长度为n的数组和一个目标整数k,找到最长的连续子数组,使得该子数组内不同数字的个数不超过k个,输出这个最长长度。
这道题是典型的滑动窗口模型,核心思路是维护一个窗口,窗口内用哈希表记录每个数字出现的次数,同时用一个变量记录当前窗口内不同数字的数量。右指针不断向右扩展,当不同数字个数超过k时,左指针向右收缩直到不同数字个数重新满足要求。每次右指针扩展后更新最大窗口长度。
这个思路看起来简单,但有几个实现细节容易出错。第一个细节是:窗内不同数字数量的更新时机。当某个数字的出现次数从0变成1时,distinct加1;当某个数字的出现次数从1变成0时,distinct减1。这个更新逻辑必须放在哈希表操作之后,并且要区分增减的方向,否则会出现计数错乱。
第二个细节是:当不同数字个数超过k时,滑动左指针的循环条件。有些同学习惯用while循环去收缩窗口,直到合法,但收缩时要注意,左指针右移时对应数字的计数要先减,再判断减到0后distinct是否要减1。顺序乱了就会导致边界错误,而且这种错误在本地调试时不容易发现,因为样例数据往往不够特殊。
第三道题的优化思路是:如果数组元素范围是有限的整数,比如在0到100000之间,可以把哈希表换成数组来计数,用vector<int> cnt(100001),访问速度更快,常数更小。如果数组元素范围很大或者数据不是整数,就必须用unordered_map。笔试中的数据范围一般会给清楚,选择合适的数据结构能省不少运行时间。
我当时的实现是这样的:
#include <bits/stdc++.h> using namespace std; int main() { int n, k; cin >> n >> k; vector<int> a(n); for (int i = 0; i < n; i++) cin >> a[i]; unordered_map<int, int> cnt; int left = 0, ans = 0, distinct = 0; for (int right = 0; right < n; right++) { if (cnt[a[right]] == 0) distinct++; cnt[a[right]]++; while (distinct > k) { cnt[a[left]]--; if (cnt[a[left]] == 0) distinct--; left++; } ans = max(ans, right - left + 1); } cout << ans << endl; return 0; }这道题要拿满分,不仅依赖算法思路对,还要注意边界条件:n为0、k为0、数组中所有数字相同等情况。我提交时先跑了一遍自己构造的边界用例,确认无误后才提交,确实能避免一次WA的空格扣分。实际笔试的时候,一题能AC,一题能拿部分分,编程题这块就算稳住了。
3.3 编程题答题策略:先拿稳分再冲击满分
在120分钟里做完20道选择题和2道算法题,时间其实是紧的。我的策略是:拿到卷子先把两道编程题看一遍,花30秒判断各自的难度,然后决定先做哪道、后做哪道。如果一道是模拟题一道是综合题,我推荐先做模拟题,因为它思路直接、实现简单、边界容易控制,属于确定性拿分,做完之后心态也会稳很多。综合题如果思路顺畅就继续做完,如果碰到卡点,先把暴力解法写上去,保证至少过掉部分用例,拿到部分分,再到简答题里拿分。这个策略的本质是:笔试的核心是总分最大化,不是每道题都满分。
另外,在线笔试的判题系统对格式非常敏感。题目要求输出一行结果就只输出一行,不要有任何多余的调试输出。如果本地调试时cout了一些中间变量,提交前一定要删干净。很多同学代码逻辑是对的,就是因为格式问题导致0分,这种亏吃一次就够了。
4. 简答题答题框架:从总分结构到工程思维
4.1 图片加载库设计题:经典系统设计类简答的拆解思路
简答题第一道是:“如果让你设计一个图片加载库,你会考虑哪些方面?”这题看起来开放,实际上有明确的踩分点。回答的核心是分维度结构化,而不是想到哪说到哪。
我当时的回答框架是四层:
第一层是缓存策略。图片加载最耗时的环节是网络请求和解码,所以缓存是最关键的模块。我会设计三级缓存:内存缓存优先,用LruCache实现,保证最近使用的图片不会被淘汰;其次是磁盘缓存,用DiskLruCache实现,设置一个合理的容量上限;最后才是网络加载。缓存读写的顺序一定是“内存优先、磁盘次之、网络兜底”,并且要处理好缓存key的生成,通常是用URL加尺寸参数做MD5哈希。
第二层是加载流程。一个图片从请求到展示要经过:计算所需尺寸、查内存缓存、查磁盘缓存、发起网络请求、解码压缩、回调显示。这里需要说明为什么要先计算所需尺寸——如果不按控件实际大小对图片进行采样压缩,一张几兆的图片直接加载到内存里,OOM就是分分钟的事。
第三层是并发控制。图片加载通常是多线程并发的,要管理好线程池的配置,比如根据设备CPU核心数设置线程池大小;同时要支持请求优先级,列表滚动时首屏的图片应该比后面的图片优先加载。还有一个细节是请求去重,也就是说同一个URL在同一时间内只允许一个加载任务执行,避免重复请求浪费流量。
第四层是生命周期管理。图片加载请求要跟Activity或Fragment的生命周期绑定,界面销毁时自动取消未完成的请求。这一块如果不做,就容易出现界面销毁后回调更新UI导致崩溃,或请求泄漏的问题。
在笔试里遇到这种系统设计类简答题,记住一个回答公式:先说总体架构思路,再按模块拆解,每个模块展开讲清楚“为什么这么做”和“关键实现点是什么”,最后补充如果让你优化,你会优先做哪一块。这样回答既有层次又有深度,比想到哪写到哪能拿到的分高很多。
4.2 线上崩溃排查题:用结构化复盘展示排障实战能力
第二道简答题是:“描述一次线上崩溃排查的全过程。”这题考察的是工程经验和问题排查能力。回答时不要只讲一个笼统的“定位问题-修复问题”过程,要把步骤拆细,让面试官看到一个完整的、可落地的排查链路。
我记得当时答的是一个启动阶段空指针崩溃的案例,整个排查过程分五步:
第一步是整理崩溃现场。通过崩溃监控平台拿到堆栈日志、设备型号、系统版本、App版本号和应用使用页面。这一步的目的是尽可能多地收集上下文信息,不同设备、不同系统版本上代码的执行路径可能不同,有了这些信息才能缩小排查范围。
第二步是定位崩溃点。根据堆栈信息找到崩溃的代码行号,分析这条代码涉及哪些对象和引用关系。通常空指针崩溃可以直接从堆栈定位到具体位置,但有些异步线程的崩溃堆栈不完整,需要结合日志中前几条输出信息还原执行路径。
第三步是代码走查和复现验证。在代码中搜索崩溃点涉及的对象是谁赋值的、何时赋值的、在什么条件下可能为null。同时尝试在本地复现,看能否稳定复现崩溃,如果可以,就用断点调试进一步缩小范围。
第四步是修复并验证。修复的方式一般是做判空处理或者是调整初始化时机,修完之后要覆盖崩溃前的场景做回归测试,同时要考虑修复是否会引入新的问题,比如原来直接崩的路径现在只是静默失败了,会不会导致后续逻辑错误。
第五步是复盘和沉淀。把崩溃根因、触发条件、修复方案和预防措施记录到文档里,同时思考这类问题能不能通过代码规范、自动化检查等方式在开发阶段就提前发现。
这道题的得分点不在于你修的崩溃有多复杂,而在于你是不是有个清晰的排查框架:采集现场、定位根因、复现验证、修复回归、沉淀复盘。只要把这五步讲完整,每一步里有具体的做法,就是能拿高分的回答。
5. 备考节奏与踩坑记录
5.1 时间分配:校招笔试前的基础复习规划
结合我自己的经验,给准备移动客户端校招笔试的同学一个复习节奏参考。不要等到收到笔试通知了才开始突击,那样只能刷题感,补不了基本功。如果你还有4-6周时间,建议按这个节奏来:
第一周主攻数据结构与算法。把链表、栈、队列、二叉树、堆、哈希表这些基础数据结构过一遍,每种结构至少手写一遍常见操作;把排序算法的实现和复杂度对照表整理出来,重点理解快排和归并。然后刷两种最常见题型:滑动窗口和双指针,因为笔试编程题里这两种套路的出现频率最高。
第二周主攻操作系统和计算机网络。操作系统重点看进程线程模型、进程间通信、死锁、内存管理、虚拟内存、页面置换算法;计算机网络重点看TCP/IP协议栈、三次握手四次挥手、TCP可靠传输、HTTP常见状态码和缓存机制、HTTPS的握手流程。这一块没有捷径,把知识点用表格整理出来,对着表格自测。
第三周主攻语言基础和移动端框架。如果你投的是Android客户端,Java和Kotlin都要看,重点看集合类源码、垃圾回收机制、类加载过程、Kotlin协程;框架方面重点看Handler、Activity启动模式、四大组件工作原理、View的绘制流程、事件分发机制。如果投iOS,就把Swift基础、RunLoop、内存管理、消息传递机制作为重点。
第四周开始做真题和模拟题。目标不是刷多少套,而是每做完一套都要复盘。选择题错的题,把知识点找到并搞清楚;编程题AC不了的,把思路和标准解法研究明白然后自己重新写一遍。
后期的碎片时间用来整理错题本,把每次笔试面试中遇到的不会的知识点记下来,按模块归类,考前翻一遍。这个方法比重新看一遍书效率高得多,因为错题本里全是你个人的薄弱点。
5.2 实操踩坑:在线笔试环境下的几个真实教训
分享几个我在多次在线笔试中踩过的坑,全是真金白银买来的教训。
第一个坑是不提前调试环境。牛客网笔试系统、赛码网笔试系统、各有各的输入输出习惯。有的系统要求你写完整main函数,有的系统只要求你写核心函数,有的系统用Node.js处理输入的方式还不同。考前一定要去平台自带的模拟环境里做一道题,把读取输入、输出结果的模板提前准备好,而不是等考试开始了边写边试。我见过有同学在笔试开始后花了20分钟才搞明白怎么读数据,直接导致后面时间不够。
第二个坑是编译环境版本不同。你在本地用C++17编译通过的代码,在笔试系统里可能是C++14标准,某些特性和函数不可用。考前在平台上试试标准模板库的常见头文件能不能直接用,如果系统不支持bits/stdc++.h这种万能头文件,提前改写成单独引入具体的头文件,不要把希望寄托在平台的编译器配置上。
第三个坑是不重视边界条件。程序题里最常丢分的就是边界条件:空输入、数组长度是0、字符串全是问号、k值大于数组长度等等。这些情况可能不会出现在样例中,但判题系统用隐藏用例来测。每道编程题写完,都自己构造几个边界用例再跑一遍,很多AC代码从90%的通过率变成100%,就差这一步。
第四个坑是简答题不写结构。简答题虽然没有标准答案,但是阅卷人打分是有踩分点的。回答时用总-分-总的结构,先亮观点再逐步展开,每个要点单独成段。不要写一大段流水账,阅卷人看到一大片文字没有断落,第一反应就是扣分。
5.3 心态管理:笔试中最容易被低估的能力
在线笔试的心态管理,看起来是个软技能,但它的影响非常大。2023秋招的移动端岗位竞争比较激烈,笔试过程中遇到不会的选择题、没思路的算法题,都很正常。关键是不能慌,一套卷子不可能所有题都会做,你要做的是把会的题稳稳拿分,不会的题果断跳过,保证整体总分最大化。
有一个小技巧比较实用:做选择题时,每道题给自己限定90秒。超过这个时间还没思路,先标出来跳过,最后如果有剩余时间再回来纠结。这样不会因为一道题卡住,挤压掉后面编程题的时间。编程题如果连续想了15分钟没有进展,就先把暴力解法写上,不要指望一上来就写出最优解。
校招笔试只是整个秋招流程中的一环,不是终点。笔试通过后面还有面试,真正的技术考察重点在面试环节。我的建议是:把笔试当成一次技术体检,做完之后把不会的知识点补上,把做错的题弄明白,这才是每一场笔试最重要的产出。甚至可以说,你秋招笔试做过的每一套卷子,都是为后面的面试积累经验和素材。我面试时被问到的一些技术点,就是在前一场笔试的错题里复习到的。
这个思路也推荐给正在准备笔试的同学:不要只从“通过了没”的角度看待笔试,要从“这场笔试帮我发现了哪些盲区”的角度去复盘。把秋招当作一个持续学习和迭代的过程,几次笔试不太理想并不代表你不行,只能说明你的薄弱点恰好被考到了。补齐它,就好了。
