文章目录
- 引言
- 一面面试内容
- 基础知识
- 一、Redis为什么进行AOF重写?
- 二、AQS和Conditon的使用
- 三、乐观锁和分布式锁有什么差异?频繁使用乐观锁行不行?
- 四、Java的即时编译技术
- 五、Java中的JVM调优是如何做的?
- 六、Java中创建对象的流程?
- 七、Java中的反射编程,为什么慢?
- 八、AOP的两种实现方式,有什么区别 ?
- 手撕
- 1、写一个SQL,实现A给B转账50
- 2、使用Java实现一个同步机制中的wait方法
- 结果
- 二面(挂)
- 基础知识
- 1、HashMap介绍一下?是线程安全的吗?
- 2、有其他线程安全的吗?怎么实现的?
- 3、说一说CAS和Synchronized两种同步机制的差异
- 4、说一下锁的自动升级?
- 5、数据库中有哪几种类型的索引?
- 6、数据库是如何保证持久化?数据一定不会丢失吗?在主从复制的情况下,我一点数据都不想让他让丢失,怎么办?
- 7、数据在可重复读隔离级别下,解决了什么问题?
- 8、怎么解决幻读问题?是否完全解决?如果不行,举一个例子?(当初看这里的时候跳过了,以为不会有一家公司会问的那么细致吧!结果,字节问到了,给我整懵了!)
- 9、说一说B+树的具体结构?
- 10、将所有最低分都超过80分的学生的id列出来,写一个SQL
- 11、分析下面几个where子句是否用到了索引?
- 算法题
- 总结
引言
- 今天面试字节,发现很多细节的问题并不了解,很多东西都是知道,但是具体细节就不知道了,然后深感我的知识储备,还是比较草率或者说疏忽的!
- 下面每一章都需要好好补充,好好弄懂!都是难点!
一面面试内容
基础知识
一、Redis为什么进行AOF重写?
AOF写满了怎么办?
- 触发AOF重写机制
AOF是什么
- 是redis的一种持久化机制,将redis的写操作,以日志的形式记录在文件中
为什么进行AOF重写
- 减少文件大小
- AOF不断追加写操作日志,会包括重复或者无效命令,重写以优化其性能和存储效率
- 提高恢复速度
- 重写之后,会去除很多重复或者无效的命令,恢复起来执行的命令就会减少,速度更快
- 优化性能
- AOF文件过大,会增加redis的内存消耗和CPU负载,重写的时候子进程要干的活就多了,重写之后,能够减轻负担,优化性能
重写机制
- 主进程fork一个子进程
- 子进程扫描Redis数据库,将每一个键值对转换为相应的写入命令,写入到一个临时文件中
- 注意!!!,这里是直接扫描数据库,将每一个键值对转为命令
- 主进程继续接收和处理客户端的请求,将新的写操作追加到一个重写缓冲区中。
- 子进程完成之后,会将缓冲区的写操作追加到临时文件中
- 通知主进程用临时文件替换旧的AOF文件
- 子进程扫描Redis数据库,将每一个键值对转换为相应的写入命令,写入到一个临时文件中
AOF重写的参数配置
- 开启重写功能,在redis.conf文件中,设置appendonly yes参数
- 设置重写触发条件,需要同时满足内存使用率和文件大小在进行重写
- 内存使用率:auto-aof-rewrite-percentage 100
- 内存的大小:auto-aof-rewrite-min-size 64mb
- 重启redis
疏忽点
- AOF重写并不是重写指令,是直接将某个时刻的数据库的键值对转换成指令,然后追加写入
二、AQS和Conditon的使用
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.*;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
static int curNum = 0;
static class ThreadTest implements Runnable {
int count = 0;
ThreadTest(int countType) {
count = countType;
}
void printNum() {
while(true) {
lock.lock();
while (curNum % 3 != count) {
try {
condition.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if(curNum > 100) break;
System.out.println("Thread No:" + count + " print :" + curNum++);
condition.signalAll();
lock.unlock();
}
}
@Override
public void run() {
printNum();
}
}
public static void main(String[] args) {
Thread thread1 = new Thread(new ThreadTest(1));
Thread thread2 = new Thread(new ThreadTest(2));
Thread thread3 = new Thread(new ThreadTest(0));
thread1.start();
thread2.start();
thread3.start();
}
}
正常情况下,condition是可以有多个信号量的,比如说读者和写者,就是使用了多个condition的方法
三、乐观锁和分布式锁有什么差异?频繁使用乐观锁行不行?
1、乐观锁特点
- 高并发性能
- 并不阻塞其他事物的读取操作,只在提交的时候检查数据是否被修改,适合读多写少的情况
- 无锁操作
- 乐观锁并不需要显示的获取锁和释放锁,减少了锁竞争和上下文切换的开销
- 无死锁操作
- 由于乐观所不会阻塞其他事物的访问,所以不会出现死锁的情况
2、乐观锁:如果数据被修改了会怎么样?
- 当乐观锁提交更新时检测到数据已经被其他事物修改
- 认为当前数据的更新操作处于过时的数据版本,会拒绝提交更新操作
- 触发回滚操作,将事物恢复到修改前的状态,保证了数据的一致性。
- 检测到异常之后,应用层会捕获对应异常的
- 认为当前数据的更新操作处于过时的数据版本,会拒绝提交更新操作
3、为什么乐观锁仅仅适合读多写少的高并发情况?
- 在读多写少的情况下,数据并发修改的概率相对较低
- 乐观锁在并发环境下,冲突很少发生,读数据时并不需要加锁,提高读操作的并发性
- 在高并发写频繁的环境下,乐观锁会导致大量的冲突和事物重试,降低系统的性能和吞吐量
4、批量事物处理
- 使用批量修改,事物和事物之间发生冲突的概率就越大,整体回滚的可能性越高,所以使用批量处理的话,还是推荐使用分布式锁
5、与分布式锁的比较
-
性能
- 乐观锁
- 适用于并发冲突较少的情况,性能高,大部分时间都不需要等待锁的释放
- 分布式锁
- 性能相对较低,增加了网络通信的开销
- 乐观锁
-
复杂性
- 乐观锁
- 实现相对简单,只需要一个版本号或者时间戳字段即可,但是在冲突上处理比较复杂,需要应用层来处理回滚和重试
- 分布式锁
- 实现相对复杂,但是能够确保锁的可靠性和已执行,同时,还需要处理锁的超时、续期和释放等问题
- 乐观锁
-
安全性
- 乐观锁
- 在并发冲突较多的场景下,乐观所可能会导致数据一致性的风险(为什么)
- 分布式锁
- 能够在一定程度上减少数据一致性问题,但是中心街店出现故障的话,会导致锁无法正常工作
- 乐观锁
四、Java的即时编译技术
1、简介
- JVM虚拟机性能优化的手段,将频繁使用的热点代码翻译成高效的本地机器码,直接由硬件执行!
- 传统编译过程
- 源代码首先编译成JVM可执行的字节码,通过JVM逐条解释执行,翻译成机器码
2、即时编译和正常编译的区别
- 正常编译
- 发生在程序运行时
- 监控程序的执行,识别出频繁执行的代码(热点代码),将这些代码动态编译成机器码。
- 在程序运行过程中,不断优化代码,适应当前的运行环境。
- 启动速度慢
- 启动时需要花时间来编译热点代码,启动较慢
- 发生在程序运行时
- 即时编译
- 发生在程序运行前
- 将源代码一次性编译成与特定平台相关的机器码,生成可执行文件,无法修改
- 启动速度快
- 无需等待编译过程,直接运行
- 发生在程序运行前
3、工作原理
-
1、解释执行
- 使用JVM的解释器,以字节码的形式进行解释执行
- 收集程序运行时的统计信息
-
2、特点探测
- 记录频繁执行的代码是特点代码
- 基于计数器的热点探测机制
-
3、即时编译
- 对热点代码进行即时编译,利用优化技术将热点代码优化,转为本地机器码
-
4、本地代码执行
- 优化之后的本地代码是在本地缓存,直接在CPU上执行,远快于解释执行
4、优化技术
- 方法内联
- 将频繁调用的小型方法体,直接插入到调用点,减少方法调用的开销
- 循环优化
- 改进循环执行的效率,通过循环展开减少循环次数
- 死代码消除
- 删除不会运行到的代码
- 逃逸分析
- 分析对象的作用域,决定是否可以在栈上分析,不是在堆上,减少内存分配和回收开销
五、Java中的JVM调优是如何做的?
1、堆内存调优
- 堆内存大小,避免频繁垃圾回收
- 新生代和老年代的比例
2、垃圾回收GC调优
- GC算法
3、线程调优
- 线程池大小
- 线程栈大小,控制线程的内存占用
4、JIT编译器调优
- 选择合适的JIT编译器
- 编译阈值等
5、内存分配调优
六、Java中创建对象的流程?
1、类加载
- 判定对应类是否已经加载的,如果没加载,需要执行对应的类加载程序
- 加载
- 连接
- 验证
- 准备
- 解析
- 初始化
- 如果类已经加载过
- JVM方法区中已经存在该类的Class对象,不需要执行对应的加载过程。
2、分配内存
- JVM方法区中已经存在该类的Class对象,不需要执行对应的加载过程。
- 基于两种方法,在堆内存上分配对象需要的内存
- 指针碰撞:内存规整,向高低之移动对应空间大小的距离
- 空闲列表:内存散乱,从列表中找到可用空间分配给对象
3、初始化对象
- 将对象的实例变量初始化为其类型的默认值
4、构造对象
- 执行构造函数,初始化实例变量
5、引用对象
- 新创建的对象通过引用变量被访问和使用
- 对象存储在堆空间中
- 引用存储在栈的局部变量中
七、Java中的反射编程,为什么慢?
反射慢的原因
- 1、动态解析
- 访问对象的方法需要查找类的元数据信息,基于这些信息进行方法调用和字段的访问
- 常规方法:
- 编译时已经确定,直接调用
- 2、安全性检查
- 使用反射,JVM会进行额外的安全性检查,确保当前线程有权访问目标类和字段
- 安全性检查会增加开销
- 3、方法调用开销
- 不会直接跳转到方法的实现代码地址
- 需要通过反射API间接调用
八、AOP的两种实现方式,有什么区别 ?
- AOP是通过动态代理实现的,主要是基于两种动态代理机制,JDK动态代理和CGLIB动态代理
JDK动态代理
- 实现方式
- Java原生的动态代理机制,目标类必须实现接口,代理类通过接口代理被代理对象的方法调用
- 适用场景
- 目标对象必须实现一个或者多个接口
- 代理类将实现与目标相同的接口,
- 优点
- JDK动态代理原生,通过反射机制生成的代理类会更快
- 缺点
- 目标类必须实现接口
CGLIB动态代理
-
实现方式
- 第三方库,直接操作字节码,生成目标类的子类,重写类的方法完成代理
-
适用场景
- 目标类没有实现接口时,使用CGLIB代理
-
优点
- 不需要实现接口,更灵活
- 执行代理方法的效率更高,避免了反射的额外开销
-
缺点
- 操作字节码,生成速度慢
手撕
1、写一个SQL,实现A给B转账50
注意点
- 原子操作
- A账户扣款操作和B账户的收款操作应该是院子操作,不可中断
- 是否可转账
- A能够转账应该是A的钱是刚好够转账的,所以需要检查一下
下述代码只是应用于面试,实际开发中不会这么写
标准的SQL并不支持单个SQL查询或者命令中直接嵌套IF语句来控制事物的提交或者回滚
--开始事物--
START TRANSACTION;
UPDATE ACCOUNTS
SET BALANCE = BALANCE - 100
WHERE USER_ID = 1;
IF (SELECT BALANCE FROM ACCOUNTS WHERE USER_ID = 1) < 0 THEN
ROLLBACK;
ELSE
UPDATE ACCOUNTS
SET BALANCE = BALANCE + 100
WHERE USER_ID = 2;
COMMIT;
END IF;
2、使用Java实现一个同步机制中的wait方法
- wait方法前提
- 何时等待锁
- 通常是在某一个条件不满足时,等待一个资源变得可用,或者等待某一个操作的手结果
- 通过检查一个条件变量来实现
- 在哪里等待
- 必须在同步快或者同步方法的内部进行
- wait方法会释放当前线程持有的锁,导致该线程进入同步状态
- 如何唤醒
- 唤醒和等待通常在同一个锁上实现
- 何时等待锁
这里是让实现wait方法,所以并不是调用这方法,需要对AQS有很深的理解
-
这里将AQS内容再进一步深化,之前仅仅是浅浅地了解一下,并没有深究,现在还需要深究一下,两次学习连接如
- 第一次粗浅地学习
- 第二次深入学习
-
通过第二次学习,可以知道,专门创建一个等待队列,用来保存阻塞节点,然后锁的竞争机制,创建一个同步队列,保存竞争失败的线程。
-
升级为非公平锁
- 在加入队列之前,先竞争锁的使用
下述是参考的百度文心一言生成的,实际上我就写了一个大概,算是过了
class Node {
// 线程引用
Thread thread;
// 等待状态
int waitStatus;
// 前驱节点
Node prev;
// 后继节点
Node next;
// 构造函数等...
}
class Lock {
// 同步队列的头节点
private volatile Node head;
// 同步队列的尾节点
private volatile Node tail;
// 锁的状态,0表示未锁定,1表示锁定
private volatile int state = 0;
// 尝试获取锁(非公平)
public void lock() {
// 尝试直接获取锁
if (compareAndSetState(0, 1)) {
// 成功获取锁
setExclusiveOwnerThread(Thread.currentThread());
} else {
// 竞争失败,加入同步队列
enqueue(addWaiter(Node.EXCLUSIVE));
acquireQueued(addWaiter(Node.EXCLUSIVE), true);
}
}
// 尝试释放锁
public void unlock() {
// 释放锁逻辑...
// 唤醒同步队列中的一个等待线程
}
// 等待操作(模拟)
public void await() {
// 简化处理,直接加入同步队列并阻塞当前线程
Node node = addWaiter(Node.SHARED);
// 阻塞当前线程,直到被唤醒
parkAndCheckInterrupt();
}
// 其他辅助方法...
}
结果
- 很意外,面试面的我心惊肉跳,但是过了,可能最后那个AQS写对了吧!不过后续的二面和三面不抱太大希望了,因为我觉得字节太难了,真的太难了!
二面(挂)
基础知识
1、HashMap介绍一下?是线程安全的吗?
简介
- 基于哈希表实现,访问速度快。
- 插入和删除的时间复杂度都是O(1)
- 元素之间没有顺序
- 哈希冲突的解决方式
- Java7采用数组 + 链表的方式实现
- Java8采用 数组 + 链表 + 红黑树 的方式实现
- 链表的长度大于9,自动转成红黑树
- 是否线程安全
- 非线程安全,推荐使用concurrentHashMap
- 数组扩容方式
- 当前存量 和 容量 * 负载因子的比较
- 扩容两倍的方式实现
- 通过按位 与 的方式实现取余运算
2、有其他线程安全的吗?怎么实现的?
为什么使用ConcurrentHashMap
- 本身不是线程安全的
- Hashtable线程安全,但是运行效率低,底层使用synchronized加锁
- 锁的颗粒度太高
- ConcurrentHashMap使用分段锁,提高并发率的同时,保证线程安全
- 将数据分段存储,每一段配一把锁,一个线程占用锁访问其中一段的时候,不影响其他线程继续访问
- 读数据不加锁,写数据才会加锁
JDK1.7分段锁的实现
- Segment数组将hash表分段
- ConcurrentHashMap对象中,保存了Segment数组,将整个Hash表划分为多个分段
- 每一个分段类似一个HashTable
- 默认支持16个线程并发操作
- ConcurrentHashMap对象中,保存了Segment数组,将整个Hash表划分为多个分段
- 访问流程
- 先根据hash算法定位到对应的Segment,对Segment加锁就行
JDK1.8实现
- 实现原理
- 数组+ 链表 + 红黑树
- 通过CAS和Synchronized实现加锁
- 具体加锁过程
- 对应hash位置没有元素
- 使用CAS插入元素
- 对应位置已经有链表了
- 使用Synchronized锁住槽点,防止其他线程操作
- 对应hash位置没有元素
乐观锁和CAS有什么不同?
- CAS是乐观锁的一种实现
- 乐观锁
- 定义
- 假设多个线程之间发生冲突的可能性很小
- 读取数据的时候不加锁
- 只有在更新数据的时候才会检查是否有其他的线程已经修改了该数据。
- 假设多个线程之间发生冲突的可能性很小
- 实现方式
- 通过版本号和时间戳来实现
- 更新数据的时候,会检查版本号或者时间戳是否和最初读取的一致
- 如果一致说明这个阶段没有人修改数据,直接修改即可
- 如果不一致说明这个阶段有人修改数据,放弃或者重试
- 定义
- CAS锁
- 定义
- 比较和替换,是一种无锁编程技术,用于实现多线程间的原子操作
- 特点
- 只能保证单个共享变量的原子性
- 定义
3、说一说CAS和Synchronized两种同步机制的差异
锁的性质
- CAS
- 乐观锁
- 假设在多线程数据处理过程中,不会发生冲突。所以读取数据的时候,不会加锁。修改数据的时候,才会去检查是否有其他线程修改了数据。
- 非阻塞
- CAS操作在尝试更新数据时,如果数据未被修改,直接更新;如果数据被修改了,操作失败,不会阻塞当前线程,允许器继续执行或者尝试。
- 乐观锁
- Synchronized
- 悲观锁
- 假设在多线程的环境下,一定会发生冲突,所以当问数据的时候一定会加锁,知道访问资源结束。
- 阻塞
- 访问失败,会陷入阻塞,只当前锁被释放,其他线程唤醒。
- 悲观锁
4、说一下锁的自动升级?
无锁状态
- 对象在创建之初,对象头的Mark Word被没有任何锁的标志位设置,自由访问
偏向锁
- 第一个线程获取就升级为偏向锁
- 当第一个线程尝试获取某个对象的synchronized锁时,会将对象头标记设置为偏向锁,并在对象头标记填上获取当前锁的线程ID。
- 作用
- 减少在单线程环境下获取锁的开销,一个线程肯定会多次获取一个锁的
轻量级锁
- 第二个线程尝试获取偏向锁
- 当有第二个线程尝试获取偏向锁时,偏向锁就会撤销,变为轻量级锁状态
- 具体实现
- 使用线程的本地的ThreadLocal变量存储锁记录,包含了锁记录的MarkWord拷贝
- 锁竞争
- 如果获取锁失败,会通过自旋来获取锁,超过阈值,轻量级锁膨胀为重量级锁
重量级锁
- 自旋失败就是重量级锁
- 如果自旋尝试失败就是重量级锁,重量级锁是通过操作系统的互斥锁实现的
- 获取锁失败,阻塞唤醒都需要操作系统实现
5、数据库中有哪几种类型的索引?
B+树索引
- 用途
- 保持数据排序,并允许对索引进行高效的顺序访问和随机访问
哈希索引
- 用途
- 基于哈希表实现,适用于等值查询
全文索引
- 用途
- 用于搜索存储在数据库中的文本内容的关键字。
- 支持短语搜索、模糊搜索等
复合索引
- 用途
- 在表的多个列上创建的索引,允许基于这些列的组合的索引查询。
6、数据库是如何保证持久化?数据一定不会丢失吗?在主从复制的情况下,我一点数据都不想让他让丢失,怎么办?
主从复制的过程(具体见上图)
- 主库的角度出发
- 主库发生数据变更,并将结果写入binlog日志
- 主库会随时开启dump线程检测到binlog日志的更改,会将修改的binlong日志发送给从库
- 从库的接受binlog日志
- 从库接受binlog日志,并将日志文件写入relaylog日志中,并发送复制成功的响应
- 从库创建线程回放relaylog日志
三种主从复制模式,这里我会选择同步复制模式
-
1、同步复制==》从库成功了,事物才成功
- 提交事务的线程,需要的等待所有复制的从库确认复制成功的响应,才返回给客户端结果。
- 特点
- 性能差
- 安全性高,对于数据一点都不允许丢失的,可以使用如下方式
-
2、半同步复制==》一部分成功了就行了
- 提交事务的线程,只要等到一部分的从库复制成功的响应,就返回给客户端结果。
- 特点
- 兼顾了异步的高效性,以及同步的安全性,但是是针对的多个从节点而言的
-
3、异步复制==》不管从库,主库搞定了就行
- 提交事务的县城,不会等待binlog同步到各个从库,立刻返回给客户端的
- 特点
- 安全性最差,宕机就完蛋
7、数据在可重复读隔离级别下,解决了什么问题?
完全解决了脏读、不可重复读、部分解决了幻读
脏读
- 定义
- 一个事务读取了另外一个事务还没有提交的数据(有可能发生回滚,数据就不存在了)
不可重复读
- 定义
- 同一个事务内部,多次读取同一个数据集合的时候,由于其他并发事务的提交,导致后续读取到的数据和前面的不一致。
- 重在于修改,一开始读取性别是男的,后面就变成女的了
幻读==》眼花了吗?我记得之前没有呀?
- 定义
- 在一个事务内,同一查询多次执行,由于其他事务的插入操作,后续的结果集中出现了之前查询不到的记录
- 一开始查一个班级里是42个人,结果变成了41个人,出现了幻觉!
8、怎么解决幻读问题?是否完全解决?如果不行,举一个例子?(当初看这里的时候跳过了,以为不会有一家公司会问的那么细致吧!结果,字节问到了,给我整懵了!)
可重复度隔离级别解决幻读问题
- 快照读
- 通过MVCC解决幻读,事务执行过程中看到的数据和事务开始之前是一致的,
- 即使其他事务插入一条新的数据,也是一样的。
- 通过MVCC解决幻读,事务执行过程中看到的数据和事务开始之前是一致的,
- 当前读
- 通过行级锁解决,通过**nextkey锁(记录锁 + 间隙锁)**解决
- select for update:会在查询范围内插入间隙锁,其他事务无法往其中插入数据
未完全解决的幻读问题
- 快照读的问题
- 当前事务A更新了一条事务B插入的记录,
- 事务A前后两次查询的记录条目就不一样了
- 发生了幻读
A:select * from table_a where id = 7; //数据不存在,还没有结果
B:insert into table_a values( 7,"ren"); //事务B插入新数据
A:update table_a set name = "a" where id = 7; // 更新记录7
A:select * from table_a where id = 7; 再查就能看见了
- 当前读的问题
- 事务开启前,使用快照读,没有使用当前读,有一个A
- 另外一个事务执行之后,在执行当前读,for update 就会出现问题
9、说一说B+树的具体结构?
基本构成
-
非叶子节点
- 仅仅包含索引
- 关键字在节点中按升序排列
-
叶子节点
- 所有的数据记录或指向数据记录的指针。
- 叶子节点之间通过指针相连,这些指针按照关键字的顺序排列
特点
- 关键字冗余
- 关键字可以在内部节点和叶子节点中重复
- 内部节点中的关键字是分割子树的指标
- 叶子节点中的关键字则指向数据记录。
- 关键字可以在内部节点和叶子节点中重复
- 非叶子节点仅仅包含索引
10、将所有最低分都超过80分的学生的id列出来,写一个SQL
注意
- 在GROUP BY操作中,你不能直接选择非聚合列(即没有包含在聚合函数中的列),除非这些列也被包括在GROUP BY子句中。
SELECT stu_id
FROM course_table
GROUP BY stu_id
HAVING MIN(score) >= 80;
进一步就是
SELECT *
FROM course_table
WHERE stu_id in {
SELECT stu_id
FROM course_table
GROUP BY stu_id
HAVING MIN(score) >= 80;
}
11、分析下面几个where子句是否用到了索引?
- 有一个联合索引(a,b,c)
- 然后判断下面三个语句的索引使用情况
- where a=‘a’ and b=‘b’ and c=‘c’;
- where a=‘a’ and b>‘b’ and c<‘c’;
- where a=‘a’ and c=‘c’;
where a=‘a’ and b=‘b’ and c=‘c’;
- 索引使用情况:这个查询会完全使用到联合索引(a,b,c)。
where a=‘a’ and b>‘b’ and c<‘c’;
- 索引使用情况:这个查询会使用索引a和b。
where a=‘a’ and c=‘c’;
- 索引使用情况:这个查询会使用索引a。
索引最左匹配的使用
- 复合索引如果匹配到的范围查找,就不走索引了,后续会走索引下推
- 复合索引的最左匹配原则,不是说顺序,是说具体的值,where a and b and c 对于索引(a,b,c)是满足最左匹配原则的,但是如果是where c and b就不满足了,因为少了一个。
索引相关学习链接
算法题
题目链接
总结
- 最后算法题来了一道hard的题目,没写出来,就挂了!
- 再接再厉,好好准备一下,后面还有很多其他的公司,在准备一下!