1. 从一道校招题看C/算法岗的筛选逻辑
2018年前后那几年,计算机视觉和深度学习方向正处于大规模扩张期,各家做AI的公司都在抢人,校招笔试自然成了第一道筛子。商汤那年的C/算法笔试题在圈子里流传挺广,我后来陆续跟几个当年参加过这场笔试的人聊过,也自己拿这套题做过复盘。说实话,这套题放到今天看依然有参考价值,因为它考的不是死记硬背,而是你对C语言底层细节和基础算法的手感。
很多人一听到"算法岗笔试"就默认全是LeetCode那套动态规划、图论,其实那年的卷子结构挺有意思:C语言基础题占了相当比例,指针、内存布局、位运算这些"老派"内容反复出现,算法部分反而更偏向数组操作、字符串处理和简单的递归/迭代转换。这个配比背后是有逻辑的——做底层算子优化、模型部署、推理引擎的人,C功底不过关根本没法干活。你写得出快速排序,但搞不清指针和数组的区别,在实际工程里是要出事的。
这篇文章我打算把这套题的几个典型考点拆开讲透,不是给你一份标准答案就完事,而是把每道题背后"面试官到底想筛掉什么人"讲清楚。适合准备C/C++方向校招的应届生,也适合工作几年但想回头补一补底层基础的开发者。我会补充一些当年题目里没写、但实际面试中经常追问的延伸点,以及我自己踩过的坑。
2. 指针与数组的边界题:最容易翻车的第一道坎
2.1 为什么这类题几乎年年出现
C语言笔试卷子里,指针和数组的关系是永恒的开场题。当年那套题里有一道很典型的:给一段涉及数组名、指针运算、sizeof和strlen混用的代码,让你判断输出。这类题看着简单,但正确率一直不高,原因在于大部分人学C的时候是"背结论"而不是"理解内存"。
我先把核心结论摆出来,但更重要的是理解它为什么成立。数组名在大多数表达式里会"退化"成指向首元素的指针,但它本身不是指针——它是一个数组类型的对象。这个区别在sizeof和取地址&两种场景下会暴露出来。sizeof(数组名)得到的是整个数组占用的字节数,而sizeof(指针)得到的是指针本身的大小(32位系统4字节,64位系统8字节)。很多人栽就栽在这里。
int arr[10]; int *p = arr; printf("%zu\n", sizeof(arr)); // 40,整个数组 printf("%zu\n", sizeof(p)); // 8,指针本身(64位) printf("%zu\n", sizeof(arr) / sizeof(arr[0])); // 10,元素个数注意:
sizeof是编译期运算符,不是函数。它不会对表达式求值,所以sizeof(arr++)里的arr++根本不会执行。这个点在笔试题里被反复利用来设陷阱。
2.2 指针运算的步长问题
指针加减整数时,移动的步长是"所指类型的大小",不是字节数。int *p做p+1,地址实际增加4字节;char *p做p+1,地址增加1字节。当年有道题给了一个int数组和一个指向它的指针,让你算p+1和(char*)p+1分别指向哪里,本质就是考这个步长概念。
我见过太多人在这里出错,因为他们脑子里把指针当成"地址数字"在算,而忽略了类型信息。正确的思维模型是:指针 = 地址 + 类型。类型决定了它一次能跨多远,也决定了解引用时读几个字节。
再延伸一个高频追问:a和&a的区别。假设int a[5],a退化后是int*,&a是int(*)[5],两者数值相同但类型不同,a+1跨4字节,&a+1跨20字节。这个点在实际写代码时很少用到,但面试官特别爱问,因为它能瞬间区分出"真懂"和"背过"的人。
2.3 二维数组传参的经典陷阱
二维数组作为函数参数时,第二维的长度不能省略。void f(int a[][5])合法,void f(int a[][])非法。原因是编译器需要知道每一行有多长,才能正确计算a[i][j]的地址。这个知识点在当年那套题里以"找错"的形式出现,让你指出函数声明哪里有问题。
我在实际项目里也踩过这个坑。早期写图像处理代码时,想把一个二维像素数组传给处理函数,图省事写成了int **,结果运行直接崩。因为二维数组在内存里是连续的一块,而int **是"指针的指针",两者内存布局完全不同。正确做法要么用int (*p)[WIDTH],要么干脆退化成一维数组加手动索引data[i * WIDTH + j]。后者在性能敏感的场合反而更常见,因为编译器优化起来更友好。
3. 位运算与内存对齐:区分"会用C"和"懂C"的分水岭
3.1 位运算题的套路与实战价值
那套题里位运算部分有几道很经典的:判断一个数是不是2的幂、统计二进制中1的个数、不用临时变量交换两个数。这些题现在网上烂大街了,但当年做出来的人不多,因为很多人对位运算不熟。
判断2的幂,标准解法是n > 0 && (n & (n - 1)) == 0。原理是2的幂的二进制只有一个1,减1之后那个1变成0、后面全变1,两者按位与必然为0。这个技巧在实际工程里用得很多,比如哈希表扩容时判断容量、内存池按2的幂对齐分配。
统计1的个数,n & (n - 1)能消掉最低位的1,循环几次就有几个1。比逐位检查快,因为循环次数等于1的个数而不是总位数。还有个更骚的解法是查表法,把8位的结果预先算好存数组,然后按字节查,适合对性能极致要求的场景。
int count_ones(unsigned int n) { int count = 0; while (n) { n &= (n - 1); count++; } return count; }提示:写位运算时一定要用无符号类型。有符号数的右移行为是实现定义的(算术右移还是逻辑右移取决于编译器),而且负数做位运算容易出意外。这是当年题目里一个隐藏的坑。
3.2 内存对齐:笔试爱考、工程更要命
内存对齐是C语言里"课本讲得少、实际用得多"的典型。当年有道题给一个结构体,让你算sizeof,里面故意混了char、int、double,考的就是对齐规则。
规则本身不复杂:每个成员的偏移量必须是它自身大小的整数倍(在默认对齐下),结构体总大小必须是最大成员大小的整数倍。但实际算起来容易乱。我一般用"画格子"的方法:从偏移0开始,每个成员先对齐再占位,最后整体补齐。
struct S { char a; // 偏移0,占1字节 // 填充3字节 int b; // 偏移4,占4字节 char c; // 偏移8,占1字节 // 填充7字节 double d; // 偏移16,占8字节 }; // 总大小24为什么要有对齐?因为CPU访问内存是按字(word)来的,不对齐的话一个变量可能横跨两个内存块,需要两次访问再拼接,性能差。有些架构(比如某些ARM)甚至直接不允许非对齐访问,会触发硬件异常。
实际工程里的经验:结构体成员按大小从大到小排列,能减少填充字节。上面那个结构体如果改成double d; int b; char a; char c;,大小能压到16字节。这个技巧在写网络协议包、文件格式解析时特别有用,因为你要精确控制每个字节的位置。我做过一个二进制协议解析的项目,结构体没排好序,多出来的填充字节导致解析错位,排查了大半天才定位到。
3.3 大小端判断与联合体的妙用
判断机器是大端还是小端,经典写法是用联合体:
int is_little_endian() { union { int i; char c; } u; u.i = 1; return u.c == 1; }联合体的所有成员共享同一块内存,给i赋值1后,如果最低地址的字节是1,说明是小端。这个技巧当年也考过。大小端在网络编程里是绕不开的,跨平台传输数据必须统一字节序,通常约定用大端(网络字节序),发送前用htonl、htons转换。
4. 算法题拆解:数组、字符串与递归的实战变形
4.1 数组类题目的"原地操作"思路
算法部分那套题偏向基础,没有特别刁钻的。有一道是"原地删除数组中指定元素",要求空间复杂度O(1)。标准解法是双指针:一个快指针遍历,一个慢指针记录有效位置,遇到不等于目标值的就写到慢指针位置。
int remove_element(int *nums, int n, int val) { int slow = 0; for (int fast = 0; fast < n; fast++) { if (nums[fast] != val) { nums[slow++] = nums[fast]; } } return slow; }这类"原地"题的核心思想是:不要新建数组,用读写指针在原数组上覆盖。这个模式在图像处理里很常见,比如原地做像素值的阈值过滤,避免额外分配内存。我做过一个实时视频处理的项目,每帧都新建缓冲区的话内存分配开销扛不住,必须原地操作。
4.2 字符串处理的边界条件
字符串题最容易在边界上翻车:空串、只有空格、全是同一个字符、超长串。当年有道题是"反转字符串里的单词",比如"hello world"变成"world hello"。思路是先整体反转,再逐个单词反转。
void reverse(char *s, int l, int r) { while (l < r) { char t = s[l]; s[l] = s[r]; s[r] = t; l++; r--; } } void reverse_words(char *s) { int n = strlen(s); reverse(s, 0, n - 1); int start = 0; for (int i = 0; i <= n; i++) { if (s[i] == ' ' || s[i] == '\0') { reverse(s, start, i - 1); start = i + 1; } } }注意:处理字符串时永远要问自己"如果输入是空串会怎样""如果末尾没有空格会怎样"。我见过太多代码在
i <= n和i < n之间搞错,导致最后一个单词没被处理或者越界访问。
4.3 递归转迭代:面试官最爱追问的延伸
那套题里有一道递归题,具体记不太清了,但面试环节经常追问"能不能改成迭代"。递归转迭代是C/算法岗的高频考点,因为递归有栈溢出风险,工程代码里能迭代就迭代。
以斐波那契为例,朴素递归是指数级复杂度,改成迭代就是O(n):
long fib(int n) { if (n < 2) return n; long a = 0, b = 1; for (int i = 2; i <= n; i++) { long c = a + b; a = b; b = c; } return b; }更一般的情况,递归转迭代需要自己用栈模拟调用过程。比如二叉树的中序遍历,递归写法三行搞定,迭代写法要手动维护一个栈。这个转换能力能体现你对"函数调用栈"的理解深度,面试官很看重。
5. 复盘这套题:当年考生最容易丢分的几个点
5.1 眼高手低:算法会写,C基础题反而错
我聊过的几个人里,有个共同现象:刷了几百道LeetCode,结果C基础题错一片。原因很简单,LeetCode用的是高级语言的抽象,你不需要关心内存、指针、对齐这些。但C/算法岗的笔试恰恰要考这些底层细节,因为岗位本身就是要跟底层打交道。
我的建议是,准备这类岗位时,专门花时间过一遍《C专家编程》和《深入理解计算机系统》里关于指针、内存、链接的章节。不用全看,挑指针、数组、结构体对齐、位运算这几块精读。这些内容在笔试里性价比极高,投入产出比远超再刷50道动态规划。
5.2 忽略编译期与运行期的区别
sizeof、宏展开、const、static这些概念,很多人在"编译期还是运行期"上犯迷糊。比如sizeof是编译期求值,宏是预处理期文本替换,const变量在C里其实还是运行期的(C++里才更多体现编译期常量)。这些区别在笔试题里经常被拿来设陷阱。
举个典型例子:#define SQUARE(x) x*x,调用SQUARE(1+2)得到的是1+2*1+2 = 5而不是9。因为宏是文本替换,没有括号保护。正确写法是#define SQUARE(x) ((x)*(x))。这个坑我当年也踩过,后来写宏一律给参数和整体都加括号。
5.3 时间分配失衡
那套题题量不小,很多人前面C基础题磨太久,后面算法题没时间写。合理的策略是:先扫一遍全卷,把有把握的快速拿下,卡壳的先跳过。C基础题如果30秒内没思路,大概率是想偏了,先标记跳过。算法题哪怕写不出最优解,也要把暴力解写出来拿部分分。
我个人的经验是,笔试和实际写代码是两回事。笔试考的是"在压力下快速调用知识储备",所以平时练习一定要计时。我当年准备的时候,每周掐时间做两套完整卷子,模拟真实考场节奏,效果比零散刷题好得多。
6. 从这套题延伸到今天的准备策略
6.1 底层功底依然是硬通货
虽然现在AI岗很多转向了Python和框架层面,但做推理优化、算子开发、嵌入式部署的方向,C功底依然是硬门槛。那套2018年的题放到今天,考点几乎没变,因为底层的东西变化慢。指针、内存、位运算、对齐,这些十年后还是这些。
如果你目标是这类岗位,我的建议是别只盯着刷题网站。找一本讲C底层的书,配合实际写点小项目——比如手写一个内存池、实现一个简单的哈希表、做个位图。写的过程中你会自然遇到对齐、指针运算、内存管理这些问题,比单纯做题理解深得多。
6.2 算法部分要抓"变形能力"
基础算法就那些,但笔试和面试考的是变形。同一道"两数之和",可以变成"三数之和""最接近的两数之和""有序数组的两数之和"。核心是理解双指针、哈希、排序这些工具的适用场景,而不是背题。
我习惯的做法是,每做完一道题,问自己三个问题:这题的暴力解是什么?优化点在哪?如果条件变一下(比如数组变成链表、有序变成无序)该怎么改?这三个问题想清楚,一道题顶十道。
6.3 笔试之外的准备
笔试只是第一关,后面还有面试。那套题对应的面试环节,据我了解会追问项目细节、手写代码、现场分析问题。所以准备笔试的同时,别忽略项目梳理和表达能力。我见过笔试满分但面试讲不清自己项目的人,一样被刷。
具体来说,把你做过的项目按"背景-难点-方案-结果"四段式整理好,每个项目准备一个能讲三分钟的故事。手写代码部分,重点练数组、字符串、链表、二叉树这几类,因为面试现场写代码时间紧,这些是最常考的。
最后分享一个我自己的小习惯:每次做完一套笔试卷,不管对错,都把错题和卡壳的题整理到一个文档里,标注"错因"和"正确思路"。考前一周只看这个文档,比重新刷题效率高得多。这个习惯我从准备校招一直保持到现在,工作后排查线上问题也靠类似的复盘文档积累经验。