C++面试经典问题

常见问题:智能指针、多态、虚函数、STL原理、链表、排序、二叉树、设计模式、线程进程、内存

对象所有权

在接触智能指针之前首先要理解对象的所有权是什么,在这之前我们总是用newdelete来进行内存的申请与释放,在这种堆内存分配的方式中,要遵守一个很基本的原则-谁创建谁销毁原则,而对象所有权指的是谁来负责销毁这个对象的关系。
根据几种智能指针的用途来分,对象的所有权可以分为独占所有权分享所有权弱引用。
独占所有权(unique_ptr):独占该对象,不共享,所有权可以转移,但是转移之后,所有权也是独占。比如:Dad拥有Son的独占所有权,那么Son就必须由Daddelete,独占意味着若有另一个对象Tmp想要拥有Son的话,就必须让Dad放弃对Son的所有权,此所谓独占,亦即不可分享。
分享所有权(shared_ptr):与独占所有权正好相反,对某个对象的所有权可以共享。比如:此时A拥有对象C,在没有其他拥有该对象的情况下,对象C的释放由A来负责,如果此时B也想拥有该对象C,则A不需要放弃自己的所有权,而是将所有权分享给B,那么对象C的释放由最后一个拥有它的来负责(若A先销毁则由B负责,否则由A负责)。
弱引用(weak_ptr):指的是可以使用该对象,但是没有所有权,由真正拥有其所有权的来负责释放。比如:AB有弱引用的话,那么A可以使用B,但是A不负责释放B,如果B已经被拥有其所有权的对象(比如C)释放后,那么A还想继续使用B的时候就会获得一个nullptr。

  • unique_ptr:使用上限制最多的一种智能指针,被用来取代之前的auto_ptr,一个对象只能被一个unique_ptr所拥有,而不能被共享,如果需要将其所拥有的对象转移给其他unique_ptr,则需要使用move语义
  • shared_ptr:与unique_ptr不同是,unique_ptr是独占管理权,而shared_ptr则是共享管理权,即多个shared_ptr可以公用同一块关联对象,其内部采用的是引用计数,在拷贝的时候,引用计数+1,而在某个对象退出作用域或者释放的时候,引用数-1,当引用计数为0的时候,会自动释放其管理的对象。
  • weak_ptr:weak_ptr的出现,主要是为了解决shared_ptr的循环引用(循环引用会导致内存无法正常释放,因为引用计数不会为0,以至于对象不能释放,导致内存泄漏),其主要是与shared_ptr一起来使用。和shared_ptr不同的地方在于,其并不会拥有资源,也就是说不能访问对象所提供的的成员函数,不过,可以通过weak_ptr.lock()来产生一个拥有访问权限的shared_ptr。例子:A类中有一个需求,需要存储其他A类对象的信息(比如,一个人的类,他需要有另一个朋友的信息,那么就需要有个指针指向他的朋友),如果使用shared_ptr,那么在销毁时会遇到循环依赖问题,所以我们这里需要用一个不需要拥有所有权的指针来标记该同类对象。weak_ptr不能单独存在,不能通过make_weak创建,一般通过shared_ptr来存在。
// 创建weak_ptr方式,通过shared_ptr来创建
shared_ptr<Cat> s_p1 = make_shared<Cat>("C1");
weak_ptr<Cat> w_p1(s_p1);

智能指针实现原理

智能指针也是一个类,当超出类的作用域时,类会自动调用析构函数,析构函数会自动释放资源。所以智能指针的作用原理就是在函数结束时自动释放内存空间,而不需要手动释放内存空间。
C++程序设计中使用堆内存是非常频繁的操作,堆内存的申请和释放都由程序员自己管理。程序员自己管理堆内存可以提高程序的效率,但是整体来说堆内存的管理时麻烦的,C++11中引入了智能指针的概念,方便管理堆内存。使用普通指针容易造成堆内存泄漏(忘记释放),二次释放,程序发生异常时内存泄漏等问题,使用智能指针能更好的的管理堆内存。

  1. 智能指针是利用了一种叫做RAII(资源获得即初始化)的技术对普通的指针进行封装,这使得智能指针实质是一个对象,行为表现的却像一个指针。
  2. 智能指针的作用是防止忘记调用delete释放内存和程序异常的进入catch块(捕获异常)忘记释放内存。另外指针的释放时机也是非常有考究的,多次释放同一个指针会造成程序崩溃,这些都可以通过智能指针来解决。

智能指针是一种用于管理动态分配内存的模板类对象。它们通过在其生命周期结束时自动释放内存,帮助避免内存泄漏和悬空指针等问题。智能指针的实现原理主要涉及两个概念:所有权引用计数
智能指针的实现通常基于对象所有权的概念。所有权可以理解为对于某一块内存的拥有权,一个智能指针对象可以通过获取所有权来管理该内存。当一个智能指针获取了所有权后,其他指针将无法在对该内存进行访问,从而避免了多个指针同时访问同一块内存的问题,所有权确保内存的唯一管理者负责内存的分配和释放,提高内存管理的安全性和可靠性
引用计数是另一个智能指针的核心概念。每个智能指针对象都会记录对应内存的引用计数,即有多少个指针指向该内存。当引用计数变为0时,表示没有任何指针指向该内存,这时智能指针会自动释放内存。

智能指针的实现通常利用了C++的RAII(Resource Acquisition Is Initialization)机制,通过在构造函数中申请内存,在析构函数中释放内存。这样可以确保在智能指针对象的生命周期结束时,内存能够被正确地释放。另外,为了确保异常安全性,智能指针还可以重载拷贝构造函数和赋值运算符,以便正确处理内存的所有权转移。
智能指针并不能解决所有的内存管理问题,例如循环引用。在使用智能指针时,仍然需要注意避免循环引用等问题,以免导致内存泄漏。

智能指针里面的计数器何时会改变

引用计数会在以下几种情况下改变:

  1. 智能指针的构造:当一个智能指针对象被创建时,引用计数会被初始化为1。这表示该智能指针对象认为自己是唯一拥有所指内存的指针。
  2. 拷贝构造:当一个智能指针对象被拷贝给另一个智能指针对象时,引用计数会增加。这是为了记录有多少个指针指向同一内存块。
  3. 赋值运算符:当一个智能指针对象被赋值给另一个智能指针对象时,引用计数会根据情况进行增加或减少,源对象(等号左边对象)的引用计数减1,目标对象(等号右边对象)的引用计数加1。如果原先的智能指针对象引用计数变为0,则可能会释放相关内容。
  4. 智能指针的析构:当一个智能指针对象的生命周期结束时,其析构函数会被调用,引用计数会减少。当引用计数减少到0时,表明没有任何智能指针对象指向该内存,内存会被自动释放。

智能指针和管理的对象分别在哪个区

智能指针实际上是一个栈对象,存储在栈区,托管的资源在堆区,利用了栈对象超出生命周期后自动析构的特征来释放被管理对象的内存,所以无需手动delete释放资源。

RAII机制

RAII: Resource Acquisition Is Initialization,资源获取即初始化,将资源的生命周期与一个对象的生命周期绑定,举例来说就是,把一些资源封装在类中,在构造函数中请求资源,在析构函数中释放资源且绝不抛出异常,而一个对象在生命周期结束时会自动调用析构函数,即资源的生命周期和一个对象的生命周期绑定。

volatile 关键字的作用?什么时候需要使用volatile 关键字

volatile关键字告诉编译器其修饰的变量是易变的,它会确保修饰的变量每次读操作都从内存里读取,每次写操作都将值写到内存里。
如果一个变量需要被多个线程共享,就需要添加volatile

左值和右值、左值引用和右值引用

左值:在内存中有确定存储地址、有变量名、表达式结束依然存在的值。
左值引用:绑定到左值的引用,通过&来获得左值引用。
右值:在内存中没有确定存储位置、没有变量名,表达式结束就会销毁的值,比如:字面常量、表达式返回值,传值返回函数的返回值。右值不能出现在赋值符号的左边且不能取地址
右值引用:绑定到右值的引用,通过&&来获得右值引用。

new和malloc的区别

特征new/delete(操作符)malloc/free(库函数)
分配内存的位置自由存储区
内存分配失败抛出异常返回NULL
返回类型安全性完整类型指针void*
分配内存的大小编译器根据类型计算得出显式指定字节数
处理数组有处理数组的new版本new[]需要用户计算书组的大小后进行内存分配
已分配内存的扩张不支持使用realloc完成
分配内存时内存不足无法通过用户代码进行处理可以指定处理函数(realloc)或重新制定分配器
是否可以重载可以不可以
构造函数与析构函数调用不调用

网络协议栈

七层模型五层模型协议示例
应用层应用层应用程序DNS ftp
表示层
会话层
传输层传输层内核程序TCP UDP
网络层网络层IP
数据链路层数据链路层IEEE802.3
物理层物理层以太网

TCP

  • TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议,其传输的单位是报文段

特征:

  • 面向连接
  • 只能点对点通信(一对一)
  • 可靠交互
  • 全双工通信
  • 面向字节流

TCP如何保证可靠传输:

  • 确认和超时重传
  • 数据合理分片和排序
  • 流量控制
  • 拥塞控制
  • 数据校验

UDP

  • UDP(用户数据报协议)是OSI(开放式系统互联)参考模型中的一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务,其传输的单位是用户数据报

特征:

  • 无连接,实时性强
  • 尽最大努力交付
  • 面向报文
  • 没有拥塞控制
  • 支持一对一、一对多、多对一、多对多的交互通信
  • 首部开销小

TCP与UDP区别

  1. TCP面向连接,UDP是无连接的
  2. TCP提供可靠的服务,也就是说,通过TCP连接传送的数据,无差错,不丢失,不重复,且按序到达;UDP尽最大努力交付,即不保证可靠交付
  3. TCP的逻辑通信信道是全双工的可靠信道;UDP则是不可靠信道
  4. 每一条TCP连接只能是点到点的;UDP支持一对一、一对多、多对一、多对多的交互通信
  5. TCP面向字节流(可能出现黏包问题),实际上是TCP把数据看成一连串无结构的字节流;UDP是面向报文的(不会出现黏包问题)
  6. UDP没有拥塞控制,因此网络出现拥塞不会使源主机的发送速率降低(对实时应用很有用,如IP电话,实时视频会议等)
  7. TCP首部开销20字节;UDP的首部开销小,只有8个字节

TCP黏包问题

原因
TCP是一个基于字节流的传输服务(UDP基于报文的),"流"意味着TCP所传输的数据是没有边界的。所以可能会出现两个数据包黏在一起的情况
解决

  • 发送定长包。如果每个消息的大小都是一样的,那么在接收对等方只要累计接收数据,知道数据等于一个定长的数值就将它作为一个消息
  • 包头加上包体长度。包头是定长的4个字节,说明了包体的长度。接收对等方先接收包头长度,依据包头长度来接收包体
  • 在数据包之间设置边界,如添加特殊符号\r\n标记。FTP协议正式这么做的。但问题在于如果数据正文中也含有\r\n,则会误判为消息的边界
  • 使用更加复杂的应用层协议

TCP流量控制

概念
流量控制就是让发送方的发送速率不要太快,要让接收方来得及接收
方法
利用可变窗口(滑动窗口)进行流量控制

TCP拥塞控制

概念
拥塞控制就是防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载
方法

  • 慢开始
  • 拥塞避免
  • 快重传
  • 快恢复

TCP三次握手建立连接

[TCP建立连接全过程解释]

  1. 客户端发送SYN给服务器,说明客户端请求建立连接
  2. 服务端收到客户端发的SYN,并回复SYN+ACK给客户端(同意建立连接)
  3. 客户端收到服务端的SYN+ACK后,回复ACK给服务端(表示客户端收到了服务端发的同意报文)
  4. 服务端收到客户端的ACK,连接已建立,可以数据传输

TCP为什么要进行三次握手

  1. 因为信道不可靠,而TCP想在不可靠信道上建立可靠传输,那么三次通信是理论上的最小值(而UDP则不需建立可靠传输,因此UDP不需要三次握手)
  2. 因为双方都需要确认对方收到了自己发送的序列号,确认过程至少要进行三次通信
  3. 为了防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误

三次握手
image.png

TCP四次挥手释放连接

[TCP释放连接全过程解释]

  1. 客户端发送FIN给服务器,说明客户端不必发送数据给服务器了(请求释放从客户端到服务器的连接)
  2. 服务器接收到客户端发的FIN,并回复ACK给客户端(同意释放从客户端到服务器的连接)
  3. 客户端收到服务端回复的ACK,此时从客户端到服务器的连接已释放(但服务端到客户端的连接还未释放,并且客户端还可以接收数据)
  4. 服务端继续发送之前没发完的数据给客户端
  5. 服务端再发送FIN给客户端,说明服务端发送完了数据(请求释放从服务端到客户端的连接,就算没收到客户端的回复,过段时间也会自动释放)
  6. 客户端收到服务端的FIN+ACK,并回复ACK给服务端(同意释放从服务端到客户端的连接)
  7. 服务端收到客户端的ACK后,释放从服务端到客户端的连接

TCP为什么要进行四次挥手

[问题一] TCP为什么要进行四次挥手? / 为什么TCP建立连接需要三次,而释放连接则需要四次?
[答案一] 因为TCP是全双工模式,客户端请求关闭连接后,客户端向服务端的连接关闭(一二次挥手),服务端继续传输之前没传完的数据给客户端(数据传输),服务端向客户端的连接关闭(三四次挥手)。所以TCP释放连接时服务器的ACK和FIN是分开发送的(中间隔着数据传输),而TCP建立连接时服务器的ACK和SYN是一起发送的(第二次握手),所以TCP建立连接需要三次,而释放连接则需要四次。
[问题二] 为什么TCP连接时可以ACK和SYN一起发送,而释放时则ACK和FIN分开发送?(ACK和FIN分开是指第二次和第三次挥手)

[答案二] 因为客户端请求释放时,服务器可能还有数据需要传输给客户端,因此服务端要先响应客户端FIN请求(服务端发送ACK),然后数据传输,传输完成后,服务端再提出FIN请求(服务端发送FIN);而连接时则没有中间的数据传输,因此连接时可以ACK和SYN一起发送
[问题三] 为什么客户端释放后需要TIME-WAIT等待2MSL(报文最大生存时间 1MSL=2min)?

[答案三]

  1. 为了保证客户端发送的最后一个ACK报文能够到达服务端。若未成功到达,则服务端超时重传FIN+ACK报文段,客户端再重传ACK,并重新计时
  2. 防止已失效的连接请求报文段出现在本连接中。TIME-WAIT持续2MSL可使本连接持续的时间内所产生的所有报文段都从网络中消失,这样可使下次连接中不会出现旧的连续报文段

socket编程

socket中的read()、write()函数

ssize_t read(int fd, void* buf, size_t count);
ssize_t write(int fd, const void * buf, size_t count);


read()

  • read函数是负责从文件描述符fd中读取内容
  • 当读成功时,read返回实际所读的字节数
  • 如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误
  • 如果错误为EINTR说明读是由中断引起的;如果是ECONNREST表示网络连接出了问题

write()

  • write函数将buf中的nbytes字节内容写入文件描述符fd
  • 成功时返回写的字节数。失败时返回-1,并设置errno变量
  • 在网络程序中,当我们向套接字文件描述符写时有两种可能
    • write的返回值大于0,表示写了部分或者是全部的数据
    • 返回值小于0,此时出现了错误
  • 如果错误为EINTR表示在写的时候出现了中断的错误;如果为EPIPE表示网络连接出现了问题(对方已经关闭了连接)

多线程多进程

多进程和多线程间的对比、劣势与选择
对比

对比维度多进程多线程总结
数据共享、同步数据共享复杂,需要用IPC(多进程通信)
数据是分开的同步简单
因为共享进程数据,数据共享简单,但也是因为这个原因导致同步复杂各有优势
内存、CPU占用内存多、切换复杂、CPU利用率低占用内存少,切换简单,CPU利用率高线程占优
创建销毁、切换创建销毁、切换复杂、速度慢创建销毁、切换简单、速度很快线程占优
编程、调试编程简单、调试简单编程复杂、调试复杂进程占优
可靠性进程间不会互相影响一个线程挂掉将导致整个进程挂掉
(当一个线程向非法地址读取或者写入,无法确认这个操作是否会影响同一进程中的其它线程,所以只能是整个进程一起崩溃)
进程占优
分布式适合用于多核、多机分布式
如果一台机器不够,扩展到多台机器比较简单
适应于多核分布式进程占优

优劣

优劣多进程多线程
优点编程、调试简单、可靠性高创建、销毁、切换速度快
内存、资源占用小、CPU利用率高
缺点创建、销毁、切换速度慢
内存、资源占用大、CPU利用率低
编程、调试复杂、可靠性差

选择

  • 需要频繁创建销毁的优先用线程
  • 需要进行大量计算的优先使用线程
  • 强相关的处理用线程,弱相关的处理用进程(如消息收发和消息处理就是弱相关,用进程;消息解码和业务处理是强相关,用线程)
  • 可能要扩展到多机分布的用进程,多核分布的用线程

为什么要用多线程

  1. 进程之间切换代价比较高,线程之间切换代价比较小
  2. 解决CPU和IO速度不匹配的问题,多线程更适合在IO切换频繁的场景
  3. 充分利用多核CPU资源、提高程序的并发效率

线程池主要优势

线程池的主要优势是它可以重用已经创建的线程,从而减少了线程创建和销毁的开销。这样可以提高任务的执行效率,避免频繁地创建和销毁线程所带来的性能损耗

线程池的应用场景

线程池适用于需要频繁执行任务的场景,比如网络服务器、多媒体应用等。在这些场景下,线程池可以提高程序的性能和响应速度,同时避免线程数量过多导致系统资源的浪费和竞争

线程池的组成

线程池通常由线程容器、任务队列、条件变量和互斥锁等组成。线程容器用于存储线程对象,任务队列用于存储待执行的任务,条件变量和互斥锁用于线程之间的同步和通信

线程池的工作流程

创建线程池:在程序启动时,创建一个线程池对象,并指定线程池中线程的数量
添加任务:当有任务需要执行时,将任务添加到任务队列中
线程执行任务:线程池中的线程会从任务队列中取出任务并执行,直到任务队列为空
等待任务完成:将所有任务都执行完成后,线程池会等待所有线程执行完毕,并关闭线程池

线程与进程的区别

  1. 进程是资源分配的基本单位;线程是程序执行的基本单位
  2. 进程拥有自己的资源空间,每启动一个进程,系统就会为它分配地址空间;而线程与资源分配无关,多个线程共享同一进程内的资源,使用相同的地址空间
  3. 一个进程可以包含若干个线程

如何保证线程安全

  1. 互斥量(Mutex):通过互斥量锁定代码块,以保证只有一个线程同时访问该代码。
  2. 条件变量(Condition variable):在互斥量的基础上,当等待执行的线程满足条件时,唤醒执行。
  3. 原子操作(Atomic operation):使用原子变量来跟踪正在工作的线程数量,确保多个线程可以安全地更新此变量而不会发生数据竞争
  4. 信号量(Semaphore):通过信号量管理线程的并发访问,保证合理的资源分配。
  5. 读写锁(Read-write lock):读写锁分为读锁和写锁,读锁允许多个线程同时读,写锁只允许一个线程写。 这些方法可以根据具体的需求选择使用

常见锁机制

  1. 互斥锁:互斥锁用于控制多个线程对它们之间共享资源互斥访问的一个信号量。为了避免多个线程在某一时刻同时操作一个共享资源。

  2. 信号量Semaphore:二值信号量:信号量的值只有0和1,这和互斥量很类似,若资源被锁住,信号量的值为0,若资源可用,则信号量的值为1;
    计数信号量:信号量的值在0到一个大于1的限制值之间,该计数表示可用的资源的个数

  3. **条件锁:**当某一个线程因为某个条件未满足时可以使用条件变量使该程序处于阻塞状态,一旦条件满足则以“信号量”的方式唤醒一个因为该条件而被阻塞的线程。

  4. **读写锁:**多个线程可以并行读取数据,但只能独占式地写或修改数据

const关键字

作用:

  1. 修饰变量,说明该变量不可以被改变
  2. 修饰指针,分为**指针常量(指针类型的常量)常量指针(指向常量的指针),指针常量(int * const p)中,指针自身的值是一个常量,不可改变,始终指向同一个地址,但其内容可以修改;在常量指针(const int *p, int const * p)**中,指针指向的内容是不可改变的,但是指针可以指向其他地址。
  3. 修饰引用,**常量引用(const int &p),**它可以指向一个非常量对象,但是不允许用该引用修改非常量对象的值。并且指向常量对象时,一定要使用常量引用,而不能是一般的引用。不能让一个非常量引用指向一个常量对象。
  4. 修饰成员函数,说明该成员函数内不能修改成员变量
  5. 修饰函数,常量函数(int readme(int i) const), 修饰符const要加在函数说明的尾部,只有权读取外部的数据内容,但无权修改他们,也无法对外部数据进行任何写入操作(比如将i赋值给me)

static关键字

作用:

  1. 修饰普通变量,修改变量的存储区域和生命周期,使变量存储在静态区,在main函数运行前就分配了空间,如果有初始值就用初始值初始化它,如果没有初始值系统用默认值初始化它
  2. 修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用,不能被其他文件使用。在多人开发项目时,为了防止与他人命名空间里的函数重名,可以将函数定位为static,这样不会发生冲突
  3. 修饰成员变量,修饰成员变量使所有的对象只保存一个该变量,而且不需要生成对象就可以访问该成员变量
  4. 修饰成员函数,修饰成员函数使得不需要生成对象就可以访问该成员函数,但是在static函数内不能访问非静态成员

this指针

  1. this指针是一个隐含于每一个非静态成员函数中的特殊指针。他指向调用该成员函数的那个对象
  2. 当对一个对象调用成员函数时,编译程序先将对象的地址赋值给this指针,然后调用成员函数,每次成员函数存取数据成员时,都隐式使用this指针
  3. 当一个成员函数被调用时,自动向它传递一个隐含的参数,该参数是一个指向这个成员函数所在的对象的指针
  4. this指针被隐含的声明为:ClassName* const this,这意味着不能给this指针赋值;在ClassName类的const成员函数中,this指针的类型为const ClassName* const,这说明不能对this指针所指向的这种对象进行修改(即不能对这种对象的数据成员进行赋值操作)
  5. this并不是一个常规变量,而是个右值,所以不能取得this的地址(不能&this)
  6. 在以下场景中,经常需要显式引用this指针:
    1. 为实现对象的链式引用
    2. 为避免对同一对象进行赋值操作
    3. 在实现一些数据结构时,如list

inline内联函数

内联函数的代码会被复制到每个调用它的地方,而普通函数的代码只会在调用时执行。
特征:

  • 相当于把内联函数里面的内容写在调用内联函数处
  • 相当于不用执行进入函数的步骤,直接执行函数体
  • 相当于宏,却比宏多了类型检查,真正具有函数特性
  • 编译器一般不内联包含循环、递归、switch等复杂操作的内联函数
  • 在类声明中定义的函数,除了虚函数的其他函数都会自动隐式地当成内联函数,即类内定义的函数都是内联函数
  • 函数声明在类内,但定义在类外的看是否有inline修饰符,有就是内联函数,否则不是
  • 内联函数在程序中调用几次,内联函数的代码就会复制几份在对应的位置上

利与弊:

  • :避免了指令的来回跳转,加快程序执行速度
  • :代码被多次复制,增加了代码量,占用更多的内存空间

递归函数和函数代码量多功能复杂时不能使用内联函数
函数本身内容较少,功能简单,被调用频繁的时候使用内联函数

内联函数与宏的区别

宏是由预处理器对宏进行替代(在编译时进行),而内联函数是通过编译器控制来实现的(程序运行时调用)
内联函数比宏多了类型检查,更加安全,真正具有函数特性
内联函数的调用是传参,宏定义只是简单的文本替换
内联函数在运行时可调试,宏定义不可以

虚函数(virtual)

  • 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联
  • 内联是在编译期建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联
  • inline virtual唯一可以内联的时候是:编译器知道所调用的对象是那个类,这只有在编译器具有实际对象而不是对象的指针或引用时才会发生

虚函数是基类中的同名函数。虚函数的主要目的是实现多态
为什么要使用虚函数:

  1. 多态:虚函数允许我们通过基类指针或引用来调用派生类的实现,从而实现多态。这使得我们可以编写更通用、可扩展的代码
  2. 可扩展性:通过使用虚函数,我们可以轻松地添加新的派生类,而无需修改现有的基类代码
  3. 代码重用:虚函数允许派生类重用和扩展基类的功能,而无需完全重写函数

以下是虚函数的简单示例:

#include <iostream>
class Animal{
public:
	virtual void makeSound(){
        std::cout << " The animal makes a sound " << std::endl;
    }
};

class Dog: public Animal{
public:
	void makeSound(){
        std::cout << "The dog barks" << std::endl;
    }
};

int main(){
    Animal* animal = new Dog();
    animal->makeSound(); // 输出The dog barks
    delete animal;
    return 0;
}

在这个例子中,Animal类有一个虚函数makeSound(),Dog类继承了Animal类并重写了makeSound()函数。当我们通过基类指针调用makeSound()时,实际上调用的是派生类Dog的实现。这就是虚函数实现多态的一个例子。

纯虚函数

纯虚函数是一种特殊的虚函数,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。

virtual int A() = 0;

虚函数、纯虚函数

  • 类里如果声明了虚函数,这个函数是实现的,哪怕是空实现,它的作用就是为了能让这个函数在它的子类里面可以被覆盖(override),这样的话,编译器就可以使用后期绑定来达到多态。纯虚函数只是一个接口,是个函数的声明,它要留到子类里去实现
  • 虚函数在子类里面可以不重写;但纯虚函数必须在子类实现才可以实例化子类
  • 虚函数的类用于"实作继承",继承接口的同时也继承了父类的实现。纯虚函数关注的是接口的统一性,实现由子类完成
  • 带纯虚函数的类叫抽象类,这种类不能直接生成对象,而只有被继承,并重写其虚函数后才能使用。抽象类被继承后,子类可以继续是抽象类,也可以是普通类
  • 虚基类是虚继承中的基类

虚函数指针、虚函数表

  • 虚函数指针:在含有虚函数类的对象中,指向虚函数表,在运行时确定
  • 虚函数表:存放在程序的只读数据段(.rodata),即内存模型中的常量区,存放虚函数指针,如果派生类实现了基类的某个虚函数,则在虚表中覆盖原本基类的那个虚函数指针,在编译时根据类的声明创建

类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不在堆中

C++的内存分区

堆、栈、全局/静态存储区、常量存储区、代码区(如函数)

类模板、成员模板、虚函数

  • 类模板中可以使用虚函数
  • 一个类(无论是普通类还是类模板)的成员模板(本身是模板的成员函数)不能是虚函数

assert()

断言,是宏,而非函数。assert宏的原型定义在<assert.h>/<cassert>中,其作用是如果它的条件返回错误,则终止程序执行
最常见的操作是用来判断创建的指针是否创建成功

assert(p != NULL);

C语言与C++的区别

C语言是面向过程的,抽象化的通用设计语言,主要用于底层开发,C++是C的超集,继承并扩展了C语言,C++即可以进行C语言的过程化程序设计,又可以进行以面向对象为主要特点的程序设计。

C++中的struct和class

在C++中,struct和class用法相似,总的来说,struct更适合看成是一个数据结构的实现体,class更适合看成是一个对象的实体
**区别:**最本质的区别是默认的访问控制

  1. 默认的继承访问权限,struct是public的,class是private的
  2. struct作为数据结构的实现体,他默认的数据访问控制是public的,而class作为对象的实现体,他默认的成员变量访问控制是private的

C语言中的struct和C++中的struct

C语言中的struct可以包含成员变量,但不能包含成员函数,C中的struct成员变量是public的
C++中的struct可以包含成员变量,也可以包含成员函数,可以使用构造函数和析构函数,而且C++struct可以进行继承,可以使用private、protected来限制成员的访问性

extern "C"的作用

extern “C” 的主要作用就是为了能够正确实现C++代码调用其它C语言代码。加上extern "C"后,会提示编译器这部分代码按C语言(而不是C++)的方式进行编译。
由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。extern “C” 的目的就是主要实现C和C++的相互调用问题。
这个功能主要用在以下情况:

  1. C++代码调用C语言代码;
  2. 在C++的头文件中使用;
  3. 在多人协同开发时,有的人比较擅长C语言,而有的比较擅长C++,这样的情况下也会有用到。

union联合

联合(union)是一种节省空间的特殊的类,一个union可以有多个数据成员,但是在任意时刻只有一个数据成员可以有值。当某个成员被赋值后其他成员变为未定义状态。联合有如下特点:

  • 默认访问控制为public
  • 可以含有构造函数、析构函数
  • 不能含有引用类型的成员
  • 不能继承自其他类,不能作为基类
  • 不能含有虚函数
  • 匿名union在定义所在作用域可直接访问union成员
  • 匿名union不能包含protected成员或private成员
  • 全局匿名联合必须是静态(static)的
#include<iostream>
using namespace;
union unionTest{
	unionTest() : i(10){};
	int i;
	double d;	
};

static union{
	int i;
	double d;
};

int main(){
    unionTest u;
    union{
        int i;
        double d;
    };
	cout << u.i << endl;  // 输出unionTest联合的 10

    ::i = 20;
    cout << ::i << endl;  // 输出全局静态匿名联合的 20

    i = 30;
    cout << i << endl;   // 输出局部匿名联合的 30
 
    return 0;
}

explicit(显式)关键字

  • explicit修饰构造函数时,可以防止隐式转换和复制初始化
  • explicit修饰转换函数时,可以防止隐式转换,但按语境转换除外
struct B{
	explicit B(int){}
	explicit operator bool() const{return true;}
};

void doB(B b){}

int main()
{	
	B b1(1);  // OK 直接初始化
    B b2 = 1; // error 被explicit修饰构造函数的对象不可以复制初始化
    B b3{1};  // OK 直接列表初始化
    B b4 = {1}; // error 被explicit修饰构造函数的对象不可以复制列表初始化
    B b5 = (B)1; // Ok 允许显式转换
    doB(1);     // error 被explicit修饰构造函数的对象不能从int到B的隐式转换
    if(b1);    // OK 被explicit修饰转换函数 B::operator bool()的对象可以从B到bool的按语境转换
    bool b6(b1); // OK 同上
    bool b7 = b1; // error 被explicit修饰转换函数B::operator bool()的对象不可以隐式转换
    bool b8 = (bool)(b1) // OK 可以直接进行初始化,显式转换
    return 0;
}

隐式和显式类型转换

类型转换包括隐式类型转换和显式类型转换,
隐式类型转换通常用于将一个较小范围的数据转换为一个较大范围的数据类型,以便执行某些操作而不需要显示指定类型转换。**隐式类型转换的目的是确保不会发生数据丢失或溢出。**例如将short和int类型相加时,会将short隐式转换为int,然后在进行相加
显式类型转换也叫强制类型转换,由程序员来指定类型转换的操作

友元关键字(friend)

类的友元函数是定义在类外部,但有权访问类的所有私有(private)成员和保护(protected)成员。尽管友元函数的原型有在类的定义中出现过,但是友元函数并不是成员函数。
友元可以是一个函数,该函数称为友元函数;友元也可以是一个类,该类称为友元类,在这种情况下,整个类及其所有成员都是友元,可以访问类的任何成员
如果要声明函数为一个类的友元,需要在类定义中该函数原型的前面使用关键字friend,如:

class Box{
	double width;
public:
	double length;
	friend void printWidth(Box box);
	void setWidth(double wid);
};

声明类classTwo的所有成员函数作为类classOne的友元,需要在类classOne的定义中放置如下声明:

friend class classTwo;

具体例子如下:

#include<iostream>
using namespace std;
class Box{
	double width;
public:
	double length;
	friend void printWidth(Box box);
	void setWidth(double wid);
};

// 成员函数定义
void Box::setWidth(double wid){
    width = wid;
}

//printWidth()不是任何类的成员函数
void printWidth(Box box){
    // 因为printWidth()是Box的友元,他可以直接访问该类的任何成员
    cout << "Width of box:" << box.width << endl;
}

int main()
{
    Box box;
    // 使用成员函数设置宽度
    box.setWidth(10.0);
    // 使用友元函数输出宽度
    printWidth(box);   // 输出 Width of box: 10
    return 0;
}

friend友元类和友元函数

  • 能访问私有成员
  • 破坏封装性(因为其他友元可以访问自身的私有成员)
  • 友元关系不可传递
  • 友元关系的单向性
  • 友元声明的形式及数量不受限制

enum枚举类型

C++包含两种枚举类型:

  1. 限定作用域的枚举类型
  2. 不限定作用域的枚举类型

带限定作用域的枚举类别通过enum class声明,不限定作用域的枚举类别通过enum声明

不限定作用域的枚举类型

  • 枚举元素是常量,不能对他们赋值 例如enum Weekday={SUN, MON, TUE, SAT}; 不能写赋值表达式:SUM = 0;
  • 枚举元素有默认值,依次为:0,1,2
  • 可以在声明时另行指定枚举元素的值:enum Weekday = {SUM=7,MON=1,TUE,SAT};
  • 枚举值可以运行关系运算 不能直接用一个整数给枚举类型赋值,需要进行强制类型转化
#include<iostream>
using namespace std;

enum Result{WIN,LOSE,TIE,CANCLE};
int main()
{
    // 带不带enum关键字都可以
    Result result;
    enum Result omit = CANCLE;
    int count = WIN;
    // 不能直接用一个整数给枚举值进行运算,需要进行强制类型转化
    result = static_cast<Result>(count);
    if(result == omit)
        cout << " cancelled" << endl;
    return 0;
}

限定作用域的枚举类型

对初始化变量名称的影响
对于不限定作用域的枚举类型,其枚举量作用域的范围是包含这个枚举类别的作用域

enum color{
	blue,red
};
auto red = 0; // 错误,red已经在范围内被声明过

限定作用域的枚举类别则不同

enum class color{
	blue, red
};
auto red = false;
color a = color:red;

对于限定作用域的枚举类别,可以降低对名字空间的污染

enum和enum class 区别

  • enum class 是类型安全的
  • enum class 定义被限制在枚举作用域内,不能隐式转换为整数类型,但是可以强制转换为整数类型。
  • 使用enum class定义的枚举必须带作用域名。

enum 和 union

enum用于定义一组整数常量,union用于定义一个数据结构,但是在任意时刻只有一个数据成员可以有值
enum成员都是整数常量,union成员可以是不同的数据类型
enum每个枚举成员占用一个整数值的内存,union内存占用取决于最大成员的大小
enum适用于需要表示离散选项或状态的情况,如定义颜色、星期几、状态等
union适用于需要在不同类型之间进行相互转换的情况

enum 中的元素不是变量,而是常数,在声明时初始化,初始化之后不能对枚举元素进行赋值
enum值可以用来做判断
一个整数不能直接赋给一个枚举变量,必须强制进行类型转换才能赋值day = (enum weekday) 2

C与C++中enum的区别

  1. C 枚举类型支持不同类型枚举值之间赋值、以及数字赋值、比较
    2. C++ 中枚举不允许不同类型的值给枚举类型变量赋值,但仍然支持不同类型之间枚举进行比较
    3. C++ 枚举类型不允许不同类型之间的赋值、比较

成员初始化列表

优点:

  • 更高效:少了一次调用默认构造函数的过程
  • 有些场合必须要用初始化列表:
    • 常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
    • 引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面
    • 没有默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化

C++的构造函数与其他函数不同,构造函数除了有名字,参数列表和函数体之外,还可以有初始化列表,初始化列表以冒号开头,后面跟一系列以逗号分隔的初始化字段

class Foo{
private:
	string name;
	int id;
public:
	Foo(string s, int i) : name(s), id(i){}; // 初始化列表
};

面向对象

面向对象的三大特征——封装、继承、多态
面向对象编程是将问题看做是若干个事物之间相互作用,首先抽象各个事物的属性和行为并进行封装,然后在创建各个对象,各个对象之间通过各自的方法来影响,或事物之间可能也存在继承或包含的关系
传统的程序设计多是基于功能的思想来进行考虑和设计的,而面向对象的程序设计则是基于对象的角度来考虑问题。这样做能够使得程序更加的简洁清晰

封装

把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。关键字:public,protected,private。不写默认为private。
隐藏对象的属性和实现细节,仅对外公开接口,控制在程序中属性的读和修改的访问级别

  • public成员:可以被任意实体访问
  • protected成员:只允许被子类及本类的成员函数访问
  • private成员:只允许被本类的成员函数、友元类或友元函数访问

继承

  • 基类(父类) ——> 派生类(子类)
  1. 公有继承(public inheritance):在公有继承中,基类的公有成员和保护成员都会成为派生类的公有成员和保护成员,而基类的私有成员对派生类是不可访问的。这意味着派生类可以直接访问基类的公有和保护成员,但不能直接访问基类的私有成员。
  2. 私有继承(private inheritance):在私有继承中,基类的公有成员、保护成员和私有成员都会成为派生类的私有成员。这意味着派生类无法直接访问基类的成员,只能通过基类的公有和保护成员函数间接访问。
  3. 保护继承(protected inheritance):在保护继承中,基类的公有成员和保护成员都会成为派生类的保护成员,而基类的私有成员对派生类是不可访问的。这意味着派生类可以直接访问基类的保护成员,但不能直接访问基类的公有成员和私有成员。

有公有继承和私有继承了为什么还需要保护继承?

保护继承是一种介于公有继承和私有继承之间的继承方式,主要用于在派生类和基类之间建立一种保护关系,使得派生类可以访问基类的受保护成员,但不会破坏基类的封装性。

多态

定义:同一操作作用于不同的对象,产生不同的执行结果。C++多态意味着当调用虚成员函数时,会根据调用类型对象的实际类型执行不同的操作。

  • 多态,即多种状态(形态)。简单来说,我们可以将多态定义为消息以多种形式显示的能力。
  • 多态是以封装和继承为基础的
  • C++多态分类及实现:
    • 重载多态(编译器):函数重载、运算符重载
    • 子类型多态(运行期):虚函数
    • 参数多态性(编译器):类模板、函数模板
    • 强制多态(编译器/运行期):基本类型转换、自定义类型转换

什么是多态?
多态是一种基于继承的、使用虚函数产生的父子类
好处?

  • 结构清晰,便于理解;
  • 增加了程序的可扩充性,利于后期代码扩展、维护
  • 实现了对修改屏蔽、对扩展开放

如何实现多态

通过使用virtual关键字标记基类的函数,然后在派生类中重写此函数,通过基类指针或引用来调用派生类中的实现

静态多态(编译期/早绑定)

函数重载

class A{
public:
	void do(int a);
	void do(int a, int b);
};

动态多态(运行期/晚绑定)

  • 虚函数:用virtual修饰成员函数,使其成为虚函数
  • 动态绑定:当使用基类的引用或指针调用一个虚函数时将发生动态绑定

注意:

  • 可以将派生类的对象赋值给基类的指针或引用,反之不可
  • 普通函数(非类成员函数)不能是虚函数
  • 静态函数(static)不能是虚函数
  • 构造函数不能是虚函数(因为在调用构造函数时,虚表指针并没有在对象的内存空间中,必须要构造函数调用完成后才会形成虚表指针) 虚表指针是虚函数的指针
  • 内联函数不能是表现多态性时的虚函数

动态多态的使用:
虚析构函数是为了解决基类的指针指向派生类对象,并用基类的指针删除派生类对象,防止内存泄漏,如果不设为虚函数,则只会调用到基类的析构函数而不会调用到子类的析构函数

class Shape{ //形状类
public:
	Shape();
	virtual double calcArea(){}
	virtual ~Shape(); // 虚析构函数
};

class Circle : public Shape{  // 圆形类
public:
	virtual double calcArea();
};

class Rect : public Shape{  // 矩形类
public:
	virtual double calcArea();
};
int main()
{
    Shape* shape1 = new Circle(4.0);
    Shape* shape2 = new Rect(5.0, 6.0);
    shape1->calcArea();  // 调用圆形类里面的方法
    shape2->calcArea();  // 调用矩形类里面的方法
    delete shape1;	// 因为Shape有虚析构函数,所以delete释放内存时,先调用子类析构函数,再调用基类析构函数,防止内存泄漏
    shape1 = nullptr;   
    delete shape2;
    shape2 = nullptr;
    return 0;
}

抽象类、接口类、聚合类

  • 抽象类:含有纯虚函数的类
  • 接口类:仅含有纯虚函数的抽象类
  • 聚合类:用户可以直接访问其成员,并且具有特殊的初始化语法形式。满足如下特点:
    • 所有成员都是public
    • 没有定义任何构造函数
    • 没有类内初始化
    • 没有基类,也没有虚函数

STL容器

容器底层数据结构时间复杂度有无序可不可重复其他
array数组随机读改 O(1)无序可重复支持随机访问
vector动态数组随机读改,尾部插入、删除O(1)
头部插入、删除O(n)
无序可重复支持随机访问
deque双端队列头尾插入、删除O(1)无序可重复支持首尾快速增删,支持随机访问
forward_list单向链表插入、删除O(1)无序可重复不支持随机访问
list双向链表插入、删除O(1)无序可重复不支持随机访问
stackdeque/list顶部插入、删除O(1)无序可重复deque/list封闭头端开口,不用vector的原因是容量大小有限制,扩容耗时
queuedeque/list尾部插入、头部删除O(1)无序可重复deque/list封闭头端开口,不用vector的原因是容量大小有限制,扩容耗时
priority_queuevector + max-heap插入、删除O( l o g 2 n log_2n log2n)有序可重复vector + heap处理规则
set红黑树插入、删除、查找O( l o g 2 n log_2n log2n)有序不可重复
multiset红黑树插入、删除、查找O( l o g 2 n log_2n log2n)有序可重复
map红黑树插入、删除、查找O( l o g 2 n log_2n log2n)有序不可重复
multimap红黑树插入、删除、查找O( l o g 2 n log_2n log2n)有序可重复

如何实现STL容器

  1. 容器的数据结构:STL中的容器有vector、list、deque、set、map等,每个容器都有不同的数据结构和特点。实现一个STL容器需要先确定容器的数据结构,例如可以使用数组、链表、红黑树等数据结构来实现不同的容器。
  2. 容器的迭代器:STL中的容器都支持迭代器,迭代器是容器中元素的指针,可以用于遍历容器中的元素。实现一个STL容器需要定义容器的迭代器,以支持遍历容器中的元素。
  3. 容器的成员函数:STL中的容器都有一些成员函数,例如插入元素、删除元素、查找元素等。实现一个STL容器需要定义容器的成员函数,并实现这些函数的具体功能。
  4. 容器的迭代器操作:STL中的容器迭代器支持自增、自减、解引用等操作,这些操作都需要在实现容器时考虑到。
  5. 容器的内存管理:STL中的容器都需要动态分配内存来存储元素,因此实现一个STL容器需要考虑内存的分配和释放,以及内存的管理和优化。

vector与数组Array之间的区别

数组适合用于存储数据量固定的情况,vector适合用于数据量不确定或需要动态扩展的情况
vector中的size和capacity:

  • size:表示实际容器中保存元素的个数
  • capacity:表示在发生重新分配之前允许存放多少元素

C++数组和std::array

std::array比数组好,它除了有传统数组支持随机访问、效率高、存储大小固定等特点外,还支持迭代器访问、获取容量、获取原始指针等高级功能

vector最大特点?内部实现?

特点

  1. 指定一块如同数组一样的连续存储空间,但其空间可以动态扩展
  2. 随机访问方便,支持[]操作符和at()
  3. 只能在vector的尾部进行push和pop,不能再头部进行push和pop

内部实现:底层就是一段连续的线性内存。内部使用三个迭代器表示,分别指向vector容器对象的起始字节位置,当前最后一个元素的末尾字节,整个容器所占用内存空间的末尾字节。通过这三个迭代器可以计算出容器的size和capacity。
扩容机制:当vector大小和容量相等时,如果再想添加元素,则需要扩容,首先重新申请更大的内存空间,一般为原空间大小的1.5倍或2倍,将旧内存空间中的数据按原有的顺序移动到新内存中,最后将旧内存空间释放

vector中的resize、reserve和clear

  • resize:调整容器元素数量的大小,即改变容器的size
  • reserve:调整容器的容量大小,即改变容器的capacity
  • clear:clear只是将vector的size设置为0,并不保证capacity为0,因此clear并不能释放vector已经申请的内存

deque内部实现

deque的底层数据结构是双端队列,适合在头部和尾部添加或删除数据。
deque(双端队列)底层实现原理是使用一段连续的存储空间,被分配为多个内存块,每个内存块独立分配,内部使用指针互相连接。deque中包含一个中控器,中控器中存放着指向第一块内存块和最后一块内存块的迭代器,以及指向每个内存块的迭代器,中控器的作用是管理内存块的分配和释放,并提供访问内存块的接口
deque的元素在内存中并不是连续存储的,而是分散存储在不同的内存块中
当deque需要在头部或尾部增加存储空间时,它会申请一段新的连续空间,同时在map数组的开头或结尾添加指向该空间的指针,由此该空间就接到了deque容器的头部或尾部
deque容器的分段存储结构,提高了在序列两端添加或删除元素的效率

map和unordered_map区别

优点:

  • map: map内部实现了一个红黑树,该结构具有自动排序的功能,因此map内部的所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素,因此,对于map进行的查找,删除,添加等一系列的操作都相当于是对红黑树进行这样的操作,故红黑树的效率决定了map的效率,效率高(O(logn))
  • unordered_map: unordered_map内部实现了一个哈希表,因此其元素的排列顺序是杂乱的,无序的,查找速度快

缺点:

  • map:空间占用率高,因为map内部实现了红黑树,虽然提高了运行效率,但是因为每一个节点都需要额外保存父节点,孩子节点以及红/黑性质,使得每一个节点都占用大量的空间
  • unordered_map:哈希表的建立比较耗费时间

适用处

  • map适用于有顺序要求的问题,unordered_map适用于查找问题

unordered_map使用链地址法来解决哈希冲突

哈希表

概念: 哈希函数:H(key) : K->D, key ∈ \in K
构造方法:

  • 直接定址法 : H(key) = a* key + b, 以关键码key的某个线性函数值为哈希地址,不会产生冲突
  • 除留余数法:H(key) = key % p(p是一个整数),以关键码除以p的余数作为哈希地址,一般p<=m且为质数
  • 数字分析法:某关键字的某几位组合哈希地址,各种符号在该位上出现的频率大致相同
  • 折叠法:将关键码自左到右分成位数相等的几部分(最后一部分可以短些),然后将这几部分叠加求和,并按哈希表表长,取后几位作为哈希地址
  • 平方取中法:对关键码平方后,按哈希表大小,取中间的若干位作为哈希地址

冲突处理方法

  • 链地址法:key相同的用单链表链接
  • 开放地址法
    • 线性探测法:key相同->放到key的下一个位置,Hi = (H(key) + i) % m
    • 二次探测法:key相同->放到Di = 1^2, -1^2 , ... , ±k^2, (k <=m/2)
    • 随机探测法:H = (H(key) + 随机数)%m

递归

概念:函数直接或间接地调用自身
递归与分治:

  • 分治法
    • 问题的分解
    • 问题规模的分解
  • 折半查找(递归)
  • 归并排序(递归)
  • 快速排序(递归)

递归与迭代

  • 迭代:反复利用变量旧值退出新值
  • 折半查找(迭代)
  • 归并排序(迭代)

VTK

VTK是三维计算机图形、图像处理即可视化的工具包,其基本渲染流程如下:
image.png
source(数据源):各个类型图像数据
filter(过滤器):对原始数据做一些操作 ,例如三角化,提取轮廓等
mapper(映射器):把不同的数据类型,转成图形数据
actor(演员):执行渲染mapper的对象
render(渲染器):用于渲染图像
renderWindow(窗口):创建渲染窗口,显示渲染的图像
interactor(交互):在渲染窗口上交互,用于获取渲染窗口上发生的鼠标,键盘事件,提供了独立于平台的与渲染窗口进行交互的机制
流程:首先准备进行可视化的数据,然后创建source数据源对象来表示数据,通过filter过滤器对象对数据进行预处理操作,然后创建一个Mapper映射器对象,接下来创建一个actor演员对象,来执行渲染mapper的对象,再创建render渲染器对象,用于显示演员actor的内容,创建渲染窗口renderwindow,来显示渲染器中的内容,最后创建interactor交互器对象,允许用户与可视化结果进行交互。

排序

排序算法判断稳不稳定:相等的数,相对位置会不会发生改变
image.png

深拷贝和浅拷贝

浅拷贝:只是复制了对象的引用地址,两个对象指向同一个内存地址,修改其中任意的值,另一个值会随之变化。默认情况下,C++类的拷贝构造函数和赋值运算符执行的是浅拷贝

深拷贝:是将对象及值复制过来,两个对象修改其中任意的值,另一个值不会发生改变

设计模式

对象创建模式:通过对象创建模式绕开new,来避免对象创建(new)过程中所导致的紧耦合(依赖具体类),从而支持对象创建的稳定。他是接口抽象之后的第一步工作。

工厂方法模式(Factory Method)

工厂方法模式是一种创建型设计模式,其在父类中提供一个创建对象的方法,允许子类决定实例化对象的类型
工厂方法可通过构建方法来识别,他会创建具体类的对象,但以抽象类型或接口的形式返回这些对象
模式定义:定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method使得一个类的实例化延迟到子类(目的:解耦,手段:虚函数)
工厂方法模式的实质是“定义一个创建对象的接口,但让实现这个接口的类别来决定实例化对象的类别。
相当于你需要创建什么样类型的数据,对应类型的工厂就会创建一个对象给你使用
使用工厂方法模式来解决依赖具体类问题:

#include <iostream>
using namespace std;
// 抽象基类就是类里定义了纯虚成员函数的类,
// 纯虚函数一般只提供接口并不做具体的实现,实现由它的派生类去重写
// 抽象类不能被实例化
class ISplitter{  
public:
    virtual void split()=0;
    virtual ~ISplitter(){}
};

class SplitterFactory{
public:
    virtual ISplitter* CreateSplitter()=0;
    virtual ~SplitterFactory(){} // 任何一个抽象基类都需要一个virtual的析构函数
};

// 具体类
class BinarySplitter : public ISplitter{
    virtual void split(){
        cout << "I am BinarySplitter" << endl;
    }
};

class TextSplitter : public ISplitter{
    virtual void split(){
        cout << "I am TextSplitter" << endl;
    }
};

class VideoSplitter : public ISplitter{
    virtual void split(){
        cout << "I am VideoSplitter" << endl;
    }
};

// 具体工厂
class BinarySplitterFactory : public SplitterFactory{
public:
    virtual ISplitter* CreateSplitter(){
        return new BinarySplitter();
    }
};
class TextSplitterFactory : public SplitterFactory{
public:
    virtual ISplitter* CreateSplitter(){
        return new TextSplitter();
    }
};
class VideoSplitterFactory : public SplitterFactory{
public:
    virtual ISplitter* CreateSplitter(){
        return new VideoSplitter();
    }
    
};

int main()
{
    VideoSplitterFactory* factory = new VideoSplitterFactory();
    ISplitter* splitter = 
    // 多态new  传进来的factory是什么类型的  此处就会创建对应的具体类
        factory->CreateSplitter();  
    splitter->split();   // 输出I am VideoSplitter
    return 0;
}

要点总结

  • Factory Method模式用于隔离类对象的使用者和具体类型之间的耦合关系。面对一个经常变化的具体类型,紧耦合关系(new)会导致软件的脆弱
  • Factory Method模式通过面向对象的手法,将所要创建的具体对象工作延迟到子类,从而实现一种扩展的策略(不需要根据需求的变化而改变代码,只需要添加子类和类工厂即可),较好的解决了这种紧耦合关系
  • Factory Method模式解决"单个对象"的需求变化,缺点在于要求创建方法/参数相同

模板方法模式(Template Method)

模板方法模式是一种行为设计模式,他在基类中定义了一个算法的框架,允许子类在不修改结构的情况下重写算法的特定步骤
模板方法模式建议将算法分解为一系列步骤,然后将这些步骤改写成方法,最后在模板方法中依次调用这些方法,步骤可以是抽象的,也可以有一些默认的实现,为了能够使用算法,需要自行提供子类并实现所有的抽象步骤

#include <iostream>
using namespace std;
class Library{
public:
	//稳定 template method
	void Run(){
        Step1();
        if(Step2()){  // 支持变化 ==> 虚函数的多态使用
            Step3();
        }
    	for(int i = 0; i < 4; i++)
            Step4();   // 支持变化 ==> 虚函数的多态使用
        Step5();
    }
	virtual ~Library(){}
protected:
	void Step1(){ //稳定
        //....
    }
	void Step3(){ //稳定
        //....
    }
	void Step5(){ //稳定
        //....
    }
	virtual bool Step2() = 0; // 变化
	virtual void Step4() = 0; // 变化
};

class Application : public Library{
protected:
	bool Step2(){
        // ... 子类的重写实现
    }
	void Step4(){
        // ... 子类的重写实现
    }
};

int main(){
    // 创建的对象类型是派生类类型,但是通过基类指针只能访问基类中定义的成员函数或成员变量,而不能直接访问派生类特有的成员
    Library* pLib = new Application(); 
    pLib->Run();
    delete pLib;
    return 0;    
}

要点总结:

  • Template Method模式是一种非常基础性的设计模式,在面向对象系统中有着大量的应用。它用最简洁的机制(虚函数的多态性)为很多应用程序框架提供了灵活的扩展点,是代码复用方面的基本实现结构
  • "不要调用我,让我来调用你"的反向控制结构(基类调用派生类方法,而不是派生类调用基类的方法)是Template Method的典型应用
  • 在具体实现方面,被Template Method调用的虚方法可以具有实现,也可以没有任何实现(抽象方法、纯虚方法),但一般推荐将他们设置为protected方法,因为模板方法的设计目标是将算法的框架定义在基类中,同时允许派生类对其中的某些步骤进行自定义,但不允许外部类或函数直接访问这些步骤。

抽象工厂(Abstract Factory)

抽象工厂提供一个接口,让它能创建一系列相关的对象,而无需指定其具体类
抽象工厂是围绕一个超级工厂创建其他工厂。该超级工厂又称为其他工厂的工厂。
抽象工厂定义了用于创建不同产品的接口,但将实际的创建工作留给了具体工厂类。每个工厂类型都对应一个特定的产品变体
在创建产品时,客户端代码调用的是工厂对象的构造方法,而不是直接调用构造函数(new操作符)。由于一个工厂对应一种产品变体,因此它创建的所有产品都可相互兼容

// 数据库访问有关的基类
class IDBConnection{

}; 

class IDBCommand{

};

class IDataReader{

};

class IDBFactory{
public: 
	virtual IDBConnection* CreateDBConnection()=0;
	virtual IDBCommand* CreateDBCommand()=0;
	virtual IDataReader* CreateDBDataReader()=0;
};

// 支持 SQL Server
class SqlConnection : public IDBConnection{

};

class SqlCommand : public IDBCommand{

};

class SqlDataReader : public IDataReader{

};

class SqlDBFactory : public IDBFactory{
public: 
	virtual IDBConnection* CreateDBConnection()=0;
	virtual IDBCommand* CreateDBCommand()=0;
	virtual IDataReader* CreateDBDataReader()=0;
};

// 支持Oracle
class OracleConnection : public IDBConnection{

};
// oracle也需要创建与sql相似的工厂,这里不写了

class OracleCommand : public IDBCommand{

};

class OracleDataReader : public IDataReader{

};

class EmployeeDAO{
	// 使用时,比如创建sql工厂就dbFactory = new SqlDBFactory()就行
	IDBFactory* dbFactory;  
public:
	vector<EmployeeDAO> GetEmployees(){
        IDBConnection* connection = dbFactory->CreateDBConnection();
        connection->ConnectionString("...");
        IDBCommand* command = dbFactory->CreateDBCommand();
        command->CommandText("...");
        command->SetConnection(connection);  // 关联性
        IDBDataReader* reader = command->ExecuteReader();   // 关联性
        while(reader->Read()){
            
        }
    }
};

要点总结:

  • 如果没有应对多系列对象构建的需求变化(就比如数据库连接时,可能是mysql也可能是oracle),则没有必要使用抽象工厂模式
  • 系列对象指的是在某一特定系列下的对象之间有相互依赖,或作用的关系。不同系列的对象之间不能相互依赖
  • 抽象工厂(Abstract Factory)模式主要在于应对“新系列”的需求变动。其缺点在于难以应对新对象的需求变动(即在基类工厂中不能再加入新的对象方法,否则其派生类需要在进行修改)

原型(Prototype)

模式定义:使用原型实例指定创建对象的种类,然后通过拷贝这些原型来创建新的对象
原型模式使你能够复制已有对象,而又无需使代码依赖它们所属的类
原型模式将克隆过程委派给被克隆的实际对象。模式为所有支持克隆的对象声明一个通用接口,该接口能够克隆对象,同时又无需将代码和对象所属类耦合。通常情况下这样的接口中仅包含一个
克隆
方法
所有的类对克隆方法的实现都非常相似。该方法会创建一个当前类的对象,然后将原始对象所有的成员变量值复制到新建的类中。你甚至可以复制私有成员变量,因为绝大部分编程语言都允许对象访问其同类对象的私有成员变量

// 抽象类
class ISplitter{
public:
	virtual void split()=0;
	virtual ISplitter* clone()=0;  // 通过克隆自己来创建对象
	virtual ~ISplitter(){}
};

// 具体类
class PictureSplitter : public ISplitter{
public:
	virtual ISplitter* clone(){
        return new PictureSplitter(*this);   // 拷贝构造 使用new进行深拷贝
    }
};

class MainForm{
	ISplitter* prototype; //原型对象
public:
	MainForm(ISplitter* prototype){
        this->prototpye = prototype;
    }
	void Button_Click(){
        // 原型对象只是提供我们来克隆,真正使用的时候需要创建新的对象
        ISplitter* splitter = prototype->clone();  // 通过克隆原型得到新对象
        splitter->split();
    }
};

生成器(Builder)

适用场景:当希望用代码创建不同形式的产品时(如石头或木头房屋),这些产品的制造过程相似且仅有细节上的差异,可使用生成器模式
模式定义:将一个复杂对象的构建与其表示相分离,使得同样的构建过程可以创建不同的表示
生成器模式使你能够分步骤创建复杂对象。该模式允许你使用相同的创建代码生成不同类型和形式的对象

class House{
public:
	void Init(){
        this->BuildPart1();
        for(int i = 0; i < 4; i++)
            this->BuildPart2();
        bool flag = this->BuildPart3();
        if(flag)
            this->BuildPart4();
        this->BuildPart5();
    }
	virtual ~House(){}
protected:
	virtual void BuildPart1()=0;
	virtual void BuildPart2()=0;
	virtual bool BuildPart3()=0;
	virtual void BuildPart4()=0;
	virtual void BuildPart5()=0;
};

class StoneHouse : public House{
protected:
	virtual void BuildPart1(){}
	virtual void BuildPart2(){}
	virtual bool BuildPart3(){}
	virtual void BuildPart4(){}
	virtual void BuildPart5(){}
};

int main(){
	House * pHouse = new StoneHouse();
    pHouse->Init();
    return 0;
}

要点总结

  • Builder模式主要用于"分步骤构建一个复杂的对象"。在这其中"分步骤"是一个稳定的算法,而复杂对象的各部分则经常变化
  • 变化点在哪里,封装哪里,Builder模式主要在于应对"复杂对象各个部分"的频繁需求变动。其缺点在于难以应对"分步骤构建算法"的需求变动

生成器和模板方法模式的区别:

  • 生成器模式包括一个Director(指挥者)和一个Builder(生成器)。Director负责指导对象的构建过程,而Builder则负责实际构建对象的细节。
  • 模板方法模式通过一个抽象类定义算法的结构,其中包含一个模板方法,该方法定义了算法的步骤和顺序。其中的具体步骤由子类通过重写来实现。

滑动窗口

class Solution {
private:
    class MyQueue { //单调队列(从大到小)
    public:
        deque<int> que; // 使用deque来实现单调队列
        // 每次弹出的时候,比较当前要弹出的数值是否等于队列出口元素的数值,如果相等则弹出。
        // 同时pop之前判断队列当前是否为空。
        void pop(int value) {
            if (!que.empty() && value == que.front()) {
                que.pop_front();
            }
        }
        // 如果push的数值大于入口元素的数值,那么就将队列后端的数值弹出,直到push的数值小于等于队列入口元素的数值为止。
        // 这样就保持了队列里的数值是单调从大到小的了。
        void push(int value) {
            while (!que.empty() && value > que.back()) {
                que.pop_back();
            }
            que.push_back(value);

        }
        // 查询当前队列里的最大值 直接返回队列前端也就是front就可以了。
        int front() {
            return que.front();
        }
    };
public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
        MyQueue que;
        vector<int> result;
        for (int i = 0; i < k; i++) { // 先将前k的元素放进队列
            que.push(nums[i]);
        }
        result.push_back(que.front()); // result 记录前k的元素的最大值
        for (int i = k; i < nums.size(); i++) {
            que.pop(nums[i - k]); // 滑动窗口移除最前面元素
            que.push(nums[i]); // 滑动窗口前加入最后面的元素
            result.push_back(que.front()); // 记录对应的最大值
        }
        return result;
    }
};

KMP算法

求解Next数组需要理解以下概念:

  • 前缀:包含首位字符但不包含末位字符的子串
  • 后缀:包含末位字符但不包含首位字符的子串
  • next数组的定义:当主串与模式串的某一位字符不匹配时,模式串要 回退的位置
  • next[j]:其值 = 第j位字符前面j-1位字符组成的子串的前后缀重合字符数+1

**next数组规律: **

  • next[j]的值每次最多增加1
  • 模式串的最后一位字符不影响next数组的结果

手算Next数组:(next数组第一位存放字符串长度)

j12345678
Pabaabcac
Next[j]01122312
int GetNext(char ch[], int length, int next[]){
    next[1] = 0;
    int i = 1, j = 0;
    while(i <= length){
        if(j == 0 || ch[i] == ch[j]) 
            next[++i] = ++j;
        else
            j = next[j];
    }
    return next;
}

探索规律背后的原理

  1. next[j+1]的最大值为next[j]+1
  2. 如果 P K 1 ≠ P j P_{K1}\neq P_j PK1=Pj,其中K1=next[j],那么next[j+1]可能的次大值为next[next[j]]+1,以此类推即可高效求出next[j+1]

image.png
KMP算法的完整版:

#include<iostream>
using namespace std;
int* GetNext(char ch[], int length, int next[]){
    next[1] = 0;
    int i = 1, j = 0;
    while(i < length){
        if(j == 0 || ch[i-1] == ch[j-1]) 
            next[++i] = ++j;
        else
            j = next[j];
    }
    return next;
}
int KMP(char s[], char t[],int length){
    int* next = new int[length];
    next = GetNext(t, length, next);
    int i = 1;
    int j = 1;
    while(i <= int(strlen(s)) && j <= int(strlen(t))){
        // 如果j == 0 ,则代表模式串的第一个字符和子串的字符不相等
        if(j == 0 || s[i-1] == t[j-1]){
            i++;
            j++;
        }
        else{
            // 如果两个字符不相等,则移动模式串
            j = next[j];
        }
    }
    if(j > int(strlen(t))){ // 如果为真,则匹配成功
        return i-j;
    }
    return -1;
}

int main(){
    char s[] = "ababcabaabcacbab";
    char t[] = "abaabcac";
	int i = KMP(s, t, strlen(t)+1);
    cout << i << endl;
    return 0;
}


LRU算法

LRU(最近最少使用)是Least Recently Used的缩写,这种算法认为最近使用的数据是热门数据,下一次很大概率将会再次被使用。而最近很少使用的数据,很大概率下一次不会用到。当缓存容量已满的时候,优先淘汰最近很少使用的数据。
LRU算法的具体步骤:

  • 新数据直接插入到列表头部
  • 缓存数据被命中,将数据移动到列表头部
  • 缓存已满的时候,移除列表尾部数据

从步骤可以看出来,LRU算法需要添加头结点、删除尾节点。而链表添加节点/删除节点的时间复杂度为O(1),非常适合当做存储缓存数据容器,但是不能使用普通的单向链表,单向链表有几点劣势:

  1. 每次获取任何节点数据,都需要从头结点遍历下去,这导致获取节点复杂度为O(N)
  2. 移动中间节点到头结点,我们需要知道中间节点前一节点的信息,单向链表就不得不再次遍历获取信息
  3. 删除尾节点的时间复杂度为O(N),需要遍历找到尾节点的前一个节点再进行删除

针对以上问题,可以结合其他数据结构来解决。
使用散列表存储节点,获取节点的复杂度将会降低为O(1)。节点移动问题可以在节点中再增加前驱指针,记录上一个节点的信息,这样链表就从单向链表变成了双向链表。
综上,LRU使用的数据结构为双向链表和散列表(哈希表)的组合,如下图所示,相当于hashmap
image.png
代码实现:

#include<iostream>
#include<unordered_map>
using namespace std;
class LRUCache{
public:
// L | 7 5 8 9 10 | R
	struct Node{
    	int key, val;
    	Node* prev, *next;
    	Node(int k, int v) :key(k),val(v),prev(nullptr),next(nullptr){
            
        }
    };
	unordered_map<int, Node*> mp;

	Node* L, *R;  // 头尾节点
	// 容量
	int n;
	LRUCache(int capacity){
        n = capacity;
        L = new Node(-1, -1);
        R = new Node(-1, -1);
        L->next = R;
        R->prev = L;
    }

	int get(int key){
        if(mp.count(key)){ // 在容器中查找以key键的键值对个数
            Node * node = mp[key];
            remove(node); //从链表中移除该节点
            insert(node->key, node->val);//在链表的最右边插入节点
            return node->val;
        }
        else{
            return -1;
        }
    }

	void put(int key, int val){
        if(mp.count(key)){
            Node* node = mp[key]; //通过hash表获取节点
            remove(node);
            insert(key,val);
        } 
        else{
            if(mp.size() == n){
                Node* node = L->next;
                remove(node);
                insert(key, val);
            }else{
                insert(key, val);
            }
        }
    }
	// 同时在链表和哈希表中删除某个key
	void remove(Node* node){
        Node* pre = node->prev;
        Node* nxt = node->next;
        pre->next = nxt;
        nxt->prev = pre;
        mp.erase(node->key);
    }

	// 同时操作链表和哈希表进行插入
	void insert(int key, int val){
        Node* node = new Node(key,val);
        Node* pre = R->prev;
        Node* nxt = R;
        pre->next = node;
    	node->next = nxt;
        node->prev = pre;
        nxt->prev = node;
        mp[key] = node;
    }

    void printmp(){
        unordered_map<int,Node*>::iterator it;
        for(it=mp.begin(); it !=mp.end(); it++)
        {
            cout << it->first << "-" << it->second->val << endl;
        }
    }

};

int main(){
    LRUCache cache(2);
    cache.put(1,7);
    cache.put(2,5);
    cache.printmp();
    cout << endl;
    cache.put(3,8);
    cache.printmp();
    cout << endl;
    cout << cache.get(1) << endl;
    cout << cache.get(2) << endl;
    cout << endl;
    cache.put(2, 3);
    cache.printmp();
    cout << endl;
    cache.put(4,9);
    cache.put(5,10);
    cache.printmp();
    return 0;
}

结果:
image.png

最高频率三个单词

使用unordered_map对字符串中单词进行统计,然后再对unordered_map中的val值进行排序

#include <unordered_map>
#include <vector>
#include <algorithm>
using namespace std;
unordered_map<string, int> my_map = {{"tea",5}, {"tree",3}, {"aasd",4}};

vector<pair<string, int>> sorted_pairs;
for (auto it : my_map) {
    sorted_pairs.push_back(it);
}

sort(sorted_pairs.begin(), sorted_pairs.end(),
    [](const pair<string, int> &a, const pair<string, int> &b) {
        return a.second < b.second;
    });

完整代码:

#include <iostream>
#include <string>
#include <unordered_map>
#include <vector>
#include <algorithm>
#include <sstream>

int main() {
    std::string text = "This is a sample text. This text contains some repeated words. Sample text.";

    // 使用unordered_map来存储单词频率
    std::unordered_map<std::string, int> wordFrequency;

    // 使用stringstream将文本分割为单词
    std::istringstream iss(text);
    std::string word;
    while (iss >> word) {
        // 去除标点符号,可以根据需要进一步处理
        word.erase(std::remove_if(word.begin(), word.end(), ::ispunct), word.end());

        // 更新单词频率
        wordFrequency[word]++;
    }

    // 使用vector将单词频率数据排序
    std::vector<std::pair<std::string, int>> sortedFrequency(wordFrequency.begin(), wordFrequency.end());
    std::sort(sortedFrequency.begin(), sortedFrequency.end(), [](const auto& a, const auto& b) {
        return a.second > b.second; // 根据频率降序排序
    });

    // 输出前三个最高频率的单词
    std::cout << "最高频率的三个英文单词:" << std::endl;
    int count = 0;
    for (const auto& pair : sortedFrequency) {
        if (count < 3) {
            std::cout << pair.first << ": " << pair.second << std::endl;
            count++;
        } else {
            break;
        }
    }

    return 0;
}

快排

排序算法判断稳不稳定:相等的数,相对位置会不会发生改变,一般涉及交换的排序算法都是不稳定的比如快排和选择排序
为什么要从右指针先移动?
每次从右往左开始找,j停在了比基准值小的数的位置上,与i相遇,将这个值与基准值交换,符合条件
要是从左往右开始找,当i停在了比基准值大的位置上,与j相遇,将这个值与基准值交换的话,就不符合条件了

#include<iostream>
#include<vector>
using namespace std;
void swap(int & a, int & b){
    int temp = a;
    a = b;
    b = temp;
}

void quickSort(vector<int> &nums, int left, int right){
    if(left >= right) return;
    int i = left, j = right;
    int k = nums[left];
    while(i < j){
        while(i < j && nums[j] >= k)
            j--;
        while(i < j && nums[i] <= k) 
            i++;
        if(i < j) // 减少最后一次i=j时的交换
        	swap(nums[i],nums[j]);
    }
    swap(nums[left], nums[j]);
    quickSort(nums,left, i-1);
    quickSort(nums, i+1, right);
}
int main(){
	vector<int> nums = {3, 5, 1, 2, 6, 7, 8, 4};
    quickSort(nums, 0, nums.size() -1);
    for(int i = 0 ; i < nums.size(); i++)
            cout << nums[i] << " " ;
    return 0;
}

希尔排序

  1. 间隔分组
  2. 组内排序
  3. 重新设置间隔分组(为前一次分组的一半)
  4. 插入排序
void shell_sort(int arr[], int n){
    int i,j,inc,key;
    //初始增量:n/2  每一趟之后除以二
    for(inc = n/2; inc > 0; inc /=2){
        //每一趟采用插入排序
        for(i = inc; i < n; i++){
            key = arr[i];
            for(j = i; j >= inc && key < arr[j-inc]; j-=inc){
                arr[j] = arr[j-inc];
            }
            arr[j] = key;
        }
    }
}

归并排序

首先递归分组,然后依次将已有序的子序列合并,得到完全有序的序列

// 合并
void merge(int arr[], int tempArr[], int left, int mid, int right){
    // 标记左半区第一个未排序的元素
    int l_pos = left;
    // 标记右半区第一个未排序的元素
	int r_pos = mid + 1;
    // 临时数组元素的下标
	int pos = left;
    //合并
	while(l_pos <= mid && r_pos <= right){
        if(arr[l_pos] < arr[r_pos])
            tempArr[pos++] = arr[l_pos++];
        else
            tempArr[pos++] = arr[r_pos++];
    }
    //合并左半区剩余元素
	while(l_pos <= mid)
        tempArr[pos++] = arr[l_pos++];
    //合并右半区剩余元素
	while(r_pos <= right)
        tempArr[pos++] = arr[r_pos++];
    // 把临时数组中合并后的元素复制回原来的数组
    while(left <= right){
        arr[left] = tempArr[left];
        left++;
    }
}

// 归并排序
void msort(int arr[], int tempArr[], int left, int right){
	// 如果只有一个元素,那么久不需要继续划分
    // 只有一个元素的区域,本生就是有序的,只需要被归并即可
    if(left < right){
        int mid = (left + right) / 2;
        // 递归划分左右半区
        msort(arr, tempArr, left, mid);
        msort(arr, tempArr, mid+1, right);
        // 合并左右半区
        merge(arr, tempArr, left, mid, right);
    }
}

// 归并排序入口
void merge_sort(int arr[], int n)
{
    // 分配一个辅助数组
    int * tempArr = new int[n];
    msort(arr, tempArr, 0, n-1);
}

堆排序

void swap(int* a, int* b){
    int temp = *a;
    *a = *b;
    *b = temp;
}

//维护堆性质
// i表示待维护节点的下标
void heapify(int arr[], int n, int i){
    int largest = i;
    int lson = 2*i +1;
    int rson = 2*i +2;
    if(lson < n && arr[largest] < arr[lson])
        largest = lson;
    if(rson < n && arr[largest] < arr[rson])
        largest = rson;
    if(largest != i){
        swap(&arr[largest], &arr[i]);
    	heapify(arr, n, largest); // 交换的位置可能破坏堆性质,因此需要递归维护
    }
}

void heap_sort(int arr[], int n){
    int i;
    //建堆
    for(i = n/2 -1; i>=0; i--){
        heapify(arr,n,i);
    }
    // 排序
    for(i = n - 1; i >0; i--){
        swap(&arr[i], &arr[0]);
        heapify(arr, i, 0);
    }
}

汉诺塔移动次数和移动过程

对于n层塔的移动步数: 2 n − 1 2^n-1 2n1
公式: f ( n ) = 1 ( n = 1 ) f(n) = 1(n=1) f(n)=1(n=1) f ( n ) = 2 ∗ f ( n − 1 ) + 1 ( n > 1 ) f(n) = 2*f(n-1)+1(n>1) f(n)=2f(n1)+1(n>1)

// 汉诺塔移动次数
int f(int n){
    if(n == 1)
        return 1;
    return 2*f(n-1) + 1;
}

// 汉诺塔移动过程
void f(int n, char L, char M, char R){
    if(n == 1)
    	cout << L << "->" << R << endl;
    else{
        f(n-1, L, R, M);   //左子树遍历
    	cout << L << "->" << R << endl;
    	f(n-1, M, L, R);   // 右子树遍历
    }
}

拿球必胜问题

假设有N个球,甲乙两个人先后拿球,每次只能取1-M个球,谁拿到最后一个球就获胜,则甲先拿多少个球必胜?
如果N%(M+1)不等于0,则甲先拿这些个数的球,然后之后每次乙拿多少个球,都补充到M+1个,这样甲一定能拿到最后一个球,如果N%(M+1)等于0,则甲先拿多少个球都会输。

动态规划

动归五部曲:

  1. 确定dp数组、下标以及dp数组的含义
  2. 确定递推公式
  3. 确定dp数组如何初始化
  4. 确定遍历顺序
  5. 打印dp数组(主要用来debug)

最长递增子序列问题

int lengthOfLIS(vector<int>& nums) {
    int n = nums.size();
    if(n == 0)
        return 0;
    int res = 1;
    vector<int> dp(n, 1);
    for(int i = 0; i < n; i++){
        for(int j = 0; j < i; j++){
            if(nums[i] > nums[j]){
                dp[i] = max(dp[j]+1, dp[i]);
                res = max(res, dp[i]);
            }
        }
    }
    return res;
}

最长连续递增序列

class Solution {
public:
    int findLengthOfLCIS(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp(n,1);
        int res = 1;
        for(int i = 1; i < n; i++){
            if(nums[i] > nums[i-1]){
                dp[i] = dp[i-1] +1;
                res = max(res, dp[i]);
            }
        }
        return res;
    }
};

买卖股票最佳时机问题

有一支股票,含有n天的股票价格,只能进行买卖一次股票,使得其获得的利润最大。

dp[i][0] 表示持有该股票的最大利润
dp[i][1] 表示不持有该股票的最大利润
class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int n = prices.size();
        int dp[n][2];
        dp[0][0] = -prices[0];  // 持有该股票的价值
        dp[0][1] = 0;  // 不持有该股票的价值       
        for(int i = 1; i < n; i++){
            dp[i][0] = max(dp[i-1][0], -prices[i]);
            dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i]);
        } 
        return dp[n-1][1];
    }
};

若能进行多次股票买卖则代码如下:

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int n = prices.size();
        vector<vector <int>> dp(n);
        for(int i = 0 ; i < n; i++)
            dp[i].resize(2);
        dp[0][0] = -prices[0];  // 持有该股票的价值
        dp[0][1] = 0;  // 不持有该股票的价值    
        for(int i = 1; i < n; i++){
            // 差别在于第二天持有该股票的金钱时,需要用上一天不持有该股票的金钱减去今天的股票
            dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i]);
            dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i]);
        }
        return dp[n-1][1];
    }
};

背包问题

01背包的递推表达式(二维表达式):

// i表示第一个物品,j表示背包重量,dp[i][j]表示遇见第i个物品时,背包容量为j时的最大价值
for(int i = 1; i < n; i++){
    for(int j = 1; j < bagweight; j++){
        dp[i][j] = max(dp[i-1][j],dp[i-1][j-weight[i] + value[i]]);
	}
}

进行一维压缩:

// 一维dp数组进行01背包时,需要倒序遍历重量,否则会覆盖前面的数据
for(int i = 0; i < n; i++){
    for(int j = bagweight; j >= weight[i]; j--){
        dp[j] = max(dp[j], dp[j-weight[i]]+value[i]);
    }
}

数组指针自增操作

数组名是指针常量,不能自增操作修改,而指向数组的指针是指针变量可以自增修改操作
数组名可以进行加一操作,如arr+1,表示数组的第二个元素的地址

编译过程

源文件(.c)->预处理(.i)->编译(.s)->汇编(.o)->链接
预处理:条件编译,头文件包含,宏替换的处理 gcc -E 生成.i文件 gcc -E hello.c -o hello.i
编译:预处理后的文件转换为汇编语言 gcc -S 生成.s文件
汇编:产生目标文件(.o) gcc -c 生成.o文件
链接:链接目标文件,生成可执行程序 gcc 生成可执行文件 gcc hello.o -o hello

C++11特性

智能指针,shared_ptr,unique_ptr和weak_ptr
引入auto关键字,自动判断变量类型
Lambda表达式:允许在函数内部定义匿名函数,即创建和使用没有显式名称的函数
比如sort函数第三个参数可以通过lambda表达式来定义排序是升序还是降序

auto sayHello = []() {
    std::cout << "Hello, Lambda!" << std::endl;
};

// Lambda表达式示例 2: 带参数的Lambda
auto add = [](int a, int b) -> int {
    return a + b;
};

sort(numbers.begin(), numbers.end(), [](int a, int b) {
        return a < b;  // 升序排序 若要降序排序,可以使用 return a > b;
});

引入了for循环的新语法,可以方便遍历容器或数组,比如(for auto number : numbers)numbers是vector容器,每次循环迭代都会自动获取vector下一个元素

函数重载

在同一个作用域内,可以声明几个功能类似的同名函数,但是这些同名函数的形式参数(指参数的个数、类型或者顺序)必须不同
不同的参数列表:重载的函数必须具有不同的参数列表,这可以通过参数的类型、数量或顺序来区分。至少有一个参数的类型、数量、或顺序不同。

野指针

野指针是指指向已经释放或无效的内存地址或者没有初始化的指针
如何避免野指针?

  1. 在声明指针时就初始化指针,为null或合适的地址
  2. 在释放指针之后,及时将指针指向nullptr
  3. 使用智能指针,他能够在对象不再需要时自动释放内存
  4. 确保不要返回指向在函数退出后将被销毁的局部变量的指针

内存泄漏

内存泄漏是用动态存储分配函数动态开辟的空间,在使用完毕后未释放,结果导致一直占据该内存单元,直到程序结束

命名冲突

  1. 使用命名空间,将标识符放入不同的命名空间中,可通过命名空间限定符来指定使用的变量
  2. 使用别名,可以使用别名来明确指定使用哪个命名空间中的标识符,如
namespace A {
    int value = 1;
}

namespace B {
    int value = 2;
}

int main() {
    using namespace A;
    int a = value; // 使用A命名空间的value
    int b = B::value; // 使用B命名空间的value
    return 0;
}

宏定义作用范围

宏定义的作用范围取决于宏定义的位置和作用域。
如果宏定义在全局作用域(在任何函数外部)或在头文件中,它将具有全局作用范围,可以在整个源文件或多个源文件中访问
如果宏定义在函数内部或者某个{}内部,它将具有局部作用范围,出了函数或{}作用域则无法访问

函数指针与指针函数

函数指针是指一个指向函数的指针变量,它可以动态调用不同的函数

int add(int a, int b) {
    return a + b;
}

int main() {
    int (*operation)(int, int);  // 声明一个函数指针
    operation = add;             // 将函数指针指向add函数
    int result = operation(5,3);   

指针函数本质是一个函数,其返回值为指针。
函数指针本质是一个指针,其指向一个函数。
指针函数:int* fun(int x,int y);
函数指针:int (*fun)(int x,int y);
函数名带括号的就是函数指针,否则就是指针函数

C++中的类型转换操作符(四个cast)

静态转换(static_cast):用于进行通用的类型转换,例如将整数类型转换为浮点类型,转换失败的话会抛出一个编译错误

float result = static_cast<float>(intvalue);

动态转换(dynamic_cast):通常用于类层次结构中:主要用于类层次结构中的类型安全的向下转换。它用于检查是否可以将基类指针安全的转换为派生类指针,
对于指针,如果转换失败返回NULL,对于引用,如果转换失败则抛出bad_cast异常

DerivedClass * derive = dynamic_cast<DerivedClass*>(basePtr);

常量转换(const_cast):用于添加或去除变量的const修饰符

const int * constptr = const_cast<int*> (mutablePtr);   //将可变指针变为常量指针

强制类型转换(reinterpret_cast):用于执行低级别的转换,通常用于将指针从一个类型转换为另一个完全不同的类型

如何保证static_cast(类型转换)的安全性

  1. 仅在可以进行隐式转换的类型之间使用,比如整数类型之间的转换
  2. 不要进行不相关类型之间的转换
  3. 不要进行基类指针到派生类指针的转换

如何判断字节序大端小端

  1. 使用联合体Union,创建一个联合体,其中包含一个整型和一个字符数组,数组长度为int类型的大小。然后将整型变量赋值为1,若检查字符数组的第一个字节等于1则是小端,否则为大端
  2. 使用指针转换,创建一个整型变量num赋值为1,然后创建一个指向 num 的字符类型指针 ptr,通过判断指针指向的字节来判断大端小端。若指针指向的值为1,则为小端,否则为大端
int num = 1;
char *ptr = (char *)&num;

extern关键字

extern声明告诉编译器在连接时查找该符号的定义,声明外部的变量或函数,而不是在当前文件中创建一个新变量或函数
主要有两个用途:

  1. 外部变量声明,当想在一个文件中使用另一个文件定义的全局变量时,可以使用extern来声明该变量,这样就知道该变量的定义在其他文件中
  2. 函数声明,当想在一个文件中调用另一个文件中定义的函数时,可以使用extern来声明该函数,这样就知道该函数的定义在其他文件中
extern int x;

QT理解程度

QT是一个C++跨平台应用程序开发框架,用于创建图形用户界面(GUI)应用程序。其中最重要的核心概念就是信号与槽,它用于实现对象之间的通信和事件处理。信号是Qt中的事件或状态的通知器,它是由一个对象发出,以指示发生了某种事件或状态的变化。信号通常与特定的事件相关联,比如按钮被点击、文本字段的内容变化等。槽是用于响应信号的函数或方法。它可以连接到一个或多个信号,以在信号触发时执行相关的操作。然后通过connect将信号与槽关联起来,连接(connect)通常是在应用程序的初始化阶段或在对象创建时建立。
信号与槽机制允许多个对象同时监听同一个信号,以便并发处理事件,信号与槽机制实现了松耦合,使得不同模块之间的通信更加灵活和可维护。

可以将任意数量的信号连接到单个槽上,信号也可以连接到任意数量的槽上,如果将多个槽函数连接到一个信号,则发出信号时,将按照连接的顺序依次执行槽函数。甚至可以将一个信号连接到另一个信号上(此时,发出第一个信号时立即触发第二个信号)。一般情况下,发出信号后,与其连接的槽函数会立即执行。一旦所有槽函数都返回后,将继续执行emit语句之后的代码。当排队连接时,emit关键字之后的代码不会等待槽函数执行完,而是将继续执行emit后的代码,并且稍后执行槽函数。

connect(发送方, SIGNAL(...), 接收方, SLOT(..);

connect函数的第五个参数是Qt::ConnectionType,它代表信号和槽的连接类型。 它决定了信号和槽在何时执行,是同步执行还是异步执行。 Qt::AutoConnection和Qt::DirectConnection是两种常用的连接类型,分别代表自动连接和直接连接。
1、Qt::AutoConnection: 默认值,使用这个值则连接类型会在信号发送时决定。如果接收者和发送者在同一个线程,则自动使用Qt::DirectConnection类型。如果接收者和发送者不在一个线程,则自动使用Qt::QueuedConnection类型。
2、Qt::DirectConnection:槽函数会在信号发送的时候直接被调用,槽函数运行于信号发送者所在线程。效果看上去就像是直接在信号发送位置调用了槽函数。这个在多线程环境下比较危险,可能会造成奔溃。
3、Qt::QueuedConnection:槽函数在控制回到接收者所在线程的事件循环时被调用,槽函数运行于信号接收者所在线程。发送信号之后,槽函数不会立刻被调用,等到接收者的当前函数执行完,进入事件循环之后,槽函数才会被调用。多线程环境下一般用这个。
4、Qt::BlockingQueuedConnection:槽函数的调用时机与Qt::QueuedConnection一致,不过发送完信号后发送者所在线程会阻塞,直到槽函数运行完。接收者和发送者绝对不能在一个线程,否则程序会死锁。在多线程间需要同步的场合可能需要这个。
5、Qt::UniqueConnection:这个flag可以通过按位或(|)与以上四个结合在一起使用。当这个flag设置时,当某个信号和槽已经连接时,再进行重复的连接就会失败。也就是避免了重复连接。

Qt信号槽机制的优缺点

优点

  • 松耦合:信号槽机制可以实现组件之间的松耦合,组件之间不需要直接相互调用,只需要通过信号和槽进行通信即可,这样可以降低组件之间的耦合度,提高代码的复用性和可维护性。
  • 灵活性:信号槽机制可以实现非常灵活的事件处理,可以动态的连接和断开信号和槽,可以在运行时动态修改信号和槽的参数等。
  • 易于扩展:信号槽机制可以非常容易地扩展新的事件和处理逻辑,只需要定义新的信号和槽即可,无需修改原有代码。
  • 跨线程:信号槽机制可以支持跨线程的事件处理,可以将信号和槽连接在不同的线程中,这样可以实现线程之间的通信。

缺点

  • 速度慢:信号槽机制的性能相对于直接调用函数来说会有一定的开销,因为它需要进行信号的发射和槽的执行,而且还需要维护信号槽的连接关系。
  • 调试:信号槽机制是基于事件驱动的编程模型,调试比较困难,特别是在信号和槽之间存在多层嵌套的情况下

模态窗口和非模态窗口

模态窗口阻塞了父窗口和其他兄弟窗口的交互,直到对话框被关闭,用于需要先处理模态对话框,才能回到主窗口,比如警告框、确认框等。
在Qt中,使用exec()方法显示模态对话框,他会阻塞调用线程直到对话框关闭为止
非模态窗口不会阻塞父窗口或其他窗口的交互,允许用户同时与对话框和应用程序的其他部分进行交互,比如提供辅助功能、工具等。
在Qt中使用show()或open()方法来显示非模态对话框,这些方法不会阻塞调用线程

栈溢出、堆溢出

堆是在程序进行动态分配时的内存。在使用new,malloc的时候可能会产生堆溢出。
栈是在保存函数列表,函数参数和函数返回地址的内存。在函数递归的空间太多的时候会产生栈溢出。

完全、满二叉树,红黑树、二叉查找树,AVL数

完全二叉树:一颗深度为k的有n个节点的二叉树,对树中的节点按从上至下,从左至右的顺序进行编号,如果编号为i的结点与满二叉树中编号为i的节点在二叉树中的位置相同,则这颗二叉树称为完全二叉树
满二叉树:除最后一层无任何子节点外,每一层上的所有节点都有两个子节点,即除叶子节点外的所有节点均有两个子节点。节点数达到最大值。所有叶子结点必须在同一层上。
二叉查找树(二叉搜索树、二叉排序树):它或者是一颗空树,或者是具有以下性质的二叉树:若它的左子树不空,则左子树上所有节点的值均小于它的根节点的值,若它右子树不为空,则右子树上所有节点的值均大于它的根节点的值,它的左右子树也分别为二叉排序树
**红黑树:**红黑树是一种平衡二叉查找树,每个节点额外存储了一个color字段(red或black),用于确保树在插入和删除时保持平衡。
一颗合法的红黑树必须遵循以下四条性质:

  1. 节点为红色或黑色
  2. NIL节点(空叶子结点)为黑色
  3. 红色节点的子节点为黑色
  4. 从根节点到NIL节点的每条路径上的黑色节点数量相同

image.png
**AVL树:**是一种自平衡二叉搜索树,每个节点都有一个平衡因子,平衡因子可以是-1、0或1。插入删除时间复杂度为O(log n)

堆排序、堆、快排

是一颗完全二叉树,堆分为两种类型:最大堆和最小堆。在最大堆中,父节点的值大于或等于其子节点的值;而在最小堆中,父节点的值小于或等于其子节点的值
堆排序:比如升序,首先构建一个大根堆,将待排序的数组看做是一个完全二叉树,通过从最后一个非叶子节点(len/2 - 1)开始,逐步向上调整,使得每个节点都满足最大堆的性质,即父节点的值大于或等于其子节点的值,然后交换根节点和最后一个叶子结点,并将堆的大小减一,接着,对剩余的元素重新调整堆,使堆顶元素再次成为最大值。重复这个过程,直到堆中的所有元素都被取出
时间复杂度O(nlogn) 空间复杂度O(1) 不稳定的排序

快排:首先要找一个数字作为基准数。我们一般选择第 1 个数字作为基准数。接下来我们需要把这个待排序的数列中小于基准数的元素移动到待排序的数列的左边,把大于基准数的元素移动到待排序的数列的右边,确定基准数的位置。这时,左右两个分区的元素就相对有序了;接着把两个分区的元素分别按照上面两种方法继续对每个分区找出基准数的位置,然后移动,直到各个分区只有一个数时为止。
平均时间复杂度O(nlogn) 最坏时间复杂度(每次选的基准数都是最大或最小元素)O(n^2) 空间复杂度O(logn) 不稳定的排序

指针和引用区别

内存地址:指针存储的是变量的内存地址,而引用则是变量的别名,指针可以指向任意的内存地址,包括空地址,而引用必须在声明时初始化,并始终引用同一个变量
空间占用:指针本身占用内存空间,而引用不占用额外的内存空间,它是变量的别名

C++的好处或优势

C++是编译型语言,它的执行速度通常比解释型语言(如python)更快。C++经过编译后直接转化为机器码,运行效率高,它可以直接操作内存、指针和位级操作(对数据的二进制表示直接进行操作)。C++拥有丰富的标准库(iostream、vector、string等)和第三方库(opencv、Qt、VTK等),提供了大量的功能和工具。

如何让一个对象只在栈上分配内存

重载new(也需要重载delete),将operator new设置为私有,即可禁止对象被new在堆上

如何只在堆上分配内存

只在堆上分配类对象,就是不能静态建立类对象,即不能直接调用类的构造函数
将析构函数设为私有,类对象就无法建立在栈上了

时间复杂度和空间复杂度

时间复杂度:算法的执行时间,或者说是算法中语句的执行次数
空间复杂度:算法所需内存的大小,用于对程序运行过程中所需要的临时存储空间的度量

内存对齐

内存对齐是编译器将程序中的每个数据单元安排在合适的位置上
为什么要内存对齐?
内存是以字节为单位存储的,但是处理器存取内存是以块为单位,块的大小是2,4,8,16字节大小,这是内存的存取粒度。从内存存取效率方面,内存对其可以提升CPU的存取内存效率,假如有一个int整型变量,有一块内存单元,地址0~7,那么这个整型变量在未内存对齐情况存放在1,2,3,4,处理器假设4字节读,则需要两次访问(先读0-3,再读4-7)。如果内存对齐,这个整型变量会存放在0开始的地址,只需要读一次,内存效率提升
内存对齐的好处:提升性能,减少CPU访问内存次数
内存对齐原则:结构体总体大小可被最宽成员大小整除;结构体起始地址可被最宽成员大小整除;自身起始地址偏移能被自身整除
可以使用预处理命令**#pargma pack(n)**更改内存对齐方式

智商题

现在有2 顶黑帽子,3 顶白帽子,主持人用黑布蒙上A、B、C三人的眼睛,并在每个人头上戴一顶帽子,剩下的两顶藏起来,然后解开B、C的蒙眼布,让他们三个人分别说出自己头上戴的帽子颜色,B、C看着别人头上的帽子却都回答不上来。这个时候A却说他知道了 ,她戴的是白色。主持人说对,请问 A是怎么推理的?
:因为丙说不知道自己帽子什么颜色就可以得出乙甲的帽子颜色为白白、黑白、白黑三种可能,乙如果看到甲的帽子颜色是黑色的,那么他就可以得出自己的帽子不是黑色,因为只有两顶黑色帽子,甲的帽子的颜色是白色的时候乙猜不出来他的帽子是什么颜色,从乙说不知道他自己帽子什么颜色可以得出甲的帽子颜色为白色。

有一位老师,拿了五顶帽子,其中三顶白的,两顶黑的,给三个学生看了看,然后让他们闭上眼睛,给每个学生戴上一顶帽子,并把另外两顶帽子藏起来。最后让学生睁开眼睛,要他们判断自己头上戴的是什么颜色的帽子。三个学生互相望了望,犹豫了一会儿,忽然,一个同学说:我戴的是白帽子。另一位同学也马上说:我戴的也是白帽子。第三位同学说:我戴的是黑帽子。他们都答对了。请问:他们是怎样知道自己所带帽子的颜色的?
:三个人,帽子的格局只能是三白,两百一黑,两黑一白。若两黑一白则有一人看到两黑必不会犹豫,故知自己是白,另外两人看到他没有犹豫的说出自己是白,说明他看到了两黑,所以知道自己是黑。若一黑两白,三人首先都会犹豫,但是思考过后排除了两黑一白的格局,若有人看到了一黑一白,则一定知道自己是白(因为看到了一黑一白则不可能是三白的格局,因为大家犹豫了所以不可能是两黑一白的格局,则一定是两白一黑。两白一黑又看到了另外两人是一黑一白则知自己一定是白。)。所以犹豫之后知道自己是白,同理另外一个白也会知道自己是白,这时候帽子为黑的人就会知道自己一定是黑,不然另外两人不可能犹豫不久就有结论。若为三白,则过了n久之后大家都没有任何结论,则知自己为白。

  • 37
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值