☰
趋势科技校招C/C++笔试题全解析:从sizeof到链表反转的陷阱与实战
2026/10/28 15:35:10 网站建设 项目流程

2017年秋季,我坐在趋势科技校招笔试现场拿到B卷,第一遍翻完其实心里有点底:没有偏题怪题,全部是C/C++基本功、数据结构基础和少量系统编程知识,但真正动笔做到最后才发现,这套卷子几乎每一道题都埋着雷。尤其是sizeof与指针运算那几道选择题,看起来是送分题,实际拉分极狠。

现在回头看,这套题非常适合两类人参考:一类是准备安全软件、系统软件、嵌入式方向开发岗的同学,另一类是正在刷C/C++笔试题但总在细节上翻车的人。虽然年份是2017年,但C++的底层素养、内存布局、边界条件处理这些东西,到今天依然是校招开发岗的高频考点。这篇文章我把能回忆起来的题目整理成完整解析,同时从"为什么这么考"的角度拆一遍,希望能帮你避开我当年踩过的坑。

1. 这套B卷长什么样:题型结构与时间分配

趋势科技这份开发岗笔试题,整体风格偏"古典"。没有大数据、机器学习之类花哨内容,就是朴素地考察C/C++语言功底、数据结构算法以及基础的TCP/IP和多线程知识。试卷大结构分三块:不定项选择题、简答与代码填空、编程题。

题量大概是这样(按我当时拿到的B卷回忆,个别题目的排列顺序可能有出入,但考点范围是这样):

题型题量分值占比考察重点
不定项选择题10道约40%指针与数组、sizeof、C++对象模型、关键字语义、字节序
简答/代码填空3道约25%多线程数据同步、socket生命周期、代码输出预测
编程题2道约35%链表操作、字符串处理(atoi/子串)

90分钟的考试时间,我的建议分配是:选择题30分钟,简答20分钟,编程题40分钟。这里要特别说一句,顺序很重要。我当时拿到卷子先花10分钟把编程题看了一遍,确认两道题都能想到思路,心里有底之后才从第一题开始做。这样做的好处是避免先扣选择题结果在某个纠结的选项上耗太久,导致后面编程题时间不够。

选择题的不定项设置也是一个陷阱。它不确定是单选还是多选,所以如果你遇到一个拿不准的选项,宁可少选也不要乱选。我印象中考场里有人把单选当多选做,白白失分。

整套卷子还有一个隐性特点:它用大量场景化的小题考察"防御性编程"意识。这在后面编程题上体现得尤其明显,不单单是算法,你提交的代码有没有做空指针检查、有没有处理输入溢出,这些都是拿分点。

2. 选择题中的高频陷阱:指针、内存与语言细节

这套选择题密集地考了一个主题:你对C/C++内存布局到底懂多少。它不是考你能背出多少概念,而是给你一段代码让你预测输出。我挑几个印象最深的题目,逐个拆给你看。

2.1 sizeof与数组传参:看起来送分,实际最拉分

先看这道经典变体:

char str[] = "hello"; char *p = str; printf("%lu %lu\n", sizeof(str), sizeof(p));

在很多人的预期里,输出是"5 5"或者"6 6",但正确结果是"6 8"。sizeof(str)算的是数组总字节数,str是char[6],包含结尾的'\0',所以是6。sizeof(p)算的是指针变量本身的大小,在64位系统上指针统一是8字节。

这里有个关键认知:sizeof是编译期运算符,它永远不会去"追问"一个指针后面到底指向多大的内存。编译器看到数组名str,知道它有6个元素的完整类型信息;看到指针p,只知道它是个指向char的指针。这个题型几乎年年出现,但每次都会有人填错。

另一个高频变体是数组作为函数参数:

void foo(char s[10]) { printf("%lu\n", sizeof(s)); }

你一定知道函数形参会退化成指针,但拿到考场上还是容易写成10。因为代码里s[10]太有迷惑性了。编译器眼里,这个函数签名就是void foo(char *s),sizeof(s)在64位下永远是8。

我当时做题时的判断方法很简单:看到sizeof,先问自己"括号里这个东西是数组还是指针"。是数组,算整个数组的大小;是指针,就是机器字长(32位下4,64位下8),与它指向什么类型无关。别看这方法简单,它能帮你逃过一半的sizeof陷阱。

2.2 &a+1到底跳了多少:指针运算的步长逻辑

这道题我记得很清楚,因为考场上一大半人犹豫了很久:

int a[5] = {1, 2, 3, 4, 5}; int *p = (int *)(&a + 1); printf("%d\n", *(p - 1));

输出是5。关键在于&a的类型不是int*,而是int()[5],也就是指向整个数组的指针。对这个指针加1,地址会往后跳动一个完整数组的长度,也就是5个int。然后把这个地址强转成int,再做p-1,就回退一个int的大小,正好指向数组最后一个元素a[4]。

很多地方会把这道题归类为"冷知识",但我觉得它考的是非常本质的东西:指针算术是类型敏感的。加1等于加上sizeof(指针所指类型)的字节数。int加1跳4个字节,int()[5]加1跳20个字节。

顺带一提,如果题目把&a改成a,那a+1指向的就是a[1]了。&a和a虽然在数值上相同,但类型权限完全不同。建议你做题时先在草稿纸上写出每个表达式的类型,再决定它加1后跳多远。我在考场上就是靠"先写类型再做运算"防住了这类错。

2.3 volatile与const共存:语言细节里的分水岭

有一道简答题给了这样的代码片段:

extern const volatile int flag;

问这个声明是否合法,以及它是什么意思。

答案是合法。const和volatile并不冲突,它们分别从不同维度约束变量。const表示"程序自身不应该去修改这个变量",volatile表示"这个变量的值可能被外部因素(硬件寄存器、信号处理函数、多线程环境)在程序不可见的情况下改变"。

真正让你在纸上写答案的时候,要讲清楚应用场景。比如一个只读的硬件状态寄存器,你用const修饰防止代码里误赋值,但同时它反映的是外部硬件状态,运行中会变,所以必须加volatile防止编译器把它优化到寄存器里缓存。

这个点很符合安全软件公司的风格:写代码的人如果不理解编译器优化和硬件交互,写出的驱动或扫描引擎迟早出bug。别以为这种偏底层的题只有嵌入式公司会考,趋势科技这类做系统级安全工具的公司同样看重。

2.4 字节序:小端机器上的输出

选择题里有一道判断题是给int赋值后按字节输出:

int x = 0x12345678; char *c = (char *)&x; printf("%x\n", *c);

如果你在x86小端机器上跑,输出的会是0x78。小端机的规则是低字节存放在低地址。0x12345678的最低字节是0x78,而指针c指向的就是低地址,所以解引用拿到0x78。

这道题很多人会把它记成"倒过来输出",其实没那么复杂,你只需要记住两个事实:第一,大端像人写字,高字节放前面;小端像人读数字,低字节放前面。第二,从低地址开始读,读到的就是低位字节。这就不会错了。

字节序对安全软件有一个实打实的关联:解析网络包和文件格式时,如果没处理好字节序,轻则解析出错,重则出现越界访问。网络安全公司很喜欢考这类题,不是故意刁难,是真实业务里天天会遇到。

3. 简答题里的系统知识:线程、socket还有动态库

选择题之后是简答和代码填空。这部分题量不大,但每道题都需要写清楚"为什么",不是填个答案就完了。

3.1 多线程共享什么、独占什么:别用教科书回答

有一道题是:两个线程共享同一个进程地址空间,请说明哪些资源是线程共享的,哪些是线程私有的。

共享的资源包括:全局变量和静态变量、堆内存、打开的文件描述符表、信号处理函数等。线程私有部分包括:栈(每个线程有独立的栈空间)、寄存器状态(包括程序计数器)、线程局部存储(thread local)。

这道题的答案教科书上都有,但面试官想听的其实是背后的风险:既然堆内存是共享的,那么多线程同时malloc或者操作同一个全局变量,就可能出现数据竞争。如果两个线程各自持有指向同一个堆内存的指针,一个线程释放了,另一个线程还在用,就是悬空指针。

我当时还在答案里补了一句:线程共享的还有errno变量。以前errno是全局的,多线程环境下它必须是线程私有的,否则一个线程出错,另一个线程拿到的errno可能被覆盖。这个细节能体现你不是死记硬背,而是真能应用到排查问题上。

3.2 socket编程中TIME_WAIT的意义:一个连接为什么不能立刻关

有一道网络方向的简答题,问主动关闭连接的一方为什么需要TIME_WAIT状态,它存在的两个主要作用是什么。

两个作用要记牢:第一,保证最后一次ACK能够到达对端。如果这个ACK丢了,对端会重发FIN,主动关闭方需要留在TIME_WAIT状态来处理这个重发的FIN。第二,确保本端旧连接的报文段在网络中消逝,避免新连接收到旧连接的延迟数据。

我在写解析时建议你画一个TCP状态迁移图帮助记忆,但不建议在考卷上画复杂的图,面试时画图讲更合适。笔试时把状态名写全:主动关闭方发FIN进入FIN_WAIT_1,收到ACK进FIN_WAIT_2,收到对端FIN后回ACK,然后进入TIME_WAIT,持续2MSL后才真正关闭。

这道题对趋势科技这类公司尤其合理,因为网络威胁检测系统要处理大量TCP会话,TIME_WAIT堆积会影响服务端端口耗尽和连接重试策略。你如果能把"大量短连接导致TIME_WAIT过多,生产系统里怎么优化"这层经验补进去,会是个明显的加分项。

3.3 动态库与静态库的区别:笔试中的高频万金油

还有一道简答题,问静态库和动态库的区别,以及动态库的优缺点。

静态库在链接阶段被打进可执行文件,程序运行时不需要额外依赖,但可执行文件体积大,多个程序使用同一静态库会产生多份副本。动态库在运行时被加载,多个进程可共享内存中的同一份代码,节省磁盘和内存,升级库时不需要重新编译可执行文件,但带来了依赖管理和版本兼容问题。

这道题本身不难,但我在笔试时补充了一个实际现象:如果两个程序依赖同一个动态库的不同版本,可能出现"库地狱",典型的解决办法是把版本信息写进动态库文件名或者使用符号版本控制机制。这样的补充比单纯背定义更有区分度,也更能体现你真正发布过软件。

4. 两道编程题的完整拆解:反转链表与手写atoi

编程题是这套卷子的重头戏。两道题难度适中,但一上来就会筛掉一批没有边界条件意识的人。

4.1 反转单链表:从双指针到递归两种写法

第一道编程题是反转单链表,函数签名大概是:

struct ListNode { int val; ListNode *next; }; ListNode* reverseList(ListNode* head);

这题不新鲜,但胜在考察基础稳定性。迭代写法用三个指针,pre、cur、next,逐个翻转:

ListNode* reverseList(ListNode* head) { ListNode *pre = nullptr; ListNode *cur = head; while (cur != nullptr) { ListNode *next = cur->next; // 先保存下一个节点 cur->next = pre; // 当前节点指向前驱 pre = cur; // 前驱后移 cur = next; // 当前节点后移 } return pre; }

我当年考场里写的是教科书式的迭代版。但这里我建议你提前准备好递归版本,因为面试官很可能追问:"能不能用递归写一个?"

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

递归版本的核心理解点是:假设head->next已经反转完成,当前要做的就是让原链表中的第二个节点指向第一个节点,也就是head->next->next = head,然后断开head->next。很多人的误区是总想着怎么在递归中维护前驱指针,其实递归的力气花在"递归返回后如何把当前节点接到末尾"。

写完代码后,我习惯性在草稿上自测三个case:空链表、单节点链表、普通多节点链表。笔试时不允许跑测试代码,手写自测非常能发现问题。比如反转后原头节点head的next必须置空,很多人在这里漏写,导致形成环形链表,面试官一眼就能看出来。

4.2 手写atoi:边界条件就是面试官的连环追问点

第二道编程题是手写atoi,把字符串转成整数:

int my_atoi(const char *str);

这道题表面简单,骨子里考的全是防御性编程。你至少需要考虑以下输入情况:字符串为空指针、开头有空格、带正负号、中间遇到非数字字符、数字超出int范围。

我当年写出来的版本大概长这样:

int my_atoi(const char *str) { if (str == nullptr) { return 0; } while (*str == ' ') { str++; } int sign = 1; if (*str == '+' || *str == '-') { if (*str == '-') { sign = -1; } str++; } long result = 0; while (*str >= '0' && *str <= '9') { result = result * 10 + (*str - '0'); if (sign == 1 && result > INT_MAX) { return INT_MAX; } if (sign == -1 && result > (long)INT_MAX + 1) { return INT_MIN; } str++; } return (int)(sign * result); }

用一个long变量做累加,再和INT_MAX/INT_MIN比较,这是处理溢出最简单可靠的手段。如果你只用一个普通int来做累加,到了边界附近再判断就已经晚了,因为乘法加法本身就可能溢出。这个坑我见过很多人踩。

另外两个容易漏的点:第一,正负号之后直接是字符串结尾,例如输入"-",你的代码不能崩溃,第二个while循环因为不满足条件直接退出,返回0即可。第二,如果输入是"abc123",按标准atoi语义,应该返回0,因为前面的非数字字符不是空格也不是正负号,数字转换根本没有开始。我在考场上有几个人讨论过在中间遇到数字再接非数字字符怎么处理,比如"123abc",正确做法是转换到a之前为止,返回123。

面试官很可能会追问:如果数字超出int范围,是返回INT_MAX/INT_MIN还是返回一个错误码?不同的库实现有不同的约定,C标准库atoi本身是未定义行为,而C++的std::stoi会抛异常。这个问题的答案并不唯一,但你要能解释自己为什么选择这个方案。我回答的是:作为安全软件公司,解析不可信输入时宁可饱和到INT_MAX/INT_MIN,也不能让调用方拿到一个被截断的错误值。这种表达比单纯给代码更有说服力。

4.3 扩展:最长无重复子串

严格来说,这套卷子编程题是否包含LeetCode风格题我记得不全,但我强烈建议你把"最长无重复字符子串"这道题也准备好,因为它属于固定窗口滑动类题最经典的敲门砖,考法太常见了。

int lengthOfLongestSubstring(const string &s) { vector<int> lastPos(256, -1); int left = 0; int maxLen = 0; for (int right = 0; right < (int)s.size(); right++) { unsigned char ch = (unsigned char)s[right]; if (lastPos[ch] >= left) { left = lastPos[ch] + 1; } lastPos[ch] = right; maxLen = max(maxLen, right - left + 1); } return maxLen; }

这里有个值得留意的细节:用数组模拟哈希表时,下标要转成unsigned char。如果直接用char做下标,在某些平台上是带符号的,遇到大于127的字节(UTF-8编码的中文)会出现负数下标,属于未定义行为。这个坑在真实项目里踩到过并修复的人,是能让面试官眼前一亮的。

5. 答题之外的隐性标准:面试官是从代码里读人的

很多人以为笔试只要答案正确就稳了,其实不是。对于开发岗,面试官批卷子时,会透过笔试题看你写代码的习惯和思维方式。

5.1 手写代码的字体与布局也是得分点

当时我有个切身的体会:批卷的人真会看你代码的可读性。在纸上写代码,没有编译器帮你检查,变量命名规不规范、缩进整不整齐、有没有在关键步骤旁边写注释,直接反映你平时写代码的随手习惯。

我给一个非常实用的建议:在纸上写代码,宁可多花两分钟把变量名写完整,也不要写a、b、c这种缩写。写代码的时候顺手写一行注释解释"这一步在干什么",比如cur->next = pre这个操作很多人一眼就懂,但手写卷子上写着"让当前节点指向前一个节点"会让批卷人知道你是理解着写的,不是背模板背出来的。

5.2 踩过的坑是最值钱的备考素材

现在回头看这份卷子,它的难点不在于题目本身有多难,而在于它把程序员最容易忽略的细节铺满了整张卷子。我在准备校招时,把C++面试常考的坑整理过一个清单,之后每次做笔试题都会用这个清单过一遍:有没有检查空指针、数组会不会越界、整数会不会溢出、字符串有没有终止符、字节序假设是否正确、多线程共享数据有没有加锁。

这套方法放到现在依然有效。准备任何一家公司的C/C++开发笔试,你都应该先过一遍自己的常见bug清单,再去刷题。因为考场上人一紧张,最容易犯错的往往不是高深的算法,而是平时就没改掉的粗心习惯。

还有一个建议:在笔试前,把你心仪公司的主营业务方向过一遍。趋势科技这种做安全产品的公司,看重边界条件处理和不可信输入防御是很自然的事。同样的思路,投嵌入式公司要多看位运算和内存映射,投中间件公司要多看并发和IO模型。抓住这个规律,你能从试卷结构里读出很多有效信息。

最后再分享一个我自己的经验:笔试结束后,无论感觉好不好,第一时间把记得的题目写下来,趁热核对答案。我当时在回学校的公交车上就拿手机记了大概七八道题,后来面试被问到时,这些记录成了复盘的第一手素材。备考这件事,很多时候比的就是谁更早开始整理,谁的整理更体系化。这套2017年的题放到今天看依然没有过时,希望你也能从这份解析里找到自己的复习坐标。

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

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

立即咨询