☰
C++校招笔试A卷核心考点拆解:内存模型、网络编程与编程实战
2026/10/14 17:49:24 网站建设 项目流程

秋招季总能看到有人在群里发同一套题,说某安全厂商的校园招聘C/C++软件开发A卷又出了原题。去年我也完整刷过这套题,和几个一起准备秋招的同学对过答案,还专门把错题整理过一遍。今天想把整套题的考察逻辑、核心高频考点、编程题的实战思路,以及考场里容易踩的坑全部拆开聊一遍。准备C++后端、网络协议栈、高性能服务方向的同学,完全可以把它当成一份浓缩的考点地图,因为每一类题目背后,基本都对应真实工作里某一项躲不开的硬技能。

这套卷子我最直观的感受是:不靠背诵,靠理解。很多选择题看起来在考语法,实际在考内存模型和编译器行为;编程题看起来是算法题,实际上在考边界处理和工程习惯。下面按模块一层层说。

1. 试卷整体结构与考察逻辑

1.1 题型构成与分值权重

不同批次的卷子题型会有微调,但大体逃不出这四类:单选题、多选题、填空题、在线编程题。有些批次还会加一两道简答题,比如让你解释TCP的TIME_WAIT状态,或者描述线程同步的几种方式。

题型常见考察内容分值占比参考
单选/多选C++语法、内存管理、网络协议、操作系统30% - 40%
填空题sizeof结果、结构体对齐、虚函数表相关输出10% - 20%
简答题死锁条件、进程线程区别、TCP状态迁移10% - 20%
编程题链表操作、字符串处理、数据结构设计、并发场景30% - 50%

注意,这只是我根据过去几年的校招笔试题做的经验总结,不代表某个固定版本。但题型覆盖面基本一致,核心永远是“C++语言功底 + 系统底层认知 + 编码实现能力”。

选择题里面有一些特别典型的题,比如:

  • sizeof(A)的结果,其中A是一个包含虚函数、静态成员、对齐修饰符的类;
  • char* p与char p[]在sizeof和strlen上的差异;
  • vector扩容后迭代器是否失效;
  • malloc和new在异常安全上的区别;
  • 进程和线程在地址空间、切换开销上的区别。

这些题考的都是不能靠背答案混过去的东西。如果只是死记结论,换个问法立刻露馅。比如“迭代器失效”考得深一点,会问你vector在push_back之后,end()迭代器还能不能继续使用,这需要你理解底层连续内存的重新分配逻辑。

编程题则一般是两到三道,难度递增。第一道通常是链表或字符串,送分;第二道考数据结构设计,比如LRU缓存;第三道开始涉及并发或者较复杂的状态设计,用来区分考生层次。

1.2 难度梯度与选人逻辑

这套题的设置其实很有代表性。校招不像社招,可以聊业务经验和项目细节,应届生在这两方面基本是一张白纸。笔试是少数能高效量化候选人的环节,所以题目设计必须贴着一线开发真实会用到的东西。

第一梯队是送分题,目标是筛掉基本功不合格的。比如“构造函数能不能是虚函数”“析构函数为什么要声明成virtual”,这些是C++面试的入门级问题,答不对说明基础没打牢。

第二梯队是中档题,用来拉开差距。比如给一段带内存泄漏的代码让你分析问题,或者让你手写atoi并考虑所有边界情况。这类题能看出候选人平时有没有认真写过代码,有没有防御式编程的思维。

第三梯队是压轴题,用来筛选对底层理解和系统设计有感觉的人。比如上一道“线程安全单例”的编程题,背后还藏着new和malloc的异同、内存屏障、std::atomic的使用时机。这种题不是临时刷题能突击出来的,平时没有深入思考过并发编程的人,基本会在设计上翻车。

所以这套题看得不是“你刷过多少题”,而是“你有没有把C++当成一门需要理解机器行为的语言在学”。这也是很多同学刷题很多但分数不高的重要原因:答案会背,原理不理解。

2. 高频考点逐个拆解

2.1 C++语法与内存模型

每次做完选择题复盘,我都会发现C++语法类题目有一个共同特征:如果只记住结论,很容易在变种题上翻车。比如下面几个点,反复出现但总有人错。

先说sizeof相关。空类的大小通常是1,因为要保证每个对象都有唯一地址;包含虚函数的类,开头会多一个指向虚函数表的指针,在64位平台下这个指针占8字节。但如果加上static成员会怎样?static成员不属于任何对象,所以不计入sizeof结果。这个考点几乎年年有,错的人却不少,因为很多人分不清“类的大小”和“对象存储的内容”。

结构体对齐也是永恒经典。一个结构体里放了一个char、一个int、一个char,在默认对齐规则下sizeof不是6,而是12(64位平台下可能不同,但思路一致)。原因很简单:CPU访问对齐数据更高效,编译器会在成员之间插入填充字节。理解了这个原理,遇到#pragma pack(1)的变种题也能自己推导,不用背。

指针和数组的区别同样高频。char arr[] = "hello"的sizeof(arr)是6,因为包括结尾的\0;而char* p = "hello"的sizeof(p)在64位下是8,因为存的是一个指针本身,不是字符串长度。很多人写代码多年都搞不清这个区别,笔试一考就现原形。

还有一类题是关于const和指针组合的:

  • const char* p:指针指向的内容不可修改;
  • char* const p:指针本身不可修改;
  • const char* const p:两者都不可修改。

别笑,这个知识点在选择题里命中率极高。我见过不少同学在群里争论“const到底修饰的是谁”,其实口诀很简单:从右往左读,const修饰它左边最近的那个类型。

移动语义和智能指针也值得提。std::shared_ptr的引用计数是线程安全的,但指向对象的线程安全是另一回事,这两句话对很多人来说信息量极大。选择题如果问你“shared_ptr本身能否在多线程环境下安全读写”,答案是并不能,必须加锁或使用原子操作。weak_ptr的作用主要在于解决循环引用,这也是一个常考点。

2.2 操作系统与并发基础

操作系统类的题目在这套卷子里占的比例不低,尤其集中在进程线程、虚拟内存和并发控制。很多人觉得这部分偏理论,实际工作里每天都在碰,只是你没有意识到。

进程和线程的区别属于必考。进程拥有独立的地址空间,线程共享进程的地址空间;所以线程创建快、切换快,但正因为共享空间,一个线程写坏内存可能影响整个进程。考点通常还会延伸到:多进程崩溃隔离性好,多线程资源利用率高,为什么服务端常见模型是“多线程 + 事件驱动”而不是“多进程 + 阻塞”。

虚拟内存的页表映射也是常见考点。选择题会问“为什么一个进程的malloc分配的内存不一定会立即占用物理内存”,答案是Linux的malloc可能只是做了地址空间映射,真正分配物理内存发生在首次访问时(缺页中断)。我做过这道题之后才意识到,原来malloc分配快和实际使用内存快是两回事,程序跑着跑着RSS上涨就是这个原理。

死锁四个条件(互斥、持有并等待、不可剥夺、循环等待)基本算送分题。扩展问法是在一段代码里让你指出可能死锁的位置。这种题不算难,但需要你手动检查锁的顺序。解题思路很简单:两个线程加锁的顺序相反,就可能死锁;要解决就是保证所有线程按相同顺序加锁,或者用std::lock同时锁定多个锁。

并发控制里的几个工具要会区分使用场景:

  • std::mutex:临界区互斥;
  • std::atomic:简单的自增自减、标志位操作,无锁;
  • std::condition_variable:通知某个线程条件已经满足;
  • std::future/std::promise:异步获取结果。

选择题还喜欢考volatile和原子操作的区别。volatile只告诉编译器不要优化,不提供任何内存序保证;std::atomic则提供了原子性和内存屏障。写过并发代码的人都知道,这两个完全不是一个层次的东西。

2.3 网络协议与服务端编程

网络安全厂商的客户和服务端开发必然离不开网络协议,因此TCP、UDP、socket编程是这套卷子的重头戏。题型覆盖很广,从单纯的记忆题到场景分析都有。

TCP三次握手和四次挥手属于基础中的基础。但大多数人的问题在于:背得出状态名,讲不清为什么。比如为什么握手要三次而不是两次?因为要让双方都确认自己的收发能力和对方的收发能力正常。为什么挥手要四次?因为全双工通信下,一个方向的数据发送结束,不代表另一个方向也结束了。

TIME_WAIT也是高频考点,而且我怀疑是从真实运维问题里改编过来的。选择题问你“主动关闭连接的一方为什么需要TIME_WAIT”,答案是让对方足够时间收到最终的ACK,同时防止新旧连接混淆。实际服务端开发中,如果短连接量很大,TIME_WAIT状态过多会导致端口被占用、连接建立失败。排查时先用netstat看状态统计,再考虑开SO_REUSEADDR或调整内核参数。

socket编程题经常和IO模型捆绑考察。select、poll、epoll三者的区别要能说清楚:select有1024个fd的限制(因平台而异),poll用链表实现没有数量上限但仍然是线性扫描,epoll由内核直接维护事件集合,只返回就绪的fd。选择题如果给你一个“百万连接、低活跃度”的场景,答案基本就是epoll。ET(边沿触发)和LT(水平触发)的区别也是常考细节,ET模式只在状态变化时通知一次,要求你把缓冲区的数据全部读完,否则容易漏数据;LT模式是只要还有数据就持续通知,编程更简单。

网络字节序和主机字节序转换也是一个很容易失分的点。比如传输整数时,用htonl/ntohl做转换。笔试有时候会在填空里给你一个简单的值,让你算出转换结果,本质上考的是大端小端的概念。

2.4 数据结构与算法

算法题在这套卷子里不是纯粹的LeetCode推导,而是和实际工程场景相关。比如缓存设计、连接管理、定时器排序,这些都要有算法基础。

链表类题目是编程题常客,因为实现简单但边界条件多,能在很短的代码里看出编码习惯。常见的有反转链表、环形链表检测、合并两个有序链表。我后面会详细写反转链表的代码思路,这里先提一下复杂度要求,基本都是O(n)时间、O(1)空间。

二叉树考察频率也不低,层序遍历、最近公共祖先、判断是否平衡这些题目,笔试中出现的概率挺高。std::map底层是红黑树,std::unordered_map底层是哈希表,这种选择题考的不是红黑树的详细实现,而是对复杂度特性的理解:红黑树保持有序但插入/查找O(log n),哈希表平均O(1)但不保证顺序。

排序算法的稳定性属于送分题,但很多人翻车。排序稳定指的是值相等的元素在排序前后的相对顺序不变。稳定排序有冒泡、插入、归并;不稳定排序有快排、堆排、选择排序。C++的std::sort在数据量少时会切换插入排序,这种“底层优化”有时也能成为选择题素材,就算不考,理解std::sort的实现也能帮你写出性能更好的代码。

编程题如果出到“Top K”这类问题,基本考察堆的使用。用std::priority_queue维护大小为K的小顶堆,就能在O(n log K)的复杂度内拿到前K大元素。设计这类题的原因也很简单:定时器、排行榜、历史数据统计都要用到堆。

3. 编程题实战思路与代码示范

3.1 反转链表:迭代与递归

反转链表应该是C/C++笔试里出现频率最高的编程题之一。它不考察算法复杂度,考察的是你对指针操作和边界条件的掌控。题目一般是这样:给定单链表头节点,返回反转后的新头节点。

先给迭代版代码:

struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(nullptr) {} }; ListNode* reverseList(ListNode* head) { ListNode* prev = nullptr; ListNode* curr = head; while (curr) { ListNode* nextNode = curr->next; curr->next = prev; prev = curr; curr = nextNode; } return prev; }

关键点在于nextNode这一步。很多人会先写curr->next = prev再找next,结果发现curr->next已经变成前驱节点,原链表断掉了。所以必须先把下一个节点保存下来,再进行指针修改。

递归版同样要注意这个逻辑:

ListNode* reverseListRecursive(ListNode* head) { if (!head || !head->next) return head; ListNode* newHead = reverseListRecursive(head->next); head->next->next = head; head->next = nullptr; return newHead; }

递归法的核心是“先反转后面的链表,再处理当前节点”。很多人写递归时会困惑为什么返回的是newHead而不是head,其实因为head反转后会变成最后一个节点,不是新的链表头。面试官还可能追问一句“递归版的空间复杂度是多少”,答案是O(n),因为递归调用栈会占空间;迭代版是O(1)。这个追问经常出现,不要答错。

3.2 LRU缓存设计

LRU(Least Recently Used)是编程题里最能拉开分数的一道设计题。题目通常要求实现一个类,支持get(key)和put(key, value),在容量满时淘汰最久未使用的键值对。要求get和put的平均时间复杂度都是O(1)。

能想到“哈希表 + 双向链表”这个组合,基本就能拿到一半分数。哈希表负责O(1)查找,双向链表负责O(1)移动节点和删除尾节点。注意必须是双向链表,因为删除任意节点时你需要知道它的前驱,单向链表要遍历。

核心实现思路:

  • get(key):如果存在,把对应节点移动到链表头部,并返回值;
  • put(key, value):如果已存在,更新值并移动到头部;如果不存在,插入头部;如果容量超出,删除尾部节点,同时删掉哈希表里的对应项。

简单示例:

class LRUCache { public: LRUCache(int capacity) : cap(capacity) {} int get(int key) { auto it = mp.find(key); if (it == mp.end()) return -1; auto node = it->second; cache.splice(cache.begin(), cache, node); // 移动到头部 return node->value; } void put(int key, int value) { auto it = mp.find(key); if (it != mp.end()) { it->second->value = value; cache.splice(cache.begin(), cache, it->second); return; } if (mp.size() >= cap) { auto tail = cache.back(); mp.erase(tail.key); cache.pop_back(); } cache.emplace_front(key, value); mp[key] = cache.begin(); } private: struct Node { int key; int value; Node(int k, int v) : key(k), value(v) {} }; int cap; std::list<Node> cache; std::unordered_map<int, std::list<Node>::iterator> mp; };

这段代码用了std::list的splice把节点移动到头部,省去了手动写双向链表指针的麻烦。但笔试如果明确要求手写双向链表,你最好直接把指针版本写出来,否则会被判定为过度依赖STL。手写版本的时候,最容易出错的两个点是:移动节点时忘记维护prev指针、删除尾节点时没有同步删除哈希表项。代码写完后我会立刻补几个自测用例:空缓存get、容量为1时put两次、更新已有key的value。这三类边角能覆盖大部分bug。

3.3 线程安全单例

单例模式本身不算难,但“线程安全”三个字能把题目难度提一个台阶。老版本会要求你用C++03风格实现双重检查锁(Double-Checked Locking),这里有几个经典坑。

先看一个不推荐但有历史意义的写法:

class Singleton { public: static Singleton* instance() { if (!m_instance) { std::lock_guard<std::mutex> lock(m_mutex); if (!m_instance) { m_instance = new Singleton(); } } return m_instance; } private: Singleton() {} static Singleton* m_instance; static std::mutex m_mutex; };

这个写法在C++11之前存在内存序问题:new Singleton()包含“分配内存 -> 构造对象 -> 赋值指针”三个步骤,编译器可能重排为“先赋值指针,再构造对象”,另一个线程看到非空指针后直接使用,此时对象还没构造完成。C++11之后,只要m_instance声明为std::atomic<Singleton*>,并且正确处理内存序,双重检查锁才是安全的。

但更推荐直接用C++11提供的静态局部变量初始化:

class Singleton { public: static Singleton& instance() { static Singleton inst; return inst; } Singleton(const Singleton&) = delete; Singleton& operator=(const Singleton&) = delete; private: Singleton() {} };

这个写法的正确性由编译器保证:静态局部变量在首次被使用时初始化,且线程安全。我在面试中问过很多候选人为什么不用这个写法,很多人一脸茫然,说明很多人没意识到Magic Static的存在。笔试如果时间紧张,直接写静态局部变量版本,既简洁又安全,还能节省时间给后面的题目。

不过要注意:如果题目明确要求手写new版本并解释内存屏障,你就不能只写Magic Static,需要展开说明为什么双重检查锁需要原子变量和std::memory_order_acquire/release。这也是面试官喜欢追问的点:内存序到底在保护什么。答案是为了防止编译器和CPU对指令进行重排,保证线程A发布对象时使用的资源,在线程B获取时可见。

3.4 字符串处理与防御式编程

字符串题在这套卷子里出现频率也很高,尤其是手写实现库函数的变体。最简单的就是strcpy,你以为大家都会,但大部分人写出来都有问题。

char* my_strcpy(char* dest, const char* src) { if (dest == nullptr || src == nullptr) return nullptr; char* p = dest; while ((*p++ = *src++) != '\0'); return dest; }

这里有几个隐藏考点:一是为什么要返回dest,因为这是函数链式调用的基础,比如能写出printf("%s", my_strcpy(a, b));二是要加空指针判断,笔试判题机器可能不会测,但面试官一定会问;三是有人会忘记源字符串末尾的\0,导致拷贝结果后面跟着一堆乱码。

atoi是另一个高发题,看起来简单,实际上坑非常多。要处理:

  • 空字符串;
  • 前导空白字符;
  • 正负号;
  • 整数溢出(返回INT_MAX或INT_MIN);
  • 非数字字符的停止条件;
  • 多个正负号的情况。
int my_atoi(const char* str) { if (!str) return 0; while (*str == ' ' || *str == '\t') ++str; int sign = 1; if (*str == '+' || *str == '-') { if (*str == '-') sign = -1; ++str; } long long result = 0; while (*str >= '0' && *str <= '9') { result = result * 10 + (*str - '0'); if (result * sign > INT_MAX) return INT_MAX; if (result * sign < INT_MIN) return INT_MIN; ++str; } return (int)(result * sign); }

溢出判断我倾向于用long long暂存结果,代码简单且不容易引入复杂逻辑。有人喜欢直接判断result > (INT_MAX - digit) / 10,也能用,但可读性差一些。笔试判分不会纠结优化,写清楚最重要。

字符串题还有一个常见扩展:判断回文、实现字符串循环右移、压缩字符串。这些都逃不开遍历、双指针、原地操作三个套路,平时把这个套路练熟能省很多时间。

4. 考场节奏与丢分点实录

4.1 做题顺序和时间分配

参加过在线笔试的人都有经验:系统倒计时一旦开始,人的心态是完全不一样的。我见过身边同学因为死磕一道选择题,最后编程题没写完,非常可惜。

我的建议是拿到题先花一分钟浏览全部题目,尤其是编程题分布。如果编程题是三道,通常第一道送分、第二道中等、第三道偏难,先把送分题拿到。编程题写完后,再回头做选择题和填空题。

时间分配上,编程题一定要留出足够时间。很多平台不会在你过了一两个测试用例后自动判定通过,你需要自己肉眼检查边界。代码写完如果没有时间测用例,至少把主流程和边界条件重读一遍。

填空和选择题的价值不低,但不要无限制地耗。一道选择题卡超过三分钟就应该先跳过,等编程题写完还有剩余时间再回来推敲。整套卷子能做完已经很不容易,要确保每一分都花在回报率高的地方。

4.2 代码实现里的隐藏扣分点

笔试编程题的扣分点经常不在算法本身,而在代码细节。我整理几个反复出现的问题:

第一,没有初始化变量。局部变量不初始化就是随机值,有时候在本地跑刚好是对的,放到判题环境就是错的。尤其是指针变量、计数变量,声明时随手给个初始值。

第二,数组越界和下标错误。链表遍历时没有判断当前节点是否为空,树遍历时忘了判空左子树右子树。在线判题一般不会报“段错误”,而是直接显示“运行时错误”,如果自己不排查很难定位。

第三,内存泄漏。C/C++笔试里不会每次都用valgrind,但面试官可能问“你的代码有没有内存管理问题”。平时写题就养成new和delete配对、new[]和delete[]配对的习惯。如果代码里有异常分支,要保证分支里也能释放内存,否则就是隐患。

第四,返回值不完整。有些函数需要返回指针,有些需要返回长度,很多人写着写着就漏了。先把函数签名看清楚,再动手写实现。

编译环境也经常坑人。在线笔试平台的编译器版本通常不会太新,有些甚至只支持C++14,你用了C++17的std::optional或者if constexpr就可能不通过。我在笔试前习惯用-std=c++14(或者该平台支持的版本)先自查一遍,避免在编译器特性上出幺蛾子。

4.3 编译不过、运行崩溃的排查方式

在线笔试最糟心的情况是编译能过,但跑样例就崩。我总结了一套快速的排查流程。

先跑最小输入。链表反转就试试空链表和单节点;LRU就试试容量为1、反复put相同key;字符串处理就试试空字符串和只包含空白字符的字符串。大部分崩溃问题都能靠这种边界用例暴露出来。

程序超时要看看是否出现了死循环。链表环检测里容易漏掉fast == nullptr的判断;字符串处理里没有让指针前进;树递归里递归出口写错。超时问题加一个简单的计数器辅助观察,也能快速定位。

内存相关的崩溃会用模糊的“段错误”提示。我习惯把可疑的指针变量用哨兵值填充,比如把所有指针初始化为nullptr,这样崩溃点会更容易暴露。如果本地有valgrind,直接跑valgrind --tool=memcheck就能看到非法内存访问的行号,虽然笔试环境不一定支持,但平时练习时的定位能力会迁移到考场上。

还有一个很实用的经验:代码写完先自己做一次静态走查,在关键节点输出中间状态。哪怕最后注释掉这些调试输出,也比面对黑盒判题系统毫无头绪要强得多。

5. 笔试后的延伸准备

5.1 从题目反推知识体系

这套卷子的最大价值不是让你背下题目本身,而是能帮你发现自己知识体系的漏洞。做完之后我会把错题分类,分成“C++语言”“操作系统”“网络”“算法与数据结构”四类,再针对性的补。

如果选择题错在虚函数和内存布局,说明你对C++对象模型的理解浮于表面,需要回去把“指针、引用、虚函数表、内存对齐”这一串概念串一遍。如果错在TCP状态,说明你对协议栈缺少整体感知,建议拿抓包工具实际看一次握手和挥手过程,比死背状态名有效得多。

补知识点时不要孤立去看。比如你复习shared_ptr,可以顺带看weak_ptr解决循环引用的场景;再看unique_ptr和移动语义;接着看new、delete和malloc、free的对比;最后落到内存池、分配器。一条线拉下来,C++内存管理这一块就完整了。

5.2 面试追问的常见路线

笔试题目很多时候是面试提问的引子。一位技术面试官看到你AC了LRU题,很可能直接追问:“如果并发访问LRU,你会怎么做?” 这样问的目的不是让你当场写一个高性能并发容器,而是看你能不能在接口设计上给出合理方案,比如用shared_mutex区分读写锁,或者用分片锁减少竞争。

单例模式的编程题完成后,面试官大概率接着问:“new和malloc有哪些区别?” 如果你能答出new会调用构造函数、失败时抛异常而不是返回空指针,再补充new[]和delete[]的配对,这一题基本就过关了。如果还能提到定位new(placement new),面试官对你的印象会很不一样。

网络题完成之后被追问“大规模连接如何维护”也是个常见套路。你先答epoll,然后要准备好解释为什么用epoll而不用多线程,接着可能再问“哪个字段能快速定位到对应的连接对象”。这就把一个笔试知识点变成了完整的系统设计链路,所以平时练习时不要只求AC,要多想想“还能怎么考”。

这套题刷完之后我最大的感悟是:C/C++开发岗的笔试从来不是刷题那么简单。它考验的是你有没有用工程思维去理解语言和系统,有没有在平时的练习里养成边界检查、内存管理、防御式编程的习惯。我见过代码写得飞快但边界全挂的,也见过写得慢但一次通过的,差距就差在这些细节上。

最后再分享一个小技巧:笔试前一个晚上不用做新题,把之前错题集里的代码重新手写一遍,尤其是链表反转、LRU、线程安全单例这三个经典题。手感热了,面试官看到你的代码也会觉得你是个有工程意识的人,而不是一个只会背题库的刷题机器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询