算法与数据结构--从二叉搜索树到红黑树(2)

前言

红黑树是二叉搜索树的优化版本,主要是为了解决二叉搜索树可能退化成链表的问题,如果不了解二叉搜索树的建议先阅读我的前一篇文章算法与数据结构–从二叉搜索树到红黑树(1)

本文在写红黑树的内容的时候,偏重于去写红黑树的特性以及如何创建一棵红黑树的规则,至于这些特性和规则背后的原理还需要读者去阅读相关的论文(笔者也未深入到此,后续有时间再做进一步的研究)。

什么是红黑树

红黑树的目的:解决搜索二叉树退化成链表的极端场景。
比如我们直接给一棵搜索二叉树插入一段有序的数值【1,2,3,4,5】,则它每次都会直接生成右子节点,这就直接退化成链表了。具体内容参考 算法与数据结构–从二叉搜索树到红黑树(1)
R-B Tree,全称是Red-Black Tree,又称为“红黑树”,它一种特殊的二叉查找树。红黑树的每个节点上都有存储位表示节点的颜色,可以是红(Red)或黑(Black)。

在这里插入图片描述

红黑树的特性:
(1)每个节点或者是黑色,或者是红色。
(2)根节点是黑色。
(3)每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
(4)如果一个节点是红色的,则它的子节点必须是黑色的。
(5)从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。

基于上面的5个特性,它同时可以推导出一个定理:

一棵含有n个节点的红黑树的高度至多为2log(n+1)。

如何给红黑树插入一个节点

首先红黑树本质上就是二叉搜索树,所以具体插入到哪个节点下的左还是右,逻辑也是按照二叉搜索树的来的:

  • 如果它的左子树不为空,则左子树上结点的值都小于根结点。
  • 如果它的右子树不为空,则右子树上结点的值都大于根结点。
  • 子树同样也要遵循以上两点

在这里插入图片描述
此图来源:https://www.cnblogs.com/skywang12345/p/3245399.html

注意点:

  • case1中如果叔父节点都是红色,则只需要变换颜色就能解决问题,但是祖父节点变为红色,但是如果曾祖父节点也是红色,则违背了红黑树第四条性质:如果一个节点是红色的,则它的子节点必须是黑色的。所以它会再次将“祖父节点”设为“当前节点”(红色节点),即之后继续对“当前节点”进行操作。
  • 待插入的节点为红色
  • case2,case3分别涉及到了左旋和右旋这两个概念,我们在下一节进行讲解

左旋

插入的前置条件:当前节点的父节点是红色,叔叔节点是黑色,且当前节点是其父节点的右孩子。
在这里插入图片描述
(01) 将“父节点”作为“新的当前节点”。
(02) 以“新的当前节点”为支点进行左旋。
(03) 指针重新指向父节点E(节点),再以E节点为当前节点继续重新开始判断。

图中的E即是父节点,以父节点为支点进行左旋。从代码实现的层面考虑:从上面动图中可以看出左旋的步骤本质上是一次父节点,祖父节点,待插入节点的指针引用的变换。

右旋

插入的前置条件:当前节点的父节点是红色,叔叔节点是黑色,且当前节点是其父节点的左孩子。

在这里插入图片描述
但是它相比左旋来说,还需要做变色的操作。
(01) 将“父节点”设为“黑色”。
(02) 将“祖父节点”设为“红色”。
(03) 以“祖父节点”为支点进行右旋。
(04) 指针重新指向祖父节点S(节点),再以S节点为当前节点继续重新开始判断。
图中的S节点为祖父节点。

红黑树小结

红黑树是一种性能优化后的二叉搜索树,它主要是为了解决二叉搜索树退化为链表的极端情况。红黑树主要是为了提高数据查询的性能,它在搜索/修改节点的时间复杂度都为logn,如果你按照插入n个节点来说,那插入节点的时间复杂度则为nlogn。

红黑树的应用

TreeMap

TreeMap 是一个有序的key-value集合,它是通过红黑树实现的。它最重要的特性是key是有序的,默认是按照自然顺序从小到大,当然你也可以定义属于自己的比较器。TreeMap不支持可以为null。

比较器是在什么时候使用的呢?上面红黑树我们说到,红黑树是一种特殊的二叉搜索树,二叉树又是怎么来定义左子节点和右子节点?

  • 如果它的左子树不为空,则左子树上结点的值都小于根结点。
  • 如果它的右子树不为空,则右子树上结点的值都大于根结点。
  • 子树同样也要遵循以上两点

所以比较器真正的用途是决定插入的节点是放在左子节点还是右子节点。

使用演示

下面代码段,分别演示了插入数据后按照默认key排序的遍历,以及自己定义比较器后实现的倒序遍历。

package com.yan.javaClub.collections.tree;

import java.util.Comparator;
import java.util.Iterator;
import java.util.Map;
import java.util.TreeMap;

/**
 * @program: java-club
 * @description:
 * @author: heyunpeng
 * @created: 2021/04/27 15:57
 */
public class TreeMapDemo {
    public static void main(String[] args) {
        TreeMap treeMap = new TreeMap();
        treeMap.put(3,3);
        treeMap.put(1,1);
        treeMap.put(2,2);
//        treeMap.put(null, null); 不支持key为null
        Iterator<Map.Entry> iterator = treeMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry entry = iterator.next();
            System.out.println(entry.getKey() + ":" + entry.getValue());
        }

        //使用导航功能,返回比3小的节点中最大的节点
        System.out.println("导航方法lowerEntry" + treeMap.lowerEntry(3));

        System.out.println("*****");

        //按照key倒序
        treeMap = new TreeMap(new Comparator<Integer>() {
            @Override
            public int compare(Integer o1, Integer o2) {
                return o2.compareTo(o1);
            }
        });

        //Lambda的写法
//        treeMap = new TreeMap<Integer, Integer>((Integer o1, Integer o2) -> o2.compareTo(o1));
        treeMap.put(3,3);
        treeMap.put(1,1);
        treeMap.put(2,2);
        iterator = treeMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry entry = iterator.next();
            System.out.println(entry.getKey() + ":" + entry.getValue());
        }
    }
}

打印结果:默认顺序是按照从小到大的顺序打印出来。我们定义了比较器后是按照倒序的形式打印出来。
在这里插入图片描述

深入剖析

在这里插入图片描述
TreeMap继承于AbstractMap,实现了Map, Cloneable, NavigableMap, Serializable接口。

  1. TreeMap继承于AbstractMap,而AbstractMap实现了Map接口,并实现了Map接口中定义的方法,减少了其子类继承的复杂度;其实了解设计模式的同学来说,这就是一个模板模式。

  2. TreeMap 实现了Map接口,成为Map框架中的一员,可以包含着key–value形式的元素;

  3. TreeMap 实现了NavigableMap接口,意味着拥有了更强的元素搜索能力;

  4. TreeMap 实现了Cloneable接口,实现了clone()方法,可以被克隆;

  5. TreeMap 实现了Java.io.Serializable接口,支持序列化操作,可通过Hessian协议进行传输;

克隆和序列化基本上我们都能了解,主要就是NavigableMap到底是个什么鬼。首先NavigableMap继承了SortedMap,SortedMap接口中提供了Comparator<? super K> comparator()的比较器方法,来支持继承它的其它Map实现比较排序的方法。

那NavigableMap本身又提供了哪些方法呢?treeMap本身结构就是个红黑树,我们是很容易通过这个结构来寻找某个指定节点中比它小的节点(向左),或者比它大的节点(向右),所以这个导航(Navigable)可以理解成顺着指定节点在红黑树继续往下去路由的意思。

比如去寻找小于指定key中最大的节点的key

 K lowerKey(K key);

去寻找大于指定key中最小的节点

Map.Entry<K,V> ceilingEntry(K key);

接下来我们通过代码看看Entry的结构,可以看出它就是一典型的红黑树结构,不像HashMap还有数组的结构。
在这里插入图片描述

使用场景

  1. 需要有序的Map
  2. 需要使用到TreeMap的导航相关的方法

TreeSet

TreeSet 是一个有序的集合,它的作用是提供有序的Set集合。它继承于AbstractSet抽象类,实现了NavigableSet, Cloneable, java.io.Serializable接口。
TreeSet 继承于AbstractSet,所以它是一个Set集合,具有Set的属性和方法。
TreeSet 实现了NavigableSet接口,意味着它支持一系列的导航方法。比如查找与指定目标最匹配项。
TreeSet 实现了Cloneable接口,意味着它能被克隆。
TreeSet 实现了java.io.Serializable接口,意味着它支持序列化。

TreeSet是基于TreeMap实现的。所以底层数据结构还是红黑树,只不过它不存在key-value的键值对,节点直接存放的是TreeSet.add的对象或者元数据。

TreeSet中的元素支持2种排序方式:自然排序 或者 根据创建TreeSet 时提供的 Comparator 进行排序。这取决于使用的构造方法。
TreeSet为基本操作(add、remove 和 contains)提供受保证的 log(n) 时间开销。
另外,TreeSet是非同步的。 它的iterator 方法返回的迭代器是fail-fast的。

TreeSet和TreeMap当然是一样的,都不支持null(其实不支持null也很好理解,因为红黑树每次插入节点是要比较大小的,null怎么做比较呢)。TreeSet还有个特性就是如果存放的元素已经存在,则会丢弃,不是被覆盖哦

使用演示

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.TreeSet;

/**
 * @program: java-club
 * @description:
 * @author: heyunpeng
 * @created: 2021/04/27 15:51
 */
public class TreeSetDemo {
    public static void main(String[] args) {
        TreeSet treeSet = new TreeSet();
        treeSet.add(3);
        treeSet.add(1);
        treeSet.add(2);
//        treeSet.add(null); 不支持存放null
        treeSet.add(2);
        treeSet.add(4);
        Iterator iterator = treeSet.iterator();
        while (iterator.hasNext()) {
            System.out.println(iterator.next());
        }
    }
}

在这里插入图片描述

深入剖析

比较TreeSet的实现最后还是有TreeMap存放到红黑树上的,只不过它的Entity(或者Node)节点不会存放key-value的键值对,而是直接存放整个元素。所以这里需要特别注意的地方,如果我们存放的是对象的话,那对象必须实现Comparable接口(不然对象做不了比较,它就不知道把这个元素插入到红黑树的哪个节点下左子节点还是右子节点了)。这里在深一步的说,TreeSet的存放和你对象里定义的hashcode,equals方法是没有任何关联的。

这里我定义了个Student对象,只有surname(姓),name(名)两个属性。并实现了Comparable方法,只使用了name属性的比较来决定Student的大小。

import java.util.Objects;

/**
 * @program: java-club
 * @description:
 * @author: heyunpeng
 * @created: 2021/04/30 17:13
 */
public class Student implements Comparable<Student>{

    private String surname;
    private String name;
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getSurname() {
        return surname;
    }

    public void setSurname(String surname) {
        this.surname = surname;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        } else {
            return false;
        }
//        if (o == null || getClass() != o.getClass()) return false;
//        Student student = (Student) o;
//        return Objects.equals(name, student.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(surname, name);
    }

    @Override
    public int compareTo(Student o) {
        return o.getName().compareTo(this.name) ;
    }
}


然后我们生成两个student对象,但是name一样的,我们可以看到treeset遍历后只会打印第一个student对象。
在这里插入图片描述

与HashSet的区别

  1. HashSet是一个无序的集合,基于HashMap实现;TreeSet是一个有序的集合,基于TreeMap实现。

  2. HashSet集合中允许有null元素,TreeSet集合中不允许有null元素。

HashMap

HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。HashMap的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。

JDK1.8之前的HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了节解决哈希碰撞(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)而存在的(“拉链法”解决冲突)。

JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为8),此时此索引位置上的所有数据改为使用红黑树存储。

HashMap的数据结构

这了解HashMap的数据结构之前,需要你最起码对数组和链表有一定的理解。这里我先从数据开始讲起。

加入我们有如下的键值对{0:“a”,1:“b”,2:“c”,3:“d”},我们需要实现通过key来查找value,相对比较简单的设计即是数组:
在这里插入图片描述
但是这里有一个前提呀,我们提供的数据里key=index的,那如果我们存放的key不等于index, 那就需要我们建立key与index的关联

比如我们现在的键值对变成了{“A”:“a”,“B”:“b”,“C”:“c”},那该如何建立key与index的关系呢。所以这个时候hashCode就出现了, A,B,C都有自己的HashCode,我们的数组长度为3,我们直接使用HashCode/数组长度取模来做index的映射可以吗?

哈希码并不是完全唯一的,它是一种算法,让同一个类的对象按照自己不同的特征尽量的有不同的哈希码,但不表示不同的对象哈希码完全不同

可是hashCode不是唯一的,而且7/3 和 13/3取模都是等于4的,那这该怎么办呢。

  • hash取模后得到index,存放到数组index位置;如果数组中已经有值了,则在判断存放的key是否equals, 如果equals则覆盖;如果不相等呢,则我们就以链表的形式追加上去。(其实这里也解释了HashMap的key 如果你重写了hashCode,也需要重写equals方法)

在这里插入图片描述

这样似乎已经解决了我们的需求,可是如果有太多的哈希碰撞(index相等),则有些index对应的链表就会很长,我们知道链表查询元素的时间复杂度是O(n),这可是不能接受的;但是红黑树的时间复杂度是O(logn),所以jdk1.8后,当链表长度大于阈值(或者红黑树的边界值,默认为8)时,此时此索引位置上的所有数据改为使用红黑树存储。

在这里插入图片描述

使用场景

红黑树虽然查询会比链表快,但是红黑树的插入还设计到左旋右旋等复杂的操作,删除那就更复杂了,而链表的插入和删除它的时间复杂度就是O(1);

但是如果在hash冲突比较严重的业务场景下,jdk1.8的HashMap在查询的性能上是大幅度提高的。

在实际场景中,我们还是建议你考虑下key的设计,尽量使得hashcode(key)相对均匀分布的,这样既可以避免大部分链表进化为红黑树后带来的插入,删除的性能的下降,且均匀化的hashcode后,数组中大部分的结构哪怕是短链表的形式(<8的长度),查询性能也不会差。

同时我们在设计HashMap,最好能根据实际业务场景给出它一个预估的size,这样可以避免扩容带来的性能消耗。

HashMap不是线程安全的,如果需要线程安全的HashMap的话,建议使用ConcurrentHashMap

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值