Skip to content

字节C++后端面经

来源:https://www.nowcoder.com/discuss/801171624639692800

1、HTTP请求中有哪些请求方式?

HTTP定义了一组请求方法,用来指明对指定资源要执行的操作。这些方法也常被称为“HTTP动词”。尽管有多种请求方法,但最常用的是 GETPOST

HTTP请求方法

以下是HTTP/1.1标准中定义的几种主要请求方法及其用途:

请求方法描述与用途特性
GET请求获取指定资源的表示形式。GET请求应该是安全且幂等的,即多次相同的GET请求应返回相同的结果,且不应对服务器状态产生任何副作用。安全、幂等、可缓存
POST用于将实体提交到指定的资源,通常导致在服务器上的状态变化或副作用。例如,创建新用户、提交表单或发布消息。非安全、非幂等、通常不可缓存
PUT用请求的有效载荷替换目标资源的所有当前表示。可以理解为“更新”或“替换”。如果目标资源不存在,PUT可以创建一个新的资源。非安全、幂等
DELETE删除指定的资源。非安全、幂等
PATCH用于对资源进行部分修改。与PUT不同,PATCH只更新资源的一部分,而不是整个替换。非安全、非幂等
HEAD请求一个与GET请求的响应相同的响应,但没有响应体。常用于在不实际下载整个资源的情况下,获取资源的元数据(如响应头信息)。安全、幂等、可缓存
OPTIONS用于描述目标资源的通信选项。客户端可以查询服务器支持哪些HTTP方法,或者查询服务器的某些特性(例如,在CORS中用于预检请求)。安全、幂等
CONNECT建立一个到由目标资源标识的服务器的隧道。通常用于通过HTTP代理进行SSL加密的通信(HTTPS)。-
TRACE沿着到目标资源的路径执行一个消息环回测试。主要用于诊断。安全、幂等

安全:指请求方法不会改变服务器上的资源状态。例如,GETHEAD被认为是安全的。

幂等:指相同的请求执行一次与执行多次,对服务器资源产生的影响是相同的。GETPUTDELETE都是幂等的,而POSTPATCH不是。

2、HTTPS是如何保证链接安全的?

HTTPS (Hypertext Transfer Protocol Secure) 并非一个全新的协议,而是HTTP协议与SSL/TLS协议的组合。

它通过在HTTP和TCP层之间加入SSL/TLS (Secure Sockets Layer/Transport Layer Security) 协议层,为数据传输提供了加密、身份认证和数据完整性保护,从而确保了通信的安全性。

HTTPS主要通过以下机制来保证链接安全:

  1. 数据加密 (Encryption):

    • 混合加密机制:HTTPS结合了非对称加密和对称加密的优点。在连接建立初期(TLS握手阶段),使用非对称加密来安全地交换一个对称密钥。一旦对称密钥协商成功,后续的所有应用层数据都将使用这个对称密钥进行加密和解密。对称加密的效率远高于非对称加密,因此适合大量数据的传输。
    • 防止窃听:即使数据包被中间人截获,由于没有正确的密钥,也无法解密获取原始信息。
  2. 身份认证 (Authentication):

    • 数字证书:服务器会向客户端提供一个数字证书。这个证书由受信任的第三方机构——证书颁发机构 (CA, Certificate Authority) 签发。证书中包含了服务器的公钥、服务器的身份信息(如域名)以及CA的数字签名。
    • 信任链验证:客户端收到证书后,会验证证书的有效性(是否过期、是否被吊销)、是否由受信任的CA签发。通过验证CA的数字签名,客户端可以确认服务器的身份是真实的,而非伪造的。这有效防止了“中间人攻击 (Man-in-the-Middle Attack)”,确保客户端连接的是预期的服务器。
  3. 数据完整性 (Data Integrity):

    • 消息认证码 (MAC) 或数字签名:在数据传输过程中,SSL/TLS协议会使用消息认证码 (MAC) 或数字签名来验证数据的完整性。发送方在发送数据前会计算数据的哈希值,并用密钥对其进行加密(或使用数字签名),然后将加密后的哈希值与数据一同发送。接收方收到数据后,会用相同的算法和密钥重新计算哈希值,并与接收到的哈希值进行比对。
    • 防止篡改:如果数据在传输过程中被篡改,哈希值将不匹配,接收方就能立即发现数据已被破坏或篡改,从而拒绝处理该数据。

HTTPS安全机制

TLS/SSL握手过程简述: 当客户端发起HTTPS请求时,会经历一个TLS/SSL握手过程:

  1. 客户端Hello:客户端发送支持的TLS版本、加密套件列表、随机数等。
  2. 服务器Hello:服务器选择一个TLS版本和加密套件,并发送其数字证书和另一个随机数。
  3. 证书验证:客户端验证服务器证书的合法性。
  4. 密钥交换:客户端生成一个预主密钥,用服务器公钥加密后发送给服务器。服务器用私钥解密得到预主密钥。双方再通过各自的随机数和预主密钥生成相同的会话密钥(对称密钥)。
  5. 加密通信:握手完成后,客户端和服务器使用协商好的会话密钥进行对称加密通信。

通过上述机制,HTTPS构建了一个安全可靠的通信通道,保护了用户数据的隐私和安全。

3、HTTPS的加密方式是怎样的?对称还是非对称?

HTTPS的加密方式是混合加密,它结合了对称加密和非对称加密两种方式的优点。

这两种加密方式在HTTPS中扮演着不同的角色,共同确保了通信的安全性和效率。

HTTPS加密方式

3.1、非对称加密

  • 用途:主要用于密钥交换和身份认证。
  • 原理:非对称加密使用一对密钥:公钥 (Public Key) 和私钥 (Private Key)。公钥可以公开,私钥必须保密。
    • 用公钥加密的数据,只能用对应的私钥解密。
    • 用私钥加密的数据(数字签名),只能用对应的公钥解密。
  • 在HTTPS中的应用:
    • 密钥协商:在TLS握手阶段,客户端会使用服务器的公钥来加密一个随机生成的对称密钥(或用于生成对称密钥的预主密钥),然后发送给服务器。只有拥有对应私钥的服务器才能解密得到这个对称密钥。
    • 身份认证:服务器的数字证书中包含其公钥,并由受信任的CA使用其私钥进行数字签名。客户端使用CA的公钥验证证书的数字签名,从而确认服务器的身份是真实的。
  • 特点:安全性高,但加密和解密速度慢,不适合大量数据的传输。

3.2、对称加密

  • 用途:主要用于实际数据传输。
  • 原理:对称加密使用同一个密钥进行加密和解密。发送方和接收方必须共享这个密钥。
  • 在HTTPS中的应用:
    • 数据传输:一旦TLS握手完成,客户端和服务器就通过非对称加密安全地协商出了一个会话密钥(对称密钥)。之后的所有应用层数据(如HTTP请求和响应)都将使用这个会话密钥进行对称加密和解密。
  • 特点:加密和解密速度快,效率高,适合大量数据的传输,但密钥的传输和管理是其安全性的挑战。

混合加密的工作流程

HTTPS通过以下步骤实现混合加密:

  1. TLS握手阶段:客户端和服务器使用非对称加密算法(如RSA、Diffie-Hellman)进行身份认证和安全地交换一个对称密钥。
  2. 数据传输阶段:一旦对称密钥协商成功,客户端和服务器就会使用这个对称密钥对后续的所有通信数据进行对称加密和解密。

这种混合加密方案完美地结合了两种加密方式的优点:非对称加密解决了对称密钥安全传输的问题,而对称加密则保证了数据传输的效率。

4、HTTP的状态码都有哪些,代表什么意思?

HTTP状态码是由3位数字组成的代码,作为HTTP响应的一部分,用来表示服务器对客户端请求的处理结果。它们被分为五大类,由第一位数字标识:

  • 1xx (信息性状态码):表示请求已被接收,继续处理。
  • 2xx (成功状态码):表示请求已成功被服务器接收、理解、并接受。
  • 3xx (重定向状态码):表示需要客户端采取进一步的操作才能完成请求。
  • 4xx (客户端错误状态码):表示客户端的请求有语法错误或请求无法实现。
  • 5xx (服务器错误状态码):表示服务器在处理请求的过程中发生了错误。

以下是一些常见且重要的HTTP状态码及其含义:

2xx (成功)

状态码名称 (Name)含义
200OK请求已成功。响应的有效载荷取决于请求方法。
201Created请求成功并且服务器创建了新的资源。
202Accepted服务器已接受请求,但尚未处理。
204No Content服务器成功处理了请求,但没有返回任何内容。

3xx (重定向)

状态码名称 (Name)含义
301Moved Permanently请求的资源已永久移动到新URI。浏览器会自动重定向到新地址,搜索引擎会更新其索引。
302Found (Moved Temporarily)请求的资源临时移动到新URI。浏览器会自动重定向,但搜索引擎不应更新其索引。
304Not Modified资源未被修改。客户端可以使用缓存的版本。这通常用于响应带有If-Modified-SinceIf-None-Match头的GET请求。

4xx (客户端错误)

状态码名称 (Name)含义
400Bad Request服务器无法理解客户端的请求,请求报文中可能存在语法错误。
401Unauthorized请求需要用户身份验证。客户端需要提供凭据(如用户名/密码、token)才能访问资源。
403Forbidden服务器理解请求,但拒绝执行。客户端没有访问权限,即使提供了身份验证也无济于事。
404Not Found服务器找不到请求的资源。
405Method Not Allowed服务器知道请求的资源,但禁用了请求使用的方法。例如,对一个只读资源使用POST请求。

5xx (服务器错误)

状态码名称 (Name)含义
500Internal Server Error服务器在执行请求时遇到了一个意外情况,导致无法完成请求。这是一个通用的服务器错误响应。
502Bad Gateway作为网关或代理的服务器,从上游服务器收到了无效的响应。
503Service Unavailable服务器当前无法处理请求。这通常是临时的,可能是由于服务器过载或正在进行维护。
504Gateway Timeout作为网关或代理的服务器,未及时从上游服务器接收到响应。

5、TCP是如何实现可靠传输的呢?

传输控制协议 (TCP) 是一种面向连接的、可靠的、基于字节流的传输层通信协议。它在不可靠的IP层之上,通过一系列复杂的机制,确保了数据能够准确、完整、有序地从发送端传输到接收端。

TCP可靠传输机制

TCP实现可靠传输的核心机制包括:

  1. 连接管理(三次握手与四次挥手):

    • 三次握手:在数据传输之前,TCP通过三次握手建立连接。这确保了发送方和接收方都已准备好发送和接收数据,并且双方都知晓对方的初始序列号,为后续的可靠传输奠定基础。
    • 四次挥手:在数据传输结束后,TCP通过四次挥手断开连接。这确保了所有待发送的数据都已传输完毕,并且双方都同意关闭连接,避免数据丢失。
  2. 序列号与确认应答:

    • 序列号:TCP将发送的数据分割成报文段,并为每个报文段分配一个序列号。这个序列号标识了报文段中第一个字节在整个字节流中的位置。
    • 确认应答:接收方收到数据后,会发送一个确认应答 (ACK) 报文,其中包含确认号。确认号表示接收方已成功接收到的下一个期望收到的字节的序列号。例如,如果接收方发送的确认号是 N,则表示它已经收到了 N-1 之前的所有数据。
    • 有序传输:通过序列号,接收方可以对乱序到达的报文段进行排序,确保数据按正确的顺序交付给应用层。
  3. 超时重传机制:

    • 发送方在发送一个报文段后,会启动一个定时器。如果在定时器超时之前没有收到该报文段的确认应答,发送方就会认为该报文段丢失,并重新发送该报文段。
    • 自适应的超时时间:TCP的重传超时时间 (RTO) 是动态调整的,它会根据网络的往返时间 (RTT) 变化而变化,以适应不同的网络状况。
    • 快速重传:如果发送方收到三个重复的确认应答(即接收方连续三次对同一个报文段发送确认,表明它期望收到该报文段,但该报文段之后的数据已收到),发送方会立即重传丢失的报文段,而无需等待定时器超时,从而提高传输效率。
  4. 流量控制:

    • TCP利用滑动窗口机制实现流量控制。接收方在确认应答报文中会告知发送方自己当前的接收窗口 (Receive Window, rwnd) 大小,表示自己还能接收多少字节的数据。
    • 发送方会根据接收方的接收窗口大小来限制自己发送的数据量,避免发送速度过快导致接收方缓冲区溢出,从而造成数据丢失。
  5. 拥塞控制:

    • 流量控制是针对接收方的能力,而拥塞控制是针对整个网络的状况。当网络出现拥塞时,TCP会降低发送速率,以避免进一步加剧网络拥塞。
    • TCP的拥塞控制主要包括四个算法:慢启动、拥塞避免、快速重传和快速恢复。
    • 通过动态调整拥塞窗口 (Congestion Window, cwnd) 的大小,TCP能够探测网络的承载能力,并在网络拥塞时及时做出反应,保护网络稳定。
  6. 校验和:

    • TCP在发送数据时会计算报文段的校验和,并将其添加到报文段头部。接收方收到报文段后,会重新计算校验和并与报文头部的校验和进行比对。
    • 如果校验和不匹配,说明数据在传输过程中发生了错误,接收方会丢弃该报文段,并不会发送确认应答,从而触发发送方的重传机制。

这些机制协同工作,使得TCP能够在复杂的网络环境中提供高度可靠的数据传输服务。

6、在浏览器中输入URL后会发生哪些事情?

当你在浏览器中输入一个URL并按下回车键后,一系列复杂而迅速的步骤会在幕后发生,最终将你带到目标网页。

这个过程可以概括为以下几个主要阶段:

  1. URL解析:

    • 浏览器首先会解析你输入的URL,识别出协议(如httphttps)、域名(如www.example.com)、端口号(如果指定,默认为80443)、路径和查询参数等。
    • 浏览器还会检查URL是否合法,并进行必要的编码处理。
  2. DNS解析:

    • 浏览器需要知道目标服务器的IP地址才能建立连接。它会检查本地缓存(浏览器缓存、操作系统缓存、路由器缓存)是否有该域名的IP地址。
    • 如果本地没有,浏览器会向本地DNS服务器发起查询。本地DNS服务器会逐级向上(根DNS服务器 -> 顶级域DNS服务器 -> 权威DNS服务器)查询,直到找到对应的IP地址。
    • DNS服务器将IP地址返回给浏览器,浏览器会将这个IP地址缓存起来以备后用。
  3. 建立TCP连接:

    • 获取到服务器的IP地址后,浏览器会与目标服务器建立一个TCP连接。这个过程通常通过三次握手完成,确保客户端和服务器都准备好进行数据传输。
    • 如果URL是https,在TCP连接建立后,还会进行TLS/SSL握手,协商加密算法和会话密钥,并验证服务器的数字证书,为后续的数据传输建立加密通道。
  4. 发送HTTP请求:

    • TCP连接(和TLS加密通道)建立后,浏览器会构造一个HTTP请求报文(包括请求行、请求头、请求体),并将其通过TCP连接发送给服务器。
    • 请求报文中包含了请求方法(GET、POST等)、请求的资源路径、Host头(指定目标域名)、用户代理(User-Agent)、Cookie等信息。
  5. 服务器处理请求并返回HTTP响应:

    • 服务器接收到HTTP请求后,会根据请求的资源路径、方法和参数等信息进行处理(例如,查询数据库、执行业务逻辑)。
    • 处理完成后,服务器会生成一个HTTP响应报文(包括状态行、响应头、响应体),并通过TCP连接返回给浏览器。
    • 响应报文中包含了状态码(如200 OK、404 Not Found)、响应头(如Content-Type、Set-Cookie)以及请求的资源内容(如HTML、CSS、JavaScript、图片等)。
  6. 浏览器渲染页面:

    • 浏览器接收到HTTP响应后,会根据响应头中的Content-Type来判断如何处理响应体内容。
    • 解析HTML:浏览器开始解析HTML文档,构建DOM树 (Document Object Model)。
    • 加载资源:在解析HTML的过程中,如果遇到外部资源(如CSS文件、JavaScript文件、图片、字体等),浏览器会再次发起新的HTTP请求去下载这些资源。
    • 解析CSS:浏览器解析CSS文件,构建CSSOM树 (CSS Object Model)。
    • 构建渲染树:DOM树和CSSOM树结合,生成渲染树。渲染树只包含需要显示在页面上的元素及其样式信息。
    • 布局:浏览器根据渲染树计算每个元素在屏幕上的精确位置和大小。
    • 绘制 (Painting):浏览器将渲染树的每个元素绘制到屏幕上,包括文本、颜色、图像、边框和阴影等。
    • 执行JavaScript:JavaScript通常会在HTML解析和渲染过程中执行,它可以修改DOM、CSSOM,从而触发页面的重新布局和绘制。
  7. 连接关闭:

    • 一旦所有资源都加载完毕,并且页面渲染完成,TCP连接可能会根据HTTP协议的设置(如Connection: keep-alive)保持一段时间以便后续请求复用,或者直接通过四次挥手关闭连接。

整个过程是一个高度并行和优化的过程,现代浏览器会利用各种技术(如预解析、预连接、并行下载、GPU加速等)来尽可能快地呈现页面。

7、C++指针和引用的差别是什么?

在C++中,指针 和 引用都是间接访问其他变量的机制,但它们在概念、语法和使用上存在显著差异。

C++指针vs引用

核心区别概览

特性指针引用
定义一个变量,存储另一个变量的内存地址。另一个变量的别名 (alias),与原变量共享内存地址。
初始化可以不初始化,或初始化为 nullptr (C++11)。必须在定义时初始化,且一旦初始化就不能改变其绑定的对象。
空值可以是 nullptr (空指针),表示不指向任何对象。不能为 nullptr,引用必须始终绑定到一个有效的对象。
重新赋值可以指向不同的对象(即改变其存储的地址)。一旦绑定,不能改变其绑定的对象。对引用的赋值操作是修改其所引用对象的值。
内存占用内存空间(通常是4或8字节,取决于系统架构),有自己的地址。不占用独立的内存空间(在编译时通常被优化为直接访问原变量,但在某些情况下,编译器可能会在底层实现为常量指针,此时可能占用内存)。
操作符需要使用 * (解引用) 和 & (取地址) 操作符进行操作。无需特殊操作符,直接使用引用名即可访问或修改其所引用的对象。
多级可以有多级指针(如 int**)。没有多级引用。
算术运算可以进行指针算术运算(如 ptr++ptr + n)。不能进行算术运算。
合法性检查使用前需要检查是否为 nullptr,以避免空指针解引用错误。无需检查合法性,因为引用总是绑定到有效对象。
实现在底层通常直接对应内存地址。在底层通常被编译器实现为常量指针(T* const),但对用户隐藏了指针的语义。

详细解释

  1. 本质不同:

    • 指针是一个实体,它是一个变量,其值是另一个变量的内存地址。你可以对指针进行各种操作,比如取地址、解引用、指针算术等。指针本身有自己的内存地址。
    • 引用是一个别名,它不是一个独立的实体,而是它所绑定对象的另一个名字。一旦引用被初始化,它就和它所引用的对象完全等价,对引用的操作就是对它所引用对象的操作。
  2. 初始化与空值:

    • 指针可以在定义时不初始化,或者初始化为 nullptr。这使得指针在使用前需要进行空值检查,否则可能导致程序崩溃(空指针解引用)。
    • 引用必须在定义时立即初始化,并且不能绑定到 nullptr。这意味着引用总是指向一个有效的对象,使用引用时无需担心空值问题,更加安全。
  3. 重新绑定:

    • 指针可以被重新赋值,使其指向不同的对象。例如,int* p = &a; p = &b; 是合法的。
    • 引用一旦初始化,就不能再改变其绑定的对象。例如,int& r = a; r = b; 并不是让 r 引用 b,而是将 b 的值赋给了 a(因为 ra 的别名)。
  4. 内存占用:

    • 指针本身是一个变量,需要占用内存空间来存储地址。其大小通常与系统架构相关(32位系统4字节,64位系统8字节)。
    • 引用在概念上不占用独立的内存空间。编译器通常会将其优化为直接访问被引用对象,或者在底层实现为常量指针,此时可能占用与指针相同的内存空间,但从C++语言层面来看,引用没有自己的地址。
  5. 操作符:

    • 指针需要使用 * (解引用) 来访问其指向的值,使用 & (取地址) 来获取变量的地址。
    • 引用不需要任何特殊操作符。你可以像使用原始变量一样使用引用,编译器会自动处理底层细节。

何时使用指针,何时使用引用?

  • 使用引用:
    • 作为函数参数,特别是需要修改传入对象或避免对象拷贝时(如 void func(MyClass& obj))。
    • 作为函数返回值,返回一个已存在的对象(但需注意避免返回局部变量的引用)。
    • 迭代器通常使用引用语义。
    • 当你确定对象始终存在且不希望改变其绑定时。
  • 使用指针:
    • 需要表示“没有对象”(即 nullptr)的情况时。
    • 需要动态内存分配和释放时(如 newdelete)。
    • 需要进行指针算术或遍历数组时。
    • 需要实现多态性时(通过基类指针指向派生类对象)。
    • 当你可能需要改变指针指向的对象时。

总的来说,引用更安全、更简洁,因为它消除了空值和重新绑定的问题,使得代码更易读和维护。指针则提供了更大的灵活性和底层控制能力,但需要开发者承担更多的责任来确保其正确使用。

8、说一下动态链接和静态链接是什么,以及各自的优缺点?

在程序开发中,链接(Linking) 是将编译器生成的各个目标文件(.o.obj 文件)以及程序所使用的库文件组合起来,生成一个可执行文件(或库文件)的过程。

链接方式

链接方式主要分为两种:静态链接和动态链接。

8.1、静态链接

定义:静态链接是指在程序编译阶段,将程序所有需要的库函数(包括标准库和第三方库)的所有代码都复制到最终的可执行文件中。链接器会将所有相关的目标文件和库文件中的代码段、数据段等合并成一个完整的可执行文件。

优点:

  • 独立性强:生成的可执行文件是独立的,不依赖外部库文件。这意味着程序可以在任何没有安装这些库的系统上运行,部署简单。
  • 加载速度快:程序运行时,操作系统直接加载可执行文件即可,无需再进行额外的链接操作,启动速度相对较快。
  • 性能略高:由于所有代码都在一个文件中,函数调用可能不需要经过复杂的地址转换,理论上执行效率可能略高。
  • 兼容性好:避免了不同版本库文件带来的兼容性问题。

缺点:

  • 文件体积大:每个可执行文件都包含其所需库的完整副本,导致可执行文件体积显著增大,占用更多的磁盘空间。
  • 内存占用大:如果多个程序都静态链接了同一个库,那么该库的代码会在内存中存在多份副本,造成内存浪费。
  • 更新维护困难:如果库文件有更新或修复了bug,所有静态链接了该库的程序都需要重新编译和发布,维护成本高。
  • 安全性差:如果库中存在安全漏洞,由于代码被复制到每个可执行文件中,更新漏洞需要重新编译所有程序。

8.2、动态链接

定义:动态链接是指在程序编译阶段,只在可执行文件中保留对所需库函数的引用(符号信息),而不将库的实际代码复制进去。库的实际代码在程序运行时才被加载到内存中,并与程序进行链接。

优点:

  • 文件体积小:可执行文件只包含少量引用信息,体积小,节省磁盘空间。
  • 内存利用率高:多个程序可以共享同一个动态链接库的内存副本。当一个库被加载到内存后,所有使用该库的程序都可以共享它,节省了内存资源。
  • 更新维护方便:如果库文件有更新或修复了bug,只需替换新的库文件,所有依赖该库的程序无需重新编译即可获得更新,降低了维护成本。
  • 安全性高:库的安全漏洞可以集中修复,用户只需更新库文件即可。
  • 模块化:程序可以更容易地实现模块化,便于开发和维护。

缺点:

  • 依赖性强:程序运行时需要依赖外部的动态链接库文件。如果库文件缺失或版本不兼容,程序可能无法运行(“DLL Hell”问题)。
  • 加载速度慢:程序启动时需要进行额外的链接操作,查找和加载动态库,启动速度相对较慢。
  • 性能略低:函数调用可能需要经过动态查找和地址转换,理论上执行效率可能略低于静态链接。

总结与选择

特性静态链接动态链接
可执行文件大小
部署简单,独立性强复杂,依赖外部库
内存占用多个程序使用同一库时,内存占用大多个程序使用同一库时,内存占用小,共享库
更新维护困难,需重新编译所有程序方便,只需替换库文件
启动速度
兼容性好,不易受库版本影响差,易受库版本影响(DLL Hell)

在实际开发中,通常会根据项目需求和场景来选择链接方式。例如,对于一些小型工具或对独立性要求较高的场景,可能会选择静态链接;而对于大型项目、操作系统组件或需要频繁更新的应用程序,动态链接是更常见的选择。

9、说一下深拷贝和浅拷贝的区别

在C++中,当涉及到对象的复制时,深拷贝和 浅拷贝是两个非常重要的概念,尤其是在对象内部包含指针或动态分配的内存时。

它们的主要区别在于如何处理对象内部的资源。

浅拷贝vs深拷贝

9.1、浅拷贝

定义:浅拷贝是指在复制对象时,只复制对象本身的值,而不复制对象内部指向的资源。如果对象中包含指针,那么浅拷贝只会复制指针的地址值,而不会复制指针所指向的实际内容。这意味着,原始对象和复制对象中的指针将指向同一块内存区域。

特点:

  • 默认行为:C++的默认拷贝构造函数和赋值运算符执行的就是浅拷贝(逐成员复制)。
  • 共享资源:原始对象和复制对象共享同一块动态分配的内存或其他资源。
  • 问题:当原始对象或复制对象析构时,它们会尝试释放同一块内存两次,导致二次释放错误,或者一个对象修改了共享资源,会影响到另一个对象。

示例: 假设有一个类 MyClass 包含一个 int* data 成员。

cpp
class MyClass {
public:
    int* data;
    MyClass(int val) : data(new int(val)) {}
    ~MyClass() { delete data; }
};

MyClass obj1(10);
MyClass obj2 = obj1; // 浅拷贝:obj2.data 和 obj1.data 指向同一块内存

// 此时 obj1.data 和 obj2.data 都指向存储了 10 的内存地址
// 当 obj2 析构时,会释放 data 指向的内存
// 当 obj1 析构时,会再次释放同一块内存,导致错误

9.2、深拷贝

定义:深拷贝是指在复制对象时,不仅复制对象本身的值,还会递归地复制对象内部指向的所有资源(如动态分配的内存)。这意味着,原始对象和复制对象将拥有各自独立的资源副本,它们之间互不影响。

特点:

  • 独立资源:原始对象和复制对象拥有完全独立的资源,互不干扰。
  • 安全:避免了浅拷贝可能导致的二次释放、数据污染等问题。
  • 实现:需要自定义拷贝构造函数和赋值运算符来手动实现深拷贝逻辑。

示例: 为 MyClass 实现深拷贝:

cpp
class MyClass {
public:
    int* data;
    MyClass(int val) : data(new int(val)) {}

    // 拷贝构造函数 (深拷贝)
    MyClass(const MyClass& other) : data(new int(*other.data)) {}

    // 赋值运算符 (深拷贝)
    MyClass& operator=(const MyClass& other) {
        if (this == &other) { // 处理自我赋值
            return *this;
        }
        delete data; // 释放旧资源
        data = new int(*other.data); // 分配新资源并复制内容
        return *this;
    }

    ~MyClass() { delete data; }
};

MyClass obj1(10);
MyClass obj2 = obj1; // 深拷贝:obj2.data 指向一块新的内存,内容为 10

// 此时 obj1.data 和 obj2.data 指向不同的内存地址,但内容相同
// 它们各自管理自己的资源,析构时不会冲突

总结

特性浅拷贝深拷贝
复制内容复制值,指针只复制地址复制值,指针指向的内容也复制一份
资源共享共享动态分配的内存各自拥有独立的动态分配内存
默认行为默认拷贝构造函数和赋值运算符需要自定义拷贝构造函数和赋值运算符
安全性不安全,可能导致二次释放或数据污染安全,避免资源管理问题
适用场景对象不包含动态分配资源,或资源由其他智能指针管理时对象包含动态分配资源时

当类中包含指针或动态分配的内存时,通常需要实现深拷贝来确保对象的正确行为和资源管理的安全性。C++11引入的移动语义(Move Semantics)可以在某些情况下避免不必要的深拷贝,提高性能。

10、进程通信的解耦机制?

进程通信(Inter-Process Communication, IPC)的解耦机制是指在不同的进程之间进行数据交换或协调时,尽量减少它们之间的直接依赖和耦合度。

解耦的目的是提高系统的灵活性、可维护性、可扩展性和健壮性。当进程之间高度耦合时,一个进程的改变可能会影响到其他进程,使得系统难以修改和扩展。

解耦机制通常通过引入中间件或抽象层来实现。

进程通信解耦机制

以下是一些常见的进程通信解耦机制:

消息队列 (Message Queues):

  • 机制:进程将消息发送到队列中,另一个进程从队列中接收消息。发送方和接收方不需要同时在线,也不需要知道对方的具体地址。
  • 解耦体现:
    • 时间解耦:发送方和接收方可以异步操作,发送方发送消息后可以继续执行,接收方可以在稍后处理。消息在队列中持久化。
    • 空间解耦:发送方和接收方不需要直接连接,它们只与消息队列交互。
    • 协议解耦:消息队列可以定义通用的消息格式,使得不同语言、不同平台的进程也能通信。
  • 应用场景:任务队列、异步处理、日志收集。

发布/订阅模式 (Publish/Subscribe Pattern):

  • 机制:引入一个消息代理(Broker)。发布者将消息发布到特定的“主题(Topic)”上,订阅者订阅感兴趣的主题。消息代理负责将消息路由给所有订阅者。
  • 解耦体现:
    • 发送方与接收方解耦:发布者不知道有哪些订阅者,订阅者也不知道消息来自哪个发布者。
    • 多对多通信:一个消息可以被多个订阅者接收,一个订阅者可以接收多个发布者的消息。
    • 异步性:消息发布和接收是异步的。
  • 应用场景:事件通知、实时数据分发、微服务架构中的服务间通信(如Kafka、RabbitMQ)。

远程过程调用 (Remote Procedure Call, RPC) / 远程方法调用 (Remote Method Invocation, RMI):

  • 机制:允许程序调用另一个地址空间(通常是另一台机器上)的过程或函数,就像调用本地过程一样。RPC框架负责底层的网络通信、数据序列化/反序列化。
  • 解耦体现:
    • 位置透明:客户端无需知道服务端的具体位置,只需要知道服务接口。
    • 语言/平台无关:优秀的RPC框架支持跨语言、跨平台的调用。
  • 应用场景:分布式系统中的服务调用、微服务架构。

共享内存 (Shared Memory):

  • 机制:多个进程可以访问同一块物理内存区域。这是最快的IPC方式,但需要进程自行处理同步问题。
  • 解耦体现:
    • 数据共享解耦:进程不需要通过复制数据来通信,直接读写共享内存。
  • 应用场景:高性能数据交换、大型数据传输。

文件锁 (File Locking) / 数据库:

  • 机制:通过对文件或数据库中的记录进行加锁,实现进程间的互斥访问和数据共享。
  • 解耦体现:
    • 数据存储解耦:进程通过文件或数据库作为中间存储介质进行通信,无需直接交互。
  • 应用场景:配置管理、分布式锁、数据持久化。

管道 (Pipes):

  • 机制:分为匿名管道(通常用于父子进程)和命名管道(可用于任意无关进程)。数据流是单向的。
  • 解耦体现:
    • 数据流解耦:进程通过管道作为中间缓冲区进行数据传输,无需直接发送。
  • 应用场景:简单的数据流传输、命令行工具的组合。

11、Linux进程通信的几种方式以及各自的应用场景?

Linux提供了多种进程间通信(Inter-Process Communication, IPC)机制,每种机制都有其特点和适用场景。

Linux进程通信方式

以下是主要的几种方式:

11.1、管道(Pipe)

匿名管道

  • 特点:
    • 半双工(数据单向流动)。
    • 只能用于有亲缘关系的进程(如父子进程)。
    • 数据存放在内核缓冲区,随进程结束而消失。
  • 典型应用:
    • 在 shell 中使用 ls | grepls 的输出作为 grep 的输入。

有名管道

  • 特点:
    • 通过文件系统中的一个特殊文件(FIFO 文件)实现。
    • 不要求通信的进程有亲缘关系。
    • 依然是半双工通信。
  • 典型应用:
    • 不相关进程之间的数据流通信,例如日志收集程序通过 FIFO 获取数据。

11.2、消息队列

  • 概念:消息队列是内核维护的一个消息链表,每个消息附带类型标识符,允许进程以“消息”为单位进行通信。
  • 特点:
    • 支持随机访问:接收者可以按消息类型选择性接收。
    • 数据持久性:消息在被读取前一直保存在内核中。
    • 比管道灵活,支持多进程、多方向通信。
  • 应用场景:
    • 多进程间复杂通信。
    • 有优先级的任务调度系统。
    • 日志服务或后台任务队列系统。

11.3、共享内存

  • 概念:共享内存允许多个进程直接访问同一块物理内存区域,是速度最快的 IPC 机制。
  • 特点:
    • 通信效率高(不需要内核复制数据)。
    • 需要额外的同步机制(如信号量)来防止并发冲突。
    • 数据持久化周期与共享段的生命周期相关。
  • 应用场景:
    • 高性能场景:如数据库缓存、广告系统模型共享、视频流共享。
    • 大量数据交互的进程,如 Web 服务器与 Worker 进程之间。

11.4、信号量(Semaphores)

  • 概念:信号量本质是一个计数器,用于控制多个进程对共享资源的访问,是同步机制而非数据传输工具。
  • 特点:
    • 用于进程同步与互斥,防止数据竞争。
    • 分为二值信号量(互斥锁)和计数信号量。
    • 操作原子化(P/V 操作)。
  • 应用场景:
    • 控制访问共享内存的多个进程。
    • 实现生产者-消费者模式、临界区保护。

11.5、信号(Signals)

  • 概念:信号是 Linux 提供的一种异步事件通知机制,用于通知进程某种事件的发生。
  • 特点:
    • 不携带数据,只传递事件类型(例如“终止”、“中断”、“重启”)。
    • 异步触发,能中断进程当前执行。
    • 可通过 signal()sigaction() 注册信号处理函数。
  • 应用场景:
    • 异常事件通知:如 Ctrl+C 发送 SIGINT 终止进程。
    • 守护进程控制:发送 SIGHUP 让服务重新加载配置。
    • 父进程通知子进程执行控制操作(挂起、继续)。

11.6、套接字(Sockets)

  • 概念:套接字是一种通用的通信接口,不仅支持同主机进程通信(Unix 域套接字),还支持不同主机间的网络通信。
  • 特点:
    • 全双工通信,支持 TCP(面向连接)与 UDP(无连接)两种模式。
    • 跨主机通信,是网络编程的基础。
    • 灵活通用,既能用于本地 IPC,也能用于分布式系统。
  • 应用场景:
    • Web 服务、数据库、消息队列等网络应用。
    • 分布式系统的 RPC 通信。
    • 本地进程间高性能通信(Unix Domain Socket)。

总结对比表

通信方式是否跨主机是否有亲缘关系要求是否双向是否支持同步控制通信效率典型场景
匿名管道父子进程数据传递
有名管道简单多进程通信
消息队列否(需匹配类型)任务队列、事件传递
共享内存是(需信号量)大数据共享、高速缓存
信号量同步、互斥控制
信号事件通知
套接字视网络情况网络通信、分布式RPC

12、说一下数据库的范式

数据库范式 (Normal Forms) 是关系型数据库设计中的一组规则,旨在通过消除数据冗余和避免数据异常(插入异常、删除异常、更新异常)来优化数据库结构。

范式级别越高,数据冗余越少,数据完整性越好,但有时也可能导致查询复杂性增加和性能下降。

通常,数据库设计达到第三范式 (3NF) 或巴斯-科德范式 (BCNF) 就能满足大部分应用的需求。

数据库范式

以下是主要的数据库范式:

12.1、第一范式 (1NF)

  • 定义:所有属性都是不可再分的原子值。这意味着数据库表的每一列都不能再分解,每个字段都是单一的、不可分割的。
  • 规则:
    • 列的原子性:每个列都包含单一的值,而不是值的集合或列表。
    • 没有重复的行:每行都必须是唯一的,通常通过主键来保证。
  • 目的:确保数据的基本结构化,为后续范式奠定基础。
  • 示例:一个学生表,如果学生电话列存储了多个电话号码(如“13812345678, 13987654321”),则不符合1NF。应该拆分为多行或单独的学生电话表。

12.2、第二范式 (2NF)

  • 定义:在满足1NF的基础上,非主键属性必须完全依赖于整个主键,而不是主键的一部分。如果主键是复合主键(由多个属性组成),则非主键属性不能只依赖于复合主键中的某一个或几个属性。
  • 规则:
    • 满足1NF。
    • 消除部分函数依赖:所有非主键属性都必须完全依赖于候选键(特别是主键)。
  • 目的:消除部分函数依赖,减少数据冗余。
  • 示例:在一个订单明细表(主键为{订单ID, 商品ID})中,如果商品名称只依赖于商品ID,而不依赖于订单ID,则不符合2NF。商品名称应该移到商品表中。

12.3、第三范式 (3NF)

  • 定义:在满足2NF的基础上,非主键属性不能传递依赖于主键。这意味着非主键属性之间不能存在函数依赖关系。
  • 规则:
    • 满足2NF。
    • 消除传递函数依赖:所有非主键属性都必须直接依赖于候选键,而不是通过其他非主键属性间接依赖。
  • 目的:消除传递函数依赖,进一步减少数据冗余和更新异常。
  • 示例:在一个学生表(主键为学生ID)中,如果系主任依赖于系名,而系名又依赖于学生ID,那么系主任就传递依赖于学生ID。这不符合3NF。系主任应该移到表中。

12.4、巴斯-科德范式 (BCNF / Boyce-Codd Normal Form)

  • 定义:在满足3NF的基础上,所有决定因素都必须是候选键。BCNF比3NF更严格,它解决了3NF中未能处理的某些特殊情况,即当一个非主属性决定了另一个主属性时。
  • 规则:
    • 满足3NF。
    • 对于表中所有的函数依赖 X -> YX 必须是表的候选键。
  • 目的:进一步消除主属性对非主属性的依赖,以及主属性之间可能存在的冗余。
  • 示例:考虑一个学生-课程-教师表,主键为{学生ID, 课程ID}。如果存在教师 -> 课程的函数依赖(一个老师只教一门课),且教师不是候选键,则不符合BCNF。需要将教师课程的关系拆分出去。

范式总结与选择

  • 1NF:确保每列的原子性。
  • 2NF:消除非主属性对主键的部分依赖。
  • 3NF:消除非主属性对主键的传递依赖。
  • BCNF:消除主属性对非主属性的依赖,以及主属性之间的冗余。

在实际数据库设计中,通常会追求达到3NF。

BCNF虽然更严格,但有时为了查询性能,可能会适当放宽到3NF。过高的范式级别可能会导致表过多,增加连接操作,从而降低查询效率。

因此,需要在数据冗余和查询性能之间进行权衡。

13、说一下多线程死锁的原因吧

死锁 (Deadlock) 是指在多线程(或多进程)并发系统中,两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法继续推进。

死锁的四个必要条件

死锁的发生通常需要同时满足以下四个必要条件:

13.1、互斥条件

  • 定义:指一个资源在同一时间只能被一个线程占用。如果一个线程已经占用了某个资源,那么其他线程就不能再占用该资源,只能等待该资源被释放。
  • 解释:这是死锁发生的基础。如果资源可以被多个线程同时访问(例如,读共享数据),那么就不会有竞争,也就不会有死锁。像打印机、文件、锁(互斥量)等都是互斥资源。

13.2、占有并等待条件

  • 定义:指一个线程已经至少占有了一个资源,但又请求新的资源,而新的资源又被其他线程占有,此时该线程会阻塞等待,同时不释放自己已经占有的资源。
  • 解释:线程在等待新资源的同时,仍然“霸占”着已有的资源,这阻止了其他线程获取这些资源,从而可能形成僵局。

13.3、不可剥夺条件

  • 定义:指线程已获得的资源在未使用完之前,不能被其他线程强行剥夺,只能由占有该资源的线程主动释放。
  • 解释:一旦资源被一个线程占有,它就不能被强制性地从该线程手中抢走。如果资源可以被强制剥夺(例如,操作系统可以强制回收某个线程的CPU时间片),那么死锁就可能被避免。

13.4、循环等待条件

  • 定义:指在发生死锁时,必然存在一个线程—资源—线程—资源…的循环链。即线程A等待线程B占有的资源,线程B等待线程C占有的资源,而线程C又等待线程A占有的资源,形成一个环路。
  • 解释:这是死锁的直接原因。如果不能形成这样的环路,即使前三个条件都满足,也不会发生死锁。例如,线程T1等待R2,T2等待R3,T3等待R1,这就形成了一个循环等待链。

总结:

死锁的发生必须同时满足这四个条件。只要破坏其中任何一个条件,就可以有效地预防死锁的发生。

14、如何避免死锁呢?

避免死锁的核心思想是破坏死锁发生的四个必要条件中的至少一个。通过精心设计资源分配策略和线程行为,可以有效地预防或避免死锁的发生。

避免死锁策略

以下是针对每个必要条件的破坏策略:

14.1、破坏互斥条件

策略:将互斥资源改造为可共享资源。但这通常很难实现,因为很多资源(如打印机、写文件、锁本身)本质上就是互斥的。

方法:

  • 使用无锁数据结构 (Lock-Free Data Structures):例如,使用原子操作(std::atomic)或无锁队列等,允许多个线程并发访问数据而无需加锁,从而消除互斥条件。但这通常实现复杂,且只适用于特定场景。
  • 将互斥资源虚拟化:例如,打印机可以被多个用户共享,但实际上是通过打印队列来排队处理的,每个用户感觉是独占的。

局限性:互斥条件是很多资源固有的特性,很难完全消除。因此,通常不会从这个角度入手来避免死锁。

14.2、破坏占有并等待条件

策略:要求线程在申请新的资源时,必须释放其已经占有的所有资源,或者一次性申请所有需要的资源。

方法:

  • 一次性申请所有资源:线程在开始执行之前,一次性申请所有它需要的资源。如果不能一次性获得所有资源,就一个也不获得,并等待直到所有资源都可用。这可以防止线程在持有部分资源的同时等待其他资源。
    • 优点:简单有效。
    • 缺点:可能导致资源利用率低(资源被长时间占用),可能导致饥饿(如果某个线程总是无法一次性获得所有资源)。
  • 请求新资源时释放已占有资源:线程在请求新的资源时,如果不能立即获得,就必须释放它当前已经占有的所有资源,然后重新尝试获取所有资源。
    • 优点:相对灵活。
    • 缺点:实现复杂,可能导致已经完成一部分工作的线程需要回滚,增加开销。

14.3、破坏不可剥夺条件

策略:允许系统从占有资源的线程手中抢占资源。

方法:

  • 资源抢占:当一个线程请求的资源被另一个线程占用,并且该线程无法立即获得时,操作系统可以强制性地从占有线程手中剥夺资源,分配给请求线程。这通常只适用于CPU和内存等易于保存和恢复状态的资源。
  • 超时机制:为锁的获取设置超时时间。如果线程在指定时间内未能获取到锁,就放弃当前尝试,释放已持有的资源,然后稍后重试。这虽然不能完全消除死锁,但可以将其转化为活锁(Livelock),即线程不断尝试但总是失败,但至少不会永久阻塞。

局限性:对于大多数用户级资源(如文件锁、数据库记录锁),强制剥夺是不可行的,因为这可能导致数据不一致。

14.4、破坏循环等待条件

策略:对所有资源进行排序,并规定线程必须按照资源的顺序来请求资源。

方法:

  • 资源有序分配法 (Resource Ordering):给系统中的所有资源(或锁)编号,规定线程必须按照资源编号的递增(或递减)顺序来请求资源。例如,如果线程需要资源R1和R2,且R1的编号小于R2,那么线程必须先请求R1,再请求R2。
    • 优点:简单且有效,是实际编程中最常用的死锁预防方法之一。
    • 缺点:需要预先知道所有资源,并为它们定义一个全局的顺序,这在复杂系统中可能难以实现和维护。同时,可能降低并发性,因为线程可能需要等待一些它暂时不需要的资源。

其他避免死锁的方法

除了破坏死锁的四个必要条件外,还有一些通用的方法可以帮助避免死锁:

  • 避免嵌套锁:尽量减少在一个锁的保护区域内再获取另一个锁的情况。如果必须嵌套,请确保遵循严格的锁获取顺序。
  • 使用 std::lock (C++11):std::lock 可以一次性获取多个互斥量,并且能够避免死锁。它会尝试锁定所有互斥量,如果失败,则会释放所有已获得的互斥量,然后重试。
  • 使用 std::unique_lockstd::defer_lock:结合 std::unique_lockstd::defer_lock 选项,可以延迟锁的获取,然后配合 std::lock 进行原子性加锁。
  • 减小锁的粒度:只在真正需要保护共享数据时才加锁,并且锁定的时间尽可能短,减少资源被占用的时间。
  • 避免不必要的锁:仔细分析哪些数据确实需要锁保护,避免对非共享数据或只读数据加锁。
  • 死锁检测与恢复:在某些系统中,可以允许死锁发生,然后通过死锁检测算法(如银行家算法)发现死锁,并采取措施(如终止进程、剥夺资源)来解除死锁。但这通常是操作系统的职责,在应用层较少使用。

15、说一下缓存穿透、击穿、雪崩

在分布式系统和高并发场景下,缓存是提高系统性能和可用性的重要手段。

然而,不当的缓存使用或异常情况可能导致一系列问题,其中最常见且影响较大的就是缓存穿透、缓存击穿和缓存雪崩。

缓存问题

15.1、缓存穿透

  • 定义:指查询一个根本不存在的数据,导致请求直接穿过缓存,到达数据库。由于缓存中没有,数据库中也没有,每次请求都会打到数据库,造成数据库压力骤增,甚至崩溃。
  • 原因:
    • 业务逻辑错误,查询了不存在的数据。
    • 恶意攻击,通过大量查询不存在的ID来攻击数据库。
  • 解决方案:
    • 布隆过滤器:在缓存层和数据库层之间增加一个布隆过滤器。布隆过滤器是一个数据结构,可以判断某个数据是否存在。当请求到达时,先通过布隆过滤器判断数据是否存在。如果布隆过滤器判断数据不存在,则直接返回空,避免访问数据库。如果判断可能存在,则继续查询缓存和数据库。布隆过滤器存在误判(即判断存在但实际不存在),但不会漏判(即判断不存在就一定不存在)。
    • 缓存空对象:当从数据库查询不到数据时,也将这个空结果(或一个特定的占位符)缓存起来,并设置一个较短的过期时间。这样,下次查询相同的不存在数据时,可以直接从缓存中返回空,避免再次查询数据库。

15.2、缓存击穿

  • 定义:指某个热点数据在缓存中过期的瞬间,同时有大量并发请求访问该热点数据。这些请求无法从缓存中获取数据,会同时打到数据库上,导致数据库压力骤增,甚至崩溃。
  • 原因:某个高并发访问的热点数据,其缓存恰好过期。
  • 解决方案:
    • 互斥锁 (Mutex Lock) / 分布式锁 (Distributed Lock):当缓存失效时,不是所有请求都去查询数据库,而是让第一个请求去查询数据库,并将数据加载到缓存中,其他请求则等待。第一个请求完成后,唤醒其他等待的请求,它们就可以从缓存中获取数据了。这可以通过在查询数据库前获取一个锁来实现。
    • 设置永不过期:对于一些访问频率极高、数据更新不频繁的热点数据,可以考虑将其缓存设置为永不过期。当数据需要更新时,通过后台任务或消息通知来主动更新缓存。
    • 异步更新:当热点数据即将过期时,通过后台线程提前刷新缓存,避免在请求高峰期进行同步刷新。

15.3、缓存雪崩

  • 定义:指在某个时间段内,大量缓存数据同时失效,或者缓存服务宕机。这会导致所有原本应该由缓存处理的请求都直接打到数据库上,数据库瞬间承受巨大压力,可能导致整个系统崩溃。
  • 原因:
    • 大量缓存设置了相同的过期时间,在某一时刻同时失效。
    • 缓存服务宕机,导致所有请求都无法命中缓存。
  • 解决方案:
    • 过期时间错开:为缓存的key设置不同的过期时间,例如在原有过期时间的基础上加上一个随机值(expire_time = base_time + random(0, N)),使大量缓存不会在同一时间点失效。
    • 多级缓存:引入多级缓存机制,例如本地缓存 + 分布式缓存。当分布式缓存失效时,请求可以先尝试从本地缓存获取数据,减轻对数据库的压力。
    • 缓存高可用:部署缓存集群,如Redis Sentinel或Redis Cluster,确保缓存服务本身的高可用性,避免单点故障导致缓存服务宕机。
    • 熔断、降级和限流:
      • 熔断:当数据库压力过大时,熔断机制可以暂时阻止对数据库的访问,防止其彻底崩溃。
      • 降级:在系统负载高或数据库压力大时,可以暂时关闭一些非核心功能,或者返回默认值、静态页面,保证核心服务的可用性。
      • 限流:限制进入系统的请求数量,保护后端服务不被突发流量冲垮。
    • 数据预热:在系统上线前或低峰期,提前将热点数据加载到缓存中,避免在启动初期就出现大量缓存未命中的情况。

16、算法:判断有环链表

判断一个单向链表中是否存在环(即链表的某个节点的 next 指针指向了链表前面的某个节点,形成一个闭环)是链表操作中的一个经典问题。最常用且高效的算法是 Floyd判圈算法 ,也称为快慢指针法。

什么是环形链表?

一个链表如果存在环,意味着从链表的某个节点开始,沿着 next 指针遍历,最终会回到这个节点,形成一个循环。如果链表没有环,则最终会遍历到 nullptr

Floyd判圈算法(快慢指针法)

核心思想:

设置两个指针,一个快指针 (Fast Pointer) 和一个慢指针 (Slow Pointer),它们都从链表的头节点开始遍历。快指针每次向前移动两步,慢指针每次向前移动一步。如果链表中存在环,那么快指针最终一定会追上慢指针;如果链表中没有环,快指针会先到达链表末尾(nullptr)。

算法步骤:

  1. 初始化:创建两个指针 slowfast,都指向链表的头节点 head
  2. 遍历:
    • slow 指针每次向前移动一步:slow = slow->next
    • fast 指针每次向前移动两步:fast = fast->next->next
  3. 判断:
    • 在每次移动后,检查 fastslow 是否相遇。如果 fast == slow,则说明链表中存在环。
    • 如果 fastfast->next 变为 nullptr,则说明链表没有环,快指针已经到达链表末尾。

为什么快慢指针一定会相遇?

如果链表有环,当慢指针进入环后,快指针也会在某个时刻进入环。由于快指针比慢指针移动得快,它们之间的距离会逐渐缩小(每次缩小一步),最终快指针一定会追上慢指针并相遇。

判断有环链表

示例代码 (C++):

cpp
struct ListNode {
    int val;
    ListNode *next;
    ListNode(int x) : val(x), next(nullptr) {}
};

bool hasCycle(ListNode *head) {
    if (head == nullptr || head->next == nullptr) {
        return false; // 空链表或只有一个节点,不可能有环
    }

    ListNode *slow = head;
    ListNode *fast = head;

    while (fast != nullptr && fast->next != nullptr) {
        slow = slow->next;          // 慢指针走一步
        fast = fast->next->next;    // 快指针走两步

        if (slow == fast) {
            return true; // 快慢指针相遇,存在环
        }
    }

    return false; // 快指针到达链表末尾,不存在环
}

寻找环的入口节点 (Optional)

在判断出链表有环之后,还可以进一步找到环的入口节点。当快慢指针相遇时:

  1. 将其中一个指针(例如 fast)重新指向链表头 head
  2. 然后,slow 指针保持在相遇点,fast 指针从 head 开始,两个指针都以每次一步的速度向前移动。
  3. 当它们再次相遇时,相遇点就是环的入口节点。

原理:

假设链表头到环入口的距离是 L,环的长度是 C,快慢指针在环内相遇时,慢指针走了 S 步,快指针走了 2S 步。同时,快指针比慢指针多走了 k 圈(k >= 1)。

2S - S = k * C => S = k * C

也就是说,慢指针走的步数是环长度的整数倍。

设相遇点距离环入口的距离为 M。那么 S = L + n * C + M (其中 n 是慢指针在环内走的圈数)。

fast 指针回到 headslow 指针留在相遇点,两者都以一步的速度前进。当 fast 走了 L 步到达环入口时,slow 也走了 L 步。由于 S = k * Cslow 此时的位置距离环入口的距离是 M + L。因为 SC 的整数倍,所以 L + M 也是 C 的整数倍。这意味着 slow 走了 L 步后,也会恰好到达环的入口。

示例代码 (C++) 寻找环入口:

cpp
ListNode *detectCycle(ListNode *head) {
    if (head == nullptr || head->next == nullptr) {
        return nullptr;
    }

    ListNode *slow = head;
    ListNode *fast = head;
    bool has_cycle = false;

    while (fast != nullptr && fast->next != nullptr) {
        slow = slow->next;
        fast = fast->next->next;

        if (slow == fast) {
            has_cycle = true;
            break;
        }
    }

    if (!has_cycle) {
        return nullptr; // 没有环
    }

    // 找到环的入口
    fast = head; // 将fast指针重新指向头节点
    while (slow != fast) {
        slow = slow->next;
        fast = fast->next;
    }
    return slow; // 此时slow和fast相遇,即为环的入口
}

复杂度分析

  • 时间复杂度:O(N),其中 N 是链表的节点总数。快慢指针最多遍历链表两次。
  • 空间复杂度:O(1),只需要常数级别的额外空间来存储两个指针。