请阐述面向对象的三大特性。
面向对象编程有三大特性,分别是封装、继承和多态。
封装是指将数据和操作数据的方法绑定在一起,对数据的访问和操作进行限制。这样做的好处是可以隐藏对象的内部细节,只暴露必要的接口给外部。例如,我们可以把一个汽车类的内部引擎状态、速度等属性封装起来,外部通过一些规定的方法如启动汽车、加速汽车等来间接访问和改变这些内部状态。通过封装,可以增强数据的安全性,防止外部随意修改数据导致系统出现不可预期的错误。同时,它也使得代码的维护更加容易,因为对象的内部实现可以独立地修改而不影响使用这个对象的其他部分代码。
继承是一种创建新类的方式,新类(子类)可以从已有的类(父类)那里继承属性和方法。这就像是子女继承父母的某些特征一样。例如,我们有一个交通工具类作为父类,它有移动的方法。然后我们可以创建汽车类和自行车类作为子类,它们都继承了交通工具类的移动方法,同时汽车类可以有自己特有的如燃烧汽油来移动的方法,自行车类可以有脚蹬来移动的方法。继承可以提高代码的复用性,减少代码的冗余。通过继承,我们可以构建出层次分明的类体系,更好地描述现实世界中的事物关系。
多态是指同一种操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。多态分为编译时多态和运行时多态。编译时多态主要通过函数重载来实现,例如我们可以有多个同名函数,但是它们的参数列表不同,编译器会根据参数的类型和数量来决定调用哪一个函数。运行时多态主要是通过虚函数来实现。比如,我们有一个图形类作为基类,它有一个绘制函数是虚函数,然后有圆形类和矩形类继承自这个图形类。当我们通过基类指针或者引用调用绘制函数时,实际调用的是派生类中重写后的绘制函数,这样就实现了根据对象的实际类型来执行相应的操作,增加了代码的灵活性和可扩展性。
如果只有子类想访问父类,该如何处理?
在 C++ 中,如果只有子类想访问父类的成员(如成员变量和成员函数),可以通过几种方式来实现。
首先是使用保护(protected)访问修饰符。在父类中,将希望被子类访问的成员声明为 protected。和 private 成员不同,private 成员只能在类的内部访问,而 protected 成员可以在类的内部以及它的子类中访问。例如,有一个基类叫做 “动物(Animal)”,它有一个 protected 成员变量 “年龄(age)” 和一个 protected 成员函数 “获取年龄(getAge)”。当我们创建一个子类 “狗(Dog)” 时,在 “狗” 类的成员函数中就可以访问 “动物” 类的 “年龄” 变量和 “获取年龄” 函数。这样就实现了子类对父类特定成员的访问。
另外,还可以通过友元(friend)关系来实现。不过这种方式比较特殊,友元机制打破了类的封装性。如果我们在父类中将子类声明为友元类,那么子类就可以访问父类的私有(private)和保护(protected)成员。但是这种方式需要谨慎使用,因为它可能会导致代码的维护性变差,并且违背了面向对象编程中封装的原则。例如,我们定义一个父类 “形状(Shape)” 和一个子类 “圆形(Circle)”,在 “形状” 类中通过 friend 关键字声明 “圆形” 类为友元类,那么 “圆形” 类中的函数就可以访问 “形状” 类中的私有和保护成员。不过在实际的编程中,应该尽量避免过度使用友元关系,除非有非常特殊的需求,比如需要在一个类中对另一个类的内部成员进行非常紧密的操作并且这种操作不能通过其他常规方式实现。
指针和引用的区别是什么?
指针和引用是 C++ 中两个比较重要的概念,它们有很多区别。
首先,从定义和语法上来说,指针是一个变量,它存储的是另一个变量的地址。例如,我们定义一个整型指针int *p;
,这里的p
就是一个指针变量,它可以用来存储一个整型变量的地址。而引用是一个别名,它必须在定义的时候初始化,并且在之后不能再绑定到其他变量上。例如,int a = 10; int &r = a;
,这里的r
就是a
的引用,它相当于a
的另一个名字,对r
的操作就是对a
的操作。
在内存分配方面,指针本身需要占用一定的内存空间来存储它所指向变量的地址。在 32 位系统中,指针变量通常占用 4 个字节的内存,在 64 位系统中通常占用 8 个字节的内存。而引用只是一个别名,它本身不占用额外的内存空间,它和它所引用的变量共享同一块内存。
在使用过程中,指针可以被重新赋值,指向不同的变量。例如,int a = 10, b = 20; int *p = &a; p = &b;
,这里先让p
指向a
,然后又让p
指向b
。但是引用一旦初始化绑定到一个变量后,就不能再绑定到其他变量了。另外,指针可以为空(nullptr),当一个指针为空时,它不指向任何有效的内存地址。而引用必须始终绑定到一个有效的对象,不能引用一个不存在的对象,否则程序会出现错误。
在作为函数参数方面,指针和引用都可以用来在函数之间传递变量。使用指针作为参数时,在函数内部可以通过解引用指针来修改它所指向的变量的值。例如,void changeValue(int *p) { *p = 20; }
,调用这个函数并传入一个指向整型变量的指针,就可以修改这个变量的值。使用引用作为函数参数时,函数可以直接修改引用所绑定变量的值,因为引用就是变量的别名。例如,void anotherChange(int &r) { r = 30; }
,调用这个函数并传入一个整型变量的引用,就可以修改这个变量的值。不过,使用引用作为参数在语法上看起来更简洁,并且对于使用者来说,更像是直接对原始变量进行操作。
在安全性方面,指针相对来说更容易出现错误。因为指针可以为空,并且可以进行一些危险的操作,如野指针(指向已经释放的内存或者未初始化的内存)和悬空指针(指针所指向的对象已经被销毁,但是指针还存在)的情况。而引用在一定程度上更加安全,因为它始终绑定到一个有效的对象,只要这个对象的生命周期是正确管理的,引用就不会出现像指针那样的悬空等问题。
请介绍智能指针,重点讲一下 weak_ptr 的实现原理。
智能指针是 C++ 中用于管理动态分配内存的一种机制,它可以帮助我们自动地释放内存,避免内存泄漏等问题。
智能指针主要有三种类型,分别是std::unique_ptr
、std::shared_ptr
和std::weak_ptr
。std::unique_ptr
拥有它所指向的对象的唯一所有权,当unique_ptr
离开作用域或者被重置时,它所指向的对象会被自动销毁。例如,std::unique_ptr<int> p(new int(10));
,这里p
是一个unique_ptr
,它指向一个动态分配的整型对象,当p
的生命周期结束时,这个整型对象会被自动删除。std::shared_ptr
采用引用计数的方式来管理对象的所有权。多个shared_ptr
可以指向同一个对象,当最后一个指向该对象的shared_ptr
离开作用域或者被重置时,对象才会被销毁。例如,std::shared_ptr<int> p1(new int(10)); std::shared_ptr<int> p2 = p1;
,这里p1
和p2
都指向同一个整型对象,它们的引用计数为 2,当p1
和p2
都不再使用这个对象(比如它们离开作用域)时,对象才会被删除。
std::weak_ptr
是一种不控制对象生命周期的智能指针,它主要用于解决shared_ptr
所带来的循环引用问题。weak_ptr
必须从一个shared_ptr
或者另一个weak_ptr
构造而来。它的实现原理主要基于以下几点。
weak_ptr
内部有一个指向控制块(control block)的指针,这个控制块和shared_ptr
所共享。控制块中包含了引用计数(强引用计数和弱引用计数)等信息。强引用计数用于记录有多少个shared_ptr
指向这个对象,而弱引用计数用于记录有多少个weak_ptr
指向这个对象。当一个shared_ptr
被创建并指向一个对象时,强引用计数加 1;当一个weak_ptr
被创建并指向这个对象时,弱引用计数加 1。
当我们通过weak_ptr
来访问它所指向的对象时,它会首先检查所指向对象的强引用计数是否为 0。如果强引用计数为 0,说明对象已经被销毁,那么weak_ptr
的lock
函数会返回一个空的shared_ptr
。如果强引用计数不为 0,weak_ptr
的lock
函数会返回一个指向该对象的shared_ptr
,这个shared_ptr
的创建会使得强引用计数再加 1。通过这种方式,weak_ptr
可以安全地访问它所指向的对象,同时又不会影响对象的生命周期。
例如,我们有两个类A
和B
,它们之间有相互引用的关系。如果都使用shared_ptr
来实现这种引用,就会出现循环引用的问题,导致对象无法被正常销毁。而如果我们在其中一个类的引用成员中使用weak_ptr
,就可以打破这种循环引用。当外部没有shared_ptr
指向这两个类的对象时,对象就可以正常地根据强引用计数被销毁,而weak_ptr
只是在需要的时候通过lock
函数来检查对象是否还存在并且获取一个有效的shared_ptr
来访问对象。
常见的 STL 容器有哪些?
C++ 的标准模板库(STL)提供了许多容器,这些容器可以帮助我们高效地存储和管理数据。
首先是顺序容器,其中最常见的是vector
。vector
是一种动态数组,它可以在运行时动态地改变大小。它在内存中是连续存储的,这使得它可以通过索引快速地访问元素,就像普通数组一样。例如,我们可以定义一个vector
来存储整数:std::vector<int> v; v.push_back(1); v.push_back(2);
,这里我们先创建了一个空的vector
,然后通过push_back
函数向其中添加了两个整数。我们可以通过v[0]
和v[1]
来访问这两个元素。vector
的优点是访问速度快,并且在尾部插入和删除元素比较高效。但是在中间或者头部插入和删除元素可能会比较慢,因为这可能需要移动大量的元素来保持内存的连续存储。
另一个顺序容器是list
。list
是一个双向链表,它的每个节点包含数据和指向前一个节点和后一个节点的指针。与vector
不同,list
在任意位置插入和删除元素都比较高效,因为它只需要修改节点之间的指针关系,不需要移动大量的元素。但是list
的缺点是不能像vector
那样通过索引快速地访问元素,访问元素需要遍历链表。例如,我们可以定义一个list
来存储字符串:std::list<std::string> l; l.push_back("hello"); l.push_back("world");
,如果我们要访问l
中的元素,就不能像vector
那样直接通过索引,而是需要使用迭代器或者遍历整个链表。
还有deque
(双端队列),它是一种双端开口的连续线性空间,可以在两端高效地插入和删除元素,同时也可以通过索引访问元素,不过它的内部实现比vector
更复杂。例如,std::deque<int> d; d.push_back(1); d.push_front(2);
,我们可以在deque
的后端和前端分别插入元素。
关联容器也是 STL 中的重要组成部分。map
是一种关联容器,它存储的是键 - 值对,并且根据键来进行排序。通过键可以快速地查找对应的的值。例如,std::map<std::string, int> m; m["apple"] = 1; m["banana"] = 2;
,我们可以通过键(如 "apple" 和 "banana")来访问对应的整数值。map
的内部实现通常是红黑树,这使得它的插入、删除和查找操作的时间复杂度在平均情况下都是对数时间。
set
也是关联容器,它存储的是一组唯一的元素,并且会对元素进行排序。例如,std::set<int> s; s.insert(1); s.insert(2);
,它会自动对插入的元素进行排序,并且保证元素的唯一性。如果我们尝试插入一个已经存在的元素,set
会忽略这个插入操作。set
的内部实现同样通常是红黑树,它的查找、插入和删除操作的时间复杂度在平均情况下也是对数时间。
另外,还有unordered_map
和unordered_set
,它们和map
、set
类似,但是它们的内部实现是基于哈希表。这使得它们在平均情况下,插入、删除和查找操作的时间复杂度接近常数时间,但是它们不会对元素进行排序。例如,std::unordered_map<std::string, int> um; um["cat"] = 3; um["dog"] = 4;
和std::unordered_set<int> us; us.insert(3); us.insert(4);
。这些容器在不同的场景下有不同的优势,可以根据具体的需求来选择合适的容器来存储和管理数据。
vector 和 list 的主要区别是什么?
vector 和 list 在多个方面存在区别。
从存储方式来讲,vector 是动态数组,其元素在内存中是连续存储的。就好像在一条连续的直线上排列元素,每个元素紧密相连。例如,当我们创建一个 vector 并添加元素时,这些元素会依次存放在相邻的内存位置。这种连续存储的方式使得通过索引访问元素非常高效,因为可以直接根据元素的偏移量来计算其在内存中的位置,就像使用普通数组一样。比如对于一个 vector<int> v,访问 v [3] 可以很快地定位到第三个元素。
而 list 是双向链表,它的每个节点包含数据以及指向前一个节点和后一个节点的指针。这意味着元素在内存中的位置不是连续的,而是通过指针链接起来的。以存储字符串为例,当创建一个 std::liststd::string,每个节点存储一个字符串,节点之间通过指针关联,使得在内存中的布局比较分散。
在插入和删除操作方面,vector 在尾部插入和删除元素相对高效。这是因为当在尾部插入元素时,通常只需要在已有内存的末尾添加新元素即可;在删除尾部元素时,也只是简单地减少元素个数。但是,如果要在 vector 的中间或者头部插入或删除元素,就会比较复杂,因为这可能需要移动大量的元素来保持内存的连续性。例如,在一个有很多元素的 vector 中间插入一个元素,那么这个插入位置之后的所有元素都要向后移动一位。
list 则在任意位置插入和删除元素都比较高效。因为它只需要修改节点之间的指针关系,不需要移动大量的元素。例如,在一个 list 中插入一个新节点,只需要调整插入位置前后节点的指针,使其指向新节点即可。不过,由于 list 的元素不是连续存储的,所以访问元素的效率较低。如果要访问 list 中的某个元素,通常需要从链表的头部或者尾部开始遍历,直到找到目标元素。
在空间利用方面,vector 可能会存在一定的空间浪费情况。因为 vector 为了支持动态增长,可能会分配比实际需要更多的内存。而 list 每个节点除了存储数据还需要存储指针,相对来说,存储相同数量的元素,list 占用的空间会比 vector 多一些,但是它没有像 vector 那样可能的额外内存预留导致的空间浪费。
请描述数组和哈希表这两种数据结构的特点。
数组是一种简单而基础的数据结构。
数组的元素在内存中是连续存储的,这使得它具有非常高效的随机访问特性。例如,对于一个整数数组 int arr [5],我们可以通过索引直接访问任意一个元素,如 arr [3] 就能很快地获取到数组中的第四个元素(索引从 0 开始)。这是因为计算机可以通过数组的首地址加上索引与元素大小的乘积来快速计算出元素的存储位置。
数组的大小通常是固定的,在创建数组时就需要指定其大小。例如,在 C++ 中定义一个数组 int myArray [10],这个数组的大小就是 10,不能动态地增加或减少。不过,在一些语言中也有支持动态大小的数组实现,但其本质上可能是对固定大小数组的封装或者采用了其他的数据结构来模拟动态特性。
数组在插入和删除元素方面存在一定的局限性。如果要在数组中间插入一个元素,那么需要将插入位置之后的所有元素都向后移动一位,这在元素较多的情况下是比较耗时的操作。同样,删除中间的一个元素也需要将后面的元素向前移动来填补空缺。不过,在数组的尾部插入和删除元素相对比较简单,效率也比较高。
哈希表是一种用于存储键 - 值对的数据结构,它的主要特点是能够快速地查找、插入和删除元素。
哈希表通过一个哈希函数来计算键对应的存储位置。哈希函数将键映射到一个固定大小的数组(桶数组)中的某个位置。例如,对于一个简单的哈希函数,它可能会根据键的某种计算(如取键的模运算)来确定元素在桶数组中的位置。当有一个键 - 值对要插入哈希表时,先通过哈希函数计算位置,然后将值存储在对应的位置。
哈希表的查找速度非常快。当需要查找一个键对应的元素时,只需要通过哈希函数计算出位置,然后直接在该位置查找即可。在理想情况下,查找、插入和删除操作的时间复杂度可以接近常数时间 O (1)。但实际情况中,可能会出现哈希冲突,即不同的键通过哈希函数计算得到相同的位置。为了解决哈希冲突,哈希表通常采用链地址法或者开放地址法等策略。
链地址法是指当发生哈希冲突时,将冲突的元素存储在同一个桶中的链表中。这样,在查找一个元素时,可能需要遍历桶中的链表。开放地址法是指当发生冲突时,通过一定的规则寻找下一个可用的存储位置。哈希表的空间利用率取决于哈希函数的质量和处理冲突的方法,在设计良好的情况下,可以高效地利用空间,但如果哈希冲突过多,可能会导致空间浪费或者性能下降。
三次握手四次挥手的过程是怎样的?
三次握手是 TCP 协议中建立连接的过程。
首先是客户端发起第一次握手,客户端会向服务器发送一个带有 SYN(同步序列号)标志的 TCP 报文段。这个报文段中包含了客户端随机生成的初始序列号(ISN,Initial Sequence Number)。这一步就像是客户端在向服务器打招呼说 “我想和你建立连接”。
接着是第二次握手,服务器收到客户端的 SYN 报文段后,会向客户端发送一个 SYN/ACK 报文段。这个报文段中,SYN 标志表示服务器也同意建立连接,ACK 标志用于确认收到客户端的 SYN 报文段。同时,服务器也会生成自己的初始序列号。这就好比服务器回应客户端 “我收到你的请求了,我也愿意和你建立连接”。
最后是第三次握手,客户端收到服务器的 SYN/ACK 报文段后,会向服务器发送一个 ACK 报文段。这个 ACK 报文段用于确认收到服务器的 SYN/ACK 报文段,此时,连接就成功建立了。可以理解为客户端对服务器说 “我也收到你的回应了,我们现在可以开始通信啦”。
四次挥手是 TCP 协议中关闭连接的过程。
首先由主动关闭方(通常是客户端)发送一个 FIN(结束标志)报文段,表示自己没有数据要发送了,希望关闭连接。这就像是主动关闭方在说 “我要结束通信啦”。
然后被动关闭方收到 FIN 报文段后,会发送一个 ACK 报文段,表示已经收到了主动关闭方的 FIN 报文段。不过,此时被动关闭方可能还有数据没发送完,所以暂时不能关闭连接。这相当于被动关闭方回应 “我知道你要关闭了,不过我还没说完呢”。
当被动关闭方也没有数据要发送了,它会发送一个 FIN 报文段给主动关闭方,表示自己也准备好关闭连接了。这就如同被动关闭方说 “我也说完啦,我们可以关闭连接了”。
最后,主动关闭方收到 FIN 报文段后,会发送一个 ACK 报文段,确认收到被动关闭方的 FIN 报文段,然后经过一段时间(2MSL,Maximum Segment Lifetime)后,连接彻底关闭。这一步可以理解为主动关闭方最后确认 “好的,我们的连接正式关闭啦”。
请解释 epoll 的工作机制及其应用场景。
epoll 是 Linux 中的一种 I/O 复用机制,它主要用于高效地处理大量的文件描述符(比如网络套接字)的 I/O 事件。
epoll 的工作机制涉及几个关键部分。首先是创建一个 epoll 实例,这就像是创建了一个事件管理器。通过系统调用 epoll_create 来创建,它会返回一个 epoll 句柄。
然后是向 epoll 实例中添加文件描述符以及关注的事件类型,这通过 epoll_ctl 函数来完成。可以添加的事件类型主要包括可读事件(EPOLLIN)、可写事件(EPOLLOUT)等。例如,当有一个网络套接字需要关注它是否有数据可读或者可写时,就把这个套接字的文件描述符添加到 epoll 实例中,并指定关注的事件类型。
当有事件发生时,epoll 会通知应用程序。这是通过 epoll_wait 函数来实现的。当调用 epoll_wait 时,它会阻塞等待,直到有之前添加的文件描述符上的事件发生。一旦有事件发生,epoll_wait 会返回发生事件的文件描述符列表以及对应的事件类型。这样,应用程序就可以根据返回的信息来处理相应的 I/O 事件。
epoll 的高效之处在于它采用了事件驱动的机制。与传统的 select 和 poll 相比,epoll 不需要每次都遍历所有的文件描述符来检查事件是否发生。它在内核中维护了一个红黑树来管理添加的文件描述符,并且有一个就绪链表来存放已经发生事件的文件描述符。当有事件发生时,内核会直接将发生事件的文件描述符放入就绪链表,这样 epoll_wait 只需要返回这个链表中的文件描述符即可,大大减少了不必要的遍历操作。
epoll 的应用场景主要是在网络服务器等需要处理大量并发连接的程序中。比如,在一个 Web 服务器中,需要同时处理多个客户端的连接请求。使用 epoll 可以高效地监听多个套接字的可读可写事件。当有客户端发送请求数据时,服务器可以通过 epoll 快速地得知哪个套接字有数据可读,然后进行相应的处理,如读取请求数据、处理请求并返回响应。同时,在处理高并发的长连接场景下,epoll 也能够很好地发挥作用,它可以及时地发现哪些连接有数据可写,从而高效地发送响应数据,避免了因大量的 I/O 检查而导致的性能瓶颈。
C++ 中的多进程的通信方式有哪些?
在 C++ 中,多进程通信有多种方式。
管道是一种简单的通信方式。无名管道主要用于具有亲缘关系的进程之间通信,比如父子进程。它是一个半双工的通信通道,数据只能单向流动。例如,父进程可以创建一个管道,然后通过 fork 函数创建子进程,子进程会继承父进程的文件描述符,这样父子进程就可以通过管道来传输数据。数据在管道中的传输是基于字节流的,发送端将数据写入管道,接收端从管道中读取数据。不过无名管道只能在创建它的进程及其子孙进程之间使用,并且通信范围比较有限。
有名管道(FIFO)则不同,它可以在不相关的进程之间通信。有名管道在文件系统中有一个名字,其他进程可以通过这个名字来访问管道。就像一个公共的通信通道,只要知道管道的名字,不同的进程就可以通过它来交换数据。有名管道的使用过程包括先创建管道文件,然后打开管道文件进行读写操作,多个进程可以通过打开同一个有名管道文件来实现通信。
共享内存是一种高效的通信方式。多个进程可以共享同一块内存区域,这样进程之间可以直接读写这块内存中的数据,而不需要经过内核的多次复制。例如,通过 shmget 函数可以创建一块共享内存区域,然后使用 shmat 函数将共享内存映射到进程的地址空间中。不同的进程可以通过操作这块共享内存来交换信息。不过,由于多个进程可以同时访问共享内存,所以需要通过同步机制,如信号量等来保证数据的一致性和完整性。
消息队列也是一种通信方式。它是一个由消息组成的链表,消息队列可以在不同的进程之间传递消息。每个消息都有一个特定的类型,接收进程可以根据消息类型有选择地接收消息。消息队列在内核中维护,发送进程将消息发送到消息队列中,接收进程从消息队列中读取消息。通过这种方式,进程之间的通信更加灵活,而且消息队列可以缓冲消息,使得发送和接收的速度不需要完全匹配。
信号也是一种进程间通信的方式,不过它传递的信息比较简单。信号是一种异步事件通知机制,一个进程可以向另一个进程发送信号,接收信号的进程在收到信号后会采取相应的动作,如终止进程、暂停进程或者忽略信号等。例如,当一个子进程异常终止时,内核会向父进程发送 SIGCHLD 信号,父进程可以捕获这个信号并进行相应的处理,如回收子进程的资源。
C++ 中的多进程和多线程的区别是什么?
在 C++ 中,多进程和多线程有着诸多区别。
从资源分配角度来看,多进程是操作系统分配资源的基本单位。每个进程都有自己独立的地址空间,包括代码段、数据段、堆和栈等。这意味着一个进程无法直接访问另一个进程的内存空间,进程之间的数据是隔离的。例如,在两个不同的进程中分别定义了一个变量,它们在内存中的存储是完全独立的,一个进程对自己变量的修改不会影响另一个进程中的同名变量。
而多线程是在同一个进程内部的执行单元,所有线程共享进程的地址空间。线程之间可以直接访问进程中的全局变量和堆内存等。比如,在一个包含多个线程的进程中,所有线程都可以访问进程中的同一个全局数组,这使得线程之间的数据共享更加容易,但同时也可能带来数据同步的问题。
在创建和销毁的开销方面,进程的创建和销毁开销较大。创建一个进程需要操作系统为其分配独立的资源,包括内存空间、文件描述符等,并且要初始化进程控制块等信息。销毁进程时,也需要释放这些资源。而线程的创建和销毁相对来说开销较小,因为线程是在已有进程的基础上创建的,只需要分配一些线程相关的资源,如线程栈等,并且共享进程的大部分资源,所以创建和销毁线程的速度通常比进程快。
在切换成本上,进程切换的成本较高。由于进程有独立的地址空间,当进行进程切换时,需要保存和恢复大量的上下文信息,包括内存映射、寄存器值等。而线程切换的成本相对较低,因为线程共享进程的地址空间,在切换时只需要保存和恢复少量的线程相关的寄存器和栈信息。
从稳定性和独立性角度看,一个进程的崩溃通常不会影响其他进程,因为每个进程有自己独立的资源。但是一个线程的崩溃可能会导致整个进程崩溃,因为线程共享进程的资源和地址空间,一个线程出现严重错误可能会破坏进程中的数据或者资源,从而影响其他线程。
在并发性能方面,多线程在多核 CPU 上可以充分利用多核的优势,实现真正的并行执行。在单核 CPU 上,多线程通过时间片轮转等方式实现并发执行。而多进程同样可以在多核 CPU 上并行执行,并且由于进程之间独立性强,在处理一些复杂的、相互独立的任务时可能更具优势。
手撕快速排序
快速排序是面试时考察最多的排序算法。快速排序(Quick Sort)是一种常用的排序算法,采用分治法(Divide and Conquer)进行排序。其基本思路是通过选择一个基准元素(pivot),将待排序的数组分成两部分,一部分所有元素都小于基准元素,另一部分所有元素都大于基准元素。然后递归地对这两部分继续进行排序,最终达到排序整个数组的效果。
快速排序的步骤:
- 选择基准元素:选择数组中的一个元素作为基准元素(常见的选择有第一个元素、最后一个元素、随机选择等)。
- 分区操作:将数组分成两部分,小于基准的放左边,大于基准的放右边。基准元素最终的位置已经确定。
- 递归排序:对基准元素左侧和右侧的子数组进行递归调用快速排序,直到子数组的大小为1或0,排序完成。
时间复杂度:
- 最佳情况:
O(n log n)
,发生在每次分割时都能平衡地分成两部分。 - 最坏情况:
O(n^2)
,当数组已经有序或反向有序时,每次选择的基准元素都可能是最小或最大的元素,从而导致不均匀的分割。 - 平均情况:
O(n log n)
,在大多数情况下,快速排序的时间复杂度表现良好。
空间复杂度:
- 快速排序是原地排序,只需要
O(log n)
的栈空间来存储递归调用的状态。 - 空间复杂度主要取决于递归的深度,最坏情况下是
O(n)
,但平均情况下是O(log n)
。
快速排序的C++实现代码:
#include <iostream>
using namespace std;// 交换数组中两个元素的位置
void swap(int arr[], int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;
}// 分区操作,返回基准元素的最终位置
int partition(int arr[], int left, int right) {// 选择最右边的元素作为基准元素int pivot = arr[right];int i = left - 1; // i 指向比基准小的元素区域的最后一个元素for (int j = left; j < right; j++) {if (arr[j] < pivot) {// 交换 arr[i + 1] 和 arr[j]i++;swap(arr, i, j);}}// 将基准元素放到正确位置swap(arr, i + 1, right);return i + 1; // 返回基准元素的索引
}// 快速排序的核心递归函数
void quickSortHelper(int arr[], int left, int right) {if (left < right) {// 分区操作,返回基准元素的正确位置int pivotIndex = partition(arr, left, right);// 递归对基准元素左侧和右侧的子数组排序quickSortHelper(arr, left, pivotIndex - 1);quickSortHelper(arr, pivotIndex + 1, right);}
}// 主函数:调用快速排序
void quickSort(int arr[], int n) {if (arr == nullptr || n < 2) {return;}quickSortHelper(arr, 0, n - 1);
}// 打印数组的辅助函数
void printArray(int arr[], int n) {for (int i = 0; i < n; i++) {cout << arr[i] << " ";}cout << endl;
}// 主函数入口,打印排序后的结果
int main() {int arr[] = {5, 3, 8, 4, 6, 3, 2};int n = sizeof(arr) / sizeof(arr[0]); // 计算数组的长度cout << "Original array: " << endl;printArray(arr, n);quickSort(arr, n);cout << "Sorted array: " << endl;printArray(arr, n);return 0;
}
求数组的全排列
思路:
要获取一个数组的全排列,我们可以利用回溯算法。具体来说,回溯算法通过递归的方式逐步生成排列,在每一步都将一个元素加入排列中,然后在下一步递归中排除已选元素,回溯的时候撤销选择,尝试其他可能。
步骤:
-
递归生成排列:
- 使用一个辅助数组来记录当前的排列。
- 对于每个位置,我们尝试填充每一个可能的元素,并递归地填充后续的位置。
- 使用回溯的方式,在完成一个排列后,撤回当前选择,继续尝试其他可能性。
-
交换元素:
- 通过交换数组中的元素来生成排列,而不是额外使用空间存储状态。这样可以减少空间复杂度。
时间复杂度:
- 生成全排列的时间复杂度是 O(n!),因为每个元素都需要和其他元素交换一遍,排列的总数为 n!。
空间复杂度:
- 空间复杂度是 O(n),因为递归调用栈的深度是 n(每次递归深度为数组的长度),且我们只需要常数空间来交换数组元素。
C++代码实现:
#include <iostream>
#include <vector>
using namespace std;class Permutations {
public:// 主函数,返回所有的全排列vector<vector<int>> permute(vector<int>& nums) {vector<vector<int>> result;vector<int> current;vector<bool> used(nums.size(), false);backtrack(nums, current, result, used);return result;}private:// 回溯函数,生成排列void backtrack(vector<int>& nums, vector<int>& current, vector<vector<int>>& result, vector<bool>& used) {// 当当前排列的长度等于nums的长度时,说明找到了一个全排列if (current.size() == nums.size()) {result.push_back(current);return;}// 遍历nums数组中的每个元素for (int i = 0; i < nums.size(); i++) {// 如果该元素已经被使用过,则跳过if (used[i]) continue;// 做选择,标记当前元素为已使用used[i] = true;current.push_back(nums[i]);// 递归生成剩余的排列backtrack(nums, current, result, used);// 撤销选择,回溯used[i] = false;current.pop_back();}}
};int main() {Permutations solution;vector<int> nums = {1, 2, 3};vector<vector<int>> result = solution.permute(nums);// 打印结果for (const auto& perm : result) {for (int num : perm) {cout << num << " ";}cout << endl;}return 0;
}