2023年奇安信春招C++方向的那套试卷,在朋友圈里被讨论过好几轮。作为当时也在筹备C++岗位面试的人,我特意把这套卷子里里外外拆了一遍,今天把一些核心考点和做题思路整理出来。这套卷子表面上看是常规的C++八股加算法题,但仔细研究会发现,它其实在考察一个安全厂商C++开发者的底层基本功:内存管理是否扎实、并发思维是否严密、对新特性的掌握是否跟得上工程节奏。无论你是准备投奇安信,还是想检验自己的C++水平,都值得对着这套题做一次完整复盘。
1. 从试卷定位看奇安信要什么样的C++开发
1.1 安全厂商笔试和互联网大厂的区别
先聊一个很多人忽略的问题:安全公司的C++岗位和普通业务开发岗,笔试侧重点是完全不同的。互联网大厂喜欢考高并发架构、海量数据处理,因为他们的系统面对的是海量用户访问;而安全厂商的C++代码通常跑在终端、网关、检测引擎这类环境里,更看重你写的代码在资源受限的情况下是否稳定、是否高效、是否存在内存破损或逻辑漏洞。
这套卷子从头到尾都在贯彻这个思路。它不是简单地问你“constexpr是哪个C++版本引入的”这种背诵题,而是会把这个知识点放到具体场景里,让你判断一段代码能不能在编译期求值、如果不行应该怎么改。这背后考的是你对C++语言演进方向的理解:现代C++越来越强调编译期计算、类型安全、资源管理,而不是靠程序员小心翼翼地去查边界。
1.2 2023春招C++试卷的题型分布与时间分配
我根据记忆和当时收集到的信息,把试卷的题型分布整理成了下面这张表。具体细节可能和原卷略有出入,但整体结构足以说明问题。
| 模块 | 题型 | 题量 | 建议用时 | 考察重点 |
|---|---|---|---|---|
| C++基础语法 | 选择+改错 | 15题左右 | 25分钟 | 初始化、类型转换、重载、移动语义 |
| 内存与资源管理 | 选择+编程 | 10题左右 | 30分钟 | 智能指针、内存布局、new/delete |
| 并发与多线程 | 选择+问答 | 8题左右 | 25分钟 | 线程安全、ABA问题、锁、原子操作 |
| 算法与数据结构 | 编程 | 3题左右 | 50分钟 | 排序、快速幂、单调栈、模拟 |
| 设计模式与工程 | 问答+代码 | 5题左右 | 20分钟 | 回调函数、观察者模式、构建配置 |
整套卷子时间大约两个半小时,题量不算小。我见过太多人死在时间分配上:前面选择题纠结太久,后面算法题只能草草写个开头。所以我的建议是——遇到拿不准的选择题,先凭第一感觉标记好,把时间留给分值更高的算法题。毕竟算法题一道可能就是15到20分,顶得上五道选择题。
2. 基础语法与内存管理:从字符串初始化到constexpr的演进
2.1 字符串数组初始化的三个坑,你踩过几个
这套试卷在基础题部分就给人一个下马威。有一道题直接让你判断下面几种字符串初始化方式的对错:
const char* str1 = "hello"; char str2[] = "hello"; char* str3 = (char*)"hello"; // C风格强转 char str4[] = {'h', 'e', 'l', 'l', 'o'}; // 没有结尾'\0'很多人一看就觉得都差不多,但这四行每一行都有文章。第一行是合法的,字符串字面量存在只读区,const char* 指向它没问题。第二行也是合法的,它会在栈上复制一个字符数组并自动补上结尾的'\0',sizeof(str2) 是6。第三行在C++里已经不行了,从C++11开始字符串字面量类型是 const char[N],强转成 char* 属于未定义行为,虽然很多老编译器只是警告,但严格按标准来说,这是个错误。第四行是最隐蔽的坑:数组长度是5,但只初始化了5个字符,没有容纳结尾空字符的空间,这个字符串根本没有正确终止。如果你拿它去调 strlen 或 std::cout,大概率会读越界,行为不可预测。
这种题目考察的不是你会不会背“字符串要以\0结尾”这句话,而是你会不会在写代码的时候意识到边界。安全厂商最怕的就是这种不可预测的越界行为,它们往往是漏洞的源头。我自己的习惯是:新代码一律用 std::string,完全避开这个问题;只有在做协议解析、自定义内存池这类高性能模块时才手动处理,而且必须用显式的长度限制函数。
2.2 constexpr的版本演进,背后藏着现代C++的设计哲学
热搜词里的“constexpr哪个C++版本引入的”正是这份试卷里的一道典型选择题。constexpr 从 C++11 首次引入,C++14 放宽了函数内可以出现多条 return、允许局部变量声明,C++17 支持了 if constexpr,C++20 之后甚至可以定义 constexpr 的虚函数、在编译期使用 vector 和 string。
试卷不会只考一个版本号,它会把版本演进的点作为区分度。比如它可能会给你一段代码:
constexpr int square(int x) { int sum = 0; for (int i = 0; i < x; ++i) { sum += x; } return sum; }让你判断这段代码在哪个C++标准下可以编译通过。C++11 的 constexpr 函数限制很死,函数体只能由一条 return 语句组成,带循环直接报错;到 C++14 才允许这种写法。如果你只记得“constexpr 是 C++11 引入的”,而不知道后续版本的能力变化,这道题就答不上来。
这里我多说一句。很多人觉得背版本号很无聊,但工程上这个知识非常实用。比如你在写一个跨平台项目,编译标准必须兼容 C++14,那你就不能使用 if constexpr,只能用模板特化或 SFINAE 绕过。这套卷子考版本演进,其实是在考察你有没有实际做过需要兼容不同标准的项目。
2.3 new/delete、malloc/free混用的致命细节
内存管理几乎是必考项。试卷里有一道题问:为什么 C++ 里不推荐把 malloc/free 和 new/delete 混用?标准答案是“因为 new 会调用构造函数,delete 会调用析构函数,而 malloc/free 只申请和释放内存”。但真正的坑在于,如果你对一个 malloc 出来的内存执行 delete,就意味着对一块没有构造对象的内存调用了析构函数,这是未定义行为;反过来,如果你对 new 出来的对象用 free,那对象内部的资源没法被析构函数释放,直接造成资源泄漏。
我在实际开发中还真遇到过这种问题。当时维护一个老模块,有人图省事用 realloc 扩容一个结构体数组,结果结构体里有 std::string 成员。浅拷贝了新数据之后,旧内存被 free,成员指针直接悬空,程序在随机时刻崩溃。排查了一整天才定位到问题:用 C 的方式管理 C++ 对象,就是灾难。
所以我的朴素原则是:在 C++ 代码里,非极端场景不用裸指针管理生命周期,优先 unique_ptr、shared_ptr;如果必须和 C 库打交道,就用 malloc/free 处理纯 POD 数据,并且做好注释,禁止对非 POD 类型使用。
3. 多线程与并发考点:ABA问题、线程安全与锁的取舍
3.1 ABA问题:一个比死锁更容易被忽略的并发陷阱
这套试卷里明确出现了“ABA问题c++”相关的知识点。ABA问题最早在无锁数据结构中被重视起来,核心场景是这样的:线程 P 读到了共享变量的值 A,然后被调度挂起;另一个线程 Q 将值从 A 改成 B,再改回 A。等线程 P 恢复执行后,它用 CAS 比较发现值还是 A,以为没人动过,于是执行交换——但事实上数据已经被改过两轮了,基于“值没变”的假设做的操作很可能是错的。
试卷里的问法往往不给完整代码,而是让你结合无锁栈或无锁队列描述 ABA 问题如何发生,以及怎么解决。最常见的解决手段是给每个数据加上版本号或标签:CAS 的对象从“值”变成“值和版本号”的复合结构。每次修改值的同时递增版本号,即使值变回原来的样子,版本号也已经变了,CAS 就能识别出中间发生了变更。
struct Node { int value; uint64_t version; }; std::atomic<Node> atomicNode;在 x86 平台上,如果 Node 的大小不超过 16 字节,可以用双字 CAS 指令来原子更新。如果结构更大,就得用指针+计数器的组合,这也是哈特博士经典的无锁队列中常用的做法。
3.2 这道卷子里出现的多线程题,考的是工程思维
有一道题让我印象很深:给出一段使用 std::thread 去更新共享 map 的代码,要求指出线程安全问题并改进。代码里大概是这样:
std::map<int, int> data; std::vector<int> keys = {1, 2, 3}; std::thread t1([&]() { data[keys[0]] = 10; }); std::thread t2([&]() { data[keys[1]] = 20; }); t1.join(); t2.join();这里有两个线程同时写同一个 map,即使写的是不同 key,也不行。std::map 的内部结构是红黑树,插入元素可能引发节点左旋右旋,多个线程同时修改树结构就会破坏结点的父子关系,导致崩溃或死循环。必须加锁,或者使用并发安全的容器,比如 concurrent_map(第三方库)或 std::mutex 保护。
很多人会陷入“只要操作不同 key 就安全”的误区。我在实际项目里见过类似情况:两个线程一个只做读,一个只做写,其中读线程一直正常运行,写线程偶尔被调用,看起来没问题。后来有一天写线程频率陡增,读线程开始读到半更新状态的数据,程序直接卡死。这就是没有加锁引起的脏读。
这道题的正解是引入 std::shared_mutex,让多线程并发读、写线程独占。这样做既保护了数据,又把并发性能损失降到最低。试卷的判分点大概率就在这:你是不是只会用 std::mutex,还是能说出 shared_mutex 这种更精细的同步工具。
3.3 原子操作、内存序和锁之间怎么选
并发部分还有一道关于 std::atomic 的题,问 atomic 的 fetch_add 和 mutex 保护下的 i++ 有什么区别。最直观的回答是:fetch_add 是无锁的,编译器会映射到 CPU 的原子指令,比如 x86 的 lock xadd;而 mutex 保护下的 i++ 是一次加锁、修改、解锁的完整流程,开销要重得多,且可能发生线程切换。
更深一层,试卷考的是内存序的理解。std::atomic 支持 memory_order_relaxed、memory_order_consume、memory_order_acquire、memory_order_release、memory_order_acq_rel、memory_order_seq_cst。默认是 seq_cst,提供最强的顺序保证,但性能也有损耗。如果你熟悉 lock-free 编程,会知道在某些场景下用 acquire/release 就能保证同一线程的修改对其他线程可见。
需要注意的一点:原子操作并不等于线程安全。如果两个线程都在对同一个原子变量做 fetch_add,那么累加是原子的;但如果你的业务逻辑是“先读旧值,再根据旧值计算新状态,最后写回”,这整个过程就不是原子的,必须用 CAS 循环或锁。这一点在无锁队列实现中尤其容易出错。
4. 算法题实战:排序、快速幂、单调栈与校招的底层逻辑
4.1 冒泡排序不只是“两两交换”,它还有优化空间
搜索热词里“冒泡排序算法c++”排得很靠前,确实是校招笔试的常客。这套卷子并没有直接让你背冒泡排序代码,而是给出了一个排了一半的数组,让你补全内层循环。很多人觉得这种题简单,但越简单越容易马虎。
冒泡排序有两个常见的优化点。一个是设置标志位,如果某一轮扫描中没有发生任何交换,说明序列已经有序,可以提前退出:
template <typename T> void bubbleSort(std::vector<T>& arr) { for (size_t i = 0; i < arr.size() - 1; ++i) { bool swapped = false; for (size_t j = 0; j < arr.size() - i - 1; ++j) { if (arr[j] > arr[j + 1]) { std::swap(arr[j], arr[j + 1]); swapped = true; } } if (!swapped) { break; } } }另一个优化是记录最后一次交换的位置,这样后面的部分说明已经有序,下轮扫描不用再走到数组末尾。试卷里这种题考的不是你能不能写出 O(n^2) 的排序,而是你会不会在熟悉的代码里增加细节、提高效率。面试官看的是代码习惯,不是看能不能跑通。
4.2 快速幂算法:从“求x的n次方”到“模幂运算”
算法题里有一道快速幂,原题大概是求 x 的 n 次方并对某个质数取模。这几乎是和“斐波那契数列”同级的高频题。快速幂的核心思想是把指数拆成二进制,利用分治减少乘法次数。比如 x^13 = x^8 * x^4 * x^1,其中 13 的二进制是 1101。
long long fastPow(long long base, long long exp, long long mod) { long long result = 1; base %= mod; while (exp > 0) { if (exp & 1) { result = (result * base) % mod; } base = (base * base) % mod; exp >>= 1; } return result; }这里有两个容易写错的地方。第一,base 要先对 mod 取模,防止前面几次乘法就溢出。第二,result 和 base 的乘法要用 long long 或更大的类型承接,否则中间结果可能超出 int 范围。如果题目要求的 mod 是 10^9+7,两个数相乘可能接近 10^18,必须用 long long;如果 modulus 更大,还可能需要“快速乘”或 __int128,这也是进阶的考法。
我记得这道题的变体里加入了矩阵快速幂。让你用矩阵快速幂求斐波那契数列的第 n 项。这类题其实不存在什么新意,核心就是先定义矩阵乘法,再用同样的二进制拆解思路。写矩阵乘法时要注意为每一层循环单独开一份临时结果,避免污染当前矩阵。
4.3 单调栈:解决“下一个更大元素”的经典框架
如果你刷过 LeetCode,对“单调栈”一定不陌生。《物流网络》那道题我在别的文章里详细讲过,这里单说单调栈本身。这套卷子里的题目场景是:给你一个数组,要你求出每个元素右侧第一个比它大的元素的下标。暴力解法是两重循环 O(n^2),单调栈能做到 O(n)。
单调栈的核心是维护一个栈,让栈内元素保持单调递减(或递增)。遍历数组时,每来一个新元素,就循环把栈内比当前元素小的元素弹掉,并记录答案。这样每个元素最多进栈一次、出栈一次,总复杂度 O(n)。
std::vector<int> nextGreaterElement(const std::vector<int>& nums) { int n = nums.size(); std::vector<int> res(n, -1); std::stack<int> stk; for (int i = 0; i < n; ++i) { while (!stk.empty() && nums[stk.top()] < nums[i]) { res[stk.top()] = i; stk.pop(); } stk.push(i); } return res; }单调栈的难点从来不是模板本身,而是能否识别出题目可以转化为单调栈。这套卷子里的题把场景包装成了“每日温度”的变体,本质没变。我建议把所有单调栈题型的套路背熟:找右边第一个更大元素,用单调递增栈存下标;找右边第一个更小元素,用单调递减栈存下标。每次循环先清栈,再压栈,思路非常固定。
4.4 排序、搜索、模拟题之外,还要注意复杂度推导
这套卷子里的算法题并不难,但最后一道“求 n 个整数的最小公倍数”反而刷掉了一批人。单求两个数的 LCM 很简单:a * b / gcd(a, b)。但 n 个数的 LCM 需要小心溢出。正确的做法是边遍历边约分,每次用当前结果和下一个数求 gcd,然后乘除,这样才能保证中间结果尽量小。
我见过很多人一上来就把所有数乘起来再除 gcd,结果中间乘积直接溢出。正确的写法是:
long long lcm = 1; for (int x : nums) { lcm = lcm / std::gcd(lcm, x) * x; }先除后乘,这是基本技巧,却很容易被忽略。这套卷子考的不只是算法模板,还有对数值范围、溢出风险的敏感度。安全方向的开发,对数据边界、溢出问题的敏感度是基本素养。
5. 设计模式与工程化能力:回调、观察者与C++项目构建
5.1 回调函数在C++里的几种实现方式
奇安信这套卷子考了回调函数的例子。回调函数本质上是一种解耦机制:你定义一个函数,并把函数指针(或可调用对象)传给另一个模块,让它在特定时机调用你定义的函数。C++ 里实现回调的方式至少有五种:
| 方式 | 场景 | 注意点 |
|---|---|---|
| C 风格函数指针 | 兼容 C 库接口 | 只能捕获自由函数或静态函数,无法捕获 this |
| 函数对象(仿函数) | 需要保存状态 | 通过重载 operator(),灵活但代码量大 |
| std::function | 万能可调用对象封装 | 开销较大,注意生命周期 |
| Lambda 表达式 | 现代C++首选 | 捕获列表要小心悬空引用 |
| 模板回调 | 性能要求高的场景 | 编译期多态,增加代码体积 |
写异步网络库时,我习惯用 std::function 加回调参数。比如一个异步读取操作,用户传入一个 lambda 作为完成回调。但这里有个大坑:如果异步任务比回调对象的生命周期还长,而 lambda 捕获了对象成员引用,就会出现悬空引用。正确的做法是捕获 shared_ptr 的副本,或者保证回调生命周期被统一管理。
试卷里那道回调题还顺带问了一句“std::function 和函数指针有什么区别”。答案在于 std::function 是一个类模板,它可以封装任意可调用对象,代价是通常会有一次或多次隐式分配。如果你在一个高频回调路径上,建议使用模板或函数对象来避免 std::function 的内存分配开销。
5.2 观察者模式:从发布订阅到信号槽
作为信号槽机制的前身,观察者模式在客户端开发中特别常见。试卷里要求你设计一个简单的事件系统:某个事件发生时,多个监听者都能收到通知。最直观的实现是维护一个 list of std::function 回调,每次触发事件就遍历调用。
class EventBus { public: using Handler = std::function<void(const Event&)>; void subscribe(const Handler& handler) { handlers_.push_back(handler); } void publish(const Event& event) { for (const auto& handler : handlers_) { handler(event); } } private: std::vector<Handler> handlers_; };但是如果我在发布事件的同时订阅新回调,或者某个回调内部把自己从列表中移除,就会导致迭代器失效。这种细节在笔试现场很难被察觉,然而它就是工程里最常见的 bug。所以我会选择先拷贝一份 handler 列表再遍历,或者在回调中使用索引延迟更新。
我在实际项目里写了太多观察者模式,核心教训是:不要在回调中直接操作订阅列表。如果必须支持,就加入一个 pending 队列,在事件循环之外更新。
5.3 Visual C++ Redistributable 和构建环境:笔试外的隐形考点
热搜词里有不少是关于 visual c++ redistributable 的,这套试卷虽然没有直接考它,但在简历和面试里很可能会被问到“你如何在 Windows 上部署一个依赖了 MSVC 运行时的程序”。这涉及 C++ 工程化的基础知识:64位版本的 Visual C++ Redistributable 只解决运行时 DLL 缺失问题,比如 VCRUNTIME140.dll、MSVCP140.dll。如果你静态链接了运行时,就不需要装 Redistributable;如果你使用了 /MD 动态链接,目标机器上必须装有对应版本的运行库。
这套卷子最后一题问的是项目构建工具的选择,并让你对比 vscode 配置 C/C++ 环境时用 tasks.json 和 CMake 的区别。很多人只会用 IDE 一键构建,说不出底层原理。我一般推荐在 VSCode 里使用 CMake Tools 插件,因为 CMake 能把编译器、链接器、依赖库、安装规则全都声明清楚,跨平台也能用。而 tasks.json 相当于写死命令脚本,一旦编译器路径变化就得改。笔试里写出“我会使用 CMake 管理项目,用 CMakeLists.txt 指定 C++ 标准、源文件、目标、依赖”,会显得比较专业。
另外,“c++怎么只能加代码的情况下减少运行时间”这个热搜词很有意思。回答的核心其实无非是:把函数参数用 const 引用传递、避免不必要的拷贝、开启编译器优化 O2、使用 noexcept 帮助优化器。笔试如果想要在算法题上跑出更快的程序,应该先关注大 O 复杂度,其次才是常数优化。顺序不能反过来。
5.4 从C++八股到真正能落地的心法
这套试卷里还有一部分是“C++八股文”,比如移动语义、完美转发、RAII、虚函数表、RTTI 等。这些题目本身不复杂,但想要答好却不容易。就拿移动语义来说,很多人能背出“std::move 是一个 cast”,但遇到下面这个写法就开始困惑:
class Foo { public: Foo(std::vector<int> data) : data_(std::move(data)) {} private: std::vector<int> data_; };这里按值接收参数再移动,和直接用一个 const ref 接收参数再拷贝,性能差异很大。按值接收允许调用者传左值时拷贝一次、传右值时零拷贝直接移动,是一种常见的现代C++写法。但这种写法对空实参也会分配内存吗?不会,移动构造只是转移指针所有权,没有为底层数据重新分配空间。
类似的点太多了,但我觉得最重要的不是背概念,而是理解 C++ 设计的底层动机。C++ 之所以在安全领域仍是主力语言,是因为它可以精细控制内存布局、直接操作硬件、接近零成本抽象、并通过模板在编译期完成大量逻辑。这套试卷正是从这些角度去筛选人的。如果你只是背题,也许能过笔试,但面试官稍一追问就会露馅。建议每一步都问自己“为什么这么设计”“换成另一种方式会有什么代价”,这样的复习方式才撑得起一个真正可用的 C++ 技能树。
最后再分享一个小技巧:这套卷子的算法题做完之后,一定要回过头来检查边界。n 等于 0 怎么办?数组为空怎么办?输入达到上限会不会溢出?我当年就因为少考虑了一个 n=0 的场景,丢掉了整套题里最不该丢的分。安全方向尤其看重边界,所谓漏洞,几乎都是边界条件没处理好的产物。这一点,无论你考不考奇安信,都值得记在心里。