主管负责人面 · 技术 × 业务

番茄小说客户端模拟面试报告

候选人:杨浩目标公司:字节跳动岗位:番茄小说客户端日期:2026-08-11

总评

47/ 100
❌ 当前不建议直接录用

项目经历真实,具备从 Camera、ISP、驱动到 ARM 应用的跨层实践能力;但客户端动机、C++ 资源管理、异步状态设计和线上稳定性决策尚未达到主管可放心放权的程度。优势是学习跨度和真实排障潜力,短板集中且可以针对性补齐。

能力维度

专业能力5.5/10

底层项目扎实,但客户端 C++ 与生命周期基础不足。

沟通表达4.5/10

结论直接,但论证、边界和量化结果经常缺失。

逻辑思维4/10

能抓到局部方向,尚未形成完整故障模型与状态机。

应变能力4/10

高压追问下容易停在一句话结论,需要结构化展开。

文化匹配5/10

认可 AI 和平台,但对阅读业务本身的长期动机不够清晰。

成长潜力7/10

测试→硬件→驱动→全栈的迁移经历证明学习能力较强。

逐题反馈

Q1|90 秒自我介绍与岗位匹配

3/10

回答概要:智能相机 MVP 最有说服力;当前是嵌入式全栈;字节平台好并认可“拥抱 AI”。

优点:快速指出了代表项目,也能把现岗位与端上开发建立联系。
不足:没有形成完整自我介绍;“平台好、拥抱 AI”适用于大量岗位,不能证明为何长期选择番茄小说客户端。
建议:把职业主线定义为“把底层能力做成直接影响用户体验的端上产品”,AI 只作为效率工具。

Q2|智能相机 MVP 的个人贡献

6/10

回答概要:负责传感器选型、驱动适配、ARM 验证软件与参数链路,最终可在 MVS 中控制摄像头。

优点:链路完整,项目真实性较强,能说明自己不是只负责单一页面或脚本。
不足:“几乎负责全部业务”口径过宽;成功标准只有“能够操控”,缺少帧率、稳定运行、重启恢复和异常率等验收数据。
建议:区分实验 MVP 与正式产品,明确本人、硬件同事和后续量产团队的边界。

Q3|ISP 白平衡寄存器方案

5/10

回答概要:关闭 3A,开启写使能,通过 ISP 资料找到寄存器路径,加锁写入,用白纸校准、色卡验证。

优点:能查阅底层公开资料并定位到实际寄存器,体现真实硬件排障能力。
不足:仍停留在验证方案;缺少正式驱动封装、参数范围校验、失败回滚、重启恢复和量化色差指标。
建议:回答正式交付题时,固定补充“归属层、并发、异常、回滚、监控、验收”六项。

Q4|没有 AI 工具是否仍愿意做客户端

3/10

回答概要:客户端工作也能让 AI 参与;硬件资料和寄存器仍需要人工复核。

优点:能够举出不能盲信 AI 的真实场景。
不足:仍然围绕 AI 回答,没有证明对阅读体验、性能、弱网和稳定性工作的兴趣。
个性化参考:“我的经历跨度大,但主线一直是端上产品。Camera 项目里,用户看到的帧是否正确最终取决于并发、buffer 和状态一致性;阅读页同样要求把底层资源问题转化为稳定体验。即使暂时不能使用外部 AI,我仍愿意长期做这种问题。”

Q5|弱网快速翻页导致章节覆盖

4/10

回答概要:先查服务端、网络、缓存和页面日志;展示前校验书名、章节数和哈希,旧内容进入有限缓存。

优点:意识到旧响应不应直接更新当前页面,也知道旧结果仍可能具有缓存价值。
不足:书名不是稳定主键;缺少 book_id、chapter_id、content_version、page_generation、取消令牌和页面生命周期;“永久缓存但限制大小”概念冲突。
建议:UI 更新条件应为“页面会话仍有效 + 当前 book/chapter/version 匹配”;旧响应可以缓存,但不能触碰失效页面。

Q6|线程安全 LRU 与对象生命周期

5/10

回答概要:哈希表加双向链表;get 会移动节点,因此需要写锁;移动时加锁,倾向返回值。

优点:准确识别 LRU 的核心数据结构和 get 的写语义。
不足:没有解决大型对象复制成本及释放锁后的生命周期安全;对 shared_ptr/weak_ptr 不熟悉。
推荐设计:锁内查表并 splice 到链表头,复制一份 shared_ptr<const Chapter> 后释放锁;淘汰仅删除缓存引用,调用方全部释放后对象才析构。

Q7|自适应预取与 A/B 实验

4/10

回答概要:根据阅读时长、连续阅读行为、设备性能、内存、网络、服务器状态和电量动态调整。

优点:自适应输入维度较完整,能看到用户、设备和服务器三侧成本。
不足:没有独立给出实验组、对照组、主指标、护栏指标和停止条件。
建议:主指标使用翻页等待率或章节打开成功率;护栏包含无效预取率、流量、耗电、P99 内存、OOM、崩溃和服务端 QPS。

Q8|低端设备 OOM 的灰度决策

3/10

回答概要:先看影响人数;因未影响大部分用户,倾向继续灰度并后续优化。

优点:知道先观察设备配置和受影响人群。
不足:忽略了 OOM 从 0.1% 到 2% 的 20 倍恶化;大 DAU 产品中的 3% 可能是大量用户;缺少定向关闭与稳定性红线。
正确决策:冻结灰度扩张,通过远程配置对低端设备关闭新策略;保留正常设备实验。无法准确隔离时启用全量 kill switch,修复后从小流量重新验证。

Q9|RAII、智能指针与异步回调

2/10

回答概要:超出当前理解,要求参考答案。

不足:这是客户端 C++ 的关键基础,会直接影响文件描述符、线程、网络连接、页面回调与缓存对象安全。
建议:必须掌握 unique_ptr 唯一所有权、shared_ptr 共享生命周期、weak_ptr 不续命及打破循环引用;亲手实现一次页面—任务异步回调。

Q10|入职 30 天计划

2/10

回答概要:未独立作答,要求参考。

个性化参考:第 1 周跑通构建、阅读链路和监控;第 2 周交付章节请求取消/旧响应隔离类小需求;第 3 周复现并分析一类弱网或 OOM 历史问题;第 4 周独立完成一个小功能的代码、测试、真机、灰度和复盘闭环。

主管面题目地图

方向题目核心考察
动机为何番茄客户端,而不是泛 AI 岗位业务兴趣、长期稳定性
项目智能相机 MVP 与 ISP 白平衡真实性、边界、正式交付
异步弱网请求乱序覆盖页面generation、取消、生命周期
缓存线程安全 LRUO(1)、锁、对象所有权
业务自适应预取和 A/B 实验主指标、成本护栏
稳定性低端机 OOM 是否继续灰度止损、定向开关、红线
C++RAII 与智能指针资源安全、循环引用
成长入职 30 天计划可验证产出、迁移能力

备考行动清单

1

补齐 C++ 生命周期基础

RAII、移动语义、unique/shared/weak_ptr、循环引用、线程退出与异步回调。不要只背定义,要写一个页面退出后自动取消下载且不会回调失效 UI 的 Demo。

2

建立客户端状态模型

统一使用 book_id、chapter_id、content_version、page_generation 和 cancellation token 描述乱序请求、切书、退出页面与预取缓存。

3

训练业务实验与稳定性决策

任何优化题都回答:目标用户 → 主指标 → 成本护栏 → 灰度分层 → 停止阈值。稳定性不能只看是否影响“大多数”。

4

压缩表达

采用“结论一句 → 方案三点 → 一个取舍 → 指标/验证”。普通问题控制在 60–90 秒,不确定时明确边界,停止猜术语。

三面高概率提问与个性化参考回答

以下不是只给答题框架,而是结合你的 Camera、RK3588/RK3576、CamOS、WOL、青创通和 AI Coding 经历写出的可直接练习版本。点击题目展开。

A|动机与稳定性

Q1|为什么从硬件/嵌入式全栈转客户端?主管必问

参考回答

我的经历看起来跨度比较大,但主线一直是做端上产品。硬件阶段我处理 sensor、驱动和 Camera 数据链路,CamOS 阶段则把这些设备能力做成用户能直接操作的产品。我发现自己最有动力的工作,不是只停留在某一个底层模块,而是同时处理性能、资源、网络和交互,并最终改善用户体验。

因此我选择客户端不是因为否定硬件,也不是单纯比较薪资,而是通过这段经历确认自己更适合面向用户的端上开发。Camera 项目中积累的并发、buffer 生命周期、存储和稳定性经验,也能迁移到阅读客户端。

可能追问:以后遇到更热门的方向是否还会转?回答重点:方向可以学习,但职业主线已经确定为端上产品与客户端工程。
Q2|为什么是番茄小说,而不是其他客户端?动机

阅读是长期驻留、强状态的客户端场景。用户可能连续阅读几十分钟,冷启动、翻页流畅、弱网、离线缓存、阅读进度、耗电和稳定性中的小问题都会被反复感知。我过去处理过 Camera 实时链路、帧错位、buffer 生命周期和存储问题,本质上也是让复杂底层状态最终呈现为稳定体验。

番茄小说的业务规模也意味着性能优化和稳定性改进可以影响大量真实用户。我虽然没有原生移动端经历,但愿意从阅读链路中的缓存、异步状态和性能问题切入,长期补齐客户端能力。

可能追问:你对番茄哪项体验最感兴趣?可选弱网连续阅读、字号重排、进度同步或听书缓冲其中一项深入。
Q3|你没有 Android/iOS 经验,凭什么能快速上手?压力题

我不会把“做过全栈”直接等同于“已经会客户端”。我的优势是多次完成陌生领域迁移:从测试进入 PCB 验证,再进入 Linux sensor/ISP 和 CamOS 全系统,并且都形成了真实交付。我的学习方式不是只看教程,而是先建立生命周期、线程、存储、网络和 UI 状态模型,再选择一个小功能完成真机闭环。

如果入职,我会先做章节加载、缓存、进度恢复和弱网状态这类范围明确的需求,通过代码评审、自动化测试、真机和灰度数据证明上手能力。

可能追问:入职前能做什么证明?回答:完成一个原生阅读 Demo,覆盖章节加载、本地缓存、旋转/字号重排和请求取消。
Q4|如果工作主要是性能、缓存和线上排障,没有 AI 功能呢?稳定性

我仍然愿意做。AI 是提高搜索、实现和测试效率的工具,不是我选择岗位的前提。我过去做硬件适配时,需要长期查 datasheet、寄存器、时序和驱动源码,很多结论必须人工复核和实机验证。Camera 的 N-1 帧问题也不是一次生成代码就能解决,而是持续分层加日志、排除假设并做多场景回归。

客户端的卡顿、OOM、弱网和状态错乱同样需要这种耐心。我真正感兴趣的是把复杂系统问题变成稳定的用户体验。

Q5|如果入职后发现工作和预期不一样怎么办?行为题

我会先明确实际业务目标、团队分工和评价标准,给自己至少一个完整交付周期去适应,而不是因为技术栈或任务类型不同立即否定岗位。遇到不熟的模块,我会按小功能、代码评审、测试和复盘的方式补齐。

如果长期方向确实需要调整,我也会先完成已承诺的交付和交接,不会在项目中途退出。过去从硬件验证转到系统和全栈时,我也是先把当前阶段的 MVP 做完,再完成团队分工迁移。

B|项目深挖

Q6|最能证明你适合客户端的项目是什么?项目主线

我会选择 Camera 外部触发下的 N-1 帧错位。现象是用户第 N 次触发,预览和处理结果却收到第 N-1 帧。我的任务是判断问题发生在前端、用户态 pipeline、V4L2 queue,还是 CIF/ISP。

我先给用户态原始帧增加 sequence,排除了前端和网络缓存;随后在 sensor、CIF、ISP、VB2 和出队路径分层加日志,定位到 ISP 会持有一帧并等待下一帧边界。修复时处理了触发模式下的 pipeline 重建、buffer 生命周期和帧序号对齐,避免旧 pipeline 的迟到帧污染新会话。最后在真实板卡上覆盖 4 类重启场景,每类 12 次,共 48 次触发,没有缺失或重复。

这与客户端很接近:异步结果可能乱序,资源有明确生命周期,旧状态必须隔离,用户最终看到的内容必须和当前操作一致。

可能追问:为什么不是前端缓存?为什么 48 次足够?如果重新设计会增加哪些自动化与指标?
Q7|智能相机 MVP 你到底负责了什么?边界

早期新部门需要验证 Camera 链路是否可行,我承担了实验 MVP:参与传感器选型,完成 Linux 驱动和设备树适配,在 ARM 板端开发验证软件,并打通 GigE Vision 链路,使电脑端 MVS 能够查看画面和调节曝光、增益、白平衡等参数。

这个 MVP 的目标是验证链路,不是最终量产产品。后续正式硬件和量产设计由团队继续完善,我主要继续负责 Camera/ISP 与系统软件。因此我想证明的是自己能快速跨领域完成验证闭环,而不是把团队最终成果全部归到个人。

可能追问:MVP 的量化验收是什么?需准备帧率、稳定运行、重启恢复、参数范围及异常处理数据。
Q8|直接修改 ISP 寄存器如何做成正式功能?技术深挖

实验阶段我关闭 3A,通过写使能和 ISP 寄存器验证了白平衡增益可调。正式产品中我不会让业务层直接写寄存器,而是把能力封装在驱动或受控的底层服务中,对外提供明确的参数接口。

写入前校验范围和当前 ISP 状态,用互斥锁保证同一时间只有一个配置写者;保存旧值,写入或校验失败时回滚。pipeline 重启、切换分辨率和设备恢复时重新应用已持久化配置。验证上先用中性灰/白目标检查 RGB 通道接近程度,再用标准色卡对比调整前后的色偏和稳定性,并覆盖多种曝光和色温环境。

Q9|讲一次失败或技术判断错误反转题

我早期排查 IMX415 的某项外部触发能力时,多个 AI 模型给出了相似结论,我因此过度相信“多个模型一致就更可靠”,在错误方向上浪费了时间。最后我回到 datasheet、驱动源码和实际寄存器能力,确认该功能并不存在。

之后我建立了事实来源优先级:官方文档、源码和实测高于 AI;涉及硬件能力、危险寄存器操作和产品边界时必须二次验证。AI 可以帮助搜索候选方向,但不能作为最终证据。这次错误让我修改了整个研发流程,而不是只记住一个型号的结论。

可能追问:具体浪费了多久、是否影响项目?按真实情况回答,不要编造时间。

C|番茄小说业务场景

Q10|设计阅读页的章节加载和缓存最高频

我先确认阅读模式、是否支持离线、章节大小和低端机内存限制。整体分为服务端分页、内存窗口、本地缓存和 UI 渲染四层。

服务端返回稳定的 book_id、chapter_id 和 content_version。客户端展示当前章节,并根据阅读方向预取前后少量章节;每个页面会话持有 generation,请求完成时只有 book、chapter、version 和 generation 都匹配才能更新 UI。旧响应如果内容有效可以进入对应章节缓存,但不能覆盖当前页面。

内存只保留当前位置附近的有界窗口,采用容量限制和 LRU;普通预取可淘汰,用户主动下载必须单独管理。本地写入使用临时文件、校验和原子替换。关注章节打开成功率、首屏时间、翻页等待率、缓存命中率、无效预取率、P99 内存和 OOM。

Q11|字号变化或屏幕旋转后如何保持阅读位置?状态恢复

不能只保存像素偏移,因为字号和屏幕宽度变化后像素位置失效。我会保存 book_id、chapter_id、段落或字符锚点,并带少量上下文校验。重新排版后定位到相同锚点,再计算新的页面位置。

排版放到后台线程;连续调整字号时取消旧任务,并通过 generation 只接收最后一次排版结果。页面退出或切书后,旧任务即使完成也不能更新已经失效的 UI。

Q12|弱网或断网如何保证阅读体验?弱网

首先保证已加载内容继续可读,离线提示不能遮挡当前正文。网络层区分 DNS、连接、读取超时和业务错误;章节 GET 请求属于幂等操作,可以有限重试,并使用指数退避和随机抖动,不能无限重试。

提前预取少量后续章节,本地缓存带版本和校验。恢复网络后按章节版本增量更新,不清空用户已有可读内容。核心指标是弱网章节打开成功率、弱网首屏时间、重试次数、离线可读时长和失败恢复率。

Q13|快速翻页时旧请求覆盖新页面怎么解决?本次弱项

请求标识不能只使用书名和章节数,应使用稳定的 book_id、chapter_id、content_version,并关联当前页面会话的 generation。用户从第 10 章翻到第 11 章时,UI 当前状态已经更新为第 11 章;第 10 章迟到后,可以校验并写入第 10 章缓存,但因为不匹配当前 chapter 和 generation,不能更新页面。

切换书籍或退出页面时递增 generation 并取消可取消的任务;回调执行前使用 weak_ptr 或生命周期令牌确认页面仍存在,避免异步任务访问已经销毁的页面。

Q14|整本书离线下载怎么设计?状态机

下载任务使用 queued、running、paused、success、failed、cancelled 状态机。以章节为最小单元支持断点续传、版本和内容校验,限制下载并发、磁盘占用和失败重试;应用进入后台、网络切换或进程恢复时可以从持久化状态继续。

用户主动下载的内容不能被普通预取 LRU 清理,只能由用户删除或明确的空间管理策略移除。低电量、非 Wi-Fi 或磁盘水位不足时暂停,并向用户说明原因。书籍更新后按章节版本做增量下载。

Q15|阅读进度如何本地与云端同步?一致性

每次翻页只更新内存状态,不进行高频网络上报;定时、章节切换、进入后台和退出时持久化并批量同步。进度记录包含 book_id、chapter_id、字符锚点、服务端版本、设备 ID 和更新时间。

多端冲突不能只相信客户端本地时间,服务端应生成版本。通常以最新有效阅读进度为主,但需要防止长期离线的旧设备重新上线后覆盖新进度,同时保留用户主动回退阅读的合法行为。

Q16|阅读页卡顿或 OOM 怎么定位?稳定性

先判断是主线程卡顿还是内存问题。卡顿关注长任务、排版计算、磁盘 IO、图片解码、锁等待和频繁 GC;OOM 关注图片/字体缓存、章节预取、排版对象和异步任务是否同时持有大量数据。

通过问题设备分布、操作路径、P95/P99 内存、对象数量、内存快照和引用链区分泄漏与瞬时峰值:页面退出后对象仍有引用链倾向泄漏;对象最终释放但快速翻页时短时间暴增,倾向预取并发和缓存上限问题。

止损上先冻结灰度,通过远程配置对低端设备关闭新策略;治理包括有界缓存、图片降采样、取消旧排版任务、避免主线程 IO 和设备分层。只有 OOM、崩溃率和 P99 内存回到护栏内才继续扩大灰度。

Q17|产品经理说预取越多越流畅,你怎么处理?业务取舍

我先认可减少等待的目标,但不会直接把窗口从 2 章固定增加到 20 章,因为会增加无效流量、内存、磁盘、耗电和服务端压力。

我会做自适应预取:结合阅读速度、网络类型、设备内存、电量和章节大小,在 2 到一个受控上限之间调整。对照组保持原策略,实验组使用自适应策略。主指标选翻页等待率或章节打开成功率;护栏包括人均流量、无效预取率、P99 内存、OOM、崩溃、耗电和服务端 QPS。只有主指标显著改善且护栏不越线才逐步全量。

Q18|听书缓冲与播放状态如何设计?扩展业务

音频按分段缓存,区分播放缓冲和用户离线下载。播放器状态机覆盖 loading、playing、paused、buffering、completed 和 error,并处理进入后台、耳机拔出、电话中断、网络切换和进程恢复。

网络好时适度扩大预取,弱网时优先保证当前段连续播放;缓存有容量和版权边界。核心指标是首播时间、播放卡顿次数、卡顿时长、恢复成功率、流量和耗电。

D|C++、并发和资源管理

Q19|两个线程执行 i++ 为什么不一定正确?并发基础

i++ 是读取、加一、写回三个步骤,不是不可分割的原子操作。两个线程可能同时读取相同旧值,然后分别写回,造成更新丢失;在 C++ 中无同步的数据竞争本身还是未定义行为。

简单独立计数可以使用 std::atomic<int>;需要同时维护多个字段或复合不变量时使用互斥锁。客户端主线程更新 UI,网络、排版和磁盘任务放后台,但异步结果必须同步回主线程并检查页面生命周期。

Q20|RAII、unique_ptr、shared_ptr、weak_ptr 分别是什么?必须补齐

RAII 是把资源获取与对象生命周期绑定:对象构造成功后拥有资源,析构时自动释放,因此中途 return 或异常也不会漏掉清理。文件描述符、网络连接、锁、线程、DMABUF 和临时文件都适合封装成 RAII 对象。

unique_ptr 表示唯一所有权,适合下载任务独占的连接或写入器;shared_ptr 表示多个模块共同拥有对象,最后一个引用释放时析构;weak_ptr 只观察、不延长生命周期,用于异步回调和打破循环引用。页面持有任务,而任务回调又持有页面时,任务应保存页面的 weak_ptr,回调时 lock 成功才更新 UI。

Q21|线程安全 LRU 如何保证对象不被淘汰后悬空?LRU 追问

哈希表加双向链表保证 get/put 平均 O(1),一个互斥锁保护哈希表、链表和容量的一致性。get 在锁内查找并把节点移动到头部,同时复制一份 shared_ptr<const Chapter>,随后释放锁。

淘汰时只从哈希表和链表删除缓存持有的 shared_ptr;如果排版线程仍持有返回对象,对象不会立即析构,最后一个读者释放后才真正回收。链表节点之间不使用 shared_ptr 互相持有,从而避免循环引用。

Q22|C++ 从源文件到可执行文件经历什么?基础复查

第一步预处理,展开头文件和宏并处理条件编译;第二步编译,把预处理后的代码做语义分析和优化并生成汇编;第三步汇编,生成目标文件;第四步链接,解析跨目标文件和库的符号、完成重定位并生成可执行文件。

静态链接把所需库代码复制进可执行文件,体积较大但部署依赖少;动态链接在运行时加载共享库,节省空间并便于更新,但要处理库版本和加载路径。

二面逐题复盘与补充答案

按番茄小说客户端岗位重新校准:二面约 70/100,可进入终面,但网络/系统基础、业务设计和算法是明确风险。

二面 1|自我介绍与跨硬件经历7/10
原回答风险:强调“公司没人、实习生被抓过去”和“硬件工资低”,容易让面试官怀疑转客户端只为薪资,方向还会继续摇摆。

我在深度视觉实习一年多,经历过测试、硬件验证、Linux Camera 链路和 CamOS 全栈开发。看起来跨度大,但主线一直是把真实设备能力做成稳定可用的端上产品。硬件经历让我理解资源、性能和底层链路,全栈经历让我能从交互一直追到系统。我选择客户端,是因为我确认自己更喜欢直接影响用户体验,同时处理性能、网络和稳定性,而不是因为否定硬件。

二面 2|WOL 网络唤醒原理8/10

这题是二面优势项:你能准确说明 6 个 0xFF 加 16 次 MAC 地址,共 102 字节,并说明 UDP、BIOS 和网卡配置。

补充产品边界:Magic Packet 通常通过二层广播或 UDP 广播发送。跨公网真正的难点不是包格式,而是 NAT、CGNAT、路由器广播转发、ARP 状态和安全策略。因此产品更适合有内网代理或可控网关的场景,不能承诺所有电脑都能直接从公网唤醒。

二面 3|输入 HTTPS URL 后发生什么4/10
主要错误:TLS 与 TCP 顺序不清;HTTPS 默认端口是 443;404/429 不是连接超时;RSA、SHA-256、AES 概念混淆。

浏览器先解析 URL,并检查缓存、HSTS 和 Service Worker;然后进行 DNS 解析。HTTP/1.1 或 HTTP/2 通常先连接服务器 443 端口并完成 TCP 三次握手,再进行 TLS 握手;HTTP/3 使用 QUIC。TLS 中客户端校验证书链、域名和有效期,并协商会话密钥。之后发送 HTTP 请求,服务端返回响应。浏览器解析 HTML 和 CSS,构建 DOM/CSSOM,执行脚本、布局并绘制,同时加载其他资源。

二面 4|连接超时、HTTP 错误和重试3/10
主要错误:把服务端数据不存在解释成 connect timeout;混淆 404 与 429;未考虑幂等、退避和重试风暴。

DNS timeout 是解析失败;connect timeout 常见于路由不可达、SYN 丢失、防火墙丢包或服务未监听;read timeout 是连接建立后服务端迟迟未返回。404 是资源不存在,429 是限流,它们说明请求已经到达 HTTP 层。

重试只适合暂时性错误,并考虑幂等性。GET 通常可以有限重试,写请求需要幂等键;采用指数退避加随机抖动,并设置最大次数和总超时。证书错误、权限错误和大多数 4xx 不应盲目重试;429/503 可以结合 Retry-After。

二面 5|TLS 证书与 TCP 三次握手3.5/10
主要错误:“RSA256”“AES 证书”等说法不准确。RSA/ECDSA 用于身份认证相关的非对称能力,SHA-256 是摘要算法,AES 是对称加密。

TCP 三次握手是:客户端发送 SYN, seq=x;服务端返回 SYN+ACK, seq=y, ack=x+1;客户端发送 ACK, ack=y+1,用于同步初始序列号并确认双向收发能力。

TLS 中客户端检查证书链能否追溯到受信任 CA、域名是否匹配、证书是否过期。TLS 1.3 通常通过 ECDHE 协商临时会话密钥,用证书对应私钥完成签名认证,之后使用 AES-GCM 或 ChaCha20-Poly1305 加密应用数据。

二面 6|read() 从用户态进入内核5/10
原回答问题:把虚拟地址到物理地址的转换当作 read 主链路。地址翻译主要由 MMU/TLB 完成,不是每次 read 都显式执行的业务步骤。

用户程序调用 libc 的 read(fd, buf, count),通过系统调用指令切换到内核态。内核根据当前进程的 fd table 找到对应 file 对象,再进入 VFS 和具体文件系统或设备驱动。如果数据在 page cache 中可以直接读取,否则可能发起设备 IO 并让进程等待。最后通过 copy_to_user 把数据安全复制到用户缓冲区,返回读取字节数并恢复用户态。

结合 Camera:读取 V4L2 设备时,VFS 后面进入相应设备驱动和 VB2 buffer 路径。

二面 7|Camera 60 FPS,消费者跟不上7/10

你答到了实时预览允许丢帧、buffer pool、引用计数和避免无限积压,是有效工程答案。

先区分业务语义:实时预览追求低延迟,使用 latest-frame 或 drop-oldest;拍照和关键触发结果可能要求不丢,需要独立有界队列或同步确认。底层使用固定大小的 buffer pool,每帧带 sequence、timestamp 和引用计数。生产者只能写空闲 buffer,消费者持有不可变引用,最后一个消费者释放后 buffer 回池。监控 queue depth、drop count、处理延迟和超时。

二面 8|小说无限滚动、预取和缓存5.5/10
主要缺失:没有先澄清阅读模式;未讲 cursor、乱序、取消、去重、UI 虚拟化和阅读锚点;把 JSON 与数据库当成二选一。

我会分四层回答:服务端使用稳定 cursor 或章节版本;客户端接近边界时预取,请求带 generation,过期结果不更新 UI,并按内容 ID 去重;内存只保留当前位置附近的有界窗口,通过 LRU/TTL 淘汰;离线需求再写本地数据库或文件。UI 使用虚拟化,只渲染可视区域。旋转或字号变化时保存章节 ID 和字符锚点,不保存像素位置。

二面 9|如何用 AI/Agent 给已有客户端加功能8/10

这是优势项,但现场表达应该压缩,减少工具名。

流程是:明确需求、约束和验收标准;只读分析现有架构;形成影响面和实施 Plan,由人删除过度设计;小步修改并查看 diff;执行单测、集成测试、真机与异常路径;最后由人批准提交。客户端还要额外检查生命周期、权限、隐私、弱网、低端机和版本兼容。

二面 10|AI 幻觉与越权操作9/10

这是二面最强回答。IMX415 能力幻觉和 AI 未经允许 merge 的案例真实,也能说明你已经建立规则。

终面补充映射:硬件能力以 datasheet、源码和实测为准;客户端需求以接口契约、埋点口径和产品验收为准。AI 生成的改动即使能运行,也必须检查用户隐私、账号、内容缓存和灰度风险。删除、发布、push、merge 等动作必须显式授权。

二面 11|如果重做智能相机产品7.5/10

你提到 RK3588 转 RK3576、成本下降约 20% 和外围能力取舍,体现了业务意识。

更成熟的供应链表达不是“提前囤内存和闪存”,而是建立 BOM 风险表,为关键料准备双供应商和可替代设计,根据交期与价格滚动备货、分批锁价。技术选型则先定义业务所需算力、接口、功耗和成本,再选择满足需求且留有合理余量的平台,避免一开始过度配置。

二面 12|LRU 手撕4/10
问题:知道哈希表和双向链表,但没有稳定写出可运行实现。终面如果再次出现同等级手写失败,会成为明显否决点。

答题时先说:哈希表负责 O(1) 定位,双向链表负责 O(1) 删除任意节点并移动到头部;头部最新、尾部最旧。编码顺序固定为节点/容器定义 → remove/splice → get → put → 超容量淘汰。下方算法区第 1 题需要空白手写至少三遍。

二面 13|反问环节4.5/10
原回答风险:长时间讨论工作时间、token 成本、AI 安全和程序员是否消失,让反问变成个人焦虑陈述。

终面建议只问两个:

1. 番茄小说客户端目前最希望改善的用户体验或技术指标是什么?新人通常从哪类需求切入?

2. 结合我没有原生移动端经历、但有系统和全栈经验的背景,您建议我入职前优先补 Android/iOS 的哪一层能力?

算法刷题清单|ACM 模式 · C++17

点击题目展开输入输出、思路、复杂度和可直接编译的参考代码。

练习标准
  • 不使用 IDE 补全,15–20 分钟完成核心题。
  • 先口述思路和复杂度,再编码。
  • 输入输出严格按 ACM 模式处理。
  • 写完手动覆盖空/单元素、重复值、容量为 1、全负数等边界。
01|LRU Cache(手写双向链表)必写 3 遍

哈希表负责 O(1) 找到节点,手写双向链表负责 O(1) 删除和移动。使用虚拟 head/tail 后,插入和删除不需要分别判断首节点、尾节点和空链表。

理解步骤:① head 后面是最近使用;② tail 前面是最久未使用;③ get 命中后把节点移动到 head 后;④ put 新节点插入 head 后;⑤ 超容量时删除 tail 前节点;⑥ 哈希表和链表必须同时删除。
输入第一行 capacity q;随后 q 行:get keyput key value
输出每个 get 输出值,不存在输出 -1。

时间:get/put 平均 O(1);空间:O(capacity)。

#include <bits/stdc++.h>
using namespace std;

class LRUCache {
    struct DLinkedNode {
        int key;
        int value;
        DLinkedNode* prev;
        DLinkedNode* next;

        DLinkedNode() : key(0), value(0), prev(nullptr), next(nullptr) {}
        DLinkedNode(int k, int v)
            : key(k), value(v), prev(nullptr), next(nullptr) {}
    };

    int capacity;
    int currentSize;
    unordered_map<int, DLinkedNode*> cache;
    DLinkedNode* head; // 虚拟头节点:head->next 是最新节点
    DLinkedNode* tail; // 虚拟尾节点:tail->prev 是最旧节点

    // 把 node 插入虚拟头节点后面
    void addToHead(DLinkedNode* node) {
        node->prev = head;
        node->next = head->next;
        head->next->prev = node;
        head->next = node;
    }

    // 把 node 从当前链表位置摘除,但暂时不 delete
    void removeNode(DLinkedNode* node) {
        node->prev->next = node->next;
        node->next->prev = node->prev;
    }

    void moveToHead(DLinkedNode* node) {
        removeNode(node);
        addToHead(node);
    }

    // 删除链表中的最旧位置,返回节点给调用方清理哈希表和内存
    DLinkedNode* removeTail() {
        DLinkedNode* oldest = tail->prev;
        removeNode(oldest);
        return oldest;
    }

public:
    explicit LRUCache(int cap) : capacity(cap), currentSize(0) {
        head = new DLinkedNode();
        tail = new DLinkedNode();
        head->next = tail;
        tail->prev = head;
    }

    // 释放虚拟节点和缓存中剩余的全部真实节点,避免内存泄漏
    ~LRUCache() {
        DLinkedNode* node = head;
        while (node != nullptr) {
            DLinkedNode* next = node->next;
            delete node;
            node = next;
        }
    }

    LRUCache(const LRUCache&) = delete;
    LRUCache& operator=(const LRUCache&) = delete;

    int get(int key) {
        auto it = cache.find(key); // 只进行一次哈希查找
        if (it == cache.end()) return -1;

        DLinkedNode* node = it->second;
        moveToHead(node);           // 被访问过,更新为最近使用
        return node->value;
    }

    void put(int key, int value) {
        auto it = cache.find(key);

        if (it != cache.end()) {
            // key 已存在:更新值并移动到链表头
            DLinkedNode* node = it->second;
            node->value = value;
            moveToHead(node);
            return;
        }

        // key 不存在:创建新节点并插入链表头
        DLinkedNode* node = new DLinkedNode(key, value);
        cache[key] = node;
        addToHead(node);
        ++currentSize;

        if (currentSize > capacity) {
            DLinkedNode* oldest = removeTail();
            cache.erase(oldest->key);
            delete oldest;
            --currentSize;
        }
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int capacity, q;
    cin >> capacity >> q;
    LRUCache cache(capacity);
    while (q--) {
        string op; int key, value;
        cin >> op >> key;
        if (op == "get") cout << cache.get(key) << '\n';
        else { cin >> value; cache.put(key, value); }
    }
    return 0;
}
02|最长无重复子串滑动窗口

维护字符最后出现位置;左边界只能向右移动,不能回退。

理解步骤:right 每次加入一个字符;如果它在当前窗口中出现过,就把 left 跳到“上次位置 + 1”;窗口始终满足无重复;每轮用 right-left+1 更新答案。关键是 left=max(left,last[c]+1),避免 left 倒退。
输入一行不含空格的字符串。
输出最长无重复子串长度。

时间 O(n),空间 O(字符集)。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    string s; cin >> s;
    vector<int> last(256, -1);
    int left = 0, ans = 0;
    for (int right = 0; right < (int)s.size(); ++right) {
        unsigned char c = s[right];
        left = max(left, last[c] + 1);
        last[c] = right;
        ans = max(ans, right - left + 1);
    }
    cout << ans << '\n';
    return 0;
}
03|二维有序矩阵查找O(n+m)

从右上角出发:当前值大于 target 向左,小于 target 向下。

为什么选右上角:它左边的数更小、下边的数更大,因此每比较一次都能排除一整行或一整列。若从左上角开始,向右和向下都会变大,无法唯一决定方向。
输入n m target,随后 n×m 矩阵;行列均递增。
输出存在输出 1,否则 0。

时间 O(n+m),空间 O(1)。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, m, target;
    cin >> n >> m >> target;
    vector<vector<int>> a(n, vector<int>(m));
    for (auto &row : a) for (int &x : row) cin >> x;
    int r = 0, c = m - 1;
    while (r < n && c >= 0) {
        if (a[r][c] == target) { cout << 1 << '\n'; return 0; }
        if (a[r][c] > target) --c;
        else ++r;
    }
    cout << 0 << '\n';
    return 0;
}
04|二叉树最大路径和树形 DFS

DFS 返回“从当前节点向下选择一条边”的最大贡献;全局答案使用左贡献 + 当前值 + 右贡献。负贡献按 0 处理。

两个值不要混淆:更新全局答案时可以同时使用左、右两条分支;但返回给父节点时只能选左或右其中一条,否则路径会在当前节点分叉。即:答案候选是 node+left+right,向上返回是 node+max(left,right)
输入n;随后 n 行:value left right,子节点编号从 1 开始,-1 表示空,根为 1。
输出任意非空路径的最大节点和。

时间 O(n),递归空间 O(h)。

#include <bits/stdc++.h>
using namespace std;

struct Node { long long val; int left, right; };
vector<Node> tree;
long long answer = LLONG_MIN;

long long dfs(int u) {
    if (u == -1) return 0;
    long long leftGain = max(0LL, dfs(tree[u].left));
    long long rightGain = max(0LL, dfs(tree[u].right));
    answer = max(answer, tree[u].val + leftGain + rightGain);
    return tree[u].val + max(leftGain, rightGain);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n; cin >> n;
    tree.resize(n);
    for (int i = 0; i < n; ++i) {
        cin >> tree[i].val >> tree[i].left >> tree[i].right;
        if (tree[i].left != -1) --tree[i].left;
        if (tree[i].right != -1) --tree[i].right;
    }
    dfs(0);
    cout << answer << '\n';
    return 0;
}
05|Top K 高频元素哈希 + 排序

先统计频率,再按“频率降序、数值升序”排序,保证 ACM 输出确定。

理解步骤:第一遍遍历只负责计数;把哈希表转换为 (数字,频率) 数组;排序比较器先比较频率,频率相同再比较数字;最后输出前 k 项。若面试官要求更优复杂度,再改用大小为 k 的小根堆或桶排序。
输入n k,随后 n 个整数。
输出前 k 个元素,频率相同按数值升序。

时间 O(n+u log u),空间 O(u),u 为不同元素数。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, k; cin >> n >> k;
    unordered_map<int, int> freq;
    for (int i = 0, x; i < n; ++i) { cin >> x; ++freq[x]; }
    vector<pair<int,int>> items(freq.begin(), freq.end());
    sort(items.begin(), items.end(), [](auto &a, auto &b) {
        if (a.second != b.second) return a.second > b.second;
        return a.first < b.first;
    });
    k = min(k, (int)items.size());
    for (int i = 0; i < k; ++i)
        cout << items[i].first << (i + 1 == k ? '\n' : ' ');
    return 0;
}
06|合并 K 个有序数组小根堆

堆中只保留每个数组当前最小的未输出元素,弹出后推进对应数组。

理解步骤:初始化时每个非空数组只把第 0 个元素入堆;堆顶一定是所有数组当前候选中的最小值;弹出后只需要把“同一数组的下一个元素”加入堆。堆的大小最多是 k,而不是总元素 N。
输入k;随后每行先给长度 len,再给该有序数组。
输出合并后的升序序列。

总元素 N,时间 O(N log k),空间 O(k)。

#include <bits/stdc++.h>
using namespace std;

struct Item {
    int value, arrayId, index;
    bool operator>(const Item &o) const { return value > o.value; }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int k; cin >> k;
    vector<vector<int>> a(k);
    priority_queue<Item, vector<Item>, greater<Item>> pq;
    for (int i = 0, len; i < k; ++i) {
        cin >> len; a[i].resize(len);
        for (int &x : a[i]) cin >> x;
        if (len) pq.push({a[i][0], i, 0});
    }
    bool first = true;
    while (!pq.empty()) {
        auto cur = pq.top(); pq.pop();
        if (!first) cout << ' ';
        first = false; cout << cur.value;
        int next = cur.index + 1;
        if (next < (int)a[cur.arrayId].size())
            pq.push({a[cur.arrayId][next], cur.arrayId, next});
    }
    cout << '\n';
    return 0;
}
07|有界生产者—消费者mutex + condition_variable

生产者等待“队列未满”,消费者等待“队列非空”。演示程序输出消费数量和元素总和,结果不受线程调度顺序影响。

理解步骤:生产者拿锁后发现队列满,就在 notFull 上睡眠;消费者弹出元素后通知 notFull。消费者发现队列空,就在 notEmpty 上睡眠;生产者入队后通知 notEmpty。wait(lock, 条件) 会自动释放锁并在唤醒后重新加锁,条件必须再次检查以处理虚假唤醒。
输入capacity producers consumers itemsPerProducer。
输出总消费数量与总和。

每次 push/pop 摊还 O(1),队列空间 O(capacity)。编译需添加 -pthread

#include <bits/stdc++.h>
using namespace std;

class BlockingQueue {
    queue<long long> q;
    size_t cap;
    mutex m;
    condition_variable notFull, notEmpty;
public:
    explicit BlockingQueue(size_t capacity) : cap(capacity) {}
    void push(long long value) {
        unique_lock<mutex> lock(m);
        notFull.wait(lock, [&] { return q.size() < cap; });
        q.push(value);
        lock.unlock();
        notEmpty.notify_one();
    }
    long long pop() {
        unique_lock<mutex> lock(m);
        notEmpty.wait(lock, [&] { return !q.empty(); });
        long long value = q.front(); q.pop();
        lock.unlock();
        notFull.notify_one();
        return value;
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int capacity, producers, consumers, each;
    cin >> capacity >> producers >> consumers >> each;
    BlockingQueue q(capacity);
    atomic<long long> count{0}, sum{0};
    vector<thread> cs, ps;
    for (int c = 0; c < consumers; ++c) cs.emplace_back([&] {
        while (true) {
            long long x = q.pop();
            if (x == -1) break;
            ++count; sum += x;
        }
    });
    for (int p = 0; p < producers; ++p) ps.emplace_back([&, p] {
        for (int i = 1; i <= each; ++i) q.push(1LL * p * each + i);
    });
    for (auto &t : ps) t.join();
    for (int i = 0; i < consumers; ++i) q.push(-1);
    for (auto &t : cs) t.join();
    cout << count.load() << ' ' << sum.load() << '\n';
    return 0;
}
08|线程安全 LRU(手写链表 + shared_ptr)主管追问版

链表节点仍然手写,互斥锁只保护哈希表和链表结构;节点中的大对象使用 shared_ptr。get 在锁内移动节点并复制 shared_ptr,离开函数后锁释放,但调用方持有的对象仍然有效。

理解步骤:① 原始节点由 LRU 独占并手动 delete;② 节点里的章节对象由 shared_ptr 管理;③ 淘汰节点只会减少缓存持有的一份引用;④ 排版线程仍持有引用时,章节对象不会被释放;⑤ 链表节点之间仍用普通指针,不会形成 shared_ptr 循环引用。
输入与第 01 题相同,示例 value 使用字符串。
输出每个 get 输出字符串,不存在输出 NULL。

get/put 平均 O(1);shared_ptr 引用计数保证被淘汰对象延迟到最后一个读者释放。

#include <bits/stdc++.h>
using namespace std;

class ThreadSafeLRU {
    struct Node {
        int key;
        shared_ptr<const string> value;
        Node* prev;
        Node* next;

        Node(int k = 0, shared_ptr<const string> v = {})
            : key(k), value(move(v)), prev(nullptr), next(nullptr) {}
    };

    size_t capacity;
    size_t currentSize = 0;
    unordered_map<int, Node*> cache;
    Node* head;
    Node* tail;
    mutex m; // 同时保护 cache、链表指针和 currentSize

    void addToHead(Node* node) {
        node->prev = head;
        node->next = head->next;
        head->next->prev = node;
        head->next = node;
    }

    void removeNode(Node* node) {
        node->prev->next = node->next;
        node->next->prev = node->prev;
    }

    void moveToHead(Node* node) {
        removeNode(node);
        addToHead(node);
    }

    Node* removeTail() {
        Node* node = tail->prev;
        removeNode(node);
        return node;
    }

public:
    explicit ThreadSafeLRU(size_t cap) : capacity(cap) {
        head = new Node();
        tail = new Node();
        head->next = tail;
        tail->prev = head;
    }

    ~ThreadSafeLRU() {
        // 析构时应由外部保证已经没有线程继续访问该缓存
        Node* node = head;
        while (node != nullptr) {
            Node* next = node->next;
            delete node;
            node = next;
        }
    }

    ThreadSafeLRU(const ThreadSafeLRU&) = delete;
    ThreadSafeLRU& operator=(const ThreadSafeLRU&) = delete;

    shared_ptr<const string> get(int key) {
        lock_guard<mutex> lock(m);
        auto it = cache.find(key);
        if (it == cache.end()) return {};

        Node* node = it->second;
        moveToHead(node);

        // 在锁内复制 shared_ptr。返回后即使节点被淘汰,value 仍然有效。
        return node->value;
    }

    void put(int key, string value) {
        // 构造大对象不需要占用缓存的全局锁
        auto object = make_shared<const string>(move(value));
        lock_guard<mutex> lock(m);

        auto it = cache.find(key);
        if (it != cache.end()) {
            Node* node = it->second;
            node->value = move(object);
            moveToHead(node);
            return;
        }

        Node* node = new Node(key, move(object));
        cache[key] = node;
        addToHead(node);
        ++currentSize;

        if (currentSize > capacity) {
            Node* oldest = removeTail();
            cache.erase(oldest->key);
            delete oldest; // 只删除节点;value 是否释放由 shared_ptr 决定
            --currentSize;
        }
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int capacity, qn; cin >> capacity >> qn;
    ThreadSafeLRU cache(capacity);
    while (qn--) {
        string op; int key; cin >> op >> key;
        if (op == "get") {
            auto value = cache.get(key);
            cout << (value ? *value : "NULL") << '\n';
        } else {
            string value; cin >> value;
            cache.put(key, move(value));
        }
    }
    return 0;
}

第二梯队:按顺序完成

  1. 反转链表
  2. 合并两个有序链表
  3. 环形链表与入环点
  4. 二叉树层序遍历
  5. 最近公共祖先
  6. 数组第 K 大元素
  7. 合并区间
  8. 旋转数组二分查找
  9. 最大子数组和
  10. 最长递增子序列
  11. 岛屿数量
  12. 课程表(拓扑排序)
  13. 最小覆盖子串
  14. 零钱兑换