360C++一面面经
1、自我介绍
2、简历里挑一个项目讲
3、代码题,leetcode1222.可以攻击国王的皇后 (秒了)
思路:
- 创建一个二维数组
chessboard表示棋盘,将黑皇后的位置标记为1,白国王的位置标记为2。 - 定义八个方向的数组,表示八个可能的方向:上、下、左、右、左上、右上、左下、右下。
- 对每个方向,从国王的位置开始,不断前进,检查是否有黑皇后的位置。一旦找到,将该位置加入结果集。
- 返回结果集。
参考代码:
#include <vector>
class Solution {
public:
vector<vector<int>> queensAttacktheKing(vector<vector<int>>& queens, vector<int>& king) {
// 定义八个方向的数组
int directions[8][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}, {-1, -1}, {-1, 1}, {1, -1}, {1, 1}};
// 创建棋盘
vector<vector<int>> chessboard(8, vector<int>(8, 0));
// 在棋盘上标记黑皇后和白国王的位置
for (const auto& queen : queens) {
chessboard[queen[0]][queen[1]] = 1; // 黑皇后
}
chessboard[king[0]][king[1]] = 2; // 白国王
vector<vector<int>> result;
// 遍历八个方向
for (int i = 0; i < 8; ++i) {
int x = king[0], y = king[1];
// 沿着当前方向前进
while (x >= 0 && x < 8 && y >= 0 && y < 8) {
if (chessboard[x][y] == 1) {
// 找到黑皇后的位置,加入结果集
result.push_back({x, y});
break;
}
x += directions[i][0];
y += directions[i][1];
}
}
return result;
}
};
int main() {
Solution solution;
// 测试例子
vector<vector<int>> queens = {{0,1},{1,0},{4,0},{0,4},{3,3},{2,4}};
vector<int> king = {0, 0};
vector<vector<int>> result = solution.queensAttacktheKing(queens, king);
// 输出结果
for (const auto& res : result) {
cout << "[" << res[0] << ", " << res[1] << "] ";
}
return 0;
}4、知道哪些c++11新特性
- 自动类型推导(Auto): 允许编译器推导变量的类型,使代码更加简洁。
auto x = 5; // x的类型将被推导为int- 范围-based for 循环: 简化了对容器元素的遍历。
std::vector<int> numbers = {1, 2, 3, 4, 5};
for (const auto& num : numbers) {
// 使用num
}- 智能指针: 引入了
std::shared_ptr和std::unique_ptr等智能指针,用于管理动态分配的内存,帮助防止内存泄漏。
std::shared_ptr<int> sharedPtr = std::make_shared<int>(42);- Lambda 表达式: 允许在函数内部定义匿名函数,提高代码可读性和灵活性。
auto add = [](int a, int b) { return a + b; };- nullptr: 引入了空指针常量
nullptr,用于替代传统的空指针NULL。
int* ptr = nullptr;- 强制类型转换(Type Casting): 引入了
static_cast、dynamic_cast、const_cast、reinterpret_cast等更安全和灵活的类型转换操作符。
double x = 3.14;
int y = static_cast<int>(x);- 右值引用和移动语义: 支持通过右值引用实现移动语义,提高了对临时对象的处理效率。
std::vector<int> getVector() {
// 返回一个临时vector
return std::vector<int>{1, 2, 3};
}
std::vector<int> numbers = getVector(); // 使用移动语义- 新的容器和算法: 引入了新的容器,如
std::unordered_map、std::unordered_set,以及一些新的算法。
std::unordered_map<int, std::string> myMap = {{1, "one"}, {2, "two"}};- 线程支持(std::thread): 提供了原生的多线程支持,使得并发编程更加方便。
#include <thread>
void myFunction() {
// 线程执行的代码
}
int main() {
std::thread t(myFunction);
t.join(); // 等待线程结束
return 0;
}5、虚函数的作用?
- 多态性:虚函数使得通过基类指针或引用调用对象的成员函数时,可以根据实际对象的类型来执行相应的函数。这有助于实现运行时多态性,允许编写更灵活、可扩展的代码。
class Shape {
public:
virtual void draw() {
// 实现基类的绘制操作
}
};
class Circle : public Shape {
public:
void draw() override {
// 实现派生类的绘制操作
}
};
int main() {
Shape* shapePtr = new Circle();
shapePtr->draw(); // 在运行时调用 Circle 类的 draw 函数
delete shapePtr;
return 0;
}实现动态绑定:虚函数的存在允许在运行时选择正确的函数实现,而不是在编译时确定。这种动态绑定通过虚函数表(virtual function table,vtable)实现,每个带有虚函数的类都有一个对应的vtable。
允许在派生类中覆盖基类函数:派生类可以通过覆盖基类的虚函数来提供特定于自身的实现。这允许你在基类中定义通用行为,而在派生类中进行必要的定制。
方便的基类接口定义:虚函数允许你在基类中定义一个接口,而不需要提供具体的实现。这样,你可以通过基类指针或引用调用这些函数,然后在派生类中提供实际的实现。
6、C++中override关键字的作用
- 明确重写关系:
override关键字告诉编译器,程序员的意图是要重写基类中的虚函数。这样做可以增加代码的可读性,使得代码更容易理解。
class Base {
public:
virtual void foo();
};
class Derived : public Base {
public:
void foo() override; // 使用 override 明确表明重写关系
};- 编译器检查:如果派生类中的成员函数使用了
override关键字,而实际上并没有覆盖基类中的虚函数,编译器将生成一个错误。这有助于捕获在重写虚函数时的潜在错误。
class Base {
public:
virtual void foo();
};
class Derived : public Base {
public:
void bar() override; // 编译器会生成错误,因为 bar() 没有重写任何基类虚函数
};- 提高可维护性:显式地使用
override有助于保持代码的一致性。如果在基类中添加或修改了虚函数,而派生类中相应的函数没有使用override,编译器会发出警告,提醒你检查是否需要更新派生类的实现。
7、构造函数是否可以是虚函数(解释原因)?
在C++中,构造函数不能被声明为虚函数。这是因为虚函数的调用是依赖于对象的类型的,而构造函数的目的是在对象创建的过程中进行初始化,这种初始化必须在对象的实际类型确定之前完成。
一些原因:
- 构造函数的调用顺序:构造函数的调用是在对象创建的过程中发生的,首先调用基类构造函数,然后是派生类的构造函数。在调用基类构造函数时,对象的实际类型尚未完全确定,因此无法进行虚函数调用。
- 虚函数表(vtable)的构建:虚函数表是在对象构造期间构建的,其中包含了虚函数的地址。由于构造函数的调用发生在虚函数表构建之前,因此构造函数无法参与虚函数表的机制。
- 对象生命周期:虚函数的调用需要一个完全构造的对象,而在对象的构造阶段,对象可能处于不完全构造状态。因此,在构造函数中进行虚函数调用可能导致不可预测的结果。
8、虚函数是否可以使用static关键字?
在C++中,虚函数和static关键字有一些冲突,因为它们的语义和用途不同。
- 虚函数: 虚函数是用于实现运行时多态性的概念,允许在运行时根据对象的实际类型调用相应的函数。虚函数通过使用
virtual关键字声明,并且它们的调用是在运行时解析的,而不是在编译时。
class Base {
public:
virtual void foo() {
// 虚函数的实现
}
};- static函数:
static关键字在不同的上下文中有不同的含义,但在成员函数中,它表示该函数是属于类而不是实例的。static函数不依赖于特定的实例,因此不可以使用this指针访问实例的成员。
class MyClass {
public:
static void staticFunction() {
// 静态函数的实现
}
};将static关键字用于虚函数是不合理的,因为虚函数的核心概念是实现运行时多态性,而static函数是与特定实例无关的。在声明虚函数时,不应该使用static关键字。
9、C++内存中堆和栈的区别
- 分配方式:
- 栈(Stack):栈是由编译器自动管理的一块内存区域,用于存储局部变量和函数调用信息。栈上的内存分配和释放是由系统自动进行的,遵循先进后出(LIFO)的原则。
- 堆(Heap):堆是由程序员手动管理的一块内存区域,用于动态分配和释放内存。在堆上分配的内存需要程序员负责释放,否则可能导致内存泄漏。
- 生命周期:
- 栈:栈上的数据的生命周期与其所在的函数相对应。当函数执行完毕时,栈上的局部变量会被自动销毁。
- 堆:堆上的数据的生命周期由程序员显式控制。动态分配的内存会一直存在,直到程序员使用
delete或free释放它。
- 大小限制:
- 栈:栈的大小通常较小,因为它是由系统自动管理的,对于每个线程都有一个有限的栈大小。
- 堆:堆的大小通常较大,受系统总体内存大小的限制。堆上的内存分配可以是灵活的,但也容易受到内存碎片的影响。
- 访问速度:
- 栈:栈上的内存分配和释放是非常快速的,因为只需要移动栈指针。
- 堆:堆上的内存分配和释放可能会更慢,因为它需要进行更复杂的内存管理操作。
- 使用场景:
- 栈:适用于存储相对较小的局部变量和函数调用信息,生命周期较短。
- 堆:适用于需要动态分配内存、生命周期不确定或较长的情况,例如动态数组、对象等。
10、为什么栈的效率比堆高?
- 分配和释放速度:
- 栈:栈上的内存分配和释放是非常快速的,只需简单地移动栈指针。由于栈上的内存是按照后进先出(LIFO)的顺序分配和释放的,所以分配和释放的操作非常简单高效。
- 堆:堆上的内存分配和释放可能涉及到更复杂的管理机制,如内存分配表的维护、内存合并等操作。因此,堆上的分配和释放通常比栈上的操作慢一些。
- 作用域和生命周期管理:
- 栈:栈上的变量的作用域和生命周期是由程序的执行流程自动管理的。当函数退出时,栈上的局部变量会被自动销毁,无需程序员显式释放内存。
- 堆:堆上的内存由程序员手动分配和释放,需要更仔细的管理。如果程序员忘记释放堆上的内存,可能会导致内存泄漏,影响程序的性能和稳定性。
- 内存访问的局部性:
- 栈:栈上的数据是紧凑排列的,而且通常与函数的调用关系相关,这有利于局部性原理,即近期访问的数据很可能是相邻的或者在同一缓存行中。这样的局部性有助于提高内存访问的效率。
- 堆:堆上的内存分布相对来说比较分散,数据的访问可能涉及到不同的内存块,导致缓存未命中的可能性增加。
11、介绍二叉搜索树,平衡二叉树(左旋右旋操作),红黑树?
二叉搜索树(BST):
二叉搜索树是一种二叉树,其中每个节点都有一个值,并且满足以下性质:
- 左子树中的所有节点的值小于根节点的值。
- 右子树中的所有节点的值大于根节点的值。
- 左右子树都是二叉搜索树。
这个性质使得在二叉搜索树中,查找、插入和删除等操作的平均时间复杂度为 O(log n),其中 n 是树中节点的数量。然而,如果树的形状不平衡,最坏情况下可能变成链表,时间复杂度会退化为 O(n)。
平衡二叉树:
平衡二叉树是为了解决二叉搜索树的不平衡问题而设计的。在平衡二叉树中,左右子树的高度差不能超过1。平衡二叉树的一种常见实现是 AVL 树。
AVL 树的平衡操作:
平衡二叉树通过旋转操作来维护平衡。有两种基本的旋转操作:
- 左旋(Left Rotation): 通过将一个节点的右子树提升为根节点,将原根节点降为左子树的右子节点。
- 右旋(Right Rotation): 通过将一个节点的左子树提升为根节点,将原根节点降为右子树的左子节点。
这两种旋转操作可以组合使用来保持整棵树的平衡。
红黑树:
红黑树是另一种平衡二叉搜索树,它引入了颜色的概念,每个节点都带有红色或黑色。红黑树的性质如下:
- 每个节点要么是红色,要么是黑色。
- 根节点是黑色的。
- 每个叶子节点(NIL 节点,通常是 null 或者特殊的哨兵节点)是黑色的。
- 如果一个节点是红色的,则其两个子节点都是黑色的(不存在两个相邻的红色节点)。
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点,即保持了从根到叶子的最长路径的长度不超过最短路径的两倍。
这些性质保证了红黑树的平衡,插入和删除等操作的最坏情况时间复杂度为 O(log n)。
12、介绍全连接层和卷积神经网络
全连接层(Fully Connected Layer):
全连接层是神经网络中最基本的层之一。在全连接层中,每个神经元与上一层的所有神经元相连接,形成一个完全连接的结构。每个连接都有一个权重,这些权重用于调整输入的影响力,每个神经元还有一个偏置(bias)用于偏移输入的总和。
全连接层通常用于神经网络的最后一层,将前面的层提取到的特征映射映射到输出类别或回归值。全连接层的参数量较大,容易导致过拟合,因此在深度学习中常常与其他类型的层结合使用,如卷积层、池化层等。
卷积神经网络(Convolutional Neural Network,CNN):
卷积神经网络是一种专门用于处理具有网格结构数据的深度学习模型。它在图像处理领域取得了很大成功,但也适用于其他类型的网格结构数据,如音频、时间序列等。
CNN的主要特征是卷积层(Convolutional Layer),其中卷积核(filter)通过滑动窗口的方式在输入数据上进行卷积操作。卷积操作能够捕捉输入中的局部特征,通过共享权重的方式减少参数量,同时保留位置信息。卷积层通常与池化层(Pooling Layer)交替使用,池化操作用于减少特征图的大小,降低计算复杂度。
CNN的典型结构包括卷积层、池化层、全连接层和激活函数等。通过这些层的组合,CNN能够逐渐提取输入数据的高级抽象特征,从而实现对图像、语音等复杂数据的有效分类和识别。
13、什么是梯度?
在数学和机器学习中,梯度是一个向量,表示某个函数在给定点处的变化率和方向。对于单变量函数,梯度就是导数;而对于多变量函数,梯度是偏导数的向量。
梯度向量的方向是函数值增加最快的方向,而梯度的大小表示变化率。当梯度的大小很大时,表示函数在给定点的变化很快;当梯度的大小很小时,表示函数在给定点的变化相对较慢。
在机器学习中,梯度的概念经常与梯度下降算法相关联。梯度下降是一种优化算法,通过迭代更新参数的值,使得损失函数最小化。在每次迭代中,梯度指导了参数调整的方向,从而朝着损失函数下降最快的方向更新参数。
14、反向传播时为什么会有梯度消失现象?
梯度消失是指在神经网络中进行反向传播算法时,某些权重的梯度变得非常小,甚至趋近于零,导致这些权重几乎不再更新,从而使得网络在训练过程中难以学习。
梯度消失现象的主要原因有两个:
- 激活函数的选择: 使用一些常见的激活函数,如 sigmoid 或 tanh 函数,这些函数在输入值较大或较小的情况下,梯度会趋近于零。在反向传播过程中,这样的激活函数导致网络层之间的梯度传播逐渐减小,最终导致梯度消失。
- 深度网络结构: 在深度神经网络中,梯度需要经过多个层传播回到输入层。在这个过程中,由于链式法则的乘法,梯度可能会被多次相乘,使得整个梯度趋于零。这种现象在深度网络中尤为明显,因为随着层数的增加,梯度的影响逐渐减小。
梯度消失问题影响了网络的训练能力,尤其是深度网络。为了缓解梯度消失问题,可以采取以下措施:
- 使用合适的激活函数: 选择一些在大部分输入范围内梯度都不会过小的激活函数,如 ReLU(Rectified Linear Unit)。
- 使用批标准化:批标准化在每个小批量数据上对网络进行标准化,有助于缓解梯度消失问题。
- 使用残差连接:残差连接可以通过跳过某些层的梯度传播来减轻梯度消失问题,它在一些深度网络结构中表现很好。
- 梯度裁剪:对梯度进行裁剪,限制其在一个合理的范围内,以防止梯度爆炸和消失问题。
