简介:这是一份面向准备AI相关岗位笔试与面试的学习者整理的名企面试题精选PDF,侧重计算机语言基础与常见工程问题,覆盖C++调用C函数时的链接规则、多线程与多任务区别、Java类初始化顺序、死锁构造、集合遍历删除、Linux CPU与进程排查、top内存指标、iptables访问控制以及Python列表去重、lambda、快排、回文判断等高频考点。资料包共1个PDF文件,压缩包约49.22MB,内容按题目加解析组织,部分题目配有代码片段或命令示例,目录从语言基础延伸到算法与大数据、机器学习、深度学习及应用方向,便于按知识点集中刷题和面试前快速回顾。已有251人学习,题解通常先点明原理,再给出可操作的解决方式或排错思路,适合校招、社招及转岗面试者用于自测与查漏补缺。
1. 从4000到100:AI面试题库的压缩为什么反而更有效
AI面试的题库很容易给人一种错觉:题越多,覆盖越全,刷完就稳了。但真正面过几轮的人会发现,面试官反复问的往往集中在几十个"根问题"上,剩下的都是这些根问题的变体。这份《名企AI面试100题》做的最反直觉的一件事,是从近4000道题里砍到100道,却仍然覆盖计算机语言基础、算法与大数据、机器学习、深度学习、应用方向五大块。压缩不是随机抽样,而是沿着知识依赖链往上收:一个extern "C"的问题背后是链接器符号匹配,一个XGBoost泰勒展开的问题背后是目标函数二阶近似。把根节点搞懂,比刷完4000道答案更耐用。下面从链接器开始,逐层拆开这些题到底在考什么。
2. extern "C"与链接符号:C++调用C函数时编译器到底改了什么
2.1 名称修饰:函数重载在符号表里留下的痕迹
C++支持函数重载,编译器必须让同名但参数不同的函数在符号表里区分开,这套机制叫名称修饰(name mangling)。C语言没有重载,函数名编译后基本保持原名,不同编译器可能加下划线前缀。链接器不认语法,只认符号名,所以C++调用C函数时,如果不管符号名,链接阶段就会报未定义引用。
常见的实现思路是:GCC 的 Itanium ABI 把int add(int, int)修饰成_Z3addii,把float add(float, float)修饰成_Z3addff;MSVC 则生成?add@@YAHHH@Z这类名字。名字里编码了函数名、参数个数、参数类型,甚至命名空间。C编译器生成的符号通常就是add或_add。两边对不上,链接器自然找不到。
// C++ 侧声明:两个重载函数 int add(int a, int b); float add(float a, float b); // 编译后符号(GCC Itanium ABI 近似): // _Z3addii // _Z3addff上面这段代码没有加extern "C",所以两个add都被修饰。如果C文件里只提供了int add(int, int),C++侧链接时找的是_Z3addii,而C目标文件里只有add,链接器就会报undefined reference to 'add(int, int)'。extern "C"的作用就是告诉C++编译器:这段声明按C的规则生成符号,不要修饰。
2.2 链接阶段:从目标文件到可执行文件,符号表怎么对上
编译单元先变成目标文件,里面有一张符号表,记录本文件定义了哪些符号、引用了哪些未定义符号。链接器的任务是把多个目标文件的符号表拼起来,把未定义符号解析到定义符号的地址上。这个过程不看源代码,只看符号名和重定位信息。
所以混合编程的关键不在语法,而在符号名一致。C++侧用extern "C"包住C头文件,C++编译器就会为这些函数生成未修饰的符号,链接时正好命中C目标文件里的同名符号。
// cppFile.cpp:C++ 调用 C 函数 extern "C" { #include "cExample.h" // C 头文件里只有普通声明 } int main() { add(2, 3); // 链接器找的是未修饰的 add return 0; }/* cExample.h:C 头文件,不能写 extern "C" */ #ifndef C_EXAMPLE_H #define C_EXAMPLE_H extern int add(int x, int y); /* extern 只表示可见性,不改变符号名 */ #endif这里有两个容易混淆的关键字:extern管的是可见性,告诉编译器这个函数或变量定义在别的模块;extern "C"管的是链接方式,告诉C++编译器按C规则生成符号。C头文件里只能写extern,因为C编译器不认识extern "C",写了会直接语法错误。C++侧包含C头文件时,才需要在外层包一层extern "C"。
2.3 反向混编:C调C++时为什么头文件要加 extern "C"
反过来,C代码调用C++函数时,C++头文件里必须把函数声明成extern "C",这样C++编译器生成未修饰符号,C编译器才能按同名符号去链接。但C文件里不能直接#include这个头文件,因为C编译器看到extern "C"会报错。常见做法是C文件里手写一份extern int add(int, int);声明,或者用条件编译把extern "C"包起来。
/* cFile.c:C 调用 C++ 函数 */ extern int add(int x, int y); /* 只能写 extern,不能写 extern "C" */ int main(void) { add(2, 3); return 0; }// cppExample.h:C++ 头文件 #ifndef CPP_EXAMPLE_H #define CPP_EXAMPLE_H extern "C" int add(int x, int y); /* 生成未修饰符号 */ #endif这套双向写法的动机很实在:C和C++的符号规则不同,但链接器只认符号名。extern "C"不是"让函数可以被外部调用"的开关,extern才是管可见性的;它真正解决的是符号名不一致导致的链接失败。面试里问这道题,考的是你有没有把编译期和链接期分开看。
2.4 Java类初始化: 与 的两阶段
Java的初始化顺序题看起来是背结论,底层其实是两个不同的阶段。类加载的最后一步是初始化,JVM执行<clinit>方法,里面按源代码文本顺序收集静态字段赋值和静态块;对象创建时执行<init>方法,先调父类构造,再执行实例字段赋值和实例块,最后执行自己的构造体。
class Base { static { System.out.println("Base static block"); } private static String v = init("Base static field"); { System.out.println("Base instance block"); } private String name = init("Base instance field"); Base() { System.out.println("Base constructor"); } static String init(String s) { System.out.println(s); return s; } }这段代码输出顺序是:Base static block、Base static field、Base instance block、Base instance field、Base constructor。静态部分只在类第一次初始化时执行一次,实例部分每次new都执行。如果子类继承Base,父类的<clinit>先于子类,父类的<init>也先于子类。很多题会在这个顺序上挖坑,比如静态块里引用后面才声明的静态字段,拿到的是默认值null,因为字段声明会被提到前面,但赋值按文本顺序执行。
2.5 死锁与fail-fast:两个运行时的资源竞争与一致性检查
死锁题的核心不是背四个条件,而是能写出一个稳定复现的死锁。两个线程以相反顺序拿两把锁,各自持有第一把、等待第二把,循环等待形成,谁也走不下去。
Object lockA = new Object(); Object lockB = new Object(); Thread t1 = new Thread(() -> { synchronized (lockA) { sleep(100); // 放大交错窗口 synchronized (lockB) { } // 等 t2 释放 lockB } }); Thread t2 = new Thread(() -> { synchronized (lockB) { sleep(100); synchronized (lockA) { } // 等 t1 释放 lockA } });死锁的四个必要条件是互斥、占有且等待、不可抢占、循环等待。这段代码四个条件全占:锁互斥、每个线程持有一把等另一把、锁不能强行剥夺、A等B且B等A。线上排查时,jstack能看到两个线程互相BLOCKED,并且各自持有对方需要的锁。面试里如果只背条件写不出代码,通常会被追问"你怎么保证它每次都死"。
另一个运行时一致性检查是ArrayList的fail-fast。for-each底层走的是迭代器,迭代器在创建时记录expectedModCount = modCount,每次next()检查两者是否相等。如果直接在循环里调list.remove(),集合的modCount会加一,迭代器不知情,下一次next()就抛ConcurrentModificationException。
List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4)); for (Integer x : list) { if (x == 2) { list.remove(x); // modCount++,迭代器 expectedModCount 没变 } }正确写法是用iterator.remove(),它会同步更新expectedModCount;或者用list.removeIf(x -> x == 2)。注意,删倒数第二个元素时可能不抛异常,因为循环刚好结束,没机会再调next()。这种"偶发不报错"最容易让人误以为代码没问题,到线上数据量一大就翻车。
3. 算法与大数据的底层套路:快慢指针、B树分裂与分治Top K
3.1 链表判环:Floyd判圈为什么快慢指针一定会相遇
单链表判环的经典解法是快慢指针,慢指针走一步,快指针走两步。如果存在环,快指针先进入环,慢指针后进入,之后快指针每轮比慢指针多走一步,两者在环上的距离每次缩小一,最多经过环长那么多轮就会相遇。
def has_cycle(head): slow = fast = head while fast and fast.next: slow = slow.next # 慢指针一步 fast = fast.next.next # 快指针两步 if slow is fast: # 相遇即有环 return True return False # 快指针走到 None,无环这里的关键是"步差为一"保证了相遇,而不是靠运气。如果快指针走三步,步差为二,在某些环长下可能跳过慢指针,需要更复杂的分析。面试里常追问"怎么找环的入口":相遇后把一个指针放回头节点,两个指针都每次走一步,再次相遇的位置就是入口。推导依赖的是相遇点到入口的距离与头节点到入口的距离模环长同余。
3.2 B树插入与删除:分裂、借位、合并的触发条件
B树是一种多路平衡搜索树,每个节点最多有m个子节点、m-1个关键字。插入时先找到叶子节点放入关键字,如果节点关键字数超过m-1,就把中间关键字上移到父节点,左右两半拆成两个节点。删除时如果节点关键字数少于下限,先向兄弟借一个,兄弟不够借就与兄弟合并,合并可能继续向上触发分裂的逆过程。
# B树插入的简化逻辑(阶为 m) def insert(node, key): if node.is_leaf: node.keys.append(key) node.keys.sort() if len(node.keys) > m - 1: split(node) # 中间关键字上移,节点一分为二 else: child = find_child(node, key) insert(child, key) def split(node): mid = len(node.keys) // 2 up_key = node.keys[mid] left = Node(node.keys[:mid]) right = Node(node.keys[mid + 1:]) parent = node.parent parent.insert_child(up_key, left, right) if len(parent.keys) > m - 1: split(parent) # 父节点溢出,继续向上分裂B树的设计动机是减少磁盘I/O:每个节点大小对齐一个磁盘页,树高被压得很低,一次查找只需要几次页读取。插入分裂和删除合并都是为了维持"所有叶子在同一层"这个平衡条件。面试里问B树插入删除,重点不是背步骤,而是说清"为什么分裂是向上传的"——因为节点溢出破坏了关键字数量约束,必须把中间元素交给父节点,让父节点重新分配左右子树的取值范围。
3.3 海量日志Top IP:分治哈希与小顶堆的两次收敛
从海量日志里找访问次数最多的IP,单机内存放不下全量数据,常见思路是分治加堆。先对每条日志里的IP做哈希,按hash(ip) % N把日志分散到N个小文件,同一个IP一定会落到同一个文件;然后每个小文件单独用哈希表统计词频;最后用一个小顶堆在所有文件的统计结果里取Top K。
import heapq def top_k(counter, k): heap = [] for ip, cnt in counter.items(): if len(heap) < k: heapq.heappush(heap, (cnt, ip)) # 小顶堆,堆顶是当前第 k 大 elif cnt > heap[0][0]: heapq.heapreplace(heap, (cnt, ip)) # 替换堆顶,维持 k 个 return [ip for cnt, ip in sorted(heap, reverse=True)]分治哈希解决的是内存上限,小顶堆解决的是"不全量排序"。如果单个小文件仍然太大,可以继续二次哈希拆分。面试里常追问"为什么不用大顶堆":大顶堆需要把所有数据都压进去,内存扛不住;小顶堆只维护K个元素,空间是O(K)。另一个坑是哈希函数要足够均匀,否则某个文件会倾斜,分治就失去意义。
4. 机器学习与深度学习的机制题:从SVM对偶到Transformer注意力
4.1 SVM:为什么要把原问题转成对偶形式
SVM的原问题是带约束的凸二次规划:最小化1/2 ||w||^2,约束每个样本满足y_i(w·x_i + b) >= 1。直接解这个约束优化不方便,于是引入拉格朗日乘子,把约束吸收进目标函数,再根据强对偶性转成对偶问题。对偶形式里样本只以内积x_i·x_j出现,这才方便把内积替换成核函数,把线性SVM扩展到非线性场景。
对偶问题长这样:最大化sum(alpha_i) - 1/2 sum sum(alpha_i alpha_j y_i y_j x_i·x_j),约束sum(alpha_i y_i) = 0且alpha_i >= 0(软间隔时上界为C)。KKT条件给出一个重要结论:只有alpha_i > 0的样本才影响决策边界,这些样本就是支持向量;alpha_i = 0的样本在间隔外,对模型没有贡献。面试里问"SVM为什么能处理非线性",答案就落在核函数替换内积这一步,而不是"用了核技巧"这种空话。
4.2 XGBoost二阶泰勒展开:目标函数近似与增益计算
XGBoost的目标函数由损失项和正则项组成,每轮加一棵新树去拟合当前残差。它没有直接优化原始损失,而是对损失做二阶泰勒展开,用一阶梯度g_i和二阶梯度h_i近似目标函数。这样做的好处是:不管损失函数长什么样,只要二阶可导,就能统一成二次函数形式,叶子权重有解析解。
# 叶子权重解析解 w_j = -G_j / (H_j + lambda) # 分裂增益 gain = 0.5 * ( G_L**2 / (H_L + lambda) + G_R**2 / (H_R + lambda) - (G_L + G_R)**2 / (H_L + H_R + lambda) ) - gamma其中G_j是叶子j上样本一阶梯度和,H_j是二阶梯度和,lambda控制叶子权重收缩,gamma控制分裂门槛。一阶信息只能告诉你"往哪走",二阶信息告诉你"走多远",所以XGBoost比传统GBDT收敛更快、更稳。面试里追问"泰勒展开的优势在哪",要答到"把损失函数的优化转化为叶子权重的解析解,避免每轮做数值线搜索"。
4.3 树模型与归一化:为什么分裂点不关心特征尺度
树模型的分裂只看特征排序:对某个特征排好序,枚举切分点,计算左右子集的 impurity 下降。对特征做单调变换,比如归一化、标准化、取对数,不改变样本之间的相对顺序,所以分裂点不变,树结构也不变。这就是"树形结构不需要归一化"的底层原因。
线性模型和距离模型则不同。线性模型的系数大小和特征尺度直接相关,尺度不统一会导致正则项惩罚不公平;KNN、SVM、KMeans依赖距离,量纲不同会让大数值特征主导距离计算。面试里常把"归一化"和"标准化"混在一起问:归一化通常指缩放到[0,1],标准化指减去均值除以标准差。两者都依赖全局统计量,推理时必须复用训练集的均值和方差,不能在各批次里重新算。
4.4 梯度消失与BN:链式乘积与训练推理不一致
梯度消失的根源是链式法则的连乘。以sigmoid为例,导数最大只有0.25,多层反向传播时梯度不断乘以小于1的数,层数一多就趋近于零。ReLU的正区间导数为1,负区间为0,缓解了这个问题;残差连接则给梯度提供了一条常数通路,让深层网络至少能学到恒等映射。
Batch Normalization的做法是在每个mini-batch上算均值和方差,把激活值标准化,再乘可学习的gamma、加beta。训练时用当前batch的统计量,推理时用训练阶段累积的滑动平均均值和方差。这个差异是很多线上事故的来源:训练和推理行为不一致,如果推理时误用了batch统计,单条样本的方差为零,输出会直接崩掉。
训练:mu_B = mean(x), sigma_B^2 = var(x) x_hat = (x - mu_B) / sqrt(sigma_B^2 + eps) y = gamma * x_hat + beta 推理:用 running_mean / running_var 代替 mu_B / sigma_B^2BN能加速收敛,一个常见解释是它把每层输入拉回相对稳定的分布,减少了内部协变量偏移;另一个更严格的角度是它改变了损失曲面的平滑性,允许更大的学习率。面试里能把训练和推理的差异讲清楚,比背"防止梯度消失"更有说服力。
4.5 Transformer与NMS:注意力权重和后处理抑制
Transformer的核心是自注意力:每个位置产生Query、Key、Value三个向量,用Query和所有Key做点积得到相关性分数,除以sqrt(d_k)防止点积过大导致softmax饱和,再对Value加权求和。多头的意义是让模型在不同子空间里学不同的相关性模式。
NMS是目标检测的后处理:按置信度从高到低排序,取最高分框,删掉与它IoU超过阈值的框,重复直到没有框可删。它解决的是同一个物体被多个锚框重复检测的问题。
def nms(boxes, scores, iou_thresh): order = scores.argsort()[::-1] # 按置信度降序 keep = [] while order.size > 0: i = order[0] keep.append(i) # 保留当前最高分框 ious = compute_iou(boxes[i], boxes[order[1:]]) order = order[1:][ious <= iou_thresh] # 抑制重叠过大的框 return keepNMS的取舍在于阈值:阈值低会误删相邻物体,阈值高会留下重复框。工业界后来出现Soft-NMS、可学习的NMS,都是为了缓解这个硬阈值带来的问题。面试里问"one-stage和two-stage的区别",落到机制上就是:two-stage先出候选区域再分类回归,精度高但慢;one-stage直接在特征图上密集预测,速度快但正负样本极不均衡,需要Focal Loss这类手段来缓解。
5. 刷题与理解中的五个翻车现场:从符号名到集合迭代器
5.1 以为 extern "C" 是"导出函数"的开关
现象:在C++头文件里给函数加了extern "C",以为这样别的模块就能调用,结果C文件那边链接时报找不到符号。
原因:把可见性和链接方式混为一谈。extern才是声明"这个符号定义在别处",extern "C"只改变符号名的生成规则。如果C文件里没有对应的未修饰符号,加多少extern "C"都没用。
解决:先确认C目标文件里到底生成了什么符号,Linux下用nm看目标文件符号表,确认是add还是_Z3addii;再检查C++侧是否用extern "C"包住了声明,C侧是否只写了extern。
5.2 Java静态初始化顺序背了结论,写代码还是错
现象:静态块里打印一个静态字段,得到null,但字段明明在下面赋了值。
原因:静态字段和静态块按源代码文本顺序执行,字段声明会被提到前面,但赋值语句留在原位。静态块在赋值之前执行,自然读到默认值。
解决:把依赖的静态初始化放到静态块里,或者把静态块放到字段声明之后。更稳妥的做法是避免在静态块里依赖后面的静态字段,用显式的初始化方法按顺序调用。
5.3 死锁代码单线程跑,永远测不出来
现象:本地跑死锁示例,程序正常结束,以为死锁不会发生。
原因:死锁需要两个线程并发且交错到特定顺序。单线程或者没有强制交错的调度,两个线程可能一前一后跑完,根本形不成循环等待。
解决:用两个线程加CountDownLatch强制交错,确保两个线程都拿到第一把锁之后再请求第二把;线上排查用jstack看线程状态和持有锁,别靠猜。
5.4 ArrayList在for循环里删除,偶发异常
现象:删一个元素时没报错,删多个元素时抛ConcurrentModificationException,换台机器或换个数据量又好了。
原因:for-each走迭代器,迭代器的expectedModCount和集合的modCount在next()时校验。删最后一个或倒数第二个元素时,循环可能刚好结束,没机会触发校验;删中间元素则一定触发。
解决:用iterator.remove(),或者removeIf(),或者改用CopyOnWriteArrayList。不要用索引for循环边删边遍历,索引会错位。
5.5 归一化和标准化混用,树模型上做归一化白费功夫
现象:给树模型做了归一化,精度没提升,反而多了预处理开销;给线性模型只做了归一化没做标准化,正则项效果不对。
原因:树模型只看特征排序,单调变换不影响分裂点;线性模型和距离模型对尺度敏感,需要标准化或归一化,但两者目标不同,混用会破坏正则项对系数的公平惩罚。
解决:树模型可以跳过归一化;线性模型、SVM、KNN、KMeans优先标准化;需要把特征压到固定区间时用归一化。推理阶段必须复用训练集的统计量,不能在线重算。
5.6 海量Top K直接全量排序,内存直接爆
现象:日志文件几个G,读进内存排序找Top IP,程序直接OOM。
原因:全量排序需要把所有数据放进内存,空间复杂度O(N),数据量一上来就扛不住;而且排序本身是O(N log N),找Top K没必要。
解决:先哈希分片,保证同一IP落同一文件,每个文件单独统计词频;再用小顶堆维护Top K,空间降到O(K)。分片数要足够让单文件能放进内存,哈希函数要均匀,避免数据倾斜。
6. 题库组织的取舍:为什么按依赖链排序比按难度排序更耐用
这份手册的章节顺序是计算机语言基础、算法和大数据、机器学习、深度学习、应用方向,题目顺序还和在线题库页面保持一致。这个安排看起来朴素,背后有一个明确的取舍:按依赖链排序,而不是按难度排序。
按难度排序的问题是,难度是主观且不稳定的。一道SVM对偶题对做过凸优化的人是中等,对没学过拉格朗日的人是困难;把难题放前面,读者会被卡住,把简单题放前面,读者又不知道后面要用到前面的哪个结论。按依赖链排序则不同:语言基础里的链接符号、类加载顺序、集合迭代器,是后面算法和工程题的地基;算法里的分治和堆,是海量数据处理的前提;机器学习里的梯度、正则、过拟合,是深度学习调参和BN理解的前置。读的时候可能觉得前面慢,但到后面会发现少了很多"这里为什么这样"的卡顿。
另一个取舍是题目顺序和在线题库页面一致。这牺牲了一部分"按学习路径重新编排"的自由,换来的是可跳转、可讨论、可对照。读者在手册里看到某道题,能顺着题号回到对应页面看更多讨论和勘误。手册只有100题,但真实笔试面试不会局限在这100题,所以它更像一个索引,而不是一个封闭的题集。
如果让我给一条复习路径,我会先把语言基础和算法两块过一遍,确保链接、初始化、集合、链表、树、堆这些机制能自己讲清楚;再进机器学习和深度学习,每道题都追问"这个设计解决了什么问题、不这么设计会怎样";最后用应用方向的题做串联,比如目标检测里的NMS和IoU,能同时串起CNN、后处理和评价指标。刷题时不要只看答案,答案是对结果的描述,机制才是可迁移的部分。
从那以后我每次带人复习,都会强制让他先讲一道题的机制,再对答案,讲不出来就回去补前置。这份手册的价值不在于100这个数字,而在于它把近4000道题里最常被追问的根节点挑了出来,剩下的靠读者自己往下挖。希望帮到你。
本文还有配套的精品资源,点击获取