一文带你全面深入了解TreeMap

ゝ一纸荒年。 2024-05-06 21:31 169阅读 0赞

概述

TreeMap是Map家族中的一员,也是用来存放key-value键值对的。平时在工作中使用的可能并不多,它最大的特点是遍历时是有顺序的,根据key的排序规则来,那么它具体是如何使用,又是怎么实现的呢?本文基于jdk8做一个讲解。

TreeMap介绍

TreeMap是一个基于key有序的key value散列表。

  • map根据其键的自然顺序排序,或者根据map创建时提供的Comparator排序
  • 不是线程安全的
  • key 不可以存入null
  • 底层是基于红黑树实现的

format_png

以上是TreeMap的类结构图:

  1. 实现了NavigableMap接口,NavigableMap又实现了Map接口,提供了导航相关的方法。
  2. 继承了AbstractMap,该方法实现Map操作的骨干逻辑。
  3. 实现了Cloneable接口,标记该类支持clone方法复制
  4. 实现了Serializable接口,标记该类支持序列化

构造方法

  • TreeMap()

说明:使用键的自然排序构造一个新的空树映射。

  • TreeMap(Comparator<? super K> comparator)

说明:构造一个新的空树映射,根据给定的比较器排序。

  • TreeMap(Map<? extends K,? extends V> m)

说明:构造一个新的树映射,包含与给定映射相同的映射,按照键的自然顺序排序。

  • TreeMap(SortedMap m)

说明:构造一个新的树映射,包含相同的映射,并使用与指定排序映射相同的顺序。

关键方法

这边主要讲解下NavigableMap和SortedMap提供的一些方法,Map相关的方法大家应该都很熟悉了。

SortedMap接口:

  • Comparator<? super K> comparator()

返回用于排序此映射中的键的比较器,如果此映射使用其键的自然排序,则返回null。

  • Set> entrySet()

返回此映射中包含的映射的Set视图。

  • K firstKey()

返回当前映射中的第一个(最低)键。

  • K lastKey()

返回当前映射中的最后(最高)键。

NavigableMap接口:

  • Map.Entry ceilingEntry(K key)

返回与大于或等于给定键的最小键相关联的键值映射,如果没有这样的键则返回null。

  • K ceilingKey(K key)

返回大于或等于给定键的最小键,如果没有这样的键,则返回null。

  • NavigableMap descendingMap()

返回此映射中包含的映射的倒序视图。

  • Map.Entry firstEntry()

返回与该映射中最小的键关联的键值映射,如果映射为空,则返回null。

  • Map.Entry floorEntry(K key)

返回与小于或等于给定键的最大键相关联的键值映射,如果没有这样的键则返回null。

  • SortedMap headMap(K toKey)

返回该映射中键严格小于toKey的部分的视图。

  • Map.Entry higherEntry(K key)

返回与严格大于给定键的最小键关联的键值映射,如果没有这样的键,则返回null。

  • Map.Entry lastEntry()

返回与此映射中最大键关联的键值映射,如果映射为空,则返回null。

  • Map.Entry lowerEntry(K key)

返回与严格小于给定键的最大键关联的键值映射,如果没有这样的键,则返回null。

  • Map.Entry pollFirstEntry()

删除并返回与该映射中最小的键关联的键值映射,如果映射为空,则返回null。

  • Map.Entry pollLastEntry()

删除并返回与此映射中最大键关联的键值映射,如果映射为空,则返回null。

  • SortedMap subMap(K fromKey, K toKey)

返回该映射中键范围从fromKey(包含)到toKey(独占)的部分的视图。

  • SortedMap tailMap(K fromKey)

返回该映射中键大于或等于fromKey的部分的视图。

使用案例

  • 验证顺序性

    @Test

    1. public void test1() {
    2. Map<Integer, String> treeMap = new TreeMap<>();
    3. treeMap.put(16, "a");
    4. treeMap.put(1, "b");
    5. treeMap.put(4, "c");
    6. treeMap.put(3, "d");
    7. treeMap.put(8, "e");
    8. // 遍历
    9. System.out.println("默认排序:");
    10. treeMap.forEach((key, value) -> {
    11. System.out.println("key: " + key + ", value: " + value);
    12. });
    13. // 构造方法传入比较器
    14. Map<Integer, String> tree2Map = new TreeMap<>((o1, o2) -> o2 - o1);
    15. tree2Map.put(16, "a");
    16. tree2Map.put(1, "b");
    17. tree2Map.put(4, "c");
    18. tree2Map.put(3, "d");
    19. tree2Map.put(8, "e");
    20. // 遍历
    21. System.out.println("倒序排序:");
    22. tree2Map.forEach((key, value) -> {
    23. System.out.println("key: " + key + ", value: " + value);
    24. });
    25. }

运行结果:

format_png 1

  • 验证不能存储null

    @Test

    1. public void test2() {
    2. Map<Integer, String> treeMap = new TreeMap<>();
    3. treeMap.put(null, "a");
    4. }

运行结果:

format_png 2

  • 验证NavigableMap相关方法

    @Test

    1. public void test3() {
    2. NavigableMap<Integer, String> treeMap = new TreeMap<>();
    3. treeMap.put(16, "a");
    4. treeMap.put(1, "b");
    5. treeMap.put(4, "c");
    6. treeMap.put(3, "d");
    7. treeMap.put(8, "e");
    8. // 获取大于等于5的key
    9. Integer ceilingKey = treeMap.ceilingKey(5);
    10. System.out.println("ceilingKey 5 is " + ceilingKey);
    11. // 获取最大的key
    12. Integer lastKey = treeMap.lastKey();
    13. System.out.println("lastKey is " + lastKey);
    14. }

运行结果:

format_png 3

核心机制

实现原理

大家有想过TreeMap的底层是怎么实现的吗,是如何维护key的顺序呢?答案就是基于红黑树实现的。

那什么是红黑树呢?我们在这里简单的认识一下,了解一下红黑树的特点:红黑树是一颗自平衡的排序二叉树。我们就先从二叉树开始说起。

  • 二叉树

二叉树很容易理解,就是一棵树分俩叉。

format_png 4

上面这颗就是一颗最普通的二叉树。但是你会发现看起来不那么美观,因为你以H为根节点,发现左右两边高低不平衡,高度相差达到了2。于是出现了平衡二叉树,使得左右两边高低差不多。

  • 平衡二叉树

format_png 5

这下子应该能看到,不管是从任何一个字母为根节点,左右两边的深度差不了2,最多是1。这就是平衡二叉树。不过好景不长,有一天,突然要把字母变成数字,还要保持这种特性怎么办呢?于是又出现了平衡二叉排序树。

  • 平衡二叉排序树

format_png 6

不管是从长相(平衡),还是从规律(排序)感觉这棵树超级完美。但是有一个问题,那就是在增加删除节点的时候,你要时刻去让这棵树保持平衡,需要做太多的工作了,旋转的次数超级多,于是乎出现了红黑树。

  • 红黑树

format_png 7

这就是传说中的红黑树,和平衡二叉排序树的区别就是每个节点涂上了颜色,他有下列五条性质:

  1. 每个节点都只能是红色或者黑色
  2. 根节点是黑色
  3. 每个叶节点(NIL节点,空节点)是黑色的。
  4. 如果一个结点是红的,则它两个子节点都是黑的。也就是说在一条路径上不能出现相邻的两个红色结点。
  5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。

这些性质有什么优点呢?就是插入效率超级高。因为在插入一个元素的时候,最多只需三次旋转,O(1)的复杂度,但是有一点需要说明他的查询效率略微逊色于平衡二叉树,因为他比平衡二叉树会稍微不平衡最多一层,也就是说红黑树的查询性能只比相同内容的avl树最多多一次比较。如何去旋转呢?如下图所示。

首先是左旋:

format_png 8

然后是右旋:

format_png 9

红黑树更详细的内容可以参考这篇文章:segmentfault.com/a/119000001…

源码解析

成员变量

  1. //这是一个比较器,方便插入查找元素等操作
  2. private final Comparator<? super K> comparator;
  3. //红黑树的根节点:每个节点是一个Entry
  4. private transient Entry<K,V> root;
  5. //集合元素数量
  6. private transient int size = 0;
  7. //集合修改的记录
  8. private transient int modCount = 0;
  • comparator是一个排序器,作为key的排序规则
  • root是红黑树的根节点,说明的确底层用的红黑树作为数据结构。

    static final class Entry implements Map.Entry {

    1. K key;
    2. V value;
    3. //左子树
    4. Entry<K,V> left;
    5. //右子树
    6. Entry<K,V> right;
    7. //父节点
    8. Entry<K,V> parent;
    9. //每个节点的颜色:红黑树属性。
    10. boolean color = BLACK;
    11. Entry(K key, V value, Entry<K,V> parent) {
    12. this.key = key;
    13. this.value = value;
    14. this.parent = parent;
    15. }
    16. public K getKey() {
    17. return key;
    18. }
    19. public V getValue() {
    20. return value;
    21. }
    22. public V setValue(V value) {
    23. V oldValue = this.value;
    24. this.value = value;
    25. return oldValue;
    26. }
    27. public boolean equals(Object o) {
    28. if (!(o instanceof Map.Entry))
    29. return false;
    30. Map.Entry<?,?> e = (Map.Entry<?,?>)o;
    31. return valEquals(key,e.getKey()) && valEquals(value,e.getValue());
    32. }
    33. public int hashCode() {
    34. int keyHash = (key==null ? 0 : key.hashCode());
    35. int valueHash = (value==null ? 0 : value.hashCode());
    36. return keyHash ^ valueHash;
    37. }
    38. public String toString() {
    39. return key + "=" + value;
    40. }
    41. }

查找get方法

TreeMap基于红黑树实现,而红黑树是一种自平衡二叉查找树,所以 TreeMap 的查找操作流程和二叉查找树一致。二叉树的查找流程是这样的,先将目标值和根节点的值进行比较,如果目标值小于根节点的值,则再和根节点的左孩子进行比较。如果目标值大于根节点的值,则继续和根节点的右孩子比较。在查找过程中,如果目标值和二叉树中的某个节点值相等,则返回 true,否则返回 false。TreeMap 查找和此类似,只不过在 TreeMap 中,节点(Entry)存储的是键值对。在查找过程中,比较的是键的大小,返回的是值,如果没找到,则返回null。TreeMap 中的查找方法是get。

  1. public V get(Object key) {
  2. //调用 getEntry方法查找
  3. Entry<K,V> p = getEntry(key);
  4. return (p==null ? null : p. value);
  5. }
  6. final Entry<K,V> getEntry(Object key) {
  7. / 如果比较器为空,只是用key作为比较器查询
  8. if (comparator != null)
  9. return getEntryUsingComparator(key);
  10. if (key == null)
  11. throw new NullPointerException();
  12. Comparable<? super K> k = (Comparable<? super K>) key;
  13. // 取得root节点
  14. Entry<K,V> p = root;
  15. //核心来了:从root节点开始查找,根据比较器判断是在左子树还是右子树
  16. while (p != null) {
  17. int cmp = k.compareTo(p.key );
  18. if (cmp < 0)
  19. p = p. left;
  20. else if (cmp > 0)
  21. p = p. right;
  22. else
  23. return p;
  24. }

插入put方法

我们来看下关键的插入方法,在插入时候是如何维护key的。

  1. public V put(K key, V value) {
  2. Entry<K,V> t = root;
  3. // 1.如果根节点为 null,将新节点设为根节点
  4. if (t == null) {
  5. compare(key, key); // type (and possibly null) check
  6. root = new Entry<>(key, value, null);
  7. size = 1;
  8. modCount++;
  9. return null;
  10. }
  11. //如果root不为null,说明已存在元素
  12. int cmp;
  13. Entry<K,V> parent;
  14. // split comparator and comparable paths
  15. Comparator<? super K> cpr = comparator;
  16. //如果比较器不为null 则使用比较器
  17. if (cpr != null) {
  18. //找到元素的插入位置
  19. do {
  20. parent = t;
  21. cmp = cpr.compare(key, t.key);
  22. //当前key小于节点key 向左子树查找
  23. if (cmp < 0)
  24. t = t.left;
  25. //当前key大于节点key 向右子树查找
  26. else if (cmp > 0)
  27. t = t.right;
  28. else
  29. //相等的情况下 直接更新节点值
  30. return t.setValue(value);
  31. } while (t != null);
  32. }
  33. //如果比较器为null 则使用默认比较器
  34. else {
  35. //如果key为null 则抛出异常
  36. if (key == null)
  37. throw new NullPointerException();
  38. @SuppressWarnings("unchecked")
  39. Comparable<? super K> k = (Comparable<? super K>) key;
  40. //找到元素的插入位置
  41. do {
  42. parent = t;
  43. cmp = k.compareTo(t.key);
  44. if (cmp < 0)
  45. t = t.left;
  46. else if (cmp > 0)
  47. t = t.right;
  48. else
  49. return t.setValue(value);
  50. } while (t != null);
  51. }
  52. Entry<K,V> e = new Entry<>(key, value, parent);
  53. //根据比较结果决定插入到左子树还是右子树
  54. if (cmp < 0)
  55. parent.left = e;
  56. else
  57. parent.right = e;
  58. //保持红黑树性质,进行红黑树的旋转等操作
  59. fixAfterInsertion(e);
  60. size++;
  61. modCount++;
  62. return null;
  63. }

比较关键的就是fixAfterInsertion方法, 看懂这个方法需要你对红黑树的机制比较了解。

  1. private void fixAfterInsertion(Entry<K,V> x) {
  2. // 将新插入节点的颜色设置为红色
  3. x. color = RED;
  4. // while循环,保证新插入节点x不是根节点或者新插入节点x的父节点不是红色(这两种情况不需要调整)
  5. while (x != null && x != root && x. parent.color == RED) {
  6. // 如果新插入节点x的父节点是祖父节点的左孩子
  7. if (parentOf(x) == leftOf(parentOf (parentOf(x)))) {
  8. // 取得新插入节点x的叔叔节点
  9. Entry<K,V> y = rightOf(parentOf (parentOf(x)));
  10. // 如果新插入x的父节点是红色
  11. if (colorOf(y) == RED) {
  12. // 将x的父节点设置为黑色
  13. setColor(parentOf (x), BLACK);
  14. // 将x的叔叔节点设置为黑色
  15. setColor(y, BLACK);
  16. // 将x的祖父节点设置为红色
  17. setColor(parentOf (parentOf(x)), RED);
  18. // 将x指向祖父节点,如果x的祖父节点的父节点是红色,按照上面的步奏继续循环
  19. x = parentOf(parentOf (x));
  20. } else {
  21. // 如果新插入x的叔叔节点是黑色或缺少,且x的父节点是祖父节点的右孩子
  22. if (x == rightOf( parentOf(x))) {
  23. // 左旋父节点
  24. x = parentOf(x);
  25. rotateLeft(x);
  26. }
  27. // 如果新插入x的叔叔节点是黑色或缺少,且x的父节点是祖父节点的左孩子
  28. // 将x的父节点设置为黑色
  29. setColor(parentOf (x), BLACK);
  30. // 将x的祖父节点设置为红色
  31. setColor(parentOf (parentOf(x)), RED);
  32. // 右旋x的祖父节点
  33. rotateRight( parentOf(parentOf (x)));
  34. }
  35. } else { // 如果新插入节点x的父节点是祖父节点的右孩子和上面的相似
  36. Entry<K,V> y = leftOf(parentOf (parentOf(x)));
  37. if (colorOf(y) == RED) {
  38. setColor(parentOf (x), BLACK);
  39. setColor(y, BLACK);
  40. setColor(parentOf (parentOf(x)), RED);
  41. x = parentOf(parentOf (x));
  42. } else {
  43. if (x == leftOf( parentOf(x))) {
  44. x = parentOf(x);
  45. rotateRight(x);
  46. }
  47. setColor(parentOf (x), BLACK);
  48. setColor(parentOf (parentOf(x)), RED);
  49. rotateLeft( parentOf(parentOf (x)));
  50. }
  51. }
  52. }
  53. // 最后将根节点设置为黑色
  54. root.color = BLACK;
  55. }

删除remove方法

删除remove是最复杂的方法。

  1. public V remove(Object key) {
  2. // 根据key查找到对应的节点对象
  3. Entry<K,V> p = getEntry(key);
  4. if (p == null)
  5. return null;
  6. // 记录key对应的value,供返回使用
  7. V oldValue = p. value;
  8. // 删除节点
  9. deleteEntry(p);
  10. return oldValue;
  11. }
  12. private void deleteEntry(Entry<K,V> p) {
  13. modCount++;
  14. //元素个数减一
  15. size--;
  16. // 如果被删除的节点p的左孩子和右孩子都不为空,则查找其替代节
  17. if (p.left != null && p. right != null) {
  18. // 查找p的替代节点
  19. Entry<K,V> s = successor (p);
  20. p. key = s.key ;
  21. p. value = s.value ;
  22. p = s;
  23. }
  24. Entry<K,V> replacement = (p. left != null ? p.left : p. right);
  25. if (replacement != null) {
  26. // 将p的父节点拷贝给替代节点
  27. replacement. parent = p.parent ;
  28. // 如果替代节点p的父节点为空,也就是p为跟节点,则将replacement设置为根节点
  29. if (p.parent == null)
  30. root = replacement;
  31. // 如果替代节点p是其父节点的左孩子,则将replacement设置为其父节点的左孩子
  32. else if (p == p.parent. left)
  33. p. parent.left = replacement;
  34. // 如果替代节点p是其父节点的左孩子,则将replacement设置为其父节点的右孩子
  35. else
  36. p. parent.right = replacement;
  37. // 将替代节点p的left、right、parent的指针都指向空
  38. p. left = p.right = p.parent = null;
  39. // 如果替代节点p的颜色是黑色,则需要调整红黑树以保持其平衡
  40. if (p.color == BLACK)
  41. fixAfterDeletion(replacement);
  42. } else if (p.parent == null) { // return if we are the only node.
  43. // 如果要替代节点p没有父节点,代表p为根节点,直接删除即可
  44. root = null;
  45. } else {
  46. // 如果p的颜色是黑色,则调整红黑树
  47. if (p.color == BLACK)
  48. fixAfterDeletion(p);
  49. // 下面删除替代节点p
  50. if (p.parent != null) {
  51. // 解除p的父节点对p的引用
  52. if (p == p.parent .left)
  53. p. parent.left = null;
  54. else if (p == p.parent. right)
  55. p. parent.right = null;
  56. // 解除p对p父节点的引用
  57. p. parent = null;
  58. }
  59. }
  60. }

最终还是落到了对红黑树节点的删除上,需要维持红黑树的特性,做一系列的工作。

发表评论

表情:
评论列表 (有 0 条评论,169人围观)

还没有评论,来说两句吧...

相关阅读

    相关 了解云原生

    > 文章转载于百度百家号:AI课工场 自进入云计算时代后,大量的新概念、新技术如雨后春笋般的涌现出来,从早期的openstack、IAAS平台,到中期的容器技术、微服务架构,