简介:《名企AI面试100题1》是一份面向求职者与在校生的AI笔试面试复习资料,聚焦计算机语言基础这一入门且高频的考察模块,适合准备互联网名企技术岗面试、需要快速梳理常见考点的人群使用。压缩包内为1个PDF文件,大小约49.22MB,内容按题目组织,涵盖C++中extern "C"的作用与名称修饰、多线程与多任务的区别、Java类初始化顺序、死锁的构造方式、for循环删除ArrayList元素的隐患与替代方案、Linux下查看CPU核数与进程、用iptables拒绝指定来源IP访问80端口、通过smartctl判断硬盘健康状态,以及Python去重、lambda、快排、回文判断、深浅拷贝等知识点,每题配有解释与示例代码。该资源已有251人学习,题目解析经过多轮审校,可帮助读者在短时间内建立答题框架、对照查漏补缺,也便于按章节顺序系统刷题。
1. undefined reference to 与 ConcurrentModificationException:两个最常被刷题跳过、面试被追问的报错
编译通过、链接失败,大概是每个写 C++ 的人绕不过去的第一道坎。这次翻车现场来自《名企AI面试100题》计算机语言基础那一章里的一道老题:C++ 文件调用 C 模块的函数,g++ -c阶段干干净净,最后一步ld甩出一行undefined reference to 'add(int, int)'——函数名明明写对了,头文件也包含了,偏偏就是找不到符号。同样的事也发生在 Java 侧,for循环里删一个ArrayList元素,ConcurrentModificationException直接糊脸,而很多人刷题库时只记住了「用 Iterator」,却没搞清楚它为什么能躲开这个异常。
这类故障看着零散,其实是同一类问题:语法层面对了,链接期或运行期的隐式规则没对上。题库里那 100 道题覆盖语言基础、算法、机器学习、深度学习几个方向,真正在面试里拉开差距的,往往不是能不能背出答案,而是被追问「为什么这么写」「换个写法会怎样」时能不能接住。这篇就把我在复盘这些题时反复踩到的几类报错摊开:从链接期找不到符号,到集合遍历时抛并发修改异常,再到模型训练 loss 变 NaN,每一条都落到能直接抄的修法和排查命令上。
2. undefined reference to:extern “C” 与 name mangling 的链接期翻车
先看报错长什么样。Linux 下 GCC 给的是undefined reference to 'add(int, int)',Windows 下 MSVC 给的是unresolved external symbol "int __cdecl add(int,int)" (?add@@YAHHH@Z)。两句话说的是同一件事:链接器在目标文件里翻了一圈,没找到被调用方应该导出的那个符号。注意报错里括号中的参数类型——这是 C++ 编译器对函数名做了加工之后的结果,也正是问题的源头。
2.1 先看符号表,别急着改代码
很多人一看到这个报错,第一反应是把头文件里的声明改成extern "C"再重编译,改完发现还是错,就开始怀疑编译器。正确的顺序是先确认两边到底生成了什么符号,一条nm就能看清。
# 两个模块分开编译,注意一个是 gcc,一个是 g++ gcc -c cExample.c -o cExample.o g++ -c cppFile.cpp -o cppFile.o # 看 C 模块导出的符号 nm cExample.o | grep add # 0000000000000000 T add # 看 C++ 模块引用的符号 nm cppFile.o | grep add # U _Z3addiiT表示这个符号在本目标文件里已定义,U表示未定义、需要链接时从别处找。C 编译器生成的是干净的add,C++ 编译器生成的是_Z3addii——_Z是 Itanium ABI 的名字修饰前缀,3add表示函数名长度 3,ii表示两个 int 参数。链接器拿_Z3addii去cExample.o里找,自然找不到。
这就是 name mangling(名字修饰)。C++ 支持函数重载,编译器必须把参数类型编进符号名里,才能让add(int, int)和add(int, float)在符号表里区分开;C 不支持重载,所以符号名就是函数名本身。两边规则不一致,链接就对不上。
提示:Windows 上
nm换成dumpbin /symbols cExample.obj,看输出里的UNDEF和External标记,逻辑是一样的。macOS 的nm符号名前面多一个下划线,属于同一套机制。
nm -C cppFile.o可以把修饰名还原成可读形式,排查长模板名时报错信息会友好很多。
2.2 正确写法:头文件用 __cplusplus 守卫
修法本身不复杂,难的是写对位置。extern "C"是「链接声明」,它告诉 C++ 编译器:这一段里声明的函数按 C 的规则生成和查找符号,不做名字修饰。它只对声明生效,不对函数体生效,所以加在哪里、加几处,决定了能不能对上。
/* cExample.h:C 模块的头文件,用 __cplusplus 守卫包住 extern "C" */ #ifndef C_EXAMPLE_H #define C_EXAMPLE_H #ifdef __cplusplus extern "C" { #endif int add(int x, int y); #ifdef __cplusplus } #endif #endif关键在#ifdef __cplusplus。这个宏只在 C++ 编译器里被定义,C 编译器预处理时整段extern "C" { }会被跳过,只剩下int add(int x, int y);这一行普通声明。这样同一个头文件,C 和 C++ 都能#include,谁也不会报语法错。
参数说明只有两点:extern "C"后面可以跟单条声明,也可以跟花括号包住一组声明;花括号是语法结构,不是作用域,不改变变量或函数的可见性。写的时候别漏掉右花括号和#endif的配对,漏了会出现「头文件被包含两次后声明丢失」这种更难查的问题。
2.3 反向调用:C 调 C++ 不能照抄
面试里常被追问的第二问是:如果反过来,C 代码要调 C++ 写的函数,该怎么写?答案是同样的守卫,但位置要放到 C++ 侧的头文件里。
// cppExample.h:C++ 模块对外暴露的头文件 #ifndef CPP_EXAMPLE_H #define CPP_EXAMPLE_H #ifdef __cplusplus extern "C" { #endif int add(int x, int y); // 实现放在 .cpp 里,正常写 C++ 代码 #ifdef __cplusplus } #endif #endifC 侧调用时,直接#include "cppExample.h"就行——因为守卫生效,extern "C"被跳过,C 编译器看到的只是一条extern int add(int, int);的声明。
/* cFile.c:C 侧调用,include 带守卫的头文件即可 */ #include "cppExample.h" int main(void) { return add(2, 3); }这里有个历史遗留的坑要提前说清楚:网上不少老文章写「C 文件不能 include 声明了 extern “C” 的头文件」,这话只对没有守卫的老式头文件成立。早期写法是把extern "C"直接裸写在头文件顶层,C 编译器读到字符串字面量会报expected identifier or '(' before string constant。加了__cplusplus守卫之后,这个限制就不存在了,两边都能安全包含。
2.4 三个高频误用,逐条对照
第一类误用是在.c文件里直接写extern "C"。C 编译器不认识这个语法,编译阶段就挂了,报错位置通常指向那个字符串。修法是把它挪进头文件并加守卫,源文件只写普通声明。
第二类误用是「声明加了、定义没加」。extern "C"是声明侧的链接属性,函数实现本身不需要写。如果头文件里加了守卫、调用方也按 C 规则找符号,但实现方是用 C++ 编译且头文件没被包含进去,导出的仍然是修饰名,照样对不上。判断方法是分别nm两个目标文件,看一边是T _Z3addii、另一边是U add,就是这种错位。
第三类误用是「以为 extern 能解决所有找不到符号的问题」。extern关键字本身只声明可见性,跟符号名规则无关;真正决定符号名的是extern “C”这个组合。链接器报undefined reference,先分清是名字对不上,还是根本就没把这个.o或.a加进链接命令。后者加-L和-l也救不了,得直接确认目标文件是否存在。
3. ConcurrentModificationException:for 循环删 ArrayList 炸在了哪里
这个异常属于「代码看起来特别正常、跑起来就炸」的典型。写一个再简单不过的循环,把列表里某个元素删掉,控制台立刻甩出十几行堆栈,最上面一行是java.util.ConcurrentModificationException,往下翻能看到ArrayList$Itr.checkForComodification。很多人第一次遇到会懵:明明是单线程,哪来的并发?
3.1 报错栈在检查什么
先把最小复现摆出来。
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); // 写法一:增强 for 循环里删元素 for (String s : list) { if ("b".equals(s)) { list.remove(s); // 抛 ConcurrentModificationException } }异常栈一般长这样:
Exception in thread "main" java.util.ConcurrentModificationException at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:911) at java.util.ArrayList$Itr.next(ArrayList.java:861) at com.example.Demo.main(Demo.java:8)checkForComodification这个名字已经把答案写出来了:它在比对两个计数器的值。ArrayList内部维护一个modCount,每次结构性修改(add、remove、clear)都会加一。增强 for 循环是语法糖,编译后等价于拿一个Iterator,这个迭代器在创建时把当时的modCount存进自己的expectedModCount。每调一次next(),都要检查modCount == expectedModCount,不相等就抛异常。
问题在于:你调的是list.remove(s),改的是ArrayList自己的modCount;迭代器手里的expectedModCount还停在创建那一刻。两边一对不上,直接 fail-fast。它不是真的检测到了并发,而是在说「遍历过程中这个集合被改过,继续遍历的结果不可靠,我拒绝服务」。
注意:这个异常是 fail-fast 机制主动抛的,属于「尽早暴露问题」。所以它有时不是必现——如果删除正好发生在最后一个元素,循环可能不进入下一次
next(),异常就不抛了。这种「偶尔不报错」最坑,测试环境跑得过、线上数据量一大就炸。
3.2 四种改法,按场景选
第一种,也是最标准的写法:用迭代器自己的remove。
Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if ("b".equals(s)) { it.remove(); // 迭代器 remove 会同步 expectedModCount = modCount } }Iterator.remove()内部除了删元素,还会把expectedModCount重新对齐到modCount,校验自然就过了。这是「一边遍历一边删」的通用解,List、Set都适用。
第二种,Java 8 之后更简洁的removeIf。
// 内部用位图批量标记再统一删除,只改一次 modCount list.removeIf(s -> "b".equals(s));它适合条件可以写成一个布尔表达式的场景,代码短,也不会有漏删问题。注意removeIf接收的是Predicate,删除逻辑复杂、需要记录被删元素时,还是用迭代器更灵活。
第三种,倒序索引遍历。
for (int i = list.size() - 1; i >= 0; i--) { if ("b".equals(list.get(i))) { list.remove(i); } }正序删除的问题是:删掉下标 i 的元素后,i 后面的元素整体前移,i++再执行就跳过了一个元素,导致漏删。倒序遍历不受影响,因为被删元素后面的下标都已经处理过了。这个写法在只删不增、且集合支持随机访问时最省事。
第四种,CopyOnWriteArrayList。它的迭代器基于创建时的数组快照,遍历期间不会抛这个异常。代价是每次写操作都复制整个数组,内存开销和写延迟都不小。
// 读多写极少才考虑,比如监听器列表 List<String> list = new CopyOnWriteArrayList<>(Arrays.asList("a", "b", "c"));还有一点要说清楚:它不抛异常,不代表拿到的是最新数据。迭代器遍历的是快照,遍历期间别的线程写进去的元素你看不到。把它当成「万能解」用到写频繁的场景,性能会先崩给你看。
3.3 两个容易忽略的同类坑
第一个坑是List<Integer>的remove重载。
List<Integer> nums = new ArrayList<>(Arrays.asList(1, 2, 3)); nums.remove(1); // 删的是下标 1,元素 2 nums.remove(Integer.valueOf(1)); // 删的才是值为 1 的元素remove(int index)和remove(Object o)是两个重载,传基本类型 int 时走的是删下标。刷题时看到列表长度莫名其妙少了一个,先回来核对这里。
第二个坑是Arrays.asList返回的固定长度列表。对它调remove抛的是UnsupportedOperationException,跟本文说的并发修改异常不是一回事,但报错位置相近,容易混。要改就包一层new ArrayList<>(Arrays.asList(...))。
还有subList:它返回的是原列表的视图,原列表结构一变,视图上的任何操作都会抛ConcurrentModificationException。需要长期持有的子列表,老老实实复制一份。
4. loss 变 NaN、精度虚高:模型训练里最坑的三类报错
前三节都在讲代码,这一节换到模型侧。题库里机器学习、深度学习那两章,很多题背后其实是同一批工程问题:训练跑着跑着 loss 变成nan,或者训练集准确率 99%、验证集只有 70%,又或者换了模型结构之后怎么调都不收敛。这些现象不像编译错误那样给你一行明确的报错,得靠主动观测。
4.1 梯度爆炸:先确认,再动手
loss = nan是最典型的表现。训练跑了几十个 step 突然中断,打印出来一片nan,权重也跟着变成nan,之后无论怎么训练都回不来。第一反应是调小学习率,这个方向没错,但直接调小是猜,不是排查。
先打印每一层的梯度范数,看是哪些层出了问题。
# 在 optimizer.step() 之前插入 total_norm = 0.0 for name, p in model.named_parameters(): if p.grad is None: continue param_norm = p.grad.data.norm(2).item() total_norm += param_norm ** 2 if param_norm > 100.0: # 阈值按模型规模调整 print(f"[warn] {name} grad_norm={param_norm:.2f}") print(f"total grad norm = {total_norm ** 0.5:.4f}")输出里如果某一层梯度范数到了 1e4 以上,或者连续几步总范数在指数级增长,就是梯度爆炸实锤,而不是学习率调得不够细。
确认之后的动作按顺序来:先加梯度裁剪,把更新幅度卡住。
# max_norm 是裁剪阈值,超过就按比例缩放整个梯度向量 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step()max_norm取 1.0、5.0 是常见起点,具体值要结合模型深度和 batch size 试。裁剪只治标,标治住了再往下沿根因找:初始化是不是用了标准差过大的正态分布、学习率是不是比网络深度能承受的高一个量级、有没有在深层网络里连续堆没做归一化的线性层。BatchNorm 和残差连接在结构层面把梯度尺度稳住,比事后调参更靠谱。
4.2 梯度消失:不是所有 loss 不降都是学习率问题
另一类现象是 loss 几乎不动,或者降到一个平台就彻底卡住。很多人第一反应是学习率太小,改大一倍继续跑,跑完发现 loss 直接跳成nan——从消失变成了爆炸。
判断方法同样是看梯度范数:如果底层(靠近输入的那几层)梯度范数是 1e-6 甚至更小,而顶层正常,就是梯度消失。Sigmoid 和 Tanh 在饱和区的导数接近 0,网络一深,反向传播连乘下来就归零了。
对应的修法:隐藏层激活函数换 ReLU 及其变体,在层间插 BatchNorm,深层结构上残差连接,初始化用 He 或 Xavier 而不是简单的小随机数。这四条在题库的深度学习章节里被反复提到,落到工程上就是一套组合拳,单独用其中一条往往还是不够。
4.3 过拟合:训练集 99% 不是喜报
第三种现象最容易被忽略,因为它不报错。训练集准确率一路涨到 99%,验证集卡在 70% 上下,训练 loss 还在降,验证 loss 先降后升——这条「验证 loss 的 U 型曲线」比任何指标都直观。
| 现象 | 可能原因 | 第一步验证方式 |
|---|---|---|
| loss 变 NaN | 梯度爆炸、学习率过大 | 打印每层梯度范数 |
| loss 长期不降 | 梯度消失、归一化缺失 | 看底层梯度范数、检查输入尺度 |
| 训练集高、验证集低 | 过拟合 | 对比两条 loss 曲线的分叉点 |
| 训练和验证都很低 | 欠拟合、特征不足 | 先加深模型或补特征 |
过拟合的处理手段题库里列了一串:L2 正则、Dropout、早停、数据增强、减小模型容量。真到实操,顺序上我会先看数据量和标注质量——样本只有几千条却上了很深的网络,正则调得再细也压不住,先补数据或者换更简单的模型收益更大。
最后补一个高频坑:归一化。神经网络对输入尺度很敏感,忘了做标准化会出现「训练慢到怀疑人生」;但归一化的统计量只能在训练集上算,再应用到验证集和测试集。
# 正确:fit 只在训练集上做一次 scaler.fit(X_train) X_train = scaler.transform(X_train) X_val = scaler.transform(X_val) # 用训练集的 mean/std # 错误:在验证集/测试集上重新 fit,等于把未来信息泄露进训练流程至于树模型(XGBoost、随机森林),对特征尺度不敏感,做不做归一化影响很小,这一点在面试里也常被拿来对比。
5. 常见问题与避坑:五条踩出来的经验
前面三节按故障类型拆了根因和修法,这一节换个角度,把几个最容易在复盘题库时一带而过、实际写代码就翻车的点单独拎出来。每条都按「现象 → 原因 → 解决」走一遍,能对上自己遇到的场景就直接用。
5.1 在 .c 文件里写 extern “C”
现象:C 源文件编译直接失败,报expected identifier or '(' before string constant,光标停在extern后面那个双引号上。
原因:extern "C"是 C++ 的链接声明语法,C 编译器不认识字符串字面量出现在这个位置。编译器没有「理解不了就忽略」这种选项。
解决:把这行从.c文件里删掉,挪进对应的.h文件并用#ifdef __cplusplus包住。C 源文件只保留普通的extern int add(int, int);声明,或者直接 include 带守卫的头文件。
5.2 static 字段在类初始化阶段被 NPE
现象:程序一启动就抛NullPointerException,堆栈最顶上不是业务方法,而是<clinit>,也就是类初始化方法。断点打进去发现某个静态字段还是 null。
原因:Java 静态字段按代码中出现的先后顺序初始化,静态代码块也参与这个顺序。如果静态块里用到了声明在它后面的静态字段,那一刻字段还没赋值,拿到的是默认值 null。
解决:把静态字段的声明挪到静态块之前;字段依赖关系复杂时,改用静态内部类持有实例(holder 模式),把初始化推迟到第一次访问。
5.3 用 size() 当循环上界边遍历边删
现象:代码不报错,但删完之后列表里还剩几个本该被删掉的元素,数量对不对全看运气。
原因:正序循环里删掉下标 i 的元素后,后面的元素整体前移一位,i++再执行就跳过了新移到 i 位置的那个元素。数据量小的时候跳过的正好不是目标,看起来一切正常。
解决:倒序循环,或者换成Iterator.remove()、removeIf。判断标准很简单——循环体里只要出现remove,就别用正序for (int i = 0; i < list.size(); i++)。
5.4 死锁发生了却只盯着 CPU
现象:服务进程卡住不响应,日志停在某一行不再输出,top一看 CPU 占用接近 0。
原因:死锁的线程都在等锁,处于阻塞状态,不消耗 CPU。按「CPU 高才是问题」的思路去查,方向从一开始就偏了。
解决:jstack <pid>导出线程栈,搜BLOCKED,再搜Found one Java-level deadlock,JVM 会直接把互相等待的线程和锁对象列出来。写死锁复现的时候也一样,两个线程按相反顺序去拿两把锁,跑起来必现,这是题库里那道「写一个死锁」的标准答案,但真正有价值的是知道怎么把它捞出来。
5.5 在测试集上 fit 归一化
现象:离线评估指标漂亮得不像话,一上线或者换一批数据就大幅下滑。
原因:归一化时在测试集上重新fit,测试集的均值和方差信息被带进了预处理流程,属于典型的数据泄漏。模型在离线阶段「见过」了测试数据的分布。
解决:fit只在训练集上做一次,验证集和测试集一律transform。把预处理写进 pipeline 里,用同一套参数贯穿训练和推理,能从根本上堵住这个口子。
6. 快速定位:从报错原文到根因的三步走
排错最耗时间的往往不是修,而是判断这一步该往哪儿看。我的习惯是先给报错分类,再决定用哪个工具,最后才动手改代码。三步下来,绝大多数报错能在几分钟内收敛到一个小范围。
第一步,看报错发生在哪个阶段。编译期报错指向语法和类型,链接期报错指向符号和依赖,运行期报错指向状态和并发,训练期没有明确报错、只有指标异常。阶段判断错了,后面所有动作都是白费——拿nm去查一个NullPointerException,永远查不出东西。
第二步,做最小复现。把出问题的代码剥到只剩几行,能稳定复现再往下走。ConcurrentModificationException这种「有时抛有时不抛」的,尤其要构造出必现的最小例子,否则你改完也不知道是真修好了还是碰巧没触发。
第三步,用工具确认,而不是靠猜。下面这张表是我常备的对照,报错关键词对上哪一行,就先跑哪个命令。
| 报错关键词 | 所处阶段 | 第一条动作 |
|---|---|---|
undefined reference/unresolved external | 链接 | nm或dumpbin /symbols比对两边符号名 |
expected identifier before string constant | 编译 | 检查extern "C"是否写进了.c文件 |
ConcurrentModificationException | 运行 | 看堆栈是否落在checkForComodification,确认遍历中有没有直接改集合 |
UnsupportedOperationException | 运行 | 检查集合是否来自Arrays.asList或Collections.unmodifiableXxx |
| 进程卡住、CPU 接近 0 | 运行 | jstack <pid>搜BLOCKED和deadlock |
loss = nan | 训练 | 打印每层梯度范数,加上clip_grad_norm_ |
| 训练高、验证低 | 训练 | 对比两条 loss 曲线,检查正则和数据量 |
拿undefined reference举例走一遍:先跑nm确认一边是T _Z3addii、另一边是U add,根因就锁定在名字修饰上,修法只有加extern "C"守卫这一条路;如果两边符号名一致却还是报错,那问题就不在修饰,而在链接命令漏了目标文件或库路径,方向立刻换掉。整个过程不需要读源码,两条命令就够了。
再补一个验证习惯:改完不要只看「这次没报错」。ConcurrentModificationException可能因为数据量小没触发,梯度裁剪可能只是把爆炸推迟了几十个 step。修完要跑一遍能覆盖边界的数据,把原来触发报错的那条路径重新走一遍,确认异常栈真的消失了,而不是被绕过去了。
血泪经验是:我以前遇到undefined reference会先去翻链接命令和 Makefile,绕一大圈才发现是两个目标文件的符号名根本不一样。从那以后我每次碰到链接期报错,第一件事都是nm看一眼符号表,确认名字对不上还是文件没链上,再决定往哪个方向查。这个习惯省下来的时间,比背十道题的答案实在得多。希望帮到你。
本文还有配套的精品资源,点击获取