☰
大厂算法工程师笔试题拆解:C++对象模型与概率统计自测
2026/10/28 11:51:47 网站建设 项目流程

简介:一份面向阿里巴巴算法工程师岗位求职者的笔试题PDF,适合准备技术面试、想检验C++与概率统计基础的中高级候选人。内容包含11道选择与简答题,涉及继承多态下的构造与虚函数调用顺序、猜拳奖金分配、四维向量内积符号概率、淘宝收藏与购买的条件概率、C语言文件I/O函数归类、新股抽签盈利期望、结对编程缺陷归因、SATA与SSD读写特性、初始化列表约束以及循环链表步长追赶等,基本覆盖笔试常见考点。题目附有逐题解释与知识点归因,按程序输出、概率统计、线性代数、计算机系统等模块梳理,便于对照复习。压缩包仅含1个PDF文件,大小约198KB,轻量便于离线查阅和打印练习。已有975人浏览学习,可作为面试前限时自测与知识点查漏补缺的题目清单,并借助题面快速定位薄弱环节。

1. 一份 PDF 笔试卷,暴露的是「默认你该会」的那部分

刷过几百道 LeetCode 的人,第一次翻这份大厂算法工程师笔试题 PDF,大概率会在第一屏卡住。整份卷子 11 道题,真正需要手写算法的只有最后一道循环链表追及,其余全是 C++ 对象模型、条件概率、存储层次和 I/O 函数分类的选择题。它的价值不在押题,而在于把算法岗面试里那些「默认你应该知道」的常识,压缩成 11 个能在 90 分钟内暴露短板的探针。适合三类人:正在准备校招社招算法岗的、写 C++ 但说不清构造期虚函数行为的、以及概率题一看答案就懂、一合上就忘的。下面按「拆题、跑代码、验算、避坑」四步,把这份 PDF 用成一套可复现的自测流程。

2. 把 11 道题拆成一张知识点地图:先做哪几道,哪些可以放

拿到一份笔试卷,按题号顺序从头做是最低效的方式。我自己的习惯是先做一次「考点聚类」:把题干里的关键词抄出来,两分钟就能看出这份卷子其实只有五个知识簇,而不是十一个孤立知识点。聚类之后你会发现,同一簇里的题共用同一套判断逻辑,做对一道等于做对一簇,做错一道也说明整簇都没过关。

2.1 逐题映射:11 道题背后的五个知识簇

下表是按题干关键词、真实考点、闭卷建议用时和参考答案整理出的映射表。参考答案一并列出,方便做完之后立刻对账,不必再去翻解析。

题号题干关键词真实考点建议用时参考答案
1基类构造调用虚函数C++ 构造期虚函数表状态4 分钟C(0 1 2)
2三人猜拳、先赢满 7 局多项竞赛的状态递推6 分钟D(19/27、6/27、2/27)
3四维向量夹角 60 度几何概率降维5 分钟D(2/3)
4购买 5%、收藏 20%贝叶斯公式3 分钟E(80%)
5fread、gets、preadstdio 缓冲与系统调用分层3 分钟D(pread)
6新股申购、10 个签号二项分布与期望的线性6 分钟D(375)
7代码比例 3:5、缺陷率加权归因4 分钟A(28.6%)
8扇区、文件系统块、SSD存储层次单位4 分钟C
9const、引用、static 成员初始化列表强制条件4 分钟F(b、c)
10read、pread、fseek同上,反向问法3 分钟E(fseek)
1199 节点循环链表、步长 1 和 3模运算与追及问题4 分钟A(99)

五个簇分别是:C++ 对象模型(第 1、9 题)、概率与统计(第 2、3、4、6、7 题)、I/O 接口分层(第 5、10 题)、存储层次(第 8 题)、基础算法与模运算(第 11 题)。其中概率簇占了将近一半分值,是整份卷子的重心;C++ 两题虽然只有两分,却是区分「写过 C++」和「读过 C++ 标准」的分水岭。

值得单独说的是第 5 题和第 10 题。它们看似问的是「哪个函数不属于同一类」,实际考的是同一件事:你能否分清「带用户态缓冲的标准库 I/O」和「直接进内核的系统调用」。第 5 题里,fread、gets、getchar、getline、scanf 全部属于前者,只有 pread 是后者;第 10 题反过来,read、pread、write、pwrite、lseek 都是系统调用,只有 fseek 属于标准库。两题的答案方向相反,判断依据却是同一套分层模型。把这两题放在一起做,比分开做效率高一倍。

2.2 五个知识簇的复习成本排序

如果时间有限,复习顺序不该按题号来,而该按「单位时间能补回多少分」排序。我一般的排序是这样:

第一优先是 C++ 对象模型两题。成本最低、收益最直接:把代码复制到本地编译一次,加上几行打印,十分钟内就能把「构造期虚函数不生效」这件事看得清清楚楚。这种题一旦跑通,下次遇到「析构期调用虚函数」「构造函数里调用纯虚函数」的变种也不会慌。

第二优先是概率五题。公式本身不难,难在建模:谁是先验、谁是似然、谁是全概率分母。这部分建议手算一遍再用脚本验算一遍,两边对上了才算真的会。成本中等,但分值最高,值得投入。

第三优先是 I/O 分层两题。这部分属于「知道了就永远知道」的知识,不需要推导,只需要建立一张对照表:哪些函数带缓冲、哪些带偏移、哪些能作用于文件描述符。花二十分钟整理一张表,比反复做题更稳。

第四优先是存储层次题。这道题是纯记忆题,但要小心「常识干扰」:很多人记得「硬盘最小读写单位是扇区」,于是顺手把「文件系统最小单位也是扇区」也判成对的。实际上文件系统的最小分配单位是块,通常是 4KB,是扇区的整数倍,两者层级不同。

最后是链表追及题。这是整份卷子里唯一一道需要动笔算的算法题,但套路固定:环形结构上的追及,本质是解同余方程,相对速度乘以步数必须等于环长的整数倍。记住这个模型,三十秒就能出结果。

2.3 三轮使用法:闭卷、写解析、只刷错题

这份 PDF 最好的用法不是「看一遍解析」,而是当成一套三轮使用的模拟卷。第一轮闭卷,掐 90 分钟,把答案写在一张纸上,不许查资料;第二轮不限制时间,逐题写解析,要求每道题都能写出「为什么其他选项不对」,写不出来的就是知识盲区;第三轮只做第一轮做错的题,隔三天再做一次,检验是否真的记住。

轮次时长目标判断标准
第一轮90 分钟暴露真实水平记录每题耗时,超时的题单独标记
第二轮不限补齐解析每题能说出三个干扰项错在哪
第三轮40 分钟验证记忆留存错题正确率回到 90% 以上

第一轮最容易犯的错是「做完不记时」。笔试的时间压力本身就是考点之一,第 2 题和第 6 题都是计算量偏大的题,如果在它们身上耗掉十五分钟,后面的题就会慌。所以第一轮一定要记录每题耗时,超时的题即使做对了也要标出来,因为考场上它仍然会拖累你。

2.4 错题记录的字段设计

错题本不要只抄题目和答案,那样复盘时没有信息量。我用的字段是五个:题号、我选的、正确答案、错误类型、一句话复盘。错误类型只分四类:知识盲区、建模错误、看错题、计算失误。这四类的补救方式完全不同:知识盲区要回去补概念,建模错误要重做同类题,看错题要在题干上划线,计算失误只能靠放慢速度。

把 11 道题按这五个字段填一遍,你会发现错误往往集中在某一两类,而不是均匀分布。有人是建模错误占多数,说明公式背得熟但不会用;有人是看错题占多数,说明做题太快、题干没读完就下手。这两种人的复习策略应该完全相反。

3. C++ 对象模型两题:构造函数里的虚函数与初始化列表

C++ 这两道题是整份卷子里最值得动手跑一遍的。纸面上推演「构造期虚函数表指向谁」很容易绕晕,实际编译运行一次,把每个阶段的输出打印出来,理解会牢固得多。

3.1 第 1 题的输出为什么是 0 1 2

先把原题代码补全成可编译的版本,加上必要的注释。这段代码和原题逻辑一致,只是把省略号补成了完整定义,并去掉了原题里未使用的参数。

#include <iostream> class A { public: A() : m_iVal(0) { test(); } // 第一步:基类构造体内调用 test() virtual void func() { std::cout << m_iVal << ' '; } void test() { func(); } // 非虚函数,但内部调用虚函数 func() public: int m_iVal; }; class B : public A { public: B() { test(); } // 第二步:派生类构造体内再次调用 test() void func() override { // 覆盖基类虚函数 ++m_iVal; std::cout << m_iVal << ' '; } }; int main() { A* p = new B; // 构造顺序:先 A 子对象,再 B 子对象 p->test(); // 第三步:对象已完整,虚调用落到 B::func return 0; // 原题未定义虚析构,此处不 delete,避免制造未定义行为 }

逻辑说明要分三段看。第一段发生在new B的基类构造阶段:A的构造函数把m_iVal初始化为 0,然后调用test(),test()里调用虚函数func()。此时派生类部分还没构造,对象的虚函数表指针仍然指向A的虚表,所以调用的是A::func(),输出0。这是 C++ 标准明确规定的行为:构造函数和析构函数期间,虚调用不会下沉到派生类。

第二段发生在B的构造函数体内。此时A子对象已经构造完成,虚表指针被改写为指向B的虚表,test()里的func()调用落到B::func(),先自增m_iVal变成 1,再输出1。

第三段是main里的p->test()。test()是非虚函数,按静态类型A*找到A::test,但它内部的func()是虚调用,此时对象已经完全构造,虚表指针指向B的虚表,所以再次进入B::func(),m_iVal从 1 变成 2,输出2。三段合起来就是0 1 2,对应选项 C。

参数和边界说明:m_iVal是公有成员,方便在派生类里直接自增;如果把func声明为纯虚函数,A就变成抽象类,A的构造函数里调用纯虚函数属于未定义行为,这也是一个高频变种考点。另外,原题没有虚析构函数,通过基类指针删除派生类对象是未定义行为,所以示例代码里刻意没有delete,这一点在真实项目里必须补上虚析构。

3.2 把三段输出拆开验证:加打印跑一遍

光看代码容易「以为自己懂了」。更稳的做法是在每个关键节点加一行带标记的打印,把执行顺序可视化出来。下面这段是改造后的验证版本,输出会带上阶段标签。

#include <iostream> class A { public: A() : m_iVal(0) { std::cout << "[A ctor] vptr -> A\n"; test(); } virtual ~A() = default; // 补上虚析构,便于安全释放 virtual void func() { std::cout << "A::func " << m_iVal << '\n'; } void test() { func(); } public: int m_iVal; }; class B : public A { public: B() { std::cout << "[B ctor] vptr -> B\n"; test(); } void func() override { ++m_iVal; std::cout << "B::func " << m_iVal << '\n'; } }; int main() { A* p = new B; std::cout << "[main] object complete\n"; p->test(); delete p; return 0; }

预期输出依次是:[A ctor] vptr -> A、A::func 0、[B ctor] vptr -> B、B::func 1、[main] object complete、B::func 2。这六行把「虚表指针在构造过程中被改写」这件事完整呈现出来了,比任何文字描述都直观。

编译命令建议带上警告选项,很多对象模型的问题在编译期就有提示:

g++ -std=c++17 -Wall -Wextra -Wreorder -o ctor_demo ctor_demo.cpp && ./ctor_demo

-Wall -Wextra打开常用警告,-Wreorder专门提示初始化列表顺序与成员声明顺序不一致的问题,这个警告在写第 9 题那类代码时特别有用。如果用的是 clang,可以换成-Wall -Wextra -Wreorder-ctor。

提示:不同编译器对「构造函数中调用虚函数」的实现细节可能有差异,但标准规定的语义是一致的:构造期虚调用不会进入尚未构造完成的派生类。如果在本机跑出的结果和预期不符,先检查是不是把func写成了非虚函数。

3.3 第 9 题:必须走初始化列表的只有 const 和引用

第 9 题问的是「构造函数中,成员变量一定要通过初始化列表来初始化的是哪些」。原题给的成员包括普通int、const int、引用、static const char*和自类型指针。正确答案是const成员和引用成员两个,即选项 F。很多人会多选一个指针,或者把static成员也算进去,这两处都是典型误判。

判断依据只有一条:这个成员有没有「必须在进入构造函数体之前完成初始化」的约束。const对象一旦构造就必须有值,进入函数体后再赋值是不允许的;引用必须在绑定时就确定目标,之后不能改绑。普通int和指针可以先默认初始化,再在函数体里赋值;static成员属于类而不属于对象,根本不出现在构造函数的初始化列表里,它必须在类外定义。

成员类型能否在函数体内赋值是否必须初始化列表原因
int a能否有默认初始化,可后赋值
const int b不能是构造后不可修改
A& c不能是引用必须绑定后不可改绑
static const char* d不适用否属于类,不属于对象
B* e能否指针可默认初始化后赋值

下面这段代码把五类成员放进同一个类里,可以直接编译验证。

#include <iostream> class A { public: A() : a(0) {} private: int a; }; A g_a; // 全局对象,供引用成员绑定 class B : public A { public: // b(const)和 c(引用)只能走初始化列表 // e 是指针,列表或函数体赋值都可以 // d 是 static 成员,不属于对象,不能在列表里初始化 B() : b(1), c(g_a), e(nullptr) { a = 2; } static const char* d; private: int a; const int b; A& c; B* e; }; const char* B::d = "static-member"; // static 成员必须在类外定义 int main() { B obj; std::cout << "sizeof(B) = " << sizeof(B) << '\n'; std::cout << "d = " << B::d << '\n'; return 0; }

这段代码有两个观察点。第一,sizeof(B)的结果不包含static成员d,因为静态成员存放在静态存储区,不在对象布局里;第二,初始化列表里b和c的顺序必须和声明顺序一致,否则-Wreorder会报警告。实际工程里我一般会强制自己按声明顺序写初始化列表,这样即使有依赖关系也不会踩到「未初始化就被使用」的坑。

参数说明:g_a是全局对象,生命周期贯穿整个程序,用它来绑定引用成员可以避免悬垂引用;如果改成绑定构造函数的局部变量,对象构造完成后引用就悬空了,这是另一个高频错误。

3.4 编译参数与三个高频报错

这三类报错在做这类题时几乎必然遇到,提前知道怎么读,能省不少时间。

第一类是error: uninitialized const member,意思是 const 成员没有在初始化列表里给出值。解决方式是把它挪进初始化列表,而不是在函数体里赋值。第二类是error: uninitialized reference member,引用成员没绑定,同理。第三类是warning: 'B::b' will be initialized after 'B::a',初始化列表顺序和声明顺序不一致,虽然大多数情况下不影响结果,但涉及相互依赖的成员时会导致读到未初始化的值,必须按声明顺序改写。

注意:-Wreorder只是警告不是错误,很多人会直接忽略。我自己的习惯是把它当成错误处理,加-Werror=reorder让它直接编译失败,强迫自己改对顺序。

4. 概率统计四题:贝叶斯、期望、归因与三人分奖金

概率这五道题是整份卷子里最容易「看答案觉得简单、自己做就错」的部分。原因不在公式,而在建模:先验和似然放错位置、权重和概率混为一谈、期望的线性用错对象。下面按题目顺序拆,每道题都配一段可运行的验算脚本。

4.1 第 4 题:贝叶斯的分母是全概率,不是先验

题目给的是:看到商品后购买的概率 5%,收藏的概率 20%,收藏之后购买的概率 20%,求已知购买的情况下收藏过的概率。很多人第一反应是 20% 乘以 20% 得 4%,然后就卡住了,因为他把「购买」当成了条件而不是结果。

正确的写法是贝叶斯公式:P(收藏|购买) = P(购买|收藏) × P(收藏) / P(购买)。分子是「收藏且购买」的联合概率,等于 0.2 乘 0.2 得 0.04;分母是「购买」的边缘概率,题目直接给了 0.05。两者相除得 0.8,也就是 80%。

# 第 4 题:已知购买,求收藏过的条件概率 p_buy = 0.05 # P(购买):看到商品后购买 p_fav = 0.20 # P(收藏):看到商品后收藏 p_buy_given_fav = 0.20 # P(购买|收藏):收藏之后购买 # 贝叶斯:分母用 P(购买) 这个边缘概率 p_fav_given_buy = p_buy_given_fav * p_fav / p_buy print(f"P(收藏|购买) = {p_fav_given_buy:.2%}") # 80.00%

脚本里三个变量对应题目给的三个数,没有多余假设。参数说明:p_buy必须是边缘概率而不是联合概率,这是这道题唯一容易错的地方。如果题目改成「购买且收藏的概率是 4%」,那分母就要换成 0.04,答案会完全不同。做题时先确认每个数字是边缘概率还是联合概率,比记住公式更重要。

4.2 第 6 题:先算单位收益,再乘期望中签数

新股申购这道题的信息量比较大:最多申购 1 万股,每 1 千股一个签号,共 10 个号,每个号中签概率 0.5% 且相互独立,发行价 10 元,涨到 15 元和 20 元的概率各 50%。求最大申购下的盈利期望。

拆成两层就清楚了。第一层是每中一签的期望收益:一签对应 1000 股,成本 10 元每股,涨到 15 元赚 5000 元,涨到 20 元赚 10000 元,两种情形各占一半,所以每签期望收益是 7500 元。第二层是期望中签数:10 个签号,每个中签概率 0.005,期望中签数是 0.05 个。两层相乘,7500 乘 0.05 等于 375 元。

from math import comb n_signs = 10 # 1 万股 / 每 1 千股一个号 = 10 个号 p_hit = 0.005 # 每个号中签概率 0.5% # 每中一签:1000 股,涨到 15 赚 5000,涨到 20 赚 10000,各 50% profit_per_hit = 0.5 * 5000 + 0.5 * 10000 # 期望的线性:E[总收益] = E[中签数] * 每签收益 expected_hits = n_signs * p_hit print(expected_hits * profit_per_hit) # 375.0

这段代码里comb其实没用到,如果想把完整分布也算出来,可以补一段二项分布的概率质量函数,验证「中 0 签的概率约 95.1%」这个结论,顺便确认期望的线性不依赖于独立性假设。参数说明:profit_per_hit之所以可以直接乘期望中签数,是因为期望的线性不要求随机变量独立,这是这道题最值得记住的一点。如果题目问的是「盈利超过某个阈值的概率」,那就必须用完整分布,不能只算期望。

4.3 第 7 题:代码比例是权重,不是概率

两个人结对编程,代码比例 3:5,千行缺陷率分别是 10‰ 和 15‰,发现一个缺陷,求由前者代码引起的概率。这道题的陷阱在于「代码比例」和「缺陷概率」是两个不同的量,必须相乘之后才能比较。

正确做法是加权:第一人的「缺陷贡献」是 3 乘 10‰ 等于 0.03,第二人是 5 乘 15‰ 等于 0.075,总贡献 0.105,前者占比 0.03 除以 0.105,约等于 28.57%,对应选项 A。

w_a, w_b = 3, 5 # 代码量比例,作为权重 d_a, d_b = 10e-3, 15e-3 # 千行缺陷率 # 先算各自的缺陷贡献,再做归一化 p_a = (w_a * d_a) / (w_a * d_a + w_b * d_b) print(f"P(A 引起 | 发现缺陷) = {p_a:.2%}") # 28.57%

参数说明:10e-3就是千分之十,写成 0.01 也可以,但保留千分号的形式更贴近题干,复盘时不容易看错数量级。这道题和第 4 题是同一个模型的两个变种:都是「先算联合,再除以总贡献」,区别只在于第 4 题的分母是题目直接给的,这道题的分母要自己加出来。

4.4 第 2 题:三人分奖金,用状态递推代替直觉

三人猜拳,先赢满 7 局者获胜,当前比分是 6、5、4,问奖金怎么分最公平。直觉上很多人会按「还差几局」反比例分配,或者按当前胜局数比例分配,这两种都不对。公平分配的依据是每个人最终夺冠的概率,而夺冠概率要用状态递推算。

设三个人还差的局数分别是 1、2、3,每一局三人获胜的概率各为三分之一(平局重来,不影响相对概率)。定义P(i, j, k)为三人还差 i、j、k 局时,第一位选手最终夺冠的概率。边界条件是:谁的差局数归零谁就赢,所以P(0, j, k) = 1,P(i, 0, k) = 0,P(i, j, 0) = 0。递推式是三个方向各取三分之一。

from functools import lru_cache @lru_cache(maxsize=None) def win(need, who): """need: 三人各自还差的局数;who: 0/1/2 表示要求谁夺冠的概率。""" if need[who] == 0: return 1.0 if any(need[x] == 0 for x in range(3) if x != who): return 0.0 total = 0.0 for x in range(3): nxt = list(need) nxt[x] -= 1 total += win(tuple(nxt), who) / 3.0 return total need = (1, 2, <p> <a href="https://download.csdn.net/download/custertian/12705568" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>

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

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

立即咨询