C++闯关手册 - 面试实例:第一轮面试
第一轮面试,通常侧重于基础知识的考察,包括C++的基本语法、数据结构和算法等。例如,你可能需要解释一下什么是类和对象,或者编写一个简单的排序算法。面试官通常就是以入职后的平级同事。本文包含高频面试题目及解析,注意问的和回答的越深度底层,越凸显更高的技能。
C++ 闯关手册 - 面试实例
第一面:基础面试
1)自我介绍
每一个面试都会有个自我介绍,这里省略,基本面试几次自己都会说的比较溜了。面试官是有可能根据你的介绍内容进行提问的,这里介绍的时候可以根据情况引导面试官。
2)C++11的新特性有哪些?
简单回答:智能指针、auto、for range、lamada、初始化列表等;
详细回答如下:
C++11是C++语言的一个重要版本,它引入了许多新特性以增强语言的功能性和易用性。以下是一些主要的C++11特性:
-
自动类型推断(auto):
auto
关键字可以让编译器自动推断变量的类型。auto a = 1; // a is an integer
-
范围for循环(Range-based for loops):这种新的for循环语法可以更简洁地遍历容器。
std::vector<int> v = {1, 2, 3, 4, 5}; for(auto i : v) std::cout << i << ' ';
-
nullptr:
nullptr
是一个新的关键字,用于表示空指针,以替代C++03中的NULL
。int* p = nullptr;
-
初始化列表(Initializer lists):初始化列表提供了一种统一的初始化语法。
std::vector<int> v = {1, 2, 3, 4, 5};
-
Lambda表达式(Lambda expressions):Lambda表达式是一种创建匿名函数对象的方式。
auto f = [](int a, int b) -> int { return a + b; };
-
智能指针(Smart pointers):C++11引入了
unique_ptr
、shared_ptr
和weak_ptr
等智能指针,以便更好地管理内存。std::unique_ptr<int> p(new int(5));
-
多线程支持(Multithreading support):C++11在标准库中增加了对多线程编程的支持。
-
移动语义和右值引用(Move semantics and Rvalue references):这两个特性可以提高程序的性能,减少不必要的拷贝。
-
静态断言(Static assertions):
static_assert
可以在编译时进行断言。 -
类型推导(Type inference):
decltype
关键字可以推导出表达式的类型。
3)C++智能指针有哪些?原理是什么?
C++11中主要有三种智能指针:std::unique_ptr
,std::shared_ptr
和std::weak_ptr
。
- std::unique_ptr:
std::unique_ptr
是一种独占所有权的智能指针,即在任何时刻,只能有一个std::unique_ptr
指向给定的对象。std::unique_ptr
通过禁止复制构造和复制赋值来实现独占所有权的语义。但你可以使用std::move
来转移所有权。std::unique_ptr
在其析构函数中删除其所指向的对象,这样可以避免内存泄漏和其他资源管理问题。std::unique_ptr
还支持自定义删除器,这可以让你自定义资源的清理方式。 - std::shared_ptr:
std::shared_ptr
是一种共享所有权的智能指针,即多个std::shared_ptr
可以指向同一个对象。std::shared_ptr
通过引用计数来实现共享所有权的语义。每当一个std::shared_ptr
被复制时,引用计数就会增加。当std::shared_ptr
被删除或者离开其作用域时,引用计数就会减少。只有当引用计数降为0时,其所指向的对象才会被删除。std::shared_ptr
也支持自定义删除器。 - std::weak_ptr:
std::weak_ptr
是一种不控制所有权的智能指针,它可以观察std::shared_ptr
,但不会增加其引用计数。std::weak_ptr
主要用来解决std::shared_ptr
的循环引用问题。你不能直接使用std::weak_ptr
来访问其所观察的对象,但你可以使用std::weak_ptr::lock
或std::weak_ptr::expired
来获取一个std::shared_ptr
。
这些智能指针的设计目标是为了提供一种自动管理资源的方式,避免内存泄漏和其他资源管理问题。同时,它们也提供了灵活的所有权语义,可以满足不同的使用场景。
深度延伸:weak_ptr的实现原理及简单实现
std::weak_ptr
是C++11引入的一种智能指针,它的主要目的是为了解决std::shared_ptr
的循环引用问题。std::weak_ptr
不控制所有权,只能观察std::shared_ptr
,但不会增加其引用计数。
以下是std::weak_ptr
的一些关键实现原理:
-
观察者语义:
std::weak_ptr
不控制所有权,只能观察std::shared_ptr
。这意味着你不能直接使用std::weak_ptr
来访问其所观察的对象,但你可以使用std::weak_ptr::lock
或std::weak_ptr::expired
来获取一个std::shared_ptr
。 -
引用计数:
std::weak_ptr
和std::shared_ptr
共享同一个引用计数。当std::shared_ptr
被复制时,引用计数会增加。当std::shared_ptr
被删除或者离开其作用域时,引用计数会减少。只有当引用计数降为0时,其所指向的对象才会被删除。但是,std::weak_ptr
不会增加引用计数。 -
延迟删除:当所有的
std::shared_ptr
都被删除时,其所指向的对象会被删除,但引用计数的控制块不会被删除,直到所有的std::weak_ptr
也被删除。这意味着你可以在所有的std::shared_ptr
都被删除后,仍然可以使用std::weak_ptr
来获取一个新的std::shared_ptr
,只要对象还没有被删除。
总的来说,std::weak_ptr
是一种强大的工具,它可以帮助你解决std::shared_ptr
的循环引用问题,同时也提供了一种安全的方式来观察std::shared_ptr
。
实现一个weak_ptr
需要考虑到以下几个关键点:
weak_ptr
不拥有对象,只是观察shared_ptr
。weak_ptr
需要共享shared_ptr
的引用计数。weak_ptr
需要能够检查所观察的对象是否还存在。
以下是一个简单的weak_ptr
实现的示例:
template<typename T>
class SharedPtr;
template<typename T>
class WeakPtr {
public:
WeakPtr() : ptr_(nullptr), control_block_(nullptr) {}
WeakPtr(const SharedPtr<T>& sp) : ptr_(sp.ptr_), control_block_(sp.control_block_) {
if (control_block_) {
control_block_->weak_count_++;
}
}
~WeakPtr() {
if (control_block_ && --control_block_->weak_count_ == 0 && control_block_->shared_count_ == 0) {
delete control_block_;
}
}
bool expired() const {
return control_block_ == nullptr || control_block_->shared_count_ == 0;
}
SharedPtr<T> lock() const {
if (expired()) {
return SharedPtr<T>();
} else {
return SharedPtr<T>(*this);
}
}
private:
T* ptr_;
ControlBlock* control_block_;
friend class SharedPtr<T>;
};
template<typename T>
class SharedPtr {
public:
SharedPtr(T* ptr) : ptr_(ptr), control_block_(new ControlBlock) {
control_block_->shared_count_ = 1;
}
SharedPtr(const WeakPtr<T>& wp) : ptr_(wp.ptr_), control_block_(wp.control_block_) {
if (control_block_) {
control_block_->shared_count_++;
}
}
~SharedPtr() {
if (--control_block_->shared_count_ == 0) {
delete ptr_;
if (control_block_->weak_count_ == 0) {
delete control_block_;
}
}
}
private:
T* ptr_;
ControlBlock* control_block_;
struct ControlBlock {
int shared_count_;
int weak_count_ = 0;
};
friend class WeakPtr<T>;
};
这个例子中的`weak_ptr`和`shared_ptr`都是模板类,可以用于任何类型的对象。`weak_ptr`和`shared_ptr`共享一个控制块,其中包含了共享的引用计数和弱引用计数。当`shared_ptr`的引用计数降为0时,它会删除其所指向的对象。当`weak_ptr`的引用计数也降为0时,它会删除控制块。
注意,这只是一个简单的实现,没有考虑到线程安全、异常安全和其他一些细节。在实际使用中,你应该使用标准库提供的std::weak_ptr
和std::shared_ptr
。
以下是一些可能的改进:
- 线程安全:在多线程环境中,我们需要确保对引用计数的操作是原子的。我们可以使用
std::atomic
来实现这一点。 - 异常安全:我们需要确保在构造函数和赋值操作中,如果发生异常,对象的状态仍然保持有效。我们可以使用“拷贝和交换”或者“资源获取即初始化”(RAII)等技术来实现这一点。
- 删除器:我们需要支持自定义删除器,以便用户可以自定义如何删除对象。我们可以在控制块中添加一个删除器成员。
- 类型转换:我们需要支持
static_pointer_cast
、dynamic_pointer_cast
和const_pointer_cast
等类型转换操作。 - 数组:我们需要支持数组类型,包括正确的删除操作和
operator[]
。
由于这些改进涉及到很多复杂的细节,所以在实际使用中,我们通常建议使用标准库提供的std::shared_ptr
和std::weak_ptr
,而不是自己实现。标准库的实现已经考虑到了这些细节,并且经过了广泛的测试和优化。
4)介绍下C++的多态
在C++中,多态是面向对象编程的一个重要特性,它允许我们通过基类指针来操作派生类对象。多态分为两种:编译时多态(静态多态)和运行时多态(动态多态)。
- 编译时多态:通过函数重载和运算符重载实现。在编译时,编译器会根据参数类型和数量决定调用哪个函数。
- 运行时多态:通过虚函数和纯虚函数实现。在运行时,根据对象的实际类型来决定调用哪个函数。这是通过虚表(vtable)和虚表指针(vptr)来实现的。
虚表是一个存储类中虚函数地址的表,每个包含虚函数的类都有一个虚表。虚表指针是一个指向虚表的指针,每个对象都有一个虚表指针。
当我们通过基类指针调用虚函数时,编译器会先通过虚表指针找到虚表,然后在虚表中查找虚函数的地址,最后调用该函数。这个过程是在运行时完成的,因此称为运行时多态。
以下是一个简单的示例:
class Base {
public:
virtual void print() {
cout << "Base" << endl;
}
};
class Derived : public Base {
public:
void print() override {
cout << "Derived" << endl;
}
};
int main() {
Base* ptr = new Derived();
ptr->print(); // 输出 "Derived"
delete ptr;
return 0;
}
在这个示例中,print
函数是虚函数,当我们通过基类指针ptr
调用print
函数时,实际上调用的是派生类Derived
的print
函数。这就是运行时多态的一个例子。
5)构造函数能被声明为虚函数吗,为什么?
在C++中,构造函数不能被声明为虚函数。
这是因为虚函数依赖于对象的虚表指针(vptr)来进行动态绑定,而这个虚表指针是在构造函数中初始化的。当我们创建一个对象时,首先会调用构造函数,这时对象的虚表指针还没有被初始化,所以我们不能在构造函数中使用虚函数机制。
另外,虚函数主要用于实现多态,允许我们通过基类指针来调用派生类的函数。但是,构造函数和析构函数是特殊的函数,它们不是为了被派生类重写的,而是为了初始化和清理对象的。构造函数是用来创建对象的,它决定了对象的初始状态,而析构函数是用来销毁对象的,它负责清理对象占用的资源。
因此,构造函数不能被声明为虚函数。
6)析构函数需要被声明为虚函数吗,为什么?
在C++中,析构函数可以并且在某些情况下应该被声明为虚函数。
当我们通过基类指针删除派生类对象时,如果基类的析构函数不是虚函数,那么只有基类的析构函数会被调用,派生类的析构函数不会被调用,这可能会导致资源泄漏。
如果基类的析构函数是虚函数,那么在删除派生类对象时,首先会调用派生类的析构函数,然后再调用基类的析构函数。这样就可以确保对象的资源被正确地清理。
以下是一个简单的示例:
class Base {
public:
virtual ~Base() {
cout << "Base Destructor" << endl;
}
};
class Derived : public Base {
public:
~Derived() {
cout << "Derived Destructor" << endl;
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // 输出 "Derived Destructor" 和 "Base Destructor"
return 0;
}
在这个示例中,Base
类的析构函数是虚函数,所以在删除Derived
对象时,首先调用Derived
的析构函数,然后再调用Base
的析构函数。这就是虚析构函数的作用。
7)new和malloc的区别
new
和malloc
都是用于在堆上分配内存的,但它们之间有几个主要的区别:
- 调用构造函数和析构函数:
new
不仅会分配内存,还会调用对象的构造函数。同样,delete
不仅会释放内存,还会调用对象的析构函数。而malloc
和free
只负责分配和释放内存,不会调用构造函数和析构函数。 - 返回类型:
new
返回的是指定类型的指针,不需要类型转换。而malloc
返回的是void*
,通常需要进行类型转换。 - 错误处理:如果内存分配失败,
new
会抛出std::bad_alloc
异常,而malloc
则返回NULL
。 - 分配数组:
new[]
和delete[]
可以用于分配和删除数组,它们会自动调用构造函数和析构函数。而malloc
和free
则需要手动计算数组的大小。
以下是一个简单的示例:
// 使用 new 和 delete
int* p1 = new int(10); // 分配内存并初始化为10
delete p1; // 释放内存
// 使用 malloc 和 free
int* p2 = (int*)malloc(sizeof(int)); // 分配内存
*p2 = 10; // 初始化为10
free(p2); // 释放内存
在这个示例中,new
和delete
不仅分配和释放了内存,还自动调用了构造函数和析构函数。而malloc
和free
则只负责分配和释放内存。
8)new底层是用什么实现的?
在C++中,new
操作符的底层实现通常依赖于一种名为operator new
的函数。这是一个可以被重载的全局函数,其默认实现通常使用C语言的malloc
函数来分配内存。
当你使用new
操作符创建一个对象时,实际上发生了两步操作:
- 首先,
new
操作符调用operator new
函数来分配足够的内存。这个函数的默认实现通常使用malloc
函数。 - 然后,
new
操作符在分配的内存上调用对象的构造函数来初始化对象。
在C++中,你可以重载operator new
和operator delete
来自定义对象的内存分配和释放行为。以下是一个简单的示例:
class MyClass {
public:
MyClass() {
cout << "Constructor is called" << endl;
}
~MyClass() {
cout << "Destructor is called" << endl;
}
// 重载 operator new
static void* operator new(size_t size) {
cout << "operator new is called" << endl;
void* p = malloc(size); // 使用 malloc 分配内存
return p;
}
// 重载 operator delete
static void operator delete(void* p) {
cout << "operator delete is called" << endl;
free(p); // 使用 free 释放内存
}
};
int main() {
MyClass* p = new MyClass(); // 输出 "operator new is called" 和 "Constructor is called"
delete p; // 输出 "Destructor is called" 和 "operator delete is called"
return 0;
}
在这个示例中,我们重载了MyClass
的operator new
和operator delete
。当我们使用new MyClass()
创建一个MyClass
对象时,首先调用重载的operator new
来分配内存,然后在分配的内存上调用MyClass
的构造函数。当我们使用delete
删除对象时,首先调用MyClass
的析构函数,然后调用重载的operator delete
来释放内存。
需要注意的是,operator new
和operator delete
必须是静态的,因为它们在对象创建和删除时被调用,这时对象可能还不存在或已经不存在了。
9)new实现需要类型转换吗?
在C++中,new
操作符的实现不需要类型转换。当你使用new
操作符创建一个对象时,new
操作符会自动返回正确类型的指针。
例如,如果你使用new int
创建一个整数,new
操作符会返回一个int*
指针。如果你使用new MyClass
创建一个MyClass
对象,new
操作符会返回一个MyClass*
指针。你不需要进行任何类型转换。
以下是一个简单的示例:
int* p1 = new int; // 创建一个整数,返回 int* 指针
class MyClass {};
MyClass* p2 = new MyClass; // 创建一个 MyClass 对象,返回 MyClass* 指针
在这个示例中,new
操作符自动返回了正确类型的指针,我们不需要进行任何类型转换。
然而,如果你在重载operator new
时使用了malloc
函数,你需要进行类型转换,因为malloc
返回的是void*
指针。例如:
class MyClass {
public:
static void* operator new(size_t size) {
void* p = malloc(size); // malloc 返回 void* 指针
return p; // 我们需要返回 void* 指针,new 操作符会自动进行类型转换
}
};
在这个示例中,我们在operator new
中使用了malloc
函数,所以我们需要返回void*
指针。但是,当我们使用new MyClass
创建一个MyClass
对象时,new
操作符会自动将void*
指针转换为MyClass*
指针,我们不需要进行任何类型转换。
10)调用析构函数的时候类型是void类型,指向的是类对象,会正确调用析构函数吗? 怎么调用?使用地址吗?
如果你有一个 void*
指针,指向一个对象,然后尝试通过这个指针调用该对象的析构函数,这是不安全的,也是不正确的。void*
指针是一个通用指针类型,它不知道它所指向的对象的实际类型,因此也就无法知道应该调用哪个析构函数。
在C++中,如果你需要删除一个通过 new
创建的对象,你应该使用正确的类型的指针来调用 delete
,这样就会自动调用正确的析构函数。例如:
class MyClass {
public:
~MyClass() {
// 析构函数的实现
}
};
MyClass* obj = new MyClass();
delete obj; // 这将调用 MyClass 的析构函数
如果你只有一个 void*
指针,你需要先将它转换回正确的类型,然后再调用 delete
:
void* ptr = new MyClass();
delete static_cast<MyClass*>(ptr); // 这将调用 MyClass 的析构函数
ptr
实际上并不指向一个 MyClass
对象,那么这将导致未定义的行为。扩展:如果你确实需要显式调用析构函数,你可以这样做:
class MyClass {
public:
~MyClass() {
// 析构函数的实现
}
};
MyClass* obj = new MyClass();
obj->~MyClass(); // 显式调用析构函数
请注意,这只是调用了析构函数,但并没有释放 obj
指向的内存。如果你这样做,你还需要手动调用 operator delete
来释放内存:
operator delete(obj); // 释放内存
但是,通常更安全、更简单的做法是使用 delete
关键字,这将自动调用析构函数并释放内存:
delete obj; // 调用析构函数并释放内存
11)vector/list/map的底层实现,增删改查复杂度
C++ STL中的vector
、list
和map
的底层实现和操作复杂度如下:
- vector:
vector
是一个动态数组,其底层实现是一个连续的内存块。因此,它支持随机访问,访问元素的时间复杂度是O(1)。添加元素到末尾的时间复杂度也是O(1),但如果内存不足,需要重新分配内存,那么时间复杂度就变成了O(n)。删除末尾元素的时间复杂度是O(1),但删除中间元素的时间复杂度是O(n),因为需要移动元素。 - list:
list
是一个双向链表,其底层实现是一系列的节点,每个节点包含一个元素和两个指向前后节点的指针。因此,它不支持随机访问,访问元素的时间复杂度是O(n)。但添加和删除元素的时间复杂度都是O(1),因为只需要改变指针。 - map:
map
是一个平衡二叉搜索树(通常是红黑树),其底层实现是一系列的节点,每个节点包含一个键值对和两个指向左右子节点的指针。因此,它支持按键排序和快速查找,查找、添加和删除元素的时间复杂度都是O(log n)。
以上是这三种数据结构的基本操作的时间复杂度,但实际的性能可能会受到许多因素的影响,比如内存分配和缓存效果等。
扩展:vector、list和map之间的选择取决于什么因素?
选择vector
、list
或map
取决于你的具体需求和以下几个因素:
- 访问方式:如果你需要频繁地随机访问元素,那么
vector
是最好的选择,因为它支持O(1)时间复杂度的随机访问。而list
和map
的随机访问时间复杂度都是O(n)。 - 插入和删除:如果你需要频繁地在中间位置插入或删除元素,那么
list
是最好的选择,因为它支持O(1)时间复杂度的插入和删除。而vector
的插入和删除时间复杂度是O(n),map
的插入和删除时间复杂度是O(log n)。 - 排序和查找:如果你需要按键排序或快速查找元素,那么
map
是最好的选择,因为它支持O(log n)时间复杂度的查找。而vector
和list
的查找时间复杂度都是O(n)。 - 内存使用:
vector
和array
在内存中是连续的,所以它们对缓存友好,通常有更好的性能。而list
和map
的元素在内存中是分散的,所以它们的内存使用效率较低。 - 数据量:对于大量数据,
vector
可能会因为需要重新分配内存而导致性能问题,而list
和map
则不会有这个问题。
以上是一些基本的考虑因素,但实际的选择可能还会受到其他因素的影响,比如具体的数据类型和操作等。
12)const关键字,const怎么修改?
在C++中,const
关键字的主要作用是声明一个常量,也就是说,一旦被初始化,就不能再改变其值。例如:
const int a = 10;
在这个例子中,a
是一个常量,你不能再改变它的值。
此外,const
关键字还有以下几个作用:
- 修饰函数参数:当函数参数被声明为
const
时,意味着这个参数在函数内部不能被修改。这可以防止函数意外地修改参数的值。
void foo(const int a) {
// a = 10; // 这是错误的,因为a是const的
}
- 修饰函数:当成员函数被声明为
const
时,意味着这个函数不能修改类的任何成员变量(除非这个成员变量是mutable
的)。这可以保证这个函数不会改变对象的状态。
class MyClass {
public:
int a;
mutable int b;
void foo() const {
// a = 10; // 这是错误的,因为foo是const的
b = 20; //这是合法的
}
};
- 修饰指针:当指针被声明为
const
时,可以防止通过这个指针来修改指向的值。例如:
int a = 10;
const int* p = &a;
// *p = 20; // 这是错误的,因为p是一个指向const的指针
在这个例子中,p
是一个指向const int
的指针,你不能通过p
来修改a
的值。然而,有时候我们可能需要修改一个const
变量的值。这时,我们可以使用const_cast
来移除变量的const
属性。例如:
const int a = 10;
int* p = const_cast<int*>(&a);
*p = 20;
在这个例子中,我们首先使用const_cast
将a
的地址转换为一个非const
的指针,然后通过这个指针来修改a
的值。
注意
这种做法是非常危险的,因为它破坏了const的语义,可能会导致未定义的行为。在实际编程中,我们应该尽量避免修改const变量的值。如果你需要修改一个变量的值,那么你应该在声明这个变量时不要使用const关键字
13)进程和线程的同步方法和效率,哪种效率最高,为什么?
进程间同步
进程间同步用于协调多个进程之间的操作,确保它们共享资源或数据的一致性。常用的进程间同步方法包括:
- 管道(Pipe):管道是一种单向数据传输方式,数据从一个进程流向另一个进程。它类似于水管,数据只能从一个方向流动。管道通常用于父子进程之间或具有亲缘关系的进程之间通信。效率一般,因为数据只能单向流动,并且管道的大小有限。
- 消息队列(Message Queue):消息队列是一种存储消息的容器,进程可以向队列中发送消息,其他进程可以从队列中读取消息。消息队列允许非父子进程之间通信,但通信模式是异步的,即发送消息的进程不会立即收到接收进程的响应。效率较高,因为消息队列可以存储大量数据,并且允许多个进程同时读写消息队列。
- 共享内存(Shared Memory):共享内存是一块物理内存区域,多个进程可以同时访问。共享内存提供最快的数据传输速度,但需注意同步和数据一致性问题,因为多个进程可以同时修改共享内存中的数据。效率最高,但需谨慎使用,否则可能导致数据损坏或死锁。
- 信号量(Semaphore):信号量是一种用于控制资源访问的计数器。信号量可以防止多个进程同时访问同一资源,并允许进程间通信。效率中等,因为信号量需要原子操作来保证线程安全。
- 套接字(Socket):套接字用于不同机器上的进程通信。套接字是一种网络通信协议,可以可靠地传输数据。效率取决于网络状况,如果网络延迟高或带宽不足,则套接字的效率会降低。
- 信号(Signal):信号是一种轻量级的通信方式,用于通知进程事件。信号可以用于进程间通信,但语义简单,只传递有限的信息。效率较高,但需注意信号的处理机制。
选择进程间同步方法取决于以下因素:
- 数据复杂度:如果需要传输复杂数据,可能需要更复杂的同步机制,如共享内存或消息队列。
- 进程关系:亲缘进程可以使用简单机制,如管道,非亲缘进程需要更通用机制,如消息队列或套接字。
- 同步需求:如果需要严格的同步控制,可能需要更强的机制,如信号量或互斥锁。如果不需要严格的同步控制,可以使用简单机制,如信号。
- 性能需求:如果对性能有高要求,可能需要更快的机制,如共享内存。如果对性能要求不高,可以使用简单机制,如管道或信号。
- 跨机器通信:如果进程需要在不同的机器上进行通信,则需要支持网络通信的机制,如套接字。
线程间同步
线程间同步用于协调同一进程内多个线程之间的操作,确保它们共享资源或数据的一致性。常用的线程间同步方法包括:
- 互斥锁(Mutex):互斥锁用于保护共享资源,一次只能有一个线程访问。互斥锁是线程同步中最常用的方法,但容易造成锁竞争,即多个线程同时尝试获取同一个锁。效率中等,但易造成锁竞争。
- 读写锁(Read-Write Lock):读写锁允许多个线程同时读,但写时只能有一个线程操作。读写锁适用于读多写少的场景,可以提高读操作的并发性。效率较高,但需注意读写锁的升级和降级。
- 条件变量(Condition Variable):条件变量用于线程等待特定条件满足。条件变量通常与互斥锁一起使用,用于线程等待锁的释放或特定事件的发生。效率中等,但需注意条件变量的唤醒机制。
- 信号量(Semaphore):信号量在多线程环境下与进程间信号量类似,用于控制资源访问。信号量可以防止多个线程同时访问同一资源,并允许线程间通信。效率中等,但需注意信号量的初始化和操作。
- 屏障(Barrier):屏障使一组线程在特定点同步。屏障通常用于需要所有线程都完成特定任务后再继续的场景。效率较低,但可实现很好的同步效果。
- 原子操作(Atomic Operation):原子操作是不可中断的操作,用于保护单个变量的访问。原子操作是效率最高的。
选择线程间同步方法取决于以下因素:
- 锁竞争程度:高竞争需要更有效的机制,如自旋锁或无锁机制。
- 锁保护范围:尽量减小锁的保护范围,提高效率。
- 锁类型:根据竞争情况选择合适的锁类型,如自旋锁、互斥锁等。
- 等待策略:忙等待消耗CPU,睡眠等待延迟较高,需权衡选择。
总体而言,原子操作是效率最高的同步方法,因为它避免了锁竞争和上下文切换的开销。但是,原子操作仅适用于保护单个变量,对于更复杂的数据结构或同步需求,其他方法可能更合适。
以下是一些提高同步效率的建议:
- 尽量减少锁的使用范围和时间:只在必要时加锁,并尽快解锁。
- 使用无锁机制:当锁竞争激烈时,可以考虑使用无锁机制,如CAS(Compare And Swap)操作。
- 选择合适的锁类型:根据竞争情况选择合适的锁类型,如自旋锁、互斥锁等。
- 合理设计同步算法:避免不必要的等待和唤醒。
扩展:避免同步时的死锁问题
避免线程间同步时的死锁问题,可以遵循以下几个策略:
- 避免互斥条件:尽可能减少需要互斥访问的资源。如果资源可以共享,那么就没有必要使用互斥锁。
- 避免占有并等待:一个线程如果已经占有了一些资源,那么它在申请新的资源时,应该先释放已经占有的资源。这样可以避免一个线程在占有一些资源的同时,还在等待其他资源。
- 避免不可抢占:如果一个线程已经占有了一些资源,那么在其他线程需要这些资源时,应该允许这些资源被抢占。
- 避免循环等待:为所有的资源指定一个顺序,所有的线程都按照这个顺序申请资源。这样可以避免循环等待的情况。
在实际的编程中,可能无法完全避免互斥和不可抢占的条件,因此,最常用的策略是避免占有并等待和循环等待。例如,可以使用锁排序(lock ordering)或者锁层次(lock hierarchy)的方法来避免循环等待。
14)算法题:求链表的第K个节点,写测试用例测试
要获取链表的第K个节点,我们可以使用一个简单的迭代方法。首先,我们创建一个指针,让它指向链表的头部。然后,我们将这个指针向前移动K-1次。如果我们能够这样做,那么这个指针现在就指向第K个节点。如果链表的长度小于K,那么在尝试移动指针时,我们将达到链表的尾部,此时我们应该返回一个错误或者null。
以下是这个思路的C++实现:
struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next(NULL) {}
};
ListNode* findKthNode(ListNode* head, int k) {
ListNode* node = head;
for (int i = 0; i < k - 1; ++i) {
if (node == NULL) {
return NULL; // 链表长度小于k
}
node = node->next;
}
return node;
}
然后,我们可以编写一些测试用例来测试这个函数:
int main() {
// 创建一个链表:1 -> 2 -> 3 -> 4 -> 5
ListNode* head = new ListNode(1);
head->next = new ListNode(2);
head->next->next = new ListNode(3);
head->next->next->next = new ListNode(4);
head->next->next->next->next = new ListNode(5);
// 测试用例1:获取第3个节点
ListNode* node = findKthNode(head, 3);
if (node != NULL) {
cout << "The 3rd node is: " << node->val << endl; // 应该输出:3
} else {
cout << "The 3rd node does not exist." << endl;
}
// 测试用例2:获取第6个节点
node = findKthNode(head, 6);
if (node != NULL) {
cout << "The 6th node is: " << node->val << endl;
} else {
cout << "The 6th node does not exist." << endl; // 应该输出:不存在
}
return 0;
}
扩展:如何找出链表的“倒数”第K个节点?
在链表中查找“**倒数”**第K个节点的问题,我们可以使用两个指针,这种方法也被称为“快慢指针”或者“双指针”方法。这种方法的优点是只需要遍历一次链表。
具体的步骤如下:
- 创建两个指针,都指向链表的头部。
- 将第一个指针向前移动K-1步。
- 然后,同时移动两个指针,直到第一个指针到达链表的尾部。
- 此时,第二个指针指向的就是倒数第K个节点。
ListNode* findKthNode(ListNode* head, int k) {
if(k<=0) return NULL;
ListNode* fast = head;
ListNode* slow = head;
for (int i = 0; i < k; ++i) {
if (fast == NULL) {
return NULL; // 链表长度小于k
}
fast = fast->next;
}
while (fast != NULL) {
fast = fast->next;
slow = slow->next;
}
return slow;
}
15)算法题:数组的TOPK,给一个数组,求数组的前K个小的数字并返回
这是一个经典的问题,可以使用优先队列(堆)来解决。我们可以创建一个大顶堆,然后遍历数组,将每个元素加入堆中。如果堆的大小超过了K,我们就从堆中删除最大的元素。这样,当我们遍历完数组后,堆中就包含了前K个最小的元素。
以下是解决这个问题的C++代码:
#include <vector>
#include <queue>
std::vector<int> topK(std::vector<int>& nums, int k) {
std::priority_queue<int> pq;
for (int num : nums) {
pq.push(num);
if (pq.size() > k) {
pq.pop();
}
}
std::vector<int> res;
while (!pq.empty()) {
res.push_back(pq.top());
pq.pop();
}
std::reverse(res.begin(), res.end());
return res;
}
这个函数接受一个整数数组和一个整数K作为输入,返回一个包含数组中前K个最小元素的向量。注意,返回的结果可能不是按照原始数组中的顺序。
扩展:TOPK,给一个数组,求数组的前第K小的数字并返回
这个问题可以使用快速选择算法来解决。快速选择是快速排序的一种改进,它只处理了数据中的一部分,但可以确定数组中第k小的元素。
以下是解决这个问题的C++代码:
#include <vector>
#include <algorithm>
int findKthSmallest(std::vector<int>& nums, int k) {
int left = 0, right = nums.size() - 1;
while (true) {
int pos = partition(nums, left, right);
if (pos == k - 1) {
return nums[pos];
}
if (pos > k - 1) {
right = pos - 1;
} else {
left = pos + 1;
}
}
}
int partition(std::vector<int>& nums, int left, int right) {
int pivot = nums[left], l = left + 1, r = right;
while (l <= r) {
if (nums[l] > pivot && nums[r] < pivot) {
std::swap(nums[l], nums[r]);
}
if (nums[l] <= pivot) {
l++;
}
if (nums[r] >= pivot) {
r--;
}
}
std::swap(nums[left], nums[r]);
return r;
}
这个函数接受一个整数数组和一个整数K作为输入,返回数组中第K小的元素。注意,这个函数会改变输入数组的顺序。
快速排序实现
#include <vector>
#include <algorithm>
void quickSort(std::vector<int>& nums, int left, int right) {
if (left < right) {
int pivot = partition(nums, left, right);
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
}
int partition(std::vector<int>& nums, int left, int right) {
int pivot = nums[right];
int i = left;
for (int j = left; j < right; j++) {
if (nums[j] < pivot) {
std::swap(nums[i], nums[j]);
i++;
}
}
std::swap(nums[i], nums[right]);
return i;
}
这个函数接受一个整数数组和两个整数作为输入,表示要排序的数组的范围。它会在原地对数组进行排序,不会返回任何值。注意,这个函数会改变输入数组的顺序。
对比
快速选择和快速排序的 partition
实现可以是一样的。实际上,快速选择算法是基于快速排序的 partition
过程的。在这两个算法中,partition
的目标都是将数组分为两部分,一部分的元素都比基准元素小,另一部分的元素都比基准元素大。
不过,这两个算法在 partition
之后的处理是不同的:
- 在快速排序中,我们会对
partition
的两边都进行递归排序。 - 在快速选择中,我们只对包含目标元素的那一边进行递归选择。
因此,虽然这两个算法的 partition
实现可以是一样的,但是他们在 partition
之后的处理是不同的。
快速排序算法的平均时间复杂度是O(n log n),最坏情况下的时间复杂度是O(n^2)。这是因为快速排序每次都要处理数组的两部分,每部分的大小大约是原数组的一半,所以时间复杂度是O(n log n)。最坏情况下,如果每次选择的基准元素都是最大或最小的元素,那么每次只能将数组分为两部分,一部分有n-1个元素,另一部分有0个元素,这样的话时间复杂度就是O(n^2)。
快速选择算法的平均时间复杂度是O(n),最坏情况下的时间复杂度也是O(n2)。这是因为快速选择每次只需要处理数组的一部分,平均情况下,每次处理的数组大小都是原数组的一半,所以平均时间复杂度是O(n)。最坏情况下,如果每次选择的基准元素都是最大或最小的元素,那么每次只能将数组分为两部分,一部分有n-1个元素,另一部分有0个元素,这样的话时间复杂度就是O(n2)。
所以,快速选择算法在平均情况下比快速排序算法更快,但在最坏情况下,两者的时间复杂度都是O(n^2)。
16)反问环节
一般可以问些岗位、团队相关的,如:面试的岗位会大概负责什么方向?团队在公司的定位及未来的方向?这也是要求职者本质需要关注的问题,团队的问负责人会更好。
忌讳问面试感受类的,这个是后面HR通知你的;