1 语言基础(C/C++/Go)
1.1 C++
1.1.1 C++智能指针介绍一下(希姆科技一面)
- auto_ptr是较早版本的智能指针,在进⾏指针拷⻉和赋值的时候,新指针直接接管旧指针的资源并
且将旧指针指向空,但是这种⽅式在需要访问旧指针的时候,就会出现问题。 - unique_ptr是auto_ptr的⼀个改良版,不能赋值也不能拷⻉,保证⼀个对象同⼀时间只有⼀个智能
指针。 - shared_ptr可以使得⼀个对象可以有多个智能指针,当这个对象所有的智能指针被销毁时就会⾃动
进⾏回收。(内部使⽤计数机制进⾏维护) - weak_ptr是为了协助shared_ptr⽽出现的。它不能访问对象,只能观测shared_ptr的引⽤计数,防
⽌出现死锁。不增加引用share_ptr的引用计数,通过 lock() 来获取对应的shared_ptr。
1.1.2 shared_ptr 是否是线程安全的?在多线程开发中是否需要加锁?(欢乐互娱一面)
因为 shared_ptr 有两个数据乘员,读写操作不能原子化,使得多线程读写同一个 shared_ptr 对象时需要加锁。
当执行 shared_ptr y = x 时涉及两个成员的复制,这两步拷贝不会同时发生。
1.1.3 unordered_map 和 map 的底层?
unordered_map的底层实现是hashtable,采⽤开链法(也就是⽤桶)来解决哈希冲突,当桶的⼤⼩超
过8时,就⾃动转为红⿊树进⾏组织。
1.1.4 auto 和 decltype 的用法
auto 让编译器通过初始值来进行类型的推算。从而获得定义变量的类型,所以说auto定义的变量必须有初始值。
decltype可以从表达式中推断出要定义变量的类型,但却不想用表达式的值去初始化变量。
1.1.5 public、protected、private访问和继承权限的区别?
- public的变量和函数在类的内部外部都可以访问。
- protected的变量和函数只能在类的内部和其派生类中访问。
- private修饰的元素只能在类内访问。
1.1.6 友元函数和友元类
友元函数是定义在类外的普通函数,不属于任何类,可以访问其他类的私有成员。但是需要在类的定义中声明所有可以访问它的友元函数。
友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。
但是另一个类里面也要相应的进行声明。
(1)友元关系不能被继承。(2)友元关系是单向的。(3)友元关系不具有传递性。
1.1.7 C++ 中的static用法和意义
变量:被static修饰的变量是静态变量,会在程序运行的过程中一直存在,放在静态存储区。局部静态变量作用域在函数体中;全局静态变量作用域在文件里。
函数:被static修饰的函数就是静态函数,静态函数只能在本文件中使用,不能被其他文件调用,也不会和其他文件中的同名函数冲突。
类:在类中,被static修饰的成员变量是类静态成员,会被类的多个对象共用,不会属于某个对象,只能在类外初始化。访问这个静态函数通过引用类名来访问。类静态成员函数只能访问类静态成员变量。
1.1.8 什么是抽象类?(百度一面)
C++ 接口是使用抽象类来实现的,如果类至少有一个函数被声明为纯虚函数,则这个类就是抽象类。
设计抽象类的目的,是为了给其他类提供一个可以继承的适当基类。抽象类不能用于实例化对象,它只能作为接口使用。如果试图实例化一个抽象类对象,会导致编译错误。
因此,如果一个子类需要被实例化,则必须实现每个虚函数,这也意味着c++支持使用抽象类声明的接口。如果没有在派生类中重写虚函数,就尝试实例化该类对象,会导致编译错误。
class Box
{
public:
// 纯虚函数
virtual double getVolume() = 0;
private:
double length; // 长度
double breadth; // 宽度
double height; // 高度
};
1.1.9 在c++14中,使用移动语义来优化vector扩容?(百度一面)
1.1.10 动态绑定与静态绑定?(百度一面)
静态类型:对象在声明时采用的类型,叫做静态类型。
动态类型:通常指一个指针或者引用目前所指向的对象类型,是在运行期间决定的。
静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期。
动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期。
1.1.11 重载函数,为什么c++能重载函数c不行?(百度一面)
函数重载是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表必须不同。函数重载常用来处理实现功能类似,而数据类型不同的问题。
(函数汇总出来的符号不同)
在C语言中,汇编阶段对符号进行汇总时,一个函数汇总后的符号就是其函数名,所以当汇总时发现多个相同的函数符号时,编译器会报错。
在C++中,在对符号进行汇总时,对函数的名字修饰做了改动,函数汇总出的符号不单单只有函数名,还有其参数的类型和个数以及顺序等等。所以就算是同名函数,汇总出来的符号也不同。
1.1.12 什么是野指针,如何避免野指针?(百度一面)
野指针指的是指向了一个不可用的内存地址,往往会造成内存越界、段错误等问题。
产生的原因:
- 局部指针变量没有初始化,且直接引用该指针。
- 指针指向空间的释放,在free之后没有将指针置为空。
- 指针越界访问。
如何避免野指针?
- 初始化为nullptr,或者使用引用代替使用指针。
- 在free或者delete之后,需要将指针置为空。
1.1.13 C++的四种强制转换?
dynamic_cast:用于动态类型转换。具体的说,就是基类指针到派生类指针,或者派生类到基类指针的转换。提供运行时类型检查,只用于Base包含至少一个虚函数的类。
static_cast:用于各种隐式转换。具体的说,就是用户基本类型之间的转换。比如将int换成char,float换成int等。不提供运行时类型的检查,会有安全隐患。
const_cast:const_cast 可以用来设置或者一处指针所指向对象的 const。
reinterpret_cast:能够完成任意指针类型的转换。该转换操作的结果是出现一份完全相同的二进制复制品,既不会有指向内容的检查,也不会有指针本身类型的检查。
1.1.14 C/C++ 内存对齐
什么是内存对齐?
struct{
int x;
char y;
}s;
理论下,32位系统下,int 占 4byte,char 占1 byte,那么将它们放到一个结构体中应该占4+1=5byte;但是实际上,通过运行程序得到的结果是8 byte,这就是内存对齐导致的。
为什么要进行内存对齐?
因为大部分处理器在存取内存时,以双字节,4字节,8字节,16字节甚至32字节为单位来存取内存的。如果不进行内存对齐,需要剔除不要的数据。
内存对齐的规则
每个平台的编译器都有自己默认的 ”对齐系数“,可以通过预编译命令 #pragma pack(n) 来改变这一系数。
有效对齐值= min( #pragma pack(n) , 结构体中最长数据类型长度 )
对于结构体中的每个成员,对齐时需要遵循的规则:
(1) 每个成员的首地址都是 min(该成员大小, 有效对齐数) 的整数倍。
(2) 结构体的总大小为有效对齐数的整数倍。
了解规则后,定义结构体时需要考虑成员变量定义的先后顺序:
1.1.15 C++ 浅拷贝与深拷贝
浅拷贝就是对象的数据成员之间的简单赋值。但是当拷贝对象中有对其他资源(如堆、文件、系统)的引用时,对象会另开辟一块新的资源,而不再对拷贝对象中引用其他资源的对象的指针或引用进行单纯的赋值。避免两个对象析构时,释放同一块内存。
深拷贝和浅拷贝的区别是在对象状态中包含其它对象的引用的时候,当拷贝一个对象时,如果需要拷贝这个对象引用的对象,则是深拷贝,否在是浅拷贝。
深拷贝要自定义拷贝构造函数的内容。
参考资料:
https://blog.csdn.net/u010700335/article/details/39830425
1.1.16 左值引用 & 右值引用
- 可以取地址的,有名字的,非临时的就是左值。
- 不能取地址的,没有名字的,临时的就是右值。
左值引用:左值引用要求右边的值必须能够取地址,如果无法取地址,可以用常引用。但使用常引用后,我们只能通过引用去读取数据,无法去修改数据。
参考资料:
https://zhuanlan.zhihu.com/p/97128024
1.1.17 C++ 内存结构
在C++中,内存分成5个区,从高地址到底地址分别是 栈、堆、全局/静态存储区、常量存储区和代码区。
- 栈,在执行函数时,函数内部的局部变量的存储单元都可以在栈上创建,函数执行结束后这些存储单元自动被释放。栈内存分配运算于处理器的指令集中,效率很高。
1.1.18 c++ 内存泄漏问题 & 如何检测(深信服一面)
动态分配的内存所开辟的空间,在使用完毕后未手动释放,导致一直占据该内存,即为内存泄漏。内存泄漏的几种原因:
- 类的构造函数和析构函数中 new 和 delete 没有配套使用。
- 在释放对象数组时没有使用 delete[],使用了 delete
- 没有讲基类对象的析构函数定义为虚函数,当基类指针指向子类对象时,如果基类的析构函数不是 virtual,那么子类的析构函数将不会被调用,子类的资源没有正确释放,导致内存泄漏。
1.1.19 动态链接 & 静态链接
静态链接:链接阶段时,链接器从静态库文件中复制所需的代码到可生成执行文件中。优点是编译后执行文件不需要外部库的支持。缺点是当静态函数库改变后,需要重新编译。
动态链接:当程序执行到相关函数的时候才调用库中的函数。优点是多个应用程序在使用同一个库时非常适合。缺点是程序的运行环境中必须有这个库。
不管是静态库还是动态库,都是由.o
目标文件生成的。
1.2 Go
1.2.1 new 和 make 的区别
- make 只能用来分配和初始化类型为 slice、map、chan的数据。new可以分配任何类型的数据。
- new 分配返回的是指针,即类型 *Type。make 返回的是引用,即 Type;
- new 分配的空间被清零。make 分配空间后,会进行初始化。
make 在编译期的类型检查阶段,会根据参数类型的不同,将OMAKE节点转换成 OMAKESLICE、OMAKECHAN、OMAKEMAP 三种类型,这些节点最终会调用不同的运行时函数来进行初始化数据结构。
new
2 计算机网络
2.1 http状态码
200 OK 请求成功
301 Move Permanently请求的资源已被永久的移动到新URL
400 Bad Request 客户端请求的语法错误
403 Forbidden 禁止访问
404 Not Found 请求的资源不存在
2.2 三次握手与四次挥手(重点)
三次握手:
- client 给 server 发送链接请求报文,发送之后client处于SYN-SENT状态。
- server 端接收到了这个请求,并分配资源,同时给client返回一个ACK报文,于是处于SYN-RCVD状态。
- client 收到 server 发来的报文后,也会回一个ACK报文,这样连接就建立了,client进入established的状态。
四次挥手:
- client没有数据要发给server了,他会给server发送一个FIN报文,这是第一次挥手,挥手后client进入FIN_WAIT_1的第一阶段。
- server收到client发来的FIN报文后,返回一个ACK信息。server进入CLOSE_WAIT阶段,client收到之后处于FIN_WAIT_2的第二阶段。
- server发完所有数据时,会给client发送一个FIN报文,然后server变成LAST_ACK状态。
- client收到FIN报文时,给server发送ACK信息,但是它不相信网络,怕server收不到信息,它会进入TIME_WAIT状态,万一server没收到ACK消息它可以重传,而当server收到这个ACK信息后,就正式关闭了tcp连接,处于CLOSED状态。而client等待了2MSL这样长时间后还没等到消息,它知道server已经关闭连接了,于是它自己也断开了。
2.3 为什么使用三次握手,不能两次握手与四次握手?
两次握手:
- 无法阻止历史连接,导致可能建立一个历史连接,使得资源浪费。
- 无法同步序列号,server无法知道client是否已经接收到自己的同步信号。
2.4 TCP怎么保证可靠性?
- 校验和
- 确认应答+序列号
- 重传机制
超时重传:当TCP发出一个段后,它启动一个定时器,等待目的端确认收到这个报文段。如果不能及时收到一个确认,将重发这个报文段。超时重传时间(RTO)应该略大于报文往返的时间(RTT)。每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。 - 流量控制
TCP提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。 - 拥塞控制
当网络拥塞时,减少数据的发送。swnd = min(cwnd, rwnd) 发送窗口 = min(拥塞窗口, 接收窗口)
拥塞控制主要的四个算法: - 慢启动:当 cwnd < ssthresh 时,使用慢启动算法。当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。
- 拥塞避免:当 cwnd >= ssthresh 时,就会使用「拥塞避免算法」。每当收到一个 ACK 时,cwnd 增加 1/cwnd。
- 拥塞发生:当网络出现拥塞,发生数据包重传时,重传的机制有两种
- 发生「超时重传」,则就会使用拥塞发生的算法。这个时候,ssthresh 设为 cwnd/2,cwnd 重置为 1。超时重传后会进入慢启动。
- 发生「快速重传算法」,TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,cwnd = cwnd/2 ,ssthresh = cwnd;然后进入快速恢复算法。
- 快速恢复:快速重传和快速恢复算法一般同时使用,cwnd = ssthresh + 3;重传丢失的数据包;每收到重复的ACK,cwnd增加1;收到新数据的ACK后,把cwnd设置为第一步的ssthresh值,再次进入拥塞避免状态。
2.5 TCP粘包问题
TCP是面向字节流的协议,UDP是面向报文的协议。
每个UDP报文就是一个用户消息的边界。
当用户消息通过 TCP 协议传输时,消息可能会被操作系统分组成多个的 TCP 报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。
TCP粘包问题解决:
- 固定长度的消息
- 以特殊字符作为边界
- 自定义消息结构
2.6 HTTP与HTTPS的区别
- HTTP是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS则解决HTTP不安全的缺陷,在TCP与HTTP网络层之间加入了SSL/TLS安全协议,使得报文能够加密传输。
- HTTP的端口号为80,HTTPS的端口号为443。
- HTTPS要向权威机构CA(证书权威机构)申请数字证书,来保证服务器的身份是可靠的。
2.7 https具体实现与优点
- 服务端先把自己的公钥注册到CA,CA用自己的私钥将服务器的公钥数字签名并颁发数字证书。
- 客户端拿到服务器的数字证书后,使用CA的公钥去确认服务器数字证书的真实性,并且取出服务器的公钥。
- 将pre-master用服务器的公钥加密发送给服务端,服务端用自己的私钥将pre-master解密。之后的会话传输使用会话密钥(利用三个随机数算出的)进行对称加密。
3 数据库
36. Mysql索引的分类
按数据结构分类:B+Tree索引、Hash索引。
按物理存储分类:聚簇索引(主键索引)、二级索引(辅助索引)。
按字段特性分类:主键索引、唯一索引、普通索引、前缀索引。
按字段个数分类:单列索引、联合索引。
3.1 聚簇索引和非聚簇索引的区别?
- 主键索引的 B+Tree 的叶子节点存放的是实际数据,所有完整的用户记录都存放在主键索引的 B+Tree 的叶子节点里;
- 二级索引的 B+Tree 的叶子节点存放的是主键值,而不是实际数据。
所以,在查询时使用了二级索引,如果查询的数据能在二级索引里查询的到,那么就不需要回表,这个过程就是覆盖索引。如果查询的数据不在二级索引里,就会先检索二级索引,找到对应的叶子节点,获取到主键值后,然后再检索主键索引,就能查询到数据了,这个过程就是回表。
3.2 mysql 建立索引的几大原则
- 为经常需要排序、分组和联合操作的字段建立索引
- 最左前缀匹配原则。
- = 和 in 可以乱序,比如 a = 1 and b = 2 and c = 3,mysql的查询优化器会优化成索引识别的样子。
- 尽量选择区分度高的列作为索引
- 为常作为查询条件的字段建立索引。
3.3 mysql 索引优化/怎么查看 mysql 的执行计划。
做 mysql 优化时,我们用 explain 来查看 sql 的执行计划。可以查看到:
- key 列,使用到的索引名。
- key_len 列,索引长度。
- rows 列,扫描行数。
3.4 mysql 索引下推与索引覆盖
索引下推(Index Condition Pushdown,简称ICP):指将部分上层(服务层)负责的事情,交给了下层(引擎层)去处理。没有 ICP 的情况,把查询的所有结果回表,回表后的查询结果在服务层再进行过滤;使用 ICP 后,先按照索引的部分信息进行过滤,然后再把过滤后的数据进行回表,最后交给服务层再过滤。
40. 讲一下主键索引与唯一索引的关系(百度一面)
3.2 Mysql 有哪些数据类型与约束?(美团一面)
数据类型:
char、varchar、int、float、date、datetime、timestamp
char 与 varchar 的区别:(常见面试题)
- char类型的长度是固定的,varchar的长度是可变的。char类型最多能存放的字符个数为255,varchar 最多能存放的字符个数为65532。
- 使用char时,如果插入数据的长度小于char的固定长度时,则用空格填充。
- varchar比char节省空间,但是在效率上varchar比char稍差些。
date、datetime、timestamp 的区别:
- date 是年月日,datetime 和 timestamp 是年月日时分秒。
- datetime 支持的范围比 timestamp 更大。datetime 存储的是实际格式,与时区无关;timestamp 存储的是 UTC 格式,有时区的转化。
约束:
NOT NULL:保证该字段值一定不为空;
DEFAULT:保证字段有默认值;
PRIMARY KEY:标志一列或者多咧,并保证其值在表内的唯一性。
UNIQUE:限制一列或多列的值,保证字段值在表内的唯一性。
参考资料:
char 与 varchar 的区别:https://leetcode.cn/leetbook/read/database-handbook/pxcw1g/
SQL 约束有哪几种类型:https://leetcode.cn/leetbook/read/database-handbook/pxy7ce/
28. B树与B+树的区别
B+ 树的非叶子节点不存放实际的记录数据,仅存放索引,因此数据量相同的情况下,相比存储即存索引又存记录的 B 树,B+树的非叶子节点可以存放更多的索引,因此 B+ 树可以比 B 树更「矮胖」,查询底层节点的磁盘 I/O次数会更少。
B+ 树有大量的冗余节点(所有非叶子节点都是冗余索引),这些冗余索引让 B+ 树在插入、删除的效率都更高,比如删除根节点的时候,不会像 B 树那样会发生复杂的树的变化;
B+ 树叶子节点之间用链表连接了起来,有利于范围查询,而 B 树要实现范围查询,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘 I/O 操作,范围查询效率不如 B+ 树。
29. MyISAM 的索引与 InnoDB索引的区别
MyISAM,B+Tree叶节点的data域存放的是数据记录的地址。辅助索引(Secondary key)和主索引在结构上没有任何区别,只是主索引要求key是唯一的,而辅助索引的key可以重复。
InnoDB,主键索引中B+树的节点的data域保存的是完整的数据。辅助索引都引用主键作为data域。
30. MyISAM 与 InnoDB 的差别
事务:InnoDB支持事务;MyISAM不支持事务。
并发:InnoDB支持行级锁;MyISAM只支持表级锁。
外键:InnoDB支持外键;MyISAM不支持。
查询速度:MyISAM 比 InnoDB 快。
InnoDB 与 MyISAM 的差别
31. Mysql 中 快照读与当前读
Mysql除了普通查询是快照读,其他查询都是当前读(包括下面两个语句):
select…lock in share mode (共享读锁)
select…for update
快照读通过MVCC来解决幻读。
当前读通过next-key lock(记录锁+间隙锁)的方式来解决幻读
3.5 什么是乐观锁和悲观锁?(美团一面)
悲观锁:先获取锁,再进行业务操作,类似于 SELECT…FOR UPDATE 这样的语句,对数据加锁,避免其他事务意外修改数据。
乐观锁:先进行业务处理,只在最后更新数据时进行检查数据是否被更新过。适用于 读多写少 的应用场景。乐观锁有三种常用的实现形式:
- 一种是执行事务时把整个数据都拷贝到应用中,在数据更新提交的时候比较数据库中的数据与新数据,如果两个数据一模一样则表示没有冲突可以直接提交,如果有冲突就要交给业务逻辑去解决。
- 一种是使用版本号来对数据进行标记,数据每发生一次修改,版本号就增加1。某条数据在提交的时候,如果数据库中的版本号与自己的一致,就说明数据没有发生修改,否则就认为是过期数据需要处理。
- 最后一种采用时间戳对数据最后修改的时间进行标记。与上一种类似。
3.4 数据库高并发解决方案
- 使用缓存
- 添加索引
- 主从读写分离,让主服务器负责写,从服务器负责读。
- 分库分表
- 负载均衡集群:通过集群或者分布式来解决并发的压力。
3.3 Mysql 分库分表问题
分库:当QPS过高,数据库连接数不足时,把各个业务的数据从一个单一的数据库中拆分开,分别放入到单独的数据库中。
分表:当单表数据量非常大时,此时的QPS不高,数据库的连接数还够时,可以通过将数据拆分到多个表中,来减少单表的数据量。一般我们认为当单表行数超过500万条或者单表容量超过2G时,才需要做分库分表。
- 水平拆分:把一个表中不同的记录分别放到不同的表中。
- 垂直拆分:把一个表中某一条记录的多个字段,拆分到多张表中。
33. Redis的数据类型与底层的数据结构(美团一面)
五种常见的数据结构:
String:SDS(好处:拼接字符串不会导致缓冲区溢出,空间不够会自动扩容;获取长度的时间复杂度时O(1);可以保存二进制文件)
Hash:哈希表、压缩列表
List:双向链表、压缩列表
Set:整数集合、哈希表
Sorted Set(ZSet):跳表、压缩列表
3.5 Redis 过期删除与内存淘汰?(美团一面)
Redis 使用的过期删除策略是「惰性删除+定期删除」。
- 惰性删除:每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该key。
- 定期删除:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中过期的 key。
Redis 内存数据量达到一定限制的时候,就会实行数据淘汰策略(回收策略)。Redis 会根据 maxmemory-policy 配置策略,来决定具体的行为。Redis 的内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
- 不进行数据淘汰策略
- noeviction:不删除策略,达到最大的内存限制时刻,如果需要更多内存,直接返回错误信息。
- 进行数据淘汰策略,在设置了过期时间的数据中进行淘汰:
- volatile-random:随机淘汰设置了过期时间的键值对。
- volatile-ttl:优先淘汰更早过期的键值。
- volatile-lru:在所有设置了过期时间的键值中,淘汰最久未使用的键值。
- volatile-lfu:在所有设置了过期时间的键值中,淘汰最少使用的键值。
在所有数据范围内进行淘汰: - allkeys-random:随机淘汰键值对。
- allkeys-lru:在所有键值对中,淘汰最久未使用的键值。
- allkeys-lfu:在所有键值对中,淘汰最少使用的键值。
参考资料:
https://leetcode.cn/leetbook/read/database-handbook/p5pwd5/
https://www.xiaolincoding.com/redis/base/redis_interview.html#redis-%E8%BF%87%E6%9C%9F%E5%88%A0%E9%99%A4%E4%B8%8E%E5%86%85%E5%AD%98%E6%B7%98%E6%B1%B0
3.6 单线程的 Redis,为何如此高效?(美团一面)
- Redis 的大部分操作都在内存中完成,Redis 的瓶颈可能是机器的内存或者网络带宽,而并非 CPU,自然可以采用单线程的解决方案。
- Redis 的单线程模型可以避免多线程之间的竞争。
- Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求。在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。一旦有请求到达,就会交给 Redis 线程处理,实现一个 Redis 线程处理多个IO流的效果。
3.8 什么是缓存击穿、缓存穿透、缓存雪崩?如何解决?(元戎一面)
- 缓存雪崩是指缓存同一时间大面积失效,所以后面的请求都会落在数据库上,造成数据库短时间内承受大量请求而崩掉。解决办法:将缓存数据的过期时间设置随机,防止同一时间大量数据过期现象的产生。
- 缓存穿透是指查询一个一定不存在的数据,由于缓存不命中,接着查询数据库也无法查询出结果,因此也不会写入缓存中,导致每个查询都会去请求数据库,造成缓存穿透。解决方法:缓存空对象,使用布隆过滤器。
- 缓存击穿指一个key非常热点,在不停的扛着大并发,当这个key在失效的瞬间,持续的大并发会直接请求到数据库。解决办法:使用SingleFlight机制,单机内相同的key,同时只会执行一次。
3.9 讲一下布隆过滤器?(元戎一面)
布隆过滤器:k个哈希函数计算出k个散列值,之后取模描黑(数组中对应的比特位置为1),判断时还是k个哈希函数取模做判断,只有全为黑(全是1)才属于,有一个为白就不属于。所以有可能把白判断为黑,但绝不可能把黑判断为白。
三个公式:
n = 样本量;
p = 失误率;
m = 最优的位数组大小;
k = Hash 函数个数选取最优数目;
算法错误的认为某一原本不在集合中的元素却被检测为在该集合中(False Positives),该概率(失误率p)由以下公式确定:
使用:
有两种方式:使用Redis4.0版本提供的插件功能、使用bitmap(位图)来实现布隆过滤器。
https://blog.csdn.net/qq_38571892/article/details/123503418
45. Redis 持久化的两大机制(AOF 和 RDB)
目前,Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB(Redis Database)快照。
AOF 日志:
AOF日志是写后日志,即先执行命令,再记日志。AOF 是以文件的形式在记录接收到的所有写命令。AOF日志有三种写回磁盘的策略(配置项 appendfsync),分别是 always、everysec、no。随着接收的写命令越来越多,AOF 文件会越来越大,为了避免日志文件过大,Redis 提供了 AOF 重写机制。
AOF 重写并不需要对原有的 AOF 文件进行任何写入和读取,它针对的是数据库中键的当前值。和 AOF 日志由主线程写回不同,重写过程是由后台子线程 bgrewriteaof 来完成的。重写的过程总结为“一个拷贝,两处日志”。
“一个拷贝”:主线程 fork 出后台的 bgrewriteaof 子进程,执行一次内存拷贝,用于重写。
“两处日志”:子进程在进行AOF重写期间,主进程还需要继续处理命令,而新的命令可能对现有的数据进行修改,这会让当前数据库的数据和重写后的 AOF 文件中的数据不一致。此时,如果有写操作,第一处日志是指 Redis 会把这个操作追加写到「AOF缓冲区」,保证现有的 AOF 功能会继续执行,即使在 AOF 重写期间发生停机,也不会有任何数据丢失。而第二处日志,是指 Redis 会把这个操作写到「AOF重写缓冲区」中。等到拷贝数据的所有操作记录重写完成后,再将「AOF重写缓冲区」中的所有内容追加写入到新的 AOF 文件,并覆盖原有的文件。
RDB 快照:
用 AOF 日志的方式来恢复数据其实是很慢的,因为 Redis 执行命令由单线程负责,而 AOF 日志恢复数据的方式是顺序执行日志里的每一条数据。于是有另一种持久化方法:内存快照。所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。而这个快照文件就称为 RDB文件。
Redis 的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照。Redis 提供来两个命令来生成 RDB 文件,分别是 save 和 bgsave。
- save:在主线程中执行,会导致阻塞;
- bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。
为了执行快照的同时,正常处理写操作。Redis 借助操作系统提供的写时复制技术(Copy-On-Write,COW)。当主线程要修改一块数据的时候,这块数据会被复制一份,生成该数据的副本。然后主线程在这个数据副本上进行修改。同时,子进程可以继续把原来的数据写入 RDB 文件中。此时会出现以下问题:
- 如果系统恰好在 RDB 快照文件创建完毕后崩溃,Redis 将会丢失主线程在快照期间修改的数据。
- 如果所有的共享内存都被修改,此时的内存占用时原来的 2 倍。
在做了一次全量快照后,后续的快照只对修改的数据进行快照记录,称为增量快照。
Redis 4.0 中提出了一个混合使用AOF日志和内存快照的方法。内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
参考资料:
https://www.xiaolincoding.com/redis/storage/aof.html#%E6%80%BB%E7%BB%93
https://time.geekbang.org/column/article/271839
https://redisbook.readthedocs.io/en/latest/internal/aof.html
3.7 Mysql 和 Redis 一致性的问题。(元戎一面,美团一面)
**强一致性:**系统写入什么,读出来的也会是什么,用户体验好,实现起来往往对系统的影响大。
**弱一致性:**这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能保证到某个时间级别后,数据能够达到一致。
**最终一致性:**最终一致性是弱一致性的特例,系统会保证在一定时间内,能够达到一个数据一致的状态。
Cache-Aside:旁路缓存模式。
- Cache-Aside 更新的时候不能写缓存,否则会有双写不一致的问题(会有脏数据)。所以需要改成删缓存,因为删除缓存是天然幂等的。
- 先删缓存,再更新数据库。有可能在删缓存和更新数据库中间有读数据库的操作,于是缓存又被旧的数据更新,导致不一致的问题。解决办法:延时双删。
- 先更新数据库,再删缓存。当写请求删缓存后,读请求又把旧数据写到缓存中。一般读数据库相比于写数据库耗时更短。解决办法:延时双删或缓存过期。
- 双删的时候第二步删除失败,可以使用 MQ 补偿,或者采用数据库的 binlog 日志来发送 MQ,然后通过 ACK 机制确认处理这条更新消息,删除缓存来保证数据缓存一致性。
Read-Through/Write-Through:读写穿透。
Read/Write Through模式中,应用程序跟数据库缓存交互,都是通过抽象缓存层实现的。
Write behind:异步缓存写入。
Read/Write Through 是同步更新缓存和数据,Write Behind 则是只更新缓存,不直接更新数据库,通过批量异步的方式来更新数据库。缺点:缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用。
4 Linux
4.1 Linux的I/O模型介绍以及同步异步阻塞非阻塞的区别?
IO的两个阶段:
-
数据准备阶段。内核从设备读取数据到内核空间的缓冲区。(内核从IO设备读数据)
-
内核空间复制回用户空间进程缓冲区阶段。(进程从内核复制数据)
- 阻塞IO(BIO):调用 IO 操作的时候,如果无数据报准备好,调用的进程或者线程就会处于阻塞状态直到 IO 可用并完成数据拷贝。
- 非阻塞IO(NIO):调用 IO 操作的时候,内核会马上返回结果。如果无数据报准备好,会返回错误,这种方式下进程需要不断轮询直到 IO 可用为止,但是进程从内核拷贝数据时是阻塞的。
- IO 多路复用:同时监听多个描述符,一旦某个描述符 IO 就绪(读就绪或者写就绪),就能够通知进程进行相应的 IO 操作,否则就将进程阻塞在select或者epoll语句上。
- 同步IO:包括阻塞IO、非阻塞IO、IO多路复用。特点是当进程从内核复制数据的时候都是阻塞的。
- 异步IO(AIO):在检测IO是否可用和进程拷贝数据的两个阶段都是不阻塞的,进程可以做其他事情,当IO完成后内核会给进程发送一个信号。
4.2 EPOLL 的介绍和了解
epoll 全称 eventpoll,是 Linux 进行IO多路复用的一种方式,用于在一个线程里监听多个IO源,在IO源可用的时候返回并进行操作。它的特点是基于事件驱动,性能很高。
epoll 将文件描述符拷贝到内核空间后使用红黑树来进行维护,同时向内核注册每个文件描述符的回调函数。当某个文件描述符可读可写的时候,将这个文件描述符加入到就绪的链表中,并唤起进程,返回就绪链表到用户空间,由用户程序进行处理。
epoll 有三个系统调用:epoll_create(), epoll_ctl() 和 epoll_wait()
-
epoll_create() 函数在内核中初始化一个epoll对象,同时初始化红黑树和就绪链表。
-
epoll_ctl() 用来对监听的文件描述符进行管理。将文件描述符插入到红黑树,或者从红黑树中删除,这个过程的时间复杂度是log(N)。同时向内核注册文件描述符的回调函数。
-
epoll_wait() 会将进程放到epoll的等待队列中,将进程阻塞,当某个文件描述符的IO可用时,内核通过回调函数将该文件描述符放到就绪链表里,epoll_wait() 会将就绪链表里的文件描述符返回到用户空间。
参考资料:
https://zhuanlan.zhihu.com/p/56486633
4.3 IO复用的三种方法(select,poll,epoll)深入理解,包括三种区别,内部原理实现?
- select 将所有监听的文件描述符拷贝到内核中,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此文件描述符标记为可读或可写,接着再把整个文件描述符集合拷贝回用户空间,用户态还需要再通过遍历的方式找到可读或可写的文件描述符,然后再对其处理。
- poll 使用链表保存文件描述符,其它的跟select没有什么不同。
- epoll 将文件描述符拷贝到内核空间后使用红黑树进行维护,同时向内核注册每个文件描述符的回调函数。当某个文件描述符可读可写的时候,将这个文件描述符加入到就绪链表里,并唤起进程,返回就绪链表到用户空间。
参考资料:
https://www.xiaolincoding.com/os/8_network_system/selete_poll_epoll.html#select-poll
5 消息队列
5.1 RocketMQ 和 Kafka 的区别?(元戎一面)
适用场景:Kafka更适合日志处理,RocketMQ更适合业务的处理。
性能:Kafka吞吐量更高,单机百万/秒,RocketMQ单机10万/秒。
可靠性:Kafka使用异步刷盘方式,异步 Replication;RocketMQ 支持异步/同步刷盘;异步/同步 Replication。
消费失败重试:Kafka不支持消费失败重试,RocketMQ消费失败支持定时重试,每次重试间隔时间顺延。
定时/延时消息:Kafka不支持定时消息,RocketMQ支持定时消息。
分布式事务(附):Kafka在提交事务失败的时候,直接抛出异常,让用户进行重试。RocketMQ有事务反查的机制,定期反查事务状态,来补偿提交事务消息可能出现的通讯问题。
5.2 消息队列如何保证消息不被重复消费?(美团一面)
在消息传递过程中,如果出现传递失败的情况,发送方会执行重试,重试的过程中就有可能会产生重复的消息。一般解决重复消息的办法是,在消费端,让我们消费消息的操作具有幂等性。所以,常用的设计幂等操作的方法:
- 使用唯一约束,用数据库或者 Redis 来保证幂等,用类似 “INSERT IF NOT EXIST” 或 Redis 的 SETNX 命令来代替数据库中的唯一约束。
- 为更新的数据设置前置条件,比如给数据增加一个版本号属性,每次更新数据前比较当前数据的版本号是否和消息中的版本号一致,如果不一致就拒绝更新数据,更新数据的时候将版本号+1。
参考资料:
如何处理消费过程中的重复消息:https://time.geekbang.org/column/article/111552
5.3 消息积压了该如何处理?
扩容 Consumer 的实例数量与主题中分区的数量。
6 操作系统
6.1 进程与线程的区别和联系(重点)
- 进程是对运行时程序的封装,是系统进行资源调度和分配的基本单元,而线程是CPU分配和调度的基本单元。
- 进程执行中有独立的内存单元,而多个线程共享进程的内存。同一个进程中线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有的局部变量和临时变量。
- 一个线程挂掉会导致整个进程挂掉。
6.2 虚拟内存 & 段页式管理
分页:产生内部碎片。
分段:按照逻辑模块来划分内存,段式管理容易产生外部碎片。
段页式管理:现将用户程序分成若干个段,再把每个段分成若干页。
先找段表始址,再去段表通过段号找到页表始址,再通过页号找到内存块号,再通过页内偏移量计算实际的物理地址。
6.3 用户态与内核态的区别
用户空间中的代码被限制了只能使用一个局部的内存空间,我们说这些程序在用户态执行。内核空间中的代码可以访问所有内存,我们称这些程序在内核态执行。
用户态和内核态是操作系统的两种运行级别,两者最大的区别就是特权级不同。
6.4 用户态和内核态之间的切换:
- 系统调用:用户态进程主动要求切换到内核态的一种方式。用户态进程通过系统调用申请使用操作系统提供的服务程序来完成工作。例如 fork() 就是创建一个新的进程的系统调用。系统调用的核心机制是操作系统为用户特别开放的一个中断实现。
- 异常:在CPU执行用户态程序时,如果发生了一些不可预知的异常,这时会触发由当前运行进程切换到异常相关的内核进程中,也就是切换到了内核态,比如缺页中断。
- 外设中断:当外设完成用户的请求时,会向CPU发送中断信号。这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序。
参考资料:
https://segmentfault.com/a/1190000039774784
6.5 线程同步与线程异步(华为二面)
线程同步:线程同步是指线程之间所具有的一种制约关系,例如:两个线程A和B在运行过程中协同步调,按预定的先后次序运行,比如 A 任务的运行依赖于 B 任务产生的数据。
线程互斥:线程互斥是指对于共享的操作系统资源,在各线程访问时具有排它性。如果A占有了该资源则B需要等待A释放。
6.6 进程切换与线程切换的区别(华为二面)
进程切换分两步:
- 切换页目录以使用新的地址空间
- 切换内核栈和硬件上下文。
进程切换涉及到虚拟地址空间的切换,导致TLB失效,导致虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢,而线程切换则不会。
每个线程都有独立一套的寄存器和栈,当同一个进程发生线程切换的时候,只需要切换线程的私有数据、寄存器等不共享的数据。
6.7 进程之间通信方法有几种(华为二面)
管道:
- ps aux | grep mysql “|” 表示的管道称为匿名管道,用完了就销毁。
- mkfifo myPipe 创建的管道为命名管道,也被叫做 FIFO。
管道的通信方式效率低,不适合进程间频繁地交换数据。
这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符fd[0],另一个是管道的写入端描述符fd[1]。所谓的管道,就是内核中的一串缓存。
在 shell 里面执行A|B 命令的时候,A进程和B进程都是shell创建出来的子进程,A和B之间不存在父子关系,它俩的父进程都是shell。
对于匿名管道,它的通信范围是存在父子关系的进程。
共享内存:共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,不需要拷贝来拷贝去。
信号量:主要用PV操作来实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号:对于异常情况下的工作模式,就需要用“信号”的方式来通知进程。
Socket:跨网络与不同主机上的进程之间通信,就需要Socket。
6.8 进程的五个状态
- 创建状态:操作系统会在内存上申请进程代码控制块(PCB),之后操作系统会通过操作硬件将静态的代码块从磁盘加载到内存中。这个状态为创建状态。
- 就绪状态:程序在创建的过程中,已经将PCB控制快和代码段加载进入内存。就绪队列就是将数据从磁盘空间加载到内存空间中。之后将进程挂载在就绪队列中,等待分配处理机资源。
- 运行状态:当进程挂载在就绪队列上就会被操作系统进行调度,进入运行状态。当进程还没运行完,这个时候进程除了数据外什么资源都不缺,因此进程挂载在就绪队列中。
- 阻塞状态:由于进程等待某种条件(如I/O操作或者进程同步),在条件满足之前无法继续执行。则进程会进入阻塞队列
- 消亡状态:程序运行完成后,会进入消亡状态。该状态下主要实现PCB进程代码块的释放以及数据写回操作。
6.9 什么是 DMA 技术?
在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/
%E9%9B%B6%E6%8B%B7%E8%B4%9D/DRM%20I_O%20%E8%BF%87%E7%A8%8B.png
参考资料:
https://www.xiaolincoding.com/os/8_network_system/zero_copy.html#%E4%B8%BA%E4%BB%80%E4%B9%88%E8%A6%81%E6%9C%89-dma-%E6%8A%80%E6%9C%AF
6.10 什么是零拷贝,如何实现零拷贝?
场景:如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,再通过网络协议发送给客户端。期间共发生了4次用户态与内核态的上下文切换,还发生了4次数据拷贝。
零拷贝技术:不从内存层面去拷贝数据,全程没有通过 CPU 来搬运数据,所有数据都是通过 DMA 来进行传输的。
什么时候不能使用零拷贝技术?当传输大文件时,原因:1.PageCache被大文件占据,其他「热点」的小文件无法充分使用 PageCache。 2. 大文件使得 PageCache 没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次。
零拷贝技术实现的方式通常有2种:
- mmap + write:将内核缓冲区与应用进程缓冲区进行共享,当操作系统调用write()的时候,操作系统直接将内核缓冲区的数据拷贝到socket缓冲区中,一切都发生在内核态。
- sendfile:可以替代 read() 和 write() 这两个系统调用。可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区中,不再拷贝到用户态。但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA 技术,我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
- SG-DMA 技术:sendfile 时将缓冲区的描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中。
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7wwau2Vq-1665651010818)(https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/senfile-%E9%9B%B6%E6%8B%B7%E8%B4%9D.png)]
参考资料:
https://www.xiaolincoding.com/os/8_network_system/zero_copy.html#sendfile
7 工程/设计模式/框架/源码
7.1 讲一下什么是CI(项目中写了,元戎一面)
CI(CI-Continuous integration,持续集成)
持续集成是指多名开发者在开发不同功能代码的过程当中,可以频繁地将代码行合并到一起并且相互不影响工作。
持续集成的目的,是让产品可以快速迭代,同时还能保持高质量。它的核心措施是,**代码集成到主干之前,必须通过自动化测试(pipeline)。**只要有一个测试用例失败,就不能集成。
8 算法/数学/算法/几何题
8.1 给出一个三角形的三个点的坐标,写一个函数,可以输出三角形内随机的一个点的位置。
方法一:假设三角形三个点为x,y,z,随机 a , b ∈ [ 0 , 1 ] a, b \in [0,1] a,b∈[0,1],条件为 a + b ∈ [ 0 , 1 ] a + b \in [0,1] a+b∈[0,1],向量 a X Y ⃗ + b X Z ⃗ a\vec{XY} + b\vec{XZ} aXY+bXZ就是答案,问题就变成了在正方形里随机点,使得点在下三角中。如果点在上三角中,则翻折三角形。
方法二:三个随机数 a,b,c 满足 a+b+c = 1,然后画出点 ax+by+cz 即可。
x = np.array([0.0, 0.0])
y = np.array([2.0, 0.0])
z = np.array([1.0, math.sqrt(3)])
plt.plot([0.0, 2.0], [0.0, 0.0], color='black')
plt.plot([0.0, 1.0], [0.0, math.sqrt(3)], color='black')
plt.plot([1.0, 2.0], [math.sqrt(3), 0.0], color='black')
for i in range(1000):
a = random.uniform(0,1)
b = random.uniform(0,1)
if a + b > 1: # 关于正方形副对角线对称
d = (a + b - 1) / 2
a -= 2 * d
b -= 2 * d
va = y - x
vb = z - x
point = va * a + vb * b
print(a, b, point)
plt.scatter(point[0], point[1])
plt.show()
x = np.array([0.0, 0.0])
y = np.array([2.0, 0.0])
z = np.array([1.0, math.sqrt(3)])
plt.plot([0.0, 2.0], [0.0, 0.0], color='black')
plt.plot([0.0, 1.0], [0.0, math.sqrt(3)], color='black')
plt.plot([1.0, 2.0], [math.sqrt(3), 0.0], color='black')
for i in range(1000):
a = random.uniform(0,1)
b = random.uniform(0,1)
if a > b:
a = a + b
b = a - b
a = a - b
point = a * x + (b - a) * y + (1 - b) * z
print(a, b, point)
plt.scatter(point[0], point[1])
plt.show()
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BrCKf4LM-1665651010819)(./myplot)]
8.2 判断点在不在多边形内
只需要 计算点与多边形顶点的面积和 是否等于 多边形的面积 即可。
8.3 大根堆的插入删除
大根堆是一个完全二叉树。
插入的时候会插入到完全二叉树的最右边,然后进行上浮。
删除的时候先把堆顶与最右边的节点交换,之后将最右边的节点删掉。最后将新换上来的节点下沉。
8.4 哈希冲突是如何解决的?(华为二面)
- 线性探查。该元素的哈希值对应的桶不能存放元素时,循序往后⼀⼀查找,直到找到⼀个空桶为
⽌,在查找时也⼀样,当哈希值对应位置上的元素与所要寻找的元素不同时,就往后⼀⼀查找,直
到找到吻合的元素,或者空桶。 - ⼆次探查。该元素的哈希值对应的桶不能存放元素时,就往后寻找 1 2 1^2 12, 2 2 2^2 22, 3 2 3^2 32, 4 2 4^2 42, . . . . . i 2 .....i^2 .....i2个位置。
- 双散列函数法。当第⼀个散列函数发⽣冲突的时候,使⽤第⼆个散列函数进⾏哈希,作为步⻓。
- 开链法。在每⼀个桶中维护⼀个链表,由元素哈希值寻找到这个桶,然后将元素插⼊到对应的链表
中,STL的hashtable就是采⽤这种实现⽅式。
8.5 回文自动机实现(CF 17E)
统计相交回文串的个数
struct PAM {
// num[i] 以 i 节点为后缀的回文字符串所包含的回文串数量。
// cnt[i] 整串中能形成 i 节点回文串的数量。
int fail[N], step[N], g[N][27], n, last, tot, cnt[N], num[N];
void init() {
memset(step,0,sizeof(step));
memset(fail,0,sizeof(fail));
memset(num,0,sizeof(num));
memset(cnt,0,sizeof(cnt));
memset(g,0,sizeof(g));
fail[0] = fail[1] = 1; step[1] = -1; last = 0; tot = 1; n = -1;
}
int insert(int nx) {
int p = last; n++;
while(s[n] != s[n - step[p] - 1]) p = fail[p];
if(!g[p][nx]) {
int np = ++tot; step[np] = step[p] + 2;
int q = fail[p];
while(s[n] != s[n - step[q] - 1]) q = fail[q];
fail[np] = g[q][nx]; g[p][nx] = np;
num[np] = num[fail[np]] + 1;
}last = g[p][nx];
cnt[last] ++;
return num[last];
}
int calc() { // 计算回文数的个数
int ret = 0;
for(int i=tot;i>=1;i--) {
cnt[fail[i]] = (cnt[fail[i]] + cnt[i]) % mod;
ret = (ret + cnt[i]) % mod;
}
return ret;
}
}pam;
8.6 后缀自动机(CF 235)
给定母串,统计母串中含子串或其循环同构串的个数。
后缀自动机的原理:
- 判断 step[q] = step[p] + 1,代表从p节点走向转移状态q只有一条路径,则可以直接继承 fail[np] = q。
- 当 step[q] 不等于 step[p] + 1,代表从p节点走向转移状态q有多条路径,不能直接继承,则拆一个只有一条路径的节点出来,重新计算。
struct SAM {
// cnt[i] 表示当前节点代表了多少个子串
int fail[N], step[N], g[N][27], last, tot, R[N], r[N];
long long cnt[N];
bool vis[N];
void init() {
memset(fail,0,sizeof(fail));
memset(step,0,sizeof(step));
memset(g,0,sizeof(g));
memset(cnt,0,sizeof(cnt));
memset(R,0,sizeof(R));
memset(r,0,sizeof(r));
memset(vis,0,sizeof(vis));
last = tot = 1;
}
void insert(int nx) {
int p = last; int np = ++tot; step[np] = step[p] + 1;
while(p && !g[p][nx]) g[p][nx] = np, p = fail[p];
if(!p) fail[np] = 1;
else {
int q = g[p][nx];
if(step[q] == step[p] + 1) fail[np] = q;
else {
int nq = ++tot; step[nq] = step[p] + 1;
fail[nq] = fail[q]; fail[np] = fail[q] = nq;
for(int i=1;i<=26;i++) g[nq][i] = g[q][i];
while(p && g[p][nx] == q) g[p][nx] = nq, p = fail[p];
}
}last = np;
cnt[np] = 1;
}
void Rsort() {
for(int i=1;i<=tot;i++) R[step[i]] ++;
for(int i=1;i<=tot;i++) R[i] += R[i-1];
for(int i=tot;i>=1;i--) r[R[step[i]]--] = i;
for(int i=tot;i>=1;i--) cnt[fail[r[i]]] += cnt[r[i]];
}
}sam;