ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从趋势科技C++笔试题拆解校招底层能力考察

从趋势科技C++笔试题拆解校招底层能力考察 趋势科技2016年校招C工程师笔试试卷B卷这份卷子在当年的应届生圈子里流传度相当高即便放到现在也依然是很有参考价值的一套题。原因很简单趋势科技做的是安全方向它的笔试题目不像纯互联网公司那样只盯着算法和数据结构而是会额外考察系统底层、内存管理、网络协议这些偏底层的功底这对C岗位来说反而是最对口的考察方向。我身边不少后来去了安全大厂的朋友当年都拿这套卷子做过自测。这篇博文不打算逐题搬运答案而是把这套卷子背后真正想考察的C能力拆开来讲清楚顺便给准备校招的读者一条可执行的复习路线。1. 先说说这份卷子给我的整体印象1.1 题型构成与考察侧重点2016年的校招笔试题型结构今天看起来依然很典型分为客观题和主观题两大块。客观题以选择题为主覆盖C语法、内存模型、STL底层、操作系统、网络基础题量不大但覆盖面很广主观题则集中在代码阅读、程序输出推断和一到两道编程题。这套卷子有一个非常明显的特点它不考偏题怪题但特别爱考你自以为会但其实没吃透的知识点。比如const的各种用法、指针和引用的区别、虚函数表布局、构造函数和析构函数的调用顺序、vector扩容机制、map底层红黑树特性这些题目单独拿出来每道都不算难但组合在一张卷子里能比较准确地筛出背过八股和真正写过代码的人。B卷和A卷的关系通常不是难度差异而是题目顺序和部分题目不同用来防止相邻考生互抄。所以如果你在网上看到的回忆版是A卷还是B卷其实不需要太纠结核心考点是高度一致的。真正值得关注的是这份卷子对安全方向C工程师的能力预期——底层内存管理要清楚、多线程要懂、网络协议要熟同时对算法基本功也有要求。1.2 安全公司笔试题和互联网公司的差异同样考C安全公司和做业务系统的互联网公司考察风格差别很明显。互联网公司的C岗位更多关注业务开发能力比如高并发服务、分布式存储、中间件使用算法题往往占大头安全公司的C岗位更接近系统软件工程师因为它要写的是杀毒引擎、沙箱、网络监控、漏洞分析工具这类底层软件所以笔试会更偏向内存安全、进程模型、系统调用、协议解析。举个例子互联网公司可能考设计一个LRU Cache安全公司则更可能考分析一段存在内存泄漏的代码或者是说明malloc和new的底层区别。前者看的是算法设计能力后者看的是对运行时内存行为的理解深度。所以备考策略也应该有差别如果你目标明确要投安全厂商的C岗复习重心就不能只刷LeetCodeC对象模型、内存管理、操作系统原理这些反而要投入更多时间。2. 从这份卷子里抽丝剥茧C语法考点深挖2.1 指针、引用与内存管理永远绕不开的主题指针和内存管理是这套卷子客观题的重头戏。常见考察形式是给一段代码问输出结果或者指出错误。比如经典的指针传参陷阱void func(char* p) { p (char*)malloc(100); } int main() { char* str NULL; func(str); strcpy(str, hello); return 0; }这段代码的问题在于func内部修改的是形参p的副本str在调用后依然是NULLstrcpy必然崩溃。这类题目考的是指针作为函数参数传递时函数内修改指针本身无法影响外部这一基本认知。要修正就得传二级指针或者用引用void func(char* p) { p (char*)malloc(100); }这套卷子在内存管理上还喜欢考malloc/free和new/delete的对比。malloc只分配内存不调用构造函数new会先分配内存再调用构造函数。释放时同理。这个区别大家都背过但题目往往会再深挖一层比如malloc(0)会返回什么答案是返回一个非空指针但你不能通过它访问任何实际内存。再比如new[]和delete[]为什么要配套因为new[]分配数组时会在内存块头部记录数组长度delete[]需要读取这个长度才能决定调用多少次析构函数混用delete去释放new[]出来的数组在部分编译器上能运行在部分编译器上会崩溃本质上属于未定义行为。这类题目的价值不只是应对笔试而是安全软件开发的日常。做漏洞分析时堆溢出、UAFUse-After-Free、double free这些漏洞类型本质上都是对内存管理理解不到位造成的。2.2 构造函数、析构函数与虚函数对象模型的底层逻辑C笔试几乎必考构造和析构顺序这份卷子也不例外。考察方式是给出一个继承体系问创建派生类对象时构造函数和析构函数的调用顺序。class Base { public: Base() { cout Base构造 endl; } virtual ~Base() { cout Base析构 endl; } }; class Derived : public Base { public: Derived() { cout Derived构造 endl; } ~Derived() { cout Derived析构 endl; } }; int main() { Base* p new Derived(); delete p; return 0; }输出顺序是Base构造、Derived构造、Derived析构、Base析构。构造从基类到派生类析构从派生类到基类。但这里还有一个隐藏考点如果Base的析构函数不是虚函数那么delete p只会调用Base::~Base()导致Derived的析构不被执行派生类中申请的资源就可能泄漏。这就是为什么基类析构函数几乎总应该声明为virtual。深入一点虚函数表的布局也是常考内容。有虚函数的类对象内存布局最前面会有一个虚表指针vptr指向该类的虚函数表。派生类如果覆盖了虚函数虚表中对应槽位会更新为派生类函数地址。这类题目往往结合sizeof一起考比如class A { int x; virtual void f() {} };在64位系统上sizeof(A)不是4而是16。因为要内存对齐到8字节int占4字节加padding 4字节再加虚表指针8字节。如果类里有两个虚函数sizeof依然是16因为只有一个vptr。理解了这个笔试里判断sizeof的题基本不会错。2.3 STL与string容器底层机制是高频考点STL部分这份卷子围绕vector扩容机制出题最多。vector是一个动态数组当元素数量超过容量时会重新分配一块更大的内存把旧元素拷贝或移动过去再释放旧内存。不同的STL实现扩容策略不一样常见的GCC实现是扩容为原来的2倍MSVC早期版本是1.5倍。题目通常会让计算一个vector初始容量为1依次push_back10个元素期间会发生多少次内存重新分配按2倍扩容容量变化是1、2、4、8、16前10次push_back中第1次不需要扩容初始容量1第2、3、5、9次触发扩容一共4次重分配。这类题目看起来简单但很多人会忽略初始容量或者把扩容次数算成元素个数很容易错。map的底层是红黑树插入、删除、查找都是O(logN)元素按key有序排列unordered_map底层是哈希表平均O(1)查找但元素无序。笔试选择题会问哪种容器适合需要有序遍历的场景答案是map或set问哪种容器查找效率最高在数据量很大时通常是unordered_map但要注意哈希冲突攻击的问题安全软件处理不可信输入时用unordered_map可能有被哈希碰撞拒绝服务攻击的风险这种场景下map反而更安全。这个点当年很多人没想到但它恰恰是安全公司笔试的加分项。string的考察更多集中在和C风格字符串的对比上。std::string会自动管理内存、自动维护长度、支持直接赋值和拼接比char*安全得多。但题目会考察c_str()返回的指针在什么情况下失效——比如string对象重新分配内存或析构之后。还有string的find、substr、replace这些接口的边界条件也是常见的出题方向。3. 算法与数据结构这些题现在依然高频3.1 链表类题目笔试编程题的常青树这套卷子的编程题链表方向出了经典的单链表反转。题目不难但非常考察基本功和边界处理。struct ListNode { int val; ListNode* next; ListNode(int x) : val(x), next(NULL) {} }; ListNode* reverseList(ListNode* head) { ListNode* prev NULL; ListNode* curr head; while (curr) { ListNode* next curr-next; curr-next prev; prev curr; curr next; } return prev; }链表的题目最怕的就是指针丢失。反转过程中先保存next再改curr-next这个顺序一旦写反链表就断了。笔试时如果时间紧张至少要用一个简单用例在纸上推演一遍确认边界节点都能正确处理。另一个常见题目是判断链表是否有环用快慢指针快指针每次走两步慢指针每次走一步如果相遇则有环。这两个题目现在是热身级别的但在2016年属于标准校招难度可以说这份卷子的算法题定位是基础扎实的工程向而不是竞赛向。3.2 排序与查找手写快排和二分边界排序题在选择题里出现过编程题有时会让手写快排。我当年一个很重要的经验是快排的写法一定要固定成自己最熟悉的一种不要每次临场想。不同写法在边界处理上差异很大临场容易写出死循环或者越界。我个人习惯的写法是经典的双指针交换版int partition(vectorint nums, int left, int right) { int pivot nums[left]; while (left right) { while (left right nums[right] pivot) right--; nums[left] nums[right]; while (left right nums[left] pivot) left; nums[right] nums[left]; } nums[left] pivot; return left; } void quickSort(vectorint nums, int left, int right) { if (left right) return; int mid partition(nums, left, right); quickSort(nums, left, mid - 1); quickSort(nums, mid 1, right); }注意nums[right] pivot和nums[left] pivot这两个判断必须带等号否则遇到重复元素会死循环。这类细节笔试题不会直接考代码填空但会在程序输出题里让你看出错误。二分查找的考点集中在边界条件。比如用二分法找数组中的第一个大于等于目标值的位置int lowerBound(vectorint nums, int target) { int left 0, right nums.size(); while (left right) { int mid left (right - left) / 2; if (nums[mid] target) { left mid 1; } else { right mid; } } return left; }这里right nums.size()区间是左闭右开这是最不容易出错的一种写法。如果你的习惯是right nums.size() - 1那就要配套处理mid - 1和mid 1的移动。关键是不管用哪种区间约定整套代码必须自洽不混用。3.3 动态规划与经典算法题掌握递推思路编程题偶尔会涉及动态规划。卷子里出现过的最典型的一类是求最长递增子序列长度。这个题O(N^2)的写法很直观但更好的解法是维护一个当前最长递增子序列的尾部最小值数组然后做二分查找将时间复杂度优化到O(NlogN)。int lengthOfLIS(vectorint nums) { vectorint tails; for (int num : nums) { auto it lower_bound(tails.begin(), tails.end(), num); if (it tails.end()) { tails.push_back(num); } else { *it num; } } return tails.size(); }这类题目在笔试现场不要求一定写出最优解O(N^2)版本也能拿到大部分分数。如果目标是保证拿到编程题的分数建议先把朴素版本写正确再考虑优化。算法题拿满分的优先级低于把基础题全部做对的优先级。快速幂算法也是一个值得掌握的考点。虽然本题出现的概率不高但在考察大数取模相关计算类题时快速幂往往是核心工具。实现上利用二分思想把指数拆成二进制每次对底数平方遇到二进制位为1时乘入结果。long long fastPow(long long a, long long b, long long mod) { long long res 1; while (b) { if (b 1) res res * a % mod; a a * a % mod; b 1; } return res; }复杂度从O(b)降到O(log b)数据量大时是质变。3.4 单调栈冷门但已经进入视野的考点2016年那会儿单调栈还不是校招笔试的常客但最近几年已经变成主流考点。虽然这套卷子里没有直接考察但准备校招的时候值得一并复习。单调栈的核心思想是维护一个栈内元素单调递增或递减常用于解决下一个更大元素柱状图中最大矩形这类问题。vectorint nextGreaterElement(vectorint nums) { int n nums.size(); vectorint res(n, -1); stackint st; for (int i 0; i n; i) { while (!st.empty() nums[i] nums[st.top()]) { res[st.top()] nums[i]; st.pop(); } st.push(i); } return res; }栈里存的是下标而不是元素值这样才能在出栈时准确记录结果位置。这种空间换时间的思路配合前面总结的常规算法基本能覆盖校招算法题的主要类型。4. 安全公司的特色考题操作系统与网络基础4.1 进程与线程多线程安全是C工程师的必修课趋势科技这套卷子的选择题里操作系统知识占比不低尤其是进程线程相关。常见考察点是进程和线程的区别、线程同步方式、死锁产生的四个必要条件。这些是常规考点但安全方向会多一个视角线程安全问题本质上和漏洞利用中的竞态条件race condition密切相关。举一个典型的考察方式给一段多线程代码问输出是否确定int counter 0; void increment() { for (int i 0; i 100000; i) { counter; } }两个线程同时执行increment最终counter不一定是200000因为counter不是原子操作它包含读取、加法、写回三步两个线程可能同时读取到同一个旧值都加1后写回导致中间丢失一次更新。解决方案是加锁或使用std::atomic。这个点在服务端开发和漏洞研究里都非常重要一个看似正确的多线程程序可能在特定调度下崩溃或产生错误结果。面试时关于线程栈默认大小、线程切换开销、锁的粒度控制这类延伸问题也能在笔试复习阶段一并准备好。尤其是锁的粒度越大越安全但不高效粒度越小越高效但容易出现并发bug这一trade-off安全软件里同样存在比如在hook系统调用时需要保证全局hook表不被并发修改同时不能长时间持有锁导致性能回退。4.2 网络协议与安全基础TCP和HTTP是必备常识网络方向的题目集中在TCP/UDP协议、TCP三次握手、HTTP协议基础。这些知识点对C后端和网络安全岗位都很重要。TCP三次握手是必考中的必考SYN、SYNACK、ACK为什么需要三次而不是两次核心原因是避免历史重复SYN包干扰连接。因为网络环境不可靠可能迟到的旧SYN包先到达服务端如果没有第三次握手服务端会误以为客户端已收到确认从而建立一条已经废弃的无效连接。这类题在安全视角下可以延伸SYN Flood攻击就是利用TCP三次握手的缺陷由攻击者发送大量SYN包但不完成第三次握手耗尽服务端的半连接队列导致正常用户无法建立连接。这个知识点如果能在笔试主观题中写出来在安全厂商的面试中是很大的加分项。HTTP相关题目有些会问到GET和POST的区别、幂等性以及HTTP无状态特性如何用Cookie和Session解决。安全方向还会关注HTTP头注入、CSRF、XSS这类基于协议层的攻击模型。4.3 安全的底子内存安全相关考察作为安全公司偶尔会在笔试中加入一道找漏洞类的题目最典型的就是栈溢出。给一段使用了strcpy、gets这类不安全函数的代码问哪里有问题、如何修复。比如void copyData(const char* input) { char buffer[64]; strcpy(buffer, input); }问题在strcpy不会检查input的长度如果输入超过63字节就会发生缓冲区溢出可能覆盖栈上的返回地址造成程序崩溃或被利用执行任意代码。修复方式包括改用strncpy并在末尾手动加\0或者直接使用std::string和std::copy这类安全接口。这类题目考的不是漏洞利用技巧而是你是否具备写安全代码的直觉。作为C工程师在编码时主动避开危险函数、使用安全的字符串类、检查数组边界是基本职业素养。5. 从笔试到面试这份卷子告诉我们的备考路线5.1 复习优先级根据岗位方向分配时间如果目标明确是安全厂商C岗位复习优先级我建议这样排第一优先级C语言本身。对象模型、内存管理、STL底层原理、构造析构、虚函数、const、static、智能指针。这些是笔试的基本盘也是面试问答的主战场。参考书目方面《Effective C》和《C Primer》的对应章节就足够了不需要把标准库每一个接口都背下来。第二优先级操作系统和网络。进程线程、同步互斥、死锁、虚拟内存、TCP/IP、HTTP。安全厂商尤其重视这些而且这部分知识和C笔试题目可以互相印证比如写一个线程安全的单例模式就同时考到了C和线程同步。第三优先级算法与数据结构。链表、树、栈、队列、排序、二分、动态规划是核心。不需要死磕难题偏题把基础题型练熟保证笔试编程题至少能做出一到两题就足够进入下一轮了。5.2 答题策略如何把会做的题稳定拿分我在帮学弟学妹做笔试模拟时发现C笔试题丢分最可惜的往往不是不会的题而是会做但写错的题。比如选择题里以下哪个选项会导致编译错误很多人在A和B之间犹豫最后选了看似正确的错误选项。这里有一条经验C选择题凡是涉及未定义行为的选项通常就是正确答案。比如解引用空指针、数组越界、使用未初始化的变量、delete非new分配的内存这些都属于未定义行为题目常拿它们当正确答案来出。主观题部分读代码写输出这类题最好的方式是先在草稿纸上把每一步的内存变化画出来尤其是涉及指针、引用、虚函数的题。不要直接在脑内跑结果很容易漏掉隐式转换和临时对象析构的影响。编程题时间分配方面建议先花两到三分钟把题目读懂确认输入输出格式再动手写。写完必须手动跑一遍小用例比如链表反转用三个节点、快排用一个含重复元素的数组。很多细节错误都能通过手跑用例暴露出来。如果时间充裕再补充对空输入、单元素输入、重复元素输入的边界处理。5.3 从笔试延伸到面试把题目答案变成面试素材笔试结束之后面试官很可能会顺着笔试题继续追问。最典型的追问方式就是你刚才写的链表反转如果链表很长怎么避免递归栈溢出你刚才说vector扩容会拷贝旧元素如果元素不可拷贝怎么办map和unordered_map如果让你选什么场景下你会用哪个这些追问考察的不是标准答案而是你有没有真正理解背后的机制。所以备考的时候不要只记结论要尝试把每个知识点讲成一个为什么的故事。比如vector扩容为什么通常选2倍因为这样均摊时间复杂度能达到O(1)而如果每次只增加固定大小均摊复杂度会变成O(N)。又比如shared_ptr的引用计数为什么线程安全但指向的对象不线程安全因为引用计数的增减是原子操作但对象的读写没有加锁。在安全厂商面试中还有一个常见的延伸角度如果构造一个vector反复push_back大量数据内存碎片会不会很严重这个问题没有标准答案但它考察的是你愿不愿意站在系统层面思考内存分配的代价。平时写代码时多留一个心眼面试时就能多一层谈资。5.4 最后分享几个我在刷题过程中的实操习惯准备这套卷子的时候我养成了几个习惯一直保留到现在。第一个习惯是每一道错题都写一段错误原因分析而不是简单地记正确答案。比如某个sizeof题算错了分析原因是对虚表指针和内存对齐的记忆不牢固那就把内存对齐规则重新过一遍再找三五道同类题验证。这种按错误追根的复习方式比按章节从头过一遍效率高得多。第二个习惯是准备一个代码片段本把高频的手写代码整理成自己最熟悉的一套写法。链表反转、快排、二分、快慢指针、单调栈、快速幂、单例模式、线程安全的懒加载这些片段反复写到肌肉记忆的程度。笔试现场时间紧张能直接默写出来的代码就是最稳的分数。第三个习惯是做完一份卷子之后主动把错题关联的知识点列一个清单再结合前文说的优先级做二次复习。比如一套卷子下来发现C对象模型错了三题那就集中花半天时间把这个主题吃透而不是每天刷一道题碎片化地补。这种集中突破的方式对校招准备阶段尤其有效。这份2016年的B卷虽然已经过去多年但它考察的知识结构并没有过时。C的底层功底、算法基本功、操作系统和网络基础至今依然是校招笔试的核心框架。拿它做一次完整的自测再按照薄弱点做专项突破备考思路会比漫无目的地刷题清晰很多。
返回列表