3月14日下午,我坐在电脑前完成了OPPO 2026届春招的在线笔试。用到的平台是赛码网,岗位是后端开发,整体题型偏基础算法,三道编程题覆盖了贪心、双指针、二分答案,单选则考到C++、计算机网络和Android底层知识。如果你正在准备OPPO或者其他大厂春招,这篇笔试真题复盘值得看完——我会把每道题从读题到AC的完整思考过程还原出来,包括在考场上踩过的坑,以及那些“如果再给我一次机会我一定先看这里”的细节。
先说结论:这场笔试的难度不算夸张,但很考验答题节奏和边界处理。三道编程题都给了完整的数据范围,暴力基本只能过部分用例,需要你稳定写出O(n)或O(n log n)的解法。单选题部分反而更杂,从TCP拥塞控制到Linux的僵尸进程,再到Android的Handler消息机制都有涉及,没有任何一门课能完全覆盖,靠的是平时的积累。
提示:以下题目内容基于个人回忆整理,细节可能与原题有少量出入,但核心解法与考点是准确的。建议先自己思考一遍再看代码。
1. 整场笔试的题型构成与我的时间分配
1.1 题量、分值分布与平台特点
这场笔试一共两部分:10道单选题和3道编程题。总时长120分钟。单选每题4分,编程题每题20分,总分100分。从分值就能看出编程题才是大头,一道题不小心爆零,基本就告别这轮筛选了。
赛码网的操作界面和牛客类似,代码编辑器支持Python、Java、C++,我选的是Python,信手拈来。需要提醒的是赛码网对Python的版本支持是3.8,不支持的语法特性提前避开,我在考场上就没敢用match-case这类新特性。
编程题的输入输出是标准的sys.stdin.readline()模式,比牛客的模板友好一点,但还是建议提前把本地读入模板准备好,省得现场手敲浪费时间。我自己的固定模板是这样:
import sys def solve(): data = sys.stdin.read().strip().split() if not data: return # 按需解析 data ... if __name__ == "__main__": solve()用sys.stdin.read()一次性读入,再统一split,比一行行readline()更快也更不容易出错,尤其是数组输入较多的时候。
1.2 做题顺序:把最熟悉的题先拿满
我拿到试卷后的第一件事不是逐个看题目,而是花3分钟把所有题目全部扫一遍。当时扫完的感受是:
- 单选涉及的知识点比较杂,想拿满分不容易,但也不该花太多时间。
- 编程题第1题是经典的跳跃游戏变体,思路秒出。
- 第2题是滑窗统计,很直白。
- 第3题眼前一亮,是“分段最小化最大值”的二分答案套路。
于是我的策略很简单:先写第1题,再用第2题保底,最后啃第3题。单选放在编程题之间作为“换脑”,避免连续高强度的思考让自己卡住。
实际执行下来,这种节奏非常有效。第1题写完带测试不到15分钟,第2题花了20分钟,第3题花了35分钟。留了20分钟回头检查边界。整套卷子做完还有一点余量,心态比较稳。
考点提醒:大厂在线笔试的时间一般刚好够用或略有紧张,先扫全卷、按熟悉程度排序作答,是提升整体AC率的有效习惯。
2. 第一题:ColorOS桌面小组件的跳跃问题,从贪心到路径还原
2.1 题目回忆与暴力思路
这道题披了一层很OPPO的外衣:你在排列ColorOS桌面上的小组件,每个位置有一个数字,代表从这个位置最多可以往后跳多少格。现在从第一个位置出发,目标是到达最后一个位置,问最少跳几次。
核心就是LeetCode第45题的变体。原题我也刷过,看到的时候差点笑出声,但笔试平台不会白送分,它加了一个小改动:输出时不仅要求最少跳跃次数,还要打印出一条可行的最短跳跃路径。这就从前缀和、贪心升级到了“贪心+回溯记录”。
先说我第一时间想到的暴力写法:
def min_jumps_with_dfs(nums): n = len(nums) if n <= 1: return 0, [0] best = float("inf") best_path = [] path = [0] def dfs(pos, steps): nonlocal best, best_path if steps >= best: return # 剪枝 if pos == n - 1: best = steps best_path = path.copy() return for nxt in range(pos + nums[pos], pos, -1): if nxt >= n - 1: dfs(n - 1, steps + 1) elif nxt > pos: path.append(nxt) dfs(nxt, steps + 1) path.pop() dfs(0, 0) return best, best_path数据范围如果给到n ≤ 1000,这种DFS加剪枝勉强能过小数据,但如果n ≤ 10^5,第二层循环就可能爆炸。实际情况就是后者,所以必须上O(n)的贪心。
2.2 为什么贪心能保证最少跳跃次数
跳跃问题的经典结论是:在能到达的位置范围内,每次都选出覆盖最远位置的那个点,一定会得到最优解。数学上可以理解为:任何一次“少跳一点的决策”都不会让你覆盖更远的范围,所以用当前步能到达的最远位置作为下一次起跳的决策依据即可。
我把这个策略映射到变量上:
cur_end:当前这一跳最远能到哪。far:在当前跳跃步数下,所有可达位置中能再跳到的最远距离。jumps:已跳跃次数。
def min_jumps(nums): n = len(nums) if n <= 1: return 0, [0] jumps = 0 cur_end = 0 far = 0 for i in range(n): far = max(far, i + nums[i]) if i == cur_end: jumps += 1 cur_end = far if cur_end >= n - 1: return jumps, None # 至少要跳到终点,但路径后面单独求 return -1, None这段已经能把跳跃次数算出来,而且复杂度是O(n)。笔试时如果只要求次数,到这里就已经拿满分了。不过题目偏偏要求路径,这让我在考场上多想了几个环节。
2.3 路径还原:从最远决策反推
路径还原的常规做法是从终点反向做一次“下降”:已知最少需要jumps步,那么在第steps-1步时,一定有一个位置能一步跳到终点,而且这个位置是上一步可达范围内最舒适的“跳板”。
我在考场上采用的实现是先用贪心算出最少次数,再反向找路径:
def min_jumps_with_path(nums): n = len(nums) if n <= 1: return 0, [0] jumps = 0 cur_end = 0 far = 0 for i in range(n - 1): far = max(far, i + nums[i]) if i == cur_end: jumps += 1 cur_end = far if cur_end >= n - 1: break path = [n - 1] step = jumps # 从终点往前找第 step-1 步能到达的位置 while step > 0: for i in range(n - 2, -1, -1): if step >= 2: # 第 step-1 个位置必须能跳到当前 path[-1] if i + nums[i] >= path[-1] and i + nums[i] >= path[-1]: pass # 简化:只要能到达 path[-1],且在更早的步数可达范围内即可 # 实现略 step -= 1 return jumps, path说实话,反向路径还原很容易写乱。为了保险,我换了一种更稳的办法:在正向贪心的时候顺手记录每一层“最远可跳点”的候选位置。
def min_jumps_with_path_v2(nums): n = len(nums) if n <= 1: return 0, [0] jumps = 0 cur_end = 0 far = 0 # 每跳一步选择的落脚点 choose = [] for i in range(n - 1): # 在当前位置范围内更新最远覆盖 if i + nums[i] > far: far = i + nums[i] # 如果 i == cur_end,说明当前这一跳范围结束,必须选择落脚点 if i == cur_end: jumps += 1 cur_end = far choose.append(i) # 这里是上一段范围内最后遍历到的位置,作为跳板 if cur_end >= n - 1: break # 构造真实路径:0 -> choose -> n-1 path = [0] for pos in choose: if pos > path[-1]: path.append(pos) if path[-1] != n - 1: path.append(n - 1) return jumps, path这段代码在考场上跑过了所有样例。核心点是:当区间边界cur_end触发时,记录下当前已经扩展到的最远位置对应的下标i,这个i恰好可以作为下一跳的起点。
不过要承认,这个版本的“choose”记录方式存在一点取巧成分,因为它把触发边界时的i作为起点,如果数据不够规律,路径可能不是最优的完整路径。更严谨的写法是维护一个“上一跳终点数组”,用DP记录到达每个位置的最小步数,再回溯。可惜考场上我没有太多时间纠缠,所以如果你追求100%正确性,第二套方案的边界细化可以参考:
def min_jumps_with_dp(nums): n = len(nums) dp = [float("inf")] * n pre = [-1] * n dp[0] = 0 for i in range(n): for j in range(i + 1, min(n, i + nums[i] + 1)): if dp[j] > dp[i] + 1: dp[j] = dp[i] + 1 pre[j] = i # 还原路径 path = [] idx = n - 1 while idx != -1: path.append(idx) idx = pre[idx] path.reverse() return dp[n - 1], pathO(n²)在数据范围不大时完全够用,而且绝对正确。笔试里如果拿不准贪心的路径记录,果断用DP,AC优先于炫技。
3. 第二题:统计“每个字符出现次数都不超过k”的子串数量
3.1 题意与数据范围
第二题的情景是:你的手机便签里有一段纯小写字母文本,现在要统计有多少个子串满足——这个子串中每个字母的出现次数都不超过k。注意,不是统计最长长度,而是统计数量。
第一版朴素解法很简单,枚举所有子串并统计字符频次。复杂度O(n²),对于n ≤ 5000的case够用,但题目给的n ≤ 10^5,O(n²)会超时,必须想线性做法。
我当时盯着题目想了几分钟,直觉告诉我这是滑动窗口。但滑动窗口通常用于求“最长窗口”,这里要求“子串数量”,需要额外绕一道弯。
3.2 滑动窗口为什么能统计数量
关键技巧在于:对每个右端点right,维护一个左端点left,使得[left, right]是满足条件的最长区间。由于子串要求连续,所有以right为结尾的合法子串,其实就是left到right之间的所有起点:
- 起点可以是
left, left+1, ..., right。 - 每一个起点到
right构成的子串都满足条件。 - 这个区间内起点的数量等于
right - left + 1。
所以每次移动右端点后,把right - left + 1累加进答案即可。这是“滑窗求子串数量”的通用套路,和求最长窗口只有一步之遥。
代码实现如下:
def count_valid_substrings(s: str, k: int) -> int: left = 0 cnt = {} ans = 0 for right, ch in enumerate(s): cnt[ch] = cnt.get(ch, 0) + 1 # 当前右端点的字符出现次数超过k,左指针右移 while cnt[ch] > k: cnt[s[left]] -= 1 left += 1 ans += right - left + 1 return ans这里有个细节容易忽略:while循环的收缩条件只需要盯着当前新加入的右端点字符。因为其他字符在之前的窗口中已经满足不超过k,只有新字符可能破坏条件。这是最简化的判断,也是滑窗题能写出短代码的关键。
如果你不放心,可以把while条件写成“任何一个字符的频次都≤k”,但要遍历字典判断,复杂度就不是严格O(n)。我当时在考场上一开始就是这么写的:
while any(v > k for v in cnt.values()): ...样例能过,但稍微大一点的数据就慢。后来意识到只需要判断s[right]自己就够了,才改成了上面的版本。这个是考场上一个比较值得记录的优化点。
3.3 边界条件与实现细节
数据范围看清楚了再动手。这道题还埋了一个小坑:如果字符串很长但k很小,甚至k=0,会出现什么?如果k=0,那任何含字符的子串都不合法,答案是0。上面的代码在k=0时会自然退化成:每次加入字符后cnt[ch]必然大于0,于是left一直右移直到cnt[ch]变为0,最后的ans也永远是0。逻辑没有bug,很稳。
还有个大坑:子串非空,单个字符也是子串。所以代码里ans的累加天然包含单字符子串,不需要额外处理。
我额外用暴力代码做了对拍:
def brute_force(s, k): n = len(s) ans = 0 for i in range(n): cnt = {} for j in range(i, n): cnt[s[j]] = cnt.get(s[j], 0) + 1 if cnt[s[j]] > k: break ans += 1 return ans对拍了几组随机构造的数据,滑窗解法和暴力完全一致。这一步让我在提交时非常有底气。
考后我复盘发现,这道题其实不只是OPPO喜欢考,很多大厂的笔试都爱用滑窗变体来考“字符串计数类问题”。记住一个核心即可:“求数量”时,让每个右端点对应一个合法左端点的区间,累加区间长度。
4. 第三题:把一组文件日志分成k段,最小化最大段和
4.1 题目描述与分析
这一题的场景是云端同步:给你一个非负整数数组,代表每个待传输文件块的大小,现在需要按顺序将这些文件块分成连续的k组,每组的总大小尽量均衡,问在最优分组下,最大的一组文件总大小最小是多少。
看到“最大化最小”或者“最小化最大”,条件反射就应该是二分答案。这种问题的标准解法是:
- 二分枚举答案
limit,即“最大段和”的候选值。 - 用贪心检查,在段和不超过
limit的前提下,能否把数组分成不超过k段。 - 如果
k段能完成,说明limit可能还可以更小,继续向左二分;否则需要调大limit。
这个套路我在刷LeetCode 410“分割数组的最大值”时练过很多次,所以这道题的思路来得很快。
4.2 check函数的设计,以及为什么贪心是可行的
check函数是二分答案题目的灵魂。我的写法如下:
def can_split(nums, k, limit): cnt = 1 cur = 0 for x in nums: if x > limit: return False if cur + x > limit: cnt += 1 cur = x if cnt > k: return False else: cur += x return True逻辑很简单:按顺序累加,一旦当前段放下一个元素就会超过limit,就立即开新段。如果你对此有疑问,可以想想为什么“尽量开新段晚一点”是安全的。假设某一步有两个连续的元素a和b,把它们放在同一段都不会超限,那显然不应该拆开,因为拆开只会增加段数而不降低任何段的总和——在“段数不超过k”的约束下,让前面的段尽可能长,是对后面更有利的。
这个性质在非负数组下成立,因为元素都是非负的,提前拆段不会让任何一段“更小”,只会增加段数,而增加段数对可行性没有帮助。
4.3 二分边界:这道题最容易翻车的地方
我见过很多人在二分边界上栽跟头。写二分答案时的“左边界”和“右边界”需要根据题目场景确定:
- 左边界
left:理论上limit必须至少是数组中的最大元素,否则单个元素就无法放入任何一段。 - 右边界
right:limit最大可以设为数组总和,此时只需一段就能装下所有元素,必然可行。
然后套用“找左边界”的模板:
def split_array(nums, k): left = max(nums) right = sum(nums) while left < right: mid = (left + right) // 2 if can_split(nums, k, mid): right = mid else: left = mid + 1 return left这里mid向下取整没问题。检验一下:如果当前mid可行,就说明答案不会大于mid,所以把右边界压缩到mid;如果不可行,答案一定大于mid,所以左边界设为mid + 1。模板背熟之后,最怕的是把left和right的更新写反,我通常会花半分钟用两个最简数据手动验证:
- 数组
[1, 2, 3],k=3,答案应为3,因为每段一个元素,最大段和是3。 - 数组
[1, 2, 3],k=1,答案应为6,因为只能分一段。
把这两组数据代入二分逻辑跑一遍,能迅速发现边界是否写反。这个习惯帮我避免了很多无谓的WA。
4.4 考场上我为什么会卡住
这道题其实思路不难,但我还是卡住了几分钟,原因在于我把“恰好分成k段”和“最多分为k段”搞混了。如果题目要求“恰好k段”,那can_split里cnt < k时是否还能再继续拆段?是可以的,因为非负数组的连续拆分总会让每段和变小,不会破坏limit约束。所以“不超过k”的check天然兼容“恰好k”的要求。
这个逻辑写代码时容易绕晕,但想明白了以后,check函数保持原封不动即可。我最终提交的代码就是上面的版本,样例全部通过。
5. 单选与基础知识题里的易错点复盘
5.1 C++与数据结构
单选题一共10道,我印象比较深的是几个容易混淆的知识点。第一道考了C++虚函数表:一个有虚函数的类实例化后,对象内存布局中最前面的8字节(64位机器)是虚表指针,它指向该类的虚函数表。题目给了四个类继承关系的描述,让你选出虚表指针数量正确的是哪个选项。
这道题光靠死记硬背很容易错,底层逻辑是:虚表指针本质上是类自身包含的、用于多态分发的隐藏成员,它和继承链上父类的虚表指针是不同的。只要当前类里拥有或重写了虚函数,它就需要自己的虚表指针对应自己的虚函数表。
第二道印象深的是红黑树的性质判断题。它混淆了两个学生非常容易搞混的点:
- 红黑树不严格平衡,最长路径不超过最短路径的两倍。
- AVL树是严格平衡的,左右子树高度差绝对值不超过1。
- 红黑树插入最多需要2次旋转,删除最多需要3次旋转,而AVL树插入/删除可能需要回溯很多次。
题目给出的选项里,有一个写着“红黑树左右子树高度差不超过1”,这明显是AVL的定义,不能选。
5.2 操作系统和计算机网络
网络部分考到了TCP的拥塞控制。选项里有一个“快重传算法的作用是降低拥塞窗口”,这实际上是错的。快重传的作用是让发送方尽快收到丢包反馈,从而快速重传丢失报文,拥塞窗口的调整是靠慢启动、拥塞避免和快恢复实现的。
操作系统部分考了进程与线程,并且玩了个文字游戏:“线程切换一定比进程切换快”。这个“一定”就很坏,因为如果两个线程不在同一个进程里,切换成本可能和进程切换接近;同一个进程内线程切换虽然相对轻量,但也存在TLB刷新、用户态到内核态的转换成本。正确选项应该是“同一进程内线程切换通常比进程切换开销小”。
这里值得单独列一个表格,把我踩过和见过的易错点整理出来:
| 考点 | 易错选项 | 正确理解 |
|---|---|---|
| 红黑树 | 左右子树高度差不超过1 | 最长路径不超过最短路径的2倍即可 |
| TCP快重传 | 降低拥塞窗口 | 让发送方快速重传丢失报文 |
| 线程切换 | 一定比进程切换快 | 同一进程内通常更快,跨进程不一定 |
| Linux僵尸进程 | 父进程调用wait后仍存在 | wait/waitpid回收后僵尸状态被清除 |
| C++虚表指针 | 继承后只保留父类指针 | 自身有虚函数时会维护自己的虚表指针 |
5.3 Android专项问题
这类单选一直是OPPO笔试的特色,因为毕竟是手机厂。题目问了Handler机制中MessageQueue的阻塞原理,选项里有“next()方法使用Thread.sleep()阻塞线程”。不熟悉Android源码的同学很容易被带偏。
正确的机制是:MessageQueue.next()在消息队列为空时,会调用nativePollOnce()进入Linux epoll等待,线程进入Idle状态而不是sleep状态。二者最大的区别是sleep会占用CPU忙碌等待?不会,但sleep无法做到“有消息到来时立刻唤醒”的精准响应,而epoll是基于事件驱动的,只在消息到来的瞬间被唤醒。
这道题的启示是:投OPPO这类硬件厂商的软件岗,如果简历方向是Android,那考前最好刷一遍Handler、Binder、View绘制流程的基础问题。它们不一定多深,但一定会出现。
6. 从这场笔试里沉淀下来的Debug顺序与提交习惯
6.1 先跑样例,再跑边界
笔试题里最可惜的丢分不是不会做,而是“样例过了但边界错了”造成的WA。我现在已经形成了一套固定的提交前检查顺序:
- 先跑题目给的示例,确认基本流程正确。
- 再跑最小规模输入,比如
n=1、数组长度为0的情况。很多题在n=1时会出现数组越界或者除以0的异常,必须提前处理。 - 跑最大规模输入,确认时间复杂度和内存没问题。我一般会在本地生成一个
10^5长度的随机数组,看代码是否能在1到2秒内跑完。 - 如果有暴力解法,和最优解法做随机对拍,至少拍100组。
这一步真的非常关键。第三题的二分模板,我在提交前就用k=数组长度和k=1两组数据进行了验证,确认边界无误后才提交。
6.2 申明变量名和数据范围
第二题我差一点翻车,是因为最初用了Counter来统计字符频次。Counter在每次滑动窗口更新时需要反复操作字典,虽然也能跑,但性能比手工维护的普通字典差一些。在n=10^5的规模下,这种微小的性能差异不至于超时,但如果是多个测试用例叠加,就可能有隐患。
所以我在笔试中更倾向于用“最朴素的字典+整型计数”而不是花哨的库函数,尤其在比赛环境下,简单直接意味着出问题的概率更低。
另外一个习惯是:在代码开头注释里写清楚数据范围。这看似多此一举,但在做二分、滑窗时能防止自己忘记复杂度约束。比如第三题我在注释里写下“n ≤ 10^5,sum(nums) ≤ 10^9”,这样在验证二分上限时就能快速判断right会不会溢出。
6.3 关于评测平台的输入解析
赛码网和牛客的输入格式有些不同,赛码网很多时候不会明确告诉你第一行是几个数,而是直接给一整行空格分隔的数据。我见过不少人在这类平台上栽在输入解析上,代码逻辑没毛病但读入就出错。
稳妥的做法是永远优先用sys.stdin.read()整体读取,然后按空格切分。这样无论题目怎么给输入,都能正确处理。如果是多组测试数据,就先读第一个数字为T,再循环解析,不要贪图写短的代码。
import sys def solve(): data = sys.stdin.read().split() if not data: return idx = 0 t = int(data[idx]); idx += 1 for _ in range(t): n = int(data[idx]); idx += 1 arr = list(map(int, data[idx:idx+n])); idx += n # 处理当前测试用例这个模板非常适合“第一行是测试用例数,之后每行是数组”的常见笔试输入类型。
其实想想,整场OPPO笔试的难度是典型的中档偏上:编程题不算很难,但如果不熟悉套路,很容易在第三题的二分边界上纠结半天。我个人的感受是,考前把“跳跃游戏”“滑窗计数”“分割数组最大值”这三类经典题吃透,就能覆盖不少大厂笔试的编程题部分。而单选题的积累更像一场马拉松,C++、操作系统、网络、Android基础一个都不能明显短板。
最后再分享一个小技巧:笔试结束后趁记忆还热,立刻把题目和思路整理归档。这套“考后复盘模板”我用了两三年,让我在后续面试时能清楚地讲出自己遇到过的题型和解题思路——面试官问到项目或刷题经历时,随手就能举出一个真实的笔试案例。希望这篇复盘也能成为你的备考素材之一。