公司动态
搜狗C++笔试题复盘:从内存布局到多线程的工程思维考察
搜狗 2016 年的 C 工程师笔试题在当年互联网公司的校招题库里算是比较有辨识度的一批。它不像某些厂那样通篇堆砌偏题怪题但也不是靠背八股就能轻松过关的类型。作为参加过那场笔试、后来也断断续续给朋友讲过多轮题目的人我整理了一份比较完整的复盘把题目背后真正想考察的东西拆开揉碎结合搜狗自身的业务场景和大家聊一聊。1. 搜狗那道题为什么值得反复琢磨输入法级别的高性能诉求聊任何一家公司的笔试题之前先看这家公司在做什么。搜狗的核心产品是输入法、搜索、浏览器背后全是高频、高并发、低延迟的 C 服务。输入法这个场景尤其特殊用户每敲一个键候选词排序、拼音切分、用户词频调整都要在毫秒级完成内存分配不能频繁触发字符串处理效率直接决定体验。这就决定了搜狗的 C 笔试题会特别关注几个点内存管理、字符串操作、STL 底层机制、多线程并发以及最基础的指针和对象模型。2016 年的这套题我印象里大概分这么几块C 语言基础指针、引用、const、static、面向对象继承、多态、虚函数表、STL 容器与算法、内存管理堆栈、内存对齐、智能指针、多线程与同步外加一两道算法编程题。表面上都是常见考点但实际做起来会发现每一道题都在逼你往底层想一层。比如单纯说vector 扩容机制是怎样的可能很多人在背答案但搜狗会换个问法让你分析某个场景下为什么 vector 的性能会劣化怎么优化。这种问法就是拿真实业务场景来考的。我后来带过一些应届生发现一个普遍现象大家在学校里学 C 是通过做题和背书到了实际项目里遇到性能问题却不知道从哪下手。搜狗的笔试其实就是一个缩影它考察的不是你会不会背某个结论而是你能不能从内存布局、汇编层面、数据结构特性这些角度真正理解 C 的行为。所以这套题即使放到今天依然有很强的参考价值。2. 指针、引用和内存布局的多层考法从语法到字节的跨越2.1 指针和引用的本质区别不止是语法糖搜狗 2016 的题里指针和引用几乎必考而且很少直接问指针和引用的区别这种送分题。它更常见的考法是给一段代码让你分析输出结果或者指出错误。我当时遇到的一个典型例子是#include iostream void func(int* p) { p new int(10); } int main() { int* ptr nullptr; func(ptr); std::cout (ptr nullptr ? null : std::to_string(*ptr)) std::endl; return 0; }这道题问输出是什么。很多人会答10但正确答案是null。原因在于参数传递时指针本身是值传递func 内部修改的是指针副本的指向不会影响外面的 ptr。如果要让外部指针指向新分配的内存需要传递指针的指针int**或者指针的引用int*。这个知识点本身不复杂但我们被提醒的题海战术很容易让人忽略这背后的重要信息C 参数传递默认是值传递所谓传指针其实传的是指针的值。笔试考这个是希望候选人具备第一性思维而不是机械背结论。还有一道和引用相关的隐患题int func() { int a 5; return a; }返回局部变量的引用函数结束后栈帧销毁返回的是悬空引用。这种题在搜狗的试卷里属于送命题基础上的热身但它背后真正考察的是你是否清楚局部变量在栈上的生命周期以及数组越界、野指针这类问题为什么在 C 里屡禁不止。2.2 内存对齐与 sizeof类大小计算的底层逻辑内存对齐在 2016 年的搜狗笔试题里也是高频考点。这道题我在不少公司也见过类似的但搜狗考得更注重细节。struct A { char c; // 1 byte int i; // 4 bytes double d; // 8 bytes }; struct B { char c; // 1 byte double d; // 8 bytes int i; // 4 bytes }; int main() { std::cout sizeof(A) std::endl; std::cout sizeof(B) std::endl; }答案是 sizeof(A)16sizeof(B)24。原因在于编译器默认的对齐规则每个成员按自身大小对齐结构体的最终大小按最大成员大小对齐。A 的排列是c 占 1 字节后面 padding 3 字节i 占 4 字节d 占 8 字节总共 16 字节。 B 的排列是c 占 1 字节后面 padding 7 字节d 占 8 字节i 占 4 字节最后 padding 4 字节对齐到 8 的倍数总共 24 字节。这个知识点看起来只是 sizeof 的计算题但搜狗的考察意图是候选人对内存布局的直觉。在一个需要处理大规模候选词、需要把内存占用压到极限的场景里结构体成员的排列顺序直接影响缓存命中率和内存带宽。笔试里加这么一道题是在筛选那些真正会关注内存布局的人。我当时在答题时还额外默写了一个注意点如果结构体里有虚函数还要加上虚表指针的大小64 位系统下是 8 字节。这是这个知识点的常见延伸。2.3 const 和 static 的组合拳从声明到存储位置关于 const 和 static 的题在 2016 年搜狗笔试中占比也不小。这道题很经典const int a 10; static const int b 20; const int* p1 a; int* const p2 a;问哪个是正确的哪个编译会报错。这道题涉及的是const int a 定义了一个常量它可能被放到只读段也可能被优化掉static const int b 定义内部链接的常量const int* p1 是指向常量的指针可以指向 aint* const p2 是常量指针本身不能修改指向但用它指向 const int 是类型不匹配通常编译不过。更深入的版本会结合 C17 的 inline static 和 constexpr 来考但在 2016 年那会儿重点还是 const 的位置决定作用对象这个语法细节。搜狗还会考一个实际业务里经常遇到的问题const 成员函数里为什么不能修改成员变量mutable 关键字是干什么的答案大家都懂但搜狗会加上线程安全的维度const 成员函数意味着逻辑上不修改对象状态但如果你在 const 函数里修改一个 mutable 计数器而这个对象被多个线程共享那就需要加锁。这种从语法到并发安全的延伸是我觉得搜狗这套题比较有含金量的地方。3. 对象模型、多态和虚函数表C 工程师的看家本领3.1 虚函数表的字节级理解搜狗的笔试对虚函数和对象的考察几乎到了字节级的深度这倒是很典型的 C 岗位要求。2016 年有一道题让我印象深刻问的是class Base { public: virtual void f() {} virtual void g() {} int x; }; class Derived : public Base { public: virtual void h() {} int y; };在一个 64 位系统下sizeof(Base) 和 sizeof(Derived) 各是多少答案是 Base 是 16 字节虚表指针 8 字节 int 4 字节 padding 4 字节Derived 是 24 字节继承 Base 的 16 字节 int y 4 字节 padding 4 字节。注意派生类不会因为新增虚函数而增加新的虚表指针它共享基类的虚表指针只是虚表内容不同。这类题已经不算难了搜狗还会进一步加一个问题如果你把两个 int 成员去掉只保留虚函数sizeof 是多少那就是 8 字节——一个空类加虚函数后只有 vptr。这些问题反复交替出现不外乎想确认两件事你知道对象里到底存了什么以及你知道哪些信息存在对象里哪些存在虚表里。3.2 多态的构造和析构顺序问题问构造和析构顺序的题在 C 笔试里算是常规题但搜狗会在里面藏一个有点反直觉的坑class Base { public: Base() { f(); } virtual void f() { std::cout Base f std::endl; } ~Base() { f(); } }; class Derived : public Base { public: Derived() : Base() { f(); } virtual void f() { std::cout Derived f std::endl; } ~Derived() { f(); } };调用 Derived d; 时输出什么正确答案是Base f Derived f Derived f Base f注意在 Base 的构造函数和析构函数里调用虚函数时虚函数不会进入派生类的版本。这是因为对象的动态类型在构造和析构的过程中会发生变化基类构造期间派生类部分还没初始化虚表绑定在基类上析构时同理派生类析构完后虚表才回到基类版本。这个行为是 C 标准明确规定的但很多写代码多年的人也会在这个细节上犯错。搜狗考这个明显不是在考语法本身而是在考察对象生命周期和虚表绑定的完整理解。实际业务中如果有人在构造函数里调虚函数往往会导致 bug 且非常难排查。笔试提前把这类坑摆到面前是在帮面试官筛掉那些靠猜和模糊记忆写代码的人。3.3 复制控制拷贝构造、赋值运算和移动语义2016 年的搜狗笔试已经出现了移动语义相关的题。题目大概是这样class String { public: String() {} String(const char* s) {} String(const String other) {} String operator(const String other) {} String(String other) {} String operator(String other) {} ~String() {} private: char* data_; };问你哪些是浅拷贝相关的坑哪些函数在什么情况下会被调用。这里的问题核心是如果你不写拷贝构造函数编译器会默认生成一个逐位拷贝版本对于含有指针成员的对象这就会导致双重释放和悬空指针。搜狗在这个知识点上还有一道经典题下面这段代码会调用哪个构造函数std::vectorString vec; vec.push_back(hello);这里 hello 是 const char*会先隐式转换为一个临时 String 对象然后这个临时对象被拷贝或移动到 vector 中。如果只提供了拷贝构造而没有移动构造会调用拷贝构造如果提供了移动构造则优先调用移动构造。这个问题的背后是 vector 扩容时的元素搬运方式也就是常说的右值引用优化。输入法引擎里候选词字符串动辄几十万甚至上百万的规模频繁拷贝字符串的开销是实实在在的性能瓶颈。搜狗考移动语义本质上是在问你知不知道在大量临时对象的场景下怎么减少拷贝4. STL 容器与算法笔试题里的性能题4.1 vector 扩容机制从 push_back 到迭代器失效搜狗的 STL 相关题是我认为这套试卷里最接近真实业务的部分。因为搜狗的核心引擎大量使用 STL 容器容器选择的直接影响性能。2016 年考的 vector 扩容问题具体是std::vectorint v; for (int i 0; i 100; i) { v.push_back(i); }问过程中发生了什么如何减少扩容次数标准答案是vector 在 capacity 不足时按一定倍数扩容通常是 2 倍但不同实现可能不同扩容的流程是分配新内存、把旧数据搬过去C11 之前是拷贝C11 之后是移动、释放旧内存。整个过程会带来两方面的开销一次性的大内存分配以及高成本的对象搬运。减少扩容次数的方法是直接用v.reserve(100)预分配或初始时就指定容量vectorint v(100)。这个知识点本身不难但搜狗会出一个更实操的变形如果 vector 里存的是有昂贵拷贝成本的对象例如一个包含字符串和若干成员的类扩容时的搬运成本会非常高。而如果你提供了 noexcept 的移动构造函数vector 扩容就会用移动而不是拷贝性能会有数量级的提升。这其实是 C11 引入移动语义后容器性能改进的核心案例之一。4.2 map 和 unordered_map 的取舍从红黑树到哈希表搜狗也考过 map 和 unordered_map 的对比这个问题现在依然是高频面试题。主要对比点有这么几个维度std::mapstd::unordered_map底层结构红黑树平衡二叉搜索树哈希表链地址法或开放寻址法插入/查找复杂度O(log n)平均 O(1)最坏 O(n)内存占用每个节点额外存颜色、左右子树指针需要维护桶数组和哈希冲突链表迭代顺序按键有序无序适用场景需要有序遍历、范围查询高并发查找、插入顺序无所谓搜狗在这道题上会延伸到输入法业务词库里的候选词表需要按词频排序输出天然要求有序所以底层用 map 或者按词频排的数组更合适而一个词到拼音的映射表只需要快速查找用 unordered_map 更合适。要注意的是unordered_map 的平均 O(1) 是建立在哈希函数均匀分布的假设上的如果哈希函数设计不当导致大量冲突性能会急剧退化到 O(n)。这种场景在笔试里常引申出一个问题为什么同一个类的对象作为 key 时需要提供 size_t operator()(const Key) 的哈希函数重载。4.3 排序算法从冒泡到快速排序的复杂度陷阱关于排序2016 年的题里有一道是我认为最能区分水平层次的给定一个已经近乎有序的数组选择哪种排序算法最优答案是插入排序它在这类输入下的时间复杂度可以逼近 O(n)。很多人会脱口而出快速排序但标准快速排序在近乎有序的输入下其实会退化到 O(n^2)除非做了随机化三数取中。这样一道题把算法考察从背诵复杂度表拉到了对不同数据分布特性的理解上。搜狗这里常见的额外问法是如果让你对一份用户词库按词频排序内存有限数据量比较大你用什么方案实际解法是外排序的思路或者通过哈希分桶把大文件拆成多个能在内存中排序的小文件再用优先队列做 k 路归并。搜狗作为搜索和输入法公司处理大规模词表是非常自然的业务场景笔试里出这种题顺理成章。5. 操作系统、多线程与并发客户端和服务端都躲不开的题5.1 进程和线程的经典辨析从资源归属到切换开销搜狗的 C 笔试里操作系统内容占比不少尤其多线程。有一道基础题是这样一个进程里的多个线程共享什么不共享什么共享的资源地址空间代码段、数据段、堆、全局变量、打开的文件描述符、信号处理器、当前工作目录。不共享的资源栈、寄存器上下文包括程序计数器、线程局部存储thread_local 变量、信号掩码。这道题的深挖点是为什么线程切换比进程切换快答案在于线程切换不需要切换虚拟地址空间和页表虽然线程切换也要保存寄存器上下文但省掉了地址空间切换TLB 刷新这个最大的开销。这个知识点后续在考察协程的时候还会再出现协程是在用户态切换上下文连内核态都不需要进所以更轻量。5.2 死锁四要素与哲学家就餐问题死锁相关的题目基本是必考的。搜狗考的形式一般是给出一个场景让你找出死锁风险并给出解决措施。场景大概是两个线程 A 和 B各持有一把锁然后尝试获取对方的锁。这就是经典的相互等待死锁。死锁的四个必要条件是互斥、持有并等待、不可剥夺、循环等待。解法有保证加锁顺序一致、使用 trylock 并在一段时间后回退、使用一个全局锁或其他无锁方案。搜狗有一个比较进阶的问法对于一个有两个锁的场景为了避免死锁是否可以用 RAII 的方式保证锁的顺序释放这里可以引到 std::lock_guard 和 std::unique_lock 的区别。std::lock_guard 在构造时加锁、析构时解锁不能手动控制std::unique_lock 可以手动 lock 和 unlock灵活性更高但代价是有额外的状态标记性能略低。在实际的输入法引擎里很多关键路径会用无锁数据结构比如无锁队列来避免锁竞争但这种题在笔试里不会直接让你写无锁代码更多是考察能否看出锁竞争的风险点。5.3 原子操作和内存序从 ABA 到 CAS2016 年搜狗笔试里的并发题还有一道让我印象挺深它是关于 CASCompare-And-Swap和 ABA 问题的。代码原型是std::atomicint cnt; int expected 0; bool success cnt.compare_exchange_strong(expected, 10);这个题会问CAS 操作是否保证线程安全当然保证。但 CAS 有一个著名的 ABA 问题如果一个线程读到值 A另一个线程把它改成 B 又改回 A第一个线程的 CAS 会误以为它没变过然后成功交换。解法是使用带版本号的原子指针比如 std::atomicstd::shared_ptr 或者直接使用带 ABA 检测的原子变量。实际在搜狗这类高并发服务里无锁队列的头部指针更新就可能遇到 ABA所以这道题问得相当贴近实践。6. 算法编程题复杂度之外的边界条件才是分水岭6.1 快排、链表反转、字符串操作经典题的固定套路搜狗笔试的算法编程题整体难度在互联网公司里属于中等偏上不会出太偏的题但很看重实现质量。2016 年的题里有一道是链表反转这道题我见过太多人在思路正确的前提下因为边界条件丢分。核心是三个指针 prev、cur、next循环里先保存 next 再改 cur-next最后更新 prev 和 cur。看起来简单但如果不考虑空链表和单节点链表这两种特殊情况代码很容易崩。题目给我印象较深的另一类是字符串相关的操作比如实现一个字符串分割函数或者查找一个字符串在另一个字符串中出现的所有位置。搜狗输入法就是做文本处理的字符串算法的考察很符合业务。这类题不仅要写出正确的代码还要注意是否是 O(n) 复杂度以及是否能处理 UTF-8 编码。2016 年那会儿 C 对 Unicode 的标准支持还不像今天这么完善所以考察重点在于你能否意识到 char 型字符串和宽字符字符串的区别是否会预留编码转换的接口。6.2 从手写 memcpy 到高质量代码习惯另外一道很有搜狗特色的编程题是手写 memcpy。这道题考的是 C 工程师的根本功底能不能写出正确、高效、安全的内存拷贝函数。考察点一般有以下几个指针为空的判断源地址和目的地址重叠时的处理需要从后往前拷贝按字节拷贝还是按字拷贝按 4/8 字节拷贝性能更好但要注意内存对齐返回值设计为目标地址方便链式调用。这道题我第一次做的时候只考虑了非重叠的情况后面面试官追问如果 dst 和 src 有重叠怎么办时有点慌。实际上这正是 memmove 和 memcpy 的区别所在memcpy 不保证处理重叠区域而 memmove 保证。搜狗这道题的意义不只是为了难倒人而是在提醒你C 工程师写的每一行底层代码都要对内存边界负责。7. 从 2016 年的题目到今天的应试策略当年踩过的坑现在依然有效7.1 环境准备与刷题路线的优先级如果你现在要参考搜狗这套 C 笔试题来准备面试我的建议是按下面这个优先级来第一优先级C 语言本身包括指针、引用、对象模型、虚函数、拷贝移动语义、智能指针、STL 容器与算法。搜狗考这些考得最深入几乎是必考的重头戏。第二优先级操作系统基础尤其是内存管理、多线程与并发、进程间通信。搜狗客户端和服务端的开发都离不开这些笔试题目占比也很高。第三优先级数据结构和算法。基础题链表、栈、队列、排序、二分、字符串必须熟练到条件反射中等难度的题动态规划、贪心、树的遍历要能快速讲出思路和复杂度。我当时比较吃亏的一点是花了很多时间刷偏题怪题比如手写红黑树删除、计算几何、平衡树旋转等结果搜狗这套题一个都没考到。它考的恰恰是那些看似平淡但能真正反映工程能力的基础题。所以建议大家在准备阶段与其纠结难题不如把每个基础知识点从原理到编码都过一遍。7.2 答题时的三个实用习惯关于考场上的表现我想分享几个搜狗笔试之后复盘总结出来的实用习惯。第一个习惯做代码题时先在注释里写清楚边界条件。不管是链表反转还是 memcpy先把空指针、空容器、单元素、重叠内存这些情况列出来。这样做一方面能避免自己写代码时漏掉特殊情况另一方面也能给阅卷人一个良好的结构化思维印象。第二个习惯涉及容量和复杂度的题目一定要算清楚再答。比如让你设计一个缓存淘汰策略不要直接说用 unordered_map 加 list而要说明 memory 的存储结构、get 和 put 的平均复杂度、为什么 unordered_map 的查找在均摊情况下是 O(1) 但扩容时可能出现 O(n)。这种量化的回答在搜狗笔试题里特别吃香。第三个习惯不要只写答案要写为什么。搜狗批卷不看结论的准确性更看重推理过程的严谨度。如果一道题你只有一个正确答案但没有任何推导过程大概率会丢一半分。尤其是虚表、内存对齐、基于范围的 for 循环这种概念题建议都用一两句话补充背后的原理。7.3 笔试之后的启发从应试到工程能力最后我想说一点很多人会忽略的搜狗 2016 年这套 C 笔试题哪怕考完拿到 offer 之后回头再看也依然有学习和复盘的价值因为它们本质上是在帮你建立一套工程视角。比如你学会了内存布局在做缓存优化时就会刻意调整结构体字段顺序你理解了 vector 的扩容机制在写高频调用的函数时就会主动用 reserve 减少内存分配你搞清楚了死锁产生的条件在设计多线程模块时就会提前制定统一的加锁顺序。我自己在搜狗笔试之后最大的收获就是意识到 C 的笔试不是在考你会不会用某种语法而是在考察你是否具备从内存和并发角度思考问题的习惯。这种习惯一旦建立对后续做高性能应用开发、甚至系统级开发都有巨大的帮助。如果你现在正打算投搜狗的 C 岗位或者只是单纯想把 C 基础打得更牢一点建议你把这套题考察的知识点逐条过一遍每一条都要做到能讲清楚原理、能写出代码、能分析边界条件。做到这三步哪怕不是 2016 年的题将来遇到其他公司的 C 笔试也基本能从容应对。