第27讲:单例的实现方式有几种?它们有什么优缺点?
单例模式是 Java 中最简单的设计模式之一,它是指一个类在运行期间始终只有一个实例,我们就把它称之为单例模式。它不但被应用在实际的工作中,而且还是面试中最常考的题目之一。通过单例模式我们可以知道此人的编程风格,以及对于基础知识的掌握是否牢固。
我们本课时的面试题是,单例的实现方式有几种?它们有什么优缺点?
典型回答
单例的实现分为饿汉模式和懒汉模式。顾名思义,饿汉模式就好比他是一个饿汉,而且有一定的危机意识,他会提前把食物囤积好,以备饿了之后直接能吃到食物。对应到程序中指的是,在类加载时就会进行单例的初始化,以后访问时直接使用单例对象即可。
饿汉模式的实现代码如下:
public class Singleton {
// 声明私有对象
private static Singleton instance = new Singleton();
// 获取实例(单例对象)
public static Singleton getInstance() {
return instance;
}
private Singleton() {
}
// 方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
class SingletonTest {
public static void main(String[] args) {
// 调用单例对象
Singleton singleton = Singleton.getInstance();
// 调用方法
singleton.sayHi();
}
}
以上程序的执行结果为:
Hi,Java.
从上述结果可以看出,单例对象已经被成功获取到并顺利地执行了类中的方法。它的优点是线程安全,因为单例对象在类加载的时候就已经被初始化了,当调用单例对象时只是把早已经创建好的对象赋值给变量;它的缺点是可能会造成资源浪费,如果类加载了单例对象(对象被创建了),但是一直没有使用,这样就造成了资源的浪费。
懒汉模式也被称作为饱汉模式,顾名思义他比较懒,每次只有需要吃饭的时候,才出去找饭吃,而不是像饿汉那样早早把饭准备好。对应到程序中指的是,当每次需要使用实例时,再去创建获取实例,而不是在类加载时就将实例创建好。
懒汉模式的实现代码如下:
public class Singleton {
// 声明私有对象
private static Singleton instance;
// 获取实例(单例对象)
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
private Singleton() {
}
// 方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
class SingletonTest {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
singleton.sayHi();
}
}
以上程序的执行结果为:
Hi,Java.
从上述结果可以看出,单例对象已经被成功获取到并顺利地执行了类中的方法,它的优点是不会造成资源的浪费,因为在调用的时候才会创建被实例化对象;它的缺点在多线程环境下是非线程是安全的,比如多个线程同时执行到 if 判断处,此时判断结果都是未被初始化,那么这些线程就会同时创建 n 个实例,这样就会导致意外的情况发生。
考点分析
使用单例模式可以减少系统的内存开销,提高程序的运行效率,但是使用不当的话就会造成多线程下的并发问题。饿汉模式为最直接的实现单例模式的方法,但它可能会造成对系统资源的浪费,所以只有既能保证线程安全,又可以避免系统资源被浪费的回答才能彻底地征服面试官。
和此知识点相关的面试题还有以下这些:
- 什么是双重检测锁?它是线程安全的吗?
- 单例的还有其他实现方式吗?
知识扩展
双重检测锁
为了保证懒汉模式的线程安全我们最简单的做法就是给获取实例的方法上加上 synchronized(同步锁)修饰,如下代码所示:
public class Singleton {
// 声明私有对象
private static Singleton instance;
// 获取实例(单例对象)
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
private Singleton() {
}
// 类方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
这样虽然能让懒汉模式变成线程安全的,但由于整个方法都被 synchronized 所包围,因此增加了同步开销,降低了程序的执行效率。
于是为了改进程序的执行效率,我们将 synchronized 放入到方法中,以此来减少被同步锁所修饰的代码范围,实现代码如下:
public class Singleton {
// 声明私有对象
private static Singleton instance;
// 获取实例(单例对象)
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
private Singleton() {
}
// 类方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
细心的你可能会发现以上的代码也存在着非线程安全的问题。例如,当两个线程同时执行到「if (instance == null) { 」判断时,判断的结果都为 true,于是他们就排队都创建了新的对象,这显然不符合我们的预期。于是就诞生了大名鼎鼎的双重检测锁(Double Checked Lock,DCL),实现代码如下:
public class Singleton {
// 声明私有对象
private static Singleton instance;
// 获取实例(单例对象)
public static Singleton getInstance() {
// 第一次判断
if (instance == null) {
synchronized (Singleton.class) {
// 第二次判断
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
// 类方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
上述代码看似完美,其实隐藏着一个不容易被人发现的小问题,该问题就出在 new 对象这行代码上,也就是 instance = new Singleton() 这行代码。这行代码看似是一个原子操作,然而并不是,这行代码最终会被编译成多条汇编指令,它大致的执行流程为以下三个步骤:
- 给对象实例分配内存空间;
- 调用对象的构造方法、初始化成员字段;
- 将 instance 对象指向分配的内存空间。
但由于 CPU 的优化会对执行指令进行重排序,也就说上面的执行流程的执行顺序有可能是 1-2-3,也有可能是 1-3-2。假如执行的顺序是 1-3-2,那么当 A 线程执行到步骤 3 时,切换至 B 线程了,而此时 B 线程判断 instance 对象已经指向了对应的内存空间,并非为 null 时就会直接进行返回,而此时因为没有执行步骤 2,因此得到的是一个未初始化完成的对象,这样就导致了问题的诞生。执行时间节点如下表所示:
时间点 | 线程 | 执行操作 |
---|---|---|
t1 | A | instance = new Singleton() 的 1-3 步骤,待执行步骤 2 |
t2 | B | if (instance == null) { 判断结果为 false |
t3 | B | 返回半初始的 instance 对象 |
为了解决此问题,我们可以使用关键字 volatile 来修饰 instance 对象,这样就可以防止 CPU 指令重排,从而完美地运行懒汉模式,实现代码如下:
public class Singleton {
// 声明私有对象
private volatile static Singleton instance;
// 获取实例(单例对象)
public static Singleton getInstance() {
// 第一次判断
if (instance == null) {
synchronized (Singleton.class) {
// 第二次判断
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
// 类方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
单例其他实现方式
除了以上的 6 种方式可以实现单例模式外,还可以使用静态内部类和枚举类来实现单例。静态内部类的实现代码如下:
public class Singleton {
// 静态内部类
private static class SingletonInstance {
private static final Singleton instance = new Singleton();
}
// 获取实例(单例对象)
public static Singleton getInstance() {
return SingletonInstance.instance;
}
private Singleton() {
}
// 类方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
从上述代码可以看出,静态内部类和饿汉方式有异曲同工之妙,它们都采用了类装载的机制来保证,当初始化实例时只有一个线程执行,从而保证了多线程下的安全操作。JVM 会在类初始化阶段(也就是类装载阶段)创建一个锁,该锁可以保证多个线程同步执行类初始化的工作,因此在多线程环境下,类加载机制依然是线程安全的。
但静态内部类和饿汉方式也有着细微的差别,饿汉方式是在程序启动时就会进行加载,因此可能造成资源的浪费;而静态内部类只有在调用 getInstance() 方法时,才会装载内部类从而完成实例的初始化工作,因此不会造成资源浪费的问题。由此可知,此方式也是较为推荐的单例实现方式。
单例的另一种实现方式为枚举,它也是《Effective Java》作者极力推荐地单例实现方式,因为枚举的实现方式不仅是线程安全的,而且只会装载一次,无论是序列化、反序列化、反射还是克隆都不会新创建对象。它的实现代码如下:
public class Singleton {
// 枚举类型是线程安全的,并且只会装载一次
private enum SingletonEnum {
INSTANCE;
// 声明单例对象
private final Singleton instance;
// 实例化
SingletonEnum() {
instance = new Singleton();
}
private Singleton getInstance() {
return instance;
}
}
// 获取实例(单例对象)
public static Singleton getInstance() {
return SingletonEnum.INSTANCE.getInstance();
}
private Singleton() {
}
// 类方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
class SingletonTest {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
singleton.sayHi();
}
}
以上程序的执行结果为:
Hi,Java.
小结
本课时我们讲了 8 种实现单例的方式,包括线程安全但可能会造成系统资源浪费的饿汉模式,以及懒汉模式和懒汉模式变种的 5 种实现方式。其中包含了两种双重检测锁的懒汉变种模式,还有最后两种线程安全且可以实现延迟加载的静态内部类的实现方式和枚举类的实现方式,其中比较推荐使用的是后两种单例模式的实现方式。
第28讲:你知道哪些设计模式?分别对应的应用场景有哪些?
上一课时我们讲了单例模式的 8 种实现方式以及它的优缺点,可见设计模式的内容是非常丰富且非常有趣。我们在一些优秀的框架中都能找到设计模式的具体使用,比如前面 MyBatis 中(第 13 课时)讲的那些设计模式以及具体的使用场景,但由于设计模式的内容比较多,有些常用的设计模式在 MyBatis 课时中并没有讲到。因此本课时我们就以全局的视角,来重点学习一下这些常用设计模式。
我们本课时的面试题是,你知道哪些设计模式?它的使用场景有哪些?它们有哪些优缺点?
典型回答
设计模式从大的维度来说,可以分为三大类:创建型模式、结构型模式及行为型模式,这三大类下又有很多小分类。
创建型模式是指提供了一种对象创建的功能,并把对象创建的过程进行封装隐藏,让使用者只关注具体的使用而并非对象的创建过程。它包含的设计模式有单例模式、工厂模式、抽象工厂模式、建造者模式及原型模式。
结构型模式关注的是对象的结构,它是使用组合的方式将类结合起来,从而可以用它来实现新的功能。它包含的设计模式是代理模式、组合模式、装饰模式及外观模式。
行为型模式关注的是对象的行为,它是把对象之间的关系进行梳理划分和归类。它包含的设计模式有模板方法模式、命令模式、策略模式和责任链模式。
下面我们来看看那些比较常见的设计模式的定义和具体的应用场景。
1. 单例模式
单例模式是指一个类在运行期间始终只有一个实例,我们把它称之为单例模式。
单例模式的典型应用场景是 Spring 中 Bean 实例,它默认就是 singleton 单例模式。
单例模式的优点很明显,可以有效地节约内存,并提高对象的访问速度,同时避免重复创建和销毁对象所带来的性能消耗,尤其是对频繁创建和销毁对象的业务场景来说优势更明显。然而单例模式一般不会实现接口,因此它的扩展性不是很好,并且单例模式违背了单一职责原则,因为单例类在一个方法中既创建了类又提供类对象的复合操作,这样就违背了单一职责原则,这也是单例模式的缺点所在。
2. 原型模式
原型模式属于创建型模式,它是指通过“克隆”来产生一个新的对象。所以它的核心方法是 clone(),我们通过该方法就可以复制出一个新的对象。
在 Java 语言中我们只需要实现 Cloneable 接口,并重写 clone() 方法就可以实现克隆了,实现代码如下:
public class CloneTest {
public static void main(String[] args) throws CloneNotSupportedException {
// 创建一个新对象
People p1 = new People();
p1.setId(1);
p1.setName("Java");
// 克隆对象
People p2 = (People) p1.clone();
// 输出新对象的名称
System.out.println("People 2:" + p2.getName());
}
static class People implements Cloneable {
private Integer id;
private String name;
/**
* 重写 clone 方法
*/
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}
程序的执行结果为:
People 2:Java
但需要注意的是,以上代码为浅克隆的实现方式,如果要实现深克隆(对所有属性无论是基本类型还是引用类型的克隆)可以通过以下手段实现:
- 所有对象都实现克隆方法;
- 通过构造方法实现深克隆;
- 使用 JDK 自带的字节流实现深克隆;
- 使用第三方工具实现深克隆,比如 Apache Commons Lang;
- 使用 JSON 工具类实现深克隆,比如 Gson、FastJSON 等。
具体的实现代码可以参考我们第 07 课时的内容。
原型模式的典型使用场景是 Java 语言中的 Object.clone() 方法,它的优点是性能比较高,因为它是通过直接拷贝内存中的二进制流实现的复制,因此具备很好的性能。它的缺点是在对象层级嵌套比较深时,复制的代码实现难度比较大。
3. 命令模式
命令模式属于行为模式的一种,它是指将一个请求封装成一个对象,并且提供命令的撤销和恢复功能。说得简单一点就是将发送者、接收者和调用命令封装成独立的对象,以供客户端来调用,它的具体实现代码如下。
接收者的示例代码:
// 接收者
class Receiver {
public void doSomething() {
System.out.println("执行业务逻辑");
}
}
命令对象的示例代码:
// 命令接口
interface Command {
void execute();
}
// 具体命令类
class ConcreteCommand implements Command {
private Receiver receiver;
public ConcreteCommand(Receiver receiver) {
this.receiver = receiver;
}
public void execute() {
this.receiver.doSomething();
}
}
请求者的示例代码:
// 请求者类
class Invoker {
// 持有命令对象
private Command command;
public Invoker(Command command) {
this.command = command;
}
// 请求方法
public void action() {
this.command.execute();
}
}
客户端的示例代码:
// 客户端
class Client {
public static void main(String[] args) {
// 创建接收者
Receiver receiver = new Receiver();
// 创建命令对象,设定接收者
Command command = new ConcreteCommand(receiver);
// 创建请求者,把命令对象设置进去
Invoker invoker = new Invoker(command);
// 执行方法
invoker.action();
}
}
Spring 框架中的 JdbcTemplate 使用的就是命令模式,它的优点是降低了系统的耦合度,新增的命令可以很容易地添加到系统中;其缺点是如果命令很多就会造成命令类的代码很长,增加了维护的复杂性。
考点分析
对于设计模式的掌握程度来说,一般面试官都不会要求你要精通所有的设计模式,但需要对几个比较常用的设计模式有所理解和掌握才行。本课时介绍了 3 种设计模式加上 MyBatis 那一课时介绍的 7 种设计模式,足以应对日常的工作和一般性面试了。
和此知识点相关的面试题还有,软件中的六大设计原则是什么?这也是面试中经常会问的面试题,同时也是优秀程序设计的指导思想。
知识扩展:六大设计原则
六大设计原则包括:单一职责原则、里氏替换原则、依赖倒置原则、接口隔离原则、迪米特法则、开闭原则,接下来我们一一来看看它们分别是什么。
1. 单一职责原则
单一职责是指一个类只负责一个职责。比如现在比较流行的微服务,就是将之前很复杂耦合性很高的业务,分成多个独立的功能单一的简单接口,然后通过服务编排组装的方式实现不同的业务需求,而这种细粒度的独立接口就是符合单一职责原则的具体实践。
2. 开闭原则
开闭原则指的是对拓展开放、对修改关闭。它是说我们在实现一个新功能时,首先应该想到的是扩展原来的功能,而不是修改之前的功能。
这个设计思想非常重要,也是一名优秀工程师所必备的设计思想。至于为什么要这样做?其实非常简单,我们团队在开发后端接口时遵循的也是这个理念。
随着软件越做越大,对应的客户端版本也越来越多,而这些客户端都是安装在用户的手机上。因此我们不能保证所有用户手中的 App(客户端)都一直是最新版本的,并且也不能每次都强制用户进行升级或者是协助用户去升级,那么我们在开发新功能时,就强制要求团队人员不允许直接修改原来的老接口,而是要在原有的接口上进行扩展升级。
因为直接修改老接口带来的隐患是老版本的 App 将不能使用,这显然不符合我们的要求。那么此时在老接口上进行扩展无疑是最好的解决方案,因为这样我们既可以满足新业务也不用担心新加的代码会影响到老版本的使用。
3. 里氏替换原则
里氏替换原则是面向对象(OOP)编程的实现基础,它指的是所有引用了父类的地方都能被子类所替代,并且使用子类替代不会引发任何异常或者是错误的出现。
比如,如果把鸵鸟归为了“鸟”类,那么鸵鸟就是“鸟”的子类,但是鸟类会飞,而鸵鸟不会飞,那么鸵鸟就违背了里氏替换原则。
4. 依赖倒置原则
依赖倒置原则指的是要针对接口编程,而不是面向具体的实现编程。也就说高层模块不应该依赖底层模块,因为底层模块的职责通常更单一,不足以应对高层模块的变动,因此我们在实现时,应该依赖高层模块而非底层模块。
比如我们要从 A 地点去往 B 地点,此时应该掏出手机预约一个“车”,而这个“车”就是一个顶级的接口,它的实现类可以是各种各样的车,不同厂商的车甚至是不同颜色的车,而不应该依赖于某一个具体的车。例如,我们依赖某个车牌为 XXX 的车,那么一旦这辆车发生了故障或者这辆车正拉着其他乘客,就会对我的出行带来不便。所以我们应该依赖是“车”这一个顶级接口,而不是具体的某一辆车。
5. 接口隔离原则
接口隔离原则是指使用多个专门的接口比使用单一的总接口要好,即接口应该是相互隔离的小接口,而不是一个臃肿且庞杂的大接口。
使用接口隔离原则的好处是避免接口的污染,提高了程序的灵活性。
可以看出,接口隔离原则和单一职责原则的概念很像,单一职责原则要求接口的职责要单一,而接口隔离原则要求接口要尽量细化,二者虽然有异曲同工之妙,但可以看出单一职责原则要求的粒度更细。
6. 迪米特法则
迪米特法则又叫最少知识原则,它是指一个类对于其他类知道的越少越好。
迪米特法则设计的初衷是降低类之间的耦合,让每个类对其他类都不了解,因此每个类都在做自己的事情,这样就能降低类之间的耦合性。
这就好比我们在一些电视中看到的有些人在遇到强盗时,会选择闭着眼睛不看强盗,因为知道的信息越少反而对自己就越安全,这就是迪米特法则的基本思想。
小结
本课时我们讲了 3 种设计模式:单例模式、原型模式和命令模式,结合 MyBatis 那一课时(第 13 课时)介绍的 7 种设计模式,足以应对日常的工作和一般性的面试了。最后我们还介绍了设计模式中的 6 大设计原则:单一职责原则、开闭原则、里氏替换原则、依赖倒置原则、接口隔离原则和迪米特法则,我们应该结合这些概念对照日常项目中的代码,看看还有哪些代码可以进行优化和改进。
第29讲:红黑树和平衡二叉树有什么区别?
数据结构属于理解一些源码和技术所必备的知识,比如要读懂 Java 语言中 TreeMap 和 TreeSet 的源码就要懂红黑树的数据结构,不然是无法理解源码中关于红黑树数据的操作代码的,比如左旋、右旋、添加和删除操作等。因此本课时我们就来学习一下数据结构的基础知识,方便看懂源码或者是防止面试中被问到。
我们本课时的面试题是,红黑树和二叉树有什么区别?
典型回答
要回答这个问题之前,我们先要弄清什么是二叉树?什么是红黑树?
二叉树(Binary Tree)是指每个节点最多只有两个分支的树结构,即不存在分支大于 2 的节点,二叉树的数据结构如下图所示:
这是一棵拥有 6 个节点深度为 2(深度从 0 开始),并且根节点为 3 的二叉树。
二叉树有两个分支通常被称作“左子树”和“右子树”,而且这些分支具有左右次序不能随意地颠倒。
一棵空树或者满足以下性质的二叉树被称之为二叉查找树:
-
若任意节点的左子树不为空,则左子树上所有节点的值均小于它的根节点的值;
-
若任意节点的右子树不为空,则右子树上所有节点的值均大于或等于它的根节点的值;
-
任意节点的左、右子树分别为二叉查找树。
如下图所示,这就是一个标准的二叉查找树:
二叉查找树(Binary Search Tree)也被称为二叉搜索树、有序二叉树(Ordered Binary Tree)或排序二叉树(Sorted Binary Tree)等。
红黑树(Red Black Tree)是一种自平衡二叉查找树,它最早被称之为“对称二叉 B 树”,它现在的名字源于 1978 年的一篇论文,之后便被称之为红黑树了。
所谓的平衡树是指一种改进的二叉查找树,顾名思义平衡树就是将二叉查找树平衡均匀地分布,这样的好处就是可以减少二叉查找树的深度。
一般情况下二叉查找树的查询复杂度取决于目标节点到树根的距离(即深度),当节点的深度普遍较大时,查询的平均复杂度就会上升,因此为了实现更高效的查询就有了平衡树。
非平衡二叉树如下图所示:
平衡二叉树如下图所示:
可以看出使用平衡二叉树可以有效的减少二叉树的深度,从而提高了查询的效率。
红黑树除了具备二叉查找树的基本特性之外,还具备以下特性:
-
节点是红色或黑色;
-
根节点是黑色;
-
所有叶子都是黑色的空节点(NIL 节点);
-
每个红色节点必须有两个黑色的子节点,也就是说从每个叶子到根的所有路径上,不能有两个连续的红色节点;
-
从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑色节点。
红黑树结构如下图所示:
考点分析
红黑树是一个较为复杂的数据结构,尤其是对于增加和删除操作来说,一般面试官不会让你直接手写红黑树的具体实现。如果你只有很短的时间准备面试的话,那么我建议你不要死磕这些概念,要学会有的放矢,因为即使你花费很多的时间来背这些概念,一转眼的功夫就会彻底忘掉,所以你只需要大概地了解其中的一些概念和明白大致的原理就足够了。
和此知识点相关的面试题还有以下这些:
-
为什么工程中喜欢使用红黑树而不是其他二叉查找树?
-
红黑树是如何保证自平衡的?
知识扩展
红黑树的优势
红黑树的优势在于它是一个平衡二叉查找树,对于普通的二叉查找树(非平衡二叉查找树)在极端情况下可能会退化为链表的结构,例如,当我们依次插入 3、4、5、6、7、8 这些数据时,二叉树会退化为如下链表结构:
当二叉查找树退化为链表数据结构后,再进行元素的添加、删除以及查询时,它的时间复杂度就会退化为 O(n);而如果使用红黑树的话,它就会将以上数据转化为平衡二叉查找树,这样就可以更加高效的添加、删除以及查询数据了,这就是红黑树的优势。
小贴士:红黑树的高度近似 log2n,它的添加、删除以及查询数据的时间复杂度为 O(logn)。
我们在表示算法的执行时间时,通常会使用大 O 表示法,常见的标识类型有以下这些:
-
O(1):常量时间,计算时间与数据量大小没关系;
-
O(n):计算时间与数据量成线性正比关系;
-
O(logn):计算时间与数据量成对数关系;
自平衡的红黑树
红黑树能够实现自平衡和保持红黑树特征的主要手段是:变色、左旋和右旋。
左旋指的是围绕某个节点向左旋转,也就是逆时针旋转某个节点,使得父节点被自己的右子节点所替代,如下图所示:
在 TreeMap 源码中左旋的实现源码如下:
// 源码基于 JDK 1.8
private void rotateLeft(Entry<K,V> p) {
if (p != null) {
// 右子节点
Entry<K,V> r = p.right;
// p 节点的右子节点为 r 的左子节点
p.right = r.left;
// r 左子节点如果非空,r 左子节点的父节点设置为 p 节点
if (r.left != null)
r.left.parent = p;
r.parent = p.parent; // r 父节点等于 p 父节点
// p 父节点如果为空,那么讲根节点设置为 r 节点
if (p.parent == null)
root = r;
// p 父节点的左子节点如果等于 p 节点,那么 p 父节点的左子节点设置 r 节点
else if (p.parent.left == p)
p.parent.left = r;
else
p.parent.right = r;
r.left = p;
p.parent = r;
}
}
左旋代码说明:在刚开始时,p 为父节点,r 为子节点,在左旋操作后,r 节点代替 p 节点的位置,p 节点成为 r 节点的左孩子,而 r 节点的左孩子成为 p 节点的右孩子。
右旋指的是围绕某个节点向右旋转,也就是顺时针旋转某个节点,此时父节点会被自己的左子节点取代,如下图所示:
在 TreeMap 源码中右旋的实现源码如下:
private void rotateRight(Entry<K,V> p) {
if (p != null) {
Entry<K,V> l = p.left;
// p 节点的左子节点为 l 的右子节点
p.left = l.right;
// l 节点的右子节点非空时,设置 l 的右子节点的父节点为 p
if (l.right != null) l.right.parent = p;
l.parent = p.parent;
// p 节点的父节点为空时,根节点设置成 l 节点
if (p.parent == null)
root = l;
// p 节点的父节点的右子节点等于 p 节点时,p 的父节点的右子节点设置为 l
else if (p.parent.right == p)
p.parent.right = l;
else p.parent.left = l;
l.right = p;
p.parent = l;
}
}
右旋代码说明:在刚开始时,p 为父节点 l 为子节点,在右旋操作后,l 节点代替 p 节点,p 节点成为 l 节点的右孩子,l 节点的右孩子成为 p 节点的左孩子。
对于红黑树来说,如果当前节点的左、右子节点均为红色时,因为需要满足红黑树定义的第四条特征,所以需要执行变色操作,如下图所示:
由于篇幅有限,我这里只能带你简单地了解一下红黑树和二叉树的基本概念,想要深入地学习更多的内容,推荐查阅《算法》(第四版)和《算法导论》等书籍。
小结
我们本课时介绍了二叉树、二叉查找树及红黑树的概念,还有红黑树的五个特性。普通二叉查找树在特殊情况下会退化成链表的数据结构,因此操作和查询的时间复杂度变成了 O(n),而红黑树可以实现自平衡,因此它的操作(插入、删除)和查找的时间复杂度都是 O(logn),效率更高更稳定,红黑树保证平衡的手段有三个:变色、左旋和右旋。
第30讲:你知道哪些算法?讲一下它的内部实现过程?
上一课时我们介绍了数据结构的知识,数据结构属于计算机存储的基础,有了它才能更好地将数据进行存储。而算法可以这样理解:它是为数据结构服务的,使用合适的算法可以更快地操作和查询这些数据。
算法的内容有很多,随随便便一本算法书有个 700 页到 1500 页也是很平常的事,因此我们在这里不能把所有的算法问题全部讲到,即使专门再开设一个算法专栏,也只能挑重点的讲。那么我们好钢就要用在刀刃上,本课时会把面试中经常出现的和平常工作中使用频率最高的算法,拿出来给大家分享。
我们本课时的面试题是,你知道哪些算法?讲一下它的内部实现?
典型回答
最常见、最基础的算法是二分法,它是二分查找算法的简称(Binary Search Algorithm),也叫折半搜索算法或对数搜索算法。它是一种在有序数组中查找某一特定元素的搜索算法,顾名思义,是将一组有序元素中的数据划分为两组,通过判断中间值来确认要查找值的大致位置,然后重复此过程进行元素查询。
例如,我们要查询 1~100 中的某个数值,比如我们要查询的数值为 75,如果按照顺序从 1 开始一直往后排序对比的话,需要经历 75 次,才能查询到我们想要的数据;而如果使用二分法,则会先判断 50(1~100 的中间值)和 75 哪个大,然后就能确定要查询的值是在 50~100 之间,最后再进行二分,用 75 和 75 进行比较,结果发现此值就是我们想要找的那个值,于是我们只用了两步就找到了要查询的值,这就是算法的“魔力”。
二分查找算法的实现代码如下:
public class AlgorithmExample {
public static void main(String[] args) {
// 要查询的数组
int[] binaryNums = new int[100];
for (int i = 0; i < 100; i++) {
// 初始化数组(存入 100 个数据)
binaryNums[i] = (i + 1);
}
// 要查询的数值
int findValue = 75;
// 调用二分查找算法
int binaryResult = binarySearch(binaryNums, 0, binaryNums.length - 1, findValue);
// 打印结果
System.out.println("元素的位置是:" + (binaryResult + 1));
}
/**
* 二分查找算法(返回该值第一次出现的位置)
* @param nums 查询数组
* @param start 开始下标
* @param end 结束下标
* @param findValue 要查找的值
* @return int
*/
private static int binarySearch(int[] nums, int start, int end, int findValue) {
if (start <= end) {
// 中间位置
int middle = (start + end) / 2;
// 中间的值
int middleValue = nums[middle];
if (findValue == middleValue) {
// 等于中值直接返回
return middle;
} else if (findValue < middleValue) {
// 小于中值,在中值之前的数据中查找
return binarySearch(nums, start, middle - 1, findValue);
} else {
// 大于中值,在中值之后的数据中查找
return binarySearch(nums, middle + 1, end, findValue);
}
}
return -1;
}
}
以上程序的执行结果为:
元素的位置是:75
从上面的内容我们可以看出二分法虽然简单,但是也要满足一个特定的条件才行,那就是要使用二分法必须是有序排列的数值才行,不然是没办法实现二分法的。
除了二分法之外还有另一个比较常用的排序算法:冒泡排序。
冒泡排序(Bubble Sort)又被称为泡式排序,它是指重复走访要排序的数列,每次比较两个元素,如果顺序不对就进行交换,直到没有被交换的元素为止,这样就完成了一次冒泡排序。
为了让大家更好地理解冒泡排序,我录制了一个 gif 图片用于展示冒泡排序的执行过程,如下图所示:
由上图可以看出,冒泡排序的关键执行流程是:依次对比相邻的两个数字,如果前面的数字大于后面的数字,那么就将前、后两个数字进行位置交换;这样每次对比完一轮数据之后,能找出此轮最大的数字并放置到尾部,如此重复,直到没有可以交换的数据为止,这样就完成了冒泡排序。
接下来我们就使用 Java 代码来实现一个冒泡排序算法,实现代码如下:
import java.util.Arrays;
public class AlgorithmExample {
public static void main(String[] args) {
// 待排序数组
int[] nums = {33, 45, 11, 22, 12, 39, 27};
bubbleSort(nums);
// 打印排序完数组
System.out.println(Arrays.toString(nums));
}
/**
* 冒泡排序
*/
private static void bubbleSort(int[] nums) {
for (int i = 1; i < nums.length; i++) {
for (int j = 0; j < nums.length - i; j++) {
if (nums[j] > nums[j + 1]) {
// 前面数字大于后面的数字,执行位置交换
int temp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = temp;
}
}
}
}
}
以上程序的执行结果为:
[11, 12, 22, 27, 33, 39, 45]
从以上结果可以看出,冒泡排序算法的执行成功了,结果也符合我们的预期。
考点分析
冒泡排序和二分法属于程序员必须掌握的两种最基础的算法了,如果连这两个算法都不知道或者都不能手写这两种算法的话,可能会给面试官留下非常不好的印象。因为二者都属于基础中的基础了,其实只要理解了两种算法的核心思想,再手写代码也不是什么难事,如果实在写不出具体的代码,最起码要做到能写出伪代码的程度。
和此知识点相关的面试题,还有以下这些:
-
如何优化冒泡排序算法?
-
是否还知道更多的算法?
知识扩展
冒泡排序优化
从上面冒泡排序的 gif 图片可以看出,在最后一轮对比之前,数组的排序如下图所示:
从图片可以看出,此时数组已经完全排序好了,但是即使这样,冒泡排序还是又执行了一次遍历对比,如下图所示:
因此我们就可以想办法去掉无效的遍历,这样就可以优化冒泡排序的执行效率了。
我们可以在第一层循环内加一个判断标识,每次赋值为 true,假如在第二层循环(内层循环)时执行了位置交换,也就是 if 中的代码之后,我们把此值设置成 false;如果执行完内层循环判断之后,变量依然为 true,这就说明没有可以移动的元素了,冒泡排序可以结束执行了,优化后的代码如下所示:
import java.util.Arrays;
public class AlgorithmExample {
public static void main(String[] args) {
// 待排序数组
int[] nums = {33, 45, 11, 22, 12, 39, 27};
bubbleSort(nums);
// 打印排序完数组
System.out.println(Arrays.toString(nums));
}
/**
* 冒泡排序
*/
private static void bubbleSort(int[] nums) {
for (int i = 1; i < nums.length; i++) {
// 判断标识
boolean flag = true;
for (int j = 0; j < nums.length - i; j++) {
if (nums[j] > nums[j + 1]) {
// 前面数字大于后面的数字,执行位置交换
int temp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = temp;
// 执行了位置交换,更改标识
flag = false;
}
}
if (flag) {
// 没有可以移动的元素了,跳出循环
break;
}
}
}
}
以上程序的执行结果为:
[11, 12, 22, 27, 33, 39, 45]
此结果说明,冒泡排序的执行符合我们的预期,执行成功。
插入排序
插入排序(Insertion Sort)算法是指依次循环当前的列表,通过对比将每个元素插入到合适的位置,它的具体执行过程,如下图所示:
插入算法的具体实现代码如下:
public class AlgorithmExample {
public static void main(String[] args) {
int[] insertNums = {4, 33, 10, 13, 49, 20, 8};
// 插入排序调用
insertSort(insertNums);
System.out.println("插入排序后:" + Arrays.toString(insertNums));
}
/**
* 插入排序
*/
private static void insertSort(int[] nums) {
int i, j, k;
for (i = 1; i < nums.length; i++) {
k = nums[i];
j = i - 1;
// 对 i 之前的数据,给当前元素找到合适的位置
while (j >= 0 && k < nums[j]) {
nums[j + 1] = nums[j];
// j-- 继续往前寻找
j--;
}
nums[j + 1] = k;
}
}
}
以上程序的执行结果为:
插入排序后:[4, 8, 10, 13, 20, 33, 49]
选择排序
选择排序(Selection Sort)算法是指依次循环数组,每轮找出最小的值放到数组的最前面,直到循环结束就能得到一个有序数组,它的执行过程如下图所示:
选择排序的具体实现代码如下:
public class AlgorithmExample {
public static void main(String[] args) {
int[] insertNums = {4, 33, 10, 13, 49, 20, 8};
// 调用选择排序
selectSort(insertNums);
System.out.println("选择排序后结果:" + Arrays.toString(insertNums));
}
/**
* 选择排序
*/
private static void selectSort(int[] nums) {
int index;
int temp;
for (int i = 0; i < nums.length - 1; i++) {
index = i;
for (int j = i + 1; j < nums.length; j++) {
if (nums[j] < nums[index]) {
index = j;
}
}
if (index != i) {
temp = nums[i];
nums[i] = nums[index];
nums[index] = temp;
}
System.out.print("第" + i + "次排序:");
System.out.println(Arrays.toString(nums));
}
}
}
以上程序的执行结果为:
第0次排序:[4, 33, 10, 13, 49, 20, 8]
第1次排序:[4, 8, 10, 13, 49, 20, 33]
第2次排序:[4, 8, 10, 13, 49, 20, 33]
第3次排序:[4, 8, 10, 13, 49, 20, 33]
第4次排序:[4, 8, 10, 13, 20, 49, 33]
第5次排序:[4, 8, 10, 13, 20, 33, 49]
选择排序后结果:[4, 8, 10, 13, 20, 33, 49]
小结
本着将一个知识点吃透的原则,本课时我们总共介绍了四种算法:冒泡排序算法、二分法、插入排序算法、选择排序算法等,并且还讲了冒泡排序算法的优化。但由于篇幅的原因,这些只能介绍一些常用的算法,如果想要更加深入地学习算法,还需要你投入更多的时间来学习,推荐阅读《算法》(第 4 版)的内容。