该系列主要用于收集我每天在各种地方不论是刷题还是面经,看到的一些小的或者还没见过的知识点。

[TOC]

1. 什么是实型值,什么是整形值

来源:牛客选择题

在 C++ 编程中,整型值和实型值是两种基础的数字表示方式,它们的命名直接体现了各自的数学本质与用途。整型值对应数学中的整数概念,其名称中的”整”字强调了数值的完整性和离散性,用于表示那些天然以完整单位存在的事物,如人数、物品数量等。整型值在内存中以精确的二进制形式存储,不包含小数部分,确保了计算结果的绝对精确,特别适合需要精确计数的场景。

实型值的名称源于数学中的实数概念,这里的”实”并非指真假,而是表示能够刻画连续变化量的数值类型。它主要用于处理带小数的数据或范围极大的数值,如测量值、科学计算中的物理量等。实型值在计算机内部通常以浮点数格式存储,这种表示方法允许小数点”浮动”以兼顾数值范围和精度,但会引入微小的舍入误差。因此,实型值适合对精度要求相对宽松但需要表达连续量或极大极小数值的场合。

1
2
3
4
5
int         // 基本整型,通常占 4 字节
short // 短整型,通常占 2 字节
long // 长整型,通常占 4 或 8 字节
long long // 长长整型,通常占 8 字节
char // 字符型,也可用作小整数(1 字节)
1
2
3
float       // 单精度浮点型,通常占 4 字节,精度约 6-7 位小数
double // 双精度浮点型,通常占 8 字节,精度约 15-16 位小数
long double // 扩展精度浮点型,精度更高

2. i++到底何时生效呢

来源:牛客选择题,dowhile里面包含C++,求运行后某个变量的值

牛客上的这个选择题风格差不多如下:

1
2
3
4
5
int i = 0;
do {
cout << "循环内: i = " << i << endl;
i++;
} while (i++ < 3);

初始状态i = 0

第一次循环:

1
2
3
4
5
6
7
1. 进入循环体,打印:循环内: i = 0
2. 执行循环体内的 i++,这立即让 i = 1
3. 判断 while(i++ < 3):
- 计算 i++ < 3:此时 i = 1
- 判断 1 < 3true
- 然后 i++ 立即生效,i 变成 2
- 整个表达式返回 true,继续循环

第二次循环:

1
2
3
4
5
6
7
1. 打印:循环内: i = 2 
2. 循环体内 i++,i 变成 3
3. 判断 while(i++ < 3):
- 计算 i++ < 3:此时 i = 3
- 判断 3 < 3false
- 然后 i++ 立即生效,i 变成 4
- 整个表达式返回 false,退出循环

注意:

最开始我的理解是:当前表达式执行完之后,再执行一个i=i+1的语句。并非如此。实际上是这样的:

1
2
3
4
5
int j = i++;
// 编译相当于:
int temp = i; // 1. 备份当前 i 的值
i = i + 1; // 2. 立即修改 i 的值(副作用)
return temp; // 3. 返回备份的旧值

编译器保证自增操作在该表达式求值完成前一定发生!

3. 构造函数到底有多少种类型?

来源:牛客的选择题,出现了各种构造函数,名词有些分不清楚

在 C++ 中,构造函数是一个类在创建对象时自动调用的特殊成员函数,主要用于初始化对象的数据成员和执行必要的资源分配。根据不同的使用场景和特性,构造函数可分为多种类型:

最基本的默认构造函数不接受任何参数,用于创建对象的默认状态;

参数化构造函数则通过接收特定参数来初始化对象,允许创建时就赋予定制化的值;

拷贝构造函数通过接收同类型对象的常量引用来创建新对象,实现对象的深拷贝或浅拷贝逻辑;

移动构造函数则利用右值引用高效转移资源所有权,避免不必要的复制开销;

转换构造函数能从其他类型隐式或显式转换而来,需注意使用 explicit 关键字防止意外转换;

委托构造函数则允许一个构造函数调用同类中的其他构造函数,减少代码重复;

此外还有继承构造函数、**constexpr 构造函数**等特殊形式。

下面是总结的C++ 构造函数类型及注意事项

构造函数类型 语法示例 主要用途 注意事项
默认构造函数 ClassName() 创建对象时不提供参数时的初始化 1. 如果定义了任何构造函数,编译器不会自动生成默认构造函数 2. 可提供默认参数使其成为默认构造函数
参数化构造函数 ClassName(int x, int y) 根据参数初始化对象 1. 可以重载多个版本 2. 推荐使用成员初始化列表提高效率
拷贝构造函数 ClassName(const ClassName& other) 通过已有对象创建新对象(拷贝初始化) 1. 必须使用 const & 参数 2. 需要深拷贝时一定要自定义 3. 避免无限递归拷贝
移动构造函数 ClassName(ClassName&& other) noexcept 从临时对象或即将销毁的对象转移资源 1. 必须使用 && 右值引用 2. 标记为 noexcept 以保证异常安全 3. 转移后需使原对象处于有效但可析构状态
转换构造函数 ClassName(OtherType value) 从其他类型隐式或显式转换 1. 单参数构造函数默认是转换构造函数 2. 使用 explicit 可禁止隐式转换 3. 谨慎使用,避免意外转换
委托构造函数 ClassName() : ClassName(0, 0) {} 一个构造函数调用同类的其他构造函数 1. C++11 引入的特性 2. 初始化列表中只能委托一个构造函数 3. 避免循环委托
继承构造函数 using Base::Base; 派生类直接使用基类的构造函数 1. C++11 引入 2. 不会继承基类的默认/拷贝/移动构造函数 3. 派生类新增成员需另外初始化
constexpr 构造函数 constexpr ClassName(...) 编译期常量对象的构造 1. C++11 引入 2. 函数体必须为空或只包含 static_assertusing 等 3. 所有成员都必须是字面类型

目前我做的题里,对于转换构造函数比较陌生,这里补充一下转换构造函数的一些知识点:

转换构造函数是指只接受一个参数(或多个参数但第一个后都有默认值)的非拷贝/移动构造函数。它允许从参数类型到类类型的隐式或显式转换。

具体而言,如果某处需要一个 A 类的对象,但提供了其他类型的值,而 A 类存在能接受该类型值的构造函数,编译器将自动调用该构造函数,将该值转换为 A 类的临时对象。

例如,一个复数类 Complex 拥有接受浮点数的构造函数。当编写 Complex c = 3.14; 时,编译器会自动调用该构造函数,将 3.14 转换为复数对象(实部为 3.14,虚部为 0)。这里存在一个重要问题:这种自动转换虽然方便,但可能带来风险。特别是当构造函数涉及分配内存、打开文件或其他资源操作时。代码可能无意中创建对象并消耗资源。

一个 Buffer 类的构造函数接受表示大小的整数。当编写 processBuffer(1024) 时,本意可能是处理现有缓冲区,但编译器会创建一个大小为 1024 的新缓冲区对象。为解决此问题,C++ 提供了 explicit 关键字。在构造函数前添加 explicit 后,编译器不再自动进行转换。必须显式调用构造函数,如 Buffer buf(1024)processBuffer(Buffer(1024))

实际编程中,除非确有需要自动转换的理由,否则建议为所有单参数构造函数添加 explicit。特别是管理资源的类,如智能指针、文件句柄等,应使用 explicit 防止意外创建对象。

4. 为什么定义二维数组至少指明一个维度(不允许省略列数)

来源:牛客选择题 类似的题我错了百八十遍了就是记不住,字符串和char相关的也记不住,也不知道怎么了。

在C++中定义二维数组时必须至少指定列数,这是因为数组在内存中是按行连续存储的,访问元素 arr[i][j] 时需要通过公式 基地址 + (i × 列数 + j) × 元素大小 来计算内存地址。即使已知行数,编译器仍无法从初始化列表推断出统一的列数——初始化数据可能不规则,例如 {{1,2}, {3,4,5}} 中各行长度不一致,而C++原生数组要求每行列数必须相同以保证内存连续。指定列数相当于明确了每行的边界,让编译器能够正确计算每个元素的位置并分配连续的矩形内存空间,这是C++数组连续内存模型的直接要求。

5. 神奇的结构体数组初始化方式

来源:牛客选择题的题干。见到了这种初始化方式:Node a[3] = {1, &a[1], 2, &a[2]};

这种是C++种聚合初始化的特性。结构体数组可以使用聚合初始化的简写形式,像 Node a[3] = {1, &a[1], 2, &a[2]}; 这样写是因为 C++ 允许按结构体成员的声明顺序,用扁平化的列表依次填充所有数组元素。编译器会把这个列表按顺序拆解:先填 a[0] 的第一个成员 value=1,再填第二个成员 next=&a[1],然后自动跳到 a[1] 填它的 value=2next=&a[2],剩下的 a[2] 所有成员则被默认初始化为零值。这种写法本质上是省略了嵌套花括号的语法糖,虽然紧凑但不直观,虽然打竞赛很常用,但实际开发中别用,谁用我干谁。建议用明确的嵌套形式 {{1,&a[1]},{2,&a[2]},{}} 来避免混淆。

6. mutilset/map处理键值重复的原理

来源:牛客

对于这种带mutil帽子的我还是用得太少了。只知道底层都是红黑树。

multiset和multimap通过修改红黑树的插入策略来支持重复键值,核心在于使用insert_equal而非insert_unique操作——插入时跳过键值存在性检查,允许直接创建新节点。

相同键值的多个元素在红黑树中被视为不同节点,它们按照严格弱序规则排列:键值不同的节点按比较器排序,键值相同的节点则保持插入顺序依次排列。这种设计使得查找单个元素时仍为O(log n)复杂度,但要获取所有相同键值元素需要使用equal_range()方法返回迭代器范围,然后遍历该范围内的所有节点。虽然每个重复键值都占用独立节点内存,但这保证了与set/map一致的迭代器稳定性和操作效率。

7. vector和list支持的运算符?

vector的迭代器支持完整的算术运算(加减n、比较、下标访问),因为它本质上封装了指针,而vector元素存储在连续内存中,指针运算可以直接对应到内存地址的偏移,时间复杂度为O(1)。list的迭代器只支持前后移动(++/–)和相等比较,因为链表节点在内存中分散存储,要访问第n个元素必须从头遍历n个节点,时间复杂度为O(n)。STL故意不提供list迭代器的随机访问运算符,是为了避免程序员误以为it + n这样的操作是高效常数时间,实际上如果支持也会被实现为隐式遍历。

vector的iterator没重载>><<输入输出流运算符,它就是个迭代器。迭代器主要用于容器元素的访问和遍历,而不需要处理输入输出操作。

8. 什么是动态联编,什么又是静态联编

总会见到眼熟但一做题就不会的名词。虚函数调用相关

我知道绑定。

  • 静态联编(静态绑定):在编译期间确定调用哪个函数。
  • 动态联编(动态绑定):在程序运行时确定调用哪个函数。

注意,函数重载是通过参数列表不同实现的。

9. 动态类型转换(dynamic_cast)和静态类型转换(static_cast)在多重继承场景下的使用规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct A1{
virtual ~A1(){}
};
struct A2{
virtual ~A2(){}
};
struct B1 : A1, A2{};
int main()
{
B1 d;
A1* pb1 = &d;
A2* pb2 = dynamic_cast<A2*>(pb1); //L1
A2* pb22 = static_cast<A2*>(pb1); //L2
return 0;
}

这里直接引用官方答案:

dynamic_cast和static_cast在多重继承情况下的行为是不同的:

  1. L1行dynamic_cast(pb1)能够编译通过:
    - dynamic_cast在运行时进行类型检查,可以安全地在继承体系中进行转换
    - 由于B1同时继承自A1和A2,且A1和A2都有虚析构函数(有虚函数表),dynamic_cast可以正确处理这种多重继承的情况

  2. L2行static_cast(pb1)编译失败:
    - static_cast是编译时的类型转换,不能在多重继承下将基类指针直接转换为”无关”的另一个基类指针
    - A1和A2之间没有直接的继承关系,编译器无法确定如何安全地进行这种转换