1. 谈谈你对面向对象的理解
  2. 什么是线程,什么是进程?进程崩溃会怎么样,线程呢
  3. TCP 和 UDP 有了解吗, 讲一讲
  4. UDP 的不可靠性怎么改进

C++ 八股

  1. c++三大特性
  2. 讲一下对多态的理解
  3. 重写和重载的区别,以及他们的实现方法
  4. 构造函数和析构函数可以是虚函数吗
  5. 内存分区 堆和栈的区别
  6. New 和 malloc 的使用和区别
  7. 什么时候会内存泄露, 举几个例子
  8. 了解智能指针吗 有几种,什么时候使用
  9. STL 容器有了解吗 讲一讲map 和 unordered_map 的实现和底层原理
  10. 说一下对 unordered_map 的认识,如何实现一个哈希表
  11. List 和 vector 的底层原理
  12. 什么是平衡二叉树,什么时候会用到
  13. 给一组数取前k个最小的数,用什么数据结构
  14. 常见的稳定排序算法,不稳定的排序算法

参考答案与延伸

以下内容针对上面的面试题整理,回答尽量采用面试时可以直接表达的方式。具体项目中还应结合自己的经历举例。

一、基础知识

1. 谈谈你对面向对象的理解

面向对象是一种通过对象组织程序的思想。对象把数据和操作数据的方法封装在一起,常见核心特征包括:

  • 封装:把数据和行为放在类中,通过 private、protected、public 控制访问,保护对象的不变量。
  • 继承:子类复用父类的属性和行为,表达 is-a 关系。
  • 多态:同一个接口在不同对象上表现出不同的行为。
  • 抽象:提取共性,只暴露必要的接口,隐藏具体实现。

C++ 中,父类指针或引用调用虚函数时,可以执行派生类的实现,这就是运行时多态。

延伸:继承不一定比组合好。实际设计中通常优先考虑组合,只有确实存在稳定的 is-a 关系时再使用继承。面向对象设计还应关注高内聚、低耦合、单一职责和依赖倒置。

2. 什么是线程和进程?进程或线程崩溃会怎么样

进程是操作系统进行资源分配和隔离的基本单位,拥有独立的虚拟地址空间、文件描述符和系统资源。线程是 CPU 调度的基本单位,同一进程内的线程共享代码段、堆、全局变量和打开的文件,但各自拥有独立的栈、寄存器和线程局部存储。

进程创建和切换的成本通常比线程高,但隔离性更好;线程通信方便,但共享数据会带来竞态条件和同步问题。

进程崩溃时,通常该进程的所有线程都会结束,操作系统会回收其资源,但文件写入、数据库事务等业务状态可能已经不一致。线程正常退出时通常只影响当前线程;但 C++ 线程中未捕获的异常会触发 std::terminate,非法内存访问等错误也通常会导致整个进程退出,而不只是结束当前线程。

延伸:多线程常见问题有竞态条件、死锁、活锁和数据可见性问题。线程池可以减少线程创建和销毁的开销;进程间可以通过管道、共享内存、消息队列或 Socket 通信。

3. TCP 和 UDP 有什么区别

TCP 是面向连接的可靠字节流协议,具有三次握手、确认应答、超时重传、流量控制和拥塞控制等机制。TCP 保证数据按序到达,但不保留应用层消息边界。

UDP 是无连接的数据报协议,不保证数据一定到达、不保证顺序,也不保证不重复,但保留数据报边界,头部开销小、延迟较低。

TCP 适合网页、文件传输、数据库连接等场景;UDP 常用于实时音视频、游戏同步、DNS 和直播。

延伸:TCP 能保证数据最终可靠到达,但不能保证在规定时间内到达。UDP 的可靠性可以由应用层自行实现。TCP 还存在队头阻塞问题,实时场景不一定适合 TCP。

4. UDP 的不可靠性如何改进

可以在应用层增加可靠传输机制:

  1. 为数据包增加序列号。
  2. 接收方返回 ACK 或 NACK。
  3. 超时后重传。
  4. 使用滑动窗口提高吞吐量。
  5. 对数据排序、去重。
  6. 使用心跳检测连接状态。
  7. 使用 FEC 前向纠错,减少重传。
  8. 增加拥塞控制,避免发送过快。

QUIC 就是基于 UDP 的现代协议,它在协议内部实现了可靠传输、多路复用、拥塞控制、TLS 加密和连接迁移。

延伸:可靠、按序、低延迟之间需要权衡。比如游戏状态同步通常更关心最新状态,过期数据即使重传成功也没有意义。

二、C++ 八股

1. C++ 三大特性

C++ 面向对象的三大特性是封装、继承和多态。

封装是把数据和操作数据的方法放在类中,并通过访问权限隐藏实现细节;继承是子类复用父类成员,表达类型之间的层次关系;多态是同一个接口在不同对象上具有不同实现。

抽象也经常被作为面向对象的重要特征,C++ 中可以通过抽象类和纯虚函数实现抽象。

2. 讲一下对多态的理解

多态可以分成编译期多态和运行时多态。

编译期多态包括函数重载、运算符重载和模板,调用目标在编译时确定。运行时多态通过父类指针或引用调用虚函数,在运行时根据对象的实际类型决定执行哪个函数。

class Base {
public:
    virtual void run() { }
    virtual ~Base() = default;
};
 
class Derived : public Base {
public:
    void run() override { }
};
 
Base* p = new Derived;
p->run();
delete p;

编译器通常使用虚函数表和虚指针实现运行时多态,但这是常见实现方式,不是 C++ 标准强制规定的内部结构。

延伸:建议使用 override 让编译器检查是否真的重写了父类虚函数。多态基类通常需要虚析构函数。虚函数会增加少量空间和间接调用开销,但通常不是主要性能瓶颈。

3. 重写和重载的区别及实现方式

重写发生在父子类之间。子类提供与父类相同函数签名的实现,父类函数通常声明为 virtual,通过虚函数表实现运行时动态绑定。

重载发生在同一作用域中,函数名相同,但参数的类型、个数或顺序不同。重载由编译器在编译期根据参数选择,只有返回值不同不能构成重载。

void print(int);
void print(double);
void print(const std::string&);

延伸:子类定义同名函数可能隐藏父类的其他重载,可以使用 using Base::show 引入父类重载。C++ 通过名字修饰保存重载函数的信息。

4. 构造函数和析构函数可以是虚函数吗

构造函数不能是虚函数。构造对象时,派生类部分还没有构造完成,无法安全地进行虚函数派发;同时构造函数本身就是用来确定对象类型的。

析构函数可以是虚函数。如果通过父类指针删除派生类对象,父类析构函数必须是虚函数:

class Base {
public:
    virtual ~Base() = default;
};

否则可能只调用父类析构函数,导致派生类资源没有释放。

延伸:纯虚析构函数也可以存在,但必须提供定义。构造函数中调用虚函数时,不会调用派生类版本,只会调用当前构造阶段所属类的版本。

5. 内存分区,以及堆和栈的区别

典型进程内存区域包括代码段、只读数据段、全局和静态区、堆、栈、共享库和内存映射区。

栈通常由编译器自动管理,保存函数调用信息和局部变量,分配释放较快,每个线程拥有独立栈空间,但空间通常有限。堆由程序或分配器管理,生命周期灵活,空间较大,但可能产生内存碎片、泄漏和分配开销。

延伸:C++ 标准不保证所有局部变量一定物理上位于栈上,编译器可能将其优化到寄存器中。new 创建的对象通常位于堆上,但 placement new 可以在指定内存区域构造对象。

6. new 和 malloc 的区别

new 是 C++ 运算符,返回具体类型指针,会调用构造函数,分配失败默认抛出 std::bad_alloc,对应 delete;数组形式 new[] 对应 delete[]。

malloc 是 C 标准库函数,只分配原始字节,不调用构造函数,失败时返回 nullptr,对应 free。

int* p = new int(10);
delete p;
 
int* q = static_cast<int*>(malloc(sizeof(int)));
free(q);

new 和 malloc 不能混用,否则会产生未定义行为。C++ 项目中优先使用容器、make_unique 和 make_shared。new(std::nothrow) 失败时返回空指针而不是抛异常。

7. 什么时候会发生内存泄漏

内存泄漏是指动态内存已经无法访问,但仍然没有释放。常见情况包括:忘记 delete、指针被新地址覆盖、异常导致释放代码没有执行、容器保存了拥有所有权的裸指针,以及 shared_ptr 循环引用。

void func() {
    int* p = new int;
    do_something();
    delete p;
}

如果 do_something 抛出异常,上面的 delete 可能不会执行。更好的方式是使用 RAII 和智能指针。

延伸:可以使用 AddressSanitizer、LeakSanitizer 或 Valgrind 检查内存泄漏。文件描述符、锁、Socket 等未释放资源,也属于类似的资源泄漏问题。

8. 智能指针有哪些,什么时候使用

unique_ptr 表示独占所有权,不能复制,只能移动,适合一个对象只有一个管理者的情况,开销较小。

shared_ptr 表示共享所有权,通过引用计数管理对象,所有 shared_ptr 都销毁后对象才销毁。

weak_ptr 不拥有对象,只用于观察 shared_ptr 管理的对象,可以解决 shared_ptr 循环引用。使用 weak_ptr 时可以调用 lock 获取临时 shared_ptr,并判断对象是否仍然存在。

延伸:默认优先使用 unique_ptr,只有确实存在共享所有权时才使用 shared_ptr。make_shared 通常具有更好的异常安全性和分配效率。auto_ptr 已经废弃,不应使用。

9. STL 容器中 map 和 unordered_map 的实现及底层原理

map 通常使用红黑树实现,Key 按顺序排列,查找、插入和删除的复杂度为 O(log n),支持有序遍历和范围查询。

unordered_map 使用哈希表实现,通过哈希函数把 Key 映射到桶中。平均查找、插入和删除复杂度为 O(1),最坏情况可能退化到 O(n),遍历顺序不固定。负载因子较高时会扩容并重新哈希。

需要有序遍历、范围查询或 lower_bound 时使用 map;只需要快速 Key-Value 查找时通常使用 unordered_map。

延伸:map 的 Key 不能直接修改,否则会破坏树的有序性。unordered_map 可以通过 reserve 提前预留桶,减少扩容次数。

10. 对 unordered_map 的认识,以及如何实现哈希表

哈希表的基本结构是桶数组,每个桶保存一个或多个键值对。

插入或查找时,先计算 Key 的哈希值,再根据哈希值计算桶下标。如果发生冲突,可以使用链地址法,也可以使用开放寻址法。元素数量和桶数量的比例称为负载因子,负载因子过高时需要扩容并重新哈希。

哈希函数和相等比较器必须满足:如果两个 Key 相等,那么它们的哈希值必须相等。不同 Key 产生相同哈希值是正常情况,必须通过冲突处理解决。

延伸:哈希函数质量过差会导致大量冲突;面对恶意输入时还要考虑 Hash Flooding 攻击。哈希表扩容通常需要重新计算所有元素的桶位置。

11. list 和 vector 的底层原理

vector 本质上是连续的动态数组,支持 O(1) 随机访问,尾部插入平均为 O(1),中间插入和删除通常为 O(n)。扩容时会重新分配内存并移动元素,但连续内存带来很好的缓存局部性。

list 通常是双向链表,每个节点保存前驱、后继和数据。它不支持高效随机访问,但在已知迭代器的情况下,插入和删除通常是 O(1)。缺点是节点分散、额外指针开销大、缓存局部性差。

延伸:vector 扩容可能导致指针、引用和迭代器失效;list 只有被删除节点的迭代器失效。现代 CPU 中,vector 的缓存优势经常超过 list 的理论插入优势。deque 适合两端插入并需要随机访问的场景。

12. 什么是平衡二叉树,什么时候会用到

平衡二叉树是在二叉搜索树基础上,通过旋转等操作控制树高,避免退化成链表,使树高保持 O(log n)。因此查找、插入和删除通常都可以达到 O(log n)。

常见类型有 AVL 树、红黑树和 B 树。AVL 树平衡更严格;红黑树对平衡要求相对宽松,插入删除调整成本较低;B 树和 B+ 树常用于数据库及文件系统。

平衡二叉树适合需要动态插入删除、保持元素有序、进行范围查询的场景。C++ 的 map 和 set 通常使用红黑树。

延伸:如果数据是静态的,排序数组可能比树更快,因为缓存局部性更好;如果只关心最大值或最小值,堆通常比平衡树更合适。

13. 一组数取前 K 个最小的数,用什么数据结构

最常用的是维护一个大小为 K 的大根堆:先把前 K 个元素放入堆中,之后遍历其他元素。如果当前元素比堆顶更小,就删除堆顶并插入当前元素。最终堆中保存的就是前 K 个最小元素。

复杂度为 O(n log K),空间复杂度为 O(K)。使用大根堆是因为堆顶保存当前前 K 个元素中最大的一个,便于淘汰。

如果所有数据都在内存中,也可以使用 std::nth_element,平均复杂度接近 O(n);直接排序的复杂度是 O(n log n)。如果数据是数据流,大小为 K 的大根堆更合适。

14. 常见的稳定排序算法和不稳定排序算法

稳定排序是指两个关键字相等的元素,排序后仍保持原来的相对顺序。

常见稳定排序:

  • 冒泡排序
  • 插入排序
  • 归并排序
  • std::stable_sort
  • Timsort
  • 稳定实现的计数排序和基数排序

常见不稳定排序:

  • 选择排序
  • 快速排序
  • 堆排序
  • 希尔排序
  • std::sort
  • std::partial_sort

需要保留相同关键字原始顺序时使用稳定排序;只关心最终大小关系时可以使用普通排序。C++ 中 std::sort 通常性能更好,但不保证稳定;std::stable_sort 才提供稳定性保证。

高频追问总结

面试中建议重点准备以下追问:

  1. 为什么多态基类需要虚析构函数。
  2. shared_ptr 循环引用如何产生,weak_ptr 如何解决。
  3. vector 扩容时发生什么,哪些迭代器会失效。
  4. map 和 unordered_map 的复杂度及适用场景。
  5. TCP 如何保证可靠传输,UDP 如何在应用层实现可靠性。
  6. 哈希表如何处理冲突以及什么时候扩容。
  7. 进程和线程崩溃时资源和其他执行单元会受到什么影响。