本篇文章引用他人百度二面的面试题,抽出部分对现阶段自己需要掌握的题目进行解析:
一、vector分配内存的方式
当我们使用 std::vector 时,它会动态地管理内存,以适应元素的增长。std::vector 内部使用动态数组来存储元素,这个动态数组的大小是动态调整的,因此我们可以在运行时向 std::vector 中添加或删除元素,而不需要担心数组大小的问题。
std::vector 会维护两个关键的属性:
size:表示当前 std::vector 中的元素个数。
capacity:表示 std::vector 内部分配的存储空间的容量。
当我们向 std::vector 中添加新元素时,如果 size 达到了 capacity,即当前存储空间已经满了,std::vector 就会触发内部的重新分配内存的过程,来扩充存储空间。这个过程通常包括以下几个步骤:
分配新的更大的内存空间:std::vector 会根据需要申请一块更大的内存空间,通常会将容量扩大为原来的两倍或更多,以减少频繁的内存分配操作。
将原有元素从旧的内存空间拷贝到新的内存空间:std::vector 会将原来的元素按顺序拷贝到新的内存空间中。
释放旧的内存空间:一旦所有元素都已成功拷贝到新的内存空间,std::vector 会释放原来的内存空间。
二、std::queue的数据结构
std::queue 是 C++ 标准库中的队列容器适配器,它是基于其他底层容器(比如 std::deque 或 std::list)实现的。std::queue 的特点是**先进先出(FIFO)**的数据结构,它只允许在队列的末尾(尾部)添加元素,并且只允许在队列的开头(头部)移除元素。
默认情况下,std::queue 使用 std::deque(双端队列)作为其默认底层容器。std::deque 是一种双端队列,它允许在两端高效地添加和删除元素,因此非常适合作为队列的底层数据结构。在 std::queue 中,元素的添加操作称为入队(enqueue),元素的移除操作称为出队(dequeue)。std::queue 提供了 push(入队)、pop(出队)、front(返回队头元素)、back(返回队尾元素)等操作,以及 empty(判断队列是否为空)、size(返回队列中元素的个数)等方法。
#include
#include
using namespace std;
int main() {
// 创建一个 std::queue
queue<int> q;
// 向队列中添加元素
q.push(1);
q.push(2);
q.push(3);
// 输出队列中的元素
cout << "队列中的元素:";
while (!q.empty()) {
cout << q.front() << " "; // 输出队头元素
q.pop(); // 出队
}
cout << endl;
return 0;
}
三、map的数据结构
在 C++ 标准库中,std::map 是一个关联容器,它提供了一种将键值对关联起来的方式。std::map 的数据结构通常是基于红黑树(Red-Black Tree)实现的。
红黑树是一种自平衡的二叉查找树,它具有以下特点:
每个节点要么是红色,要么是黑色。
根节点是黑色的。
每个叶子节点(NIL 节点,空节点)是黑色的。
如果一个节点是红色的,则它的两个子节点都是黑色的。
对于每个节点,从该节点到其所有后代叶子节点的简单路径上,均包含相同数目的黑色节点。
由于红黑树具有自平衡的特性,它可以保证在进行插入、删除等操作时,树的高度始终保持在 O(log n) 的水平,从而保证了 std::map 的操作效率。
在 std::map 中,每个元素都是一个键值对,键和值可以是任意类型。std::map 中的元素按照键的顺序进行排序,因此可以通过键来快速查找和访问对应的值。在 std::map 中,键是唯一的,如果插入具有相同键的元素,新元素会覆盖旧元素。
四、unordered_map的数据结构
在 C++ 标准库中,std::unordered_map 是一个关联容器,它提供了一种将键值对关联起来的方式,但与 std::map 不同的是,std::unordered_map 使用哈希表(hash table)作为其底层数据结构,而不是红黑树。
哈希表是一种通过哈希函数将键映射到索引的数据结构,它具有以下特点:
快速查找:通过哈希函数计算键对应的索引,可以在接近 O(1) 的时间复杂度内查找到对应的值。
无序性:哈希表中的元素是无序的,与元素插入的顺序无关。
冲突处理:由于哈希函数的映射可能存在冲突(即多个键映射到同一个索引),因此哈希表需要解决冲突的方法,常见的方法包括链地址法(Chaining)和开放地址法(Open Addressing)等。
在 std::unordered_map 中,每个元素都是一个键值对,键和值可以是任意类型。std::unordered_map 中的元素存储在哈希表中,通过键的哈希值来确定元素的存储位置,因此可以快速查找和访问对应的值。
需要注意的是,由于哈希表的特性,std::unordered_map 中的元素是无序的,因此在需要有序性的场景下,可能需要考虑使用 std::map。此外,由于哈希表的性质,std::unordered_map 中的元素插入和查找操作的平均时间复杂度为 O(1),但最坏情况下的时间复杂度可能会达到 O(n),其中 n 是元素的数量。
五、map和unordered_map的使用场景,大数据量的时候用哪一个?
std::map 和 std::unordered_map 都是关联容器,它们提供了键值对的关联方式,但是它们在实现和使用上有一些不同,因此适用的场景也有所不同。
std::map:
基于红黑树实现,有序性好,元素按照键的顺序排列。
插入、删除、查找等操作的平均时间复杂度为 O(log n),其中 n 是元素的数量。
适用于需要元素有序存储,并且需要频繁地进行插入、删除、查找等操作的场景。
std::unordered_map:
基于哈希表实现,无序性好,元素存储在哈希表中,根据键的哈希值快速查找。
插入、删除、查找等操作的平均时间复杂度为 O(1),但最坏情况下的时间复杂度可能为 O(n),其中 n 是元素的数量。
适用于不需要元素有序存储,但需要快速进行插入、删除、查找等操作的场景。
当需要元素有序存储,并且需要频繁地进行插入、删除、查找等操作时,可以选择使用 std::map。而当不需要元素有序存储,但需要快速进行插入、删除、查找等操作时,可以选择使用 std::unordered_map。
对于大数据量的情况,一般情况下 std::unordered_map 更适合,因为它的插入、删除、查找等操作的平均时间复杂度为 O(1),而 std::map 的这些操作的时间复杂度是 O(log n),在大数据量的情况下,std::unordered_map 的性能优势会更加明显。但是需要注意,std::unordered_map 在极端情况下(比如哈希冲突严重)的性能可能会下降,需要根据具体场景进行选择。
六、简单说一下用过的智能指针?
1、std::shared_ptr:
原理:std::shared_ptr是基于引用计数的智能指针,用于管理动态分配的对象。它维护一个引用计数,当计数为零时,释放对象的内存。
使用场景:适用于多个智能指针需要共享同一块内存的情况。例如,在多个对象之间共享某个资源或数据。
std::shared_ptr<int> sharedInt = std::make_shared<int>(42);
std::shared_ptr<int> anotherSharedInt = sharedInt; // 共享同一块内存
2、std::unique_ptr:
原理:std::unique_ptr是独占式智能指针,意味着它独占拥有所管理的对象,当其生命周期结束时,对象会被自动销毁。
使用场景:适用于不需要多个指针共享同一块内存的情况,即单一所有权。通常用于资源管理,例如动态分配的对象或文件句柄。
std::unique_ptr<int> uniqueInt = std::make_unique<int>(42);
// uniqueInt 的所有权是唯一的
3、std::weak_ptr:
原理:std::weak_ptr是一种弱引用指针,它不增加引用计数。它通常用于协助std::shared_ptr,以避免循环引用问题。
使用场景:适用于协助解决std::shared_ptr的循环引用问题,其中多个shared_ptr互相引用,导致内存泄漏。
std::shared_ptr<int> sharedInt = std::make_shared<int>(42);
std::weak_ptr<int> weakInt = sharedInt;
七、make_shared函数里面干了哪些事情?
std::make_shared 是 C++11 引入的函数模板,用于创建 std::shared_ptr 对象并初始化它所管理的对象。在调用 std::make_shared 时,它会完成以下几件事情:
分配内存:std::make_shared 会在一块连续的内存上分配所需的内存空间,用于存储对象的数据以及 std::shared_ptr 的控制块。
构造对象:在分配的内存空间中调用对象的构造函数,初始化对象的数据。
创建 std::shared_ptr:将分配的内存空间与一个控制块关联起来,并返回一个 std::shared_ptr 对象,该对象包含了指向分配的内存空间的指针以及一个引用计数。
由于 std::make_shared 在一次内存分配中完成了对象的构造和控制块的分配,因此它通常比直接使用 new 来创建 std::shared_ptr 更高效,因为减少了额外的内存分配和构造的开销。
八、什么时候不能用make_shared函数,只能用shared_ptr的构造函数?
需要控制对象的内存分配方式:std::make_shared 会在一次内存分配中分配对象和控制块的内存,但有时可能需要更精细的控制,比如指定自定义的内存分配器或者在构造对象时需要传递额外的参数。
需要避免一次性分配大量内存:由于 std::make_shared 会一次性分配内存来存储对象和控制块,当对象较大或者需要创建大量对象时,可能会导致内存浪费或者内存碎片化。
需要延迟对象的构造:std::make_shared 会立即构造对象,如果需要延迟对象的构造(比如在构造函数中可能会抛出异常),就不能使用 std::make_shared。
在这些情况下,可以使用 std::shared_ptr 的构造函数来手动分配内存,并在需要时显式地调用对象的构造函数。
九、shared_ptr和unique_ptr如何自定义删除函数?
在 C++11 及更新的标准中,可以使用自定义的删除器(deleter)来管理 std::shared_ptr 和 std::unique_ptr 指向的资源。自定义删除器是一个函数对象或者函数指针,用于在释放资源时执行特定的清理操作。
对于 std::shared_ptr,可以通过在创建 std::shared_ptr 时指定删除器来自定义删除函数。例子:
struct MyDeleter {
void operator()(int* p) const {
std::cout << "Deleting int pointer" << std::endl;
delete p;
}
};
int main() {
std::shared_ptr<int> sp(new int(42), MyDeleter());
// 使用 sp
return 0;
}
对于 std::unique_ptr,可以在创建 std::unique_ptr 对象时指定删除器,也可以在运行时使用 std::unique_ptr::reset() 方法重新指定删除器。例如:
struct MyDeleter {
void operator()(int* p) const {
std::cout << "Deleting int pointer" << std::endl;
delete p;
}
};
int main() {
std::unique_ptr<int, MyDeleter> up(new int(42));
// 使用 up
up.reset(new int(10), MyDeleter()); // 重新指定删除器
return 0;
}
例子中,MyDeleter 是一个自定义的删除器,它重载了函数调用运算符 operator(),在其中执行了特定的清理操作。在创建 std::shared_ptr 或 std::unique_ptr 对象时,将自定义的删除器作为第二个模板参数传递给它们,即可使用自定义的删除函数来管理资源的释放。
十、左值和右值的区别?
左值:
左值是指向内存位置的表达式,可以出现在赋值语句的左边或右边。
左值具有持久性,可以取地址。
可以修改左值所代表的对象的值。
右值:
右值是指临时对象的表达式,通常出现在赋值语句的右边。
右值通常是临时生成的,没有持久性,不能取地址。
不能对右值进行赋值操作。
在 C++11 中,引入了右值引用的概念,它可以绑定到右值,例如 int&& 就是一个右值引用类型。右值引用可以用于移动语义和完美转发等高级特性。
十一、如何快速判断一个值是左值还是右值?
左值:
如果一个表达式有名称(变量、函数、对象等),那么它通常是一个左值。
可以对左值进行取地址操作(&运算符)。
右值:
如果一个表达式是临时生成的、没有名称的,通常是一个右值。
不能对右值进行取地址操作。
int x = 10; // x 是左值
int y = x; // x 是左值,y 是左值
int z = x + y; // x + y 是右值
int* ptr = &x; // &x 是左值,ptr 是左值
十二、介绍一下左值引用和右值引用?
左值引用:
左值引用是对左值的引用,通常表示一个具体对象的引用。
使用 & 符号声明,例如 int& ref = x;,其中 ref 是对 x 的左值引用。
左值引用可以绑定到左值,但不能绑定到右值。
右值引用:
右值引用是对右值的引用,通常表示一个临时对象的引用。
使用 && 符号声明,例如 int&& rref = 10;,其中 rref 是对临时对象 10 的右值引用。
右值引用可以绑定到右值,但不能绑定到左值。
右值引用最重要的应用之一是支持移动语义(Move Semantics),通过移动构造函数和移动赋值运算符,可以高效地将资源(比如动态分配的内存)从一个对象转移到另一个对象,避免了不必要的资源拷贝,提高了程序的性能。
十三、函数的返回值是左值还是右值?
函数的返回值可以是左值也可以是右值,取决于返回类型和返回表达式的类型。
1、如果返回类型是非引用类型(如 int、double 等基本类型或者类类型),并且返回的是一个具体的对象(非引用),那么返回值是一个右值。
int getValue() {
return 10; // 返回值是一个右值
}
2、如果返回类型是引用类型,那么返回值是一个左值。
int x = 10;
int& getRef() {
return x; // 返回值是一个左值引用
}
3、如果返回类型是右值引用类型,那么返回值是一个右值。
int&& getRvalueRef() {
return 10; // 返回值是一个右值引用
}
十四、前置++和后置++的区别?返回值的类型是什么?
前置递增运算符 ++:
int x = 10;
int& ref = ++x; // x 先递增为 11,然后将递增后的值 11 赋给 ref
先对操作数进行递增操作,然后返回递增后的值。
返回的是递增后的值的引用。
后置递增运算符 ++:
int x = 10;
int y = x++; // 先将 x 的值 10 赋给 y,然后 x 递增为 11
先返回操作数的原值,然后再对操作数进行递增操作。
返回的是递增前的值的副本(而非引用)。
十五、堆和栈的区别?
栈:
栈是一种线性的数据结构,它具有后进先出(LIFO)的特性。
栈的内存分配由编译器自动完成,存储局部变量、函数参数、函数返回地址等信息。
栈上的内存由系统自动分配和释放,变量的生命周期与函数的调用关系密切相关。
栈的大小有限,通常是固定的,因此存储在栈上的数据大小和生命周期需要在编译时确定。
堆:
堆是一种树形的数据结构,它的内存分配由程序员手动控制。
堆上的内存由程序员手动申请和释放,存储动态分配的对象、数组等。
堆上的内存分配和释放不受函数调用关系的影响,生命周期由程序员控制。
堆上的内存大小一般比较大,并且可以动态增长,但需要程序员手动管理内存,避免内存泄漏和内存访问错误。
十六、设计模式用过吗?写一下单例模式?
懒汉式单例模式:
懒汉式单例模式在需要时才创建单例实例。如果实例尚未创建,就创建并返回,否则直接返回现有的实例。这种方式在首次使用时才创建实例,延迟了对象的创建时间。
#include
class LazySingleton {
private:
// 私有构造函数,防止外部创建实例
LazySingleton() {
std::cout << "LazySingleton instance created." << std::endl;
}
// 静态成员变量,用于保存唯一实例
static LazySingleton* instance;
public:
// 获取单例实例的静态方法
static LazySingleton* getInstance() {
if (!instance) {
instance = new LazySingleton();
}
return instance;
}
// 其他成员函数
void doSomething() {
std::cout << "LazySingleton is doing something." << std::endl;
}
};
// 初始化静态成员变量
LazySingleton* LazySingleton::instance = nullptr;
int main() {
// 获取单例实例
LazySingleton* instance1 = LazySingleton::getInstance();
LazySingleton* instance2 = LazySingleton::getInstance();
// 执行一些操作
instance1->doSomething();
instance2->doSomething();
// 输出结果应该表明 instance1 和 instance2 是同一个实例
return 0;
}
饿汉式单例模式:
饿汉式单例模式在类加载时就创建了单例实例。这样可以确保实例在首次使用之前就已经存在。饿汉式的缺点是在程序启动时就创建实例,可能会导致不必要的资源浪费。
#include
class EagerSingleton {
private:
// 私有构造函数,防止外部创建实例
EagerSingleton() {
std::cout << "EagerSingleton instance created." << std::endl;
}
// 静态成员变量,用于保存唯一实例
static EagerSingleton* instance;
public:
// 获取单例实例的静态方法
static EagerSingleton* getInstance() {
return instance;
}
// 其他成员函数
void doSomething() {
std::cout << "EagerSingleton is doing something." << std::endl;
}
};
// 初始化静态成员变量
EagerSingleton* EagerSingleton::instance = new EagerSingleton();
int main() {
// 获取单例实例
EagerSingleton* instance1 = EagerSingleton::getInstance();
EagerSingleton* instance2 = EagerSingleton::getInstance();
// 执行一些操作
instance1->doSomething();
instance2->doSomething();
// 输出结果应该表明 instance1 和 instance2 是同一个实例
return 0;
}
这两种单例模式各有优缺点,懒汉式节省了资源,但在首次使用时会稍微延迟,而饿汉式则在程序启动时就创建了实例,确保了实例的立即可用性。
十七、写个程序,删除一个vector中所有值为奇数的元素?
1、定义一个 std::vector 类型的变量 numbers,其中包含了一组整数。
2、使用 std::remove_if 算法和 Lambda 表达式来删除 numbers 中所有的奇数元素:
2.1 std::remove_if 算法接受三个参数:容器的起始迭代器、容器的结束迭代器和一个谓词函数(predicate function)。
2.2 谓词函数是一个返回 bool 类型的函数,用于判断容器中的元素是否满足某个条件。在这个例子中,Lambda 表达式 [](int i) { return i % 2 != 0; } 是一个用于判断奇数的谓词函数,它会返回 true 如果一个数是奇数,这样 std::remove_if 就能找到所有奇数,并将它们移动到容器末尾。
3、使用 erase 方法删除 numbers 容器末尾的所有奇数元素:
3.1 erase 方法接受两个迭代器参数,表示要删除的范围。在这个例子中,我们使用 std::remove_if 的返回值作为 erase 方法的参数,它指向了容器中最后一个不满足条件的元素之后的位置。
3.2 erase 方法会删除从指定位置到容器末尾的所有元素,从而实现了删除所有奇数元素的操作。
4、最后,使用循环遍历 numbers 容器,并输出删除后的结果。
#include
#include
#include
int main() {
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9};
// 使用 erase-remove 习惯用法删除奇数元素
numbers.erase(std::remove_if(numbers.begin(), numbers.end(), [](int i) { return i % 2 != 0; }), numbers.end());
// 打印删除后的结果
for (int num : numbers) {
std::cout << num << " ";
}
std::cout << std::endl;
return 0;
}
十八、手撕代码,反转链表?
给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。
这里给大家讲最经典的双指针法。
1、如果链表为空或者只有一个节点,直接返回头节点 head。
2、初始化 pre 为 nullptr,cur 为头节点 head,node 为 cur 的下一个节点。
3、在循环中,不断更新 pre、cur 和 node 的值,使得 cur 的 next 指向 pre,然后将 pre、cur 和 node 分别向后移动一位。
4、当 cur 移动到链表末尾时,pre 就是反转后的新头节点。
过程演示:
初始状态
第一步:
先定义一个空节点并初始化为nullptr,分别将这两个指针指向这个空节点和头节点,再定义一个节点用来临时存放节点。
ListNode* pre = nullptr; // 初始化 pre 为 nullptr
ListNode* cur = head; // 初始化 cur 为头节点
ListNode* node = nullptr; // 初始化 node 为 nullptr
node = cur->next; // 保存当前节点的下一个节点
cur->next = pre; // 当前节点的 next 指向 pre,完成反转
pre = cur; // 更新 pre
cur = node; // 更新 cur
到这里,其实我们就可以使用一个循环,让继续这两步操作。不过为了大家更加看明白,我就将这个示例画完吧。
第四步:
node = cur->next; // 保存当前节点的下一个节点
cur->next = pre; // 当前节点的 next 指向 pre,完成反转
pre = cur; // 更新 pre
cur = node; // 更新 cur
node = cur->next; // 保存当前节点的下一个节点
cur->next = pre; // 当前节点的 next 指向 pre,完成反转
pre = cur; // 更新 pre
cur = node; // 更新 cur
node = cur->next; // 保存当前节点的下一个节点
cur->next = pre; // 当前节点的 next 指向 pre,完成反转
node = cur->next; // 保存当前节点的下一个节点
cur->next = pre; // 当前节点的 next 指向 pre,完成反转
node = cur->next; // 保存当前节点的下一个节点
cur->next = pre; // 当前节点的 next 指向 pre,完成反转
pre = cur; // 更新 pre
cur = node; // 更新 cur
整体代码:
#include
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return head; // 如果链表为空或者只有一个节点,直接返回头节点
}
ListNode* pre = nullptr; // 初始化 pre 为 nullptr
ListNode* cur = head; // 初始化 cur 为头节点
ListNode* node = nullptr; // 初始化 node 为 nullptr
while (cur != nullptr) {
node = cur->next; // 保存当前节点的下一个节点
cur->next = pre; // 当前节点的 next 指向 pre,完成反转
pre = cur; // 更新 pre
cur = node; // 更新 cur
}
return pre; // pre 就是反转后的新头节点
}
};
int main() {
ListNode* head = new ListNode(1);
head->next = new ListNode(2);
head->next->next = new ListNode(3);
Solution solution;
ListNode* newHead = solution.reverseList(head);
while (newHead != nullptr) {
std::cout << newHead->val << " ";
newHead = newHead->next;
}
return 0;
}